Skip to content

Commit

Permalink
Merge pull request #245 from NaridaL/master
Browse files Browse the repository at this point in the history
Add ObservableGroupMap
  • Loading branch information
NaridaL authored May 15, 2020
2 parents 643c44a + c13110f commit dbd3975
Show file tree
Hide file tree
Showing 5 changed files with 313 additions and 11 deletions.
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"]
}

0 comments on commit dbd3975

Please sign in to comment.