-
-
Notifications
You must be signed in to change notification settings - Fork 126
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
4 changed files
with
225 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters