@platform/state
Version:
A small, simple, strongly typed, [rx/observable] state-machine.
263 lines (262 loc) • 10.1 kB
JavaScript
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;