@eventcatalog/core 5.0.0-beta.7 → 5.0.0-beta.9
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 +227 -128
- package/dist/analytics/analytics.cjs +18 -3
- package/dist/analytics/analytics.d.cts +1 -1
- package/dist/analytics/analytics.d.ts +1 -1
- package/dist/analytics/analytics.js +3 -2
- package/dist/analytics/log-build.cjs +70 -34
- package/dist/analytics/log-build.js +5 -4
- package/dist/analytics/telemetry.cjs +42 -0
- package/dist/analytics/telemetry.d.cts +20 -0
- package/dist/analytics/telemetry.d.ts +20 -0
- package/dist/analytics/telemetry.js +8 -0
- package/dist/{chunk-3VBB2ZMP.js → chunk-4CFZUHGG.js} +9 -3
- package/dist/{chunk-PIYJM642.js → chunk-AWOO63ME.js} +70 -44
- package/dist/chunk-CPW6G62V.js +17 -0
- package/dist/{chunk-MDWB5HLO.js → chunk-ENAG6UH7.js} +5 -0
- package/dist/{chunk-FARBIZ7Y.js → chunk-EUNQJO3J.js} +1 -1
- package/dist/{chunk-S7ODHVII.js → chunk-GAHKCOQM.js} +1 -1
- package/dist/{chunk-BOKAG5XC.js → chunk-JQMYHZWI.js} +58 -35
- package/dist/{chunk-SJJKWZYZ.js → chunk-TZVDMWQX.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +207 -123
- package/dist/eventcatalog.config.d.cts +55 -0
- package/dist/eventcatalog.config.d.ts +55 -0
- package/dist/eventcatalog.js +39 -19
- package/dist/features.cjs +8 -2
- package/dist/features.d.cts +3 -1
- package/dist/features.d.ts +3 -1
- package/dist/features.js +5 -3
- package/dist/federation/federate.js +4 -4
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/dist/utils/license-status.cjs +70 -44
- package/dist/utils/license-status.d.cts +14 -5
- package/dist/utils/license-status.d.ts +14 -5
- package/dist/utils/license-status.js +1 -1
- package/eventcatalog/integrations/studio-server.mjs +15 -3
- package/eventcatalog/src/components/Header.astro +3 -3
- package/eventcatalog/src/features/integrations/eventcatalog-features.ts +10 -2
- package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +9 -1
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +9 -2
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +70 -69
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
- package/eventcatalog/src/features/mcp/mcp-server.ts +5 -3
- package/eventcatalog/src/features/studio/agent-choreography.ts +8 -5
- package/eventcatalog/src/features/studio/canvas-actions.ts +148 -21
- package/eventcatalog/src/features/studio/canvas-doc.ts +138 -9
- package/eventcatalog/src/features/studio/canvas-preview.ts +53 -0
- package/eventcatalog/src/features/studio/catalog-resources.ts +5 -2
- package/eventcatalog/src/features/studio/clipboard.ts +97 -0
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +4 -3
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +39 -22
- package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +117 -0
- package/eventcatalog/src/features/studio/components/Comments.tsx +345 -30
- package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +140 -76
- package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +144 -0
- package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +1 -1
- package/eventcatalog/src/features/studio/components/DragGhost.tsx +115 -0
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +0 -24
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +131 -35
- package/eventcatalog/src/features/studio/components/Picture.tsx +21 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +2 -0
- package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +187 -50
- package/eventcatalog/src/features/studio/components/ShareDialog.tsx +260 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +442 -40
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +198 -22
- package/eventcatalog/src/features/studio/components/icons.ts +6 -1
- package/eventcatalog/src/features/studio/components/status.ts +30 -1
- package/eventcatalog/src/features/studio/edges.ts +2 -0
- package/eventcatalog/src/features/studio/grouping.ts +95 -2
- package/eventcatalog/src/features/studio/hooks/presence-store.ts +5 -1
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +13 -3
- package/eventcatalog/src/features/studio/hooks/use-comments.ts +8 -1
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +7 -2
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +174 -27
- package/eventcatalog/src/features/studio/identity.ts +35 -0
- package/eventcatalog/src/features/studio/layout.ts +7 -4
- package/eventcatalog/src/features/studio/levels.ts +138 -72
- package/eventcatalog/src/features/studio/node-types.ts +35 -3
- package/eventcatalog/src/features/studio/pages/[id].astro +7 -2
- package/eventcatalog/src/features/studio/pages/index.astro +555 -2
- package/eventcatalog/src/features/studio/pages/new.astro +24 -0
- package/eventcatalog/src/features/studio/pages/wake.ts +15 -0
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +48 -13
- package/eventcatalog/src/features/studio/server/runtime.ts +193 -14
- package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
- package/eventcatalog/src/features/studio/server/storage.ts +110 -0
- package/eventcatalog/src/features/studio/studio.css +184 -0
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +1 -1
- package/eventcatalog/src/utils/feature.ts +7 -3
- package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
- package/package.json +3 -3
- package/dist/{chunk-AYNZXUB5.js → chunk-ZEY2XUJN.js} +7 -7
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useEffect, useState
|
|
2
|
-
import { Bot, Check, Copy, X } from 'lucide-react';
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Bot, Check, ChevronDown, Copy, X } from 'lucide-react';
|
|
3
3
|
import type { WebMcpStatus } from '../hooks/use-canvas-webmcp';
|
|
4
4
|
import { STATUS } from './status';
|
|
5
5
|
|
|
@@ -71,12 +71,12 @@ Connect with the first of these you can use:
|
|
|
71
71
|
3. WebMCP local relay: if you run on my computer (e.g. Claude Code, Cursor) and can't reach the MCP server, add the MCP server \`${RELAY_COMMAND}\`. It gives you the tools of my open canvas tab.
|
|
72
72
|
|
|
73
73
|
Once connected:
|
|
74
|
-
- Read the canvas first (getCanvas / get_canvas) and tell me who else is on it.
|
|
75
|
-
- Ask me what we are designing, then work on the canvas as we talk: add catalog resources (find them with getResources / search_catalog), add new components for things that don't exist yet, and connect them. Leave out positions so new nodes go next to what they connect to; only re-lay the whole canvas (layoutCanvas / layout_canvas) when I ask.
|
|
76
|
-
-
|
|
74
|
+
- Read the canvas first (getCanvas / get_canvas) and tell me here who else is on it.
|
|
75
|
+
- Ask me here, in our conversation, what we are designing, then work on the canvas as we talk: add catalog resources (find them with getResources / search_catalog), add new components for things that don't exist yet, and connect them. Leave out positions so new nodes go next to what they connect to; only re-lay the whole canvas (layoutCanvas / layout_canvas) when I ask.
|
|
76
|
+
- Talk to me here, not in canvas comments. Only comment on the canvas when I ask you to (e.g. to review it), and reply to comments people leave for you.
|
|
77
77
|
- Pass agentName with your name on MCP tools, so people on the canvas can see who you are.`;
|
|
78
78
|
|
|
79
|
-
function CopyButton({ text, label = 'Copy' }: { text: string; label?: string }) {
|
|
79
|
+
function CopyButton({ text, label = 'Copy', large = false }: { text: string; label?: string; large?: boolean }) {
|
|
80
80
|
const [copied, setCopied] = useState(false);
|
|
81
81
|
return (
|
|
82
82
|
<button
|
|
@@ -85,31 +85,56 @@ function CopyButton({ text, label = 'Copy' }: { text: string; label?: string })
|
|
|
85
85
|
setCopied(true);
|
|
86
86
|
setTimeout(() => setCopied(false), 1500);
|
|
87
87
|
}}
|
|
88
|
-
className=
|
|
88
|
+
className={`flex shrink-0 items-center justify-center gap-1.5 rounded-lg font-medium bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))] ${
|
|
89
|
+
large ? 'w-full px-4 py-2.5 text-sm' : 'px-2.5 py-1.5 text-xs'
|
|
90
|
+
}`}
|
|
89
91
|
>
|
|
90
|
-
{copied ? <Check size={14} /> : <Copy size={14} />}
|
|
92
|
+
{copied ? <Check size={large ? 16 : 14} /> : <Copy size={large ? 16 : 14} />}
|
|
91
93
|
{copied ? 'Copied' : label}
|
|
92
94
|
</button>
|
|
93
95
|
);
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
|
|
98
|
+
/** Something to copy (an address or a command), with its copy button */
|
|
99
|
+
function CopyField({ value }: { value: string }) {
|
|
97
100
|
return (
|
|
98
|
-
<div className="
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
<code className="min-w-0 flex-1 truncate rounded-md border px-2 py-1.5 text-xs bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))]">
|
|
104
|
-
{value}
|
|
105
|
-
</code>
|
|
106
|
-
<CopyButton text={value} />
|
|
107
|
-
</div>
|
|
108
|
-
)}
|
|
101
|
+
<div className="flex items-center gap-2">
|
|
102
|
+
<code className="min-w-0 flex-1 truncate rounded-md border px-2 py-1.5 text-xs bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))]">
|
|
103
|
+
{value}
|
|
104
|
+
</code>
|
|
105
|
+
<CopyButton text={value} />
|
|
109
106
|
</div>
|
|
110
107
|
);
|
|
111
108
|
}
|
|
112
109
|
|
|
110
|
+
/** Turns something on for this tab, or says it's on */
|
|
111
|
+
function TabSwitch({ on, onTurnOn, label }: { on: boolean; onTurnOn: () => void; label: string }) {
|
|
112
|
+
return on ? (
|
|
113
|
+
<p className={`flex shrink-0 items-center gap-1.5 whitespace-nowrap text-xs font-medium ${STATUS.success.text}`}>
|
|
114
|
+
<span className={`h-2 w-2 rounded-full ${STATUS.success.dot}`} />
|
|
115
|
+
On for this tab
|
|
116
|
+
</p>
|
|
117
|
+
) : (
|
|
118
|
+
<button
|
|
119
|
+
onClick={onTurnOn}
|
|
120
|
+
className="shrink-0 whitespace-nowrap rounded-lg border px-2.5 py-1.5 text-xs font-medium border-[rgb(var(--ec-page-border))] hover:bg-[rgb(var(--ec-page-border)/0.4)]"
|
|
121
|
+
>
|
|
122
|
+
{label}
|
|
123
|
+
</button>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
type Way = 'chat' | 'code' | 'browser';
|
|
128
|
+
const WAYS: { id: Way; label: string; hint: string }[] = [
|
|
129
|
+
{ id: 'chat', label: 'Chat app', hint: 'Claude, ChatGPT' },
|
|
130
|
+
{ id: 'code', label: 'Coding agent', hint: 'Claude Code, Cursor, Codex' },
|
|
131
|
+
{ id: 'browser', label: 'This browser', hint: 'WebMCP' },
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Bringing an AI agent onto the canvas: copy the prompt and give it to the agent (it works out how to connect).
|
|
136
|
+
* The prompt is there to read, and how each kind of agent connects is one step each, for setting it up by hand.
|
|
137
|
+
*/
|
|
113
138
|
export default function ConnectAgentDialog({
|
|
114
139
|
link,
|
|
115
140
|
webMcp,
|
|
@@ -127,6 +152,8 @@ export default function ConnectAgentDialog({
|
|
|
127
152
|
}) {
|
|
128
153
|
const prompt = buildAgentPrompt(link);
|
|
129
154
|
const [relayOn, setRelayOn] = useState(isRelayEnabled);
|
|
155
|
+
const [showPrompt, setShowPrompt] = useState(false);
|
|
156
|
+
const [way, setWay] = useState<Way>('chat');
|
|
130
157
|
|
|
131
158
|
useEffect(() => {
|
|
132
159
|
const onKeyDown = (event: KeyboardEvent) => event.key === 'Escape' && onClose();
|
|
@@ -134,82 +161,119 @@ export default function ConnectAgentDialog({
|
|
|
134
161
|
return () => window.removeEventListener('keydown', onKeyDown);
|
|
135
162
|
}, [onClose]);
|
|
136
163
|
|
|
164
|
+
const turnOnRelay = () => {
|
|
165
|
+
if (!relayScriptPath) return;
|
|
166
|
+
enableRelay(relayScriptPath);
|
|
167
|
+
setRelayOn(true);
|
|
168
|
+
// The relay hands this tab's WebMCP tools to desktop agents
|
|
169
|
+
onEnableWebMcp();
|
|
170
|
+
};
|
|
171
|
+
|
|
137
172
|
return (
|
|
138
173
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
|
139
174
|
<div
|
|
140
175
|
role="dialog"
|
|
141
176
|
aria-label="Connect your agent"
|
|
142
177
|
onClick={(event) => event.stopPropagation()}
|
|
143
|
-
className="flex max-h-full w-full max-w-
|
|
178
|
+
className="flex max-h-full w-full max-w-md flex-col rounded-xl border shadow-2xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]"
|
|
144
179
|
>
|
|
145
|
-
<
|
|
146
|
-
<div className="
|
|
147
|
-
<
|
|
180
|
+
<header className="flex items-start justify-between gap-3 px-5 pb-3 pt-5">
|
|
181
|
+
<div className="flex items-center gap-3">
|
|
182
|
+
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[rgb(var(--ec-accent-subtle))] text-[rgb(var(--ec-accent))]">
|
|
148
183
|
<Bot size={18} />
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
</p>
|
|
184
|
+
</span>
|
|
185
|
+
<div>
|
|
186
|
+
<h2 className="text-base font-semibold">Bring in an AI agent</h2>
|
|
187
|
+
<p className="text-xs text-[rgb(var(--ec-page-text-muted))]">It joins this canvas and designs with you, live.</p>
|
|
188
|
+
</div>
|
|
155
189
|
</div>
|
|
156
190
|
<button
|
|
157
191
|
onClick={onClose}
|
|
158
192
|
aria-label="Close"
|
|
159
|
-
className="rounded p-1 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)]"
|
|
193
|
+
className="rounded-md p-1 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)]"
|
|
160
194
|
>
|
|
161
195
|
<X size={16} />
|
|
162
196
|
</button>
|
|
163
|
-
</
|
|
197
|
+
</header>
|
|
164
198
|
|
|
165
|
-
<div className="min-h-0 space-y-5 overflow-y-auto
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
199
|
+
<div className="min-h-0 space-y-5 overflow-y-auto px-5 pb-5">
|
|
200
|
+
{/* The one thing most people need: the prompt */}
|
|
201
|
+
<section className="space-y-2">
|
|
202
|
+
<CopyButton text={prompt} label="Copy prompt" large />
|
|
203
|
+
<p className="text-center text-xs text-[rgb(var(--ec-page-text-muted))]">
|
|
204
|
+
Paste it into your agent. It works out how to connect.{' '}
|
|
205
|
+
<button
|
|
206
|
+
onClick={() => setShowPrompt((shown) => !shown)}
|
|
207
|
+
aria-expanded={showPrompt}
|
|
208
|
+
className="inline-flex items-center gap-0.5 font-medium text-[rgb(var(--ec-accent))] hover:underline"
|
|
209
|
+
>
|
|
210
|
+
{showPrompt ? 'Hide prompt' : 'Show prompt'}
|
|
211
|
+
<ChevronDown size={12} className={`transition-transform ${showPrompt ? 'rotate-180' : ''}`} />
|
|
212
|
+
</button>
|
|
213
|
+
</p>
|
|
214
|
+
{showPrompt && (
|
|
215
|
+
<pre className="max-h-56 overflow-y-auto whitespace-pre-wrap rounded-lg border p-3 font-mono text-[11px] leading-relaxed bg-[rgb(var(--ec-page-bg))] border-[rgb(var(--ec-page-border))]">
|
|
216
|
+
{prompt}
|
|
217
|
+
</pre>
|
|
218
|
+
)}
|
|
219
|
+
</section>
|
|
175
220
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
221
|
+
{/* Or set it up by hand, for the kind of agent you use */}
|
|
222
|
+
<section className="space-y-3 border-t pt-4 border-[rgb(var(--ec-page-border))]">
|
|
223
|
+
<h3 className="text-xs font-semibold">Or connect it yourself</h3>
|
|
224
|
+
<div role="tablist" className="grid grid-cols-3 gap-1 rounded-lg p-1 bg-[rgb(var(--ec-page-border)/0.5)]">
|
|
225
|
+
{WAYS.map(({ id, label, hint }) => (
|
|
226
|
+
<button
|
|
227
|
+
key={id}
|
|
228
|
+
role="tab"
|
|
229
|
+
aria-selected={way === id}
|
|
230
|
+
onClick={() => setWay(id)}
|
|
231
|
+
className={`rounded-md px-2 py-1.5 text-center ${
|
|
232
|
+
way === id ? 'bg-[rgb(var(--ec-card-bg))] shadow-sm' : 'text-[rgb(var(--ec-page-text-muted))]'
|
|
233
|
+
}`}
|
|
234
|
+
>
|
|
235
|
+
<span className="block text-xs font-medium">{label}</span>
|
|
236
|
+
<span className="block truncate text-[10px] text-[rgb(var(--ec-page-text-muted))]">{hint}</span>
|
|
188
237
|
</button>
|
|
189
|
-
)}
|
|
190
|
-
</
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
{way === 'chat' && (
|
|
242
|
+
<div className="space-y-2">
|
|
243
|
+
<p className="text-xs text-[rgb(var(--ec-page-text-muted))]">
|
|
244
|
+
Add this MCP server to your chat app. Chats that show MCP Apps open the canvas right in the chat.
|
|
245
|
+
</p>
|
|
246
|
+
<CopyField value={link.mcpUrl} />
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
{way === 'code' && (
|
|
250
|
+
<div className="space-y-2">
|
|
251
|
+
<p className="text-xs text-[rgb(var(--ec-page-text-muted))]">
|
|
252
|
+
Add EventCatalog's MCP server, e.g. in Claude Code:
|
|
253
|
+
</p>
|
|
254
|
+
<CopyField value={`claude mcp add --transport http eventcatalog ${link.mcpUrl}`} />
|
|
255
|
+
{relayScriptPath && (
|
|
256
|
+
<div className="flex items-center justify-between gap-3 pt-1">
|
|
257
|
+
<p className="text-[11px] text-[rgb(var(--ec-page-text-muted))]">
|
|
258
|
+
Can't reach this server? Use this tab through the local relay: add <code>{RELAY_COMMAND}</code> as an MCP
|
|
259
|
+
server.
|
|
260
|
+
</p>
|
|
261
|
+
<TabSwitch on={relayOn} onTurnOn={turnOnRelay} label="Turn on relay" />
|
|
262
|
+
</div>
|
|
209
263
|
)}
|
|
210
|
-
</
|
|
264
|
+
</div>
|
|
211
265
|
)}
|
|
212
|
-
|
|
266
|
+
{way === 'browser' && (
|
|
267
|
+
<div className="flex items-center justify-between gap-3">
|
|
268
|
+
<p className="text-xs text-[rgb(var(--ec-page-text-muted))]">
|
|
269
|
+
{webMcp.state === 'on'
|
|
270
|
+
? `This tab offers ${webMcp.tools} canvas tools to agents in your browser (like the MCP-B extension).`
|
|
271
|
+
: 'Let agents in your browser (like the MCP-B extension) use this canvas through WebMCP.'}
|
|
272
|
+
</p>
|
|
273
|
+
<TabSwitch on={webMcp.state === 'on'} onTurnOn={onEnableWebMcp} label="Turn on" />
|
|
274
|
+
</div>
|
|
275
|
+
)}
|
|
276
|
+
</section>
|
|
213
277
|
</div>
|
|
214
278
|
</div>
|
|
215
279
|
</div>
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactElement } from 'react';
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
import type { ConnectionGroup, ConnectionGroupId } from '../canvas-actions';
|
|
4
|
+
import type { CatalogResource } from '../catalog-resources';
|
|
5
|
+
import { getNodeLabel } from '../node-types';
|
|
6
|
+
import { CardPreview } from './ContainerChooser';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Asked when a catalog service or message is dropped on the canvas (and connects to things that aren't on it yet):
|
|
10
|
+
* just it, or it with its connections (a service's messages and data stores, the services that send and receive a
|
|
11
|
+
* message), picked by group. Styled like the domain and system chooser.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type ConnectionsChoice = { as: 'card' } | { as: 'connected'; include: CatalogResource[] };
|
|
15
|
+
|
|
16
|
+
// It in the middle, what comes in before it and what goes out after it
|
|
17
|
+
const ConnectedPreview = () => (
|
|
18
|
+
<svg viewBox="0 0 64 36" className="h-9 w-16" aria-hidden>
|
|
19
|
+
<rect x="1" y="7" width="10" height="8" rx="1.5" fill="currentColor" fillOpacity="0.4" />
|
|
20
|
+
<rect x="1" y="21" width="10" height="8" rx="1.5" fill="currentColor" fillOpacity="0.4" />
|
|
21
|
+
<path d="M11 11h8M11 25h8" stroke="currentColor" strokeOpacity="0.5" strokeWidth="1.5" />
|
|
22
|
+
<rect
|
|
23
|
+
x="19"
|
|
24
|
+
y="8"
|
|
25
|
+
width="26"
|
|
26
|
+
height="20"
|
|
27
|
+
rx="4"
|
|
28
|
+
fill="currentColor"
|
|
29
|
+
fillOpacity="0.1"
|
|
30
|
+
stroke="currentColor"
|
|
31
|
+
strokeOpacity="0.5"
|
|
32
|
+
strokeWidth="1.5"
|
|
33
|
+
/>
|
|
34
|
+
<rect x="23" y="13" width="13" height="3" rx="1.5" fill="currentColor" fillOpacity="0.6" />
|
|
35
|
+
<rect x="23" y="19" width="17" height="2" rx="1" fill="currentColor" fillOpacity="0.35" />
|
|
36
|
+
<path d="M45 18h8" stroke="currentColor" strokeOpacity="0.5" strokeWidth="1.5" />
|
|
37
|
+
<rect x="53" y="14" width="10" height="8" rx="1.5" fill="currentColor" fillOpacity="0.4" />
|
|
38
|
+
</svg>
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/** What "with its connections" brings, by what's dropped */
|
|
42
|
+
const CONNECTED_HINTS: Record<string, string> = {
|
|
43
|
+
services: 'What it sends, receives and stores, around it',
|
|
44
|
+
};
|
|
45
|
+
const MESSAGE_HINT = 'The services that send and receive it, around it';
|
|
46
|
+
|
|
47
|
+
export default function ConnectionsChooser({
|
|
48
|
+
resource,
|
|
49
|
+
groups,
|
|
50
|
+
position,
|
|
51
|
+
onChoose,
|
|
52
|
+
onCancel,
|
|
53
|
+
}: {
|
|
54
|
+
resource: CatalogResource;
|
|
55
|
+
/** What it connects to that isn't on the canvas yet */
|
|
56
|
+
groups: ConnectionGroup[];
|
|
57
|
+
/** Where to show it (screen coordinates) */
|
|
58
|
+
position: { x: number; y: number };
|
|
59
|
+
onChoose: (choice: ConnectionsChoice) => void;
|
|
60
|
+
onCancel: () => void;
|
|
61
|
+
}) {
|
|
62
|
+
const noun = getNodeLabel(resource.node.type).toLowerCase();
|
|
63
|
+
const [included, setIncluded] = useState<Set<ConnectionGroupId>>(() => new Set(groups.map((group) => group.id)));
|
|
64
|
+
const toggle = (id: ConnectionGroupId) =>
|
|
65
|
+
setIncluded((current) => {
|
|
66
|
+
const next = new Set(current);
|
|
67
|
+
if (next.has(id)) next.delete(id);
|
|
68
|
+
else next.add(id);
|
|
69
|
+
return next;
|
|
70
|
+
});
|
|
71
|
+
const include = groups.flatMap((group) => (included.has(group.id) ? group.resources : []));
|
|
72
|
+
const options: { as: ConnectionsChoice['as']; label: string; hint: string; Preview: () => ReactElement }[] = [
|
|
73
|
+
{ as: 'card', label: `Just the ${noun}`, hint: 'Connected to what is already on the canvas', Preview: CardPreview },
|
|
74
|
+
{
|
|
75
|
+
as: 'connected',
|
|
76
|
+
label: 'With its connections',
|
|
77
|
+
hint: CONNECTED_HINTS[resource.collection] ?? MESSAGE_HINT,
|
|
78
|
+
Preview: ConnectedPreview,
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const onKeyDown = (event: KeyboardEvent) => event.key === 'Escape' && onCancel();
|
|
84
|
+
window.addEventListener('keydown', onKeyDown);
|
|
85
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
86
|
+
}, [onCancel]);
|
|
87
|
+
|
|
88
|
+
// Kept on screen, next to where it was dropped
|
|
89
|
+
const left = Math.min(Math.max(position.x - 160, 12), window.innerWidth - 332);
|
|
90
|
+
const top = Math.min(Math.max(position.y - 20, 12), window.innerHeight - 300);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className="fixed inset-0 z-50" onPointerDown={onCancel}>
|
|
94
|
+
<div
|
|
95
|
+
role="dialog"
|
|
96
|
+
aria-label={`Add ${resource.name}`}
|
|
97
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
98
|
+
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))]"
|
|
99
|
+
style={{ left, top }}
|
|
100
|
+
>
|
|
101
|
+
<div className="flex items-start justify-between gap-2 px-2 pb-1.5 pt-1">
|
|
102
|
+
<div>
|
|
103
|
+
<p className="text-[10px] font-semibold uppercase tracking-wider text-[rgb(var(--ec-page-text-muted))]">
|
|
104
|
+
Add this {noun}
|
|
105
|
+
</p>
|
|
106
|
+
<p className="truncate text-xs font-semibold">{resource.name}</p>
|
|
107
|
+
</div>
|
|
108
|
+
<button
|
|
109
|
+
onClick={onCancel}
|
|
110
|
+
aria-label="Cancel"
|
|
111
|
+
className="rounded p-0.5 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)]"
|
|
112
|
+
>
|
|
113
|
+
<X size={14} />
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div className="grid grid-cols-2 gap-1.5 px-0.5 pb-0.5">
|
|
118
|
+
{options.map(({ as, label, hint, Preview }) => (
|
|
119
|
+
<button
|
|
120
|
+
key={as}
|
|
121
|
+
autoFocus={as === 'connected'}
|
|
122
|
+
disabled={as === 'connected' && include.length === 0}
|
|
123
|
+
onClick={() => onChoose(as === 'card' ? { as } : { as, include })}
|
|
124
|
+
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))] disabled:cursor-not-allowed disabled:opacity-50"
|
|
125
|
+
>
|
|
126
|
+
<Preview />
|
|
127
|
+
<span className="font-semibold">{label}</span>
|
|
128
|
+
<span className="text-center text-[10px] leading-tight text-[rgb(var(--ec-page-text-muted))]">{hint}</span>
|
|
129
|
+
</button>
|
|
130
|
+
))}
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<div className="mt-1 space-y-0.5 px-2 pb-1">
|
|
134
|
+
{groups.map((group) => (
|
|
135
|
+
<label key={group.id} className="flex items-center gap-2 py-0.5 text-[11px] text-[rgb(var(--ec-page-text-muted))]">
|
|
136
|
+
<input type="checkbox" checked={included.has(group.id)} onChange={() => toggle(group.id)} />
|
|
137
|
+
{group.label} ({group.resources.length})
|
|
138
|
+
</label>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -10,7 +10,7 @@ import type { CatalogResource } from '../catalog-resources';
|
|
|
10
10
|
export type ContainerChoice = { as: 'card' } | { as: 'container'; withContents: boolean };
|
|
11
11
|
|
|
12
12
|
// Small pictures of the two ways a domain or system can be on the canvas
|
|
13
|
-
const CardPreview = () => (
|
|
13
|
+
export const CardPreview = () => (
|
|
14
14
|
<svg viewBox="0 0 64 36" className="h-9 w-16" aria-hidden>
|
|
15
15
|
<rect
|
|
16
16
|
x="18"
|
|
@@ -0,0 +1,115 @@
|
|
|
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,29 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
|
|
3
|
-
const COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899'];
|
|
4
|
-
const NAME_KEY = 'eventcatalog-studio-name';
|
|
5
|
-
|
|
6
|
-
export const getStoredName = () => {
|
|
7
|
-
try {
|
|
8
|
-
return localStorage.getItem(NAME_KEY);
|
|
9
|
-
} catch {
|
|
10
|
-
return null;
|
|
11
|
-
}
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
export const storeName = (name: string) => {
|
|
15
|
-
try {
|
|
16
|
-
localStorage.setItem(NAME_KEY, name);
|
|
17
|
-
} catch {}
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
// Same name, same colour across reloads
|
|
21
|
-
export const colorForName = (name: string) => {
|
|
22
|
-
let hash = 0;
|
|
23
|
-
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
|
|
24
|
-
return COLORS[Math.abs(hash) % COLORS.length];
|
|
25
|
-
};
|
|
26
|
-
|
|
27
3
|
export default function JoinForm({ onJoin }: { onJoin: (name: string) => void }) {
|
|
28
4
|
const [draft, setDraft] = useState('');
|
|
29
5
|
|