(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

@@ -625,6 +625,30 @@ export class GristDoc extends DisposableWithEvents {
);
}
// Convert column to pure formula column.
public async convertToFormula(colRefs: number, formula: string): Promise<void> {
return this.docModel.columns.sendTableAction(
['UpdateRecord', colRefs, {
isFormula: true,
formula,
recalcWhen: RecalcWhen.DEFAULT,
recalcDeps: null,
}]
);
}
// Convert column to data column with a trigger formula
public async convertToTrigger(colRefs: number, formula: string): Promise<void> {
return this.docModel.columns.sendTableAction(
['UpdateRecord', colRefs, {
isFormula: false,
formula,
recalcWhen: RecalcWhen.DEFAULT,
recalcDeps: null,
}]
);
}
public getCsvLink() {
const filters = this.viewModel.activeSection.peek().filteredFields.get().map(field=> ({
colRef : field.colRef.peek(),

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'),
),
),

View File

@@ -38,6 +38,7 @@ export type IconName = "ChartArea" |
"Copy" |
"CrossBig" |
"CrossSmall" |
"Database" |
"Dots" |
"Download" |
"DragDrop" |
@@ -79,6 +80,7 @@ export type IconName = "ChartArea" |
"Repl" |
"ResizePanel" |
"RightAlign" |
"Script" |
"Search" |
"Settings" |
"Share" |
@@ -133,6 +135,7 @@ export const IconList: IconName[] = ["ChartArea",
"Copy",
"CrossBig",
"CrossSmall",
"Database",
"Dots",
"Download",
"DragDrop",
@@ -174,6 +177,7 @@ export const IconList: IconName[] = ["ChartArea",
"Repl",
"ResizePanel",
"RightAlign",
"Script",
"Search",
"Settings",
"Share",

View File

@@ -100,6 +100,16 @@ export const bigBasicButtonLink = tbind(button, null, {link: true, large: true})
export const primaryButtonLink = tbind(button, null, {link: true, primary: true});
export const bigPrimaryButtonLink = tbind(button, null, {link: true, large: true, primary: true});
// Button that looks like a link (have no background and no border).
export const textButton = styled(cssButton, `
border: none;
padding: 0px;
background-color: inherit !important;
&:disabled {
color: ${colors.inactiveCursor};
}
`);
const cssButtonLink = styled('a', `
display: inline-block;
&, &:hover, &:focus {

View File

@@ -1,14 +1,14 @@
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';
import {Computed, dom, DomElementArg, DomElementMethod, MaybeObsArray, MutableObsArray, Observable,
styled} from 'grainjs';
import { Command } from 'app/client/components/commands';
import { NeedUpgradeError, reportError } from 'app/client/models/errors';
import { cssCheckboxSquare, cssLabel, cssLabelText } from 'app/client/ui2018/checkbox';
import { colors, testId, vars } from 'app/client/ui2018/cssVars';
import { IconName } from 'app/client/ui2018/IconList';
import { icon } from 'app/client/ui2018/icons';
import { cssSelectBtn } from 'app/client/ui2018/select';
import { commonUrls } from 'app/common/gristUrls';
import { BindableValue, Computed, dom, DomElementArg, DomElementMethod, IDomArgs,
MaybeObsArray, MutableObsArray, Observable, styled } from 'grainjs';
import * as weasel from 'popweasel';
import {cssCheckboxSquare, cssLabel, cssLabelText} from 'app/client/ui2018/checkbox';
export interface IOptionFull<T> {
value: T;
@@ -304,6 +304,71 @@ export function autocomplete(
});
}
/**
* Creates simple (not reactive) static menu that looks like a select-box.
* Primary usage is for menus, where you want to control how the options and a default
* label will look. Label is not updated or changed when one of the option is clicked, for those
* use cases use a select component.
* Icons are optional, can use custom elements instead of labels and options.
*
* Usage:
*
* selectMenu(selectTitle("Title", "Script"), () => [
* selectOption(() => ..., "Option1", "Database"),
* selectOption(() => ..., "Option2", "Script"),
* ]);
*
* // Control disabled state (if the menu will be opened or not)
*
* const disabled = observable(false);
* selectMenu(selectTitle("Title", "Script"), () => [
* selectOption(() => ..., "Option1", "Database"),
* selectOption(() => ..., "Option2", "Script"),
* ], disabled);
*
*/
export function selectMenu(
label: DomElementArg,
items: () => DomElementArg[],
...args: IDomArgs<HTMLDivElement>
) {
return cssSelectBtn(
label,
icon('Dropdown'),
menu(
items,
{
...weasel.defaultMenuOptions,
menuCssClass: cssSelectMenuElem.className + ' grist-floating-menu',
stretchToSelector : `.${cssSelectBtn.className}`,
trigger : [(triggerElem, ctl) => {
const isDisabled = () => triggerElem.classList.contains('disabled');
dom.onElem(triggerElem, 'click', () => isDisabled() || ctl.toggle());
dom.onKeyElem(triggerElem as HTMLElement, 'keydown', {
ArrowDown: () => isDisabled() || ctl.open(),
ArrowUp: () => isDisabled() || ctl.open()
});
}]
},
),
...args,
);
}
export function selectTitle(label: BindableValue<string>, iconName?: BindableValue<IconName>) {
return cssOptionRow(
iconName ? dom.domComputed(iconName, (name) => cssOptionRowIcon(name)) : null,
dom.text(label)
);
}
export function selectOption(
action: (item: HTMLElement) => void,
label: BindableValue<string>,
iconName?: BindableValue<IconName>) {
return menuItem(action, selectTitle(label, iconName));
}
export const menuSubHeader = styled('div', `
font-size: ${vars.xsmallFontSize};
text-transform: uppercase;
@@ -404,13 +469,13 @@ const cssOptionIcon = styled(icon, `
margin: -3px 8px 0 2px;
`);
const cssOptionRow = styled('span', `
export const cssOptionRow = styled('span', `
display: flex;
align-items: center;
width: 100%;
`);
const cssOptionRowIcon = styled(cssOptionIcon, `
export const cssOptionRowIcon = styled(cssOptionIcon, `
margin: 0 8px 0 0;
flex: none;

View File

@@ -517,13 +517,22 @@ export class FieldBuilder extends Disposable {
/**
* Open the formula editor in the side pane. It will be positioned over refElem.
*/
public openSideFormulaEditor(editRow: DataRowModel, refElem: Element) {
public openSideFormulaEditor(
editRow: DataRowModel,
refElem: Element,
editValue?: string,
onSave?: (formula: string) => Promise<void>,
onCancel?: () => void) {
const editorHolder = openSideFormulaEditor({
gristDoc: this.gristDoc,
field: this.field,
editRow,
refElem,
editValue,
onSave,
onCancel
});
// Add editor to document holder - this will prevent multiple formula editor instances.
this.gristDoc.fieldEditorHolder.autoDispose(editorHolder);
}
}

View File

@@ -13,7 +13,7 @@ import {asyncOnce} from "app/common/AsyncCreate";
import {CellValue} from "app/common/DocActions";
import {isRaisedException} from 'app/common/gristTypes';
import * as gutil from 'app/common/gutil';
import {Disposable, Emitter, Holder, IDisposable, MultiHolder, Observable} from 'grainjs';
import {Disposable, Emitter, Holder, MultiHolder, Observable} from 'grainjs';
import isEqual = require('lodash/isEqual');
import { CellPosition } from "app/client/components/CellPosition";
@@ -380,7 +380,10 @@ export function openSideFormulaEditor(options: {
field: ViewFieldRec,
editRow: DataRowModel, // Needed to get exception value, if any.
refElem: Element, // Element in the side pane over which to position the editor.
}): IDisposable {
editValue?: string,
onSave?: (formula: string) => Promise<void>,
onCancel?: () => void,
}): Disposable {
const {gristDoc, field, editRow, refElem} = options;
const holder = MultiHolder.create(null);
const column = field.column();
@@ -388,17 +391,19 @@ export function openSideFormulaEditor(options: {
// AsyncOnce ensures it's called once even if triggered multiple times.
const saveEdit = asyncOnce(async () => {
const formula = editor.getCellValue();
if (formula !== column.formula.peek()) {
if (options.onSave) {
await options.onSave(formula as string);
} else if (formula !== column.formula.peek()) {
await column.updateColValues({formula});
}
holder.dispose(); // Deactivate the editor.
holder.dispose();
});
// These are the commands for while the editor is active.
const editCommands = {
fieldEditSave: () => { saveEdit().catch(reportError); },
fieldEditSaveHere: () => { saveEdit().catch(reportError); },
fieldEditCancel: () => { holder.dispose(); },
fieldEditCancel: () => { holder.dispose(); options.onCancel?.(); },
};
// Replace the item in the Holder with a new one, disposing the previous one.
@@ -407,7 +412,7 @@ export function openSideFormulaEditor(options: {
field,
cellValue: column.formula(),
formulaError: getFormulaError(gristDoc, editRow, column),
editValue: undefined,
editValue: options.editValue,
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
commands: editCommands,
cssClass: 'formula_editor_sidepane',