@dxos/app-graph 0.8.4-main.69d29f4 → 0.8.4-main.6fa680abb7
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/index.mjs +480 -198
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +480 -198
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- 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/node-matcher.d.ts +4 -4
- package/dist/types/src/node-matcher.d.ts.map +1 -1
- package/dist/types/src/node.d.ts +15 -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/util.d.ts +34 -0
- package/dist/types/src/util.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +25 -25
- package/src/graph-builder.test.ts +444 -102
- package/src/graph-builder.ts +191 -72
- package/src/graph.test.ts +187 -52
- package/src/graph.ts +179 -98
- package/src/node-matcher.ts +8 -10
- package/src/node.ts +20 -5
- package/src/stories/EchoGraph.stories.tsx +86 -56
- package/src/stories/Tree.tsx +1 -1
- package/src/util.ts +71 -0
|
@@ -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
|
};
|
|
@@ -217,13 +217,13 @@ const Controls = ({ children }: PropsWithChildren) => {
|
|
|
217
217
|
<Input.TextInput
|
|
218
218
|
autoComplete='off'
|
|
219
219
|
size={5}
|
|
220
|
-
classNames='
|
|
220
|
+
classNames='w-[100px] text-right pe-[22px]'
|
|
221
221
|
placeholder='Interval'
|
|
222
222
|
value={actionInterval}
|
|
223
223
|
onChange={({ target: { value } }) => setActionInterval(value)}
|
|
224
224
|
/>
|
|
225
225
|
</Input.Root>
|
|
226
|
-
<Icon icon='ph--timer--regular' classNames={mx('absolute
|
|
226
|
+
<Icon icon='ph--timer--regular' classNames={mx('absolute right-1 top-1 mt-[6px]', getSize(3))} />
|
|
227
227
|
</div>
|
|
228
228
|
<IconButton icon='ph--plus--regular' label='Add' onClick={() => action && runAction(client, action)} />
|
|
229
229
|
<Select.Root value={action?.toString()} onValueChange={(action) => setAction(action as unknown as Action)}>
|
|
@@ -252,9 +252,10 @@ const Controls = ({ children }: PropsWithChildren) => {
|
|
|
252
252
|
const meta = {
|
|
253
253
|
title: 'sdk/app-graph/EchoGraph',
|
|
254
254
|
decorators: [
|
|
255
|
-
withTheme,
|
|
255
|
+
withTheme(),
|
|
256
256
|
withClientProvider({
|
|
257
257
|
createIdentity: true,
|
|
258
|
+
types: [TestSchema.Expando],
|
|
258
259
|
onCreateIdentity: async ({ client }) => {
|
|
259
260
|
await client.spaces.create();
|
|
260
261
|
await client.spaces.create();
|
|
@@ -291,60 +292,97 @@ export const TreeView: Story = {
|
|
|
291
292
|
const stateRef = useRef(new Map<string, Atom.Writable<{ open: boolean; current: boolean }>>());
|
|
292
293
|
|
|
293
294
|
const getOrCreateState = useMemo(
|
|
294
|
-
() => (
|
|
295
|
-
let atom = stateRef.current.get(
|
|
295
|
+
() => (pathKey: string) => {
|
|
296
|
+
let atom = stateRef.current.get(pathKey);
|
|
296
297
|
if (!atom) {
|
|
297
298
|
atom = Atom.make({ open: true, current: false }).pipe(Atom.keepAlive);
|
|
298
|
-
stateRef.current.set(
|
|
299
|
+
stateRef.current.set(pathKey, atom);
|
|
299
300
|
}
|
|
300
301
|
return atom;
|
|
301
302
|
},
|
|
302
303
|
[],
|
|
303
304
|
);
|
|
304
305
|
|
|
305
|
-
const
|
|
306
|
-
(
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
306
|
+
const childIdsFamily = useMemo(
|
|
307
|
+
() =>
|
|
308
|
+
Atom.family((id: string) =>
|
|
309
|
+
Atom.make((get) => {
|
|
310
|
+
const connections = get(graph.connections(id, 'child'));
|
|
311
|
+
return connections.map((connection) => connection.id);
|
|
312
|
+
}),
|
|
313
|
+
),
|
|
310
314
|
[graph],
|
|
311
315
|
);
|
|
312
316
|
|
|
313
|
-
const
|
|
314
|
-
(
|
|
315
|
-
|
|
316
|
-
.
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
317
|
+
const itemFamily = useMemo(
|
|
318
|
+
() =>
|
|
319
|
+
Atom.family((id: string) =>
|
|
320
|
+
Atom.make((get) => {
|
|
321
|
+
const node = get(graph.node(id));
|
|
322
|
+
return Option.isSome(node) ? node.value : undefined;
|
|
323
|
+
}),
|
|
324
|
+
),
|
|
325
|
+
[graph],
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const itemPropsFamily = useMemo(
|
|
329
|
+
() =>
|
|
330
|
+
Atom.family((pathKey: string) => {
|
|
331
|
+
const path = pathKey.split('~');
|
|
332
|
+
const id = path[path.length - 1];
|
|
333
|
+
return Atom.make((get) => {
|
|
334
|
+
const nodeOpt = get(graph.node(id));
|
|
335
|
+
const node = Option.isSome(nodeOpt) ? nodeOpt.value : undefined;
|
|
336
|
+
if (!node) {
|
|
337
|
+
return { id, label: id };
|
|
338
|
+
}
|
|
339
|
+
const connections = get(graph.connections(node.id, 'child'));
|
|
340
|
+
const safeChildren = connections.filter((n) => !path.includes(n.id));
|
|
341
|
+
const parentOf =
|
|
342
|
+
safeChildren.length > 0
|
|
343
|
+
? safeChildren.map(({ id }) => id)
|
|
344
|
+
: node.properties.role === 'branch'
|
|
345
|
+
? []
|
|
346
|
+
: undefined;
|
|
347
|
+
return {
|
|
348
|
+
id: node.id,
|
|
349
|
+
label: node.id,
|
|
350
|
+
icon: node.type === 'org.dxos.type.space' ? 'ph--planet--regular' : 'ph--placeholder--regular',
|
|
351
|
+
parentOf,
|
|
352
|
+
};
|
|
353
|
+
});
|
|
354
|
+
}),
|
|
331
355
|
[graph],
|
|
332
356
|
);
|
|
333
357
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
358
|
+
const itemOpenFamily = useMemo(
|
|
359
|
+
() =>
|
|
360
|
+
Atom.family((pathKey: string) => {
|
|
361
|
+
const stateAtom = getOrCreateState(pathKey);
|
|
362
|
+
return Atom.make((get) => get(stateAtom).open);
|
|
363
|
+
}),
|
|
364
|
+
[getOrCreateState],
|
|
365
|
+
);
|
|
340
366
|
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
367
|
+
const itemCurrentFamily = useMemo(
|
|
368
|
+
() =>
|
|
369
|
+
Atom.family((pathKey: string) => {
|
|
370
|
+
const stateAtom = getOrCreateState(pathKey);
|
|
371
|
+
return Atom.make((get) => get(stateAtom).current);
|
|
372
|
+
}),
|
|
373
|
+
[getOrCreateState],
|
|
374
|
+
);
|
|
344
375
|
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
376
|
+
const model: TreeModel<Node.Node> = useMemo(
|
|
377
|
+
() => ({
|
|
378
|
+
childIds: (parentId?: string) => childIdsFamily(parentId ?? Node.RootId),
|
|
379
|
+
item: (id: string) => itemFamily(id),
|
|
380
|
+
itemProps: (path: string[]) => itemPropsFamily(path.join('~')),
|
|
381
|
+
itemOpen: (path: string[]) => itemOpenFamily(Path.create(...path)),
|
|
382
|
+
itemCurrent: (path: string[]) => itemCurrentFamily(Path.create(...path)),
|
|
383
|
+
}),
|
|
384
|
+
[childIdsFamily, itemFamily, itemPropsFamily, itemOpenFamily, itemCurrentFamily],
|
|
385
|
+
);
|
|
348
386
|
|
|
349
387
|
const onOpenChange = useCallback(
|
|
350
388
|
({ path: _path, open }: { path: string[]; open: boolean }) => {
|
|
@@ -373,15 +411,7 @@ export const TreeView: Story = {
|
|
|
373
411
|
return (
|
|
374
412
|
<>
|
|
375
413
|
<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
|
-
/>
|
|
414
|
+
<Tree model={model} id={Node.RootId} onOpenChange={onOpenChange} onSelect={onSelect} />
|
|
385
415
|
</>
|
|
386
416
|
);
|
|
387
417
|
},
|
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
|
);
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { invariant } from '@dxos/invariant';
|
|
6
|
+
|
|
7
|
+
import * as Node from './node';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Key separators for compound string keys used across the app-graph package.
|
|
11
|
+
* `primary` separates top-level components (e.g., node ID from relation).
|
|
12
|
+
* `secondary` separates sub-components within an encoded value (e.g., relation kind from direction).
|
|
13
|
+
* `path` separates segments in qualified node IDs (e.g., parent path from local segment).
|
|
14
|
+
* Two distinct characters are needed because secondary separators appear inside primary-separated fields.
|
|
15
|
+
*/
|
|
16
|
+
export const Separators = {
|
|
17
|
+
primary: '\u0001',
|
|
18
|
+
secondary: '\u0002',
|
|
19
|
+
path: '/',
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Normalize a relation input to a full Relation object.
|
|
24
|
+
*/
|
|
25
|
+
export const normalizeRelation = (relation?: Node.RelationInput): Node.Relation =>
|
|
26
|
+
relation == null ? Node.childRelation() : typeof relation === 'string' ? Node.relation(relation) : relation;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Shallow-compare two values: same reference, or same own-keys with === values.
|
|
30
|
+
*/
|
|
31
|
+
export const shallowEqual = (a: unknown, b: unknown): boolean => {
|
|
32
|
+
if (a === b) return true;
|
|
33
|
+
if (a == null || b == null || typeof a !== 'object' || typeof b !== 'object') return false;
|
|
34
|
+
const keysA = Object.keys(a as Record<string, unknown>);
|
|
35
|
+
const keysB = Object.keys(b as Record<string, unknown>);
|
|
36
|
+
if (keysA.length !== keysB.length) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return keysA.every((k) => (a as Record<string, unknown>)[k] === (b as Record<string, unknown>)[k]);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Returns true if two NodeArg arrays are semantically identical (same id, type, data, properties per index).
|
|
44
|
+
*/
|
|
45
|
+
export const nodeArgsUnchanged = (prev: Node.NodeArg<any>[], next: Node.NodeArg<any>[]): boolean => {
|
|
46
|
+
if (prev.length !== next.length) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return prev.every((prevNode, idx) => {
|
|
51
|
+
const nextNode = next[idx];
|
|
52
|
+
return (
|
|
53
|
+
prevNode.id === nextNode.id &&
|
|
54
|
+
prevNode.type === nextNode.type &&
|
|
55
|
+
shallowEqual(prevNode.data, nextNode.data) &&
|
|
56
|
+
shallowEqual(prevNode.properties, nextNode.properties)
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build a qualified node ID by joining a parent path with a local segment.
|
|
63
|
+
*/
|
|
64
|
+
export const qualifyId = (parentId: string, segmentId: string): string => `${parentId}${Separators.path}${segmentId}`;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validate that a segment ID does not contain the path separator.
|
|
68
|
+
*/
|
|
69
|
+
export const validateSegmentId = (id: string): void => {
|
|
70
|
+
invariant(!id.includes(Separators.path), `Node segment ID must not contain '${Separators.path}': ${id}`);
|
|
71
|
+
};
|