(core) Update UI for formula and column label/id in the right-side panel.

Summary:
- Update styling of label, id, and "derived ID from label" checkbox.
- Implement a label which shows 'Data Column' vs 'Formula Column' vs 'Empty Column',
  and a dropdown with column actions (such as Clear/Convert)
- Implement new formula display in the side-panel, and open the standard
  FormulaEditor when clicked.
- Remove old FieldConfigTab, of which now very little would be used.
- Fix up remaining code that relied on it (RefSelect)

Test Plan: Fixed old tests, added new browser cases, and a case for a new helper function.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2757
This commit is contained in:
Dmitry S
2021-03-16 23:45:44 -04:00
parent e2d3b70509
commit b4c34cedad
18 changed files with 554 additions and 292 deletions

View File

@@ -19,7 +19,7 @@ import { buttonSelect } from 'app/client/ui2018/buttonSelect';
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 } from 'app/client/widgets/FieldEditor';
import { FieldEditor, openSideFormulaEditor, saveWithoutEditor } from 'app/client/widgets/FieldEditor';
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
import * as UserType from 'app/client/widgets/UserType';
import * as UserTypeImpl from 'app/client/widgets/UserTypeImpl';
@@ -213,7 +213,7 @@ export class FieldBuilder extends Disposable {
cssRow(
grainjsDom.autoDispose(selectType),
select(selectType, this.availableTypes, {
disabled: (use) => use(this.isTransformingFormula) || use(this.origColumn.disableModify) ||
disabled: (use) => use(this.isTransformingFormula) || use(this.origColumn.disableModifyBase) ||
use(this.isCallPending)
}),
testId('type-select')
@@ -301,7 +301,7 @@ export class FieldBuilder extends Disposable {
dom('span.glyphicon.glyphicon-flash'),
dom.testId("FieldBuilder_editTransform"),
kd.toggleClass('disabled', () => this.isTransformingType() || this.origColumn.isFormula() ||
this.origColumn.disableModify())
this.origColumn.disableModifyBase())
)
)
),
@@ -450,7 +450,6 @@ export class FieldBuilder extends Disposable {
}
}
public buildEditorDom(editRow: DataRowModel, mainRowModel: DataRowModel, options: {
init?: string
}) {
@@ -495,8 +494,20 @@ export class FieldBuilder extends Disposable {
this.gristDoc.fieldEditorHolder.autoDispose(fieldEditor);
}
public isEditorActive() {
return !this._fieldEditorHolder.isEmpty();
}
/**
* Open the formula editor in the side pane. It will be positioned over refElem.
*/
public openSideFormulaEditor(editRow: DataRowModel, refElem: Element) {
const editorHolder = openSideFormulaEditor({
gristDoc: this.gristDoc,
field: this.field,
editRow,
refElem,
});
this.gristDoc.fieldEditorHolder.autoDispose(editorHolder);
}
}

View File

