(core) Enhance autocomplete and choice colors

Summary:
Choice columns can now add new choices directly
from the autocomplete menu. The autocomplete will
now highlight the first matching item, even if there are equally
ranked alternatives. No changes have been made to how the
autocomplete index is created, or how it scores items.

For choice and choice list columns, the filter menu will
now display values using their configured colors, similar to the
rest of the UI. Choice tokens throughout the UI now do a better
job of handling text overflow by showing an ellipsis whenever
there isn't enough space to show the full text of a choice.

Test Plan: Browser tests.

Reviewers: cyprien

Reviewed By: cyprien

Differential Revision: https://phab.getgrist.com/D2904
pull/9/head
George Gevoian 3 years ago
parent 997be24a21
commit 5b2666a88a

@ -132,12 +132,10 @@ export class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
const highlightFunc = highlightMatches.bind(null, searchWords); const highlightFunc = highlightMatches.bind(null, searchWords);
// The best match is the first item. If it actually starts with the search text, AND has a // The best match is the first item. If any word in the item actually starts with the search
// strictly better score than other items, highlight it as a default selection. Otherwise, no // text, highlight it as a default selection. Otherwise, no item will be auto-selected.
// item will be auto-selected.
let selectIndex = -1; let selectIndex = -1;
if (items.length > 0 && items[0].cleanText.startsWith(cleanedSearchText) && if (items.length > 0 && sortedMatches.length > 0 && startsWithText(items[0], cleanedSearchText)) {
(sortedMatches.length <= 1 || sortedMatches[1][1] < sortedMatches[0][1])) {
selectIndex = 0; selectIndex = 0;
} }
return {items, highlightFunc, selectIndex}; return {items, highlightFunc, selectIndex};
@ -248,3 +246,13 @@ function findCommonPrefixLength(text1: string, text2: string): number {
while (i < text1.length && text1[i] === text2[i]) { ++i; } while (i < text1.length && text1[i] === text2[i]) { ++i; }
return i; return i;
} }
/**
* Checks whether `item` starts with `text`, or has any words that start with `text`.
*/
function startsWithText(item: ACItem, text: string): boolean {
if (item.cleanText.startsWith(text)) { return true; }
const words = item.cleanText.split(wordSepRegexp);
return words.some(w => w.startsWith(text));
}

