gristlabs_grist-core/app/client/widgets/FieldEditor.ts
Jarosław Sadziński da323fb741 (core) Floating formula editor
Summary:
Adding a way to detach an editor. Initially only implemented for the formula editor, includes redesign for the AI part.
- Initially, the detached editor is tight with the formula assistant and both are behind GRIST_FORMULA_ASSISTANT flag, but this can be relaxed
later on, as the detached editor can be used on its own.

- Detached editor is only supported in regular fields and on the creator panel. It is not supported yet for conditional styles, due to preview limitations.
- Old code for the assistant was removed completely, as it was only a temporary solution, but the AI conversation part was copied to the new one.
- Prompting was not modified in this diff, it will be included in the follow-up with more test cases.

Test Plan: Added only new tests; existing tests should pass.

Reviewers: JakubSerafin

Reviewed By: JakubSerafin

Differential Revision: https://phab.getgrist.com/D3863
2023-06-02 17:59:22 +02:00

466 lines
18 KiB
TypeScript

import * as commands from 'app/client/components/commands';
import {Cursor, CursorPos} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc';
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {reportError} from 'app/client/models/errors';
import {showTooltipToCreateFormula} from 'app/client/widgets/EditorTooltip';
import {FormulaEditor, getFormulaError} 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 * as gutil from 'app/common/gutil';
import {CellPosition} from "app/client/components/CellPosition";
import {FloatingEditor} from 'app/client/widgets/FloatingEditor';
import isEqual = require('lodash/isEqual');
import {Disposable, dom, Emitter, Holder, MultiHolder, Observable} from 'grainjs';
type IEditorConstructor = typeof NewBaseEditor;
const t = makeT('FieldEditor');
/**
* 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());
public floatingEditor: FloatingEditor;
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;
private _detached = Observable.create(this, false);
private _detachedAt: CursorPos|null = null;
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);
// Create a floating editor, which will be used to display the editor in a popup.
this.floatingEditor = FloatingEditor.create(this, this, this._gristDoc);
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 detached editor, we don't need to cleanup anything.
// It will be cleanuped automatically.
const onCleanup = async () => {
if (this._detached.get()) { return; }
await this._saveEdit();
};
// for readonly field we don't need to do anything special
if (!options.readonly) {
setupEditorCleanup(this, this._gristDoc, this._field.editingFormula, onCleanup);
} 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 errorHolder = new MultiHolder();
const error = getFormulaError(errorHolder, {
gristDoc: this._gristDoc,
editRow: this._editRow,
field: this._field
});
// 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.
const active = this._isFormula && editValue !== undefined;
this._field.editingFormula(active);
}
this._detached.set(false);
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,
column: this._field.column(), // needed for FormulaEditor
editingFormula: this._field.editingFormula, // needed for Formula editor
cellValue,
rowId: this._editRow.id(),
formulaError: error,
editValue,
cursorPos,
state,
canDetach: true,
commands: this._editCommands,
readonly : this._readonly
}));
editor.autoDispose(errorHolder);
// 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 detach() {
this._detached.set(true);
this._detachedAt = this._gristDoc.cursorPosition.get()!;
return this._editorHolder.get()!.detach()!;
}
public async attach(content: HTMLElement) {
// If we are disconnected from the dom (maybe page was changed or something), we can't
// simply attach the editor back, we need to rebuild it.
if (!this._cellElem.isConnected) {
dom.domDispose(content);
if (await this._gristDoc.recursiveMoveToCursorPos(this._detachedAt!, true)) {
await this._gristDoc.activateEditorAtCursor();
}
this.dispose();
return;
}
this._detached.set(false);
this._editorHolder.get()?.attach(this._cellElem);
this._field.viewSection.peek().hasFocus(true);
}
public getDom() {
return this._editorHolder.get()?.getDom();
}
// calculate current cell's absolute position
public cellPosition() {
const rowId = this._editRow.getRowId();
const colRef = this._field.column.peek().origColRef.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(t("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 = String(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._detached.get() && 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(t("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());
}
}
/**
* 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.
*/
export function setupEditorCleanup(
owner: MultiHolder, gristDoc: GristDoc, editingFormula: ko.Computed<boolean>, _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.
editingFormula(false);
});
}