@startsimpli/ui 0.4.14 → 0.4.16

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 (78) hide show
  1. package/README.md +457 -398
  2. package/package.json +18 -13
  3. package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
  4. package/src/components/__tests__/chat.test.tsx +129 -0
  5. package/src/components/__tests__/meetings-list.test.tsx +114 -0
  6. package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
  7. package/src/components/__tests__/workspace.test.tsx +106 -0
  8. package/src/components/account/__tests__/account.test.tsx +5 -32
  9. package/src/components/account/change-password-form.tsx +1 -28
  10. package/src/components/calendar/calendar-view.tsx +31 -0
  11. package/src/components/calendar/index.ts +7 -0
  12. package/src/components/calendar/meetings-list.tsx +202 -0
  13. package/src/components/calendar/upcoming-meetings.tsx +5 -5
  14. package/src/components/chat/ChatComposer.tsx +113 -0
  15. package/src/components/chat/ChatMessage.tsx +81 -0
  16. package/src/components/chat/ChatThread.tsx +57 -0
  17. package/src/components/chat/index.ts +12 -0
  18. package/src/components/chat/types.ts +20 -0
  19. package/src/components/index.ts +13 -0
  20. package/src/components/slide-deck/SlideCanvas.tsx +68 -0
  21. package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
  22. package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
  23. package/src/components/slide-deck/index.ts +7 -0
  24. package/src/components/slide-deck/types.ts +18 -0
  25. package/src/components/team/DomainClaimCard.tsx +170 -0
  26. package/src/components/team/InviteMemberDialog.tsx +182 -0
  27. package/src/components/team/LeaveTeamDialog.tsx +130 -0
  28. package/src/components/team/MembersTable.tsx +138 -0
  29. package/src/components/team/OrgSwitcher.tsx +68 -0
  30. package/src/components/team/PendingInvitationCallout.tsx +106 -0
  31. package/src/components/team/RoleSelector.tsx +68 -0
  32. package/src/components/team/__tests__/team-components.test.tsx +352 -0
  33. package/src/components/team/__tests__/team-settings-page.test.tsx +146 -0
  34. package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
  35. package/src/components/team/index.ts +62 -0
  36. package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
  37. package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
  38. package/src/components/team/members-table-default-class-names.ts +39 -0
  39. package/src/components/team/org-switcher-default-class-names.ts +13 -0
  40. package/src/components/team/pages/DomainsSettingsPage.tsx +289 -0
  41. package/src/components/team/pages/TeamSettingsPage.tsx +423 -0
  42. package/src/components/team/pages/domains-settings-page-default-class-names.ts +89 -0
  43. package/src/components/team/pages/index.ts +33 -0
  44. package/src/components/team/pages/team-settings-page-default-class-names.ts +116 -0
  45. package/src/components/team/pages/types.ts +135 -0
  46. package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
  47. package/src/components/team/role-selector-default-class-names.ts +11 -0
  48. package/src/components/team/types.ts +97 -0
  49. package/src/components/workflows/ExecNodeDetails.tsx +83 -0
  50. package/src/components/workflows/ExecutionTimeline.tsx +146 -0
  51. package/src/components/workflows/NodeInspector.tsx +257 -0
  52. package/src/components/workflows/NodePalette.tsx +119 -0
  53. package/src/components/workflows/WorkflowCanvas.tsx +113 -0
  54. package/src/components/workflows/WorkflowEdge.tsx +65 -0
  55. package/src/components/workflows/WorkflowEditor.tsx +130 -0
  56. package/src/components/workflows/WorkflowNode.tsx +198 -0
  57. package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
  58. package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
  59. package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
  60. package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
  61. package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
  62. package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
  63. package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
  64. package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
  65. package/src/components/workflows/__tests__/serialization.test.ts +278 -0
  66. package/src/components/workflows/exec-status.ts +90 -0
  67. package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
  68. package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
  69. package/src/components/workflows/index.ts +78 -0
  70. package/src/components/workflows/layout/auto-layout.ts +142 -0
  71. package/src/components/workflows/node-icons.ts +31 -0
  72. package/src/components/workflows/serialization.ts +171 -0
  73. package/src/components/workflows/theme/categories.ts +96 -0
  74. package/src/components/workflows/types.ts +231 -0
  75. package/src/components/workflows/workflows.css +29 -0
  76. package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
  77. package/src/components/workspace/SplitPane.tsx +174 -0
  78. package/src/components/workspace/index.ts +4 -0
