infrahub-schema-visualizer 0.0.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/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ // Components
2
+ export {
3
+ BottomToolbar,
4
+ type BottomToolbarProps,
5
+ FilterPanel,
6
+ type FilterPanelProps,
7
+ NodeDetailsPanel,
8
+ type NodeDetailsPanelProps,
9
+ SchemaNode,
10
+ SchemaVisualizer,
11
+ type SchemaVisualizerProps,
12
+ } from "./src/components";
13
+
14
+ // Types
15
+ export type {
16
+ AttributeSchema,
17
+ BaseSchema,
18
+ GenericSchema,
19
+ NodeSchema,
20
+ ProfileSchema,
21
+ RelationshipSchema,
22
+ SchemaType,
23
+ SchemaVisualizerData,
24
+ TemplateSchema,
25
+ } from "./src/types";
26
+
27
+ // Utilities
28
+ export {
29
+ applyNamespaceLayout,
30
+ cn,
31
+ getSchemaKind,
32
+ groupByNamespace,
33
+ type SchemaFlowData,
34
+ type SchemaFlowNode,
35
+ type SchemaNodeData,
36
+ schemaToFlow,
37
+ schemaToFlowFiltered,
38
+ } from "./src/utils";
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "infrahub-schema-visualizer",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "index.ts",
6
+ "types": "./index.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./index.ts",
10
+ "import": "./index.ts"
11
+ },
12
+ "./webview": {
13
+ "import": "./dist/webview/schema-visualizer.js",
14
+ "require": "./dist/webview/schema-visualizer.js"
15
+ },
16
+ "./webview/styles": {
17
+ "import": "./dist/webview/schema-visualizer.css",
18
+ "require": "./dist/webview/schema-visualizer.css"
19
+ },
20
+ "./dist/webview/*": "./dist/webview/*"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "src",
25
+ "index.ts"
26
+ ],
27
+ "scripts": {
28
+ "build": "vite build",
29
+ "build:webview": "vite build --config vite.config.webview.ts",
30
+ "prepublishOnly": "npm run build:webview",
31
+ "lint": "biome check ."
32
+ },
33
+ "peerDependencies": {
34
+ "react": ">=18.0.0",
35
+ "react-dom": ">=18.0.0"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "react": {
39
+ "optional": true
40
+ },
41
+ "react-dom": {
42
+ "optional": true
43
+ }
44
+ },
45
+ "dependencies": {
46
+ "@dagrejs/dagre": "^1.1.8",
47
+ "@iconify-icon/react": "^2.3.0",
48
+ "@xyflow/react": "^12.6.1",
49
+ "class-variance-authority": "^0.7.1",
50
+ "clsx": "^2.1.0",
51
+ "html-to-image": "^1.11.11",
52
+ "tailwind-merge": "^3.4.0"
53
+ },
54
+ "devDependencies": {
55
+ "@biomejs/biome": "^2.3.8",
56
+ "@tailwindcss/vite": "^4.1.17",
57
+ "@types/react": "^19.2.7",
58
+ "@types/react-dom": "^19.2.3",
59
+ "@vitejs/plugin-react": "^5.1.1",
60
+ "react": "^19.2.1",
61
+ "react-dom": "^19.2.1",
62
+ "tailwindcss": "^4.1.17",
63
+ "typescript": "^5.9.3",
64
+ "vite": "^7.2.4"
65
+ }
66
+ }
@@ -0,0 +1,195 @@
1
+ import { Icon } from "@iconify-icon/react";
2
+ import { Panel, useReactFlow } from "@xyflow/react";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { cn } from "../utils/cn";
5
+
6
+ export type EdgeStyle = "bezier" | "smoothstep";
7
+ export type LayoutDirection = "TB" | "LR";
8
+ export type ExportFormat = "png" | "svg";
9
+
10
+ export interface BottomToolbarProps {
11
+ onFilterClick: () => void;
12
+ isFilterOpen: boolean;
13
+ edgeStyle: EdgeStyle;
14
+ onEdgeStyleChange: (style: EdgeStyle) => void;
15
+ onLayout: (direction: LayoutDirection) => void;
16
+ onExport: (format: ExportFormat) => void;
17
+ onReset?: () => void;
18
+ showReset?: boolean;
19
+ }
20
+
21
+ export function BottomToolbar({
22
+ onFilterClick,
23
+ isFilterOpen,
24
+ edgeStyle,
25
+ onEdgeStyleChange,
26
+ onLayout,
27
+ onExport,
28
+ onReset,
29
+ showReset = false,
30
+ }: BottomToolbarProps) {
31
+ const { zoomIn, zoomOut, fitView } = useReactFlow();
32
+ const [exportMenuOpen, setExportMenuOpen] = useState(false);
33
+ const exportMenuRef = useRef<HTMLDivElement>(null);
34
+
35
+ const handleExport = useCallback(
36
+ (format: ExportFormat) => {
37
+ onExport(format);
38
+ setExportMenuOpen(false);
39
+ },
40
+ [onExport],
41
+ );
42
+
43
+ // Close export menu when clicking outside
44
+ useEffect(() => {
45
+ const handleClickOutside = (event: MouseEvent) => {
46
+ if (
47
+ exportMenuRef.current &&
48
+ !exportMenuRef.current.contains(event.target as Node)
49
+ ) {
50
+ setExportMenuOpen(false);
51
+ }
52
+ };
53
+
54
+ if (exportMenuOpen) {
55
+ document.addEventListener("mousedown", handleClickOutside);
56
+ return () => {
57
+ document.removeEventListener("mousedown", handleClickOutside);
58
+ };
59
+ }
60
+ }, [exportMenuOpen]);
61
+
62
+ return (
63
+ <Panel
64
+ position="bottom-center"
65
+ className="mb-4 flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg"
66
+ >
67
+ <button
68
+ type="button"
69
+ onClick={() => zoomOut()}
70
+ className="flex h-8 w-8 items-center justify-center rounded hover:bg-gray-100 text-gray-600"
71
+ title="Zoom out"
72
+ >
73
+ <Icon icon="mdi:minus" className="text-lg" />
74
+ </button>
75
+ <button
76
+ type="button"
77
+ onClick={() => fitView({ padding: 0.2 })}
78
+ className="flex h-8 w-8 items-center justify-center rounded hover:bg-gray-100 text-gray-600"
79
+ title="Fit to screen"
80
+ >
81
+ <Icon icon="mdi:fit-to-screen" className="text-lg" />
82
+ </button>
83
+ <button
84
+ type="button"
85
+ onClick={() => zoomIn()}
86
+ className="flex h-8 w-8 items-center justify-center rounded hover:bg-gray-100 text-gray-600"
87
+ title="Zoom in"
88
+ >
89
+ <Icon icon="mdi:plus" className="text-lg" />
90
+ </button>
91
+ <div className="mx-2 h-6 w-px bg-gray-200" />
92
+ <button
93
+ type="button"
94
+ onClick={() =>
95
+ onEdgeStyleChange(edgeStyle === "bezier" ? "smoothstep" : "bezier")
96
+ }
97
+ className="flex h-8 items-center justify-center gap-1.5 rounded px-2 hover:bg-gray-100 text-gray-600"
98
+ title={`Switch to ${edgeStyle === "bezier" ? "step" : "smooth"} edges`}
99
+ >
100
+ <Icon
101
+ icon={
102
+ edgeStyle === "bezier" ? "mdi:vector-curve" : "mdi:vector-polyline"
103
+ }
104
+ className="text-lg"
105
+ />
106
+ <span className="text-xs">
107
+ {edgeStyle === "bezier" ? "Smooth" : "Step"}
108
+ </span>
109
+ </button>
110
+ <div className="mx-2 h-6 w-px bg-gray-200" />
111
+ <button
112
+ type="button"
113
+ onClick={() => onLayout("LR")}
114
+ className="flex h-8 w-8 items-center justify-center rounded hover:bg-gray-100 text-gray-600"
115
+ title="Auto-layout horizontal"
116
+ >
117
+ <Icon icon="mdi:arrow-right" className="text-lg" />
118
+ </button>
119
+ <button
120
+ type="button"
121
+ onClick={() => onLayout("TB")}
122
+ className="flex h-8 w-8 items-center justify-center rounded hover:bg-gray-100 text-gray-600"
123
+ title="Auto-layout vertical"
124
+ >
125
+ <Icon icon="mdi:arrow-down" className="text-lg" />
126
+ </button>
127
+ <div className="mx-2 h-6 w-px bg-gray-200" />
128
+ <button
129
+ type="button"
130
+ onClick={onFilterClick}
131
+ className={cn(
132
+ "flex h-8 w-8 items-center justify-center rounded",
133
+ isFilterOpen
134
+ ? "bg-indigo-500 text-white hover:bg-indigo-600"
135
+ : "hover:bg-gray-100 text-gray-600",
136
+ )}
137
+ title="Filter nodes"
138
+ >
139
+ <Icon icon="mdi:filter-variant" className="text-lg" />
140
+ </button>
141
+ {showReset && onReset && (
142
+ <button
143
+ type="button"
144
+ onClick={onReset}
145
+ className="flex h-8 w-8 items-center justify-center rounded hover:bg-gray-100 text-gray-600"
146
+ title="Reset to default view"
147
+ >
148
+ <Icon icon="mdi:refresh" className="text-lg" />
149
+ </button>
150
+ )}
151
+ <div className="mx-2 h-6 w-px bg-gray-200" />
152
+ <div className="relative" ref={exportMenuRef}>
153
+ <button
154
+ type="button"
155
+ onClick={() => setExportMenuOpen(!exportMenuOpen)}
156
+ className={cn(
157
+ "flex h-8 w-8 items-center justify-center rounded",
158
+ exportMenuOpen
159
+ ? "bg-indigo-500 text-white hover:bg-indigo-600"
160
+ : "hover:bg-gray-100 text-gray-600",
161
+ )}
162
+ title="Export diagram"
163
+ >
164
+ <Icon icon="mdi:download" className="text-lg" />
165
+ </button>
166
+ {exportMenuOpen && (
167
+ <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 min-w-[120px] rounded-lg bg-white py-1 shadow-lg border border-gray-200">
168
+ <button
169
+ type="button"
170
+ onClick={() => handleExport("png")}
171
+ className="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
172
+ >
173
+ <Icon
174
+ icon="mdi:image-outline"
175
+ className="text-lg text-gray-500"
176
+ />
177
+ PNG
178
+ </button>
179
+ <button
180
+ type="button"
181
+ onClick={() => handleExport("svg")}
182
+ className="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
183
+ >
184
+ <Icon
185
+ icon="mdi:file-code-outline"
186
+ className="text-lg text-gray-500"
187
+ />
188
+ SVG
189
+ </button>
190
+ </div>
191
+ )}
192
+ </div>
193
+ </Panel>
194
+ );
195
+ }
@@ -0,0 +1,134 @@
1
+ import { Icon } from "@iconify-icon/react";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+
4
+ export interface EdgeInfo {
5
+ id: string;
6
+ source: string;
7
+ target: string;
8
+ sourceRelName: string;
9
+ targetRelName: string | null;
10
+ cardinality: "one" | "many";
11
+ }
12
+
13
+ export interface EdgeContextMenuProps {
14
+ edge: EdgeInfo;
15
+ x: number;
16
+ y: number;
17
+ onClose: () => void;
18
+ onHighlight: (edgeId: string) => void;
19
+ }
20
+
21
+ export function EdgeContextMenu({
22
+ edge,
23
+ x,
24
+ y,
25
+ onClose,
26
+ onHighlight,
27
+ }: EdgeContextMenuProps) {
28
+ const menuRef = useRef<HTMLDivElement>(null);
29
+ const [copied, setCopied] = useState(false);
30
+
31
+ // Close menu when clicking outside
32
+ useEffect(() => {
33
+ const handleClickOutside = (event: MouseEvent) => {
34
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
35
+ onClose();
36
+ }
37
+ };
38
+
39
+ const handleEscape = (event: KeyboardEvent) => {
40
+ if (event.key === "Escape") {
41
+ onClose();
42
+ }
43
+ };
44
+
45
+ document.addEventListener("mousedown", handleClickOutside);
46
+ document.addEventListener("keydown", handleEscape);
47
+ return () => {
48
+ document.removeEventListener("mousedown", handleClickOutside);
49
+ document.removeEventListener("keydown", handleEscape);
50
+ };
51
+ }, [onClose]);
52
+
53
+ const handleHighlight = useCallback(() => {
54
+ onHighlight(edge.id);
55
+ onClose();
56
+ }, [edge.id, onHighlight, onClose]);
57
+
58
+ const handleCopyInfo = useCallback(async () => {
59
+ const info = [
60
+ `Relationship: ${edge.sourceRelName}`,
61
+ `From: ${edge.source}`,
62
+ `To: ${edge.target}`,
63
+ `Cardinality: ${edge.cardinality}`,
64
+ edge.targetRelName ? `Reverse: ${edge.targetRelName}` : null,
65
+ ]
66
+ .filter(Boolean)
67
+ .join("\n");
68
+
69
+ try {
70
+ // Try the modern clipboard API first
71
+ if (navigator.clipboard?.writeText) {
72
+ await navigator.clipboard.writeText(info);
73
+ } else {
74
+ // Fallback for environments without clipboard API
75
+ const textArea = document.createElement("textarea");
76
+ textArea.value = info;
77
+ textArea.style.position = "fixed";
78
+ textArea.style.left = "-9999px";
79
+ document.body.appendChild(textArea);
80
+ textArea.select();
81
+ document.execCommand("copy");
82
+ document.body.removeChild(textArea);
83
+ }
84
+ setCopied(true);
85
+ setTimeout(() => {
86
+ setCopied(false);
87
+ onClose();
88
+ }, 1000);
89
+ } catch (err) {
90
+ console.error("Failed to copy:", err);
91
+ // Still show feedback even if copy failed
92
+ onClose();
93
+ }
94
+ }, [edge, onClose]);
95
+
96
+ return (
97
+ <div
98
+ ref={menuRef}
99
+ className="fixed z-50 min-w-[220px] rounded-lg bg-white py-1 shadow-lg border border-gray-200"
100
+ style={{ left: x, top: y }}
101
+ >
102
+ {/* Edge info header */}
103
+ <div className="px-3 py-2 border-b border-gray-100">
104
+ <div className="text-xs font-medium text-gray-700 truncate">
105
+ {edge.sourceRelName}
106
+ </div>
107
+ <div className="text-[10px] text-gray-400 mt-0.5">
108
+ {edge.source} → {edge.target}
109
+ </div>
110
+ </div>
111
+
112
+ <button
113
+ type="button"
114
+ onClick={handleHighlight}
115
+ className="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
116
+ >
117
+ <Icon icon="mdi:spotlight-beam" className="text-lg text-gray-500" />
118
+ Highlight relationship
119
+ </button>
120
+ <div className="my-1 border-t border-gray-100" />
121
+ <button
122
+ type="button"
123
+ onClick={handleCopyInfo}
124
+ className="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
125
+ >
126
+ <Icon
127
+ icon={copied ? "mdi:check" : "mdi:content-copy"}
128
+ className={`text-lg ${copied ? "text-green-500" : "text-gray-500"}`}
129
+ />
130
+ {copied ? "Copied!" : "Copy relationship info"}
131
+ </button>
132
+ </div>
133
+ );
134
+ }