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.
@@ -0,0 +1,114 @@
1
+ import { Icon } from "@iconify-icon/react";
2
+ import { useCallback, useEffect, useRef } from "react";
3
+
4
+ export interface NodeContextMenuProps {
5
+ nodeId: string;
6
+ x: number;
7
+ y: number;
8
+ onClose: () => void;
9
+ onSelectConnected: (nodeId: string) => void;
10
+ onSelectNode: (nodeId: string) => void;
11
+ onShowPeers: (nodeId: string) => void;
12
+ onHideNode: (nodeId: string) => void;
13
+ }
14
+
15
+ export function NodeContextMenu({
16
+ nodeId,
17
+ x,
18
+ y,
19
+ onClose,
20
+ onSelectConnected,
21
+ onSelectNode,
22
+ onShowPeers,
23
+ onHideNode,
24
+ }: NodeContextMenuProps) {
25
+ const menuRef = useRef<HTMLDivElement>(null);
26
+
27
+ // Close menu when clicking outside
28
+ useEffect(() => {
29
+ const handleClickOutside = (event: MouseEvent) => {
30
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
31
+ onClose();
32
+ }
33
+ };
34
+
35
+ const handleEscape = (event: KeyboardEvent) => {
36
+ if (event.key === "Escape") {
37
+ onClose();
38
+ }
39
+ };
40
+
41
+ document.addEventListener("mousedown", handleClickOutside);
42
+ document.addEventListener("keydown", handleEscape);
43
+ return () => {
44
+ document.removeEventListener("mousedown", handleClickOutside);
45
+ document.removeEventListener("keydown", handleEscape);
46
+ };
47
+ }, [onClose]);
48
+
49
+ const handleSelectConnected = useCallback(() => {
50
+ onSelectConnected(nodeId);
51
+ onClose();
52
+ }, [nodeId, onSelectConnected, onClose]);
53
+
54
+ const handleSelectNode = useCallback(() => {
55
+ onSelectNode(nodeId);
56
+ onClose();
57
+ }, [nodeId, onSelectNode, onClose]);
58
+
59
+ const handleShowPeers = useCallback(() => {
60
+ onShowPeers(nodeId);
61
+ onClose();
62
+ }, [nodeId, onShowPeers, onClose]);
63
+
64
+ const handleHideNode = useCallback(() => {
65
+ onHideNode(nodeId);
66
+ onClose();
67
+ }, [nodeId, onHideNode, onClose]);
68
+
69
+ return (
70
+ <div
71
+ ref={menuRef}
72
+ className="fixed z-50 min-w-[200px] rounded-lg bg-white py-1 shadow-lg border border-gray-200"
73
+ style={{ left: x, top: y }}
74
+ >
75
+ <button
76
+ type="button"
77
+ onClick={handleSelectNode}
78
+ className="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
79
+ >
80
+ <Icon icon="mdi:select" className="text-lg text-gray-500" />
81
+ Select this node
82
+ </button>
83
+ <button
84
+ type="button"
85
+ onClick={handleSelectConnected}
86
+ className="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
87
+ >
88
+ <Icon icon="mdi:select-group" className="text-lg text-gray-500" />
89
+ Select connected nodes
90
+ </button>
91
+ <div className="my-1 border-t border-gray-100" />
92
+ <button
93
+ type="button"
94
+ onClick={handleShowPeers}
95
+ className="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
96
+ >
97
+ <Icon icon="mdi:eye-outline" className="text-lg text-gray-500" />
98
+ Show first-level peers
99
+ </button>
100
+ <button
101
+ type="button"
102
+ onClick={handleHideNode}
103
+ className="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
104
+ >
105
+ <Icon icon="mdi:eye-off-outline" className="text-lg text-gray-500" />
106
+ Hide this node
107
+ </button>
108
+ <div className="my-1 border-t border-gray-100" />
109
+ <div className="px-3 py-1.5 text-xs text-gray-400">
110
+ Drag selected nodes to organize
111
+ </div>
112
+ </div>
113
+ );
114
+ }
@@ -0,0 +1,200 @@
1
+ import { Icon } from "@iconify-icon/react";
2
+ import type { NodeSchema } from "../types/schema";
3
+ import { cn } from "../utils/cn";
4
+
5
+ export interface NodeDetailsPanelProps {
6
+ schema: NodeSchema | null;
7
+ onClose: () => void;
8
+ }
9
+
10
+ function PropertyRow({
11
+ label,
12
+ value,
13
+ }: {
14
+ label: string;
15
+ value?: string | null;
16
+ }) {
17
+ if (!value) return null;
18
+ return (
19
+ <div className="flex justify-between gap-2">
20
+ <span className="text-gray-500">{label}</span>
21
+ <span className="text-right font-medium text-gray-700">{value}</span>
22
+ </div>
23
+ );
24
+ }
25
+
26
+ function Badge({
27
+ children,
28
+ variant = "gray",
29
+ }: {
30
+ children: React.ReactNode;
31
+ variant?: "gray" | "blue" | "yellow" | "red";
32
+ }) {
33
+ const variantClasses = {
34
+ gray: "bg-gray-100 text-gray-600",
35
+ blue: "bg-blue-100 text-blue-700",
36
+ yellow: "bg-yellow-100 text-yellow-700",
37
+ red: "bg-red-100 text-red-700",
38
+ };
39
+
40
+ return (
41
+ <span
42
+ className={cn("px-1.5 py-0.5 rounded text-xs", variantClasses[variant])}
43
+ >
44
+ {children}
45
+ </span>
46
+ );
47
+ }
48
+
49
+ export function NodeDetailsPanel({ schema, onClose }: NodeDetailsPanelProps) {
50
+ if (!schema) return null;
51
+
52
+ return (
53
+ <div className="flex h-full w-96 flex-col border-gray-200 border-l bg-white">
54
+ <div className="flex items-center justify-between border-gray-200 border-b px-4 py-3">
55
+ <div className="flex items-center gap-2">
56
+ {schema.icon && (
57
+ <Icon icon={schema.icon} className="text-indigo-600 text-xl" />
58
+ )}
59
+ <div>
60
+ <h3 className="font-semibold text-gray-700">
61
+ {schema.label ?? schema.name}
62
+ </h3>
63
+ <p className="text-gray-500 text-xs">{schema.kind}</p>
64
+ </div>
65
+ </div>
66
+ <button
67
+ type="button"
68
+ onClick={onClose}
69
+ className="p-1 rounded hover:bg-gray-100 text-gray-500"
70
+ >
71
+ <Icon icon="mdi:close" className="text-lg" />
72
+ </button>
73
+ </div>
74
+
75
+ <div className="flex-1 overflow-y-auto">
76
+ {/* Properties */}
77
+ <div className="border-gray-100 border-b p-4">
78
+ <h4 className="mb-2 font-semibold text-gray-600 text-xs uppercase tracking-wide">
79
+ Properties
80
+ </h4>
81
+ <div className="space-y-2 text-sm">
82
+ <PropertyRow label="Namespace" value={schema.namespace} />
83
+ <PropertyRow label="Name" value={schema.name} />
84
+ <PropertyRow label="Kind" value={schema.kind} />
85
+ {schema.description && (
86
+ <PropertyRow label="Description" value={schema.description} />
87
+ )}
88
+ {schema.inherit_from && schema.inherit_from.length > 0 && (
89
+ <div className="flex justify-between">
90
+ <span className="text-gray-500">Inherit from</span>
91
+ <div className="flex flex-wrap justify-end gap-1">
92
+ {schema.inherit_from.map((k) => (
93
+ <Badge key={k} variant="blue">
94
+ {k}
95
+ </Badge>
96
+ ))}
97
+ </div>
98
+ </div>
99
+ )}
100
+ </div>
101
+ </div>
102
+
103
+ {/* Attributes */}
104
+ <div className="border-gray-100 border-b p-4">
105
+ <h4 className="mb-2 font-semibold text-gray-600 text-xs uppercase tracking-wide">
106
+ Attributes ({schema.attributes?.length ?? 0})
107
+ </h4>
108
+ {schema.attributes && schema.attributes.length > 0 ? (
109
+ <div className="space-y-2">
110
+ {schema.attributes.map((attr) => (
111
+ <div
112
+ key={attr.name}
113
+ className={cn(
114
+ "rounded border p-2 text-sm",
115
+ attr.inherited
116
+ ? "border-gray-100 bg-gray-50"
117
+ : "border-gray-200",
118
+ )}
119
+ >
120
+ <div className="flex items-center justify-between">
121
+ <span className="font-medium text-gray-700">
122
+ {attr.label ?? attr.name}
123
+ {attr.optional && (
124
+ <span className="ml-1 text-gray-400">?</span>
125
+ )}
126
+ </span>
127
+ <Badge variant="gray">{attr.kind}</Badge>
128
+ </div>
129
+ {attr.description && (
130
+ <p className="mt-1 text-gray-500 text-xs">
131
+ {attr.description}
132
+ </p>
133
+ )}
134
+ <div className="mt-1 flex flex-wrap gap-1">
135
+ {attr.inherited && (
136
+ <Badge variant="yellow">inherited</Badge>
137
+ )}
138
+ {attr.unique && <Badge variant="red">unique</Badge>}
139
+ {attr.read_only && <Badge variant="blue">read-only</Badge>}
140
+ </div>
141
+ </div>
142
+ ))}
143
+ </div>
144
+ ) : (
145
+ <p className="text-gray-400 text-sm">No attributes</p>
146
+ )}
147
+ </div>
148
+
149
+ {/* Relationships */}
150
+ <div className="p-4">
151
+ <h4 className="mb-2 font-semibold text-gray-600 text-xs uppercase tracking-wide">
152
+ Relationships ({schema.relationships?.length ?? 0})
153
+ </h4>
154
+ {schema.relationships && schema.relationships.length > 0 ? (
155
+ <div className="space-y-2">
156
+ {schema.relationships.map((rel) => (
157
+ <div
158
+ key={rel.name}
159
+ className={cn(
160
+ "rounded border p-2 text-sm",
161
+ rel.inherited
162
+ ? "border-gray-100 bg-gray-50"
163
+ : "border-gray-200",
164
+ )}
165
+ >
166
+ <div className="flex items-center justify-between">
167
+ <span className="font-medium text-gray-700">
168
+ {rel.label ?? rel.name}
169
+ </span>
170
+ <div className="flex items-center gap-1">
171
+ <Badge
172
+ variant={rel.cardinality === "many" ? "blue" : "gray"}
173
+ >
174
+ {rel.cardinality}
175
+ </Badge>
176
+ <span className="text-gray-500 text-xs">
177
+ → {rel.peer}
178
+ </span>
179
+ </div>
180
+ </div>
181
+ {rel.description && (
182
+ <p className="mt-1 text-gray-500 text-xs">
183
+ {rel.description}
184
+ </p>
185
+ )}
186
+ <div className="mt-1 flex flex-wrap gap-1">
187
+ {rel.inherited && <Badge variant="yellow">inherited</Badge>}
188
+ {rel.optional && <Badge variant="gray">optional</Badge>}
189
+ </div>
190
+ </div>
191
+ ))}
192
+ </div>
193
+ ) : (
194
+ <p className="text-gray-400 text-sm">No relationships</p>
195
+ )}
196
+ </div>
197
+ </div>
198
+ </div>
199
+ );
200
+ }
@@ -0,0 +1,260 @@
1
+ import { Icon } from "@iconify-icon/react";
2
+ import { Handle, type NodeProps, Position } from "@xyflow/react";
3
+ import { memo } from "react";
4
+ import { cn } from "../utils/cn";
5
+ import type { SchemaNodeData } from "../utils/schema-to-flow";
6
+
7
+ // Get header colors based on schema type
8
+ const getHeaderColors = (
9
+ schemaType: "node" | "generic" | "profile" | "template",
10
+ ) => {
11
+ switch (schemaType) {
12
+ case "profile":
13
+ return "from-pink-500 to-pink-600";
14
+ case "template":
15
+ return "from-amber-500 to-amber-600";
16
+ case "generic":
17
+ return "from-emerald-500 to-emerald-600";
18
+ default:
19
+ return "from-indigo-500 to-indigo-600";
20
+ }
21
+ };
22
+
23
+ // Get border color based on schema type
24
+ const getBorderColor = (
25
+ schemaType: "node" | "generic" | "profile" | "template",
26
+ selected: boolean,
27
+ ) => {
28
+ if (selected) {
29
+ switch (schemaType) {
30
+ case "profile":
31
+ return "border-pink-500";
32
+ case "template":
33
+ return "border-amber-500";
34
+ case "generic":
35
+ return "border-emerald-500";
36
+ default:
37
+ return "border-indigo-500";
38
+ }
39
+ }
40
+ return "border-gray-200";
41
+ };
42
+
43
+ // Get schema type label
44
+ const getSchemaTypeLabel = (
45
+ schemaType: "node" | "generic" | "profile" | "template",
46
+ ) => {
47
+ switch (schemaType) {
48
+ case "profile":
49
+ return "Profile";
50
+ case "template":
51
+ return "Template";
52
+ case "generic":
53
+ return "Generic";
54
+ default:
55
+ return null;
56
+ }
57
+ };
58
+
59
+ export const SchemaNode = memo(function SchemaNode({
60
+ data,
61
+ selected,
62
+ }: NodeProps) {
63
+ const nodeData = data as SchemaNodeData;
64
+ const hasInheritance =
65
+ nodeData.inheritFrom && nodeData.inheritFrom.length > 0;
66
+ const schemaType = nodeData.schemaType ?? "node";
67
+ const typeLabel = getSchemaTypeLabel(schemaType);
68
+
69
+ return (
70
+ <div
71
+ className={cn(
72
+ "bg-white rounded-lg shadow-lg border-2 min-w-[280px] max-w-[320px]",
73
+ "transition-all duration-200",
74
+ getBorderColor(schemaType, selected ?? false),
75
+ selected && "shadow-xl",
76
+ "hover:shadow-xl",
77
+ )}
78
+ >
79
+ {/* Header */}
80
+ <div
81
+ className={cn(
82
+ "bg-gradient-to-r text-white px-4 py-3 rounded-t-md",
83
+ getHeaderColors(schemaType),
84
+ )}
85
+ >
86
+ <div className="flex items-center gap-2">
87
+ {nodeData.icon && (
88
+ <Icon icon={nodeData.icon} className="text-2xl opacity-90" />
89
+ )}
90
+ <div className="flex-1 min-w-0">
91
+ <div className="flex items-center gap-2">
92
+ <h3 className="font-semibold text-sm truncate">
93
+ {nodeData.label}
94
+ </h3>
95
+ {typeLabel && (
96
+ <span className="px-1.5 py-0.5 text-[10px] bg-white/20 rounded">
97
+ {typeLabel}
98
+ </span>
99
+ )}
100
+ </div>
101
+ <p className="text-xs opacity-70 truncate">{nodeData.kind}</p>
102
+ </div>
103
+ </div>
104
+ {hasInheritance && (
105
+ <div className="mt-2 flex flex-wrap gap-1">
106
+ {nodeData.inheritFrom?.map((generic) => (
107
+ <span
108
+ key={generic}
109
+ className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-indigo-400/30 text-indigo-100"
110
+ >
111
+ ↑ {generic}
112
+ </span>
113
+ ))}
114
+ </div>
115
+ )}
116
+ </div>
117
+
118
+ {/* Attributes Section */}
119
+ {nodeData.attributes.length > 0 && (
120
+ <div className="border-b border-gray-100">
121
+ <div className="px-3 py-2 bg-gray-50 border-b border-gray-100">
122
+ <h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
123
+ Attributes ({nodeData.attributes.length})
124
+ </h4>
125
+ </div>
126
+ <div className="max-h-[150px] overflow-y-auto">
127
+ {nodeData.attributes.map((attr) => (
128
+ <div
129
+ key={attr.name}
130
+ className={cn(
131
+ "px-3 py-1.5 flex items-center justify-between text-xs border-b border-gray-50 last:border-0",
132
+ attr.inherited ? "bg-gray-50/50" : "",
133
+ )}
134
+ >
135
+ <div className="flex items-center gap-2 min-w-0 flex-1">
136
+ <span
137
+ className={cn(
138
+ "font-medium truncate",
139
+ attr.inherited ? "text-gray-400" : "text-gray-700",
140
+ )}
141
+ >
142
+ {attr.name}
143
+ {attr.optional && (
144
+ <span className="text-gray-400 ml-0.5">?</span>
145
+ )}
146
+ </span>
147
+ {attr.inherited && (
148
+ <span className="text-[10px] text-gray-400">
149
+ (inherited)
150
+ </span>
151
+ )}
152
+ </div>
153
+ <span className="text-gray-400 text-[10px] uppercase ml-2 shrink-0">
154
+ {attr.kind}
155
+ </span>
156
+ </div>
157
+ ))}
158
+ </div>
159
+ </div>
160
+ )}
161
+
162
+ {/* Relationships Section */}
163
+ {nodeData.relationships.length > 0 && (
164
+ <div>
165
+ <div className="px-3 py-2 bg-gray-50 border-b border-gray-100">
166
+ <h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
167
+ Relationships ({nodeData.relationships.length})
168
+ </h4>
169
+ </div>
170
+ <div>
171
+ {nodeData.relationships.map((rel) => (
172
+ <div
173
+ key={rel.name}
174
+ className={cn(
175
+ "px-3 py-1.5 flex items-center justify-between text-xs border-b border-gray-50 last:border-0 relative group/rel",
176
+ rel.inherited ? "bg-gray-50/50" : "",
177
+ )}
178
+ >
179
+ {/* Left handle - invisible but functional */}
180
+ <Handle
181
+ type="source"
182
+ position={Position.Left}
183
+ id={`rel-${rel.name}-left`}
184
+ style={{
185
+ left: -2,
186
+ width: 4,
187
+ height: 4,
188
+ background: "transparent",
189
+ border: "none",
190
+ }}
191
+ />
192
+ <div className="flex items-center gap-2 min-w-0 flex-1">
193
+ <span
194
+ className={cn(
195
+ "font-medium truncate",
196
+ rel.inherited ? "text-gray-400" : "text-gray-700",
197
+ )}
198
+ >
199
+ {rel.name}
200
+ </span>
201
+ {rel.inherited && (
202
+ <span className="text-[10px] text-gray-400">
203
+ (inherited)
204
+ </span>
205
+ )}
206
+ </div>
207
+ <div className="flex items-center gap-1 ml-2 shrink-0">
208
+ <span
209
+ className={cn(
210
+ "px-1.5 py-0.5 rounded text-[10px]",
211
+ rel.cardinality === "many"
212
+ ? "bg-purple-100 text-purple-700"
213
+ : "bg-blue-100 text-blue-700",
214
+ )}
215
+ >
216
+ {rel.cardinality}
217
+ </span>
218
+ {rel.peer === nodeData.kind ? (
219
+ <span
220
+ className="text-[10px] text-orange-500 flex items-center gap-0.5"
221
+ title="Self-referencing relationship"
222
+ >
223
+ <Icon icon="mdi:reload" className="text-sm" />
224
+ self
225
+ </span>
226
+ ) : (
227
+ <span className="text-gray-400 text-[10px] truncate max-w-[80px]">
228
+ → {rel.peer}
229
+ </span>
230
+ )}
231
+ </div>
232
+ {/* Right handle - invisible but functional */}
233
+ <Handle
234
+ type="source"
235
+ position={Position.Right}
236
+ id={`rel-${rel.name}-right`}
237
+ style={{
238
+ right: -2,
239
+ width: 4,
240
+ height: 4,
241
+ background: "transparent",
242
+ border: "none",
243
+ }}
244
+ />
245
+ </div>
246
+ ))}
247
+ </div>
248
+ </div>
249
+ )}
250
+
251
+ {/* Empty state */}
252
+ {nodeData.attributes.length === 0 &&
253
+ nodeData.relationships.length === 0 && (
254
+ <div className="px-4 py-6 text-center text-gray-400 text-sm">
255
+ No attributes or relationships
256
+ </div>
257
+ )}
258
+ </div>
259
+ );
260
+ });