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).
 | 
			
		||||
  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() {
 | 
			
		||||
 | 
			
		||||
@ -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<void> {
 | 
			
		||||
  public async convertIsFormula(colRefs: number[], opts: {toFormula: boolean, noRecalc?: boolean}): Promise<void> {
 | 
			
		||||
    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),
 | 
			
		||||
      }]
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -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', `
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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 { 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;
 | 
			
		||||
`);
 | 
			
		||||
 | 
			
		||||
@ -120,7 +120,7 @@ export const cssLabelText = styled('span', `
 | 
			
		||||
type CheckboxArg = DomArg<HTMLInputElement>;
 | 
			
		||||
 | 
			
		||||
function checkbox(
 | 
			
		||||
  obs: Observable<boolean>, cssCheckbox: typeof cssCheckboxSquare, label: string = '',  ...domArgs: CheckboxArg[]
 | 
			
		||||
  obs: Observable<boolean>, cssCheckbox: typeof cssCheckboxSquare, label: DomArg = '',  ...domArgs: CheckboxArg[]
 | 
			
		||||
) {
 | 
			
		||||
  return cssLabel(
 | 
			
		||||
    cssCheckbox(
 | 
			
		||||
@ -141,11 +141,11 @@ export function circleCheckbox(obs: Observable<boolean>, ...domArgs: CheckboxArg
 | 
			
		||||
  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);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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
 | 
			
		||||
 * 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",
 | 
			
		||||
    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": {
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user