@ -36,6 +36,7 @@ export interface ITokenFieldOptions<Token extends IToken> {
openAutocompleteOnFocus?: boolean; openAutocompleteOnFocus?: boolean;
styles?: ITokenFieldStyles; styles?: ITokenFieldStyles;
readonly?: boolean; readonly?: boolean;
trimLabels?: boolean;
keyBindings?: ITokenFieldKeyBindings; keyBindings?: ITokenFieldKeyBindings;
// Allows overriding how tokens are copied to the clipboard, or retrieved from it. // Allows overriding how tokens are copied to the clipboard, or retrieved from it.
@ -101,7 +102,10 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
const addSelectedItem = this._addSelectedItem.bind(this); const addSelectedItem = this._addSelectedItem.bind(this);
const openAutocomplete = this._openAutocomplete.bind(this); const openAutocomplete = this._openAutocomplete.bind(this);
this._acOptions = _options.acOptions && {..._options.acOptions, onClick: addSelectedItem}; this._acOptions = _options.acOptions && {..._options.acOptions, onClick: addSelectedItem};
this._tokens.set(_options.initialValue.map(t => new TokenWrap(t)));
const initialTokens = _options.initialValue;
this._maybeTrimLabels(initialTokens);
this._tokens.set(initialTokens.map(t => new TokenWrap(t)));
this.tokensObs = this.autoDispose(computedArray(this._tokens, t => t.token)); this.tokensObs = this.autoDispose(computedArray(this._tokens, t => t.token));
this._keyBindings = {...defaultKeyBindings, ..._options.keyBindings}; this._keyBindings = {...defaultKeyBindings, ..._options.keyBindings};
@ -191,12 +195,19 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
return this._textInput; return this._textInput;
} }
/**
* Returns the current value of the text input.
*/
public getTextInputValue(): string {
return this._options.trimLabels ? this._textInput.value.trim() : this._textInput.value;
}
// The invisible input that has focus while we have some tokens selected. // The invisible input that has focus while we have some tokens selected.
public getHiddenInput(): HTMLInputElement { public getHiddenInput(): HTMLInputElement {
return this._hiddenInput; return this._hiddenInput;
} }
// Replaces a token (if it exists) // Replaces a token (if it exists).
public replaceToken(label: string, newToken: Token): void { public replaceToken(label: string, newToken: Token): void {
const tokenIdx = this._tokens.get().findIndex(t => t.token.label === label); const tokenIdx = this._tokens.get().findIndex(t => t.token.label === label);
if (tokenIdx === -1) { return; } if (tokenIdx === -1) { return; }
@ -216,8 +227,9 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
// that; otherwise if options.createToken is present, creates a token from text input value. // that; otherwise if options.createToken is present, creates a token from text input value.
private _addSelectedItem(): boolean { private _addSelectedItem(): boolean {
let item: Token|undefined = this._acHolder.get()?.getSelectedItem(); let item: Token|undefined = this._acHolder.get()?.getSelectedItem();
if (!item && this._options.createToken && this._textInput.value) { const textInput = this.getTextInputValue();
item = this._options.createToken(this._textInput.value); if (!item && this._options.createToken && textInput) {
item = this._options.createToken(textInput);
} }
if (item) { if (item) {
this._tokens.push(new TokenWrap(item)); this._tokens.push(new TokenWrap(item));
@ -421,6 +433,8 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
tokens = values.map(v => this._options.createToken(v)).filter((t): t is Token => Boolean(t)); tokens = values.map(v => this._options.createToken(v)).filter((t): t is Token => Boolean(t));
} }
if (!tokens.length) { return; } if (!tokens.length) { return; }
this._maybeTrimLabels(tokens);
tokens = this._getNonEmptyTokens(tokens);
const wrappedTokens = tokens.map(t => new TokenWrap(t)); const wrappedTokens = tokens.map(t => new TokenWrap(t));
this._combineUndo(() => { this._combineUndo(() => {
this._deleteTokens(this._selection.get(), 1); this._deleteTokens(this._selection.get(), 1);
@ -566,6 +580,25 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
} }
} }
} }
/**
* Trims all labels in `tokens` if the option is set.
*
* Note: mutates `tokens`.
*/
private _maybeTrimLabels(tokens: Token[]): void {
if (!this._options.trimLabels) { return; }
tokens.forEach(t => {
t.label = t.label.trim();
});
}
/**
* Returns a filtered array of tokens that don't have empty labels.
*/
private _getNonEmptyTokens(tokens: Token[]): Token[] {
return tokens.filter(t => t.label !== '');
}
} }
const cssTokenField = styled('div', ` const cssTokenField = styled('div', `

@ -11,14 +11,14 @@ import {RowId, RowSource} from 'app/client/models/rowset';
import {ColumnFilterFunc, SectionFilter} from 'app/client/models/SectionFilter'; import {ColumnFilterFunc, SectionFilter} from 'app/client/models/SectionFilter';
import {TableData} from 'app/client/models/TableData'; import {TableData} from 'app/client/models/TableData';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {cssCheckboxSquare, cssLabel, cssLabelText, Indeterminate, import {cssLabel as cssCheckboxLabel, cssCheckboxSquare, cssLabelText, Indeterminate, labeledTriStateSquareCheckbox
labeledTriStateSquareCheckbox} from 'app/client/ui2018/checkbox'; } from 'app/client/ui2018/checkbox';
import {colors, vars} from 'app/client/ui2018/cssVars'; import {colors, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {menuCssClass, menuDivider} from 'app/client/ui2018/menus'; import {menuCssClass, menuDivider} from 'app/client/ui2018/menus';
import {CellValue} from 'app/common/DocActions'; import {CellValue} from 'app/common/DocActions';
import {isEquivalentFilter} from "app/common/FilterState"; import {isEquivalentFilter} from "app/common/FilterState";
import {Computed, dom, DomElementMethod, IDisposableOwner, input, makeTestId, styled} from 'grainjs'; import {Computed, dom, DomElementArg, DomElementMethod, IDisposableOwner, input, makeTestId, styled} from 'grainjs';
import concat = require('lodash/concat'); import concat = require('lodash/concat');
import identity = require('lodash/identity'); import identity = require('lodash/identity');
import noop = require('lodash/noop'); import noop = require('lodash/noop');
@ -28,18 +28,22 @@ import tail = require('lodash/tail');
import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel'; import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel';
import {decodeObject} from 'app/plugin/objtypes'; import {decodeObject} from 'app/plugin/objtypes';
import {isList} from 'app/common/gristTypes'; import {isList} from 'app/common/gristTypes';
import {choiceToken} from 'app/client/widgets/ChoiceToken';
import {ChoiceOptions} from 'app/client/widgets/ChoiceTextBox';
import {cssInvalidToken} from 'app/client/widgets/ChoiceListCell';
interface IFilterMenuOptions { interface IFilterMenuOptions {
model: ColumnFilterMenuModel; model: ColumnFilterMenuModel;
valueCounts: Map<CellValue, IFilterCount>; valueCounts: Map<CellValue, IFilterCount>;
doSave: (reset: boolean) => void; doSave: (reset: boolean) => void;
onClose: () => void; onClose: () => void;
renderValue: (key: CellValue, value: IFilterCount) => DomElementArg;
} }
const testId = makeTestId('test-filter-menu-'); const testId = makeTestId('test-filter-menu-');
export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement { export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement {
const { model, doSave, onClose } = opts; const { model, doSave, onClose, renderValue } = opts;
const { columnFilter } = model; const { columnFilter } = model;
// Save the initial state to allow reverting back to it on Cancel // Save the initial state to allow reverting back to it on Cancel
const initialStateJson = columnFilter.makeFilterJson(); const initialStateJson = columnFilter.makeFilterJson();
@ -142,11 +146,14 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
dom.domComputed(filteredValues, (values) => values.slice(0, model.limitShown).map(([key, value]) => ( dom.domComputed(filteredValues, (values) => values.slice(0, model.limitShown).map(([key, value]) => (
cssMenuItem( cssMenuItem(
cssLabel( cssLabel(
cssCheckboxSquare({type: 'checkbox'}, cssCheckboxSquare(
dom.on('change', (_ev, elem) => {type: 'checkbox'},
elem.checked ? columnFilter.add(key) : columnFilter.delete(key)), dom.on('change', (_ev, elem) =>
(elem) => { elem.checked = columnFilter.includes(key); checkboxMap.set(key, elem); }), elem.checked ? columnFilter.add(key) : columnFilter.delete(key)),
cssItemValue(value.label === undefined ? key as string : value.label), (elem) => { elem.checked = columnFilter.includes(key); checkboxMap.set(key, elem); },
dom.style('position', 'relative'),
),
renderValue(key, value),
), ),
cssItemCount(value.count.toLocaleString(), testId('count'))) cssItemCount(value.count.toLocaleString(), testId('count')))
))) // Include comma separator ))) // Include comma separator
@ -311,9 +318,45 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
sectionFilter.resetTemporaryRows(); sectionFilter.resetTemporaryRows();
} }
}, },
renderValue: getRenderFunc(columnType, field),
}); });
} }
/**
* Returns a callback function for rendering values in a filter menu.
*
* For example, Choice and Choice List columns will differ from other
* column types by rendering their values as colored tokens instead of
* text.
*/
function getRenderFunc(columnType: string, field: ViewFieldRec) {
if (['Choice', 'ChoiceList'].includes(columnType)) {
const options = field.column().widgetOptionsJson.peek();
const choiceSet: Set<string> = new Set(options.choices || []);
const choiceOptions: ChoiceOptions = options.choiceOptions || {};
return (_key: CellValue, value: IFilterCount) => {
if (value.label === '') {
return cssItemValue(value.label);
}
return choiceToken(
value.label,
{
fillColor: choiceOptions[value.label]?.fillColor,
textColor: choiceOptions[value.label]?.textColor,
},
dom.cls(cssToken.className),
cssInvalidToken.cls('-invalid', !choiceSet.has(value.label)),
testId('choice-token')
);
};
}
return (key: CellValue, value: IFilterCount) =>
cssItemValue(value.label === undefined ? String(key) : value.label);
}
interface ICountOptions { interface ICountOptions {
keyMapFunc?: (v: any) => any; keyMapFunc?: (v: any) => any;
labelMapFunc?: (v: any) => any; labelMapFunc?: (v: any) => any;
@ -439,10 +482,10 @@ const cssMenuItem = styled('div', `
display: flex; display: flex;
padding: 8px 16px; padding: 8px 16px;
`); `);
const cssItemValue = styled(cssLabelText, ` export const cssItemValue = styled(cssLabelText, `
margin-right: 12px; margin-right: 12px;
color: ${colors.dark}; color: ${colors.dark};
white-space: nowrap; white-space: pre;
`); `);
const cssItemCount = styled('div', ` const cssItemCount = styled('div', `
flex-grow: 1; flex-grow: 1;
@ -490,3 +533,11 @@ const cssSortIcon = styled(icon, `
--icon-color: ${colors.lightGreen} --icon-color: ${colors.lightGreen}
} }
`); `);
const cssLabel = styled(cssCheckboxLabel, `
align-items: center;
font-weight: initial; /* negate bootstrap */
`);
const cssToken = styled('div', `
margin-left: 8px;
margin-right: 12px;
`);

