@gadmin2n/schematics 0.0.78 → 0.0.80

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.
@@ -0,0 +1,388 @@
1
+ import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react';
2
+ import {
3
+ ReactFlow,
4
+ ReactFlowProvider,
5
+ Background,
6
+ Controls,
7
+ MiniMap,
8
+ ConnectionMode,
9
+ useReactFlow,
10
+ type Node,
11
+ type Edge,
12
+ type Connection,
13
+ type NodeChange,
14
+ type EdgeChange,
15
+ applyNodeChanges,
16
+ } from '@xyflow/react';
17
+ import '@xyflow/react/dist/style.css';
18
+ import { CustomNode } from './CustomNode';
19
+ import { ExecutionStatusNode } from './ExecutionStatusNode';
20
+ import type { WorkflowDSL, WorkflowNodeType } from '../types';
21
+
22
+ interface NodeExecution {
23
+ id: string;
24
+ nodeId: string;
25
+ nodeType: string;
26
+ nodeLabel: string | null;
27
+ status: string;
28
+ input: any;
29
+ output: any;
30
+ error: any;
31
+ startedAt: string | null;
32
+ finishedAt: string | null;
33
+ }
34
+
35
+ const nodeTypes = { custom: CustomNode, executionStatus: ExecutionStatusNode };
36
+
37
+ interface EnhancedFlowRendererProps {
38
+ dsl: WorkflowDSL | null;
39
+ nodeTypeMap?: Record<string, { category: string; icon: string }>;
40
+ selectedNodeId?: string | null;
41
+ readonly?: boolean;
42
+ onNodeClick?: (nodeId: string) => void;
43
+ onDslChange?: (dsl: WorkflowDSL) => void;
44
+ // New props for execution status
45
+ nodeExecutions?: NodeExecution[];
46
+ showExecutionStatus?: boolean;
47
+ }
48
+
49
+ function formatDuration(
50
+ startedAt: string | null,
51
+ finishedAt: string | null,
52
+ ): string {
53
+ if (!startedAt) return '';
54
+ const endTime = finishedAt ? new Date(finishedAt).getTime() : Date.now();
55
+ const ms = endTime - new Date(startedAt).getTime();
56
+ if (ms < 1000) return `${ms}ms`;
57
+ const s = ms / 1000;
58
+ if (s < 60) return `${s.toFixed(1)}s`;
59
+ const m = Math.floor(s / 60);
60
+ const rem = s % 60;
61
+ return rem > 0 ? `${m}m ${rem.toFixed(0)}s` : `${m}m`;
62
+ }
63
+
64
+ function EnhancedFlowCanvas({
65
+ dsl,
66
+ nodeTypeMap,
67
+ selectedNodeId,
68
+ readonly,
69
+ onNodeClick,
70
+ onDslChange,
71
+ nodeExecutions,
72
+ showExecutionStatus,
73
+ }: EnhancedFlowRendererProps) {
74
+ const reactFlowInstance = useReactFlow();
75
+ const dslRef = useRef(dsl);
76
+ dslRef.current = dsl;
77
+ const containerRef = useRef<HTMLDivElement>(null);
78
+ const hasSetViewport = useRef(false);
79
+
80
+ // Build execution map: nodeId -> execution record
81
+ const executionMap = useMemo(() => {
82
+ if (!nodeExecutions) return {};
83
+ const map: Record<string, NodeExecution> = {};
84
+ nodeExecutions.forEach((exec) => {
85
+ map[exec.nodeId] = exec;
86
+ });
87
+ return map;
88
+ }, [nodeExecutions]);
89
+
90
+ const { nodes, edges } = useMemo(() => {
91
+ if (!dsl) return { nodes: [] as Node[], edges: [] as Edge[] };
92
+
93
+ const rfNodes: Node[] = dsl.nodes.map((n) => {
94
+ const category = nodeTypeMap?.[n.type]?.category || 'ACTION';
95
+ // Transpose x/y for vertical (top-to-bottom) layout
96
+ // Scale y down to compress vertical spacing (original x-step=250 → 120)
97
+ // Add y offset so top node isn't clipped
98
+ const position = { x: n.position.y * 1.5, y: n.position.x * 0.5 + 20 };
99
+
100
+ // Get execution status for this node
101
+ const execution = executionMap[n.id];
102
+ const nodeType = showExecutionStatus ? 'executionStatus' : 'custom';
103
+
104
+ const nodeData: any = {
105
+ label: n.label,
106
+ category,
107
+ isSelected: n.id === selectedNodeId,
108
+ readonly: !!readonly,
109
+ };
110
+
111
+ // Add execution status data — show NOT_EXECUTED for nodes without records
112
+ if (showExecutionStatus) {
113
+ if (execution) {
114
+ nodeData.executionStatus = execution.status;
115
+ nodeData.duration = formatDuration(
116
+ execution.startedAt,
117
+ execution.finishedAt,
118
+ );
119
+ nodeData.error = execution.error;
120
+ } else {
121
+ nodeData.executionStatus = 'NOT_EXECUTED';
122
+ }
123
+ }
124
+
125
+ return {
126
+ id: n.id,
127
+ type: nodeType,
128
+ position,
129
+ measured: { width: 140, height: 40 },
130
+ selected: n.id === selectedNodeId,
131
+ data: nodeData,
132
+ };
133
+ });
134
+
135
+ const rfEdges: Edge[] = dsl.edges.map((e) => ({
136
+ id: e.id,
137
+ source: e.source,
138
+ target: e.target,
139
+ sourceHandle: e.sourceHandle,
140
+ label: e.label,
141
+ type: 'smoothstep',
142
+ animated: showExecutionStatus ? false : true, // Disable animation when showing execution
143
+ }));
144
+
145
+ return { nodes: rfNodes, edges: rfEdges };
146
+ }, [dsl, nodeTypeMap, selectedNodeId, executionMap, showExecutionStatus]);
147
+
148
+ // Set viewport once before first paint: horizontal center + vertical top padding
149
+ useLayoutEffect(() => {
150
+ if (!hasSetViewport.current && containerRef.current && nodes.length > 0) {
151
+ const containerWidth = containerRef.current.clientWidth;
152
+ let minX = Infinity,
153
+ maxX = -Infinity;
154
+ nodes.forEach((n) => {
155
+ const w = n.measured?.width || 140;
156
+ minX = Math.min(minX, n.position.x);
157
+ maxX = Math.max(maxX, n.position.x + w);
158
+ });
159
+ const contentWidth = maxX - minX;
160
+ const x = (containerWidth - contentWidth) / 2 - minX;
161
+ reactFlowInstance.setViewport({ x, y: 0, zoom: 1 });
162
+ hasSetViewport.current = true;
163
+ }
164
+ }, [nodes, reactFlowInstance]);
165
+
166
+ const onNodesChange = useCallback(
167
+ (changes: NodeChange[]) => {
168
+ const currentDsl = dslRef.current;
169
+ if (!currentDsl || !onDslChange) return;
170
+
171
+ // Apply changes to get new node positions
172
+ const updatedRfNodes = applyNodeChanges(changes, nodes);
173
+
174
+ // Check if any position actually changed
175
+ const positionChanged = changes.some(
176
+ (c) => c.type === 'position' && c.position,
177
+ );
178
+ const removeChanges = changes.filter((c) => c.type === 'remove');
179
+
180
+ if (removeChanges.length > 0) {
181
+ const removedIds = new Set(
182
+ removeChanges.map((c) => (c as any).id as string),
183
+ );
184
+ const newNodes = currentDsl.nodes.filter((n) => !removedIds.has(n.id));
185
+ const newEdges = currentDsl.edges.filter(
186
+ (e) => !removedIds.has(e.source) && !removedIds.has(e.target),
187
+ );
188
+ onDslChange({ nodes: newNodes, edges: newEdges });
189
+ } else if (positionChanged) {
190
+ const newNodes = currentDsl.nodes.map((n) => {
191
+ const rfNode = updatedRfNodes.find((rn) => rn.id === n.id);
192
+ if (rfNode && rfNode.position) {
193
+ // Reverse transpose: display (x,y) → stored (y,x) with scale reversal
194
+ return {
195
+ ...n,
196
+ position: {
197
+ x: (rfNode.position.y - 20) / 0.5,
198
+ y: rfNode.position.x / 1.5,
199
+ },
200
+ };
201
+ }
202
+ return n;
203
+ });
204
+ onDslChange({ ...currentDsl, nodes: newNodes });
205
+ }
206
+ },
207
+ [nodes, onDslChange],
208
+ );
209
+
210
+ const onEdgesChange = useCallback(
211
+ (changes: EdgeChange[]) => {
212
+ const currentDsl = dslRef.current;
213
+ if (!currentDsl || !onDslChange) return;
214
+
215
+ const removeChanges = changes.filter((c) => c.type === 'remove');
216
+ if (removeChanges.length > 0) {
217
+ const removedIds = new Set(
218
+ removeChanges.map((c) => (c as any).id as string),
219
+ );
220
+ const newEdges = currentDsl.edges.filter((e) => !removedIds.has(e.id));
221
+ onDslChange({ ...currentDsl, edges: newEdges });
222
+ }
223
+ },
224
+ [onDslChange],
225
+ );
226
+
227
+ const onConnect = useCallback(
228
+ (connection: Connection) => {
229
+ const currentDsl = dslRef.current;
230
+ if (!currentDsl || !onDslChange) return;
231
+
232
+ const newEdge = {
233
+ id: `e-${connection.source}-${connection.target}-${Date.now()}`,
234
+ source: connection.source!,
235
+ target: connection.target!,
236
+ sourceHandle: connection.sourceHandle || undefined,
237
+ label: undefined,
238
+ };
239
+ onDslChange({
240
+ ...currentDsl,
241
+ edges: [...currentDsl.edges, newEdge],
242
+ });
243
+ },
244
+ [onDslChange],
245
+ );
246
+
247
+ const onDragOver = useCallback((event: React.DragEvent) => {
248
+ event.preventDefault();
249
+ event.dataTransfer.dropEffect = 'move';
250
+ }, []);
251
+
252
+ const onDrop = useCallback(
253
+ (event: React.DragEvent) => {
254
+ event.preventDefault();
255
+ const currentDsl = dslRef.current;
256
+ if (!onDslChange) return;
257
+
258
+ const data = event.dataTransfer.getData('application/workflow-node-type');
259
+ if (!data) return;
260
+
261
+ let nodeType: WorkflowNodeType;
262
+ try {
263
+ nodeType = JSON.parse(data);
264
+ } catch {
265
+ return;
266
+ }
267
+
268
+ const flowPosition = reactFlowInstance.screenToFlowPosition({
269
+ x: event.clientX,
270
+ y: event.clientY,
271
+ });
272
+ // Reverse transpose: display (x,y) → stored (y,x) with scale reversal
273
+ const position = {
274
+ x: (flowPosition.y - 20) / 0.5,
275
+ y: flowPosition.x / 1.5,
276
+ };
277
+
278
+ // Initialize config with default values from configSchema
279
+ const initialConfig: Record<string, any> = {};
280
+ const schema = nodeType.configSchema as any;
281
+ if (schema?.properties) {
282
+ for (const [key, prop] of Object.entries<any>(schema.properties)) {
283
+ if (prop.type === 'string') initialConfig[key] = '';
284
+ else if (prop.type === 'number') initialConfig[key] = undefined;
285
+ else if (prop.type === 'boolean') initialConfig[key] = false;
286
+ else if (prop.type === 'array') initialConfig[key] = [];
287
+ else if (prop.type === 'object') initialConfig[key] = {};
288
+ }
289
+ }
290
+
291
+ const newNode = {
292
+ id: crypto.randomUUID(),
293
+ type: nodeType.type,
294
+ label: nodeType.label,
295
+ position,
296
+ config: initialConfig,
297
+ };
298
+
299
+ const newDsl: WorkflowDSL = currentDsl
300
+ ? { ...currentDsl, nodes: [...currentDsl.nodes, newNode] }
301
+ : { nodes: [newNode], edges: [] };
302
+
303
+ onDslChange(newDsl);
304
+ },
305
+ [reactFlowInstance, onDslChange],
306
+ );
307
+
308
+ if (!dsl || dsl.nodes.length === 0) {
309
+ return (
310
+ <div
311
+ style={{ width: '100%', height: '100%' }}
312
+ onDragOver={onDragOver}
313
+ onDrop={onDrop}
314
+ >
315
+ <ReactFlow
316
+ nodes={[]}
317
+ edges={[]}
318
+ nodeTypes={nodeTypes}
319
+ fitView
320
+ onDragOver={onDragOver}
321
+ onDrop={onDrop}
322
+ >
323
+ <Background />
324
+ <Controls showInteractive={false} />
325
+ </ReactFlow>
326
+ </div>
327
+ );
328
+ }
329
+
330
+ return (
331
+ <div ref={containerRef} style={{ width: '100%', height: '100%' }}>
332
+ <ReactFlow
333
+ nodes={nodes}
334
+ edges={edges}
335
+ nodeTypes={nodeTypes}
336
+ onNodesChange={onNodesChange}
337
+ onEdgesChange={onEdgesChange}
338
+ onConnect={onConnect}
339
+ onDragOver={onDragOver}
340
+ onDrop={onDrop}
341
+ nodesDraggable={!showExecutionStatus}
342
+ nodesConnectable={!showExecutionStatus}
343
+ elementsSelectable={true}
344
+ connectionMode={ConnectionMode.Loose}
345
+ deleteKeyCode={['Backspace', 'Delete']}
346
+ panOnScroll={true}
347
+ zoomOnScroll={false}
348
+ zoomOnPinch={false}
349
+ zoomOnDoubleClick={false}
350
+ onNodeClick={(_event, node) => {
351
+ onNodeClick?.(node.id);
352
+ }}
353
+ onPaneClick={() => {
354
+ onNodeClick?.('');
355
+ }}
356
+ >
357
+ <Background />
358
+ <Controls showInteractive={false} />
359
+ <MiniMap
360
+ nodeColor={(node) => {
361
+ const category = (node.data as any)?.category || 'ACTION';
362
+ const colors: Record<string, string> = {
363
+ TRIGGER: '#91caff',
364
+ ACTION: '#b7eb8f',
365
+ CONDITION: '#ffd591',
366
+ LOOP: '#d3adf7',
367
+ APPROVAL: '#ffa39e',
368
+ SUB_WORKFLOW: '#adc6ff',
369
+ };
370
+ return colors[category] || '#b7eb8f';
371
+ }}
372
+ nodeStrokeWidth={3}
373
+ maskColor="rgba(240, 240, 240, 0.6)"
374
+ zoomable
375
+ pannable
376
+ />
377
+ </ReactFlow>
378
+ </div>
379
+ );
380
+ }
381
+
382
+ export function EnhancedFlowRenderer(props: EnhancedFlowRendererProps) {
383
+ return (
384
+ <ReactFlowProvider>
385
+ <EnhancedFlowCanvas {...props} />
386
+ </ReactFlowProvider>
387
+ );
388
+ }
@@ -0,0 +1,229 @@
1
+ import React, { memo } from 'react';
2
+ import { Handle, Position, type NodeProps } from '@xyflow/react';
3
+ import {
4
+ CheckCircleOutlined,
5
+ ClockCircleOutlined,
6
+ CloseCircleOutlined,
7
+ LoadingOutlined,
8
+ } from '@ant-design/icons';
9
+
10
+ const CATEGORY_COLORS: Record<string, { bg: string; border: string }> = {
11
+ TRIGGER: { bg: '#e6f7ff', border: '#91caff' },
12
+ ACTION: { bg: '#f6ffed', border: '#b7eb8f' },
13
+ CONDITION: { bg: '#fff7e6', border: '#ffd591' },
14
+ LOOP: { bg: '#f9f0ff', border: '#d3adf7' },
15
+ APPROVAL: { bg: '#fff1f0', border: '#ffa39e' },
16
+ SUB_WORKFLOW: { bg: '#f0f5ff', border: '#adc6ff' },
17
+ };
18
+
19
+ // Status-specific background colors (overlay on top of category colors)
20
+ const STATUS_COLORS: Record<string, string> = {
21
+ COMPLETED: '#f6ffed', // light green
22
+ RUNNING: '#e6f7ff', // light blue
23
+ FAILED: '#fff1f0', // light red
24
+ PENDING: '#fffbe6', // light yellow
25
+ NOT_EXECUTED: '#fafafa', // gray
26
+ };
27
+
28
+ export interface ExecutionStatusNodeData {
29
+ label: string;
30
+ category: string;
31
+ isSelected: boolean;
32
+ readonly?: boolean;
33
+ executionStatus?:
34
+ | 'PENDING'
35
+ | 'RUNNING'
36
+ | 'COMPLETED'
37
+ | 'FAILED'
38
+ | 'NOT_EXECUTED';
39
+ duration?: string; // e.g., "2.5s"
40
+ error?: string;
41
+ [key: string]: unknown;
42
+ }
43
+
44
+ function formatDuration(
45
+ startedAt: string | null,
46
+ finishedAt: string | null,
47
+ ): string {
48
+ if (!startedAt) return '';
49
+ const endTime = finishedAt ? new Date(finishedAt).getTime() : Date.now();
50
+ const ms = endTime - new Date(startedAt).getTime();
51
+ if (ms < 1000) return `${ms}ms`;
52
+ const s = ms / 1000;
53
+ if (s < 60) return `${s.toFixed(1)}s`;
54
+ const m = Math.floor(s / 60);
55
+ const rem = s % 60;
56
+ return rem > 0 ? `${m}m ${rem.toFixed(0)}s` : `${m}m`;
57
+ }
58
+
59
+ function getStatusIcon(status?: string) {
60
+ switch (status) {
61
+ case 'COMPLETED':
62
+ return <CheckCircleOutlined style={{ color: '#52c41a', fontSize: 16 }} />;
63
+ case 'RUNNING':
64
+ return <LoadingOutlined style={{ color: '#1677ff', fontSize: 16 }} />;
65
+ case 'FAILED':
66
+ return <CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: 16 }} />;
67
+ case 'NOT_EXECUTED':
68
+ return <ClockCircleOutlined style={{ color: '#bfbfbf', fontSize: 16 }} />;
69
+ case 'PENDING':
70
+ default:
71
+ return <ClockCircleOutlined style={{ color: '#faad14', fontSize: 16 }} />;
72
+ }
73
+ }
74
+
75
+ export const ExecutionStatusNode = memo(({ data }: NodeProps) => {
76
+ const nodeData = data as ExecutionStatusNodeData;
77
+ const colors = CATEGORY_COLORS[nodeData.category] || CATEGORY_COLORS.ACTION;
78
+ const isNotExecuted = nodeData.executionStatus === 'NOT_EXECUTED';
79
+
80
+ // If there's execution status, use status color as background; otherwise use category color
81
+ const bgColor = nodeData.executionStatus
82
+ ? STATUS_COLORS[nodeData.executionStatus]
83
+ : colors.bg;
84
+
85
+ const borderColor = nodeData.isSelected
86
+ ? '#1677ff'
87
+ : isNotExecuted
88
+ ? '#d9d9d9'
89
+ : colors.border;
90
+ const borderStyle = nodeData.isSelected
91
+ ? '2px solid'
92
+ : isNotExecuted
93
+ ? '1.5px dashed'
94
+ : '1px solid';
95
+
96
+ return (
97
+ <div
98
+ style={{
99
+ background: bgColor,
100
+ border: `${borderStyle} ${borderColor}`,
101
+ borderRadius: 8,
102
+ padding: '8px 12px',
103
+ fontSize: 13,
104
+ minWidth: 140,
105
+ textAlign: 'center',
106
+ opacity: isNotExecuted ? 0.6 : 1,
107
+ boxShadow: nodeData.isSelected
108
+ ? '0 0 0 2px rgba(22,119,255,0.2)'
109
+ : !isNotExecuted && nodeData.executionStatus
110
+ ? '0 2px 8px rgba(0,0,0,0.15)'
111
+ : undefined,
112
+ position: 'relative',
113
+ transition: 'all 0.2s ease',
114
+ }}
115
+ >
116
+ {!nodeData.readonly && (
117
+ <Handle
118
+ type="target"
119
+ position={Position.Top}
120
+ style={{
121
+ width: 14,
122
+ height: 14,
123
+ background: '#fff',
124
+ border: '2px solid #555',
125
+ cursor: 'crosshair',
126
+ }}
127
+ />
128
+ )}
129
+ {nodeData.readonly && (
130
+ <Handle
131
+ type="target"
132
+ position={Position.Top}
133
+ style={{
134
+ width: 0,
135
+ height: 0,
136
+ border: 'none',
137
+ background: 'transparent',
138
+ visibility: 'hidden',
139
+ }}
140
+ />
141
+ )}
142
+
143
+ {/* Status icon in top-right corner */}
144
+ {nodeData.executionStatus && (
145
+ <div
146
+ style={{
147
+ position: 'absolute',
148
+ top: -8,
149
+ right: -8,
150
+ display: 'flex',
151
+ alignItems: 'center',
152
+ justifyContent: 'center',
153
+ width: 24,
154
+ height: 24,
155
+ background: '#fff',
156
+ borderRadius: '50%',
157
+ border: '1px solid #ddd',
158
+ }}
159
+ >
160
+ {getStatusIcon(nodeData.executionStatus)}
161
+ </div>
162
+ )}
163
+
164
+ <div style={{ fontWeight: 500, marginBottom: nodeData.duration ? 4 : 0 }}>
165
+ {nodeData.label}
166
+ </div>
167
+
168
+ {/* Duration badge */}
169
+ {nodeData.duration && (
170
+ <div
171
+ style={{
172
+ fontSize: 11,
173
+ color: '#666',
174
+ fontFamily: 'monospace',
175
+ marginBottom: 4,
176
+ }}
177
+ >
178
+ {nodeData.duration}
179
+ </div>
180
+ )}
181
+
182
+ {/* Execution status text */}
183
+ {nodeData.executionStatus &&
184
+ nodeData.executionStatus !== 'NOT_EXECUTED' && (
185
+ <div
186
+ style={{
187
+ fontSize: 11,
188
+ color: nodeData.executionStatus === 'FAILED' ? '#ff4d4f' : '#666',
189
+ fontWeight: 500,
190
+ }}
191
+ >
192
+ {nodeData.executionStatus === 'COMPLETED' && 'Completed'}
193
+ {nodeData.executionStatus === 'RUNNING' && 'Running...'}
194
+ {nodeData.executionStatus === 'FAILED' && 'Failed'}
195
+ {nodeData.executionStatus === 'PENDING' && 'Pending'}
196
+ </div>
197
+ )}
198
+
199
+ {!nodeData.readonly && (
200
+ <Handle
201
+ type="source"
202
+ position={Position.Bottom}
203
+ style={{
204
+ width: 14,
205
+ height: 14,
206
+ background: '#fff',
207
+ border: '2px solid #555',
208
+ cursor: 'crosshair',
209
+ }}
210
+ />
211
+ )}
212
+ {nodeData.readonly && (
213
+ <Handle
214
+ type="source"
215
+ position={Position.Bottom}
216
+ style={{
217
+ width: 0,
218
+ height: 0,
219
+ border: 'none',
220
+ background: 'transparent',
221
+ visibility: 'hidden',
222
+ }}
223
+ />
224
+ )}
225
+ </div>
226
+ );
227
+ });
228
+
229
+ ExecutionStatusNode.displayName = 'ExecutionStatusNode';
@@ -278,6 +278,7 @@ function FlowCanvas({
278
278
  elementsSelectable={true}
279
279
  connectionMode={ConnectionMode.Loose}
280
280
  deleteKeyCode={['Backspace', 'Delete']}
281
+ panOnScroll={true}
281
282
  zoomOnScroll={false}
282
283
  zoomOnPinch={false}
283
284
  zoomOnDoubleClick={false}