1 | import { id as idUtil } from '@platform/util.value';
|
2 | import { Subject } from 'rxjs';
|
3 | import { filter, share, take, takeUntil } from 'rxjs/operators';
|
4 | import { is } from '../common';
|
5 | import { StateObject } from '../StateObject';
|
6 | import { TreeIdentity } from '../TreeIdentity';
|
7 | import { TreeQuery } from '../TreeQuery';
|
8 | import { helpers } from './helpers';
|
9 | import * as events from './TreeState.events';
|
10 | import * as path from './TreeState.path';
|
11 | import * as sync from './TreeState.sync';
|
12 | const Identity = TreeIdentity;
|
13 | export class TreeState {
|
14 | constructor(args) {
|
15 | this._children = [];
|
16 | this._kind = 'TreeState';
|
17 | this._dispose$ = new Subject();
|
18 | this.dispose$ = this._dispose$.pipe(share());
|
19 | this._event$ = new Subject();
|
20 | this.change = (fn, options) => this._change(fn, options);
|
21 | this.add = (args) => {
|
22 | if (TreeState.isInstance(args)) {
|
23 | args = { parent: this.id, root: args };
|
24 | }
|
25 | const self = this;
|
26 | const child = this.getOrCreateInstance(args);
|
27 | if (this.childExists(child)) {
|
28 | const err = `Cannot add child '${child.id}' as it already exists within the parent '${this.state.id}'.`;
|
29 | throw new Error(err);
|
30 | }
|
31 | this._children.push(child);
|
32 | this.change((draft, ctx) => {
|
33 | const root = args.parent && args.parent !== draft.id ? ctx.findById(args.parent) : draft;
|
34 | if (!root) {
|
35 | const err = `Cannot add child-state because the parent sub-node '${args.parent}' within '${draft.id}' does not exist.`;
|
36 | throw new Error(err);
|
37 | }
|
38 | TreeState.children(root).push(child.state);
|
39 | });
|
40 | this.listen(child);
|
41 | child.dispose$
|
42 | .pipe(take(1))
|
43 | .pipe(filter(() => this.childExists(child)))
|
44 | .subscribe(() => this.remove(child));
|
45 | this.fire({ type: 'TreeState/child/added', payload: { parent: self, child } });
|
46 | return child;
|
47 | };
|
48 | this.remove = (input) => {
|
49 | const child = this.child(input);
|
50 | if (!child) {
|
51 | const err = `Cannot remove child-state as it does not exist in the parent '${this.state.id}'.`;
|
52 | throw new Error(err);
|
53 | }
|
54 | this._children = this._children.filter((item) => item.state.id !== child.state.id);
|
55 | const self = this;
|
56 | this.fire({ type: 'TreeState/child/removed', payload: { parent: self, child } });
|
57 | return child;
|
58 | };
|
59 | this.clear = () => {
|
60 | this.children.forEach((child) => this.remove(child));
|
61 | return this;
|
62 | };
|
63 | this.contains = (match) => {
|
64 | return Boolean(this.find(match));
|
65 | };
|
66 | this.find = (input) => {
|
67 | if (!input) {
|
68 | return undefined;
|
69 | }
|
70 | const match = typeof input === 'function'
|
71 | ? input
|
72 | : (e) => {
|
73 | const id = TreeIdentity.toNodeId(input);
|
74 | return e.id === id ? true : Boolean(e.tree.query.findById(id));
|
75 | };
|
76 | let result;
|
77 | this.walkDown((e) => {
|
78 | if (e.level > 0) {
|
79 | if (match(e) === true) {
|
80 | e.stop();
|
81 | result = e.tree;
|
82 | }
|
83 | }
|
84 | });
|
85 | return result;
|
86 | };
|
87 | this.walkDown = (visit) => {
|
88 | const inner = (level, index, tree, parent, state) => {
|
89 | if (state.stopped) {
|
90 | return;
|
91 | }
|
92 | let skipped = false;
|
93 | const args = {
|
94 | level,
|
95 | id: tree.id,
|
96 | key: Identity.key(tree.id),
|
97 | namespace: tree.namespace,
|
98 | index,
|
99 | tree,
|
100 | parent,
|
101 | stop: () => (state.stopped = true),
|
102 | skip: () => (skipped = true),
|
103 | toString: () => tree.id,
|
104 | };
|
105 | visit(args);
|
106 | if (state.stopped) {
|
107 | return;
|
108 | }
|
109 | if (!skipped && tree.children.length) {
|
110 | tree.children.forEach((child, i) => {
|
111 | inner(level + 1, i, child, tree, state);
|
112 | });
|
113 | }
|
114 | };
|
115 | return inner(0, -1, this, undefined, {});
|
116 | };
|
117 | this.syncFrom = (args) => {
|
118 | const { until$ } = args;
|
119 | const isObservable = is.observable(args.source.event$);
|
120 | const source$ = isObservable
|
121 | ? args.source.event$
|
122 | : args.source.event.$;
|
123 | const parent = isObservable
|
124 | ? args.source.parent
|
125 | : args.source.parent;
|
126 | const initial = isObservable ? undefined : args.source.state;
|
127 | return sync.syncFrom({ target: this, parent, initial, source$, until$ });
|
128 | };
|
129 | this.fire = (e) => this._event$.next(e);
|
130 | const root = (typeof args.root === 'string' ? { id: args.root } : args.root);
|
131 | if (root.id.includes('/')) {
|
132 | const err = `Tree node IDs cannot contain the "/" character`;
|
133 | throw new Error(err);
|
134 | }
|
135 | this.key = Identity.key(root.id);
|
136 | this.namespace = Identity.namespace(root.id) || idUtil.cuid();
|
137 | this.parent = args.parent;
|
138 | const store = (this._store = StateObject.create(root));
|
139 | this.event = events.create({
|
140 | event$: this._event$,
|
141 | until$: this.dispose$,
|
142 | });
|
143 | this._change((draft) => helpers.ensureNamespace(draft, this.namespace), {
|
144 | ensureNamespace: false,
|
145 | });
|
146 | store.event.changed$.pipe(takeUntil(this.dispose$)).subscribe((e) => {
|
147 | this.fire({ type: 'TreeState/changed', payload: e });
|
148 | });
|
149 | store.event.patched$.pipe(takeUntil(this.dispose$)).subscribe((e) => {
|
150 | this.fire({ type: 'TreeState/patched', payload: e });
|
151 | });
|
152 | if (args.dispose$) {
|
153 | args.dispose$.subscribe(() => this.dispose());
|
154 | }
|
155 | }
|
156 | static create(args) {
|
157 | const root = (args === null || args === void 0 ? void 0 : args.root) || 'node';
|
158 | const e = Object.assign(Object.assign({}, args), { root });
|
159 | return new TreeState(e);
|
160 | }
|
161 | dispose() {
|
162 | if (!this.isDisposed) {
|
163 | this.children.forEach((child) => child.dispose());
|
164 | this._store.dispose();
|
165 | this.fire({
|
166 | type: 'TreeState/disposed',
|
167 | payload: { final: this.state },
|
168 | });
|
169 | this._dispose$.next();
|
170 | this._dispose$.complete();
|
171 | }
|
172 | }
|
173 | get isDisposed() {
|
174 | return this._dispose$.isStopped;
|
175 | }
|
176 | get readonly() {
|
177 | return this;
|
178 | }
|
179 | get state() {
|
180 | return this._store.state;
|
181 | }
|
182 | get id() {
|
183 | return this.state.id;
|
184 | }
|
185 | get children() {
|
186 | return this._children;
|
187 | }
|
188 | get query() {
|
189 | const root = this.state;
|
190 | const namespace = this.namespace;
|
191 | return TreeQuery.create({ root, namespace });
|
192 | }
|
193 | get path() {
|
194 | return path.create(this);
|
195 | }
|
196 | _change(fn, options = {}) {
|
197 | const { action } = options;
|
198 | const res = this._store.change((draft) => {
|
199 | const ctx = this.ctx(draft);
|
200 | fn(draft, ctx);
|
201 | if (options.ensureNamespace !== false) {
|
202 | helpers.ensureNamespace(draft, this.namespace);
|
203 | }
|
204 | }, { action });
|
205 | return res;
|
206 | }
|
207 | ctx(root) {
|
208 | const namespace = this.namespace;
|
209 | const query = TreeQuery.create({ root, namespace });
|
210 | const ctx = Object.assign(Object.assign({}, query), { props: TreeState.props, children: TreeState.children, toObject: (draft) => StateObject.toObject(draft), query: (node, namespace) => TreeQuery.create({ root: node || root, namespace }) });
|
211 | return ctx;
|
212 | }
|
213 | child(id) {
|
214 | id = typeof id === 'string' ? id : id.state.id;
|
215 | return this.children.find((item) => item.id === id);
|
216 | }
|
217 | childExists(input) {
|
218 | return Boolean(this.child(input));
|
219 | }
|
220 | getOrCreateInstance(args) {
|
221 | const root = (typeof args.root === 'string' ? { id: args.root } : args.root);
|
222 | if (TreeState.isInstance(root)) {
|
223 | return args.root;
|
224 | }
|
225 | let parent = Identity.toString(args.parent);
|
226 | parent = parent ? parent : Identity.stripNamespace(this.id);
|
227 | if (!this.query.exists((e) => e.key === parent)) {
|
228 | const err = `Cannot add child-state because the parent node '${parent}' does not exist.`;
|
229 | throw new Error(err);
|
230 | }
|
231 | parent = Identity.format(this.namespace, parent);
|
232 | return TreeState.create({ parent, root });
|
233 | }
|
234 | listen(child) {
|
235 | const removed$ = this.event
|
236 | .payload('TreeState/child/removed')
|
237 | .pipe(filter((e) => e.child.id === child.id));
|
238 | removed$.subscribe((e) => {
|
239 | this.change((draft, ctx) => {
|
240 | draft.children = TreeState.children(draft).filter(({ id }) => id !== child.id);
|
241 | });
|
242 | });
|
243 | child.event
|
244 | .payload('TreeState/changed')
|
245 | .pipe(takeUntil(child.dispose$), takeUntil(removed$))
|
246 | .subscribe((e) => {
|
247 | this.change((draft, ctx) => {
|
248 | const children = TreeState.children(draft);
|
249 | const index = children.findIndex(({ id }) => id === child.id);
|
250 | if (index > -1) {
|
251 | children[index] = e.to;
|
252 | }
|
253 | });
|
254 | });
|
255 | }
|
256 | }
|
257 | TreeState.identity = Identity;
|
258 | TreeState.props = helpers.props;
|
259 | TreeState.children = helpers.children;
|
260 | TreeState.isInstance = helpers.isInstance;
|