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"] }