From 79f6f605f84c381759c1e1e47bfdb9d4a0203be8 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Thu, 12 Aug 2021 11:06:40 -0700 Subject: [PATCH] (core) Polish and enable Reference List widget Summary: Adds Reference List as a widget type. Reference List is similar to Choice List: multiple references can be added to each cell through a similar editor, and the individual references will always reflect their current value from the referenced table. Test Plan: Browser tests. Reviewers: dsagal Reviewed By: dsagal Subscribers: paulfitz, jarek, alexmojaki, dsagal Differential Revision: https://phab.getgrist.com/D2959 --- app/client/components/TypeConversion.ts | 11 +- app/client/lib/TokenField.ts | 38 +- app/client/models/ColumnACIndexes.ts | 2 +- app/client/models/entities/ViewFieldRec.ts | 6 +- app/client/ui/ColumnFilterMenu.ts | 54 ++- app/client/ui2018/menus.ts | 5 +- app/client/widgets/FieldBuilder.ts | 9 +- app/client/widgets/Reference.ts | 55 ++- app/client/widgets/ReferenceEditor.ts | 13 +- app/client/widgets/ReferenceList.ts | 30 +- app/client/widgets/ReferenceListEditor.ts | 400 ++++++++++++++++++++- app/client/widgets/UserType.js | 30 +- app/common/ColumnFilterFunc.ts | 4 +- app/common/gristTypes.ts | 8 +- sandbox/grist/relation.py | 2 +- sandbox/grist/usertypes.py | 20 +- 16 files changed, 594 insertions(+), 93 deletions(-) diff --git a/app/client/components/TypeConversion.ts b/app/client/components/TypeConversion.ts index 8de63ed0..3a5be82e 100644 --- a/app/client/components/TypeConversion.ts +++ b/app/client/components/TypeConversion.ts @@ -11,6 +11,7 @@ import * as gristTypes from 'app/common/gristTypes'; import {isFullReferencingType} from 'app/common/gristTypes'; import * as gutil from 'app/common/gutil'; import {TableData} from 'app/common/TableData'; +import {decodeObject} from 'app/plugin/objtypes'; export interface ColInfo { type: string; @@ -92,9 +93,11 @@ export async function prepTransformColInfo(docModel: DocModel, origCol: ColumnRe } 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. - const columnData = tableData.getDistinctValues(origCol.colId(), 100); + const colId = isReferenceCol(origCol) ? origDisplayCol.colId() : origCol.colId(); + const columnData = tableData.getDistinctValues(colId, 100); if (columnData) { columnData.delete(""); + columnData.delete(null); widgetOptions = {choices: Array.from(columnData, String)}; } } @@ -108,8 +111,10 @@ export async function prepTransformColInfo(docModel: DocModel, origCol: ColumnRe // 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 colId = isReferenceCol(origCol) ? origDisplayCol.colId() : origCol.colId(); + for (let value of tableData.getColValues(colId) || []) { + if (value === null) { continue; } + value = String(decodeObject(value)).trim(); const tags: string[] = (value.startsWith('[') && gutil.safeJsonParse(value, null)) || value.split(","); for (const tag of tags) { choices.add(tag.trim()); diff --git a/app/client/lib/TokenField.ts b/app/client/lib/TokenField.ts index 4f94ea1f..f2cfb61e 100644 --- a/app/client/lib/TokenField.ts +++ b/app/client/lib/TokenField.ts @@ -103,9 +103,7 @@ export class TokenField extends Disposable { const openAutocomplete = this._openAutocomplete.bind(this); this._acOptions = _options.acOptions && {..._options.acOptions, onClick: addSelectedItem}; - const initialTokens = _options.initialValue; - this._maybeTrimLabels(initialTokens); - this._tokens.set(initialTokens.map(t => new TokenWrap(t))); + this.setTokens(_options.initialValue); this.tokensObs = this.autoDispose(computedArray(this._tokens, t => t.token)); this._keyBindings = {...defaultKeyBindings, ..._options.keyBindings}; @@ -207,6 +205,26 @@ export class TokenField extends Disposable { return this._hiddenInput; } + /** + * Returns the Autocomplete instance used by the TokenField. + */ + public getAutocomplete(): Autocomplete | null { + return this._acHolder.get(); + } + + /** + * Sets the `tokens` that the TokenField should be populated with. + * + * Can be called after the TokenField is created to override the + * stored tokens. This is useful for delayed token initialization, + * where `tokens` may need to be set shortly after the TokenField + * is opened (e.g. ReferenceListEditor). + */ + public setTokens(tokens: Token[]): void { + const formattedTokens = this._maybeTrimTokens(tokens); + this._tokens.set(formattedTokens.map(t => new TokenWrap(t))); + } + // Replaces a token (if it exists). public replaceToken(label: string, newToken: Token): void { const tokenIdx = this._tokens.get().findIndex(t => t.token.label === label); @@ -433,7 +451,7 @@ export class TokenField extends Disposable { tokens = values.map(v => this._options.createToken(v)).filter((t): t is Token => Boolean(t)); } if (!tokens.length) { return; } - this._maybeTrimLabels(tokens); + tokens = this._maybeTrimTokens(tokens); tokens = this._getNonEmptyTokens(tokens); const wrappedTokens = tokens.map(t => new TokenWrap(t)); this._combineUndo(() => { @@ -582,15 +600,11 @@ export class TokenField extends Disposable { } /** - * Trims all labels in `tokens` if the option is set. - * - * Note: mutates `tokens`. + * Returns an array of tokens formatted according to the `trimLabels` option. */ - private _maybeTrimLabels(tokens: Token[]): void { - if (!this._options.trimLabels) { return; } - tokens.forEach(t => { - t.label = t.label.trim(); - }); + private _maybeTrimTokens(tokens: Token[]): Token[] { + if (!this._options.trimLabels) { return tokens; } + return tokens.map(t => ({...t, label: t.label.trim()})); } /** diff --git a/app/client/models/ColumnACIndexes.ts b/app/client/models/ColumnACIndexes.ts index 0eeb754a..26d5ff1a 100644 --- a/app/client/models/ColumnACIndexes.ts +++ b/app/client/models/ColumnACIndexes.ts @@ -6,7 +6,7 @@ * * It is available as tableData.columnACIndexes. * - * It is currently used for auto-complete in the ReferenceEditor widget. + * It is currently used for auto-complete in the ReferenceEditor and ReferenceListEditor widgets. */ import {ACIndex, ACIndexImpl} from 'app/client/lib/ACIndex'; import {ColumnCache} from 'app/client/models/ColumnCache'; diff --git a/app/client/models/entities/ViewFieldRec.ts b/app/client/models/entities/ViewFieldRec.ts index 1fcb0f27..e75cd08d 100644 --- a/app/client/models/entities/ViewFieldRec.ts +++ b/app/client/models/entities/ViewFieldRec.ts @@ -76,8 +76,8 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> { // Helper which adds/removes/updates field's displayCol to match the formula. saveDisplayFormula(formula: string): Promise|undefined; - // Helper for Reference columns, which returns a formatter according to the visibleCol - // associated with field. Subscribes to observables if used within a computed. + // Helper for Reference/ReferenceList columns, which returns a formatter according + // to the visibleCol associated with field. Subscribes to observables if used within a computed. createVisibleColFormatter(): BaseFormatter; } @@ -160,7 +160,7 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void this.displayColModel = refRecord(docModel.columns, this.displayColRef); this.visibleColModel = refRecord(docModel.columns, this.visibleColRef); - // Helper for Reference columns, which returns a formatter according to the visibleCol + // Helper for Reference/ReferenceList columns, which returns a formatter according to the visibleCol // associated with this field. If no visible column available, return formatting for the field itself. // Subscribes to observables if used within a computed. // TODO: It would be better to replace this with a pureComputed whose value is a formatter. diff --git a/app/client/ui/ColumnFilterMenu.ts b/app/client/ui/ColumnFilterMenu.ts index a2c260bd..fdd38c5f 100644 --- a/app/client/ui/ColumnFilterMenu.ts +++ b/app/client/ui/ColumnFilterMenu.ts @@ -27,7 +27,7 @@ import some = require('lodash/some'); import tail = require('lodash/tail'); import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel'; import {decodeObject} from 'app/plugin/objtypes'; -import {isList} from 'app/common/gristTypes'; +import {isList, isRefListType} 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'; @@ -281,12 +281,9 @@ function formatUniqueCount(values: Array<[CellValue, IFilterCount]>) { export function createFilterMenu(openCtl: IOpenController, sectionFilter: SectionFilter, field: ViewFieldRec, rowSource: RowSource, tableData: TableData, onClose: () => void = noop) { // Go through all of our shown and hidden rows, and count them up by the values in this column. - const keyMapFunc = tableData.getRowPropFunc(field.column().colId())!; - const labelGetter = tableData.getRowPropFunc(field.displayColModel().colId())!; - const formatter = field.createVisibleColFormatter(); - const labelMapFunc = (rowId: number) => formatter.formatAny(labelGetter(rowId)); - const activeFilterBar = field.viewSection.peek().activeFilterBar; const columnType = field.column().type.peek(); + const {keyMapFunc, labelMapFunc} = getMapFuncs(columnType, tableData, field); + const activeFilterBar = field.viewSection.peek().activeFilterBar; function getFilterFunc(f: ViewFieldRec, colFilter: ColumnFilterFunc|null) { return f.getRowId() === field.getRowId() ? null : colFilter; @@ -322,6 +319,37 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio }); } +/** + * Returns two callback functions, `keyMapFunc` and `labelMapFunc`, + * which map row ids to cell values and labels respectively. + * + * The functions vary based on the `columnType`. For example, + * Reference Lists have a unique `labelMapFunc` that returns a list + * of all labels in a given cell, rather than a single label. + * + * Used by ColumnFilterMenu to compute counts of unique cell + * values and display them with an appropriate label. + */ +function getMapFuncs(columnType: string, tableData: TableData, field: ViewFieldRec) { + const keyMapFunc = tableData.getRowPropFunc(field.column().colId())!; + const labelGetter = tableData.getRowPropFunc(field.displayColModel().colId())!; + const formatter = field.createVisibleColFormatter(); + + let labelMapFunc: (rowId: number) => string | string[]; + if (isRefListType(columnType)) { + labelMapFunc = (rowId: number) => { + const maybeLabels = labelGetter(rowId); + if (!maybeLabels) { return ''; } + const labels = isList(maybeLabels) ? maybeLabels.slice(1) : [maybeLabels]; + return labels.map(l => formatter.formatAny(l)); + }; + } else { + labelMapFunc = (rowId: number) => formatter.formatAny(labelGetter(rowId)); + } + + return {keyMapFunc, labelMapFunc}; +} + /** * Returns a callback function for rendering values in a filter menu. * @@ -358,9 +386,9 @@ function getRenderFunc(columnType: string, field: ViewFieldRec) { } interface ICountOptions { + columnType: string; keyMapFunc?: (v: any) => any; labelMapFunc?: (v: any) => any; - columnType?: string; areHiddenRows?: boolean; } @@ -379,13 +407,23 @@ function addCountsToMap(valueMap: Map, rowIds: RowId[], let key = keyMapFunc(rowId); // If row contains a list and the column is a Choice List, treat each choice as a separate key - if (isList(key) && columnType === 'ChoiceList') { + if (isList(key) && (columnType === 'ChoiceList')) { const list = decodeObject(key) as unknown[]; for (const item of list) { addSingleCountToMap(valueMap, item, () => item, areHiddenRows); } continue; } + + // If row contains a Reference List, treat each reference as a separate key + if (isList(key) && isRefListType(columnType)) { + const refIds = decodeObject(key) as unknown[]; + const refLabels = labelMapFunc(rowId); + refIds.forEach((id, i) => { + addSingleCountToMap(valueMap, id, () => refLabels[i], areHiddenRows); + }); + continue; + } // For complex values, serialize the value to allow them to be properly stored if (Array.isArray(key)) { key = JSON.stringify(key); } addSingleCountToMap(valueMap, key, () => labelMapFunc(rowId), areHiddenRows); diff --git a/app/client/ui2018/menus.ts b/app/client/ui2018/menus.ts index eea42099..322b2aaa 100644 --- a/app/client/ui2018/menus.ts +++ b/app/client/ui2018/menus.ts @@ -103,11 +103,12 @@ export function select(obs: Observable, optionArray: MaybeObsArray diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index f0d8d9dd..6030b3e2 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -28,7 +28,7 @@ import * as gristTypes from 'app/common/gristTypes'; import { getReferencedTableId, isFullReferencingType } from 'app/common/gristTypes'; import { CellValue } from 'app/plugin/GristData'; import { Computed, Disposable, fromKo, dom as grainjsDom, - Holder, IDisposable, makeTestId, toKo } from 'grainjs'; + Holder, IDisposable, makeTestId, styled, toKo } from 'grainjs'; import * as ko from 'knockout'; import * as _ from 'underscore'; @@ -222,7 +222,8 @@ export class FieldBuilder extends Disposable { grainjsDom.autoDispose(selectType), select(selectType, this._availableTypes, { disabled: (use) => use(this._isTransformingFormula) || use(this.origColumn.disableModifyBase) || - use(this.isCallPending) + use(this.isCallPending), + menuCssClass: cssTypeSelectMenu.className, }), testId('type-select'), grainjsDom.cls('tour-type-selector'), @@ -526,3 +527,7 @@ export class FieldBuilder extends Disposable { this.gristDoc.fieldEditorHolder.autoDispose(editorHolder); } } + +const cssTypeSelectMenu = styled('div', ` + max-height: 500px; +`); diff --git a/app/client/widgets/Reference.ts b/app/client/widgets/Reference.ts index 1411ccc8..25909d66 100644 --- a/app/client/widgets/Reference.ts +++ b/app/client/widgets/Reference.ts @@ -77,32 +77,55 @@ export class Reference extends NTextBox { } public buildDom(row: DataRowModel) { + // Note: we require 2 observables here because changes to the cell value (reference id) + // and the display value (display column) are not bundled. This can cause `formattedValue` + // to briefly display incorrect values (e.g. [Blank] when adding a reference to an empty cell) + // because the cell value changes before the display column has a chance to update. + // + // TODO: Look into a better solution (perhaps updating the display formula to return [Blank]). + const referenceId = Computed.create(null, (use) => { + const id = row.cells[use(this.field.colId)]; + return id && use(id); + }); const formattedValue = Computed.create(null, (use) => { + let [value, hasBlankReference] = ['', false]; if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) { // Work around JS errors during certain changes (noticed when visibleCol field gets removed // for a column using per-field settings). - return ""; + return {value, hasBlankReference}; } - const value = row.cells[use(use(this.field.displayColModel).colId)]; - if (!value) { return ""; } - const content = use(value); - if (isVersions(content)) { + + const displayValueObs = row.cells[use(use(this.field.displayColModel).colId)]; + if (!displayValueObs) { + return {value, hasBlankReference}; + } + + const displayValue = use(displayValueObs); + value = isVersions(displayValue) ? // We can arrive here if the reference value is unchanged (viewed as a foreign key) // but the content of its displayCol has changed. Postponing doing anything about // this until we have three-way information for computed columns. For now, // just showing one version of the cell. TODO: elaborate. - return use(this._formatValue)(content[1].local || content[1].parent); - } - return use(this._formatValue)(content); + use(this._formatValue)(displayValue[1].local || displayValue[1].parent) : + use(this._formatValue)(displayValue); + + hasBlankReference = referenceId.get() !== 0 && value.trim() === ''; + + return {value, hasBlankReference}; }); - return dom('div.field_clip', + + return cssRef( dom.autoDispose(formattedValue), + dom.autoDispose(referenceId), + cssRef.cls('-blank', use => use(formattedValue).hasBlankReference), dom.style('text-align', this.alignment), dom.cls('text_wrapping', this.wrapping), - cssRefIcon('FieldReference', - testId('ref-link-icon') - ), - dom.text(formattedValue) + cssRefIcon('FieldReference', testId('ref-link-icon')), + dom.text(use => { + if (use(referenceId) === 0) { return ''; } + if (use(formattedValue).hasBlankReference) { return '[Blank]'; } + return use(formattedValue).value; + }) ); } } @@ -112,3 +135,9 @@ const cssRefIcon = styled(icon, ` background-color: ${colors.slate}; margin: -1px 2px 2px 0; `); + +const cssRef = styled('div.field_clip', ` + &-blank { + color: ${colors.slate} + } +`); diff --git a/app/client/widgets/ReferenceEditor.ts b/app/client/widgets/ReferenceEditor.ts index decdfc7a..7194b6d8 100644 --- a/app/client/widgets/ReferenceEditor.ts +++ b/app/client/widgets/ReferenceEditor.ts @@ -9,7 +9,8 @@ import {menuCssClass} from 'app/client/ui2018/menus'; import {Options} from 'app/client/widgets/NewBaseEditor'; import {NTextEditor} from 'app/client/widgets/NTextEditor'; import {CellValue} from 'app/common/DocActions'; -import {removePrefix, undef} from 'app/common/gutil'; +import {getReferencedTableId} from 'app/common/gristTypes'; +import {undef} from 'app/common/gutil'; import {BaseFormatter} from 'app/common/ValueFormatter'; import {styled} from 'grainjs'; @@ -31,7 +32,7 @@ export class ReferenceEditor extends NTextEditor { const field = options.field; // Get the table ID to which the reference points. - const refTableId = removePrefix(field.column().type(), "Ref:"); + const refTableId = getReferencedTableId(field.column().type()); if (!refTableId) { throw new Error("ReferenceEditor used for non-Reference column"); } @@ -195,7 +196,9 @@ const cssRefEditor = styled('div', ` } `); -const cssRefList = styled('div', ` +// Set z-index to be higher than the 1000 set for .cell_editor. +export const cssRefList = styled('div', ` + z-index: 1001; overflow-y: auto; padding: 8px 0 0 0; --weaseljs-menu-item-padding: 8px 16px; @@ -235,7 +238,7 @@ const cssRefItem = styled('li', ` } `); -const cssPlusButton = styled('div', ` +export const cssPlusButton = styled('div', ` display: inline-block; width: 20px; height: 20px; @@ -250,7 +253,7 @@ const cssPlusButton = styled('div', ` } `); -const cssPlusIcon = styled(icon, ` +export const cssPlusIcon = styled(icon, ` background-color: ${colors.light}; `); diff --git a/app/client/widgets/ReferenceList.ts b/app/client/widgets/ReferenceList.ts index 85870673..296b2da4 100644 --- a/app/client/widgets/ReferenceList.ts +++ b/app/client/widgets/ReferenceList.ts @@ -1,10 +1,10 @@ import {DataRowModel} from 'app/client/models/DataRowModel'; -import {testId} from 'app/client/ui2018/cssVars'; +import {colors, testId} from 'app/client/ui2018/cssVars'; import {isList} from 'app/common/gristTypes'; import {dom} from 'grainjs'; -import {cssChoiceList, cssToken} from "./ChoiceListCell"; -import {Reference} from "./Reference"; -import {choiceToken} from "./ChoiceToken"; +import {cssChoiceList, cssToken} from "app/client/widgets/ChoiceListCell"; +import {Reference} from "app/client/widgets/Reference"; +import {choiceToken} from "app/client/widgets/ChoiceToken"; /** * ReferenceList - The widget for displaying lists of references to another table's records. @@ -27,7 +27,9 @@ export class ReferenceList extends Reference { return null; } const content = use(value); - // if (isVersions(content)) { // TODO + if (!content) { return null; } + // TODO: Figure out what the implications of this block are for ReferenceList. + // if (isVersions(content)) { // // We can arrive here if the reference value is unchanged (viewed as a foreign key) // // but the content of its displayCol has changed. Postponing doing anything about // // this until we have three-way information for computed columns. For now, @@ -36,18 +38,22 @@ export class ReferenceList extends Reference { // } const items = isList(content) ? content.slice(1) : [content]; return items.map(use(this._formatValue)); - }, (input) => { + }, + (input) => { if (!input) { return null; } - return input.map(token => - choiceToken( - String(token), - {}, // default colors + return input.map(token => { + const isBlankReference = token.trim() === ''; + return choiceToken( + isBlankReference ? '[Blank]' : token, + { + textColor: isBlankReference ? colors.slate.value : undefined + }, dom.cls(cssToken.className), testId('ref-list-cell-token') - ), - ); + ); + }); }), ); } diff --git a/app/client/widgets/ReferenceListEditor.ts b/app/client/widgets/ReferenceListEditor.ts index 51c622b9..714b37d1 100644 --- a/app/client/widgets/ReferenceListEditor.ts +++ b/app/client/widgets/ReferenceListEditor.ts @@ -1,16 +1,402 @@ -import {NTextEditor} from 'app/client/widgets/NTextEditor'; +import {createGroup} from 'app/client/components/commands'; +import {ACItem, ACResults, 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 {menuCssClass} from 'app/client/ui2018/menus'; +import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons'; +import {EditorPlacement} from 'app/client/widgets/EditorPlacement'; +import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor'; +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 {cssRefList, renderACItem} from 'app/client/widgets/ReferenceEditor'; +import {TableData} from 'app/client/models/TableData'; +import {BaseFormatter} from 'app/common/ValueFormatter'; +import {reportError} from 'app/client/models/errors'; +import {getReferencedTableId} from 'app/common/gristTypes'; +import {cssInvalidToken} from 'app/client/widgets/ChoiceListCell'; +class ReferenceItem implements IToken, ACItem { + /** + * A slight misnomer: what actually gets shown inside the TokenField + * is the `text`. Instead, `label` identifies a Token in the TokenField by either + * its row id (if it has one) or its display text. + * + * TODO: Look into removing `label` from IToken altogether, replacing it with a solution + * similar to getItemText() from IAutocompleteOptions. + */ + public label: string = typeof this.rowId === 'number' ? String(this.rowId) : this.text; + public cleanText: string = this.text.trim().toLowerCase(); + + constructor( + public text: string, + public rowId: number | 'new' | 'invalid', + ) {} +} /** * A ReferenceListEditor offers an autocomplete of choices from the referenced table. */ -export class ReferenceListEditor extends NTextEditor { - public getCellValue(): CellValue { - try { - return ['L', ...JSON.parse(this.textInput.value)]; - } catch { - return null; // This is the default value for a reference list column. +export class ReferenceListEditor extends NewBaseEditor { + protected cellEditorDiv: HTMLElement; + protected commandGroup: any; + + private _tableData: TableData; + private _formatter: BaseFormatter; + private _enableAddNew: boolean; + private _showAddNew: boolean = false; + private _visibleCol: string; + 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; + + constructor(options: Options) { + super(options); + + const field = options.field; + + // Get the table ID to which the reference list points. + const refTableId = getReferencedTableId(field.column().type()); + if (!refTableId) { + throw new Error("ReferenceListEditor used for non-ReferenceList column"); } + + const docData = options.gristDoc.docData; + const tableData = docData.getTable(refTableId); + if (!tableData) { + throw new Error("ReferenceListEditor: invalid referenced table"); + } + this._tableData = tableData; + + // Construct the formatter for the displayed values using the options from the target column. + this._formatter = field.createVisibleColFormatter(); + + const vcol = field.visibleColModel(); + // Whether we should enable the "Add New" entry to allow adding new items to the target table. + this._enableAddNew = vcol && !vcol.isRealFormula() && !!vcol.colId(); + + this._visibleCol = vcol.colId() || 'id'; + + const acOptions: IAutocompleteOptions = { + menuCssClass: `${menuCssClass} ${cssRefList.className}`, + search: this._doSearch.bind(this), + renderItem: this._renderItem.bind(this), + getItemText: (item) => item.text, + }; + + 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 startRowIds: unknown[] = options.editValue || !Array.isArray(cellValue) ? [] : cellValue; + + // If referenced table hasn't loaded yet, hold off on initializing tokens. + const needReload = (options.editValue === undefined && !tableData.isLoaded); + const startTokens = needReload ? + [] : startRowIds.map(id => new ReferenceItem(this._idToText(id), typeof id === 'number' ? id : 'invalid')); + + this._tokenField = TokenField.ctor().create(this, { + initialValue: startTokens, + renderToken: item => { + const isBlankReference = item.cleanText === ''; + return [ + isBlankReference ? '[Blank]' : item.text, + cssToken.cls('-blank', isBlankReference), + cssInvalidToken.cls('-invalid', item.rowId === 'invalid') + ]; + }, + createToken: text => new ReferenceItem(text, 'invalid'), + acOptions, + openAutocompleteOnFocus: true, + readonly : options.readonly, + trimLabels: true, + styles: {cssTokenField, cssToken, cssDeleteButton, cssDeleteIcon}, + }); + + this._dom = dom('div.default_editor', + dom.cls("readonly_editor", options.readonly), + dom.cls(cssReadonlyStyle.className, options.readonly), + 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(), + ); + + // The referenced table has probably already been fetched (because there must already be a + // Reference widget instantiated), but it's better to avoid this assumption. + docData.fetchTable(refTableId).then(() => { + if (this.isDisposed()) { return; } + if (needReload) { + this._tokenField.setTokens( + startRowIds.map(id => new ReferenceItem(this._idToText(id), typeof id === 'number' ? id : 'invalid')) + ); + this.resizeInput(); + } + const autocomplete = this._tokenField.getAutocomplete(); + if (autocomplete) { + autocomplete.search(); + } + }) + .catch(reportError); + } + + 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 { + const rowIds = this._tokenField.tokensObs.get().map(t => typeof t.rowId === 'number' ? t.rowId : t.text); + return encodeObject(rowIds); + } + + public getTextValue(): string { + const rowIds = this._tokenField.tokensObs.get().map(t => typeof t.rowId === 'number' ? String(t.rowId) : t.text); + return csvEncodeRow(rowIds, {prettier: true}); + } + + public getCursorPos(): number { + return this._textInput.selectionStart || 0; + } + + /** + * If any 'new' item are saved, add them to the referenced table first. + */ + public async prepForSave() { + const tokens = this._tokenField.tokensObs.get(); + const newValues = tokens.filter(t => t.rowId === 'new'); + if (newValues.length === 0) { return; } + + // Add the new items to the referenced table. + const colInfo = {[this._visibleCol]: newValues.map(t => t.text)}; + const rowIds = await this._tableData.sendTableAction( + ["BulkAddRecord", new Array(newValues.length).fill(null), colInfo] + ); + + // Update the TokenField tokens with the returned row ids. + let i = 0; + const newTokens = tokens.map(t => { + return t.rowId === 'new' ? new ReferenceItem(t.text, rowIds[i++]) : t; + }); + this._tokenField.setTokens(newTokens); + } + + 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'; + } + + /** + * If the search text does not match anything exactly, adds 'new' item to it. + * + * Also see: prepForSave. + */ + private async _doSearch(text: string): Promise> { + const acIndex = this._tableData.columnACIndexes.getColACIndex(this._visibleCol, this._formatter); + const {items, selectIndex, highlightFunc} = acIndex.search(text); + const result: ACResults = { + selectIndex, + highlightFunc, + items: items.map(i => new ReferenceItem(i.text, i.rowId)) + }; + + this._showAddNew = false; + if (!this._enableAddNew || !text) { return result; } + + const cleanText = text.trim().toLowerCase(); + if (result.items.find((item) => item.cleanText === cleanText)) { + return result; + } + + result.items.push(new ReferenceItem(text, 'new')); + this._showAddNew = true; + + return result; + } + + private _idToText(value: unknown) { + if (typeof value === 'number') { + return this._formatter.formatAny(this._tableData.getValue(value, this._visibleCol)); + } + return String(value || ''); + } + + private _renderItem(item: ReferenceItem, highlightFunc: HighlightFunc) { + return renderACItem( + item.text, + highlightFunc, + item.rowId === 'new', + this._showAddNew + ); } } + +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; + white-space: pre; + + &.selected { + box-shadow: inset 0 0 0 1px ${colors.lightGreen}; + } + + &-blank { + color: ${colors.slate}; + } +`); + +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; +`); + +const cssReadonlyStyle = styled('div', ` + padding-left: 16px; + background: white; +`); diff --git a/app/client/widgets/UserType.js b/app/client/widgets/UserType.js index 4b5e1434..ec95c1ad 100644 --- a/app/client/widgets/UserType.js +++ b/app/client/widgets/UserType.js @@ -237,21 +237,21 @@ var typeDefs = { }, default: 'Reference' }, - // RefList: { - // label: 'Reference List', - // icon: 'FieldReference', - // widgets: { - // Reference: { - // cons: 'ReferenceList', - // editCons: 'ReferenceListEditor', - // icon: 'FieldReference', - // options: { - // alignment: 'left' - // } - // } - // }, - // default: 'Reference' - // }, + RefList: { + label: 'Reference List', + icon: 'FieldReference', + widgets: { + Reference: { + cons: 'ReferenceList', + editCons: 'ReferenceListEditor', + icon: 'FieldReference', + options: { + alignment: 'left' + } + } + }, + default: 'Reference' + }, Attachments: { label: 'Attachment', icon: 'FieldAttachment', diff --git a/app/common/ColumnFilterFunc.ts b/app/common/ColumnFilterFunc.ts index d8757c95..5d486426 100644 --- a/app/common/ColumnFilterFunc.ts +++ b/app/common/ColumnFilterFunc.ts @@ -1,7 +1,7 @@ import { CellValue } from "app/common/DocActions"; import { FilterState, makeFilterState } from "app/common/FilterState"; import { decodeObject } from "app/plugin/objtypes"; -import { isList } from "./gristTypes"; +import { isList, isRefListType } from "./gristTypes"; export type ColumnFilterFunc = (value: CellValue) => boolean; @@ -13,7 +13,7 @@ export function makeFilterFunc({ include, values }: FilterState, // For example, a TypeError in the formula column and the string '["E","TypeError"]' would be seen as the same. // TODO: This narrow corner case seems acceptable for now, but may be worth revisiting. return (val: CellValue) => { - if (isList(val) && columnType === 'ChoiceList') { + if (isList(val) && (columnType === 'ChoiceList' || isRefListType(String(columnType)))) { const list = decodeObject(val) as unknown[]; return list.some(item => values.has(item as any) === include); } diff --git a/app/common/gristTypes.ts b/app/common/gristTypes.ts index 33cddbbb..76cbd7b0 100644 --- a/app/common/gristTypes.ts +++ b/app/common/gristTypes.ts @@ -329,6 +329,10 @@ export function getReferencedTableId(type: string) { return removePrefix(type, "Ref:") || removePrefix(type, "RefList:"); } -export function isFullReferencingType(type: string) { - return type.startsWith('Ref:') || type.startsWith('RefList:'); +export function isRefListType(type: string) { + return type.startsWith('RefList:'); +} + +export function isFullReferencingType(type: string) { + return type.startsWith('Ref:') || isRefListType(type); } diff --git a/sandbox/grist/relation.py b/sandbox/grist/relation.py index 2f5a3676..4ffe653a 100644 --- a/sandbox/grist/relation.py +++ b/sandbox/grist/relation.py @@ -131,7 +131,7 @@ class ReferenceRelation(Relation): self.inverse_map.setdefault(target_row_id, set()).add(referring_row_id) def remove_reference(self, referring_row_id, target_row_id): - self.inverse_map[target_row_id].remove(referring_row_id) + self.inverse_map[target_row_id].discard(referring_row_id) def clear(self): self.inverse_map.clear() diff --git a/sandbox/grist/usertypes.py b/sandbox/grist/usertypes.py index 24bff9ff..b43432a8 100644 --- a/sandbox/grist/usertypes.py +++ b/sandbox/grist/usertypes.py @@ -166,9 +166,15 @@ class Text(BaseColumnType): @classmethod def typeConvert(cls, value): - # When converting NULLs (that typically show up as a plain empty cell for Numeric or Date - # columns) to Text, it makes more sense to end up with a plain blank text cell. - return '' if value is None else value + if value is None: + # When converting NULLs (that typically show up as a plain empty cell for Numeric or Date + # columns) to Text, it makes more sense to end up with a plain blank text cell. + return '' + elif isinstance(value, bool): + # Normalize True/False to true/false (Toggle columns use true/false). + return str(value).lower() + else: + return value class Blob(BaseColumnType): @@ -350,6 +356,8 @@ class ChoiceList(BaseColumnType): @classmethod def typeConvert(cls, value): + if value is None: + return value if isinstance(value, six.string_types) and not value.startswith('['): # Try to parse as CSV. If this doesn't work, we'll still try usual conversions later. try: @@ -357,6 +365,8 @@ class ChoiceList(BaseColumnType): return tuple(t.strip() for t in tags if t.strip()) except Exception: pass + if not isinstance(value, (tuple, list)): + value = [Choice.typeConvert(value)] return value @classmethod @@ -434,7 +444,7 @@ class Reference(Id): @classmethod def typeConvert(cls, value, ref_table, visible_col=None): # pylint: disable=arguments-differ - if ref_table and visible_col: + if value and ref_table and visible_col: return ref_table.lookupOne(**{visible_col: value}) or six.text_type(value) else: return value @@ -478,7 +488,7 @@ class ReferenceList(BaseColumnType): def typeConvert(cls, value, ref_table, visible_col=None): # noqa # pylint: disable=arguments-differ # TODO this is based on Reference.typeConvert. # It doesn't make much sense as a conversion but I don't know what would - if ref_table and visible_col: + if value and ref_table and visible_col: return ref_table.lookupRecords(**{visible_col: value}) or six.text_type(value) else: return value