@eventcatalog/core 5.0.0-beta.12 → 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/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-FY6MSP6J.js → chunk-BO2HHVZ3.js} +1 -1
- package/dist/{chunk-CX2VB7AI.js → chunk-JPT3QC5V.js} +1 -1
- package/dist/{chunk-BWC4YKW3.js → chunk-PLTLW45Q.js} +5 -5
- package/dist/{chunk-TSY5CC27.js → chunk-T75DOVFP.js} +1 -1
- package/dist/{chunk-QB7X34OD.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/MDX/NodeGraph/AstroNodeGraph.tsx +11 -3
- 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 +133 -71
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +66 -66
- package/eventcatalog/src/features/studio/agent-choreography.ts +5 -4
- package/eventcatalog/src/features/studio/agents.ts +28 -0
- package/eventcatalog/src/features/studio/api/canvases-api.ts +1 -1
- package/eventcatalog/src/features/studio/canvas-actions.ts +22 -14
- package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
- package/eventcatalog/src/features/studio/catalog.ts +1 -4
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +246 -115
- package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +7 -3
- 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 +1 -1
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +102 -241
- package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
- package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +1 -1
- package/eventcatalog/src/features/studio/components/Presence.tsx +6 -8
- 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 +1005 -354
- package/eventcatalog/src/features/studio/document-name.ts +7 -0
- package/eventcatalog/src/features/studio/from-visualiser.ts +1 -1
- 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-studio-flow.ts +162 -23
- package/eventcatalog/src/features/studio/identity.ts +23 -1
- package/eventcatalog/src/features/studio/pages/[id].astro +2 -1
- package/eventcatalog/src/features/studio/pages/index.astro +1 -2
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +7 -22
- package/eventcatalog/src/features/studio/server/runtime.ts +4 -5
- package/package.json +3 -3
- package/eventcatalog/src/features/studio/canvas-doc.ts +0 -569
- package/eventcatalog/src/features/studio/clipboard.ts +0 -97
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -119
- package/eventcatalog/src/features/studio/components/Comments.tsx +0 -777
- 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 -271
- 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 -121
- package/eventcatalog/src/features/studio/grouping.ts +0 -230
- package/eventcatalog/src/features/studio/hooks/use-counter-scale.ts +0 -20
- package/eventcatalog/src/features/studio/layout.ts +0 -56
- package/eventcatalog/src/features/studio/level-layout.ts +0 -145
- package/eventcatalog/src/features/studio/levels.ts +0 -337
- package/eventcatalog/src/features/studio/node-types.ts +0 -245
- 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,271 +0,0 @@
|
|
|
1
|
-
import { createContext, memo, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import { NodeResizeControl, NodeResizer, ResizeControlVariant, useStoreApi, type NodeProps, type NodeTypes } from '@xyflow/react';
|
|
3
|
-
import { diagramNodeComponents, DomainCardNode, memoNode, SystemGroupNode } from '@eventcatalog/visualiser';
|
|
4
|
-
import { GROUP_PADDING } from '../grouping';
|
|
5
|
-
import { getNodeName, GROUP_TYPES } from '../node-types';
|
|
6
|
-
|
|
7
|
-
/** How the canvas draws each kind of node: the visualiser's nodes, notes and text written on the canvas, and resizable containers */
|
|
8
|
-
|
|
9
|
-
export type UpdateNodeData = (id: string, updater: (data: Record<string, unknown>) => Record<string, unknown>) => void;
|
|
10
|
-
|
|
11
|
-
// Notes are edited in place, so their callbacks need to reach the shared design
|
|
12
|
-
export const UpdateNodeDataContext = createContext<UpdateNodeData>(() => {});
|
|
13
|
-
|
|
14
|
-
// Which container something dragged would drop into, to highlight it
|
|
15
|
-
export const DropTargetContext = createContext<string | null>(null);
|
|
16
|
-
|
|
17
|
-
const SystemGroupComponent: NodeTypes[string] = SystemGroupNode;
|
|
18
|
-
|
|
19
|
-
/** The handles that set a node's width, on either side (its height follows what's written in it) */
|
|
20
|
-
function WidthHandles() {
|
|
21
|
-
return (
|
|
22
|
-
<>
|
|
23
|
-
{/* Horizontal only: otherwise React Flow saves the height too, and it stops growing with what's written */}
|
|
24
|
-
<NodeResizeControl
|
|
25
|
-
position="left"
|
|
26
|
-
variant={ResizeControlVariant.Line}
|
|
27
|
-
resizeDirection="horizontal"
|
|
28
|
-
minWidth={80}
|
|
29
|
-
className="studio-width-handle"
|
|
30
|
-
/>
|
|
31
|
-
<NodeResizeControl
|
|
32
|
-
position="right"
|
|
33
|
-
variant={ResizeControlVariant.Line}
|
|
34
|
-
resizeDirection="horizontal"
|
|
35
|
-
minWidth={80}
|
|
36
|
-
className="studio-width-handle"
|
|
37
|
-
/>
|
|
38
|
-
</>
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* What's written on a note or text, and writing it: Enter starts a new line, Escape or ⌘/Ctrl + Enter (or clicking
|
|
44
|
-
* away) finishes, and it's saved for everyone then
|
|
45
|
-
*/
|
|
46
|
-
function Writing({
|
|
47
|
-
id,
|
|
48
|
-
text,
|
|
49
|
-
editing,
|
|
50
|
-
onDone,
|
|
51
|
-
placeholder,
|
|
52
|
-
className,
|
|
53
|
-
}: {
|
|
54
|
-
id: string;
|
|
55
|
-
text: string;
|
|
56
|
-
editing: boolean;
|
|
57
|
-
onDone: () => void;
|
|
58
|
-
placeholder: string;
|
|
59
|
-
className: string;
|
|
60
|
-
}) {
|
|
61
|
-
const updateNodeData = useContext(UpdateNodeDataContext);
|
|
62
|
-
// The box grows with what's written (the node's height follows it)
|
|
63
|
-
const fit = (area: HTMLTextAreaElement) => {
|
|
64
|
-
area.style.height = 'auto';
|
|
65
|
-
area.style.height = `${area.scrollHeight}px`;
|
|
66
|
-
};
|
|
67
|
-
if (!editing) return <p className={className}>{text || <span className="opacity-50">{placeholder}</span>}</p>;
|
|
68
|
-
return (
|
|
69
|
-
<textarea
|
|
70
|
-
ref={(area) => {
|
|
71
|
-
if (area) fit(area);
|
|
72
|
-
}}
|
|
73
|
-
autoFocus
|
|
74
|
-
defaultValue={text}
|
|
75
|
-
placeholder={placeholder}
|
|
76
|
-
onFocus={(event) => event.currentTarget.select()}
|
|
77
|
-
onInput={(event) => fit(event.currentTarget)}
|
|
78
|
-
onBlur={(event) => {
|
|
79
|
-
const value = event.currentTarget.value;
|
|
80
|
-
onDone();
|
|
81
|
-
if (value !== text) updateNodeData(id, (current) => ({ ...current, text: value }));
|
|
82
|
-
}}
|
|
83
|
-
onKeyDown={(event) => {
|
|
84
|
-
if (event.key === 'Escape' || (event.key === 'Enter' && (event.metaKey || event.ctrlKey))) event.currentTarget.blur();
|
|
85
|
-
}}
|
|
86
|
-
className={`nodrag nopan nowheel block w-full resize-none overflow-hidden bg-transparent outline-none ${className}`}
|
|
87
|
-
/>
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** A sticky note's colours: soft paper colours, readable in light and dark themes (the names are the visualiser's) */
|
|
92
|
-
const NOTE_COLORS: Record<string, { fill: string; border: string }> = {
|
|
93
|
-
yellow: { fill: '#fef9c3', border: '#fde047' },
|
|
94
|
-
blue: { fill: '#dbeafe', border: '#93c5fd' },
|
|
95
|
-
green: { fill: '#dcfce7', border: '#86efac' },
|
|
96
|
-
pink: { fill: '#fce7f3', border: '#f9a8d4' },
|
|
97
|
-
purple: { fill: '#ede9fe', border: '#c4b5fd' },
|
|
98
|
-
gray: { fill: '#f3f4f6', border: '#d1d5db' },
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* A sticky note: a few lines on paper, about as wide as a card and as tall as what's on it. Double click it to
|
|
103
|
-
* write; when it's selected, its colour can be changed and its width set.
|
|
104
|
-
*/
|
|
105
|
-
const StickyNote = memo(function StickyNote({ id, data, selected }: NodeProps) {
|
|
106
|
-
const updateNodeData = useContext(UpdateNodeDataContext);
|
|
107
|
-
const [editing, setEditing] = useState(false);
|
|
108
|
-
const colorName = typeof data.color === 'string' && NOTE_COLORS[data.color] ? data.color : 'yellow';
|
|
109
|
-
const color = NOTE_COLORS[colorName];
|
|
110
|
-
return (
|
|
111
|
-
<div
|
|
112
|
-
className="studio-note"
|
|
113
|
-
style={{ background: color.fill, borderColor: color.border }}
|
|
114
|
-
onDoubleClick={() => setEditing(true)}
|
|
115
|
-
>
|
|
116
|
-
{selected && !editing && (
|
|
117
|
-
<>
|
|
118
|
-
<WidthHandles />
|
|
119
|
-
<div className="nodrag nopan studio-note-colors" role="radiogroup" aria-label="Note colour">
|
|
120
|
-
{Object.entries(NOTE_COLORS).map(([name, { fill, border }]) => (
|
|
121
|
-
<button
|
|
122
|
-
key={name}
|
|
123
|
-
role="radio"
|
|
124
|
-
aria-checked={name === colorName}
|
|
125
|
-
title={name.charAt(0).toUpperCase() + name.slice(1)}
|
|
126
|
-
onClick={() => updateNodeData(id, (current) => ({ ...current, color: name }))}
|
|
127
|
-
className="studio-note-color"
|
|
128
|
-
style={{ background: fill, borderColor: name === colorName ? 'rgb(var(--ec-page-text))' : border }}
|
|
129
|
-
/>
|
|
130
|
-
))}
|
|
131
|
-
</div>
|
|
132
|
-
</>
|
|
133
|
-
)}
|
|
134
|
-
<Writing
|
|
135
|
-
id={id}
|
|
136
|
-
text={String(data.text ?? '')}
|
|
137
|
-
editing={editing}
|
|
138
|
-
onDone={() => setEditing(false)}
|
|
139
|
-
placeholder="Double-click to write a note"
|
|
140
|
-
className="studio-note-body"
|
|
141
|
-
/>
|
|
142
|
-
</div>
|
|
143
|
-
);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Text written straight on the canvas (titles, labels, notes to self): double click it to write. Its width is set
|
|
148
|
-
* with the handles on either side; its height follows what's written.
|
|
149
|
-
*/
|
|
150
|
-
const TextNode = memo(function TextNode({ id, data, selected }: NodeProps) {
|
|
151
|
-
const [editing, setEditing] = useState(false);
|
|
152
|
-
return (
|
|
153
|
-
<div className="studio-text" onDoubleClick={() => setEditing(true)}>
|
|
154
|
-
{selected && !editing && <WidthHandles />}
|
|
155
|
-
<Writing
|
|
156
|
-
id={id}
|
|
157
|
-
text={String(data.text ?? '')}
|
|
158
|
-
editing={editing}
|
|
159
|
-
onDone={() => setEditing(false)}
|
|
160
|
-
placeholder="Double-click to write"
|
|
161
|
-
className="studio-text-body"
|
|
162
|
-
/>
|
|
163
|
-
</div>
|
|
164
|
-
);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
/** A domain inside another one is a subdomain (badged as one, like the visualiser), unless its data says otherwise */
|
|
168
|
-
const useDomainData = ({ type, data, parentId }: Pick<NodeProps, 'type' | 'data' | 'parentId'>) =>
|
|
169
|
-
useMemo(() => {
|
|
170
|
-
const isDomain = type === GROUP_TYPES.domain || type === 'context-domain';
|
|
171
|
-
return isDomain && parentId && data.subdomain === undefined ? { ...data, subdomain: true } : data;
|
|
172
|
-
}, [type, data, parentId]);
|
|
173
|
-
|
|
174
|
-
/** A domain card, badged as a subdomain when it's in another domain */
|
|
175
|
-
const DomainCard = memo(function DomainCard(props: NodeProps) {
|
|
176
|
-
return <DomainCardNode {...props} data={useDomainData(props)} />;
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* A domain or system as a container: resizable when selected, highlighted when something's dragged over it.
|
|
181
|
-
* Like frames in design tools, it's moved and selected by its header, and dragging inside it draws a selection
|
|
182
|
-
* box (its body lets the pointer through to the canvas: see studio.css).
|
|
183
|
-
*/
|
|
184
|
-
const GroupNode = memo(function GroupNode(props: NodeProps) {
|
|
185
|
-
const isDropTarget = useContext(DropTargetContext) === props.id;
|
|
186
|
-
const data = useDomainData(props);
|
|
187
|
-
const store = useStoreApi();
|
|
188
|
-
// Where what's inside has to stay (with room around it and for the header), in the container's parent's
|
|
189
|
-
// coordinates: found when a resize starts, so it can't be resized smaller than what's in it
|
|
190
|
-
const contents = useRef<{ left: number; top: number; right: number; bottom: number } | null>(null);
|
|
191
|
-
const onResizeStart = useCallback(() => {
|
|
192
|
-
const { nodeLookup } = store.getState();
|
|
193
|
-
const container = nodeLookup.get(props.id);
|
|
194
|
-
const children = [...nodeLookup.values()].filter((node) => node.parentId === props.id);
|
|
195
|
-
if (!container || children.length === 0) {
|
|
196
|
-
contents.current = null;
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
const { x, y } = container.position;
|
|
200
|
-
const size = (node: (typeof children)[number]) => ({
|
|
201
|
-
width: node.measured.width ?? node.width ?? 0,
|
|
202
|
-
height: node.measured.height ?? node.height ?? 0,
|
|
203
|
-
});
|
|
204
|
-
const own = size(container);
|
|
205
|
-
// A side already closer in than that (e.g. something placed in the padding) can still move out, just not in
|
|
206
|
-
contents.current = {
|
|
207
|
-
left: Math.max(x + Math.min(...children.map((node) => node.position.x)) - GROUP_PADDING.left, x),
|
|
208
|
-
top: Math.max(y + Math.min(...children.map((node) => node.position.y)) - GROUP_PADDING.top, y),
|
|
209
|
-
right: Math.min(
|
|
210
|
-
x + Math.max(...children.map((node) => node.position.x + size(node).width)) + GROUP_PADDING.right,
|
|
211
|
-
x + own.width
|
|
212
|
-
),
|
|
213
|
-
bottom: Math.min(
|
|
214
|
-
y + Math.max(...children.map((node) => node.position.y + size(node).height)) + GROUP_PADDING.bottom,
|
|
215
|
-
y + own.height
|
|
216
|
-
),
|
|
217
|
-
};
|
|
218
|
-
}, [store, props.id]);
|
|
219
|
-
const shouldResize = useCallback(
|
|
220
|
-
(_: unknown, { x, y, width, height }: { x: number; y: number; width: number; height: number }) => {
|
|
221
|
-
const bounds = contents.current;
|
|
222
|
-
return !bounds || (x <= bounds.left && y <= bounds.top && x + width >= bounds.right && y + height >= bounds.bottom);
|
|
223
|
-
},
|
|
224
|
-
[]
|
|
225
|
-
);
|
|
226
|
-
return (
|
|
227
|
-
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
|
228
|
-
<NodeResizer
|
|
229
|
-
isVisible={props.selected}
|
|
230
|
-
onResizeStart={onResizeStart}
|
|
231
|
-
shouldResize={shouldResize}
|
|
232
|
-
minWidth={240}
|
|
233
|
-
minHeight={160}
|
|
234
|
-
lineClassName="!border-[rgb(var(--ec-accent))]"
|
|
235
|
-
handleClassName="!h-2.5 !w-2.5 !rounded-sm !border-2 !bg-[rgb(var(--ec-card-bg))] !border-[rgb(var(--ec-accent))]"
|
|
236
|
-
/>
|
|
237
|
-
<SystemGroupComponent {...props} data={data} />
|
|
238
|
-
{/* The header (and the badge on its border) */}
|
|
239
|
-
<div className="ec-group-grip absolute inset-x-0 -top-3 h-[60px] cursor-grab" />
|
|
240
|
-
{isDropTarget ? (
|
|
241
|
-
// What's dragged over it goes in it when it's dropped
|
|
242
|
-
<div className="ec-drop-target">
|
|
243
|
-
<span className="ec-drop-target-label">Drop to add to {getNodeName(props.type, props.data, 'this container')}</span>
|
|
244
|
-
</div>
|
|
245
|
-
) : (
|
|
246
|
-
props.selected && <div className="ec-group-selected" />
|
|
247
|
-
)}
|
|
248
|
-
</div>
|
|
249
|
-
);
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
const components: NodeTypes = {
|
|
253
|
-
// Everything the visualiser draws, drawn the same (so a diagram's levels look as they do there). It types its
|
|
254
|
-
// components' props as nodes (Node<Data>) rather than React Flow's NodeProps, which they're rendered with.
|
|
255
|
-
...(diagramNodeComponents as unknown as NodeTypes),
|
|
256
|
-
// Written on the canvas
|
|
257
|
-
note: StickyNote,
|
|
258
|
-
text: TextNode,
|
|
259
|
-
// Actors look like the visualiser's actors on its architecture diagrams
|
|
260
|
-
actor: diagramNodeComponents['context-actor'] as NodeTypes[string],
|
|
261
|
-
'context-domain': DomainCard,
|
|
262
|
-
// Domains and systems as containers
|
|
263
|
-
[GROUP_TYPES.domain]: GroupNode,
|
|
264
|
-
[GROUP_TYPES.system]: GroupNode,
|
|
265
|
-
};
|
|
266
|
-
|
|
267
|
-
// React Flow gives every node its position, which changes on every frame it moves (dragged, in a dragged container,
|
|
268
|
-
// or gliding to a layout): like the visualiser's, nodes render again only when something else changes
|
|
269
|
-
export const nodeTypes: NodeTypes = Object.fromEntries(
|
|
270
|
-
Object.entries(components).map(([type, component]) => [type, memoNode(component)])
|
|
271
|
-
);
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import type { LucideIcon } from 'lucide-react';
|
|
2
|
-
import {
|
|
3
|
-
ArrowRightLeft,
|
|
4
|
-
Bot,
|
|
5
|
-
Boxes,
|
|
6
|
-
Database,
|
|
7
|
-
Globe,
|
|
8
|
-
Group,
|
|
9
|
-
Mail,
|
|
10
|
-
MonitorSmartphone,
|
|
11
|
-
Search,
|
|
12
|
-
ServerIcon,
|
|
13
|
-
StickyNote,
|
|
14
|
-
Type,
|
|
15
|
-
User,
|
|
16
|
-
Zap,
|
|
17
|
-
} from 'lucide-react';
|
|
18
|
-
import { GROUP_TYPES } from '../node-types';
|
|
19
|
-
|
|
20
|
-
/** How each kind of node, and each catalog collection, looks in the canvas's lists (the colours are each kind's own) */
|
|
21
|
-
export type NodeIcon = { icon: LucideIcon; className: string };
|
|
22
|
-
|
|
23
|
-
export const NODE_ICONS: Record<string, NodeIcon> = {
|
|
24
|
-
[GROUP_TYPES.domain]: { icon: Boxes, className: 'text-yellow-500' },
|
|
25
|
-
[GROUP_TYPES.system]: { icon: Group, className: 'text-violet-600' },
|
|
26
|
-
// Catalog domains and systems added as cards
|
|
27
|
-
'context-domain': { icon: Boxes, className: 'text-yellow-500' },
|
|
28
|
-
system: { icon: Group, className: 'text-violet-600' },
|
|
29
|
-
service: { icon: ServerIcon, className: 'text-pink-600' },
|
|
30
|
-
event: { icon: Zap, className: 'text-orange-600' },
|
|
31
|
-
command: { icon: Mail, className: 'text-blue-600' },
|
|
32
|
-
query: { icon: Search, className: 'text-green-600' },
|
|
33
|
-
channel: { icon: ArrowRightLeft, className: 'text-gray-600' },
|
|
34
|
-
data: { icon: Database, className: 'text-blue-600' },
|
|
35
|
-
view: { icon: MonitorSmartphone, className: 'text-sky-600' },
|
|
36
|
-
externalSystem: { icon: Globe, className: 'text-purple-600' },
|
|
37
|
-
agent: { icon: Bot, className: 'text-violet-600' },
|
|
38
|
-
actor: { icon: User, className: 'text-sky-600' },
|
|
39
|
-
note: { icon: StickyNote, className: 'text-yellow-500' },
|
|
40
|
-
text: { icon: Type, className: 'text-[rgb(var(--ec-icon-color))]' },
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
/** The catalog's collections, in the order the catalog list shows them, with the icon of the node each becomes */
|
|
44
|
-
export const CATALOG_GROUPS: ({ collection: string; label: string } & NodeIcon)[] = [
|
|
45
|
-
{ collection: 'domains', label: 'Domains', ...NODE_ICONS[GROUP_TYPES.domain] },
|
|
46
|
-
{ collection: 'systems', label: 'Systems', ...NODE_ICONS[GROUP_TYPES.system] },
|
|
47
|
-
{ collection: 'services', label: 'Services', ...NODE_ICONS.service },
|
|
48
|
-
{ collection: 'events', label: 'Events', ...NODE_ICONS.event },
|
|
49
|
-
{ collection: 'commands', label: 'Commands', ...NODE_ICONS.command },
|
|
50
|
-
{ collection: 'queries', label: 'Queries', ...NODE_ICONS.query },
|
|
51
|
-
{ collection: 'channels', label: 'Channels', ...NODE_ICONS.channel },
|
|
52
|
-
{ collection: 'containers', label: 'Data Stores', ...NODE_ICONS.data },
|
|
53
|
-
];
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import type { CanvasStatus } from '../canvas-doc';
|
|
2
|
-
import type { Status } from '../hooks/use-studio-flow';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Status and destructive colours for the canvas, from the theme's badge colours (EventCatalog has no status
|
|
6
|
-
* variables), so they follow light, dark and custom themes. Kept here rather than repeated in each component.
|
|
7
|
-
*/
|
|
8
|
-
export const STATUS = {
|
|
9
|
-
success: { text: 'text-[rgb(var(--ec-badge-color-green-text))]', dot: 'bg-[rgb(var(--ec-badge-color-green-text))]' },
|
|
10
|
-
warning: { text: 'text-[rgb(var(--ec-badge-color-yellow-text))]', dot: 'bg-[rgb(var(--ec-badge-color-yellow-text))]' },
|
|
11
|
-
danger: {
|
|
12
|
-
text: 'text-[rgb(var(--ec-badge-color-red-text))]',
|
|
13
|
-
dot: 'bg-[rgb(var(--ec-badge-color-red-text))]',
|
|
14
|
-
hover: 'hover:bg-[rgb(var(--ec-badge-color-red-background))]',
|
|
15
|
-
/** A button that deletes something */
|
|
16
|
-
button: 'bg-[rgb(var(--ec-badge-color-red-text))] text-white hover:bg-[rgb(var(--ec-badge-color-red-text)/0.85)]',
|
|
17
|
-
},
|
|
18
|
-
} as const;
|
|
19
|
-
|
|
20
|
-
/** The dot showing whether the canvas is connected */
|
|
21
|
-
export const CONNECTION_DOT: Record<Status, string> = {
|
|
22
|
-
connected: STATUS.success.dot,
|
|
23
|
-
connecting: STATUS.warning.dot,
|
|
24
|
-
disconnected: STATUS.danger.dot,
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
/** How each canvas status looks and what it means (from the theme's badge colours) */
|
|
28
|
-
export const CANVAS_STATUS_LOOK: Record<CanvasStatus, { label: string; description: string; pill: string; dot: string }> = {
|
|
29
|
-
draft: {
|
|
30
|
-
label: 'Draft',
|
|
31
|
-
description: 'Being worked on',
|
|
32
|
-
pill: 'bg-[rgb(var(--ec-badge-color-gray-background))] text-[rgb(var(--ec-badge-color-gray-text))] border-[rgb(var(--ec-badge-color-gray-text)/0.25)]',
|
|
33
|
-
dot: 'bg-[rgb(var(--ec-badge-color-gray-text))]',
|
|
34
|
-
},
|
|
35
|
-
proposed: {
|
|
36
|
-
label: 'Proposed',
|
|
37
|
-
description: 'Ready for review',
|
|
38
|
-
pill: 'bg-[rgb(var(--ec-badge-color-yellow-background))] text-[rgb(var(--ec-badge-color-yellow-text))] border-[rgb(var(--ec-badge-color-yellow-text)/0.3)]',
|
|
39
|
-
dot: 'bg-[rgb(var(--ec-badge-color-yellow-text))]',
|
|
40
|
-
},
|
|
41
|
-
accepted: {
|
|
42
|
-
label: 'Accepted',
|
|
43
|
-
description: 'The agreed design',
|
|
44
|
-
pill: 'bg-[rgb(var(--ec-badge-color-green-background))] text-[rgb(var(--ec-badge-color-green-text))] border-[rgb(var(--ec-badge-color-green-text)/0.3)]',
|
|
45
|
-
dot: 'bg-[rgb(var(--ec-badge-color-green-text))]',
|
|
46
|
-
},
|
|
47
|
-
rejected: {
|
|
48
|
-
label: 'Rejected',
|
|
49
|
-
description: 'Decided against, kept for the record',
|
|
50
|
-
pill: 'bg-[rgb(var(--ec-badge-color-red-background))] text-[rgb(var(--ec-badge-color-red-text))] border-[rgb(var(--ec-badge-color-red-text)/0.3)]',
|
|
51
|
-
dot: 'bg-[rgb(var(--ec-badge-color-red-text))]',
|
|
52
|
-
},
|
|
53
|
-
};
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import type { Connection, Edge, EdgeMarker } from '@xyflow/react';
|
|
2
|
-
|
|
3
|
-
/** Connections on a canvas, labelled with EventCatalog's wording (e.g. "publishes event", "subscribed by") */
|
|
4
|
-
|
|
5
|
-
/** Node types that are messages, and their catalog collection */
|
|
6
|
-
export const MESSAGE_COLLECTIONS: Record<string, string> = {
|
|
7
|
-
event: 'events',
|
|
8
|
-
command: 'commands',
|
|
9
|
-
query: 'queries',
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export const EDGE_COLOR = '#6b7280';
|
|
13
|
-
export const EDGE_MARKER: EdgeMarker = { type: 'arrowclosed', color: EDGE_COLOR, width: 18, height: 18 };
|
|
14
|
-
|
|
15
|
-
const PRODUCERS = ['service', 'agent', 'actor', 'externalSystem', 'view'];
|
|
16
|
-
const PRODUCER_LABELS: Record<string, string> = { commands: 'invokes', events: 'publishes \nevent', queries: 'requests' };
|
|
17
|
-
const MESSAGE_LABELS: Record<string, string> = { commands: 'accepts', events: 'subscribed by', queries: 'accepts' };
|
|
18
|
-
|
|
19
|
-
const getCollection = (sourceType?: string, targetType?: string) =>
|
|
20
|
-
MESSAGE_COLLECTIONS[sourceType ?? ''] ?? MESSAGE_COLLECTIONS[targetType ?? ''];
|
|
21
|
-
|
|
22
|
-
export const getEdgeLabel = (sourceType = '', targetType = ''): string => {
|
|
23
|
-
const sourceCollection = MESSAGE_COLLECTIONS[sourceType];
|
|
24
|
-
const targetCollection = MESSAGE_COLLECTIONS[targetType];
|
|
25
|
-
|
|
26
|
-
if (PRODUCERS.includes(sourceType) && targetCollection) return PRODUCER_LABELS[targetCollection];
|
|
27
|
-
// One message leading to another (e.g. an event that causes a command)
|
|
28
|
-
if (sourceCollection && targetCollection) return 'triggers';
|
|
29
|
-
if (sourceCollection && targetType === 'channel') return 'sent to';
|
|
30
|
-
if (sourceCollection) return MESSAGE_LABELS[sourceCollection];
|
|
31
|
-
if (sourceType === 'channel') return 'routes to';
|
|
32
|
-
if (targetType === 'channel') return 'publishes to';
|
|
33
|
-
if (targetType === 'data') return 'writes to';
|
|
34
|
-
if (sourceType === 'data') return 'read by';
|
|
35
|
-
if (sourceType === 'actor') return 'uses';
|
|
36
|
-
if (targetType === 'view') return 'serves';
|
|
37
|
-
if (targetType === 'externalSystem') return 'calls';
|
|
38
|
-
if (targetType === 'actor') return 'notifies';
|
|
39
|
-
return '';
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
/** Message edges get the animated envelope edge from the visualiser, everything else a labelled smooth step edge */
|
|
43
|
-
export const createEdge = (connection: Connection, sourceType?: string, targetType?: string): Edge => {
|
|
44
|
-
const collection = getCollection(sourceType, targetType);
|
|
45
|
-
return {
|
|
46
|
-
id: `edge-${crypto.randomUUID().slice(0, 8)}`,
|
|
47
|
-
source: connection.source,
|
|
48
|
-
target: connection.target,
|
|
49
|
-
sourceHandle: connection.sourceHandle ?? null,
|
|
50
|
-
targetHandle: connection.targetHandle ?? null,
|
|
51
|
-
type: collection ? 'animated' : 'smoothstep',
|
|
52
|
-
label: getEdgeLabel(sourceType, targetType),
|
|
53
|
-
markerEnd: EDGE_MARKER,
|
|
54
|
-
data: collection ? { message: { collection } } : {},
|
|
55
|
-
};
|
|
56
|
-
};
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import type { Edge, Node, XYPosition } from '@xyflow/react';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The design (nodes and edges) comes from the shared document; some state stays with each person: what they
|
|
5
|
-
* have selected, what they're dragging and resizing, and the sizes React Flow measured in their browser.
|
|
6
|
-
*/
|
|
7
|
-
export const LOCAL_NODE_KEYS = ['selected', 'dragging', 'resizing', 'measured'] as const satisfies readonly (keyof Node)[];
|
|
8
|
-
export const LOCAL_EDGE_KEYS = ['selected'] as const satisfies readonly (keyof Edge)[];
|
|
9
|
-
|
|
10
|
-
/** A shared item with the local-only state of the one already shown. Nothing else is kept from the local one. */
|
|
11
|
-
export const withLocalState = <T extends object>(shared: T, local: T | undefined, localKeys: readonly (keyof T)[]): T => {
|
|
12
|
-
if (!local) return shared;
|
|
13
|
-
const kept: Partial<T> = {};
|
|
14
|
-
for (const key of localKeys) if (local[key] !== undefined) kept[key] = local[key];
|
|
15
|
-
return { ...shared, ...kept };
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Applies changes to shared items (by id) to the items shown, keeping every unchanged item as the same object
|
|
20
|
-
* (so React Flow doesn't re-render it) and the order they were first seen in (new ones go at the end).
|
|
21
|
-
* `getShared` returns an item's shared value, or undefined if it was deleted. Returns `local` itself when
|
|
22
|
-
* nothing changed.
|
|
23
|
-
*/
|
|
24
|
-
export const patchShared = <T extends { id: string }>(
|
|
25
|
-
local: T[],
|
|
26
|
-
changedIds: Iterable<string>,
|
|
27
|
-
getShared: (id: string) => T | undefined,
|
|
28
|
-
localKeys: readonly (keyof T)[]
|
|
29
|
-
): T[] => {
|
|
30
|
-
const indexById = new Map(local.map((item, index) => [item.id, index]));
|
|
31
|
-
let next: T[] | undefined;
|
|
32
|
-
const removed = new Set<string>();
|
|
33
|
-
const added: T[] = [];
|
|
34
|
-
|
|
35
|
-
for (const id of changedIds) {
|
|
36
|
-
const shared = getShared(id);
|
|
37
|
-
const index = indexById.get(id);
|
|
38
|
-
if (!shared) {
|
|
39
|
-
if (index !== undefined) removed.add(id);
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
if (index === undefined) {
|
|
43
|
-
added.push(shared);
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
next ??= [...local];
|
|
47
|
-
next[index] = withLocalState(shared, local[index], localKeys);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (!next && removed.size === 0 && added.length === 0) return local;
|
|
51
|
-
const patched = next ?? local;
|
|
52
|
-
return [...(removed.size ? patched.filter((item) => !removed.has(item.id)) : patched), ...added];
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
/** Whether two plain values (what nodes and edges hold) are the same, comparing what's in them */
|
|
56
|
-
const isSame = (a: unknown, b: unknown): boolean => {
|
|
57
|
-
if (Object.is(a, b)) return true;
|
|
58
|
-
if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) return false;
|
|
59
|
-
const keys = Object.keys(a);
|
|
60
|
-
return (
|
|
61
|
-
keys.length === Object.keys(b).length &&
|
|
62
|
-
keys.every((key) => isSame((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]))
|
|
63
|
-
);
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Items worked out again (e.g. a level arranged again), keeping each one that hasn't changed as the object already
|
|
68
|
-
* shown (so React Flow doesn't re-render it), and the local-only state of the ones that have. Returns `previous`
|
|
69
|
-
* itself when nothing changed.
|
|
70
|
-
*/
|
|
71
|
-
export const keepUnchanged = <T extends { id: string }>(previous: T[], next: T[], localKeys: readonly (keyof T)[]): T[] => {
|
|
72
|
-
const shown = new Map(previous.map((item) => [item.id, item]));
|
|
73
|
-
const local = new Set<PropertyKey>(localKeys);
|
|
74
|
-
const kept = next.map((item) => {
|
|
75
|
-
const before = shown.get(item.id);
|
|
76
|
-
if (!before) return item;
|
|
77
|
-
const keys = new Set([...Object.keys(before), ...Object.keys(item)]) as Set<keyof T>;
|
|
78
|
-
const same = [...keys].every((key) => local.has(key) || isSame(before[key], item[key]));
|
|
79
|
-
return same ? before : withLocalState(item, before, localKeys);
|
|
80
|
-
});
|
|
81
|
-
return kept.length === previous.length && kept.every((item, index) => item === previous[index]) ? previous : kept;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
/** Where nodes being dragged are, by id, until they're dropped (and saved) */
|
|
85
|
-
export type Moves = ReadonlyMap<string, XYPosition>;
|
|
86
|
-
|
|
87
|
-
const samePosition = (a: XYPosition, b: XYPosition) => a.x === b.x && a.y === b.y;
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Nodes moved to the positions given (e.g. where someone else is dragging them), except ones being dragged
|
|
91
|
-
* here. Returns `nodes` itself when none move.
|
|
92
|
-
*/
|
|
93
|
-
export const withPositions = (nodes: Node[], positions: Moves): Node[] => {
|
|
94
|
-
if (positions.size === 0) return nodes;
|
|
95
|
-
let next: Node[] | undefined;
|
|
96
|
-
nodes.forEach((node, index) => {
|
|
97
|
-
const position = positions.get(node.id);
|
|
98
|
-
if (!position || node.dragging || samePosition(position, node.position)) return;
|
|
99
|
-
next ??= [...nodes];
|
|
100
|
-
next[index] = { ...node, position };
|
|
101
|
-
});
|
|
102
|
-
return next ?? nodes;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* After shared changes are applied (`next`, from `previous`): nodes being dragged here stay where they're being
|
|
107
|
-
* dragged (when they're dropped, the drop is saved and wins). Returns `next` itself when none are.
|
|
108
|
-
*/
|
|
109
|
-
export const keepDragged = (next: Node[], previous: Node[]): Node[] => {
|
|
110
|
-
if (next === previous) return next;
|
|
111
|
-
const dragged = new Map(previous.filter((node) => node.dragging).map((node) => [node.id, node.position]));
|
|
112
|
-
if (dragged.size === 0) return next;
|
|
113
|
-
let kept: Node[] | undefined;
|
|
114
|
-
next.forEach((node, index) => {
|
|
115
|
-
const position = dragged.get(node.id);
|
|
116
|
-
if (!position || samePosition(position, node.position)) return;
|
|
117
|
-
kept ??= [...next];
|
|
118
|
-
kept[index] = { ...node, position };
|
|
119
|
-
});
|
|
120
|
-
return kept ?? next;
|
|
121
|
-
};
|