gristlabs_grist-core/app/client/widgets/ChoiceListEntry.ts
Jarosław Sadziński 8be920dd25 (core) Multi-column configuration
Summary:
Creator panel allows now to edit multiple columns at once
for some options that are common for them. Options that
are not common are disabled.

List of options that can be edited for multiple columns:
- Column behavior (but limited to empty/formula columns)
- Alignment and wrapping
- Default style
- Number options (for numeric columns)
- Column types (but only for empty/formula columns)

If multiple columns of the same type are selected, most of
the options are available to change, except formula, trigger formula
and conditional styles.

Editing column label or column id is disabled by default for multiple
selection.

Not related: some tests were fixed due to the change in the column label
and id widget in grist-core (disabled attribute was replaced by readonly).

Test Plan: Updated and new tests.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3598
2022-10-17 09:51:19 +02:00

583 lines
19 KiB
TypeScript

import {IToken, TokenField} from 'app/client/lib/TokenField';
import {cssBlockedCursor} from 'app/client/ui/RightPanelStyles';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {colorButton, ColorOption} from 'app/client/ui2018/ColorSelect';
import {colors, testId, theme} 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 {Computed, Disposable, dom, DomContents, DomElementArg, Holder, MultiHolder, Observable, styled} from 'grainjs';
import {createCheckers, iface, ITypeSuite, opt, union} 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,
// 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 previousLabel: string | null,
public options?: IChoiceOptions
) {}
public rename(label: string) {
return new ChoiceItem(label, this.previousLabel, this.options);
}
public changeStyle(options: IChoiceOptions) {
return new ChoiceItem(this.label, this.previousLabel, {...this.options, ...options});
}
}
const ChoiceItemType = iface([], {
label: "string",
previousLabel: union("string", "null"),
options: opt("ChoiceOptionsType"),
});
const ChoiceOptionsType = iface([], {
textColor: opt("string"),
fillColor: opt("string"),
fontBold: opt("boolean"),
fontUnderline: opt("boolean"),
fontItalic: opt("boolean"),
fontStrikethrough: opt("boolean"),
});
const choiceTypes: ITypeSuite = {
ChoiceItemType,
ChoiceOptionsType,
};
const {ChoiceItemType: ChoiceItemChecker} = createCheckers(choiceTypes);
/**
* ChoiceListEntry - Editor for choices and choice colors.
*
* The ChoiceListEntry can be in one of two modes: edit or view (default).
*
* When in edit mode, it displays a custom, vertical TokenField that allows for entry
* of new choice values. Once changes are saved, the new values become valid choices,
* and can be used in Choice and Choice List columns. Each choice in the TokenField
* also includes a color picker button to customize the fill/text color of the choice.
* The same capabilities of TokenField, such as undo/redo and rich copy/paste support,
* are present in ChoiceListEntry as well.
*
* When in view mode, it looks similar to edit mode, but hides the bottom input and the
* color picker dropdown buttons. Past 6 choices, it stops rendering individual choices
* and only shows the total number of additional choices that are hidden, and can be
* seen when edit mode is activated.
*
* Usage:
* > dom.create(ChoiceListEntry, values, options, (vals, options) => {});
*/
export class ChoiceListEntry extends Disposable {
private _isEditing: Observable<boolean> = Observable.create(this, false);
private _tokenFieldHolder: Holder<TokenField<ChoiceItem>> = Holder.create(this);
constructor(
private _values: Observable<string[]>,
private _choiceOptionsByName: Observable<ChoiceOptionsByName>,
private _onSave: (values: string[], choiceOptions: ChoiceOptionsByName, renames: Record<string, string>) => void,
private _disabled: Observable<boolean>,
private _mixed: Observable<boolean>,
) {
super();
// Since the saved values can be modified outside the ChoiceListEntry (via undo/redo),
// add a listener to update edit status on changes.
this.autoDispose(this._values.addListener(() => {
this._cancel();
}));
}
// Arg maxRows indicates the number of rows to display when the editor is inactive.
public buildDom(maxRows: number = 6): DomContents {
return dom.domComputed(this._isEditing, (editMode) => {
if (editMode) {
// If we have mixed values, we can't show any options on the editor.
const initialValue = this._mixed.get() ? [] : this._values.get().map(label => {
return new ChoiceItem(label, label, this._choiceOptionsByName.get().get(label));
});
const tokenField = TokenField.ctor<ChoiceItem>().create(this._tokenFieldHolder, {
initialValue,
renderToken: token => this._renderToken(token),
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).
clipboard.setData('application/json', JSON.stringify(tokens));
// Save token labels as newline-separated text, for general use (e.g. pasting into cells).
clipboard.setData('text/plain', tokens.map(t => t.label).join('\n'));
},
openAutocompleteOnFocus: false,
trimLabels: true,
styles: {cssTokenField, cssToken, cssTokenInput, cssInputWrapper, cssDeleteButton, cssDeleteIcon},
keyBindings: {
previous: 'ArrowUp',
next: 'ArrowDown'
},
variant: 'vertical',
});
return cssVerticalFlex(
cssListBox(
elem => {
tokenField.attach(elem);
this._focusOnOpen(tokenField.getTextInput());
},
testId('choice-list-entry')
),
cssButtonRow(
primaryButton('Save',
dom.on('click', () => this._save() ),
testId('choice-list-entry-save')
),
basicButton('Cancel',
dom.on('click', () => this._cancel()),
testId('choice-list-entry-cancel')
)
),
dom.onKeyDown({Escape$: () => this._cancel()}),
dom.onKeyDown({Enter$: () => this._save()}),
);
} else {
const holder = new MultiHolder();
const someValues = Computed.create(holder, this._values, (_use, values) =>
values.length <= maxRows ? values : values.slice(0, maxRows - 1));
const noChoices = Computed.create(holder, someValues, (_use, values) => values.length === 0);
return cssVerticalFlex(
dom.autoDispose(holder),
dom.maybe(this._mixed, () => [
cssListBoxInactive(
dom.cls(cssBlockedCursor.className, this._disabled),
row('Mixed configuration')
)
]),
dom.maybe(use => !use(this._mixed), () => [
cssListBoxInactive(
dom.cls(cssBlockedCursor.className, this._disabled),
dom.maybe(noChoices, () => row('No choices configured')),
dom.domComputed(this._choiceOptionsByName, (choiceOptions) =>
dom.forEach(someValues, val => {
return row(
cssTokenColorInactive(
dom.style('background-color', getFillColor(choiceOptions.get(val)) || '#FFFFFF'),
dom.style('color', getTextColor(choiceOptions.get(val)) || '#000000'),
dom.cls('font-bold', choiceOptions.get(val)?.fontBold ?? false),
dom.cls('font-underline', choiceOptions.get(val)?.fontUnderline ?? false),
dom.cls('font-italic', choiceOptions.get(val)?.fontItalic ?? false),
dom.cls('font-strikethrough', choiceOptions.get(val)?.fontStrikethrough ?? false),
'T',
testId('choice-list-entry-color')
),
cssTokenLabel(
val,
testId('choice-list-entry-label')
)
);
}),
),
// Show description row for any remaining rows
dom.maybe(use => use(this._values).length > maxRows, () =>
row(
dom('span',
testId('choice-list-entry-label'),
dom.text((use) => `+${use(this._values).length - (maxRows - 1)} more`)
)
)
),
dom.on('click', () => this._startEditing()),
cssListBoxInactive.cls("-disabled", this._disabled),
testId('choice-list-entry')
),
]),
dom.maybe(use => !use(this._disabled), () => [
cssButtonRow(
primaryButton(
dom.text(use => use(this._mixed) ? 'Reset' : 'Edit'),
dom.on('click', () => this._startEditing()),
testId('choice-list-entry-edit')
),
),
]),
);
}
});
}
private _startEditing(): void {
if (!this._disabled.get()) {
this._isEditing.set(true);
}
}
private _save(): void {
const tokenField = this._tokenFieldHolder.get();
if (!tokenField) { return; }
const tokens = tokenField.tokensObs.get();
const tokenInputVal = tokenField.getTextInputValue();
if (tokenInputVal !== '') {
tokens.push(new ChoiceItem(tokenInputVal, null));
}
const newTokens = uniqBy(tokens, t => t.label);
const newValues = newTokens.map(t => t.label);
const newOptions: ChoiceOptionsByName = new Map();
const keys: Array<keyof IChoiceOptions> = [
'fillColor', 'textColor', 'fontBold', 'fontItalic', 'fontStrikethrough', 'fontUnderline'
];
for (const t of newTokens) {
if (t.options) {
const options: IChoiceOptions = {};
keys.filter(k => t.options![k] !== undefined)
.forEach(k => options[k] = t.options![k] as any);
newOptions.set(t.label, options);
}
}
// Call user save function if the values and/or options have changed.
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, new RenameMap(newTokens));
} else {
this._cancel();
}
}
private _cancel(): void {
this._isEditing.set(false);
}
private _focusOnOpen(elem: HTMLInputElement): void {
setTimeout(() => focus(elem), 0);
}
private _renderToken(token: ChoiceItem) {
const fillColorObs = Observable.create(null, getFillColor(token.options));
const textColorObs = Observable.create(null, getTextColor(token.options));
const fontBoldObs = Observable.create(null, token.options?.fontBold);
const fontItalicObs = Observable.create(null, token.options?.fontItalic);
const fontUnderlineObs = Observable.create(null, token.options?.fontUnderline);
const fontStrikethroughObs = Observable.create(null, token.options?.fontStrikethrough);
const choiceText = Observable.create(null, token.label);
const rename = async (to: string) => {
const tokenField = this._tokenFieldHolder.get();
if (!tokenField) { return; }
to = to.trim();
// 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());
};
const tokenColorAndLabel: HTMLDivElement = cssColorAndLabel(
dom.autoDispose(fillColorObs),
dom.autoDispose(textColorObs),
dom.autoDispose(choiceText),
colorButton({
textColor: new ColorOption({color: textColorObs, defaultColor: '#000000'}),
fillColor: new ColorOption(
{color: fillColorObs, allowsNone: true, noneText: 'none', defaultColor: '#FFFFFF'}),
fontBold: fontBoldObs,
fontItalic: fontItalicObs,
fontUnderline: fontUnderlineObs,
fontStrikethrough: fontStrikethroughObs
},
async () => {
const tokenField = this._tokenFieldHolder.get();
if (!tokenField) { return; }
const fillColor = fillColorObs.get();
const textColor = textColorObs.get();
const fontBold = fontBoldObs.get();
const fontItalic = fontItalicObs.get();
const fontUnderline = fontUnderlineObs.get();
const fontStrikethrough = fontStrikethroughObs.get();
tokenField.replaceToken(token.label, ChoiceItem.from(token).changeStyle({
fillColor,
textColor,
fontBold,
fontItalic,
fontUnderline,
fontStrikethrough,
}));
}
),
editableLabel(choiceText, {
save: rename,
inputArgs: [
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),
dom.on('copy', stopPropagation),
dom.on('cut', stopPropagation),
dom.on('paste', stopPropagation),
dom.onKeyDown({
// On enter, focus on the input element.
Enter : focusOnNew,
// On escape, focus on the token (i.e. the parent node of this element). That way
// the browser will scroll the view if needed, and a subsequent escape will close
// the editor.
Escape: () => tokenColorAndLabel.parentElement?.focus(),
}),
// Don't bubble up click, as it would change focus.
dom.on('click', stopPropagation),
dom.cls(cssEditableLabelInput.className),
],
args: [dom.cls(cssEditableLabel.className)],
}),
);
return [
tokenColorAndLabel,
dom.onKeyDown({Escape$: () => this._cancel()}),
];
}
}
// Helper to focus on the token input and select/scroll to the bottom
function focus(elem: HTMLInputElement) {
elem.focus();
elem.setSelectionRange(elem.value.length, elem.value.length);
elem.scrollTo(0, elem.scrollHeight);
}
// Build a display row with the given DOM arguments
function row(...domArgs: DomElementArg[]): Element {
return cssListRow(
...domArgs,
testId('choice-list-entry-row')
);
}
function getTextColor(choiceOptions?: IChoiceOptions) {
return choiceOptions?.textColor;
}
function getFillColor(choiceOptions?: IChoiceOptions) {
return choiceOptions?.fillColor;
}
/**
* Converts clipboard contents (if any) to choices.
*
* Attempts to convert from JSON first, if clipboard contains valid JSON.
* If conversion is not possible, falls back to converting from newline-separated plaintext.
*/
function clipboardToChoices(clipboard: DataTransfer): ChoiceItem[] {
const maybeTokens = clipboard.getData('application/json');
if (maybeTokens && isJSON(maybeTokens)) {
const tokens: ChoiceItem[] = JSON.parse(maybeTokens);
if (Array.isArray(tokens) && tokens.every((t): t is ChoiceItem => ChoiceItemChecker.test(t))) {
tokens.forEach(t => t.previousLabel = null);
return tokens;
}
}
const maybeText = clipboard.getData('text/plain');
if (maybeText) {
return maybeText.split('\n').map(label => new ChoiceItem(label, null));
}
return [];
}
function isJSON(string: string) {
try {
JSON.parse(string);
return true;
} catch {
return false;
}
}
const cssListBox = styled('div', `
width: 100%;
padding: 1px;
line-height: 1.5;
padding-left: 4px;
padding-right: 4px;
border: 1px solid ${theme.choiceEntryBorderHover};
border-radius: 4px;
background-color: ${theme.choiceEntryBg};
`);
const cssListBoxInactive = styled(cssListBox, `
cursor: pointer;
border: 1px solid ${theme.choiceEntryBorder};
&:hover:not(&-disabled) {
border: 1px solid ${theme.choiceEntryBorderHover};
}
&-disabled {
opacity: 0.6;
}
`);
const cssListRow = styled('div', `
display: flex;
margin-top: 4px;
margin-bottom: 4px;
padding: 4px 8px;
color: ${colors.dark};
background-color: ${colors.mediumGreyOpaque};
border-radius: 3px;
text-overflow: ellipsis;
`);
const cssTokenField = styled('div', `
&.token-dragactive {
cursor: grabbing;
}
`);
const cssToken = styled(cssListRow, `
position: relative;
display: flex;
justify-content: space-between;
user-select: none;
cursor: grab;
&.selected {
background-color: ${colors.darkGrey};
}
&.token-dragging {
pointer-events: none;
z-index: 1;
opacity: 0.7;
}
.${cssTokenField.className}.token-dragactive & {
cursor: unset;
}
&:focus {
outline: none;
}
`);
const cssTokenColorInactive = styled('div', `
flex-shrink: 0;
width: 18px;
height: 18px;
display: grid;
place-items: center;
`);
const cssTokenLabel = styled('span', `
margin-left: 6px;
display: inline-block;
text-overflow: ellipsis;
white-space: pre;
overflow: hidden;
`);
const cssEditableLabelInput = styled('input', `
display: inline-block;
text-overflow: ellipsis;
white-space: pre;
overflow: hidden;
`);
const cssEditableLabel = styled('div', `
margin-left: 6px;
text-overflow: ellipsis;
white-space: pre;
overflow: hidden;
`);
const cssTokenInput = styled('input', `
background-color: ${theme.choiceEntryBg};
padding-top: 4px;
padding-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
flex: auto;
-webkit-appearance: none;
-moz-appearance: none;
border: none;
outline: none;
`);
const cssInputWrapper = styled('div', `
margin-top: 4px;
margin-bottom: 4px;
position: relative;
flex: auto;
display: flex;
`);
const cssFlex = styled('div', `
display: flex;
`);
const cssColorAndLabel = styled(cssFlex, `
max-width: calc(100% - 20px);
`);
const cssVerticalFlex = styled('div', `
width: 100%;
display: flex;
flex-direction: column;
`);
const cssButtonRow = styled('div', `
gap: 8px;
display: flex;
margin-top: 8px;
margin-bottom: 16px;
`);
const cssDeleteButton = styled('div', `
display: inline;
float: right;
margin-left: 4px;
cursor: pointer;
.${cssTokenField.className}.token-dragactive & {
cursor: unset;
}
`);
const cssDeleteIcon = styled(icon, `
--icon-color: ${colors.slate};
&:hover {
--icon-color: ${colors.dark};
}
`);