diff --git a/app/client/components/TypeConversion.ts b/app/client/components/TypeConversion.ts index 4a22139d..649f9526 100644 --- a/app/client/components/TypeConversion.ts +++ b/app/client/components/TypeConversion.ts @@ -85,7 +85,7 @@ export async function prepTransformColInfo(docModel: DocModel, origCol: ColumnRe case 'Choice': { if (Array.isArray(prevOptions.choices)) { // Use previous choices if they are set, e.g. if converting from ChoiceList - widgetOptions = {choices: prevOptions.choices}; + widgetOptions = {choices: prevOptions.choices, choiceOptions: prevOptions.choiceOptions}; } else { // Set suggested choices. Limit to 100, since too many choices is more likely to cause // trouble than desired behavior. For many choices, recommend using a Ref to helper table. @@ -100,7 +100,7 @@ export async function prepTransformColInfo(docModel: DocModel, origCol: ColumnRe case 'ChoiceList': { if (Array.isArray(prevOptions.choices)) { // Use previous choices if they are set, e.g. if converting from ChoiceList - widgetOptions = {choices: prevOptions.choices}; + widgetOptions = {choices: prevOptions.choices, choiceOptions: prevOptions.choiceOptions}; } else { // Set suggested choices. This happens before the conversion to ChoiceList, so we do some // light guessing for likely choices to suggest. diff --git a/app/client/lib/TokenField.ts b/app/client/lib/TokenField.ts index c59531e2..6ddcc2e9 100644 --- a/app/client/lib/TokenField.ts +++ b/app/client/lib/TokenField.ts @@ -21,83 +21,97 @@ import { Autocomplete, IAutocompleteOptions } from 'app/client/lib/autocomplete' import { colors, testId } from 'app/client/ui2018/cssVars'; import { icon } from 'app/client/ui2018/icons'; import { csvDecodeRow, csvEncodeRow } from 'app/common/csvFormat'; -import { computedArray, IObsArraySplice, ObsArray, obsArray, Observable } from 'grainjs'; +import { computedArray, IDisposableCtor, IObsArraySplice, ObsArray, obsArray, Observable } from 'grainjs'; import { Disposable, dom, DomElementArg, Holder, styled } from 'grainjs'; export interface IToken { label: string; } -export interface ITokenFieldOptions { - initialValue: IToken[]; - renderToken: (token: IToken) => DomElementArg; - createToken: (inputText: string) => IToken|undefined; - acOptions?: IAutocompleteOptions; +export interface ITokenFieldOptions { + initialValue: Token[]; + renderToken: (token: Token) => DomElementArg; + createToken: (inputText: string) => Token|undefined; + acOptions?: IAutocompleteOptions; openAutocompleteOnFocus?: boolean; styles?: ITokenFieldStyles; readonly?: boolean; + keyBindings?: ITokenFieldKeyBindings; // Allows overriding how tokens are copied to the clipboard, or retrieved from it. // By default, tokens are placed into clipboard as text/plain comma-separated token labels, with // CSV escaping, and pasted from clipboard by applying createToken() to parsed CSV text. - tokensToClipboard?: (tokens: IToken[], clipboard: DataTransfer) => void; - clipboardToTokens?: (clipboard: DataTransfer) => IToken[]; + tokensToClipboard?: (tokens: Token[], clipboard: DataTransfer) => void; + clipboardToTokens?: (clipboard: DataTransfer) => Token[]; } +/** + * Overrides for default TokenField shortcut bindings. + * + * Values should be Key Values (https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values). + */ +export interface ITokenFieldKeyBindings { + previous?: string; + next?: string; +} + +const defaultKeyBindings: Required = { + previous: 'ArrowLeft', + next: 'ArrowRight' +}; + // TokenWrap serves to distinguish multiple instances of the same token in the list. -class TokenWrap { - constructor(public token: IToken) {} +class TokenWrap { + constructor(public token: Token) {} } class UndoItem { constructor(public redo: () => void, public undo: () => void) {} } -export class TokenField extends Disposable { - public tokensObs: ObsArray; +export class TokenField extends Disposable { + public static ctor(): IDisposableCtor, [ITokenFieldOptions]> { + return this; + } - private _acHolder = Holder.create>(this); - private _acOptions: IAutocompleteOptions|undefined; + public tokensObs: ObsArray; + + private _acHolder = Holder.create>(this); + private _acOptions: IAutocompleteOptions|undefined; private _rootElem: HTMLElement; private _textInput: HTMLInputElement; + private _styles: Required; // ClipboardAPI events work as expected only when the focus is in an actual input. // This is where we place focus when we have some tokens selected. private _hiddenInput: HTMLInputElement; // Keys to navigate tokens. In a vertical list, these would be changed to Up/Down. - // TODO Support a vertical list. - private _keyPrev = 'ArrowLeft'; - private _keyNext = 'ArrowRight'; + private _keyBindings: Required; - private _tokens = this.autoDispose(obsArray()); - private _selection = Observable.create(this, new Set()); - private _selectionAnchor: TokenWrap|null = null; + private _tokens = this.autoDispose(obsArray>()); + private _selection = Observable.create(this, new Set>()); + private _selectionAnchor: TokenWrap|null = null; private _undoStack: UndoItem[] = []; private _undoIndex = 0; // The last action done; next to undo. private _inUndoRedo = false; - constructor(private _options: ITokenFieldOptions) { + constructor(private _options: ITokenFieldOptions) { super(); const addSelectedItem = this._addSelectedItem.bind(this); const openAutocomplete = this._openAutocomplete.bind(this); this._acOptions = _options.acOptions && {..._options.acOptions, onClick: addSelectedItem}; this._tokens.set(_options.initialValue.map(t => new TokenWrap(t))); this.tokensObs = this.autoDispose(computedArray(this._tokens, t => t.token)); + this._keyBindings = {...defaultKeyBindings, ..._options.keyBindings}; // We can capture undo info in a consistent way as long as we change _tokens using its // obsArray interface, by listening to the splice events. this.autoDispose(this._tokens.addListener(this._recordUndo.bind(this))); // Use overridden styles if any were provided. - const { - cssTokenField, - cssToken, - cssInputWrapper, - cssTokenInput, - cssDeleteButton, - cssDeleteIcon} = - {...tokenFieldStyles, ..._options.styles}; + this._styles = {...tokenFieldStyles, ..._options.styles}; + const {cssTokenField, cssToken, cssInputWrapper, cssTokenInput, cssDeleteButton, cssDeleteIcon} = this._styles; function stop(ev: Event) { ev.stopPropagation(); @@ -141,8 +155,8 @@ export class TokenField extends Disposable { a$: this._maybeSelectAllTokens.bind(this), Backspace$: this._maybeBackspace.bind(this), Delete$: this._maybeDelete.bind(this), - [this._keyPrev + '$']: (ev) => this._maybeAdvance(ev, -1), - [this._keyNext + '$']: (ev) => this._maybeAdvance(ev, +1), + [this._keyBindings.previous + '$']: (ev) => this._maybeAdvance(ev, -1), + [this._keyBindings.next + '$']: (ev) => this._maybeAdvance(ev, +1), // ['Mod+z'] triggers undo; ['Mod+Shift+Z', 'Ctrl+y' ] trigger redo z$: (ev) => { if (ev[modKeyProp()]) { ev.shiftKey ? this._redo(ev) : this._undo(ev); } }, y$: (ev) => { if (ev.ctrlKey && !ev.shiftKey) { this._redo(ev); } }, @@ -155,7 +169,7 @@ export class TokenField extends Disposable { } }), ), - dom.on('focus', () => this._hiddenInput.focus()), + dom.on('focus', () => this._hiddenInput.focus({preventScroll: true})), dom.on('copy', this._onCopyEvent.bind(this)), dom.on('cut', this._onCutEvent.bind(this)), dom.on('paste', this._onPasteEvent.bind(this)), @@ -182,6 +196,13 @@ export class TokenField extends Disposable { return this._hiddenInput; } + // Replaces a token (if it exists) + public replaceToken(label: string, newToken: Token): void { + const tokenIdx = this._tokens.get().findIndex(t => t.token.label === label); + if (tokenIdx === -1) { return; } + this._tokens.splice(tokenIdx, 1, new TokenWrap(newToken)); + } + // Open the autocomplete dropdown, if autocomplete was configured in the options. private _openAutocomplete() { // don't open dropdown in a readonly mode @@ -194,7 +215,7 @@ export class TokenField extends Disposable { // Adds the typed-in or selected item. If an item is selected in autocomplete dropdown, adds // that; otherwise if options.createToken is present, creates a token from text input value. private _addSelectedItem(): boolean { - let item: IToken|undefined = this._acHolder.get()?.getSelectedItem(); + let item: Token|undefined = this._acHolder.get()?.getSelectedItem(); if (!item && this._options.createToken && this._textInput.value) { item = this._options.createToken(this._textInput.value); } @@ -218,10 +239,10 @@ export class TokenField extends Disposable { // Handle for a click on a token or the token's delete button. This handles selection, including // Shift+Click and Ctrl+Click. - private _onTokenClick(ev: MouseEvent, t: TokenWrap) { + private _onTokenClick(ev: MouseEvent, t: TokenWrap) { const idx = this._tokens.get().indexOf(t); if (idx < 0) { return; } - if (ev.target && (ev.target as HTMLElement).matches('.' + cssDeleteIcon.className)) { + if (ev.target && (ev.target as HTMLElement).matches('.' + this._styles.cssDeleteIcon.className)) { // Delete token. this._tokens.splice(idx, 1); } else { @@ -266,6 +287,7 @@ export class TokenField extends Disposable { if (this._textInput.value === '') { ev.stopPropagation(); ev.preventDefault(); + if (ev.repeat) { return; } if (this._selection.get().size === 0) { this._tokens.pop(); } else { @@ -307,7 +329,7 @@ export class TokenField extends Disposable { } } else { // For arrow keys, move to the next token after the selection. - let next: TokenWrap|null = null; + let next: TokenWrap|null = null; if (this._selection.get().size > 0) { next = this._getNextToken(this._selection.get(), advance); } else if (advance < 0 && tokens.length > 0) { @@ -323,7 +345,7 @@ export class TokenField extends Disposable { } } - private _toggleTokenSelection(token: TokenWrap) { + private _toggleTokenSelection(token: TokenWrap) { const selection = this._selection.get(); if (selection.has(token)) { selection.delete(token); @@ -334,13 +356,13 @@ export class TokenField extends Disposable { this._selection.setAndTrigger(selection); } - private _resetTokenSelection(token: TokenWrap|null) { + private _resetTokenSelection(token: TokenWrap|null) { this._selectionAnchor = token; this._selection.set(token ? new Set([token]) : new Set()); } // Delete the given set of tokens, and select either the following or the preceding one. - private _deleteTokens(toDelete: Set, advance: 1|-1|0) { + private _deleteTokens(toDelete: Set>, advance: 1|-1|0) { if (this._selection.get().size === 0) { return; } const selectAfter = advance ? this._getNextToken(toDelete, advance) : null; this._tokens.set(this._tokens.get().filter(t => !toDelete.has(t))); @@ -348,13 +370,13 @@ export class TokenField extends Disposable { this._setFocus(); } - private _getNextToken(selection: Set, advance: 1|-1): TokenWrap|null { + private _getNextToken(selection: Set>, advance: 1|-1): TokenWrap|null { const [first, last] = this._getSelectedIndexRange(selection); if (last < 0) { return null; } return this._tokens.get()[advance > 0 ? last + 1 : first - 1] || null; } - private _getSelectedIndexRange(selection: Set): [number, number] { + private _getSelectedIndexRange(selection: Set>): [number, number] { const tokens = this._tokens.get(); let first = -1, last = -1; for (let i = 0; i < tokens.length; i++) { @@ -390,13 +412,13 @@ export class TokenField extends Disposable { private _onPasteEvent(ev: ClipboardEvent) { if (!ev.clipboardData) { return; } ev.preventDefault(); - let tokens: IToken[]; + let tokens: Token[]; if (this._options.clipboardToTokens) { tokens = this._options.clipboardToTokens(ev.clipboardData); } else { const text = ev.clipboardData.getData('text/plain'); const values = csvDecodeRow(text); - tokens = values.map(v => this._options.createToken(v)).filter((t): t is IToken => Boolean(t)); + tokens = values.map(v => this._options.createToken(v)).filter((t): t is Token => Boolean(t)); } if (!tokens.length) { return; } const wrappedTokens = tokens.map(t => new TokenWrap(t)); @@ -417,10 +439,10 @@ export class TokenField extends Disposable { // For a mousedown on a token, register events for mousemove/mouseup, and start dragging as soon // as mousemove occurs. - private _onMouseDown(startEvent: MouseEvent, t: TokenWrap) { + private _onMouseDown(startEvent: MouseEvent, t: TokenWrap) { const xInitial = startEvent.clientX; const yInitial = startEvent.clientY; - const dragTargetSelector = `.${cssToken.className}, .${cssInputWrapper.className}`; + const dragTargetSelector = `.${this._styles.cssToken.className}, .${this._styles.cssInputWrapper.className}`; let started = false; let allTargets: HTMLElement[]; @@ -468,7 +490,7 @@ export class TokenField extends Disposable { // end (just before or over the input box), destToken will be undefined. const index = allTargets.indexOf(ev.target as HTMLElement); if (index < 0) { return; } - const destToken: TokenWrap|undefined = this._tokens.get()[index]; + const destToken: TokenWrap|undefined = this._tokens.get()[index]; const selection = this._selection.get(); if (selection.has(destToken)) { return; } // Not actually moving anywhere new. @@ -491,7 +513,7 @@ export class TokenField extends Disposable { const stopLis = dom.onElem(document, 'mouseup', onStop, {useCapture: true}); } - private _recordUndo(val: TokenWrap[], prev: TokenWrap[], change?: IObsArraySplice) { + private _recordUndo(val: TokenWrap[], prev: TokenWrap[], change?: IObsArraySplice>) { if (this._inUndoRedo) { return; } const splice = change || {start: 0, numAdded: val.length, deleted: [...prev]}; const newTokens = val.slice(splice.start, splice.start + splice.numAdded); diff --git a/app/client/lib/listEntry.ts b/app/client/lib/listEntry.ts deleted file mode 100644 index d531a987..00000000 --- a/app/client/lib/listEntry.ts +++ /dev/null @@ -1,212 +0,0 @@ -import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; -import {colors, testId} from 'app/client/ui2018/cssVars'; -import {icon} from 'app/client/ui2018/icons'; -import {Computed, Disposable, dom, DomContents, DomElementArg, Observable, styled} from 'grainjs'; -import isEqual = require('lodash/isEqual'); -import uniq = require('lodash/uniq'); - -/** - * ListEntry class to build a textarea of unique newline separated values, with a nice - * display mode when the values are not being edited. - * - * Usage: - * > dom.create(ListEntry, values, (vals) => choices.saveOnly(vals)); - */ -export class ListEntry extends Disposable { - // Should start in edit mode if there are no initial values. - private _isEditing: Observable = Observable.create(this, this._values.get().length === 0); - private _textVal: Observable = Observable.create(this, ""); - - constructor( - private _values: Observable, - private _onSave: (values: string[]) => void - ) { - super(); - - // Since the saved values can be modified outside the ListEntry (via undo/redo), - // add a listener to update edit status on changes. - this.autoDispose(this._values.addListener(values => { - if (values.length === 0) { this._textVal.set(""); } - this._isEditing.set(values.length === 0); - })); - } - - // Arg maxRows indicates the number of rows to display when the textarea is inactive. - public buildDom(maxRows: number = 6): DomContents { - return dom.domComputed(this._isEditing, (editMode) => { - if (editMode) { - // Edit mode dom. - let textArea: HTMLTextAreaElement; - return cssVerticalFlex( - cssListBox( - textArea = cssListTextArea( - dom.prop('value', this._textVal), - dom.on('input', (ev, elem) => this._textVal.set(elem.value)), - (elem) => this._focusOnOpen(elem), - dom.on('blur', (ev, elem) => { setTimeout(() => this._save(elem), 0); }), - dom.onKeyDown({Escape: (ev, elem) => this._save(elem)}), - // Keep height to be two rows taller than the number of text rows - dom.style('height', (use) => { - const rows = use(this._textVal).split('\n').length; - return `${(rows + 2) * 22}px`; - }) - ), - cssHelpLine( - cssIdeaIcon('Idea'), 'Type one option per line' - ), - testId('list-entry') - ), - // Show buttons if the textArea has or had valid text content - dom.maybe((use) => use(this._values).length > 0 || use(this._textVal).trim().length > 0, () => - cssButtonRow( - primaryButton('Save', {style: 'margin-right: 8px;'}, - // Prevent textarea focus loss on mousedown - dom.on('mousedown', (ev) => ev.preventDefault()), - dom.on('click', () => this._save(textArea)), - testId('list-entry-save') - ), - basicButton('Cancel', - // Prevent textarea focus loss on mousedown - dom.on('mousedown', (ev) => ev.preventDefault()), - dom.on('click', () => this._cancel()), - testId('list-entry-cancel') - ) - ) - ) - ); - } else { - // Inactive display dom. - const someValues = Computed.create(null, this._values, (use, values) => - values.length <= maxRows ? values : values.slice(0, maxRows - 1)); - return cssListBoxInactive( - dom.autoDispose(someValues), - dom.forEach(someValues, val => this._row(val)), - // Show description row for any remaining rows - dom.maybe(use => use(this._values).length > maxRows, () => - this._row( - dom.text((use) => `+${use(this._values).length - (maxRows - 1)} more`) - ) - ), - dom.on('click', () => this._startEditing()), - testId('list-entry') - ); - } - }); - } - - // Build a display row with the given text value - private _row(...domArgs: DomElementArg[]): Element { - return cssListRow( - ...domArgs, - testId('list-entry-row') - ); - } - - // Indicates whether the listEntry currently has saved values. - private _hasValues(): boolean { - return this._values.get().length > 0; - } - - private _startEditing(): void { - this._textVal.set(this._hasValues() ? (this._values.get().join('\n') + '\n') : ''); - this._isEditing.set(true); - } - - private _save(elem: HTMLTextAreaElement): void { - if (!this._isEditing.get()) { return; } - const newValues = uniq( - elem.value.split('\n') - .map(val => val.trim()) - .filter(val => val !== '') - ); - // Call user save function if the values have changed. - if (!isEqual(this._values.get(), newValues)) { - // Because of the listener on this._values, editing will stop if values are updated. - this._onSave(newValues); - } else { - this._cancel(); - } - } - - private _cancel(): void { - if (this._hasValues()) { - this._isEditing.set(false); - } else { - this._textVal.set(""); - } - } - - private _focusOnOpen(elem: HTMLTextAreaElement): void { - // Do not grab focus if the textArea is empty, since it indicates that the listEntry - // started in edit mode, and was not set to be so by the user. - if (this._textVal.get()) { - setTimeout(() => focus(elem), 0); - } - } -} - -// Helper to focus on the textarea and select/scroll to the bottom -function focus(elem: HTMLTextAreaElement) { - elem.focus(); - elem.setSelectionRange(elem.value.length, elem.value.length); - elem.scrollTo(0, elem.scrollHeight); -} - -const cssListBox = styled('div', ` - width: 100%; - background-color: white; - padding: 1px; - border: 1px solid ${colors.hover}; - border-radius: 4px; -`); - -const cssListBoxInactive = styled(cssListBox, ` - cursor: pointer; - border: 1px solid ${colors.darkGrey}; - - &:hover { - border: 1px solid ${colors.hover}; - } -`); - -const cssListTextArea = styled('textarea', ` - width: 100%; - max-height: 150px; - padding: 2px 12px; - line-height: 22px; - border: none; - outline: none; - resize: none; -`); - -const cssListRow = styled('div', ` - margin: 4px; - padding: 4px 8px; - color: ${colors.dark}; - background-color: ${colors.mediumGrey}; - border-radius: 4px; - overflow: hidden; - text-overflow: ellipsis; -`); - -const cssHelpLine = styled('div', ` - display: flex; - margin: 2px 8px 8px 8px; - color: ${colors.slate}; -`); - -const cssIdeaIcon = styled(icon, ` - background-color: ${colors.lightGreen}; - margin-right: 4px; -`); - -const cssVerticalFlex = styled('div', ` - width: 100%; - display: flex; - flex-direction: column; -`); - -const cssButtonRow = styled('div', ` - display: flex; - margin: 16px 0; -`); diff --git a/app/client/ui2018/ColorSelect.ts b/app/client/ui2018/ColorSelect.ts index 139f0004..2938448d 100644 --- a/app/client/ui2018/ColorSelect.ts +++ b/app/client/ui2018/ColorSelect.ts @@ -36,6 +36,25 @@ export function colorSelect(textColor: Observable, fillColor: Observable return selectBtn; } +export function colorButton(textColor: Observable, fillColor: Observable, + onSave: () => Promise): Element { + const iconBtn = cssIconBtn( + icon( + 'Dropdown', + dom.style('background-color', textColor), + testId('color-button-dropdown') + ), + dom.style('background-color', (use) => use(fillColor).slice(0, 7)), + dom.on('click', (e) => { e.stopPropagation(); e.preventDefault(); }), + testId('color-button'), + ); + + const domCreator = (ctl: IOpenController) => buildColorPicker(ctl, textColor, fillColor, onSave); + setPopupToCreateDom(iconBtn, domCreator, { ...defaultMenuOptions, placement: 'bottom-end' }); + + return iconBtn; +} + function buildColorPicker(ctl: IOpenController, textColor: Observable, fillColor: Observable, onSave: () => Promise): Element { const textColorModel = PickerModel.create(null, textColor); @@ -318,3 +337,13 @@ const cssButtonIcon = styled(cssColorSquare, ` margin-right: 6px; margin-left: 4px; `); + +const cssIconBtn = styled('div', ` + min-width: 18px; + width: 18px; + height: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +`); diff --git a/app/client/widgets/ChoiceEditor.js b/app/client/widgets/ChoiceEditor.js index 41af28f1..cebefe5f 100644 --- a/app/client/widgets/ChoiceEditor.js +++ b/app/client/widgets/ChoiceEditor.js @@ -2,7 +2,14 @@ var _ = require('underscore'); var dispose = require('../lib/dispose'); var TextEditor = require('./TextEditor'); -const {autocomplete} = require('app/client/ui2018/menus'); +const {Autocomplete} = require('app/client/lib/autocomplete'); +const {ACIndexImpl, buildHighlightedDom} = require('app/client/lib/ACIndex'); +const {ChoiceItem, cssChoiceList, cssItem, cssItemLabel, cssMatchText} = require('app/client/widgets/ChoiceListEditor'); +const {cssRefList} = require('app/client/widgets/ReferenceEditor'); +const {getFillColor, getTextColor} = require('app/client/widgets/ChoiceTextBox'); +const {menuCssClass} = require('app/client/ui2018/menus'); +const {testId} = require('app/client/ui2018/cssVars'); +const {dom} = require('grainjs'); /** * ChoiceEditor - TextEditor with a dropdown for possible choices. @@ -11,17 +18,52 @@ function ChoiceEditor(options) { TextEditor.call(this, options); this.choices = options.field.widgetOptionsJson.peek().choices || []; - - // Add autocomplete if there are any choices to select from - if (this.choices.length > 0 && !options.readonly) { - - autocomplete(this.textInput, this.choices, { - allowNothingSelected: true, - onClick: () => this.options.commands.fieldEditSave(), - }); - } + this.choiceOptions = options.field.widgetOptionsJson.peek().choiceOptions || {}; } + dispose.makeDisposable(ChoiceEditor); _.extend(ChoiceEditor.prototype, TextEditor.prototype); +ChoiceEditor.prototype.getCellValue = function() { + const selectedItem = this.autocomplete && this.autocomplete.getSelectedItem(); + return selectedItem ? selectedItem.label : TextEditor.prototype.getCellValue.call(this); +} + +ChoiceEditor.prototype.renderACItem = function(item, highlightFunc) { + const options = this.choiceOptions[item.label]; + const fillColor = getFillColor(options); + const textColor = getTextColor(options); + + return cssItem( + cssItemLabel( + buildHighlightedDom(item.label, highlightFunc, cssMatchText), + dom.style('background-color', fillColor), + dom.style('color', textColor), + testId('choice-editor-item-label') + ), + testId('choice-editor-item'), + ); +} + +ChoiceEditor.prototype.attach = function(cellElem) { + TextEditor.prototype.attach.call(this, cellElem); + // Don't create autocomplete if readonly, or if there are no choices. + if (this.options.readonly || this.choices.length === 0) { return; } + + const acItems = this.choices.map(c => new ChoiceItem(c, false)); + const acIndex = new ACIndexImpl(acItems); + const acOptions = { + popperOptions: { + placement: 'bottom' + }, + menuCssClass: menuCssClass + ' ' + cssRefList.className + ' ' + cssChoiceList.className + ' test-autocomplete', + search: (term) => acIndex.search(term), + renderItem: (item, highlightFunc) => this.renderACItem(item, highlightFunc), + getItemText: (item) => item.label, + onClick: () => this.options.commands.fieldEditSave(), + }; + + this.autocomplete = Autocomplete.create(this, this.textInput, acOptions); +} + module.exports = ChoiceEditor; diff --git a/app/client/widgets/ChoiceListCell.ts b/app/client/widgets/ChoiceListCell.ts index 7b4b4a92..a1ca0238 100644 --- a/app/client/widgets/ChoiceListCell.ts +++ b/app/client/widgets/ChoiceListCell.ts @@ -1,6 +1,11 @@ import {DataRowModel} from 'app/client/models/DataRowModel'; -import {colors} from 'app/client/ui2018/cssVars'; -import {ChoiceTextBox} from 'app/client/widgets/ChoiceTextBox'; +import {colors, testId} from 'app/client/ui2018/cssVars'; +import { + ChoiceOptionsByName, + ChoiceTextBox, + getFillColor, + getTextColor +} from 'app/client/widgets/ChoiceTextBox'; import {CellValue} from 'app/common/DocActions'; import {decodeObject} from 'app/plugin/objtypes'; import {Computed, dom, styled} from 'grainjs'; @@ -17,21 +22,28 @@ export class ChoiceListCell extends ChoiceTextBox { return cssChoiceList( dom.cls('field_clip'), cssChoiceList.cls('-wrap', this.wrapping), - dom.style('justify-content', this.alignment), + dom.style('justify-content', use => use(this.alignment) === 'right' ? 'flex-end' : use(this.alignment)), dom.domComputed((use) => { - return use(row._isAddRow) ? null : [use(value), use(this._choiceSet)] as [CellValue, Set]; + return use(row._isAddRow) ? null : + [ + use(value), use(this._choiceSet), + use(this.getChoiceOptions()) + ] as [CellValue, Set, ChoiceOptionsByName]; }, (input) => { if (!input) { return null; } - const [rawValue, choiceSet] = input; + const [rawValue, choiceSet, choiceOptionsByName] = input; const val = decodeObject(rawValue); if (!val) { return null; } // Handle any unexpected values we might get (non-array, or array with non-strings). const tokens: unknown[] = Array.isArray(val) ? val : [val]; return tokens.map(token => - cssToken( - String(token), - cssInvalidToken.cls('-invalid', !choiceSet.has(token as string)) - ) + cssToken( + String(token), + cssInvalidToken.cls('-invalid', !choiceSet.has(token as string)), + dom.style('background-color', getFillColor(choiceOptionsByName.get(String(token)))), + dom.style('color', getTextColor(choiceOptionsByName.get(String(token)))), + testId('choice-list-cell-token') + ), ); }), ); @@ -57,7 +69,6 @@ const cssToken = styled('div', ` min-width: 0px; overflow: hidden; border-radius: 3px; - background-color: ${colors.mediumGreyOpaque}; padding: 1px 4px; margin: 2px; line-height: 16px; @@ -69,7 +80,4 @@ export const cssInvalidToken = styled('div', ` box-shadow: inset 0 0 0 1px var(--grist-color-error); color: ${colors.slate}; } - &-invalid.selected { - background-color: ${colors.lightGrey} !important; - } `); diff --git a/app/client/widgets/ChoiceListEditor.ts b/app/client/widgets/ChoiceListEditor.ts index e41240f6..fd4bc9e0 100644 --- a/app/client/widgets/ChoiceListEditor.ts +++ b/app/client/widgets/ChoiceListEditor.ts @@ -1,20 +1,21 @@ import {createGroup} from 'app/client/components/commands'; -import {ACIndexImpl, ACItem, ACResults} from 'app/client/lib/ACIndex'; +import {ACIndexImpl, ACItem, ACResults, buildHighlightedDom, HighlightFunc} from 'app/client/lib/ACIndex'; import {IAutocompleteOptions} from 'app/client/lib/autocomplete'; import {IToken, TokenField, tokenFieldStyles} from 'app/client/lib/TokenField'; -import {colors, testId} from 'app/client/ui2018/cssVars'; +import {colors, testId, vars} from 'app/client/ui2018/cssVars'; import {menuCssClass} from 'app/client/ui2018/menus'; import {cssInvalidToken} from 'app/client/widgets/ChoiceListCell'; import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons'; import {EditorPlacement} from 'app/client/widgets/EditorPlacement'; import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor'; -import {cssRefList, renderACItem} from 'app/client/widgets/ReferenceEditor'; +import {cssPlusButton, cssPlusIcon, cssRefList} from 'app/client/widgets/ReferenceEditor'; import {csvEncodeRow} from 'app/common/csvFormat'; import {CellValue} from "app/common/DocActions"; import {decodeObject, encodeObject} from 'app/plugin/objtypes'; import {dom, styled} from 'grainjs'; +import {ChoiceOptions, getFillColor, getTextColor} from 'app/client/widgets/ChoiceTextBox'; -class ChoiceItem implements ACItem, IToken { +export class ChoiceItem implements ACItem, IToken { public cleanText: string = this.label.toLowerCase().trim(); constructor( public label: string, @@ -27,7 +28,7 @@ export class ChoiceListEditor extends NewBaseEditor { protected cellEditorDiv: HTMLElement; protected commandGroup: any; - private _tokenField: TokenField; + private _tokenField: TokenField; private _textInput: HTMLInputElement; private _dom: HTMLElement; private _editorPlacement: EditorPlacement; @@ -40,10 +41,14 @@ export class ChoiceListEditor extends NewBaseEditor { private _enableAddNew: boolean = true; private _showAddNew: boolean = false; + private _choiceOptionsByName: ChoiceOptions; + constructor(options: Options) { super(options); const choices: string[] = options.field.widgetOptionsJson.peek().choices || []; + this._choiceOptionsByName = options.field.widgetOptionsJson + .peek().choiceOptions || {}; const acItems = choices.map(c => new ChoiceItem(c, false)); const choiceSet = new Set(choices); @@ -51,8 +56,7 @@ export class ChoiceListEditor extends NewBaseEditor { const acOptions: IAutocompleteOptions = { menuCssClass: menuCssClass + ' ' + cssRefList.className + ' ' + cssChoiceList.className + ' test-autocomplete', search: async (term: string) => this._maybeShowAddNew(acIndex.search(term), term), - renderItem: (item: ChoiceItem, highlightFunc) => - renderACItem(item.label, highlightFunc, item.isNew || false, this._showAddNew), + renderItem: (item, highlightFunc) => this._renderACItem(item, highlightFunc), getItemText: (item) => item.label, }; @@ -64,9 +68,14 @@ export class ChoiceListEditor extends NewBaseEditor { const startLabels: unknown[] = options.editValue || !Array.isArray(cellValue) ? [] : cellValue; const startTokens = startLabels.map(label => new ChoiceItem(String(label), !choiceSet.has(String(label)))); - this._tokenField = TokenField.create(this, { + this._tokenField = TokenField.ctor().create(this, { initialValue: startTokens, - renderToken: item => [item.label, cssInvalidToken.cls('-invalid', (item as ChoiceItem).isInvalid)], + renderToken: item => [ + item.label, + dom.style('background-color', getFillColor(this._choiceOptionsByName[item.label])), + dom.style('color', getTextColor(this._choiceOptionsByName[item.label])), + cssInvalidToken.cls('-invalid', item.isInvalid) + ], createToken: label => new ChoiceItem(label, !choiceSet.has(label)), acOptions, openAutocompleteOnFocus: true, @@ -138,7 +147,7 @@ export class ChoiceListEditor extends NewBaseEditor { } public async prepForSave() { - const tokens = this._tokenField.tokensObs.get() as ChoiceItem[]; + const tokens = this._tokenField.tokensObs.get(); const newChoices = tokens.filter(t => t.isNew).map(t => t.label); if (newChoices.length > 0) { const choices = this.options.field.widgetOptionsJson.prop('choices'); @@ -205,6 +214,27 @@ export class ChoiceListEditor extends NewBaseEditor { } return result; } + + private _renderACItem(item: ChoiceItem, highlightFunc: HighlightFunc) { + const options = this._choiceOptionsByName[item.label]; + const fillColor = getFillColor(options); + const textColor = getTextColor(options); + + return cssItem( + (item.isNew ? + [cssItem.cls('-new'), cssPlusButton(cssPlusIcon('Plus'))] : + [cssItem.cls('-with-new', this._showAddNew)] + ), + cssItemLabel( + buildHighlightedDom(item.label, highlightFunc, cssMatchText), + dom.style('background-color', fillColor), + dom.style('color', textColor), + testId('choice-list-editor-item-label') + ), + testId('choice-list-editor-item'), + item.isNew ? testId('choice-list-editor-new-item') : null, + ); + } } const cssCellEditor = styled('div', ` @@ -228,6 +258,10 @@ const cssToken = styled(tokenFieldStyles.cssToken, ` padding: 1px 4px; margin: 2px; line-height: 16px; + + &.selected { + box-shadow: inset 0 0 0 1px ${colors.lightGreen}; + } `); const cssDeleteButton = styled(tokenFieldStyles.cssDeleteButton, ` @@ -280,7 +314,7 @@ const cssInputSizer = styled('div', ` `); // Set z-index to be higher than the 1000 set for .cell_editor. -const cssChoiceList = styled('div', ` +export const cssChoiceList = styled('div', ` z-index: 1001; box-shadow: 0 0px 8px 0 rgba(38,38,51,0.6) `); @@ -289,3 +323,49 @@ const cssReadonlyStyle = styled('div', ` padding-left: 16px; background: white; `); + +// We need to know the height of the sticky "+" element. +const addNewHeight = '37px'; + +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', ` + display: inline-block; + padding: 1px 4px; + border-radius: 3px; +`); + +export const cssMatchText = styled('span', ` + text-decoration: underline; +`); diff --git a/app/client/widgets/ChoiceListEntry.ts b/app/client/widgets/ChoiceListEntry.ts new file mode 100644 index 00000000..88256e79 --- /dev/null +++ b/app/client/widgets/ChoiceListEntry.ts @@ -0,0 +1,411 @@ +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 '../lib/TokenField'; +import {ChoiceOptionsByName, DEFAULT_TEXT_COLOR, IChoiceOptions} from 'app/client/widgets/ChoiceTextBox'; +import {colorButton} from 'app/client/ui2018/ColorSelect'; +import {createCheckers, iface, ITypeSuite, opt} from 'ts-interface-checker'; + +class ChoiceItem implements IToken { + constructor( + public label: string, + public options?: IChoiceOptions, + ) {} +} + +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 = Observable.create(this, false); + private _tokenFieldHolder: Holder> = Holder.create(this); + + constructor( + private _values: Observable, + private _choiceOptionsByName: Observable, + private _onSave: (values: string[], choiceOptions: ChoiceOptionsByName) => void + ) { + 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().create(this._tokenFieldHolder, { + initialValue: this._values.get().map(label => { + return new ChoiceItem(label, this._choiceOptionsByName.get().get(label)); + }), + renderToken: token => this._renderToken(token), + createToken: label => label.trim() !== '' ? new ChoiceItem(label.trim()) : undefined, + 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, + 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._save()}), + ); + } else { + const someValues = Computed.create(null, this._values, (_use, values) => + values.length <= maxRows ? values : values.slice(0, maxRows - 1)); + + return cssVerticalFlex( + cssListBoxInactive( + 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()), + testId('choice-list-entry') + ), + cssButtonRow( + primaryButton('Edit', + dom.on('click', () => this._startEditing()), + testId('choice-list-entry-edit') + ) + ) + ); + } + }); + } + + private _startEditing(): void { + this._isEditing.set(true); + } + + private _save(): void { + const tokenField = this._tokenFieldHolder.get(); + if (!tokenField) { return; } + + const tokens = tokenField.tokensObs.get(); + const tokenInputVal = tokenField.getTextInput().value.trim(); + if (tokenInputVal !== '') { + tokens.push(new ChoiceItem(tokenInputVal)); + } + + 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); + } 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)); + + return cssColorAndLabel( + dom.autoDispose(fillColorObs), + dom.autoDispose(textColorObs), + colorButton(textColorObs, + fillColorObs, + async () => { + const tokenField = this._tokenFieldHolder.get(); + if (!tokenField) { return; } + + const fillColor = fillColorObs.get(); + const textColor = textColorObs.get(); + tokenField.replaceToken(token.label, new ChoiceItem(token.label, {fillColor, textColor})); + } + ), + cssTokenLabel(token.label) + ); + } +} + +// 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').filter(t => t.trim() !== '').map(label => new ChoiceItem(label)); + } + + 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 { + border: 1px solid ${colors.hover}; + } +`); + +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: nowrap; + 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}; + } + `); diff --git a/app/client/widgets/ChoiceTextBox.ts b/app/client/widgets/ChoiceTextBox.ts index 99653fc7..b0a6469a 100644 --- a/app/client/widgets/ChoiceTextBox.ts +++ b/app/client/widgets/ChoiceTextBox.ts @@ -1,5 +1,5 @@ import * as commands from 'app/client/components/commands'; -import {ListEntry} from 'app/client/lib/listEntry'; +import {ChoiceListEntry} from 'app/client/widgets/ChoiceListEntry'; import {DataRowModel} from 'app/client/models/DataRowModel'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {KoSaveableObservable} from 'app/client/models/modelUtil'; @@ -10,25 +10,61 @@ import {menu, menuItem} from 'app/client/ui2018/menus'; import {NTextBox} from 'app/client/widgets/NTextBox'; import {Computed, dom, styled} from 'grainjs'; +export interface IChoiceOptions { + textColor: string; + fillColor: string; +} + +export type ChoiceOptions = Record; +export type ChoiceOptionsByName = Map; + +const DEFAULT_FILL_COLOR = colors.mediumGreyOpaque.value; +export const DEFAULT_TEXT_COLOR = '#000000'; + +export function getFillColor(choiceOptions?: IChoiceOptions) { + return choiceOptions?.fillColor ?? DEFAULT_FILL_COLOR; +} + +export function getTextColor(choiceOptions?: IChoiceOptions) { + return choiceOptions?.textColor ?? DEFAULT_TEXT_COLOR; +} + /** * ChoiceTextBox - A textbox for choice values. */ export class ChoiceTextBox extends NTextBox { private _choices: KoSaveableObservable; private _choiceValues: Computed; + private _choiceOptions: KoSaveableObservable; + private _choiceOptionsByName: Computed constructor(field: ViewFieldRec) { super(field); this._choices = this.options.prop('choices'); + this._choiceOptions = this.options.prop('choiceOptions'); this._choiceValues = Computed.create(this, (use) => use(this._choices) || []); + this._choiceOptionsByName = Computed.create(this, (use) => toMap(use(this._choiceOptions))); } public buildDom(row: DataRowModel) { const value = row.cells[this.field.colId()]; return cssChoiceField( - cssChoiceText( - dom.style('text-align', this.alignment), - dom.text((use) => use(row._isAddRow) ? '' : use(this.valueFormatter).format(use(value))), + cssChoiceTextWrapper( + dom.style('justify-content', (use) => use(this.alignment) === 'right' ? 'flex-end' : use(this.alignment)), + dom.domComputed((use) => { + if (use(row._isAddRow)) { return cssChoiceText(''); } + + const formattedValue = use(this.valueFormatter).format(use(value)); + if (formattedValue === '') { return cssChoiceText(''); } + + const choiceOptions = use(this._choiceOptionsByName).get(formattedValue); + return cssChoiceText( + dom.style('background-color', getFillColor(choiceOptions)), + dom.style('color', getTextColor(choiceOptions)), + formattedValue, + testId('choice-text') + ); + }), ), this.buildDropdownMenu(), ); @@ -37,9 +73,20 @@ export class ChoiceTextBox extends NTextBox { public buildConfigDom() { return [ super.buildConfigDom(), - cssLabel('OPTIONS'), + cssLabel('CHOICES'), cssRow( - dom.create(ListEntry, this._choiceValues, (values) => this._choices.saveOnly(values)) + dom.create( + ChoiceListEntry, + this._choiceValues, + this._choiceOptionsByName, + (choices, choiceOptions) => { + return this.options.setAndSave({ + ...this.options.peek(), + choices, + choiceOptions: toObject(choiceOptions) + }); + } + ) ) ]; } @@ -52,6 +99,10 @@ export class ChoiceTextBox extends NTextBox { return this._choiceValues; } + protected getChoiceOptions(): Computed { + return this._choiceOptionsByName; + } + protected buildDropdownMenu() { return cssDropdownIcon('Dropdown', // When choices exist, click dropdown icon to open edit autocomplete. @@ -74,14 +125,43 @@ export class ChoiceTextBox extends NTextBox { } } +// Converts a POJO containing choice options to an ES6 Map +function toMap(choiceOptions?: ChoiceOptions | null): ChoiceOptionsByName { + if (!choiceOptions) { return new Map(); } + + return new Map(Object.entries(choiceOptions)); +} + +// Converts an ES6 Map containing choice options to a POJO +function toObject(choiceOptions: ChoiceOptionsByName): ChoiceOptions { + const object: ChoiceOptions = {}; + for (const [choice, options] of choiceOptions.entries()) { + object[choice] = options; + } + return object; +} + const cssChoiceField = styled('div.field_clip', ` display: flex; + align-items: center; + padding: 0 3px; +`); + +const cssChoiceTextWrapper = styled('div', ` + display: flex; + width: 100%; + min-width: 0px; + overflow: hidden; `); const cssChoiceText = styled('div', ` - width: 100%; + border-radius: 3px; + padding: 1px 4px; + margin: 2px; overflow: hidden; text-overflow: ellipsis; + height: min-content; + line-height: 16px; `); const cssDropdownIcon = styled(icon, ` diff --git a/app/client/widgets/ReferenceEditor.ts b/app/client/widgets/ReferenceEditor.ts index abd711a5..e705080c 100644 --- a/app/client/widgets/ReferenceEditor.ts +++ b/app/client/widgets/ReferenceEditor.ts @@ -227,7 +227,7 @@ const cssRefItem = styled('li', ` } `); -const cssPlusButton = styled('div', ` +export const cssPlusButton = styled('div', ` display: inline-block; width: 20px; height: 20px; @@ -242,7 +242,7 @@ const cssPlusButton = styled('div', ` } `); -const cssPlusIcon = styled(icon, ` +export const cssPlusIcon = styled(icon, ` background-color: ${colors.light}; `); diff --git a/app/client/widgets/UserType.js b/app/client/widgets/UserType.js index f8f723a1..8d2b2632 100644 --- a/app/client/widgets/UserType.js +++ b/app/client/widgets/UserType.js @@ -198,7 +198,8 @@ var typeDefs = { icon: 'FieldTextbox', options: { alignment: 'left', - choices: null + choices: null, + choiceOptions: null } } }, @@ -214,7 +215,8 @@ var typeDefs = { icon: 'FieldTextbox', options: { alignment: 'left', - choices: null + choices: null, + choiceOptions: null } } },