From dfda71c3502216f149377f313793d501b43a26a0 Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Tue, 16 Mar 2021 14:54:53 -0700 Subject: [PATCH] Implemented sorting Summary: For context see https://fb.workplace.com/notes/470523670998369 This diff adds the capability to apply a sorting, and inserts item in a sorted way using binary search in a temporarily intermediate collection. (That collection is optimized away in later diffs, so it is mostly the idea and the tests that are interesting) Reviewed By: nikoant Differential Revision: D25953336 fbshipit-source-id: a51b05e25242f0835280ada99798676311511ef0 --- desktop/flipper-plugin/package.json | 1 + .../src/state/datasource/DataSource.tsx | 111 +++++++++++++++++- .../__tests__/datasource-basics.node.tsx | 71 +++++++++++ 3 files changed, 179 insertions(+), 4 deletions(-) diff --git a/desktop/flipper-plugin/package.json b/desktop/flipper-plugin/package.json index 07bdbbd8d..aafe031b8 100644 --- a/desktop/flipper-plugin/package.json +++ b/desktop/flipper-plugin/package.json @@ -12,6 +12,7 @@ "@emotion/css": "^11.0.0", "@emotion/react": "^11.1.1", "immer": "^8.0.1", + "lodash": "^4.17.20", "react-element-to-jsx-string": "^14.3.2" }, "devDependencies": { diff --git a/desktop/flipper-plugin/src/state/datasource/DataSource.tsx b/desktop/flipper-plugin/src/state/datasource/DataSource.tsx index b91cfae94..18f9a2197 100644 --- a/desktop/flipper-plugin/src/state/datasource/DataSource.tsx +++ b/desktop/flipper-plugin/src/state/datasource/DataSource.tsx @@ -7,8 +7,11 @@ * @format */ +import {sortedIndexBy, sortedLastIndexBy, property} from 'lodash'; + // TODO: support better minification // TODO: separate views from datasource to be able to support multiple transformation simultanously +// TODO: expose interface with public members only type ExtractKeyType< T extends object, @@ -22,6 +25,7 @@ type AppendEvent = { type UpdateEvent = { type: 'update'; value: T; + oldValue: T; index: number; }; @@ -37,7 +41,9 @@ class DataSource< private keyAttribute: undefined | keyof T; private idToIndex: Map = new Map(); dataUpdateQueue: DataEvent[] = []; - // viewUpdateQueue; + + private sortBy: undefined | ((a: T) => number | string); + private _sortedRecords: T[] | undefined; viewRecords: T[] = []; nextViewRecords: T[] = []; // for double buffering @@ -64,8 +70,17 @@ class DataSource< return this._recordsById; } + /** + * Exposed for testing only. + * Returns the set of records after applying sorting + */ + get sortedRecords(): readonly T[] { + return this.sortBy ? this._sortedRecords! : this._records; + } + constructor(keyAttribute: KEY | undefined) { this.keyAttribute = keyAttribute; + this.setSortBy(undefined); } private assertKeySet() { @@ -127,7 +142,15 @@ 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) { + const oldValue = this._records[index]; + if (value === oldValue) { + return; + } if (this.keyAttribute) { const key = this.getKey(value); const currentKey = this.getKey(this._records[index]); @@ -142,6 +165,7 @@ class DataSource< this.emitDataEvent({ type: 'update', value, + oldValue, index, }); } @@ -156,6 +180,25 @@ class DataSource< throw new Error('Not Implemented'); } + setSortBy(sortBy: undefined | keyof T | ((a: T) => number | string)) { + if (this.sortBy === sortBy) { + return; + } + if (typeof sortBy === 'string') { + sortBy = property(sortBy); // TODO: it'd be great to recycle those if sortBy didn't change! + } + this.sortBy = sortBy as any; + if (sortBy === undefined) { + this._sortedRecords = undefined; + } else { + this._sortedRecords = []; + // TODO: using .sort will be faster? + this._records.forEach((value) => { + this.insertSorted(value); + }); + } + } + emitDataEvent(event: DataEvent) { this.dataUpdateQueue.push(event); // TODO: schedule @@ -164,9 +207,69 @@ class DataSource< processEvents() { const events = this.dataUpdateQueue.splice(0); - events.forEach((_event) => { - // TODO: - }); + events.forEach(this.processEvent); + } + + processEvent = (event: DataEvent) => { + const {value} = event; + switch (event.type) { + case 'append': + // sort + if (this.sortBy) { + this.insertSorted(value); + } + // reverse + + // filter + + // notify + break; + case 'update': + // sort + if (this.sortBy) { + // find old entry + const oldIndex = this.getSortedIndex(event.oldValue); + if ( + this.sortBy(this._sortedRecords![oldIndex]) === this.sortBy(value) + ) { + // sort value is the same? just swap the item + this._sortedRecords![oldIndex] = value; + } else { + // sort value is different? remove and add + this._sortedRecords!.splice(oldIndex, 1); + this.insertSorted(value); + } + // reverse + + // filter + + // notify + } + break; + default: + throw new Error('unknown event type'); + } + }; + + private getSortedIndex(value: T) { + let index = sortedIndexBy(this._sortedRecords, value, this.sortBy); + // the item we are looking for is not necessarily the first one at the insertion index + while (this._sortedRecords![index] !== value) { + index++; + if (index >= this._sortedRecords!.length) { + throw new Error('illegal state: sortedIndex not found'); // sanity check to avoid browser freeze if people mess up with internals + } + } + return index; + } + + private insertSorted(value: T) { + const insertionIndex = sortedLastIndexBy( + this._sortedRecords, + value, + this.sortBy!, + ); + this._sortedRecords!.splice(insertionIndex, 0, value); } } diff --git a/desktop/flipper-plugin/src/state/datasource/__tests__/datasource-basics.node.tsx b/desktop/flipper-plugin/src/state/datasource/__tests__/datasource-basics.node.tsx index f0d75bdea..60d338fbf 100644 --- a/desktop/flipper-plugin/src/state/datasource/__tests__/datasource-basics.node.tsx +++ b/desktop/flipper-plugin/src/state/datasource/__tests__/datasource-basics.node.tsx @@ -94,3 +94,74 @@ test('throws on invalid keys', () => { ds.append({id: 'cookie', title: 'test'}); }).toThrow(`Duplicate key: 'cookie'`); }); + +test('sorting works', () => { + const ds = createDataSource([eatCookie, drinkCoffee]); + ds.setSortBy((todo) => todo.title); + expect(ds.sortedRecords).toEqual([drinkCoffee, eatCookie]); + + ds.setSortBy(undefined); + ds.setSortBy(undefined); + expect(ds.sortedRecords).toEqual([eatCookie, drinkCoffee]); + ds.setSortBy((todo) => todo.title); + expect(ds.sortedRecords).toEqual([drinkCoffee, eatCookie]); + + const aleph = { + id: 'd', + title: 'aleph', + }; + ds.append(aleph); + expect(ds.records).toEqual([eatCookie, drinkCoffee, aleph]); + expect(ds.sortedRecords).toEqual([aleph, drinkCoffee, eatCookie]); +}); + +test('sorting preserves insertion order with equal keys', () => { + type N = { + $: string; + name: string; + }; + + const a = {$: 'a', name: 'a'}; + const b1 = {$: 'b', name: 'b1'}; + const b2 = {$: 'b', name: 'b2'}; + const b3 = {$: 'b', name: 'b3'}; + const c = {$: 'c', name: 'c'}; + + const ds = createDataSource([]); + ds.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(ds.sortedRecords).toEqual([a, b1, b2, b3, c]); + + // if we append a new item with existig item, it should end up in the end + const b4 = { + $: 'b', + name: 'b4', + }; + ds.append(b4); + expect(ds.records).toEqual([b1, c, b2, a, b3, b4]); + expect(ds.sortedRecords).toEqual([a, b1, b2, b3, b4, c]); + + // if we replace the middle item, it should end up in the middle + const b2r = { + $: 'b', + name: 'b2replacement', + }; + ds.update(2, b2r); + expect(ds.records).toEqual([b1, c, b2r, a, b3, b4]); + expect(ds.sortedRecords).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 = { + $: 'aa', + name: 'b3replacement', + }; + ds.update(4, b3r); + expect(ds.records).toEqual([b1, c, b2r, a, b3r, b4]); + expect(ds.sortedRecords).toEqual([a, b3r, b1, b2r, b4, c]); +});