@ -1,14 +1,14 @@
var _ = require('underscore'); var _ = require('underscore');
var dispose = require('../lib/dispose'); var dispose = require('app/client/lib/dispose');
var TextEditor = require('./TextEditor'); var TextEditor = require('app/client/widgets/TextEditor');
const {Autocomplete} = require('app/client/lib/autocomplete'); const {Autocomplete} = require('app/client/lib/autocomplete');
const {ACIndexImpl, buildHighlightedDom} = require('app/client/lib/ACIndex'); const {ACIndexImpl, buildHighlightedDom} = require('app/client/lib/ACIndex');
const {ChoiceItem, cssChoiceList, cssItem, cssItemLabel, cssMatchText} = require('app/client/widgets/ChoiceListEditor'); const {ChoiceItem, cssChoiceList, cssMatchText, cssPlusButton,
const {cssRefList} = require('app/client/widgets/ReferenceEditor'); cssPlusIcon} = require('app/client/widgets/ChoiceListEditor');
const {getFillColor, getTextColor} = require('app/client/widgets/ChoiceTextBox');
const {menuCssClass} = require('app/client/ui2018/menus'); const {menuCssClass} = require('app/client/ui2018/menus');
const {testId} = require('app/client/ui2018/cssVars'); const {testId} = require('app/client/ui2018/cssVars');
const {choiceToken, cssChoiceACItem} = require('app/client/widgets/ChoiceToken');
const {dom} = require('grainjs'); const {dom} = require('grainjs');
/** /**
@ -19,6 +19,10 @@ function ChoiceEditor(options) {
this.choices = options.field.widgetOptionsJson.peek().choices || []; this.choices = options.field.widgetOptionsJson.peek().choices || [];
this.choiceOptions = options.field.widgetOptionsJson.peek().choiceOptions || {}; this.choiceOptions = options.field.widgetOptionsJson.peek().choiceOptions || {};
// Whether to include a button to show a new choice.
// TODO: Disable when the user cannot change column configuration.
this.enableAddNew = true;
} }
dispose.makeDisposable(ChoiceEditor); dispose.makeDisposable(ChoiceEditor);
@ -31,14 +35,16 @@ ChoiceEditor.prototype.getCellValue = function() {
ChoiceEditor.prototype.renderACItem = function(item, highlightFunc) { ChoiceEditor.prototype.renderACItem = function(item, highlightFunc) {
const options = this.choiceOptions[item.label]; const options = this.choiceOptions[item.label];
const fillColor = getFillColor(options);
const textColor = getTextColor(options);
return cssItem( return cssChoiceACItem(
cssItemLabel( (item.isNew ?
[cssChoiceACItem.cls('-new'), cssPlusButton(cssPlusIcon('Plus')), testId('choice-editor-new-item')] :
[cssChoiceACItem.cls('-with-new', this.showAddNew)]
),
choiceToken(
buildHighlightedDom(item.label, highlightFunc, cssMatchText), buildHighlightedDom(item.label, highlightFunc, cssMatchText),
dom.style('background-color', fillColor), options || {},
dom.style('color', textColor), dom.style('max-width', '100%'),
testId('choice-editor-item-label') testId('choice-editor-item-label')
), ),
testId('choice-editor-item'), testId('choice-editor-item'),
@ -47,8 +53,8 @@ ChoiceEditor.prototype.renderACItem = function(item, highlightFunc) {
ChoiceEditor.prototype.attach = function(cellElem) { ChoiceEditor.prototype.attach = function(cellElem) {
TextEditor.prototype.attach.call(this, cellElem); TextEditor.prototype.attach.call(this, cellElem);
// Don't create autocomplete if readonly, or if there are no choices. // Don't create autocomplete if readonly.
if (this.options.readonly || this.choices.length === 0) { return; } if (this.options.readonly) { return; }
const acItems = this.choices.map(c => new ChoiceItem(c, false)); const acItems = this.choices.map(c => new ChoiceItem(c, false));
const acIndex = new ACIndexImpl(acItems); const acIndex = new ACIndexImpl(acItems);
@ -56,8 +62,8 @@ ChoiceEditor.prototype.attach = function(cellElem) {
popperOptions: { popperOptions: {
placement: 'bottom' placement: 'bottom'
}, },
menuCssClass: menuCssClass + ' ' + cssRefList.className + ' ' + cssChoiceList.className + ' test-autocomplete', menuCssClass: `${menuCssClass} ${cssChoiceList.className} test-autocomplete`,
search: (term) => acIndex.search(term), search: (term) => this.maybeShowAddNew(acIndex.search(term), term),
renderItem: (item, highlightFunc) => this.renderACItem(item, highlightFunc), renderItem: (item, highlightFunc) => this.renderACItem(item, highlightFunc),
getItemText: (item) => item.label, getItemText: (item) => item.label,
onClick: () => this.options.commands.fieldEditSave(), onClick: () => this.options.commands.fieldEditSave(),
@ -66,4 +72,39 @@ ChoiceEditor.prototype.attach = function(cellElem) {
this.autocomplete = Autocomplete.create(this, this.textInput, acOptions); this.autocomplete = Autocomplete.create(this, this.textInput, acOptions);
} }
/**
* Updates list of valid choices with any new ones that may have been
* added from directly inside the editor (via the "add new" item in autocomplete).
*/
ChoiceEditor.prototype.prepForSave = async function() {
const selectedItem = this.autocomplete && this.autocomplete.getSelectedItem();
if (selectedItem && selectedItem.isNew) {
const choices = this.options.field.widgetOptionsJson.prop('choices');
await choices.saveOnly([...choices.peek(), selectedItem.label]);
}
}
/**
* If the search text does not match anything exactly, adds 'new' item to it.
*
* Also see: prepForSave.
*/
ChoiceEditor.prototype.maybeShowAddNew = function(result, text) {
// TODO: This logic is also mostly duplicated in ChoiceListEditor and ReferenceEditor.
// See if there's anything common we can factor out and re-use.
this.showAddNew = false;
const trimmedText = text.trim();
if (!this.enableAddNew || !trimmedText) { return result; }
const addNewItem = new ChoiceItem(trimmedText, false, true);
if (result.items.find((item) => item.cleanText === addNewItem.cleanText)) {
return result;
}
result.items.push(addNewItem);
this.showAddNew = true;
return result;
}
module.exports = ChoiceEditor; module.exports = ChoiceEditor;

