Skip to content

Commit

Permalink
Add ObservableGroupMap
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
NaridaL authored and Adrian Leonhard committed May 4, 2020
1 parent 4b59640 commit 5ff0b3f
Show file tree
Hide file tree
Showing 4 changed files with 225 additions and 9 deletions.
173 changes: 173 additions & 0 deletions src/ObservableGroupMap.ts
Original file line number Diff line number Diff line change
@@ -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<G, T> extends ObservableMap<G, IObservableArray<T>> {
private readonly keyToName: (group: G) => string

private readonly groupBy: (x: T) => G

private readonly grouperInfoKey: string

private readonly base: IObservableArray<T>

clear(): void {
throw new Error("not supported")
}

delete(_key: G): boolean {
throw new Error("not supported")
}

set(_key: G, _value: IObservableArray<T>): 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<T>,
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)
}
}
}
1 change: 1 addition & 0 deletions src/mobx-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
47 changes: 47 additions & 0 deletions test/ObservableGroupMap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { observable, IObservableArray } from "mobx"
import * as assert from "assert"

import { ObservableGroupMap } from "../src/mobx-utils"

const json = <G>(ogm: ObservableGroupMap<string, G>): { [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<Slice>
let ogm: ObservableGroupMap<string, Slice>

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()
})
})
13 changes: 4 additions & 9 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}

0 comments on commit 5ff0b3f

Please sign in to comment.