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
|
@@ -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
|
+
});
|