/** * ActionLog manages the list of actions from server and displays them in the side bar. */ import * as dispose from 'app/client/lib/dispose'; import dom from 'app/client/lib/dom'; import {timeFormat} from 'app/common/timeFormat'; import * as ko from 'knockout'; import map = require('lodash/map'); import koArray from 'app/client/lib/koArray'; import {KoArray} from 'app/client/lib/koArray'; import * as koDom from 'app/client/lib/koDom'; import * as koForm from 'app/client/lib/koForm'; import {GristDoc} from 'app/client/components/GristDoc'; import {ActionGroup} from 'app/common/ActionGroup'; import {ActionSummary, asTabularDiffs, defunctTableName, getAffectedTables, LabelDelta} from 'app/common/ActionSummary'; import {CellDelta} from 'app/common/TabularDiff'; import {IDomComponent} from 'grainjs'; import {makeT} from 'app/client/lib/localization'; /** * * Actions that are displayed in the log get a state observable * to track if they are undone/buried. * * Also for each table shown in the log, we create an observable * to track its name. References to these observables are stored * with each action, by the name of the table at that time (the * name of a table can change). * */ export interface ActionGroupWithState extends ActionGroup { state?: ko.Observable; // is action undone/buried tableFilters?: {[tableId: string]: ko.Observable}; // current names of tables affectedTableIds?: Array>; // names of tables affecting this ActionGroup } const gristNotify = (window as any).gristNotify; // Action display state enum. const state = { UNDONE: 'undone', BURIED: 'buried', DEFAULT: 'default' }; const t = makeT('components.ActionLog'); export class ActionLog extends dispose.Disposable implements IDomComponent { private _displayStack: KoArray; private _gristDoc: GristDoc|null; private _selectedTableId: ko.Computed; private _showAllTables: ko.Observable; // should all tables be visible? private _pending: ActionGroupWithState[] = []; // cache for actions that arrive while loading log private _loaded: boolean = false; // flag set once log is loaded private _loading: ko.Observable; // flag set while log is loading /** * Create an ActionLog. * @param options - supplies the GristDoc holding the log, if we have one, so that we * can cross-reference with it. We may not have a document, if used from the * command line renderActions utility, in which case we don't set up cross-references. */ public create(options: {gristDoc: GristDoc|null}) { // By default, just show actions for the currently viewed table. this._showAllTables = ko.observable(false); // We load the ActionLog lazily now, when it is first viewed. this._loading = ko.observable(false); this._gristDoc = options.gristDoc; // TODO: _displayStack grows without bound within a single session. // Stack of actions as they should be displayed to the user. this._displayStack = koArray(); // Computed for the tableId of the table currently being viewed. if (!this._gristDoc) { this._selectedTableId = this.autoDispose(ko.computed(() => "")); } else { this._selectedTableId = this.autoDispose(ko.computed( () => this._gristDoc!.viewModel.activeSection().table().tableId())); } } public buildDom() { return this._buildLogDom(); } /** * Pushes actions as they are received from the server to the display stack. * @param {Object} actionGroup - ActionGroup instance from the server. */ public pushAction(ag: ActionGroupWithState): void { if (this._loading()) { this._pending.push(ag); return; } this._setupFilters(ag, this._displayStack.at(0) || undefined); const otherAg = ag.otherId ? this._displayStack.all().find(a => a.actionNum === ag.otherId) : null; if (otherAg) { // Undo/redo action. if (otherAg.state) { otherAg.state(ag.isUndo ? state.UNDONE : state.DEFAULT); } } else { // Any (non-link) action. if (ag.fromSelf) { // Bury all undos immediately preceding this action since they can no longer // be redone. This is triggered by normal actions and undo/redo actions whose // targets are not recent (not in the stack). for (let i = 0; i < this._displayStack.peekLength; i++) { const prevAction = this._displayStack.at(i)!; if (!prevAction.state) { continue; } const prevState = prevAction.state(); if (prevAction.fromSelf && prevState === state.DEFAULT) { // When a normal action is found, stop looking to bury previous actions. break; } else if (prevAction.fromSelf && prevState === state.UNDONE) { // The previous action was undone, so now it has become buried. prevAction.state(state.BURIED); } } } if (!ag.otherId) { ag.state = ko.observable(state.DEFAULT); this._displayStack.unshift(ag); } } } /** * Render a description of an action prepared on the server. * @param {TabularDiffs} act - a collection of table changes * @param {string} txt - a textual description of the action * @param {ActionGroupWithState} ag - the full action information we have */ public renderTabularDiffs(sum: ActionSummary, txt: string, ag?: ActionGroupWithState) { const act = asTabularDiffs(sum); const editDom = dom('div', this._renderTableSchemaChanges(sum, ag), this._renderColumnSchemaChanges(sum, ag), map(act, (tdiff, table) => { if (tdiff.cells.length === 0) { return dom('div'); } return dom('table.action_log_table', koDom.show(() => this._showForTable(table, ag)), dom('caption', this._renderTableName(table)), dom('tr', dom('th'), tdiff.header.map(diff => { return dom('th', this._renderCell(diff)); })), tdiff.cells.map(row => { return dom('tr', dom('td', this._renderCell(row[0])), row[2].map((diff, idx: number) => { return dom('td', this._renderCell(diff), dom.on('click', () => { return this._selectCell(row[1], act[table].header[idx], table, ag ? ag.actionNum : 0); })); })); })); }), dom('span.action_comment', txt)); return editDom; } /** * Decorate an ActionGroup with observables for controlling visibility of any * table information rendered from it. Observables are shared with the previous * ActionGroup, and simply stored under a new name as needed. */ private _setupFilters(ag: ActionGroupWithState, prev?: ActionGroupWithState): void { const filt: {[name: string]: ko.Observable} = ag.tableFilters = {}; // First, bring along observables for tables from previous actions. if (prev) { // Tables are renamed from time to time - prepare dictionary of updates. const renames = new Map(ag.actionSummary.tableRenames); for (const name of Object.keys(prev.tableFilters!)) { if (name.startsWith('-')) { // skip } else if (renames.has(name)) { const newName = renames.get(name) || defunctTableName(name); filt[newName] = prev.tableFilters![name]; filt[newName](newName); // Update the observable with the new name. } else { filt[name] = prev.tableFilters![name]; } } } // Add any more observables that we need for this action. const names = getAffectedTables(ag.actionSummary); for (const name of names) { if (!filt[name]) { filt[name] = ko.observable(name); } } // Record the observables that affect this ActionGroup specifically ag.affectedTableIds = names.map(name => ag.tableFilters![name]).filter(obs => obs); } /** * Helper function that returns true if any table touched by the ActionGroup * is set to be visible. */ private _hasSelectedTable(ag: ActionGroupWithState): boolean { if (!this._gristDoc) { return true; } return ag.affectedTableIds!.some(tableId => tableId() === this._selectedTableId()); } /** * Return a koDom.show clause that activates when the named table is not * filtered out. */ private _showForTable(tableName: string, ag?: ActionGroupWithState): boolean { if (!ag) { return true; } const obs = ag.tableFilters![tableName]; return this._showAllTables() || !obs || obs() === this._selectedTableId(); } private _buildLogDom() { this._loadActionSummaries().catch((error) => gristNotify(t("ActionLogFailed"))); return dom('div.action_log', dom('div.preference_item', koForm.checkbox(this._showAllTables, dom.testId('ActionLog_allTables'), dom('span.preference_desc', 'All tables'))), dom('div.action_log_load', koDom.show(() => this._loading()), 'Loading...'), koDom.foreach(this._displayStack, (ag: ActionGroupWithState) => { const timestamp = ag.time ? timeFormat("D T", new Date(ag.time)) : ""; let desc = ag.desc || ""; if (ag.actionSummary) { desc = this.renderTabularDiffs(ag.actionSummary, desc, ag); } return dom('div.action_log_item', koDom.cssClass(ag.state), koDom.show(() => this._showAllTables() || this._hasSelectedTable(ag)), dom('div.action_info', dom('span.action_info_action_num', `#${ag.actionNum}`), ag.user ? dom('span.action_info_user', ag.user, koDom.toggleClass('action_info_from_self', ag.fromSelf) ) : '', dom('span.action_info_timestamp', timestamp)), dom('span.action_desc', desc) ); }) ); } /** * Fetch summaries of recent actions (with summaries) from the server. */ private async _loadActionSummaries() { if (this._loaded || !this._gristDoc) { return; } this._loading(true); // Returned actions are ordered with earliest actions first. const result = await this._gristDoc.docComm.getActionSummaries(); this._loading(false); this._loaded = true; // Add the actions to our action log. result.forEach(item => this.pushAction(item)); // Add any actions that came in while we were fetching. Unlikely, but // perhaps possible? const top = result.length > 0 ? result[result.length - 1].actionNum : 0; for (const item of this._pending) { if (item.actionNum > top) { this.pushAction(item); } } this._pending.length = 0; } /** * Prepare dom element(s) for a cell that has been created, destroyed, * or modified. * * @param {CellDelta|string|null} cell - a structure with before and after values, * or a plain string, or null * */ private _renderCell(cell: CellDelta|string|null) { // we'll show completely empty cells as "..." if (cell === null) { return "..."; } // strings are shown as themselves if (typeof(cell) === 'string') { return cell; } // by elimination, we have a TabularDiff.CellDelta with before and after values. const [pre, post] = cell; if (!pre && !post) { // very boring before + after values :-) return ""; } else if (pre && !post) { // this is a cell that was removed return dom('span.action_log_cell_remove', pre[0]); } else if (post && (pre === null || (pre[0] === null || pre[0] === ''))) { // this is a cell that was added, or modified from a previously empty value return dom('span.action_log_cell_add', post[0]); } else if (pre && post) { // a modified cell return dom('div', dom('span.action_log_cell_remove.action_log_cell_pre', pre[0]), dom('span.action_log_cell_add', post[0])); } return JSON.stringify(cell); } /** * Choose a table name to show. For now, we show diffs of metadata tables also. * For those tables, we show "_grist_Foo_bar" as "[Foo.bar]". * @param {string} name - tableId of table * @returns {string} a friendlier name for the table */ private _renderTableName(name: string): string { if (name.indexOf('_grist_') !== 0) { // Ordinary data table. Ideally, we would look up // a friendly name from a raw data view - TODO. return name; } const metaName = name.split('_grist_')[1].replace(/_/g, '.'); return `[${metaName}]`; } /** * Show an ActionLog item when a column or table is renamed, added, or removed. * Make sure the item is only shown when the affected table is not filtered out. * * @param scope: blank for tables, otherwise "." * @param pair: the rename/addition/removal in LabelDelta format: [null, name1] * for addition of name1, [name2, null] for removal of name2, [name1, name2] * for a rename of name1 to name2. * @return a filtered dom element. */ private _renderSchemaChange(scope: string, pair: LabelDelta, ag?: ActionGroupWithState) { const [pre, post] = pair; // ignore addition/removal of manualSort column if ((pre || post) === 'manualSort') { return dom('div'); } return dom('div.action_log_rename', koDom.show(() => this._showForTable(post || defunctTableName(pre!), ag)), (!post ? ["Remove ", scope, dom("span.action_log_rename_pre", pre)] : (!pre ? ["Add ", scope, dom("span.action_log_rename_post", post)] : ["Rename ", scope, dom("span.action_log_rename_pre", pre), " to ", dom("span.action_log_rename_post", post)]))); } /** * Show any table additions/removals/renames. */ private _renderTableSchemaChanges(sum: ActionSummary, ag?: ActionGroupWithState) { return dom('div', sum.tableRenames.map(pair => this._renderSchemaChange("", pair, ag))); } /** * Show any column additions/removals/renames. */ private _renderColumnSchemaChanges(sum: ActionSummary, ag?: ActionGroupWithState) { return dom('div', Object.keys(sum.tableDeltas).filter(key => !key.startsWith('-')).map(key => dom('div', koDom.show(() => this._showForTable(key, ag)), sum.tableDeltas[key].columnRenames.map(pair => this._renderSchemaChange(key + ".", pair))))); } /** * Move cursor to show a given cell of a given table. Uses primary view of table. */ private async _selectCell(rowId: number, colId: string, tableId: string, actionNum: number) { if (!this._gristDoc) { return; } // Find action in the stack. const index = this._displayStack.peek().findIndex(a => a.actionNum === actionNum); if (index < 0) { throw new Error(`Cannot find action ${actionNum} in the action log.`); } // Found the action. Now trace forward to find current tableId, colId, rowId. for (let i = index; i >= 0; i--) { const action = this._displayStack.at(i)!; const sum = action.actionSummary; // Check if this table was renamed / removed. const tableRename: LabelDelta|undefined = sum.tableRenames.find(r => r[0] === tableId); if (tableRename) { const newName = tableRename[1]; if (!newName) { // TODO - find a better way to send informative notifications. gristNotify(t('TableRemovedInAction', {tableId:tableId, actionNum: action.actionNum})); return; } tableId = newName; } const td = sum.tableDeltas[tableId]; if (!td) { continue; } // Check is this row was removed - if so there's no reason to go on. if (td.removeRows.indexOf(rowId) >= 0) { // TODO - find a better way to send informative notifications. gristNotify(t("RowRemovedInAction", {actionNum})); return; } // Check if this column was renamed / added. const columnRename: LabelDelta|undefined = td.columnRenames.find(r => r[0] === colId); if (columnRename) { const newName = columnRename[1]; if (!newName) { // TODO - find a better way to send informative notifications. gristNotify(t("ColumnRemovedInAction", {colId, actionNum: action.actionNum})); return; } colId = newName; } } // Find the table model of interest. const tableModel = this._gristDoc.getTableModel(tableId); if (!tableModel) { return; } // Get its "primary" view. const viewRow = tableModel.tableMetaRow.primaryView(); const viewId = viewRow.getRowId(); // Switch to that view. await this._gristDoc.openDocPage(viewId); // Now let's pick a reasonable section in that view. const viewSection = viewRow.viewSections().peek().find((s: any) => s.table().tableId() === tableId); if (!viewSection) { return; } const sectionId = viewSection.getRowId(); // Within that section, find the column of interest if possible. const fieldIndex = viewSection.viewFields().peek().findIndex((f: any) => f.colId.peek() === colId); // Finally, move cursor position to the section, column (if we found it), and row. this._gristDoc.moveToCursorPos({rowId, sectionId, fieldIndex}).catch(() => { /* do nothing */ }); } }