@eventcatalog/core 5.0.0-beta.11 → 5.0.0-beta.13
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 +1 -1
- package/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +4 -4
- package/dist/catalog-runtime.cjs +1 -1
- package/dist/{chunk-MFEMKQZ6.js → chunk-BO2HHVZ3.js} +1 -1
- package/dist/{chunk-4YFM63DS.js → chunk-JPT3QC5V.js} +1 -1
- package/dist/{chunk-76DAAJLV.js → chunk-PLTLW45Q.js} +5 -5
- package/dist/{chunk-WM4QYMIA.js → chunk-T75DOVFP.js} +1 -1
- package/dist/{chunk-LQZ7IG7B.js → chunk-XA5W6DXX.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +2 -2
- package/dist/eventcatalog.js +14 -14
- package/dist/federation/federate.js +3 -3
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
- package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
- package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
- package/dist/runtime-dependencies/manifest.json +3 -0
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
- package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +42 -3
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
- package/eventcatalog/src/components/NavigationProgress.astro +7 -0
- package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
- package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +3 -3
- package/eventcatalog/src/features/mcp/apps/canvas/styles.css +3 -1
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +7 -5
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +132 -70
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +71 -71
- package/eventcatalog/src/features/studio/agent-choreography.ts +13 -10
- package/eventcatalog/src/features/studio/agents.ts +28 -0
- package/eventcatalog/src/features/studio/api/canvases-api.ts +64 -15
- package/eventcatalog/src/features/studio/api/client.ts +6 -1
- package/eventcatalog/src/features/studio/canvas-actions.ts +160 -34
- package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
- package/eventcatalog/src/features/studio/catalog.ts +16 -14
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +263 -115
- package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +15 -14
- package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +272 -119
- package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +1 -2
- package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +28 -44
- package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +119 -230
- package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
- package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +52 -45
- package/eventcatalog/src/features/studio/components/ShareDialog.tsx +277 -141
- package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1286 -411
- package/eventcatalog/src/features/studio/document-name.ts +7 -0
- package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
- package/eventcatalog/src/features/studio/hooks/presence-sender.ts +19 -2
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +9 -3
- package/eventcatalog/src/features/studio/hooks/use-comments.ts +2 -2
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +223 -30
- package/eventcatalog/src/features/studio/identity.ts +23 -1
- package/eventcatalog/src/features/studio/limits.ts +2 -0
- package/eventcatalog/src/features/studio/pages/[id].astro +2 -1
- package/eventcatalog/src/features/studio/pages/index.astro +2 -2
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +8 -23
- package/eventcatalog/src/features/studio/server/runtime.ts +16 -9
- package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
- package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
- package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
- package/package.json +3 -3
- package/eventcatalog/src/features/studio/canvas-doc.ts +0 -414
- package/eventcatalog/src/features/studio/clipboard.ts +0 -97
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -154
- package/eventcatalog/src/features/studio/components/Comments.tsx +0 -770
- package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +0 -145
- package/eventcatalog/src/features/studio/components/DragGhost.tsx +0 -115
- package/eventcatalog/src/features/studio/components/Picture.tsx +0 -21
- package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +0 -212
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +0 -255
- package/eventcatalog/src/features/studio/components/icons.ts +0 -53
- package/eventcatalog/src/features/studio/components/status.ts +0 -53
- package/eventcatalog/src/features/studio/edges.ts +0 -56
- package/eventcatalog/src/features/studio/flow-state.ts +0 -92
- package/eventcatalog/src/features/studio/grouping.ts +0 -226
- package/eventcatalog/src/features/studio/layout.ts +0 -42
- package/eventcatalog/src/features/studio/levels.ts +0 -282
- package/eventcatalog/src/features/studio/node-types.ts +0 -205
- package/eventcatalog/src/features/studio/placement.ts +0 -197
- package/eventcatalog/src/features/studio/studio.css +0 -273
- package/dist/{chunk-KHACCTW2.js → chunk-ZEY2XUJN.js} +4 -4
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import { useEffect, useState, type ReactElement } from 'react';
|
|
2
|
-
import { X } from 'lucide-react';
|
|
3
|
-
import type { CatalogResource } from '../catalog-resources';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Asked when a domain or system is dropped on the canvas: is it a card (connected to what it contains), or a
|
|
7
|
-
* container that what it contains sits inside? Styled like the visualiser's group-by picker (boxes or lanes).
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export type ContainerChoice = { as: 'card' } | { as: 'container'; withContents: boolean };
|
|
11
|
-
|
|
12
|
-
// Small pictures of the two ways a domain or system can be on the canvas
|
|
13
|
-
export const CardPreview = () => (
|
|
14
|
-
<svg viewBox="0 0 64 36" className="h-9 w-16" aria-hidden>
|
|
15
|
-
<rect
|
|
16
|
-
x="18"
|
|
17
|
-
y="8"
|
|
18
|
-
width="28"
|
|
19
|
-
height="20"
|
|
20
|
-
rx="4"
|
|
21
|
-
fill="currentColor"
|
|
22
|
-
fillOpacity="0.1"
|
|
23
|
-
stroke="currentColor"
|
|
24
|
-
strokeOpacity="0.5"
|
|
25
|
-
strokeWidth="1.5"
|
|
26
|
-
/>
|
|
27
|
-
<rect x="23" y="13" width="14" height="3" rx="1.5" fill="currentColor" fillOpacity="0.6" />
|
|
28
|
-
<rect x="23" y="19" width="18" height="2" rx="1" fill="currentColor" fillOpacity="0.35" />
|
|
29
|
-
<path d="M46 18h12" stroke="currentColor" strokeOpacity="0.5" strokeWidth="1.5" strokeDasharray="2 2" />
|
|
30
|
-
<rect x="57" y="14" width="6" height="8" rx="1.5" fill="currentColor" fillOpacity="0.4" />
|
|
31
|
-
</svg>
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
const ContainerPreview = () => (
|
|
35
|
-
<svg viewBox="0 0 64 36" className="h-9 w-16" aria-hidden>
|
|
36
|
-
<rect
|
|
37
|
-
x="4"
|
|
38
|
-
y="2"
|
|
39
|
-
width="56"
|
|
40
|
-
height="32"
|
|
41
|
-
rx="4"
|
|
42
|
-
fill="currentColor"
|
|
43
|
-
fillOpacity="0.08"
|
|
44
|
-
stroke="currentColor"
|
|
45
|
-
strokeOpacity="0.5"
|
|
46
|
-
strokeWidth="1.5"
|
|
47
|
-
/>
|
|
48
|
-
<path d="M4 10h56" stroke="currentColor" strokeOpacity="0.35" strokeWidth="1" />
|
|
49
|
-
<rect x="8" y="4.5" width="14" height="3" rx="1.5" fill="currentColor" fillOpacity="0.6" />
|
|
50
|
-
<rect x="10" y="15" width="18" height="12" rx="2" fill="currentColor" fillOpacity="0.5" />
|
|
51
|
-
<rect x="36" y="15" width="18" height="12" rx="2" fill="currentColor" fillOpacity="0.5" />
|
|
52
|
-
</svg>
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
const OPTIONS: { as: ContainerChoice['as']; label: string; hint: string; Preview: () => ReactElement }[] = [
|
|
56
|
-
{ as: 'card', label: 'Card', hint: 'Connected to what it contains', Preview: CardPreview },
|
|
57
|
-
{ as: 'container', label: 'Container', hint: 'What it contains sits inside it', Preview: ContainerPreview },
|
|
58
|
-
];
|
|
59
|
-
|
|
60
|
-
const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
61
|
-
|
|
62
|
-
export default function ContainerChooser({
|
|
63
|
-
resource,
|
|
64
|
-
contents,
|
|
65
|
-
position,
|
|
66
|
-
onChoose,
|
|
67
|
-
onCancel,
|
|
68
|
-
}: {
|
|
69
|
-
resource: CatalogResource;
|
|
70
|
-
/** What it contains in the catalog that isn't on the canvas yet */
|
|
71
|
-
contents: { systems: number; services: number };
|
|
72
|
-
/** Where to show it (screen coordinates) */
|
|
73
|
-
position: { x: number; y: number };
|
|
74
|
-
onChoose: (choice: ContainerChoice) => void;
|
|
75
|
-
onCancel: () => void;
|
|
76
|
-
}) {
|
|
77
|
-
const [withContents, setWithContents] = useState(true);
|
|
78
|
-
const noun = resource.collection === 'domains' ? 'domain' : 'system';
|
|
79
|
-
const contentsLabel = [
|
|
80
|
-
contents.systems > 0 && plural(contents.systems, 'system'),
|
|
81
|
-
contents.services > 0 && plural(contents.services, 'service'),
|
|
82
|
-
]
|
|
83
|
-
.filter(Boolean)
|
|
84
|
-
.join(' and ');
|
|
85
|
-
|
|
86
|
-
useEffect(() => {
|
|
87
|
-
const onKeyDown = (event: KeyboardEvent) => event.key === 'Escape' && onCancel();
|
|
88
|
-
window.addEventListener('keydown', onKeyDown);
|
|
89
|
-
return () => window.removeEventListener('keydown', onKeyDown);
|
|
90
|
-
}, [onCancel]);
|
|
91
|
-
|
|
92
|
-
// Kept on screen, next to where it was dropped
|
|
93
|
-
const left = Math.min(Math.max(position.x - 160, 12), window.innerWidth - 332);
|
|
94
|
-
const top = Math.min(Math.max(position.y - 20, 12), window.innerHeight - 260);
|
|
95
|
-
|
|
96
|
-
return (
|
|
97
|
-
<div className="fixed inset-0 z-50" onPointerDown={onCancel}>
|
|
98
|
-
<div
|
|
99
|
-
role="dialog"
|
|
100
|
-
aria-label={`Add ${resource.name} as`}
|
|
101
|
-
onPointerDown={(event) => event.stopPropagation()}
|
|
102
|
-
className="absolute w-80 rounded-lg border p-1.5 shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]"
|
|
103
|
-
style={{ left, top }}
|
|
104
|
-
>
|
|
105
|
-
<div className="flex items-start justify-between gap-2 px-2 pb-1.5 pt-1">
|
|
106
|
-
<div>
|
|
107
|
-
<p className="text-[10px] font-semibold uppercase tracking-wider text-[rgb(var(--ec-page-text-muted))]">
|
|
108
|
-
Add this {noun} as
|
|
109
|
-
</p>
|
|
110
|
-
<p className="truncate text-xs font-semibold">{resource.name}</p>
|
|
111
|
-
</div>
|
|
112
|
-
<button
|
|
113
|
-
onClick={onCancel}
|
|
114
|
-
aria-label="Cancel"
|
|
115
|
-
className="rounded p-0.5 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)]"
|
|
116
|
-
>
|
|
117
|
-
<X size={14} />
|
|
118
|
-
</button>
|
|
119
|
-
</div>
|
|
120
|
-
|
|
121
|
-
<div className="grid grid-cols-2 gap-1.5 px-0.5 pb-0.5">
|
|
122
|
-
{OPTIONS.map(({ as, label, hint, Preview }) => (
|
|
123
|
-
<button
|
|
124
|
-
key={as}
|
|
125
|
-
autoFocus={as === 'container'}
|
|
126
|
-
onClick={() => onChoose(as === 'card' ? { as } : { as, withContents: withContents && !!contentsLabel })}
|
|
127
|
-
className="flex flex-col items-center gap-1 rounded-md border px-2 pb-1.5 pt-2 text-xs outline-none transition-colors border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text-muted))] hover:border-[rgb(var(--ec-accent))] hover:bg-[rgb(var(--ec-accent)/0.08)] hover:text-[rgb(var(--ec-accent))] focus-visible:border-[rgb(var(--ec-accent))] focus-visible:text-[rgb(var(--ec-accent))]"
|
|
128
|
-
>
|
|
129
|
-
<Preview />
|
|
130
|
-
<span className="font-semibold">{label}</span>
|
|
131
|
-
<span className="text-center text-[10px] leading-tight text-[rgb(var(--ec-page-text-muted))]">{hint}</span>
|
|
132
|
-
</button>
|
|
133
|
-
))}
|
|
134
|
-
</div>
|
|
135
|
-
|
|
136
|
-
{contentsLabel && (
|
|
137
|
-
<label className="mt-1 flex items-center gap-2 rounded-md px-2 py-1.5 text-[11px] text-[rgb(var(--ec-page-text-muted))]">
|
|
138
|
-
<input type="checkbox" checked={withContents} onChange={(event) => setWithContents(event.target.checked)} />
|
|
139
|
-
As a container, bring its {contentsLabel} inside, with their messages and data stores
|
|
140
|
-
</label>
|
|
141
|
-
)}
|
|
142
|
-
</div>
|
|
143
|
-
</div>
|
|
144
|
-
);
|
|
145
|
-
}
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from 'react';
|
|
2
|
-
import { ReactFlow, ReactFlowProvider, useStoreApi, type Node, type XYPosition } from '@xyflow/react';
|
|
3
|
-
import { getNodeSize, isGroupType } from '../node-types';
|
|
4
|
-
import { nodeTypes } from './canvas-nodes';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Dragging something from the left panel onto the canvas shows the node it becomes, under the pointer, at the
|
|
8
|
-
* canvas's zoom and where it will land (centred on the pointer, as it's dropped). Drawn here rather than as the
|
|
9
|
-
* browser's drag image, which browsers shrink, fade and put a backing behind.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/** What's dragged: the node it becomes */
|
|
13
|
-
export type DragPreview = { type: string; data: Record<string, unknown> };
|
|
14
|
-
/** Starts dragging `value` as `dragType`, showing `preview` as what's dragged */
|
|
15
|
-
export type StartDrag = (dragType: string, value: string, preview: DragPreview) => (event: DragEvent) => void;
|
|
16
|
-
|
|
17
|
-
// Hides the browser's own drag image (a transparent pixel)
|
|
18
|
-
const NO_DRAG_IMAGE =
|
|
19
|
-
typeof Image === 'undefined'
|
|
20
|
-
? undefined
|
|
21
|
-
: Object.assign(new Image(), { src: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' });
|
|
22
|
-
|
|
23
|
-
// Room around the node for the badge on its top edge
|
|
24
|
-
const MARGIN = 16;
|
|
25
|
-
const PRO_OPTIONS = { hideAttribution: true };
|
|
26
|
-
|
|
27
|
-
type Ghost = { preview: DragPreview; zoom: number; at: XYPosition };
|
|
28
|
-
|
|
29
|
-
/** The ghost of what's being dragged, and how to start dragging something with one */
|
|
30
|
-
export function useDragGhost() {
|
|
31
|
-
const store = useStoreApi();
|
|
32
|
-
const [ghost, setGhost] = useState<Ghost | null>(null);
|
|
33
|
-
|
|
34
|
-
const startDrag = useCallback<StartDrag>(
|
|
35
|
-
(dragType, value, preview) => (event) => {
|
|
36
|
-
event.dataTransfer.setData(dragType, value);
|
|
37
|
-
event.dataTransfer.effectAllowed = 'move';
|
|
38
|
-
if (NO_DRAG_IMAGE) event.dataTransfer.setDragImage(NO_DRAG_IMAGE, 0, 0);
|
|
39
|
-
setGhost({ preview, zoom: store.getState().transform[2], at: { x: event.clientX, y: event.clientY } });
|
|
40
|
-
},
|
|
41
|
-
[store]
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
const element = ghost && <DragGhost ghost={ghost} onEnd={() => setGhost(null)} />;
|
|
45
|
-
return { startDrag, ghost: element };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function DragGhost({ ghost, onEnd }: { ghost: Ghost; onEnd: () => void }) {
|
|
49
|
-
const ref = useRef<HTMLDivElement>(null);
|
|
50
|
-
const { preview, zoom } = ghost;
|
|
51
|
-
const container = isGroupType(preview.type);
|
|
52
|
-
// The size it's added at (containers are sized; cards are drawn at their own size inside this)
|
|
53
|
-
const size = getNodeSize(preview.type);
|
|
54
|
-
// The node is placed with its middle where it's dropped, at the size it's added at
|
|
55
|
-
const offset = { x: (size.width / 2) * zoom + MARGIN, y: (size.height / 2) * zoom + MARGIN };
|
|
56
|
-
|
|
57
|
-
// Follows the pointer (as the browser reports it while dragging) without re-rendering, and goes when it's dropped
|
|
58
|
-
useEffect(() => {
|
|
59
|
-
const move = (x: number, y: number) => {
|
|
60
|
-
if (ref.current) ref.current.style.transform = `translate(${x - offset.x}px, ${y - offset.y}px)`;
|
|
61
|
-
};
|
|
62
|
-
move(ghost.at.x, ghost.at.y);
|
|
63
|
-
const onDragOver = (event: globalThis.DragEvent) => {
|
|
64
|
-
if (event.clientX || event.clientY) move(event.clientX, event.clientY);
|
|
65
|
-
};
|
|
66
|
-
document.addEventListener('dragover', onDragOver);
|
|
67
|
-
document.addEventListener('drop', onEnd);
|
|
68
|
-
document.addEventListener('dragend', onEnd);
|
|
69
|
-
return () => {
|
|
70
|
-
document.removeEventListener('dragover', onDragOver);
|
|
71
|
-
document.removeEventListener('drop', onEnd);
|
|
72
|
-
document.removeEventListener('dragend', onEnd);
|
|
73
|
-
};
|
|
74
|
-
}, [ghost]);
|
|
75
|
-
|
|
76
|
-
const nodes = useMemo<Node[]>(
|
|
77
|
-
() => [
|
|
78
|
-
{
|
|
79
|
-
id: 'drag-ghost',
|
|
80
|
-
type: preview.type,
|
|
81
|
-
position: { x: 0, y: 0 },
|
|
82
|
-
data: preview.data,
|
|
83
|
-
...(container && size),
|
|
84
|
-
},
|
|
85
|
-
],
|
|
86
|
-
[preview, container, size]
|
|
87
|
-
);
|
|
88
|
-
const viewport = useMemo(() => ({ x: MARGIN, y: MARGIN, zoom }), [zoom]);
|
|
89
|
-
|
|
90
|
-
return (
|
|
91
|
-
<div
|
|
92
|
-
ref={ref}
|
|
93
|
-
aria-hidden
|
|
94
|
-
// Sized here: the visualiser's styles make its class fill the window
|
|
95
|
-
className="studio-drag-preview eventcatalog-visualizer pointer-events-none fixed left-0 top-0 z-[100]"
|
|
96
|
-
style={{ width: size.width * zoom + MARGIN * 2, height: size.height * zoom + MARGIN * 2 }}
|
|
97
|
-
>
|
|
98
|
-
<ReactFlowProvider>
|
|
99
|
-
<ReactFlow
|
|
100
|
-
nodes={nodes}
|
|
101
|
-
nodeTypes={nodeTypes}
|
|
102
|
-
defaultViewport={viewport}
|
|
103
|
-
proOptions={PRO_OPTIONS}
|
|
104
|
-
nodesDraggable={false}
|
|
105
|
-
nodesConnectable={false}
|
|
106
|
-
elementsSelectable={false}
|
|
107
|
-
panOnDrag={false}
|
|
108
|
-
zoomOnScroll={false}
|
|
109
|
-
zoomOnPinch={false}
|
|
110
|
-
preventScrolling={false}
|
|
111
|
-
/>
|
|
112
|
-
</ReactFlowProvider>
|
|
113
|
-
</div>
|
|
114
|
-
);
|
|
115
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Someone's picture from their sign-in provider (SSO), loaded from there. By default it covers their avatar's
|
|
5
|
-
* initials, which show again if it doesn't load (blocked, expired, or no longer there).
|
|
6
|
-
*/
|
|
7
|
-
export default function Picture({ src, className = 'absolute inset-0 h-full w-full' }: { src?: string; className?: string }) {
|
|
8
|
-
const [failed, setFailed] = useState<string>();
|
|
9
|
-
if (!src || failed === src) return null;
|
|
10
|
-
return (
|
|
11
|
-
<img
|
|
12
|
-
src={src}
|
|
13
|
-
alt=""
|
|
14
|
-
draggable={false}
|
|
15
|
-
// Some providers (e.g. Google) refuse pictures loaded with a referrer from another site
|
|
16
|
-
referrerPolicy="no-referrer"
|
|
17
|
-
onError={() => setFailed(src)}
|
|
18
|
-
className={`rounded-full object-cover ${className}`}
|
|
19
|
-
/>
|
|
20
|
-
);
|
|
21
|
-
}
|
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
import { memo, useState, type FormEvent, type ReactNode } from 'react';
|
|
2
|
-
import type { Node } from '@xyflow/react';
|
|
3
|
-
import { Box, ExternalLink, Library, Trash2, X } from 'lucide-react';
|
|
4
|
-
import { getCatalogLink } from '../catalog';
|
|
5
|
-
import { getNodeLabel, getResource, isVersioned, updateResource } from '../node-types';
|
|
6
|
-
import type { UpdateNodeData } from './canvas-nodes';
|
|
7
|
-
import { NODE_ICONS } from './icons';
|
|
8
|
-
import { STATUS } from './status';
|
|
9
|
-
|
|
10
|
-
const inputClass =
|
|
11
|
-
'w-full rounded-lg border px-3 py-2 text-sm shadow-sm bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))] placeholder:text-[rgb(var(--ec-page-text-muted))] focus:outline-none focus:border-[rgb(var(--ec-accent))] focus:ring-2 focus:ring-[rgb(var(--ec-accent)/0.25)] read-only:cursor-default read-only:opacity-70 read-only:shadow-none read-only:focus:ring-0 read-only:focus:border-[rgb(var(--ec-input-border))]';
|
|
12
|
-
const buttonClass = 'rounded-lg px-4 py-2 text-sm font-medium transition-colors';
|
|
13
|
-
|
|
14
|
-
// Versions are usually semantic versions (1.0.0), but any text is allowed
|
|
15
|
-
const LOOKS_LIKE_A_VERSION = /^v?\d+(\.\d+){0,2}([-+][\w.-]+)?$/;
|
|
16
|
-
|
|
17
|
-
/** How to name each type, shown under the name */
|
|
18
|
-
const NAME_HINTS: Record<string, string> = {
|
|
19
|
-
event: 'The name of the event. Use a verb-noun format in the past tense (e.g., OrderPlaced).',
|
|
20
|
-
command: 'The name of the command. Use an instruction (e.g., PlaceOrder).',
|
|
21
|
-
query: 'The name of the query. Ask for something (e.g., GetOrder).',
|
|
22
|
-
service: 'The name of the service (e.g., Orders Service).',
|
|
23
|
-
channel: 'The name of the channel (e.g., orders.events).',
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
type Field = 'name' | 'version' | 'summary';
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* The details of the node being edited: its name, version and summary, saved together with Save changes (one
|
|
30
|
-
* change for everyone, and one step to undo). Catalog resources are read only: they're changed in the catalog.
|
|
31
|
-
* Notes and text are edited on the canvas.
|
|
32
|
-
*/
|
|
33
|
-
export default memo(function PropertiesPanel({
|
|
34
|
-
node,
|
|
35
|
-
onChange,
|
|
36
|
-
onDelete,
|
|
37
|
-
onClose,
|
|
38
|
-
}: {
|
|
39
|
-
/** What's shown of the node (not its position, so dragging it doesn't re-render the panel) */
|
|
40
|
-
node: Pick<Node, 'id' | 'type' | 'data'>;
|
|
41
|
-
onChange: UpdateNodeData;
|
|
42
|
-
onDelete: (ids: string[]) => void;
|
|
43
|
-
onClose: () => void;
|
|
44
|
-
}) {
|
|
45
|
-
const resource = getResource(node.type, node.data);
|
|
46
|
-
const link = getCatalogLink(node);
|
|
47
|
-
const label = getNodeLabel(node.type);
|
|
48
|
-
const noun = label.toLowerCase();
|
|
49
|
-
const { icon: Icon, className: iconClassName } = NODE_ICONS[node.type ?? ''] ?? { icon: Box, className: '' };
|
|
50
|
-
const versioned = isVersioned(node.type);
|
|
51
|
-
|
|
52
|
-
// What's been changed here and not saved. Fields not changed show the node as it is, so changes others make while
|
|
53
|
-
// it's open show up.
|
|
54
|
-
const [draft, setDraft] = useState<Partial<Record<Field, string>>>({});
|
|
55
|
-
const saved: Record<Field, string> = {
|
|
56
|
-
name: String(resource.name ?? ''),
|
|
57
|
-
version: String(link?.version ?? resource.version ?? ''),
|
|
58
|
-
summary: String(resource.summary ?? ''),
|
|
59
|
-
};
|
|
60
|
-
const value = (field: Field) => draft[field] ?? saved[field];
|
|
61
|
-
const edit = (field: Field) => (event: { target: { value: string } }) =>
|
|
62
|
-
setDraft((current) => ({ ...current, [field]: event.target.value }));
|
|
63
|
-
|
|
64
|
-
const changes = Object.entries(draft).filter(([field, text]) => text !== saved[field as Field]);
|
|
65
|
-
const missing = !value('name').trim() || (versioned && !value('version').trim());
|
|
66
|
-
const canSave = !link && changes.length > 0 && !missing;
|
|
67
|
-
const version = value('version');
|
|
68
|
-
|
|
69
|
-
const save = (event?: FormEvent) => {
|
|
70
|
-
event?.preventDefault();
|
|
71
|
-
if (!canSave) return;
|
|
72
|
-
onChange(node.id, (data) => updateResource(node.type, data, Object.fromEntries(changes)));
|
|
73
|
-
setDraft({});
|
|
74
|
-
onClose();
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
return (
|
|
78
|
-
<aside className="flex w-[22rem] shrink-0 flex-col border-l bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]">
|
|
79
|
-
<header className="flex items-center gap-3 border-b px-5 py-4 border-[rgb(var(--ec-page-border))]">
|
|
80
|
-
<Icon size={22} className={`shrink-0 ${iconClassName}`} />
|
|
81
|
-
<h2 className="min-w-0 flex-1 truncate text-lg font-semibold">{label}</h2>
|
|
82
|
-
<button
|
|
83
|
-
type="button"
|
|
84
|
-
onClick={onClose}
|
|
85
|
-
title="Close"
|
|
86
|
-
aria-label="Close"
|
|
87
|
-
className="shrink-0 rounded-md p-1 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)] hover:text-[rgb(var(--ec-icon-hover))]"
|
|
88
|
-
>
|
|
89
|
-
<X size={18} />
|
|
90
|
-
</button>
|
|
91
|
-
</header>
|
|
92
|
-
|
|
93
|
-
<form
|
|
94
|
-
onSubmit={save}
|
|
95
|
-
// ⌘/Ctrl + Enter saves from any field (Enter alone adds a line to the summary)
|
|
96
|
-
onKeyDown={(event) => (event.metaKey || event.ctrlKey) && event.key === 'Enter' && save(event)}
|
|
97
|
-
className="flex min-h-0 flex-1 flex-col"
|
|
98
|
-
>
|
|
99
|
-
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-5">
|
|
100
|
-
{link && (
|
|
101
|
-
<div className="space-y-2 rounded-lg border p-3 text-xs bg-[rgb(var(--ec-accent-subtle)/0.5)] border-[rgb(var(--ec-accent)/0.25)]">
|
|
102
|
-
<p className="flex items-center gap-1.5 font-semibold text-[rgb(var(--ec-page-text))]">
|
|
103
|
-
<Library size={14} className="text-[rgb(var(--ec-accent))]" />
|
|
104
|
-
From your catalog
|
|
105
|
-
</p>
|
|
106
|
-
<p className="text-[rgb(var(--ec-page-text-muted))]">
|
|
107
|
-
Its name, version and summary come from the catalog: change them there and they're kept in sync here.
|
|
108
|
-
</p>
|
|
109
|
-
<a
|
|
110
|
-
href={link.url}
|
|
111
|
-
target="_blank"
|
|
112
|
-
rel="noreferrer"
|
|
113
|
-
className="inline-flex items-center gap-1 font-medium text-[rgb(var(--ec-accent))] hover:underline"
|
|
114
|
-
>
|
|
115
|
-
Open in catalog <ExternalLink size={12} />
|
|
116
|
-
</a>
|
|
117
|
-
</div>
|
|
118
|
-
)}
|
|
119
|
-
|
|
120
|
-
<FormField label="Name" hint={NAME_HINTS[node.type ?? ''] ?? `The name of the ${noun}.`} required={!link}>
|
|
121
|
-
<input
|
|
122
|
-
autoFocus={!link}
|
|
123
|
-
value={value('name')}
|
|
124
|
-
readOnly={!!link}
|
|
125
|
-
onChange={edit('name')}
|
|
126
|
-
placeholder={`Name this ${noun}`}
|
|
127
|
-
className={inputClass}
|
|
128
|
-
/>
|
|
129
|
-
</FormField>
|
|
130
|
-
{versioned && (
|
|
131
|
-
<FormField label="Version" hint="The version number (e.g., 1.0.0)." required={!link}>
|
|
132
|
-
<input
|
|
133
|
-
value={version}
|
|
134
|
-
readOnly={!!link}
|
|
135
|
-
onChange={edit('version')}
|
|
136
|
-
placeholder="0.0.1"
|
|
137
|
-
spellCheck={false}
|
|
138
|
-
className={inputClass}
|
|
139
|
-
/>
|
|
140
|
-
{!link && version && !LOOKS_LIKE_A_VERSION.test(version) && (
|
|
141
|
-
<p className={`text-xs ${STATUS.warning.text}`}>Versions are usually like 1.0.0</p>
|
|
142
|
-
)}
|
|
143
|
-
</FormField>
|
|
144
|
-
)}
|
|
145
|
-
<FormField label="Summary" hint={`A brief summary of the ${noun}.`}>
|
|
146
|
-
<textarea
|
|
147
|
-
rows={5}
|
|
148
|
-
value={value('summary')}
|
|
149
|
-
readOnly={!!link}
|
|
150
|
-
onChange={edit('summary')}
|
|
151
|
-
placeholder={`What does this ${noun} do?`}
|
|
152
|
-
className={`${inputClass} resize-y`}
|
|
153
|
-
/>
|
|
154
|
-
</FormField>
|
|
155
|
-
</div>
|
|
156
|
-
|
|
157
|
-
<footer className="flex items-center gap-2 border-t px-5 py-4 border-[rgb(var(--ec-page-border))]">
|
|
158
|
-
<button
|
|
159
|
-
type="button"
|
|
160
|
-
onClick={() => onDelete([node.id])}
|
|
161
|
-
title={`Delete ${noun}`}
|
|
162
|
-
aria-label={`Delete ${noun}`}
|
|
163
|
-
className={`mr-auto rounded-lg p-2 ${STATUS.danger.text} ${STATUS.danger.hover}`}
|
|
164
|
-
>
|
|
165
|
-
<Trash2 size={16} />
|
|
166
|
-
</button>
|
|
167
|
-
<button
|
|
168
|
-
type="button"
|
|
169
|
-
onClick={onClose}
|
|
170
|
-
className={`${buttonClass} border border-[rgb(var(--ec-page-border))] hover:bg-[rgb(var(--ec-page-border)/0.4)]`}
|
|
171
|
-
>
|
|
172
|
-
{link ? 'Close' : 'Cancel'}
|
|
173
|
-
</button>
|
|
174
|
-
{!link && (
|
|
175
|
-
<button
|
|
176
|
-
type="submit"
|
|
177
|
-
disabled={!canSave}
|
|
178
|
-
className={`${buttonClass} bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))] disabled:cursor-not-allowed disabled:opacity-50`}
|
|
179
|
-
>
|
|
180
|
-
Save changes
|
|
181
|
-
</button>
|
|
182
|
-
)}
|
|
183
|
-
</footer>
|
|
184
|
-
</form>
|
|
185
|
-
</aside>
|
|
186
|
-
);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
function FormField({
|
|
190
|
-
label,
|
|
191
|
-
hint,
|
|
192
|
-
required = false,
|
|
193
|
-
children,
|
|
194
|
-
}: {
|
|
195
|
-
label: string;
|
|
196
|
-
hint: string;
|
|
197
|
-
required?: boolean;
|
|
198
|
-
children: ReactNode;
|
|
199
|
-
}) {
|
|
200
|
-
return (
|
|
201
|
-
<label className="block space-y-2">
|
|
202
|
-
<span className="block">
|
|
203
|
-
<span className="block text-sm font-semibold text-[rgb(var(--ec-page-text))]">
|
|
204
|
-
{label}
|
|
205
|
-
{required && <span className={`ml-0.5 ${STATUS.danger.text}`}>*</span>}
|
|
206
|
-
</span>
|
|
207
|
-
<span className="mt-0.5 block text-xs text-[rgb(var(--ec-page-text-muted))]">{hint}</span>
|
|
208
|
-
</span>
|
|
209
|
-
{children}
|
|
210
|
-
</label>
|
|
211
|
-
);
|
|
212
|
-
}
|