@@ -0,0 +1,257 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '../../lib/utils';
5
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
6
+ import { StatusBadge } from '../badge/StatusBadge';
7
+ import { EmptyState } from '../states/EmptyState';
8
+ import { NODE_STATUS_BADGE } from './exec-status';
9
+ import type { JsonSchema, NodeExecView, NodeTypeDef, WfNode } from './types';
10
+
11
+ export interface NodeInspectorProps {
12
+ /** The selected node, or null when nothing is selected. */
13
+ node: WfNode | null | undefined;
14
+ /** Node-type def supplying the parameter schema. */
15
+ nodeType?: NodeTypeDef;
16
+ /** Emitted with the node id + full next parameter object on any change. */
17
+ onParametersChange?: (nodeId: string, parameters: Record<string, unknown>) => void;
18
+ /** When present, adds read-only Input/Output exec tabs. */
19
+ execution?: NodeExecView;
20
+ className?: string;
21
+ }
22
+
23
+ function fieldId(nodeId: string, key: string): string {
24
+ return `nodeparam-${nodeId}-${key}`;
25
+ }
26
+
27
+ function SchemaField({
28
+ nodeId,
29
+ name,
30
+ schema,
31
+ value,
32
+ onChange,
33
+ }: {
34
+ nodeId: string;
35
+ name: string;
36
+ schema: JsonSchema;
37
+ value: unknown;
38
+ onChange: (next: unknown) => void;
39
+ }) {
40
+ const id = fieldId(nodeId, name);
41
+ const label = schema.title ?? name;
42
+
43
+ if (schema.enum && schema.enum.length > 0) {
44
+ return (
45
+ <div className="flex flex-col gap-1">
46
+ <label htmlFor={id} className="text-xs font-medium text-foreground">
47
+ {label}
48
+ </label>
49
+ <select
50
+ id={id}
51
+ value={String(value ?? '')}
52
+ onChange={(e) => onChange(e.target.value)}
53
+ className="rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary"
54
+ >
55
+ {schema.enum.map((opt) => (
56
+ <option key={String(opt)} value={String(opt)}>
57
+ {String(opt)}
58
+ </option>
59
+ ))}
60
+ </select>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ if (schema.type === 'boolean') {
66
+ return (
67
+ <div className="flex items-center gap-2">
68
+ <input
69
+ id={id}
70
+ type="checkbox"
71
+ checked={Boolean(value)}
72
+ onChange={(e) => onChange(e.target.checked)}
73
+ className="h-4 w-4 rounded border-border"
74
+ />
75
+ <label htmlFor={id} className="text-xs font-medium text-foreground">
76
+ {label}
77
+ </label>
78
+ </div>
79
+ );
80
+ }
81
+
82
+ if (schema.type === 'number' || schema.type === 'integer') {
83
+ return (
84
+ <div className="flex flex-col gap-1">
85
+ <label htmlFor={id} className="text-xs font-medium text-foreground">
86
+ {label}
87
+ </label>
88
+ <input
89
+ id={id}
90
+ type="number"
91
+ value={value === undefined || value === null ? '' : Number(value)}
92
+ onChange={(e) =>
93
+ onChange(e.target.value === '' ? undefined : Number(e.target.value))
94
+ }
95
+ className="rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary"
96
+ />
97
+ </div>
98
+ );
99
+ }
100
+
101
+ // default: string
102
+ return (
103
+ <div className="flex flex-col gap-1">
104
+ <label htmlFor={id} className="text-xs font-medium text-foreground">
105
+ {label}
106
+ </label>
107
+ <input
108
+ id={id}
109
+ type="text"
110
+ value={value === undefined || value === null ? '' : String(value)}
111
+ onChange={(e) => onChange(e.target.value)}
112
+ className="rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary"
113
+ />
114
+ {schema.description && (
115
+ <p className="text-[11px] text-muted-foreground">{schema.description}</p>
116
+ )}
117
+ </div>
118
+ );
119
+ }
120
+
121
+ function JsonBlock({ value }: { value: Record<string, unknown> | null | undefined }) {
122
+ const isEmpty = value == null || Object.keys(value).length === 0;
123
+ if (isEmpty) {
124
+ return <p className="text-xs italic text-muted-foreground">No data.</p>;
125
+ }
126
+ return (
127
+ <pre className="overflow-auto rounded-md border border-border bg-muted/40 p-2 text-xs leading-relaxed">
128
+ {JSON.stringify(value, null, 2)}
129
+ </pre>
130
+ );
131
+ }
132
+
133
+ function ParamForm({
134
+ node,
135
+ schema,
136
+ onParametersChange,
137
+ }: {
138
+ node: WfNode;
139
+ schema: JsonSchema | undefined;
140
+ onParametersChange?: NodeInspectorProps['onParametersChange'];
141
+ }) {
142
+ const params = (node.parameters ?? {}) as Record<string, unknown>;
143
+ const properties = schema?.properties ?? {};
144
+ const keys = Object.keys(properties);
145
+
146
+ const handleChange = (key: string, next: unknown) => {
147
+ const updated = { ...params, [key]: next };
148
+ onParametersChange?.(node.id, updated);
149
+ };
150
+
151
+ if (keys.length === 0) {
152
+ return (
153
+ <p className="text-xs italic text-muted-foreground">
154
+ This node type has no configurable parameters.
155
+ </p>
156
+ );
157
+ }
158
+
159
+ return (
160
+ <div className="flex flex-col gap-3">
161
+ {keys.map((key) => (
162
+ <SchemaField
163
+ key={key}
164
+ nodeId={node.id}
165
+ name={key}
166
+ schema={properties[key]}
167
+ value={params[key]}
168
+ onChange={(next) => handleChange(key, next)}
169
+ />
170
+ ))}
171
+ </div>
172
+ );
173
+ }
174
+
175
+ /**
176
+ * Editor inspector panel for the selected node. Renders a JSON-Schema-driven
177
+ * read/write form from the node-type's `parameterSchema` (string/number/
178
+ * boolean/enum fields), emitting the full next `parameters` object via
179
+ * `onParametersChange`. When an {@link NodeExecView} is supplied, adds
180
+ * read-only Input/Output tabs alongside the parameters tab.
181
+ */
182
+ export function NodeInspector({
183
+ node,
184
+ nodeType,
185
+ onParametersChange,
186
+ execution,
187
+ className,
188
+ }: NodeInspectorProps) {
189
+ if (!node) {
190
+ return (
191
+ <EmptyState
192
+ title="No node selected"
193
+ description="Select a node on the canvas to edit its parameters."
194
+ className={className}
195
+ />
196
+ );
197
+ }
198
+
199
+ const header = (
200
+ <header className="flex flex-col gap-1 border-b border-border px-4 py-3">
201
+ <div className="flex items-center gap-2">
202
+ <h3 className="truncate text-sm font-semibold text-foreground">
203
+ {node.name}
204
+ </h3>
205
+ {execution && (
206
+ <StatusBadge
207
+ status={execution.status}
208
+ config={NODE_STATUS_BADGE}
209
+ size="sm"
210
+ />
211
+ )}
212
+ </div>
213
+ <span className="text-xs text-muted-foreground">{node.type}</span>
214
+ </header>
215
+ );
216
+
217
+ const paramForm = (
218
+ <div className="overflow-auto p-4">
219
+ <ParamForm
220
+ node={node}
221
+ schema={nodeType?.parameterSchema}
222
+ onParametersChange={onParametersChange}
223
+ />
224
+ </div>
225
+ );
226
+
227
+ if (!execution) {
228
+ return (
229
+ <div className={cn('flex h-full min-h-0 flex-col', className)}>
230
+ {header}
231
+ {paramForm}
232
+ </div>
233
+ );
234
+ }
235
+
236
+ return (
237
+ <div className={cn('flex h-full min-h-0 flex-col', className)}>
238
+ {header}
239
+ <Tabs defaultValue="params" className="flex min-h-0 flex-1 flex-col">
240
+ <TabsList className="mx-4 mt-3 self-start">
241
+ <TabsTrigger value="params">Parameters</TabsTrigger>
242
+ <TabsTrigger value="input">Input</TabsTrigger>
243
+ <TabsTrigger value="output">Output</TabsTrigger>
244
+ </TabsList>
245
+ <TabsContent value="params" className="min-h-0 flex-1">
246
+ {paramForm}
247
+ </TabsContent>
248
+ <TabsContent value="input" className="min-h-0 flex-1 overflow-auto p-4">
249
+ <JsonBlock value={execution.inputData} />
250
+ </TabsContent>
251
+ <TabsContent value="output" className="min-h-0 flex-1 overflow-auto p-4">
252
+ <JsonBlock value={execution.outputData} />
253
+ </TabsContent>
254
+ </Tabs>
255
+ </div>
256
+ );
257
+ }
@@ -0,0 +1,119 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '../../lib/utils';
5
+ import { getCategoryToken } from './theme/categories';
6
+ import { getCategoryIcon } from './node-icons';
7
+ import type { NodeTypeDef } from './types';
8
+
9
+ /** MIME-ish key used to carry a node-type slug across HTML5 drag-and-drop. */
10
+ export const NODE_DND_MIME = 'application/x-workflow-node';
11
+
12
+ export interface NodePaletteProps {
13
+ /** The node-type catalog to list. */
14
+ nodeTypes: NodeTypeDef[];
15
+ /** Optional click handler (e.g. add node at canvas center). */
16
+ onAddNode?: (slug: string) => void;
17
+ className?: string;
18
+ }
19
+
20
+ function matches(nt: NodeTypeDef, query: string): boolean {
21
+ if (!query) return true;
22
+ const q = query.toLowerCase();
23
+ return (
24
+ nt.name.toLowerCase().includes(q) ||
25
+ nt.slug.toLowerCase().includes(q) ||
26
+ nt.category.toLowerCase().includes(q) ||
27
+ (nt.description?.toLowerCase().includes(q) ?? false)
28
+ );
29
+ }
30
+
31
+ function groupByCategory(
32
+ nodeTypes: NodeTypeDef[]
33
+ ): { category: string; items: NodeTypeDef[] }[] {
34
+ const groups = new Map<string, NodeTypeDef[]>();
35
+ for (const nt of nodeTypes) {
36
+ const list = groups.get(nt.category) ?? [];
37
+ list.push(nt);
38
+ groups.set(nt.category, list);
39
+ }
40
+ return [...groups.entries()].map(([category, items]) => ({ category, items }));
41
+ }
42
+
43
+ /**
44
+ * Draggable palette of {@link NodeTypeDef}s, grouped by category with a search
45
+ * filter. Each item is HTML5-draggable: dragging sets the node-type slug on
46
+ * `dataTransfer` under {@link NODE_DND_MIME} for the canvas to read on drop.
47
+ */
48
+ export function NodePalette({ nodeTypes, onAddNode, className }: NodePaletteProps) {
49
+ const [query, setQuery] = React.useState('');
50
+
51
+ const groups = React.useMemo(() => {
52
+ const filtered = nodeTypes.filter((nt) => matches(nt, query));
53
+ return groupByCategory(filtered);
54
+ }, [nodeTypes, query]);
55
+
56
+ return (
57
+ <div className={cn('flex h-full min-h-0 flex-col', className)}>
58
+ <div className="shrink-0 border-b border-border p-2">
59
+ <input
60
+ type="search"
61
+ role="searchbox"
62
+ aria-label="Filter node types"
63
+ placeholder="Search nodes…"
64
+ value={query}
65
+ onChange={(e) => setQuery(e.target.value)}
66
+ className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary"
67
+ />
68
+ </div>
69
+
70
+ <div className="min-h-0 flex-1 overflow-auto p-2">
71
+ {groups.length === 0 ? (
72
+ <p className="px-1 py-4 text-center text-xs text-muted-foreground">
73
+ No matching nodes.
74
+ </p>
75
+ ) : (
76
+ groups.map(({ category, items }) => {
77
+ const token = getCategoryToken(category);
78
+ return (
79
+ <div key={category} className="mb-3">
80
+ <h4 className="mb-1 px-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
81
+ {token.label}
82
+ </h4>
83
+ <ul className="flex flex-col gap-1">
84
+ {items.map((nt) => {
85
+ const Icon = getCategoryIcon(token.icon);
86
+ return (
87
+ <li key={nt.slug}>
88
+ <div
89
+ draggable
90
+ onDragStart={(e) => {
91
+ e.dataTransfer.setData(NODE_DND_MIME, nt.slug);
92
+ e.dataTransfer.effectAllowed = 'move';
93
+ }}
94
+ onClick={() => onAddNode?.(nt.slug)}
95
+ className="flex cursor-grab items-center gap-2 rounded-md border border-border bg-card px-2 py-1.5 text-sm transition-colors hover:bg-accent active:cursor-grabbing"
96
+ title={nt.description ?? nt.name}
97
+ >
98
+ <span
99
+ className="flex h-6 w-6 shrink-0 items-center justify-center rounded"
100
+ style={{ background: token.background, color: token.color }}
101
+ >
102
+ <Icon className="h-3.5 w-3.5" aria-hidden="true" />
103
+ </span>
104
+ <span className="min-w-0 flex-1 truncate text-foreground">
105
+ {nt.name}
106
+ </span>
107
+ </div>
108
+ </li>
109
+ );
110
+ })}
111
+ </ul>
112
+ </div>
113
+ );
114
+ })
115
+ )}
116
+ </div>
117
+ </div>
118
+ );
119
+ }
@@ -0,0 +1,113 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import {
5
+ ReactFlow,
6
+ ReactFlowProvider,
7
+ Background,
8
+ Controls,
9
+ type NodeTypes,
10
+ type EdgeTypes,
11
+ } from '@xyflow/react';
12
+ import { cn } from '../../lib/utils';
13
+ import { WorkflowNode } from './WorkflowNode';
14
+ import { WorkflowEdge } from './WorkflowEdge';
15
+ import { useCanvasGraph } from './hooks/useCanvasGraph';
16
+ import { useNodeStatusOverlay } from './hooks/useNodeStatusOverlay';
17
+ import type { WorkflowGraph, WfExecutionView, NodeTypeDef } from './types';
18
+ import type { LayoutDirection } from './layout/auto-layout';
19
+
20
+ const nodeTypes: NodeTypes = { workflowNode: WorkflowNode };
21
+ const edgeTypes: EdgeTypes = { workflowEdge: WorkflowEdge };
22
+
23
+ export interface WorkflowCanvasProps {
24
+ /** The graph to render. */
25
+ graph: WorkflowGraph;
26
+ /** Node-type catalog (enriches nodes with category + ports). */
27
+ nodeTypes?: NodeTypeDef[];
28
+ /**
29
+ * Optional run overlay. When present, node status badges and edge
30
+ * animations reflect the live execution. Pass a fresh snapshot per poll.
31
+ */
32
+ execution?: WfExecutionView;
33
+ /** Layout direction for positionless nodes. Default 'LR'. */
34
+ direction?: LayoutDirection;
35
+ /** Inline-rename callback. */
36
+ onRename?: (nodeId: string, name: string) => void;
37
+ /** Fired when a node is selected on the canvas. */
38
+ onSelectNode?: (nodeId: string | null) => void;
39
+ /** Drop handler for palette drag-and-drop (slug + client coords). */
40
+ onDropNodeType?: (slug: string, clientX: number, clientY: number) => void;
41
+ className?: string;
42
+ }
43
+
44
+ function CanvasInner({
45
+ graph,
46
+ nodeTypes: catalog,
47
+ execution,
48
+ direction = 'LR',
49
+ onRename,
50
+ onSelectNode,
51
+ onDropNodeType,
52
+ className,
53
+ }: WorkflowCanvasProps) {
54
+ const { nodes: baseNodes, edges: baseEdges } = useCanvasGraph(graph, {
55
+ nodeTypes: catalog,
56
+ direction,
57
+ onRename,
58
+ });
59
+ const { nodes, edges } = useNodeStatusOverlay(baseNodes, baseEdges, execution);
60
+
61
+ const handleDragOver = React.useCallback((e: React.DragEvent) => {
62
+ if (!onDropNodeType) return;
63
+ e.preventDefault();
64
+ e.dataTransfer.dropEffect = 'move';
65
+ }, [onDropNodeType]);
66
+
67
+ const handleDrop = React.useCallback(
68
+ (e: React.DragEvent) => {
69
+ if (!onDropNodeType) return;
70
+ e.preventDefault();
71
+ const slug = e.dataTransfer.getData('application/x-workflow-node');
72
+ if (slug) onDropNodeType(slug, e.clientX, e.clientY);
73
+ },
74
+ [onDropNodeType]
75
+ );
76
+
77
+ return (
78
+ <div
79
+ className={cn('workflow-canvas h-full w-full', className)}
80
+ onDragOver={handleDragOver}
81
+ onDrop={handleDrop}
82
+ >
83
+ <ReactFlow
84
+ nodes={nodes as never}
85
+ edges={edges as never}
86
+ nodeTypes={nodeTypes}
87
+ edgeTypes={edgeTypes}
88
+ fitView
89
+ proOptions={{ hideAttribution: true }}
90
+ onNodeClick={(_, n) => onSelectNode?.(n.id)}
91
+ onPaneClick={() => onSelectNode?.(null)}
92
+ >
93
+ <Background gap={16} />
94
+ <Controls showInteractive={false} />
95
+ </ReactFlow>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Renders a {@link WorkflowGraph} on an interactive xyflow canvas with custom
102
+ * workflow nodes/edges, auto-layout, fit-view, and controls. When an
103
+ * `execution` overlay is supplied, node statuses + edge animations reflect the
104
+ * live run. Wraps its own {@link ReactFlowProvider} so it can be dropped in
105
+ * anywhere.
106
+ */
107
+ export function WorkflowCanvas(props: WorkflowCanvasProps) {
108
+ return (
109
+ <ReactFlowProvider>
110
+ <CanvasInner {...props} />
111
+ </ReactFlowProvider>
112
+ );
113
+ }
@@ -0,0 +1,65 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import {
5
+ BaseEdge,
6
+ getBezierPath,
7
+ type EdgeProps,
8
+ } from '@xyflow/react';
9
+ import type { NodeStatus } from './types';
10
+
11
+ /**
12
+ * Data payload carried on an xyflow edge rendered by {@link WorkflowEdge}.
13
+ * `sourceStatus` is layered on by the canvas status overlay so the edge can
14
+ * animate while its source node is running (or has just succeeded).
15
+ */
16
+ export interface WorkflowEdgeData {
17
+ connectionType?: string;
18
+ /** Runtime status of the source node (drives the animated flow). */
19
+ sourceStatus?: NodeStatus;
20
+ [key: string]: unknown;
21
+ }
22
+
23
+ const ANIMATED: ReadonlySet<NodeStatus> = new Set(['running', 'success']);
24
+
25
+ /**
26
+ * Custom workflow edge: a bezier connector that animates a dashed flow when
27
+ * its source node is running or has succeeded (so live runs visibly "flow").
28
+ */
29
+ export function WorkflowEdge({
30
+ id,
31
+ sourceX,
32
+ sourceY,
33
+ targetX,
34
+ targetY,
35
+ sourcePosition,
36
+ targetPosition,
37
+ data,
38
+ markerEnd,
39
+ selected,
40
+ }: EdgeProps) {
41
+ const [edgePath] = getBezierPath({
42
+ sourceX,
43
+ sourceY,
44
+ targetX,
45
+ targetY,
46
+ sourcePosition,
47
+ targetPosition,
48
+ });
49
+
50
+ const d = data as WorkflowEdgeData | undefined;
51
+ const animated = d?.sourceStatus ? ANIMATED.has(d.sourceStatus) : false;
52
+
53
+ return (
54
+ <BaseEdge
55
+ id={id}
56
+ path={edgePath}
57
+ markerEnd={markerEnd}
58
+ className={animated ? 'workflow-edge--animated' : undefined}
59
+ style={{
60
+ strokeWidth: selected ? 2.5 : 1.5,
61
+ stroke: selected ? '#2563eb' : '#94a3b8',
62
+ }}
63
+ />
64
+ );
65
+ }
@@ -0,0 +1,130 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '../../lib/utils';
5
+ import { DualPaneWorkspace } from '../workspace/DualPaneWorkspace';
6
+ import { WorkflowCanvas } from './WorkflowCanvas';
7
+ import { NodePalette } from './NodePalette';
8
+ import { NodeInspector } from './NodeInspector';
9
+ import type { NodeTypeDef, WfNode, WorkflowGraph } from './types';
10
+
11
+ export interface WorkflowEditorProps {
12
+ /** The graph being edited (controlled when `onGraphChange` is provided). */
13
+ graph: WorkflowGraph;
14
+ /** Node-type catalog for the palette + canvas enrichment + inspector. */
15
+ nodeTypes: NodeTypeDef[];
16
+ /** Emitted with the next graph whenever the editor mutates it. */
17
+ onGraphChange?: (graph: WorkflowGraph) => void;
18
+ className?: string;
19
+ }
20
+
21
+ function nextNodeId(graph: WorkflowGraph): string {
22
+ let i = graph.nodes.length + 1;
23
+ const ids = new Set(graph.nodes.map((n) => n.id));
24
+ while (ids.has(`node-${i}`)) i += 1;
25
+ return `node-${i}`;
26
+ }
27
+
28
+ /**
29
+ * Visual workflow editor: a {@link NodePalette} + interactive
30
+ * {@link WorkflowCanvas} on the left, a JSON-Schema-driven
31
+ * {@link NodeInspector} on the right (via {@link DualPaneWorkspace}). Manages
32
+ * selection internally and surfaces graph mutations (rename, param edits,
33
+ * palette add) through `onGraphChange`.
34
+ */
35
+ export function WorkflowEditor({
36
+ graph,
37
+ nodeTypes,
38
+ onGraphChange,
39
+ className,
40
+ }: WorkflowEditorProps) {
41
+ const [selectedId, setSelectedId] = React.useState<string | null>(null);
42
+
43
+ const typeIndex = React.useMemo(() => {
44
+ const idx: Record<string, NodeTypeDef> = {};
45
+ for (const nt of nodeTypes) idx[nt.slug] = nt;
46
+ return idx;
47
+ }, [nodeTypes]);
48
+
49
+ const selectedNode = selectedId
50
+ ? graph.nodes.find((n) => n.id === selectedId) ?? null
51
+ : null;
52
+ const selectedType = selectedNode ? typeIndex[selectedNode.type] : undefined;
53
+
54
+ const handleRename = React.useCallback(
55
+ (nodeId: string, name: string) => {
56
+ onGraphChange?.({
57
+ ...graph,
58
+ nodes: graph.nodes.map((n) => (n.id === nodeId ? { ...n, name } : n)),
59
+ });
60
+ },
61
+ [graph, onGraphChange]
62
+ );
63
+
64
+ const handleParametersChange = React.useCallback(
65
+ (nodeId: string, parameters: Record<string, unknown>) => {
66
+ onGraphChange?.({
67
+ ...graph,
68
+ nodes: graph.nodes.map((n) =>
69
+ n.id === nodeId ? { ...n, parameters } : n
70
+ ),
71
+ });
72
+ },
73
+ [graph, onGraphChange]
74
+ );
75
+
76
+ const handleAddNode = React.useCallback(
77
+ (slug: string) => {
78
+ const def = typeIndex[slug];
79
+ const id = nextNodeId(graph);
80
+ const node: WfNode = {
81
+ id,
82
+ name: def?.name ?? slug,
83
+ type: slug,
84
+ parameters: {},
85
+ };
86
+ onGraphChange?.({ ...graph, nodes: [...graph.nodes, node] });
87
+ setSelectedId(id);
88
+ },
89
+ [graph, onGraphChange, typeIndex]
90
+ );
91
+
92
+ const left = (
93
+ <DualPaneWorkspace
94
+ leftLabel="Nodes"
95
+ initialLeftSize={30}
96
+ minLeftSize={18}
97
+ maxLeftSize={45}
98
+ left={<NodePalette nodeTypes={nodeTypes} onAddNode={handleAddNode} />}
99
+ right={
100
+ <WorkflowCanvas
101
+ graph={graph}
102
+ nodeTypes={nodeTypes}
103
+ onRename={handleRename}
104
+ onSelectNode={setSelectedId}
105
+ onDropNodeType={(slug) => handleAddNode(slug)}
106
+ />
107
+ }
108
+ />
109
+ );
110
+
111
+ return (
112
+ <div className={cn('h-full min-h-0 w-full', className)}>
113
+ <DualPaneWorkspace
114
+ leftLabel="Editor"
115
+ rightLabel="Inspector"
116
+ initialLeftSize={68}
117
+ minLeftSize={45}
118
+ maxLeftSize={82}
119
+ left={left}
120
+ right={
121
+ <NodeInspector
122
+ node={selectedNode}
123
+ nodeType={selectedType}
124
+ onParametersChange={handleParametersChange}
125
+ />
126
+ }
127
+ />
128
+ </div>
129
+ );
130
+ }