From 9c9f2a882667629736eecf70320ecba889f61825 Mon Sep 17 00:00:00 2001 From: Adrian Leonhard Date: Sat, 4 Apr 2020 22:24:17 +0200 Subject: [PATCH] Add ObservableGroupMap An ObservableGroupMap is constructed from a base IObservableArray and a `groupBy` function. It maps `groupBy` values to IObservableArrays of the items in the base array which have groupBy(item) == key The IObservableArrays are updated reactively when items in the base array are removed, changed or added. Updates are done without iterating through the base array (except once at the beginning) --- src/ObservableGroupMap.ts | 173 ++++++++++++++++++++++++++++++++ src/mobx-utils.ts | 1 + test/ObservableGroupMap.test.ts | 47 +++++++++ tsconfig.json | 13 +-- 4 files changed, 225 insertions(+), 9 deletions(-) create mode 100644 src/ObservableGroupMap.ts create mode 100644 test/ObservableGroupMap.test.ts diff --git a/src/ObservableGroupMap.ts b/src/ObservableGroupMap.ts new file mode 100644 index 0000000..93ba2e5 --- /dev/null +++ b/src/ObservableGroupMap.ts @@ -0,0 +1,173 @@ +import { + observable, + IReactionDisposer, + reaction, + observe, + IObservableArray, + transaction, + ObservableMap, +} from "mobx" + +interface GrouperItemInfo { + groupByValue: any + reaction: IReactionDisposer + grouperArrIndex: number +} + +export class ObservableGroupMap extends ObservableMap> { + private readonly keyToName: (group: G) => string + + private readonly groupBy: (x: T) => G + + private readonly grouperInfoKey: string + + private readonly base: IObservableArray + + clear(): void { + throw new Error("not supported") + } + + delete(_key: G): boolean { + throw new Error("not supported") + } + + set(_key: G, _value: IObservableArray): this { + throw new Error("not supported") + } + + private _getGroupArr(key: G) { + let result = super.get(key) + if (undefined === result) { + result = observable([], { name: `GroupArray[${this.keyToName(key)}]` }) + super.set(key, result) + } + return result + } + + private _removeFromGroupArr(key: G, itemIndex: number) { + const arr = this.get(key)! + if (1 === arr.length) { + super.delete(key) + } else if (itemIndex === arr.length - 1) { + // last position in array + arr.length-- + } else { + arr[itemIndex] = arr[arr.length - 1] + ;(arr[itemIndex] as any)[this.grouperInfoKey].grouperArrIndex = itemIndex + arr.length-- + } + } + + private checkState() { + for (const key of Array.from(this.keys())) { + const arr = this.get(key as any)! + for (let i = 0; i < arr!.length; i++) { + const item = arr[i] + const info: GrouperItemInfo = (item as any)[this.grouperInfoKey] + if (info.grouperArrIndex != i) { + throw new Error(info.grouperArrIndex + " " + i) + } + if (info.groupByValue != key) { + throw new Error(info.groupByValue + " " + key) + } + } + } + } + + private _addItem(item: any) { + const groupByValue = this.groupBy(item) + const groupArr = this._getGroupArr(groupByValue) + const value: GrouperItemInfo = { + groupByValue: groupByValue, + grouperArrIndex: groupArr.length, + reaction: reaction( + () => this.groupBy(item), + (newGroupByValue, _r) => { + console.log("new group by value ", newGroupByValue) + const grouperItemInfo = (item as any)[this.grouperInfoKey] + this._removeFromGroupArr( + grouperItemInfo.groupByValue, + grouperItemInfo.grouperArrIndex + ) + + const newGroupArr = this._getGroupArr(newGroupByValue) + const newGrouperArrIndex = newGroupArr.length + newGroupArr.push(item) + grouperItemInfo.groupByValue = newGroupByValue + grouperItemInfo.grouperArrIndex = newGrouperArrIndex + this.checkState() + } + ), + } + Object.defineProperty(item, this.grouperInfoKey, { + configurable: true, + enumerable: false, + value, + }) + groupArr.push(item) + this.checkState() + } + + private _removeItem(item: any) { + this.checkState() + const grouperItemInfo: GrouperItemInfo = (item as any)[this.grouperInfoKey] + this._removeFromGroupArr(grouperItemInfo.groupByValue, grouperItemInfo.grouperArrIndex) + grouperItemInfo.reaction() + + delete (item as any)[this.grouperInfoKey] + this.checkState() + } + + constructor( + base: IObservableArray, + groupBy: (x: T) => G, + { + name, + keyToName = (x) => "" + x, + }: { name?: string; keyToName?: (group: G) => string } = {} + ) { + super() + this.keyToName = keyToName + this.groupBy = groupBy + this.grouperInfoKey = + "function" == typeof Symbol + ? ((Symbol("grouperInfo" + name) as unknown) as string) + : "__grouperInfo" + name + this.base = base + + for (let i = 0; i < base.length; i++) { + const item = base[i] + this._addItem(item) + } + + observe(base, (change) => { + if ("splice" === change.type) { + transaction(() => { + for (const removed of change.removed) { + this._removeItem(removed) + } + for (const added of change.added) { + this._addItem(added) + } + }) + } else if ("update" === change.type) { + transaction(() => { + this._removeItem(change.oldValue) + this._addItem(change.newValue) + }) + } else { + throw new Error("illegal state") + } + }) + } + dispose() { + for (let i = 0; i < this.base.length; i++) { + const item = this.base[i] + const grouperItemInfo: GrouperItemInfo = (item as any)[this.grouperInfoKey] + grouperItemInfo.reaction() + + delete (item as any)[this.grouperInfoKey] + this._addItem(item) + } + } +} diff --git a/src/mobx-utils.ts b/src/mobx-utils.ts index c4c66ec..1a40e3c 100644 --- a/src/mobx-utils.ts +++ b/src/mobx-utils.ts @@ -15,5 +15,6 @@ export * from "./when-async" export * from "./expr" export * from "./create-transformer" export * from "./deepObserve" +export { ObservableGroupMap } from "./ObservableGroupMap" export { computedFn } from "./computedFn" export { actionAsync, task } from "./action-async" diff --git a/test/ObservableGroupMap.test.ts b/test/ObservableGroupMap.test.ts new file mode 100644 index 0000000..2ce1475 --- /dev/null +++ b/test/ObservableGroupMap.test.ts @@ -0,0 +1,47 @@ +import { observable, IObservableArray } from "mobx" +import * as assert from "assert" + +import { ObservableGroupMap } from "../src/mobx-utils" + +const json = (ogm: ObservableGroupMap): { [k: string]: G } => + Array.from(ogm.keys()).reduce((r, k) => ((r[k] = ogm.get(k)?.toJS()), r), {} as any) + +describe("ObservableGroupMap", () => { + type Slice = { day: string; hours: number } + let base: IObservableArray + let ogm: ObservableGroupMap + + beforeEach((done) => { + base = observable([ + { day: "mo", hours: 12 }, + { day: "tu", hours: 2 }, + ]) + ogm = new ObservableGroupMap(base, (x) => x.day) + done() + }) + + it("initializes correctly", (done) => { + assert.deepEqual(json(ogm), { + mo: [{ day: "mo", hours: 12 }], + tu: [{ day: "tu", hours: 2 }], + }) + done() + }) + + it("updates groups correctly when an item is removed from the base", (done) => { + base[0] = base.pop()! + assert.deepEqual(json(ogm), { + tu: [{ day: "tu", hours: 2 }], + }) + done() + }) + + it("moves item from group array to new one when groupBy value changes to a new one", (done) => { + base[1].day = "we" + assert.deepEqual(json(ogm), { + mo: [{ day: "mo", hours: 12 }], + we: [{ day: "we", hours: 2 }], + }) + done() + }) +}) diff --git a/tsconfig.json b/tsconfig.json index c6719ca..9c5f8da 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,16 +5,11 @@ "module": "commonjs", "moduleResolution": "node", "experimentalDecorators": true, + "downlevelIteration": true, "noEmit": true, "rootDir": ".", - "lib": [ - "dom", "es2015", "scripthost" - ] + "lib": ["dom", "es2015", "scripthost"] }, - "include": [ - "**/*.ts" - ], - "exclude": [ - "/node_modules" - ] + "include": ["**/*.ts"], + "exclude": ["/node_modules"] }