mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-20 01:02:22 +00:00
ae6342810d
Summary: Improving user experience on summary columns. - Showing 'not-allowed' cursor on sections/menus that can't be changed - Disabling menu options and buttons in the column behavior section that converts a formula column to a data column - Showing nicer error message about converting formula to a data column. Test Plan: manual tests, no behavior change Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3222
493 lines
15 KiB
TypeScript
493 lines
15 KiB
TypeScript
import {IToken, TokenField} from 'app/client/lib/TokenField';
|
|
import {cssBlockedCursor} from 'app/client/ui/RightPanel';
|
|
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 {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,
|
|
// 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([], {
|
|
label: "string",
|
|
options: opt("ChoiceOptionsType"),
|
|
});
|
|
|
|
const ChoiceOptionsType = iface([], {
|
|
textColor: "string",
|
|
fillColor: "string",
|
|
});
|
|
|
|
const choiceTypes: ITypeSuite = {
|
|
ChoiceItemType,
|
|
ChoiceOptionsType,
|
|
};
|
|
|
|
const {ChoiceItemType: ChoiceItemChecker} = createCheckers(choiceTypes);
|
|
|
|
const UNSET_COLOR = '#ffffff';
|
|
|
|
/**
|
|
* 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>
|
|
) {
|
|
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) {
|
|
const tokenField = TokenField.ctor<ChoiceItem>().create(this._tokenFieldHolder, {
|
|
initialValue: this._values.get().map(label => {
|
|
return new ChoiceItem(label, label, this._choiceOptionsByName.get().get(label));
|
|
}),
|
|
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'
|
|
}
|
|
});
|
|
|
|
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 someValues = Computed.create(null, this._values, (_use, values) =>
|
|
values.length <= maxRows ? values : values.slice(0, maxRows - 1));
|
|
|
|
return cssVerticalFlex(
|
|
cssListBoxInactive(
|
|
dom.cls(cssBlockedCursor.className, this._disabled),
|
|
dom.autoDispose(someValues),
|
|
dom.maybe(use => use(someValues).length === 0, () =>
|
|
row('No choices configured')
|
|
),
|
|
dom.domComputed(this._choiceOptionsByName, (choiceOptions) =>
|
|
dom.forEach(someValues, val => {
|
|
return row(
|
|
cssTokenColorInactive(
|
|
dom.style('background-color', getFillColor(choiceOptions.get(val))),
|
|
testId('choice-list-entry-color')
|
|
),
|
|
cssTokenLabel(val)
|
|
);
|
|
}),
|
|
),
|
|
// Show description row for any remaining rows
|
|
dom.maybe(use => use(this._values).length > maxRows, () =>
|
|
row(
|
|
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('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();
|
|
for (const t of newTokens) {
|
|
if (t.options) {
|
|
newOptions.set(t.label, {
|
|
fillColor: t.options.fillColor,
|
|
textColor: t.options.textColor
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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 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());
|
|
};
|
|
|
|
return cssColorAndLabel(
|
|
dom.autoDispose(fillColorObs),
|
|
dom.autoDispose(textColorObs),
|
|
dom.autoDispose(choiceText),
|
|
colorButton(textColorObs,
|
|
fillColorObs,
|
|
async () => {
|
|
const tokenField = this._tokenFieldHolder.get();
|
|
if (!tokenField) { return; }
|
|
|
|
const fillColor = fillColorObs.get();
|
|
const textColor = textColorObs.get();
|
|
tokenField.replaceToken(token.label, ChoiceItem.from(token).changeColors({fillColor, textColor}));
|
|
}
|
|
),
|
|
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();
|
|
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 ?? DEFAULT_TEXT_COLOR;
|
|
}
|
|
|
|
function getFillColor(choiceOptions?: IChoiceOptions) {
|
|
return choiceOptions?.fillColor ?? UNSET_COLOR;
|
|
}
|
|
|
|
/**
|
|
* 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 = JSON.parse(maybeTokens);
|
|
if (Array.isArray(tokens) && tokens.every((t): t is ChoiceItem => ChoiceItemChecker.test(t))) {
|
|
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 ${colors.hover};
|
|
border-radius: 4px;
|
|
background-color: white;
|
|
`);
|
|
|
|
const cssListBoxInactive = styled(cssListBox, `
|
|
cursor: pointer;
|
|
border: 1px solid ${colors.darkGrey};
|
|
|
|
&:hover:not(&-disabled) {
|
|
border: 1px solid ${colors.hover};
|
|
}
|
|
&-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.mediumGrey};
|
|
border-radius: 3px;
|
|
overflow: hidden;
|
|
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;
|
|
}
|
|
`);
|
|
|
|
const cssTokenColorInactive = styled('div', `
|
|
flex-shrink: 0;
|
|
width: 18px;
|
|
height: 18px;
|
|
`);
|
|
|
|
const cssTokenLabel = styled('span', `
|
|
margin-left: 6px;
|
|
display: inline-block;
|
|
text-overflow: ellipsis;
|
|
white-space: pre;
|
|
overflow: hidden;
|
|
`);
|
|
|
|
const cssTokenInput = styled('input', `
|
|
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% - 16px);
|
|
`);
|
|
|
|
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;
|
|
cursor: pointer;
|
|
.${cssTokenField.className}.token-dragactive & {
|
|
cursor: unset;
|
|
}
|
|
`);
|
|
|
|
const cssDeleteIcon = styled(icon, `
|
|
--icon-color: ${colors.slate};
|
|
&:hover {
|
|
--icon-color: ${colors.dark};
|
|
}
|
|
`);
|