diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab2165794..f17cccf6f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,15 +18,26 @@ `agOptions.defaultGroupSortComparator`). * The `Column.cellClass` and `Column.headerClass` configs now accept functions to dynamically generate custom classes based on the Record and/or Column being rendered. - +* The `Record` object now provides an additional getter `Record.allChildren` to return all children of the + record, irrespective of the current filter in place on the record's store. This supplements the + existing `Record.children` getter, which returns only the children meeting the filter. + +### 💥 Breaking Changes +* The class `LocalStore` has been renamed `Store`, and is now the main implementation and base class + for Store Data. The extraneous abstract superclass `BaseStore` has been removed. +* `Store.dataLastUpdated` had been renamed `Store.lastUpdated` on the new class and is now a + simple timestamp (ms) rather than a javascript date object. +* The constructor argument `Store.processRawData` now expects a function that *returns* a modified object with the + necessary edits. This allows implementations to safely *clone* the raw data rather than mutating it. +* The method `Store.removeRecord` has been replaced with the method `Store.removeRecords`. This will + facilitate efficient bulk deletes. ### ⚙️ Technical - * `Grid` now performs an important performance workaround when loading a new dataset that would result in the removal of a significant amount of existing records/rows. The underlying ag-Grid component has a serious bottleneck here (acknowledged as AG-2879 in their bug tracker). The Hoist grid wrapper will now detect when this is likely and proactively clear all data using a different API call before loading the new dataset. -* The implementations of Hoist store classes, `RecordSet`, and `Record` have been updated to more +* The implementations `Store`, `RecordSet`, and `Record` have been updated to more efficiently re-use existing record references when loading, updating, or filtering data in a store. This keeps the Record objects within a store as stable as possible, and allows additional optimizations by ag-Grid and its `deltaRowDataMode`. diff --git a/cmp/grid/Grid.js b/cmp/grid/Grid.js index ba3986be19..e40bacb550 100644 --- a/cmp/grid/Grid.js +++ b/cmp/grid/Grid.js @@ -338,7 +338,7 @@ export class Grid extends Component { {agGridModel, store} = model; return { - track: () => [agGridModel.agApi, store.records, store.dataLastUpdated], + track: () => [agGridModel.agApi, store.records, store.lastUpdated], run: ([api, records]) => { if (!api) return; @@ -363,10 +363,8 @@ export class Grid extends Component { console.debug(`Loaded ${records.length} records into ag-Grid: ${Date.now() - now}ms`); // Set flag if data is hierarchical. - this._isHierarchical = model.store.allRecords.some( - rec => rec.parentId != null - ); - + this._isHierarchical = store.allRootCount != store.allCount; + // Increment version counter to trigger selectionReaction w/latest data. this._dataVersion++; }); diff --git a/cmp/grid/GridModel.js b/cmp/grid/GridModel.js index ee1af13afd..001682b65b 100644 --- a/cmp/grid/GridModel.js +++ b/cmp/grid/GridModel.js @@ -7,7 +7,7 @@ import {HoistModel, LoadSupport, XH} from '@xh/hoist/core'; import {Column, ColumnGroup} from '@xh/hoist/cmp/grid'; import {AgGridModel} from '@xh/hoist/cmp/ag-grid'; -import {BaseStore, LocalStore, StoreSelectionModel} from '@xh/hoist/data'; +import {Store, StoreSelectionModel} from '@xh/hoist/data'; import { ColChooserModel as DesktopColChooserModel, StoreContextMenu @@ -58,7 +58,7 @@ export class GridModel { //------------------------ // Immutable public properties //------------------------ - /** @member {BaseStore} */ + /** @member {Store} */ store; /** @member {StoreSelectionModel} */ selModel; @@ -109,8 +109,8 @@ export class GridModel { /** * @param {Object} c - GridModel configuration. * @param {Object[]} c.columns - {@link Column} or {@link ColumnGroup} configs - * @param {(BaseStore|Object)} [c.store] - a Store instance, or a config with which to create a - * default LocalStore. If not supplied, store fields will be inferred from columns config. + * @param {(Store|Object)} [c.store] - a Store instance, or a config with which to create a + * Store. If not supplied, store fields will be inferred from columns config. * @param {boolean} [c.treeMode] - true if grid is a tree grid (default false). * @param {(StoreSelectionModel|Object|String)} [c.selModel] - StoreSelectionModel, or a * config or string `mode` with which to create one. @@ -575,7 +575,7 @@ export class GridModel { parseStore(store) { store = withDefault(store, {}); - if (store instanceof BaseStore) { + if (store instanceof Store) { return store; } @@ -587,6 +587,8 @@ export class GridModel { colFieldNames = uniq(compact(map(this.getLeafColumns(), 'field'))), missingFieldNames = difference(colFieldNames, storeFieldNames); + pull(missingFieldNames, 'id'); + if (missingFieldNames.length) { store = { ...store, @@ -594,11 +596,11 @@ export class GridModel { }; } - return this.markManaged(new LocalStore(store)); + return this.markManaged(new Store(store)); } throw XH.exception( - 'The GridModel.store config must be either a concrete instance of BaseStore or a config to create one.'); + 'The GridModel.store config must be either a concrete instance of Store or a config to create one.'); } parseSelModel(selModel) { diff --git a/data/BaseStore.js b/data/BaseStore.js deleted file mode 100644 index 7d4c1eaf37..0000000000 --- a/data/BaseStore.js +++ /dev/null @@ -1,111 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2019 Extremely Heavy Industries Inc. - */ -import {isString} from 'lodash'; - -import {Field} from './Field'; - -/** - * A managed and observable set of Records. - * @see LocalStore - * @see UrlStore - * @abstract - */ -export class BaseStore { - - /** - * Fields contained in each record. - * @member {Field[]} - */ - fields = null; - - /** - * Get a specific field, by name. - * @param {string} name - field name to locate. - * @return {Field} - */ - getField(name) { - return this.fields.find(it => it.name === name); - } - - /** - * Records in this store, respecting any filter (if applied). - * @return {Record[]} - */ - get records() {} - - /** - * All records in this store, unfiltered. - * @return {Record[]} - */ - get allRecords() {} - - /** - * Records in this store, respecting any filter, returned in a tree structure. - * @return {RecordNode[]} - */ - get recordsAsTree() {} - - /** - * All records in this store, unfiltered, returned in a tree structure. - * @return {RecordNode[]} - */ - get allRecordsAsTree() {} - - /** Filter function to be applied. */ - get filter() {} - setFilter(filterFn) {} - - /** Get the count of all records loaded into the store. */ - get allCount() {} - - /** Get the count of the filtered record in the store. */ - get count() {} - - /** Is the store empty after filters have been applied? */ - get empty() {return this.count === 0} - - /** Is this store empty before filters have been applied? */ - get allEmpty() {return this.allCount === 0} - - /** - * Get a record by ID, or null if no matching record found. - * - * @param {(string|number)} id - * @param {boolean} [filteredOnly] - true to skip records excluded by any active filter. - * @return {Record} - */ - getById(id, filteredOnly) {} - - /** - * @param {Object} c - BaseStore configuration. - * @param {(string[]|Object[]|Field[])} c.fields - names or config objects for Fields. - */ - constructor({fields}) { - this.fields = fields.map(f => { - if (f instanceof Field) return f; - if (isString(f)) f = {name: f}; - return new this.defaultFieldClass(f); - }); - } - - /** Destroy this store, cleaning up any resources used. */ - destroy() {} - - //-------------------- - // For Implementations - //-------------------- - get defaultFieldClass() { - return Field; - } -} - -/** - * @typedef {Object} RecordNode - node for a record and its children, representing a tree structure. - * @property {Record} record - * @property {RecordNode[]} children - */ - diff --git a/data/Field.js b/data/Field.js index 11b0b09ae9..eb37925775 100644 --- a/data/Field.js +++ b/data/Field.js @@ -5,8 +5,6 @@ * Copyright © 2019 Extremely Heavy Industries Inc. */ -import {Record} from '@xh/hoist/data/Record'; -import {throwIf} from '@xh/hoist/utils/js'; import {startCase, isEqual as lodashIsEqual} from 'lodash'; import {XH} from '@xh/hoist/core'; @@ -38,12 +36,6 @@ export class Field { label = startCase(name), defaultValue = null }) { - - throwIf( - Record.RESERVED_FIELD_NAMES.includes(name), - `Field name "${name}" cannot be used. It is reserved as a top-level property of the Record class.` - ); - this.name = name; this.type = type; this.label = label; diff --git a/data/LocalStore.js b/data/LocalStore.js deleted file mode 100644 index e627306adf..0000000000 --- a/data/LocalStore.js +++ /dev/null @@ -1,152 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2019 Extremely Heavy Industries Inc. - */ - -import {observable, action} from '@xh/hoist/mobx'; -import {RecordSet} from './impl/RecordSet'; -import {BaseStore} from './BaseStore'; - -/** - * Primary BaseStore implementation for local, in-memory data. - */ -export class LocalStore extends BaseStore { - - /** @member {function} */ - processRawData; - /** @member {(function|string)} */ - idSpec; - - /** @member {Date} */ - @observable.ref _dataLastUpdated; - /** @member {RecordSet} */ - @observable.ref _all; - /** @member {RecordSet} */ - @observable.ref _filtered; - - _filter = null; - - /** - * @param {Object} c - LocalStore configuration. - * @param {function} [c.processRawData] - function to run on each individual data object - * presented to loadData() prior to creating a record from that raw object. - * @param {(function|string)} [c.idSpec] - specification for selecting or producing an immutable - * unique id for each record. May be either a property (default is 'id') or a function to - * create an id from a record. If there is no natural id to select/generate, you can use - * `XH.genId` to generate a unique id on the fly. NOTE that in this case, grids and other - * components bound to this store will not be able to maintain record state across reloads. - * @param {function} [c.filter] - filter function to be run. - * @param {...*} [c.baseStoreArgs] - Additional properties to pass to BaseStore. - */ - constructor( - { - processRawData = null, - filter = null, - idSpec = 'id', - ...baseStoreArgs - }) { - super(baseStoreArgs); - this._filtered = this._all = new RecordSet(this); - this.setFilter(filter); - this.idSpec = idSpec; - this.processRawData = processRawData; - this._dataLastUpdated = new Date(); - } - - /** - * Load new data into this store, replacing any/all pre-existing rows. - * - * If raw data objects have a `children` property it will be expected to be an array - * and its items will be recursively processed into child records. - * - * Note {@see RecordSet.loadData} regarding the re-use of existing Records for efficiency. - * - * @param {Object[]} rawData - */ - @action - loadData(rawData) { - this._all = this._all.loadData(rawData); - this.rebuildFiltered(); - this._dataLastUpdated = new Date(); - } - - /** - * Add or update data in store. Existing records not matched by ID to rows in the update - * dataset will be left in place. - * @param {Object[]} rawData - */ - @action - updateData(rawData) { - this._all = this._all.updateData(rawData); - this.rebuildFiltered(); - this._dataLastUpdated = new Date(); - } - - /** - * Remove a record (and all its children, if any) from the store. - * @param {(string|number)} id - ID of the the record to be removed. - */ - @action - removeRecord(id) { - this._all = this._all.removeRecord(id); - this.rebuildFiltered(); - this._dataLastUpdated = new Date(); - } - - /** - * Call if/when any records have had their data modified directly, outside of this store's load - * and update APIs. - * - * If the structure of the data has changed (e.g. deletion, additions, re-parenting of children) - * loadData() should be called instead. - */ - @action - noteDataUpdated() { - this.rebuildFiltered(); - this._dataLastUpdated = new Date(); - } - - /** - * The last time this store's data was changed via loadData() or as marked by noteDataUpdated(). - */ - get dataLastUpdated() { - return this._dataLastUpdated; - } - - //----------------------------- - // Implementation of Store - //----------------------------- - get records() {return this._filtered.list} - get allRecords() {return this._all.list} - get recordsAsTree() {return this._filtered.tree} - get allRecordsAsTree() {return this._all.tree} - - get filter() {return this._filter} - setFilter(filterFn) { - this._filter = filterFn; - this.rebuildFiltered(); - } - - get allCount() { - return this._all.count; - } - - get count() { - return this._filtered.count; - } - - getById(id, fromFiltered = false) { - const rs = fromFiltered ? this._filtered : this._all; - return rs.records.get(id); - } - - //------------------------ - // Private Implementation - //------------------------ - @action - rebuildFiltered() { - this._filtered = this._all.applyFilter(this.filter); - } -} \ No newline at end of file diff --git a/data/Record.js b/data/Record.js index f7a6cd59de..7fa45e87b9 100644 --- a/data/Record.js +++ b/data/Record.js @@ -4,7 +4,8 @@ * * Copyright © 2019 Extremely Heavy Industries Inc. */ -import {isEqual} from 'lodash'; +import {isEqual, isNil, isString} from 'lodash'; +import {throwIf} from '@xh/hoist/utils/js'; /** * Wrapper object for each data element within a {@see BaseStore}. @@ -14,11 +15,9 @@ import {isEqual} from 'lodash'; */ export class Record { - static RESERVED_FIELD_NAMES = ['parentId', 'store', 'xhTreePath'] - /** @member {(string|number)} */ id; - /** @member {BaseStore} */ + /** @member {Store} */ store; /** @member {String[]} - unique path within hierarchy - for ag-Grid implementation. */ xhTreePath; @@ -31,6 +30,23 @@ export class Record { return this.parentId != null ? this.store.getById(this.parentId) : null; } + /** + * The children of this record, respecting any filter (if applied). + * @returns {Record[]} + */ + get children() { + return this.store.getChildrenById(this.id, true); + } + + /** + * All children of this record unfiltered. + * @returns {Record[]} + */ + get allChildren() { + return this.store.getChildrenById(this.id, false); + } + + /** * Construct a Record from a raw source object. Extract values from the source object for all * Fields defined on the given Store and install them as top-level properties on the new Record. @@ -43,20 +59,30 @@ export class Record { * requiring children to also be recreated.) * * @param {Object} c - Record configuration + * @param {Object} c.data - data for constructing the record. * @param {Object} c.raw - raw data for record. - * @param {BaseStore} c.store - store containing this record. + * @param {Store} c.store - store containing this record. * @param {Record} [c.parent] - parent record, if any. */ - constructor({raw, store, parent}) { - const id = raw.id; + constructor({data, raw, store, parent}) { + const {idSpec} = store, + id = isString(idSpec) ? data[idSpec] : idSpec(data); + + throwIf(isNil(id), "Record has an undefined ID. Use 'Store.idSpec' to resolve a unique ID for each record."); this.id = id; this.store = store; + this.raw = raw; this.parentId = parent ? parent.id : null; this.xhTreePath = parent ? [...parent.xhTreePath, id] : [id]; store.fields.forEach(f => { - this[f.name] = f.parseVal(raw[f.name]); + const {name} = f; + throwIf( + name in this, + `Field name "${name}" cannot be used for data. It is reserved as a top-level property of the Record class.` + ); + this[name] = f.parseVal(data[name]); }); } diff --git a/data/Store.js b/data/Store.js new file mode 100644 index 0000000000..c7b109c189 --- /dev/null +++ b/data/Store.js @@ -0,0 +1,261 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2019 Extremely Heavy Industries Inc. + */ + +import {observable, action} from '@xh/hoist/mobx'; +import {RecordSet} from './impl/RecordSet'; +import {Field} from './Field'; +import {isString, castArray} from 'lodash'; +import {throwIf} from '@xh/hoist/utils/js'; + +/** + * A managed and observable set of local, in-memory records. + */ +export class Store { + + /** @member {Field[]} */ + fields = null; + /** @member {(function|string)} */ + idSpec; + /** @member {function} */ + processRawData; + + /** + * Timestamp (ms) of last time this store's data was changed via loadData() or as marked by noteDataUpdated(). + */ + @observable lastUpdated; + + @observable.ref _all; + @observable.ref _filtered; + _filter = null; + + /** + * @param {Object} c - Store configuration. + * @param {(string[]|Object[]|Field[])} c.fields - Fields, Field names, or config objects for Fields. + * @param {(function|string)} [c.idSpec] - specification for selecting or producing an immutable + * unique id for each record. May be either a property (default is 'id') or a function to + * create an id from a record. If there is no natural id to select/generate, you can use + * `XH.genId` to generate a unique id on the fly. NOTE that in this case, grids and other + * components bound to this store will not be able to maintain record state across reloads. + * @param {function} [c.processRawData] - function to run on each individual data object + * presented to loadData() prior to creating a record from that object. This function should + * return a data object, taking care to clone the original object if edits are necessary. + * @param {function} [c.filter] - filter function to be run. + */ + constructor( + { + fields, + idSpec = 'id', + processRawData = null, + filter = null + }) { + this.fields = this.parseFields(fields); + this._filtered = this._all = new RecordSet(this); + this.setFilter(filter); + this.idSpec = idSpec; + this.processRawData = processRawData; + this.lastUpdated = Date.now(); + } + + /** + * Load new data into this store, replacing any/all pre-existing rows. + * + * If raw data objects have a `children` property it will be expected to be an array + * and its items will be recursively processed into child records. + * + * Note that this process will re-use pre-existing Records if they are present in the new + * dataset (as identified by their ID), contain the same data, and occupy the same place in any + * hierarchy across old and new loads. + * + * This is to maximize the ability of downstream consumers (e.g. ag-Grid) to recognize Records + * that have not changed and do not need to be re-evaluated / re-rendered. + * + * @param {Object[]} rawData + */ + @action + loadData(rawData) { + this._all = this._all.loadData(rawData); + this.rebuildFiltered(); + this.lastUpdated = Date.now(); + } + + /** + * Add or update data in store. Existing records not matched by ID to rows in the update + * dataset will be left in place. + * + * @param {Object[]} rawData + */ + @action + updateData(rawData) { + this._all = this._all.updateData(rawData); + this.rebuildFiltered(); + this.lastUpdated = Date.now(); + } + + /** + * Remove a record (and all its children, if any) from the store. + * @param {(string[]|number[])} ids - IDs of the records to be removed. + */ + @action + removeRecords(ids) { + ids = castArray(ids); + this._all = this._all.removeRecords(ids); + this.rebuildFiltered(); + this.lastUpdated = Date.now(); + } + + /** + * Call if/when any records have had their data modified directly, outside of this store's load + * and update APIs. + * + * If the structure of the data has changed (e.g. deletion, additions, re-parenting of children) + * loadData() should be called instead. + */ + @action + noteDataUpdated() { + this.rebuildFiltered(); + this.lastUpdated = Date.now(); + } + + /** + * Get a specific field, by name. + * @param {string} name - field name to locate. + * @return {Field} + */ + getField(name) { + return this.fields.find(it => it.name === name); + } + + /** + * Records in this store, respecting any filter (if applied). + * @return {Record[]} + */ + get records() { + return this._filtered.list; + } + + /** + * All records in this store, unfiltered. + * @return {Record[]} + */ + get allRecords() { + return this._all.list; + } + + /** + * Root records in this store, respecting any filter (if applied). + * If this store is not hierarchical, this will be identical to 'records'. + * + * @return {Record[]} + */ + get rootRecords() { + return this._filtered.rootList; + } + + /** + * Root records in this store, unfiltered. + * If this store is not hierarchical, this will be identical to 'allRecords'. + * + * @return {Record[]} + */ + get allRootRecords() { + return this._all.rootList; + } + + /** Filter function to be applied. */ + get filter() {return this._filter} + setFilter(filterFn) { + this._filter = filterFn; + this.rebuildFiltered(); + } + + /** Get the count of all records loaded into the store. */ + get allCount() { + return this._all.count; + } + + /** Get the count of the filtered records in the store. */ + get count() { + return this._filtered.count; + } + + /** Get the count of the filtered root records in the store. */ + get rootCount() { + return this._filtered.rootCount; + } + + /** Get the count of all root records in the store. */ + get allRootCount() { + return this._all.rootCount; + } + + /** Is the store empty after filters have been applied? */ + get empty() {return this.count === 0} + + /** Is this store empty before filters have been applied? */ + get allEmpty() {return this.allCount === 0} + + /** + * Get a record by ID, or null if no matching record found. + * + * @param {(string|number)} id + * @param {boolean} [fromFiltered] - true to skip records excluded by any active filter. + * @return {Record} + */ + getById(id, fromFiltered = false) { + const rs = fromFiltered ? this._filtered : this._all; + return rs.records.get(id); + } + + /** + * Get children records for a record. + * + * See also the 'children' and 'allChildren' properties on Record. These + * should be more convenient for most applications. + * + * @param {(string|number)} id - id of record to be queried. + * @param {boolean} [fromFiltered] - true to skip records excluded by any active filter. + * @return {Record[]} + */ + getChildrenById(id, fromFiltered = false) { + const rs = fromFiltered ? this._filtered : this._all, + ret = rs.childrenMap.get(id); + return ret ? ret : []; + } + + //-------------------- + // For Implementations + //-------------------- + get defaultFieldClass() { + return Field; + } + + /** Destroy this store, cleaning up any resources used. */ + destroy() {} + + //------------------------ + // Private Implementation + //------------------------ + @action + rebuildFiltered() { + this._filtered = this._all.applyFilter(this.filter); + } + + parseFields(fields) { + const ret = fields.map(f => { + if (f instanceof Field) return f; + if (isString(f)) f = {name: f}; + return new this.defaultFieldClass(f); + }); + + throwIf( + ret.some(it => it.name == 'id'), + `Applications should not specify a field for the id of a record. An id property is created + automatically for all records. See Store.idSpec for more info.` + ); + return ret; + } +} \ No newline at end of file diff --git a/data/StoreSelectionModel.js b/data/StoreSelectionModel.js index c0f889b20a..463f914988 100644 --- a/data/StoreSelectionModel.js +++ b/data/StoreSelectionModel.js @@ -16,7 +16,7 @@ import {castArray, intersection, union} from 'lodash'; @HoistModel export class StoreSelectionModel { - /** @member {BaseStore} */ + /** @member {Store} */ store; /** @member {string} */ mode; @@ -25,7 +25,7 @@ export class StoreSelectionModel { /** * @param {Object} c - StoreSelectionModel configuration. - * @param {BaseStore} c.store - Store containing the data. + * @param {Store} c.store - Store containing the data. * @param {string} [c.mode] - one of ['single', 'multiple', 'disabled']. */ constructor({store, mode = 'single'}) { diff --git a/data/UrlStore.js b/data/UrlStore.js index 53e8b9bec2..3e40a21b5b 100644 --- a/data/UrlStore.js +++ b/data/UrlStore.js @@ -7,13 +7,13 @@ import {XH, LoadSupport} from '@xh/hoist/core'; -import {LocalStore} from './LocalStore'; +import {Store} from './Store'; /** * A store with built-in support for loading data from a URL. */ @LoadSupport -export class UrlStore extends LocalStore { +export class UrlStore extends Store { url; dataRoot; @@ -22,7 +22,7 @@ export class UrlStore extends LocalStore { * @param {Object} c - UrlStore configuration. * @param {string} c.url - URL from which to load data. * @param {?string} [c.dataRoot] - Key of root node for records in returned data object. - * @param {...*} - Additional arguments to pass to LocalStore. + * @param {...*} - Additional arguments to pass to Store. */ constructor({url, dataRoot = null, ...localStoreArgs}) { super(localStoreArgs); diff --git a/data/impl/RecordSet.js b/data/impl/RecordSet.js index d8b3be40f3..609f7deaee 100644 --- a/data/impl/RecordSet.js +++ b/data/impl/RecordSet.js @@ -4,65 +4,61 @@ * * Copyright © 2019 Extremely Heavy Industries Inc. */ - -import {isString, isNil, partition} from 'lodash'; import {throwIf} from '@xh/hoist/utils/js/'; import {Record} from '../Record'; /** * Internal container for Record management within a Store. + * * Note this is an immutable object; its update and filtering APIs return new instances as required. * * @private */ export class RecordSet { - /** @member {BaseStore} - source store. */ store; - /** @member {Map} - map of all records by id. */ - records; - - /** @member {Record[]} - lazily constructed array of Records. */ - _list; - /** @member {RecordNode[]} - lazily constructed array of root RecordNodes. */ - _tree; - - /** - * @param {BaseStore} store - * @param {Map} [records] - */ + records; // Records by id + count; + rootCount; + + _childrenMap; // Lazy map of children by parentId + _list; // Lazy list of all records. + _rootList; // Lazy list of root records. + constructor(store, records = new Map()) { - this.records = records; this.store = store; + this.records = records; + this.count = records.size; + this.rootCount = this.countRoots(records); } - /** Total number of records contained in this RecordSet. */ - get count() { - return this.records.size; + //---------------------------------------------------------- + // Lazy getters + // Avoid memory allocation and work -- in many cases + // clients will never ask for list or tree representations. + //---------------------------------------------------------- + get childrenMap() { + if (!this._childrenMap) this._childrenMap = this.computeChildrenMap(this.records); + return this._childrenMap; } - /** All records as a flat list. */ get list() { - if (!this._list) { - this._list = Array.from(this.records.values()); - } + if (!this._list) this._list = Array.from(this.records.values()); return this._list; } - /** All records in a tree representation. */ - get tree() { - if (!this._tree) { - this._tree = this.toTree(); + get rootList() { + if (!this._rootList) { + const {list, count, rootCount} = this; + this._rootList = (count == rootCount ? list : list.filter(r => r.parentId == null)); } - return this._tree; + return this._rootList; } - /** - * Return a filtered version of this RecordSet. - * - * @param {function} filter - if null, this method will return the RecordSet itself. - * @return {RecordSet} - */ + //---------------------------------------------- + // Editing operations that spawn new recordsets. + // Preserve all record references we can! + //----------------------------------------------- applyFilter(filter) { if (!filter) return this; @@ -84,19 +80,6 @@ export class RecordSet { return new RecordSet(this.store, passes); } - /** - * Create a new RecordSet with new rawData to replace this instance. - * - * Note that this process will re-use pre-existing Records if they are present in the new - * dataset (as identified by their ID), contain the same data, and occupy the same place in any - * hierarchy across old and new loads. - * - * This is to maximize the ability of downstream consumers (e.g. ag-Grid) to recognize Records - * that have not changed and do not need to be re-evaluated / re-rendered. - * - * @param {Object[]} rawData - * @return {RecordSet} - */ loadData(rawData) { const {records} = this, newRecords = this.createRecords(rawData); @@ -116,30 +99,6 @@ export class RecordSet { return new RecordSet(this.store, newRecords); } - /** - * Return a version of this RecordSet with a record (and all its children, if any) removed. - * - * @param {(string|number)} id - ID of record to be removed. - * @return {RecordSet} - */ - removeRecord(id) { - const filter = (rec) => { - if (rec.id == id) return false; - const {parent} = rec; - if (parent && !filter(parent)) return false; - return true; - }; - - return this.applyFilter(filter); - } - - /** - * Return a version of this RecordSet with records added or updated. Existing records not - * matched by ID to rows in the update dataset will be left in place. - * - * @param {Object[]} rawData - raw data for records to be added or updated. - * @return {RecordSet} - */ updateData(rawData) { const newRecords = this.createRecords(rawData), existingRecords = new Map(this.records); @@ -154,6 +113,12 @@ export class RecordSet { return new RecordSet(this.store, existingRecords); } + removeRecords(ids) { + const removes = new Set(); + ids.forEach(id => this.gatherDescendants(id, removes)); + return this.applyFilter(r => !removes.has(r.id)); + } + //------------------------ // Implementation //------------------------ @@ -164,54 +129,56 @@ export class RecordSet { } createRecord(raw, records, parent) { - const {store} = this, - {idSpec} = store; - - if (store.processRawData) store.processRawData(raw); - - raw.id = isString(idSpec) ? raw[idSpec] : idSpec(raw); + const {store} = this; - throwIf( - isNil(raw.id), - "Record has a null/undefined ID. Use the 'LocalStore.idSpec' config to resolve a unique ID for each record." - ); + let data = raw; + if (store.processRawData) { + data = store.processRawData(raw); + throwIf(!data, 'processRawData should return an object. If writing/editing, be sure to return a clone!'); + } + const rec = new Record({data, raw, parent, store}); throwIf( - records.has(raw.id), - `ID ${raw.id} is not unique. Use the 'LocalStore.idSpec' config to resolve a unique ID for each record.` + records.has(rec.id), + `ID ${rec.id} is not unique. Use the 'Store.idSpec' config to resolve a unique ID for each record.` ); - - const rec = new Record({raw, parent, store}); records.set(rec.id, rec); - - if (raw.children) { - raw.children.forEach(rawChild => this.createRecord(rawChild, records, rec)); + if (data.children) { + data.children.forEach(rawChild => this.createRecord(rawChild, records, rec)); } } - toTree() { - const childrenMap = new Map(); - - // Pass 1, create nodes. - const nodes = this.list.map(record => ({record})), - [roots, nonRoots] = partition(nodes, (node) => node.record.parentId == null); - - // Pass 2, collect children by parent. - nonRoots.forEach(node => { - let {parentId} = node.record, - children = childrenMap.get(parentId); - if (!children) { - children = []; - childrenMap.set(parentId, children); + computeChildrenMap(records) { + const ret = new Map(); + records.forEach(r => { + const {parentId} = r; + if (parentId) { + const children = ret.get(parentId); + if (!children) { + ret.set(parentId, [r]); + } else { + children.push(r); + } } - children.push(node); }); + return ret; + } - // Pass 3, assign children. - nodes.forEach(node => { - node.children = childrenMap.get(node.record.id) || []; + countRoots(records) { + let ret = 0; + records.forEach(rec => { + if (rec.parentId == null) ret++; }); + return ret; + } - return roots; + gatherDescendants(id, idSet) { + if (!idSet.has(id)) { + idSet.add(id); + const children = this.childrenMap.get(id); + if (children) { + children.forEach(child => this.gatherDescendants(child.id, idSet)); + } + } } } \ No newline at end of file diff --git a/data/index.js b/data/index.js index e70779886d..56b704e5e2 100644 --- a/data/index.js +++ b/data/index.js @@ -5,9 +5,8 @@ * Copyright © 2019 Extremely Heavy Industries Inc. */ -export * from './BaseStore'; export * from './Field'; -export * from './LocalStore'; +export * from './Store'; export * from './Record'; export * from './RecordAction'; export * from './StoreSelectionModel'; diff --git a/desktop/cmp/dataview/DataViewModel.js b/desktop/cmp/dataview/DataViewModel.js index 70606b92b9..a493330f7b 100644 --- a/desktop/cmp/dataview/DataViewModel.js +++ b/desktop/cmp/dataview/DataViewModel.js @@ -25,8 +25,8 @@ export class DataViewModel { /** * @param {Object} c - DataViewModel configuration. * @param {Column~elementRendererFn} c.itemRenderer - function which returns a React component. - * @param {(BaseStore|Object)} c.store - a Store instance, or a config with which to create a - * default LocalStore. The store is the source for the view's data. + * @param {(Store|Object)} c.store - a Store instance, or a config with which to create a + * default Store. The store is the source for the view's data. * @param {(StoreSelectionModel|Object|String)} [c.selModel] - StoreSelectionModel, or a * config or string `mode` from which to create. * @param {string} [c.emptyText] - text/HTML to display if view has no records. diff --git a/desktop/cmp/leftrightchooser/LeftRightChooserModel.js b/desktop/cmp/leftrightchooser/LeftRightChooserModel.js index 27c3802801..c0864acead 100644 --- a/desktop/cmp/leftrightchooser/LeftRightChooserModel.js +++ b/desktop/cmp/leftrightchooser/LeftRightChooserModel.js @@ -8,7 +8,6 @@ import {HoistModel, XH, managed} from '@xh/hoist/core'; import {GridModel} from '@xh/hoist/cmp/grid'; import {computed} from '@xh/hoist/mobx'; import {convertIconToSvg, Icon} from '@xh/hoist/icon'; -import {isNil} from 'lodash'; /** * A Model for managing the state of a LeftRightChooser. @@ -173,21 +172,21 @@ export class LeftRightChooserModel { preprocessData(data) { return data - .filter(rec => !rec.exclude) - .map(raw => { - raw.group = raw.group || this._ungroupedName; - raw.side = raw.side || 'left'; - raw.id = isNil(raw.id) ? XH.genId() : raw.id; - return raw; + .filter(r => !r.exclude) + .map(r => { + return { + id: XH.genId(), + group: this._ungroupedName, + side: 'left', + ...r + }; }); } moveRows(rows) { rows.forEach(rec => { if (rec.locked) return; - - const rawRec = this._data.find(raw => raw === rec.raw); - rawRec.side = (rec.side === 'left' ? 'right' : 'left'); + rec.raw.side = (rec.side === 'left' ? 'right' : 'left'); }); this.refreshStores(); diff --git a/desktop/cmp/rest/data/RestStore.js b/desktop/cmp/rest/data/RestStore.js index 97f0588922..8a4ad10ac0 100644 --- a/desktop/cmp/rest/data/RestStore.js +++ b/desktop/cmp/rest/data/RestStore.js @@ -49,7 +49,7 @@ export class RestStore extends UrlStore { url: `${url}/${rec.id}`, method: 'DELETE' }).then(() => { - this.removeRecord(rec.id); + this.removeRecords([rec.id]); }).linkTo( this.loadModel ); diff --git a/desktop/cmp/store/StoreCountLabel.js b/desktop/cmp/store/StoreCountLabel.js index 0b374c0bcb..0647423bb4 100644 --- a/desktop/cmp/store/StoreCountLabel.js +++ b/desktop/cmp/store/StoreCountLabel.js @@ -13,8 +13,7 @@ import {fmtNumber} from '@xh/hoist/format'; import {singularize, pluralize} from '@xh/hoist/utils/js'; import {GridModel} from '@xh/hoist/cmp/grid'; import {throwIf, withDefault} from '@xh/hoist/utils/js'; -import {BaseStore} from '@xh/hoist/data'; -import {reduce} from 'lodash'; +import {Store} from '@xh/hoist/data'; /** * A component to display the number of records in a given store. @@ -28,7 +27,7 @@ export class StoreCountLabel extends Component { static propTypes = { /** Store to count. Specify this or 'gridModel' */ - store: PT.instanceOf(BaseStore), + store: PT.instanceOf(Store), /** GridModel with Store that this control should count. Specify this or 'store' */ gridModel: PT.instanceOf(GridModel), @@ -58,7 +57,7 @@ export class StoreCountLabel extends Component { if (!store) return null; const includeChildren = withDefault(this.props.includeChildren, false), - count = includeChildren ? store.count : this.rootCount(store), + count = includeChildren ? store.count : store.rootCount, countStr = fmtNumber(count, {precision: 0}), unitLabel = count === 1 ? this.oneUnit : this.manyUnits; @@ -76,13 +75,5 @@ export class StoreCountLabel extends Component { const {gridModel, store} = this.props; return store || (gridModel && gridModel.store); } - - rootCount(store) { - return reduce( - store.records, - (ret, val) => val.parentId == null ? ret + 1 : ret, - 0 - ); - } } export const storeCountLabel = elemFactory(StoreCountLabel); diff --git a/desktop/cmp/store/StoreFilterField.js b/desktop/cmp/store/StoreFilterField.js index c2e4bbbbd1..795d06d4f3 100644 --- a/desktop/cmp/store/StoreFilterField.js +++ b/desktop/cmp/store/StoreFilterField.js @@ -13,7 +13,7 @@ import {observable, action} from '@xh/hoist/mobx'; import {textInput} from '@xh/hoist/desktop/cmp/input'; import {GridModel} from '@xh/hoist/cmp/grid'; import {Icon} from '@xh/hoist/icon'; -import {BaseStore} from '@xh/hoist/data'; +import {Store} from '@xh/hoist/data'; import {withDefault, throwIf, warnIf} from '@xh/hoist/utils/js'; /** @@ -36,7 +36,7 @@ export class StoreFilterField extends Component { * Store that this control should filter. By default, all fields configured on the Store * will be used for matching. Do not configure this and `gridModel` on the same component. */ - store: PT.instanceOf(BaseStore), + store: PT.instanceOf(Store), /** * GridModel whose Store this control should filter. When given a GridModel, this component diff --git a/svc/GridExportService.js b/svc/GridExportService.js index 9b7488e810..82d29441a0 100644 --- a/svc/GridExportService.js +++ b/svc/GridExportService.js @@ -42,17 +42,17 @@ export class GridExportService { if (isFunction(filename)) filename = filename(gridModel); const columns = this.getExportableColumns(gridModel, includeHiddenCols), - recordNodes = gridModel.store.recordsAsTree, + records = gridModel.store.rootRecords, meta = this.getColumnMetadata(columns), rows = []; - if (recordNodes.length === 0) { + if (records.length === 0) { XH.toast({message: 'No data found to export', intent: 'danger', icon: Icon.warning()}); return; } rows.push(this.getHeaderRow(columns, type)); - rows.push(...this.getRecordRowsRecursive(gridModel, recordNodes, columns, 0)); + rows.push(...this.getRecordRowsRecursive(gridModel, records, columns, 0)); // Show separate 'started' and 'complete' toasts for larger (i.e. slower) exports. // We use cell count as a heuristic for speed - this may need to be tweaked. @@ -127,21 +127,22 @@ export class GridExportService { return {data: headers, depth: 0}; } - getRecordRowsRecursive(gridModel, recordNodes, columns, depth) { + getRecordRowsRecursive(gridModel, records, columns, depth) { const {sortBy, treeMode} = gridModel, - ret = [], - nodes = [...recordNodes]; + ret = []; + + records = [...records]; [...sortBy].reverse().forEach(it => { const compFn = it.comparator.bind(it), direction = it.sort === 'desc' ? -1 : 1; - nodes.sort((a, b) => compFn(a.record[it.colId], b.record[it.colId]) * direction); + records.sort((a, b) => compFn(a[it.colId], b[it.colId]) * direction); }); - nodes.forEach(node => { - ret.push(this.getRecordRow(node.record, columns, depth)); - if (treeMode && node.children.length) { - ret.push(...this.getRecordRowsRecursive(gridModel, node.children, columns, depth + 1)); + records.forEach(record => { + ret.push(this.getRecordRow(record, columns, depth)); + if (treeMode && record.children.length) { + ret.push(...this.getRecordRowsRecursive(gridModel, record.children, columns, depth + 1)); } });