mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(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
This commit is contained in:
		
							parent
							
								
									e180641c7d
								
							
						
					
					
						commit
						b537539b73
					
				@ -529,7 +529,7 @@ GridView.prototype._convertFormulasToData = function(selection) {
 | 
				
			|||||||
  // prevented by ACL rules).
 | 
					  // prevented by ACL rules).
 | 
				
			||||||
  const fields = selection.fields.filter(f => f.column.peek().isFormula.peek());
 | 
					  const fields = selection.fields.filter(f => f.column.peek().isFormula.peek());
 | 
				
			||||||
  if (!fields.length) { return null; }
 | 
					  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() {
 | 
					GridView.prototype.selectAll = function() {
 | 
				
			||||||
 | 
				
			|||||||
@ -42,6 +42,7 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
 | 
				
			|||||||
import {isSchemaAction} from 'app/common/DocActions';
 | 
					import {isSchemaAction} from 'app/common/DocActions';
 | 
				
			||||||
import {OpenLocalDocResult} from 'app/common/DocListAPI';
 | 
					import {OpenLocalDocResult} from 'app/common/DocListAPI';
 | 
				
			||||||
import {HashLink, IDocPage} from 'app/common/gristUrls';
 | 
					import {HashLink, IDocPage} from 'app/common/gristUrls';
 | 
				
			||||||
 | 
					import {RecalcWhen} from 'app/common/gristTypes';
 | 
				
			||||||
import {encodeQueryParams, waitObs} from 'app/common/gutil';
 | 
					import {encodeQueryParams, waitObs} from 'app/common/gutil';
 | 
				
			||||||
import {StringUnion} from 'app/common/StringUnion';
 | 
					import {StringUnion} from 'app/common/StringUnion';
 | 
				
			||||||
import {TableData} from 'app/common/TableData';
 | 
					import {TableData} from 'app/common/TableData';
 | 
				
			||||||
@ -572,16 +573,20 @@ export class GristDoc extends DisposableWithEvents {
 | 
				
			|||||||
      ['BulkUpdateRecord', colRefs, {
 | 
					      ['BulkUpdateRecord', colRefs, {
 | 
				
			||||||
        isFormula: colRefs.map(f => true),
 | 
					        isFormula: colRefs.map(f => true),
 | 
				
			||||||
        formula: colRefs.map(f => ''),
 | 
					        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.
 | 
					  // Convert the given columns to data, saving the calculated values and unsetting the formulas.
 | 
				
			||||||
  public async convertFormulasToData(colRefs: number[]): Promise<void> {
 | 
					  public async convertIsFormula(colRefs: number[], opts: {toFormula: boolean, noRecalc?: boolean}): Promise<void> {
 | 
				
			||||||
    return this.docModel.columns.sendTableAction(
 | 
					    return this.docModel.columns.sendTableAction(
 | 
				
			||||||
      ['BulkUpdateRecord', colRefs, {
 | 
					      ['BulkUpdateRecord', colRefs, {
 | 
				
			||||||
        isFormula: colRefs.map(f => false),
 | 
					        isFormula: colRefs.map(f => opts.toFormula),
 | 
				
			||||||
        formula: colRefs.map(f => ''),
 | 
					        recalcWhen: colRefs.map(f => opts.noRecalc ? RecalcWhen.NEVER : RecalcWhen.DEFAULT),
 | 
				
			||||||
 | 
					        recalcDeps: colRefs.map(f => null),
 | 
				
			||||||
      }]
 | 
					      }]
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
				
			|||||||
@ -2,6 +2,7 @@ import type {GristDoc} from 'app/client/components/GristDoc';
 | 
				
			|||||||
import type {ColumnRec} from 'app/client/models/entities/ColumnRec';
 | 
					import type {ColumnRec} from 'app/client/models/entities/ColumnRec';
 | 
				
			||||||
import type {CursorPos} from "app/client/components/Cursor";
 | 
					import type {CursorPos} from "app/client/components/Cursor";
 | 
				
			||||||
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
 | 
					import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
 | 
				
			||||||
 | 
					import {buildFormulaTriggers} from 'app/client/ui/TriggerFormulas';
 | 
				
			||||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
 | 
					import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
 | 
				
			||||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
 | 
					import {colors, testId, vars} from 'app/client/ui2018/cssVars';
 | 
				
			||||||
import {textInput} from 'app/client/ui2018/editableLabel';
 | 
					import {textInput} from 'app/client/ui2018/editableLabel';
 | 
				
			||||||
@ -68,7 +69,8 @@ export function buildFormulaConfig(
 | 
				
			|||||||
  owner: MultiHolder, origColumn: ColumnRec, gristDoc: GristDoc, buildEditor: BuildEditor
 | 
					  owner: MultiHolder, origColumn: ColumnRec, gristDoc: GristDoc, buildEditor: BuildEditor
 | 
				
			||||||
) {
 | 
					) {
 | 
				
			||||||
  const clearColumn = () => gristDoc.clearColumns([origColumn.id.peek()]);
 | 
					  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);
 | 
					  const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return dom.maybe(use => {
 | 
					  return dom.maybe(use => {
 | 
				
			||||||
@ -98,7 +100,7 @@ export function buildFormulaConfig(
 | 
				
			|||||||
        return [
 | 
					        return [
 | 
				
			||||||
          buildHeader('EMPTY COLUMN', () => [
 | 
					          buildHeader('EMPTY COLUMN', () => [
 | 
				
			||||||
            menuItem(clearColumn, 'Clear column', dom.cls('disabled', true)),
 | 
					            menuItem(clearColumn, 'Clear column', dom.cls('disabled', true)),
 | 
				
			||||||
            menuItem(convertToData, 'Make into data column'),
 | 
					            menuItem(() => convertIsFormula({toFormula: false}), 'Make into data column'),
 | 
				
			||||||
          ]),
 | 
					          ]),
 | 
				
			||||||
          buildFormulaRow(),
 | 
					          buildFormulaRow(),
 | 
				
			||||||
        ];
 | 
					        ];
 | 
				
			||||||
@ -106,17 +108,27 @@ export function buildFormulaConfig(
 | 
				
			|||||||
        return [
 | 
					        return [
 | 
				
			||||||
          buildHeader('FORMULA COLUMN', () => [
 | 
					          buildHeader('FORMULA COLUMN', () => [
 | 
				
			||||||
            menuItem(clearColumn, 'Clear column'),
 | 
					            menuItem(clearColumn, 'Clear column'),
 | 
				
			||||||
            menuItem(convertToData, 'Convert to data column'),
 | 
					            menuItem(() => convertIsFormula({toFormula: false, noRecalc: true}), 'Convert to data column'),
 | 
				
			||||||
          ]),
 | 
					          ]),
 | 
				
			||||||
          buildFormulaRow(),
 | 
					          buildFormulaRow(),
 | 
				
			||||||
        ];
 | 
					        ];
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        return [
 | 
					        return [
 | 
				
			||||||
          buildHeader('DATA COLUMN', () => [
 | 
					          buildHeader('DATA COLUMN', () => {
 | 
				
			||||||
            menuItem(clearColumn, 'Clear and make into formula'),
 | 
					            return origColumn.formula.peek() ? [
 | 
				
			||||||
          ]),
 | 
					              // If there is a formula available, offer a separate option to convert to formula
 | 
				
			||||||
          buildFormulaRow('Default formula'),
 | 
					              // without clearing it.
 | 
				
			||||||
          cssHintRow('Default formula for new records'),
 | 
					              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', `
 | 
					const cssHintRow = styled('div', `
 | 
				
			||||||
  margin: -4px 16px 8px 16px;
 | 
					  margin: -4px 16px 8px 16px;
 | 
				
			||||||
  color: ${colors.slate};
 | 
					  color: ${colors.slate};
 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const cssColLabelBlock = styled('div', `
 | 
					const cssColLabelBlock = styled('div', `
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										271
									
								
								app/client/ui/TriggerFormulas.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										271
									
								
								app/client/ui/TriggerFormulas.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<void>
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  // 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;
 | 
				
			||||||
 | 
					`);
 | 
				
			||||||
@ -3,6 +3,7 @@ import { colors, testId, vars } from 'app/client/ui2018/cssVars';
 | 
				
			|||||||
import { textInput } from "app/client/ui2018/editableLabel";
 | 
					import { textInput } from "app/client/ui2018/editableLabel";
 | 
				
			||||||
import { icon } from "app/client/ui2018/icons";
 | 
					import { icon } from "app/client/ui2018/icons";
 | 
				
			||||||
import { isValidHex } from "app/common/gutil";
 | 
					import { isValidHex } from "app/common/gutil";
 | 
				
			||||||
 | 
					import { cssSelectBtn } from 'app/client/ui2018/select';
 | 
				
			||||||
import { Computed, Disposable, dom, DomArg, Observable, onKeyDown, styled } from "grainjs";
 | 
					import { Computed, Disposable, dom, DomArg, Observable, onKeyDown, styled } from "grainjs";
 | 
				
			||||||
import { defaultMenuOptions, IOpenController, setPopupToCreateDom } from "popweasel";
 | 
					import { defaultMenuOptions, IOpenController, setPopupToCreateDom } from "popweasel";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -315,17 +316,5 @@ const cssColorSquare = styled('div', `
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const cssButtonIcon = styled(cssColorSquare, `
 | 
					const cssButtonIcon = styled(cssColorSquare, `
 | 
				
			||||||
  margin-right: 6px;
 | 
					  margin-right: 6px;
 | 
				
			||||||
`);
 | 
					  margin-left: 4px;
 | 
				
			||||||
 | 
					 | 
				
			||||||
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;
 | 
					 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
				
			|||||||
@ -120,7 +120,7 @@ export const cssLabelText = styled('span', `
 | 
				
			|||||||
type CheckboxArg = DomArg<HTMLInputElement>;
 | 
					type CheckboxArg = DomArg<HTMLInputElement>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function checkbox(
 | 
					function checkbox(
 | 
				
			||||||
  obs: Observable<boolean>, cssCheckbox: typeof cssCheckboxSquare, label: string = '',  ...domArgs: CheckboxArg[]
 | 
					  obs: Observable<boolean>, cssCheckbox: typeof cssCheckboxSquare, label: DomArg = '',  ...domArgs: CheckboxArg[]
 | 
				
			||||||
) {
 | 
					) {
 | 
				
			||||||
  return cssLabel(
 | 
					  return cssLabel(
 | 
				
			||||||
    cssCheckbox(
 | 
					    cssCheckbox(
 | 
				
			||||||
@ -141,11 +141,11 @@ export function circleCheckbox(obs: Observable<boolean>, ...domArgs: CheckboxArg
 | 
				
			|||||||
  return checkbox(obs, cssCheckboxCircle, '', ...domArgs);
 | 
					  return checkbox(obs, cssCheckboxCircle, '', ...domArgs);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function labeledSquareCheckbox(obs: Observable<boolean>, label: string, ...domArgs: CheckboxArg[]) {
 | 
					export function labeledSquareCheckbox(obs: Observable<boolean>, label: DomArg, ...domArgs: CheckboxArg[]) {
 | 
				
			||||||
  return checkbox(obs, cssCheckboxSquare, label, ...domArgs);
 | 
					  return checkbox(obs, cssCheckboxSquare, label, ...domArgs);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function labeledCircleCheckbox(obs: Observable<boolean>, label: string, ...domArgs: CheckboxArg[]) {
 | 
					export function labeledCircleCheckbox(obs: Observable<boolean>, label: DomArg, ...domArgs: CheckboxArg[]) {
 | 
				
			||||||
  return checkbox(obs, cssCheckboxCircle, label, ...domArgs);
 | 
					  return checkbox(obs, cssCheckboxCircle, label, ...domArgs);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,6 +1,7 @@
 | 
				
			|||||||
import {Command} from 'app/client/components/commands';
 | 
					import {Command} from 'app/client/components/commands';
 | 
				
			||||||
import {NeedUpgradeError, reportError} from 'app/client/models/errors';
 | 
					import {NeedUpgradeError, reportError} from 'app/client/models/errors';
 | 
				
			||||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
 | 
					import {colors, testId, vars} from 'app/client/ui2018/cssVars';
 | 
				
			||||||
 | 
					import {cssSelectBtn} from 'app/client/ui2018/select';
 | 
				
			||||||
import {IconName} from 'app/client/ui2018/IconList';
 | 
					import {IconName} from 'app/client/ui2018/IconList';
 | 
				
			||||||
import {icon} from 'app/client/ui2018/icons';
 | 
					import {icon} from 'app/client/ui2018/icons';
 | 
				
			||||||
import {commonUrls} from 'app/common/gristUrls';
 | 
					import {commonUrls} from 'app/common/gristUrls';
 | 
				
			||||||
@ -280,24 +281,6 @@ const cssSelectBtnContainer = styled('div', `
 | 
				
			|||||||
  width: 100%;
 | 
					  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', `
 | 
					const cssSelectBtnLink = styled('div', `
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  align-items: center;
 | 
					  align-items: center;
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										48
									
								
								app/client/ui2018/select.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								app/client/ui2018/select.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					`);
 | 
				
			||||||
@ -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
 | 
					 * 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
 | 
					 * Grist type. The list of types is based on an analysis of SQL type string outputs
 | 
				
			||||||
 | 
				
			|||||||
@ -31,7 +31,7 @@ export const schema = {
 | 
				
			|||||||
    summarySourceCol    : "Ref:_grist_Tables_column",
 | 
					    summarySourceCol    : "Ref:_grist_Tables_column",
 | 
				
			||||||
    displayCol          : "Ref:_grist_Tables_column",
 | 
					    displayCol          : "Ref:_grist_Tables_column",
 | 
				
			||||||
    visibleCol          : "Ref:_grist_Tables_column",
 | 
					    visibleCol          : "Ref:_grist_Tables_column",
 | 
				
			||||||
    recalcWhen          : "Text",
 | 
					    recalcWhen          : "Int",
 | 
				
			||||||
    recalcDeps          : "RefList:_grist_Tables_column",
 | 
					    recalcDeps          : "RefList:_grist_Tables_column",
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -204,8 +204,8 @@ export interface SchemaTypes {
 | 
				
			|||||||
    summarySourceCol: number;
 | 
					    summarySourceCol: number;
 | 
				
			||||||
    displayCol: number;
 | 
					    displayCol: number;
 | 
				
			||||||
    visibleCol: number;
 | 
					    visibleCol: number;
 | 
				
			||||||
    recalcWhen: string;
 | 
					    recalcWhen: number;
 | 
				
			||||||
    recalcDeps: number[];
 | 
					    recalcDeps: ['L', ...number[]]|null;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  "_grist_Imports": {
 | 
					  "_grist_Imports": {
 | 
				
			||||||
 | 
				
			|||||||
@ -16,7 +16,7 @@ _ts_types = {
 | 
				
			|||||||
  "Int":            "number",
 | 
					  "Int":            "number",
 | 
				
			||||||
  "PositionNumber": "number",
 | 
					  "PositionNumber": "number",
 | 
				
			||||||
  "Ref":            "number",
 | 
					  "Ref":            "number",
 | 
				
			||||||
  "RefList":        "number[]",
 | 
					  "RefList":        "['L', ...number[]]|null",    # Non-primitive values are encoded
 | 
				
			||||||
  "Text":           "string",
 | 
					  "Text":           "string",
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -25,7 +25,7 @@ def get_ts_type(col_type):
 | 
				
			|||||||
  return _ts_types.get(col_type, "CellValue")
 | 
					  return _ts_types.get(col_type, "CellValue")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def main():
 | 
					def main():
 | 
				
			||||||
  print("""
 | 
					  print("""\
 | 
				
			||||||
/*** THIS FILE IS AUTO-GENERATED BY %s ***/
 | 
					/*** THIS FILE IS AUTO-GENERATED BY %s ***/
 | 
				
			||||||
// tslint:disable:object-literal-key-quotes
 | 
					// tslint:disable:object-literal-key-quotes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -457,7 +457,8 @@ class ReferenceList(BaseColumnType):
 | 
				
			|||||||
      assert value._table.table_id == self.table_id
 | 
					      assert value._table.table_id == self.table_id
 | 
				
			||||||
      return objtypes.RecordList(value._row_ids, group_by=value._group_by, sort_by=value._sort_by)
 | 
					      return objtypes.RecordList(value._row_ids, group_by=value._group_by, sort_by=value._sort_by)
 | 
				
			||||||
    elif not value:
 | 
					    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]
 | 
					    return [Reference.do_convert(val) for val in value]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @classmethod
 | 
					  @classmethod
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user