@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.
@@ -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 { byPosition, isNonNullable, safeParseInt } from '@dxos/util';
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.org/type/Space',
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.org/type/test',
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='is-[100px] text-right pie-[22px]'
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 inline-end-1 block-start-1 mt-[6px]', getSize(3))} />
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
- () => (path: string) => {
295
- let atom = stateRef.current.get(path);
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(path, atom);
299
+ stateRef.current.set(pathKey, atom);
299
300
  }
300
301
  return atom;
301
302
  },
302
303
  [],
303
304
  );
304
305
 
305
- const useItems = useCallback(
306
- (node?: Node.Node, options?: { disposition?: string; sort?: boolean }) => {
307
- const connections = useAtomValue(graph.connections(node?.id ?? Node.RootId));
308
- return options?.sort ? connections.toSorted((a, b) => byPosition(a.properties, b.properties)) : connections;
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 getProps = useCallback(
314
- (node: Node.Node, path: string[]) => {
315
- const children = Graph.getConnections(graph, node.id, 'outbound')
316
- .map((n) => {
317
- // Break cycles.
318
- const nextPath = [...path, node.id];
319
- return nextPath.includes(n.id) ? undefined : (n as Node.Node);
320
- })
321
- .filter(isNonNullable) as Node.Node[];
322
- const parentOf =
323
- children.length > 0 ? children.map(({ id }) => id) : node.properties.role === 'branch' ? [] : undefined;
324
- return {
325
- id: node.id,
326
- label: node.id,
327
- icon: node.type === 'dxos.org/type/Space' ? 'ph--planet--regular' : 'ph--placeholder--regular',
328
- parentOf,
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
- // Hook that subscribes to item state via Atom.
335
- const useItemState = (_path: string[]) => {
336
- const path = useMemo(() => Path.create(..._path), [_path.join('~')]);
337
- const atom = getOrCreateState(path);
338
- return useAtomValue(atom);
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 useIsOpen = (_path: string[]) => {
342
- return useItemState(_path).open;
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 useIsCurrent = (_path: string[]) => {
346
- return useItemState(_path).current;
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
  },
@@ -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 pli-2 border border-l-0 font-mono truncate', className)} {...props}>
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
+ };