GraphDocumentView.tsx15.3 KBView on GitHub 'use client';
/**
* A graph document, as an editable node/edge canvas.
*
* ── Where the data comes from ──
*
* ONE tRPC read (`graphs.readGraph`) returns the schema, the nodes, the edges and the layout,
* because all four live in one Y.Doc and come out of one decode. That is the payoff of keeping
* nodes inside the document: a board has to read its schema from the Y.Doc and its cards from
* SQL, and the two can disagree; a graph cannot.
*
* `hydrateNodes` then resolves the bound fields and overrides. First paint is instant off each
* node's cached `derived`; correctness arrives a beat later; and the two can only ever disagree
* in the pointed-at document's favour.
*
* ── What a gesture does ──
*
* A drag is `moveNode` — ONE `graphLayout` key, so two people dragging two nodes merge. A drawn
* connection is `connect` with the currently selected edge kind. Nothing else on this canvas is
* written back: the computed layout, the lint rings and the hydrated values are overlays.
*
* ── What is in front of the graph, and what is behind a control ──
*
* Only two things sit on the canvas: the colour KEY along the top, and one controls button on
* the left. The view knobs that used to be three always-open selects there — hierarchy axis,
* relations legend, draw-as picker — are settings you touch once and then never, and a canvas
* whose job is to be looked at should not spend its corner on them.
*
* ── The layout has a default now ──
*
* A buying committee usually has NO known reporting lines, and dagre given no edges emits one
* row: a real 37-person chart came out 9,380px wide, unreadable at the zoom `fitView` picks.
* So `graphToFlow` falls back to TIERS by `view.tierField`, and `Auto-format` re-applies them
* on demand. Neither asserts a relation nobody stated.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import '@xyflow/react/dist/style.css';
import {
Background,
ConnectionMode,
Controls,
MiniMap,
Panel,
ReactFlow,
ReactFlowProvider,
applyNodeChanges,
type Connection,
type NodeChange,
} from '@xyflow/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { useTRPC } from '@/providers/query-provider';
import { GraphInspector, type GraphSelection } from '@/modules/graph/GraphInspector';
import { GraphSidebar } from '@/modules/graph/GraphSidebar';
import { GraphLegend } from '@/modules/graph/GraphLegend';
import { GRAPH_EDGE_TYPES } from '@/modules/graph/GraphEdgeLine';
import { GRAPH_NODE_TYPES } from '@/modules/graph/GraphNodeCard';
import { connectionToEdge, dragToMove } from '@/modules/graph/flow-to-graph';
import { computeGraphLayout, graphToFlow } from '@/modules/graph/graph-to-flow';
import type { GraphFlowNode } from '@/modules/graph/graph-flow-types';
export interface GraphDocumentViewProps {
documentId: string;
className?: string;
/** Clicking a node opens ITS document — never a graph-specific detail page. */
onOpenDocument?: (documentId: string) => void;
}
export function GraphDocumentView(props: GraphDocumentViewProps) {
return (
<ReactFlowProvider>
<GraphCanvas {...props} />
</ReactFlowProvider>
);
}
function GraphCanvas({ documentId, className, onOpenDocument }: GraphDocumentViewProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const graphQuery = useQuery(trpc.graphs.readGraph.queryOptions({ graph: documentId }));
/**
* Bound fields, resolved from the pointed-at documents.
*
* `enabled` on the graph read, so it runs AFTER first paint and never before it: each node's
* cached `derived` has already painted the canvas, and blocking on hydration would trade an
* instant graph for a correct-by-a-beat blank one. `readOnly` because the CANVAS is a reader —
* refreshing someone's cache is a side effect of opening a file, and a viewer with no write
* access must not be the reason a write fails.
*/
const hydrateQuery = useQuery({
...trpc.graphs.hydrateNodes.queryOptions({ graph: documentId, readOnly: true }),
enabled: !!graphQuery.data,
});
/** `HydratedNode[]` → the keyed shapes `graphToFlow` takes. */
const hydration = useMemo(() => {
const rows = hydrateQuery.data?.nodes;
if (!rows) return null;
const hydrated: Record<string, Record<string, unknown>> = {};
const missingDocumentIds = new Set<string>();
for (const row of rows) {
hydrated[row.nodeId] = row.derived;
if (row.missingDocument) missingDocumentIds.add(row.nodeId);
}
return { hydrated, missingDocumentIds };
}, [hydrateQuery.data]);
const invalidate = useCallback(() => {
void queryClient.invalidateQueries({ queryKey=[redacted] });
}, [queryClient, trpc]);
const onError = useCallback((error: unknown) => {
toast.error(error instanceof Error ? error.message : 'Could not update the graph');
}, []);
const moveNode = useMutation({
...trpc.graphs.moveNode.mutationOptions(),
// Deliberately NOT invalidating: a drag already moved the node on screen, and re-reading
// would snap it back to the server's position for a frame. The write is one layout key; if
// it fails, the error surfaces and the next open reads the truth.
onError,
});
const connect = useMutation({
...trpc.graphs.connect.mutationOptions(),
onSuccess: (result) => {
invalidate();
// The lint delta, surfaced where the person who caused it is looking. An agent gets this
// in its tool result; a human gets it here, or the feedback loop exists for only one of
// the two callers.
const lint = (result as { lint?: Array<{ message: string }> }).lint ?? [];
for (const finding of lint) toast.warning(finding.message);
},
onError,
});
const setLayout = useMutation({ ...trpc.graphs.setLayout.mutationOptions(), onError });
const data = graphQuery.data;
const edgeKinds = useMemo(() => data?.schema.edgeKinds ?? [], [data]);
// The kind a drawn connection gets. The layout axis — the relation this graph is ABOUT —
// rather than the first declared kind, which is an ordering accident. There is no picker for
// it any more: choosing the relation before drawing is a step nobody took, and an edge of the
// wrong kind is one `disconnect` + `connect` away in the inspector.
const activeKind = data?.schema.view?.layoutEdgeKind ?? edgeKinds[0]?.key ?? null;
const flow = useMemo(() => {
if (!data) return null;
return graphToFlow({
schema: data.schema,
nodes: data.nodes,
edges: data.edges,
layout: data.layout,
...(hydration ? { hydrated: hydration.hydrated } : {}),
...(hydration ? { missingDocumentIds: hydration.missingDocumentIds } : {}),
});
}, [data, hydration]);
// Local node state so a drag is smooth; the server holds the truth and a re-read replaces it.
const [nodes, setNodes] = useState<GraphFlowNode[]>([]);
useEffect(() => {
if (flow) setNodes(flow.nodes);
}, [flow]);
// Persist a computed layout ONCE, so the next open is stable and any later re-layout is a
// deliberate act rather than something that happens because someone opened the file.
const toPersist = flow?.toPersist;
useEffect(() => {
if (!toPersist || Object.keys(toPersist).length === 0) return;
// `onlyAbsent` so this cannot clobber a position the user dragged while the write was in
// flight: the server decides against the layout as it stands when the write lands, which
// converges whichever order the two arrive in.
setLayout.mutate({ graph: documentId, layout: toPersist, onlyAbsent: true });
// `setLayout` is a stable mutation handle; including it would re-fire this on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toPersist, documentId]);
const onNodesChange = useCallback((changes: NodeChange<GraphFlowNode>[]) => {
setNodes((current) => applyNodeChanges(changes, current));
}, []);
const onNodeDragStop = useCallback(
(_event: unknown, node: GraphFlowNode) => {
moveNode.mutate({ graph: documentId, ...dragToMove(node) });
},
[documentId, moveNode],
);
const onConnect = useCallback(
(connection: Connection) => {
if (!activeKind) {
toast.error('This graph declares no relations yet — add one before drawing a connection.');
return;
}
const edge = connectionToEdge(connection, activeKind);
if (!edge) return;
connect.mutate({ graph: documentId, ...edge });
},
[activeKind, connect, documentId],
);
/**
* ONE selection, for one top-right surface.
*
* A node and an edge are both "the thing you just clicked", so they share a slot rather than
* each getting a floating card that competes with the other for the same corner.
*/
const [selection, setSelection] = useState<GraphSelection>(null);
const onNodeClick = useCallback(
(_event: unknown, node: GraphFlowNode) => setSelection({ kind: 'node', nodeId: node.id }),
[],
);
/**
* Re-tidy the layout, and PERSIST it.
*
* Deliberately an explicit action rather than something that happens on open: positions are
* authored — somebody dragged these — and a canvas that silently re-flowed every time it was
* opened would throw that away. It re-tiers rather than re-ranking, because the case worth a
* button is exactly the one with no edges to rank by.
*/
const autoFormat = useCallback(() => {
if (!data) return;
// The SAME function the automatic pass uses — reporting tree on top, everyone else tiered
// below. It used to call `tierLayout` directly, which threw away the reporting structure
// the open had just drawn, so the button appeared to do nothing useful or to make things
// worse. One layout, two callers.
const axis = data.schema.view?.layoutEdgeKind;
const layoutEdges = Object.values(data.edges).filter((e) => !axis || e.kind === axis);
const layout = computeGraphLayout(
{ schema: data.schema, nodes: data.nodes },
layoutEdges,
);
setLayout.mutate({ graph: documentId, layout }, { onSuccess: invalidate });
}, [data, documentId, setLayout, invalidate]);
/** The colour-field values this graph actually uses — so the key describes THIS chart. */
const colorValuesInUse = useMemo(() => {
const key=[redacted];
const out = new Set<string>();
if (!data || !key) return out;
const field = data.schema.nodeSchema.fields.find((f) => f.key === key);
for (const node of Object.values(data.nodes)) {
const raw = field?.from ? node.derived?.[key] : node.fields[key];
const value = Array.isArray(raw) ? raw[0] : raw;
if (value !== undefined && value !== null && value !== '') out.add(String(value));
}
return out;
}, [data]);
const onEdgeClick = useCallback(
(_event: unknown, edge: { id: string }) => setSelection({ kind: 'edge', edgeKey=[redacted] }),
[],
);
const setNode = useMutation({
...trpc.graphs.setNode.mutationOptions(),
onSuccess: invalidate,
onError,
});
const setEdge = useMutation({
...trpc.graphs.setEdge.mutationOptions(),
onSuccess: invalidate,
onError,
});
// The schema editor writes the two things that decide how the canvas DRAWS: `view` (which
// field tints a card, which relation is the hierarchy, which field tiers it) and the node
// fields' `displayOnCard`. Both re-paint from `graphQuery.data`, so both invalidate.
const setView = useMutation({
...trpc.graphs.setView.mutationOptions(),
onSuccess: invalidate,
onError,
});
const addFields = useMutation({
...trpc.graphs.addFields.mutationOptions(),
onSuccess: invalidate,
onError,
});
const onChangeNodeField = useCallback(
(nodeId: string, key=[redacted], value: string) =>
setNode.mutate({ graph: documentId, node: nodeId, fields: { [key]: value } }),
[documentId, setNode],
);
const onChangeEdgeField = useCallback(
(edgeKey=[redacted], key=[redacted], value: string) => {
const edge = data?.edges[edgeKey];
if (!edge) return;
setEdge.mutate({
graph: documentId,
source: edge.source,
target: edge.target,
kind: edge.kind,
fields: { [key]: value },
});
},
[data, documentId, setEdge],
);
if (graphQuery.isLoading) {
return <div className={cn('flex min-h-0 flex-1 items-center justify-center', className)} />;
}
if (!data || !flow) {
return (
<div className={cn('flex min-h-0 flex-1 items-center justify-center p-8', className)}>
<p className="text-sm text-muted-foreground">This graph could not be read.</p>
</div>
);
}
return (
<div className={cn('h-full w-full', className)}>
<ReactFlow
nodes={nodes}
edges={flow.edges}
onNodesChange={onNodesChange}
onNodeDragStop={onNodeDragStop}
onConnect={onConnect}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onPaneClick={() => setSelection(null)}
nodeTypes={GRAPH_NODE_TYPES}
edgeTypes={GRAPH_EDGE_TYPES}
// Every handle is both an end and a start, so a connection can be drawn from either
// dot in either direction. Which side an edge then RENDERS from is decided per-frame
// from where the two cards sit — see `floating-edge.ts`.
connectionMode={ConnectionMode.Loose}
fitView
proOptions={{ hideAttribution: true }}
className="bg-background"
>
<Background gap={16} />
<Controls showInteractive={false} />
<MiniMap pannable zoomable className="!bg-muted" />
{/* LEFT — an icon rail, vertically centred. Centred rather than tucked into the top
corner because it is the canvas's own toolbar and belongs beside the drawing, not
stacked with the legend and the inspector along the top edge. */}
<Panel position="center-left">
<GraphSidebar
schema={data.schema}
onAutoFormat={autoFormat}
onAddNode={() =>
toast.info('Add a person from the deal’s contacts — coming with the per-deal agent.')
}
onSetView={(view) => setView.mutate({ graph: documentId, view })}
onSetFields={(fields) => addFields.mutate({ graph: documentId, fields })}
/>
</Panel>
{/* BOTTOM — the colour key. Read once on arrival and then never again, which is the
whole lifecycle a legend should have, and the reason it belongs at the foot of the
canvas rather than across the top of the drawing it explains. */}
<Panel position="bottom-center">
<GraphLegend schema={data.schema} valuesInUse={colorValuesInUse} />
</Panel>
<Panel position="top-right">
<GraphInspector
selection={selection}
graphName={data.title ?? 'this graph'}
schema={data.schema}
nodes={data.nodes}
edges={data.edges}
{...(hydration ? { hydrated: hydration.hydrated } : {})}
{...(hydration ? { missingDocumentIds: hydration.missingDocumentIds } : {})}
onClose={() => setSelection(null)}
{...(onOpenDocument ? { onOpenDocument } : {})}
onChangeNodeField={onChangeNodeField}
onChangeEdgeField={onChangeEdgeField}
/>
</Panel>
</ReactFlow>
</div>
);
}