@ -3,12 +3,11 @@ import {colors, testId} from 'app/client/ui2018/cssVars';
import { import {
ChoiceOptionsByName, ChoiceOptionsByName,
ChoiceTextBox, ChoiceTextBox,
getFillColor,
getTextColor
} from 'app/client/widgets/ChoiceTextBox'; } from 'app/client/widgets/ChoiceTextBox';
import {CellValue} from 'app/common/DocActions'; import {CellValue} from 'app/common/DocActions';
import {decodeObject} from 'app/plugin/objtypes'; import {decodeObject} from 'app/plugin/objtypes';
import {Computed, dom, styled} from 'grainjs'; import {Computed, dom, styled} from 'grainjs';
import {choiceToken} from 'app/client/widgets/ChoiceToken';
/** /**
* ChoiceListCell - A cell that renders a list of choice tokens. * ChoiceListCell - A cell that renders a list of choice tokens.
@ -37,13 +36,13 @@ export class ChoiceListCell extends ChoiceTextBox {
// Handle any unexpected values we might get (non-array, or array with non-strings). // Handle any unexpected values we might get (non-array, or array with non-strings).
const tokens: unknown[] = Array.isArray(val) ? val : [val]; const tokens: unknown[] = Array.isArray(val) ? val : [val];
return tokens.map(token => return tokens.map(token =>
cssToken( choiceToken(
String(token), String(token),
cssInvalidToken.cls('-invalid', !choiceSet.has(token as string)), choiceOptionsByName.get(String(token)) || {},
dom.style('background-color', getFillColor(choiceOptionsByName.get(String(token)))), cssInvalidToken.cls('-invalid', !choiceSet.has(String(token))),
dom.style('color', getTextColor(choiceOptionsByName.get(String(token)))), dom.cls(cssToken.className),
testId('choice-list-cell-token') testId('choice-list-cell-token')
), )
); );
}), }),
); );
@ -67,9 +66,6 @@ const cssChoiceList = styled('div', `
const cssToken = styled('div', ` const cssToken = styled('div', `
flex: 0 1 auto; flex: 0 1 auto;
min-width: 0px; min-width: 0px;
overflow: hidden;
border-radius: 3px;
padding: 1px 4px;
margin: 2px; margin: 2px;
line-height: 16px; line-height: 16px;
`); `);

@ -2,18 +2,19 @@ import {createGroup} from 'app/client/components/commands';
import {ACIndexImpl, ACItem, ACResults, buildHighlightedDom, HighlightFunc} from 'app/client/lib/ACIndex'; import {ACIndexImpl, ACItem, ACResults, buildHighlightedDom, HighlightFunc} from 'app/client/lib/ACIndex';
import {IAutocompleteOptions} from 'app/client/lib/autocomplete'; import {IAutocompleteOptions} from 'app/client/lib/autocomplete';
import {IToken, TokenField, tokenFieldStyles} from 'app/client/lib/TokenField'; import {IToken, TokenField, tokenFieldStyles} from 'app/client/lib/TokenField';
import {colors, testId, vars} from 'app/client/ui2018/cssVars'; import {colors, testId} from 'app/client/ui2018/cssVars';
import {menuCssClass} from 'app/client/ui2018/menus'; import {menuCssClass} from 'app/client/ui2018/menus';
import {cssInvalidToken} from 'app/client/widgets/ChoiceListCell'; import {cssInvalidToken} from 'app/client/widgets/ChoiceListCell';
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons'; import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
import {EditorPlacement} from 'app/client/widgets/EditorPlacement'; import {EditorPlacement} from 'app/client/widgets/EditorPlacement';
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor'; import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
import {cssPlusButton, cssPlusIcon, cssRefList} from 'app/client/widgets/ReferenceEditor';
import {csvEncodeRow} from 'app/common/csvFormat'; import {csvEncodeRow} from 'app/common/csvFormat';
import {CellValue} from "app/common/DocActions"; import {CellValue} from "app/common/DocActions";
import {decodeObject, encodeObject} from 'app/plugin/objtypes'; import {decodeObject, encodeObject} from 'app/plugin/objtypes';
import {dom, styled} from 'grainjs'; import {dom, styled} from 'grainjs';
import {ChoiceOptions, getFillColor, getTextColor} from 'app/client/widgets/ChoiceTextBox'; import {ChoiceOptions, getFillColor, getTextColor} from 'app/client/widgets/ChoiceTextBox';
import {choiceToken, cssChoiceACItem} from 'app/client/widgets/ChoiceToken';
import {icon} from 'app/client/ui2018/icons';
export class ChoiceItem implements ACItem, IToken { export class ChoiceItem implements ACItem, IToken {
public cleanText: string = this.label.toLowerCase().trim(); public cleanText: string = this.label.toLowerCase().trim();
@ -36,8 +37,8 @@ export class ChoiceListEditor extends NewBaseEditor {
private _inputSizer: HTMLElement; // Part of _contentSizer to size the text input private _inputSizer: HTMLElement; // Part of _contentSizer to size the text input
private _alignment: string; private _alignment: string;
// Whether to include a button to show a new choice. (It would make sense to disable it when // Whether to include a button to show a new choice.
// user cannot change the column configuration.) // TODO: Disable when the user cannot change column configuration.
private _enableAddNew: boolean = true; private _enableAddNew: boolean = true;
private _showAddNew: boolean = false; private _showAddNew: boolean = false;
@ -54,7 +55,7 @@ export class ChoiceListEditor extends NewBaseEditor {
const acIndex = new ACIndexImpl<ChoiceItem>(acItems); const acIndex = new ACIndexImpl<ChoiceItem>(acItems);
const acOptions: IAutocompleteOptions<ChoiceItem> = { const acOptions: IAutocompleteOptions<ChoiceItem> = {
menuCssClass: menuCssClass + ' ' + cssRefList.className + ' ' + cssChoiceList.className + ' test-autocomplete', menuCssClass: `${menuCssClass} ${cssChoiceList.className} test-autocomplete`,
search: async (term: string) => this._maybeShowAddNew(acIndex.search(term), term), search: async (term: string) => this._maybeShowAddNew(acIndex.search(term), term),
renderItem: (item, highlightFunc) => this._renderACItem(item, highlightFunc), renderItem: (item, highlightFunc) => this._renderACItem(item, highlightFunc),
getItemText: (item) => item.label, getItemText: (item) => item.label,
@ -80,6 +81,7 @@ export class ChoiceListEditor extends NewBaseEditor {
acOptions, acOptions,
openAutocompleteOnFocus: true, openAutocompleteOnFocus: true,
readonly : options.readonly, readonly : options.readonly,
trimLabels: true,
styles: {cssTokenField, cssToken, cssDeleteButton, cssDeleteIcon}, styles: {cssTokenField, cssToken, cssDeleteButton, cssDeleteIcon},
}); });
@ -146,6 +148,10 @@ export class ChoiceListEditor extends NewBaseEditor {
return this._textInput.selectionStart || 0; return this._textInput.selectionStart || 0;
} }
/**
* Updates list of valid choices with any new ones that may have been
* added from directly inside the editor (via the "add new" item in autocomplete).
*/
public async prepForSave() { public async prepForSave() {
const tokens = this._tokenField.tokensObs.get(); const tokens = this._tokenField.tokensObs.get();
const newChoices = tokens.filter(t => t.isNew).map(t => t.label); const newChoices = tokens.filter(t => t.isNew).map(t => t.label);
@ -202,33 +208,39 @@ export class ChoiceListEditor extends NewBaseEditor {
this._textInput.style.width = this._inputSizer.getBoundingClientRect().width + 'px'; this._textInput.style.width = this._inputSizer.getBoundingClientRect().width + 'px';
} }
/**
* If the search text does not match anything exactly, adds 'new' item to it.
*
* Also see: prepForSave.
*/
private _maybeShowAddNew(result: ACResults<ChoiceItem>, text: string): ACResults<ChoiceItem> { private _maybeShowAddNew(result: ACResults<ChoiceItem>, text: string): ACResults<ChoiceItem> {
// If the search text does not match anything exactly, add 'new' item for it. See also prepForSave.
this._showAddNew = false; this._showAddNew = false;
if (this._enableAddNew && text) { const trimmedText = text.trim();
const addNewItem = new ChoiceItem(text, false, true); if (!this._enableAddNew || !trimmedText) { return result; }
if (!result.items.find((item) => item.cleanText === addNewItem.cleanText)) {
result.items.push(addNewItem); const addNewItem = new ChoiceItem(trimmedText, false, true);
this._showAddNew = true; if (result.items.find((item) => item.cleanText === addNewItem.cleanText)) {
} return result;
} }
result.items.push(addNewItem);
this._showAddNew = true;
return result; return result;
} }
private _renderACItem(item: ChoiceItem, highlightFunc: HighlightFunc) { private _renderACItem(item: ChoiceItem, highlightFunc: HighlightFunc) {
const options = this._choiceOptionsByName[item.label]; const options = this._choiceOptionsByName[item.label];
const fillColor = getFillColor(options);
const textColor = getTextColor(options);
return cssItem( return cssChoiceACItem(
(item.isNew ? (item.isNew ?
[cssItem.cls('-new'), cssPlusButton(cssPlusIcon('Plus'))] : [cssChoiceACItem.cls('-new'), cssPlusButton(cssPlusIcon('Plus'))] :
[cssItem.cls('-with-new', this._showAddNew)] [cssChoiceACItem.cls('-with-new', this._showAddNew)]
), ),
cssItemLabel( choiceToken(
buildHighlightedDom(item.label, highlightFunc, cssMatchText), buildHighlightedDom(item.label, highlightFunc, cssMatchText),
dom.style('background-color', fillColor), options || {},
dom.style('color', textColor), dom.style('max-width', '100%'),
testId('choice-list-editor-item-label') testId('choice-list-editor-item-label')
), ),
testId('choice-list-editor-item'), testId('choice-list-editor-item'),
@ -258,6 +270,7 @@ const cssToken = styled(tokenFieldStyles.cssToken, `
padding: 1px 4px; padding: 1px 4px;
margin: 2px; margin: 2px;
line-height: 16px; line-height: 16px;
white-space: pre;
&.selected { &.selected {
box-shadow: inset 0 0 0 1px ${colors.lightGreen}; box-shadow: inset 0 0 0 1px ${colors.lightGreen};
@ -316,7 +329,10 @@ const cssInputSizer = styled('div', `
// Set z-index to be higher than the 1000 set for .cell_editor. // Set z-index to be higher than the 1000 set for .cell_editor.
export const cssChoiceList = styled('div', ` export const cssChoiceList = styled('div', `
z-index: 1001; z-index: 1001;
box-shadow: 0 0px 8px 0 rgba(38,38,51,0.6) box-shadow: 0 0px 8px 0 rgba(38,38,51,0.6);
overflow-y: auto;
padding: 8px 0 0 0;
--weaseljs-menu-item-padding: 8px 16px;
`); `);
const cssReadonlyStyle = styled('div', ` const cssReadonlyStyle = styled('div', `
@ -324,48 +340,25 @@ const cssReadonlyStyle = styled('div', `
background: white; background: white;
`); `);
// We need to know the height of the sticky "+" element. export const cssMatchText = styled('span', `
const addNewHeight = '37px'; text-decoration: underline;
export const cssItem = styled('li', `
display: block;
font-family: ${vars.fontFamily};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
outline: none;
padding: var(--weaseljs-menu-item-padding, 8px 24px);
cursor: pointer;
&.selected {
background-color: ${colors.mediumGreyOpaque};
color: ${colors.dark};
}
&-with-new {
scroll-margin-bottom: ${addNewHeight};
}
&-new {
display: flex;
align-items: center;
color: ${colors.slate};
position: sticky;
bottom: 0px;
height: ${addNewHeight};
background-color: white;
border-top: 1px solid ${colors.mediumGreyOpaque};
scroll-margin-bottom: initial;
}
&-new.selected {
color: ${colors.lightGrey};
}
`); `);
export const cssItemLabel = styled('div', ` export const cssPlusButton = styled('div', `
display: inline-block; display: inline-block;
padding: 1px 4px; width: 20px;
border-radius: 3px; height: 20px;
border-radius: 20px;
margin-right: 8px;
text-align: center;
background-color: ${colors.lightGreen};
color: ${colors.light};
.selected > & {
background-color: ${colors.darkGreen};
}
`); `);
export const cssMatchText = styled('span', ` export const cssPlusIcon = styled(icon, `
text-decoration: underline; background-color: ${colors.light};
`); `);

@ -4,8 +4,9 @@ import {Computed, Disposable, dom, DomContents, DomElementArg, Holder, Observabl
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import isEqual = require('lodash/isEqual'); import isEqual = require('lodash/isEqual');
import uniqBy = require('lodash/uniqBy'); import uniqBy = require('lodash/uniqBy');
import {IToken, TokenField} from '../lib/TokenField'; import {IToken, TokenField} from 'app/client/lib/TokenField';
import {ChoiceOptionsByName, DEFAULT_TEXT_COLOR, IChoiceOptions} from 'app/client/widgets/ChoiceTextBox'; 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 {colorButton} from 'app/client/ui2018/ColorSelect';
import {createCheckers, iface, ITypeSuite, opt} from 'ts-interface-checker'; import {createCheckers, iface, ITypeSuite, opt} from 'ts-interface-checker';
@ -82,7 +83,7 @@ export class ChoiceListEntry extends Disposable {
return new ChoiceItem(label, this._choiceOptionsByName.get().get(label)); return new ChoiceItem(label, this._choiceOptionsByName.get().get(label));
}), }),
renderToken: token => this._renderToken(token), renderToken: token => this._renderToken(token),
createToken: label => label.trim() !== '' ? new ChoiceItem(label.trim()) : undefined, createToken: label => new ChoiceItem(label),
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).
@ -91,6 +92,7 @@ export class ChoiceListEntry extends Disposable {
clipboard.setData('text/plain', tokens.map(t => t.label).join('\n')); clipboard.setData('text/plain', tokens.map(t => t.label).join('\n'));
}, },
openAutocompleteOnFocus: false, openAutocompleteOnFocus: false,
trimLabels: true,
styles: {cssTokenField, cssToken, cssTokenInput, cssInputWrapper, cssDeleteButton, cssDeleteIcon}, styles: {cssTokenField, cssToken, cssTokenInput, cssInputWrapper, cssDeleteButton, cssDeleteIcon},
keyBindings: { keyBindings: {
previous: 'ArrowUp', previous: 'ArrowUp',
@ -116,7 +118,8 @@ export class ChoiceListEntry extends Disposable {
testId('choice-list-entry-cancel') testId('choice-list-entry-cancel')
) )
), ),
dom.onKeyDown({Escape$: () => this._save()}), dom.onKeyDown({Escape$: () => this._cancel()}),
dom.onKeyDown({Enter$: () => this._save()}),
); );
} else { } else {
const someValues = Computed.create(null, this._values, (_use, values) => const someValues = Computed.create(null, this._values, (_use, values) =>
@ -168,7 +171,7 @@ export class ChoiceListEntry extends Disposable {
if (!tokenField) { return; } if (!tokenField) { return; }
const tokens = tokenField.tokensObs.get(); const tokens = tokenField.tokensObs.get();
const tokenInputVal = tokenField.getTextInput().value.trim(); const tokenInputVal = tokenField.getTextInputValue();
if (tokenInputVal !== '') { if (tokenInputVal !== '') {
tokens.push(new ChoiceItem(tokenInputVal)); tokens.push(new ChoiceItem(tokenInputVal));
} }
@ -266,7 +269,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').filter(t => t.trim() !== '').map(label => new ChoiceItem(label)); return maybeText.split('\n').map(label => new ChoiceItem(label));
} }
return []; return [];
@ -349,7 +352,7 @@ const cssTokenLabel = styled('span', `
margin-left: 6px; margin-left: 6px;
display: inline-block; display: inline-block;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: pre;
overflow: hidden; overflow: hidden;
`); `);

@ -1,14 +1,12 @@
import * as commands from 'app/client/components/commands';
import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry'; import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry';
import {DataRowModel} from 'app/client/models/DataRowModel'; import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {KoSaveableObservable} from 'app/client/models/modelUtil'; import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {cssLabel, cssRow} from 'app/client/ui/RightPanel'; import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
import {colors, testId} from 'app/client/ui2018/cssVars'; import {testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu, menuItem} from 'app/client/ui2018/menus';
import {NTextBox} from 'app/client/widgets/NTextBox'; import {NTextBox} from 'app/client/widgets/NTextBox';
import {Computed, dom, styled} from 'grainjs'; import {Computed, dom, styled} from 'grainjs';
import {choiceToken, DEFAULT_FILL_COLOR, DEFAULT_TEXT_COLOR} from 'app/client/widgets/ChoiceToken';
export interface IChoiceOptions { export interface IChoiceOptions {
textColor: string; textColor: string;
@ -18,9 +16,6 @@ export interface IChoiceOptions {
export type ChoiceOptions = Record<string, IChoiceOptions | undefined>; export type ChoiceOptions = Record<string, IChoiceOptions | undefined>;
export type ChoiceOptionsByName = Map<string, IChoiceOptions | undefined>; export type ChoiceOptionsByName = Map<string, IChoiceOptions | undefined>;
const DEFAULT_FILL_COLOR = colors.mediumGreyOpaque.value;
export const DEFAULT_TEXT_COLOR = '#000000';
export function getFillColor(choiceOptions?: IChoiceOptions) { export function getFillColor(choiceOptions?: IChoiceOptions) {
return choiceOptions?.fillColor ?? DEFAULT_FILL_COLOR; return choiceOptions?.fillColor ?? DEFAULT_FILL_COLOR;
} }
@ -52,21 +47,20 @@ export class ChoiceTextBox extends NTextBox {
cssChoiceTextWrapper( cssChoiceTextWrapper(
dom.style('justify-content', (use) => use(this.alignment) === 'right' ? 'flex-end' : use(this.alignment)), dom.style('justify-content', (use) => use(this.alignment) === 'right' ? 'flex-end' : use(this.alignment)),
dom.domComputed((use) => { dom.domComputed((use) => {
if (use(row._isAddRow)) { return cssChoiceText(''); } if (use(row._isAddRow)) { return null; }
const formattedValue = use(this.valueFormatter).format(use(value)); const formattedValue = use(this.valueFormatter).format(use(value));
if (formattedValue === '') { return cssChoiceText(''); } if (formattedValue === '') { return null; }
const choiceOptions = use(this._choiceOptionsByName).get(formattedValue); const choiceOptions = use(this._choiceOptionsByName).get(formattedValue);
return cssChoiceText( return choiceToken(
dom.style('background-color', getFillColor(choiceOptions)),
dom.style('color', getTextColor(choiceOptions)),
formattedValue, formattedValue,
choiceOptions || {},
dom.cls(cssChoiceText.className),
testId('choice-text') testId('choice-text')
); );
}), }),
), ),
this.buildDropdownMenu(),
); );
} }
@ -102,27 +96,6 @@ export class ChoiceTextBox extends NTextBox {
protected getChoiceOptions(): Computed<ChoiceOptionsByName> { protected getChoiceOptions(): Computed<ChoiceOptionsByName> {
return this._choiceOptionsByName; return this._choiceOptionsByName;
} }
protected buildDropdownMenu() {
return cssDropdownIcon('Dropdown',
// When choices exist, click dropdown icon to open edit autocomplete.
dom.on('click', () => this._hasChoices() && commands.allCommands.editField.run()),
// When choices do not exist, open a single-item menu to open the sidepane choice option editor.
menu(() => [
menuItem(commands.allCommands.fieldTabOpen.run, 'Add Choice Options')
], {
trigger: [(elem, ctl) => {
// Only open this menu if there are no choices.
dom.onElem(elem, 'click', () => this._hasChoices() || ctl.open());
}]
}),
testId('choice-dropdown')
);
}
private _hasChoices() {
return this._choiceValues.get().length > 0;
}
} }
// Converts a POJO containing choice options to an ES6 Map // Converts a POJO containing choice options to an ES6 Map
@ -142,8 +115,6 @@ function toObject(choiceOptions: ChoiceOptionsByName): ChoiceOptions {
} }
const cssChoiceField = styled('div.field_clip', ` const cssChoiceField = styled('div.field_clip', `
display: flex;
align-items: center;
padding: 0 3px; padding: 0 3px;
`); `);
@ -155,20 +126,7 @@ const cssChoiceTextWrapper = styled('div', `
`); `);
const cssChoiceText = styled('div', ` const cssChoiceText = styled('div', `
border-radius: 3px;
padding: 1px 4px;
margin: 2px; margin: 2px;
overflow: hidden;
text-overflow: ellipsis;
height: min-content; height: min-content;
line-height: 16px; line-height: 16px;
`); `);
const cssDropdownIcon = styled(icon, `
cursor: pointer;
background-color: ${colors.lightGreen};
min-width: 16px;
width: 16px;
height: 16px;
margin-left: auto;
`);

@ -0,0 +1,81 @@
import {dom, DomContents, DomElementArg, styled} from "grainjs";
import {colors, vars} from "app/client/ui2018/cssVars";
export const DEFAULT_FILL_COLOR = colors.mediumGreyOpaque.value;
export const DEFAULT_TEXT_COLOR = '#000000';
export interface IChoiceTokenOptions {
fillColor?: string;
textColor?: string;
}
/**
* Creates a colored token representing a choice (e.g. Choice and Choice List values).
*
* Tokens are pill-shaped boxes that contain text, with custom fill and text
* colors. If colors are not specified, a gray fill with black text will be used.
*
* Additional styles and other DOM arguments can be passed in to customize the
* appearance and behavior of the token.
*
* @param {DomElementArg} label The text that will appear inside the token.
* @param {IChoiceTokenOptions} options Options for customizing the token appearance.
* @param {DOMElementArg[]} args Additional arguments to pass to the token.
* @returns {DomContents} A colored choice token.
*/
export function choiceToken(
label: DomElementArg,
{fillColor, textColor}: IChoiceTokenOptions,
...args: DomElementArg[]
): DomContents {
return cssChoiceToken(
label,
dom.style('background-color', fillColor ?? DEFAULT_FILL_COLOR),
dom.style('color', textColor ?? DEFAULT_TEXT_COLOR),
...args
);
}
const cssChoiceToken = styled('div', `
display: inline-block;
padding: 1px 4px;
border-radius: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: pre;
`);
const ADD_NEW_HEIGHT = '37px';
export const cssChoiceACItem = styled('li', `
display: block;
font-family: ${vars.fontFamily};
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
outline: none;
padding: var(--weaseljs-menu-item-padding, 8px 24px);
cursor: pointer;
&.selected {
background-color: ${colors.mediumGreyOpaque};
color: ${colors.dark};
}
&-with-new {
scroll-margin-bottom: ${ADD_NEW_HEIGHT};
}
&-new {
display: flex;
align-items: center;
color: ${colors.slate};
position: sticky;
bottom: 0px;
height: ${ADD_NEW_HEIGHT};
background-color: white;
border-top: 1px solid ${colors.mediumGreyOpaque};
scroll-margin-bottom: initial;
}
&-new.selected {
color: ${colors.lightGrey};
}
`);

@ -48,7 +48,7 @@ export class ReferenceEditor extends NTextEditor {
// Whether we should enable the "Add New" entry to allow adding new items to the target table. // Whether we should enable the "Add New" entry to allow adding new items to the target table.
const vcol = field.visibleColModel(); const vcol = field.visibleColModel();
this._enableAddNew = vcol && !vcol.isRealFormula(); this._enableAddNew = vcol && !vcol.isRealFormula() && !!vcol.colId();
this._visibleCol = vcol.colId() || 'id'; this._visibleCol = vcol.colId() || 'id';
@ -144,18 +144,26 @@ export class ReferenceEditor extends NTextEditor {
return String(value || ''); return String(value || '');
} }
/**
* If the search text does not match anything exactly, adds 'new' item to it.
*
* Also see: prepForSave.
*/
private async _doSearch(text: string): Promise<ACResults<ICellItem>> { private async _doSearch(text: string): Promise<ACResults<ICellItem>> {
const acIndex = this._tableData.columnACIndexes.getColACIndex(this._visibleCol, this._formatter); const acIndex = this._tableData.columnACIndexes.getColACIndex(this._visibleCol, this._formatter);
const result = acIndex.search(text); const result = acIndex.search(text);
// If the search text does not match anything exactly, add 'new' item for it. See also prepForSave.
this._showAddNew = false; this._showAddNew = false;
if (this._enableAddNew && text) { if (!this._enableAddNew || !text) { return result; }
const cleanText = text.trim().toLowerCase();
if (!result.items.find((item) => item.cleanText === cleanText)) { const cleanText = text.trim().toLowerCase();
result.items.push({rowId: 'new', text, cleanText}); if (result.items.find((item) => item.cleanText === cleanText)) {
this._showAddNew = true; return result;
}
} }
result.items.push({rowId: 'new', text, cleanText});
this._showAddNew = true;
return result; return result;
} }
@ -187,7 +195,7 @@ const cssRefEditor = styled('div', `
} }
`); `);
export const cssRefList = styled('div', ` const cssRefList = styled('div', `
overflow-y: auto; overflow-y: auto;
padding: 8px 0 0 0; padding: 8px 0 0 0;
--weaseljs-menu-item-padding: 8px 16px; --weaseljs-menu-item-padding: 8px 16px;
@ -199,7 +207,7 @@ const addNewHeight = '37px';
const cssRefItem = styled('li', ` const cssRefItem = styled('li', `
display: block; display: block;
font-family: ${vars.fontFamily}; font-family: ${vars.fontFamily};
white-space: nowrap; white-space: pre;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
outline: none; outline: none;
@ -227,7 +235,7 @@ const cssRefItem = styled('li', `
} }
`); `);
export const cssPlusButton = styled('div', ` const cssPlusButton = styled('div', `
display: inline-block; display: inline-block;
width: 20px; width: 20px;
height: 20px; height: 20px;
@ -242,7 +250,7 @@ export const cssPlusButton = styled('div', `
} }
`); `);
export const cssPlusIcon = styled(icon, ` const cssPlusIcon = styled(icon, `
background-color: ${colors.light}; background-color: ${colors.light};
`); `);

Loading…
Cancel
Save