gristlabs_grist-core/app/client/ui/TriggerFormulas.ts
Dmitry S b537539b73 (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
2021-06-29 10:24:16 -04:00

272 lines
9.4 KiB
TypeScript

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;
`);