@eventcatalog/core 5.0.0-beta.12 → 5.0.0-beta.14

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