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/LICENSE +190 -0
- package/README.md +121 -0
- package/dist/webview/schema-visualizer.css +1 -0
- package/dist/webview/schema-visualizer.js +17 -0
- package/index.ts +38 -0
- package/package.json +66 -0
- package/src/components/BottomToolbar.tsx +195 -0
- package/src/components/EdgeContextMenu.tsx +134 -0
- package/src/components/FilterPanel.tsx +291 -0
- package/src/components/FloatingEdge.tsx +231 -0
- package/src/components/LegendPanel.tsx +191 -0
- package/src/components/NodeContextMenu.tsx +114 -0
- package/src/components/NodeDetailsPanel.tsx +200 -0
- package/src/components/SchemaNode.tsx +260 -0
- package/src/components/SchemaVisualizer.tsx +1027 -0
- package/src/components/index.ts +12 -0
- package/src/types/index.ts +1 -0
- package/src/types/schema.ts +73 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/index.ts +20 -0
- package/src/utils/layout.ts +60 -0
- package/src/utils/persistence.ts +69 -0
- package/src/utils/schema-to-flow.ts +623 -0
- package/src/webview-entry.tsx +94 -0
- package/src/webview.css +152 -0
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
|
+
}
|