@tulipfarm/sprout 0.2.0 → 0.3.1
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/README.md +111 -0
- package/dist/components/agent-fanout.d.ts +46 -0
- package/dist/components/agent-fanout.js +228 -0
- package/dist/components/chat-confirmation.d.ts +4 -2
- package/dist/components/chat-confirmation.js +2 -2
- package/dist/components/chat-sources.js +11 -2
- package/dist/components/presence-cursor.d.ts +19 -0
- package/dist/components/presence-cursor.js +14 -0
- package/dist/components/result-card.d.ts +6 -1
- package/dist/components/result-card.js +2 -2
- package/dist/components/schema-diagram.js +5 -78
- package/dist/components/sql-viewer.d.ts +76 -0
- package/dist/components/sql-viewer.js +409 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/internal/flow.d.ts +22 -0
- package/dist/internal/flow.js +82 -0
- package/dist/styles/agent-fanout.css +529 -0
- package/dist/styles/index.css +3 -0
- package/dist/styles/message.css +1 -1
- package/dist/styles/presence-cursor.css +68 -0
- package/dist/styles/result-card.css +14 -0
- package/dist/styles/sql-viewer.css +406 -0
- package/dist/styles/themes/tulipfarm.css +1 -1
- package/package.json +19 -7
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import { Background, BaseEdge, EdgeLabelRenderer, getSmoothStepPath, Handle,
|
|
4
|
-
import {
|
|
5
|
-
import { Add } from "reicon-react/icons/Add";
|
|
3
|
+
import { Background, BaseEdge, EdgeLabelRenderer, getSmoothStepPath, Handle, Position, ReactFlow, ReactFlowProvider, } from "@xyflow/react";
|
|
4
|
+
import { useId, useMemo } from "react";
|
|
6
5
|
import { Key } from "reicon-react/icons/Key";
|
|
7
|
-
import { Minus } from "reicon-react/icons/Minus";
|
|
8
|
-
import { Scan } from "reicon-react/icons/Scan";
|
|
9
6
|
import { cn } from "../internal/classes.js";
|
|
7
|
+
import { FitOnResize, flowMaxZoom as maxZoom, flowMinZoom as minZoom, useKeyboardView, useViewportMobile, ZoomControls, } from "../internal/flow.js";
|
|
10
8
|
import { Badge } from "./badge.js";
|
|
11
|
-
import { Button } from "./button.js";
|
|
12
9
|
export const schemaTableStateNames = [
|
|
13
10
|
"new",
|
|
14
11
|
"changed",
|
|
@@ -24,10 +21,7 @@ export const schemaDiagramLayoutNames = [
|
|
|
24
21
|
const tableWidth = 264;
|
|
25
22
|
const columnGap = 80;
|
|
26
23
|
const rowGap = 48;
|
|
27
|
-
const minZoom = 0.2;
|
|
28
|
-
const maxZoom = 1.5;
|
|
29
24
|
const fitOptions = { padding: 0.15, maxZoom: 1 };
|
|
30
|
-
const panStep = 48;
|
|
31
25
|
const tableHeight = (table) => 44 +
|
|
32
26
|
(table.fields.length === 0
|
|
33
27
|
? 32
|
|
@@ -117,80 +111,13 @@ function LinkLine({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targ
|
|
|
117
111
|
}
|
|
118
112
|
const nodeTypes = { table: TableCard };
|
|
119
113
|
const edgeTypes = { link: LinkLine };
|
|
120
|
-
const mobileQuery = "(max-width: 767px)";
|
|
121
|
-
const motionQuery = "(prefers-reduced-motion: reduce)";
|
|
122
|
-
function subscribeMobile(callback) {
|
|
123
|
-
const media = window.matchMedia(mobileQuery);
|
|
124
|
-
media.addEventListener("change", callback);
|
|
125
|
-
return () => media.removeEventListener("change", callback);
|
|
126
|
-
}
|
|
127
|
-
function useViewportMobile() {
|
|
128
|
-
return useSyncExternalStore(subscribeMobile, () => window.matchMedia(mobileQuery).matches, () => false);
|
|
129
|
-
}
|
|
130
|
-
const motion = (ms) => typeof window !== "undefined" && window.matchMedia(motionQuery).matches
|
|
131
|
-
? 0
|
|
132
|
-
: ms;
|
|
133
|
-
// The canvas panel animates open, so the first fit sees a narrower pane than the final one.
|
|
134
|
-
function FitOnResize({ nodes }) {
|
|
135
|
-
const { fitView } = useReactFlow();
|
|
136
|
-
const width = useStore((state) => state.width);
|
|
137
|
-
const height = useStore((state) => state.height);
|
|
138
|
-
const measured = useNodesInitialized();
|
|
139
|
-
useEffect(() => {
|
|
140
|
-
if (!measured)
|
|
141
|
-
return;
|
|
142
|
-
const frame = requestAnimationFrame(() => void fitView(fitOptions));
|
|
143
|
-
return () => cancelAnimationFrame(frame);
|
|
144
|
-
}, [fitView, width, height, measured, nodes]);
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
function ZoomControls({ position, }) {
|
|
148
|
-
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
|
149
|
-
const zoom = useStore((state) => state.transform[2]);
|
|
150
|
-
return (_jsxs(Panel, { position: position, className: "sprout-schema-controls", "data-slot": "schema-diagram-controls", role: "toolbar", "aria-label": "Diagram view", "aria-orientation": "vertical", children: [_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Zoom in", title: "Zoom in", disabled: zoom >= maxZoom, onClick: () => void zoomIn({ duration: motion(160) }), children: _jsx(Add, { size: 16, "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Zoom out", title: "Zoom out", disabled: zoom <= minZoom, onClick: () => void zoomOut({ duration: motion(160) }), children: _jsx(Minus, { size: 16, "aria-hidden": "true" }) }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Fit to view", title: "Fit to view", onClick: () => void fitView({ ...fitOptions, duration: motion(180) }), children: _jsx(Scan, { size: 16, "aria-hidden": "true" }) })] }));
|
|
151
|
-
}
|
|
152
|
-
function useKeyboardView() {
|
|
153
|
-
const { getViewport, setViewport, zoomIn, zoomOut, fitView } = useReactFlow();
|
|
154
|
-
return (event) => {
|
|
155
|
-
const target = event.target;
|
|
156
|
-
if (event.altKey || event.ctrlKey || event.metaKey)
|
|
157
|
-
return;
|
|
158
|
-
if (target !== event.currentTarget &&
|
|
159
|
-
!target.classList.contains("react-flow__node"))
|
|
160
|
-
return;
|
|
161
|
-
const step = panStep * (event.shiftKey ? 4 : 1);
|
|
162
|
-
const pan = {
|
|
163
|
-
ArrowLeft: [step, 0],
|
|
164
|
-
ArrowRight: [-step, 0],
|
|
165
|
-
ArrowUp: [0, step],
|
|
166
|
-
ArrowDown: [0, -step],
|
|
167
|
-
};
|
|
168
|
-
const move = pan[event.key];
|
|
169
|
-
if (move) {
|
|
170
|
-
const { x, y, zoom } = getViewport();
|
|
171
|
-
void setViewport({ x: x + move[0], y: y + move[1], zoom }, { duration: motion(120) });
|
|
172
|
-
}
|
|
173
|
-
else if (event.key === "+" || event.key === "=") {
|
|
174
|
-
void zoomIn({ duration: motion(160) });
|
|
175
|
-
}
|
|
176
|
-
else if (event.key === "-" || event.key === "_") {
|
|
177
|
-
void zoomOut({ duration: motion(160) });
|
|
178
|
-
}
|
|
179
|
-
else if (event.key === "0") {
|
|
180
|
-
void fitView({ ...fitOptions, duration: motion(180) });
|
|
181
|
-
}
|
|
182
|
-
else
|
|
183
|
-
return;
|
|
184
|
-
event.preventDefault();
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
114
|
const keyboardHint = "Arrow keys pan, plus and minus zoom, and 0 fits the diagram. Tab moves between tables.";
|
|
188
115
|
function DiagramFlow({ nodes, edges, direction, label, hintId, }) {
|
|
189
|
-
const onKeyDown = useKeyboardView();
|
|
116
|
+
const onKeyDown = useKeyboardView(fitOptions);
|
|
190
117
|
return (_jsxs(ReactFlow, { className: "sprout-schema-flow", "data-slot": "schema-diagram-viewport", "aria-label": label, "aria-describedby": hintId, tabIndex: 0, onKeyDown: onKeyDown, nodes: nodes, edges: edges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, fitView: true, fitViewOptions: fitOptions, minZoom: minZoom, maxZoom: maxZoom, nodesConnectable: false, elementsSelectable: false, edgesFocusable: false, ariaLabelConfig: {
|
|
191
118
|
"node.a11yDescription.default": keyboardHint,
|
|
192
119
|
"node.a11yDescription.keyboardDisabled": keyboardHint,
|
|
193
|
-
}, proOptions: { hideAttribution: true }, children: [_jsx(Background, { gap: 16, size: 1 }), _jsx(FitOnResize, { nodes: nodes }), _jsx(ZoomControls, { position: direction === "vertical" ? "top-right" : "bottom-right" })] }));
|
|
120
|
+
}, proOptions: { hideAttribution: true }, children: [_jsx(Background, { gap: 16, size: 1 }), _jsx(FitOnResize, { nodes: nodes, fit: fitOptions }), _jsx(ZoomControls, { position: direction === "vertical" ? "top-right" : "bottom-right", fit: fitOptions, className: "sprout-schema-controls", slot: "schema-diagram-controls" })] }));
|
|
194
121
|
}
|
|
195
122
|
/**
|
|
196
123
|
* A pan-and-zoom diagram of tables, their fields, and the links between them,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type HTMLAttributes } from "react";
|
|
2
|
+
export declare const sqlViewerLayoutNames: readonly ["auto", "horizontal", "vertical"];
|
|
3
|
+
export type SqlViewerLayout = (typeof sqlViewerLayoutNames)[number];
|
|
4
|
+
export type SqlViewerCardinality = "one" | "many";
|
|
5
|
+
export interface SqlViewerOption {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
retired: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** One field of a TulipFarm table definition (format 2) or table document (format 3). */
|
|
11
|
+
export type SqlViewerField = {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
required: boolean;
|
|
15
|
+
} & ({
|
|
16
|
+
kind: "text";
|
|
17
|
+
maxLength: number;
|
|
18
|
+
} | {
|
|
19
|
+
kind: "numeric";
|
|
20
|
+
precision: number;
|
|
21
|
+
scale: number;
|
|
22
|
+
} | {
|
|
23
|
+
kind: "boolean";
|
|
24
|
+
} | {
|
|
25
|
+
kind: "date";
|
|
26
|
+
} | {
|
|
27
|
+
kind: "timestamp";
|
|
28
|
+
timezone?: "UTC";
|
|
29
|
+
} | {
|
|
30
|
+
kind: "single-select" | "multi-select";
|
|
31
|
+
options: readonly SqlViewerOption[];
|
|
32
|
+
} | {
|
|
33
|
+
kind: "relationship";
|
|
34
|
+
/** A table name (format 3) or table UUID (format 2), matching a table's `id`. */
|
|
35
|
+
targetTableId: string;
|
|
36
|
+
cardinality: SqlViewerCardinality;
|
|
37
|
+
inverseCardinality: SqlViewerCardinality;
|
|
38
|
+
reverse?: {
|
|
39
|
+
fieldId: string;
|
|
40
|
+
label: string;
|
|
41
|
+
} | null;
|
|
42
|
+
onDelete?: "restrict";
|
|
43
|
+
} | {
|
|
44
|
+
kind: "file";
|
|
45
|
+
cardinality: SqlViewerCardinality;
|
|
46
|
+
access?: "independent";
|
|
47
|
+
});
|
|
48
|
+
export interface SqlViewerUniqueKey {
|
|
49
|
+
id: string;
|
|
50
|
+
fieldIds: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
/** A TulipFarm table definition or table document. Extra keys are ignored. */
|
|
53
|
+
export interface SqlViewerTable {
|
|
54
|
+
id: string;
|
|
55
|
+
label: string;
|
|
56
|
+
owningTeamId?: string;
|
|
57
|
+
fields: readonly SqlViewerField[];
|
|
58
|
+
uniqueKeys?: readonly SqlViewerUniqueKey[];
|
|
59
|
+
}
|
|
60
|
+
export interface SqlViewerProps extends Omit<HTMLAttributes<HTMLDivElement>, "role" | "children"> {
|
|
61
|
+
/** TulipFarm table definitions or table documents, in display order. */
|
|
62
|
+
tables: readonly SqlViewerTable[];
|
|
63
|
+
/** Names the diagram for assistive tech, such as “Tables in Sales”. */
|
|
64
|
+
label: string;
|
|
65
|
+
/** Team names by `owningTeamId`. Tables of a named team sit in one tinted group. */
|
|
66
|
+
groups?: Readonly<Record<string, string>> | undefined;
|
|
67
|
+
/** Tables shown before the rest collapse into one “+N tables” card. Default 4. */
|
|
68
|
+
maxVisible?: number | undefined;
|
|
69
|
+
/** `auto` stacks tables under 768px and uses columns otherwise. */
|
|
70
|
+
layout?: SqlViewerLayout | undefined;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A pan-and-zoom map of SQL tables, their columns and keys, and the joins
|
|
74
|
+
* between them. Hover or pin a table or a foreign key to trace its joins.
|
|
75
|
+
*/
|
|
76
|
+
export declare function SqlViewer({ tables, label, groups, maxVisible, layout, className, ...props }: SqlViewerProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Background, getSmoothStepPath, Handle, Panel, Position, ReactFlow, ReactFlowProvider, useInternalNode, useNodesState, } from "@xyflow/react";
|
|
4
|
+
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useState, } from "react";
|
|
5
|
+
import { Fingerprint } from "reicon-react/icons/Fingerprint";
|
|
6
|
+
import { Key } from "reicon-react/icons/Key";
|
|
7
|
+
import { Layers } from "reicon-react/icons/Layers";
|
|
8
|
+
import { Link } from "reicon-react/icons/Link";
|
|
9
|
+
import { cn } from "../internal/classes.js";
|
|
10
|
+
import { FitOnResize, flowMaxZoom, flowMinZoom, useKeyboardView, useViewportMobile, ZoomControls, } from "../internal/flow.js";
|
|
11
|
+
import { Button } from "./button.js";
|
|
12
|
+
export const sqlViewerLayoutNames = ["auto", "horizontal", "vertical"];
|
|
13
|
+
const moreId = "~more";
|
|
14
|
+
const groupId = (key) => `~group:${key}`;
|
|
15
|
+
// Card geometry. The CSS uses the same numbers so layout never waits for a measure.
|
|
16
|
+
const cardWidth = 272;
|
|
17
|
+
const cardChrome = 4;
|
|
18
|
+
const headerHeight = 40;
|
|
19
|
+
const rowHeight = 28;
|
|
20
|
+
const keysChrome = 36;
|
|
21
|
+
const keyHeight = 26;
|
|
22
|
+
const moreHeight = 132;
|
|
23
|
+
const colGap = 104;
|
|
24
|
+
const rowGap = 32;
|
|
25
|
+
const groupPad = 20;
|
|
26
|
+
const groupHeader = 44;
|
|
27
|
+
const groupGap = 56;
|
|
28
|
+
const wrapWidth = 1400;
|
|
29
|
+
const tones = 5;
|
|
30
|
+
const fitAll = { padding: 0.12, maxZoom: 1 };
|
|
31
|
+
const compositeKeys = (table) => (table.uniqueKeys ?? []).filter((key) => key.fieldIds.length > 1);
|
|
32
|
+
const cardHeight = (table) => {
|
|
33
|
+
const keys = compositeKeys(table).length;
|
|
34
|
+
return (cardChrome +
|
|
35
|
+
headerHeight +
|
|
36
|
+
(table.fields.length + 1) * rowHeight +
|
|
37
|
+
(keys ? keysChrome + keys * keyHeight : 0));
|
|
38
|
+
};
|
|
39
|
+
function columnType(field) {
|
|
40
|
+
switch (field.kind) {
|
|
41
|
+
case "text":
|
|
42
|
+
return `varchar(${field.maxLength})`;
|
|
43
|
+
case "numeric":
|
|
44
|
+
return `numeric(${field.precision},${field.scale})`;
|
|
45
|
+
case "timestamp":
|
|
46
|
+
return "timestamptz";
|
|
47
|
+
case "single-select":
|
|
48
|
+
return "enum";
|
|
49
|
+
case "multi-select":
|
|
50
|
+
return "enum[]";
|
|
51
|
+
case "relationship":
|
|
52
|
+
return field.cardinality === "one" ? "uuid" : "uuid[]";
|
|
53
|
+
case "file":
|
|
54
|
+
return field.cardinality === "one" ? "file" : "file[]";
|
|
55
|
+
default:
|
|
56
|
+
return field.kind;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function joinText(from, field, to, many) {
|
|
60
|
+
return many
|
|
61
|
+
? `${to}.id = ANY(${from}.${field})`
|
|
62
|
+
: `${from}.${field} = ${to}.id`;
|
|
63
|
+
}
|
|
64
|
+
/** Places tables in team groups. Linked tables sit one column right of the tables that link to them. */
|
|
65
|
+
function arrange(visible, hidden, groups, direction) {
|
|
66
|
+
const blocks = [];
|
|
67
|
+
for (const table of visible) {
|
|
68
|
+
const team = table.owningTeamId;
|
|
69
|
+
const key = team !== undefined && groups?.[team] ? team : null;
|
|
70
|
+
let block = blocks.find((candidate) => candidate.key === key);
|
|
71
|
+
if (!block) {
|
|
72
|
+
block = { key, label: key ? groups[key] : "", tables: [] };
|
|
73
|
+
blocks.push(block);
|
|
74
|
+
}
|
|
75
|
+
block.tables.push(table);
|
|
76
|
+
}
|
|
77
|
+
const nodes = [];
|
|
78
|
+
let cursorX = 0;
|
|
79
|
+
let cursorY = 0;
|
|
80
|
+
let lineHeight = 0;
|
|
81
|
+
let right = 0;
|
|
82
|
+
let bottom = 0;
|
|
83
|
+
let tone = 0;
|
|
84
|
+
for (const block of blocks) {
|
|
85
|
+
const ids = new Set(block.tables.map((table) => table.id));
|
|
86
|
+
const depth = new Map(block.tables.map((table) => [table.id, 0]));
|
|
87
|
+
if (direction === "horizontal") {
|
|
88
|
+
for (let pass = 0; pass < block.tables.length; pass++) {
|
|
89
|
+
let moved = false;
|
|
90
|
+
for (const table of block.tables)
|
|
91
|
+
for (const field of table.fields) {
|
|
92
|
+
if (field.kind !== "relationship")
|
|
93
|
+
continue;
|
|
94
|
+
const target = field.targetTableId;
|
|
95
|
+
const next = depth.get(table.id) + 1;
|
|
96
|
+
if (target !== table.id &&
|
|
97
|
+
ids.has(target) &&
|
|
98
|
+
next > depth.get(target) &&
|
|
99
|
+
next < block.tables.length) {
|
|
100
|
+
depth.set(target, next);
|
|
101
|
+
moved = true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!moved)
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const columnBottoms = new Map();
|
|
109
|
+
const placed = block.tables.map((table) => {
|
|
110
|
+
const column = depth.get(table.id);
|
|
111
|
+
const y = columnBottoms.get(column) ?? 0;
|
|
112
|
+
columnBottoms.set(column, y + cardHeight(table) + rowGap);
|
|
113
|
+
return { table, x: column * (cardWidth + colGap), y };
|
|
114
|
+
});
|
|
115
|
+
const innerWidth = Math.max(...placed.map((item) => item.x)) + cardWidth;
|
|
116
|
+
const innerHeight = Math.max(...columnBottoms.values()) - rowGap;
|
|
117
|
+
const framed = block.key !== null;
|
|
118
|
+
const width = framed ? innerWidth + groupPad * 2 : innerWidth;
|
|
119
|
+
const height = framed ? innerHeight + groupHeader + groupPad : innerHeight;
|
|
120
|
+
if (direction === "horizontal" &&
|
|
121
|
+
cursorX > 0 &&
|
|
122
|
+
cursorX + width > wrapWidth) {
|
|
123
|
+
cursorX = 0;
|
|
124
|
+
cursorY += lineHeight + groupGap;
|
|
125
|
+
lineHeight = 0;
|
|
126
|
+
}
|
|
127
|
+
const blockTone = framed ? (tone++ % tones) + 1 : 0;
|
|
128
|
+
const parent = framed ? groupId(block.key) : undefined;
|
|
129
|
+
if (framed)
|
|
130
|
+
nodes.push({
|
|
131
|
+
id: parent,
|
|
132
|
+
type: "group",
|
|
133
|
+
position: { x: cursorX, y: cursorY },
|
|
134
|
+
width,
|
|
135
|
+
height,
|
|
136
|
+
data: {
|
|
137
|
+
label: block.label,
|
|
138
|
+
tone: blockTone,
|
|
139
|
+
count: block.tables.length,
|
|
140
|
+
},
|
|
141
|
+
dragHandle: ".sprout-sql-group-label",
|
|
142
|
+
focusable: false,
|
|
143
|
+
selectable: false,
|
|
144
|
+
});
|
|
145
|
+
for (const item of placed)
|
|
146
|
+
nodes.push({
|
|
147
|
+
id: item.table.id,
|
|
148
|
+
type: "table",
|
|
149
|
+
position: framed
|
|
150
|
+
? { x: groupPad + item.x, y: groupHeader + item.y }
|
|
151
|
+
: { x: cursorX + item.x, y: cursorY + item.y },
|
|
152
|
+
...(parent ? { parentId: parent, expandParent: true } : {}),
|
|
153
|
+
data: { table: item.table, tone: blockTone },
|
|
154
|
+
ariaRole: "group",
|
|
155
|
+
ariaLabel: `${item.table.label} table, ${item.table.fields.length + 1} columns`,
|
|
156
|
+
domAttributes: { "aria-roledescription": "table card" },
|
|
157
|
+
});
|
|
158
|
+
right = Math.max(right, cursorX + width);
|
|
159
|
+
bottom = Math.max(bottom, cursorY + height);
|
|
160
|
+
if (direction === "horizontal") {
|
|
161
|
+
cursorX += width + groupGap;
|
|
162
|
+
lineHeight = Math.max(lineHeight, height);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
cursorY += height + groupGap;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (hidden.length)
|
|
169
|
+
nodes.push({
|
|
170
|
+
id: moreId,
|
|
171
|
+
type: "more",
|
|
172
|
+
position: direction === "horizontal"
|
|
173
|
+
? { x: right + colGap, y: 0 }
|
|
174
|
+
: { x: 0, y: bottom + groupGap },
|
|
175
|
+
data: { tables: [...hidden] },
|
|
176
|
+
focusable: false,
|
|
177
|
+
});
|
|
178
|
+
return nodes;
|
|
179
|
+
}
|
|
180
|
+
function joins(visible, hidden) {
|
|
181
|
+
const shown = new Set(visible.map((table) => table.id));
|
|
182
|
+
const collapsed = new Set(hidden.map((table) => table.id));
|
|
183
|
+
const labels = new Map([...visible, ...hidden].map((table) => [table.id, table.label]));
|
|
184
|
+
const node = (id) => shown.has(id) ? id : collapsed.has(id) ? moreId : null;
|
|
185
|
+
const edges = [];
|
|
186
|
+
for (const table of [...visible, ...hidden])
|
|
187
|
+
for (const field of table.fields) {
|
|
188
|
+
if (field.kind !== "relationship")
|
|
189
|
+
continue;
|
|
190
|
+
const source = node(table.id);
|
|
191
|
+
const target = node(field.targetTableId);
|
|
192
|
+
if (!source || !target || (source === moreId && target === moreId))
|
|
193
|
+
continue;
|
|
194
|
+
const to = labels.get(field.targetTableId);
|
|
195
|
+
const sourceMany = field.inverseCardinality === "many";
|
|
196
|
+
const targetMany = field.cardinality === "many";
|
|
197
|
+
edges.push({
|
|
198
|
+
id: `${table.id}/${field.id}`,
|
|
199
|
+
type: "join",
|
|
200
|
+
source,
|
|
201
|
+
sourceHandle: source === moreId ? "out:right" : `${field.id}:right`,
|
|
202
|
+
target,
|
|
203
|
+
targetHandle: target === moreId ? "in:left" : "id:left",
|
|
204
|
+
data: {
|
|
205
|
+
join: joinText(table.label, field.label, to, targetMany),
|
|
206
|
+
description: `${table.label}.${field.label} joins ${to}.id, ${sourceMany ? "many" : "one"} to ${targetMany ? "many" : "one"}`,
|
|
207
|
+
sourceMany,
|
|
208
|
+
targetMany,
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return edges;
|
|
213
|
+
}
|
|
214
|
+
const ViewerContext = createContext(null);
|
|
215
|
+
const useViewer = () => useContext(ViewerContext);
|
|
216
|
+
function sideHandles(type, id) {
|
|
217
|
+
return (_jsxs(_Fragment, { children: [_jsx(Handle, { id: `${id}:left`, type: type, position: Position.Left, isConnectable: false, className: "sprout-sql-handle" }), _jsx(Handle, { id: `${id}:right`, type: type, position: Position.Right, isConnectable: false, className: "sprout-sql-handle" })] }));
|
|
218
|
+
}
|
|
219
|
+
function TableCard({ id, data: { table, tone } }) {
|
|
220
|
+
const viewer = useViewer();
|
|
221
|
+
const composite = compositeKeys(table);
|
|
222
|
+
const unique = new Set((table.uniqueKeys ?? [])
|
|
223
|
+
.filter((key) => key.fieldIds.length === 1)
|
|
224
|
+
.map((key) => key.fieldIds[0]));
|
|
225
|
+
const labels = new Map(table.fields.map((field) => [field.id, field.label]));
|
|
226
|
+
const focused = viewer.focus?.table === id;
|
|
227
|
+
return (_jsxs("section", { "data-slot": "sql-table", "data-tone": tone, "data-dimmed": viewer.focus && !viewer.related.has(id) ? "" : undefined, "data-active": focused ? "" : undefined, className: "sprout-sql-table", onMouseLeave: () => viewer.hover(null), children: [_jsx("header", { className: "sprout-sql-table-header", children: _jsx("h3", { className: "sprout-sql-table-title", title: table.label, children: table.label }) }), _jsxs("ul", { className: "sprout-sql-columns", children: [_jsxs("li", { className: "sprout-sql-column", "data-key": "primary", children: [sideHandles("target", "id"), _jsx("span", { className: "sprout-sql-column-icon", children: _jsx(Key, { size: 12, role: "img", "aria-label": "Primary key" }) }), _jsx("span", { className: "sprout-sql-column-name", children: "id" }), _jsx("span", { className: "sprout-sql-column-type", children: "uuid" })] }), table.fields.map((field) => {
|
|
228
|
+
const join = field.kind === "relationship";
|
|
229
|
+
const type = columnType(field);
|
|
230
|
+
return (_jsxs("li", { className: "sprout-sql-column", "data-join": join ? "" : undefined, "data-active": focused && viewer.focus?.field === field.id ? "" : undefined, title: field.kind === "single-select" || field.kind === "multi-select"
|
|
231
|
+
? field.options
|
|
232
|
+
.filter((option) => !option.retired)
|
|
233
|
+
.map((option) => option.label)
|
|
234
|
+
.join(" · ")
|
|
235
|
+
: undefined, onMouseEnter: join
|
|
236
|
+
? () => viewer.hover({ table: id, field: field.id })
|
|
237
|
+
: undefined, onMouseLeave: join ? () => viewer.hover({ table: id }) : undefined, onClick: join
|
|
238
|
+
? (event) => {
|
|
239
|
+
event.stopPropagation();
|
|
240
|
+
viewer.pin({ table: id, field: field.id });
|
|
241
|
+
}
|
|
242
|
+
: undefined, children: [join && sideHandles("source", field.id), _jsx("span", { className: "sprout-sql-column-icon", children: join ? (_jsx(Link, { size: 12, role: "img", "aria-label": "Foreign key" })) : unique.has(field.id) ? (_jsx(Fingerprint, { size: 12, role: "img", "aria-label": "Unique" })) : null }), _jsx("span", { className: "sprout-sql-column-name", title: field.label, children: field.label }), _jsxs("span", { className: "sprout-sql-column-type", children: [type, !field.required && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: "?" }), _jsx("span", { className: "sprout-sql-sr-only", children: ", nullable" })] }))] })] }, field.id));
|
|
243
|
+
})] }), composite.length > 0 && (_jsxs("div", { className: "sprout-sql-keys", children: [_jsx("p", { className: "sprout-sql-keys-title", children: "Unique keys" }), _jsx("ul", { className: "sprout-sql-keys-list", children: composite.map((key) => (_jsxs("li", { className: "sprout-sql-key", children: [_jsx(Fingerprint, { size: 12, "aria-hidden": "true" }), _jsx("span", { children: key.fieldIds
|
|
244
|
+
.map((field) => labels.get(field) ?? field)
|
|
245
|
+
.join(", ") })] }, key.id))) })] }))] }));
|
|
246
|
+
}
|
|
247
|
+
function GroupFrame({ data: { label, tone, count } }) {
|
|
248
|
+
return (_jsx("section", { "data-slot": "sql-group", "data-tone": tone, "aria-label": `${label}, ${count} ${count === 1 ? "table" : "tables"}`, className: "sprout-sql-group", children: _jsxs("header", { className: "sprout-sql-group-label", children: [_jsx(Layers, { size: 14, "aria-hidden": "true" }), _jsx("span", { className: "sprout-sql-group-name", children: label }), _jsx("span", { className: "sprout-sql-group-count", children: count })] }) }));
|
|
249
|
+
}
|
|
250
|
+
function MoreCard({ data: { tables } }) {
|
|
251
|
+
const { expand } = useViewer();
|
|
252
|
+
const names = tables.slice(0, 3).map((table) => table.label);
|
|
253
|
+
const rest = tables.length - names.length;
|
|
254
|
+
return (_jsxs("div", { "data-slot": "sql-more", className: "sprout-sql-more", children: [sideHandles("target", "in"), sideHandles("source", "out"), _jsx("span", { "aria-hidden": "true", className: "sprout-sql-more-sheet" }), _jsx("span", { "aria-hidden": "true", className: "sprout-sql-more-sheet" }), _jsxs("button", { type: "button", className: "nodrag sprout-sql-more-button", onClick: expand, "aria-label": `Show ${tables.length} more ${tables.length === 1 ? "table" : "tables"}: ${tables.map((table) => table.label).join(", ")}`, children: [_jsxs("span", { className: "sprout-sql-more-count", children: ["+", tables.length, " ", tables.length === 1 ? "table" : "tables"] }), _jsxs("span", { className: "sprout-sql-more-names", children: [names.join(", "), rest > 0 && ` and ${rest} more`] }), _jsx("span", { className: "sprout-sql-more-action", children: "Show all" })] })] }));
|
|
255
|
+
}
|
|
256
|
+
function handlePoint(node, type, id) {
|
|
257
|
+
const handle = node.internals.handleBounds?.[type]?.find((candidate) => candidate.id === id);
|
|
258
|
+
if (!handle)
|
|
259
|
+
return null;
|
|
260
|
+
return {
|
|
261
|
+
x: node.internals.positionAbsolute.x + handle.x + handle.width / 2,
|
|
262
|
+
y: node.internals.positionAbsolute.y + handle.y + handle.height / 2,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/** A crow's foot for many, a bar for one, drawn outward from the card edge. */
|
|
266
|
+
function endMark(x, y, side, many) {
|
|
267
|
+
const out = side === "right" ? 1 : -1;
|
|
268
|
+
return many
|
|
269
|
+
? `M ${x + out * 12} ${y} L ${x} ${y - 6} M ${x + out * 12} ${y} L ${x} ${y + 6}`
|
|
270
|
+
: `M ${x + out * 9} ${y - 5} L ${x + out * 9} ${y + 5}`;
|
|
271
|
+
}
|
|
272
|
+
// Picks the facing sides of the two cards on every render, so links stay tidy while tables are dragged.
|
|
273
|
+
function JoinLine({ id, source, target, sourceHandleId, targetHandleId, data, }) {
|
|
274
|
+
const from = useInternalNode(source);
|
|
275
|
+
const to = useInternalNode(target);
|
|
276
|
+
const viewer = useViewer();
|
|
277
|
+
if (!from || !to || !data || !sourceHandleId || !targetHandleId)
|
|
278
|
+
return null;
|
|
279
|
+
const fromLeft = from.internals.positionAbsolute.x;
|
|
280
|
+
const toLeft = to.internals.positionAbsolute.x;
|
|
281
|
+
const fromRight = fromLeft + (from.measured.width ?? 0);
|
|
282
|
+
const toRight = toLeft + (to.measured.width ?? 0);
|
|
283
|
+
const [sourceSide, targetSide] = fromRight + 24 <= toLeft
|
|
284
|
+
? ["right", "left"]
|
|
285
|
+
: toRight + 24 <= fromLeft
|
|
286
|
+
? ["left", "right"]
|
|
287
|
+
: ["right", "right"];
|
|
288
|
+
const base = (handle) => handle.slice(0, handle.lastIndexOf(":"));
|
|
289
|
+
const start = handlePoint(from, "source", `${base(sourceHandleId)}:${sourceSide}`);
|
|
290
|
+
const end = handlePoint(to, "target", `${base(targetHandleId)}:${targetSide}`);
|
|
291
|
+
if (!start || !end)
|
|
292
|
+
return null;
|
|
293
|
+
const position = (side) => side === "right" ? Position.Right : Position.Left;
|
|
294
|
+
const [path] = getSmoothStepPath({
|
|
295
|
+
sourceX: start.x,
|
|
296
|
+
sourceY: start.y,
|
|
297
|
+
sourcePosition: position(sourceSide),
|
|
298
|
+
targetX: end.x,
|
|
299
|
+
targetY: end.y,
|
|
300
|
+
targetPosition: position(targetSide),
|
|
301
|
+
borderRadius: 10,
|
|
302
|
+
offset: 28,
|
|
303
|
+
});
|
|
304
|
+
const active = viewer.activeEdges.has(id);
|
|
305
|
+
return (_jsx("path", { d: `${path} ${endMark(start.x, start.y, sourceSide, data.sourceMany)} ${endMark(end.x, end.y, targetSide, data.targetMany)}`, className: "react-flow__edge-path sprout-sql-join", "data-active": active ? "" : undefined, "data-dimmed": viewer.focus && !active ? "" : undefined }));
|
|
306
|
+
}
|
|
307
|
+
const nodeTypes = { table: TableCard, group: GroupFrame, more: MoreCard };
|
|
308
|
+
const edgeTypes = { join: JoinLine };
|
|
309
|
+
const keyboardHint = "Arrow keys pan, plus and minus zoom, and 0 fits the diagram. Tab moves between tables; Enter pins a table's joins.";
|
|
310
|
+
function ViewerFlow({ layoutNodes, edges, direction, label, hintId, expanded, setExpanded, collapsible, maxVisible, }) {
|
|
311
|
+
const [nodes, setNodes, onNodesChange] = useNodesState(layoutNodes);
|
|
312
|
+
useEffect(() => setNodes(layoutNodes), [layoutNodes, setNodes]);
|
|
313
|
+
const [hovered, setHovered] = useState(null);
|
|
314
|
+
const [pinned, setPinned] = useState(null);
|
|
315
|
+
const focus = hovered ?? pinned;
|
|
316
|
+
const pin = useCallback((next) => setPinned((current) => current?.table === next.table && current.field === next.field
|
|
317
|
+
? null
|
|
318
|
+
: next), []);
|
|
319
|
+
const state = useMemo(() => {
|
|
320
|
+
const activeEdges = new Set();
|
|
321
|
+
const related = new Set();
|
|
322
|
+
if (focus) {
|
|
323
|
+
related.add(focus.table);
|
|
324
|
+
for (const edge of edges) {
|
|
325
|
+
const hit = focus.field
|
|
326
|
+
? edge.id === `${focus.table}/${focus.field}`
|
|
327
|
+
: edge.source === focus.table || edge.target === focus.table;
|
|
328
|
+
if (!hit)
|
|
329
|
+
continue;
|
|
330
|
+
activeEdges.add(edge.id);
|
|
331
|
+
related.add(edge.source).add(edge.target);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
focus,
|
|
336
|
+
related,
|
|
337
|
+
activeEdges,
|
|
338
|
+
hover: setHovered,
|
|
339
|
+
pin,
|
|
340
|
+
expand: () => setExpanded(true),
|
|
341
|
+
};
|
|
342
|
+
}, [focus, edges, pin, setExpanded]);
|
|
343
|
+
const shownEdges = useMemo(() => state.activeEdges.size
|
|
344
|
+
? edges.map((edge) => state.activeEdges.has(edge.id) ? { ...edge, zIndex: 1000 } : edge)
|
|
345
|
+
: edges, [edges, state.activeEdges]);
|
|
346
|
+
// Phones fit the first block to the width and let people pan down; wider panes fit everything.
|
|
347
|
+
const fitFirst = useMemo(() => direction === "vertical" && layoutNodes[0]
|
|
348
|
+
? { padding: 0.06, maxZoom: 1, nodes: [{ id: layoutNodes[0].id }] }
|
|
349
|
+
: fitAll, [direction, layoutNodes]);
|
|
350
|
+
const keyboardView = useKeyboardView(fitAll);
|
|
351
|
+
const onKeyDown = (event) => {
|
|
352
|
+
const target = event.target;
|
|
353
|
+
const id = target.dataset["id"];
|
|
354
|
+
if ((event.key === "Enter" || event.key === " ") &&
|
|
355
|
+
target.classList.contains("react-flow__node-table") &&
|
|
356
|
+
id) {
|
|
357
|
+
event.preventDefault();
|
|
358
|
+
pin({ table: id });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (event.key === "Escape" && pinned) {
|
|
362
|
+
setPinned(null);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
keyboardView(event);
|
|
366
|
+
};
|
|
367
|
+
const onFocus = (event) => {
|
|
368
|
+
const id = event.target.dataset["id"];
|
|
369
|
+
if (event.target.classList.contains("react-flow__node-table") && id)
|
|
370
|
+
setHovered({ table: id });
|
|
371
|
+
};
|
|
372
|
+
const onNodeMouseEnter = (_, node) => {
|
|
373
|
+
if (node.type === "table")
|
|
374
|
+
setHovered({ table: node.id });
|
|
375
|
+
};
|
|
376
|
+
const onNodeClick = (_, node) => {
|
|
377
|
+
if (node.type === "table")
|
|
378
|
+
pin({ table: node.id });
|
|
379
|
+
};
|
|
380
|
+
return (_jsx(ViewerContext.Provider, { value: state, children: _jsxs(ReactFlow, { className: "sprout-schema-flow sprout-sql-flow", "data-slot": "sql-viewer-viewport", "data-focus": focus ? "" : undefined, "aria-label": label, "aria-describedby": hintId, tabIndex: 0, onKeyDown: onKeyDown, onFocus: onFocus, onBlur: () => setHovered(null), nodes: nodes, edges: shownEdges, onNodesChange: onNodesChange, nodeTypes: nodeTypes, edgeTypes: edgeTypes, onNodeMouseEnter: onNodeMouseEnter, onNodeClick: onNodeClick, onPaneClick: () => setPinned(null), fitView: true, fitViewOptions: fitFirst, minZoom: flowMinZoom, maxZoom: flowMaxZoom, nodesConnectable: false, elementsSelectable: false, edgesFocusable: false, ariaLabelConfig: {
|
|
381
|
+
"node.a11yDescription.default": keyboardHint,
|
|
382
|
+
"node.a11yDescription.keyboardDisabled": keyboardHint,
|
|
383
|
+
}, proOptions: { hideAttribution: true }, children: [_jsx(Background, { gap: 16, size: 1 }), _jsx(FitOnResize, { nodes: layoutNodes, fit: fitFirst }), collapsible && expanded && (_jsx(Panel, { position: "top-left", "data-slot": "sql-viewer-collapse", children: _jsxs(Button, { variant: "secondary", size: "sm", onClick: () => {
|
|
384
|
+
setPinned(null);
|
|
385
|
+
setExpanded(false);
|
|
386
|
+
}, children: ["Show ", maxVisible, " tables"] }) })), state.activeEdges.size > 0 && (_jsxs(Panel, { position: "bottom-left", "data-slot": "sql-viewer-joins", "aria-hidden": "true", className: "sprout-sql-joins", children: [edges
|
|
387
|
+
.filter((edge) => state.activeEdges.has(edge.id))
|
|
388
|
+
.slice(0, 3)
|
|
389
|
+
.map((edge) => (_jsx("code", { className: "sprout-sql-join-text", children: edge.data.join }, edge.id))), state.activeEdges.size > 3 && (_jsxs("span", { className: "sprout-sql-joins-more", children: ["and ", state.activeEdges.size - 3, " more"] }))] })), _jsx(ZoomControls, { position: direction === "vertical" ? "top-right" : "bottom-right", fit: fitAll, className: "sprout-schema-controls", slot: "sql-viewer-controls" })] }) }));
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* A pan-and-zoom map of SQL tables, their columns and keys, and the joins
|
|
393
|
+
* between them. Hover or pin a table or a foreign key to trace its joins.
|
|
394
|
+
*/
|
|
395
|
+
export function SqlViewer({ tables, label, groups, maxVisible = 4, layout = "auto", className, ...props }) {
|
|
396
|
+
const mobile = useViewportMobile();
|
|
397
|
+
const direction = layout === "auto" ? (mobile ? "vertical" : "horizontal") : layout;
|
|
398
|
+
const hintId = useId();
|
|
399
|
+
const [expanded, setExpanded] = useState(false);
|
|
400
|
+
const limit = Math.max(1, Math.floor(maxVisible));
|
|
401
|
+
const collapsible = tables.length > limit;
|
|
402
|
+
const [visible, hidden] = useMemo(() => collapsible && !expanded
|
|
403
|
+
? [tables.slice(0, limit), tables.slice(limit)]
|
|
404
|
+
: [tables, []], [tables, limit, collapsible, expanded]);
|
|
405
|
+
const layoutNodes = useMemo(() => arrange(visible, hidden, groups, direction), [visible, hidden, groups, direction]);
|
|
406
|
+
const edges = useMemo(() => joins(visible, hidden), [visible, hidden]);
|
|
407
|
+
const descriptions = joins(tables, []);
|
|
408
|
+
return (_jsxs("div", { ...props, role: "figure", "aria-label": label, "data-slot": "sql-viewer", "data-layout": direction, className: cn("sprout-sql-viewer", className), children: [_jsx("p", { id: hintId, className: "sprout-sql-sr-only", children: keyboardHint }), descriptions.length > 0 && (_jsx("ul", { "aria-label": "Joins", className: "sprout-sql-sr-only", children: descriptions.map((edge) => (_jsx("li", { children: edge.data.description }, edge.id))) })), tables.length === 0 ? (_jsx("p", { className: "sprout-sql-empty", children: "No tables to show" })) : (_jsx(ReactFlowProvider, { children: _jsx(ViewerFlow, { layoutNodes: layoutNodes, edges: edges, direction: direction, label: label, hintId: hintId, expanded: expanded, setExpanded: setExpanded, collapsible: collapsible, maxVisible: limit }) }, direction))] }));
|
|
409
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export * from "./components/accordion.js";
|
|
2
|
+
export * from "./components/agent-fanout.js";
|
|
2
3
|
export * from "./components/alert.js";
|
|
3
4
|
export * from "./components/alert-dialog.js";
|
|
4
5
|
export * from "./components/aspect-ratio.js";
|
|
@@ -53,6 +54,7 @@ export * from "./components/page-header.js";
|
|
|
53
54
|
export * from "./components/pagination.js";
|
|
54
55
|
export * from "./components/plan-outline.js";
|
|
55
56
|
export * from "./components/popover.js";
|
|
57
|
+
export * from "./components/presence-cursor.js";
|
|
56
58
|
export * from "./components/progress.js";
|
|
57
59
|
export * from "./components/prompt-list.js";
|
|
58
60
|
export * from "./components/questionnaire.js";
|
|
@@ -63,6 +65,7 @@ export * from "./components/result-card.js";
|
|
|
63
65
|
export * from "./components/review-card.js";
|
|
64
66
|
export * from "./components/rich-text-editor.js";
|
|
65
67
|
export * from "./components/schema-diagram.js";
|
|
68
|
+
export * from "./components/sql-viewer.js";
|
|
66
69
|
export * from "./components/scroll-area.js";
|
|
67
70
|
export * from "./components/search-dialog.js";
|
|
68
71
|
export * from "./components/search-field.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export * from "./components/accordion.js";
|
|
2
|
+
export * from "./components/agent-fanout.js";
|
|
2
3
|
export * from "./components/alert.js";
|
|
3
4
|
export * from "./components/alert-dialog.js";
|
|
4
5
|
export * from "./components/aspect-ratio.js";
|
|
@@ -53,6 +54,7 @@ export * from "./components/page-header.js";
|
|
|
53
54
|
export * from "./components/pagination.js";
|
|
54
55
|
export * from "./components/plan-outline.js";
|
|
55
56
|
export * from "./components/popover.js";
|
|
57
|
+
export * from "./components/presence-cursor.js";
|
|
56
58
|
export * from "./components/progress.js";
|
|
57
59
|
export * from "./components/prompt-list.js";
|
|
58
60
|
export * from "./components/questionnaire.js";
|
|
@@ -63,6 +65,7 @@ export * from "./components/result-card.js";
|
|
|
63
65
|
export * from "./components/review-card.js";
|
|
64
66
|
export * from "./components/rich-text-editor.js";
|
|
65
67
|
export * from "./components/schema-diagram.js";
|
|
68
|
+
export * from "./components/sql-viewer.js";
|
|
66
69
|
export * from "./components/scroll-area.js";
|
|
67
70
|
export * from "./components/search-dialog.js";
|
|
68
71
|
export * from "./components/search-field.js";
|