mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
bfd7243fe2
Summary: First iteration for comments system for Grist. - Comments are stored in a generic metatable `_grist_Cells` - Each comment is connected to a particular cell (hence the generic name of the table) - Access level works naturally for records stored in this table -- User can add/read comments for cells he can see -- User can't update/remove comments that he doesn't own, but he can delete them by removing cells (rows/columns) -- Anonymous users can't see comments at all. - Each comment can have replies (but replies can't have more replies) Comments are hidden by default, they can be enabled by COMMENTS=true env variable. Some things for follow-up - Avatars, currently the user's profile image is not shown or retrieved from the server - Virtual rendering for comments list in creator panel. Currently, there is a limit of 200 comments. Test Plan: New and existing tests Reviewers: georgegevoian, paulfitz Reviewed By: georgegevoian Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D3509
776 lines
26 KiB
TypeScript
776 lines
26 KiB
TypeScript
/**
|
|
* rowset.js module defines a number of classes to deal with maintaining collections of rows and
|
|
* listening to their changes.
|
|
*
|
|
* RowSource: abstract interface for a source of row changes.
|
|
* - emits rowChange('add|remove|update', rows) events with rows an iterable.
|
|
* - offers getAllRows() method that returns all rows currently in the RowSource.
|
|
*
|
|
* RowListener: base class for a listener to row changes.
|
|
* - offers subscribeTo(rowSource), unsubscribeFrom(rowSource) methods.
|
|
* - derived classes should implement onAddRows(), onRemoveRows(), onUpdateRows().
|
|
*
|
|
* FilteredRowSource(filterFunc): a RowListener that can be subscribed to any other RowSources and
|
|
* is itself a RowSource which forwards changes to rows that match filterFunc.
|
|
*
|
|
* RowGrouping(groupFunc): a RowListener that can be subscribed to any RowSources, groups
|
|
* rows by the result of groupFunc, and exposes a per-group RowSource via its getGroup() method.
|
|
*
|
|
* SortedRowSet(compareFunc): a RowListener that can be subscribed to any RowSources, and exposes
|
|
* an observable koArray via getKoArray(), which maintains rows from RowSources in sorted order.
|
|
*/
|
|
// tslint:disable:max-classes-per-file
|
|
|
|
import koArray, {KoArray} from 'app/client/lib/koArray';
|
|
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
|
import {CompareFunc, sortedIndex} from 'app/common/gutil';
|
|
import {SkippableRows} from 'app/common/TableData';
|
|
import {RowFilterFunc} from "app/common/RowFilterFunc";
|
|
import {Observable} from 'grainjs';
|
|
|
|
/**
|
|
* Special constant value that can be used for the `rows` array for the 'rowNotify'
|
|
* event to indicate that the event applies to all rows.
|
|
*/
|
|
export const ALL: unique symbol = Symbol("ALL");
|
|
|
|
export type ChangeType = 'add' | 'remove' | 'update';
|
|
export type ChangeMethod = 'onAddRows' | 'onRemoveRows' | 'onUpdateRows';
|
|
export type RowId = number | 'new';
|
|
export type RowList = Iterable<RowId>;
|
|
export type RowsChanged = RowList | typeof ALL;
|
|
|
|
// ----------------------------------------------------------------------
|
|
// RowSource
|
|
// ----------------------------------------------------------------------
|
|
|
|
/**
|
|
* RowSource is an interface expected by RowListener. It should implement `getAllRows()` method,
|
|
* and should emit `rowChange('add|remove|update', rows)` events on changes,
|
|
* and `rowNotify(rows, value)` event to notify listeners of a value associated with a row.
|
|
* For the `rowNotify` event, rows may be the rowset.ALL constant.
|
|
*/
|
|
export abstract class RowSource extends DisposableWithEvents {
|
|
/**
|
|
* Returns an iterable over all rows in this RowSource. Should be implemented by derived classes.
|
|
*/
|
|
public abstract getAllRows(): RowList;
|
|
|
|
/**
|
|
* Returns the number of rows in this row source.
|
|
*/
|
|
public abstract getNumRows(): number;
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// RowListener
|
|
// ----------------------------------------------------------------------
|
|
|
|
const _changeTypes: {[key: string]: ChangeMethod} = {
|
|
add: 'onAddRows',
|
|
remove: 'onRemoveRows',
|
|
update: 'onUpdateRows',
|
|
};
|
|
|
|
/**
|
|
* RowListener is the base class for collections that want to subscribe to rowset changes. It
|
|
* offers `subscribeTo(rowSource)` method. The derived class should implement several methods
|
|
* which will be called on row changes.
|
|
*/
|
|
export class RowListener extends DisposableWithEvents {
|
|
/**
|
|
* Subscribes to the given rowSource and adds the rows currently in it.
|
|
*/
|
|
public subscribeTo(rowSource: RowSource): void {
|
|
this.onAddRows(rowSource.getAllRows());
|
|
this.listenTo(rowSource, 'rowChange', (changeType: ChangeType, rows: RowList) => {
|
|
const method: ChangeMethod = _changeTypes[changeType];
|
|
this[method](rows);
|
|
});
|
|
this.listenTo(rowSource, 'rowNotify', this.onRowNotify);
|
|
}
|
|
|
|
/**
|
|
* Unsubscribes from the given rowSource removing its rows. This is not needed for disposal;
|
|
* dispose() on its own is sufficient and faster.
|
|
*/
|
|
public unsubscribeFrom(rowSource: RowSource): void {
|
|
this.stopListening(rowSource, 'rowChange');
|
|
this.stopListening(rowSource, 'rowNotify');
|
|
this.onRemoveRows(rowSource.getAllRows());
|
|
}
|
|
|
|
/**
|
|
* Process row additions. To be implemented by derived classes.
|
|
*/
|
|
protected onAddRows(rows: RowList) { /* no-op */ }
|
|
|
|
/**
|
|
* Process row removals. To be implemented by derived classes.
|
|
*/
|
|
protected onRemoveRows(rows: RowList) { /* no-op */ }
|
|
|
|
/**
|
|
* Process row updates. To be implemented by derived classes.
|
|
*/
|
|
protected onUpdateRows(rows: RowList) { /* no-op */ }
|
|
|
|
/**
|
|
* Derived classes may override this event to handle row notifications. By default, it re-triggers
|
|
* rowNotify on the RowListener itself.
|
|
*/
|
|
protected onRowNotify(rows: RowList, notifyValue: any) {
|
|
this.trigger('rowNotify', rows, notifyValue);
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// MappedRowSource
|
|
// ----------------------------------------------------------------------
|
|
|
|
/**
|
|
* A trivial RowSource returning a fixed list of rows.
|
|
*/
|
|
export abstract class ArrayRowSource extends RowSource {
|
|
constructor(private _rows: RowId[]) { super(); }
|
|
public getAllRows(): RowList { return this._rows; }
|
|
public getNumRows(): number { return this._rows.length; }
|
|
}
|
|
|
|
/**
|
|
* MappedRowSource wraps any other RowSource, and passes through all rows, replacing each row
|
|
* identifier with the result of mapperFunc(row) call.
|
|
*
|
|
* The underlying RowSource is exposed as this.parentRowSource.
|
|
*
|
|
* TODO: This class is not used anywhere at the moment, and is a candidate for removal.
|
|
*/
|
|
export class MappedRowSource extends RowSource {
|
|
private _mapperFunc: (row: RowId) => RowId;
|
|
|
|
constructor(
|
|
public parentRowSource: RowSource,
|
|
mapperFunc: (row: RowId) => RowId,
|
|
) {
|
|
super();
|
|
|
|
// Wrap mapperFunc to ensure arguments after the first one aren't passed on to it.
|
|
this._mapperFunc = (row => mapperFunc(row));
|
|
|
|
// Listen to the two event types a rowSource might produce, and map the rows in them.
|
|
this.listenTo(parentRowSource, 'rowChange', (changeType: ChangeType, rows: RowList) => {
|
|
this.trigger('rowChange', changeType, Array.from(rows, this._mapperFunc));
|
|
});
|
|
this.listenTo(parentRowSource, 'rowNotify', (rows: RowsChanged, notifyValue: any) => {
|
|
this.trigger('rowNotify', rows === ALL ? ALL : Array.from(rows, this._mapperFunc), notifyValue);
|
|
});
|
|
}
|
|
|
|
public getAllRows(): RowList {
|
|
return Array.from(this.parentRowSource.getAllRows(), this._mapperFunc);
|
|
}
|
|
|
|
public getNumRows(): number {
|
|
return this.parentRowSource.getNumRows();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A RowSource with some extra rows added.
|
|
*/
|
|
export class ExtendedRowSource extends RowSource {
|
|
|
|
constructor(
|
|
public parentRowSource: RowSource,
|
|
public extras: RowId[]
|
|
) {
|
|
super();
|
|
|
|
// Listen to the two event types a rowSource might produce, and map the rows in them.
|
|
this.listenTo(parentRowSource, 'rowChange', (changeType: ChangeType, rows: RowList) => {
|
|
this.trigger('rowChange', changeType, rows);
|
|
});
|
|
this.listenTo(parentRowSource, 'rowNotify', (rows: RowsChanged, notifyValue: any) => {
|
|
this.trigger('rowNotify', rows === ALL ? ALL : rows, notifyValue);
|
|
});
|
|
}
|
|
|
|
public getAllRows(): RowList {
|
|
return [...this.parentRowSource.getAllRows()].concat(this.extras);
|
|
}
|
|
|
|
public getNumRows(): number {
|
|
return this.parentRowSource.getNumRows() + this.extras.length;
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// FilteredRowSource
|
|
// ----------------------------------------------------------------------
|
|
|
|
interface FilterRowChanges {
|
|
adds?: RowId[];
|
|
updates?: RowId[];
|
|
removes?: RowId[];
|
|
}
|
|
|
|
/**
|
|
* See FilteredRowSource, for which this is the base. BaseFilteredRowSource is simpler, in that it
|
|
* does not maintain excluded rows, and does not allow changes to filterFunc.
|
|
*/
|
|
export class BaseFilteredRowSource extends RowListener implements RowSource {
|
|
protected _matchingRows: Set<RowId> = new Set(); // Set of rows matching the filter.
|
|
|
|
constructor(protected _filterFunc: RowFilterFunc<RowId>) {
|
|
super();
|
|
}
|
|
|
|
public getAllRows(): RowList {
|
|
return this._matchingRows.values();
|
|
}
|
|
|
|
public getNumRows(): number {
|
|
return this._matchingRows.size;
|
|
}
|
|
|
|
public onAddRows(rows: RowList) {
|
|
const outputRows = [];
|
|
for (const r of rows) {
|
|
if (this._filterFunc(r)) {
|
|
this._matchingRows.add(r);
|
|
outputRows.push(r);
|
|
} else {
|
|
this._addExcludedRow(r);
|
|
}
|
|
}
|
|
if (outputRows.length > 0) {
|
|
this.trigger('rowChange', 'add', outputRows);
|
|
}
|
|
}
|
|
|
|
public onRemoveRows(rows: RowList) {
|
|
const outputRows = [];
|
|
for (const r of rows) {
|
|
if (this._matchingRows.delete(r)) {
|
|
outputRows.push(r);
|
|
}
|
|
this._deleteExcludedRow(r);
|
|
}
|
|
if (outputRows.length > 0) {
|
|
this.trigger('rowChange', 'remove', outputRows);
|
|
}
|
|
}
|
|
|
|
public onUpdateRows(rows: RowList) {
|
|
const changes = this._updateRowsHelper({}, rows);
|
|
if (changes.removes) { this.trigger('rowChange', 'remove', changes.removes); }
|
|
if (changes.updates) { this.trigger('rowChange', 'update', changes.updates); }
|
|
if (changes.adds) { this.trigger('rowChange', 'add', changes.adds); }
|
|
}
|
|
|
|
public onRowNotify(rows: RowsChanged, notifyValue: any) {
|
|
if (rows === ALL) {
|
|
this.trigger('rowNotify', ALL, notifyValue);
|
|
} else {
|
|
const outputRows = [];
|
|
for (const r of rows) {
|
|
if (this._matchingRows.has(r)) {
|
|
outputRows.push(r);
|
|
}
|
|
}
|
|
if (outputRows.length > 0) {
|
|
this.trigger('rowNotify', outputRows, notifyValue);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper which goes through the given rows, applies _filterFunc() to them, and depending on the
|
|
* result, adds the row to one of the arrays: changes.adds, changes.removes, or changes.updates.
|
|
* Returns `changes` (the first parameter).
|
|
*/
|
|
protected _updateRowsHelper(changes: FilterRowChanges, rows: RowList) {
|
|
for (const r of rows) {
|
|
if (this._filterFunc(r)) {
|
|
if (this._matchingRows.has(r)) {
|
|
(changes.updates || (changes.updates = [])).push(r);
|
|
} else if (this._deleteExcludedRow(r)) {
|
|
this._matchingRows.add(r);
|
|
(changes.adds || (changes.adds = [])).push(r);
|
|
}
|
|
} else {
|
|
if (this._matchingRows.delete(r)) {
|
|
this._addExcludedRow(r);
|
|
(changes.removes || (changes.removes = [])).push(r);
|
|
}
|
|
}
|
|
}
|
|
return changes;
|
|
}
|
|
|
|
// These are implemented by FilteredRowSource, but the base class doesn't need to do anything.
|
|
protected _addExcludedRow(row: RowId): void { /* no-op */ }
|
|
protected _deleteExcludedRow(row: RowId): boolean { return true; }
|
|
}
|
|
|
|
/**
|
|
* FilteredRowSource can listen to any other RowSource, and passes through only the rows matching
|
|
* the given filter function. In particular, an 'update' event may turn into an 'add' or 'remove'
|
|
* if the row starts or stops matching the function.
|
|
*
|
|
* FilteredRowSource is also a RowListener, so to subscribe to a rowSource, use `subscribeTo()`.
|
|
*/
|
|
export class FilteredRowSource extends BaseFilteredRowSource {
|
|
private _excludedRows: Set<RowId> = new Set(); // Set of rows NOT matching the filter.
|
|
|
|
/**
|
|
* Change the filter function. This may trigger 'remove' and 'add' events as necessary to indicate
|
|
* that rows stopped or started matching the new filter.
|
|
*/
|
|
public updateFilter(filterFunc: RowFilterFunc<RowId>) {
|
|
this._filterFunc = filterFunc;
|
|
const changes: FilterRowChanges = {};
|
|
// After the first call, _excludedRows may have additional rows, but there is no harm in it,
|
|
// as we know they don't match, and so will be ignored by _updateRowsHelper.
|
|
this._updateRowsHelper(changes, this._matchingRows);
|
|
this._updateRowsHelper(changes, this._excludedRows);
|
|
if (changes.removes) { this.trigger('rowChange', 'remove', changes.removes); }
|
|
if (changes.adds) { this.trigger('rowChange', 'add', changes.adds); }
|
|
}
|
|
|
|
/**
|
|
* Re-apply the filter to the given rows, triggering add/remove events as needed. This is also
|
|
* similar to what happens on an rowChange/update event from a RowSource, except that no 'update'
|
|
* event is propagated if filter status hasn't changed.
|
|
*/
|
|
public refilterRows(rows: RowList) {
|
|
const changes = this._updateRowsHelper({}, rows);
|
|
if (changes.removes) { this.trigger('rowChange', 'remove', changes.removes); }
|
|
if (changes.adds) { this.trigger('rowChange', 'add', changes.adds); }
|
|
}
|
|
|
|
/**
|
|
* Returns an iterable over all rows that got filtered out by this FilteredRowSource.
|
|
*/
|
|
public getHiddenRows() {
|
|
return this._excludedRows.values();
|
|
}
|
|
|
|
protected _addExcludedRow(row: RowId): void { this._excludedRows.add(row); }
|
|
protected _deleteExcludedRow(row: RowId): boolean { return this._excludedRows.delete(row); }
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// RowGrouping
|
|
// ----------------------------------------------------------------------
|
|
|
|
/**
|
|
* Private helper object that maintains a set of rows for a particular group.
|
|
*/
|
|
class RowGroupHelper<Value> extends RowSource {
|
|
private _rows: Set<RowId> = new Set();
|
|
constructor(public readonly groupValue: Value) {
|
|
super();
|
|
}
|
|
|
|
public getAllRows() {
|
|
return this._rows.values();
|
|
}
|
|
|
|
public getNumRows(): number {
|
|
return this._rows.size;
|
|
}
|
|
|
|
public _addAll(rows: RowList) {
|
|
for (const r of rows) { this._rows.add(r); }
|
|
}
|
|
|
|
public _removeAll(rows: RowList) {
|
|
for (const r of rows) { this._rows.delete(r); }
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
/**
|
|
* Helper function that does map.get(key).push(r), creating an Array for the given key if
|
|
* necessary.
|
|
*/
|
|
function _addToMapOfArrays<K, V>(map: Map<K, V[]>, key: K, r: V): void {
|
|
let arr = map.get(key);
|
|
if (!arr) { map.set(key, arr = []); }
|
|
arr.push(r);
|
|
}
|
|
|
|
|
|
/**
|
|
* RowGrouping is a RowListener which groups rows by the results of _groupFunc(row) and exposes
|
|
* per-group RowSources via getGroup(val).
|
|
*
|
|
* @param {Function} groupFunc: called with row identifier, should return the value to group by.
|
|
* The returned value must be a primitive value such as a String or Number.
|
|
*/
|
|
export class RowGrouping<Value> extends RowListener {
|
|
// Maps row identifiers to groupValues.
|
|
private _rowsToValues: Map<RowId, Value> = new Map();
|
|
|
|
// Maps group values to RowGroupHelpers
|
|
private _valuesToGroups: Map<Value, RowGroupHelper<Value>> = new Map();
|
|
|
|
constructor(private _groupFunc: (row: RowId) => Value) {
|
|
super();
|
|
|
|
// On disposal, dispose all RowGroupHelpers that we maintain.
|
|
this.onDispose(() => {
|
|
for (const rowGroupHelper of this._valuesToGroups.values()) {
|
|
rowGroupHelper.dispose();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns a RowSource for the group of rows for which groupFunc(row) is equal to groupValue.
|
|
*/
|
|
public getGroup(groupValue: Value): RowGroupHelper<Value> {
|
|
let group = this._valuesToGroups.get(groupValue);
|
|
if (!group) {
|
|
group = new RowGroupHelper(groupValue);
|
|
this._valuesToGroups.set(groupValue, group);
|
|
}
|
|
return group;
|
|
}
|
|
|
|
// Implementation of the RowListener interface.
|
|
|
|
public onAddRows(rows: RowList) {
|
|
const groupedRows = new Map();
|
|
for (const r of rows) {
|
|
const newValue = this._groupFunc(r);
|
|
_addToMapOfArrays(groupedRows, newValue, r);
|
|
this._rowsToValues.set(r, newValue);
|
|
}
|
|
|
|
groupedRows.forEach((groupRows, groupValue) => {
|
|
const group = this.getGroup(groupValue);
|
|
group._addAll(groupRows);
|
|
group.trigger('rowChange', 'add', groupRows);
|
|
});
|
|
}
|
|
|
|
public onRemoveRows(rows: RowList) {
|
|
const groupedRows = new Map();
|
|
for (const r of rows) {
|
|
_addToMapOfArrays(groupedRows, this._rowsToValues.get(r), r);
|
|
this._rowsToValues.delete(r);
|
|
}
|
|
|
|
// Note that we don't dispose the RowGroupHelper itself when it becomes empty, because this
|
|
// group may be in use elsewhere (even if empty at the moment). RowGroupHelpers are only
|
|
// disposed together with the RowGrouping object itself.
|
|
groupedRows.forEach((groupRows, groupValue) => {
|
|
const group = this._valuesToGroups.get(groupValue)!;
|
|
group._removeAll(groupRows);
|
|
group.trigger('rowChange', 'remove', groupRows);
|
|
});
|
|
}
|
|
|
|
public onUpdateRows(rows: RowList) {
|
|
let updateGroup, removeGroup, insertGroup;
|
|
for (const r of rows) {
|
|
const oldValue = this._rowsToValues.get(r);
|
|
const newValue = this._groupFunc(r);
|
|
if (newValue === oldValue) {
|
|
_addToMapOfArrays(updateGroup || (updateGroup = new Map()), oldValue, r);
|
|
} else {
|
|
this._rowsToValues.set(r, newValue);
|
|
_addToMapOfArrays(removeGroup || (removeGroup = new Map()), oldValue, r);
|
|
_addToMapOfArrays(insertGroup || (insertGroup = new Map()), newValue, r);
|
|
}
|
|
}
|
|
if (removeGroup) {
|
|
removeGroup.forEach((groupRows, groupValue) => {
|
|
const group = this._valuesToGroups.get(groupValue)!;
|
|
group._removeAll(groupRows);
|
|
group.trigger('rowChange', 'remove', groupRows);
|
|
});
|
|
}
|
|
if (updateGroup) {
|
|
updateGroup.forEach((groupRows, groupValue) => {
|
|
const group = this._valuesToGroups.get(groupValue)!;
|
|
group.trigger('rowChange', 'update', groupRows);
|
|
});
|
|
}
|
|
if (insertGroup) {
|
|
insertGroup.forEach((groupRows, groupValue) => {
|
|
const group = this.getGroup(groupValue);
|
|
group._addAll(groupRows);
|
|
group.trigger('rowChange', 'add', groupRows);
|
|
});
|
|
}
|
|
}
|
|
|
|
public onRowNotify(rows: RowsChanged, notifyValue: any) {
|
|
if (rows === ALL) {
|
|
for (const group of this._valuesToGroups.values()) {
|
|
group.trigger('rowNotify', ALL, notifyValue);
|
|
}
|
|
} else {
|
|
const groupedRows = new Map();
|
|
for (const r of rows) {
|
|
_addToMapOfArrays(groupedRows, this._rowsToValues.get(r), r);
|
|
}
|
|
|
|
groupedRows.forEach((groupRows, groupValue) => {
|
|
const group = this._valuesToGroups.get(groupValue)!;
|
|
group.trigger('rowNotify', groupRows, notifyValue);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// SortedRowSet
|
|
// ----------------------------------------------------------------------
|
|
|
|
/**
|
|
* SortedRowSet is a RowListener which maintains a set of rows in a sorted order, according to the
|
|
* results of compareFunc. The sorted rows are exposed as an observable koArray.
|
|
*
|
|
* SortedRowSet re-emits 'rowNotify(rows, value)' events from RowSources that it subscribes to.
|
|
*/
|
|
export class SortedRowSet extends RowListener {
|
|
private _allRows: Set<RowId> = new Set();
|
|
private _isPaused: boolean = false;
|
|
private _koArray: KoArray<RowId>;
|
|
private _keepFunc?: (rowId: number|'new') => boolean;
|
|
|
|
constructor(private _compareFunc: CompareFunc<RowId>,
|
|
private _skippableRows?: SkippableRows) {
|
|
super();
|
|
this._koArray = this.autoDispose(koArray<RowId>());
|
|
this._keepFunc = _skippableRows?.getKeepFunc();
|
|
}
|
|
|
|
/**
|
|
* Returns the sorted observable koArray maintained by this SortedRowSet.
|
|
*/
|
|
public getKoArray() {
|
|
return this._koArray;
|
|
}
|
|
|
|
/**
|
|
* Disable the populating of koArray temporarily. When pause(false) is called, the array is
|
|
* brought back up to date. This is useful if there are multiple changes, e.g. subscriptions and
|
|
* compareFunc updates, to avoid sorting multiple times.
|
|
*/
|
|
public pause(doPause: boolean) {
|
|
if (!doPause && this._isPaused) {
|
|
this._koArray.assign(Array.from(this._allRows).sort(this._compareFunc));
|
|
}
|
|
this._isPaused = Boolean(doPause);
|
|
}
|
|
|
|
/**
|
|
* Re-sorts the array according to the new compareFunc.
|
|
*/
|
|
public updateSort(compareFunc: CompareFunc<RowId>): void {
|
|
this._compareFunc = compareFunc;
|
|
if (!this._isPaused) {
|
|
this._koArray.assign(Array.from(this._koArray.peek()).sort(this._compareFunc));
|
|
}
|
|
}
|
|
|
|
|
|
public onAddRows(rows: RowList) {
|
|
for (const r of rows) {
|
|
this._allRows.add(r);
|
|
}
|
|
if (this._isPaused) {
|
|
return;
|
|
}
|
|
if (this._canChangeIncrementally(rows)) {
|
|
for (const r of rows) {
|
|
const insertIndex = sortedIndex(this._koArray.peek(), r, this._compareFunc);
|
|
this._koArray.splice(insertIndex, 0, r);
|
|
}
|
|
} else {
|
|
this._koArray.assign(this._keep(Array.from(this._allRows).sort(this._compareFunc)));
|
|
}
|
|
}
|
|
|
|
public onRemoveRows(rows: RowList) {
|
|
for (const r of rows) {
|
|
this._allRows.delete(r);
|
|
}
|
|
if (this._isPaused) {
|
|
return;
|
|
}
|
|
if (this._canChangeIncrementally(rows)) {
|
|
for (const r of rows) {
|
|
const index = this._koArray.peek().indexOf(r);
|
|
if (index !== -1) {
|
|
this._koArray.splice(index, 1);
|
|
}
|
|
}
|
|
} else {
|
|
this._koArray.assign(this._keep(Array.from(this._allRows).sort(this._compareFunc)));
|
|
}
|
|
}
|
|
|
|
public onUpdateRows(rows: RowList) {
|
|
// If paused, do nothing, since we'll re-sort later anyway.
|
|
if (this._isPaused) {
|
|
return;
|
|
}
|
|
|
|
// If all affected rows are in correct place relative to their neighbors, then the array is
|
|
// still sorted, and there is nothing to do. (It's a common case when the update affects fields
|
|
// not participating in the sort.)
|
|
//
|
|
// Note that the logic is all or none, since we can't assume that a single row is in its right
|
|
// place by comparing to neighbors because the neighbors might themselves be affected and wrong.
|
|
const sortedRows = Array.from(rows).sort(this._compareFunc);
|
|
if (_allRowsSorted(this._koArray.peek(), this._allRows, sortedRows, this._compareFunc)) {
|
|
return;
|
|
}
|
|
|
|
if (this._canChangeIncrementally(rows)) {
|
|
// Note that we can't add any rows before we remove all affected rows, because affected rows
|
|
// may no longer be in the correct sort order, so binary search is broken until they are gone.
|
|
this.onRemoveRows(rows);
|
|
this.onAddRows(rows);
|
|
} else {
|
|
this._koArray.assign(this._keep(Array.from(this._koArray.peek()).sort(this._compareFunc)));
|
|
}
|
|
}
|
|
|
|
// Check whether a change in the specified rows can be applied incrementally.
|
|
private _canChangeIncrementally(rows: RowList) {
|
|
return !this._keepFunc && isSmallChange(rows);
|
|
}
|
|
|
|
// Filter out any rows that should be skipped. This is a no-op if no _keepFunc was found.
|
|
// All rows that sort within nContext rows of something meant to be kept are also kept.
|
|
private _keep(rows: RowId[], nContext: number = 2) {
|
|
// Nothing to be done if there's no _keepFunc.
|
|
if (!this._keepFunc) { return rows; }
|
|
|
|
// Seed a list of rows to be kept (we'll expand it as we go).
|
|
const keeping = rows.map(this._keepFunc);
|
|
|
|
// Within a range of skipped rows, we'll keep one as an interstitial, with its
|
|
// rowId replaced with a special "skip" id that makes it get rendered a special
|
|
// way (with "..." in every cell).
|
|
// Start with a blank list (we'll fill it out as we go).
|
|
const edge = rows.map(() => false);
|
|
|
|
// Keep the first and last (typically 'new') row.
|
|
const n = rows.length;
|
|
if (n >= 1) { keeping[0] = true; }
|
|
if (n >= 2) { keeping[n - 1] = true; }
|
|
|
|
// Sweep forwards through the list of kept rows, keeping an extra nContext rows
|
|
// after each.
|
|
let last = - nContext - 1;
|
|
for (let i = 0; i < n; i++) {
|
|
if (keeping[i]) { last = i; }
|
|
else if (i - last <= nContext) { keeping[i] = true; }
|
|
}
|
|
|
|
// Sweep backwards through the list of kept rows, keeping an extra nContext rows
|
|
// before each.
|
|
last = n + nContext + 1;
|
|
for (let i = n - 1; i >= 0; i--) {
|
|
if (keeping[i]) { last = i; }
|
|
else if (last - i <= nContext) { keeping[i] = true; }
|
|
}
|
|
|
|
// Keep one extra "edge" row from each sequence of rows that are to be skipped.
|
|
let skipping: boolean = false;
|
|
for (let i = 0; i < n; i++) {
|
|
if (keeping[i]) {
|
|
skipping = false;
|
|
} else {
|
|
if (!skipping) {
|
|
edge[i] = true;
|
|
skipping = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Go ahead and filter out the rows to keep, tweaking the row id of the
|
|
// "edge" rows.
|
|
const skipRowId = this._skippableRows?.getSkipRowId() || 0;
|
|
return rows
|
|
.map((v, i) => edge[i] ? skipRowId : v)
|
|
.filter((v, i) => keeping[i] || edge[i] || v === 'new');
|
|
}
|
|
}
|
|
|
|
type RowTester = (rowId: RowId) => boolean;
|
|
/**
|
|
* RowWatcher is a RowListener that maintains an observable function that checks whether a row
|
|
* is in the connected RowSource.
|
|
*/
|
|
export class RowWatcher extends RowListener {
|
|
/**
|
|
* Observable function that returns true if the row is in the connected RowSource.
|
|
*/
|
|
public rowFilter: Observable<RowTester> = Observable.create(this, () => false);
|
|
// We count the number of times the row is added or removed from the source.
|
|
// In most cases row is added and removed only once.
|
|
private _rowCounter: Map<RowId, number> = new Map();
|
|
|
|
public clear() {
|
|
this._rowCounter.clear();
|
|
this.rowFilter.set(() => false);
|
|
this.stopListening();
|
|
}
|
|
|
|
protected onAddRows(rows: RowList) {
|
|
for (const r of rows) {
|
|
this._rowCounter.set(r, (this._rowCounter.get(r) || 0) + 1);
|
|
}
|
|
this.rowFilter.set((row) => (this._rowCounter.get(row) ?? 0) > 0);
|
|
}
|
|
|
|
protected onRemoveRows(rows: RowList) {
|
|
for (const r of rows) {
|
|
this._rowCounter.set(r, (this._rowCounter.get(r) || 0) - 1);
|
|
}
|
|
this.rowFilter.set((row) => (this._rowCounter.get(row) ?? 0) > 0);
|
|
}
|
|
}
|
|
|
|
function isSmallChange(rows: RowList) {
|
|
return Array.isArray(rows) && rows.length <= 2;
|
|
}
|
|
|
|
/**
|
|
* Helper function to tell if array[index] is in order relative to its neighbors.
|
|
*/
|
|
function _isIndexInOrder<T>(array: T[], index: number, compareFunc: CompareFunc<T>): boolean {
|
|
const r = array[index];
|
|
return ((index === 0 || compareFunc(array[index - 1], r) <= 0) &&
|
|
(index === array.length - 1 || compareFunc(r, array[index + 1]) <= 0));
|
|
}
|
|
|
|
/**
|
|
* Helper function to tell if each of sortedRows, if present in the array, is in order relative to
|
|
* its neighbors. sortedRows should be sorted the same way as the array.
|
|
*/
|
|
function _allRowsSorted<T>(array: T[], allRows: Set<T>, sortedRows: Iterable<T>, compareFunc: CompareFunc<T>): boolean {
|
|
let last = 0;
|
|
for (const r of sortedRows) {
|
|
if (!allRows.has(r)) {
|
|
continue;
|
|
}
|
|
const index = array.indexOf(r, last);
|
|
if (index === -1 || !_isIndexInOrder(array, index, compareFunc)) {
|
|
// rows of sortedRows are not present in the array in the same relative order.
|
|
return false;
|
|
}
|
|
last = index;
|
|
}
|
|
return true;
|
|
}
|