2020-10-02 15:10:00 +00:00
|
|
|
import * as AceEditor from 'app/client/components/AceEditor';
|
|
|
|
import {createGroup} from 'app/client/components/commands';
|
2023-01-11 17:57:42 +00:00
|
|
|
import {makeT} from 'app/client/lib/localization';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {DataRowModel} from 'app/client/models/DataRowModel';
|
|
|
|
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
2022-09-06 01:51:57 +00:00
|
|
|
import {colors, testId, theme} from 'app/client/ui2018/cssVars';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {icon} from 'app/client/ui2018/icons';
|
2021-02-04 03:17:17 +00:00
|
|
|
import {createMobileButtons, getButtonMargins} from 'app/client/widgets/EditorButtons';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
|
|
|
|
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
2021-05-17 14:05:49 +00:00
|
|
|
import {undef} from 'app/common/gutil';
|
2022-03-22 13:41:11 +00:00
|
|
|
import {Computed, Disposable, dom, MultiHolder, Observable, styled, subscribe} from 'grainjs';
|
2021-09-25 19:14:19 +00:00
|
|
|
import {isRaisedException} from "app/common/gristTypes";
|
|
|
|
import {decodeObject, RaisedException} from "app/plugin/objtypes";
|
2022-03-22 13:41:11 +00:00
|
|
|
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');
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
// How wide to expand the FormulaEditor when an error is shown in it.
|
|
|
|
const minFormulaErrorWidth = 400;
|
2023-01-11 17:57:42 +00:00
|
|
|
const t = makeT('FormulaEditor');
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2021-03-17 03:45:44 +00:00
|
|
|
export interface IFormulaEditorOptions extends Options {
|
|
|
|
cssClass?: string;
|
2022-08-08 13:32:50 +00:00
|
|
|
editingFormula: ko.Computed<boolean>,
|
|
|
|
column: ColumnRec,
|
2021-03-17 03:45:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
/**
|
|
|
|
* 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;
|
2022-08-08 13:32:50 +00:00
|
|
|
private _editorPlacement!: EditorPlacement;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2021-03-17 03:45:44 +00:00
|
|
|
constructor(options: IFormulaEditorOptions) {
|
2020-10-02 15:10:00 +00:00
|
|
|
super(options);
|
2021-05-17 14:05:49 +00:00
|
|
|
|
2022-08-08 13:32:50 +00:00
|
|
|
const editingFormula = options.editingFormula;
|
2022-03-22 13:41:11 +00:00
|
|
|
|
2021-05-17 14:05:49 +00:00
|
|
|
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);
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
this._formulaEditor = AceEditor.create({
|
|
|
|
// A bit awkward, but we need to assume calcSize is not used until attach() has been called
|
|
|
|
// and _editorPlacement created.
|
2022-08-08 13:32:50 +00:00
|
|
|
column: options.column,
|
2020-10-02 15:10:00 +00:00
|
|
|
calcSize: this._calcSize.bind(this),
|
|
|
|
gristDoc: options.gristDoc,
|
2021-06-17 16:41:07 +00:00
|
|
|
saveValueOnBlurEvent: !options.readonly,
|
|
|
|
editorState : this.editorState,
|
|
|
|
readonly: options.readonly
|
2020-10-02 15:10:00 +00:00
|
|
|
});
|
|
|
|
|
2021-06-17 16:41:07 +00:00
|
|
|
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;
|
2022-03-22 13:41:11 +00:00
|
|
|
this._commandGroup = this.autoDispose(createGroup(allCommands, this, editingFormula));
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2021-09-25 19:14:19 +00:00
|
|
|
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 ?? "";
|
|
|
|
});
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2022-07-20 23:40:22 +00:00
|
|
|
// 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)));
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
this.autoDispose(this._formulaEditor);
|
2022-07-20 23:40:22 +00:00
|
|
|
this._dom = dom('div.default_editor.formula_editor_wrapper',
|
2021-06-17 16:41:07 +00:00
|
|
|
// switch border shadow
|
|
|
|
dom.cls("readonly_editor", options.readonly),
|
2021-02-04 03:17:17 +00:00
|
|
|
createMobileButtons(options.commands),
|
2021-03-17 03:45:44 +00:00
|
|
|
options.cssClass ? dom.cls(options.cssClass) : null,
|
2021-02-04 03:17:17 +00:00
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
// 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);
|
2021-05-17 14:05:49 +00:00
|
|
|
const val = initialValue;
|
2020-10-02 15:10:00 +00:00
|
|
|
const pos = Math.min(options.cursorPos, val.length);
|
|
|
|
this._formulaEditor.setValue(val, pos);
|
|
|
|
this._formulaEditor.attachCommandGroup(this._commandGroup);
|
|
|
|
|
2021-05-17 14:05:49 +00:00
|
|
|
// enable formula editing if state was passed
|
2021-06-17 16:41:07 +00:00
|
|
|
if (options.state || options.readonly) {
|
2022-03-22 13:41:11 +00:00
|
|
|
editingFormula(true);
|
2021-05-17 14:05:49 +00:00
|
|
|
}
|
2021-06-17 16:41:07 +00:00
|
|
|
if (options.readonly) {
|
|
|
|
this._formulaEditor.enable(false);
|
|
|
|
aceObj.gotoLine(0, 0); // By moving, ace editor won't highlight anything
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
// This catches any change to the value including e.g. via backspace or paste.
|
2022-08-08 13:32:50 +00:00
|
|
|
aceObj.once("change", () => editingFormula?.(true));
|
2020-10-02 15:10:00 +00:00
|
|
|
})
|
|
|
|
),
|
2022-07-20 23:40:22 +00:00
|
|
|
(options.formulaError ? [
|
2020-10-02 15:10:00 +00:00
|
|
|
dom('div.error_msg', testId('formula-error-msg'),
|
|
|
|
dom.on('click', () => {
|
2021-09-25 19:14:19 +00:00
|
|
|
if (errorDetails.get()){
|
|
|
|
hideErrDetails.set(!hideErrDetails.get());
|
|
|
|
this._formulaEditor.resize();
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
}),
|
2021-09-25 19:14:19 +00:00
|
|
|
dom.maybe(errorDetails, () =>
|
|
|
|
dom.domComputed(hideErrDetails, (hide) => cssCollapseIcon(hide ? 'Expand' : 'Collapse'))
|
|
|
|
),
|
|
|
|
dom.text(errorText),
|
2020-10-02 15:10:00 +00:00
|
|
|
),
|
2022-07-20 23:40:22 +00:00
|
|
|
dom.maybe(use => Boolean(use(errorDetails) && !use(hideErrDetails)), () =>
|
2021-09-25 19:14:19 +00:00
|
|
|
dom('div.error_details',
|
2022-07-20 23:40:22 +00:00
|
|
|
dom('div.error_details_inner',
|
|
|
|
dom.text(errorDetails),
|
|
|
|
),
|
2021-09-25 19:14:19 +00:00
|
|
|
testId('formula-error-details'),
|
|
|
|
)
|
|
|
|
)
|
2022-07-20 23:40:22 +00:00
|
|
|
] : null
|
2020-10-02 15:10:00 +00:00
|
|
|
)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-02-04 03:17:17 +00:00
|
|
|
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));
|
2020-10-02 15:10:00 +00:00
|
|
|
this._formulaEditor.onAttach();
|
|
|
|
this._formulaEditor.editor.focus();
|
|
|
|
}
|
|
|
|
|
2021-05-25 09:24:00 +00:00
|
|
|
public getDom(): HTMLElement {
|
|
|
|
return this._dom;
|
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
public getCellValue() {
|
|
|
|
return this._formulaEditor.getValue();
|
|
|
|
}
|
|
|
|
|
|
|
|
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) {
|
2022-07-20 23:40:22 +00:00
|
|
|
const errorBox: HTMLElement|null = this._dom.querySelector('.error_details');
|
|
|
|
const errorBoxStartHeight = errorBox?.getBoundingClientRect().height || 0;
|
|
|
|
const errorBoxDesiredHeight = errorBox?.scrollHeight || 0;
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
// 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)),
|
2022-07-20 23:40:22 +00:00
|
|
|
// Ask for extra space for the error; we'll decide how to allocate it below.
|
|
|
|
height: desiredElemSize.height + (errorBoxDesiredHeight - errorBoxStartHeight),
|
2020-10-02 15:10:00 +00:00
|
|
|
};
|
2022-07-20 23:40:22 +00:00
|
|
|
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;
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: update regexes to unicode?
|
|
|
|
private _onSetCursor(row: DataRowModel, col: ViewFieldRec) {
|
2021-06-17 16:41:07 +00:00
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
if (!col) { return; } // if clicked on row header, no col to insert
|
|
|
|
|
2021-06-17 16:41:07 +00:00
|
|
|
if (this.options.readonly) { return; }
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-03-22 13:41:11 +00:00
|
|
|
/**
|
|
|
|
* Open a formula editor. Returns a Disposable that owns the editor.
|
|
|
|
*/
|
|
|
|
export function openFormulaEditor(options: {
|
|
|
|
gristDoc: GristDoc,
|
2022-04-28 15:43:31 +00:00
|
|
|
// Associated formula from a different column (for example style rule).
|
2022-03-22 13:41:11 +00:00
|
|
|
column?: ColumnRec,
|
2022-08-08 13:32:50 +00:00
|
|
|
field?: ViewFieldRec,
|
|
|
|
editingFormula?: ko.Computed<boolean>,
|
2022-03-22 13:41:11 +00:00
|
|
|
// 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,
|
2022-08-08 13:32:50 +00:00
|
|
|
editingFormula: ko.Computed<boolean>,
|
2022-03-22 13:41:11 +00:00
|
|
|
save: () => Promise<void>
|
|
|
|
) => void,
|
|
|
|
}): Disposable {
|
2022-08-08 13:32:50 +00:00
|
|
|
const {gristDoc, editRow, refElem, setupCleanup} = options;
|
2022-03-22 13:41:11 +00:00
|
|
|
const holder = MultiHolder.create(null);
|
2022-08-08 13:32:50 +00:00
|
|
|
const column = options.column ?? options.field?.column();
|
|
|
|
|
|
|
|
if (!column) {
|
2023-01-11 17:57:42 +00:00
|
|
|
throw new Error(t('Column or field is required'));
|
2022-08-08 13:32:50 +00:00
|
|
|
}
|
2022-03-22 13:41:11 +00:00
|
|
|
|
|
|
|
// AsyncOnce ensures it's called once even if triggered multiple times.
|
|
|
|
const saveEdit = asyncOnce(async () => {
|
2022-10-17 09:47:16 +00:00
|
|
|
const formula = String(editor.getCellValue());
|
2022-07-25 09:49:35 +00:00
|
|
|
if (formula !== column.formula.peek()) {
|
|
|
|
if (options.onSave) {
|
2022-10-17 09:47:16 +00:00
|
|
|
await options.onSave(column, formula);
|
2022-07-25 09:49:35 +00:00
|
|
|
} else {
|
|
|
|
await column.updateColValues({formula});
|
|
|
|
}
|
|
|
|
holder.dispose();
|
|
|
|
} else {
|
|
|
|
holder.dispose();
|
|
|
|
options.onCancel?.();
|
2022-03-22 13:41:11 +00:00
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// 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,
|
2022-08-08 13:32:50 +00:00
|
|
|
column,
|
|
|
|
editingFormula: options.editingFormula,
|
2022-07-06 22:36:09 +00:00
|
|
|
rowId: editRow ? editRow.id() : 0,
|
2022-03-22 13:41:11 +00:00
|
|
|
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
|
2022-08-08 13:32:50 +00:00
|
|
|
} as IFormulaEditorOptions);
|
2022-03-22 13:41:11 +00:00
|
|
|
editor.attach(refElem);
|
|
|
|
|
2022-08-08 13:32:50 +00:00
|
|
|
const editingFormula = options.editingFormula ?? options?.field?.editingFormula;
|
|
|
|
|
|
|
|
if (!editingFormula) {
|
2023-01-11 17:57:42 +00:00
|
|
|
throw new Error(t('editingFormula is required'));
|
2022-08-08 13:32:50 +00:00
|
|
|
}
|
|
|
|
|
2022-03-22 13:41:11 +00:00
|
|
|
// 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()) {
|
2022-08-08 13:32:50 +00:00
|
|
|
editingFormula(true);
|
2022-03-22 13:41:11 +00:00
|
|
|
}
|
2022-08-08 13:32:50 +00:00
|
|
|
setupCleanup(holder, gristDoc, editingFormula, saveEdit);
|
2022-03-22 13:41:11 +00:00
|
|
|
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) ? '' :
|
2023-01-11 17:57:42 +00:00
|
|
|
(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})
|
2022-03-22 13:41:11 +00:00
|
|
|
);
|
|
|
|
} 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;
|
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
const cssCollapseIcon = styled(icon, `
|
|
|
|
margin: -3px 4px 0 4px;
|
|
|
|
--icon-color: ${colors.slate};
|
|
|
|
`);
|
2022-03-22 13:41:11 +00:00
|
|
|
|
|
|
|
export const cssError = styled('div', `
|
2022-09-06 01:51:57 +00:00
|
|
|
color: ${theme.errorText};
|
2022-03-22 13:41:11 +00:00
|
|
|
`);
|