gristlabs_grist-core/app/client/models/TreeModel.ts
Dmitry S 007c0f2af0 (core) Fix some bugs with repositioning rows.
Summary:
- Fixed an issue with manualSort values being very close floats. It is already handled by the data engine, but the client was being unnecessarily proactive and introduced a bug.
- The fix also helps with rearranging rows in filtered situations: they will now stay next to the row before which they were inserted.
- The fix accidentally improves (though doesn't fully fix) the issue where new columns show up in unexpected places in the raw-data column list.
- Fixed another rare bug with row order not getting updated correctly when positions update.

Test Plan: Added test cases for the improved behavior; fixed affected tests.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3462
2022-06-07 16:55:45 -04:00

263 lines
9.0 KiB
TypeScript

/**
* This module exposes the various interface that describes the model to generate a tree view. It
* provides also a way to create a TreeModel from a grist table that implements the tree view
* interface (ie: a table with both an .indentation and .pagePos fields).
*
* To use with tableData;
* > fromTableData(tableData, (rec) => dom('div', rec.label))
*
* Optionally you can build a model by reusing items from an old model with matching records
* ids. The is useful to benefit from dom reuse of the TreeViewComponent which allow to persist
* state when the model updates.
*
*/
import { BulkColValues, UserAction } from "app/common/DocActions";
import { nativeCompare } from "app/common/gutil";
import { obsArray, ObsArray } from "grainjs";
import forEach = require("lodash/forEach");
import forEachRight = require("lodash/forEachRight");
import reverse = require("lodash/reverse");
/**
* A generic definition of a tree to use with the `TreeViewComponent`. The tree implements
* `TreeModel` and any item in it implements `TreeItem`.
*/
export interface TreeNode {
// Returns an observable array of children. Or null if the node does not accept children.
children(): ObsArray<TreeItem>|null;
// Inserts newChild as a child, before nextChild, or at the end if nextChild is null. If
// newChild is already in the tree, it is the implementer's responsibility to remove it from the
// children() list of its old parent.
insertBefore(newChild: TreeItem, nextChild: TreeItem|null): void;
// Removes child from the list of children().
removeChild(child: TreeItem): void;
}
export interface TreeItem extends TreeNode {
// Returns the DOM element to render for this tree node.
buildDom(): HTMLElement;
}
export interface TreeModel extends TreeNode {
children(): ObsArray<TreeItem>;
}
// A tree record has an id and an indentation field.
export interface TreeRecord {
id: number;
indentation: number;
pagePos: number;
[key: string]: any;
}
// This is compatible with TableData from app/client/models/TableData.
export interface TreeTableData {
getRecords(): TreeRecord[];
sendTableActions(actions: UserAction[]): Promise<unknown>;
}
// describes a function that builds dom for a particular record
type DomBuilder = (id: number) => HTMLElement;
// Returns a list of the records from table that is suitable to build the tree model, ie: records
// are sorted by .posKey, and .indentation starts at 0 for the first records and can only increase
// one step at a time (but can decrease as much as you want).
function getRecords(table: TreeTableData) {
const records = table.getRecords()
.sort((a, b) => nativeCompare(a.pagePos, b.pagePos));
return fixIndents(records);
}
// The fixIndents function returns a copy of records with the guarantee the .indentation starts at 0
// and can only increase one step at a time (note that it is however permitted to decrease several
// level at a time). This is useful to build a model for the tree view.
export function fixIndents(records: TreeRecord[]) {
let maxNextIndent = 0;
return records.map((rec, index) => {
const indentation = Math.min(maxNextIndent, rec.indentation);
maxNextIndent = indentation + 1;
return {...rec, indentation};
}) as TreeRecord[];
}
// build a tree model from a grist table storing tree view data
export function fromTableData(table: TreeTableData, buildDom: DomBuilder, oldModel?: TreeModelRecord) {
const records = getRecords(table);
const storage = {table, records};
// an object to collect items at all level of indentations
const indentations = {} as {[ind: number]: TreeItemRecord[]};
// a object that map record ids to old items
const oldItems = {} as {[id: number]: TreeItemRecord};
if (oldModel) {
walkTree(oldModel, (item: TreeItemRecord) => oldItems[item.record.id] = item);
}
// Let's iterate from bottom to top so that when we visit an item we've already built all of its
// children. For each record reuses an old item if there is one with same record id.
forEachRight(records, (rec, index) => {
const siblings = indentations[rec.indentation] = indentations[rec.indentation] || [];
const children = indentations[rec.indentation + 1] || [];
delete indentations[rec.indentation + 1];
const item = oldItems[rec.id] || new TreeItemRecord();
item.init(storage, index, reverse(children));
item.buildDom = () => buildDom(rec.id);
siblings.push(item);
});
return new TreeModelRecord(storage, reverse(indentations[0] || []));
}
// a table data with all of its records as returned by getRecords(tableData)
interface Storage {
table: TreeTableData;
records: TreeRecord[];
}
// TreeNode implementation that uses a grist table.
export class TreeNodeRecord implements TreeNode {
public storage: Storage;
public index: number|"root";
public children: () => ObsArray<TreeItemRecord>;
private _children: TreeItemRecord[];
constructor() {
// nothing here
}
public init(storage: Storage, index: number|"root", children: TreeItemRecord[]) {
this.storage = storage;
this.index = index;
this._children = children;
const obsChildren = obsArray(this._children);
this.children = () => obsChildren;
}
// Moves 'item' along with all its descendant to just before 'nextChild' by updating the
// .indentation and .position fields of all of their corresponding records in the table.
public async insertBefore(item: TreeItemRecord, nextChild: TreeItemRecord|null) {
// get records for newItem and its descendants
const records = item.getRecords();
if (records.length) {
// adjust indentation for the records
const indent = this.index === "root" ? 0 : this._records[this.index].indentation + 1;
const indentations = records.map((rec, i) => rec.indentation + indent - records[0].indentation);
// adjust positions
let upperPos: number|null;
if (nextChild) {
const index = nextChild.index;
upperPos = this._records[index].pagePos;
} else {
const lastIndex = this.findLastIndex();
if (lastIndex !== "root") {
upperPos = (this._records[lastIndex + 1] || {pagePos: null}).pagePos;
} else {
upperPos = null;
}
}
// do update
const update = records.map((rec, i) => ({...rec, indentation: indentations[i], pagePos: upperPos!}));
await this.sendActions({update});
}
}
// Sends user actions to update [A, B, ...] and remove [C, D, ...] when called with
// `{update: [A, B ...], remove: [C, D, ...]}`.
public async sendActions(actions: {update?: TreeRecord[], remove?: TreeRecord[]}) {
const update = actions.update || [];
const remove = actions.remove || [];
const userActions = [];
if (update.length) {
const values = {} as BulkColValues;
// let's transpose [{key1: "val1", ...}, ...] to {key1: ["val1", ...], ...}
forEach(update[0], (val, key) => values[key] = update.map(rec => rec[key]));
const rowIds = values.id;
delete values.id;
userActions.push(["BulkUpdateRecord", rowIds, values]);
}
if (remove.length) {
userActions.push(["BulkRemove", remove.map(rec => rec.id)]);
}
if (userActions.length) {
await this.storage.table.sendTableActions(userActions);
}
}
// Removes child.
public async removeChild(child: TreeItemRecord) {
await this.sendActions({remove: child.getRecords()});
}
// Get all the records included in this item.
public getRecords(): TreeRecord[] {
const records = [] as TreeRecord[];
if (this.index !== "root") { records.push(this._records[this.index]); }
walkTree(this, (item: TreeItemRecord) => records.push(this._records[item.index]));
return records;
}
public findLastIndex(): number|"root" {
return this._children.length ? this._children[this._children.length - 1].findLastIndex() : this.index;
}
private get _records() {
return this.storage.records;
}
}
export class TreeItemRecord extends TreeNodeRecord implements TreeItem {
public index: number;
public buildDom: () => HTMLElement;
constructor() {
super();
}
public get record() { return this.storage.records[this.index]; }
}
export class TreeModelRecord extends TreeNodeRecord implements TreeModel {
constructor(storage: Storage, children: TreeItemRecord[]) {
super();
this.init(storage, "root", children);
}
}
export function walkTree<T extends TreeItem>(model: TreeNode, func: (item: T) => void): void;
export function walkTree(model: TreeNode, func: (item: TreeItem) => void) {
const children = model.children();
if (children) {
for (const child of children.get()) {
func(child);
walkTree(child, func);
}
}
}
export function find<T extends TreeItem>(model: TreeNode, func: (item: T) => boolean): T|undefined;
export function find(model: TreeNode, func: (item: TreeItem) => boolean): TreeItem|undefined {
const children = model.children();
if (children) {
for (const child of children.get()) {
const found = func(child) && child || find(child, func);
if (found) { return found; }
}
}
}