mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Label editor for Choice/ChoiceList column editor
Summary: Allowing a user to change labels' in Choice/ChoiceList entry editor. For updated entries, renaming those values in all cells in the column. Test Plan: Updated tests Reviewers: alexmojaki Reviewed By: alexmojaki Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3057
This commit is contained in:
parent
cf7a3153f9
commit
a2e066176c
@ -81,6 +81,10 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> {
|
|||||||
// Helper for Reference/ReferenceList columns, which returns a formatter according
|
// Helper for Reference/ReferenceList columns, which returns a formatter according
|
||||||
// to the visibleCol associated with field. Subscribes to observables if used within a computed.
|
// to the visibleCol associated with field. Subscribes to observables if used within a computed.
|
||||||
createVisibleColFormatter(): BaseFormatter;
|
createVisibleColFormatter(): BaseFormatter;
|
||||||
|
|
||||||
|
// Helper for Choice/ChoiceList columns, that saves widget options and renames values in a document
|
||||||
|
// in one bundle
|
||||||
|
updateChoices(renameMap: Record<string, string>, options: any): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void {
|
export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void {
|
||||||
@ -214,4 +218,20 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.documentSettings = ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson());
|
this.documentSettings = ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson());
|
||||||
|
|
||||||
|
this.updateChoices = async (renames, widgetOptions) => {
|
||||||
|
// In case this column is being transformed - using Apply Formula to Data, bundle the action
|
||||||
|
// together with the transformation.
|
||||||
|
const actionOptions = {nestInActiveBundle: this.column.peek().isTransforming.peek()};
|
||||||
|
const hasRenames = !!Object.entries(renames).length;
|
||||||
|
const callback = async () => {
|
||||||
|
await Promise.all([
|
||||||
|
this.widgetOptionsJson.setAndSave(widgetOptions),
|
||||||
|
hasRenames ?
|
||||||
|
docModel.docData.sendAction(["RenameChoices", this.column().table().tableId(), this.colId(), renames]) :
|
||||||
|
null
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
return docModel.docData.bundleActions("Update choices configuration", callback, actionOptions);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,20 +1,48 @@
|
|||||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
|
||||||
import {colors, testId} from 'app/client/ui2018/cssVars';
|
|
||||||
import {Computed, Disposable, dom, DomContents, DomElementArg, Holder, Observable, styled} from 'grainjs';
|
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
|
||||||
import isEqual = require('lodash/isEqual');
|
|
||||||
import uniqBy = require('lodash/uniqBy');
|
|
||||||
import {IToken, TokenField} from 'app/client/lib/TokenField';
|
import {IToken, TokenField} from 'app/client/lib/TokenField';
|
||||||
|
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||||
|
import {colorButton} from 'app/client/ui2018/ColorSelect';
|
||||||
|
import {colors, testId} from 'app/client/ui2018/cssVars';
|
||||||
|
import {editableLabel} from 'app/client/ui2018/editableLabel';
|
||||||
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {ChoiceOptionsByName, IChoiceOptions} from 'app/client/widgets/ChoiceTextBox';
|
import {ChoiceOptionsByName, IChoiceOptions} from 'app/client/widgets/ChoiceTextBox';
|
||||||
import {DEFAULT_TEXT_COLOR} from 'app/client/widgets/ChoiceToken';
|
import {DEFAULT_TEXT_COLOR} from 'app/client/widgets/ChoiceToken';
|
||||||
import {colorButton} from 'app/client/ui2018/ColorSelect';
|
import {Computed, Disposable, dom, DomContents, DomElementArg, Holder, Observable, styled} from 'grainjs';
|
||||||
import {createCheckers, iface, ITypeSuite, opt} from 'ts-interface-checker';
|
import {createCheckers, iface, ITypeSuite, opt} from 'ts-interface-checker';
|
||||||
|
import isEqual = require('lodash/isEqual');
|
||||||
|
import uniqBy = require('lodash/uniqBy');
|
||||||
|
|
||||||
|
class RenameMap implements Record<string, string> {
|
||||||
|
constructor(tokens: ChoiceItem[]) {
|
||||||
|
for(const {label, previousLabel: id} of tokens.filter(x=> x.previousLabel)) {
|
||||||
|
if (label === id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this[id!] = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ChoiceItem implements IToken {
|
class ChoiceItem implements IToken {
|
||||||
|
public static from(item: ChoiceItem) {
|
||||||
|
return new ChoiceItem(item.label, item.previousLabel, item.options);
|
||||||
|
}
|
||||||
constructor(
|
constructor(
|
||||||
public label: string,
|
public label: string,
|
||||||
public options?: IChoiceOptions,
|
// We will keep the previous label value for a token, to tell us which token
|
||||||
|
// was renamed. For new tokens this should be null.
|
||||||
|
public readonly previousLabel: string | null,
|
||||||
|
public options?: IChoiceOptions
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public rename(label: string) {
|
||||||
|
return new ChoiceItem(label, this.previousLabel, this.options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public changeColors(options: IChoiceOptions) {
|
||||||
|
return new ChoiceItem(this.label, this.previousLabel, {...this.options, ...options});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChoiceItemType = iface([], {
|
const ChoiceItemType = iface([], {
|
||||||
@ -63,7 +91,7 @@ export class ChoiceListEntry extends Disposable {
|
|||||||
constructor(
|
constructor(
|
||||||
private _values: Observable<string[]>,
|
private _values: Observable<string[]>,
|
||||||
private _choiceOptionsByName: Observable<ChoiceOptionsByName>,
|
private _choiceOptionsByName: Observable<ChoiceOptionsByName>,
|
||||||
private _onSave: (values: string[], choiceOptions: ChoiceOptionsByName) => void
|
private _onSave: (values: string[], choiceOptions: ChoiceOptionsByName, renames: Record<string, string>) => void
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
@ -80,10 +108,10 @@ export class ChoiceListEntry extends Disposable {
|
|||||||
if (editMode) {
|
if (editMode) {
|
||||||
const tokenField = TokenField.ctor<ChoiceItem>().create(this._tokenFieldHolder, {
|
const tokenField = TokenField.ctor<ChoiceItem>().create(this._tokenFieldHolder, {
|
||||||
initialValue: this._values.get().map(label => {
|
initialValue: this._values.get().map(label => {
|
||||||
return new ChoiceItem(label, this._choiceOptionsByName.get().get(label));
|
return new ChoiceItem(label, label, this._choiceOptionsByName.get().get(label));
|
||||||
}),
|
}),
|
||||||
renderToken: token => this._renderToken(token),
|
renderToken: token => this._renderToken(token),
|
||||||
createToken: label => new ChoiceItem(label),
|
createToken: label => new ChoiceItem(label, null),
|
||||||
clipboardToTokens: clipboardToChoices,
|
clipboardToTokens: clipboardToChoices,
|
||||||
tokensToClipboard: (tokens, clipboard) => {
|
tokensToClipboard: (tokens, clipboard) => {
|
||||||
// Save tokens as JSON for parts of the UI that support deserializing it properly (e.g. ChoiceListEntry).
|
// Save tokens as JSON for parts of the UI that support deserializing it properly (e.g. ChoiceListEntry).
|
||||||
@ -173,7 +201,7 @@ export class ChoiceListEntry extends Disposable {
|
|||||||
const tokens = tokenField.tokensObs.get();
|
const tokens = tokenField.tokensObs.get();
|
||||||
const tokenInputVal = tokenField.getTextInputValue();
|
const tokenInputVal = tokenField.getTextInputValue();
|
||||||
if (tokenInputVal !== '') {
|
if (tokenInputVal !== '') {
|
||||||
tokens.push(new ChoiceItem(tokenInputVal));
|
tokens.push(new ChoiceItem(tokenInputVal, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTokens = uniqBy(tokens, t => t.label);
|
const newTokens = uniqBy(tokens, t => t.label);
|
||||||
@ -192,7 +220,7 @@ export class ChoiceListEntry extends Disposable {
|
|||||||
if (!isEqual(this._values.get(), newValues)
|
if (!isEqual(this._values.get(), newValues)
|
||||||
|| !isEqual(this._choiceOptionsByName.get(), newOptions)) {
|
|| !isEqual(this._choiceOptionsByName.get(), newOptions)) {
|
||||||
// Because of the listener on this._values, editing will stop if values are updated.
|
// Because of the listener on this._values, editing will stop if values are updated.
|
||||||
this._onSave(newValues, newOptions);
|
this._onSave(newValues, newOptions, new RenameMap(newTokens));
|
||||||
} else {
|
} else {
|
||||||
this._cancel();
|
this._cancel();
|
||||||
}
|
}
|
||||||
@ -209,10 +237,34 @@ export class ChoiceListEntry extends Disposable {
|
|||||||
private _renderToken(token: ChoiceItem) {
|
private _renderToken(token: ChoiceItem) {
|
||||||
const fillColorObs = Observable.create(null, getFillColor(token.options));
|
const fillColorObs = Observable.create(null, getFillColor(token.options));
|
||||||
const textColorObs = Observable.create(null, getTextColor(token.options));
|
const textColorObs = Observable.create(null, getTextColor(token.options));
|
||||||
|
const choiceText = Observable.create(null, token.label);
|
||||||
|
|
||||||
|
const rename = async (to: string) => {
|
||||||
|
const tokenField = this._tokenFieldHolder.get();
|
||||||
|
if (!tokenField) { return; }
|
||||||
|
// If user removed the label, revert back to original one.
|
||||||
|
if (!to) {
|
||||||
|
choiceText.set(token.label);
|
||||||
|
} else {
|
||||||
|
tokenField.replaceToken(token.label, ChoiceItem.from(token).rename(to));
|
||||||
|
// We don't need to update choiceText, since it will be replaced (rerendered).
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function stopPropagation(ev: Event) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusOnNew = () => {
|
||||||
|
const tokenField = this._tokenFieldHolder.get();
|
||||||
|
if (!tokenField) { return; }
|
||||||
|
focus(tokenField.getTextInput());
|
||||||
|
};
|
||||||
|
|
||||||
return cssColorAndLabel(
|
return cssColorAndLabel(
|
||||||
dom.autoDispose(fillColorObs),
|
dom.autoDispose(fillColorObs),
|
||||||
dom.autoDispose(textColorObs),
|
dom.autoDispose(textColorObs),
|
||||||
|
dom.autoDispose(choiceText),
|
||||||
colorButton(textColorObs,
|
colorButton(textColorObs,
|
||||||
fillColorObs,
|
fillColorObs,
|
||||||
async () => {
|
async () => {
|
||||||
@ -221,14 +273,27 @@ export class ChoiceListEntry extends Disposable {
|
|||||||
|
|
||||||
const fillColor = fillColorObs.get();
|
const fillColor = fillColorObs.get();
|
||||||
const textColor = textColorObs.get();
|
const textColor = textColorObs.get();
|
||||||
tokenField.replaceToken(token.label, new ChoiceItem(token.label, {fillColor, textColor}));
|
tokenField.replaceToken(token.label, ChoiceItem.from(token).changeColors({fillColor, textColor}));
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
cssTokenLabel(token.label)
|
editableLabel(choiceText,
|
||||||
|
rename,
|
||||||
|
testId('token-label'),
|
||||||
|
// Don't bubble up keyboard events, use them for editing the text.
|
||||||
|
// Without this keys like Backspace, or Mod+a will propagate and modify all tokens.
|
||||||
|
dom.on('keydown', stopPropagation),
|
||||||
|
// On enter, focus on the input element.
|
||||||
|
dom.onKeyDown({
|
||||||
|
Enter : focusOnNew
|
||||||
|
}),
|
||||||
|
// Don't bubble up click, as it would change focus.
|
||||||
|
dom.on('click', stopPropagation),
|
||||||
|
dom.cls(cssTokenLabel.className)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Helper to focus on the token input and select/scroll to the bottom
|
// Helper to focus on the token input and select/scroll to the bottom
|
||||||
function focus(elem: HTMLInputElement) {
|
function focus(elem: HTMLInputElement) {
|
||||||
elem.focus();
|
elem.focus();
|
||||||
@ -269,7 +334,7 @@ function clipboardToChoices(clipboard: DataTransfer): ChoiceItem[] {
|
|||||||
|
|
||||||
const maybeText = clipboard.getData('text/plain');
|
const maybeText = clipboard.getData('text/plain');
|
||||||
if (maybeText) {
|
if (maybeText) {
|
||||||
return maybeText.split('\n').map(label => new ChoiceItem(label));
|
return maybeText.split('\n').map(label => new ChoiceItem(label, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
|
@ -73,13 +73,7 @@ export class ChoiceTextBox extends NTextBox {
|
|||||||
ChoiceListEntry,
|
ChoiceListEntry,
|
||||||
this._choiceValues,
|
this._choiceValues,
|
||||||
this._choiceOptionsByName,
|
this._choiceOptionsByName,
|
||||||
(choices, choiceOptions) => {
|
this.save.bind(this)
|
||||||
return this.options.setAndSave({
|
|
||||||
...this.options.peek(),
|
|
||||||
choices,
|
|
||||||
choiceOptions: toObject(choiceOptions)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
@ -96,6 +90,15 @@ export class ChoiceTextBox extends NTextBox {
|
|||||||
protected getChoiceOptions(): Computed<ChoiceOptionsByName> {
|
protected getChoiceOptions(): Computed<ChoiceOptionsByName> {
|
||||||
return this._choiceOptionsByName;
|
return this._choiceOptionsByName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected save(choices: string[], choiceOptions: ChoiceOptionsByName, renames: Record<string, string>) {
|
||||||
|
const options = {
|
||||||
|
...this.options.peek(),
|
||||||
|
choices,
|
||||||
|
choiceOptions: toObject(choiceOptions)
|
||||||
|
};
|
||||||
|
return this.field.updateChoices(renames, options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Converts a POJO containing choice options to an ES6 Map
|
// Converts a POJO containing choice options to an ES6 Map
|
||||||
|
Loading…
Reference in New Issue
Block a user