/** * 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 {makeT} = require('../lib/localization'); 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'); const t = makeT('components.RecordLayout'); /** * 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() { if (this.viewSection.isDisposed()) { return null; } 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(t("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, createEditor); }); 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;