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 new file mode 100644 index 0000000..eefd54f --- /dev/null +++ b/src/ObservableGroupMap.ts @@ -0,0 +1,207 @@ +import { + observable, + IReactionDisposer, + reaction, + observe, + IObservableArray, + transaction, + ObservableMap, + Lambda, +} from "mobx" + +interface GrouperItemInfo { + groupByValue: any + reaction: IReactionDisposer + groupArrIndex: 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. + * + * 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 }, + * { 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> { + /** + * 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 + + /** + * 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 = "ogm" + ((Math.random() * 1000) | 0), + keyToName = (x) => "" + x, + }: { name?: string; keyToName?: (group: G) => string } = {} + ) { + super() + this._keyToName = keyToName + this._groupBy = groupBy + this._ogmInfoKey = + "function" == typeof Symbol + ? ((Symbol("ogmInfo" + name) as unknown) as string) + : "__ogmInfo" + name + this._base = base + + for (let i = 0; i < base.length; i++) { + this._addItem(base[i]) + } + + this._disposeBaseObserver = 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") + } + + public delete(_key: G): boolean { + throw new Error("not supported") + } + + public set(_key: G, _value: IObservableArray): this { + throw new Error("not supported") + } + + /** + * Disposes all observers created during construction and removes state added to base array + * items. + */ + public dispose() { + this._disposeBaseObserver() + 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] + } + } + + 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._ogmInfoKey].grouperArrIndex = itemIndex + arr.length-- + } + } + + private _addItem(item: any) { + const groupByValue = this._groupBy(item) + const groupArr = this._getGroupArr(groupByValue) + const value: GrouperItemInfo = { + groupByValue: groupByValue, + groupArrIndex: groupArr.length, + reaction: reaction( + () => this._groupBy(item), + (newGroupByValue, _r) => { + console.log("new group by value ", newGroupByValue) + const grouperItemInfo = (item as any)[this._ogmInfoKey] + this._removeFromGroupArr( + grouperItemInfo.groupByValue, + grouperItemInfo.grouperArrIndex + ) + + const newGroupArr = this._getGroupArr(newGroupByValue) + const newGrouperArrIndex = newGroupArr.length + newGroupArr.push(item) + grouperItemInfo.groupByValue = newGroupByValue + grouperItemInfo.grouperArrIndex = newGrouperArrIndex + } + ), + } + Object.defineProperty(item, this._ogmInfoKey, { + configurable: true, + enumerable: false, + value, + }) + groupArr.push(item) + } + + private _removeItem(item: any) { + const grouperItemInfo: GrouperItemInfo = (item as any)[this._ogmInfoKey] + this._removeFromGroupArr(grouperItemInfo.groupByValue, grouperItemInfo.groupArrIndex) + grouperItemInfo.reaction() + + delete (item as any)[this._ogmInfoKey] + } +} 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..7872c29 --- /dev/null +++ b/test/ObservableGroupMap.test.ts @@ -0,0 +1,69 @@ +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("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), { + 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"] }