mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user