Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ObservableGroupMap #245

Merged
merged 5 commits into from
May 15, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,17 @@ CDN: <https://unpkg.com/mobx-utils/mobx-utils.umd.js>
- [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

Expand Down Expand Up @@ -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,
Expand Down
207 changes: 207 additions & 0 deletions src/ObservableGroupMap.ts
Original file line number Diff line number Diff line change
@@ -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<G, T> extends ObservableMap<G, IObservableArray<T>> {
/**
* Base observable array which is being sorted into groups.
*/
private readonly _base: IObservableArray<T>

/**
* 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<T>,
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<T>): 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]
}
}
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"
69 changes: 69 additions & 0 deletions test/ObservableGroupMap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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("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()
})
})
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"]
}