@@ -3,15 +3,17 @@ import {Cursor} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc';
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {reportError} from 'app/client/models/errors';
import {showTooltipToCreateFormula} from 'app/client/widgets/EditorTooltip';
import {FormulaEditor} from 'app/client/widgets/FormulaEditor';
import {IEditorCommandGroup, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
import {asyncOnce} from "app/common/AsyncCreate";
import {CellValue} from "app/common/DocActions";
import {isRaisedException} from 'app/common/gristTypes';
import * as gutil from 'app/common/gutil';
import {Disposable, Holder, Observable} from 'grainjs';
import {Disposable, Holder, IDisposable, MultiHolder, Observable} from 'grainjs';
import isEqual = require('lodash/isEqual');
type IEditorConstructor = typeof NewBaseEditor;
@@ -53,7 +55,7 @@ export class FieldEditor extends Disposable {
private _editCommands: IEditorCommandGroup;
private _editorCtor: IEditorConstructor;
private _editorHolder: Holder<NewBaseEditor> = Holder.create(this);
private _saveEditPromise: Promise<boolean>|null = null;
private _saveEdit = asyncOnce(() => this._doSaveEdit());
constructor(options: {
gristDoc: GristDoc,
@@ -116,18 +118,7 @@ export class FieldEditor extends Disposable {
this._offerToMakeFormula();
}
// Whenever focus returns to the Clipboard component, close the editor by saving the value.
this._gristDoc.app.on('clipboard_focus', this._saveEdit, this);
// TODO: This should ideally include a callback that returns true only when the editor value
// has changed. Currently an open editor is considered unsaved even when unchanged.
UnsavedChange.create(this, async () => { await this._saveEdit(); });
this.onDispose(() => {
this._gristDoc.app.off('clipboard_focus', this._saveEdit, this);
// Unset field.editingFormula flag when the editor closes.
this._field.editingFormula(false);
});
setupEditorCleanup(this, this._gristDoc, this._field, this._saveEdit);
}
// cursorPos refers to the position of the caret within the editor.
@@ -143,23 +134,12 @@ export class FieldEditor extends Disposable {
// we defer this mode until the user types something.
this._field.editingFormula(isFormula && editValue !== undefined);
let formulaError: Observable<CellValue>|undefined;
if (column.isFormula() && isRaisedException(cellCurrentValue)) {
const fv = formulaError = Observable.create(null, cellCurrentValue);
this._gristDoc.docData.getFormulaError(column.table().tableId(),
this._field.colId(),
this._editRow.getRowId()
)
.then(value => { fv.set(value); })
.catch(reportError);
}
// Replace the item in the Holder with a new one, disposing the previous one.
const editor = this._editorHolder.autoDispose(editorCtor.create({
gristDoc: this._gristDoc,
field: this._field,
cellValue,
formulaError,
formulaError: getFormulaError(this._gristDoc, this._editRow, column),
editValue,
cursorPos,
commands: this._editCommands,
@@ -212,10 +192,6 @@ export class FieldEditor extends Disposable {
}
}
private async _saveEdit() {
return this._saveEditPromise || (this._saveEditPromise = this._doSaveEdit());
}
// Returns true if Enter/Shift+Enter should NOT move the cursor, for instance if the current
// record got reordered (i.e. the cursor jumped), or when editing a formula.
private async _doSaveEdit(): Promise<boolean> {
@@ -269,3 +245,94 @@ export class FieldEditor extends Disposable {
return isFormula || (saveIndex !== cursor.rowIndex());
}
}
/**
* Open a formula editor in the side pane. Returns a Disposable that owns the editor.
*/
export function openSideFormulaEditor(options: {
gristDoc: GristDoc,
field: ViewFieldRec,
editRow: DataRowModel, // Needed to get exception value, if any.
refElem: Element, // Element in the side pane over which to position the editor.
}): IDisposable {
const {gristDoc, field, editRow, refElem} = options;
const holder = MultiHolder.create(null);
const column = field.column();
// AsyncOnce ensures it's called once even if triggered multiple times.
const saveEdit = asyncOnce(async () => {
const formula = editor.getCellValue();
if (formula !== column.formula.peek()) {
await column.updateColValues({formula});
}
holder.dispose(); // Deactivate the editor.
});
// These are the commands for while the editor is active.
const editCommands = {
fieldEditSave: () => { saveEdit().catch(reportError); },
fieldEditSaveHere: () => { saveEdit().catch(reportError); },
fieldEditCancel: () => { holder.dispose(); },
};
// Replace the item in the Holder with a new one, disposing the previous one.
const editor = FormulaEditor.create(holder, {
gristDoc,
field,
cellValue: column.formula(),
formulaError: getFormulaError(gristDoc, editRow, column),
editValue: undefined,
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
commands: editCommands,
cssClass: 'formula_editor_sidepane',
});
editor.attach(refElem);
// Enter formula-editing mode (highlight formula icons; click on a column inserts its ID).
field.editingFormula(true);
setupEditorCleanup(holder, gristDoc, field, saveEdit);
return holder;
}
/**
* For an active editor, set up its cleanup:
* - saving on click-away (when focus returns to Grist "clipboard" element)
* - unset field.editingFormula mode
* - Arrange for UnsavedChange protection against leaving the page with unsaved changes.
*/
function setupEditorCleanup(
owner: MultiHolder, gristDoc: GristDoc, field: ViewFieldRec, saveEdit: () => Promise<unknown>
) {
// Whenever focus returns to the Clipboard component, close the editor by saving the value.
gristDoc.app.on('clipboard_focus', saveEdit);
// TODO: This should ideally include a callback that returns true only when the editor value
// has changed. Currently an open editor is considered unsaved even when unchanged.
UnsavedChange.create(owner, async () => { await saveEdit(); });
owner.onDispose(() => {
gristDoc.app.off('clipboard_focus', saveEdit);
// Unset field.editingFormula flag when the editor closes.
field.editingFormula(false);
});
}
/**
* If the cell at the given row and column is a formula value containing an exception, return an
* observable with this exception, and fetch more details to add to the observable.
*/
function getFormulaError(
gristDoc: GristDoc, editRow: DataRowModel, column: ColumnRec
): Observable<CellValue>|undefined {
const colId = column.colId.peek();
let formulaError: Observable<CellValue>|undefined;
const cellCurrentValue = editRow.cells[colId].peek();
if (column.isFormula() && isRaisedException(cellCurrentValue)) {
const fv = formulaError = Observable.create(null, cellCurrentValue);
gristDoc.docData.getFormulaError(column.table().tableId(), colId, editRow.getRowId())
.then(value => { fv.set(value); })
.catch(reportError);
}
return formulaError;
}

View File

@@ -13,6 +13,11 @@ import {dom, Observable, styled} from 'grainjs';
// How wide to expand the FormulaEditor when an error is shown in it.
const minFormulaErrorWidth = 400;
export interface IFormulaEditorOptions extends Options {
cssClass?: string;
}
/**
* Required parameters:
* @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.
@@ -30,7 +35,7 @@ export class FormulaEditor extends NewBaseEditor {
private _dom: HTMLElement;
private _editorPlacement: EditorPlacement;
constructor(options: Options) {
constructor(options: IFormulaEditorOptions) {
super(options);
this._formulaEditor = AceEditor.create({
// A bit awkward, but we need to assume calcSize is not used until attach() has been called
@@ -49,6 +54,7 @@ export class FormulaEditor extends NewBaseEditor {
this.autoDispose(this._formulaEditor);
this._dom = dom('div.default_editor',
createMobileButtons(options.commands),
options.cssClass ? dom.cls(options.cssClass) : null,
// This shouldn't be needed, but needed for tests.
dom.on('mousedown', (ev) => {

View File

@@ -38,7 +38,7 @@ export abstract class NewBaseEditor extends Disposable {
* Editors and provided by FieldBuilder. TODO: remove this method once all editors have been
* updated to new-style Disposables.
*/
public static create(owner: IDisposableOwner|null, options: Options): NewBaseEditor;
public static create<Opt extends Options>(owner: IDisposableOwner|null, options: Opt): NewBaseEditor;
public static create(options: Options): NewBaseEditor;
public static create(ownerOrOptions: any, options?: any): NewBaseEditor {
return options ?

View File

@@ -13,21 +13,22 @@
color: #D0D0D0;
}
.formula_field::before, .formula_field_edit::before {
content: '=';
.formula_field::before, .formula_field_edit::before, .formula_field_sidepane::before {
/* based on standard icon styles */
content: "";
position: absolute;
left: 2px;
top: 4px;
width: 12px;
height: 12px;
border-radius: 2px;
line-height: 12px;
font-family: sans-serif;
font-size: 14px;
text-align: center;
font-weight: bold;
left: 2px;
display: inline-block;
vertical-align: middle;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-image: var(--icon-FunctionResult);
width: 16px;
height: 16px;
background-color: var(--icon-color, black);
cursor: pointer;
color: white;
}
.formula_field::before {

View File

@@ -9,10 +9,22 @@
.formula_editor {
background-color: white;
padding: 2px 0 2px 18px;
padding: 4px 0 2px 21px;
z-index: 10;
}
/* styles specific to the formula editor in the side panel */
.default_editor.formula_editor_sidepane {
border-radius: 3px;
}
.formula_editor_sidepane > .formula_editor {
padding: 5px 0 5px 24px;
border-radius: 3px;
}
.formula_editor_sidepane > .formula_field_edit::before, .formula_field_sidepane::before {
left: 4px;
}
.celleditor_cursor_editor {
background-color: white;