gristlabs_grist-core/app/client/components/RecordLayout.js
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

352 lines
13 KiB
JavaScript

/**
* Module for displaying a record of user data in a two-dimentional editable layout.
*/
// TODO:
// 1. Consider a way to upgrade a file to add layoutSpec column to the ViewSections meta table.
// Plan: add docInfo schemaVersion field.
// when opening a file, let the sandbox check the version and check if loaded metadata matches the schema.
// sandbox should return doc-version, current-version, and match status.
// if current-version != doc_version [AND mismatch] (this is optional, let's think if we
// want that), then
// Sandbox creates new temp document
// Replays action log into it.
// Renames it over the old document. [Would be nice to ask the user first]
// Reopen document
// 1. [LATER] Create RecordLayout file with APIs to support more efficient big list of laid-out
// records (so that a single RecordLayout can maintain many Layout instances).
// 2. [LATER] Allow dragging in boxes from the view config.
// 3. [LATER] Allow creating new field and inserting at the bottom.
// 4. [LATER] Allow selecting existing field from context menu and inserting.
// 5. [LATER] Add interface to Layout to tab forward and back, left, right, up, down, and use that in
// detail view.
// 6. [LATER] Implement saving and loading of widths in the layout spec.
var _ = require('underscore');
var ko = require('knockout');
var Promise = require('bluebird');
var gutil = require('app/common/gutil');
var dispose = require('../lib/dispose');
var dom = require('../lib/dom');
var {Delay} = require('../lib/Delay');
var kd = require('../lib/koDom');
var Layout = require('./Layout');
var RecordLayoutEditor = require('./RecordLayoutEditor');
/**
* Construct a RecordLayout.
* @param {MetaRowModel} options.viewSection: The model for the viewSection represented.
* @param {Function} options.buildFieldDom: Function called with (viewField) that should
* return the DOM for that field.
* @param {Function} options.resizeCallback: Optional function called with no arguments when
* the RecordLayout is modified in a way that may require resizing.
*/
function RecordLayout(options) {
this.viewSection = options.viewSection;
this.buildFieldDom = options.buildFieldDom;
this.isEditingLayout = ko.observable(false);
this.editIndex = ko.observable(0);
this.layoutEditor = ko.observable(null); // RecordLayoutEditor when one is active.
if (options.resizeCallback) {
this._resizeCallback = options.resizeCallback;
this._delayedResize = this.autoDispose(Delay.create());
}
// Observable object that will be rebuilt whenever the list of viewFields changes.
this.fieldsById = this.autoDispose(ko.computed(function() {
return _.indexBy(this.viewSection.viewFields().all(),
function(field) { return field.getRowId(); });
}, this));
// Update the stored layoutSpecObj with any missing fields that are present in viewFields.
this.layoutSpec = this.autoDispose(ko.computed(function() {
return RecordLayout.updateLayoutSpecWithFields(
this.viewSection.layoutSpecObj(), this.viewSection.viewFields().all());
}, this).extend({rateLimit: 0})); // layoutSpecObj and viewFields should be updated together.
this.autoDispose(this.layoutSpec.subscribe(() => this.resizeCallback()));
// TODO: We may want a context menu for each record, but the previous implementation wasn't
// working, and was creating a separate context menu for each row, which is very expensive. A
// better approach is to create a single context menu for the view section, as GridView does.
}
dispose.makeDisposable(RecordLayout);
RecordLayout.prototype.resizeCallback = function() {
// Note that while editing layout, scrolly is hidden, and resizeCallback is unhelpful. We rely
// on explicit resizing when isEditLayout is reset.
if (!this.isDisposed() && this._delayedResize && !this.isEditingLayout.peek()) {
this._delayedResize.schedule(0, this._resizeCallback);
}
};
RecordLayout.prototype.getField = function(fieldRowId) {
// If fieldRowId is a string, then it's actually "colRef:label:value" placeholder that we use
// when adding a new field. If so, return a special object with the fields available.
if (typeof fieldRowId === 'string') {
var parts = gutil.maxsplit(fieldRowId, ":", 2);
return {
isNewField: true, // To make it easy to distinguish from a ViewField MetaRowModel
colRef: parseInt(parts[0], 10),
label: parts[1],
value: parts[2]
};
}
return this.fieldsById()[fieldRowId];
};
/**
* Sets the layout to being edited.
*/
RecordLayout.prototype.editLayout = function(rowIndex) {
this.editIndex(rowIndex);
this.isEditingLayout(true);
};
/**
* Ends layout editing, without updating the layout on the server.
*/
RecordLayout.prototype.onEditLayoutCancel = function(layoutSpec) {
this.isEditingLayout(false);
// Call resizeCallback here, since it's possible that theme was also changed (and auto-saved)
// even though the layout itself was reverted.
this.resizeCallback();
};
/**
* Ends layout editing, and saves the given layoutSpec to the server.
*/
RecordLayout.prototype.onEditLayoutSave = async function(layoutSpec) {
try {
await this.saveLayoutSpec(layoutSpec);
} finally {
this.isEditingLayout(false);
this.resizeCallback();
}
};
/**
* If there is no layout saved, we can create a default layout just from the list of fields for
* this view section. By default we just arrange them into a list of rows, two fields per row.
*/
RecordLayout.updateLayoutSpecWithFields = function(spec, viewFields) {
// We use tmpLayout as a way to manipulate the layout before we get a final spec from it.
var tmpLayout = Layout.Layout.create(spec, function(leafId) { return dom('div'); });
var specFieldIds = tmpLayout.getAllLeafIds();
var viewFieldIds = viewFields.map(function(f) { return f.getRowId(); });
// For any stale fields (no longer among viewFields), remove them from tmpLayout.
_.difference(specFieldIds, viewFieldIds).forEach(function(leafId) {
tmpLayout.getLeafBox(leafId).dispose();
});
// For all fields that should be in the spec but aren't, add them to tmpLayout. We maintain a
// two-column layout, so add a new row, or a second box to the last row if it's a leaf.
_.difference(viewFieldIds, specFieldIds).forEach(function(leafId) {
var newBox = tmpLayout.buildLayoutBox({ leaf: leafId });
var rows = tmpLayout.rootBox().childBoxes.peek();
if (rows.length >= 1 && _.last(rows).isLeaf()) {
// Add a new child to the last row.
_.last(rows).addChild(newBox, true);
} else {
// Add a new row.
tmpLayout.rootBox().addChild(newBox, true);
}
});
spec = tmpLayout.getLayoutSpec();
tmpLayout.dispose();
return spec;
};
/**
* Saves the layout spec as build by the user. This is quite involved, because it may need to
* remove fields as well as create fields and possibly new columns. And it needs the results of
* these operations to update the spec before saving it.
*/
RecordLayout.prototype.saveLayoutSpec = async function(layoutSpec) {
// The layout hasn't actually changed. Skip the rest to avoid creating no-op actions (the
// resulting no-op undo would be particularly confusing).
if (JSON.stringify(layoutSpec) === this.viewSection.layoutSpec.peek()) {
return;
}
const docModel = this.viewSection._table.docModel;
const docData = docModel.docData;
const tableId = this.viewSection.table().tableId();
const getField = fieldRef => this.getField(fieldRef);
const addColAction = ["AddColumn", null, {}];
// Build a set of fieldRefs (i.e. rowIds) that are currently stored. Also build a map of colRef
// to fieldRef, so that we can restore a field that got removed and re-added (as a colRef).
var origRefs = [];
var colRefToFieldRef = new Map();
this.viewSection.viewFields().all().forEach(f => {
origRefs.push(f.getRowId());
colRefToFieldRef.set(f.colRef(), f.getRowId());
});
// Initialize leaf index counter and num cols to be added counter.
var nextPos = 0;
var addColNum = 0;
// Initialize arrays to keep track of existing field refs and their updated positions.
var existingRefs = [];
var existingPositions = [];
// Initialize arrays to keep track of added fields for existing but hidden columns.
var hiddenColRefs = [];
var hiddenCallbacks = [];
var hiddenPositions = [];
// Initialize arrays to keep track of newly added columns.
var addedCallbacks = [];
var addedPositions = [];
// Recursively process all layoutBoxes in the spec. Sets up bookkeeping arrays for
// exisiting fields and added fields for new/hidden cols from which the action bundle will
// be created.
function processBox(spec) {
// "empty" is a temporary placeholder used by LayoutEditor, and not a valid leaf.
if (spec.leaf && spec.leaf !== "empty") {
let pos = nextPos++;
let field = getField(spec.leaf);
let updateLeaf = ref => { spec.leaf = ref; };
if (!field.isNewField) {
// Existing field.
existingRefs.push(field.getRowId());
existingPositions.push(pos);
} else if (colRefToFieldRef.has(field.colRef)) {
// Existing field that got removed and re-added.
let fieldRef = colRefToFieldRef.get(field.colRef);
existingRefs.push(fieldRef);
existingPositions.push(pos);
updateLeaf(fieldRef);
} else if (Number.isNaN(field.colRef)) {
// We need to add a new column AND field.
addColNum++;
addedCallbacks.push(updateLeaf);
addedPositions.push(pos);
} else {
// We need to add a field for an existing column.
hiddenColRefs.push(field.colRef);
hiddenCallbacks.push(updateLeaf);
hiddenPositions.push(pos);
}
}
if (spec.children) {
spec.children.map(processBox);
}
}
processBox(layoutSpec);
// Combine data for item which require both new columns and new fields and only new fields,
// with items which require new columns first.
let callbacks = addedCallbacks.concat(hiddenCallbacks);
let positions = addedPositions.concat(hiddenPositions);
let addActions = gutil.arrayRepeat(addColNum, addColAction);
await docData.bundleActions('Updating record layout.', () => {
return Promise.try(() => {
return addColNum > 0 ? docModel.dataTables[tableId].sendTableActions(addActions) : [];
})
.then(results => {
let colRefs = results.map(r => r.colRef).concat(hiddenColRefs);
const addFieldNum = colRefs.length;
// Add fields for newly added columns and previously hidden columns.
return addFieldNum > 0 ?
docModel.viewFields.sendTableAction(["BulkAddRecord", gutil.arrayRepeat(addFieldNum, null), {
parentId: gutil.arrayRepeat(addFieldNum, this.viewSection.getRowId()),
colRef: colRefs,
parentPos: positions
}]) : [];
})
.each((fieldRef, i) => {
// Call the stored callback for each fieldRef, which each set the correct layoutSpec leaf
// to the newly obtained fieldRef.
callbacks[i](fieldRef);
})
.then(addedRefs => {
let actions = [];
// Records present before that were not present after editing must be removed.
let finishedRefs = new Set(existingRefs.concat(addedRefs));
let removed = origRefs.filter(fieldRef => !finishedRefs.has(fieldRef));
if (removed.length > 0) {
actions.push(["BulkRemoveRecord", "_grist_Views_section_field", removed]);
}
// Positions must be updated for fields which were not added/removed.
if (existingRefs.length > 0) {
actions.push(["BulkUpdateRecord", "_grist_Views_section_field", existingRefs, {
"parentPos": existingPositions
}]);
}
// And update the layoutSpecObj itself.
actions.push(["UpdateRecord", "_grist_Views_section", this.viewSection.getRowId(), {
"layoutSpec": JSON.stringify(layoutSpec)
}]);
return docData.sendActions(actions);
})
});
};
/**
* Builds the Layout dom for a single record.
*/
RecordLayout.prototype.buildLayoutDom = function(row, optCreateEditor) {
const createEditor = Boolean(optCreateEditor && !this.layoutEditor.peek());
const layout = Layout.Layout.create(this.layoutSpec(), (fieldRowId) =>
dom('div.g_record_layout_leaf.flexhbox.flexauto',
this.buildFieldDom(this.getField(fieldRowId), row),
(createEditor ?
kd.maybe(this.layoutEditor, editor => editor.buildLeafDom()) :
null
)
)
);
const sub = this.layoutSpec.subscribe((spec) => { layout.buildLayout(spec); });
if (createEditor) {
this.layoutEditor(RecordLayoutEditor.create(this, layout));
}
return dom('div.g_record_detail.flexauto',
dom.autoDispose(layout),
dom.autoDispose(sub),
createEditor ? dom.onDispose(() => {
this.layoutEditor.peek().dispose();
this.layoutEditor(null);
}) : null,
dom('div.detail_row_num', kd.text(() => (row._index() + 1))),
dom('div.g_record_detail_inner', layout.rootElem)
);
};
/**
* Returns the viewField row model for the field that the given DOM element belongs to.
*/
RecordLayout.prototype.getContainingField = function(elem, optContainer) {
return this.getField(Layout.Layout.getContainingBox(elem, optContainer).leafId());
};
/**
* Returns the RowModel for the record that the given DOM element belongs to.
*/
RecordLayout.prototype.getContainingRow = function(elem, optContainer) {
var itemElem = dom.findAncestor(elem, optContainer, '.g_record_detail');
return ko.utils.domData.get(itemElem, 'itemModel');
};
module.exports = RecordLayout;