gristlabs_grist-core/app/client/widgets/FieldEditor.ts
Jarosław Sadziński 8684c9e930 (core) Adding traceback to trigger formulas
Summary:
Traceback is available on the Creator Panel in the formula editor. It is evaluated the same way as for normal formulas.
In case when the traceback is not available, only the error name is displayed with information that traceback is not available.
Cell with an error, when edited, shows the previous valid value that was used before the error happened (or None for new rows).
Value is stored inside the RaisedException object that is stored in a cell.

Test Plan: Created tests

Reviewers: alexmojaki

Reviewed By: alexmojaki

Subscribers: alexmojaki, dsagal

Differential Revision: https://phab.getgrist.com/D3033
2021-09-27 17:12:39 +02:00

485 lines
19 KiB
TypeScript

import * as commands from 'app/client/components/commands';
import {Cursor} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc';
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {reportError} from 'app/client/models/errors';
import {showTooltipToCreateFormula} from 'app/client/widgets/EditorTooltip';
import {FormulaEditor} from 'app/client/widgets/FormulaEditor';
import {IEditorCommandGroup, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
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 isEqual = require('lodash/isEqual');
import { CellPosition } from "app/client/components/CellPosition";
type IEditorConstructor = typeof NewBaseEditor;
/**
* Check if the typed-in value should change the cell without opening the cell editor, and if so,
* saves and returns true. E.g. on typing space, CheckBoxEditor toggles the cell without opening.
*/
export function saveWithoutEditor(
editorCtor: IEditorConstructor, editRow: DataRowModel, field: ViewFieldRec, typedVal: string|undefined
): boolean {
// Never skip the editor if editing a formula. Also, check that skipEditor static function
// exists (we don't bother adding it on old-style JS editors that don't need it).
if (!field.column.peek().isRealFormula.peek() && editorCtor.skipEditor) {
const origVal = editRow.cells[field.colId()].peek();
const skipEditorValue = editorCtor.skipEditor(typedVal, origVal);
if (skipEditorValue !== undefined) {
setAndSave(editRow, field, skipEditorValue).catch(reportError);
return true;
}
}
return false;
}
// Set the given field of editRow to value, only if different from the current value of the cell.
export async function setAndSave(editRow: DataRowModel, field: ViewFieldRec, value: CellValue): Promise<void> {
const obs = editRow.cells[field.colId()];
if (!isEqual(value, obs.peek())) {
return obs.setAndSave(value);
}
}
/**
* Event that is fired when editor stat has changed
*/
export interface FieldEditorStateEvent {
position: CellPosition,
wasModified: boolean,
currentState: any,
type: string
}
export class FieldEditor extends Disposable {
public readonly saveEmitter = this.autoDispose(new Emitter());
public readonly cancelEmitter = this.autoDispose(new Emitter());
public readonly changeEmitter = this.autoDispose(new Emitter());
private _gristDoc: GristDoc;
private _field: ViewFieldRec;
private _cursor: Cursor;
private _editRow: DataRowModel;
private _cellElem: Element;
private _editCommands: IEditorCommandGroup;
private _editorCtor: IEditorConstructor;
private _editorHolder: Holder<NewBaseEditor> = Holder.create(this);
private _saveEdit = asyncOnce(() => this._doSaveEdit());
private _editorHasChanged = false;
private _isFormula = false;
private _readonly = false;
constructor(options: {
gristDoc: GristDoc,
field: ViewFieldRec,
cursor: Cursor,
editRow: DataRowModel,
cellElem: Element,
editorCtor: IEditorConstructor,
startVal?: string,
state?: any,
readonly: boolean
}) {
super();
this._gristDoc = options.gristDoc;
this._field = options.field;
this._cursor = options.cursor;
this._editRow = options.editRow;
this._editorCtor = options.editorCtor;
this._cellElem = options.cellElem;
this._readonly = options.readonly;
const startVal = options.startVal;
let offerToMakeFormula = false;
const column = this._field.column();
this._isFormula = column.isRealFormula.peek();
let editValue: string|undefined = startVal;
if (!options.readonly && startVal && gutil.startsWith(startVal, '=')) {
if (this._isFormula || this._field.column().isEmpty()) {
// If we typed '=' on an empty column, convert it to a formula. If on a formula column,
// start editing ignoring the initial '='.
this._isFormula = true;
editValue = gutil.removePrefix(startVal, '=') as string;
} else {
// If we typed '=' on a non-empty column, only suggest to convert it to a formula.
offerToMakeFormula = true;
}
}
// These are the commands for while the editor is active.
this._editCommands = {
// _saveEdit disables this command group, so when we run fieldEditSave again, it triggers
// another registered group, if any. E.g. GridView listens to it to move the cursor down.
fieldEditSave: () => {
this._saveEdit().then((jumped: boolean) => {
// To avoid confusing cursor movement, do not increment the rowIndex if the row
// was re-sorted after editing.
if (!jumped) { commands.allCommands.fieldEditSave.run(); }
})
.catch(reportError);
},
fieldEditSaveHere: () => { this._saveEdit().catch(reportError); },
fieldEditCancel: () => { this._cancelEdit(); },
prevField: () => { this._saveEdit().then(commands.allCommands.prevField.run).catch(reportError); },
nextField: () => { this._saveEdit().then(commands.allCommands.nextField.run).catch(reportError); },
makeFormula: () => this._makeFormula(),
unmakeFormula: () => this._unmakeFormula(),
};
// for readonly editor rewire commands, most of this also could be
// done by just overriding the saveEdit method, but this is more clearer
if (options.readonly) {
this._editCommands.fieldEditSave = () => {
// those two lines are tightly coupled - without disposing first
// it will run itself in a loop. But this is needed for a GridView
// which navigates to the next row on save.
this._editCommands.fieldEditCancel();
commands.allCommands.fieldEditSave.run();
};
this._editCommands.fieldEditSaveHere = this._editCommands.fieldEditCancel;
this._editCommands.prevField = () => { this._cancelEdit(); commands.allCommands.prevField.run(); };
this._editCommands.nextField = () => { this._cancelEdit(); commands.allCommands.nextField.run(); };
this._editCommands.makeFormula = () => true; /* don't stop propagation */
this._editCommands.unmakeFormula = () => true;
}
this.rebuildEditor(editValue, Number.POSITIVE_INFINITY, options.state);
if (offerToMakeFormula) {
this._offerToMakeFormula();
}
// connect this editor to editor monitor, it will restore this editor
// when user or server refreshes the browser
this._gristDoc.editorMonitor.monitorEditor(this);
// for readonly field we don't need to do anything special
if (!options.readonly) {
setupEditorCleanup(this, this._gristDoc, this._field, this._saveEdit);
} else {
setupReadonlyEditorCleanup(this, this._gristDoc, this._field, () => this._cancelEdit());
}
}
// cursorPos refers to the position of the caret within the editor.
public rebuildEditor(editValue: string|undefined, cursorPos: number, state?: any) {
const editorCtor: IEditorConstructor = this._isFormula ? FormulaEditor : this._editorCtor;
const column = this._field.column();
const cellCurrentValue = this._editRow.cells[this._field.colId()].peek();
let cellValue: CellValue;
if (column.isFormula()) {
cellValue = column.formula();
} else if (Array.isArray(cellCurrentValue) && cellCurrentValue[0] === 'C') {
// This cell value is censored by access control rules
// Really the rules should also block editing, but in case they don't, show a blank value
// rather than a 'C'. However if the user tries to edit the cell and then clicks away
// without typing anything the empty string is saved, deleting what was there.
// We should probably just automatically block updates where reading is not allowed.
cellValue = '';
} else {
cellValue = cellCurrentValue;
}
const error = getFormulaError(this._gristDoc, this._editRow, column);
// For readonly mode use the default behavior of Formula Editor
// TODO: cleanup this flag - it gets modified in too many places
if (!this._readonly){
// Enter formula-editing mode (e.g. click-on-column inserts its ID) only if we are opening the
// editor by typing into it (and overriding previous formula). In other cases (e.g. double-click),
// we defer this mode until the user types something.
this._field.editingFormula(this._isFormula && editValue !== undefined);
}
this._editorHasChanged = false;
// Replace the item in the Holder with a new one, disposing the previous one.
const editor = this._editorHolder.autoDispose(editorCtor.create({
gristDoc: this._gristDoc,
field: this._field,
cellValue,
formulaError: error,
editValue,
cursorPos,
state,
commands: this._editCommands,
readonly : this._readonly
}));
// if editor supports live changes, connect it to the change emitter
if (editor.editorState) {
editor.autoDispose(editor.editorState.addListener((currentState) => {
this._editorHasChanged = true;
const event: FieldEditorStateEvent = {
position : this.cellPosition(),
wasModified : this._editorHasChanged,
currentState,
type: this._field.column.peek().pureType.peek()
};
this.changeEmitter.emit(event);
}));
}
editor.attach(this._cellElem);
}
public getDom() {
return this._editorHolder.get()?.getDom();
}
// calculate current cell's absolute position
public cellPosition() {
const rowId = this._editRow.getRowId();
const colRef = this._field.colRef.peek();
const sectionId = this._field.viewSection.peek().id.peek();
const position = {
rowId,
colRef,
sectionId
};
return position;
}
private _makeFormula() {
const editor = this._editorHolder.get();
// On keyPress of "=" on textInput, consider turning the column into a formula.
if (editor && !this._field.editingFormula.peek() && editor.getCursorPos() === 0) {
if (this._field.column().isEmpty()) {
this._isFormula = true;
// If we typed '=' an empty column, convert it to a formula.
this.rebuildEditor(editor.getTextValue(), 0);
return false;
} else {
// If we typed '=' on a non-empty column, only suggest to convert it to a formula.
this._offerToMakeFormula();
}
}
return true; // don't stop propagation.
}
private _unmakeFormula() {
const editor = this._editorHolder.get();
// Only convert to data if we are undoing a to-formula conversion. To convert formula to
// data, use column menu option, or delete the formula first (which makes the column "empty").
if (editor && this._field.editingFormula.peek() && editor.getCursorPos() === 0 &&
!this._field.column().isRealFormula()) {
// Restore a plain '=' character. This gives a way to enter "=" at the start if line. The
// second backspace will delete it.
this._isFormula = false;
this.rebuildEditor('=' + editor.getTextValue(), 1);
return false;
}
return true; // don't stop propagation.
}
private _offerToMakeFormula() {
const editorDom = this._editorHolder.get()?.getDom();
if (!editorDom) { return; }
showTooltipToCreateFormula(editorDom, () => this._convertEditorToFormula());
}
private _convertEditorToFormula() {
const editor = this._editorHolder.get();
if (editor) {
const editValue = editor.getTextValue();
const formulaValue = editValue.startsWith('=') ? editValue.slice(1) : editValue;
this._isFormula = true;
this.rebuildEditor(formulaValue, 0);
}
}
// Cancels the edit
private _cancelEdit() {
if (this.isDisposed()) { return; }
const event: FieldEditorStateEvent = {
position : this.cellPosition(),
wasModified : this._editorHasChanged,
currentState : this._editorHolder.get()?.editorState?.get(),
type : this._field.column.peek().pureType.peek()
};
this.cancelEmitter.emit(event);
this.dispose();
}
// Returns true if Enter/Shift+Enter should NOT move the cursor, for instance if the current
// record got reordered (i.e. the cursor jumped), or when editing a formula.
private async _doSaveEdit(): Promise<boolean> {
const editor = this._editorHolder.get();
if (!editor) { return false; }
// Make sure the editor is save ready
const saveIndex = this._cursor.rowIndex();
await editor.prepForSave();
if (this.isDisposed()) {
// We shouldn't normally get disposed here, but if we do, avoid confusing JS errors.
console.warn("Unable to finish saving edited cell"); // tslint:disable-line:no-console
return false;
}
// Then save the value the appropriate way
// TODO: this isFormula value doesn't actually reflect if editing the formula, since
// editingFormula() is used for toggling column headers, and this is deferred to start of
// typing (a double-click or Enter) does not immediately set it. (This can cause a
// console.warn below, although harmless.)
const isFormula = this._field.editingFormula();
const col = this._field.column();
let waitPromise: Promise<unknown>|null = null;
if (isFormula) {
const formula = editor.getCellValue();
// Bundle multiple changes so that we can undo them in one step.
if (isFormula !== col.isFormula.peek() || formula !== col.formula.peek()) {
waitPromise = this._gristDoc.docData.bundleActions(null, () => Promise.all([
col.updateColValues({isFormula, formula}),
// If we're saving a non-empty formula, then also add an empty record to the table
// so that the formula calculation is visible to the user.
(this._editRow._isAddRow.peek() && formula !== "" ?
this._editRow.updateColValues({}) : undefined),
]));
}
} else {
const value = editor.getCellValue();
if (col.isRealFormula()) {
// tslint:disable-next-line:no-console
console.warn("It should be impossible to save a plain data value into a formula column");
} else {
// This could still be an isFormula column if it's empty (isEmpty is true), but we don't
// need to toggle isFormula in that case, since the data engine takes care of that.
waitPromise = setAndSave(this._editRow, this._field, value);
}
}
const event: FieldEditorStateEvent = {
position : this.cellPosition(),
wasModified : this._editorHasChanged,
currentState : this._editorHolder.get()?.editorState?.get(),
type : this._field.column.peek().pureType.peek()
};
this.saveEmitter.emit(event);
const cursor = this._cursor;
// Deactivate the editor. We are careful to avoid using `this` afterwards.
this.dispose();
await waitPromise;
return isFormula || (saveIndex !== cursor.rowIndex());
}
}
/**
* Open a formula editor in the side pane. Returns a Disposable that owns the editor.
*/
export function openSideFormulaEditor(options: {
gristDoc: GristDoc,
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 {
const {gristDoc, field, editRow, refElem} = options;
const holder = MultiHolder.create(null);
const column = field.column();
// AsyncOnce ensures it's called once even if triggered multiple times.
const saveEdit = asyncOnce(async () => {
const formula = editor.getCellValue();
if (formula !== column.formula.peek()) {
await column.updateColValues({formula});
}
holder.dispose(); // Deactivate the editor.
});
// These are the commands for while the editor is active.
const editCommands = {
fieldEditSave: () => { saveEdit().catch(reportError); },
fieldEditSaveHere: () => { saveEdit().catch(reportError); },
fieldEditCancel: () => { holder.dispose(); },
};
// Replace the item in the Holder with a new one, disposing the previous one.
const editor = FormulaEditor.create(holder, {
gristDoc,
field,
cellValue: column.formula(),
formulaError: getFormulaError(gristDoc, editRow, column),
editValue: undefined,
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
commands: editCommands,
cssClass: 'formula_editor_sidepane',
readonly : false
});
editor.attach(refElem);
// Enter formula-editing mode (highlight formula icons; click on a column inserts its ID).
field.editingFormula(true);
setupEditorCleanup(holder, gristDoc, field, saveEdit);
return holder;
}
/**
* For an readonly editor, set up its cleanup:
* - canceling on click-away (when focus returns to Grist "clipboard" element)
*/
function setupReadonlyEditorCleanup(
owner: MultiHolder, gristDoc: GristDoc, field: ViewFieldRec, cancelEdit: () => any
) {
// Whenever focus returns to the Clipboard component, close the editor by saving the value.
gristDoc.app.on('clipboard_focus', cancelEdit);
owner.onDispose(() => {
field.editingFormula(false);
gristDoc.app.off('clipboard_focus', cancelEdit);
});
}
/**
* For an active editor, set up its cleanup:
* - saving on click-away (when focus returns to Grist "clipboard" element)
* - unset field.editingFormula mode
* - Arrange for UnsavedChange protection against leaving the page with unsaved changes.
*/
function setupEditorCleanup(
owner: MultiHolder, gristDoc: GristDoc, field: ViewFieldRec, _saveEdit: () => Promise<unknown>
) {
const saveEdit = () => _saveEdit().catch(reportError);
// Whenever focus returns to the Clipboard component, close the editor by saving the value.
gristDoc.app.on('clipboard_focus', saveEdit);
// TODO: This should ideally include a callback that returns true only when the editor value
// has changed. Currently an open editor is considered unsaved even when unchanged.
UnsavedChange.create(owner, async () => { await saveEdit(); });
owner.onDispose(() => {
gristDoc.app.off('clipboard_focus', saveEdit);
// Unset field.editingFormula flag when the editor closes.
field.editingFormula(false);
});
}
/**
* 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.
*/
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;
}