@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.
Files changed (95) hide show
  1. package/LICENSE +227 -128
  2. package/dist/analytics/analytics.cjs +18 -3
  3. package/dist/analytics/analytics.d.cts +1 -1
  4. package/dist/analytics/analytics.d.ts +1 -1
  5. package/dist/analytics/analytics.js +3 -2
  6. package/dist/analytics/log-build.cjs +70 -34
  7. package/dist/analytics/log-build.js +5 -4
  8. package/dist/analytics/telemetry.cjs +42 -0
  9. package/dist/analytics/telemetry.d.cts +20 -0
  10. package/dist/analytics/telemetry.d.ts +20 -0
  11. package/dist/analytics/telemetry.js +8 -0
  12. package/dist/{chunk-3VBB2ZMP.js → chunk-4CFZUHGG.js} +9 -3
  13. package/dist/{chunk-PIYJM642.js → chunk-AWOO63ME.js} +70 -44
  14. package/dist/chunk-CPW6G62V.js +17 -0
  15. package/dist/{chunk-MDWB5HLO.js → chunk-ENAG6UH7.js} +5 -0
  16. package/dist/{chunk-FARBIZ7Y.js → chunk-EUNQJO3J.js} +1 -1
  17. package/dist/{chunk-S7ODHVII.js → chunk-GAHKCOQM.js} +1 -1
  18. package/dist/{chunk-BOKAG5XC.js → chunk-JQMYHZWI.js} +58 -35
  19. package/dist/{chunk-SJJKWZYZ.js → chunk-TZVDMWQX.js} +1 -1
  20. package/dist/constants.cjs +1 -1
  21. package/dist/constants.js +1 -1
  22. package/dist/eventcatalog.cjs +207 -123
  23. package/dist/eventcatalog.config.d.cts +55 -0
  24. package/dist/eventcatalog.config.d.ts +55 -0
  25. package/dist/eventcatalog.js +39 -19
  26. package/dist/features.cjs +8 -2
  27. package/dist/features.d.cts +3 -1
  28. package/dist/features.d.ts +3 -1
  29. package/dist/features.js +5 -3
  30. package/dist/federation/federate.js +4 -4
  31. package/dist/generate.cjs +1 -1
  32. package/dist/generate.js +3 -3
  33. package/dist/utils/cli-logger.cjs +1 -1
  34. package/dist/utils/cli-logger.js +2 -2
  35. package/dist/utils/license-status.cjs +70 -44
  36. package/dist/utils/license-status.d.cts +14 -5
  37. package/dist/utils/license-status.d.ts +14 -5
  38. package/dist/utils/license-status.js +1 -1
  39. package/eventcatalog/integrations/studio-server.mjs +15 -3
  40. package/eventcatalog/src/components/Header.astro +3 -3
  41. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +10 -2
  42. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +9 -1
  43. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +9 -2
  44. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +70 -69
  45. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
  46. package/eventcatalog/src/features/mcp/mcp-server.ts +5 -3
  47. package/eventcatalog/src/features/studio/agent-choreography.ts +8 -5
  48. package/eventcatalog/src/features/studio/canvas-actions.ts +148 -21
  49. package/eventcatalog/src/features/studio/canvas-doc.ts +138 -9
  50. package/eventcatalog/src/features/studio/canvas-preview.ts +53 -0
  51. package/eventcatalog/src/features/studio/catalog-resources.ts +5 -2
  52. package/eventcatalog/src/features/studio/clipboard.ts +97 -0
  53. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +4 -3
  54. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +39 -22
  55. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +117 -0
  56. package/eventcatalog/src/features/studio/components/Comments.tsx +345 -30
  57. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +140 -76
  58. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +144 -0
  59. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +1 -1
  60. package/eventcatalog/src/features/studio/components/DragGhost.tsx +115 -0
  61. package/eventcatalog/src/features/studio/components/JoinForm.tsx +0 -24
  62. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +131 -35
  63. package/eventcatalog/src/features/studio/components/Picture.tsx +21 -0
  64. package/eventcatalog/src/features/studio/components/Presence.tsx +2 -0
  65. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +187 -50
  66. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +260 -0
  67. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +442 -40
  68. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +198 -22
  69. package/eventcatalog/src/features/studio/components/icons.ts +6 -1
  70. package/eventcatalog/src/features/studio/components/status.ts +30 -1
  71. package/eventcatalog/src/features/studio/edges.ts +2 -0
  72. package/eventcatalog/src/features/studio/grouping.ts +95 -2
  73. package/eventcatalog/src/features/studio/hooks/presence-store.ts +5 -1
  74. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +13 -3
  75. package/eventcatalog/src/features/studio/hooks/use-comments.ts +8 -1
  76. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +7 -2
  77. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +174 -27
  78. package/eventcatalog/src/features/studio/identity.ts +35 -0
  79. package/eventcatalog/src/features/studio/layout.ts +7 -4
  80. package/eventcatalog/src/features/studio/levels.ts +138 -72
  81. package/eventcatalog/src/features/studio/node-types.ts +35 -3
  82. package/eventcatalog/src/features/studio/pages/[id].astro +7 -2
  83. package/eventcatalog/src/features/studio/pages/index.astro +555 -2
  84. package/eventcatalog/src/features/studio/pages/new.astro +24 -0
  85. package/eventcatalog/src/features/studio/pages/wake.ts +15 -0
  86. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +48 -13
  87. package/eventcatalog/src/features/studio/server/runtime.ts +193 -14
  88. package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
  89. package/eventcatalog/src/features/studio/server/storage.ts +110 -0
  90. package/eventcatalog/src/features/studio/studio.css +184 -0
  91. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +1 -1
  92. package/eventcatalog/src/utils/feature.ts +7 -3
  93. package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
  94. package/package.json +3 -3
  95. package/dist/{chunk-AYNZXUB5.js → chunk-ZEY2XUJN.js} +7 -7
