From 5ff0b3f1a757d9e806ba83c86ef654a3b86eb2a3 Mon Sep 17 00:00:00 2001 From: Adrian Leonhard Date: Sat, 4 Apr 2020 22:24:17 +0200 Subject: [PATCH 1/5] 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"] } From cc69152427f52526127f6131e20c00b6a0601962 Mon Sep 17 00:00:00 2001 From: Adrian Leonhard Date: Fri, 10 Apr 2020 15:05:48 +0200 Subject: [PATCH 2/5] Add OGM documentation --- README.md | 34 ++++++++++++++++++++++++++++++++-- src/ObservableGroupMap.ts | 21 +++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4dca54c..d943002 100644 --- a/README.md +++ b/README.md @@ -79,14 +79,17 @@ CDN: - [deepObserve](#deepobserve) - [Parameters](#parameters-17) - [Examples](#examples-16) +- [ObservableGroupMap](#observablegroupmap) + - [Examples](#examples-17) +- [ObservableMap](#observablemap) - [computedFn](#computedfn) - [Parameters](#parameters-18) - - [Examples](#examples-17) + - [Examples](#examples-18) - [DeepMapEntry](#deepmapentry) - [DeepMap](#deepmap) - [actionAsync](#actionasync) - [Parameters](#parameters-19) - - [Examples](#examples-18) + - [Examples](#examples-19) ## fromPromise @@ -739,6 +742,33 @@ const disposer = deepObserve(target, (change, path) => { }) ``` +## ObservableGroupMap + +Reactively sorts a base observable array into multiple observable arrays based on the value of a +`groupBy: (item: T) => G` function. + +This observes the individual computed groupBy values and only updates the source and dest arrays +when there is an actual change, so this is far more efficient than, for example +`base.filter(i => groupBy(i) === 'we')`. + +No guarantees are made about the order of items in the grouped arrays. + +### Examples + +```javascript +const slices = observable([ + { day: "mo", hours: 12 }, + { day: "tu", hours: 2 }, +]) +const slicesByDay = new ObservableGroupMap(slices, (slice) => slice.day) +autorun(() => console.log( + slicesByDay.get("mo")?.length ?? 0, + slicesByDay.get("we"))) // outputs 1, undefined +slices[0].day = "we" // outputs 0, [{ day: "we", hours: 12 }] +``` + +## ObservableMap + ## computedFn computedFn takes a function with an arbitrarily amount of arguments, diff --git a/src/ObservableGroupMap.ts b/src/ObservableGroupMap.ts index 93ba2e5..8161b6b 100644 --- a/src/ObservableGroupMap.ts +++ b/src/ObservableGroupMap.ts @@ -14,6 +14,27 @@ interface GrouperItemInfo { grouperArrIndex: number } +/** + * Reactively sorts a base observable array into multiple observable arrays based on the value of a + * `groupBy: (item: T) => G` function. + * + * This observes the individual computed groupBy values and only updates the source and dest arrays + * when there is an actual change, so this is far more efficient than, for example + * `base.filter(i => groupBy(i) === 'we')`. + * + * No guarantees are made about the order of items in the grouped arrays. + * + * @example + * const slices = observable([ + * { day: "mo", hours: 12 }, + * { day: "tu", hours: 2 }, + * ]) + * const slicesByDay = new ObservableGroupMap(slices, (slice) => slice.day) + * autorun(() => console.log( + * slicesByDay.get("mo")?.length ?? 0, + * slicesByDay.get("we"))) // outputs 1, undefined + * slices[0].day = "we" // outputs 0, [{ day: "we", hours: 12 }] + */ export class ObservableGroupMap extends ObservableMap> { private readonly keyToName: (group: G) => string From 7086cd685daacb963c3cb50c6f1a95f4d463da5f Mon Sep 17 00:00:00 2001 From: Adrian Leonhard Date: Tue, 5 May 2020 23:48:44 +0200 Subject: [PATCH 3/5] ObservableGroupMap: add two more tests, minor restructure --- src/ObservableGroupMap.ts | 156 ++++++++++++++------------------ test/ObservableGroupMap.test.ts | 22 +++++ 2 files changed, 90 insertions(+), 88 deletions(-) diff --git a/src/ObservableGroupMap.ts b/src/ObservableGroupMap.ts index 8161b6b..481fc63 100644 --- a/src/ObservableGroupMap.ts +++ b/src/ObservableGroupMap.ts @@ -36,30 +36,83 @@ interface GrouperItemInfo { * slices[0].day = "we" // outputs 0, [{ day: "we", hours: 12 }] */ export class ObservableGroupMap extends ObservableMap> { - private readonly keyToName: (group: G) => string + private readonly _keyToName: (group: G) => string - private readonly groupBy: (x: T) => G + private readonly _groupBy: (x: T) => G - private readonly grouperInfoKey: string + private readonly _ogmInfoKey: string - private readonly base: IObservableArray + private readonly _base: IObservableArray - clear(): void { + 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._ogmInfoKey = + "function" == typeof Symbol + ? ((Symbol("grouperInfo" + name) as unknown) as string) + : "__grouperInfo" + name + this._base = base + + for (let i = 0; i < base.length; i++) { + this._addItem(base[i]) + } + + 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") + } + }) + } + + public clear(): void { throw new Error("not supported") } - delete(_key: G): boolean { + public delete(_key: G): boolean { throw new Error("not supported") } - set(_key: G, _value: IObservableArray): this { + public set(_key: G, _value: IObservableArray): this { throw new Error("not supported") } + public dispose() { + for (let i = 0; i < this._base.length; i++) { + const item = this._base[i] + const grouperItemInfo: GrouperItemInfo = (item as any)[this._ogmInfoKey] + grouperItemInfo.reaction() + + delete (item as any)[this._ogmInfoKey] + this._addItem(item) + } + } + private _getGroupArr(key: G) { let result = super.get(key) if (undefined === result) { - result = observable([], { name: `GroupArray[${this.keyToName(key)}]` }) + result = observable([], { name: `GroupArray[${this._keyToName(key)}]` }) super.set(key, result) } return result @@ -74,38 +127,22 @@ export class ObservableGroupMap extends ObservableMap this.groupBy(item), + () => this._groupBy(item), (newGroupByValue, _r) => { console.log("new group by value ", newGroupByValue) - const grouperItemInfo = (item as any)[this.grouperInfoKey] + const grouperItemInfo = (item as any)[this._ogmInfoKey] this._removeFromGroupArr( grouperItemInfo.groupByValue, grouperItemInfo.grouperArrIndex @@ -116,79 +153,22 @@ export class ObservableGroupMap extends ObservableMap, - 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) - } + delete (item as any)[this._ogmInfoKey] } } diff --git a/test/ObservableGroupMap.test.ts b/test/ObservableGroupMap.test.ts index 2ce1475..7872c29 100644 --- a/test/ObservableGroupMap.test.ts +++ b/test/ObservableGroupMap.test.ts @@ -36,6 +36,28 @@ describe("ObservableGroupMap", () => { done() }) + it("updates groups correctly when an item is added to the base (new group)", (done) => { + base.push({ day: "we", hours: 3 }) + assert.deepEqual(json(ogm), { + mo: [{ day: "mo", hours: 12 }], + tu: [{ day: "tu", hours: 2 }], + we: [{ day: "we", hours: 3 }], + }) + done() + }) + + it("updates groups correctly when an item is added to the base (existing group)", (done) => { + base.push({ day: "tu", hours: 3 }) + assert.deepEqual(json(ogm), { + mo: [{ day: "mo", hours: 12 }], + tu: [ + { day: "tu", hours: 2 }, + { day: "tu", hours: 3 }, + ], + }) + 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), { From 0becd8b413c8d1bbacb8b5c22af38f48769c4790 Mon Sep 17 00:00:00 2001 From: Adrian Leonhard Date: Wed, 6 May 2020 00:08:02 +0200 Subject: [PATCH 4/5] improve docs --- src/ObservableGroupMap.ts | 47 +++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/src/ObservableGroupMap.ts b/src/ObservableGroupMap.ts index 481fc63..22c9b63 100644 --- a/src/ObservableGroupMap.ts +++ b/src/ObservableGroupMap.ts @@ -6,6 +6,7 @@ import { IObservableArray, transaction, ObservableMap, + Lambda, } from "mobx" interface GrouperItemInfo { @@ -24,6 +25,9 @@ interface GrouperItemInfo { * * No guarantees are made about the order of items in the grouped arrays. * + * The resulting map of arrays is read-only. clear(), set(), delete() are not supported and + * modifying the group arrays will lead to undefined behavior. + * * @example * const slices = observable([ * { day: "mo", hours: 12 }, @@ -36,19 +40,44 @@ interface GrouperItemInfo { * slices[0].day = "we" // outputs 0, [{ day: "we", hours: 12 }] */ export class ObservableGroupMap extends ObservableMap> { - private readonly _keyToName: (group: G) => string - - private readonly _groupBy: (x: T) => G + /** + * Base observable array which is being sorted into groups. + */ + private readonly _base: IObservableArray + /** + * The ObservableGroupMap needs to track some state per-item. This is the name/symbol of the + * property used to attach the state. + */ private readonly _ogmInfoKey: string - private readonly _base: IObservableArray + /** + * The function used to group the items. + */ + private readonly _groupBy: (x: T) => G + + /** + * This function is used to generate the mobx debug names of the observable group arrays. + */ + private readonly _keyToName: (group: G) => string + private readonly _disposeBaseObserver: Lambda + + /** + * Create a new ObservableGroupMap. This immediately observes all members of the array. Call + * #dispose() to stop tracking. + * + * @param base The array to sort into groups. + * @param groupBy The function used for grouping. + * @param options Object with properties: + * `name`: Debug name of this ObservableGroupMap. + * `keyToName`: Function to create the debug names of the observable group arrays. + */ constructor( base: IObservableArray, groupBy: (x: T) => G, { - name, + name = "ogm" + ((Math.random() * 1000) | 0), keyToName = (x) => "" + x, }: { name?: string; keyToName?: (group: G) => string } = {} ) { @@ -65,7 +94,7 @@ export class ObservableGroupMap extends ObservableMap { + this._disposeBaseObserver = observe(base, (change) => { if ("splice" === change.type) { transaction(() => { for (const removed of change.removed) { @@ -98,14 +127,18 @@ export class ObservableGroupMap extends ObservableMap Date: Fri, 15 May 2020 18:13:26 +0200 Subject: [PATCH 5/5] grouper -> ogm/group --- src/ObservableGroupMap.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ObservableGroupMap.ts b/src/ObservableGroupMap.ts index 22c9b63..eefd54f 100644 --- a/src/ObservableGroupMap.ts +++ b/src/ObservableGroupMap.ts @@ -12,7 +12,7 @@ import { interface GrouperItemInfo { groupByValue: any reaction: IReactionDisposer - grouperArrIndex: number + groupArrIndex: number } /** @@ -86,8 +86,8 @@ export class ObservableGroupMap extends ObservableMap extends ObservableMap this._groupBy(item), (newGroupByValue, _r) => { @@ -199,7 +199,7 @@ export class ObservableGroupMap extends ObservableMap