(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
pull/526/head
Jarosław Sadziński 12 months ago
parent e10067ff78
commit da323fb741

@ -114,9 +114,9 @@ AceEditor.prototype.enable = function(bool) {
* Note: Ace defers to standard behavior when false is returned.
*/
AceEditor.prototype.attachCommandGroup = function(commandGroup) {
_.each(commandGroup.knownKeys, (command, key) => {
_.each(commandGroup.knownKeys, (commandName, key) => {
this.editor.commands.addCommand({
name: command,
name: commandName,
// We are setting readonly as true to enable all commands
// in a readonly mode.
// Because FieldEditor in readonly mode will rewire all commands that
@ -129,7 +129,7 @@ AceEditor.prototype.attachCommandGroup = function(commandGroup) {
},
// AceEditor wants a command to return true if it got handled, whereas our command returns
// true to avoid stopPropagation/preventDefault, i.e. if it hasn't been handled.
exec: () => !commandGroup.commands[command]()
exec: () => !commandGroup.commands[commandName]()
});
});
};
@ -270,7 +270,7 @@ AceEditor.prototype.resize = function() {
// This won't help for zooming (where the same problem occurs but in many more places), but will
// help for Windows users who have different pixel ratio.
this.editorDom.style.width = size.width ? Math.ceil(size.width) + 'px' : 'auto';
this.editorDom.style.height = Math.ceil(size.height) + 'px';
this.editorDom.style.height = size.height ? Math.ceil(size.height) + 'px' : 'auto';
this.editor.resize();
};

@ -1,4 +1,5 @@
import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
import {commonUrls} from 'app/common/gristUrls';
import * as ace from 'brace';
export interface ICompletionOptions {
@ -265,7 +266,7 @@ function retokenizeAceCompleterRow(rowData: AceSuggestion, tokens: TokenInfo[]):
// Include into new tokens a special token that will be hidden, but include the link URL. On
// click, we find it to know what URL to open.
const href = 'https://support.getgrist.com/functions/#' +
const href = `${commonUrls.functions}/#` +
rowData.funcname.slice(linkStart, linkEnd).toLowerCase();
newTokens.push({value: href, type: 'grist_link_hidden'});

@ -49,6 +49,9 @@ const {parsePasteForView} = require("./BaseView2");
const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter');
const {CombinedStyle} = require("app/client/models/Styles");
const {buildRenameColumn} = require('app/client/ui/ColumnTitle');
const {makeT} = require('app/client/lib/localization');
const t = makeT('GridView');
// A threshold for interpreting a motionless click as a click rather than a drag.
// Anything longer than this time (in milliseconds) should be interpreted as a drag
@ -219,6 +222,14 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
// Holds column index that is hovered, works only in full-edit formula mode.
this.hoverColumn = ko.observable(-1);
// Checks if there is active formula editor for a column in this table.
this.editingFormula = ko.pureComputed(() => {
const isEditing = this.gristDoc.docModel.editingFormula();
if (!isEditing) { return false; }
return this.viewSection.viewFields().all().some(field => field.editingFormula());
});
// Debounced method to change current hover column, this is needed
// as mouse when moved from field to field will switch the hover-column
// observable from current index to -1 and then immediately back to current index.
@ -226,7 +237,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) {
// will be discarded.
this.changeHover = debounce((index) => {
if (this.isDisposed()) { return; }
if (this.gristDoc.docModel.editingFormula()) {
if (this.editingFormula()) {
this.hoverColumn(index);
}
}, 0);
@ -1054,8 +1065,9 @@ GridView.prototype.buildDom = function() {
let filterTriggerCtl;
const isTooltip = ko.pureComputed(() =>
self.gristDoc.docModel.editingFormula() &&
ko.unwrap(self.hoverColumn) === field._index());
self.editingFormula() &&
ko.unwrap(self.hoverColumn) === field._index()
);
return dom(
'div.column_name.field',
kd.style('--frozen-position', () => ko.unwrap(this.frozenPositions.at(field._index()))),
@ -1070,7 +1082,7 @@ GridView.prototype.buildDom = function() {
dom.autoDispose(tooltip),
dom.autoDispose(isTooltip.subscribe((show) => {
if (show) {
tooltip.show(`Click to insert $${field.colId.peek()}`);
tooltip.show(t(`Click to insert`) + ` $${field.origCol.peek().colId.peek()}`);
} else {
tooltip.hide();
}
@ -1316,7 +1328,7 @@ GridView.prototype.buildDom = function() {
dom.autoDispose(isSelected),
dom.on("mouseenter", () => self.changeHover(field._index())),
kd.toggleClass("hover-column", () =>
self.gristDoc.docModel.editingFormula() &&
self.editingFormula() &&
ko.unwrap(self.hoverColumn) === (field._index())),
kd.style('width', field.widthPx),
//TODO: Ensure that fields in a row resize when
@ -1624,7 +1636,7 @@ GridView.prototype.dropCols = function() {
// column movement, propose renaming the column.
if (Date.now() - this._colClickTime < SHORT_CLICK_IN_MS && oldIndices.length === 1 &&
idx === oldIndices[0]) {
this.currentEditingColumnIndex(idx);
commands.allCommands.renameField.run();
}
this._colClickTime = 0;
this.cellSelector.currentDragType(selector.NONE);

@ -28,7 +28,7 @@ import {makeT} from 'app/client/lib/localization';
import {createSessionObs} from 'app/client/lib/sessionObs';
import {setTestState} from 'app/client/lib/testState';
import {selectFiles} from 'app/client/lib/uploads';
import {reportError} from 'app/client/models/AppModel';
import {AppModel, reportError} from 'app/client/models/AppModel';
import BaseRowModel from 'app/client/models/BaseRowModel';
import DataTableModel from 'app/client/models/DataTableModel';
import {DataTableModelWithDiff} from 'app/client/models/DataTableModelWithDiff';
@ -205,6 +205,7 @@ export class GristDoc extends DisposableWithEvents {
constructor(
public readonly app: App,
public readonly appModel: AppModel,
public readonly docComm: DocComm,
public readonly docPageModel: DocPageModel,
openDocResponse: OpenLocalDocResult,
@ -440,13 +441,7 @@ export class GristDoc extends DisposableWithEvents {
// Command to be manually triggered on cell selection. Moves the cursor to the selected cell.
// This is overridden by the formula editor to insert "$col" variables when clicking cells.
setCursor(rowModel: BaseRowModel, fieldModel?: ViewFieldRec) {
return this.setCursorPos({
rowIndex: rowModel?._index() || 0,
fieldIndex: fieldModel?._index() || 0,
sectionId: fieldModel?.viewSection().getRowId(),
});
},
setCursor: this.onSetCursorPos.bind(this),
}, this, true));
this.listenTo(app.comm, 'docUserAction', this.onDocUserAction);
@ -614,6 +609,14 @@ export class GristDoc extends DisposableWithEvents {
return Object.assign(pos, viewInstance ? viewInstance.cursor.getCursorPos() : {});
}
public async onSetCursorPos(rowModel: BaseRowModel|undefined, fieldModel?: ViewFieldRec) {
return this.setCursorPos({
rowIndex: rowModel?._index() || 0,
fieldIndex: fieldModel?._index() || 0,
sectionId: fieldModel?.viewSection().getRowId(),
});
}
public async setCursorPos(cursorPos: CursorPos) {
if (cursorPos.sectionId && cursorPos.sectionId !== this.externalSectionId.get()) {
const desiredSection: ViewSectionRec = this.docModel.viewSections.getRowModel(cursorPos.sectionId);
@ -1149,7 +1152,7 @@ export class GristDoc extends DisposableWithEvents {
* Opens up an editor at cursor position
* @param input Optional. Cell's initial value
*/
public async activateEditorAtCursor(options: { init?: string, state?: any}) {
public async activateEditorAtCursor(options?: { init?: string, state?: any}) {
const view = await this._waitForView();
view?.activateEditorAtCursor(options);
}

@ -31,8 +31,8 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {byteString} from 'app/common/gutil';
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
import {ParseOptions, ParseOptionSchema} from 'app/plugin/FileParserAPI';
import {Computed, dom, DomContents, fromKo, Holder, IDisposable, MultiHolder, MutableObsArray, obsArray, Observable,
styled} from 'grainjs';
import {Computed, Disposable, dom, DomContents, fromKo, Holder, IDisposable,
MultiHolder, MutableObsArray, obsArray, Observable, styled} from 'grainjs';
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
import {ACCESS_DENIED, AUTH_INTERRUPTED, canReadPrivateFiles, getGoogleCodeForReading} from 'app/client/ui/googleAuth';
import debounce = require('lodash/debounce');
@ -824,6 +824,7 @@ export class Importer extends DisposableWithEvents {
editingFormula: field.editingFormula,
refElem,
editRow,
canDetach: false,
setupCleanup: this._setupFormulaEditorCleanup.bind(this),
onSave: async (column, formula) => {
if (formula === column.formula.peek()) { return; }
@ -842,7 +843,7 @@ export class Importer extends DisposableWithEvents {
* focus.
*/
private _setupFormulaEditorCleanup(
owner: MultiHolder, _doc: GristDoc, editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>
owner: Disposable, _doc: GristDoc, editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>
) {
const saveEdit = () => _saveEdit().catch(reportError);

@ -108,6 +108,8 @@ export type CommandName =
| 'clearSectionLinks'
| 'transformUpdate'
| 'clearCopySelection'
| 'detachEditor'
| 'activateAssistant'
;
@ -259,6 +261,11 @@ export const groups: CommendGroupDef[] = [{
keys: [],
desc: 'Shortcut to open video tour from home left panel',
},
{
name: 'activateAssistant',
keys: [],
desc: 'Activate assistant',
},
]
}, {
group: 'Navigation',
@ -391,6 +398,10 @@ export const groups: CommendGroupDef[] = [{
name: 'fieldEditSave',
keys: ['Enter'],
desc: 'Finish editing a cell, saving the value'
}, {
name: 'detachEditor',
keys: [''],
desc: 'Detach active editor'
}, {
name: 'fieldEditSaveHere',
keys: [],

@ -8,18 +8,30 @@
*/
import * as Mousetrap from 'app/client/lib/Mousetrap';
import { arrayRemove } from 'app/common/gutil';
import {arrayRemove, unwrap} from 'app/common/gutil';
import dom from 'app/client/lib/dom';
import 'app/client/lib/koUtil'; // for subscribeInit
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {CommandDef, CommandName, CommendGroupDef, groups} from 'app/client/components/commandList';
import {Disposable} from 'grainjs';
import {Disposable, Observable} from 'grainjs';
import * as _ from 'underscore';
import * as ko from 'knockout';
const G = getBrowserGlobals('window');
type BoolLike = boolean|ko.Observable<boolean>|ko.Computed<boolean>;
type BoolLike = boolean|ko.Observable<boolean>|ko.Computed<boolean>|Observable<boolean>;
/**
* A helper method that can create a subscription to ko or grains observables.
*/
function subscribe(value: Exclude<BoolLike, boolean>, fn: (value: boolean) => void) {
if (ko.isObservable(value)) {
return value.subscribe(fn);
} else if (value instanceof Observable) {
return value.addListener(fn);
} else {
throw new Error('Expected an observable');
}
}
// Same logic as used by mousetrap to map 'Mod' key to platform-specific key.
export const isMac = (typeof navigator !== 'undefined' && navigator &&
@ -291,10 +303,11 @@ export class CommandGroup extends Disposable {
this.onDispose(this._removeGroup.bind(this));
// Finally, set the activation status of the command group, subscribing if an observable.
if (ko.isObservable(activate)) {
this.autoDispose((activate as any).subscribeInit(this.activate, this));
} else {
this.activate(activate as boolean);
if (typeof activate === 'boolean' || activate === undefined) {
this.activate(activate ?? false);
} else if (activate) {
this.autoDispose(subscribe(activate, (val) => this.activate(val)));
this.activate(unwrap(activate));
}
}
@ -343,8 +356,8 @@ type BoundedMap<T> = { [key in CommandName]?: BoundedFunc<T> };
/**
* Just a shorthand for CommandGroup.create constructor.
*/
export function createGroup<T>(commands: BoundedMap<T>, context: T, activate?: BoolLike) {
return CommandGroup.create(null, commands, context, activate);
export function createGroup<T>(commands: BoundedMap<T>|null, context: T, activate?: BoolLike) {
return CommandGroup.create(null, commands ?? {}, context, activate);
}
//----------------------------------------------------------------------

@ -249,7 +249,7 @@ function toggleDisabled(boolValueOrFunc) {
exports.toggleDisabled = toggleDisabled;
/**
* Adds a css class named by an observable value. If the value changes, the previous class will be
* Adds a css class (one or many) named by an observable value. If the value changes, the previous class will be
* removed and the new one added. The value may be empty to avoid adding any class.
* Similar to knockout's `css` binding with a dynamic class.
* @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable.
@ -258,11 +258,15 @@ function cssClass(valueOrFunc) {
var prevClass;
return makeBinding(valueOrFunc, function(elem, value) {
if (prevClass) {
elem.classList.remove(prevClass);
for(const name of prevClass.split(' ')) {
elem.classList.remove(name);
}
}
prevClass = value;
if (value) {
elem.classList.add(value);
for (const name of value.split(' ')) {
elem.classList.add(name);
}
}
});
}

@ -0,0 +1,63 @@
import {dom, Holder, IDisposable, MultiHolder} from 'grainjs';
/**
* Overrides the cursor style for the entire document.
* @returns {Disposable} - a Disposable that restores the cursor style to its original value.
*/
export function documentCursor(type: 'ns-resize' | 'grabbing'): IDisposable {
const cursorStyle: HTMLStyleElement = document.createElement('style');
cursorStyle.innerHTML = `*{cursor: ${type}!important;}`;
cursorStyle.id = 'cursor-style';
document.head.appendChild(cursorStyle);
const cursorOwner = {
dispose() {
if (this.isDisposed()) { return; }
document.head.removeChild(cursorStyle);
},
isDisposed() {
return !cursorStyle.isConnected;
}
};
return cursorOwner;
}
/**
* Helper function to create a movable element.
* @param options Handlers for the movable element.
*/
export function movable<T>(options: {
onMove: (dx: number, dy: number, state: T) => void,
onStart: () => T,
}) {
return (el: HTMLElement) => {
// Remember the initial position of the mouse.
let startX = 0;
let startY = 0;
dom.onElem(el, 'mousedown', (md) => {
// Only handle left mouse button.
if (md.button !== 0) { return; }
startX = md.clientX;
startY = md.clientY;
const state = options.onStart();
// We create a holder first so that we can dispose elements earlier on mouseup, and have a fallback
// in case of a situation when the dom is removed before mouseup.
const holder = new Holder();
const owner = MultiHolder.create(holder);
dom.autoDisposeElem(el, holder);
owner.autoDispose(dom.onElem(document, 'mousemove', (mv) => {
const dx = mv.clientX - startX;
const dy = mv.clientY - startY;
options.onMove(dx, dy, state);
}));
owner.autoDispose(dom.onElem(document, 'mouseup', () => {
holder.clear();
}));
owner.autoDispose(documentCursor('ns-resize'));
md.stopPropagation();
md.preventDefault();
}, { useCapture: true });
};
}

@ -285,7 +285,7 @@ export class AppModelImpl extends Disposable implements AppModel {
this.showNewSiteModal(state.params?.planType);
}
G.window.resetSeenPopups = (seen = false) => {
G.window.resetDismissedPopups = (seen = false) => {
this.dismissedPopups.set(seen ? DismissedPopup.values : []);
this.behavioralPromptsManager.reset();
};

@ -375,7 +375,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
const comparison = comparisonUrlId ?
await this._api.getDocAPI(urlId).compareDoc(comparisonUrlId, { detail: true }) : undefined;
const gristDoc = gdModule.GristDoc.create(flow, this._appObj, docComm, this, openDocResponse,
const gristDoc = gdModule.GristDoc.create(flow, this._appObj, this.appModel, docComm, this, openDocResponse,
this.appModel.topAppModel.plugins, {comparison});
// Move ownership of docComm to GristDoc.

@ -200,7 +200,11 @@ export interface ChatMessage {
/**
* The formula returned from the AI. It is only set when the sender is the AI.
*/
formula?: string;
formula?: string|null;
/**
* Suggested actions returned from the AI.
*/
action?: any;
}
/**

@ -135,9 +135,26 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
// CSS class to add to formula cells, incl. to show that we are editing this field's formula.
this.formulaCssClass = ko.pureComputed<string|null>(() => {
const col = this.column();
return this.column().isTransforming() ? "transform_field" :
(this.editingFormula() ? "formula_field_edit" :
(col.isFormula() && col.formula() !== "" ? "formula_field" : null));
// If the current column is transforming, assign the CSS class "transform_field"
if (col.isTransforming()) {
if ( col.origCol().isFormula() && col.origCol().formula() !== "") {
return "transform_field formula_field";
}
return "transform_field";
}
// If the column is not transforming but a formula is being edited
else if (this.editingFormula()) {
return "formula_field_edit";
}
// If a formula exists and it is not empty
else if (col.isFormula() && col.formula() !== "") {
return "formula_field";
}
// If none of the above conditions are met, assign null
else {
return null;
}
});
// The fields's display column

@ -3,7 +3,6 @@ import {CursorPos} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc';
import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec';
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
import {buildAiButton} from 'app/client/ui/FormulaAssistance';
import {GristTooltips} from 'app/client/ui/GristTooltips';
import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {withInfoTooltip} from 'app/client/ui/tooltips';
@ -13,7 +12,6 @@ import {testId, theme} from 'app/client/ui2018/cssVars';
import {textInput} from 'app/client/ui2018/editableLabel';
import {cssIconButton, icon} from 'app/client/ui2018/icons';
import {IconName} from 'app/client/ui2018/IconList';
import {GRIST_FORMULA_ASSISTANT} from 'app/client/models/features';
import {selectMenu, selectOption, selectTitle} from 'app/client/ui2018/menus';
import {createFormulaErrorObs, cssError} from 'app/client/widgets/FormulaEditor';
import {sanitizeIdent} from 'app/common/gutil';
@ -134,6 +132,8 @@ export function buildFormulaConfig(
// Helper function to clear temporary state (will be called when column changes or formula editor closes)
const clearState = () => bundleChanges(() => {
// For a detached editor, we may have already been disposed when user switched page.
if (owner.isDisposed()) { return; }
maybeFormula.set(false);
maybeTrigger.set(false);
formulaField = null;
@ -277,6 +277,8 @@ export function buildFormulaConfig(
// Converts column to formula column.
const onSaveConvertToFormula = async (column: ColumnRec, formula: string) => {
// For a detached editor, we may have already been disposed when user switched page.
if (owner.isDisposed()) { return; }
// For non formula column, we will not convert it to formula column when expression is empty,
// as it means we were trying to convert data column to formula column, but changed our mind.
const notBlank = Boolean(formula);
@ -362,7 +364,6 @@ export function buildFormulaConfig(
]),
formulaBuilder(onSaveConvertToFormula),
cssEmptySeparator(),
dom.maybe(GRIST_FORMULA_ASSISTANT(), () => cssRow(buildAiButton(gristDoc, origColumn))),
cssRow(textButton(
t("Convert to trigger formula"),
dom.on("click", convertFormulaToTrigger),

@ -1,7 +1,9 @@
import {documentCursor} from 'app/client/lib/popupUtils';
import {hoverTooltip} from 'app/client/ui/tooltips';
import {isNarrowScreen, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {Disposable, dom, DomArg, DomContents, IDisposable, makeTestId, Observable, styled} from 'grainjs';
import {Disposable, dom, DomContents, DomElementArg,
IDisposable, makeTestId, Observable, styled} from 'grainjs';
const POPUP_INITIAL_PADDING_PX = 16;
const POPUP_MIN_HEIGHT = 300;
@ -15,9 +17,11 @@ export interface PopupOptions {
content?: () => DomContents;
onClose?: () => void;
closeButton?: boolean;
closeButtonHover?: () => DomContents;
autoHeight?: boolean;
/** Defaults to false. */
stopClickPropagationOnMove?: boolean;
args?: DomElementArg[];
}
export class FloatingPopup extends Disposable {
@ -34,7 +38,7 @@ export class FloatingPopup extends Disposable {
private _resize = false;
private _cursorGrab: IDisposable|null = null;
constructor(protected _options: PopupOptions = {}, private _args: DomArg[] = []) {
constructor(protected _options: PopupOptions = {}) {
super();
if (_options.stopClickPropagationOnMove){
@ -98,7 +102,7 @@ export class FloatingPopup extends Disposable {
}
protected _buildArgs(): any {
return this._args;
return this._options.args ?? [];
}
private _rememberPosition() {
@ -272,12 +276,12 @@ export class FloatingPopup extends Disposable {
// Copy buttons on the left side of the header, to automatically
// center the title.
cssPopupButtons(
!this._options.closeButton ? null : cssPopupHeaderButton(
icon('CrossSmall'),
),
cssPopupHeaderButton(
icon('Maximize')
),
!this._options.closeButton ? null : cssPopupHeaderButton(
icon('CrossBig'),
),
dom.style('visibility', 'hidden'),
),
cssPopupTitle(
@ -285,19 +289,20 @@ export class FloatingPopup extends Disposable {
testId('title'),
),
cssPopupButtons(
!this._options.closeButton ? null : cssPopupHeaderButton(
icon('CrossSmall'),
dom.on('click', () => {
this._options.onClose?.() ?? this._closePopup();
}),
testId('close'),
),
this._popupMinimizeButtonElement = cssPopupHeaderButton(
isMinimized ? icon('Maximize'): icon('Minimize'),
hoverTooltip(isMinimized ? 'Maximize' : 'Minimize', {key: 'docTutorialTooltip'}),
dom.on('click', () => this._minimizeOrMaximize()),
testId('minimize-maximize'),
),
!this._options.closeButton ? null : cssPopupHeaderButton(
icon('CrossBig'),
dom.on('click', () => {
this._options.onClose?.() ?? this._closePopup();
}),
testId('close'),
this._options.closeButtonHover && hoverTooltip(this._options.closeButtonHover())
),
// Disable dragging when a button in the header is clicked.
dom.on('mousedown', ev => ev.stopPropagation()),
dom.on('touchstart', ev => ev.stopPropagation()),
@ -345,20 +350,7 @@ export class FloatingPopup extends Disposable {
private _forceCursor() {
this._cursorGrab?.dispose();
const type = this._resize ? 'ns-resize' : 'grabbing';
const cursorStyle: HTMLStyleElement = document.createElement('style');
cursorStyle.innerHTML = `*{cursor: ${type}!important;}`;
cursorStyle.id = 'cursor-style';
document.head.appendChild(cursorStyle);
const cursorOwner = {
dispose() {
if (this.isDisposed()) { return; }
document.head.removeChild(cursorStyle);
},
isDisposed() {
return !cursorStyle.isConnected;
}
};
this._cursorGrab = cursorOwner;
this._cursorGrab = documentCursor(type);
}
}
@ -372,7 +364,7 @@ const POPUP_HEIGHT = `min(var(--height), calc(100% - (2 * ${POPUP_INITIAL_PADDIN
const POPUP_HEIGHT_MOBILE = `min(var(--height), calc(100% - (2 * ${POPUP_INITIAL_PADDING_PX}px) - (2 * 50px)))`;
const POPUP_WIDTH = `min(436px, calc(100% - (2 * ${POPUP_INITIAL_PADDING_PX}px)))`;
const cssPopup = styled('div', `
const cssPopup = styled('div.floating-popup', `
position: fixed;
display: flex;
flex-direction: column;

@ -1,605 +0,0 @@
import * as AceEditor from 'app/client/components/AceEditor';
import {GristDoc} from 'app/client/components/GristDoc';
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
import {buildHighlightedCode} from 'app/client/ui/CodeHighlight';
import {FloatingPopup} from 'app/client/ui/FloatingPopup';
import {createUserImage} from 'app/client/ui/UserImage';
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
import {theme} from 'app/client/ui2018/cssVars';
import {cssTextInput, rawTextInput} from 'app/client/ui2018/editableLabel';
import {icon} from 'app/client/ui2018/icons';
import {AssistanceResponse, AssistanceState} from 'app/common/AssistancePrompts';
import {Disposable, dom, makeTestId, MultiHolder, obsArray, Observable, styled} from 'grainjs';
import noop from 'lodash/noop';
const testId = makeTestId('test-assistant-');
export function buildAiButton(grist: GristDoc, column: ColumnRec) {
column = grist.docModel.columns.createFloatingRowModel(column.origColRef);
return textButton(
dom.autoDispose(column),
'Open AI assistant',
testId('open-button'),
dom.on('click', () => openAIAssistant(grist, column))
);
}
interface Context {
grist: GristDoc;
column: ColumnRec;
}
function buildFormula(owner: MultiHolder, props: Context) {
const { grist, column } = props;
const formula = Observable.create(owner, column.formula.peek());
const calcSize = (fullDom: HTMLElement, size: any) => {
return {
width: fullDom.clientWidth,
height: size.height,
};
};
const editor = AceEditor.create({
column,
gristDoc: grist,
calcSize,
editorState: formula,
});
owner.autoDispose(editor);
const buildDom = () => {
return cssFormulaWrapper(
dom.cls('formula_field_sidepane'),
editor.buildDom((aceObj: any) => {
aceObj.setFontSize(11);
aceObj.setHighlightActiveLine(false);
aceObj.getSession().setUseWrapMode(false);
aceObj.renderer.setPadding(0);
setTimeout(() => editor.editor.focus());
editor.setValue(formula.get());
}),
);
};
return {
buildDom,
set(value: string) {
editor.setValue(value);
},
get() {
return editor.getValue();
},
};
}
function buildControls(
owner: MultiHolder,
props: Context & {
currentFormula: () => string;
savedClicked: () => void,
robotClicked: () => void,
}
) {
const hasHistory = props.column.chatHistory.peek().get().messages.length > 0;
// State variables, to show various parts of the UI.
const saveButtonVisible = Observable.create(owner, true);
const previewButtonVisible = Observable.create(owner, true);
const robotWellVisible = Observable.create(owner, !hasHistory);
const robotButtonVisible = Observable.create(owner, !hasHistory && !robotWellVisible.get());
const helpWellVisible = Observable.create(owner, !hasHistory);
// Click handlers
const saveClicked = async () => {
await preview();
props.savedClicked();
};
const previewClicked = async () => await preview();
const robotClicked = () => props.robotClicked();
// Public API
const preview = async () => {
// Currently we don't have a preview, so just save.
const formula = props.currentFormula();
const tableId = props.column.table.peek().tableId.peek();
const colId = props.column.colId.peek();
props.grist.docData.sendAction([
'ModifyColumn',
tableId,
colId,
{ formula, isFormula: true },
]).catch(reportError);
};
const buildWells = () => {
return cssContainer(
cssWell(
'Grists AI Formula Assistance. Need help? Our AI assistant can help. ',
textButton('Ask the bot.', dom.on('click', robotClicked)),
dom.show(robotWellVisible)
),
cssWell(
'Formula Help. See our Function List and Formula Cheat Sheet, or visit our Community for more help.',
dom.show(helpWellVisible)
),
dom.show(use => use(robotWellVisible) || use(helpWellVisible))
);
};
const buildDom = () => {
return [
cssButtonsWrapper(
cssButtons(
primaryButton('Save', dom.show(saveButtonVisible), dom.on('click', saveClicked)),
basicButton('Preview', dom.show(previewButtonVisible), dom.on('click', previewClicked)),
textButton('🤖', dom.show(robotButtonVisible), dom.on('click', robotClicked)),
dom.show(
use => use(previewButtonVisible) || use(saveButtonVisible) || use(robotButtonVisible)
)
)
),
buildWells(),
];
};
return {
buildDom,
preview,
hideHelp() {
robotWellVisible.set(false);
helpWellVisible.set(false);
},
hideRobot() {
robotButtonVisible.set(false);
}
};
}
function buildChat(owner: Disposable, context: Context & { formulaClicked: (formula?: string) => void }) {
const { grist, column } = context;
const history = owner.autoDispose(obsArray(column.chatHistory.peek().get().messages));
const hasHistory = history.get().length > 0;
const enabled = Observable.create(owner, hasHistory);
const introVisible = Observable.create(owner, !hasHistory);
owner.autoDispose(history.addListener((cur) => {
const chatHistory = column.chatHistory.peek();
chatHistory.set({...chatHistory.get(), messages: [...cur]});
}));
const submit = async (regenerate: boolean = false) => {
// Send most recent question, and send back any conversation
// state we have been asked to track.
const chatHistory = column.chatHistory.peek().get();
const messages = chatHistory.messages.filter(msg => msg.sender === 'user');
const description = messages[messages.length - 1]?.message || '';
console.debug('description', {description});
const {reply, suggestedActions, state} = await askAI(grist, {
column, description, state: chatHistory.state,
regenerate,
});
console.debug('suggestedActions', {suggestedActions, reply});
const firstAction = suggestedActions[0] as any;
// Add the formula to the history.
const formula = firstAction ? firstAction[3].formula as string : undefined;
// Add to history
history.push({
message: formula || reply || '(no reply)',
sender: 'ai',
formula
});
// If back-end is capable of conversation, keep its state.
if (state) {
const chatHistoryNew = column.chatHistory.peek();
const value = chatHistoryNew.get();
value.state = state;
chatHistoryNew.set(value);
}
return formula;
};
const chatEnterClicked = async (val: string) => {
if (!val) { return; }
// Hide intro.
introVisible.set(false);
// Add question to the history.
history.push({
message: val,
sender: 'user',
});
// Submit all questions to the AI.
context.formulaClicked(await submit());
};
const regenerateClick = async () => {
// Remove the last AI response from the history.
history.pop();
// And submit again.
context.formulaClicked(await submit(true));
};
const newChat = () => {
// Clear the history.
history.set([]);
column.chatHistory.peek().set({messages: []});
// Show intro.
introVisible.set(true);
};
const userPrompt = Observable.create(owner, '');
const userImage = () => {
const user = grist.app.topAppModel.appObs.get()?.currentUser || null;
if (user) {
return (createUserImage(user, 'medium'));
} else {
// TODO: this will not happen, as this should be only for logged in users.
return (dom('div', ''));
}
};
const buildHistory = () => {
return cssVBox(
dom.forEach(history, entry => {
if (entry.sender === 'user') {
return cssMessage(
cssAvatar(userImage()),
dom.text(entry.message),
);
} else {
return cssAiMessage(
cssAvatar(cssAiImage()),
buildHighlightedCode(entry.message, {
gristTheme: grist.currentTheme,
maxLines: 10,
}, cssCodeStyles.cls('')),
cssCopyIconWrapper(
icon('Copy', dom.on('click', () => context.formulaClicked(entry.message))),
)
);
}
})
);
};
const buildIntro = () => {
return cssVBox(
dom.cls(cssTopGreenBorder.className),
dom.cls(cssTypography.className),
dom.style('flex-grow', '1'),
dom.style('min-height', '0'),
dom.style('overflow-y', 'auto'),
dom.maybe(introVisible, () =>
cssHContainer(
dom.style('margin-bottom', '10px'),
cssVBox(
dom('h4', 'Grists AI Assistance'),
dom('h5', 'Tips'),
cssWell(
'“Example prompt” Some instructions for how to draft a prompt. A link to even more examples in support.'
),
cssWell(
'Example Values. Instruction about entering example values in the column, maybe with an image?'
),
dom('h5', 'Capabilities'),
cssWell(
'Formula Assistance Only. Python code. Spreadsheet functions? May sometimes get it wrong. '
),
cssWell('Conversational. Remembers what was said and allows follow-up corrections.'),
dom('h5', 'Data'),
cssWell(
'Data Usage. Something about how we can see prompts to improve the feature and product, but cannot see doc.'
),
cssWell(
'Data Sharing. Something about OpenAI, whats being transmitted. How does it expose doc data?'
),
textButton('Learn more', dom.style('align-self', 'flex-start'))
)
)
),
dom.maybe(
use => !use(introVisible),
() => buildHistory()
),
);
};
const buildButtons = () => {
// We will show buttons only if we have a history.
return dom.maybe(use => use(history).length > 0, () => cssVContainer(
cssHBox(
cssPlainButton(icon('Script'), 'New Chat', dom.on('click', newChat)),
cssPlainButton(icon('Revert'), 'Regenerate', dom.on('click', regenerateClick), dom.style('margin-left', '8px')),
),
dom.style('padding-bottom', '0'),
dom.style('padding-top', '12px'),
));
};
const buildInput = () => {
return cssHContainer(
dom.cls(cssTopBorder.className),
dom.cls(cssVSpace.className),
cssInputWrapper(
dom.cls(cssTextInput.className),
dom.cls(cssTypography.className),
rawTextInput(userPrompt, chatEnterClicked, noop),
icon('FieldAny')
),
buildButtons()
);
};
const buildDom = () => {
return dom.maybe(enabled, () => cssVFullBox(
buildIntro(),
cssSpacer(),
buildInput(),
dom.style('overflow', 'hidden'),
dom.style('flex-grow', '1')
));
};
return {
buildDom,
show() {
enabled.set(true);
introVisible.set(true);
}
};
}
/**
* Builds and opens up a Formula Popup with an AI assistant.
*/
function openAIAssistant(grist: GristDoc, column: ColumnRec) {
const owner = MultiHolder.create(null);
const props: Context = { grist, column };
// Build up all components, and wire up them to each other.
// First is the formula editor displayed in the upper part of the popup.
const formulaEditor = buildFormula(owner, props);
// Next are the buttons in the middle. It has a Save, Preview, and Robot button, and probably some wells
// with tips or other buttons.
const controls = buildControls(owner, {
...props,
// Pass a formula accessor, it is used to get the current formula and apply or preview it.
currentFormula: () => formulaEditor.get(),
// Event or saving, we listen to it to close the popup.
savedClicked() {
grist.formulaPopup.clear();
},
// Handler for robot icon click. We hide the robot icon and the help, and show the chat area.
robotClicked() {
chat.show();
controls.hideHelp();
controls.hideRobot();
}
});
// Now, the chat area. It has a history of previous questions, and a prompt for the user to ask a new
// question.
const chat = buildChat(owner, {...props,
// When a formula is clicked (or just was returned from the AI), we set it in the formula editor and hit
// the preview button.
formulaClicked: (formula?: string) => {
if (formula) {
formulaEditor.set(formula);
controls.preview().catch(reportError);
}
},
});
const header = `${column.table.peek().tableNameDef.peek()}.${column.label.peek()}`;
const popup = FloatingPopup.create(null, {
title: () => header,
content: () => [
formulaEditor.buildDom(),
controls.buildDom(),
chat.buildDom(),
],
onClose: () => grist.formulaPopup.clear(),
closeButton: true,
autoHeight: true,
});
popup.autoDispose(owner);
popup.showPopup();
// Add this popup to the main holder (and dispose the previous one).
grist.formulaPopup.autoDispose(popup);
}
async function askAI(grist: GristDoc, options: {
column: ColumnRec,
description: string,
regenerate?: boolean,
state?: AssistanceState
}): Promise<AssistanceResponse> {
const {column, description, state, regenerate} = options;
const tableId = column.table.peek().tableId.peek();
const colId = column.colId.peek();
try {
const result = await grist.docComm.getAssistance({
context: {type: 'formula', tableId, colId},
text: description,
state,
regenerate,
});
return result;
} catch (error) {
reportError(error);
throw error;
}
}
const cssVBox = styled('div', `
display: flex;
flex-direction: column;
`);
const cssFormulaWrapper = styled('div.formula_field_edit.formula_editor', `
position: relative;
padding: 5px 0 5px 24px;
flex: auto;
overflow-y: auto;
`);
const cssVFullBox = styled(cssVBox, `
flex-grow: 1;
`);
const cssHBox = styled('div', `
display: flex;
`);
const cssVSpace = styled('div', `
padding-top: 18px;
padding-bottom: 18px;
`);
const cssHContainer = styled('div', `
padding-left: 18px;
padding-right: 18px;
display: flex;
flex-direction: column;
`);
const cssButtonsWrapper = styled(cssHContainer, `
padding-top: 0;
background-color: ${theme.formulaEditorBg};
`);
const cssVContainer = styled('div', `
padding-top: 18px;
padding-bottom: 18px;
display: flex;
flex-direction: column;
`);
const cssContainer = styled('div', `
padding: 18px;
display: flex;
flex-direction: column;
`);
const cssSpacer = styled('div', `
margin-top: auto;
display: flex;
`);
const cssButtons = styled('div', `
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 8px 0px;
`);
const cssWell = styled('div', `
padding: 8px;
color: ${theme.inputFg};
border-radius: 4px;
background-color: ${theme.rightPanelBg};
& + & {
margin-top: 8px;
}
`);
const cssMessage = styled('div', `
display: grid;
grid-template-columns: 60px 1fr;
padding-right: 54px;
padding-top: 12px;
padding-bottom: 12px;
`);
const cssAiMessage = styled('div', `
display: grid;
grid-template-columns: 60px 1fr 54px;
padding-top: 20px;
padding-bottom: 20px;
background: #D9D9D94f;
`);
const cssCodeStyles = styled('div', `
background: #E3E3E3;
border: none;
& .ace-chrome {
background: #E3E3E3;
border: none;
}
`);
const cssAvatar = styled('div', `
display: flex;
align-items: flex-start;
justify-content: center;
`);
const cssAiImage = styled('div', `
flex: none;
height: 32px;
width: 32px;
border-radius: 50%;
background-color: white;
background-image: var(--icon-GristLogo);
background-size: 22px 22px;
background-repeat: no-repeat;
background-position: center;
`);
const cssTopGreenBorder = styled('div', `
border-top: 1px solid ${theme.accentBorder};
`);
const cssTypography = styled('div', `
color: ${theme.inputFg};
`);
const cssTopBorder = styled('div', `
border-top: 1px solid ${theme.inputBorder};
`);
const cssCopyIconWrapper = styled('div', `
display: none;
align-items: center;
justify-content: center;
flex: none;
cursor: pointer;
.${cssAiMessage.className}:hover & {
display: flex;
}
`);
const cssInputWrapper = styled('div', `
display: flex;
background-color: ${theme.mainPanelBg};
align-items: center;
gap: 8px;
padding-right: 8px !important;
--icon-color: ${theme.controlSecondaryFg};
&:hover, &:focus-within {
--icon-color: ${theme.accentIcon};
}
& > input {
outline: none;
padding: 0px;
align-self: stretch;
flex: 1;
border: none;
background-color: inherit;
}
`);
const cssPlainButton = styled(basicButton, `
border-color: ${theme.inputBorder};
color: ${theme.controlSecondaryFg};
--icon-color: ${theme.controlSecondaryFg};
display: inline-flex;
gap: 10px;
align-items: flex-end;
border-radius: 3px;
padding: 5px 7px;
padding-right: 13px;
`);

@ -86,6 +86,7 @@ function resize(el: HTMLTextAreaElement) {
export function autoGrow(text: Observable<string>) {
return (el: HTMLTextAreaElement) => {
el.addEventListener('input', () => resize(el));
dom.autoDisposeElem(el, text.addListener(() => resize(el)));
setTimeout(() => resize(el), 10);
dom.autoDisposeElem(el, text.addListener(val => {
// Changes to the text are not reflected by the input event (witch is used by the autoGrow)

@ -266,7 +266,11 @@ export function setHoverTooltip(
*/
export function tooltipCloseButton(ctl: ITooltipControl): HTMLElement {
return cssTooltipCloseButton(icon('CrossSmall'),
dom.on('click', () => ctl.close()),
dom.on('mousedown', (ev) =>{
ev.stopPropagation();
ev.preventDefault();
ctl.close();
}),
testId('tooltip-close'),
);
}

@ -113,6 +113,7 @@ export type IconName = "ChartArea" |
"ResizePanel" |
"Revert" |
"RightAlign" |
"Robot" |
"Script" |
"Search" |
"Settings" |
@ -254,6 +255,7 @@ export const IconList: IconName[] = ["ChartArea",
"ResizePanel",
"Revert",
"RightAlign",
"Robot",
"Script",
"Search",
"Settings",

@ -200,6 +200,7 @@ export class ConditionalStyle extends Disposable {
editRow: vsi?.moveEditRowToCursor(),
refElem,
setupCleanup: setupEditorCleanup,
canDetach: false,
});
// Add editor to document holder - this will prevent multiple formula editor instances.
this._gristDoc.fieldEditorHolder.autoDispose(editorHolder);

@ -4,6 +4,8 @@ import { FormulaTransform } from 'app/client/components/FormulaTransform';
import { GristDoc } from 'app/client/components/GristDoc';
import { addColTypeSuffix } from 'app/client/components/TypeConversion';
import { TypeTransform } from 'app/client/components/TypeTransform';
import { FloatingEditor } from 'app/client/widgets/FloatingEditor';
import { UnsavedChange } from 'app/client/components/UnsavedChanges';
import dom from 'app/client/lib/dom';
import { KoArray } from 'app/client/lib/koArray';
import * as kd from 'app/client/lib/koDom';
@ -23,7 +25,7 @@ import { theme } from 'app/client/ui2018/cssVars';
import { IOptionFull, menu, select } from 'app/client/ui2018/menus';
import { DiffBox } from 'app/client/widgets/DiffBox';
import { buildErrorDom } from 'app/client/widgets/ErrorDom';
import { FieldEditor, saveWithoutEditor, setupEditorCleanup } from 'app/client/widgets/FieldEditor';
import { FieldEditor, saveWithoutEditor } from 'app/client/widgets/FieldEditor';
import { CellDiscussionPopup, EmptyCell } from 'app/client/widgets/DiscussionEditor';
import { openFormulaEditor } from 'app/client/widgets/FormulaEditor';
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
@ -34,7 +36,7 @@ import * as gristTypes from 'app/common/gristTypes';
import { getReferencedTableId, isFullReferencingType } from 'app/common/gristTypes';
import { CellValue } from 'app/plugin/GristData';
import { Computed, Disposable, fromKo, dom as grainjsDom,
Holder, IDisposable, makeTestId, MultiHolder, styled, toKo } from 'grainjs';
makeTestId, MultiHolder, Observable, styled, toKo } from 'grainjs';
import * as ko from 'knockout';
import * as _ from 'underscore';
@ -100,19 +102,19 @@ export class FieldBuilder extends Disposable {
private readonly _rowMap: Map<DataRowModel, Element>;
private readonly _isTransformingFormula: ko.Computed<boolean>;
private readonly _isTransformingType: ko.Computed<boolean>;
private readonly _fieldEditorHolder: Holder<IDisposable>;
private readonly _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>;
private readonly _docModel: DocModel;
private readonly _readonly: Computed<boolean>;
private readonly _comments: ko.Computed<boolean>;
private readonly _showRefConfigPopup: ko.Observable<boolean>;
private readonly _isEditorActive = Observable.create(this, false);
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
super();
this._docModel = gristDoc.docModel;
this.origColumn = field.column();
this.origColumn = field.origCol();
this.options = field.widgetOptionsJson;
this._comments = ko.pureComputed(() => toKo(ko, COMMENTS())());
@ -183,10 +185,6 @@ export class FieldBuilder extends Disposable {
(this.columnTransform instanceof TypeTransform);
}));
// This holds a single FieldEditor. When a new FieldEditor is created (on edit), it replaces the
// previous one if any.
this._fieldEditorHolder = Holder.create(this);
// Map from rowModel to cell dom for the field to which this fieldBuilder applies.
this._rowMap = new Map();
@ -580,7 +578,7 @@ export class FieldBuilder extends Disposable {
if (this.isDisposed()) { return null; } // Work around JS errors during field removal.
const value = row.cells[this.field.colId()];
const cell = value && value();
if ((value) && this._isRightType()(cell, this.options) || row._isAddRow.peek()) {
if ((value as any) && this._isRightType()(cell, this.options) || row._isAddRow.peek()) {
return this.widgetImpl();
} else if (gristTypes.isVersions(cell)) {
return this.diffImpl;
@ -677,39 +675,40 @@ export class FieldBuilder extends Disposable {
return;
}
// Clear previous editor. Some caveats:
// - The floating editor has an async cleanup routine, but it promises that it won't affect as.
// - All other editors should be synchronous, so this line will remove all opened editors.
const holder = this.gristDoc.fieldEditorHolder;
// If the global editor is from our own field, we will dispose it immediately, otherwise we will
// rely on the clipboard to dispose it by grabbing focus.
const clearOwn = () => this.isEditorActive() && holder.clear();
// If this is censored value, don't open up the editor, unless it is a formula field.
const cell = editRow.cells[this.field.colId()];
const value = cell && cell();
if (gristTypes.isCensored(value) && !this.origColumn.isFormula.peek()) {
this._fieldEditorHolder.clear();
return;
return clearOwn();
}
const editorCtor: typeof NewBaseEditor =
UserTypeImpl.getEditorConstructor(this.options().widget, this._readOnlyPureType());
// constructor may be null for a read-only non-formula field, though not today.
if (!editorCtor) {
// Actually, we only expect buildEditorDom() to be called when isEditorActive() is false (i.e.
// _fieldEditorHolder is already clear), but clear here explicitly for clarity.
this._fieldEditorHolder.clear();
return;
return clearOwn();
}
// if editor doesn't support readonly mode, don't show it
if (this._readonly.get() && editorCtor.supportsReadonly && !editorCtor.supportsReadonly()) {
this._fieldEditorHolder.clear();
return;
return clearOwn();
}
if (!this._readonly.get() && saveWithoutEditor(editorCtor, editRow, this.field, options.init)) {
this._fieldEditorHolder.clear();
return;
return clearOwn();
}
const cellElem = this._rowMap.get(mainRowModel)!;
// The editor may dispose itself; the Holder will know to clear itself in this case.
const fieldEditor = FieldEditor.create(this._fieldEditorHolder, {
const fieldEditor = FieldEditor.create(holder, {
gristDoc: this.gristDoc,
field: this.field,
cursor: this._cursor,
@ -720,15 +719,13 @@ export class FieldBuilder extends Disposable {
startVal: this._readonly.get() ? undefined : options.init, // don't start with initial value
readonly: this._readonly.get() // readonly for editor will not be observable
});
// Put the FieldEditor into a holder in GristDoc too. This way any existing FieldEditor (perhaps
// for another field, or for another BaseView) will get disposed at this time. The reason to
// still maintain a Holder in this FieldBuilder is mainly to match older behavior; changing that
// will entail a number of other tweaks related to the order of creating and disposal.
this.gristDoc.fieldEditorHolder.autoDispose(fieldEditor);
this._isEditorActive.set(true);
// expose the active editor in a grist doc as an observable
fieldEditor.onDispose(() => this.gristDoc.activeEditor.set(null));
fieldEditor.onDispose(() => {
this._isEditorActive.set(false);
this.gristDoc.activeEditor.set(null);
});
this.gristDoc.activeEditor.set(fieldEditor);
}
@ -742,11 +739,12 @@ export class FieldBuilder extends Disposable {
if (editRow._isAddRow.peek() || this._readonly.get()) {
return;
}
const holder = this.gristDoc.fieldEditorHolder;
const cell = editRow.cells[this.field.colId()];
const value = cell && cell();
if (gristTypes.isCensored(value)) {
this._fieldEditorHolder.clear();
holder.clear();
return;
}
@ -770,7 +768,8 @@ export class FieldBuilder extends Disposable {
}
public isEditorActive() {
return !this._fieldEditorHolder.isEmpty();
const holder = this.gristDoc.fieldEditorHolder;
return !holder.isEmpty() && this._isEditorActive.get();
}
/**
@ -782,19 +781,74 @@ export class FieldBuilder extends Disposable {
editValue?: string,
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
onCancel?: () => void) {
const editorHolder = openFormulaEditor({
// Remember position when the popup was opened.
const position = this.gristDoc.cursorPosition.get();
// Create a controller for the floating editor. It is primarily responsible for moving the editor
// dom from the place where it was rendered to the popup (and moving it back).
const floatController = {
attach: async (content: HTMLElement) => {
// If we haven't change page and the element is still in the DOM, move the editor to the
// back to where it was rendered. It still has it's content, so no need to dispose it.
if (refElem.isConnected) {
formulaEditor.attach(refElem);
} else {
// Else, we will navigate to the position we left off, dispose the editor and the content.
formulaEditor.dispose();
grainjsDom.domDispose(content);
await this.gristDoc.recursiveMoveToCursorPos(position!, true);
}
},
detach() {
return formulaEditor.detach();
},
autoDispose(el: Disposable) {
return formulaEditor.autoDispose(el);
},
dispose() {
formulaEditor.dispose();
}
};
// Create a custom cleanup method, that won't destroy us when we loose focus while being detached.
function setupEditorCleanup(
owner: MultiHolder, gristDoc: GristDoc,
editingFormula: ko.Computed<boolean>, _saveEdit: () => Promise<unknown>
) {
// Just override the behavior on focus lost.
const saveOnFocus = () => floatingExtension.active.get() ? void 0 : _saveEdit().catch(reportError);
UnsavedChange.create(owner, async () => { await saveOnFocus(); });
gristDoc.app.on('clipboard_focus', saveOnFocus);
owner.onDispose(() => {
gristDoc.app.off('clipboard_focus', saveOnFocus);
editingFormula(false);
});
}
// Get the field model from metatables, as the one provided by the caller might be some floating one, that
// will change when user navigates around.
const field = this.gristDoc.docModel.viewFields.getRowModel(this.field.getRowId());
// Finally create the editor passing only the field, which will enable detachable flavor of formula editor.
const formulaEditor = openFormulaEditor({
gristDoc: this.gristDoc,
column: this.field.column(),
field,
editingFormula: this.field.editingFormula,
setupCleanup: setupEditorCleanup,
editRow,
refElem,
editValue,
canDetach: true,
onSave,
onCancel
});
// And now create the floating editor itself. It is just a floating wrapper that will grab the dom
// from the editor and show it in the popup. It also overrides various parts of Grist to make smoother experience.
const floatingExtension = FloatingEditor.create(formulaEditor, floatController, this.gristDoc);
// Add editor to document holder - this will prevent multiple formula editor instances.
this.gristDoc.fieldEditorHolder.autoDispose(editorHolder);
this.gristDoc.fieldEditorHolder.autoDispose(formulaEditor);
}
}

@ -1,5 +1,5 @@
import * as commands from 'app/client/components/commands';
import {Cursor} from 'app/client/components/Cursor';
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';
@ -12,9 +12,10 @@ import {IEditorCommandGroup, NewBaseEditor} from 'app/client/widgets/NewBaseEdit
import {asyncOnce} from "app/common/AsyncCreate";
import {CellValue} from "app/common/DocActions";
import * as gutil from 'app/common/gutil';
import {Disposable, Emitter, Holder, MultiHolder} from 'grainjs';
import isEqual = require('lodash/isEqual');
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;
@ -63,6 +64,7 @@ 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;
@ -76,6 +78,8 @@ export class FieldEditor extends Disposable {
private _editorHasChanged = false;
private _isFormula = false;
private _readonly = false;
private _detached = Observable.create(this, false);
private _detachedAt: CursorPos|null = null;
constructor(options: {
gristDoc: GristDoc,
@ -154,6 +158,9 @@ export class FieldEditor extends Disposable {
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();
}
@ -162,9 +169,16 @@ export class FieldEditor extends Disposable {
// 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, this._saveEdit);
setupEditorCleanup(this, this._gristDoc, this._field.editingFormula, onCleanup);
} else {
setupReadonlyEditorCleanup(this, this._gristDoc, this._field, () => this._cancelEdit());
}
@ -190,7 +204,13 @@ export class FieldEditor extends Disposable {
cellValue = cellCurrentValue;
}
const error = getFormulaError(this._gristDoc, this._editRow, column);
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
@ -198,9 +218,11 @@ export class FieldEditor extends Disposable {
// 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);
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({
@ -214,10 +236,13 @@ export class FieldEditor extends Disposable {
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) => {
@ -235,6 +260,28 @@ export class FieldEditor extends Disposable {
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();
}
@ -242,7 +289,7 @@ export class FieldEditor extends Disposable {
// calculate current cell's absolute position
public cellPosition() {
const rowId = this._editRow.getRowId();
const colRef = this._field.colRef.peek();
const colRef = this._field.column.peek().origColRef.peek();
const sectionId = this._field.viewSection.peek().id.peek();
const position = {
rowId,
@ -344,7 +391,7 @@ export class FieldEditor extends Disposable {
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._detached.get() && this._editRow._isAddRow.peek() && formula !== "" ?
this._editRow.updateColValues({}) : undefined),
]));
}

@ -0,0 +1,129 @@
import * as commands from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {detachNode} from 'app/client/lib/dom';
import {FocusLayer} from 'app/client/lib/FocusLayer';
import {FloatingPopup} from 'app/client/ui/FloatingPopup';
import {theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {Disposable, dom, Holder, IDisposableOwner, IDomArgs,
makeTestId, MultiHolder, Observable, styled} from 'grainjs';
export interface IFloatingOwner extends IDisposableOwner {
detach(): HTMLElement;
attach(content: HTMLElement): Promise<void>|void;
}
const testId = makeTestId('test-floating-editor-');
export class FloatingEditor extends Disposable {
public active = Observable.create<boolean>(this, false);
constructor(private _fieldEditor: IFloatingOwner, private _gristDoc: GristDoc) {
super();
this.autoDispose(commands.createGroup({
detachEditor: this.createPopup.bind(this),
}, this, true));
}
public createPopup() {
const editor = this._fieldEditor;
const popupOwner = Holder.create(editor);
const tempOwner = new MultiHolder();
try {
// Create a layer to grab the focus, when we will move the editor to the popup. Otherwise the focus
// will be moved to the clipboard which can destroy us (as it will be treated as a clickaway). So here
// we are kind of simulating always focused editor (even if it is not in the dom for a brief moment).
FocusLayer.create(tempOwner, { defaultFocusElem: document.activeElement as any});
// Take some data from gristDoc to create a title.
const cursor = this._gristDoc.cursorPosition.get()!;
const vs = this._gristDoc.docModel.viewSections.getRowModel(cursor.sectionId!);
const table = vs.tableId.peek();
const field = vs.viewFields.peek().at(cursor.fieldIndex!)!;
const title = `${table}.${field.label.peek()}`;
let content: HTMLElement;
// Now create the popup. It will be owned by the editor itself.
const popup = FloatingPopup.create(popupOwner, {
content: () => (content = editor.detach()), // this will be called immediately, and will move some dom between
// existing editor and the popup. We need to save it, so we can
// detach it on close.
title: () => title, // We are not reactive yet
closeButton: true, // Show the close button with a hover
closeButtonHover: () => 'Return to cell',
onClose: async () => {
const layer = FocusLayer.create(null, { defaultFocusElem: document.activeElement as any});
try {
detachNode(content);
popupOwner.dispose();
await editor.attach(content);
} finally {
layer.dispose();
}
},
args: [testId('popup')]
});
// Set a public flag that we are active.
this.active.set(true);
popup.onDispose(() => {
this.active.set(false);
});
// Show the popup with the editor.
popup.showPopup();
} finally {
// Dispose the focus layer, we only needed it for the time when the dom was moved between parents.
tempOwner.dispose();
}
}
}
export function createDetachedIcon(...args: IDomArgs<HTMLDivElement>) {
return cssResizeIconWrapper(
cssSmallIcon('Maximize'),
dom.on('click', (e) => {
e.stopPropagation();
e.preventDefault();
commands.allCommands.detachEditor.run();
}),
dom.on('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
}),
testId('detach-button'),
...args
);
}
const cssSmallIcon = styled(icon, `
width: 14px;
height: 14px;
`);
const cssResizeIconWrapper = styled('div', `
position: absolute;
right: -2px;
top: -20px;
line-height: 0px;
cursor: pointer;
z-index: 10;
--icon-color: ${theme.cellBg};
background: var(--grist-theme-control-primary-bg, var(--grist-primary-fg));
height: 20px;
width: 21px;
--icon-color: white;
display: flex;
align-items: center;
justify-content: center;
line-height: 0px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
&:hover {
background: var(--grist-theme-control-primary-hover-bg, var(--grist-primary-fg-hover))
}
& > div {
transition: background .05s ease-in-out;
}
`);

File diff suppressed because it is too large Load Diff

@ -1,22 +1,26 @@
import * as AceEditor from 'app/client/components/AceEditor';
import {createGroup} from 'app/client/components/commands';
import {CommandName} from 'app/client/components/commandList';
import * as commands from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {ColumnRec} from 'app/client/models/DocModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {reportError} from 'app/client/models/errors';
import {GRIST_FORMULA_ASSISTANT} from 'app/client/models/features';
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 {createDetachedIcon} from 'app/client/widgets/FloatingEditor';
import {buildRobotIcon, FormulaAssistant} from 'app/client/widgets/FormulaAssistant';
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 {isRaisedException} from 'app/common/gristTypes';
import {undef} from 'app/common/gutil';
import {decodeObject, RaisedException} from 'app/plugin/objtypes';
import {Computed, Disposable, dom, Holder, MultiHolder, Observable, styled, subscribe} from 'grainjs';
import debounce = require('lodash/debounce');
// How wide to expand the FormulaEditor when an error is shown in it.
@ -25,8 +29,10 @@ const t = makeT('FormulaEditor');
export interface IFormulaEditorOptions extends Options {
cssClass?: string;
editingFormula: ko.Computed<boolean>,
column: ColumnRec,
editingFormula: ko.Computed<boolean>;
column: ColumnRec;
field?: ViewFieldRec;
canDetach?: boolean;
}
@ -42,10 +48,13 @@ export interface IFormulaEditorOptions extends Options {
* should save the value on `blur` event.
*/
export class FormulaEditor extends NewBaseEditor {
public isDetached = Observable.create(this, false);
protected options: IFormulaEditorOptions;
private _formulaEditor: any;
private _commandGroup: any;
private _dom: HTMLElement;
private _editorPlacement!: EditorPlacement;
private _placementHolder = Holder.create(this);
constructor(options: IFormulaEditorOptions) {
super(options);
@ -67,19 +76,50 @@ export class FormulaEditor extends NewBaseEditor {
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));
// For editable editor we will grab the cursor when we are in the formula editing mode.
const cursorCommands = options.readonly ? {} : { setCursor: this._onSetCursor };
const isActive = Computed.create(this, use => Boolean(use(editingFormula)));
const commandGroup = this.autoDispose(commands.createGroup(cursorCommands, this, isActive));
// We will create a group of editor commands right away.
const editorGroup = this.autoDispose(commands.createGroup({
...options.commands,
}, this, true));
// Merge those two groups into one.
const aceCommands: any = {
knownKeys: {...commandGroup.knownKeys, ...editorGroup.knownKeys},
commands: {...commandGroup.commands, ...editorGroup.commands},
};
// Tab, Shift + Tab, Enter should be handled by the editor itself when we are in the detached mode.
// We will create disabled group, but will push those commands to the editor directly.
const passThrough = (name: CommandName) => () => {
if (this.isDetached.get()) {
// For detached editor, just leave the default behavior.
return true;
}
// Else invoke regular command.
commands.allCommands[name]?.run();
return false;
};
const detachedCommands = this.autoDispose(commands.createGroup({
nextField: passThrough('nextField'),
prevField: passThrough('prevField'),
fieldEditSave: passThrough('fieldEditSave'),
}, this, false /* don't activate, we're just borrowing constructor */));
Object.assign(aceCommands.knownKeys, detachedCommands.knownKeys);
Object.assign(aceCommands.commands, detachedCommands.commands);
const hideErrDetails = Observable.create(this, true);
const raisedException = Computed.create(this, use => {
if (!options.formulaError) {
if (!options.formulaError || !use(options.formulaError)) {
return null;
}
const error = isRaisedException(use(options.formulaError)) ?
decodeObject(use(options.formulaError)) as RaisedException:
const error = isRaisedException(use(options.formulaError)!) ?
decodeObject(use(options.formulaError)!) as RaisedException:
new RaisedException(["Unknown error"]);
return error;
});
@ -98,10 +138,13 @@ export class FormulaEditor extends NewBaseEditor {
// 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(errorDetails.addListener(() => setTimeout(this.resize.bind(this), 0)));
const canDetach = GRIST_FORMULA_ASSISTANT().get() && options.canDetach && !options.readonly;
this.autoDispose(this._formulaEditor);
this._dom = dom('div.default_editor.formula_editor_wrapper',
this._dom = cssFormulaEditor(
buildRobotIcon(),
// switch border shadow
dom.cls("readonly_editor", options.readonly),
createMobileButtons(options.commands),
@ -109,9 +152,28 @@ export class FormulaEditor extends NewBaseEditor {
// This shouldn't be needed, but needed for tests.
dom.on('mousedown', (ev) => {
// If we are detached, allow user to click and select error text.
if (this.isDetached.get()) {
// If the focus is already in this editor, don't steal it. This is needed for detached editor with
// some input elements (mainly the AI assistant).
const inInput = document.activeElement instanceof HTMLInputElement
|| document.activeElement instanceof HTMLTextAreaElement;
if (inInput && this._dom.contains(document.activeElement)) {
return;
}
// Allow clicking the error message.
if (ev.target instanceof HTMLElement && (
ev.target.classList.contains('error_msg') ||
ev.target.classList.contains('error_details_inner')
)) {
return;
}
}
ev.preventDefault();
this._formulaEditor.getEditor().focus();
this.focus();
}),
canDetach ? createDetachedIcon(dom.hide(this.isDetached)) : null,
cssFormulaEditor.cls('-detached', this.isDetached),
dom('div.formula_editor.formula_field_edit', testId('formula-editor'),
this._formulaEditor.buildDom((aceObj: any) => {
aceObj.setFontSize(11);
@ -121,7 +183,7 @@ export class FormulaEditor extends NewBaseEditor {
const val = initialValue;
const pos = Math.min(options.cursorPos, val.length);
this._formulaEditor.setValue(val, pos);
this._formulaEditor.attachCommandGroup(this._commandGroup);
this._formulaEditor.attachCommandGroup(aceCommands);
// enable formula editing if state was passed
if (options.state || options.readonly) {
@ -132,48 +194,74 @@ export class FormulaEditor extends NewBaseEditor {
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));
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(options.formulaError, () => [
dom('div.error_msg', testId('formula-error-msg'),
dom.on('click', () => {
if (this.isDetached.get()) { return; }
if (errorDetails.get()){
hideErrDetails.set(!hideErrDetails.get());
this._formulaEditor.resize();
}
}),
dom.maybe(errorDetails, () =>
dom.domComputed(hideErrDetails, (hide) => cssCollapseIcon(
hide ? 'Expand' : 'Collapse',
testId('formula-error-expand'),
dom.on('click', () => {
if (!this.isDetached.get()) { return; }
if (errorDetails.get()){
hideErrDetails.set(!hideErrDetails.get());
this._formulaEditor.resize();
}
})
))
),
dom.maybe(use => Boolean(use(errorDetails) && !use(hideErrDetails)), () =>
dom('div.error_details',
dom('div.error_details_inner',
dom.text(errorDetails),
),
testId('formula-error-details'),
)
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
)
)
]),
dom.maybe(this.isDetached, () => {
return dom.create(FormulaAssistant, {
column: this.options.column,
field: this.options.field,
gristDoc: this.options.gristDoc,
editor: this,
});
}),
);
}
public attach(cellElem: Element): void {
this._editorPlacement = EditorPlacement.create(this, this._dom, cellElem, {margins: getButtonMargins()});
this.isDetached.set(false);
this._editorPlacement = EditorPlacement.create(
this._placementHolder, 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.autoDispose(this._editorPlacement.onReposition.addListener(this._formulaEditor.resize, this._formulaEditor));
this._formulaEditor.onAttach();
this._formulaEditor.editor.focus();
this._formulaEditor.resize();
this.focus();
}
public getDom(): HTMLElement {
return this._dom;
}
public setFormula(formula: string) {
this._formulaEditor.setValue(formula);
}
public getCellValue() {
const value = this._formulaEditor.getValue();
// Strip the leading "=" sign, if any, in case users think it should start the formula body (as
@ -190,14 +278,47 @@ export class FormulaEditor extends NewBaseEditor {
return aceObj.getSession().getDocument().positionToIndex(aceObj.getCursorPosition());
}
public focus() {
if (this.isDisposed()) { return; }
this._formulaEditor.getEditor().focus();
}
public resize() {
if (this.isDisposed()) { return; }
this._formulaEditor.resize();
}
public detach() {
// Remove the element from the dom (to prevent any autodispose) from happening.
this._dom.parentNode?.removeChild(this._dom);
// First mark that we are detached, to show the buttons,
// and halt the autosizing mechanism.
this.isDetached.set(true);
// Finally, destroy the normal inline placement helper.
this._placementHolder.clear();
// We are going in the full formula edit mode right away.
this.options.editingFormula(true);
// Set the focus in timeout, as the dom is added after this function.
setTimeout(() => !this.isDisposed() && this._formulaEditor.resize(), 0);
// Return the dom, it will be moved to the floating editor.
return this._dom;
}
private _calcSize(elem: HTMLElement, desiredElemSize: ISize) {
if (this.isDetached.get()) {
// If we are detached, we will stop autosizing.
return {
height: 0,
width: 0
};
}
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)),
width: Math.max(desiredElemSize.width, (this.options.formulaError.get() ? minFormulaErrorWidth : 0)),
// Ask for extra space for the error; we'll decide how to allocate it below.
height: desiredElemSize.height + (errorBoxDesiredHeight - errorBoxStartHeight),
};
@ -216,35 +337,42 @@ export class FormulaEditor extends NewBaseEditor {
}
// TODO: update regexes to unicode?
private _onSetCursor(row: DataRowModel, col: ViewFieldRec) {
if (!col) { return; } // if clicked on row header, no col to insert
private _onSetCursor(row?: DataRowModel, col?: ViewFieldRec) {
// Don't do anything when we are readonly.
if (this.options.readonly) { return; }
// If we don't have column information, we can't insert anything.
if (!col) { return; }
const colId = col.origCol.peek().colId.peek();
const aceObj = this._formulaEditor.getEditor();
if (!aceObj.selection.isEmpty()) { // If text selected, replace whole selection
aceObj.session.replace(aceObj.selection.getRange(), '$' + col.colId());
// Rect only to columns in the same table.
if (col.tableId.peek() !== this.options.column.table.peek().tableId.peek()) {
// aceObj.focus();
this.options.gristDoc.onSetCursorPos(row, col).catch(reportError);
return;
}
} else { // Not a selection, gotta figure out what to replace
if (!aceObj.selection.isEmpty()) {
// If text selected, replace whole selection
aceObj.session.replace(aceObj.selection.getRange(), '$' + 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());
if (!result) {
// Not touching an identifier, insert colId as normal
aceObj.insert('$' + 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, '$' + colId);
}
// Else touching a normal identifier, dont mangle it
// Else touching a normal identifier, don't mangle it
}
// Resize editor in case it is needed.
this._formulaEditor.resize();
aceObj.focus();
@ -276,6 +404,7 @@ export function openFormulaEditor(options: {
gristDoc: GristDoc,
// Associated formula from a different column (for example style rule).
column?: ColumnRec,
// Associated formula from a view field. If provided together with column, this field is used
field?: ViewFieldRec,
editingFormula?: ko.Computed<boolean>,
// Needed to get exception value, if any.
@ -285,24 +414,38 @@ export function openFormulaEditor(options: {
editValue?: string,
onSave?: (column: ColumnRec, formula: string) => Promise<void>,
onCancel?: () => void,
canDetach?: boolean,
// Called after editor is created to set up editor cleanup (e.g. saving on click-away).
setupCleanup: (
owner: MultiHolder,
owner: Disposable,
doc: GristDoc,
editingFormula: ko.Computed<boolean>,
save: () => Promise<void>
) => void,
}): Disposable {
}): FormulaEditor {
const {gristDoc, editRow, refElem, setupCleanup} = options;
const holder = MultiHolder.create(null);
const attachedHolder = new MultiHolder();
if (options.field) {
options.column = options.field.origCol();
} else if (options.canDetach) {
throw new Error('Field is required for detached editor');
}
// We can't rely on the field passed in, we need to create our own.
const column = options.column ?? options.field?.column();
if (!column) {
throw new Error(t('Column or field is required'));
throw new Error('Column or field is required');
}
// AsyncOnce ensures it's called once even if triggered multiple times.
const saveEdit = asyncOnce(async () => {
const detached = editor.isDetached.get();
if (detached) {
editor.dispose();
return;
}
const formula = String(editor.getCellValue());
if (formula !== column.formula.peek()) {
if (options.onSave) {
@ -310,9 +453,9 @@ export function openFormulaEditor(options: {
} else {
await column.updateColValues({formula});
}
holder.dispose();
editor.dispose();
} else {
holder.dispose();
editor.dispose();
options.onCancel?.();
}
});
@ -321,23 +464,31 @@ export function openFormulaEditor(options: {
const editCommands = {
fieldEditSave: () => { saveEdit().catch(reportError); },
fieldEditSaveHere: () => { saveEdit().catch(reportError); },
fieldEditCancel: () => { holder.dispose(); options.onCancel?.(); },
fieldEditCancel: () => { editor.dispose(); options.onCancel?.(); },
};
// Replace the item in the Holder with a new one, disposing the previous one.
const editor = FormulaEditor.create(holder, {
const formulaError = editRow ? getFormulaError(attachedHolder, {
gristDoc,
editRow,
column,
field: options.field,
}) : undefined;
const editor = FormulaEditor.create(null, {
gristDoc,
column,
field: options.field,
editingFormula: options.editingFormula,
rowId: editRow ? editRow.id() : 0,
cellValue: column.formula(),
formulaError: editRow ? getFormulaError(gristDoc, editRow, column) : undefined,
formulaError,
editValue: options.editValue,
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
commands: editCommands,
cssClass: 'formula_editor_sidepane',
readonly : false
} as IFormulaEditorOptions);
readonly : false,
canDetach: options.canDetach
} as IFormulaEditorOptions) as FormulaEditor;
editor.autoDispose(attachedHolder);
editor.attach(refElem);
const editingFormula = options.editingFormula ?? options?.field?.editingFormula;
@ -353,30 +504,92 @@ export function openFormulaEditor(options: {
if (!column.formula()) {
editingFormula(true);
}
setupCleanup(holder, gristDoc, editingFormula, saveEdit);
return holder;
setupCleanup(editor, gristDoc, editingFormula, saveEdit);
return editor;
}
/**
* 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);
export function getFormulaError(owner: Disposable, options: {
gristDoc: GristDoc,
editRow: DataRowModel,
column?: ColumnRec,
field?: ViewFieldRec,
}): Observable<CellValue|undefined> {
const {gristDoc, editRow} = options;
const formulaError = Observable.create(owner, undefined as any);
// When we don't have a field information we don't need to be reactive at all.
if (!options.field) {
const column = options.column!;
const colId = column.colId.peek();
const onValueChange = errorMonitor(gristDoc, column, editRow, owner, formulaError);
const subscription = editRow.cells[colId].subscribe(onValueChange);
owner.autoDispose(subscription);
onValueChange(editRow.cells[colId].peek());
return formulaError;
} else {
// We can't rely on the editRow we got, as this is owned by the view. When we will be detached the view will be
// gone. So, we will create our own observable that will be updated when the row is updated.
const errorRow: DataRowModel = gristDoc.getTableModel(options.field.tableId.peek()).createFloatingRowModel() as any;
errorRow.assign(editRow.getRowId());
owner.autoDispose(errorRow);
// When we have a field information we will grab the error from the column that is currently connected to the field.
// This will change when user is using the preview feature in detached editor, where a new column is created, and
// field starts showing it instead of the original column.
Computed.create(owner, use => {
// This pattern creates a subscription using compute observable.
// Create an holder for everything that is created during recomputation. It will be returned as the value
// of the computed observable, and will be disposed when the value changes.
const holder = MultiHolder.create(use.owner);
// Now subscribe to the column in the field, this is the part that will be changed when user creates a preview.
const column = use(options.field!.column);
const colId = use(column.colId);
const onValueChange = errorMonitor(gristDoc, column, errorRow, holder, formulaError);
// Unsubscribe when computed is recomputed.
holder.autoDispose(errorRow.cells[colId].subscribe(onValueChange));
// Trigger the subscription to get the initial value.
onValueChange(errorRow.cells[colId].peek());
// Return the holder, it will be disposed when the value changes.
return holder;
});
}
return undefined;
return formulaError;
}
function errorMonitor(
gristDoc: GristDoc,
column: ColumnRec,
editRow: DataRowModel,
holder: Disposable,
formulaError: Observable<CellValue|undefined> ) {
return function onValueChange(cellCurrentValue: CellValue) {
const isFormula = column.isFormula() || column.hasTriggerFormula();
if (isFormula && isRaisedException(cellCurrentValue)) {
if (!formulaError.get()) {
// Don't update it when there is already an error (to avoid flickering).
formulaError.set(cellCurrentValue);
}
gristDoc.docData.getFormulaError(column.table().tableId(), column.colId(), editRow.getRowId())
.then(value => {
if (holder.isDisposed()) { return; }
formulaError.set(value);
})
.catch((er) => {
if (!holder.isDisposed()) {
reportError(er);
}
});
} else {
formulaError.set(undefined);
}
};
}
/**
@ -429,8 +642,44 @@ export function createFormulaErrorObs(owner: MultiHolder, gristDoc: GristDoc, or
const cssCollapseIcon = styled(icon, `
margin: -3px 4px 0 4px;
--icon-color: ${colors.slate};
cursor: pointer;
`);
export const cssError = styled('div', `
color: ${theme.errorText};
`);
const cssFormulaEditor = styled('div.default_editor.formula_editor_wrapper', `
&-detached {
height: 100%;
position: relative;
box-shadow: none;
}
&-detached .formula_editor {
flex-grow: 1;
}
&-detached .error_msg, &-detached .error_details {
flex-grow: 0;
flex-shrink: 1;
cursor: default;
}
&-detached .code_editor_container {
height: 100%;
width: 100%;
}
&-detached .ace_editor {
height: 100% !important;
width: 100% !important;
}
.floating-popup .formula_editor {
min-height: 100px;
}
.floating-popup .error_details {
min-height: 100px;
}
`);

@ -33,7 +33,7 @@ export class NTextEditor extends NewBaseEditor {
options.editValue, String(options.cellValue ?? ""));
this.editorState = Observable.create<string>(this, initialValue);
this.commandGroup = this.autoDispose(createGroup(options.commands, null, true));
this.commandGroup = this.autoDispose(createGroup(options.commands, this, true));
this._alignment = options.field.widgetOptionsJson.peek().alignment || 'left';
this._dom =
dom('div.default_editor',

@ -17,7 +17,7 @@ export interface Options {
gristDoc: GristDoc;
cellValue: CellValue;
rowId: number;
formulaError?: Observable<CellValue>;
formulaError: Observable<CellValue|undefined>;
editValue?: string;
cursorPos: number;
commands: IEditorCommandGroup;
@ -83,6 +83,11 @@ export abstract class NewBaseEditor extends Disposable {
*/
public abstract attach(cellElem: Element): void;
/**
* Called to detach the editor and show it in the floating popup.
*/
public detach(): HTMLElement|null { return null; }
/**
* Returns DOM container with the editor, typically present and attached after attach() has been
* called.

@ -104,6 +104,8 @@ export const DismissedPopup = StringUnion(
'deleteRecords', // confirmation for deleting records keyboard shortcut,
'deleteFields', // confirmation for deleting columns keyboard shortcut,
'tutorialFirstCard', // first card of the tutorial,
'formulaHelpInfo', // formula help info shown in the popup editor,
'formulaAssistantInfo', // formula assistant info shown in the popup editor,
);
export type DismissedPopup = typeof DismissedPopup.type;

@ -74,6 +74,9 @@ export const commonUrls = {
plans: "https://www.getgrist.com/pricing",
sproutsProgram: "https://www.getgrist.com/sprouts-program",
contact: "https://www.getgrist.com/contact",
community: 'https://community.getgrist.com',
functions: 'https://support.getgrist.com/functions',
formulaSheet: 'https://support.getgrist.com/formula-cheat-sheet',
basicTutorial: 'https://templates.getgrist.com/woXtXUBmiN5T/Grist-Basics',
basicTutorialImage: 'https://www.getgrist.com/wp-content/uploads/2021/08/lightweight-crm.png',

@ -237,10 +237,74 @@ export class HuggingFaceAssistant implements Assistant {
}
}
/**
* Test assistant that mimics ChatGPT and just returns the input.
*/
export class EchoAssistant implements Assistant {
public async apply(doc: AssistanceDoc, request: AssistanceRequest): Promise<AssistanceResponse> {
const messages = request.state?.messages || [];
if (messages.length === 0) {
messages.push({
role: 'system',
content: ''
});
messages.push({
role: 'user', content: request.text,
});
} else {
if (request.regenerate) {
if (messages[messages.length - 1].role !== 'user') {
messages.pop();
}
}
messages.push({
role: 'user', content: request.text,
});
}
let completion = request.text;
const reply = completion;
const history = { messages };
history.messages.push({
role: 'assistant',
content: completion,
});
// This model likes returning markdown. Code will typically
// be in a code block with ``` delimiters.
let lines = completion.split('\n');
if (lines[0].startsWith('```')) {
lines.shift();
completion = lines.join('\n');
const parts = completion.split('```');
if (parts.length > 1) {
completion = parts[0];
}
lines = completion.split('\n');
}
// This model likes repeating the function signature and
// docstring, so we try to strip that out.
completion = lines.join('\n');
while (completion.includes('"""')) {
const parts = completion.split('"""');
completion = parts[parts.length - 1];
}
// If there's no code block, don't treat the answer as a formula.
if (!reply.includes('```')) {
completion = '';
}
const response = await completionToResponse(doc, request, completion, reply);
response.state = history;
return response;
}
}
/**
* Instantiate an assistant, based on environment variables.
*/
function getAssistant() {
if (process.env.OPENAI_API_KEY === 'test') {
return new EchoAssistant();
}
if (process.env.OPENAI_API_KEY) {
return new OpenAIAssistant();
}

@ -114,6 +114,7 @@
--icon-ResizePanel: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSI0IiB5PSIyIiB3aWR0aD0iMiIgaGVpZ2h0PSIxMiIgcng9IjEiLz48L3N2Zz4=');
--icon-Revert: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuMTIzMTcgMy4wMDMzTDMuMDg3MjggNi45MTEwM0w2LjgxMzM2IDUuNjY0MzYiIHN0cm9rZT0iIzE2QjM3OCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTEzLjYxNTggMTAuMDc5OUMxNC4wMzY0IDguNjg0NzggMTMuODg1NSA3LjE3OTc0IDEzLjE5NjUgNS44OTU4NiAxMi41MDc0IDQuNjExOTggMTEuMzM2NSAzLjY1NDQzIDkuOTQxMzcgMy4yMzM4NSA4LjU0NjI3IDIuODEzMjcgNy4wNDEyMyAyLjk2NDExIDUuNzU3MzUgMy42NTMyIDQuNDczNDcgNC4zNDIzIDMuNTE1OTIgNS41MTMxOCAzLjA5NTM0IDYuOTA4MjhNNi40Mjk0MyAxMy41ODc2QzUuODQ4OCAxMy4zNjgzIDUuMzA5NjIgMTMuMDUyIDQuODM0ODQgMTIuNjUyMk0xMC4wMzk4IDEzLjY4MTZDOS40OTk1MyAxMy44NTM0IDguOTM1OTUgMTMuOTQwNyA4LjM2OTAzIDEzLjk0MDJIOC4yNDMwNE0xMi44ODY4IDExLjU3MjhDMTIuNTQ4MiAxMi4wNjA4IDEyLjEzMzIgMTIuNDkxMiAxMS42NTc3IDEyLjg0NzNNMy42NjIxNSAxMS4yODA0QzMuMzUxNDggMTAuNzY2NiAzLjEyNzc2IDEwLjIwNTEgMyA5LjYxODQxIiBzdHJva2U9IiMxNkIzNzgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==');
--icon-RightAlign: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw4LjUgQzIuMjIzODU3NjMsOC41IDIsOC4yNzYxNDIzNyAyLDggQzIsNy43MjM4NTc2MyAyLjIyMzg1NzYzLDcuNSAyLjUsNy41IEwxMy41LDcuNSBDMTMuNzc2MTQyNCw3LjUgMTQsNy43MjM4NTc2MyAxNCw4IEMxNCw4LjI3NjE0MjM3IDEzLjc3NjE0MjQsOC41IDEzLjUsOC41IEwyLjUsOC41IFogTTIuNSw0IEMyLjIyMzg1NzYzLDQgMiwzLjc3NjE0MjM3IDIsMy41IEMyLDMuMjIzODU3NjMgMi4yMjM4NTc2MywzIDIuNSwzIEwxMy41LDMgQzEzLjc3NjE0MjQsMyAxNCwzLjIyMzg1NzYzIDE0LDMuNSBDMTQsMy43NzYxNDIzNyAxMy43NzYxNDI0LDQgMTMuNSw0IEwyLjUsNCBaIE04LjUsMTMgQzguMjIzODU3NjMsMTMgOCwxMi43NzYxNDI0IDgsMTIuNSBDOCwxMi4yMjM4NTc2IDguMjIzODU3NjMsMTIgOC41LDEyIEwxMy41LDEyIEMxMy43NzYxNDI0LDEyIDE0LDEyLjIyMzg1NzYgMTQsMTIuNSBDMTQsMTIuNzc2MTQyNCAxMy43NzYxNDI0LDEzIDEzLjUsMTMgTDguNSwxMyBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
--icon-Robot: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE2IDJWMEgxMFYySDEyVjRINFYySDZWMEgwVjJIMlY0SDBWMTZIMTZWNEgxNFYySDE2Wk0xMCA3QzEwLjYgNyAxMSA3LjQgMTEgOEMxMSA4LjYgMTAuNiA5IDEwIDlDOS40IDkgOSA4LjYgOSA4QzkgNy40IDkuNCA3IDEwIDdaTTYgN0M2LjYgNyA3IDcuNCA3IDhDNyA4LjYgNi42IDkgNiA5QzUuNCA5IDUgOC42IDUgOEM1IDcuNCA1LjQgNyA2IDdaTTEyIDEzSDRWMTFIMTJWMTNaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
--icon-Script: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWRhdGFiYXNlIj48cmVjdCB3aWR0aD0iMTkuMzI4IiBoZWlnaHQ9IjE5LjMyOCIgeD0iMi4zMzYiIHk9IjIuMzM2IiByeT0iNC4wMjYiIHJ4PSIzLjc0NSIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJidXR0Ii8+PGcgc3Ryb2tlLXdpZHRoPSIxLjU0MyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0iYnV0dCIgc3Ryb2tlLWxpbmVqb2luPSJtaXRlciI+PHBhdGggZD0iTTE4LjczNDQ0OSA5LjAyMTI1NTZMNS4yNjU1NTEyIDkuMDA5NTM2OE0xOC43MzQ0NDkgMTQuNzM4NTg2TDUuMjY1NTUxMiAxNC43MjY4NjciIHRyYW5zZm9ybT0ibWF0cml4KC45Mzk2OSAwIDAgMS4wMDUzMSAuNzI0IC0uMDYzKSIvPjwvZz48L3N2Zz4=');
--icon-Search: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjQzNjIxMjcsMTAuNzI5MTA1OSBMMTQuODUzNTUzNCwxNC4xNDY0NDY2IEMxNS4wNDg4MTU1LDE0LjM0MTcwODggMTUuMDQ4ODE1NSwxNC42NTgyOTEyIDE0Ljg1MzU1MzQsMTQuODUzNTUzNCBDMTQuNjU4MjkxMiwxNS4wNDg4MTU1IDE0LjM0MTcwODgsMTUuMDQ4ODE1NSAxNC4xNDY0NDY2LDE0Ljg1MzU1MzQgTDEwLjcyOTEwNTksMTEuNDM2MjEyNyBDOS41OTIzMzg0OCwxMi40MTEwNDg3IDguMTE0OTQ3NzEsMTMgNi41LDEzIEMyLjkxMDE0OTEzLDEzIDAsMTAuMDg5ODUwOSAwLDYuNSBDMCwyLjkxMDE0OTEzIDIuOTEwMTQ5MTMsMCA2LjUsMCBDMTAuMDg5ODUwOSwwIDEzLDIuOTEwMTQ5MTMgMTMsNi41IEMxMyw4LjExNDk0NzcxIDEyLjQxMTA0ODcsOS41OTIzMzg0OCAxMS40MzYyMTI3LDEwLjcyOTEwNTkgWiBNMTAuNDA5NTc0NywxMC4zNjg0OTIxIEMxMS4zOTI4MzI1LDkuMzc0ODU3OCAxMiw4LjAwODMzNDY4IDEyLDYuNSBDMTIsMy40NjI0MzM4OCA5LjUzNzU2NjEyLDEgNi41LDEgQzMuNDYyNDMzODgsMSAxLDMuNDYyNDMzODggMSw2LjUgQzEsOS41Mzc1NjYxMiAzLjQ2MjQzMzg4LDEyIDYuNSwxMiBDOC4wMDgzMzQ2OCwxMiA5LjM3NDg1NzgsMTEuMzkyODMyNSAxMC4zNjg0OTIxLDEwLjQwOTU3NDcgQzEwLjM3NDkwMDEsMTAuNDAyMzg3OSAxMC4zODE1NTE2LDEwLjM5NTM0MTYgMTAuMzg4NDQ2NiwxMC4zODg0NDY2IEMxMC4zOTUzNDE2LDEwLjM4MTU1MTYgMTAuNDAyMzg3OSwxMC4zNzQ5MDAxIDEwLjQwOTU3NDcsMTAuMzY4NDkyMSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
--icon-Settings: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuOTgwNDcxOSwzLjI3MzM2NTEyIEM0LjgxNDE0NDY1LDIuNTc4NDA2ODQgNS44NTY2MjI5MSwyLjEyNTQ1Njc0IDcsMi4wMjI0MjE1MSBMNywwLjUgQzcsMC4yMjM4NTc2MjUgNy4yMjM4NTc2MywwIDcuNSwwIEM3Ljc3NjE0MjM3LDAgOCwwLjIyMzg1NzYyNSA4LDAuNSBMOCwyLjAyMjQyMTUxIEM5LjE0MzM3NzA5LDIuMTI1NDU2NzQgMTAuMTg1ODU1NCwyLjU3ODQwNjg0IDExLjAxOTUyODEsMy4yNzMzNjUxMiBMMTIuMDk2NDQ2NiwyLjE5NjQ0NjYxIEMxMi4yOTE3MDg4LDIuMDAxMTg0NDYgMTIuNjA4MjkxMiwyLjAwMTE4NDQ2IDEyLjgwMzU1MzQsMi4xOTY0NDY2MSBDMTIuOTk4ODE1NSwyLjM5MTcwODc2IDEyLjk5ODgxNTUsMi43MDgyOTEyNCAxMi44MDM1NTM0LDIuOTAzNTUzMzkgTDExLjcyNjYzNDksMy45ODA0NzE5IEMxMi40MjE1OTMyLDQuODE0MTQ0NjUgMTIuODc0NTQzMyw1Ljg1NjYyMjkxIDEyLjk3NzU3ODUsNyBMMTQuNSw3IEMxNC43NzYxNDI0LDcgMTUsNy4yMjM4NTc2MyAxNSw3LjUgQzE1LDcuNzc2MTQyMzcgMTQuNzc2MTQyNCw4IDE0LjUsOCBMMTIuOTc3NTc4NSw4IEMxMi44NzQ1NDMzLDkuMTQzMzc3MDkgMTIuNDIxNTkzMiwxMC4xODU4NTU0IDExLjcyNjYzNDksMTEuMDE5NTI4MSBMMTIuODAzNTUzNCwxMi4wOTY0NDY2IEMxMi45OTg4MTU1LDEyLjI5MTcwODggMTIuOTk4ODE1NSwxMi42MDgyOTEyIDEyLjgwMzU1MzQsMTIuODAzNTUzNCBDMTIuNjA4MjkxMiwxMi45OTg4MTU1IDEyLjI5MTcwODgsMTIuOTk4ODE1NSAxMi4wOTY0NDY2LDEyLjgwMzU1MzQgTDExLjAxOTUyODEsMTEuNzI2NjM0OSBDMTAuMTg1ODU1NCwxMi40MjE1OTMyIDkuMTQzMzc3MDksMTIuODc0NTQzMyA4LDEyLjk3NzU3ODUgTDgsMTQuNSBDOCwxNC43NzYxNDI0IDcuNzc2MTQyMzcsMTUgNy41LDE1IEM3LjIyMzg1NzYzLDE1IDcsMTQuNzc2MTQyNCA3LDE0LjUgTDcsMTIuOTc3NTc4NSBDNS44NTY2MjI5MSwxMi44NzQ1NDMzIDQuODE0MTQ0NjUsMTIuNDIxNTkzMiAzLjk4MDQ3MTksMTEuNzI2NjM0OSBMMi45MDM1NTMzOSwxMi44MDM1NTM0IEMyLjcwODI5MTI0LDEyLjk5ODgxNTUgMi4zOTE3MDg3NiwxMi45OTg4MTU1IDIuMTk2NDQ2NjEsMTIuODAzNTUzNCBDMi4wMDExODQ0NiwxMi42MDgyOTEyIDIuMDAxMTg0NDYsMTIuMjkxNzA4OCAyLjE5NjQ0NjYxLDEyLjA5NjQ0NjYgTDMuMjczMzY1MTIsMTEuMDE5NTI4MSBDMi41Nzg0MDY4NCwxMC4xODU4NTU0IDIuMTI1NDU2NzQsOS4xNDMzNzcwOSAyLjAyMjQyMTUxLDggTDAuNSw4IEMwLjIyMzg1NzYyNSw4IDAsNy43NzYxNDIzNyAwLDcuNSBDMCw3LjIyMzg1NzYzIDAuMjIzODU3NjI1LDcgMC41LDcgTDIuMDIyNDIxNTEsNyBDMi4xMjU0NTY3NCw1Ljg1NjYyMjkxIDIuNTc4NDA2ODQsNC44MTQxNDQ2NSAzLjI3MzM2NTEyLDMuOTgwNDcxOSBMMi4xOTY0NDY2MSwyLjkwMzU1MzM5IEMyLjAwMTE4NDQ2LDIuNzA4MjkxMjQgMi4wMDExODQ0NiwyLjM5MTcwODc2IDIuMTk2NDQ2NjEsMi4xOTY0NDY2MSBDMi4zOTE3MDg3NiwyLjAwMTE4NDQ2IDIuNzA4MjkxMjQsMi4wMDExODQ0NiAyLjkwMzU1MzM5LDIuMTk2NDQ2NjEgTDMuOTgwNDcxOSwzLjI3MzM2NTEyIFogTTcuNSwxMCBDNi4xMTkyODgxMywxMCA1LDguODgwNzExODcgNSw3LjUgQzUsNi4xMTkyODgxMyA2LjExOTI4ODEzLDUgNy41LDUgQzguODgwNzExODcsNSAxMCw2LjExOTI4ODEzIDEwLDcuNSBDMTAsOC44ODA3MTE4NyA4Ljg4MDcxMTg3LDEwIDcuNSwxMCBaIE03LjUsOSBDOC4zMjg0MjcxMiw5IDksOC4zMjg0MjcxMiA5LDcuNSBDOSw2LjY3MTU3Mjg4IDguMzI4NDI3MTIsNiA3LjUsNiBDNi42NzE1NzI4OCw2IDYsNi42NzE1NzI4OCA2LDcuNSBDNiw4LjMyODQyNzEyIDYuNjcxNTcyODgsOSA3LjUsOSBaIE03LjUsMTIgQzkuOTg1MjgxMzcsMTIgMTIsOS45ODUyODEzNyAxMiw3LjUgQzEyLDUuMDE0NzE4NjMgOS45ODUyODEzNywzIDcuNSwzIEM1LjAxNDcxODYzLDMgMyw1LjAxNDcxODYzIDMsNy41IEMzLDkuOTg1MjgxMzcgNS4wMTQ3MTg2MywxMiA3LjUsMTIgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 63.1 (92452) - https://sketch.com -->
<title>Icons / UI / Robot</title>
<desc>Created with Sketch.</desc>
<g id="Icons-/-UI-/-Robot" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M16 2V0H10V2H12V4H4V2H6V0H0V2H2V4H0V16H16V4H14V2H16ZM10 7C10.6 7 11 7.4 11 8C11 8.6 10.6 9 10 9C9.4 9 9 8.6 9 8C9 7.4 9.4 7 10 7ZM6 7C6.6 7 7 7.4 7 8C7 8.6 6.6 9 6 9C5.4 9 5 8.6 5 8C5 7.4 5.4 7 6 7ZM12 13H4V11H12V13Z" id="Combined-Shape" fill="#000000" fill-rule="nonzero"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 733 B

@ -74,7 +74,7 @@ describe('DocTutorial', function () {
await gu.skipWelcomeQuestions();
// Make sure we have clean start.
await driver.executeScript('resetSeenPopups();');
await driver.executeScript('resetDismissedPopups();');
await gu.waitForServer();
await driver.navigate().refresh();
await gu.waitForDocMenuToLoad();

@ -13,7 +13,7 @@ describe('RightPanel', function() {
await mainSession.tempNewDoc(cleanup);
// Reset prefs.
await driver.executeScript('resetSeenPopups();');
await driver.executeScript('resetDismissedPopups();');
await gu.waitForServer();
// Refresh for a clean start.

@ -960,9 +960,15 @@ export async function waitForServer(optTimeout: number = 2000) {
* Sends UserActions using client api from the browser.
*/
export async function sendActions(actions: UserAction[]) {
await driver.executeScript(`
gristDocPageModel.gristDoc.get().docModel.docData.sendActions(${JSON.stringify(actions)});
const result = await driver.executeAsyncScript(`
const done = arguments[arguments.length - 1];
const prom = gristDocPageModel.gristDoc.get().docModel.docData.sendActions(${JSON.stringify(actions)});
prom.then(() => done(null));
prom.catch((err) => done(String(err?.message || err)));
`);
if (result) {
throw new Error(result as string);
}
await waitForServer();
}

@ -303,6 +303,10 @@ export class EnvironmentSnapshot {
}
}
}
public get(key: string): string|undefined {
return this._oldEnv[key];
}
}
export async function getBuildFile(relativePath: string): Promise<string> {

Loading…
Cancel
Save