gristlabs_grist-core/app/client/components/ColumnTransform.ts
Dmitry S 2a592d8b4d (core) Automatically finalize action bundles when unrelated actions/bundles come in.
Summary:
Type conversions and formula tranforms wait for the user and bundle multiple
actions. When an unrelated action is done (e.g. adding a page widget or a
column), we want to finalize the transform before applying it.

The approach turns out fairly complicated. There is an implicit queue of
bundles (which we don't let grow beyond 2, as that's too abnormal). Bundles may
be finalized by a user clicking something, or by an unrelated action/bundle, or
(as before) by transform DOM getting disposed.

- Updated RecordLayout to use bundleActions() helper
- Added support for nesting bundleActions inside another bundle (needed for
  setting visibleCol during type change)
- In an unrelated tweak, when in debug-log in ActiveDoc, use a short representation of result.

Test Plan: Added a unittest for action bundling during type transform

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2655
2020-11-10 10:32:07 -05:00

228 lines
8.5 KiB
TypeScript

/**
* ColumnTransform is used as a abstract base class for any classes which must build a dom for the
* purpose of allowing the user to transform a column. It is currently extended by FormulaTransform
* and TypeTransform.
*/
import * as commands from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {TableData} from 'app/client/models/TableData';
import {FieldBuilder} from 'app/client/widgets/FieldBuilder';
import {UserAction} from 'app/common/DocActions';
import {Disposable, Observable} from 'grainjs';
import * as ko from 'knockout';
import noop = require('lodash/noop');
// To simplify diff (avoid rearranging methods to satisfy private/public order).
// tslint:disable:member-ordering
type AceEditor = any;
/**
* Abstract class for FormulaTransform and TypeTransform to extend. Initializes properties needed
* for both types of transform. optPureType is useful for initializing type transforms.
*/
export class ColumnTransform extends Disposable {
protected field: ViewFieldRec;
protected origColumn: ColumnRec;
protected origDisplayCol: ColumnRec;
protected transformColumn: ColumnRec; // Set in prepare()
protected origWidgetOptions: unknown;
protected isCallPending: ko.Observable<boolean>;
protected editor: AceEditor|null = null; // Created when the dom is built by extending classes
protected formulaUpToDate = Observable.create(this, true);
protected _tableData: TableData;
// Whether _doFinalize should execute the transform, or cancel it.
protected _shouldExecute: boolean = false;
// Ask DocData to finalize the action bundle by calling the finalize callback provided to
// startBundlingActions. Finalizing should always be triggered this way, for a uniform flow,
// since finalizing could be triggered either from DocData or from cancel/execute methods.
// This is a noop until startBundlingActions is called.
private _triggerFinalize: (() => void) = noop;
// This is set to true once finalize has started.
private _isFinalizing: boolean = false;
constructor(protected gristDoc: GristDoc, private _fieldBuilder: FieldBuilder) {
super();
this.field = _fieldBuilder.field;
this.origColumn = this.field.column();
this.origDisplayCol = this.field.displayColModel();
this.origWidgetOptions = this.field.widgetOptionsJson();
this.isCallPending = _fieldBuilder.isCallPending;
this._tableData = gristDoc.docData.getTable(this.origColumn.table().tableId())!;
this.autoDispose(commands.createGroup({
undo: this.cancel,
redo: noop
}, this, true));
this.onDispose(() => {
this._setTransforming(false);
this._fieldBuilder.columnTransform = null;
this.isCallPending(false);
});
}
/**
* Build dom function should be implemented by extending classes.
*/
public buildDom() {
throw new Error("Not Implemented");
}
public async finalize(): Promise<void> {
return this._triggerFinalize();
}
/**
* Build general transform editor dom.
* @param {String} optInit - Optional initial value for the editor.
*/
protected buildEditorDom(optInit?: string) {
return this.editor.buildDom((aceObj: any) => {
this.editor.adjustContentToWidth();
this.editor.attachSaveCommand();
aceObj.on('change', () => {
if (this.editor) {
this.formulaUpToDate.set(this.editor.getValue() === this.transformColumn.formula());
}
});
aceObj.focus();
});
}
/**
* Helper called by contructor to prepare the column transform.
* @param {String} colType: A pure or complete type for the transformed column.
*/
public async prepare(optColType?: string) {
const colType: string = optColType || this.origColumn.type.peek();
// Start bundling all actions during the transform. The verification callback ensures
// no errant actions are added to the bundle; if there are, finalize is immediately called.
const bundlingInfo = this._tableData.docData.startBundlingActions({
description: `Transformed column ${this.origColumn.colId()}.`,
shouldIncludeInBundle: this._shouldIncludeInBundle.bind(this),
prepare: this._doPrepare.bind(this, colType),
finalize: this._doFinalize.bind(this)
});
// triggerFinalize tells DocData to call the finalize callback we passed above; this way
// DocData knows when it's finished.
this._triggerFinalize = bundlingInfo.triggerFinalize;
// preparePromise resolves once prepare() callback has got a chance to run and finish.
await bundlingInfo.preparePromise;
}
private async _doPrepare(colType: string) {
if (this.isDisposed()) { return; }
this.isCallPending(true);
try {
const newColRef = await this.addTransformColumn(colType);
// Set DocModel references
this.field.colRef(newColRef);
this.transformColumn = this.field.column();
this.transformColumn.origColRef(this.origColumn.getRowId());
this._setTransforming(true);
return await this.postAddTransformColumn();
} finally {
this.isCallPending(false);
}
}
private _shouldIncludeInBundle(actions: UserAction[]) {
// Allow certain expected actions. If we encounter anything else, the user must have
// started doing something else, and we should finalize the transform.
return actions.every(action => (
// ['AddColumn', USER_TABLE, 'gristHelper_Transform', colInfo]
(action[2] === 'gristHelper_Transform') ||
// ["SetDisplayFormula", USER_TABLE, ...]
(action[0] === 'SetDisplayFormula') ||
// ['UpdateRecord', '_grist_Table_column', transformColId, ...]
(action[1] === '_grist_Tables_column') ||
// ['UpdateRecord', '_grist_Views_section_field', transformColId, ...] (e.g. resize)
(action[1] === '_grist_Views_section_field')
));
}
/**
* Adds the tranform column and returns its colRef. May be overridden by derived classes to create
* differently-prepared transform columns.
* @param {String} colType: A pure or complete type for the transformed column.
*/
protected async addTransformColumn(colType: string): Promise<number> {
// Retrieve widget options on prepare (useful for type transforms)
const newColInfo = await this._tableData.sendTableAction(['AddColumn', "gristHelper_Transform", {
type: colType, isFormula: true, formula: this.getIdentityFormula(),
}]);
return newColInfo.colRef;
}
/**
* A derived class can override to do some processing after this.transformColumn has been set.
*/
protected postAddTransformColumn() {
// Nothing in base class.
}
public async cancel(): Promise<void> {
this._shouldExecute = false;
return this._triggerFinalize();
}
protected async execute(): Promise<void> {
this._shouldExecute = true;
return this._triggerFinalize();
}
// This is passed as a callback to startBundlingActions(), and should NOT be called directly.
// Instead, call _triggerFinalize() is used to trigger it.
private async _doFinalize(): Promise<void> {
if (this.isDisposed() || this._isFinalizing) {
return;
}
this._isFinalizing = true;
// Define variables used after await, since this will be disposed by then.
const transformColId = this.transformColumn.colId();
const field = this.field;
const fieldBuilder = this._fieldBuilder;
const origRef = this.origColumn.getRowId();
const tableData = this._tableData;
this.isCallPending(true);
try {
if (this._shouldExecute) {
// TODO: Values flicker during executing since transform column remains a formula as values are copied
// back to the original column. The CopyFromColumn useraction really ought to be "CopyAndRemove" since
// that seems the best way to avoid calculating the formula on wrong values.
return await tableData.sendTableAction(['CopyFromColumn', transformColId, this.origColumn.colId(),
JSON.stringify(fieldBuilder.options())]);
}
} finally {
// Wait until the change completed to set column back, to avoid value flickering.
field.colRef(origRef);
tableData.sendTableAction(['RemoveColumn', transformColId]);
this.dispose();
}
}
protected getIdentityFormula() {
return 'return $' + this.origColumn.colId();
}
protected _setTransforming(bool: boolean) {
this.origColumn.isTransforming(bool);
this.transformColumn.isTransforming(bool);
}
protected isFinalizing(): boolean {
return this._isFinalizing;
}
}