@@ -1,9 +1,11 @@
1
- import { memo, useMemo, useState, type DragEvent } from 'react';
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
- const headingClass = 'px-1 text-[11px] font-semibold uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]';
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 startDrag = (type: string, value: string) => (event: DragEvent) => {
24
- event.dataTransfer.setData(type, value);
25
- event.dataTransfer.effectAllowed = 'move';
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' && <CatalogList resources={resources} keysOnCanvas={keysOnCanvas} onAdd={onAddResource} />}
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
- const ComponentList = memo(function ComponentList({ onAdd }: { onAdd: (type: string) => void }) {
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-4 overflow-y-auto px-3 pb-3">
93
- {listedDefinitions.map((category) => (
94
- <section key={category.id} className="space-y-1.5">
95
- <h3 className={headingClass}>{category.label}</h3>
96
- {category.definitions.map(({ type, label }) => {
97
- const { icon: Icon, className } = NODE_ICONS[type];
98
- return (
99
- <div key={type} draggable onDragStart={startDrag(COMPONENT_DRAG_TYPE, type)} className={itemClass}>
100
- <span className="flex items-center gap-2 text-xs font-medium">
101
- <Icon size={16} className={className} />
102
- {label}
103
- </span>
104
- <button onClick={() => onAdd(type)} title={`Add ${label}`} className={addButtonClass}>
105
- <Plus size={14} />
106
- </button>
107
- </div>
108
- );
109
- })}
110
- </section>
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-4 overflow-y-auto px-3 pb-3">
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 shown = expanded.has(collection) ? items : items.slice(0, GROUP_PREVIEW_SIZE);
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
- <h3 className={headingClass}>
243
+ <SectionHeading open={open} onToggle={() => toggle(id)}>
158
244
  {label} <span className="font-normal">({items.length})</span>
159
- </h3>
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 draggable onDragStart={startDrag(CATALOG_DRAG_TYPE, resource.key)} title={resource.summary} className={itemClass}>
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-md border px-2 py-1.5 text-sm bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))]';
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
- /** The selected node's name and summary (read only for catalog resources: those are changed in the catalog) */
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 setField = (field: string, value: string) =>
26
- onChange(node.id, (data) => updateResource(node.type, data, { [field]: value }));
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-72 shrink-0 space-y-4 overflow-y-auto border-l p-4 bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]">
30
- <h2 className="text-sm font-semibold">{getNodeLabel(node.type)}</h2>
31
- {link && (
32
- <div className="space-y-2 rounded-md border p-3 text-xs border-[rgb(var(--ec-page-border))]">
33
- <p className="text-[rgb(var(--ec-page-text-muted))]">
34
- Linked to <span className="font-medium text-[rgb(var(--ec-page-text))]">{String(resource.name)}</span> v{link.version}{' '}
35
- in the catalog. Edit it in the catalog to change its name or summary.
36
- </p>
37
- <a
38
- href={link.url}
39
- target="_blank"
40
- rel="noreferrer"
41
- className="flex items-center gap-1 text-[rgb(var(--ec-accent))] hover:underline"
42
- >
43
- View in catalog <ExternalLink size={12} />
44
- </a>
45
- </div>
46
- )}
47
- <label className="block space-y-1 text-xs text-[rgb(var(--ec-page-text-muted))]">
48
- Name
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
- <Trash2 size={14} />
71
- Delete
72
- </button>
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
+ }