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
|
||||
// to the visibleCol associated with field. Subscribes to observables if used within a computed.
|
||||
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 {
|
||||
@ -214,4 +218,20 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
|
||||
});
|
||||
|
||||
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 {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 {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 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 {
|
||||
public static from(item: ChoiceItem) {
|
||||
return new ChoiceItem(item.label, item.previousLabel, item.options);
|
||||
}
|
||||
constructor(
|
||||
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([], {
|
||||
@ -63,7 +91,7 @@ export class ChoiceListEntry extends Disposable {
|
||||
constructor(
|
||||
private _values: Observable<string[]>,
|
||||
private _choiceOptionsByName: Observable<ChoiceOptionsByName>,
|
||||
private _onSave: (values: string[], choiceOptions: ChoiceOptionsByName) => void
|
||||
private _onSave: (values: string[], choiceOptions: ChoiceOptionsByName, renames: Record<string, string>) => void
|
||||
) {
|
||||
super();
|
||||
|
||||
@ -80,10 +108,10 @@ export class ChoiceListEntry extends Disposable {
|
||||
if (editMode) {
|
||||
const tokenField = TokenField.ctor<ChoiceItem>().create(this._tokenFieldHolder, {
|
||||
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),
|
||||
createToken: label => new ChoiceItem(label),
|
||||
createToken: label => new ChoiceItem(label, null),
|
||||
clipboardToTokens: clipboardToChoices,
|
||||
tokensToClipboard: (tokens, clipboard) => {
|
||||
// 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 tokenInputVal = tokenField.getTextInputValue();
|
||||
if (tokenInputVal !== '') {
|
||||
tokens.push(new ChoiceItem(tokenInputVal));
|
||||
tokens.push(new ChoiceItem(tokenInputVal, null));
|
||||
}
|
||||
|
||||
const newTokens = uniqBy(tokens, t => t.label);
|
||||
@ -192,7 +220,7 @@ export class ChoiceListEntry extends Disposable {
|
||||
if (!isEqual(this._values.get(), newValues)
|
||||
|| !isEqual(this._choiceOptionsByName.get(), newOptions)) {
|
||||
// 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 {
|
||||
this._cancel();
|
||||
}
|
||||
@ -209,10 +237,34 @@ export class ChoiceListEntry extends Disposable {
|
||||
private _renderToken(token: ChoiceItem) {
|
||||
const fillColorObs = Observable.create(null, getFillColor(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(
|
||||
dom.autoDispose(fillColorObs),
|
||||
dom.autoDispose(textColorObs),
|
||||
dom.autoDispose(choiceText),
|
||||
colorButton(textColorObs,
|
||||
fillColorObs,
|
||||
async () => {
|
||||
@ -221,14 +273,27 @@ export class ChoiceListEntry extends Disposable {
|
||||
|
||||
const fillColor = fillColorObs.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
|
||||
function focus(elem: HTMLInputElement) {
|
||||
elem.focus();
|
||||
@ -269,7 +334,7 @@ function clipboardToChoices(clipboard: DataTransfer): ChoiceItem[] {
|
||||
|
||||
const maybeText = clipboard.getData('text/plain');
|
||||
if (maybeText) {
|
||||
return maybeText.split('\n').map(label => new ChoiceItem(label));
|
||||
return maybeText.split('\n').map(label => new ChoiceItem(label, null));
|
||||
}
|
||||
|
||||
return [];
|
||||
|
@ -73,13 +73,7 @@ export class ChoiceTextBox extends NTextBox {
|
||||
ChoiceListEntry,
|
||||
this._choiceValues,
|
||||
this._choiceOptionsByName,
|
||||
(choices, choiceOptions) => {
|
||||
return this.options.setAndSave({
|
||||
...this.options.peek(),
|
||||
choices,
|
||||
choiceOptions: toObject(choiceOptions)
|
||||
});
|
||||
}
|
||||
this.save.bind(this)
|
||||
)
|
||||
)
|
||||
];
|
||||
@ -96,6 +90,15 @@ export class ChoiceTextBox extends NTextBox {
|
||||
protected getChoiceOptions(): Computed<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
|
||||
|
Loading…
Reference in New Issue
Block a user