gristlabs_grist-core/app/client/models/DataTableModelWithDiff.ts
Dmitry S 48e90c4998 (core) Change how formula columns can be converted to data.
Summary:
- No longer convert data columns to formula by typing a leading "=". Instead,
  show a tooltip with a link to click if the conversion was intended.
- No longer convert a formula column to data by deleting its formula. Leave the
  column empty instead.
- Offer the option "Convert formula to data" in column menu for formulas.
- Offer the option to "Clear column"
- If a subset of rows is shown, offer "Clear values" and "Clear entire column".

- Add logic to detect when a view shows a subset of all rows.
- Factor out showTooltip() from showTransientTooltip().

- Add a bunch of test cases to cover various combinations (there are small
  variations in options depending on whether all rows are shown, on whether
  multiple columns are selected, and whether columns include data columns).

Test Plan: Added a bunch of test cases.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2746
2021-03-05 12:42:57 -05:00

291 lines
11 KiB
TypeScript

import * as BaseRowModel from "app/client/models/BaseRowModel";
import * as DataTableModel from 'app/client/models/DataTableModel';
import { DocModel } from 'app/client/models/DocModel';
import { TableRec } from 'app/client/models/entities/TableRec';
import { TableQuerySets } from 'app/client/models/QuerySet';
import { RowGrouping, SortedRowSet } from 'app/client/models/rowset';
import { TableData } from 'app/client/models/TableData';
import { createEmptyTableDelta, getTableIdAfter, getTableIdBefore, TableDelta } from 'app/common/ActionSummary';
import { DisposableWithEvents } from 'app/common/DisposableWithEvents';
import { CellVersions, UserAction } from 'app/common/DocActions';
import { GristObjCode } from "app/common/gristTypes";
import { CellDelta } from 'app/common/TabularDiff';
import { DocStateComparisonDetails } from 'app/common/UserAPI';
import { CellValue } from 'app/plugin/GristData';
// A special row id, representing omitted rows.
const ROW_ID_SKIP = -1;
/**
* Represent extra rows in a table that correspond to rows added in a remote (right) document,
* or removed in the local (left) document relative to a common ancestor.
*
* We assign synthetic row ids for these rows somewhat arbitrarily as follows:
* - For rows added remotely, we map their id to - id * 2 - 1
* - For rows removed locally, we map their id to - id * 2 - 2
* - (id of -1 is left free for use in skipped rows)
* This should be the only part of the code that knows that.
*/
export class ExtraRows {
readonly leftTableDelta?: TableDelta;
readonly rightTableDelta?: TableDelta;
readonly rightAddRows: Set<number>;
readonly rightRemoveRows: Set<number>;
readonly leftAddRows: Set<number>;
readonly leftRemoveRows: Set<number>;
/**
* Map back from a possibly synthetic row id to an original strictly-positive row id.
*/
public static interpretRowId(rowId: number): { type: 'remote-add'|'local-remove'|'shared'|'skipped', id: number } {
if (rowId >= 0) { return { type: 'shared', id: rowId }; }
else if (rowId === ROW_ID_SKIP) { return { type: 'skipped', id: rowId }; }
else if (rowId % 2 !== 0) { return { type: 'remote-add', id: -(rowId + 1) / 2 }; }
return { type: 'local-remove', id: -(rowId + 2) / 2 };
}
public constructor(readonly tableId: string, readonly comparison?: DocStateComparisonDetails) {
const remoteTableId = getRemoteTableId(tableId, comparison);
this.leftTableDelta = this.comparison?.leftChanges?.tableDeltas[tableId];
if (remoteTableId) {
this.rightTableDelta = this.comparison?.rightChanges?.tableDeltas[remoteTableId];
}
this.rightAddRows = new Set(this.rightTableDelta?.addRows.map(id => -id * 2 - 1));
this.rightRemoveRows = new Set(this.rightTableDelta?.removeRows);
this.leftAddRows = new Set(this.leftTableDelta?.addRows);
this.leftRemoveRows = new Set(this.leftTableDelta?.removeRows.map(id => -id * 2 - 2));
}
/**
* Get a list of extra synthetic row ids to add.
*/
public getExtraRows(): ReadonlyArray<number> {
return [...this.rightAddRows].concat([...this.leftRemoveRows]);
}
/**
* Classify the row as either remote-add, remote-remove, local-add, or local-remove.
*/
public getRowType(rowId: number) {
if (this.rightAddRows.has(rowId)) { return 'remote-add'; }
else if (this.leftAddRows.has(rowId)) { return 'local-add'; }
else if (this.rightRemoveRows.has(rowId)) { return 'remote-remove'; }
else if (this.leftRemoveRows.has(rowId)) { return 'local-remove'; }
// TODO: consider what should happen when a row is removed both locally and remotely.
return '';
}
}
/**
*
* A variant of DataTableModel that is aware of a comparison with another version of the table.
* The constructor takes a DataTableModel and DocStateComparisonDetails. We act as a proxy
* for that DataTableModel, with the following changes to tableData:
*
* - a cell changed remotely from A to B is given the value ['X', {parent: A, remote: B}].
* - a cell changed locally from A to B1 and remotely from A to B2 is given the value
* ['X', {parent: A, local: B1, remote: B2}].
* - negative rowIds are served from the remote table.
*
*/
export class DataTableModelWithDiff extends DisposableWithEvents implements DataTableModel {
public docModel: DocModel;
public isLoaded: ko.Observable<boolean>;
public tableData: TableData;
public tableMetaRow: TableRec;
public tableQuerySets: TableQuerySets;
// For viewing purposes (LazyRowsModel), cells should have comparison info, so we will
// forward to a comparison-aware wrapper. Otherwise, the model is left substantially
// unchanged for now.
private _wrappedModel: DataTableModel;
public constructor(public core: DataTableModel, comparison: DocStateComparisonDetails) {
super();
this.tableMetaRow = core.tableMetaRow;
this.tableQuerySets = core.tableQuerySets;
this.docModel = core.docModel;
const tableId = core.tableData.tableId;
const remoteTableId = getRemoteTableId(tableId, comparison) || '';
this.tableData = new TableDataWithDiff(
core.tableData,
comparison.leftChanges.tableDeltas[tableId] || createEmptyTableDelta(),
comparison.rightChanges.tableDeltas[remoteTableId] || createEmptyTableDelta()) as any;
this.isLoaded = core.isLoaded;
this._wrappedModel = new DataTableModel(this.docModel, this.tableData, this.tableMetaRow);
}
public createLazyRowsModel(sortedRowSet: SortedRowSet, optRowModelClass: any) {
return this._wrappedModel.createLazyRowsModel(sortedRowSet, optRowModelClass);
}
public createFloatingRowModel(optRowModelClass: any): BaseRowModel {
return this._wrappedModel.createFloatingRowModel(optRowModelClass);
}
public fetch(force?: boolean): Promise<void> {
return this.core.fetch(force);
}
public getAllRows(): ReadonlyArray<number> {
// Could add remote rows, but this method isn't used so it doesn't matter.
return this.core.getAllRows();
}
public getNumRows(): number {
return this.core.getNumRows();
}
public getRowGrouping(groupByCol: string): RowGrouping<CellValue> {
return this.core.getRowGrouping(groupByCol);
}
public sendTableActions(actions: UserAction[], optDesc?: string): Promise<any[]> {
return this.core.sendTableActions(actions, optDesc);
}
public sendTableAction(action: UserAction, optDesc?: string): Promise<any> | undefined {
return this.core.sendTableAction(action, optDesc);
}
}
/**
* A variant of TableData that is aware of a comparison with another version of the table.
* TODO: flesh out, just included essential members so far.
*/
export class TableDataWithDiff {
public dataLoadedEmitter: any;
public tableActionEmitter: any;
private _leftRemovals: Set<number>;
private _rightRemovals: Set<number>;
private _updates: Set<number>;
constructor(public core: TableData, public leftTableDelta: TableDelta, public rightTableDelta: TableDelta) {
this.dataLoadedEmitter = core.dataLoadedEmitter;
this.tableActionEmitter = core.tableActionEmitter;
// Construct the set of all rows updated in either left/local or right/remote.
// Omit any rows that were deleted in the other version, for simplicity.
this._leftRemovals = new Set(leftTableDelta.removeRows);
this._rightRemovals = new Set(rightTableDelta.removeRows);
this._updates = new Set([
...leftTableDelta.updateRows.filter(r => !this._rightRemovals.has(r)),
...rightTableDelta.updateRows.filter(r => !this._leftRemovals.has(r))
]);
}
public getColIds(): string[] {
return this.core.getColIds();
}
public sendTableActions(actions: UserAction[], optDesc?: string): Promise<any[]> {
return this.core.sendTableActions(actions, optDesc);
}
public sendTableAction(action: UserAction, optDesc?: string): Promise<any> | undefined {
return this.core.sendTableAction(action, optDesc);
}
/**
* Make a variant of getter for a column that calls getValue for rows added remotely,
* or rows with updates.
*/
public getRowPropFunc(colId: string) {
const fn = this.core.getRowPropFunc(colId);
if (!fn) { return fn; }
return (rowId: number|"new") => {
if (rowId !== 'new' && (rowId < 0 || this._updates.has(rowId))) {
return this.getValue(rowId, colId);
}
return fn(rowId);
};
}
public getKeepFunc(): undefined | ((rowId: number|"new") => boolean) {
return (rowId: number|'new') => {
return rowId === 'new' || this._updates.has(rowId) || rowId < 0 ||
this._leftRemovals.has(rowId) || this._rightRemovals.has(rowId);
};
}
public getSkipRowId(): number {
return ROW_ID_SKIP;
}
public mayHaveVersions() {
return true;
}
/**
* Intercept requests for updated cells or cells from remote rows.
*/
public getValue(rowId: number, colId: string): CellValue|undefined {
if (rowId === ROW_ID_SKIP && colId !== 'id') {
return [GristObjCode.Skip];
}
if (this._updates.has(rowId)) {
const left = this.leftTableDelta.columnDeltas[colId]?.[rowId];
const right = this.rightTableDelta.columnDeltas[colId]?.[rowId];
if (left !== undefined && right !== undefined) {
return [GristObjCode.Versions, {
parent: oldValue(left),
local: newValue(left),
remote: newValue(right)
} as CellVersions];
} else if (right !== undefined) {
return [GristObjCode.Versions, {
parent: oldValue(right),
remote: newValue(right)
} as CellVersions];
} else if (left !== undefined) {
return [GristObjCode.Versions, {
parent: oldValue(left),
local: newValue(left)
} as CellVersions];
}
} else {
// keep row.id consistent with rowId for convenience.
if (colId === 'id') { return rowId; }
const {type, id} = ExtraRows.interpretRowId(rowId);
if (type === 'remote-add') {
const cell = this.rightTableDelta.columnDeltas[colId]?.[id];
const value = (cell !== undefined) ? newValue(cell) : undefined;
return value;
} else if (type === 'local-remove') {
const cell = this.leftTableDelta.columnDeltas[colId]?.[id];
const value = (cell !== undefined) ? oldValue(cell) : undefined;
return value;
}
}
return this.core.getValue(rowId, colId);
}
public get tableId() { return this.core.tableId; }
}
/**
* Get original value from a cell change, if available.
*/
function oldValue(delta: CellDelta) {
if (delta[0] === '?') { return null; }
return delta[0]?.[0];
}
/**
* Get new value from a cell change, if available.
*/
function newValue(delta: CellDelta) {
if (delta[1] === '?') { return null; }
return delta[1]?.[0];
}
/**
* Figure out the id of the specified table in the remote document.
* Returns null if table is deleted or unknown in the remote document.
*/
function getRemoteTableId(tableId: string, comparison?: DocStateComparisonDetails) {
if (!comparison) { return tableId; }
const parentTableId = getTableIdBefore(comparison.leftChanges.tableRenames, tableId);
return getTableIdAfter(comparison.rightChanges.tableRenames, parentTableId);
}