mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
379 lines
14 KiB
JavaScript
379 lines
14 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');
|
|
var commands = require('./commands');
|
|
var {menuToggle} = require('app/client/ui/MenuToggle');
|
|
var {menu} = require('../ui2018/menus');
|
|
var {testId} = require('app/client/ui2018/cssVars');
|
|
var {contextMenu} = require('app/client/ui/contextMenu');
|
|
|
|
/**
|
|
* 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.buildContextMenu = options.buildContextMenu;
|
|
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
|
|
// existing 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);
|
|
|
|
// Use separate copies of addColAction, since sendTableActions modified each in-place.
|
|
let addActions = gutil.arrayRepeat(addColNum, 0).map(() => addColAction.slice());
|
|
|
|
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,
|
|
// enables row context menu anywhere on the card
|
|
contextMenu(() => this.buildContextMenu(row)),
|
|
dom('div.detail_row_num',
|
|
kd.text(() => (row._index() + 1)),
|
|
dom.on('contextmenu', ev => {
|
|
// This is a little hack to position the menu the same way as with a click,
|
|
// the same hack as on a column menu.
|
|
ev.preventDefault();
|
|
// prevent 2nd context menu to show up
|
|
ev.stopPropagation();
|
|
ev.currentTarget.querySelector('.menu_toggle').click();
|
|
}),
|
|
menuToggle(null,
|
|
dom.on('click', () => {
|
|
this.viewSection.hasFocus(true);
|
|
commands.allCommands.setCursor.run(row);
|
|
}),
|
|
menu(() => this.buildContextMenu(row)),
|
|
testId('card-menu-trigger')
|
|
)
|
|
),
|
|
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;
|