From b537539b73724c5e611e67421c4dc7559fdad297 Mon Sep 17 00:00:00 2001 From: Dmitry S Date: Tue, 29 Jun 2021 00:47:59 -0400 Subject: [PATCH] (core) Implement UI for trigger formulas. Summary: - Implement UI with "Apply to new records" and "Apply on record changes" checkboxes, and options for selecting which changes to recalculate on. - For consistency, always represent empty RefList as None - Fix up generated SchemaTypes to remember that values are encoded. Included test cases for the main planned use cases: - Auto-filled UUID column - Data cleaning - NOW() formula for record's last-updated timestamp. - Updates that depend on other columns. Test Plan: Added a browser test. Reviewers: jarek Reviewed By: jarek Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D2885 --- app/client/components/GridView.js | 2 +- app/client/components/GristDoc.ts | 11 +- app/client/ui/FieldConfig.ts | 29 +++- app/client/ui/TriggerFormulas.ts | 271 ++++++++++++++++++++++++++++++ app/client/ui2018/ColorSelect.ts | 15 +- app/client/ui2018/checkbox.ts | 6 +- app/client/ui2018/menus.ts | 19 +-- app/client/ui2018/select.ts | 48 ++++++ app/common/gristTypes.ts | 11 ++ app/common/schema.ts | 6 +- sandbox/gen_js_schema.py | 4 +- sandbox/grist/usertypes.py | 3 +- 12 files changed, 372 insertions(+), 53 deletions(-) create mode 100644 app/client/ui/TriggerFormulas.ts create mode 100644 app/client/ui2018/select.ts diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index 72d1f9fe..758c1d2e 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -529,7 +529,7 @@ GridView.prototype._convertFormulasToData = function(selection) { // prevented by ACL rules). const fields = selection.fields.filter(f => f.column.peek().isFormula.peek()); if (!fields.length) { return null; } - return this.gristDoc.convertFormulasToData(fields.map(f => f.colRef.peek())); + return this.gristDoc.convertIsFormula(fields.map(f => f.colRef.peek()), {toFormula: false}); }; GridView.prototype.selectAll = function() { diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 4adda671..cba2c099 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -42,6 +42,7 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; import {isSchemaAction} from 'app/common/DocActions'; import {OpenLocalDocResult} from 'app/common/DocListAPI'; import {HashLink, IDocPage} from 'app/common/gristUrls'; +import {RecalcWhen} from 'app/common/gristTypes'; import {encodeQueryParams, waitObs} from 'app/common/gutil'; import {StringUnion} from 'app/common/StringUnion'; import {TableData} from 'app/common/TableData'; @@ -572,16 +573,20 @@ export class GristDoc extends DisposableWithEvents { ['BulkUpdateRecord', colRefs, { isFormula: colRefs.map(f => true), formula: colRefs.map(f => ''), + // Set recalc settings to defaults when emptying a column. + recalcWhen: colRefs.map(f => RecalcWhen.DEFAULT), + recalcDeps: colRefs.map(f => null), }] ); } // Convert the given columns to data, saving the calculated values and unsetting the formulas. - public async convertFormulasToData(colRefs: number[]): Promise { + public async convertIsFormula(colRefs: number[], opts: {toFormula: boolean, noRecalc?: boolean}): Promise { return this.docModel.columns.sendTableAction( ['BulkUpdateRecord', colRefs, { - isFormula: colRefs.map(f => false), - formula: colRefs.map(f => ''), + isFormula: colRefs.map(f => opts.toFormula), + recalcWhen: colRefs.map(f => opts.noRecalc ? RecalcWhen.NEVER : RecalcWhen.DEFAULT), + recalcDeps: colRefs.map(f => null), }] ); } diff --git a/app/client/ui/FieldConfig.ts b/app/client/ui/FieldConfig.ts index 1ea1ca75..b3292c83 100644 --- a/app/client/ui/FieldConfig.ts +++ b/app/client/ui/FieldConfig.ts @@ -2,6 +2,7 @@ import type {GristDoc} from 'app/client/components/GristDoc'; import type {ColumnRec} from 'app/client/models/entities/ColumnRec'; import type {CursorPos} from "app/client/components/Cursor"; import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight'; +import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas'; import {cssLabel, cssRow} from 'app/client/ui/RightPanel'; import {colors, testId, vars} from 'app/client/ui2018/cssVars'; import {textInput} from 'app/client/ui2018/editableLabel'; @@ -68,7 +69,8 @@ export function buildFormulaConfig( owner: MultiHolder, origColumn: ColumnRec, gristDoc: GristDoc, buildEditor: BuildEditor ) { const clearColumn = () => gristDoc.clearColumns([origColumn.id.peek()]); - const convertToData = () => gristDoc.convertFormulasToData([origColumn.id.peek()]); + const convertIsFormula = + (opts: {toFormula: boolean, noRecalc?: boolean}) => gristDoc.convertIsFormula([origColumn.id.peek()], opts); const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn); return dom.maybe(use => { @@ -98,7 +100,7 @@ export function buildFormulaConfig( return [ buildHeader('EMPTY COLUMN', () => [ menuItem(clearColumn, 'Clear column', dom.cls('disabled', true)), - menuItem(convertToData, 'Make into data column'), + menuItem(() => convertIsFormula({toFormula: false}), 'Make into data column'), ]), buildFormulaRow(), ]; @@ -106,17 +108,27 @@ export function buildFormulaConfig( return [ buildHeader('FORMULA COLUMN', () => [ menuItem(clearColumn, 'Clear column'), - menuItem(convertToData, 'Convert to data column'), + menuItem(() => convertIsFormula({toFormula: false, noRecalc: true}), 'Convert to data column'), ]), buildFormulaRow(), ]; } else { return [ - buildHeader('DATA COLUMN', () => [ - menuItem(clearColumn, 'Clear and make into formula'), - ]), - buildFormulaRow('Default formula'), - cssHintRow('Default formula for new records'), + buildHeader('DATA COLUMN', () => { + return origColumn.formula.peek() ? [ + // If there is a formula available, offer a separate option to convert to formula + // without clearing it. + menuItem(clearColumn, 'Clear column'), + menuItem(() => convertIsFormula({toFormula: true}), 'Convert to formula column'), + ] : [ + menuItem(clearColumn, 'Clear and make into formula'), + ]; + }), + buildFormulaRow('Optional formula'), + dom.domComputed(use => Boolean(use(origColumn.formula)), (haveFormula) => haveFormula ? + dom.create(buildFormulaTriggers, origColumn) : + cssHintRow('For default values, automatic updates, and data-cleaning.') + ) ]; } } @@ -238,7 +250,6 @@ const cssDropdownLabel = styled(cssInlineLabel, ` const cssHintRow = styled('div', ` margin: -4px 16px 8px 16px; color: ${colors.slate}; - text-align: center; `); const cssColLabelBlock = styled('div', ` diff --git a/app/client/ui/TriggerFormulas.ts b/app/client/ui/TriggerFormulas.ts new file mode 100644 index 00000000..3a0c2467 --- /dev/null +++ b/app/client/ui/TriggerFormulas.ts @@ -0,0 +1,271 @@ +import type {ColumnRec} from 'app/client/models/entities/ColumnRec'; +import type {TableRec} from 'app/client/models/entities/TableRec'; +import {reportError} from 'app/client/models/errors'; +import {cssRow} from 'app/client/ui/RightPanel'; +import {shadowScroll} from 'app/client/ui/shadowScroll'; +import {basicButton, primaryButton} from "app/client/ui2018/buttons"; +import {labeledSquareCheckbox} from "app/client/ui2018/checkbox"; +import {colors, testId} from 'app/client/ui2018/cssVars'; +import {icon} from "app/client/ui2018/icons"; +import {menuCssClass, menuDivider} from 'app/client/ui2018/menus'; +import {cssSelectBtn} from 'app/client/ui2018/select'; +import {CellValue} from 'app/common/DocActions'; +import {isEmptyList, RecalcWhen} from 'app/common/gristTypes'; +import {nativeCompare} from 'app/common/gutil'; +import {decodeObject, encodeObject} from 'app/plugin/objtypes'; +import {Computed, dom, IDisposableOwner, MultiHolder, Observable, styled} from 'grainjs'; +import {cssMenu, cssMenuItem, defaultMenuOptions, IOpenController, setPopupToCreateDom} from "popweasel"; +import isEqual = require('lodash/isEqual'); + +/** + * Build UI to select triggers for formulas in data columns (such for default values). + */ +export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec) { + // Set up observables to translate between the UI representation of triggers, and what we + // actually store. + // - We store the pair (recalcWhen, recalcDeps). When recalcWhen is DEFAULT, recalcDeps lists + // the fields to depend on; in other cases, recalcDeps is not used. + // - We show two checkboxes: + // [] Apply to new records -- toggles between recalcWhen of NEVER and DEFAULT. + // [] Apply on record changes -- when turned on, allows selecting fields to depend on. When + // "Any field" is selected, it toggles between recalcWhen of MANUAL_UPDATES and DEFAULT. + + function isApplyOnChangesChecked(recalcWhen: RecalcWhen, recalcDeps: CellValue): boolean { + return recalcWhen === RecalcWhen.MANUAL_UPDATES || + (recalcWhen === RecalcWhen.DEFAULT && recalcDeps != null && !isEmptyList(recalcDeps)); + } + + async function toggleApplyOnChanges(value: boolean) { + // Whether turning on or off, we reset to the default state. + await setRecalc(RecalcWhen.DEFAULT, null); + forceApplyOnChanges.set(value); + } + + // The state of "Apply to new records" checkbox. Only writable when applyOnChanges is false, so + // only controls if recalcWhen should be DEFAULT or NEVER. + const applyToNew = Computed.create(owner, use => use(column.recalcWhen) !== RecalcWhen.NEVER) + .onWrite(value => setRecalc(value ? RecalcWhen.DEFAULT : RecalcWhen.NEVER, null)); + + // If true, mark 'Apply on record changes' checkbox, overriding stored state. + const forceApplyOnChanges = Observable.create(owner, false); + + // The actual state of the checkbox. Clicking it toggles forceApplyOnChanges, and also resets + // recalcWhen/recalcDeps to its default state. + const applyOnChanges = Computed.create(owner, + use => (use(forceApplyOnChanges) || isApplyOnChangesChecked(use(column.recalcWhen), use(column.recalcDeps)))) + .onWrite(toggleApplyOnChanges); + + // Helper to update column's recalcWhen and recalcDeps properties. + async function setRecalc(when: RecalcWhen, deps: number[]|null) { + if (when !== column.recalcWhen.peek() || deps !== column.recalcDeps.peek()) { + return column._table.sendTableAction( + ["UpdateRecord", column.id.peek(), {recalcWhen: when, recalcDeps: encodeObject(deps)}] + ); + } + } + + const docModel = column._table.docModel; + const summaryText = Computed.create(owner, use => { + if (use(column.recalcWhen) === RecalcWhen.MANUAL_UPDATES) { + return 'Any field'; + } + const deps = decodeObject(use(column.recalcDeps)) as number[]|null; + if (!deps || deps.length === 0) { return ''; } + return deps.map(dep => use(docModel.columns.getRowModel(dep)?.label)).join(", "); + }); + + return [ + cssRow( + labeledSquareCheckbox( + applyToNew, + 'Apply to new records', + dom.boolAttr('disabled', applyOnChanges), + testId('field-formula-apply-to-new'), + ), + ), + cssRow( + labeledSquareCheckbox( + applyOnChanges, + dom.text(use => use(applyOnChanges) ? + 'Apply on changes to:' : + 'Apply on record changes' + ), + testId('field-formula-apply-on-changes'), + ), + ), + dom.maybe(applyOnChanges, () => + cssIndentedRow( + cssSelectBtn( + cssSelectSummary(dom.text(summaryText)), + icon('Dropdown'), + testId('field-triggers-select'), + elem => { + setPopupToCreateDom(elem, ctl => buildTriggerSelectors(ctl, column.table.peek(), column, setRecalc), + {...defaultMenuOptions, placement: 'bottom-end'}); + } + ) + ) + ) + ]; +} + +function buildTriggerSelectors(ctl: IOpenController, tableRec: TableRec, column: ColumnRec, + setRecalc: (when: RecalcWhen, deps: number[]|null) => Promise +) { + // ctl may be used as an owner for disposable object. Just give is a clearer name for this. + const owner: IDisposableOwner = ctl; + + // The initial set of selected columns (as a set of rowIds). + const initialDeps = new Set(decodeObject(column.recalcDeps.peek()) as number[]|null); + + // State of the "Any field" checkbox. + const allUpdates = Observable.create(owner, column.recalcWhen.peek() === RecalcWhen.MANUAL_UPDATES); + + // Collect all the ColumnRec objects for available columns in this table. + const showColumns = tableRec.columns.peek().peek().filter(col => !col.isHiddenCol.peek()); + showColumns.sort((a, b) => nativeCompare(a.label.peek(), b.label.peek())); + + // Array of observables for the checkbox for each column. There should never be so many + // columns as to make this a performance problem. + const columnsState = showColumns.map(col => Observable.create(owner, initialDeps.has(col.id.peek()))); + + // The "Current field" checkbox is merely one of the column checkboxes. + const current = columnsState.find((col, index) => showColumns[index].id.peek() === column.id.peek())!; + + // If user checks the "Any field" checkbox, all the others should get unchecked. + owner.autoDispose(allUpdates.addListener(value => { + if (value) { + columnsState.forEach(obs => obs.set(false)); + } + })); + + // Computed results based on current selections. + const when = Computed.create(owner, use => use(allUpdates) ? RecalcWhen.MANUAL_UPDATES : RecalcWhen.DEFAULT); + const deps = Computed.create(owner, use => { + return use(allUpdates) ? null : + showColumns.filter((col, index) => use(columnsState[index])).map(col => col.id.peek()); + }); + + // Whether the selections changed, i.e. warrant saving. + const isChanged = Computed.create(owner, (use) => { + return use(when) !== use(column.recalcWhen) || !isEqual(new Set(use(deps)), initialDeps); + }); + + let shouldSave = true; + function close(_shouldSave: boolean) { + shouldSave = _shouldSave; + ctl.close(); + } + + function onClose() { + if (shouldSave && isChanged.get()) { + setRecalc(when.get(), deps.get()).catch(reportError); + } + } + + return cssSelectorMenu( + { tabindex: '-1' }, // Allow menu to be focused + testId('field-triggers-dropdown'), + dom.cls(menuCssClass), + dom.onDispose(onClose), + dom.onKeyDown({ + Enter: () => close(true), + Escape: () => close(false) + }), + // Set focus on open, so that keyboard events work. + elem => { setTimeout(() => elem.focus(), 0); }, + + cssItemsFixed( + cssSelectorItem( + labeledSquareCheckbox(current, + ['Current field ', cssSelectorNote('(data cleaning)')], + dom.boolAttr('disabled', allUpdates), + ), + ), + menuDivider(), + cssSelectorItem( + labeledSquareCheckbox(allUpdates, + ['Any field ', cssSelectorNote('(except formulas)')] + ), + ), + ), + cssItemsList( + showColumns.map((col, index) => + cssSelectorItem( + labeledSquareCheckbox(columnsState[index], + col.label.peek(), + dom.boolAttr('disabled', allUpdates), + ), + ) + ), + ), + cssItemsFixed( + cssSelectorFooter( + dom.maybe(isChanged, () => + primaryButton('OK', + dom.on('click', () => close(true)), + testId('trigger-deps-apply') + ), + ), + basicButton(dom.text(use => use(isChanged) ? 'Cancel' : 'Close'), + dom.on('click', () => close(false)), + testId('trigger-deps-cancel') + ), + ) + ), + ); +} + +const cssIndentedRow = styled(cssRow, ` + margin-left: 40px; +`); + +const cssSelectSummary = styled('div', ` + flex: 1 1 0px; + overflow: hidden; + text-overflow: ellipsis; + + &:empty::before { + content: "Select fields"; + color: ${colors.slate}; + } +`); + + +const cssSelectorMenu = styled(cssMenu, ` + display: flex; + flex-direction: column; + max-height: calc(max(300px, 95vh - 300px)); + max-width: 400px; + padding-bottom: 0px; +`); + +const cssItemsList = styled(shadowScroll, ` + flex: auto; + min-height: 80px; + border-top: 1px solid ${colors.darkGrey}; + border-bottom: 1px solid ${colors.darkGrey}; + margin-top: 8px; + padding: 8px 0; +`); + +const cssItemsFixed = styled('div', ` + flex: none; +`); + +const cssSelectorItem = styled(cssMenuItem, ` + justify-content: flex-start; + align-items: center; + display: flex; + padding: 8px 16px; + white-space: nowrap; +`); + +const cssSelectorNote = styled('span', ` + color: ${colors.slate}; +`); + +const cssSelectorFooter = styled(cssSelectorItem, ` + justify-content: space-between; + margin: 3px 0; +`); diff --git a/app/client/ui2018/ColorSelect.ts b/app/client/ui2018/ColorSelect.ts index a32d7177..139f0004 100644 --- a/app/client/ui2018/ColorSelect.ts +++ b/app/client/ui2018/ColorSelect.ts @@ -3,6 +3,7 @@ import { colors, testId, vars } from 'app/client/ui2018/cssVars'; import { textInput } from "app/client/ui2018/editableLabel"; import { icon } from "app/client/ui2018/icons"; import { isValidHex } from "app/common/gutil"; +import { cssSelectBtn } from 'app/client/ui2018/select'; import { Computed, Disposable, dom, DomArg, Observable, onKeyDown, styled } from "grainjs"; import { defaultMenuOptions, IOpenController, setPopupToCreateDom } from "popweasel"; @@ -315,17 +316,5 @@ const cssColorSquare = styled('div', ` const cssButtonIcon = styled(cssColorSquare, ` margin-right: 6px; -`); - -const cssSelectBtn = styled('div', ` - display: flex; - width: 100%; - height: 30px; - justify-content: space-between; - border-radius: 3px; - border: 1px solid #D9D9D9; - padding: 5px 9px; - user-select: none; - cursor: pointer; - background-color: white; + margin-left: 4px; `); diff --git a/app/client/ui2018/checkbox.ts b/app/client/ui2018/checkbox.ts index c1cb755f..4b88108d 100644 --- a/app/client/ui2018/checkbox.ts +++ b/app/client/ui2018/checkbox.ts @@ -120,7 +120,7 @@ export const cssLabelText = styled('span', ` type CheckboxArg = DomArg; function checkbox( - obs: Observable, cssCheckbox: typeof cssCheckboxSquare, label: string = '', ...domArgs: CheckboxArg[] + obs: Observable, cssCheckbox: typeof cssCheckboxSquare, label: DomArg = '', ...domArgs: CheckboxArg[] ) { return cssLabel( cssCheckbox( @@ -141,11 +141,11 @@ export function circleCheckbox(obs: Observable, ...domArgs: CheckboxArg return checkbox(obs, cssCheckboxCircle, '', ...domArgs); } -export function labeledSquareCheckbox(obs: Observable, label: string, ...domArgs: CheckboxArg[]) { +export function labeledSquareCheckbox(obs: Observable, label: DomArg, ...domArgs: CheckboxArg[]) { return checkbox(obs, cssCheckboxSquare, label, ...domArgs); } -export function labeledCircleCheckbox(obs: Observable, label: string, ...domArgs: CheckboxArg[]) { +export function labeledCircleCheckbox(obs: Observable, label: DomArg, ...domArgs: CheckboxArg[]) { return checkbox(obs, cssCheckboxCircle, label, ...domArgs); } diff --git a/app/client/ui2018/menus.ts b/app/client/ui2018/menus.ts index 64a6c753..eea42099 100644 --- a/app/client/ui2018/menus.ts +++ b/app/client/ui2018/menus.ts @@ -1,6 +1,7 @@ import {Command} from 'app/client/components/commands'; import {NeedUpgradeError, reportError} from 'app/client/models/errors'; import {colors, testId, vars} from 'app/client/ui2018/cssVars'; +import {cssSelectBtn} from 'app/client/ui2018/select'; import {IconName} from 'app/client/ui2018/IconList'; import {icon} from 'app/client/ui2018/icons'; import {commonUrls} from 'app/common/gristUrls'; @@ -280,24 +281,6 @@ const cssSelectBtnContainer = styled('div', ` width: 100%; `); -const cssSelectBtn = styled('div', ` - width: 100%; - height: 30px; - line-height: 16px; - background-color: white; - font-size: ${vars.mediumFontSize}; - padding: 5px; - border: 1px solid ${colors.darkGrey}; - color: ${colors.dark}; - --icon-color: ${colors.dark}; - border-radius: 3px; - cursor: pointer; - outline: none; - -webkit-appearance: none; - -moz-appearance: none; - display: flex; -`); - const cssSelectBtnLink = styled('div', ` display: flex; align-items: center; diff --git a/app/client/ui2018/select.ts b/app/client/ui2018/select.ts new file mode 100644 index 00000000..91fbb707 --- /dev/null +++ b/app/client/ui2018/select.ts @@ -0,0 +1,48 @@ +import {colors, vars} from 'app/client/ui2018/cssVars'; +import {styled} from 'grainjs'; + +// Import popweasel so that the styles we define here are included later in CSS, and take priority +// over popweasel styles, when used together. +import 'popweasel'; + +/** + * Style for a select dropdown button. + * + * This incorporates styling from popweasel's select, so that it can be used to style buttons that + * don't use it. + */ +export const cssSelectBtn = styled('div', ` + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + height: 30px; + line-height: 16px; + background-color: white; + color: ${colors.dark}; + --icon-color: ${colors.dark}; + font-size: ${vars.mediumFontSize}; + padding: 5px; + border: 1px solid ${colors.darkGrey}; + border-radius: 3px; + cursor: pointer; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + -webkit-appearance: none; + -moz-appearance: none; + user-select: none; + -moz-user-select: none; + outline: none; + + &:focus { + outline: none; + box-shadow: 0px 0px 2px 2px #5E9ED6; + } + + &.disabled { + color: grey; + cursor: pointer; + } +`); diff --git a/app/common/gristTypes.ts b/app/common/gristTypes.ts index f2999176..d882f7a3 100644 --- a/app/common/gristTypes.ts +++ b/app/common/gristTypes.ts @@ -226,6 +226,17 @@ export function getGristType(pureType: string): string { } } +/** + * Enum for values of columns' recalcWhen property, corresponding to Python definitions in + * schema.py. + */ +export enum RecalcWhen { + DEFAULT = 0, // Calculate on new records or when any field in recalcDeps changes. + NEVER = 1, // Don't calculate automatically (but user can trigger manually) + MANUAL_UPDATES = 2, // Calculate on new records and on manual updates to any data field. +} + + /** * Converts SQL type strings produced by the Sequelize library into its corresponding * Grist type. The list of types is based on an analysis of SQL type string outputs diff --git a/app/common/schema.ts b/app/common/schema.ts index bb60c11b..77d03af6 100644 --- a/app/common/schema.ts +++ b/app/common/schema.ts @@ -31,7 +31,7 @@ export const schema = { summarySourceCol : "Ref:_grist_Tables_column", displayCol : "Ref:_grist_Tables_column", visibleCol : "Ref:_grist_Tables_column", - recalcWhen : "Text", + recalcWhen : "Int", recalcDeps : "RefList:_grist_Tables_column", }, @@ -204,8 +204,8 @@ export interface SchemaTypes { summarySourceCol: number; displayCol: number; visibleCol: number; - recalcWhen: string; - recalcDeps: number[]; + recalcWhen: number; + recalcDeps: ['L', ...number[]]|null; }; "_grist_Imports": { diff --git a/sandbox/gen_js_schema.py b/sandbox/gen_js_schema.py index 7fe42797..24b51a47 100644 --- a/sandbox/gen_js_schema.py +++ b/sandbox/gen_js_schema.py @@ -16,7 +16,7 @@ _ts_types = { "Int": "number", "PositionNumber": "number", "Ref": "number", - "RefList": "number[]", + "RefList": "['L', ...number[]]|null", # Non-primitive values are encoded "Text": "string", } @@ -25,7 +25,7 @@ def get_ts_type(col_type): return _ts_types.get(col_type, "CellValue") def main(): - print(""" + print("""\ /*** THIS FILE IS AUTO-GENERATED BY %s ***/ // tslint:disable:object-literal-key-quotes diff --git a/sandbox/grist/usertypes.py b/sandbox/grist/usertypes.py index 5e4400b9..efeb666e 100644 --- a/sandbox/grist/usertypes.py +++ b/sandbox/grist/usertypes.py @@ -457,7 +457,8 @@ class ReferenceList(BaseColumnType): assert value._table.table_id == self.table_id return objtypes.RecordList(value._row_ids, group_by=value._group_by, sort_by=value._sort_by) elif not value: - return [] + # Represent an empty ReferenceList as None (also its default value). Formulas will see []. + return None return [Reference.do_convert(val) for val in value] @classmethod