UNPKG

@platform/state

Version:

A small, simple, strongly typed, [rx/observable] state-machine.

263 lines (262 loc) 10.1 kB
import { id as idUtil } from '@platform/util.value'; import { Subject } from 'rxjs'; import { filter, share, take, takeUntil } from 'rxjs/operators'; import { is } from '../common'; import { StateObject } from '../StateObject'; import { TreeIdentity } from '../TreeIdentity'; import { TreeQuery } from '../TreeQuery'; import { helpers } from './helpers'; import * as events from './TreeState.events'; import * as path from './TreeState.path'; import * as sync from './TreeState.sync'; const Identity = TreeIdentity; export class TreeState { constructor(args) { this._children = []; this._kind = 'TreeState'; this._dispose$ = new Subject(); this.dispose$ = this._dispose$.pipe(share()); this._event$ = new Subject(); this.change = (fn, options) => this._change(fn, options); this.add = (args) => { if (TreeState.isInstance(args)) { args = { parent: this.id, root: args }; } const self = this; const child = this.getOrCreateInstance(args); if (this.childExists(child)) { const err = `Cannot add child '${child.id}' as it already exists within the parent '${this.root.id}'.`; throw new Error(err); } this._children.push(child); this.change((draft, ctx) => { const root = args.parent && args.parent !== draft.id ? ctx.findById(args.parent) : draft; if (!root) { const err = `Cannot add child-state because the parent sub-node '${args.parent}' within '${draft.id}' does not exist.`; throw new Error(err); } TreeState.children(root).push(child.root); }); this.listen(child); child.dispose$ .pipe(take(1)) .pipe(filter(() => this.childExists(child))) .subscribe(() => this.remove(child)); this.fire({ type: 'TreeState/child/added', payload: { parent: self, child } }); return child; }; this.remove = (input) => { const child = this.child(input); if (!child) { const err = `Cannot remove child-state as it does not exist in the parent '${this.root.id}'.`; throw new Error(err); } this._children = this._children.filter((item) => item.root.id !== child.root.id); const self = this; this.fire({ type: 'TreeState/child/removed', payload: { parent: self, child } }); return child; }; this.clear = () => { this.children.forEach((child) => this.remove(child)); return this; }; this.contains = (match) => { return Boolean(this.find(match)); }; this.find = (input) => { if (!input) { return undefined; } const match = typeof input === 'function' ? input : (e) => { const id = TreeIdentity.toNodeId(input); return e.id === id ? true : Boolean(e.tree.query.findById(id)); }; let result; this.walkDown((e) => { if (e.level > 0) { if (match(e) === true) { e.stop(); result = e.tree; } } }); return result; }; this.walkDown = (visit) => { const inner = (level, index, tree, parent, state) => { if (state.stopped) { return; } let skipped = false; const args = { level, id: tree.id, key: Identity.key(tree.id), namespace: tree.namespace, index, tree, parent, stop: () => (state.stopped = true), skip: () => (skipped = true), toString: () => tree.id, }; visit(args); if (state.stopped) { return; } if (!skipped && tree.children.length) { tree.children.forEach((child, i) => { inner(level + 1, i, child, tree, state); }); } }; return inner(0, -1, this, undefined, {}); }; this.syncFrom = (args) => { const { until$ } = args; const isObservable = is.observable(args.source.event$); const source$ = isObservable ? args.source.event$ : args.source.event.$; const parent = isObservable ? args.source.parent : args.source.parent; const initial = isObservable ? undefined : args.source.root; return sync.syncFrom({ target: this, parent, initial, source$, until$ }); }; this.fire = (e) => this._event$.next(e); const root = (typeof args.root === 'string' ? { id: args.root } : args.root); if (root.id.includes('/')) { const err = `Tree node IDs cannot contain the "/" character`; throw new Error(err); } this.key = Identity.key(root.id); this.namespace = Identity.namespace(root.id) || idUtil.cuid(); this.parent = args.parent; const store = (this._store = StateObject.create(root)); this.event = events.create({ event$: this._event$, until$: this.dispose$, }); this._change((draft) => helpers.ensureNamespace(draft, this.namespace), { ensureNamespace: false, }); store.event.changed$.pipe(takeUntil(this.dispose$)).subscribe((e) => { this.fire({ type: 'TreeState/changed', payload: e }); }); store.event.patched$.pipe(takeUntil(this.dispose$)).subscribe((e) => { this.fire({ type: 'TreeState/patched', payload: e }); }); if (args.dispose$) { args.dispose$.subscribe(() => this.dispose()); } } static create(args) { const root = (args === null || args === void 0 ? void 0 : args.root) || 'node'; const e = Object.assign(Object.assign({}, args), { root }); return new TreeState(e); } dispose() { if (!this.isDisposed) { this.children.forEach((child) => child.dispose()); this._store.dispose(); this.fire({ type: 'TreeState/disposed', payload: { final: this.root }, }); this._dispose$.next(); this._dispose$.complete(); } } get isDisposed() { return this._dispose$.isStopped; } get readonly() { return this; } get store() { return this._store; } get root() { return this._store.state; } get id() { return this.root.id; } get children() { return this._children; } get query() { const root = this.root; const namespace = this.namespace; return TreeQuery.create({ root, namespace }); } get path() { return path.create(this); } _change(fn, options = {}) { const { action } = options; const res = this._store.change((draft) => { const ctx = this.ctx(draft); fn(draft, ctx); if (options.ensureNamespace !== false) { helpers.ensureNamespace(draft, this.namespace); } }, { action }); return res; } ctx(root) { const namespace = this.namespace; const query = TreeQuery.create({ root, namespace }); return Object.assign(Object.assign({}, query), { props: TreeState.props, children: TreeState.children, toObject: (draft) => (draft ? StateObject.toObject(draft) : undefined), query: (node, namespace) => TreeQuery.create({ root: node || root, namespace }) }); } child(id) { id = typeof id === 'string' ? id : id.root.id; return this.children.find((item) => item.root.id === id); } childExists(input) { return Boolean(this.child(input)); } getOrCreateInstance(args) { const root = (typeof args.root === 'string' ? { id: args.root } : args.root); if (TreeState.isInstance(root)) { return args.root; } let parent = Identity.toString(args.parent); parent = parent ? parent : Identity.stripNamespace(this.id); if (!this.query.exists((e) => e.key === parent)) { const err = `Cannot add child-state because the parent node '${parent}' does not exist.`; throw new Error(err); } parent = Identity.format(this.namespace, parent); return TreeState.create({ parent, root }); } listen(child) { const removed$ = this.event .payload('TreeState/child/removed') .pipe(filter((e) => e.child.id === child.id)); removed$.subscribe((e) => { this.change((draft, ctx) => { draft.children = TreeState.children(draft).filter(({ id }) => id !== child.id); }); }); child.event .payload('TreeState/changed') .pipe(takeUntil(child.dispose$), takeUntil(removed$)) .subscribe((e) => { this.change((draft, ctx) => { const children = TreeState.children(draft); const index = children.findIndex(({ id }) => id === child.id); if (index > -1) { children[index] = e.to; } }); }); } } TreeState.identity = Identity; TreeState.props = helpers.props; TreeState.children = helpers.children; TreeState.isInstance = helpers.isInstance;