@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,9 +1,11 @@
|
|
|
1
|
-
import { memo, useMemo, useState, type
|
|
2
|
-
import { Check, Plus, Search } from 'lucide-react';
|
|
1
|
+
import { memo, useCallback, useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
import { Check, ChevronRight, Plus, Search } from 'lucide-react';
|
|
3
3
|
import type { CatalogResource } from '../catalog-resources';
|
|
4
|
+
import { catalogNodeData } from '../catalog';
|
|
4
5
|
import { NODE_CATEGORIES, nodeDefinitions } from '../node-types';
|
|
5
6
|
import type { Thread } from '../hooks/use-comments';
|
|
6
7
|
import { CommentList } from './Comments';
|
|
8
|
+
import { useDragGhost, type StartDrag } from './DragGhost';
|
|
7
9
|
import { CATALOG_GROUPS, NODE_ICONS } from './icons';
|
|
8
10
|
import { STATUS } from './status';
|
|
9
11
|
|
|
@@ -18,13 +20,52 @@ const itemClass =
|
|
|
18
20
|
'group flex cursor-grab items-center justify-between gap-2 rounded-md border px-2.5 py-2 bg-[rgb(var(--ec-page-bg))] border-[rgb(var(--ec-page-border))] hover:border-[rgb(var(--ec-accent))]';
|
|
19
21
|
const addButtonClass =
|
|
20
22
|
'shrink-0 rounded p-0.5 opacity-0 group-hover:opacity-100 text-[rgb(var(--ec-icon-color))] hover:text-[rgb(var(--ec-accent))]';
|
|
21
|
-
|
|
23
|
+
// The sections that are collapsed, by id (e.g. "components:messages"), remembered in this browser
|
|
24
|
+
const COLLAPSED_KEY = 'eventcatalog-studio-collapsed';
|
|
25
|
+
type Collapsed = { collapsed: ReadonlySet<string>; toggle: (id: string) => void };
|
|
22
26
|
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
const readCollapsed = (): Set<string> => {
|
|
28
|
+
try {
|
|
29
|
+
return new Set(JSON.parse(localStorage.getItem(COLLAPSED_KEY) ?? '[]') as string[]);
|
|
30
|
+
} catch {
|
|
31
|
+
return new Set();
|
|
32
|
+
}
|
|
26
33
|
};
|
|
27
34
|
|
|
35
|
+
function useCollapsed(): Collapsed {
|
|
36
|
+
const [collapsed, setCollapsed] = useState(readCollapsed);
|
|
37
|
+
const toggle = useCallback((id: string) => {
|
|
38
|
+
setCollapsed((current) => {
|
|
39
|
+
const next = new Set(current);
|
|
40
|
+
if (next.has(id)) next.delete(id);
|
|
41
|
+
else next.add(id);
|
|
42
|
+
try {
|
|
43
|
+
localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...next]));
|
|
44
|
+
} catch {
|
|
45
|
+
// Not remembered (e.g. storage is blocked): it still works for now
|
|
46
|
+
}
|
|
47
|
+
return next;
|
|
48
|
+
});
|
|
49
|
+
}, []);
|
|
50
|
+
return useMemo(() => ({ collapsed, toggle }), [collapsed, toggle]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A section's heading, which opens and closes it */
|
|
54
|
+
function SectionHeading({ open, onToggle, children }: { open: boolean; onToggle: () => void; children: ReactNode }) {
|
|
55
|
+
return (
|
|
56
|
+
<h3>
|
|
57
|
+
<button
|
|
58
|
+
onClick={onToggle}
|
|
59
|
+
aria-expanded={open}
|
|
60
|
+
className="flex w-full cursor-pointer items-center gap-1 rounded-md px-1.5 py-1 text-left text-[11px] font-semibold uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))] transition-colors hover:bg-[rgb(var(--ec-page-border)/0.6)] hover:text-[rgb(var(--ec-page-text))]"
|
|
61
|
+
>
|
|
62
|
+
<ChevronRight size={12} className={`shrink-0 transition-transform ${open ? 'rotate-90' : ''}`} />
|
|
63
|
+
{children}
|
|
64
|
+
</button>
|
|
65
|
+
</h3>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
28
69
|
type Tab = 'components' | 'catalog' | 'comments';
|
|
29
70
|
|
|
30
71
|
/** Components and catalog resources to drag onto the canvas, and the comments on it */
|
|
@@ -49,6 +90,9 @@ export default memo(function LeftPanel({
|
|
|
49
90
|
onOpenThread: (thread: Thread) => void;
|
|
50
91
|
}) {
|
|
51
92
|
const [tab, setTab] = useState<Tab>('components');
|
|
93
|
+
// What's being dragged onto the canvas, drawn as the node it becomes (see DragGhost)
|
|
94
|
+
const { startDrag, ghost } = useDragGhost();
|
|
95
|
+
const sections = useCollapsed();
|
|
52
96
|
const openThreads = threads.filter((thread) => !thread.resolved).length;
|
|
53
97
|
const tabClass = (active: boolean) =>
|
|
54
98
|
`flex flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md px-1.5 py-1 text-xs font-medium ${
|
|
@@ -73,11 +117,20 @@ export default memo(function LeftPanel({
|
|
|
73
117
|
)}
|
|
74
118
|
</button>
|
|
75
119
|
</div>
|
|
76
|
-
{tab === 'components' && <ComponentList onAdd={onAddComponent} />}
|
|
77
|
-
{tab === 'catalog' &&
|
|
120
|
+
{tab === 'components' && <ComponentList onAdd={onAddComponent} startDrag={startDrag} sections={sections} />}
|
|
121
|
+
{tab === 'catalog' && (
|
|
122
|
+
<CatalogList
|
|
123
|
+
resources={resources}
|
|
124
|
+
keysOnCanvas={keysOnCanvas}
|
|
125
|
+
onAdd={onAddResource}
|
|
126
|
+
startDrag={startDrag}
|
|
127
|
+
sections={sections}
|
|
128
|
+
/>
|
|
129
|
+
)}
|
|
78
130
|
{tab === 'comments' && (
|
|
79
131
|
<CommentList threads={threads} showResolved={showResolved} onToggleResolved={onToggleResolved} onOpen={onOpenThread} />
|
|
80
132
|
)}
|
|
133
|
+
{ghost}
|
|
81
134
|
</aside>
|
|
82
135
|
);
|
|
83
136
|
});
|
|
@@ -87,28 +140,53 @@ const listedDefinitions = NODE_CATEGORIES.map((category) => ({
|
|
|
87
140
|
definitions: nodeDefinitions.filter((definition) => definition.category === category.id && !definition.unlisted),
|
|
88
141
|
}));
|
|
89
142
|
|
|
90
|
-
|
|
143
|
+
// Each component's data, made once, for the node shown while it's dragged
|
|
144
|
+
const componentPreviews = new Map(
|
|
145
|
+
nodeDefinitions.map((definition) => [definition.type, { type: definition.type, data: definition.createData() }])
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const ComponentList = memo(function ComponentList({
|
|
149
|
+
onAdd,
|
|
150
|
+
startDrag,
|
|
151
|
+
sections: { collapsed, toggle },
|
|
152
|
+
}: {
|
|
153
|
+
onAdd: (type: string) => void;
|
|
154
|
+
startDrag: StartDrag;
|
|
155
|
+
sections: Collapsed;
|
|
156
|
+
}) {
|
|
91
157
|
return (
|
|
92
|
-
<div className="min-h-0 flex-1 space-y-
|
|
93
|
-
{listedDefinitions.map((category) =>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
158
|
+
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-3 pb-3">
|
|
159
|
+
{listedDefinitions.map((category) => {
|
|
160
|
+
const id = `components:${category.id}`;
|
|
161
|
+
const open = !collapsed.has(id);
|
|
162
|
+
return (
|
|
163
|
+
<section key={category.id} className="space-y-1.5">
|
|
164
|
+
<SectionHeading open={open} onToggle={() => toggle(id)}>
|
|
165
|
+
{category.label}
|
|
166
|
+
</SectionHeading>
|
|
167
|
+
{open &&
|
|
168
|
+
category.definitions.map(({ type, label }) => {
|
|
169
|
+
const { icon: Icon, className } = NODE_ICONS[type];
|
|
170
|
+
return (
|
|
171
|
+
<div
|
|
172
|
+
key={type}
|
|
173
|
+
draggable
|
|
174
|
+
onDragStart={startDrag(COMPONENT_DRAG_TYPE, type, componentPreviews.get(type)!)}
|
|
175
|
+
className={itemClass}
|
|
176
|
+
>
|
|
177
|
+
<span className="flex items-center gap-2 text-xs font-medium">
|
|
178
|
+
<Icon size={16} className={className} />
|
|
179
|
+
{label}
|
|
180
|
+
</span>
|
|
181
|
+
<button onClick={() => onAdd(type)} title={`Add ${label}`} className={addButtonClass}>
|
|
182
|
+
<Plus size={14} />
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
})}
|
|
187
|
+
</section>
|
|
188
|
+
);
|
|
189
|
+
})}
|
|
112
190
|
</div>
|
|
113
191
|
);
|
|
114
192
|
});
|
|
@@ -117,13 +195,18 @@ const CatalogList = memo(function CatalogList({
|
|
|
117
195
|
resources,
|
|
118
196
|
keysOnCanvas,
|
|
119
197
|
onAdd,
|
|
198
|
+
startDrag,
|
|
199
|
+
sections: { collapsed, toggle },
|
|
120
200
|
}: {
|
|
121
201
|
resources: CatalogResource[];
|
|
122
202
|
keysOnCanvas: Set<string>;
|
|
123
203
|
onAdd: (key: string) => void;
|
|
204
|
+
startDrag: StartDrag;
|
|
205
|
+
sections: Collapsed;
|
|
124
206
|
}) {
|
|
125
207
|
const [search, setSearch] = useState('');
|
|
126
208
|
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
|
209
|
+
const searching = search.trim() !== '';
|
|
127
210
|
const groups = useMemo(() => {
|
|
128
211
|
const term = search.trim().toLowerCase();
|
|
129
212
|
const matches = term
|
|
@@ -146,17 +229,20 @@ const CatalogList = memo(function CatalogList({
|
|
|
146
229
|
className="w-full rounded-md border py-1.5 pl-8 pr-2 text-xs bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))]"
|
|
147
230
|
/>
|
|
148
231
|
</div>
|
|
149
|
-
<div className="min-h-0 flex-1 space-y-
|
|
232
|
+
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-3 pb-3">
|
|
150
233
|
{groups.length === 0 && (
|
|
151
234
|
<p className="py-6 text-center text-xs text-[rgb(var(--ec-page-text-muted))]">No resources found.</p>
|
|
152
235
|
)}
|
|
153
236
|
{groups.map(({ collection, label, icon, className, items }) => {
|
|
154
|
-
const
|
|
237
|
+
const id = `catalog:${collection}`;
|
|
238
|
+
// While searching, every group with matches shows them
|
|
239
|
+
const open = searching || !collapsed.has(id);
|
|
240
|
+
const shown = !open ? [] : expanded.has(collection) ? items : items.slice(0, GROUP_PREVIEW_SIZE);
|
|
155
241
|
return (
|
|
156
242
|
<section key={collection} className="space-y-1.5">
|
|
157
|
-
<
|
|
243
|
+
<SectionHeading open={open} onToggle={() => toggle(id)}>
|
|
158
244
|
{label} <span className="font-normal">({items.length})</span>
|
|
159
|
-
</
|
|
245
|
+
</SectionHeading>
|
|
160
246
|
{shown.map((resource) => (
|
|
161
247
|
<CatalogItem
|
|
162
248
|
key={resource.key}
|
|
@@ -165,9 +251,10 @@ const CatalogList = memo(function CatalogList({
|
|
|
165
251
|
iconClassName={className}
|
|
166
252
|
onCanvas={keysOnCanvas.has(resource.key)}
|
|
167
253
|
onAdd={onAdd}
|
|
254
|
+
startDrag={startDrag}
|
|
168
255
|
/>
|
|
169
256
|
))}
|
|
170
|
-
{shown.length < items.length && (
|
|
257
|
+
{open && shown.length < items.length && (
|
|
171
258
|
<button
|
|
172
259
|
onClick={() => setExpanded((current) => new Set(current).add(collection))}
|
|
173
260
|
className="w-full rounded-md px-2 py-1.5 text-xs font-medium text-[rgb(var(--ec-accent))] hover:bg-[rgb(var(--ec-accent-subtle))]"
|
|
@@ -189,15 +276,24 @@ const CatalogItem = memo(function CatalogItem({
|
|
|
189
276
|
iconClassName,
|
|
190
277
|
onCanvas,
|
|
191
278
|
onAdd,
|
|
279
|
+
startDrag,
|
|
192
280
|
}: {
|
|
193
281
|
resource: CatalogResource;
|
|
194
282
|
icon: (typeof CATALOG_GROUPS)[number]['icon'];
|
|
195
283
|
iconClassName: string;
|
|
196
284
|
onCanvas: boolean;
|
|
197
285
|
onAdd: (key: string) => void;
|
|
286
|
+
startDrag: StartDrag;
|
|
198
287
|
}) {
|
|
288
|
+
// Drawn like the card it becomes (domains and systems can become containers too, but start as cards)
|
|
289
|
+
const preview = useMemo(() => ({ type: resource.node.type, data: catalogNodeData(resource) }), [resource]);
|
|
199
290
|
return (
|
|
200
|
-
<div
|
|
291
|
+
<div
|
|
292
|
+
draggable
|
|
293
|
+
onDragStart={startDrag(CATALOG_DRAG_TYPE, resource.key, preview)}
|
|
294
|
+
title={resource.summary}
|
|
295
|
+
className={itemClass}
|
|
296
|
+
>
|
|
201
297
|
<span className="flex min-w-0 items-center gap-2 text-xs font-medium">
|
|
202
298
|
<Icon size={16} className={`shrink-0 ${iconClassName}`} />
|
|
203
299
|
<span className="truncate">{resource.name}</span>
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
}
|
|
@@ -4,6 +4,7 @@ import { Bot, Eye, EyeOff } from 'lucide-react';
|
|
|
4
4
|
import { sizeOf } from '../grouping';
|
|
5
5
|
import { usePeople, type Peer, type PresenceStore } from '../hooks/presence-store';
|
|
6
6
|
import { useFollowCamera } from '../hooks/use-follow-camera';
|
|
7
|
+
import Picture from './Picture';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Other people on the canvas: their pointers, selections and the connections they're dragging. Drawn in the
|
|
@@ -148,6 +149,7 @@ const PeerPointer = memo(function PeerPointer({ store, peer, scale }: { store: P
|
|
|
148
149
|
style={{ backgroundColor: peer.color }}
|
|
149
150
|
>
|
|
150
151
|
{peer.agent && <Bot size={12} />}
|
|
152
|
+
<Picture src={peer.picture} className="-ml-0.5 h-4 w-4 shrink-0" />
|
|
151
153
|
{peer.name}
|
|
152
154
|
{peer.activity && <span className="font-normal opacity-90">· {peer.activity}</span>}
|
|
153
155
|
</span>
|
|
@@ -1,75 +1,212 @@
|
|
|
1
|
-
import { memo } from 'react';
|
|
1
|
+
import { memo, useState, type FormEvent, type ReactNode } from 'react';
|
|
2
2
|
import type { Node } from '@xyflow/react';
|
|
3
|
-
import { ExternalLink, Trash2 } from 'lucide-react';
|
|
3
|
+
import { Box, ExternalLink, Library, Trash2, X } from 'lucide-react';
|
|
4
4
|
import { getCatalogLink } from '../catalog';
|
|
5
|
-
import { getNodeLabel, getResource, updateResource } from '../node-types';
|
|
5
|
+
import { getNodeLabel, getResource, isVersioned, updateResource } from '../node-types';
|
|
6
6
|
import type { UpdateNodeData } from './canvas-nodes';
|
|
7
|
+
import { NODE_ICONS } from './icons';
|
|
7
8
|
import { STATUS } from './status';
|
|
8
9
|
|
|
9
10
|
const inputClass =
|
|
10
|
-
'w-full rounded-
|
|
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';
|
|
11
13
|
|
|
12
|
-
|
|
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
|
+
*/
|
|
13
33
|
export default memo(function PropertiesPanel({
|
|
14
34
|
node,
|
|
15
35
|
onChange,
|
|
16
36
|
onDelete,
|
|
37
|
+
onClose,
|
|
17
38
|
}: {
|
|
18
39
|
/** What's shown of the node (not its position, so dragging it doesn't re-render the panel) */
|
|
19
40
|
node: Pick<Node, 'id' | 'type' | 'data'>;
|
|
20
41
|
onChange: UpdateNodeData;
|
|
21
42
|
onDelete: (ids: string[]) => void;
|
|
43
|
+
onClose: () => void;
|
|
22
44
|
}) {
|
|
23
45
|
const resource = getResource(node.type, node.data);
|
|
24
46
|
const link = getCatalogLink(node);
|
|
25
|
-
const
|
|
26
|
-
|
|
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
|
+
};
|
|
27
76
|
|
|
28
77
|
return (
|
|
29
|
-
<aside className="w-
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
<input
|
|
50
|
-
value={String(resource.name ?? '')}
|
|
51
|
-
readOnly={!!link}
|
|
52
|
-
onChange={(e) => setField('name', e.target.value)}
|
|
53
|
-
className={inputClass}
|
|
54
|
-
/>
|
|
55
|
-
</label>
|
|
56
|
-
<label className="block space-y-1 text-xs text-[rgb(var(--ec-page-text-muted))]">
|
|
57
|
-
Summary
|
|
58
|
-
<textarea
|
|
59
|
-
rows={4}
|
|
60
|
-
value={String(resource.summary ?? '')}
|
|
61
|
-
readOnly={!!link}
|
|
62
|
-
onChange={(e) => setField('summary', e.target.value)}
|
|
63
|
-
className={inputClass}
|
|
64
|
-
/>
|
|
65
|
-
</label>
|
|
66
|
-
<button
|
|
67
|
-
onClick={() => onDelete([node.id])}
|
|
68
|
-
className={`flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs ${STATUS.danger.text} ${STATUS.danger.hover}`}
|
|
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"
|
|
69
98
|
>
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
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>
|
|
73
185
|
</aside>
|
|
74
186
|
);
|
|
75
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
|
+
}
|