diff --git a/app/client/components/TypeConversion.ts b/app/client/components/TypeConversion.ts index 57fbe528..1ae17257 100644 --- a/app/client/components/TypeConversion.ts +++ b/app/client/components/TypeConversion.ts @@ -91,6 +91,22 @@ export async function prepTransformColInfo(docModel: DocModel, origCol: ColumnRe } break; } + case 'ChoiceList': { + // Set suggested choices. This happens before the conversion to ChoiceList, so we do some + // light guessing for likely choices to suggest. + const choices = new Set(); + for (let value of tableData.getColValues(origCol.colId()) || []) { + value = String(value).trim(); + const tags: string[] = (value.startsWith('[') && gutil.safeJsonParse(value, null)) || value.split(","); + for (const tag of tags) { + choices.add(tag.trim()); + if (choices.size > 100) { break; } // Don't suggest excessively many choices. + } + } + choices.delete(""); + widgetOptions = {choices: Array.from(choices)}; + break; + } case 'Ref': { // Set suggested destination table and visible column. // Null if toTypeMaybeFull is a pure type (e.g. converting to Ref before a table is chosen). @@ -148,10 +164,15 @@ export function getDefaultFormula( const oldVisibleColName = isReferenceCol(origCol) ? getVisibleColName(docModel, origCol.visibleCol()) : undefined; - const origValFormula = oldVisibleColName ? + let origValFormula = oldVisibleColName ? // The `str()` below converts AltText to plain text. `$${colId}.${oldVisibleColName} if ISREF($${colId}) else str($${colId})` : `$${colId}`; + + if (origCol.type.peek() === 'ChoiceList') { + origValFormula = `grist.ChoiceList.toString($${colId})` + } + const toTypePure: string = gristTypes.extractTypeFromColType(newType); // The args are used to construct the call to grist.TYPE.typeConvert(value, [params]). diff --git a/app/client/lib/TokenField.ts b/app/client/lib/TokenField.ts index 25761798..7c2fcf68 100644 --- a/app/client/lib/TokenField.ts +++ b/app/client/lib/TokenField.ts @@ -22,7 +22,7 @@ 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 { Disposable, dom, DomContents, Holder, styled } from 'grainjs'; +import { Disposable, dom, DomElementArg, Holder, styled } from 'grainjs'; export interface IToken { label: string; @@ -30,10 +30,11 @@ export interface IToken { export interface ITokenFieldOptions { initialValue: IToken[]; - renderToken: (token: IToken) => DomContents; + renderToken: (token: IToken) => DomElementArg; createToken: (inputText: string) => IToken|undefined; acOptions?: IAutocompleteOptions; openAutocompleteOnFocus?: boolean; + styles?: ITokenFieldStyles; // 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 @@ -87,11 +88,20 @@ export class TokenField extends Disposable { // 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}; + + function stop(ev: Event) { + ev.stopPropagation(); + ev.preventDefault(); + } + this._rootElem = cssTokenField( {tabIndex: '-1'}, dom.forEach(this._tokens, (t) => cssToken(this._options.renderToken(t.token), - cssDeleteIcon('CrossSmall', testId('tokenfield-delete')), + cssDeleteButton(cssDeleteIcon('CrossSmall'), testId('tokenfield-delete')), dom.cls('selected', (use) => use(this._selection).has(t)), dom.on('click', (ev) => this._onTokenClick(ev, t)), dom.on('mousedown', (ev) => this._onMouseDown(ev, t)), @@ -102,18 +112,16 @@ export class TokenField extends Disposable { this._textInput = cssTokenInput( dom.on('focus', this._onInputFocus.bind(this)), dom.on('blur', () => { this._acHolder.clear(); }), + (this._acOptions ? + // Toggle the autocomplete on clicking the input box. + dom.on('click', () => this._acHolder.isEmpty() ? openAutocomplete() : this._acHolder.clear()) : + null + ), dom.onKeyDown({ - Escape: () => { this._acHolder.clear(); }, - Enter: addSelectedItem, + Escape$: (ev) => { this._acHolder.clear(); }, + Enter$: (ev) => addSelectedItem() && stop(ev), ArrowDown$: openAutocomplete, - Tab$: (ev) => { - // Only treat tab specially if there is some token-adding in progress. - if (this._textInput.value !== '' || !this._acHolder.isEmpty()) { - ev.stopPropagation(); - ev.preventDefault(); - addSelectedItem(); - } - }, + Tab$: (ev) => addSelectedItem() && stop(ev), }), dom.on('input', openAutocomplete), testId('tokenfield-input'), @@ -149,6 +157,21 @@ export class TokenField extends Disposable { elem.appendChild(this._rootElem); } + // Outer container for the tokens and new-entry input field. + public getRootElem(): HTMLElement { + return this._rootElem; + } + + // The new-entry input field. + public getTextInput(): HTMLInputElement { + return this._textInput; + } + + // The invisible input that has focus while we have some tokens selected. + public getHiddenInput(): HTMLInputElement { + return this._hiddenInput; + } + // Open the autocomplete dropdown, if autocomplete was configured in the options. private _openAutocomplete() { if (this._acOptions && this._acHolder.isEmpty()) { @@ -158,7 +181,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() { + private _addSelectedItem(): boolean { let item: IToken|undefined = this._acHolder.get()?.getSelectedItem(); if (!item && this._options.createToken && this._textInput.value) { item = this._options.createToken(this._textInput.value); @@ -167,7 +190,9 @@ export class TokenField extends Disposable { this._tokens.push(new TokenWrap(item)); this._textInput.value = ''; this._acHolder.clear(); + return true; } + return false; } // Handler for when text input is focused: clears selection, optionally opens dropdown. @@ -514,6 +539,7 @@ const cssTokenField = styled('div', ` border: 1px solid ${colors.darkGrey}; border-radius: 3px; padding: 0 4px; + line-height: 16px; &.token-dragactive { cursor: grabbing; @@ -527,7 +553,6 @@ const cssToken = styled('div', ` background-color: ${colors.mediumGreyOpaque}; padding: 4px; margin: 3px 2px; - line-height: 16px; user-select: none; cursor: grab; @@ -558,6 +583,7 @@ const cssTokenInput = styled('input', ` padding: 0; border: none; outline: none; + line-height: inherit; `); // This class is applied to tokens and the input box on start of dragging, to use them as drag @@ -594,15 +620,31 @@ const cssHiddenInput = styled('input', ` position: absolute; `); -const cssDeleteIcon = styled(icon, ` - vertical-align: bottom; +const cssDeleteButton = styled('div', ` + display: inline; margin-left: 4px; + vertical-align: bottom; + line-height: 1; cursor: pointer; - --icon-color: ${colors.slate}; - &:hover { - --icon-color: ${colors.dark}; - } .${cssTokenField.className}.token-dragactive & { cursor: unset; } `); + +const cssDeleteIcon = styled(icon, ` + --icon-color: ${colors.slate}; + &:hover { + --icon-color: ${colors.dark}; + } +`); + +export const tokenFieldStyles = { + cssTokenField, + cssToken, + cssInputWrapper, + cssTokenInput, + cssDeleteButton, + cssDeleteIcon, +}; + +export type ITokenFieldStyles = Partial; diff --git a/app/client/widgets/ChoiceListCell.ts b/app/client/widgets/ChoiceListCell.ts new file mode 100644 index 00000000..a7f8bf2a --- /dev/null +++ b/app/client/widgets/ChoiceListCell.ts @@ -0,0 +1,72 @@ +import {DataRowModel} from 'app/client/models/DataRowModel'; +import {colors} from 'app/client/ui2018/cssVars'; +import {ChoiceTextBox} from 'app/client/widgets/ChoiceTextBox'; +import {decodeObject} from 'app/plugin/objtypes'; +import {Computed, dom, styled} from 'grainjs'; + +/** + * ChoiceListCell - A cell that renders a list of choice tokens. + */ +export class ChoiceListCell extends ChoiceTextBox { + private _choiceSet = Computed.create(this, this.getChoiceValues(), (use, values) => new Set(values)); + + public buildDom(row: DataRowModel) { + const value = row.cells[this.field.colId.peek()]; + + return cssChoiceList( + dom.cls('field_clip'), + cssChoiceList.cls('-wrap', this.wrapping), + dom.style('justify-content', this.alignment), + dom.domComputed((use) => use(row._isAddRow) ? null : [use(value), use(this._choiceSet)], (input) => { + if (!input) { return null; } + const [rawValue, choiceSet] = 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)) + ) + ); + }), + ); + } +} + +const cssChoiceList = styled('div', ` + display: flex; + align-items: start; + padding: 0 3px; + + position: relative; + height: min-content; + min-height: 22px; + + &-wrap { + flex-wrap: wrap; + } +`); + +const cssToken = styled('div', ` + flex: 0 1 auto; + min-width: 0px; + overflow: hidden; + border-radius: 3px; + background-color: ${colors.mediumGreyOpaque}; + padding: 1px 4px; + margin: 2px; + line-height: 16px; +`); + +export const cssInvalidToken = styled('div', ` + &-invalid { + background-color: white !important; + 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 new file mode 100644 index 00000000..ec7c6c37 --- /dev/null +++ b/app/client/widgets/ChoiceListEditor.ts @@ -0,0 +1,283 @@ +import {createGroup} from 'app/client/components/commands'; +import {ACIndexImpl, ACItem, ACResults} 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 {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 {csvEncodeRow} from 'app/common/csvFormat'; +import {CellValue} from "app/common/DocActions"; +import {decodeObject, encodeObject} from 'app/plugin/objtypes'; +import {dom, styled} from 'grainjs'; + +class ChoiceItem implements ACItem, IToken { + public cleanText: string = this.label.toLowerCase().trim(); + constructor( + public label: string, + public isInvalid: boolean, // If set, this token is not one of the valid choices. + public isNew?: boolean, // If set, this is a choice to be added to the config. + ) {} +} + +export class ChoiceListEditor extends NewBaseEditor { + protected cellEditorDiv: HTMLElement; + protected commandGroup: any; + + private _tokenField: TokenField; + private _textInput: HTMLInputElement; + private _dom: HTMLElement; + private _editorPlacement: EditorPlacement; + private _contentSizer: HTMLElement; // Invisible element to size the editor with all the tokens + private _inputSizer: HTMLElement; // Part of _contentSizer to size the text input + private _alignment: string; + + // Whether to include a button to show a new choice. (It would make sense to disable it when + // user cannot change the column configuration.) + private _enableAddNew: boolean = true; + private _showAddNew: boolean = false; + + constructor(options: Options) { + super(options); + + const choices: string[] = options.field.widgetOptionsJson.peek().choices || []; + const acItems = choices.map(c => new ChoiceItem(c, false)); + const choiceSet = new Set(choices); + + const acIndex = new ACIndexImpl(acItems); + 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), + getItemText: (item) => item.label, + }; + + this.commandGroup = this.autoDispose(createGroup(options.commands, null, true)); + this._alignment = options.field.widgetOptionsJson.peek().alignment || 'left'; + + // If starting to edit by typing in a string, ignore previous tokens. + const cellValue = decodeObject(options.cellValue); + 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, { + initialValue: startTokens, + renderToken: item => [item.label, cssInvalidToken.cls('-invalid', (item as ChoiceItem).isInvalid)], + createToken: label => new ChoiceItem(label, !choiceSet.has(label)), + acOptions, + openAutocompleteOnFocus: true, + styles: {cssTokenField, cssToken, cssDeleteButton, cssDeleteIcon}, + }); + + this._dom = dom('div.default_editor', + this.cellEditorDiv = cssCellEditor(testId('widget-text-editor'), + this._contentSizer = cssContentSizer(), + elem => this._tokenField.attach(elem), + ), + createMobileButtons(options.commands), + ); + + this._textInput = this._tokenField.getTextInput(); + dom.update(this._tokenField.getRootElem(), + dom.style('justify-content', this._alignment), + ); + dom.update(this._tokenField.getHiddenInput(), + this.commandGroup.attach(), + ); + dom.update(this._textInput, + // Resize the editor whenever user types into the textbox. + dom.on('input', () => this.resizeInput(true)), + dom.prop('value', options.editValue || ''), + this.commandGroup.attach(), + ); + } + + public attach(cellElem: Element): void { + // Attach the editor dom to page DOM. + this._editorPlacement = EditorPlacement.create(this, this._dom, cellElem, {margins: getButtonMargins()}); + + // Reposition the editor if needed for external reasons (in practice, window resize). + this.autoDispose(this._editorPlacement.onReposition.addListener(() => this.resizeInput())); + + // Update the sizing whenever the tokens change. Delay it till next tick to give a chance for + // DOM updates that happen around tokenObs changes, to complete. + this.autoDispose(this._tokenField.tokensObs.addListener(() => + Promise.resolve().then(() => this.resizeInput()))); + + this.setSizerLimits(); + + // Once the editor is attached to DOM, resize it to content, focus, and set cursor. + this.resizeInput(); + this._textInput.focus(); + const pos = Math.min(this.options.cursorPos, this._textInput.value.length); + this._textInput.setSelectionRange(pos, pos); + } + + public getDom(): HTMLElement { + return this._dom; + } + + public getCellValue(): CellValue { + return encodeObject(this._tokenField.tokensObs.get().map(item => item.label)); + } + + public getTextValue() { + const values = this._tokenField.tokensObs.get().map(t => t.label); + return csvEncodeRow(values, {prettier: true}); + } + + public getCursorPos(): number { + return this._textInput.selectionStart || 0; + } + + public async prepForSave() { + const tokens = this._tokenField.tokensObs.get() as ChoiceItem[]; + const newChoices = tokens.filter(t => t.isNew).map(t => t.label); + if (newChoices.length > 0) { + const choices = this.options.field.widgetOptionsJson.prop('choices'); + await choices.saveOnly([...choices.peek(), ...new Set(newChoices)]); + } + } + + public setSizerLimits() { + // Set the max width of the sizer to the max we could possibly grow to, so that it knows to wrap + // once we reach it. + const rootElem = this._tokenField.getRootElem(); + const maxSize = this._editorPlacement.calcSizeWithPadding(rootElem, + {width: Infinity, height: Infinity}, {calcOnly: true}); + this._contentSizer.style.maxWidth = Math.ceil(maxSize.width) + 'px'; + } + + /** + * Helper which resizes the token-field to match its content. + */ + protected resizeInput(onlyTextInput: boolean = false) { + if (this.isDisposed()) { return; } + + const rootElem = this._tokenField.getRootElem(); + + // To size the content, we need both the tokens and the text typed into _textInput. We + // re-create the tokens using cloneNode(true) copies all styles and properties, but not event + // handlers. We can skip this step when we know that only _textInput changed. + if (!onlyTextInput || !this._inputSizer) { + this._contentSizer.innerHTML = ''; + + dom.update(this._contentSizer, + dom.update(rootElem.cloneNode(true) as HTMLElement, + dom.style('width', ''), + dom.style('height', ''), + this._inputSizer = cssInputSizer(), + + // Remove the testId('tokenfield') from the cloned element, to simplify tests (so that + // selecting .test-tokenfield only returns the actual visible tokenfield container). + dom.cls('test-tokenfield', false), + ) + ); + } + + // Use a separate sizer to size _textInput to the text inside it. + // \u200B is a zero-width space; so the sizer will have height even when empty. + this._inputSizer.textContent = this._textInput.value + '\u200B'; + const rect = this._contentSizer.getBoundingClientRect(); + + const size = this._editorPlacement.calcSizeWithPadding(rootElem, rect); + rootElem.style.width = size.width + 'px'; + rootElem.style.height = size.height + 'px'; + this._textInput.style.width = this._inputSizer.getBoundingClientRect().width + 'px'; + } + + private _maybeShowAddNew(result: ACResults, text: string): ACResults { + // If the search text does not match anything exactly, add 'new' item for it. See also prepForSave. + this._showAddNew = false; + if (this._enableAddNew && text) { + const addNewItem = new ChoiceItem(text, false, true); + if (!result.items.find((item) => item.cleanText === addNewItem.cleanText)) { + result.items.push(addNewItem); + this._showAddNew = true; + } + } + return result; + } +} + +const cssCellEditor = styled('div', ` + background-color: white; + font-family: var(--grist-font-family-data); + font-size: var(--grist-medium-font-size); +`); + +const cssTokenField = styled(tokenFieldStyles.cssTokenField, ` + border: none; + align-items: start; + align-content: start; + padding: 0 3px; + height: min-content; + min-height: 22px; + color: black; + flex-wrap: wrap; +`); + +const cssToken = styled(tokenFieldStyles.cssToken, ` + padding: 1px 4px; + margin: 2px; + line-height: 16px; +`); + +const cssDeleteButton = styled(tokenFieldStyles.cssDeleteButton, ` + position: absolute; + top: -8px; + right: -6px; + border-radius: 16px; + background-color: ${colors.dark}; + width: 14px; + height: 14px; + cursor: pointer; + z-index: 1; + display: none; + align-items: center; + justify-content: center; + + .${cssToken.className}:hover & { + display: flex; + } + .${cssTokenField.className}.token-dragactive & { + cursor: unset; + } +`); + +const cssDeleteIcon = styled(tokenFieldStyles.cssDeleteIcon, ` + --icon-color: ${colors.light}; + &:hover { + --icon-color: ${colors.darkGrey}; + } +`); + +const cssContentSizer = styled('div', ` + position: absolute; + left: 0; + top: -100px; + border: none; + visibility: hidden; + overflow: visible; + width: max-content; + + & .${tokenFieldStyles.cssInputWrapper.className} { + display: none; + } +`); + +const cssInputSizer = styled('div', ` + flex: auto; + min-width: 24px; + margin: 3px 2px; +`); + +// Set z-index to be higher than the 1000 set for .cell_editor. +const cssChoiceList = styled('div', ` + z-index: 1001; + box-shadow: 0 0px 8px 0 rgba(38,38,51,0.6) +`); diff --git a/app/client/widgets/ChoiceTextBox.ts b/app/client/widgets/ChoiceTextBox.ts index 0f203382..99653fc7 100644 --- a/app/client/widgets/ChoiceTextBox.ts +++ b/app/client/widgets/ChoiceTextBox.ts @@ -4,7 +4,6 @@ import {DataRowModel} from 'app/client/models/DataRowModel'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {KoSaveableObservable} from 'app/client/models/modelUtil'; import {cssLabel, cssRow} from 'app/client/ui/RightPanel'; -import {alignmentSelect} from 'app/client/ui2018/buttonSelect'; import {colors, testId} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {menu, menuItem} from 'app/client/ui2018/menus'; @@ -31,28 +30,13 @@ export class ChoiceTextBox extends NTextBox { dom.style('text-align', this.alignment), dom.text((use) => use(row._isAddRow) ? '' : use(this.valueFormatter).format(use(value))), ), - 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') - ) + this.buildDropdownMenu(), ); } public buildConfigDom() { return [ - cssRow( - alignmentSelect(this.alignment) - ), + super.buildConfigDom(), cssLabel('OPTIONS'), cssRow( dom.create(ListEntry, this._choiceValues, (values) => this._choices.saveOnly(values)) @@ -64,6 +48,27 @@ export class ChoiceTextBox extends NTextBox { return this.buildConfigDom(); } + protected getChoiceValues(): Computed { + return this._choiceValues; + } + + 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; } @@ -71,7 +76,6 @@ export class ChoiceTextBox extends NTextBox { const cssChoiceField = styled('div.field_clip', ` display: flex; - justify-content: space-between; `); const cssChoiceText = styled('div', ` @@ -86,4 +90,5 @@ const cssDropdownIcon = styled(icon, ` min-width: 16px; width: 16px; height: 16px; + margin-left: auto; `); diff --git a/app/client/widgets/ReferenceEditor.ts b/app/client/widgets/ReferenceEditor.ts index 37fc754d..2f82d4b2 100644 --- a/app/client/widgets/ReferenceEditor.ts +++ b/app/client/widgets/ReferenceEditor.ts @@ -149,17 +149,21 @@ export class ReferenceEditor extends NTextEditor { } private _renderItem(item: ICellItem, highlightFunc: HighlightFunc) { - if (item.rowId === 'new') { - return cssRefItem(cssRefItem.cls('-new'), - cssPlusButton(cssPlusIcon('Plus')), item.text, - testId('ref-editor-item'), testId('ref-editor-new-item'), - ); - } - return cssRefItem(cssRefItem.cls('-with-new', this._showAddNew), - buildHighlightedDom(item.text, highlightFunc, cssMatchText), - testId('ref-editor-item'), + return renderACItem(item.text, highlightFunc, item.rowId === 'new', this._showAddNew); + } +} + +export function renderACItem(text: string, highlightFunc: HighlightFunc, isAddNew: boolean, withSpaceForNew: boolean) { + if (isAddNew) { + return cssRefItem(cssRefItem.cls('-new'), + cssPlusButton(cssPlusIcon('Plus')), text, + testId('ref-editor-item'), testId('ref-editor-new-item'), ); } + return cssRefItem(cssRefItem.cls('-with-new', withSpaceForNew), + buildHighlightedDom(text, highlightFunc, cssMatchText), + testId('ref-editor-item'), + ); } function nocaseEqual(a: string, b: string) { @@ -172,7 +176,7 @@ const cssRefEditor = styled('div', ` } `); -const cssRefList = styled('div', ` +export const cssRefList = styled('div', ` overflow-y: auto; padding: 8px 0 0 0; --weaseljs-menu-item-padding: 8px 16px; diff --git a/app/client/widgets/UserType.js b/app/client/widgets/UserType.js index d748ea80..9afb2f0f 100644 --- a/app/client/widgets/UserType.js +++ b/app/client/widgets/UserType.js @@ -204,6 +204,22 @@ var typeDefs = { }, default: 'TextBox' }, + ChoiceList: { + label: 'Choice List', + icon: 'FieldChoice', + widgets: { + TextBox: { + cons: 'ChoiceListCell', + editCons: 'ChoiceListEditor', + icon: 'FieldTextbox', + options: { + alignment: 'left', + choices: null + } + } + }, + default: 'TextBox' + }, Ref: { label: 'Reference', icon: 'FieldReference', diff --git a/app/client/widgets/UserTypeImpl.js b/app/client/widgets/UserTypeImpl.js index 751bed20..262fde5a 100644 --- a/app/client/widgets/UserTypeImpl.js +++ b/app/client/widgets/UserTypeImpl.js @@ -28,6 +28,8 @@ const nameToWidget = { 'ReferenceEditor': ReferenceEditor, 'ChoiceTextBox': ChoiceTextBox, 'ChoiceEditor': require('./ChoiceEditor'), + 'ChoiceListCell': require('./ChoiceListCell').ChoiceListCell, + 'ChoiceListEditor': require('./ChoiceListEditor').ChoiceListEditor, 'DateTimeTextBox': require('./DateTimeTextBox'), 'DateTextBox': require('./DateTextBox'), 'DateEditor': require('./DateEditor'), diff --git a/app/common/gristTypes.ts b/app/common/gristTypes.ts index 45e88b39..d93a47ad 100644 --- a/app/common/gristTypes.ts +++ b/app/common/gristTypes.ts @@ -3,7 +3,8 @@ import isString = require('lodash/isString'); // tslint:disable:object-literal-key-quotes -export type GristType = 'Any' | 'Attachments' | 'Blob' | 'Bool' | 'Choice' | 'Date' | 'DateTime' | +export type GristType = 'Any' | 'Attachments' | 'Blob' | 'Bool' | 'Choice' | 'ChoiceList' | + 'Date' | 'DateTime' | 'Id' | 'Int' | 'ManualSortPos' | 'Numeric' | 'PositionNumber' | 'Ref' | 'RefList' | 'Text'; export type GristTypeInfo = @@ -41,6 +42,7 @@ const _defaultValues: {[key in GristType]: [CellValue, string]} = { // Bool is only supported by SQLite as 0 and 1 values. 'Bool': [ false, "0" ], 'Choice': [ '', "''" ], + 'ChoiceList': [ null, "NULL" ], 'Date': [ null, "NULL" ], 'DateTime': [ null, "NULL" ], 'Id': [ 0, "0" ], @@ -187,7 +189,8 @@ const rightType: {[key in GristType]: (value: CellValue) => boolean} = { } else { return false; } - } + }, + ChoiceList: isListOrNull, }; export function isRightType(type: string): undefined | ((value: CellValue, options?: any) => boolean) { diff --git a/app/server/lib/DocStorage.ts b/app/server/lib/DocStorage.ts index a06d5b06..461dc907 100644 --- a/app/server/lib/DocStorage.ts +++ b/app/server/lib/DocStorage.ts @@ -377,7 +377,7 @@ export class DocStorage implements ISQLiteDB { * be used within main Grist application. */ public static decodeRowValues(dbRow: ResultRow): any { - return _.mapObject(dbRow, val => DocStorage._decodeValue(val, 'Any')); + return _.mapObject(dbRow, val => DocStorage._decodeValue(val, 'Any', 'BLOB')); } /** @@ -425,7 +425,7 @@ export class DocStorage implements ISQLiteDB { const rows = _.unzip(valueColumns); for (const row of rows) { for (let i = 0; i < row.length; i++) { - row[i] = DocStorage._encodeValue(marshaller, this._getSqlType(types[i]), row[i]); + row[i] = DocStorage._encodeValue(marshaller, types[i], this._getSqlType(types[i]), row[i]); } } return rows; @@ -440,12 +440,19 @@ export class DocStorage implements ISQLiteDB { * which such encoding/marshalling is not used, and e.g. binary data is stored to BLOBs directly. */ private static _encodeValue( - marshaller: marshal.Marshaller, sqlType: string, val: any + marshaller: marshal.Marshaller, gristType: string, sqlType: string, val: any ): Uint8Array|string|number|boolean { const marshalled = () => { marshaller.marshal(val); return marshaller.dump(); }; + if (gristType == 'ChoiceList') { + // See also app/plugin/objtype.ts for decodeObject(). Here we manually check and decode + // the "List" object type. + if (Array.isArray(val) && val[0] === 'L' && val.every(tok => (typeof(tok) === 'string'))) { + return JSON.stringify(val.slice(1)); + } + } // Marshall anything non-primitive. if (Array.isArray(val) || val instanceof Uint8Array || Buffer.isBuffer(val)) { return marshalled(); @@ -494,19 +501,30 @@ export class DocStorage implements ISQLiteDB { /** * Decodes Grist data received from SQLite; the inverse of _encodeValue(). - * Type may be either grist or sql type. Only used for a Bool/BOOLEAN check. + * Both Grist and SQL types are expected. Used to interpret Bool/BOOLEANs, and to parse + * ChoiceList values. */ - private static _decodeValue(val: any, type: string): any { + private static _decodeValue(val: any, gristType: string, sqlType: string): any { if (val instanceof Uint8Array || Buffer.isBuffer(val)) { val = marshal.loads(val); } - if ((type === 'Bool' || type === 'BOOLEAN') && (val === 0 || val === 1)) { - // Boolean values come in as 0/1. If the column is of type "Bool", interpret those as - // true/false (note that the data engine does this too). - return Boolean(val); - } else { - return val; + if (gristType === 'Bool') { + if (val === 0 || val === 1) { + // Boolean values come in as 0/1. If the column is of type "Bool", interpret those as + // true/false (note that the data engine does this too). + return Boolean(val); + } } + if (gristType === 'ChoiceList') { + if (typeof val === 'string' && val.startsWith('[')) { + try { + return ['L', ...JSON.parse(val)]; + } catch (e) { + // Fall through without parsing + } + } + } + return val; } /** @@ -538,6 +556,8 @@ export class DocStorage implements ISQLiteDB { case 'Choice': case 'Text': return 'TEXT'; + case 'ChoiceList': + return 'TEXT'; // To be encoded as a JSON array of strings. case 'Date': return 'DATE'; case 'DateTime': @@ -842,7 +862,7 @@ export class DocStorage implements ISQLiteDB { const type = this._getGristType(tableId, col); const column = columnValues[col]; for (let i = 0; i < column.length; i++) { - column[i] = DocStorage._decodeValue(column[i], type); + column[i] = DocStorage._decodeValue(column[i], type, DocStorage._getSqlType(type)); } } return columnValues; @@ -1348,6 +1368,7 @@ export class DocStorage implements ISQLiteDB { if (!colInfo) { return null; // Column not found. } + const oldGristType = this._getGristType(tableId, colId); const oldSqlType = colInfo.type || 'BLOB'; const oldDefault = colInfo.dflt_value; const newSqlType = newColType ? DocStorage._getSqlType(newColType) : oldSqlType; @@ -1361,6 +1382,8 @@ export class DocStorage implements ISQLiteDB { const colSpecSql = DocStorage._prefixJoin(', ', infoRows.map(DocStorage._sqlColSpecFromDBInfo)); return { sql: `CREATE TABLE ${quoteIdent(tableId)} (id INTEGER PRIMARY KEY${colSpecSql})`, + oldGristType, + newGristType: newColType || oldGristType, oldDefault, newDefault, oldSqlType, @@ -1411,7 +1434,7 @@ export class DocStorage implements ISQLiteDB { // For any marshalled objects, check if we can now unmarshall them if they are the // native type. - if (result.newSqlType !== result.oldSqlType) { + if (result.newGristType !== result.oldGristType) { const cells = await this.all(`SELECT id, ${q(colId)} as value FROM ${q(tableId)} ` + `WHERE typeof(${q(colId)}) = 'blob'`); const marshaller = new marshal.Marshaller({version: 2}); @@ -1419,8 +1442,8 @@ export class DocStorage implements ISQLiteDB { for (const cell of cells) { const id: number = cell.id; const value: any = cell.value; - const decodedValue = DocStorage._decodeValue(value, result.oldSqlType); - const newValue = DocStorage._encodeValue(marshaller, result.newSqlType, decodedValue); + const decodedValue = DocStorage._decodeValue(value, result.oldGristType, result.oldSqlType); + const newValue = DocStorage._encodeValue(marshaller, result.newGristType, result.newSqlType, decodedValue); if (!(newValue instanceof Uint8Array)) { sqlParams.push([newValue, id]); } @@ -1505,6 +1528,8 @@ export class DocStorage implements ISQLiteDB { interface RebuildResult { sql: string; + oldGristType: string; + newGristType: string; oldDefault: string; newDefault: string; oldSqlType: string; diff --git a/sandbox/grist/column.py b/sandbox/grist/column.py index 2eb3d3e5..dd71d506 100644 --- a/sandbox/grist/column.py +++ b/sandbox/grist/column.py @@ -1,3 +1,4 @@ +import json import types from collections import namedtuple @@ -294,6 +295,26 @@ class PositionColumn(NumericColumn): return new_values, [(self._sorted_rows[i], pos) for (i, pos) in adjustments] +class ChoiceListColumn(BaseColumn): + """ + ChoiceListColumn's default value is None, but is presented to formulas as the empty list. + """ + def set(self, row_id, value): + # When a JSON string is loaded, set it to a tuple parsed from it. When a list is loaded, + # convert to a tuple to keep values immutable. + if isinstance(value, basestring) and value.startswith('['): + try: + value = tuple(json.loads(value)) + except Exception: + pass + elif isinstance(value, list): + value = tuple(value) + super(ChoiceListColumn, self).set(row_id, value) + + def _make_rich_value(self, typed_value): + return () if typed_value is None else typed_value + + class BaseReferenceColumn(BaseColumn): """ Base class for ReferenceColumn and ReferenceListColumn. @@ -386,6 +407,7 @@ class ReferenceListColumn(BaseReferenceColumn): usertypes.BaseColumnType.ColType = DataColumn usertypes.Reference.ColType = ReferenceColumn usertypes.ReferenceList.ColType = ReferenceListColumn +usertypes.ChoiceList.ColType = ChoiceListColumn usertypes.DateTime.ColType = DateTimeColumn usertypes.Date.ColType = DateColumn usertypes.PositionNumber.ColType = PositionColumn diff --git a/sandbox/grist/grist.py b/sandbox/grist/grist.py index 74bebbe1..15c5cb75 100644 --- a/sandbox/grist/grist.py +++ b/sandbox/grist/grist.py @@ -6,7 +6,7 @@ a consistent API accessible with only "import grist". # These imports are used in processing generated usercode. from usertypes import Any, Text, Blob, Int, Bool, Date, DateTime, \ -Numeric, Choice, Id, Attachments, AltText, ifError + Numeric, Choice, ChoiceList, Id, Attachments, AltText, ifError from usertypes import PositionNumber, ManualSortPos, Reference, ReferenceList, formulaType from table import UserTable from records import Record, RecordSet diff --git a/sandbox/grist/usertypes.py b/sandbox/grist/usertypes.py index e80f4cc5..f903bacf 100644 --- a/sandbox/grist/usertypes.py +++ b/sandbox/grist/usertypes.py @@ -11,7 +11,10 @@ Python's array.array. However, at least on the Python side, it means that we nee data structure for values of the wrong type, and the memory savings aren't that great to be worth the extra complexity. """ +import csv +import cStringIO import datetime +import json import six import objtypes from objtypes import AltText @@ -29,6 +32,7 @@ _type_defaults = { 'Blob': None, 'Bool': False, 'Choice': '', + 'ChoiceList': None, 'Date': None, 'DateTime': None, 'Id': 0, @@ -319,6 +323,53 @@ class Choice(Text): pass +class ChoiceList(BaseColumnType): + """ + ChoiceList is the type for a field holding a list of strings from a set of acceptable choices. + """ + def do_convert(self, value): + if not value: + return None + elif isinstance(value, basestring): + # If it's a string that looks like JSON, try to parse it as such. + if value.startswith('['): + try: + return tuple(str(item) for item in json.loads(value)) + except Exception: + pass + return value + else: + # Accepts other kinds of iterables; if that doesn't work, fail the conversion too. + return tuple(str(item) for item in value) + + @classmethod + def is_right_type(cls, value): + return value is None or (isinstance(value, (tuple, list)) and + all(isinstance(item, basestring) for item in value)) + + @classmethod + def typeConvert(cls, value): + if isinstance(value, basestring) and not value.startswith('['): + # Try to parse as CSV. If this doesn't work, we'll still try usual conversions later. + try: + tags = next(csv.reader([value])) + return tuple(t.strip() for t in tags if t.strip()) + except Exception: + pass + return value + + @classmethod + def toString(cls, value): + if isinstance(value, (tuple, list)): + try: + buf = cStringIO.StringIO() + csv.writer(buf).writerow(value) + return buf.getvalue().strip() + except Exception: + pass + return value + + class PositionNumber(BaseColumnType): """ PositionNumber is the type for a position field used to order records in record lists.