(core) Formula UI redesign

Summary:
Redesigning column type section to make it more user-friendly. Introducing column behavior concept.
Column can be either:
- Empty Formula Column: initial state (user can convert to Formula/Data Column)
- Data Column: non formula column with or without trigger (with option to add trigger, or convert to formula)
- Formula Column: pure formula column, with an option to convert to data column with a trigger.

Test Plan: Existing tests.

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D3092
This commit is contained in:
Jarosław Sadziński
2021-11-05 11:25:05 +01:00
parent 877542225d
commit e8e614c584
12 changed files with 532 additions and 126 deletions

View File

@@ -1,17 +1,20 @@
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 {CursorPos} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc';
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
import {cssEmptySeparator, cssLabel, cssRow} from 'app/client/ui/RightPanel';
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 {textButton} from 'app/client/ui2018/buttons';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {textInput} from 'app/client/ui2018/editableLabel';
import {cssIconButton, icon} from 'app/client/ui2018/icons';
import {menu, menuItem} from 'app/client/ui2018/menus';
import {selectMenu, selectOption, selectTitle} from 'app/client/ui2018/menus';
import {sanitizeIdent} from 'app/common/gutil';
import {Computed, dom, fromKo, MultiHolder, Observable, styled, subscribe} from 'grainjs';
import {bundleChanges, Computed, dom, DomContents, DomElementArg, fromKo, MultiHolder, Observable,
styled, subscribe} from 'grainjs';
import * as ko from 'knockout';
import debounce = require('lodash/debounce');
import {IconName} from 'app/client/ui2018/IconList';
export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, cursor: ko.Computed<CursorPos>) {
const untieColId = origColumn.untieColIdFromLabel;
@@ -63,86 +66,237 @@ export function buildNameConfig(owner: MultiHolder, origColumn: ColumnRec, curso
];
}
type BuildEditor = (cellElem: Element) => void;
type BuildEditor = (
cellElem: Element,
editValue?: string,
onSave?: (formula: string) => Promise<void>,
onCancel?: () => void) => void;
type BEHAVIOR = "empty"|"formula"|"data";
export function buildFormulaConfig(
owner: MultiHolder, origColumn: ColumnRec, gristDoc: GristDoc, buildEditor: BuildEditor
) {
const clearColumn = () => gristDoc.clearColumns([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 => {
if (!use(origColumn.id)) { return null; } // Invalid column, show nothing.
if (use(origColumn.isEmpty)) { return "empty"; }
return use(origColumn.isFormula) ? "formula" : "data";
},
(type: "empty"|"formula"|"data") => {
function buildHeader(label: string, menuFunc: () => Element[]) {
return cssRow(
cssInlineLabel(label,
testId('field-is-formula-label'),
),
cssDropdownLabel('Actions', icon('Dropdown'), menu(menuFunc),
cssDropdownLabel.cls('-disabled', origColumn.disableModify),
testId('field-actions-menu'),
)
);
}
function buildFormulaRow(placeholder = 'Enter formula') {
return [
cssRow(dom.create(buildFormula, origColumn, buildEditor, placeholder)),
dom.maybe(errorMessage, errMsg => cssRow(cssError(errMsg), testId('field-error-count'))),
];
}
if (type === "empty") {
return [
buildHeader('EMPTY COLUMN', () => [
menuItem(clearColumn, 'Clear column', dom.cls('disabled', true)),
menuItem(() => convertIsFormula({toFormula: false}), 'Make into data column'),
]),
buildFormulaRow(),
];
} else if (type === "formula") {
return [
buildHeader('FORMULA COLUMN', () => [
menuItem(clearColumn, 'Clear column'),
menuItem(() => convertIsFormula({toFormula: false, noRecalc: true}), 'Convert to data column'),
]),
buildFormulaRow(),
];
} else {
return [
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.')
)
];
}
// Intermediate state - user wants to specify formula, but haven't done yet
const maybeFormula = Observable.create(owner, false);
// Intermediate state - user wants to specify formula, but haven't done yet
const maybeTrigger = Observable.create(owner, false);
// Column behaviour. There are 3 types of behaviors:
// - empty: isFormula and formula == ''
// - formula: isFormula and formula != ''
// - data: not isFormula nd formula == ''
const behavior = Computed.create<BEHAVIOR|null>(owner, (use) => {
// When no id column is invalid, show nothing.
if (!use(origColumn.id)) { return null; }
// Column is a formula column, when it is a formula column with valid formula or will be a formula.
if (use(origColumn.isRealFormula) || use(maybeFormula)) { return "formula"; }
// If column is not empty, or empty but wants to be a trigger
if (use(maybeTrigger) || !use(origColumn.isEmpty)) { return "data"; }
return "empty";
});
// Reference to current editor, we will open it when user wants to specify a formula or trigger.
// And close it dispose it when user opens up behavior menu.
let formulaField: HTMLElement|null = null;
// Helper function to clear temporary state (will be called when column changes or formula editor closes)
const clearState = () => bundleChanges(() => {
maybeFormula.set(false);
maybeTrigger.set(false);
formulaField = null;
});
// Clear state when column has changed
owner.autoDispose(origColumn.id.subscribe(clearState));
// Menu helper that will show normal menu with some default options
const menu = (label: DomContents, options: DomElementArg[]) =>
cssRow(
selectMenu(
label,
() => options,
testId("field-behaviour"),
// HACK: Menu helper will add tabindex to this element, which will make
// this element focusable and will steal focus from clipboard. This in turn,
// will not dispose the formula editor when menu is clicked.
(el) => el.removeAttribute("tabindex"),
dom.cls("disabled", origColumn.disableModify)),
);
// Behaviour label
const behaviorName = Computed.create(owner, behavior, (use, type) => {
if (type === 'formula') { return "Formula Column"; }
if (type === 'data') { return "Data Column"; }
return "Empty Column";
});
const behaviorIcon = Computed.create<IconName>(owner, (use) => {
return use(behaviorName) === "Data Column" ? "Database" : "Script";
});
const behaviourLabel = () => selectTitle(behaviorName, behaviorIcon);
// Actions on select menu:
// Converts data column to formula column.
const convertDataColumnToFormulaOption = () => selectOption(
() => (maybeFormula.set(true), formulaField?.focus()),
'Clear and make into formula', 'Script');
// Converts to empty column and opens up the editor. (label is the same, but this is used when we have no formula)
const convertTriggerToFormulaOption = () => selectOption(
() => gristDoc.convertIsFormula([origColumn.id.peek()], {toFormula: true, noRecalc: true}),
'Clear and make into formula', 'Script');
// Convert column to data.
// This method is also available through a text button.
const convertToData = () => gristDoc.convertIsFormula([origColumn.id.peek()], {toFormula: false, noRecalc: true});
const convertToDataOption = () => selectOption(
convertToData,
'Convert column to data', 'Database');
// Clears the column
const clearAndResetOption = () => selectOption(
() => gristDoc.clearColumns([origColumn.id.peek()]),
'Clear and reset', 'CrossSmall');
// Actions on text buttons:
// Tries to convert data column to a trigger column.
const convertDataColumnToTriggerColumn = () => {
maybeTrigger.set(true);
// Open the formula editor.
formulaField?.focus();
};
// Converts formula column to trigger formula column.
const convertFormulaToTrigger = () =>
gristDoc.convertIsFormula([origColumn.id.peek()], {toFormula: false, noRecalc: false});
const setFormula = () => (maybeFormula.set(true), formulaField?.focus());
const setTrigger = () => (maybeTrigger.set(true), formulaField?.focus());
// Actions on save formula
// Converts column to formula column or updates formula on a formula column.
const onSaveConvertToFormula = async (formula: string) => {
const notBlank = Boolean(formula);
const trueFormula = !maybeFormula.get();
if (notBlank || trueFormula) { await gristDoc.convertToFormula(origColumn.id.peek(), formula); }
clearState();
};
// Updates formula or convert column to trigger formula column if necessary.
const onSaveConvertToTrigger = async (formula: string) => {
if (formula && maybeTrigger.get()) {
// Convert column to trigger
await gristDoc.convertToTrigger(origColumn.id.peek(), formula);
} else if (origColumn.hasTriggerFormula.peek()) {
// This is true trigger formula, just update the formula (or make it blank)
await origColumn.formula.setAndSave(formula);
}
);
clearState();
};
const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn);
// Helper that will create different flavors for formula builder.
const formulaBuilder = (onSave: (formula: string) => Promise<void>) => [
cssRow(formulaField = buildFormula(
origColumn,
buildEditor,
"Enter formula",
onSave,
clearState)),
dom.maybe(errorMessage, errMsg => cssRow(cssError(errMsg), testId('field-error-count'))),
];
return dom.maybe(behavior, (type: BEHAVIOR) => [
cssLabel('COLUMN BEHAVIOR'),
...(type === "empty" ? [
menu(behaviourLabel(), [
convertToDataOption()
]),
cssEmptySeparator(),
cssRow(textButton(
"Set formula",
dom.on("click", setFormula),
dom.prop("disabled", origColumn.disableModify),
testId("field-set-formula")
)),
cssRow(textButton(
"Set trigger formula",
dom.on("click", setTrigger),
dom.prop("disabled", origColumn.disableModify),
testId("field-set-trigger")
)),
cssRow(textButton(
"Make into data column",
dom.on("click", convertToData),
dom.prop("disabled", origColumn.disableModify),
testId("field-set-data")
))
] : type === "formula" ? [
menu(behaviourLabel(), [
convertToDataOption(),
clearAndResetOption(),
]),
formulaBuilder(onSaveConvertToFormula),
cssEmptySeparator(),
cssRow(textButton(
"Convert to trigger formula",
dom.on("click", convertFormulaToTrigger),
dom.hide(maybeFormula),
dom.prop("disabled", origColumn.disableModify),
testId("field-set-trigger")
))
] : /* type == 'data' */ [
menu(behaviourLabel(),
[
dom.domComputed(origColumn.hasTriggerFormula, (hasTrigger) => hasTrigger ?
// If we have trigger, we will convert it directly to a formula column
convertTriggerToFormulaOption() :
// else we will convert to empty column and open up the editor
convertDataColumnToFormulaOption()
),
clearAndResetOption(),
]
),
// If data column is or wants to be a trigger formula:
dom.maybe((use) => use(maybeTrigger) || use(origColumn.hasTriggerFormula), () => [
cssLabel('TRIGGER FORMULA'),
formulaBuilder(onSaveConvertToTrigger),
dom.create(buildFormulaTriggers, origColumn, maybeTrigger)
]),
// Else offer a way to convert to trigger formula.
dom.maybe((use) => !(use(maybeTrigger) || use(origColumn.hasTriggerFormula)), () => [
cssEmptySeparator(),
cssRow(textButton(
"Set trigger formula",
dom.on("click", convertDataColumnToTriggerColumn),
dom.prop("disabled", origColumn.disableModify),
testId("field-set-trigger")
))
])
])
]);
}
function buildFormula(owner: MultiHolder, column: ColumnRec, buildEditor: BuildEditor, placeholder: string) {
function buildFormula(
column: ColumnRec,
buildEditor: BuildEditor,
placeholder: string,
onSave?: (formula: string) => Promise<void>,
onCancel?: () => void) {
return cssFieldFormula(column.formula, {placeholder, maxLines: 2},
dom.cls('formula_field_sidepane'),
cssFieldFormula.cls('-disabled', column.disableModify),
cssFieldFormula.cls('-disabled-icon', use => !use(column.formula)),
dom.cls('disabled'),
{tabIndex: '-1'},
dom.on('focus', (ev, elem) => buildEditor(elem)),
// Focus event use used by a user to edit an existing formula.
// It can also be triggered manually to open up the editor.
dom.on('focus', (_, elem) => buildEditor(elem, undefined, onSave, onCancel)),
);
}
@@ -223,36 +377,6 @@ const cssToggleButton = styled(cssIconButton, `
}
`);
const cssInlineLabel = styled(cssLabel, `
padding: 4px 8px;
margin: 4px 0 -4px -8px;
`);
const cssDropdownLabel = styled(cssInlineLabel, `
margin-left: auto;
display: flex;
align-items: center;
border-radius: ${vars.controlBorderRadius};
cursor: pointer;
color: ${colors.lightGreen};
--icon-color: ${colors.lightGreen};
&:hover, &:focus, &.weasel-popup-open {
background-color: ${colors.mediumGrey};
}
&-disabled {
color: ${colors.slate};
--icon-color: ${colors.slate};
pointer-events: none;
}
`);
const cssHintRow = styled('div', `
margin: -4px 16px 8px 16px;
color: ${colors.slate};
`);
const cssColLabelBlock = styled('div', `
display: flex;
flex-direction: column;

View File

@@ -230,11 +230,19 @@ export class RightPanel extends Disposable {
}
// Helper to activate the side-pane formula editor over the given HTML element.
private _activateFormulaEditor(refElem: Element) {
private _activateFormulaEditor(
// Element to attach to.
refElem: Element,
// Simulate user typing on the cell - open editor with an initial value.
editValue?: string,
// Custom save handler.
onSave?: (formula: string) => Promise<void>,
// Custom cancel handler.
onCancel?: () => void,) {
const vsi = this._gristDoc.viewModel.activeSection().viewInstance();
if (!vsi) { return; }
const editRowModel = vsi.moveEditRowToCursor();
vsi.activeFieldBuilder.peek().openSideFormulaEditor(editRowModel, refElem);
return vsi.activeFieldBuilder.peek().openSideFormulaEditor(editRowModel, refElem, editValue, onSave, onCancel);
}
private _buildPageWidgetContent(_owner: MultiHolder) {
@@ -657,6 +665,10 @@ export const cssSeparator = styled('div', `
margin-top: 16px;
`);
export const cssEmptySeparator = styled('div', `
margin-top: 16px;
`);
const cssConfigContainer = styled('div', `
overflow: auto;
--color-list-item: none;

View File

@@ -20,7 +20,7 @@ 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) {
export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec, disable: Observable<boolean>|null = null) {
// 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
@@ -79,7 +79,7 @@ export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec) {
labeledSquareCheckbox(
applyToNew,
'Apply to new records',
dom.boolAttr('disabled', applyOnChanges),
dom.boolAttr('disabled', (use) => (disable && use(disable)) || use(applyOnChanges)),
testId('field-formula-apply-to-new'),
),
),
@@ -90,6 +90,7 @@ export function buildFormulaTriggers(owner: MultiHolder, column: ColumnRec) {
'Apply on changes to:' :
'Apply on record changes'
),
dom.boolAttr('disabled', (use) => disable ? use(disable) : false),
testId('field-formula-apply-on-changes'),
),
),