@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.
- package/README.md +457 -398
- package/package.json +18 -13
- package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
- package/src/components/__tests__/chat.test.tsx +129 -0
- package/src/components/__tests__/meetings-list.test.tsx +114 -0
- package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
- package/src/components/__tests__/workspace.test.tsx +106 -0
- package/src/components/account/__tests__/account.test.tsx +5 -32
- package/src/components/account/change-password-form.tsx +1 -28
- package/src/components/calendar/calendar-view.tsx +31 -0
- package/src/components/calendar/index.ts +7 -0
- package/src/components/calendar/meetings-list.tsx +202 -0
- package/src/components/calendar/upcoming-meetings.tsx +5 -5
- package/src/components/chat/ChatComposer.tsx +113 -0
- package/src/components/chat/ChatMessage.tsx +81 -0
- package/src/components/chat/ChatThread.tsx +57 -0
- package/src/components/chat/index.ts +12 -0
- package/src/components/chat/types.ts +20 -0
- package/src/components/index.ts +13 -0
- package/src/components/slide-deck/SlideCanvas.tsx +68 -0
- package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
- package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
- package/src/components/slide-deck/index.ts +7 -0
- package/src/components/slide-deck/types.ts +18 -0
- package/src/components/team/DomainClaimCard.tsx +170 -0
- package/src/components/team/InviteMemberDialog.tsx +182 -0
- package/src/components/team/LeaveTeamDialog.tsx +130 -0
- package/src/components/team/MembersTable.tsx +138 -0
- package/src/components/team/OrgSwitcher.tsx +68 -0
- package/src/components/team/PendingInvitationCallout.tsx +106 -0
- package/src/components/team/RoleSelector.tsx +68 -0
- package/src/components/team/__tests__/team-components.test.tsx +352 -0
- package/src/components/team/__tests__/team-settings-page.test.tsx +146 -0
- package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
- package/src/components/team/index.ts +62 -0
- package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
- package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
- package/src/components/team/members-table-default-class-names.ts +39 -0
- package/src/components/team/org-switcher-default-class-names.ts +13 -0
- package/src/components/team/pages/DomainsSettingsPage.tsx +289 -0
- package/src/components/team/pages/TeamSettingsPage.tsx +423 -0
- package/src/components/team/pages/domains-settings-page-default-class-names.ts +89 -0
- package/src/components/team/pages/index.ts +33 -0
- package/src/components/team/pages/team-settings-page-default-class-names.ts +116 -0
- package/src/components/team/pages/types.ts +135 -0
- package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
- package/src/components/team/role-selector-default-class-names.ts +11 -0
- package/src/components/team/types.ts +97 -0
- package/src/components/workflows/ExecNodeDetails.tsx +83 -0
- package/src/components/workflows/ExecutionTimeline.tsx +146 -0
- package/src/components/workflows/NodeInspector.tsx +257 -0
- package/src/components/workflows/NodePalette.tsx +119 -0
- package/src/components/workflows/WorkflowCanvas.tsx +113 -0
- package/src/components/workflows/WorkflowEdge.tsx +65 -0
- package/src/components/workflows/WorkflowEditor.tsx +130 -0
- package/src/components/workflows/WorkflowNode.tsx +198 -0
- package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
- package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
- package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
- package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
- package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
- package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
- package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
- package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
- package/src/components/workflows/__tests__/serialization.test.ts +278 -0
- package/src/components/workflows/exec-status.ts +90 -0
- package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
- package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
- package/src/components/workflows/index.ts +78 -0
- package/src/components/workflows/layout/auto-layout.ts +142 -0
- package/src/components/workflows/node-icons.ts +31 -0
- package/src/components/workflows/serialization.ts +171 -0
- package/src/components/workflows/theme/categories.ts +96 -0
- package/src/components/workflows/types.ts +231 -0
- package/src/components/workflows/workflows.css +29 -0
- package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
- package/src/components/workspace/SplitPane.tsx +174 -0
- 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
|
+
}
|