gristlabs_grist-core/app/client/components/ActionLog.ts
2023-01-03 15:49:26 +01:00

450 lines
17 KiB
TypeScript

/**
* 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<string>; // is action undone/buried
tableFilters?: {[tableId: string]: ko.Observable<string>}; // current names of tables
affectedTableIds?: Array<ko.Observable<string>>; // 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<ActionGroupWithState>;
private _gristDoc: GristDoc|null;
private _selectedTableId: ko.Computed<string>;
private _showAllTables: ko.Observable<boolean>; // 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<boolean>; // 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<ActionGroupWithState>();
// 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<string>} = 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("Action Log failed to load")));
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 "<tablename>."
* @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("Table {{tableId}} was subsequently removed in action #{{actionNum}}", {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("This row was subsequently removed in action {{action.actionNum}}", {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("Column {{colId}} was subsequently removed in action #{{action.actionNum}}", {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 */ });
}
}