diff --git a/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx b/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx index 76011f4af..17ab38cf0 100644 --- a/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx @@ -577,7 +577,7 @@ test('plugins can serialize dataSources', () => { }, ); - expect(instance.ds.records).toEqual([4, 5]); + expect(instance.ds.records()).toEqual([4, 5]); instance.ds.shift(1); instance.ds.append(6); expect(exportState()).toEqual({ diff --git a/desktop/flipper-plugin/src/state/DataSource.tsx b/desktop/flipper-plugin/src/state/DataSource.tsx index 986a760ea..f3462edde 100644 --- a/desktop/flipper-plugin/src/state/DataSource.tsx +++ b/desktop/flipper-plugin/src/state/DataSource.tsx @@ -95,132 +95,93 @@ export class DataSource< > implements Persistable { private nextId = 0; private _records: Entry[] = []; - private _recordsById: Map = new Map(); private keyAttribute: undefined | keyof T; private idToIndex: Map = new Map(); - // if we shift the window, we increase shiftOffset, rather than remapping all values + + // if we shift the window, we increase shiftOffset to correct idToIndex results, rather than remapping all values private shiftOffset = 0; - limit = defaultLimit; - - private sortBy: undefined | ((a: T) => Primitive); - - private reverse: boolean = false; - - private filter?: (value: T) => boolean; - - private dataUpdateQueue: DataEvent[] = []; - - windowStart = 0; - windowEnd = 0; - - private outputChangeListener?: (change: OutputChange) => void; /** - * Exposed for testing. - * This is the base view data, that is filtered and sorted, but not reversed or windowed + * The maximum amount of records this DataSource can have */ - output: Entry[] = []; + public limit = defaultLimit; /** - * Returns a defensive copy of the stored records. - * This is a O(n) operation! Prefer using .size and .get instead! + * The default view on this data source. A view applies + * sorting, filtering and windowing to get more constrained output. + * + * Additional views can created through the fork method. */ - get records(): readonly T[] { - return this._records.map(unwrap); - } - - serialize() { - return this.records; - } - - deserialize(value: any[]) { - this.clear(); - value.forEach((record) => { - this.append(record); - }); - } - - /** - * returns a direct reference to the stored records as lookup map, - * based on the key attribute set. - * The colletion should be treated as readonly and mutable (it might change over time). - * Create a defensive copy if needed. - */ - get recordsById(): ReadonlyMap { - this.assertKeySet(); - return this._recordsById; - } + public readonly view: DataSourceView; constructor(keyAttribute: KEY | undefined) { this.keyAttribute = keyAttribute; - this.setSortBy(undefined); + this.view = new DataSourceView(this); } public get size() { return this._records.length; } - public getRecord(index: number): T { - return this._records[index]?.value; - } - - public get outputSize() { - return this.output.length; - } - /** - * Returns a defensive copy of the current output. - * Sort, filter, reverse and are applied. - * Start and end behave like slice, and default to the currently active window. + * Returns a defensive copy of the stored records. + * This is a O(n) operation! Prefer using .size and .get instead if only a subset is needed. */ - public getOutput( - start = this.windowStart, - end = this.windowEnd, - ): readonly T[] { - if (this.reverse) { - return this.output - .slice(this.output.length - end, this.output.length - start) - .reverse() - .map((e) => e.value); - } else { - return this.output.slice(start, end).map((e) => e.value); - } + public records(): readonly T[] { + return this._records.map(unwrap); } - private assertKeySet() { - if (!this.keyAttribute) { - throw new Error( - 'No key has been set. Records cannot be looked up by key', - ); - } + public get(index: number) { + return unwrap(this._records[index]); } - private getKey(value: T): KEY_TYPE; - private getKey(value: any): any { + public getById(key: KEY_TYPE) { this.assertKeySet(); - const key = value[this.keyAttribute!]; - if ((typeof key === 'string' || typeof key === 'number') && key !== '') { - return key; - } - throw new Error(`Invalid key value: '${key}'`); + return this._recordsById.get(key); + } + + public keys(): IterableIterator { + this.assertKeySet(); + return this._recordsById.keys(); + } + + public entries(): IterableIterator<[KEY_TYPE, T]> { + this.assertKeySet(); + return this._recordsById.entries(); + } + + public [Symbol.iterator](): IterableIterator { + const self = this; + let offset = 0; + return { + next() { + offset++; + if (offset > self.size) { + return {done: true, value: undefined}; + } else { + return { + value: self._records[offset - 1].value, + }; + } + }, + [Symbol.iterator]() { + return this; + }, + }; } /** - * Returns the index of a specific key in the *source* set + * Returns the index of a specific key in the *records* set. + * Returns -1 if the record wansn't found */ - indexOfKey(key: KEY_TYPE): number { + public getIndexOfKey(key: KEY_TYPE): number { this.assertKeySet(); const stored = this.idToIndex.get(key); return stored === undefined ? -1 : stored + this.shiftOffset; } - private storeIndexOfKey(key: KEY_TYPE, index: number) { - // de-normalize the index, so that on later look ups its corrected again - this.idToIndex.set(key, index - this.shiftOffset); - } - - append(value: T) { + public append(value: T) { if (this._records.length >= this.limit) { // we're full! let's free up some space this.shift(Math.ceil(this.limit * dropFactor)); @@ -236,7 +197,8 @@ export class DataSource< const entry = { value, id: ++this.nextId, - visible: this.filter ? this.filter(value) : true, + // once we have multiple views, the following fields should be stored per view + visible: true, approxIndex: -1, }; this._records.push(entry); @@ -250,11 +212,11 @@ export class DataSource< * Updates or adds a record. Returns `true` if the record already existed. * Can only be used if a key is used. */ - upsert(value: T): boolean { + public upsert(value: T): boolean { this.assertKeySet(); const key = this.getKey(value); if (this.idToIndex.has(key)) { - this.update(this.indexOfKey(key), value); + this.update(this.getIndexOfKey(key), value); return true; } else { this.append(value); @@ -266,7 +228,7 @@ export class DataSource< * Replaces an item in the base data collection. * Note that the index is based on the insertion order, and not based on the current view */ - update(index: number, value: T) { + public update(index: number, value: T) { const entry = this._records[index]; const oldValue = entry.value; if (value === oldValue) { @@ -274,11 +236,16 @@ export class DataSource< } const oldVisible = entry.visible; entry.value = value; - entry.visible = this.filter ? this.filter(value) : true; if (this.keyAttribute) { const key = this.getKey(value); const currentKey = this.getKey(oldValue); if (currentKey !== key) { + const existingIndex = this.getIndexOfKey(key); + if (existingIndex !== -1 && existingIndex !== index) { + throw new Error( + `Trying to insert duplicate key '${key}', which already exist in the collection`, + ); + } this._recordsById.delete(currentKey); this.idToIndex.delete(currentKey); } @@ -299,7 +266,7 @@ export class DataSource< * * Warning: this operation can be O(n) if a key is set */ - remove(index: number) { + public delete(index: number) { if (index < 0 || index >= this._records.length) { throw new Error('Out of bounds: ' + index); } @@ -332,13 +299,13 @@ export class DataSource< * * Warning: this operation can be O(n) if a key is set */ - removeByKey(keyValue: KEY_TYPE): boolean { + public deleteByKey(keyValue: KEY_TYPE): boolean { this.assertKeySet(); - const index = this.indexOfKey(keyValue); + const index = this.getIndexOfKey(keyValue); if (index === -1) { return false; } - this.remove(index); + this.delete(index); return true; } @@ -346,7 +313,7 @@ export class DataSource< * Removes the first N entries. * @param amount */ - shift(amount: number) { + public shift(amount: number) { amount = Math.min(amount, this._records.length); if (amount === this._records.length) { this.clear(); @@ -365,7 +332,7 @@ export class DataSource< } if ( - this.sortBy && + this.view.isSorted && removed.length > 10 && removed.length > shiftRebuildTreshold * this._records.length ) { @@ -373,7 +340,7 @@ export class DataSource< // let's fallback to the async processing of all data instead // MWE: there is a risk here that rebuilding is too blocking, as this might happen // in background when new data arrives, and not explicitly on a user interaction - this.rebuildOutput(); + this.view.rebuild(); } else { this.emitDataEvent({ type: 'shift', @@ -383,13 +350,182 @@ export class DataSource< } } - setWindow(start: number, end: number) { + /** + * The clear operation removes any records stored, but will keep the current view preferences such as sorting and filtering + */ + public clear() { + this._records = []; + this._recordsById = new Map(); + this.shiftOffset = 0; + this.idToIndex = new Map(); + this.view.rebuild(); + } + + /** + * Returns a fork of this dataSource, that shares the source data with this dataSource, + * but has it's own FSRW pipeline, to allow multiple views on the same data + */ + public fork(): DataSourceView { + throw new Error( + 'Not implemented. Please contact oncall if this feature is needed', + ); + } + + private assertKeySet() { + if (!this.keyAttribute) { + throw new Error( + 'No key has been set. Records cannot be looked up by key', + ); + } + } + + private getKey(value: T): KEY_TYPE; + private getKey(value: any): any { + this.assertKeySet(); + const key = value[this.keyAttribute!]; + if ((typeof key === 'string' || typeof key === 'number') && key !== '') { + return key; + } + throw new Error(`Invalid key value: '${key}'`); + } + + private storeIndexOfKey(key: KEY_TYPE, index: number) { + // de-normalize the index, so that on later look ups its corrected again + this.idToIndex.set(key, index - this.shiftOffset); + } + + private emitDataEvent(event: DataEvent) { + // Optimization: potentially we could schedule this to happen async, + // using a queue, + // or only if there is an active view (although that could leak memory) + this.view.processEvent(event); + } + + /** + * @private + */ + serialize(): readonly T[] { + return this.records(); + } + + /** + * @private + */ + deserialize(value: any[]) { + this.clear(); + value.forEach((record) => { + this.append(record); + }); + } +} + +type CreateDataSourceOptions = { + /** + * If a key is set, the given field of the records is assumed to be unique, + * and it's value can be used to perform lookups and upserts. + */ + key?: K; + /** + * The maximum amount of records that this DataSource will store. + * If the limit is exceeded, the oldest records will automatically be dropped to make place for the new ones + */ + limit?: number; + /** + * Should this state persist when exporting a plugin? + * If set, the dataSource will be saved / loaded under the key provided + */ + persist?: string; +}; + +export function createDataSource( + initialSet: T[], + options: CreateDataSourceOptions, +): DataSource>; +export function createDataSource( + initialSet?: T[], +): DataSource; +export function createDataSource( + initialSet: T[] = [], + options?: CreateDataSourceOptions, +): DataSource { + const ds = new DataSource(options?.key); + if (options?.limit !== undefined) { + ds.limit = options.limit; + } + registerStorageAtom(options?.persist, ds); + initialSet.forEach((value) => ds.append(value)); + return ds; +} + +function unwrap(entry: Entry): T { + return entry?.value; +} + +class DataSourceView { + public readonly datasource: DataSource; + private sortBy: undefined | ((a: T) => Primitive) = undefined; + private reverse: boolean = false; + private filter?: (value: T) => boolean = undefined; + + /** + * @readonly + */ + public windowStart = 0; + /** + * @readonly + */ + public windowEnd = 0; + + private outputChangeListener?: (change: OutputChange) => void; + + /** + * This is the base view data, that is filtered and sorted, but not reversed or windowed + */ + private _output: Entry[] = []; + + constructor(datasource: DataSource) { + this.datasource = datasource; + } + + public get size() { + return this._output.length; + } + + public get isSorted() { + return !!this.sortBy; + } + + public get isFiltered() { + return !!this.filter; + } + + public get isReversed() { + return this.reverse; + } + + /** + * Returns a defensive copy of the current output. + * Sort, filter, reverse and are applied. + * Start and end behave like slice, and default to the currently active window. + */ + public output(start = this.windowStart, end = this.windowEnd): readonly T[] { + if (this.reverse) { + return this._output + .slice(this._output.length - end, this._output.length - start) + .reverse() + .map((e) => e.value); + } else { + return this._output.slice(start, end).map((e) => e.value); + } + } + + public setWindow(start: number, end: number) { this.windowStart = start; this.windowEnd = end; } - setOutputChangeListener( - listener: typeof DataSource['prototype']['outputChangeListener'], + public setListener( + listener: typeof DataSourceView['prototype']['outputChangeListener'], ) { if (this.outputChangeListener && listener) { console.warn('outputChangeListener already set'); @@ -397,7 +533,7 @@ export class DataSource< this.outputChangeListener = listener; } - setSortBy(sortBy: undefined | keyof T | ((a: T) => Primitive)) { + public setSortBy(sortBy: undefined | keyof T | ((a: T) => Primitive)) { if (this.sortBy === sortBy) { return; } @@ -411,42 +547,27 @@ export class DataSource< }); } this.sortBy = sortBy as any; - this.rebuildOutput(); + this.rebuild(); } - setFilter(filter: undefined | ((value: T) => boolean)) { + public setFilter(filter: undefined | ((value: T) => boolean)) { if (this.filter !== filter) { this.filter = filter; - this.rebuildOutput(); + this.rebuild(); } } - toggleReversed() { + public toggleReversed() { this.setReversed(!this.reverse); } - setReversed(reverse: boolean) { + public setReversed(reverse: boolean) { if (this.reverse !== reverse) { this.reverse = reverse; - this.notifyReset(this.output.length); + this.notifyReset(this._output.length); } } - /** - * The clear operation removes any records stored, but will keep the current view preferences such as sorting and filtering - */ - clear() { - this.windowStart = 0; - this.windowEnd = 0; - this._records = []; - this._recordsById = new Map(); - this.shiftOffset = 0; - this.idToIndex = new Map(); - this.dataUpdateQueue = []; - this.output = []; - this.notifyReset(0); - } - /** * The reset operation resets any view preferences such as sorting and filtering, but keeps the current set of records. */ @@ -454,35 +575,37 @@ export class DataSource< this.sortBy = undefined; this.reverse = false; this.filter = undefined; - this.rebuildOutput(); - } - - /** - * Returns a fork of this dataSource, that shares the source data with this dataSource, - * but has it's own FSRW pipeline, to allow multiple views on the same data - */ - fork(): DataSource { - throw new Error( - 'Not implemented. Please contact oncall if this feature is needed', - ); - } - - private emitDataEvent(event: DataEvent) { - this.dataUpdateQueue.push(event); - // TODO: schedule - this.processEvents(); + this.windowStart = 0; + this.windowEnd = 0; + this.rebuild(); } private normalizeIndex(viewIndex: number): number { - return this.reverse ? this.output.length - 1 - viewIndex : viewIndex; + return this.reverse ? this._output.length - 1 - viewIndex : viewIndex; } - getItem(viewIndex: number): T { - return this.getEntry(viewIndex)?.value; + public get(viewIndex: number): T { + return this._output[this.normalizeIndex(viewIndex)]?.value; } - getEntry(viewIndex: number): Entry { - return this.output[this.normalizeIndex(viewIndex)]; + public [Symbol.iterator](): IterableIterator { + const self = this; + let offset = this.windowStart; + return { + next() { + offset++; + if (offset > self.windowEnd || offset > self.size) { + return {done: true, value: undefined}; + } else { + return { + value: self.get(offset - 1), + }; + } + }, + [Symbol.iterator]() { + return this; + }, + }; } private notifyItemUpdated(viewIndex: number) { @@ -508,12 +631,12 @@ export class DataSource< if (this.reverse && delta < 0) { viewIndex -= delta; // we need to correct for normalize already using the new length after applying this change } - // TODO: for 'before' shifts, should the window be adjusted automatically? + // Idea: we could add an option to automatically shift the window for before events. this.outputChangeListener({ type: 'shift', delta, index: viewIndex, - newCount: this.output.length, + newCount: this._output.length, location: viewIndex < this.windowStart ? 'before' @@ -530,16 +653,15 @@ export class DataSource< }); } - private processEvents() { - const events = this.dataUpdateQueue.splice(0); - events.forEach(this.processEvent); - } - - private processEvent = (event: DataEvent) => { - const {output, sortBy, filter} = this; + /** + * @private + */ + processEvent(event: DataEvent) { + const {_output: output, sortBy, filter} = this; switch (event.type) { case 'append': { const {entry} = event; + entry.visible = filter ? filter(entry.value) : true; if (!entry.visible) { // not in filter? skip this entry return; @@ -556,6 +678,7 @@ export class DataSource< } case 'update': { const {entry} = event; + entry.visible = filter ? filter(entry.value) : true; // short circuit; no view active so update straight away if (!filter && !sortBy) { output[event.index].approxIndex = event.index; @@ -624,10 +747,10 @@ export class DataSource< default: throw new Error('unknown event type'); } - }; + } private processRemoveEvent(index: number, entry: Entry) { - const {output, sortBy, filter} = this; + const {_output: output, sortBy, filter} = this; // filter active, and not visible? short circuilt if (!entry.visible) { @@ -645,7 +768,11 @@ export class DataSource< } } - private rebuildOutput() { + /** + * Rebuilds the entire view. Typically there should be no need to call this manually + * @private + */ + rebuild() { // Pending on the size, should we batch this in smaller non-blocking steps, // which we update in a double-buffering mechanism, report progress, and swap out when done? // @@ -654,12 +781,14 @@ export class DataSource< // See also comment below const {sortBy, filter, sortHelper} = this; // copy base array or run filter (with side effecty update of visible) + // @ts-ignore prevent making _record public + const records: Entry[] = this.datasource._records; let output = filter - ? this._records.filter((entry) => { + ? records.filter((entry) => { entry.visible = filter(entry.value); return entry.visible; }) - : this._records.slice(); + : records.slice(); if (sortBy) { // Pending on the size, should we batch this in smaller steps? // The following sorthing method can be taskified, however, @@ -674,7 +803,7 @@ export class DataSource< output = lodashSort(output, sortHelper); // uses array.sort under the hood } - this.output = output; + this._output = output; this.notifyReset(output.length); } @@ -682,7 +811,7 @@ export class DataSource< this.sortBy ? this.sortBy(a.value) : a.id; private getSortedIndex(entry: Entry, oldValue: T) { - const {output} = this; + const {_output: output} = this; if (output[entry.approxIndex] === entry) { // yay! return entry.approxIndex; @@ -712,54 +841,12 @@ export class DataSource< private insertSorted(entry: Entry) { // apply sorting const insertionIndex = sortedLastIndexBy( - this.output, + this._output, entry, this.sortHelper, ); entry.approxIndex = insertionIndex; - this.output.splice(insertionIndex, 0, entry); + this._output.splice(insertionIndex, 0, entry); this.notifyItemShift(insertionIndex, 1); } } - -type CreateDataSourceOptions = { - /** - * If a key is set, the given field of the records is assumed to be unique, - * and it's value can be used to perform lookups and upserts. - */ - key?: K; - /** - * The maximum amount of records that this DataSource will store. - * If the limit is exceeded, the oldest records will automatically be dropped to make place for the new ones - */ - limit?: number; - /** - * Should this state persist when exporting a plugin? - * If set, the dataSource will be saved / loaded under the key provided - */ - persist?: string; -}; - -export function createDataSource( - initialSet: T[], - options: CreateDataSourceOptions, -): DataSource>; -export function createDataSource( - initialSet?: T[], -): DataSource; -export function createDataSource( - initialSet: T[] = [], - options?: CreateDataSourceOptions, -): DataSource { - const ds = new DataSource(options?.key); - if (options?.limit !== undefined) { - ds.limit = options.limit; - } - registerStorageAtom(options?.persist, ds); - initialSet.forEach((value) => ds.append(value)); - return ds; -} - -function unwrap(entry: Entry): T { - return entry.value; -} diff --git a/desktop/flipper-plugin/src/state/__tests__/datasource-basics.node.tsx b/desktop/flipper-plugin/src/state/__tests__/datasource-basics.node.tsx index aec5f426f..22ad19b04 100644 --- a/desktop/flipper-plugin/src/state/__tests__/datasource-basics.node.tsx +++ b/desktop/flipper-plugin/src/state/__tests__/datasource-basics.node.tsx @@ -34,43 +34,52 @@ function unwrap(array: readonly {value: T}[]): readonly T[] { return array.map((entry) => entry.value); } +function rawOutput(ds: DataSource): readonly T[] { + // @ts-ignore + const output = ds.view._output; + return unwrap(output); +} + test('can create a datasource', () => { const ds = createDataSource([eatCookie]); - expect(ds.records).toEqual([eatCookie]); + expect(ds.records()).toEqual([eatCookie]); ds.append(drinkCoffee); - expect(ds.records).toEqual([eatCookie, drinkCoffee]); + expect(ds.records()).toEqual([eatCookie, drinkCoffee]); - expect(() => ds.recordsById).toThrow(/Records cannot be looked up by key/); + // @ts-ignore + expect(() => ds.getById('stuff')).toThrow( + /Records cannot be looked up by key/, + ); ds.update(1, submitBug); - expect(ds.records[1]).toBe(submitBug); + expect(ds.records()[1]).toBe(submitBug); - ds.remove(0); - expect(ds.records[0]).toBe(submitBug); + ds.delete(0); + expect(ds.records()[0]).toBe(submitBug); }); test('can create a keyed datasource', () => { const ds = createDataSource([eatCookie], {key: 'id'}); - expect(ds.records).toEqual([eatCookie]); + expect(ds.records()).toEqual([eatCookie]); ds.append(drinkCoffee); - expect(ds.records).toEqual([eatCookie, drinkCoffee]); + expect(ds.records()).toEqual([eatCookie, drinkCoffee]); - expect(ds.recordsById.get('bug')).toBe(undefined); - expect(ds.recordsById.get('cookie')).toBe(eatCookie); - expect(ds.recordsById.get('coffee')).toBe(drinkCoffee); - expect(ds.indexOfKey('bug')).toBe(-1); - expect(ds.indexOfKey('cookie')).toBe(0); - expect(ds.indexOfKey('coffee')).toBe(1); + expect(ds.getById('bug')).toBe(undefined); + expect(ds.getById('cookie')).toBe(eatCookie); + expect(ds.getById('coffee')).toBe(drinkCoffee); + expect(ds.getIndexOfKey('bug')).toBe(-1); + expect(ds.getIndexOfKey('cookie')).toBe(0); + expect(ds.getIndexOfKey('coffee')).toBe(1); ds.update(1, submitBug); - expect(ds.records[1]).toBe(submitBug); - expect(ds.recordsById.get('coffee')).toBe(undefined); - expect(ds.recordsById.get('bug')).toBe(submitBug); - expect(ds.indexOfKey('bug')).toBe(1); - expect(ds.indexOfKey('cookie')).toBe(0); - expect(ds.indexOfKey('coffee')).toBe(-1); + expect(ds.records()[1]).toBe(submitBug); + expect(ds.getById('coffee')).toBe(undefined); + expect(ds.getById('bug')).toBe(submitBug); + expect(ds.getIndexOfKey('bug')).toBe(1); + expect(ds.getIndexOfKey('cookie')).toBe(0); + expect(ds.getIndexOfKey('coffee')).toBe(-1); // upsert existing const newBug = { @@ -79,8 +88,8 @@ test('can create a keyed datasource', () => { done: true, }; ds.upsert(newBug); - expect(ds.records[1]).toBe(newBug); - expect(ds.recordsById.get('bug')).toBe(newBug); + expect(ds.records()[1]).toBe(newBug); + expect(ds.getById('bug')).toBe(newBug); // upsert new const trash = { @@ -88,16 +97,16 @@ test('can create a keyed datasource', () => { title: 'take trash out', }; ds.upsert(trash); - expect(ds.records[2]).toBe(trash); - expect(ds.recordsById.get('trash')).toBe(trash); + expect(ds.records()[2]).toBe(trash); + expect(ds.getById('trash')).toBe(trash); // delete by key - expect(ds.records).toEqual([eatCookie, newBug, trash]); - expect(ds.removeByKey('bug')).toBe(true); - expect(ds.records).toEqual([eatCookie, trash]); - expect(ds.indexOfKey('bug')).toBe(-1); - expect(ds.indexOfKey('cookie')).toBe(0); - expect(ds.indexOfKey('trash')).toBe(1); + expect(ds.records()).toEqual([eatCookie, newBug, trash]); + expect(ds.deleteByKey('bug')).toBe(true); + expect(ds.records()).toEqual([eatCookie, trash]); + expect(ds.getIndexOfKey('bug')).toBe(-1); + expect(ds.getIndexOfKey('cookie')).toBe(0); + expect(ds.getIndexOfKey('trash')).toBe(1); }); test('throws on invalid keys', () => { @@ -110,32 +119,41 @@ test('throws on invalid keys', () => { }).toThrow(`Duplicate key: 'cookie'`); }); +test('throws on update causing duplicate key', () => { + const ds = createDataSource([eatCookie, submitBug], {key: 'id'}); + expect(() => { + ds.update(0, {id: 'bug', title: 'oops'}); + }).toThrow( + `Trying to insert duplicate key 'bug', which already exist in the collection`, + ); +}); + test('removing invalid keys', () => { const ds = createDataSource([eatCookie], {key: 'id'}); - expect(ds.removeByKey('trash')).toBe(false); + expect(ds.deleteByKey('trash')).toBe(false); expect(() => { - ds.remove(1); + ds.delete(1); }).toThrowError('Out of bounds'); }); test('sorting works', () => { const ds = createDataSource([eatCookie, drinkCoffee]); - ds.setSortBy((todo) => todo.title); - expect(unwrap(ds.output)).toEqual([drinkCoffee, eatCookie]); + ds.view.setSortBy((todo) => todo.title); + expect(rawOutput(ds)).toEqual([drinkCoffee, eatCookie]); - ds.setSortBy(undefined); - ds.setSortBy(undefined); - expect(unwrap(ds.output)).toEqual([eatCookie, drinkCoffee]); - ds.setSortBy((todo) => todo.title); - expect(unwrap(ds.output)).toEqual([drinkCoffee, eatCookie]); + ds.view.setSortBy(undefined); + ds.view.setSortBy(undefined); + expect(rawOutput(ds)).toEqual([eatCookie, drinkCoffee]); + ds.view.setSortBy((todo) => todo.title); + expect(rawOutput(ds)).toEqual([drinkCoffee, eatCookie]); const aleph = { id: 'd', title: 'aleph', }; ds.append(aleph); - expect(ds.records).toEqual([eatCookie, drinkCoffee, aleph]); - expect(unwrap(ds.output)).toEqual([aleph, drinkCoffee, eatCookie]); + expect(ds.records()).toEqual([eatCookie, drinkCoffee, aleph]); + expect(rawOutput(ds)).toEqual([aleph, drinkCoffee, eatCookie]); }); test('sorting preserves insertion order with equal keys', () => { @@ -151,15 +169,15 @@ test('sorting preserves insertion order with equal keys', () => { const c = {$: 'c', name: 'c'}; const ds = createDataSource([]); - ds.setSortBy('$'); + ds.view.setSortBy('$'); ds.append(b1); ds.append(c); ds.append(b2); ds.append(a); ds.append(b3); - expect(ds.records).toEqual([b1, c, b2, a, b3]); - expect(unwrap(ds.output)).toEqual([a, b1, b2, b3, c]); + expect(ds.records()).toEqual([b1, c, b2, a, b3]); + expect(rawOutput(ds)).toEqual([a, b1, b2, b3, c]); // if we append a new item with existig item, it should end up in the end const b4 = { @@ -167,8 +185,8 @@ test('sorting preserves insertion order with equal keys', () => { name: 'b4', }; ds.append(b4); - expect(ds.records).toEqual([b1, c, b2, a, b3, b4]); - expect(unwrap(ds.output)).toEqual([a, b1, b2, b3, b4, c]); + expect(ds.records()).toEqual([b1, c, b2, a, b3, b4]); + expect(rawOutput(ds)).toEqual([a, b1, b2, b3, b4, c]); // if we replace the middle item, it should end up in the middle const b2r = { @@ -176,8 +194,8 @@ test('sorting preserves insertion order with equal keys', () => { name: 'b2replacement', }; ds.update(2, b2r); - expect(ds.records).toEqual([b1, c, b2r, a, b3, b4]); - expect(unwrap(ds.output)).toEqual([a, b1, b2r, b3, b4, c]); + expect(ds.records()).toEqual([b1, c, b2r, a, b3, b4]); + expect(rawOutput(ds)).toEqual([a, b1, b2r, b3, b4, c]); // if we replace something with a different sort value, it should be sorted properly, and the old should disappear const b3r = { @@ -185,29 +203,29 @@ test('sorting preserves insertion order with equal keys', () => { name: 'b3replacement', }; ds.update(4, b3r); - expect(ds.records).toEqual([b1, c, b2r, a, b3r, b4]); - expect(unwrap(ds.output)).toEqual([a, b3r, b1, b2r, b4, c]); + expect(ds.records()).toEqual([b1, c, b2r, a, b3r, b4]); + expect(rawOutput(ds)).toEqual([a, b3r, b1, b2r, b4, c]); - ds.remove(3); - expect(ds.records).toEqual([b1, c, b2r, b3r, b4]); - expect(unwrap(ds.output)).toEqual([b3r, b1, b2r, b4, c]); + ds.delete(3); + expect(ds.records()).toEqual([b1, c, b2r, b3r, b4]); + expect(rawOutput(ds)).toEqual([b3r, b1, b2r, b4, c]); }); test('filter + sort', () => { const ds = createDataSource([eatCookie, drinkCoffee, submitBug]); - ds.setFilter((t) => t.title.indexOf('c') === -1); - ds.setSortBy('title'); + ds.view.setFilter((t) => t.title.indexOf('c') === -1); + ds.view.setSortBy('title'); - expect(unwrap(ds.output)).toEqual([submitBug]); + expect(rawOutput(ds)).toEqual([submitBug]); // append with and without filter const a = {id: 'a', title: 'does have that letter: c'}; const b = {id: 'b', title: 'doesnt have that letter'}; ds.append(a); - expect(unwrap(ds.output)).toEqual([submitBug]); + expect(rawOutput(ds)).toEqual([submitBug]); ds.append(b); - expect(unwrap(ds.output)).toEqual([b, submitBug]); + expect(rawOutput(ds)).toEqual([b, submitBug]); // filter in const newCookie = { @@ -215,7 +233,7 @@ test('filter + sort', () => { title: 'eat a ookie', }; ds.update(0, newCookie); - expect(unwrap(ds.output)).toEqual([b, newCookie, submitBug]); + expect(rawOutput(ds)).toEqual([b, newCookie, submitBug]); // update -> filter in const newCoffee = { @@ -223,35 +241,25 @@ test('filter + sort', () => { title: 'better drink tea', }; ds.append(newCoffee); - expect(unwrap(ds.output)).toEqual([newCoffee, b, newCookie, submitBug]); + expect(rawOutput(ds)).toEqual([newCoffee, b, newCookie, submitBug]); // update -> filter out ds.update(2, {id: 'bug', title: 'bug has c!'}); - expect(unwrap(ds.output)).toEqual([newCoffee, b, newCookie]); + expect(rawOutput(ds)).toEqual([newCoffee, b, newCookie]); ds.update(2, submitBug); - expect(unwrap(ds.output)).toEqual([newCoffee, b, newCookie, submitBug]); + expect(rawOutput(ds)).toEqual([newCoffee, b, newCookie, submitBug]); - ds.remove(3); // a - ds.remove(3); // b - expect(unwrap(ds.output)).toEqual([newCoffee, newCookie, submitBug]); + ds.delete(3); // a + ds.delete(3); // b + expect(rawOutput(ds)).toEqual([newCoffee, newCookie, submitBug]); - ds.setFilter(undefined); - expect(unwrap(ds.output)).toEqual([ - newCoffee, - drinkCoffee, - newCookie, - submitBug, - ]); + ds.view.setFilter(undefined); + expect(rawOutput(ds)).toEqual([newCoffee, drinkCoffee, newCookie, submitBug]); - ds.setSortBy(undefined); + ds.view.setSortBy(undefined); // key insertion order - expect(unwrap(ds.output)).toEqual([ - newCookie, - drinkCoffee, - submitBug, - newCoffee, - ]); + expect(rawOutput(ds)).toEqual([newCookie, drinkCoffee, submitBug, newCoffee]); }); test('filter + sort + index', () => { @@ -259,18 +267,18 @@ test('filter + sort + index', () => { key: 'id', }); - ds.setFilter((t) => t.title.indexOf('c') === -1); - ds.setSortBy('title'); + ds.view.setFilter((t) => t.title.indexOf('c') === -1); + ds.view.setSortBy('title'); - expect(unwrap(ds.output)).toEqual([submitBug]); + expect(rawOutput(ds)).toEqual([submitBug]); // append with and without filter const a = {id: 'a', title: 'does have that letter: c'}; const b = {id: 'b', title: 'doesnt have that letter'}; ds.append(a); - expect(unwrap(ds.output)).toEqual([submitBug]); + expect(rawOutput(ds)).toEqual([submitBug]); ds.append(b); - expect(unwrap(ds.output)).toEqual([b, submitBug]); + expect(rawOutput(ds)).toEqual([b, submitBug]); // filter in const newCookie = { @@ -278,7 +286,7 @@ test('filter + sort + index', () => { title: 'eat a ookie', }; ds.update(0, newCookie); - expect(unwrap(ds.output)).toEqual([b, newCookie, submitBug]); + expect(rawOutput(ds)).toEqual([b, newCookie, submitBug]); // update -> filter in const newCoffee = { @@ -286,24 +294,24 @@ test('filter + sort + index', () => { title: 'better drink tea', }; ds.upsert(newCoffee); - expect(unwrap(ds.output)).toEqual([newCoffee, b, newCookie, submitBug]); + expect(rawOutput(ds)).toEqual([newCoffee, b, newCookie, submitBug]); // update -> filter out ds.update(2, {id: 'bug', title: 'bug has c!'}); - expect(unwrap(ds.output)).toEqual([newCoffee, b, newCookie]); + expect(rawOutput(ds)).toEqual([newCoffee, b, newCookie]); ds.update(2, submitBug); - expect(unwrap(ds.output)).toEqual([newCoffee, b, newCookie, submitBug]); + expect(rawOutput(ds)).toEqual([newCoffee, b, newCookie, submitBug]); - ds.setFilter(undefined); - expect(unwrap(ds.output)).toEqual([newCoffee, a, b, newCookie, submitBug]); + ds.view.setFilter(undefined); + expect(rawOutput(ds)).toEqual([newCoffee, a, b, newCookie, submitBug]); - ds.setSortBy(undefined); + ds.view.setSortBy(undefined); // key insertion order - expect(unwrap(ds.output)).toEqual([newCookie, newCoffee, submitBug, a, b]); + expect(rawOutput(ds)).toEqual([newCookie, newCoffee, submitBug, a, b]); // verify getOutput - expect(unwrap(ds.output.slice(1, 3))).toEqual([newCoffee, submitBug]); - expect(ds.getOutput(1, 3)).toEqual([newCoffee, submitBug]); + expect(rawOutput(ds).slice(1, 3)).toEqual([newCoffee, submitBug]); + expect(ds.view.output(1, 3)).toEqual([newCoffee, submitBug]); }); test('filter', () => { @@ -311,16 +319,16 @@ test('filter', () => { key: 'id', }); - ds.setFilter((t) => t.title.indexOf('c') === -1); - expect(unwrap(ds.output)).toEqual([submitBug]); + ds.view.setFilter((t) => t.title.indexOf('c') === -1); + expect(rawOutput(ds)).toEqual([submitBug]); // append with and without filter const a = {id: 'a', title: 'does have that letter: c'}; const b = {id: 'b', title: 'doesnt have that letter'}; ds.append(a); - expect(unwrap(ds.output)).toEqual([submitBug]); + expect(rawOutput(ds)).toEqual([submitBug]); ds.append(b); - expect(unwrap(ds.output)).toEqual([submitBug, b]); + expect(rawOutput(ds)).toEqual([submitBug, b]); // filter in const newCookie = { @@ -328,7 +336,7 @@ test('filter', () => { title: 'eat a ookie', }; ds.update(0, newCookie); - expect(unwrap(ds.output)).toEqual([newCookie, submitBug, b]); + expect(rawOutput(ds)).toEqual([newCookie, submitBug, b]); // update -> filter in const newCoffee = { @@ -336,48 +344,48 @@ test('filter', () => { title: 'better drink tea', }; ds.upsert(newCoffee); - expect(unwrap(ds.output)).toEqual([newCookie, newCoffee, submitBug, b]); + expect(rawOutput(ds)).toEqual([newCookie, newCoffee, submitBug, b]); // update -> filter out ds.update(2, {id: 'bug', title: 'bug has c!'}); - expect(unwrap(ds.output)).toEqual([newCookie, newCoffee, b]); + expect(rawOutput(ds)).toEqual([newCookie, newCoffee, b]); ds.update(2, submitBug); - ds.setFilter(undefined); - expect(unwrap(ds.output)).toEqual([newCookie, newCoffee, submitBug, a, b]); + ds.view.setFilter(undefined); + expect(rawOutput(ds)).toEqual([newCookie, newCoffee, submitBug, a, b]); }); test('reverse without sorting', () => { const ds = createDataSource([eatCookie, drinkCoffee]); - ds.setWindow(0, 100); - expect(ds.getOutput()).toEqual([eatCookie, drinkCoffee]); + ds.view.setWindow(0, 100); + expect(ds.view.output()).toEqual([eatCookie, drinkCoffee]); - ds.toggleReversed(); - expect(ds.getOutput(1, 2)).toEqual([eatCookie]); - expect(ds.getOutput(0, 1)).toEqual([drinkCoffee]); - expect(ds.getOutput(0, 2)).toEqual([drinkCoffee, eatCookie]); + ds.view.toggleReversed(); + expect(ds.view.output(1, 2)).toEqual([eatCookie]); + expect(ds.view.output(0, 1)).toEqual([drinkCoffee]); + expect(ds.view.output(0, 2)).toEqual([drinkCoffee, eatCookie]); - expect(ds.getOutput()).toEqual([drinkCoffee, eatCookie]); + expect(ds.view.output()).toEqual([drinkCoffee, eatCookie]); ds.append(submitBug); - expect(ds.records).toEqual([eatCookie, drinkCoffee, submitBug]); - expect(ds.getOutput()).toEqual([submitBug, drinkCoffee, eatCookie]); + expect(ds.records()).toEqual([eatCookie, drinkCoffee, submitBug]); + expect(ds.view.output()).toEqual([submitBug, drinkCoffee, eatCookie]); const x = {id: 'x', title: 'x'}; ds.update(0, x); - expect(ds.records).toEqual([x, drinkCoffee, submitBug]); - expect(ds.getOutput()).toEqual([submitBug, drinkCoffee, x]); + expect(ds.records()).toEqual([x, drinkCoffee, submitBug]); + expect(ds.view.output()).toEqual([submitBug, drinkCoffee, x]); const y = {id: 'y', title: 'y'}; const z = {id: 'z', title: 'z'}; ds.update(2, z); ds.update(1, y); - expect(ds.records).toEqual([x, y, z]); - expect(ds.getOutput()).toEqual([z, y, x]); + expect(ds.records()).toEqual([x, y, z]); + expect(ds.view.output()).toEqual([z, y, x]); - ds.setReversed(false); - expect(ds.getOutput()).toEqual([x, y, z]); + ds.view.setReversed(false); + expect(ds.view.output()).toEqual([x, y, z]); }); test('reverse with sorting', () => { @@ -393,23 +401,23 @@ test('reverse with sorting', () => { const c = {$: 'c', name: 'c'}; const ds = createDataSource([]); - ds.setWindow(0, 100); - ds.setReversed(true); + ds.view.setWindow(0, 100); + ds.view.setReversed(true); ds.append(b1); ds.append(c); - expect(ds.getOutput()).toEqual([c, b1]); + expect(ds.view.output()).toEqual([c, b1]); - ds.setSortBy('$'); - expect(ds.getOutput()).toEqual([c, b1]); + ds.view.setSortBy('$'); + expect(ds.view.output()).toEqual([c, b1]); ds.append(b2); - expect(ds.getOutput()).toEqual([c, b2, b1]); + expect(ds.view.output()).toEqual([c, b2, b1]); ds.append(a); - expect(ds.getOutput()).toEqual([c, b2, b1, a]); + expect(ds.view.output()).toEqual([c, b2, b1, a]); ds.append(b3); - expect(ds.getOutput()).toEqual([c, b3, b2, b1, a]); + expect(ds.view.output()).toEqual([c, b3, b2, b1, a]); // if we append a new item with existig item, it should end up in the end const b4 = { @@ -417,7 +425,7 @@ test('reverse with sorting', () => { name: 'b4', }; ds.append(b4); - expect(ds.getOutput()).toEqual([c, b4, b3, b2, b1, a]); + expect(ds.view.output()).toEqual([c, b4, b3, b2, b1, a]); // if we replace the middle item, it should end up in the middle const b2r = { @@ -425,7 +433,7 @@ test('reverse with sorting', () => { name: 'b2replacement', }; ds.update(2, b2r); - expect(ds.getOutput()).toEqual([c, b4, b3, b2r, b1, a]); + expect(ds.view.output()).toEqual([c, b4, b3, b2r, b1, a]); // if we replace something with a different sort value, it should be sorted properly, and the old should disappear const b3r = { @@ -433,45 +441,45 @@ test('reverse with sorting', () => { name: 'b3replacement', }; ds.update(4, b3r); - expect(ds.getOutput()).toEqual([c, b4, b2r, b1, b3r, a]); + expect(ds.view.output()).toEqual([c, b4, b2r, b1, b3r, a]); - ds.remove(4); - expect(ds.getOutput()).toEqual([c, b4, b2r, b1, a]); + ds.delete(4); + expect(ds.view.output()).toEqual([c, b4, b2r, b1, a]); }); test('reset', () => { const ds = createDataSource([submitBug, drinkCoffee, eatCookie], { key: 'id', }); - ds.setSortBy('title'); - ds.setFilter((v) => v.id !== 'cookie'); - expect(unwrap(ds.output)).toEqual([drinkCoffee, submitBug]); - expect([...ds.recordsById.keys()]).toEqual(['bug', 'coffee', 'cookie']); + ds.view.setSortBy('title'); + ds.view.setFilter((v) => v.id !== 'cookie'); + expect(rawOutput(ds)).toEqual([drinkCoffee, submitBug]); + expect([...ds.keys()]).toEqual(['bug', 'coffee', 'cookie']); - ds.reset(); - expect(unwrap(ds.output)).toEqual([submitBug, drinkCoffee, eatCookie]); - expect([...ds.recordsById.keys()]).toEqual(['bug', 'coffee', 'cookie']); + ds.view.reset(); + expect(rawOutput(ds)).toEqual([submitBug, drinkCoffee, eatCookie]); + expect([...ds.keys()]).toEqual(['bug', 'coffee', 'cookie']); }); test('clear', () => { const ds = createDataSource([submitBug, drinkCoffee, eatCookie], { key: 'id', }); - ds.setSortBy('title'); - ds.setFilter((v) => v.id !== 'cookie'); - expect(unwrap(ds.output)).toEqual([drinkCoffee, submitBug]); - expect([...ds.recordsById.keys()]).toEqual(['bug', 'coffee', 'cookie']); + ds.view.setSortBy('title'); + ds.view.setFilter((v) => v.id !== 'cookie'); + expect(rawOutput(ds)).toEqual([drinkCoffee, submitBug]); + expect([...ds.keys()]).toEqual(['bug', 'coffee', 'cookie']); ds.clear(); - expect(unwrap(ds.output)).toEqual([]); - expect([...ds.recordsById.keys()]).toEqual([]); + expect(rawOutput(ds)).toEqual([]); + expect([...ds.keys()]).toEqual([]); ds.append(eatCookie); ds.append(drinkCoffee); ds.append(submitBug); - expect([...ds.recordsById.keys()]).toEqual(['cookie', 'coffee', 'bug']); + expect([...ds.keys()]).toEqual(['cookie', 'coffee', 'bug']); // resets in the same ordering as view preferences were preserved - expect(unwrap(ds.output)).toEqual([drinkCoffee, submitBug]); + expect(rawOutput(ds)).toEqual([drinkCoffee, submitBug]); }); function testEvents( @@ -481,9 +489,9 @@ function testEvents( ): any[] { const ds = createDataSource(initial, {key}); const events: any[] = []; - ds.setOutputChangeListener((e) => events.push(e)); + ds.view.setListener((e) => events.push(e)); op(ds); - ds.setOutputChangeListener(undefined); + ds.view.setListener(undefined); return events; } @@ -507,7 +515,7 @@ test('it emits the right events - zero window', () => { test('it emits the right events - small window', () => { expect( testEvents(['a', 'b'], (ds) => { - ds.setWindow(0, 3); + ds.view.setWindow(0, 3); ds.append('c'); ds.update(1, 'x'); }), @@ -520,13 +528,13 @@ test('it emits the right events - small window', () => { test('it emits the right events - view change', () => { expect( testEvents(['a', 'b'], (ds) => { - ds.setWindow(1, 2); - ds.setSortBy((x) => x); + ds.view.setWindow(1, 2); + ds.view.setSortBy((x) => x); // a, [b] ds.update(0, 'x'); // b, [x] - expect(ds.getItem(0)).toEqual('b'); - expect(ds.getItem(1)).toEqual('x'); + expect(ds.view.get(0)).toEqual('b'); + expect(ds.view.get(1)).toEqual('x'); ds.append('y'); // b, [x], y ds.append('c'); @@ -544,14 +552,14 @@ test('it emits the right events - view change', () => { test('it emits the right events - reversed view change', () => { expect( testEvents(['a', 'b'], (ds) => { - ds.setWindow(1, 2); - ds.setSortBy((x) => x); - ds.setReversed(true); + ds.view.setWindow(1, 2); + ds.view.setSortBy((x) => x); + ds.view.setReversed(true); // b, [a] ds.update(0, 'x'); // x, [b] - expect(ds.getItem(0)).toEqual('x'); - expect(ds.getItem(1)).toEqual('b'); + expect(ds.view.get(0)).toEqual('x'); + expect(ds.view.get(1)).toEqual('b'); ds.append('y'); // y, [x], b ds.append('c'); @@ -573,15 +581,15 @@ test('it emits the right events - reversed view change', () => { test('it emits the right events - reversed view change with filter', () => { expect( testEvents(['a', 'b'], (ds) => { - ds.setWindow(0, 2); - ds.setSortBy((x) => x); - ds.setReversed(true); - ds.setFilter((x) => ['a', 'b'].includes(x)); + ds.view.setWindow(0, 2); + ds.view.setSortBy((x) => x); + ds.view.setReversed(true); + ds.view.setFilter((x) => ['a', 'b'].includes(x)); // [b, a] ds.update(0, 'x'); // x b // [b, ] - expect(ds.getItem(0)).toEqual('b'); - expect(ds.output.length).toBe(1); + expect(ds.view.get(0)).toEqual('b'); + expect(rawOutput(ds).length).toBe(1); ds.append('y'); // x b y // [b, ] ds.append('c'); // x b y c @@ -590,9 +598,9 @@ test('it emits the right events - reversed view change with filter', () => { // [b, a] ds.append('a'); // x b y c a a // [b, a, a] // N.b. the new a is in the *middle* - ds.remove(2); // x b c a a + ds.delete(2); // x b c a a // no effect - ds.remove(4); // this removes the second a in input, so the first a in the outpat! + ds.delete(4); // this removes the second a in input, so the first a in the outpat! // [b, a] }), ).toEqual([ @@ -612,16 +620,16 @@ test('basic remove', () => { testEvents( [drinkCoffee, eatCookie, submitBug], (ds) => { - ds.setWindow(0, 100); - ds.remove(0); - expect(ds.getOutput()).toEqual([eatCookie, submitBug]); - expect(ds.recordsById.get('bug')).toBe(submitBug); - expect(ds.recordsById.get('coffee')).toBeUndefined(); - expect(ds.recordsById.get('cookie')).toBe(eatCookie); + ds.view.setWindow(0, 100); + ds.delete(0); + expect(ds.view.output()).toEqual([eatCookie, submitBug]); + expect(ds.getById('bug')).toBe(submitBug); + expect(ds.getById('coffee')).toBeUndefined(); + expect(ds.getById('cookie')).toBe(eatCookie); ds.upsert(completedBug); - ds.removeByKey('cookie'); - expect(ds.getOutput()).toEqual([completedBug]); - expect(ds.recordsById.get('bug')).toBe(completedBug); + ds.deleteByKey('cookie'); + expect(ds.view.output()).toEqual([completedBug]); + expect(ds.getById('bug')).toBe(completedBug); }, 'id', ), @@ -653,16 +661,16 @@ test('basic shift', () => { testEvents( [drinkCoffee, eatCookie, submitBug], (ds) => { - ds.setWindow(0, 100); + ds.view.setWindow(0, 100); ds.shift(2); - expect(ds.getOutput()).toEqual([submitBug]); - expect(ds.recordsById.get('bug')).toBe(submitBug); - expect(ds.recordsById.get('coffee')).toBeUndefined(); - expect(ds.indexOfKey('bug')).toBe(0); - expect(ds.indexOfKey('coffee')).toBe(-1); + expect(ds.view.output()).toEqual([submitBug]); + expect(ds.getById('bug')).toBe(submitBug); + expect(ds.getById('coffee')).toBeUndefined(); + expect(ds.getIndexOfKey('bug')).toBe(0); + expect(ds.getIndexOfKey('coffee')).toBe(-1); ds.upsert(completedBug); - expect(ds.getOutput()).toEqual([completedBug]); - expect(ds.recordsById.get('bug')).toBe(completedBug); + expect(ds.view.output()).toEqual([completedBug]); + expect(ds.getById('bug')).toBe(completedBug); }, 'id', ), @@ -684,11 +692,11 @@ test('basic shift', () => { test('sorted shift', () => { expect( testEvents(['c', 'b', 'a', 'e', 'd'], (ds) => { - ds.setWindow(0, 100); - ds.setSortBy((v) => v); - expect(ds.getOutput()).toEqual(['a', 'b', 'c', 'd', 'e']); + ds.view.setWindow(0, 100); + ds.view.setSortBy((v) => v); + expect(ds.view.output()).toEqual(['a', 'b', 'c', 'd', 'e']); ds.shift(4); - expect(ds.getOutput()).toEqual(['d']); + expect(ds.view.output()).toEqual(['d']); ds.shift(1); // optimizes to reset }), ).toEqual([ @@ -704,11 +712,11 @@ test('sorted shift', () => { test('filtered shift', () => { expect( testEvents(['c', 'b', 'a', 'e', 'd'], (ds) => { - ds.setWindow(0, 100); - ds.setFilter((v) => v !== 'b' && v !== 'e'); - expect(ds.getOutput()).toEqual(['c', 'a', 'd']); + ds.view.setWindow(0, 100); + ds.view.setFilter((v) => v !== 'b' && v !== 'e'); + expect(ds.view.output()).toEqual(['c', 'a', 'd']); ds.shift(4); - expect(ds.getOutput()).toEqual(['d']); + expect(ds.view.output()).toEqual(['d']); }), ).toEqual([ {newCount: 3, type: 'reset'}, // filter @@ -724,16 +732,16 @@ test('remove after shift works correctly', () => { testEvents( [eatCookie, drinkCoffee, submitBug, a, b], (ds) => { - ds.setWindow(0, 100); + ds.view.setWindow(0, 100); ds.shift(2); - ds.removeByKey('b'); - ds.removeByKey('bug'); - expect(ds.getOutput()).toEqual([a]); - expect(ds.indexOfKey('cookie')).toBe(-1); - expect(ds.indexOfKey('coffee')).toBe(-1); - expect(ds.indexOfKey('bug')).toBe(-1); - expect(ds.indexOfKey('a')).toBe(0); - expect(ds.indexOfKey('b')).toBe(-1); + ds.deleteByKey('b'); + ds.deleteByKey('bug'); + expect(ds.view.output()).toEqual([a]); + expect(ds.getIndexOfKey('cookie')).toBe(-1); + expect(ds.getIndexOfKey('coffee')).toBe(-1); + expect(ds.getIndexOfKey('bug')).toBe(-1); + expect(ds.getIndexOfKey('a')).toBe(0); + expect(ds.getIndexOfKey('b')).toBe(-1); }, 'id', ), @@ -764,7 +772,7 @@ test('remove after shift works correctly', () => { test('respects limit', () => { const grab = (): [length: number, first: number, last: number] => { - const output = ds.getOutput(); + const output = ds.view.output(); return [output.length, output[0], output[output.length - 1]]; }; @@ -772,7 +780,7 @@ test('respects limit', () => { [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18], {limit: 20}, ); - ds.setWindow(0, 100); + ds.view.setWindow(0, 100); ds.append(19); ds.append(20); @@ -783,7 +791,7 @@ test('respects limit', () => { ds.append(22); expect(grab()).toEqual([20, 3, 22]); - ds.remove(0); + ds.delete(0); expect(grab()).toEqual([19, 4, 22]); ds.append(23); @@ -791,3 +799,51 @@ test('respects limit', () => { ds.append(24); expect(grab()).toEqual([19, 6, 24]); }); + +test('DataSource can iterate', () => { + const ds = createDataSource([eatCookie, drinkCoffee], {key: 'id'}); + + expect([...ds]).toEqual([eatCookie, drinkCoffee]); + expect(Array.from(ds.keys())).toEqual(['cookie', 'coffee']); + expect(Array.from(ds.entries())).toEqual([ + ['cookie', eatCookie], + ['coffee', drinkCoffee], + ]); + + const seen: Todo[] = []; + for (const todo of ds) { + seen.push(todo); + } + expect(seen).toEqual([eatCookie, drinkCoffee]); + + ds.append(submitBug); + expect([...ds]).toEqual([eatCookie, drinkCoffee, submitBug]); + + ds.clear(); + expect([...ds]).toEqual([]); + + ds.append(submitBug); + expect([...ds]).toEqual([submitBug]); +}); + +test('DataSource.view can iterate', () => { + const ds = createDataSource([eatCookie, drinkCoffee, submitBug, eatCookie]); + ds.view.setSortBy('id'); + // bug coffee cookie cookie + ds.view.toggleReversed(); + // cookie cookie coffee bug + ds.view.setWindow(1, 3); + // cookie coffee + + expect(ds.view.output()).toEqual([eatCookie, drinkCoffee]); + expect([...ds.view]).toEqual([eatCookie, drinkCoffee]); + + ds.view.reset(); + // default window is empty! + expect([...ds.view]).toEqual([]); + ds.view.setWindow(0, 100); + expect([...ds.view]).toEqual([eatCookie, drinkCoffee, submitBug, eatCookie]); + + ds.clear(); + expect([...ds.view]).toEqual([]); +}); diff --git a/desktop/flipper-plugin/src/state/__tests__/datasource-perf.node.tsx b/desktop/flipper-plugin/src/state/__tests__/datasource-perf.node.tsx index 4e967baa2..e2053b69f 100644 --- a/desktop/flipper-plugin/src/state/__tests__/datasource-perf.node.tsx +++ b/desktop/flipper-plugin/src/state/__tests__/datasource-perf.node.tsx @@ -41,7 +41,7 @@ type DataSourceish = DataSource & FakeDataSource; test.skip('run perf test', () => { if (!global.gc) { console.warn( - 'Warning: garbage collector not available, skipping this test', + 'Warning: garbage collector not available, skipping this test. Make sure to start the test suite using `yarn watch`', ); return; } @@ -73,10 +73,10 @@ test.skip('run perf test', () => { }; Object.entries(datasources).forEach(([name, ds]) => { - ds.setWindow(0, 1000000); + ds.view.setWindow(0, 1000000); if (name.includes('sorted')) { - ds.setFilter(defaultFilter); - ds.setSortBy('title'); + ds.view.setFilter(defaultFilter); + ds.view.setSortBy('title'); } }); @@ -90,7 +90,7 @@ test.skip('run perf test', () => { // to 'render' we need to know the end result (this mimics a lazy evaluation of filter / sort) // note that this skews the test a bit in favor of fake data source, // as DataSource would *always* keep things sorted/ filtered, but doing that would explode the test for append / update :) - ds.buildOutput(); + ds.view.buildOutput(); } // global.gc?.(); // to cleanup our createdmess as part of the measurement const duration = Date.now() - start; @@ -119,7 +119,7 @@ test.skip('run perf test', () => { }); measure('remove', (ds) => { - ds.remove(99); + ds.delete(99); }); measure('shift', (ds) => { @@ -127,11 +127,11 @@ test.skip('run perf test', () => { }); measure('change sorting', (ds) => { - ds.setSortBy('id'); + ds.view.setSortBy('id'); }); measure('change filter', (ds) => { - ds.setFilter((t) => t.title.includes('23')); // 23 does not occur in original text + ds.view.setFilter((t) => t.title.includes('23')); // 23 does not occur in original text }); const sum: any = {}; @@ -159,37 +159,39 @@ class FakeDataSource { constructor(initial: T[]) { this.data = initial; - this.buildOutput(); + this.view.buildOutput(); } - setWindow(_start: number, _end: number) { - // noop - } + view = { + setWindow: (_start: number, _end: number) => { + // noop + }, - setFilter(filter: (t: T) => boolean) { - this.filterFn = filter; - } + setFilter: (filter: (t: T) => boolean) => { + this.filterFn = filter; + }, - setSortBy(k: keyof T) { - this.sortAttr = k; - } + setSortBy: (k: keyof T) => { + this.sortAttr = k; + }, - buildOutput() { - const filtered = this.filterFn - ? this.data.filter(this.filterFn) - : this.data; - const sorted = this.sortAttr - ? filtered - .slice() - .sort((a: any, b: any) => - String.prototype.localeCompare.call( - a[this.sortAttr!], - b[this.sortAttr!], - ), - ) - : filtered; - this.output = sorted; - } + buildOutput: () => { + const filtered = this.filterFn + ? this.data.filter(this.filterFn) + : this.data; + const sorted = this.sortAttr + ? filtered + .slice() + .sort((a: any, b: any) => + String.prototype.localeCompare.call( + a[this.sortAttr!], + b[this.sortAttr!], + ), + ) + : filtered; + this.output = sorted; + }, + }; append(v: T) { this.data = [...this.data, v]; diff --git a/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx b/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx index 90c23f433..eb1ce667a 100644 --- a/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/DataSourceRenderer.tsx @@ -96,7 +96,7 @@ export const DataSourceRenderer: ( const parentRef = React.useRef(null); const virtualizer = useVirtual({ - size: dataSource.output.length, + size: dataSource.view.size, parentRef, useObserver: _testHeight ? () => ({height: _testHeight, width: 1000}) @@ -148,7 +148,7 @@ export const DataSourceRenderer: ( } } - dataSource.setOutputChangeListener((event) => { + dataSource.view.setListener((event) => { switch (event.type) { case 'reset': rerender(UpdatePrio.HIGH, true); @@ -171,7 +171,7 @@ export const DataSourceRenderer: ( return () => { unmounted = true; - dataSource.setOutputChangeListener(undefined); + dataSource.view.setListener(undefined); }; }, [dataSource, setForceUpdate, useFixedRowHeight, _testHeight], @@ -185,15 +185,15 @@ export const DataSourceRenderer: ( useLayoutEffect(function updateWindow() { const start = virtualizer.virtualItems[0]?.index ?? 0; const end = start + virtualizer.virtualItems.length; - if (start !== dataSource.windowStart && !followOutput.current) { + if (start !== dataSource.view.windowStart && !followOutput.current) { onRangeChange?.( start, end, - dataSource.output.length, + dataSource.view.size, parentRef.current?.scrollTop ?? 0, ); } - dataSource.setWindow(start, end); + dataSource.view.setWindow(start, end); }); /** @@ -223,7 +223,7 @@ export const DataSourceRenderer: ( useLayoutEffect(function scrollToEnd() { if (followOutput.current) { virtualizer.scrollToIndex( - dataSource.output.length - 1, + dataSource.view.size - 1, /* smooth is not typed by react-virtual, but passed on to the DOM as it should*/ { align: 'end', @@ -255,7 +255,7 @@ export const DataSourceRenderer: ( onKeyDown={onKeyDown} tabIndex={0}> {virtualizer.virtualItems.map((virtualRow) => { - const entry = dataSource.getEntry(virtualRow.index); + const value = dataSource.view.get(virtualRow.index); // the position properties always change, so they are not part of the TableRow to avoid invalidating the memoized render always. // Also all row containers are renderd as part of same component to have 'less react' framework code in between*/} return ( @@ -270,7 +270,7 @@ export const DataSourceRenderer: ( transform: `translateY(${virtualRow.start}px)`, }} ref={useFixedRowHeight ? undefined : virtualRow.measureRef}> - {itemRenderer(entry.value, virtualRow.index, context)} + {itemRenderer(value, virtualRow.index, context)} ); })} diff --git a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx index dab35e601..8f05ad3b5 100644 --- a/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/DataTable.tsx @@ -197,7 +197,7 @@ export function DataTable( (e: React.KeyboardEvent) => { let handled = true; const shiftPressed = e.shiftKey; - const outputSize = dataSource.output.length; + const outputSize = dataSource.view.size; const windowSize = virtualizerRef.current!.virtualItems.length; switch (e.key) { case 'ArrowUp': @@ -244,7 +244,7 @@ export function DataTable( useEffect( function updateFilter() { - dataSource.setFilter( + dataSource.view.setFilter( computeDataTableFilter(state.searchValue, state.columns), ); }, @@ -257,11 +257,11 @@ export function DataTable( useEffect( function updateSorting() { if (state.sorting === undefined) { - dataSource.setSortBy(undefined); - dataSource.setReversed(false); + dataSource.view.setSortBy(undefined); + dataSource.view.setReversed(false); } else { - dataSource.setSortBy(state.sorting.key); - dataSource.setReversed(state.sorting.direction === 'desc'); + dataSource.view.setSortBy(state.sorting.key); + dataSource.view.setReversed(state.sorting.direction === 'desc'); } }, [dataSource, state.sorting], @@ -342,7 +342,7 @@ export function DataTable( savePreferences(stateRef.current, lastOffset.current); // if the component unmounts, we reset the SFRW pipeline to // avoid wasting resources in the background - dataSource.reset(); + dataSource.view.reset(); // clean ref if (props.tableManagerRef) { (props.tableManagerRef as MutableRefObject).current = undefined; diff --git a/desktop/flipper-plugin/src/ui/datatable/DataTableManager.tsx b/desktop/flipper-plugin/src/ui/datatable/DataTableManager.tsx index af400f384..f0550f8fe 100644 --- a/desktop/flipper-plugin/src/ui/datatable/DataTableManager.tsx +++ b/desktop/flipper-plugin/src/ui/datatable/DataTableManager.tsx @@ -342,7 +342,7 @@ export function getSelectedItem( ): T | undefined { return selection.current < 0 ? undefined - : dataSource.getItem(selection.current); + : dataSource.view.get(selection.current); } export function getSelectedItems( @@ -351,7 +351,7 @@ export function getSelectedItems( ): T[] { return [...selection.items] .sort() - .map((i) => dataSource.getItem(i)) + .map((i) => dataSource.view.get(i)) .filter(Boolean) as any[]; }