@dxos/app-graph 0.8.4-main.bc674ce → 0.8.4-main.bcb3aa67d6
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/dist/lib/browser/chunk-AKBGYELG.mjs +1603 -0
- package/dist/lib/browser/chunk-AKBGYELG.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +17 -1276
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/testing/index.mjs +39 -0
- package/dist/lib/browser/testing/index.mjs.map +7 -0
- package/dist/lib/node-esm/chunk-HR5S4XYH.mjs +1604 -0
- package/dist/lib/node-esm/chunk-HR5S4XYH.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +17 -1276
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/testing/index.mjs +40 -0
- package/dist/lib/node-esm/testing/index.mjs.map +7 -0
- package/dist/types/src/graph-builder.d.ts +11 -7
- package/dist/types/src/graph-builder.d.ts.map +1 -1
- package/dist/types/src/graph.d.ts +13 -17
- package/dist/types/src/graph.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/node-matcher.d.ts +43 -17
- package/dist/types/src/node-matcher.d.ts.map +1 -1
- package/dist/types/src/node.d.ts +21 -5
- package/dist/types/src/node.d.ts.map +1 -1
- package/dist/types/src/stories/EchoGraph.stories.d.ts.map +1 -1
- package/dist/types/src/testing/index.d.ts +2 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/setup-graph-builder.d.ts +31 -0
- package/dist/types/src/testing/setup-graph-builder.d.ts.map +1 -0
- package/dist/types/src/util.d.ts +39 -0
- package/dist/types/src/util.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +36 -26
- package/src/graph-builder.test.ts +569 -102
- package/src/graph-builder.ts +202 -74
- package/src/graph.test.ts +187 -52
- package/src/graph.ts +174 -98
- package/src/index.ts +1 -0
- package/src/node-matcher.ts +58 -28
- package/src/node.ts +46 -5
- package/src/stories/EchoGraph.stories.tsx +90 -61
- package/src/stories/Tree.tsx +1 -1
- package/src/testing/index.ts +5 -0
- package/src/testing/setup-graph-builder.ts +41 -0
- package/src/util.ts +95 -0
package/src/node-matcher.ts
CHANGED
|
@@ -69,7 +69,7 @@ export const whenId =
|
|
|
69
69
|
* ```ts
|
|
70
70
|
* GraphBuilder.createExtension({
|
|
71
71
|
* id: 'space-settings-extension',
|
|
72
|
-
* match: NodeMatcher.whenNodeType('dxos.
|
|
72
|
+
* match: NodeMatcher.whenNodeType('org.dxos.plugin.space.settings'),
|
|
73
73
|
* connector: (node) => Effect.succeed([...]),
|
|
74
74
|
* });
|
|
75
75
|
* ```
|
|
@@ -106,10 +106,13 @@ export const whenNodeType =
|
|
|
106
106
|
* });
|
|
107
107
|
* ```
|
|
108
108
|
*
|
|
109
|
-
* @
|
|
109
|
+
* Can be composed directly with {@link whenAll}/{@link whenAny}/{@link whenNot} while
|
|
110
|
+
* preserving the typed entity data in the result.
|
|
111
|
+
*
|
|
112
|
+
* @see {@link whenEchoTypeMatches} - Returns the node instead of data for legacy composition.
|
|
110
113
|
*/
|
|
111
114
|
export const whenEchoType =
|
|
112
|
-
<T extends Type.
|
|
115
|
+
<T extends Type.AnyEntity>(type: T): NodeMatcher<Entity.Entity<Schema.Schema.Type<T>>> =>
|
|
113
116
|
(node: Node.Node): Option.Option<Entity.Entity<Schema.Schema.Type<T>>> =>
|
|
114
117
|
Obj.instanceOf(type, node.data) ? Option.some(node.data) : Option.none();
|
|
115
118
|
|
|
@@ -129,12 +132,15 @@ export const whenEchoType =
|
|
|
129
132
|
* connector: (object) => {
|
|
130
133
|
* // `object` is typed as Obj.Unknown
|
|
131
134
|
* const id = Obj.getDXN(object).toString();
|
|
132
|
-
* return Effect.succeed([{ id: `${id}
|
|
135
|
+
* return Effect.succeed([{ id: `${id}.settings`, ... }]);
|
|
133
136
|
* },
|
|
134
137
|
* });
|
|
135
138
|
* ```
|
|
136
139
|
*
|
|
137
|
-
* @
|
|
140
|
+
* Can be composed directly with {@link whenAll}/{@link whenAny}/{@link whenNot} while
|
|
141
|
+
* preserving the `Obj.Unknown` data type in the result.
|
|
142
|
+
*
|
|
143
|
+
* @see {@link whenEchoObjectMatches} - Returns the node instead of data for legacy composition.
|
|
138
144
|
*/
|
|
139
145
|
export const whenEchoObject = (node: Node.Node): Option.Option<Obj.Unknown> =>
|
|
140
146
|
Obj.isObject(node.data) ? Option.some(node.data) : Option.none();
|
|
@@ -145,38 +151,53 @@ export const whenEchoObject = (node: Node.Node): Option.Option<Obj.Unknown> =>
|
|
|
145
151
|
|
|
146
152
|
/**
|
|
147
153
|
* Composes multiple matchers with AND logic - all matchers must match for success.
|
|
148
|
-
*
|
|
154
|
+
* The result data type is the intersection of all matchers' data types.
|
|
155
|
+
* Filter matchers like {@link whenNot} return `unknown`, making them transparent
|
|
156
|
+
* in the intersection (since `T & unknown = T`).
|
|
149
157
|
*
|
|
150
158
|
* @param matchers - The matchers to combine. All must return Option.some for success.
|
|
151
|
-
* @returns A matcher
|
|
159
|
+
* @returns A matcher whose data type is the intersection of all input matchers' data types.
|
|
160
|
+
* Returns the first matcher's value when all match, Option.none() otherwise.
|
|
152
161
|
*
|
|
153
162
|
* @example
|
|
154
163
|
* ```ts
|
|
155
|
-
* // Match ECHO objects that are NOT Channels
|
|
164
|
+
* // Match ECHO objects that are NOT Channels — result is NodeMatcher<Obj.Unknown>.
|
|
156
165
|
* const whenCommentable = NodeMatcher.whenAll(
|
|
157
|
-
* NodeMatcher.
|
|
166
|
+
* NodeMatcher.whenEchoObject,
|
|
158
167
|
* NodeMatcher.whenNot(NodeMatcher.whenEchoTypeMatches(Channel.Channel)),
|
|
159
168
|
* );
|
|
160
169
|
* ```
|
|
161
170
|
*/
|
|
162
|
-
export const whenAll
|
|
163
|
-
(
|
|
164
|
-
(
|
|
165
|
-
|
|
166
|
-
|
|
171
|
+
export const whenAll: {
|
|
172
|
+
<A>(a: NodeMatcher<A>, b: NodeMatcher<unknown>): NodeMatcher<A>;
|
|
173
|
+
<A>(a: NodeMatcher<unknown>, b: NodeMatcher<A>): NodeMatcher<A>;
|
|
174
|
+
<A, B>(a: NodeMatcher<A>, b: NodeMatcher<B>): NodeMatcher<A & B>;
|
|
175
|
+
<A, B, C>(a: NodeMatcher<A>, b: NodeMatcher<B>, c: NodeMatcher<C>): NodeMatcher<A & B & C>;
|
|
176
|
+
<A, B, C, D>(a: NodeMatcher<A>, b: NodeMatcher<B>, c: NodeMatcher<C>, d: NodeMatcher<D>): NodeMatcher<A & B & C & D>;
|
|
177
|
+
(...matchers: NodeMatcher<any>[]): NodeMatcher<any>;
|
|
178
|
+
} =
|
|
179
|
+
(...matchers: NodeMatcher<any>[]): NodeMatcher<any> =>
|
|
180
|
+
(node: Node.Node) => {
|
|
181
|
+
let first: Option.Option<any> = Option.none();
|
|
182
|
+
for (const candidate of matchers) {
|
|
183
|
+
const result = candidate(node);
|
|
167
184
|
if (Option.isNone(result)) {
|
|
168
185
|
return Option.none();
|
|
169
186
|
}
|
|
187
|
+
if (Option.isNone(first)) {
|
|
188
|
+
first = result;
|
|
189
|
+
}
|
|
170
190
|
}
|
|
171
|
-
return
|
|
191
|
+
return first;
|
|
172
192
|
};
|
|
173
193
|
|
|
174
194
|
/**
|
|
175
195
|
* Composes multiple matchers with OR logic - at least one matcher must match.
|
|
176
|
-
*
|
|
196
|
+
* The result data type is the union of all matchers' data types.
|
|
177
197
|
*
|
|
178
198
|
* @param matchers - The matchers to combine. At least one must return Option.some.
|
|
179
|
-
* @returns A matcher
|
|
199
|
+
* @returns A matcher whose data type is the union of all input matchers' data types.
|
|
200
|
+
* Returns the first matching matcher's value, or Option.none() if none match.
|
|
180
201
|
*
|
|
181
202
|
* @example
|
|
182
203
|
* ```ts
|
|
@@ -187,13 +208,18 @@ export const whenAll =
|
|
|
187
208
|
* );
|
|
188
209
|
* ```
|
|
189
210
|
*/
|
|
190
|
-
export const whenAny
|
|
191
|
-
(
|
|
192
|
-
(
|
|
193
|
-
|
|
194
|
-
|
|
211
|
+
export const whenAny: {
|
|
212
|
+
<A, B>(a: NodeMatcher<A>, b: NodeMatcher<B>): NodeMatcher<A | B>;
|
|
213
|
+
<A, B, C>(a: NodeMatcher<A>, b: NodeMatcher<B>, c: NodeMatcher<C>): NodeMatcher<A | B | C>;
|
|
214
|
+
<A, B, C, D>(a: NodeMatcher<A>, b: NodeMatcher<B>, c: NodeMatcher<C>, d: NodeMatcher<D>): NodeMatcher<A | B | C | D>;
|
|
215
|
+
(...matchers: NodeMatcher<any>[]): NodeMatcher<any>;
|
|
216
|
+
} =
|
|
217
|
+
(...matchers: NodeMatcher<any>[]): NodeMatcher<any> =>
|
|
218
|
+
(node: Node.Node) => {
|
|
219
|
+
for (const candidate of matchers) {
|
|
220
|
+
const result = candidate(node);
|
|
195
221
|
if (Option.isSome(result)) {
|
|
196
|
-
return
|
|
222
|
+
return result;
|
|
197
223
|
}
|
|
198
224
|
}
|
|
199
225
|
return Option.none();
|
|
@@ -229,7 +255,7 @@ export const whenAny =
|
|
|
229
255
|
* @see {@link whenEchoType} - Use instead when you need the typed entity directly.
|
|
230
256
|
*/
|
|
231
257
|
export const whenEchoTypeMatches =
|
|
232
|
-
<T extends Type.
|
|
258
|
+
<T extends Type.AnyEntity>(type: T): NodeMatcher =>
|
|
233
259
|
(node: Node.Node): Option.Option<Node.Node> =>
|
|
234
260
|
Obj.instanceOf(type, node.data) ? Option.some(node) : Option.none();
|
|
235
261
|
|
|
@@ -262,15 +288,19 @@ export const whenEchoObjectMatches = (node: Node.Node): Option.Option<Node.Node>
|
|
|
262
288
|
* Negates a matcher - matches when the given matcher does NOT match.
|
|
263
289
|
* Useful for exclusion patterns like "any object EXCEPT type X".
|
|
264
290
|
*
|
|
291
|
+
* Returns `NodeMatcher<unknown>` because negation is a filter — it doesn't provide
|
|
292
|
+
* typed data. This makes it transparent in {@link whenAll} intersections
|
|
293
|
+
* (since `T & unknown = T`).
|
|
294
|
+
*
|
|
265
295
|
* @param matcher - The matcher to negate.
|
|
266
296
|
* @returns A matcher that returns Option.some(node) if the input matcher returns none,
|
|
267
297
|
* and Option.none() if the input matcher returns some.
|
|
268
298
|
*
|
|
269
299
|
* @example
|
|
270
300
|
* ```ts
|
|
271
|
-
* // Match any ECHO object that is NOT a Channel
|
|
301
|
+
* // Match any ECHO object that is NOT a Channel — result is NodeMatcher<Obj.Unknown>.
|
|
272
302
|
* const whenCommentable = NodeMatcher.whenAll(
|
|
273
|
-
* NodeMatcher.
|
|
303
|
+
* NodeMatcher.whenEchoObject,
|
|
274
304
|
* NodeMatcher.whenNot(NodeMatcher.whenEchoTypeMatches(Channel.Channel)),
|
|
275
305
|
* );
|
|
276
306
|
*
|
|
@@ -279,6 +309,6 @@ export const whenEchoObjectMatches = (node: Node.Node): Option.Option<Node.Node>
|
|
|
279
309
|
* ```
|
|
280
310
|
*/
|
|
281
311
|
export const whenNot =
|
|
282
|
-
(matcher: NodeMatcher): NodeMatcher =>
|
|
283
|
-
(node: Node.Node): Option.Option<
|
|
312
|
+
(matcher: NodeMatcher<any>): NodeMatcher<unknown> =>
|
|
313
|
+
(node: Node.Node): Option.Option<unknown> =>
|
|
284
314
|
Option.isNone(matcher(node)) ? Option.some(node) : Option.none();
|
package/src/node.ts
CHANGED
|
@@ -15,17 +15,17 @@ export const RootId = 'root';
|
|
|
15
15
|
/**
|
|
16
16
|
* Root node type.
|
|
17
17
|
*/
|
|
18
|
-
export const RootType = 'dxos.
|
|
18
|
+
export const RootType = 'org.dxos.type.graphRoot';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Action node type.
|
|
22
22
|
*/
|
|
23
|
-
export const ActionType = 'dxos.
|
|
23
|
+
export const ActionType = 'org.dxos.type.graphAction';
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Action group node type.
|
|
27
27
|
*/
|
|
28
|
-
export const ActionGroupType = 'dxos.
|
|
28
|
+
export const ActionGroupType = 'org.dxos.type.graphActionGroup';
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Represents a node in the graph.
|
|
@@ -69,7 +69,19 @@ export type NodeFilter<TData = any, TProperties extends Record<string, any> = Re
|
|
|
69
69
|
connectedNode: Node,
|
|
70
70
|
) => node is Node<TData, TProperties>;
|
|
71
71
|
|
|
72
|
-
export type
|
|
72
|
+
export type RelationDirection = 'outbound' | 'inbound';
|
|
73
|
+
|
|
74
|
+
export type Relation = Readonly<{
|
|
75
|
+
kind: string;
|
|
76
|
+
direction: RelationDirection;
|
|
77
|
+
}>;
|
|
78
|
+
|
|
79
|
+
export type RelationInput = Relation | string;
|
|
80
|
+
|
|
81
|
+
export const relation = (kind: string, direction: RelationDirection = 'outbound'): Relation => ({ kind, direction });
|
|
82
|
+
// TODO(wittjosiah): Consider moving these helpers out of the core API.
|
|
83
|
+
export const childRelation = (direction: RelationDirection = 'outbound'): Relation => relation('child', direction);
|
|
84
|
+
export const actionRelation = (direction: RelationDirection = 'outbound'): Relation => relation('action', direction);
|
|
73
85
|
|
|
74
86
|
export const isGraphNode = (data: unknown): data is Node =>
|
|
75
87
|
data && typeof data === 'object' && 'id' in data && 'properties' in data && data.properties
|
|
@@ -84,7 +96,7 @@ export type NodeArg<TData, TProperties extends Record<string, any> = Record<stri
|
|
|
84
96
|
nodes?: NodeArg<unknown>[];
|
|
85
97
|
|
|
86
98
|
/** Will automatically add specified edges. */
|
|
87
|
-
edges?: [string,
|
|
99
|
+
edges?: [string, RelationInput][];
|
|
88
100
|
};
|
|
89
101
|
|
|
90
102
|
//
|
|
@@ -95,6 +107,9 @@ export type InvokeProps = {
|
|
|
95
107
|
/** Node the invoked action is connected to. */
|
|
96
108
|
parent?: Node;
|
|
97
109
|
|
|
110
|
+
/** Path from root to the node in the current tree context. */
|
|
111
|
+
path?: string[];
|
|
112
|
+
|
|
98
113
|
caller?: string;
|
|
99
114
|
};
|
|
100
115
|
|
|
@@ -135,3 +150,29 @@ export const isActionGroup = (data: unknown): data is ActionGroup =>
|
|
|
135
150
|
export type ActionLike = Action | ActionGroup;
|
|
136
151
|
|
|
137
152
|
export const isActionLike = (data: unknown): data is Action | ActionGroup => isAction(data) || isActionGroup(data);
|
|
153
|
+
|
|
154
|
+
//
|
|
155
|
+
// Node Factories
|
|
156
|
+
//
|
|
157
|
+
|
|
158
|
+
/** Typed factory for constructing a NodeArg. Provides auto-complete and type validation. */
|
|
159
|
+
export const make = <TData = any, TProperties extends Record<string, any> = Record<string, any>>(
|
|
160
|
+
arg: NodeArg<TData, TProperties>,
|
|
161
|
+
): NodeArg<TData, TProperties> => arg;
|
|
162
|
+
|
|
163
|
+
/** Create an action node. Automatically sets `type: ActionType`. */
|
|
164
|
+
export const makeAction = <R = never>(
|
|
165
|
+
arg: Omit<NodeArg<ActionData<R>>, 'type' | 'nodes' | 'edges'>,
|
|
166
|
+
): NodeArg<ActionData<R>> => ({
|
|
167
|
+
...arg,
|
|
168
|
+
type: ActionType,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/** Create an action group node. Automatically sets `type` and `data`. */
|
|
172
|
+
export const makeActionGroup = (
|
|
173
|
+
arg: Omit<NodeArg<typeof actionGroupSymbol>, 'type' | 'data' | 'nodes' | 'edges'>,
|
|
174
|
+
): NodeArg<typeof actionGroupSymbol> => ({
|
|
175
|
+
...arg,
|
|
176
|
+
type: ActionGroupType,
|
|
177
|
+
data: actionGroupSymbol,
|
|
178
|
+
});
|
|
@@ -17,9 +17,9 @@ import { type Client, useClient } from '@dxos/react-client';
|
|
|
17
17
|
import { withClientProvider } from '@dxos/react-client/testing';
|
|
18
18
|
import { Icon, IconButton, Input, Select } from '@dxos/react-ui';
|
|
19
19
|
import { withTheme } from '@dxos/react-ui/testing';
|
|
20
|
-
import { Path, Tree } from '@dxos/react-ui-list';
|
|
20
|
+
import { Path, Tree, type TreeModel } from '@dxos/react-ui-list';
|
|
21
21
|
import { getSize, mx } from '@dxos/ui-theme';
|
|
22
|
-
import {
|
|
22
|
+
import { safeParseInt } from '@dxos/util';
|
|
23
23
|
|
|
24
24
|
import * as CreateAtom from '../atoms';
|
|
25
25
|
import * as Graph from '../graph';
|
|
@@ -64,7 +64,7 @@ const createGraph = (client: Client, registry: Registry.Registry): Graph.Expanda
|
|
|
64
64
|
const propertiesSnapshot = get(AtomObj.make(space.properties));
|
|
65
65
|
return {
|
|
66
66
|
id: space.id,
|
|
67
|
-
type: 'dxos.
|
|
67
|
+
type: 'org.dxos.type.space',
|
|
68
68
|
properties: {
|
|
69
69
|
label: propertiesSnapshot.name,
|
|
70
70
|
},
|
|
@@ -88,7 +88,7 @@ const createGraph = (client: Client, registry: Registry.Registry): Graph.Expanda
|
|
|
88
88
|
const objects = get(AtomQuery.make(space.db, Query.type(TestSchema.Expando, { type: 'test' })));
|
|
89
89
|
return objects.map((object) => ({
|
|
90
90
|
id: object.id,
|
|
91
|
-
type: 'dxos.
|
|
91
|
+
type: 'org.dxos.type.test',
|
|
92
92
|
properties: { label: object.name },
|
|
93
93
|
data: object,
|
|
94
94
|
}));
|
|
@@ -104,9 +104,9 @@ const createGraph = (client: Client, registry: Registry.Registry): Graph.Expanda
|
|
|
104
104
|
GraphBuilder.addExtension(builder, objectBuilderExtension);
|
|
105
105
|
const graph = builder.graph;
|
|
106
106
|
graph.onNodeChanged.on(({ id }) => {
|
|
107
|
-
Graph.expand(graph, id);
|
|
107
|
+
Graph.expand(graph, id, 'child');
|
|
108
108
|
});
|
|
109
|
-
Graph.expand(graph, Node.RootId);
|
|
109
|
+
Graph.expand(graph, Node.RootId, 'child');
|
|
110
110
|
(window as any).graph = graph;
|
|
111
111
|
return graph;
|
|
112
112
|
};
|
|
@@ -146,8 +146,8 @@ const runAction = async (client: Client, action: Action) => {
|
|
|
146
146
|
case Action.RENAME_SPACE: {
|
|
147
147
|
const space = getRandomSpace(client);
|
|
148
148
|
if (space) {
|
|
149
|
-
Obj.change(space.properties, (
|
|
150
|
-
|
|
149
|
+
Obj.change(space.properties, (obj) => {
|
|
150
|
+
obj.name = faker.commerce.productName();
|
|
151
151
|
});
|
|
152
152
|
}
|
|
153
153
|
break;
|
|
@@ -176,8 +176,8 @@ const runAction = async (client: Client, action: Action) => {
|
|
|
176
176
|
if (space) {
|
|
177
177
|
const objects = await space.db.query(Filter.type(TestSchema.Expando, { type: 'test' })).run();
|
|
178
178
|
const object = objects[Math.floor(Math.random() * objects.length)];
|
|
179
|
-
Obj.change(object, (
|
|
180
|
-
|
|
179
|
+
Obj.change(object, (obj) => {
|
|
180
|
+
obj.name = faker.commerce.productName();
|
|
181
181
|
});
|
|
182
182
|
}
|
|
183
183
|
break;
|
|
@@ -216,14 +216,13 @@ const Controls = ({ children }: PropsWithChildren) => {
|
|
|
216
216
|
<Input.Root>
|
|
217
217
|
<Input.TextInput
|
|
218
218
|
autoComplete='off'
|
|
219
|
-
|
|
220
|
-
classNames='is-[100px] text-right pie-[22px]'
|
|
219
|
+
classNames='w-[100px] text-right pe-[22px]'
|
|
221
220
|
placeholder='Interval'
|
|
222
221
|
value={actionInterval}
|
|
223
222
|
onChange={({ target: { value } }) => setActionInterval(value)}
|
|
224
223
|
/>
|
|
225
224
|
</Input.Root>
|
|
226
|
-
<Icon icon='ph--timer--regular' classNames={mx('absolute
|
|
225
|
+
<Icon icon='ph--timer--regular' classNames={mx('absolute right-1 top-1 mt-[6px]', getSize(3))} />
|
|
227
226
|
</div>
|
|
228
227
|
<IconButton icon='ph--plus--regular' label='Add' onClick={() => action && runAction(client, action)} />
|
|
229
228
|
<Select.Root value={action?.toString()} onValueChange={(action) => setAction(action as unknown as Action)}>
|
|
@@ -252,9 +251,10 @@ const Controls = ({ children }: PropsWithChildren) => {
|
|
|
252
251
|
const meta = {
|
|
253
252
|
title: 'sdk/app-graph/EchoGraph',
|
|
254
253
|
decorators: [
|
|
255
|
-
withTheme,
|
|
254
|
+
withTheme(),
|
|
256
255
|
withClientProvider({
|
|
257
256
|
createIdentity: true,
|
|
257
|
+
types: [TestSchema.Expando],
|
|
258
258
|
onCreateIdentity: async ({ client }) => {
|
|
259
259
|
await client.spaces.create();
|
|
260
260
|
await client.spaces.create();
|
|
@@ -291,60 +291,97 @@ export const TreeView: Story = {
|
|
|
291
291
|
const stateRef = useRef(new Map<string, Atom.Writable<{ open: boolean; current: boolean }>>());
|
|
292
292
|
|
|
293
293
|
const getOrCreateState = useMemo(
|
|
294
|
-
() => (
|
|
295
|
-
let atom = stateRef.current.get(
|
|
294
|
+
() => (pathKey: string) => {
|
|
295
|
+
let atom = stateRef.current.get(pathKey);
|
|
296
296
|
if (!atom) {
|
|
297
297
|
atom = Atom.make({ open: true, current: false }).pipe(Atom.keepAlive);
|
|
298
|
-
stateRef.current.set(
|
|
298
|
+
stateRef.current.set(pathKey, atom);
|
|
299
299
|
}
|
|
300
300
|
return atom;
|
|
301
301
|
},
|
|
302
302
|
[],
|
|
303
303
|
);
|
|
304
304
|
|
|
305
|
-
const
|
|
306
|
-
(
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
305
|
+
const childIdsFamily = useMemo(
|
|
306
|
+
() =>
|
|
307
|
+
Atom.family((id: string) =>
|
|
308
|
+
Atom.make((get) => {
|
|
309
|
+
const connections = get(graph.connections(id, 'child'));
|
|
310
|
+
return connections.map((connection) => connection.id);
|
|
311
|
+
}),
|
|
312
|
+
),
|
|
310
313
|
[graph],
|
|
311
314
|
);
|
|
312
315
|
|
|
313
|
-
const
|
|
314
|
-
(
|
|
315
|
-
|
|
316
|
-
.
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
316
|
+
const itemFamily = useMemo(
|
|
317
|
+
() =>
|
|
318
|
+
Atom.family((id: string) =>
|
|
319
|
+
Atom.make((get) => {
|
|
320
|
+
const node = get(graph.node(id));
|
|
321
|
+
return Option.isSome(node) ? node.value : undefined;
|
|
322
|
+
}),
|
|
323
|
+
),
|
|
324
|
+
[graph],
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const itemPropsFamily = useMemo(
|
|
328
|
+
() =>
|
|
329
|
+
Atom.family((pathKey: string) => {
|
|
330
|
+
const path = pathKey.split('~');
|
|
331
|
+
const id = path[path.length - 1];
|
|
332
|
+
return Atom.make((get) => {
|
|
333
|
+
const nodeOpt = get(graph.node(id));
|
|
334
|
+
const node = Option.isSome(nodeOpt) ? nodeOpt.value : undefined;
|
|
335
|
+
if (!node) {
|
|
336
|
+
return { id, label: id };
|
|
337
|
+
}
|
|
338
|
+
const connections = get(graph.connections(node.id, 'child'));
|
|
339
|
+
const safeChildren = connections.filter((n) => !path.includes(n.id));
|
|
340
|
+
const parentOf =
|
|
341
|
+
safeChildren.length > 0
|
|
342
|
+
? safeChildren.map(({ id }) => id)
|
|
343
|
+
: node.properties.role === 'branch'
|
|
344
|
+
? []
|
|
345
|
+
: undefined;
|
|
346
|
+
return {
|
|
347
|
+
id: node.id,
|
|
348
|
+
label: node.id,
|
|
349
|
+
icon: node.type === 'org.dxos.type.space' ? 'ph--planet--regular' : 'ph--placeholder--regular',
|
|
350
|
+
parentOf,
|
|
351
|
+
};
|
|
352
|
+
});
|
|
353
|
+
}),
|
|
331
354
|
[graph],
|
|
332
355
|
);
|
|
333
356
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
357
|
+
const itemOpenFamily = useMemo(
|
|
358
|
+
() =>
|
|
359
|
+
Atom.family((pathKey: string) => {
|
|
360
|
+
const stateAtom = getOrCreateState(pathKey);
|
|
361
|
+
return Atom.make((get) => get(stateAtom).open);
|
|
362
|
+
}),
|
|
363
|
+
[getOrCreateState],
|
|
364
|
+
);
|
|
340
365
|
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
366
|
+
const itemCurrentFamily = useMemo(
|
|
367
|
+
() =>
|
|
368
|
+
Atom.family((pathKey: string) => {
|
|
369
|
+
const stateAtom = getOrCreateState(pathKey);
|
|
370
|
+
return Atom.make((get) => get(stateAtom).current);
|
|
371
|
+
}),
|
|
372
|
+
[getOrCreateState],
|
|
373
|
+
);
|
|
344
374
|
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
375
|
+
const model: TreeModel<Node.Node> = useMemo(
|
|
376
|
+
() => ({
|
|
377
|
+
childIds: (parentId?: string) => childIdsFamily(parentId ?? Node.RootId),
|
|
378
|
+
item: (id: string) => itemFamily(id),
|
|
379
|
+
itemProps: (path: string[]) => itemPropsFamily(path.join('~')),
|
|
380
|
+
itemOpen: (path: string[]) => itemOpenFamily(Path.create(...path)),
|
|
381
|
+
itemCurrent: (path: string[]) => itemCurrentFamily(Path.create(...path)),
|
|
382
|
+
}),
|
|
383
|
+
[childIdsFamily, itemFamily, itemPropsFamily, itemOpenFamily, itemCurrentFamily],
|
|
384
|
+
);
|
|
348
385
|
|
|
349
386
|
const onOpenChange = useCallback(
|
|
350
387
|
({ path: _path, open }: { path: string[]; open: boolean }) => {
|
|
@@ -373,15 +410,7 @@ export const TreeView: Story = {
|
|
|
373
410
|
return (
|
|
374
411
|
<>
|
|
375
412
|
<Controls />
|
|
376
|
-
<Tree
|
|
377
|
-
id={Node.RootId}
|
|
378
|
-
useItems={useItems}
|
|
379
|
-
getProps={getProps}
|
|
380
|
-
useIsOpen={useIsOpen}
|
|
381
|
-
useIsCurrent={useIsCurrent}
|
|
382
|
-
onOpenChange={onOpenChange}
|
|
383
|
-
onSelect={onSelect}
|
|
384
|
-
/>
|
|
413
|
+
<Tree model={model} id={Node.RootId} onOpenChange={onOpenChange} onSelect={onSelect} />
|
|
385
414
|
</>
|
|
386
415
|
);
|
|
387
416
|
},
|
package/src/stories/Tree.tsx
CHANGED
|
@@ -72,7 +72,7 @@ const Scalar: FC<{ value: any }> = ({ value }) => {
|
|
|
72
72
|
|
|
73
73
|
const Box: FC<HTMLAttributes<HTMLDivElement>> = ({ children, className, ...props }) => {
|
|
74
74
|
return (
|
|
75
|
-
<div className={mx('flex
|
|
75
|
+
<div className={mx('flex px-2 border border-l-0 font-mono truncate', className)} {...props}>
|
|
76
76
|
{children}
|
|
77
77
|
</div>
|
|
78
78
|
);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { Registry } from '@effect-atom/atom-react';
|
|
6
|
+
import * as Option from 'effect/Option';
|
|
7
|
+
|
|
8
|
+
import * as Graph from '../graph';
|
|
9
|
+
import * as GraphBuilder from '../graph-builder';
|
|
10
|
+
import * as Node from '../node';
|
|
11
|
+
|
|
12
|
+
export type SetupGraphBuilderOptions = {
|
|
13
|
+
registry?: Registry.Registry;
|
|
14
|
+
extensions?: GraphBuilder.BuilderExtensions;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const setupGraphBuilder = ({ registry = Registry.make(), extensions }: SetupGraphBuilderOptions = {}) => {
|
|
18
|
+
const builder = GraphBuilder.make({ registry });
|
|
19
|
+
const graph = builder.graph;
|
|
20
|
+
|
|
21
|
+
if (extensions) {
|
|
22
|
+
GraphBuilder.addExtension(builder, extensions);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
registry,
|
|
27
|
+
builder,
|
|
28
|
+
graph,
|
|
29
|
+
addExtensions: (nextExtensions: GraphBuilder.BuilderExtensions) => {
|
|
30
|
+
GraphBuilder.addExtension(builder, nextExtensions);
|
|
31
|
+
},
|
|
32
|
+
expand: async (id: string, relation: Node.RelationInput = 'child') => {
|
|
33
|
+
Graph.expand(graph, id, relation);
|
|
34
|
+
await GraphBuilder.flush(builder);
|
|
35
|
+
},
|
|
36
|
+
flush: () => GraphBuilder.flush(builder),
|
|
37
|
+
getConnections: (id: string, relation: Node.RelationInput = 'child') =>
|
|
38
|
+
registry.get(graph.connections(id, relation)),
|
|
39
|
+
getNode: (id: string) => Graph.getNode(graph, id).pipe(Option.getOrNull),
|
|
40
|
+
};
|
|
41
|
+
};
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { invariant } from '@dxos/invariant';
|
|
6
|
+
|
|
7
|
+
import * as Node from './node';
|
|
8
|
+
|
|
9
|
+
// PRIMARY separates top-level components (e.g., node ID from relation) in compound string keys used within the app-graph package.
|
|
10
|
+
const PRIMARY = '\u0001';
|
|
11
|
+
|
|
12
|
+
// SECONDARY separates sub-components within an encoded value (e.g., relation kind from direction) in the same context.
|
|
13
|
+
const SECONDARY = '\u0002';
|
|
14
|
+
|
|
15
|
+
// PATH separates segments in qualified node IDs (e.g., parent path from local segment).
|
|
16
|
+
const PATH = '/';
|
|
17
|
+
|
|
18
|
+
/** Join parts with the primary separator. */
|
|
19
|
+
export const primaryKey = (...parts: string[]): string => parts.join(PRIMARY);
|
|
20
|
+
|
|
21
|
+
/** Split a key on the primary separator. */
|
|
22
|
+
export const primaryParts = (key: string): string[] => key.split(PRIMARY);
|
|
23
|
+
|
|
24
|
+
/** Join parts with the secondary separator. */
|
|
25
|
+
export const secondaryKey = (...parts: string[]): string => parts.join(SECONDARY);
|
|
26
|
+
|
|
27
|
+
/** Split a key on the secondary separator. */
|
|
28
|
+
export const secondaryParts = (key: string): string[] => key.split(SECONDARY);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Normalize a relation input to a full Relation object.
|
|
32
|
+
*/
|
|
33
|
+
export const normalizeRelation = (relation?: Node.RelationInput): Node.Relation =>
|
|
34
|
+
relation == null ? Node.childRelation() : typeof relation === 'string' ? Node.relation(relation) : relation;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Shallow-compare two values: same reference, or same own-keys with === values.
|
|
38
|
+
*/
|
|
39
|
+
export const shallowEqual = (a: unknown, b: unknown): boolean => {
|
|
40
|
+
if (a === b) return true;
|
|
41
|
+
if (a == null || b == null || typeof a !== 'object' || typeof b !== 'object') return false;
|
|
42
|
+
const keysA = Object.keys(a as Record<string, unknown>);
|
|
43
|
+
const keysB = Object.keys(b as Record<string, unknown>);
|
|
44
|
+
if (keysA.length !== keysB.length) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return keysA.every((k) => (a as Record<string, unknown>)[k] === (b as Record<string, unknown>)[k]);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Returns true if two NodeArg arrays are semantically identical (same id, type, data, properties per index).
|
|
52
|
+
*/
|
|
53
|
+
export const nodeArgsUnchanged = (prev: Node.NodeArg<any>[], next: Node.NodeArg<any>[]): boolean => {
|
|
54
|
+
if (prev.length !== next.length) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return prev.every((prevNode, idx) => {
|
|
59
|
+
const nextNode = next[idx];
|
|
60
|
+
return (
|
|
61
|
+
prevNode.id === nextNode.id &&
|
|
62
|
+
prevNode.type === nextNode.type &&
|
|
63
|
+
shallowEqual(prevNode.data, nextNode.data) &&
|
|
64
|
+
shallowEqual(prevNode.properties, nextNode.properties)
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build a qualified node ID by joining path segments.
|
|
71
|
+
*/
|
|
72
|
+
export const qualifyId = (parentId: string, ...segmentIds: string[]): string => [parentId, ...segmentIds].join(PATH);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Validate that a segment ID does not contain the path separator.
|
|
76
|
+
*/
|
|
77
|
+
export const validateSegmentId = (id: string): void => {
|
|
78
|
+
invariant(!id.includes(PATH), `Node segment ID must not contain '${PATH}': ${id}`);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Extract the parent qualified ID (everything before the last path separator).
|
|
83
|
+
* Returns undefined for IDs with no parent (single segment).
|
|
84
|
+
*/
|
|
85
|
+
export const getParentId = (qualifiedId: string): string | undefined => {
|
|
86
|
+
const lastSlash = qualifiedId.lastIndexOf(PATH);
|
|
87
|
+
return lastSlash > 0 ? qualifiedId.slice(0, lastSlash) : undefined;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Extract the last segment of a qualified ID.
|
|
92
|
+
*/
|
|
93
|
+
export const getSegmentId = (qualifiedId: string): string => {
|
|
94
|
+
return qualifiedId.split(PATH).pop() ?? qualifiedId;
|
|
95
|
+
};
|