@dxos/app-graph 0.3.3-main.052ad73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/README.md +13 -0
- package/dist/lib/browser/index.mjs +252 -0
- package/dist/lib/browser/index.mjs.map +7 -0
- package/dist/lib/browser/meta.json +1 -0
- package/dist/lib/browser/testing.mjs +98 -0
- package/dist/lib/browser/testing.mjs.map +7 -0
- package/dist/lib/node/index.cjs +290 -0
- package/dist/lib/node/index.cjs.map +7 -0
- package/dist/lib/node/meta.json +1 -0
- package/dist/lib/node/testing.cjs +124 -0
- package/dist/lib/node/testing.cjs.map +7 -0
- package/dist/types/src/action.d.ts +53 -0
- package/dist/types/src/action.d.ts.map +1 -0
- package/dist/types/src/graph-builder.d.ts +31 -0
- package/dist/types/src/graph-builder.d.ts.map +1 -0
- package/dist/types/src/graph.d.ts +45 -0
- package/dist/types/src/graph.d.ts.map +1 -0
- package/dist/types/src/graph.test.d.ts +2 -0
- package/dist/types/src/graph.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +5 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/node.d.ts +71 -0
- package/dist/types/src/node.d.ts.map +1 -0
- package/dist/types/src/stories/EchoGraph.stories.d.ts +10 -0
- package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -0
- package/dist/types/src/stories/Tree.d.ts +14 -0
- package/dist/types/src/stories/Tree.d.ts.map +1 -0
- package/dist/types/src/testing.d.ts +52 -0
- package/dist/types/src/testing.d.ts.map +1 -0
- package/package.json +74 -0
- package/src/action.ts +69 -0
- package/src/graph-builder.ts +200 -0
- package/src/graph.test.ts +220 -0
- package/src/graph.ts +110 -0
- package/src/index.ts +8 -0
- package/src/node.ts +101 -0
- package/src/stories/EchoGraph.stories.tsx +233 -0
- package/src/stories/Tree.tsx +79 -0
- package/src/testing.ts +152 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { expect } from 'chai';
|
|
6
|
+
|
|
7
|
+
import { describe, test } from '@dxos/test';
|
|
8
|
+
|
|
9
|
+
import { GraphBuilder } from './graph-builder';
|
|
10
|
+
import { createTestNodeBuilder } from './testing';
|
|
11
|
+
|
|
12
|
+
describe('Graph', () => {
|
|
13
|
+
test('returns root node', () => {
|
|
14
|
+
const graph = new GraphBuilder().build();
|
|
15
|
+
expect(graph.root).to.not.be.undefined;
|
|
16
|
+
expect(graph.root.id).to.equal('root');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('root node unmodified without builders', () => {
|
|
20
|
+
const graph = new GraphBuilder().build();
|
|
21
|
+
expect(graph.root.properties).to.be.empty;
|
|
22
|
+
expect(graph.root.children).to.be.empty;
|
|
23
|
+
expect(graph.root.actions).to.be.empty;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('builder can add children to root node', () => {
|
|
27
|
+
const builder = new GraphBuilder();
|
|
28
|
+
const testNode = { id: 'test', label: 'Test', data: null };
|
|
29
|
+
const id = 'test';
|
|
30
|
+
builder.addNodeBuilder(id, (parent) => {
|
|
31
|
+
if (parent.id === 'root') {
|
|
32
|
+
parent.addNode(id, testNode);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const graph = builder.build();
|
|
37
|
+
expect(graph.root.childrenMap.test.id).to.equal(testNode.id);
|
|
38
|
+
expect(graph.root.childrenMap.test.parent!.id).to.equal(graph.root.id);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('builder can add actions to root node', () => {
|
|
42
|
+
const builder = new GraphBuilder();
|
|
43
|
+
const testAction = { id: 'test', label: 'Test', invoke: () => {} };
|
|
44
|
+
builder.addNodeBuilder('test', (parent) => {
|
|
45
|
+
if (parent.id === 'root') {
|
|
46
|
+
parent.addAction(testAction);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const graph = builder.build();
|
|
51
|
+
expect(graph.root.actionsMap.test.id).to.equal(testAction.id);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('multiple builders can add children to root node', () => {
|
|
55
|
+
const builder = new GraphBuilder();
|
|
56
|
+
builder.addNodeBuilder('test1', createTestNodeBuilder('test1').nodeBuilder);
|
|
57
|
+
builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
|
|
58
|
+
|
|
59
|
+
const graph = builder.build();
|
|
60
|
+
expect(Object.keys(graph.root.childrenMap)).to.deep.equal(['root-test1', 'root-test2']);
|
|
61
|
+
for (const node of graph.root.children) {
|
|
62
|
+
expect(graph.root.id).to.equal(node.parent!.id);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('multiple builders can add actions to root node', () => {
|
|
67
|
+
const builder = new GraphBuilder();
|
|
68
|
+
builder.addNodeBuilder('test1', createTestNodeBuilder('test1').nodeBuilder);
|
|
69
|
+
builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
|
|
70
|
+
|
|
71
|
+
const graph = builder.build();
|
|
72
|
+
expect(Object.keys(graph.root.actionsMap)).to.deep.equal(['root-test1', 'root-test2']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('builders can add children to child node', () => {
|
|
76
|
+
const builder = new GraphBuilder();
|
|
77
|
+
builder.addNodeBuilder('test1', createTestNodeBuilder('test1', 2).nodeBuilder);
|
|
78
|
+
builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
|
|
79
|
+
|
|
80
|
+
const graph = builder.build();
|
|
81
|
+
expect(Object.keys(graph.root.childrenMap['root-test1'].children)).to.be.empty;
|
|
82
|
+
expect(Object.keys(graph.root.childrenMap['root-test2'].childrenMap)).to.deep.equal(['root-test2-test1']);
|
|
83
|
+
for (const node of graph.root.childrenMap['root-test2'].children) {
|
|
84
|
+
expect(graph.root.childrenMap['root-test2'].id).to.equal(node.parent!.id);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('builders can add actions to child node', () => {
|
|
89
|
+
const builder = new GraphBuilder();
|
|
90
|
+
builder.addNodeBuilder('test1', createTestNodeBuilder('test1', 2).nodeBuilder);
|
|
91
|
+
builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
|
|
92
|
+
|
|
93
|
+
const graph = builder.build();
|
|
94
|
+
expect(Object.keys(graph.root.childrenMap['root-test1'].actionsMap)).to.be.empty;
|
|
95
|
+
expect(Object.keys(graph.root.childrenMap['root-test2'].actionsMap)).to.deep.equal(['root-test2-test1']);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('builders can add actions to action sets', () => {
|
|
99
|
+
const builder = new GraphBuilder();
|
|
100
|
+
builder.addNodeBuilder('test', (parent) => {
|
|
101
|
+
const [set] = parent.addAction({ id: 'test-set', label: 'Test Set', invoke: () => {} });
|
|
102
|
+
set.addAction({ id: 'test-action', label: 'Test', invoke: () => {} });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const graph = builder.build();
|
|
106
|
+
expect(Object.keys(graph.root.actionsMap['test-set'].actionsMap)).to.deep.equal(['test-action']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('is updated when nodes change', async () => {
|
|
110
|
+
const builder = new GraphBuilder();
|
|
111
|
+
|
|
112
|
+
// TODO(burdon): Return TestNodeBuilder object.
|
|
113
|
+
const { nodeBuilder, addNode, removeNode, addAction, removeAction, addProperty, removeProperty } =
|
|
114
|
+
createTestNodeBuilder('test1', 2);
|
|
115
|
+
builder.addNodeBuilder('test1', nodeBuilder);
|
|
116
|
+
|
|
117
|
+
const graph = builder.build();
|
|
118
|
+
expect(graph.root.children).to.have.length(1);
|
|
119
|
+
addNode('root', { id: 'root-test2', label: 'root-test2' });
|
|
120
|
+
expect(graph.root.children).to.have.length(2);
|
|
121
|
+
expect(graph.root.childrenMap['root-test2'].children).to.have.length(0);
|
|
122
|
+
|
|
123
|
+
expect(graph.root.childrenMap['root-test1'].children).to.have.length(0);
|
|
124
|
+
addNode('root-test1', { id: 'root-test1-test2', label: 'root-test1-test2' });
|
|
125
|
+
expect(graph.root.childrenMap['root-test1'].children).to.have.length(1);
|
|
126
|
+
expect(graph.root.childrenMap['root-test1'].childrenMap['root-test1-test2'].children).to.have.length(0);
|
|
127
|
+
|
|
128
|
+
expect(graph.root.actions).to.have.length(1);
|
|
129
|
+
addAction('root', { id: 'root-test2', label: 'root-test2', invoke: () => {} });
|
|
130
|
+
expect(graph.root.actions).to.have.length(2);
|
|
131
|
+
|
|
132
|
+
expect(graph.root.childrenMap['root-test1'].properties).to.not.have.property('test');
|
|
133
|
+
addProperty('root-test1', 'test', 'test');
|
|
134
|
+
expect(graph.root.childrenMap['root-test1'].properties.test).to.equal('test');
|
|
135
|
+
|
|
136
|
+
expect(graph.root.children).to.have.length(2);
|
|
137
|
+
removeNode('root', 'root-test2');
|
|
138
|
+
expect(graph.root.children).to.have.length(1);
|
|
139
|
+
|
|
140
|
+
expect(graph.root.childrenMap['root-test1'].children).to.have.length(1);
|
|
141
|
+
removeNode('root-test1', 'root-test1-test2');
|
|
142
|
+
expect(graph.root.childrenMap['root-test1'].children).to.have.length(0);
|
|
143
|
+
|
|
144
|
+
expect(graph.root.actions).to.have.length(2);
|
|
145
|
+
removeAction('root', 'root-test2');
|
|
146
|
+
expect(graph.root.actions).to.have.length(1);
|
|
147
|
+
|
|
148
|
+
expect(graph.root.childrenMap['root-test1'].properties.test).to.equal('test');
|
|
149
|
+
removeProperty('root-test1', 'test');
|
|
150
|
+
expect(graph.root.childrenMap['root-test1'].properties).to.not.have.property('test');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test.skip('node builder unsubscribe is called when builder is re-run', async () => {
|
|
154
|
+
// TODO(wittjosiah): Implement.
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('can find nodes', () => {
|
|
158
|
+
const builder = new GraphBuilder();
|
|
159
|
+
builder.addNodeBuilder('test1', createTestNodeBuilder('test1', 2).nodeBuilder);
|
|
160
|
+
builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
|
|
161
|
+
|
|
162
|
+
const graph = builder.build();
|
|
163
|
+
expect(graph.findNode('root-test1')?.id).to.equal('root-test1');
|
|
164
|
+
expect(graph.findNode('root-test2-test1')?.id).to.equal('root-test2-test1');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('can be traversed', () => {
|
|
168
|
+
const builder = new GraphBuilder();
|
|
169
|
+
builder.addNodeBuilder('test1', createTestNodeBuilder('test1', 2).nodeBuilder);
|
|
170
|
+
builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
|
|
171
|
+
|
|
172
|
+
const graph = builder.build();
|
|
173
|
+
const nodes: string[] = [];
|
|
174
|
+
graph.traverse({ visitor: (node) => nodes.push(node.id) });
|
|
175
|
+
expect(nodes).to.deep.equal(['root', 'root-test1', 'root-test2', 'root-test2-test1']);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('traversal can be limited by predicate', () => {
|
|
179
|
+
const builder = new GraphBuilder();
|
|
180
|
+
builder.addNodeBuilder('test1', createTestNodeBuilder('test1', 2).nodeBuilder);
|
|
181
|
+
builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
|
|
182
|
+
|
|
183
|
+
const graph = builder.build();
|
|
184
|
+
const nodes: string[] = [];
|
|
185
|
+
graph.traverse({
|
|
186
|
+
filter: (node) => node.id.includes('test1'),
|
|
187
|
+
visitor: (node) => nodes.push(node.id),
|
|
188
|
+
});
|
|
189
|
+
expect(nodes).to.deep.equal(['root-test1', 'root-test2-test1']);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('traversal can be started from any node', () => {
|
|
193
|
+
const builder = new GraphBuilder();
|
|
194
|
+
builder.addNodeBuilder('test1', createTestNodeBuilder('test1', 2).nodeBuilder);
|
|
195
|
+
builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
|
|
196
|
+
|
|
197
|
+
const graph = builder.build();
|
|
198
|
+
const nodes: string[] = [];
|
|
199
|
+
graph.traverse({
|
|
200
|
+
node: graph.root.childrenMap['root-test2'],
|
|
201
|
+
visitor: (node) => nodes.push(node.id),
|
|
202
|
+
});
|
|
203
|
+
expect(nodes).to.deep.equal(['root-test2', 'root-test2-test1']);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('can traverse up', () => {
|
|
207
|
+
const builder = new GraphBuilder();
|
|
208
|
+
builder.addNodeBuilder('test1', createTestNodeBuilder('test1', 2).nodeBuilder);
|
|
209
|
+
builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
|
|
210
|
+
|
|
211
|
+
const graph = builder.build();
|
|
212
|
+
const nodes: string[] = [];
|
|
213
|
+
graph.traverse({
|
|
214
|
+
direction: 'up',
|
|
215
|
+
node: graph.root.childrenMap['root-test2'].childrenMap['root-test2-test1'],
|
|
216
|
+
visitor: (node) => nodes.push(node.id),
|
|
217
|
+
});
|
|
218
|
+
expect(nodes).to.deep.equal(['root-test2-test1', 'root-test2', 'root']);
|
|
219
|
+
});
|
|
220
|
+
});
|
package/src/graph.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { deepSignal } from 'deepsignal/react';
|
|
6
|
+
// TODO(wittjosiah): Remove lodash dependency.
|
|
7
|
+
import get from 'lodash.get';
|
|
8
|
+
|
|
9
|
+
import { type Label } from './action';
|
|
10
|
+
import { type Node } from './node';
|
|
11
|
+
|
|
12
|
+
export type TraversalOptions = {
|
|
13
|
+
/**
|
|
14
|
+
* The node to start traversing from. Defaults to the root node.
|
|
15
|
+
*/
|
|
16
|
+
node?: Node;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The direction to traverse the graph. Defaults to 'down'.
|
|
20
|
+
*/
|
|
21
|
+
direction?: 'up' | 'down';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A predicate to filter nodes which are passed to the `visitor` callback.
|
|
25
|
+
*/
|
|
26
|
+
filter?: (node: Node) => boolean;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A callback which is called for each node visited during traversal.
|
|
30
|
+
*/
|
|
31
|
+
visitor?: (node: Node) => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The Graph represents...
|
|
36
|
+
*/
|
|
37
|
+
export class Graph {
|
|
38
|
+
// TODO(burdon): Document.
|
|
39
|
+
// TODO(wittjosiah): Should this support multiple paths to the same node?
|
|
40
|
+
private readonly _index = deepSignal<{ [key: string]: string[] }>({});
|
|
41
|
+
|
|
42
|
+
constructor(private readonly _root: Node) {}
|
|
43
|
+
|
|
44
|
+
toJSON() {
|
|
45
|
+
const toLabel = (label: Label) => (Array.isArray(label) ? `${label[1].ns}[${label[0]}]` : label);
|
|
46
|
+
const toJSON = (node: Node): any => {
|
|
47
|
+
return {
|
|
48
|
+
id: node.id.slice(0, 16),
|
|
49
|
+
label: toLabel(node.label),
|
|
50
|
+
children: node.children.length ? node.children.map((node) => toJSON(node)) : undefined,
|
|
51
|
+
actions: node.actions.length
|
|
52
|
+
? node.actions.map(({ id, label }) => ({
|
|
53
|
+
id,
|
|
54
|
+
label: toLabel(label),
|
|
55
|
+
}))
|
|
56
|
+
: undefined,
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return toJSON(this._root);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The root node of the graph which is the entry point for all knowledge.
|
|
65
|
+
*/
|
|
66
|
+
get root(): Node {
|
|
67
|
+
return this._root;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get the path through the graph from the root to the node with the given id.
|
|
72
|
+
*/
|
|
73
|
+
getPath(id: string): string[] | undefined {
|
|
74
|
+
return this._index[id];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @internal
|
|
79
|
+
*/
|
|
80
|
+
_setPath(id: string, path: string[]) {
|
|
81
|
+
this._index[id] = path;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Find the node with the given id in the graph.
|
|
86
|
+
*/
|
|
87
|
+
findNode(id: string): Node | undefined {
|
|
88
|
+
const path = this.getPath(id);
|
|
89
|
+
if (!path) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return path.length > 0 ? get(this._root, path) : this._root;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Recursive breadth-first traversal.
|
|
98
|
+
*/
|
|
99
|
+
traverse({ node = this._root, direction = 'down', filter, visitor }: TraversalOptions, depth = 0): void {
|
|
100
|
+
if (!filter || filter(node)) {
|
|
101
|
+
visitor?.(node);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (direction === 'down') {
|
|
105
|
+
Object.values(node.children).forEach((child) => this.traverse({ node: child, filter, visitor }));
|
|
106
|
+
} else if (direction === 'up' && node.parent) {
|
|
107
|
+
this.traverse({ node: node.parent, direction, filter, visitor }, depth + 1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/index.ts
ADDED
package/src/node.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import type { IconProps } from '@phosphor-icons/react';
|
|
6
|
+
import { type FC } from 'react';
|
|
7
|
+
|
|
8
|
+
import type { UnsubscribeCallback } from '@dxos/async';
|
|
9
|
+
|
|
10
|
+
import { type Action, type Label } from './action';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Called when a node is added to the graph, allowing other node builders to add children, actions or properties.
|
|
14
|
+
*/
|
|
15
|
+
export type NodeBuilder = (parent: Node) => UnsubscribeCallback | void;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Represents a node in the graph.
|
|
19
|
+
*/
|
|
20
|
+
export type Node<TData = any, TProperties extends Record<string, any> = Record<string, any>> = {
|
|
21
|
+
/**
|
|
22
|
+
* Globally unique ID.
|
|
23
|
+
*/
|
|
24
|
+
id: string;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parent node in the graph.
|
|
28
|
+
*/
|
|
29
|
+
parent: Node | null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Label to be used when displaying the node.
|
|
33
|
+
* For default labels, use a translated string.
|
|
34
|
+
*
|
|
35
|
+
* @example 'My Node'
|
|
36
|
+
* @example ['unknown node label, { ns: 'example-plugin' }]
|
|
37
|
+
*/
|
|
38
|
+
label: Label;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Description to be used when displaying a detailed view of the node.
|
|
42
|
+
* For default descriptions, use a translated string.
|
|
43
|
+
*/
|
|
44
|
+
description?: Label;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Icon to be used when displaying the node.
|
|
48
|
+
*/
|
|
49
|
+
icon?: FC<IconProps>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Properties of the node relevant to displaying the node.
|
|
53
|
+
*
|
|
54
|
+
* @example { index: 'a1' }
|
|
55
|
+
*/
|
|
56
|
+
// TODO(burdon): Make this extensible and move label, description, and icon into here?
|
|
57
|
+
properties: TProperties;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Data the node represents.
|
|
61
|
+
*/
|
|
62
|
+
// TODO(burdon): Type system (e.g., minimally provide identifier string vs. TypedObject vs. Graph mixin type system)?
|
|
63
|
+
// type field would prevent convoluted sniffing of object properties. And allow direct pass-through for ECHO TypedObjects.
|
|
64
|
+
// TODO(burdon): In some places `null` is cast to TData so make optional?
|
|
65
|
+
data: TData;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Children of the node stored by their id.
|
|
69
|
+
*/
|
|
70
|
+
// TODO(burdon): Rename nodes/nodeMap?
|
|
71
|
+
childrenMap: Record<string, Node>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Actions of the node stored by their id.
|
|
75
|
+
*/
|
|
76
|
+
actionsMap: Record<string, Action>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Children of the node in default order.
|
|
80
|
+
*/
|
|
81
|
+
get children(): Node[];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Actions of the node in default order.
|
|
85
|
+
*/
|
|
86
|
+
get actions(): Action[];
|
|
87
|
+
|
|
88
|
+
addProperty(key: string, value: any): void;
|
|
89
|
+
removeProperty(key: string): void;
|
|
90
|
+
|
|
91
|
+
addNode<TChildData = null, TChildProperties extends Record<string, any> = Record<string, any>>(
|
|
92
|
+
id: string,
|
|
93
|
+
...node: (Pick<Node, 'id' | 'label'> & Partial<Node<TChildData, TChildProperties>>)[]
|
|
94
|
+
): Node<TChildData, TChildProperties>[];
|
|
95
|
+
removeNode(id: string): Node;
|
|
96
|
+
|
|
97
|
+
addAction<TActionProperties extends Record<string, any> = Record<string, any>>(
|
|
98
|
+
...action: (Pick<Action, 'id' | 'label' | 'invoke'> & Partial<Action<TActionProperties>>)[]
|
|
99
|
+
): Action<TActionProperties>[];
|
|
100
|
+
removeAction(id: string): Action;
|
|
101
|
+
};
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxosTheme';
|
|
6
|
+
import { faker } from '@faker-js/faker';
|
|
7
|
+
import { Pause, Play, Plus, Timer } from '@phosphor-icons/react';
|
|
8
|
+
import { effect } from '@preact/signals-react';
|
|
9
|
+
import React, { useEffect, useState } from 'react';
|
|
10
|
+
|
|
11
|
+
import { EventSubscriptions } from '@dxos/async';
|
|
12
|
+
import { Client } from '@dxos/react-client';
|
|
13
|
+
import { Expando, type Space, SpaceProxy, SpaceState } from '@dxos/react-client/echo';
|
|
14
|
+
import { ClientDecorator, TestBuilder } from '@dxos/react-client/testing';
|
|
15
|
+
import { Button, DensityProvider, Input, Select } from '@dxos/react-ui';
|
|
16
|
+
import { getSize, mx } from '@dxos/react-ui-theme';
|
|
17
|
+
import { safeParseInt } from '@dxos/util';
|
|
18
|
+
|
|
19
|
+
import { Tree } from './Tree';
|
|
20
|
+
import { GraphBuilder } from '../graph-builder';
|
|
21
|
+
import { type Node } from '../node';
|
|
22
|
+
|
|
23
|
+
export default {
|
|
24
|
+
title: 'Echo Graph',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const DEFAULT_PERIOD = 500;
|
|
28
|
+
|
|
29
|
+
const testBuilder = new TestBuilder();
|
|
30
|
+
const client = new Client({ services: testBuilder.createLocal() });
|
|
31
|
+
await client.initialize();
|
|
32
|
+
await client.halo.createIdentity();
|
|
33
|
+
await client.spaces.create();
|
|
34
|
+
await client.spaces.create();
|
|
35
|
+
|
|
36
|
+
const spaceNodeBuilder = (parent: Node) => {
|
|
37
|
+
if (parent.id !== 'root') {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const subscriptions = new EventSubscriptions();
|
|
42
|
+
const { unsubscribe } = client.spaces.subscribe((spaces) => {
|
|
43
|
+
subscriptions.clear();
|
|
44
|
+
spaces.forEach((space) => {
|
|
45
|
+
subscriptions.add(
|
|
46
|
+
effect(() => {
|
|
47
|
+
if (space.state.get() === SpaceState.READY) {
|
|
48
|
+
parent.addNode('space', { id: space.key.toHex(), label: space.properties.name, data: space });
|
|
49
|
+
} else {
|
|
50
|
+
parent.removeNode(space.key.toHex());
|
|
51
|
+
}
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return () => {
|
|
58
|
+
unsubscribe();
|
|
59
|
+
subscriptions.clear();
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const objectNodeBuilder = (parent: Node) => {
|
|
64
|
+
if (!(parent.data instanceof SpaceProxy)) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const space = parent.data;
|
|
69
|
+
const query = space.db.query({ type: 'test' });
|
|
70
|
+
let previousObjects: Expando[] = [];
|
|
71
|
+
return effect(() => {
|
|
72
|
+
const removedObjects = previousObjects.filter((object) => !query.objects.includes(object));
|
|
73
|
+
previousObjects = query.objects;
|
|
74
|
+
|
|
75
|
+
removedObjects.forEach((object) => parent.removeNode(object.id));
|
|
76
|
+
query.objects.forEach((expando) =>
|
|
77
|
+
parent.addNode('object', { id: expando.id, label: expando.name, data: expando }),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const graph = new GraphBuilder()
|
|
83
|
+
.addNodeBuilder('space', spaceNodeBuilder)
|
|
84
|
+
.addNodeBuilder('object', objectNodeBuilder)
|
|
85
|
+
.build();
|
|
86
|
+
|
|
87
|
+
enum Action {
|
|
88
|
+
CREATE_SPACE = 'CREATE_SPACE',
|
|
89
|
+
CLOSE_SPACE = 'CLOSE_SPACE',
|
|
90
|
+
RENAME_SPACE = 'RENAME_SPACE',
|
|
91
|
+
ADD_OBJECT = 'ADD_OBJECT',
|
|
92
|
+
REMOVE_OBJECT = 'REMOVE_OBJECT',
|
|
93
|
+
RENAME_OBJECT = 'RENAME_OBJECT',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const actionWeights = {
|
|
97
|
+
[Action.CREATE_SPACE]: 2,
|
|
98
|
+
[Action.CLOSE_SPACE]: 1,
|
|
99
|
+
[Action.RENAME_SPACE]: 2,
|
|
100
|
+
[Action.ADD_OBJECT]: 4,
|
|
101
|
+
[Action.REMOVE_OBJECT]: 3,
|
|
102
|
+
[Action.RENAME_OBJECT]: 4,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const randomAction = () => {
|
|
106
|
+
const actionDistribution = Object.entries(actionWeights)
|
|
107
|
+
.map(([action, weight]): Action[] => Array(weight).fill(action))
|
|
108
|
+
.flat();
|
|
109
|
+
|
|
110
|
+
return actionDistribution[Math.floor(Math.random() * actionDistribution.length)];
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const getSpace = (): Space | undefined => {
|
|
114
|
+
const spaces = client.spaces.get().filter((space) => space.state.get() === SpaceState.READY);
|
|
115
|
+
return spaces[Math.floor(Math.random() * spaces.length)];
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const getSpaceWithObjects = (): Space | undefined => {
|
|
119
|
+
const spaces = client.spaces
|
|
120
|
+
.get()
|
|
121
|
+
.filter((space) => space.state.get() === SpaceState.READY)
|
|
122
|
+
.filter((space) => space.db.query({ type: 'test' }).objects.length > 0);
|
|
123
|
+
|
|
124
|
+
return spaces[Math.floor(Math.random() * spaces.length)];
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const runAction = (action: Action) => {
|
|
128
|
+
switch (action) {
|
|
129
|
+
case Action.CREATE_SPACE:
|
|
130
|
+
void client.spaces.create();
|
|
131
|
+
break;
|
|
132
|
+
|
|
133
|
+
case Action.CLOSE_SPACE:
|
|
134
|
+
void getSpace()?.internal.close();
|
|
135
|
+
break;
|
|
136
|
+
|
|
137
|
+
case Action.RENAME_SPACE: {
|
|
138
|
+
const space = getSpace();
|
|
139
|
+
if (space) {
|
|
140
|
+
space.properties.name = faker.animal.lion();
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case Action.ADD_OBJECT:
|
|
146
|
+
getSpace()?.db.add(new Expando({ type: 'test', name: faker.animal.cat() }));
|
|
147
|
+
break;
|
|
148
|
+
|
|
149
|
+
case Action.REMOVE_OBJECT: {
|
|
150
|
+
const space = getSpaceWithObjects();
|
|
151
|
+
if (space) {
|
|
152
|
+
const objects = space.db.query({ type: 'test' }).objects;
|
|
153
|
+
space.db.remove(objects[Math.floor(Math.random() * objects.length)]);
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
case Action.RENAME_OBJECT: {
|
|
159
|
+
const space = getSpaceWithObjects();
|
|
160
|
+
if (space) {
|
|
161
|
+
const objects = space.db.query({ type: 'test' }).objects;
|
|
162
|
+
objects[Math.floor(Math.random() * objects.length)].name = faker.animal.cat();
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const EchoGraphStory = () => {
|
|
170
|
+
const [generating, setGenerating] = useState(false);
|
|
171
|
+
const [actionInterval, setActionInterval] = useState(String(DEFAULT_PERIOD));
|
|
172
|
+
const [action, setAction] = useState<Action>();
|
|
173
|
+
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (!generating) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const interval = setInterval(() => runAction(randomAction()), safeParseInt(actionInterval) ?? DEFAULT_PERIOD);
|
|
180
|
+
return () => clearInterval(interval);
|
|
181
|
+
}, [generating, actionInterval]);
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<>
|
|
185
|
+
<div className='flex shrink-0 p-2 space-x-2'>
|
|
186
|
+
<DensityProvider density='fine'>
|
|
187
|
+
<Button onClick={() => setGenerating((generating) => !generating)}>
|
|
188
|
+
{generating ? <Pause /> : <Play />}
|
|
189
|
+
</Button>
|
|
190
|
+
<div className='relative' title='mutation period'>
|
|
191
|
+
<Input.Root>
|
|
192
|
+
<Input.TextInput
|
|
193
|
+
autoComplete='off'
|
|
194
|
+
size={5}
|
|
195
|
+
classNames='w-[100px] text-right pie-[22px]'
|
|
196
|
+
placeholder='Interval'
|
|
197
|
+
value={actionInterval}
|
|
198
|
+
onChange={({ target: { value } }) => setActionInterval(value)}
|
|
199
|
+
/>
|
|
200
|
+
</Input.Root>
|
|
201
|
+
<Timer className={mx('absolute inline-end-1 block-start-1 mt-[6px]', getSize(3))} />
|
|
202
|
+
</div>
|
|
203
|
+
<Button onClick={() => action && runAction(action)}>
|
|
204
|
+
<Plus />
|
|
205
|
+
</Button>
|
|
206
|
+
<Select.Root value={action?.toString()} onValueChange={(action) => setAction(action as unknown as Action)}>
|
|
207
|
+
<Select.TriggerButton placeholder='Select value' />
|
|
208
|
+
<Select.Portal>
|
|
209
|
+
<Select.Content>
|
|
210
|
+
<Select.ScrollUpButton />
|
|
211
|
+
<Select.Viewport>
|
|
212
|
+
{Object.keys(actionWeights).map((action) => (
|
|
213
|
+
<Select.Option key={action} value={action}>
|
|
214
|
+
{action}
|
|
215
|
+
</Select.Option>
|
|
216
|
+
))}
|
|
217
|
+
</Select.Viewport>
|
|
218
|
+
<Select.ScrollDownButton />
|
|
219
|
+
<Select.Arrow />
|
|
220
|
+
</Select.Content>
|
|
221
|
+
</Select.Portal>
|
|
222
|
+
</Select.Root>
|
|
223
|
+
</DensityProvider>
|
|
224
|
+
</div>
|
|
225
|
+
<Tree data={graph.toJSON()} />
|
|
226
|
+
</>
|
|
227
|
+
);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export const EchoGraph = {
|
|
231
|
+
render: () => <EchoGraphStory />,
|
|
232
|
+
decorators: [ClientDecorator({ clients: [client], className: 'flex flex-col' })],
|
|
233
|
+
};
|