gristlabs_grist-core/app/client/widgets/FormulaEditor.ts
Dmitry S b4cc519616 (core) Ignore leading whitespace in formulas, and strip out leading '=' sign users might add
Summary:
This addresses two issues, differently:
- For a formula with leading whitespace, like " 1+1", it is stored as is, but
  is fixed to work (it should be valid Python, and whitespace is only stripped out
  at parsing time to avoid intentation errors caused by the way it gets parsed)
- For a formula with a leading equals-sign ("="), it is stripped out on the
  client side before the formula is stored. Grist documentation uses leading
  "=" to indicate formulas (because UI shows an "=" icon), and Excel formulas
  actually contain the leading "=", so it is a common mistake to include it.

Test Plan: Added new test cases

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3873
2023-04-25 15:28:40 -04:00

437 lines
18 KiB
TypeScript

import * as AceEditor from 'app/client/components/AceEditor';
import {createGroup} from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {colors, testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
import {undef} from 'app/common/gutil';
import {Computed, Disposable, dom, MultiHolder, Observable, styled, subscribe} from 'grainjs';
import {isRaisedException} from "app/common/gristTypes";
import {decodeObject, RaisedException} from "app/plugin/objtypes";
import {GristDoc} from 'app/client/components/GristDoc';
import {ColumnRec} from 'app/client/models/DocModel';
import {asyncOnce} from 'app/common/AsyncCreate';
import {reportError} from 'app/client/models/errors';
import {CellValue} from 'app/common/DocActions';
import debounce = require('lodash/debounce');
// How wide to expand the FormulaEditor when an error is shown in it.
const minFormulaErrorWidth = 400;
const t = makeT('FormulaEditor');
export interface IFormulaEditorOptions extends Options {
cssClass?: string;
editingFormula: ko.Computed<boolean>,
column: ColumnRec,
}
/**
* Required parameters:
* @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.
* @param {Object} options.cellValue: The value in the underlying cell being edited.
* @param {String} options.editValue: String to be edited.
* @param {Number} options.cursorPos: The initial position where to place the cursor.
* @param {Object} options.commands: Object mapping command names to functions, to enable as part
* of the command group that should be activated while the editor exists.
* @param {Boolean} options.omitBlurEventForObservableMode: Flag to indicate whether ace editor
* should save the value on `blur` event.
*/
export class FormulaEditor extends NewBaseEditor {
private _formulaEditor: any;
private _commandGroup: any;
private _dom: HTMLElement;
private _editorPlacement!: EditorPlacement;
constructor(options: IFormulaEditorOptions) {
super(options);
const editingFormula = options.editingFormula;
const initialValue = undef(options.state as string | undefined, options.editValue, String(options.cellValue));
// create editor state observable (used by draft and latest position memory)
this.editorState = Observable.create(this, initialValue);
this._formulaEditor = AceEditor.create({
// A bit awkward, but we need to assume calcSize is not used until attach() has been called
// and _editorPlacement created.
column: options.column,
calcSize: this._calcSize.bind(this),
gristDoc: options.gristDoc,
saveValueOnBlurEvent: !options.readonly,
editorState : this.editorState,
readonly: options.readonly
});
const allCommands = !options.readonly
? Object.assign({ setCursor: this._onSetCursor }, options.commands)
// for readonly mode don't grab cursor when clicked away - just move the cursor
: options.commands;
this._commandGroup = this.autoDispose(createGroup(allCommands, this, editingFormula));
const hideErrDetails = Observable.create(this, true);
const raisedException = Computed.create(this, use => {
if (!options.formulaError) {
return null;
}
const error = isRaisedException(use(options.formulaError)) ?
decodeObject(use(options.formulaError)) as RaisedException:
new RaisedException(["Unknown error"]);
return error;
});
const errorText = Computed.create(this, raisedException, (_, error) => {
if (!error) {
return "";
}
return error.message ? `${error.name} : ${error.message}` : error.name;
});
const errorDetails = Computed.create(this, raisedException, (_, error) => {
if (!error) {
return "";
}
return error.details ?? "";
});
// Once the exception details are available, update the sizing. The extra delay is to allow
// the DOM to update before resizing.
this.autoDispose(errorDetails.addListener(() => setTimeout(() => this._formulaEditor.resize(), 0)));
this.autoDispose(this._formulaEditor);
this._dom = dom('div.default_editor.formula_editor_wrapper',
// switch border shadow
dom.cls("readonly_editor", options.readonly),
createMobileButtons(options.commands),
options.cssClass ? dom.cls(options.cssClass) : null,
// This shouldn't be needed, but needed for tests.
dom.on('mousedown', (ev) => {
ev.preventDefault();
this._formulaEditor.getEditor().focus();
}),
dom('div.formula_editor.formula_field_edit', testId('formula-editor'),
this._formulaEditor.buildDom((aceObj: any) => {
aceObj.setFontSize(11);
aceObj.setHighlightActiveLine(false);
aceObj.getSession().setUseWrapMode(false);
aceObj.renderer.setPadding(0);
const val = initialValue;
const pos = Math.min(options.cursorPos, val.length);
this._formulaEditor.setValue(val, pos);
this._formulaEditor.attachCommandGroup(this._commandGroup);
// enable formula editing if state was passed
if (options.state || options.readonly) {
editingFormula(true);
}
if (options.readonly) {
this._formulaEditor.enable(false);
aceObj.gotoLine(0, 0); // By moving, ace editor won't highlight anything
}
// This catches any change to the value including e.g. via backspace or paste.
aceObj.once("change", () => editingFormula?.(true));
})
),
(options.formulaError ? [
dom('div.error_msg', testId('formula-error-msg'),
dom.on('click', () => {
if (errorDetails.get()){
hideErrDetails.set(!hideErrDetails.get());
this._formulaEditor.resize();
}
}),
dom.maybe(errorDetails, () =>
dom.domComputed(hideErrDetails, (hide) => cssCollapseIcon(hide ? 'Expand' : 'Collapse'))
),
dom.text(errorText),
),
dom.maybe(use => Boolean(use(errorDetails) && !use(hideErrDetails)), () =>
dom('div.error_details',
dom('div.error_details_inner',
dom.text(errorDetails),
),
testId('formula-error-details'),
)
)
] : null
)
);
}
public attach(cellElem: Element): void {
this._editorPlacement = EditorPlacement.create(this, this._dom, cellElem, {margins: getButtonMargins()});
// Reposition the editor if needed for external reasons (in practice, window resize).
this.autoDispose(this._editorPlacement.onReposition.addListener(
this._formulaEditor.resize, this._formulaEditor));
this._formulaEditor.onAttach();
this._formulaEditor.editor.focus();
}
public getDom(): HTMLElement {
return this._dom;
}
public getCellValue() {
const value = this._formulaEditor.getValue();
// Strip the leading "=" sign, if any, in case users think it should start the formula body (as
// it does in Excel, and because the equal sign is also used for formulas in Grist UI).
return (value[0] === '=') ? value.slice(1) : value;
}
public getTextValue() {
return this._formulaEditor.getValue();
}
public getCursorPos() {
const aceObj = this._formulaEditor.getEditor();
return aceObj.getSession().getDocument().positionToIndex(aceObj.getCursorPosition());
}
private _calcSize(elem: HTMLElement, desiredElemSize: ISize) {
const errorBox: HTMLElement|null = this._dom.querySelector('.error_details');
const errorBoxStartHeight = errorBox?.getBoundingClientRect().height || 0;
const errorBoxDesiredHeight = errorBox?.scrollHeight || 0;
// If we have an error to show, ask for a larger size for formulaEditor.
const desiredSize = {
width: Math.max(desiredElemSize.width, (this.options.formulaError ? minFormulaErrorWidth : 0)),
// Ask for extra space for the error; we'll decide how to allocate it below.
height: desiredElemSize.height + (errorBoxDesiredHeight - errorBoxStartHeight),
};
const result = this._editorPlacement.calcSizeWithPadding(elem, desiredSize);
if (errorBox) {
// Note that result.height does not include errorBoxStartHeight, but includes any available
// extra space that we requested.
const availableForError = errorBoxStartHeight + (result.height - desiredElemSize.height);
// This is the key calculation: if space is available, use it; if not, give 64px to error
// (it'll scroll within that), but don't use more than desired.
const errorBoxEndHeight = Math.min(errorBoxDesiredHeight, Math.max(availableForError, 64));
errorBox.style.height = `${errorBoxEndHeight}px`;
result.height -= (errorBoxEndHeight - errorBoxStartHeight);
}
return result;
}
// TODO: update regexes to unicode?
private _onSetCursor(row: DataRowModel, col: ViewFieldRec) {
if (!col) { return; } // if clicked on row header, no col to insert
if (this.options.readonly) { return; }
const aceObj = this._formulaEditor.getEditor();
if (!aceObj.selection.isEmpty()) { // If text selected, replace whole selection
aceObj.session.replace(aceObj.selection.getRange(), '$' + col.colId());
} else { // Not a selection, gotta figure out what to replace
const pos = aceObj.getCursorPosition();
const line = aceObj.session.getLine(pos.row);
const result = _isInIdentifier(line, pos.column); // returns {start, end, id} | null
if (!result) { // Not touching an identifier, insert colId as normal
aceObj.insert("$" + col.colId());
// We are touching an identifier
} else if (result.ident.startsWith("$")) { // If ident is a colId, replace it
const idRange = AceEditor.makeRange(pos.row, result.start, pos.row, result.end);
aceObj.session.replace(idRange, "$" + col.colId());
}
// Else touching a normal identifier, dont mangle it
}
// Resize editor in case it is needed.
this._formulaEditor.resize();
aceObj.focus();
}
}
// returns whether the column in that line is inside or adjacent to an identifier
// if yes, returns {start, end, ident}, else null
function _isInIdentifier(line: string, column: number) {
// If cursor is in or after an identifier, scoot back to the start of it
const prefix = line.slice(0, column);
let startOfIdent = prefix.search(/[$A-Za-z0-9_]+$/);
if (startOfIdent < 0) { startOfIdent = column; } // if no match, maybe we're right before it
// We're either before an ident or nowhere near one. Try to match to its end
const match = line.slice(startOfIdent).match(/^[$a-zA-Z0-9_]+/);
if (match) {
const ident = match[0];
return { ident, start: startOfIdent, end: startOfIdent + ident.length};
} else {
return null;
}
}
/**
* Open a formula editor. Returns a Disposable that owns the editor.
*/
export function openFormulaEditor(options: {
gristDoc: GristDoc,
// Associated formula from a different column (for example style rule).
column?: ColumnRec,
field?: ViewFieldRec,
editingFormula?: ko.Computed<boolean>,
// Needed to get exception value, if any.
editRow?: DataRowModel,
// Element over which to position the editor.
refElem: Element,
editValue?: string,
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
onCancel?: () => void,
// Called after editor is created to set up editor cleanup (e.g. saving on click-away).
setupCleanup: (
owner: MultiHolder,
doc: GristDoc,
editingFormula: ko.Computed<boolean>,
save: () => Promise<void>
) => void,
}): Disposable {
const {gristDoc, editRow, refElem, setupCleanup} = options;
const holder = MultiHolder.create(null);
const column = options.column ?? options.field?.column();
if (!column) {
throw new Error(t('Column or field is required'));
}
// AsyncOnce ensures it's called once even if triggered multiple times.
const saveEdit = asyncOnce(async () => {
const formula = String(editor.getCellValue());
if (formula !== column.formula.peek()) {
if (options.onSave) {
await options.onSave(column, formula);
} else {
await column.updateColValues({formula});
}
holder.dispose();
} else {
holder.dispose();
options.onCancel?.();
}
});
// These are the commands for while the editor is active.
const editCommands = {
fieldEditSave: () => { saveEdit().catch(reportError); },
fieldEditSaveHere: () => { saveEdit().catch(reportError); },
fieldEditCancel: () => { holder.dispose(); options.onCancel?.(); },
};
// Replace the item in the Holder with a new one, disposing the previous one.
const editor = FormulaEditor.create(holder, {
gristDoc,
column,
editingFormula: options.editingFormula,
rowId: editRow ? editRow.id() : 0,
cellValue: column.formula(),
formulaError: editRow ? getFormulaError(gristDoc, editRow, column) : undefined,
editValue: options.editValue,
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
commands: editCommands,
cssClass: 'formula_editor_sidepane',
readonly : false
} as IFormulaEditorOptions);
editor.attach(refElem);
const editingFormula = options.editingFormula ?? options?.field?.editingFormula;
if (!editingFormula) {
throw new Error(t('editingFormula is required'));
}
// When formula is empty enter formula-editing mode (highlight formula icons; click on a column inserts its ID).
// This function is used for primarily for switching between different column behaviors, so we want to enter full
// edit mode right away.
// TODO: consider converting it to parameter, when this will be used in different scenarios.
if (!column.formula()) {
editingFormula(true);
}
setupCleanup(holder, gristDoc, editingFormula, saveEdit);
return holder;
}
/**
* If the cell at the given row and column is a formula value containing an exception, return an
* observable with this exception, and fetch more details to add to the observable.
*/
export function getFormulaError(
gristDoc: GristDoc, editRow: DataRowModel, column: ColumnRec
): Observable<CellValue>|undefined {
const colId = column.colId.peek();
const cellCurrentValue = editRow.cells[colId].peek();
const isFormula = column.isFormula() || column.hasTriggerFormula();
if (isFormula && isRaisedException(cellCurrentValue)) {
const formulaError = Observable.create(null, cellCurrentValue);
gristDoc.docData.getFormulaError(column.table().tableId(), colId, editRow.getRowId())
.then(value => {
formulaError.set(value);
})
.catch(reportError);
return formulaError;
}
return undefined;
}
/**
* Create and return an observable for the count of errors in a column, which gets updated in
* response to changes in origColumn and in user data.
*/
export function createFormulaErrorObs(owner: MultiHolder, gristDoc: GristDoc, origColumn: ColumnRec) {
const errorMessage = Observable.create(owner, '');
// Count errors in origColumn when it's a formula column. Counts get cached by the
// tableData.countErrors() method, and invalidated on relevant data changes.
function countErrors() {
if (owner.isDisposed()) { return; }
const tableData = gristDoc.docData.getTable(origColumn.table.peek().tableId.peek());
const isFormula = origColumn.isRealFormula.peek() || origColumn.hasTriggerFormula.peek();
if (tableData && isFormula) {
const colId = origColumn.colId.peek();
const numCells = tableData.getColValues(colId)?.length || 0;
const numErrors = tableData.countErrors(colId) || 0;
errorMessage.set(
(numErrors === 0) ? '' :
(numCells === 1) ? t(`Error in the cell`) :
(numErrors === numCells) ? t(`Errors in all {{numErrors}} cells`, {numErrors}) :
t(`Errors in {{numErrors}} of {{numCells}} cells`, {numErrors, numCells})
);
} else {
errorMessage.set('');
}
}
// Debounce the count calculation to defer it to the end of a bundle of actions.
const debouncedCountErrors = debounce(countErrors, 0);
// If there is an update to the data in the table, count errors again. Since the same UI is
// reused when different page widgets are selected, we need to re-create this subscription
// whenever the selected table changes. We use a Computed to both react to changes and dispose
// the previous subscription when it changes.
Computed.create(owner, (use) => {
const tableData = gristDoc.docData.getTable(use(use(origColumn.table).tableId));
return tableData ? use.owner.autoDispose(tableData.tableActionEmitter.addListener(debouncedCountErrors)) : null;
});
// The counts depend on the origColumn and its isRealFormula status, but with the debounced
// callback and subscription to data, subscribe to relevant changes manually (rather than using
// a Computed).
owner.autoDispose(subscribe(use => { use(origColumn.id); use(origColumn.isRealFormula); debouncedCountErrors(); }));
return errorMessage;
}
const cssCollapseIcon = styled(icon, `
margin: -3px 4px 0 4px;
--icon-color: ${colors.slate};
`);
export const cssError = styled('div', `
color: ${theme.errorText};
`);