@authhero/react-admin 0.10.0
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/.eslintrc.js +21 -0
- package/.vercelignore +4 -0
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/index.html +125 -0
- package/package.json +61 -0
- package/prettier.config.js +1 -0
- package/public/favicon.ico +0 -0
- package/public/manifest.json +15 -0
- package/src/App.spec.tsx +42 -0
- package/src/App.tsx +232 -0
- package/src/AuthCallback.tsx +138 -0
- package/src/Layout.tsx +12 -0
- package/src/TenantsApp.tsx +115 -0
- package/src/auth0DataProvider.ts +1242 -0
- package/src/authProvider.ts +521 -0
- package/src/components/CertificateErrorDialog.tsx +116 -0
- package/src/components/DomainSelector.tsx +401 -0
- package/src/components/TenantAppBar.tsx +83 -0
- package/src/components/TenantLayout.tsx +25 -0
- package/src/components/TenantsAppBar.tsx +21 -0
- package/src/components/TenantsLayout.tsx +28 -0
- package/src/components/activity/ActivityDashboard.tsx +381 -0
- package/src/components/activity/index.ts +1 -0
- package/src/components/branding/BrandingList.tsx +0 -0
- package/src/components/branding/BrandingShow.tsx +0 -0
- package/src/components/branding/ThemesTab.tsx +286 -0
- package/src/components/branding/edit.tsx +149 -0
- package/src/components/branding/hooks/useThemesData.ts +123 -0
- package/src/components/branding/index.ts +2 -0
- package/src/components/branding/list.tsx +12 -0
- package/src/components/clients/create.tsx +12 -0
- package/src/components/clients/edit.tsx +1285 -0
- package/src/components/clients/index.ts +3 -0
- package/src/components/clients/list.tsx +37 -0
- package/src/components/common/DateAgo.tsx +6 -0
- package/src/components/common/JsonOutput.tsx +26 -0
- package/src/components/common/index.ts +1 -0
- package/src/components/connections/create.tsx +35 -0
- package/src/components/connections/edit.tsx +212 -0
- package/src/components/connections/index.ts +3 -0
- package/src/components/connections/list.tsx +15 -0
- package/src/components/custom-domains/create.tsx +26 -0
- package/src/components/custom-domains/edit.tsx +101 -0
- package/src/components/custom-domains/index.ts +3 -0
- package/src/components/custom-domains/list.tsx +16 -0
- package/src/components/flows/create.tsx +30 -0
- package/src/components/flows/edit.tsx +238 -0
- package/src/components/flows/index.ts +3 -0
- package/src/components/flows/list.tsx +15 -0
- package/src/components/forms/FlowEditor.tsx +1363 -0
- package/src/components/forms/NodeEditor.tsx +1119 -0
- package/src/components/forms/RichTextEditor.tsx +145 -0
- package/src/components/forms/create.tsx +30 -0
- package/src/components/forms/edit.tsx +256 -0
- package/src/components/forms/index.ts +3 -0
- package/src/components/forms/list.tsx +16 -0
- package/src/components/hooks/create.tsx +96 -0
- package/src/components/hooks/edit.tsx +114 -0
- package/src/components/hooks/index.ts +3 -0
- package/src/components/hooks/list.tsx +17 -0
- package/src/components/listActions/PostListActions.tsx +10 -0
- package/src/components/logs/LogIcon.tsx +32 -0
- package/src/components/logs/LogShow.tsx +82 -0
- package/src/components/logs/LogType.tsx +38 -0
- package/src/components/logs/index.ts +4 -0
- package/src/components/logs/list.tsx +41 -0
- package/src/components/organizations/create.tsx +13 -0
- package/src/components/organizations/edit.tsx +682 -0
- package/src/components/organizations/index.ts +3 -0
- package/src/components/organizations/list.tsx +21 -0
- package/src/components/resource-servers/create.tsx +87 -0
- package/src/components/resource-servers/edit.tsx +121 -0
- package/src/components/resource-servers/index.ts +3 -0
- package/src/components/resource-servers/list.tsx +47 -0
- package/src/components/roles/create.tsx +12 -0
- package/src/components/roles/edit.tsx +426 -0
- package/src/components/roles/index.ts +3 -0
- package/src/components/roles/list.tsx +24 -0
- package/src/components/sessions/edit.tsx +101 -0
- package/src/components/sessions/index.ts +3 -0
- package/src/components/sessions/list.tsx +20 -0
- package/src/components/sessions/show.tsx +113 -0
- package/src/components/settings/edit.tsx +236 -0
- package/src/components/settings/index.ts +2 -0
- package/src/components/settings/list.tsx +14 -0
- package/src/components/tenants/create.tsx +20 -0
- package/src/components/tenants/edit.tsx +54 -0
- package/src/components/tenants/index.ts +2 -0
- package/src/components/tenants/list.tsx +67 -0
- package/src/components/themes/edit.tsx +200 -0
- package/src/components/themes/index.ts +2 -0
- package/src/components/themes/list.tsx +12 -0
- package/src/components/users/create.tsx +144 -0
- package/src/components/users/edit.tsx +1711 -0
- package/src/components/users/index.ts +3 -0
- package/src/components/users/list.tsx +35 -0
- package/src/data.json +121 -0
- package/src/dataProvider.ts +97 -0
- package/src/index.tsx +106 -0
- package/src/lib/logs.ts +21 -0
- package/src/types/reactflow.d.ts +86 -0
- package/src/utils/domainUtils.ts +169 -0
- package/src/utils/tokenUtils.ts +75 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +37 -0
- package/tsconfig.node.json +10 -0
- package/vercel.json +17 -0
- package/vite.config.ts +30 -0
|
@@ -0,0 +1,1363 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ReactFlow,
|
|
4
|
+
Node,
|
|
5
|
+
Edge,
|
|
6
|
+
MarkerType,
|
|
7
|
+
useNodesState,
|
|
8
|
+
useEdgesState,
|
|
9
|
+
Background,
|
|
10
|
+
Controls,
|
|
11
|
+
Panel,
|
|
12
|
+
NodeTypes,
|
|
13
|
+
Handle,
|
|
14
|
+
Position,
|
|
15
|
+
Connection,
|
|
16
|
+
addEdge,
|
|
17
|
+
} from "@xyflow/react";
|
|
18
|
+
import "@xyflow/react/dist/style.css";
|
|
19
|
+
import { Box, Typography, Alert, GlobalStyles } from "@mui/material";
|
|
20
|
+
import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline";
|
|
21
|
+
import { useTheme } from "@mui/material/styles";
|
|
22
|
+
|
|
23
|
+
// Import the NodeEditor component
|
|
24
|
+
import NodeEditor from "./NodeEditor";
|
|
25
|
+
|
|
26
|
+
// Type definitions
|
|
27
|
+
export interface ComponentConfig {
|
|
28
|
+
id: string;
|
|
29
|
+
type:
|
|
30
|
+
| "RICH_TEXT"
|
|
31
|
+
| "LEGAL"
|
|
32
|
+
| "NEXT_BUTTON"
|
|
33
|
+
| "TEXT"
|
|
34
|
+
| "EMAIL"
|
|
35
|
+
| "NUMBER"
|
|
36
|
+
| "PHONE";
|
|
37
|
+
required?: boolean;
|
|
38
|
+
config?: {
|
|
39
|
+
content?: string;
|
|
40
|
+
text?: string;
|
|
41
|
+
label?: string;
|
|
42
|
+
placeholder?: string;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RouterRule {
|
|
47
|
+
id: string;
|
|
48
|
+
alias?: string;
|
|
49
|
+
condition: any;
|
|
50
|
+
next_node: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface FlowNodeData {
|
|
54
|
+
id: string;
|
|
55
|
+
type: "STEP" | "FLOW" | "ROUTER";
|
|
56
|
+
alias?: string;
|
|
57
|
+
coordinates?: { x: number; y: number };
|
|
58
|
+
config?: {
|
|
59
|
+
next_node?: string;
|
|
60
|
+
components?: ComponentConfig[];
|
|
61
|
+
flow_id?: string;
|
|
62
|
+
// Router-specific config
|
|
63
|
+
rules?: RouterRule[];
|
|
64
|
+
fallback?: string;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface StartNode {
|
|
69
|
+
next_node?: string;
|
|
70
|
+
coordinates?: { x: number; y: number };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface EndingNode {
|
|
74
|
+
resume_flow?: boolean;
|
|
75
|
+
coordinates?: { x: number; y: number };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface FlowChoice {
|
|
79
|
+
id: string;
|
|
80
|
+
name: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface FlowEditorProps {
|
|
84
|
+
nodes: FlowNodeData[];
|
|
85
|
+
start?: StartNode;
|
|
86
|
+
ending?: EndingNode;
|
|
87
|
+
flows?: FlowChoice[];
|
|
88
|
+
onNodeSelect?: (nodeId: string) => void;
|
|
89
|
+
onNodeUpdate?: (
|
|
90
|
+
nodeId: string,
|
|
91
|
+
updates: Partial<FlowNodeData> | Partial<StartNode> | Partial<EndingNode>,
|
|
92
|
+
) => void;
|
|
93
|
+
onError?: (error: string) => void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface CustomNodeData extends Record<string, unknown> {
|
|
97
|
+
label?: string;
|
|
98
|
+
type?: string;
|
|
99
|
+
next?: string;
|
|
100
|
+
components?: ComponentConfig[];
|
|
101
|
+
flowId?: string;
|
|
102
|
+
orphaned?: boolean;
|
|
103
|
+
invalidConnection?: boolean;
|
|
104
|
+
resumeFlow?: string;
|
|
105
|
+
// Router-specific data
|
|
106
|
+
rules?: RouterRule[];
|
|
107
|
+
fallback?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Constants moved outside component
|
|
111
|
+
const DEFAULT_EDGE_OPTIONS = {
|
|
112
|
+
type: "smoothstep" as const,
|
|
113
|
+
animated: true,
|
|
114
|
+
style: { stroke: "#1976d2", strokeWidth: 2 },
|
|
115
|
+
markerEnd: {
|
|
116
|
+
type: MarkerType.ArrowClosed,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const FLOW_CONFIG = {
|
|
121
|
+
fitViewOptions: { padding: 0.2 },
|
|
122
|
+
minZoom: 0.5,
|
|
123
|
+
maxZoom: 1.5,
|
|
124
|
+
} as const;
|
|
125
|
+
|
|
126
|
+
// Utility functions
|
|
127
|
+
const truncateText = (text: string, maxLength: number = 20): string => {
|
|
128
|
+
const cleanText = text.replace(/<[^>]*>/g, "");
|
|
129
|
+
return cleanText.length > maxLength
|
|
130
|
+
? `${cleanText.substring(0, maxLength)}...`
|
|
131
|
+
: cleanText;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const getNodePosition = (
|
|
135
|
+
coordinates?: { x: number; y: number },
|
|
136
|
+
defaultX: number = 200,
|
|
137
|
+
defaultY: number = 200,
|
|
138
|
+
) => ({
|
|
139
|
+
x: coordinates?.x ?? defaultX,
|
|
140
|
+
y: coordinates?.y ?? defaultY,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Helper to determine target handle based on node type
|
|
144
|
+
const getTargetHandle = (target: string, nodes: FlowNodeData[]): string => {
|
|
145
|
+
if (target === "end") return "end-input";
|
|
146
|
+
const targetNode = nodes.find((n) => n.id === target);
|
|
147
|
+
if (!targetNode) return "step-input";
|
|
148
|
+
switch (targetNode.type) {
|
|
149
|
+
case "FLOW":
|
|
150
|
+
return "flow-input";
|
|
151
|
+
case "ROUTER":
|
|
152
|
+
return "router-input";
|
|
153
|
+
default:
|
|
154
|
+
return "step-input";
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Custom Node Components
|
|
159
|
+
const StartNodeComponent = React.memo(({ data }: { data: CustomNodeData }) => (
|
|
160
|
+
<Box sx={{ padding: "8px" }}>
|
|
161
|
+
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
|
|
162
|
+
Start
|
|
163
|
+
</Typography>
|
|
164
|
+
<Box sx={{ mt: 0.5, display: "flex", alignItems: "center", gap: 1 }}>
|
|
165
|
+
<Box
|
|
166
|
+
component="span"
|
|
167
|
+
sx={{
|
|
168
|
+
width: 18,
|
|
169
|
+
height: 18,
|
|
170
|
+
display: "flex",
|
|
171
|
+
alignItems: "center",
|
|
172
|
+
justifyContent: "center",
|
|
173
|
+
border: "1px solid #4CAF50",
|
|
174
|
+
borderRadius: "50%",
|
|
175
|
+
color: "#4CAF50",
|
|
176
|
+
fontSize: "14px",
|
|
177
|
+
}}
|
|
178
|
+
aria-label="Start node indicator"
|
|
179
|
+
>
|
|
180
|
+
▶
|
|
181
|
+
</Box>
|
|
182
|
+
</Box>
|
|
183
|
+
<Handle
|
|
184
|
+
type="source"
|
|
185
|
+
position={Position.Right}
|
|
186
|
+
id="start-output"
|
|
187
|
+
style={{ background: "#4CAF50" }}
|
|
188
|
+
/>
|
|
189
|
+
</Box>
|
|
190
|
+
));
|
|
191
|
+
|
|
192
|
+
const ComponentRenderer = React.memo(
|
|
193
|
+
({ component }: { component: ComponentConfig }) => {
|
|
194
|
+
switch (component.type) {
|
|
195
|
+
case "RICH_TEXT":
|
|
196
|
+
return (
|
|
197
|
+
<Box
|
|
198
|
+
sx={{
|
|
199
|
+
backgroundColor: "#f8f9fa",
|
|
200
|
+
p: 1,
|
|
201
|
+
borderRadius: "4px",
|
|
202
|
+
position: "relative",
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
<Box
|
|
206
|
+
component="span"
|
|
207
|
+
sx={{
|
|
208
|
+
position: "absolute",
|
|
209
|
+
top: 0,
|
|
210
|
+
right: 0,
|
|
211
|
+
fontSize: "16px",
|
|
212
|
+
color: "#1976d2",
|
|
213
|
+
p: 0.5,
|
|
214
|
+
}}
|
|
215
|
+
>
|
|
216
|
+
<AddCircleOutlineIcon sx={{ fontSize: 16 }} />
|
|
217
|
+
</Box>
|
|
218
|
+
<Typography
|
|
219
|
+
variant="body2"
|
|
220
|
+
sx={{
|
|
221
|
+
"& u": {
|
|
222
|
+
textDecoration: "underline",
|
|
223
|
+
color: "#1976d2",
|
|
224
|
+
cursor: "pointer",
|
|
225
|
+
},
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
{component.config?.content
|
|
229
|
+
? truncateText(component.config.content, 60)
|
|
230
|
+
: "Rich text content"}
|
|
231
|
+
</Typography>
|
|
232
|
+
</Box>
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
case "LEGAL":
|
|
236
|
+
return (
|
|
237
|
+
<Typography variant="body2" color="text.secondary">
|
|
238
|
+
{component.config?.text
|
|
239
|
+
? `Legal: ${truncateText(component.config.text)}`
|
|
240
|
+
: "Legal checkbox"}
|
|
241
|
+
</Typography>
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
case "NEXT_BUTTON":
|
|
245
|
+
return (
|
|
246
|
+
<Box
|
|
247
|
+
sx={{
|
|
248
|
+
mt: 1,
|
|
249
|
+
p: 1,
|
|
250
|
+
textAlign: "center",
|
|
251
|
+
bgcolor: "#1976d2",
|
|
252
|
+
color: "white",
|
|
253
|
+
borderRadius: "4px",
|
|
254
|
+
fontSize: "14px",
|
|
255
|
+
}}
|
|
256
|
+
>
|
|
257
|
+
{component.config?.text || "Continue"}
|
|
258
|
+
</Box>
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
default:
|
|
262
|
+
return (
|
|
263
|
+
<Typography variant="body2" color="text.secondary">
|
|
264
|
+
Unknown component: {component.type}
|
|
265
|
+
</Typography>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const StepNodeComponent = React.memo(({ data }: { data: CustomNodeData }) => (
|
|
272
|
+
<Box sx={{ padding: "8px" }}>
|
|
273
|
+
<Handle
|
|
274
|
+
type="target"
|
|
275
|
+
position={Position.Left}
|
|
276
|
+
id="step-input"
|
|
277
|
+
style={{ background: "#1976d2" }}
|
|
278
|
+
/>
|
|
279
|
+
|
|
280
|
+
<Box
|
|
281
|
+
sx={{
|
|
282
|
+
display: "flex",
|
|
283
|
+
justifyContent: "space-between",
|
|
284
|
+
alignItems: "center",
|
|
285
|
+
mb: 1,
|
|
286
|
+
}}
|
|
287
|
+
>
|
|
288
|
+
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
|
|
289
|
+
{data.label || "Step"}
|
|
290
|
+
</Typography>
|
|
291
|
+
<Box sx={{ display: "flex", gap: "4px" }}>
|
|
292
|
+
<Box
|
|
293
|
+
component="span"
|
|
294
|
+
sx={{
|
|
295
|
+
width: 20,
|
|
296
|
+
height: 20,
|
|
297
|
+
display: "flex",
|
|
298
|
+
alignItems: "center",
|
|
299
|
+
justifyContent: "center",
|
|
300
|
+
border: "1px solid #ddd",
|
|
301
|
+
borderRadius: "2px",
|
|
302
|
+
fontSize: "12px",
|
|
303
|
+
color: "#666",
|
|
304
|
+
cursor: "pointer",
|
|
305
|
+
}}
|
|
306
|
+
title="Copy step"
|
|
307
|
+
role="button"
|
|
308
|
+
tabIndex={0}
|
|
309
|
+
>
|
|
310
|
+
⎘
|
|
311
|
+
</Box>
|
|
312
|
+
<Box
|
|
313
|
+
component="span"
|
|
314
|
+
sx={{
|
|
315
|
+
width: 20,
|
|
316
|
+
height: 20,
|
|
317
|
+
display: "flex",
|
|
318
|
+
alignItems: "center",
|
|
319
|
+
justifyContent: "center",
|
|
320
|
+
border: "1px solid #ddd",
|
|
321
|
+
borderRadius: "2px",
|
|
322
|
+
fontSize: "12px",
|
|
323
|
+
color: "#666",
|
|
324
|
+
cursor: "pointer",
|
|
325
|
+
}}
|
|
326
|
+
title="Delete step"
|
|
327
|
+
role="button"
|
|
328
|
+
tabIndex={0}
|
|
329
|
+
>
|
|
330
|
+
✕
|
|
331
|
+
</Box>
|
|
332
|
+
</Box>
|
|
333
|
+
</Box>
|
|
334
|
+
|
|
335
|
+
{data.components && data.components.length > 0 && (
|
|
336
|
+
<Box sx={{ mt: 1 }}>
|
|
337
|
+
{data.components.map((comp) => (
|
|
338
|
+
<Box
|
|
339
|
+
key={comp.id}
|
|
340
|
+
sx={{
|
|
341
|
+
py: 0.5,
|
|
342
|
+
borderBottom:
|
|
343
|
+
comp.type === "NEXT_BUTTON" ? "none" : "1px solid #f0f0f0",
|
|
344
|
+
}}
|
|
345
|
+
>
|
|
346
|
+
<ComponentRenderer component={comp} />
|
|
347
|
+
</Box>
|
|
348
|
+
))}
|
|
349
|
+
</Box>
|
|
350
|
+
)}
|
|
351
|
+
|
|
352
|
+
<Handle
|
|
353
|
+
type="source"
|
|
354
|
+
position={Position.Right}
|
|
355
|
+
id="step-output"
|
|
356
|
+
style={{ background: "#1976d2" }}
|
|
357
|
+
/>
|
|
358
|
+
</Box>
|
|
359
|
+
));
|
|
360
|
+
|
|
361
|
+
const FlowNodeComponent = React.memo(({ data }: { data: CustomNodeData }) => (
|
|
362
|
+
<Box sx={{ padding: "8px" }}>
|
|
363
|
+
<Handle
|
|
364
|
+
type="target"
|
|
365
|
+
position={Position.Left}
|
|
366
|
+
id="flow-input"
|
|
367
|
+
style={{ background: "#1976d2" }}
|
|
368
|
+
/>
|
|
369
|
+
|
|
370
|
+
<Box
|
|
371
|
+
sx={{
|
|
372
|
+
display: "flex",
|
|
373
|
+
justifyContent: "space-between",
|
|
374
|
+
alignItems: "center",
|
|
375
|
+
}}
|
|
376
|
+
>
|
|
377
|
+
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
|
|
378
|
+
Flow
|
|
379
|
+
</Typography>
|
|
380
|
+
</Box>
|
|
381
|
+
<Box sx={{ mt: 1, display: "flex", alignItems: "center", gap: 1 }}>
|
|
382
|
+
<Box
|
|
383
|
+
component="span"
|
|
384
|
+
sx={{
|
|
385
|
+
width: 24,
|
|
386
|
+
height: 24,
|
|
387
|
+
display: "flex",
|
|
388
|
+
alignItems: "center",
|
|
389
|
+
justifyContent: "center",
|
|
390
|
+
border: "1px solid #1976d2",
|
|
391
|
+
borderRadius: "50%",
|
|
392
|
+
color: "#1976d2",
|
|
393
|
+
fontSize: "16px",
|
|
394
|
+
}}
|
|
395
|
+
aria-label="Flow update indicator"
|
|
396
|
+
>
|
|
397
|
+
⟳
|
|
398
|
+
</Box>
|
|
399
|
+
<Typography variant="body2" color="text.secondary">
|
|
400
|
+
{data.flowId ? `Update ${data.flowId}` : "Update metadata"}
|
|
401
|
+
</Typography>
|
|
402
|
+
</Box>
|
|
403
|
+
|
|
404
|
+
<Handle
|
|
405
|
+
type="source"
|
|
406
|
+
position={Position.Right}
|
|
407
|
+
id="flow-output"
|
|
408
|
+
style={{ background: "#1976d2" }}
|
|
409
|
+
/>
|
|
410
|
+
</Box>
|
|
411
|
+
));
|
|
412
|
+
|
|
413
|
+
const RouterNodeComponent = React.memo(({ data }: { data: CustomNodeData }) => {
|
|
414
|
+
const rules = data.rules || [];
|
|
415
|
+
const fallback = data.fallback;
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
<Box sx={{ padding: "8px" }}>
|
|
419
|
+
<Handle
|
|
420
|
+
type="target"
|
|
421
|
+
position={Position.Left}
|
|
422
|
+
id="router-input"
|
|
423
|
+
style={{ background: "#9c27b0" }}
|
|
424
|
+
/>
|
|
425
|
+
|
|
426
|
+
<Box
|
|
427
|
+
sx={{
|
|
428
|
+
display: "flex",
|
|
429
|
+
justifyContent: "space-between",
|
|
430
|
+
alignItems: "center",
|
|
431
|
+
mb: 1,
|
|
432
|
+
}}
|
|
433
|
+
>
|
|
434
|
+
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
|
|
435
|
+
{data.label || "Router"}
|
|
436
|
+
</Typography>
|
|
437
|
+
<Box
|
|
438
|
+
component="span"
|
|
439
|
+
sx={{
|
|
440
|
+
width: 24,
|
|
441
|
+
height: 24,
|
|
442
|
+
display: "flex",
|
|
443
|
+
alignItems: "center",
|
|
444
|
+
justifyContent: "center",
|
|
445
|
+
border: "1px solid #9c27b0",
|
|
446
|
+
borderRadius: "4px",
|
|
447
|
+
color: "#9c27b0",
|
|
448
|
+
fontSize: "14px",
|
|
449
|
+
}}
|
|
450
|
+
aria-label="Router indicator"
|
|
451
|
+
>
|
|
452
|
+
⑂
|
|
453
|
+
</Box>
|
|
454
|
+
</Box>
|
|
455
|
+
|
|
456
|
+
{/* Rules */}
|
|
457
|
+
{rules.length > 0 && (
|
|
458
|
+
<Box sx={{ mt: 1 }}>
|
|
459
|
+
{rules.map((rule, index) => (
|
|
460
|
+
<Box
|
|
461
|
+
key={rule.id}
|
|
462
|
+
sx={{
|
|
463
|
+
display: "flex",
|
|
464
|
+
alignItems: "center",
|
|
465
|
+
justifyContent: "space-between",
|
|
466
|
+
py: 0.5,
|
|
467
|
+
borderBottom: "1px solid #f0f0f0",
|
|
468
|
+
position: "relative",
|
|
469
|
+
}}
|
|
470
|
+
>
|
|
471
|
+
<Typography variant="body2" sx={{ fontSize: "12px", pr: 2 }}>
|
|
472
|
+
{rule.alias || `Rule ${index + 1}`}
|
|
473
|
+
</Typography>
|
|
474
|
+
<Handle
|
|
475
|
+
type="source"
|
|
476
|
+
position={Position.Right}
|
|
477
|
+
id={`router-rule-${rule.id}`}
|
|
478
|
+
style={{
|
|
479
|
+
background: "#9c27b0",
|
|
480
|
+
top: "auto",
|
|
481
|
+
right: -8,
|
|
482
|
+
}}
|
|
483
|
+
/>
|
|
484
|
+
</Box>
|
|
485
|
+
))}
|
|
486
|
+
</Box>
|
|
487
|
+
)}
|
|
488
|
+
|
|
489
|
+
{/* Fallback */}
|
|
490
|
+
<Box
|
|
491
|
+
sx={{
|
|
492
|
+
mt: 1,
|
|
493
|
+
pt: 1,
|
|
494
|
+
borderTop: "1px dashed #ddd",
|
|
495
|
+
display: "flex",
|
|
496
|
+
alignItems: "center",
|
|
497
|
+
justifyContent: "space-between",
|
|
498
|
+
position: "relative",
|
|
499
|
+
}}
|
|
500
|
+
>
|
|
501
|
+
<Typography
|
|
502
|
+
variant="body2"
|
|
503
|
+
sx={{ fontSize: "12px", color: "text.secondary", pr: 2 }}
|
|
504
|
+
>
|
|
505
|
+
Default
|
|
506
|
+
</Typography>
|
|
507
|
+
<Handle
|
|
508
|
+
type="source"
|
|
509
|
+
position={Position.Right}
|
|
510
|
+
id="router-fallback"
|
|
511
|
+
style={{ background: "#9c27b0" }}
|
|
512
|
+
/>
|
|
513
|
+
</Box>
|
|
514
|
+
</Box>
|
|
515
|
+
);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const EndNodeComponent = React.memo(({ data }: { data: CustomNodeData }) => (
|
|
519
|
+
<Box sx={{ padding: "8px" }}>
|
|
520
|
+
<Handle
|
|
521
|
+
type="target"
|
|
522
|
+
position={Position.Left}
|
|
523
|
+
id="end-input"
|
|
524
|
+
style={{ background: "#f44336" }}
|
|
525
|
+
/>
|
|
526
|
+
|
|
527
|
+
<Typography variant="subtitle1" sx={{ fontWeight: "bold" }}>
|
|
528
|
+
Ending screen
|
|
529
|
+
</Typography>
|
|
530
|
+
<Typography
|
|
531
|
+
variant="body2"
|
|
532
|
+
sx={{ color: (theme) => theme.palette.text.secondary }}
|
|
533
|
+
>
|
|
534
|
+
{data.resumeFlow === "Yes" ? "Resume authentication flow" : "End flow"}
|
|
535
|
+
</Typography>
|
|
536
|
+
</Box>
|
|
537
|
+
));
|
|
538
|
+
|
|
539
|
+
// Node types configuration
|
|
540
|
+
const nodeTypes: NodeTypes = {
|
|
541
|
+
start: StartNodeComponent,
|
|
542
|
+
step: StepNodeComponent,
|
|
543
|
+
flow: FlowNodeComponent,
|
|
544
|
+
router: RouterNodeComponent,
|
|
545
|
+
end: EndNodeComponent,
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const FlowEditor: React.FC<FlowEditorProps> = ({
|
|
549
|
+
nodes = [],
|
|
550
|
+
start,
|
|
551
|
+
ending,
|
|
552
|
+
flows,
|
|
553
|
+
onNodeSelect,
|
|
554
|
+
onNodeUpdate,
|
|
555
|
+
onError,
|
|
556
|
+
}) => {
|
|
557
|
+
const theme = useTheme();
|
|
558
|
+
|
|
559
|
+
// Theme-aware node styles
|
|
560
|
+
const NODE_STYLES = React.useMemo(() => {
|
|
561
|
+
// Use original light mode colors, and only override for dark mode
|
|
562
|
+
const isDark = theme.palette.mode === "dark";
|
|
563
|
+
return {
|
|
564
|
+
start: {
|
|
565
|
+
background: isDark ? theme.palette.grey[800] : "#f5f5f5",
|
|
566
|
+
border: isDark
|
|
567
|
+
? `1px solid ${theme.palette.divider}`
|
|
568
|
+
: "1px solid #e0e0e0",
|
|
569
|
+
borderRadius: "4px",
|
|
570
|
+
padding: "12px",
|
|
571
|
+
color: theme.palette.text.primary,
|
|
572
|
+
minWidth: "180px",
|
|
573
|
+
boxShadow: theme.shadows[1],
|
|
574
|
+
},
|
|
575
|
+
step: {
|
|
576
|
+
background: isDark ? theme.palette.grey[800] : "#f5f5f5",
|
|
577
|
+
border: isDark
|
|
578
|
+
? `1px solid ${theme.palette.divider}`
|
|
579
|
+
: "1px solid #e0e0e0",
|
|
580
|
+
borderRadius: "4px",
|
|
581
|
+
padding: "12px",
|
|
582
|
+
color: theme.palette.text.primary,
|
|
583
|
+
minWidth: "280px",
|
|
584
|
+
maxWidth: "320px",
|
|
585
|
+
boxShadow: theme.shadows[1],
|
|
586
|
+
},
|
|
587
|
+
flow: {
|
|
588
|
+
background: isDark ? theme.palette.grey[800] : "#f5f5f5",
|
|
589
|
+
border: isDark
|
|
590
|
+
? `1px solid ${theme.palette.divider}`
|
|
591
|
+
: "1px solid #e0e0e0",
|
|
592
|
+
borderRadius: "4px",
|
|
593
|
+
padding: "12px",
|
|
594
|
+
color: theme.palette.text.primary,
|
|
595
|
+
minWidth: "180px",
|
|
596
|
+
boxShadow: theme.shadows[1],
|
|
597
|
+
},
|
|
598
|
+
router: {
|
|
599
|
+
background: isDark ? theme.palette.grey[800] : "#f5f5f5",
|
|
600
|
+
border: isDark
|
|
601
|
+
? `1px solid ${theme.palette.divider}`
|
|
602
|
+
: "1px solid #e0e0e0",
|
|
603
|
+
borderRadius: "4px",
|
|
604
|
+
padding: "12px",
|
|
605
|
+
color: theme.palette.text.primary,
|
|
606
|
+
minWidth: "200px",
|
|
607
|
+
maxWidth: "280px",
|
|
608
|
+
boxShadow: theme.shadows[1],
|
|
609
|
+
},
|
|
610
|
+
end: {
|
|
611
|
+
background: isDark ? theme.palette.grey[800] : "#f5f5f5",
|
|
612
|
+
border: isDark
|
|
613
|
+
? `1px solid ${theme.palette.divider}`
|
|
614
|
+
: "1px solid #e0e0e0",
|
|
615
|
+
borderRadius: "4px",
|
|
616
|
+
padding: "12px",
|
|
617
|
+
color: theme.palette.text.primary,
|
|
618
|
+
minWidth: "180px",
|
|
619
|
+
boxShadow: theme.shadows[1],
|
|
620
|
+
},
|
|
621
|
+
};
|
|
622
|
+
}, [theme]);
|
|
623
|
+
|
|
624
|
+
// State for node selection and editor
|
|
625
|
+
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
626
|
+
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
|
627
|
+
// Memoized flow elements creation
|
|
628
|
+
const {
|
|
629
|
+
flowNodes: initialNodes,
|
|
630
|
+
edges: initialEdges,
|
|
631
|
+
warnings,
|
|
632
|
+
} = useMemo(() => {
|
|
633
|
+
const flowNodes: Node<CustomNodeData>[] = [];
|
|
634
|
+
const flowEdges: Edge[] = [];
|
|
635
|
+
const warnings: string[] = [];
|
|
636
|
+
|
|
637
|
+
try {
|
|
638
|
+
// Add ending node if present (move this up)
|
|
639
|
+
if (ending) {
|
|
640
|
+
const endNode: Node<CustomNodeData> = {
|
|
641
|
+
id: "end",
|
|
642
|
+
type: "end",
|
|
643
|
+
position: getNodePosition(
|
|
644
|
+
ending.coordinates,
|
|
645
|
+
flowNodes.length * 350 + 250,
|
|
646
|
+
200,
|
|
647
|
+
),
|
|
648
|
+
data: {
|
|
649
|
+
resumeFlow: ending.resume_flow ? "Yes" : "No",
|
|
650
|
+
},
|
|
651
|
+
style: NODE_STYLES.end,
|
|
652
|
+
};
|
|
653
|
+
flowNodes.push(endNode);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Add start node if present
|
|
657
|
+
if (start) {
|
|
658
|
+
const startNode: Node<CustomNodeData> = {
|
|
659
|
+
id: "start",
|
|
660
|
+
type: "start",
|
|
661
|
+
position: getNodePosition(start.coordinates, 100, 100),
|
|
662
|
+
data: {
|
|
663
|
+
next: start.next_node,
|
|
664
|
+
},
|
|
665
|
+
style: NODE_STYLES.start,
|
|
666
|
+
};
|
|
667
|
+
flowNodes.push(startNode);
|
|
668
|
+
|
|
669
|
+
// Create edge from start to its next node
|
|
670
|
+
if (start.next_node) {
|
|
671
|
+
let target = start.next_node;
|
|
672
|
+
let targetHandle: string;
|
|
673
|
+
if (start.next_node === "$ending") {
|
|
674
|
+
target = "end";
|
|
675
|
+
targetHandle = "end-input";
|
|
676
|
+
} else {
|
|
677
|
+
targetHandle = getTargetHandle(target, nodes);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
flowEdges.push({
|
|
681
|
+
id: `start-to-${target}`,
|
|
682
|
+
source: "start",
|
|
683
|
+
sourceHandle: "start-output",
|
|
684
|
+
target: target,
|
|
685
|
+
targetHandle: targetHandle,
|
|
686
|
+
type: "smoothstep",
|
|
687
|
+
animated: true,
|
|
688
|
+
markerEnd: {
|
|
689
|
+
type: MarkerType.ArrowClosed,
|
|
690
|
+
},
|
|
691
|
+
style: { stroke: "#1976d2", strokeWidth: 2 },
|
|
692
|
+
label: start.next_node === "$ending" ? "End" : undefined,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Add form nodes
|
|
698
|
+
nodes.forEach((node, index) => {
|
|
699
|
+
if (!node.id) {
|
|
700
|
+
warnings.push(`Node at index ${index} missing required id`);
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Determine node type for ReactFlow
|
|
705
|
+
let nodeType: string;
|
|
706
|
+
let nodeStyle: any;
|
|
707
|
+
if (node.type === "ROUTER") {
|
|
708
|
+
nodeType = "router";
|
|
709
|
+
nodeStyle = NODE_STYLES.router;
|
|
710
|
+
} else if (node.type === "FLOW") {
|
|
711
|
+
nodeType = "flow";
|
|
712
|
+
nodeStyle = NODE_STYLES.flow;
|
|
713
|
+
} else {
|
|
714
|
+
nodeType = "step";
|
|
715
|
+
nodeStyle = NODE_STYLES.step;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const defaultX = 250 + index * 350;
|
|
719
|
+
|
|
720
|
+
const flowNode: Node<CustomNodeData> = {
|
|
721
|
+
id: node.id,
|
|
722
|
+
type: nodeType,
|
|
723
|
+
position: getNodePosition(node.coordinates, defaultX, 200),
|
|
724
|
+
data: {
|
|
725
|
+
label: node.alias || node.id,
|
|
726
|
+
type: node.type,
|
|
727
|
+
next: node.config?.next_node,
|
|
728
|
+
components: node.config?.components || [],
|
|
729
|
+
flowId: node.config?.flow_id,
|
|
730
|
+
// Router-specific data
|
|
731
|
+
rules: node.config?.rules || [],
|
|
732
|
+
fallback: node.config?.fallback,
|
|
733
|
+
},
|
|
734
|
+
style: nodeStyle,
|
|
735
|
+
};
|
|
736
|
+
flowNodes.push(flowNode);
|
|
737
|
+
|
|
738
|
+
// Handle edges based on node type
|
|
739
|
+
if (node.type === "ROUTER") {
|
|
740
|
+
// Create edges for each rule
|
|
741
|
+
const rules = node.config?.rules || [];
|
|
742
|
+
rules.forEach((rule) => {
|
|
743
|
+
if (rule.next_node) {
|
|
744
|
+
const target =
|
|
745
|
+
rule.next_node === "$ending" ? "end" : rule.next_node;
|
|
746
|
+
const edgeId = `${node.id}-rule-${rule.id}-to-${target}`;
|
|
747
|
+
const targetHandle = getTargetHandle(target, nodes);
|
|
748
|
+
|
|
749
|
+
flowEdges.push({
|
|
750
|
+
id: edgeId,
|
|
751
|
+
source: node.id,
|
|
752
|
+
sourceHandle: `router-rule-${rule.id}`,
|
|
753
|
+
target: target,
|
|
754
|
+
targetHandle: targetHandle,
|
|
755
|
+
type: "smoothstep",
|
|
756
|
+
animated: true,
|
|
757
|
+
markerEnd: { type: MarkerType.ArrowClosed },
|
|
758
|
+
style: { stroke: "#9c27b0", strokeWidth: 2 },
|
|
759
|
+
label: rule.alias || undefined,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
// Create edge for fallback
|
|
765
|
+
if (node.config?.fallback) {
|
|
766
|
+
const target =
|
|
767
|
+
node.config.fallback === "$ending" ? "end" : node.config.fallback;
|
|
768
|
+
const edgeId = `${node.id}-fallback-to-${target}`;
|
|
769
|
+
const targetHandle = getTargetHandle(target, nodes);
|
|
770
|
+
|
|
771
|
+
flowEdges.push({
|
|
772
|
+
id: edgeId,
|
|
773
|
+
source: node.id,
|
|
774
|
+
sourceHandle: "router-fallback",
|
|
775
|
+
target: target,
|
|
776
|
+
targetHandle: targetHandle,
|
|
777
|
+
type: "smoothstep",
|
|
778
|
+
animated: true,
|
|
779
|
+
markerEnd: { type: MarkerType.ArrowClosed },
|
|
780
|
+
style: {
|
|
781
|
+
stroke: "#9c27b0",
|
|
782
|
+
strokeWidth: 2,
|
|
783
|
+
strokeDasharray: "5,5",
|
|
784
|
+
},
|
|
785
|
+
label: "Default",
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
} else {
|
|
789
|
+
// Create edge to the next node for STEP and FLOW nodes
|
|
790
|
+
if (node.config?.next_node) {
|
|
791
|
+
const target =
|
|
792
|
+
node.config.next_node === "$ending"
|
|
793
|
+
? "end"
|
|
794
|
+
: node.config.next_node;
|
|
795
|
+
const edgeId = `${node.id}-to-${target}`;
|
|
796
|
+
|
|
797
|
+
// Determine source and target handles
|
|
798
|
+
const sourceHandle =
|
|
799
|
+
node.type === "STEP" ? "step-output" : "flow-output";
|
|
800
|
+
const targetHandle = getTargetHandle(target, nodes);
|
|
801
|
+
|
|
802
|
+
// Validate that target exists or will exist
|
|
803
|
+
const targetExists =
|
|
804
|
+
target === "end" ||
|
|
805
|
+
nodes.some((n) => n.id === target) ||
|
|
806
|
+
(start && start.next_node === target);
|
|
807
|
+
|
|
808
|
+
if (targetExists || target === "end") {
|
|
809
|
+
flowEdges.push({
|
|
810
|
+
id: edgeId,
|
|
811
|
+
source: node.id,
|
|
812
|
+
sourceHandle: sourceHandle,
|
|
813
|
+
target: target,
|
|
814
|
+
targetHandle: targetHandle,
|
|
815
|
+
type: "smoothstep",
|
|
816
|
+
animated: true,
|
|
817
|
+
markerEnd: {
|
|
818
|
+
type: MarkerType.ArrowClosed,
|
|
819
|
+
},
|
|
820
|
+
style: { stroke: "#1976d2", strokeWidth: 2 },
|
|
821
|
+
label: node.config.next_node === "$ending" ? "End" : undefined,
|
|
822
|
+
});
|
|
823
|
+
} else {
|
|
824
|
+
warnings.push(
|
|
825
|
+
`Target node ${target} not found for edge from ${node.id}`,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
// Validation logic
|
|
833
|
+
const connectedNodeIds = new Set<string>();
|
|
834
|
+
flowEdges.forEach((edge) => {
|
|
835
|
+
connectedNodeIds.add(edge.target);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
const allNodeIds = new Set(flowNodes.map((node) => node.id));
|
|
839
|
+
|
|
840
|
+
// Mark orphaned and invalid nodes
|
|
841
|
+
flowNodes.forEach((node) => {
|
|
842
|
+
// Check for orphaned nodes
|
|
843
|
+
if (
|
|
844
|
+
node.id !== "start" &&
|
|
845
|
+
node.id !== "end" &&
|
|
846
|
+
!connectedNodeIds.has(node.id)
|
|
847
|
+
) {
|
|
848
|
+
node.data.orphaned = true;
|
|
849
|
+
node.style = {
|
|
850
|
+
...node.style,
|
|
851
|
+
border: "2px dashed #f44336",
|
|
852
|
+
boxShadow: "0 0 5px rgba(244, 67, 54, 0.5)",
|
|
853
|
+
};
|
|
854
|
+
warnings.push(`Orphaned node: ${node.id}`);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Check for invalid connections
|
|
858
|
+
if (
|
|
859
|
+
node.data.next &&
|
|
860
|
+
node.data.next !== "$ending" &&
|
|
861
|
+
!allNodeIds.has(node.data.next)
|
|
862
|
+
) {
|
|
863
|
+
node.data.invalidConnection = true;
|
|
864
|
+
node.style = {
|
|
865
|
+
...node.style,
|
|
866
|
+
borderBottom: "3px solid #FF9800",
|
|
867
|
+
boxShadow: "0 2px 4px rgba(255, 152, 0, 0.5)",
|
|
868
|
+
};
|
|
869
|
+
warnings.push(
|
|
870
|
+
`Invalid connection in ${node.id}: ${node.data.next} not found`,
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
} catch (error) {
|
|
875
|
+
const errorMessage =
|
|
876
|
+
error instanceof Error ? error.message : "Unknown error occurred";
|
|
877
|
+
warnings.push(`Error creating flow: ${errorMessage}`);
|
|
878
|
+
onError?.(errorMessage);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
return { flowNodes, edges: flowEdges, warnings };
|
|
882
|
+
}, [nodes, start, ending, onError]);
|
|
883
|
+
|
|
884
|
+
const [flowNodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
|
885
|
+
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
|
886
|
+
|
|
887
|
+
// Sync nodes and edges state with initial values when they change
|
|
888
|
+
React.useEffect(() => {
|
|
889
|
+
setNodes(initialNodes);
|
|
890
|
+
}, [initialNodes, setNodes]);
|
|
891
|
+
|
|
892
|
+
React.useEffect(() => {
|
|
893
|
+
setEdges(initialEdges);
|
|
894
|
+
}, [initialEdges, setEdges]);
|
|
895
|
+
|
|
896
|
+
// Handle node selection
|
|
897
|
+
const handleNodeClick = useCallback(
|
|
898
|
+
(_event: React.MouseEvent, node: Node) => {
|
|
899
|
+
setSelectedNodeId(node.id);
|
|
900
|
+
setIsEditorOpen(true);
|
|
901
|
+
onNodeSelect?.(node.id);
|
|
902
|
+
},
|
|
903
|
+
[onNodeSelect],
|
|
904
|
+
);
|
|
905
|
+
|
|
906
|
+
// Close the editor
|
|
907
|
+
const handleCloseEditor = useCallback(() => {
|
|
908
|
+
setIsEditorOpen(false);
|
|
909
|
+
}, []);
|
|
910
|
+
|
|
911
|
+
// Handle node updates from the editor
|
|
912
|
+
const handleNodeUpdate = useCallback(
|
|
913
|
+
(
|
|
914
|
+
nodeId: string,
|
|
915
|
+
updates: Partial<FlowNodeData> | Partial<StartNode> | Partial<EndingNode>,
|
|
916
|
+
) => {
|
|
917
|
+
onNodeUpdate?.(nodeId, updates);
|
|
918
|
+
},
|
|
919
|
+
[onNodeUpdate],
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
// Find the selected node
|
|
923
|
+
const selectedNode = useMemo(() => {
|
|
924
|
+
if (!selectedNodeId) return null;
|
|
925
|
+
return flowNodes.find((node) => node.id === selectedNodeId) || null;
|
|
926
|
+
}, [selectedNodeId, flowNodes]);
|
|
927
|
+
|
|
928
|
+
// Statistics
|
|
929
|
+
const stats = useMemo(() => {
|
|
930
|
+
const orphanedCount = flowNodes.filter(
|
|
931
|
+
(node) => node.data?.orphaned,
|
|
932
|
+
).length;
|
|
933
|
+
const invalidConnectionsCount = flowNodes.filter(
|
|
934
|
+
(node) => node.data?.invalidConnection,
|
|
935
|
+
).length;
|
|
936
|
+
|
|
937
|
+
return { orphanedCount, invalidConnectionsCount };
|
|
938
|
+
}, [flowNodes]);
|
|
939
|
+
|
|
940
|
+
// Error handling for empty flow
|
|
941
|
+
if (flowNodes.length === 0) {
|
|
942
|
+
return (
|
|
943
|
+
<Box sx={{ padding: "20px" }}>
|
|
944
|
+
<Alert severity="info">
|
|
945
|
+
<Typography variant="h6">No Flow Elements</Typography>
|
|
946
|
+
<Typography>
|
|
947
|
+
No flow elements available to display. Please provide nodes, start,
|
|
948
|
+
or ending configuration.
|
|
949
|
+
</Typography>
|
|
950
|
+
</Alert>
|
|
951
|
+
</Box>
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Handle connecting nodes
|
|
956
|
+
const handleConnect = useCallback(
|
|
957
|
+
(connection: Connection) => {
|
|
958
|
+
if (!connection.source || !connection.target) return;
|
|
959
|
+
|
|
960
|
+
// Determine the target node id (map 'end' back to '$ending')
|
|
961
|
+
const targetNodeId =
|
|
962
|
+
connection.target === "end" ? "$ending" : connection.target;
|
|
963
|
+
|
|
964
|
+
// Update the source node's next_node
|
|
965
|
+
if (connection.source === "start") {
|
|
966
|
+
// Update start node
|
|
967
|
+
onNodeUpdate?.("start", { next_node: targetNodeId });
|
|
968
|
+
} else {
|
|
969
|
+
// Find the source node to get its current config
|
|
970
|
+
const sourceNode = nodes.find((n) => n.id === connection.source);
|
|
971
|
+
if (sourceNode) {
|
|
972
|
+
// Check if this is a router connection
|
|
973
|
+
if (sourceNode.type === "ROUTER" && connection.sourceHandle) {
|
|
974
|
+
if (connection.sourceHandle === "router-fallback") {
|
|
975
|
+
// Update the fallback
|
|
976
|
+
onNodeUpdate?.(connection.source, {
|
|
977
|
+
config: {
|
|
978
|
+
...sourceNode.config,
|
|
979
|
+
fallback: targetNodeId,
|
|
980
|
+
},
|
|
981
|
+
});
|
|
982
|
+
} else if (connection.sourceHandle.startsWith("router-rule-")) {
|
|
983
|
+
// Update a specific rule's next_node
|
|
984
|
+
const ruleId = connection.sourceHandle.replace(
|
|
985
|
+
"router-rule-",
|
|
986
|
+
"",
|
|
987
|
+
);
|
|
988
|
+
const updatedRules = (sourceNode.config?.rules || []).map(
|
|
989
|
+
(rule: RouterRule) =>
|
|
990
|
+
rule.id === ruleId
|
|
991
|
+
? { ...rule, next_node: targetNodeId }
|
|
992
|
+
: rule,
|
|
993
|
+
);
|
|
994
|
+
onNodeUpdate?.(connection.source, {
|
|
995
|
+
config: {
|
|
996
|
+
...sourceNode.config,
|
|
997
|
+
rules: updatedRules,
|
|
998
|
+
},
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
} else {
|
|
1002
|
+
// Standard STEP/FLOW node
|
|
1003
|
+
onNodeUpdate?.(connection.source, {
|
|
1004
|
+
config: {
|
|
1005
|
+
...sourceNode.config,
|
|
1006
|
+
next_node: targetNodeId,
|
|
1007
|
+
},
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Add the edge visually
|
|
1014
|
+
const edgeStyle = connection.sourceHandle?.startsWith("router")
|
|
1015
|
+
? { stroke: "#9c27b0", strokeWidth: 2 }
|
|
1016
|
+
: { stroke: "#1976d2", strokeWidth: 2 };
|
|
1017
|
+
|
|
1018
|
+
setEdges((eds) =>
|
|
1019
|
+
addEdge(
|
|
1020
|
+
{
|
|
1021
|
+
...connection,
|
|
1022
|
+
type: "smoothstep",
|
|
1023
|
+
animated: true,
|
|
1024
|
+
markerEnd: { type: MarkerType.ArrowClosed },
|
|
1025
|
+
style: edgeStyle,
|
|
1026
|
+
},
|
|
1027
|
+
eds,
|
|
1028
|
+
),
|
|
1029
|
+
);
|
|
1030
|
+
},
|
|
1031
|
+
[nodes, onNodeUpdate, setEdges],
|
|
1032
|
+
);
|
|
1033
|
+
|
|
1034
|
+
// Add Step handler
|
|
1035
|
+
const handleAddStep = useCallback(() => {
|
|
1036
|
+
// Generate a short random id (4 alphanumeric chars)
|
|
1037
|
+
const randomId = () => Math.random().toString(36).slice(2, 6);
|
|
1038
|
+
const stepId = `step_${randomId()}`;
|
|
1039
|
+
const nextButtonId = `next_button_${randomId()}`;
|
|
1040
|
+
|
|
1041
|
+
// Calculate position based on existing nodes
|
|
1042
|
+
const existingPositions = flowNodes.map((n) => n.position);
|
|
1043
|
+
const maxX = Math.max(...existingPositions.map((p) => p.x), 200);
|
|
1044
|
+
const avgY =
|
|
1045
|
+
existingPositions.length > 0
|
|
1046
|
+
? existingPositions.reduce((sum, p) => sum + p.y, 0) /
|
|
1047
|
+
existingPositions.length
|
|
1048
|
+
: 200;
|
|
1049
|
+
|
|
1050
|
+
const newStep: FlowNodeData = {
|
|
1051
|
+
id: stepId,
|
|
1052
|
+
type: "STEP",
|
|
1053
|
+
coordinates: { x: maxX + 200, y: avgY },
|
|
1054
|
+
alias: "New step",
|
|
1055
|
+
config: {
|
|
1056
|
+
components: [
|
|
1057
|
+
{
|
|
1058
|
+
id: nextButtonId,
|
|
1059
|
+
type: "NEXT_BUTTON",
|
|
1060
|
+
config: { text: "Continue" },
|
|
1061
|
+
},
|
|
1062
|
+
],
|
|
1063
|
+
},
|
|
1064
|
+
};
|
|
1065
|
+
onNodeUpdate?.(stepId, newStep);
|
|
1066
|
+
}, [onNodeUpdate, flowNodes]);
|
|
1067
|
+
|
|
1068
|
+
// Add Flow handler
|
|
1069
|
+
const handleAddFlow = useCallback(() => {
|
|
1070
|
+
// Generate a short random id (4 alphanumeric chars)
|
|
1071
|
+
const randomId = () => Math.random().toString(36).slice(2, 6);
|
|
1072
|
+
const flowId = `flow_${randomId()}`;
|
|
1073
|
+
|
|
1074
|
+
// Calculate position based on existing nodes
|
|
1075
|
+
const existingPositions = flowNodes.map((n) => n.position);
|
|
1076
|
+
const maxX = Math.max(...existingPositions.map((p) => p.x), 200);
|
|
1077
|
+
const avgY =
|
|
1078
|
+
existingPositions.length > 0
|
|
1079
|
+
? existingPositions.reduce((sum, p) => sum + p.y, 0) /
|
|
1080
|
+
existingPositions.length
|
|
1081
|
+
: 200;
|
|
1082
|
+
|
|
1083
|
+
const newFlow: FlowNodeData = {
|
|
1084
|
+
id: flowId,
|
|
1085
|
+
type: "FLOW",
|
|
1086
|
+
coordinates: { x: maxX + 200, y: avgY },
|
|
1087
|
+
alias: "New flow",
|
|
1088
|
+
config: {
|
|
1089
|
+
flow_id: "",
|
|
1090
|
+
},
|
|
1091
|
+
};
|
|
1092
|
+
onNodeUpdate?.(flowId, newFlow);
|
|
1093
|
+
}, [onNodeUpdate, flowNodes]);
|
|
1094
|
+
|
|
1095
|
+
// Add Router handler
|
|
1096
|
+
const handleAddRouter = useCallback(() => {
|
|
1097
|
+
// Generate a short random id (4 alphanumeric chars)
|
|
1098
|
+
const randomId = () => Math.random().toString(36).slice(2, 6);
|
|
1099
|
+
const routerId = `router_${randomId()}`;
|
|
1100
|
+
const ruleId = `id_${Date.now()}`;
|
|
1101
|
+
|
|
1102
|
+
// Calculate position based on existing nodes
|
|
1103
|
+
const existingPositions = flowNodes.map((n) => n.position);
|
|
1104
|
+
const maxX = Math.max(...existingPositions.map((p) => p.x), 200);
|
|
1105
|
+
const avgY =
|
|
1106
|
+
existingPositions.length > 0
|
|
1107
|
+
? existingPositions.reduce((sum, p) => sum + p.y, 0) /
|
|
1108
|
+
existingPositions.length
|
|
1109
|
+
: 200;
|
|
1110
|
+
|
|
1111
|
+
const newRouter: FlowNodeData = {
|
|
1112
|
+
id: routerId,
|
|
1113
|
+
type: "ROUTER",
|
|
1114
|
+
coordinates: { x: maxX + 200, y: avgY },
|
|
1115
|
+
alias: "New router",
|
|
1116
|
+
config: {
|
|
1117
|
+
rules: [
|
|
1118
|
+
{
|
|
1119
|
+
id: ruleId,
|
|
1120
|
+
alias: "Rule 1",
|
|
1121
|
+
condition: {
|
|
1122
|
+
operands: [],
|
|
1123
|
+
operator: "AND",
|
|
1124
|
+
},
|
|
1125
|
+
next_node: "$ending",
|
|
1126
|
+
},
|
|
1127
|
+
],
|
|
1128
|
+
fallback: "$ending",
|
|
1129
|
+
},
|
|
1130
|
+
};
|
|
1131
|
+
onNodeUpdate?.(routerId, newRouter);
|
|
1132
|
+
}, [onNodeUpdate, flowNodes]);
|
|
1133
|
+
|
|
1134
|
+
return (
|
|
1135
|
+
<Box sx={{ width: "100%", height: "100%", position: "relative" }}>
|
|
1136
|
+
{/* Global styles for ReactFlow Controls to support dark mode */}
|
|
1137
|
+
<GlobalStyles
|
|
1138
|
+
styles={(theme) => ({
|
|
1139
|
+
".react-flow__controls": {
|
|
1140
|
+
background: theme.palette.background.paper,
|
|
1141
|
+
border: `1px solid ${theme.palette.divider}`,
|
|
1142
|
+
borderRadius: 8,
|
|
1143
|
+
boxShadow:
|
|
1144
|
+
theme.palette.mode === "dark"
|
|
1145
|
+
? "0 2px 8px rgba(0,0,0,0.32)"
|
|
1146
|
+
: "0 2px 8px rgba(0,0,0,0.08)",
|
|
1147
|
+
margin: 8,
|
|
1148
|
+
},
|
|
1149
|
+
".react-flow__controls-button": {
|
|
1150
|
+
background: "none",
|
|
1151
|
+
color: theme.palette.text.primary,
|
|
1152
|
+
border: "none",
|
|
1153
|
+
transition: "background 0.2s",
|
|
1154
|
+
"&:hover": {
|
|
1155
|
+
background:
|
|
1156
|
+
theme.palette.mode === "dark"
|
|
1157
|
+
? "rgba(255,255,255,0.08)"
|
|
1158
|
+
: "rgba(0,0,0,0.04)",
|
|
1159
|
+
},
|
|
1160
|
+
},
|
|
1161
|
+
".react-flow__controls-button svg": {
|
|
1162
|
+
fill: theme.palette.text.primary,
|
|
1163
|
+
},
|
|
1164
|
+
})}
|
|
1165
|
+
/>
|
|
1166
|
+
|
|
1167
|
+
{warnings.length > 0 && (
|
|
1168
|
+
<Alert severity="warning" sx={{ mb: 2 }}>
|
|
1169
|
+
<Typography variant="subtitle2">Flow Validation Warnings:</Typography>
|
|
1170
|
+
<ul>
|
|
1171
|
+
{warnings.slice(0, 5).map((warning, index) => (
|
|
1172
|
+
<li key={index}>{warning}</li>
|
|
1173
|
+
))}
|
|
1174
|
+
</ul>
|
|
1175
|
+
{warnings.length > 5 && (
|
|
1176
|
+
<Typography variant="caption">
|
|
1177
|
+
... and {warnings.length - 5} more warnings
|
|
1178
|
+
</Typography>
|
|
1179
|
+
)}
|
|
1180
|
+
</Alert>
|
|
1181
|
+
)}
|
|
1182
|
+
|
|
1183
|
+
<ReactFlow
|
|
1184
|
+
nodes={flowNodes}
|
|
1185
|
+
edges={edges}
|
|
1186
|
+
onNodesChange={onNodesChange}
|
|
1187
|
+
onEdgesChange={onEdgesChange}
|
|
1188
|
+
onConnect={handleConnect}
|
|
1189
|
+
onNodeClick={handleNodeClick}
|
|
1190
|
+
nodeTypes={nodeTypes}
|
|
1191
|
+
fitView
|
|
1192
|
+
fitViewOptions={FLOW_CONFIG.fitViewOptions}
|
|
1193
|
+
proOptions={{ hideAttribution: true }}
|
|
1194
|
+
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
|
1195
|
+
elementsSelectable={true}
|
|
1196
|
+
nodesDraggable={true}
|
|
1197
|
+
nodesConnectable={true}
|
|
1198
|
+
minZoom={FLOW_CONFIG.minZoom}
|
|
1199
|
+
maxZoom={FLOW_CONFIG.maxZoom}
|
|
1200
|
+
attributionPosition="bottom-left"
|
|
1201
|
+
style={{ height: "100%", background: theme.palette.background.default }}
|
|
1202
|
+
>
|
|
1203
|
+
<Controls
|
|
1204
|
+
showInteractive={false}
|
|
1205
|
+
style={{
|
|
1206
|
+
background: theme.palette.background.paper,
|
|
1207
|
+
color: theme.palette.text.primary,
|
|
1208
|
+
borderRadius: 8,
|
|
1209
|
+
boxShadow:
|
|
1210
|
+
theme.palette.mode === "dark"
|
|
1211
|
+
? "0 2px 8px rgba(0,0,0,0.32)"
|
|
1212
|
+
: "0 2px 8px rgba(0,0,0,0.08)",
|
|
1213
|
+
border: `1px solid ${theme.palette.divider}`,
|
|
1214
|
+
margin: 8,
|
|
1215
|
+
}}
|
|
1216
|
+
/>
|
|
1217
|
+
<Background
|
|
1218
|
+
color={
|
|
1219
|
+
theme.palette.mode === "dark" ? theme.palette.divider : "#f0f0f0"
|
|
1220
|
+
}
|
|
1221
|
+
gap={12}
|
|
1222
|
+
size={1}
|
|
1223
|
+
/>
|
|
1224
|
+
<Panel position="top-right">
|
|
1225
|
+
<Box
|
|
1226
|
+
sx={{
|
|
1227
|
+
p: 1,
|
|
1228
|
+
bgcolor: theme.palette.background.paper,
|
|
1229
|
+
borderRadius: "4px",
|
|
1230
|
+
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
|
|
1231
|
+
minWidth: 160,
|
|
1232
|
+
}}
|
|
1233
|
+
>
|
|
1234
|
+
<Typography variant="caption" color="text.secondary">
|
|
1235
|
+
Form Flow Diagram
|
|
1236
|
+
</Typography>
|
|
1237
|
+
<Typography variant="caption" sx={{ display: "block", mt: 0.5 }}>
|
|
1238
|
+
Nodes: {flowNodes.length} | Edges: {edges.length}
|
|
1239
|
+
</Typography>
|
|
1240
|
+
|
|
1241
|
+
{stats.orphanedCount > 0 && (
|
|
1242
|
+
<Typography
|
|
1243
|
+
variant="caption"
|
|
1244
|
+
sx={{
|
|
1245
|
+
display: "block",
|
|
1246
|
+
color: "#f44336",
|
|
1247
|
+
mt: 0.5,
|
|
1248
|
+
}}
|
|
1249
|
+
>
|
|
1250
|
+
⚠ {stats.orphanedCount} unconnected node
|
|
1251
|
+
{stats.orphanedCount > 1 ? "s" : ""}
|
|
1252
|
+
</Typography>
|
|
1253
|
+
)}
|
|
1254
|
+
|
|
1255
|
+
{stats.invalidConnectionsCount > 0 && (
|
|
1256
|
+
<Typography
|
|
1257
|
+
variant="caption"
|
|
1258
|
+
sx={{
|
|
1259
|
+
display: "block",
|
|
1260
|
+
color: "#FF9800",
|
|
1261
|
+
mt: 0.5,
|
|
1262
|
+
}}
|
|
1263
|
+
>
|
|
1264
|
+
⚠ {stats.invalidConnectionsCount} invalid connection
|
|
1265
|
+
{stats.invalidConnectionsCount > 1 ? "s" : ""}
|
|
1266
|
+
</Typography>
|
|
1267
|
+
)}
|
|
1268
|
+
</Box>
|
|
1269
|
+
</Panel>
|
|
1270
|
+
</ReactFlow>
|
|
1271
|
+
|
|
1272
|
+
{/* Bottom center add buttons */}
|
|
1273
|
+
<Box
|
|
1274
|
+
sx={{
|
|
1275
|
+
position: "absolute",
|
|
1276
|
+
left: "50%",
|
|
1277
|
+
bottom: 12,
|
|
1278
|
+
transform: "translateX(-50%)",
|
|
1279
|
+
zIndex: 10,
|
|
1280
|
+
display: "flex",
|
|
1281
|
+
alignItems: "center",
|
|
1282
|
+
bgcolor: theme.palette.background.paper,
|
|
1283
|
+
borderRadius: 2,
|
|
1284
|
+
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
|
1285
|
+
px: 1.5,
|
|
1286
|
+
py: 0.5,
|
|
1287
|
+
gap: 1,
|
|
1288
|
+
}}
|
|
1289
|
+
>
|
|
1290
|
+
<AddCircleOutlineIcon sx={{ color: "#1976d2", mr: 1 }} />
|
|
1291
|
+
<Box sx={{ display: "flex", gap: 1 }}>
|
|
1292
|
+
<button
|
|
1293
|
+
type="button"
|
|
1294
|
+
style={{
|
|
1295
|
+
border: `1px solid ${theme.palette.divider}`,
|
|
1296
|
+
background: theme.palette.background.paper,
|
|
1297
|
+
borderRadius: 6,
|
|
1298
|
+
padding: "4px 16px",
|
|
1299
|
+
fontWeight: 500,
|
|
1300
|
+
fontSize: 15,
|
|
1301
|
+
color: theme.palette.text.primary,
|
|
1302
|
+
cursor: "pointer",
|
|
1303
|
+
outline: "none",
|
|
1304
|
+
transition: "background 0.2s, border 0.2s",
|
|
1305
|
+
}}
|
|
1306
|
+
onClick={handleAddStep}
|
|
1307
|
+
>
|
|
1308
|
+
Step
|
|
1309
|
+
</button>
|
|
1310
|
+
<button
|
|
1311
|
+
type="button"
|
|
1312
|
+
style={{
|
|
1313
|
+
border: `1px solid ${theme.palette.divider}`,
|
|
1314
|
+
background: theme.palette.background.paper,
|
|
1315
|
+
borderRadius: 6,
|
|
1316
|
+
padding: "4px 16px",
|
|
1317
|
+
fontWeight: 500,
|
|
1318
|
+
fontSize: 15,
|
|
1319
|
+
color: theme.palette.text.primary,
|
|
1320
|
+
cursor: "pointer",
|
|
1321
|
+
outline: "none",
|
|
1322
|
+
transition: "background 0.2s, border 0.2s",
|
|
1323
|
+
}}
|
|
1324
|
+
onClick={handleAddRouter}
|
|
1325
|
+
>
|
|
1326
|
+
Router
|
|
1327
|
+
</button>
|
|
1328
|
+
<button
|
|
1329
|
+
type="button"
|
|
1330
|
+
style={{
|
|
1331
|
+
border: `1px solid ${theme.palette.divider}`,
|
|
1332
|
+
background: theme.palette.background.paper,
|
|
1333
|
+
borderRadius: 6,
|
|
1334
|
+
padding: "4px 16px",
|
|
1335
|
+
fontWeight: 500,
|
|
1336
|
+
fontSize: 15,
|
|
1337
|
+
color: theme.palette.text.primary,
|
|
1338
|
+
cursor: "pointer",
|
|
1339
|
+
outline: "none",
|
|
1340
|
+
transition: "background 0.2s, border 0.2s",
|
|
1341
|
+
}}
|
|
1342
|
+
onClick={handleAddFlow}
|
|
1343
|
+
>
|
|
1344
|
+
Flow
|
|
1345
|
+
</button>
|
|
1346
|
+
</Box>
|
|
1347
|
+
</Box>
|
|
1348
|
+
{/* Node Editor */}
|
|
1349
|
+
<NodeEditor
|
|
1350
|
+
open={isEditorOpen}
|
|
1351
|
+
selectedNode={selectedNode}
|
|
1352
|
+
nodes={nodes}
|
|
1353
|
+
start={start}
|
|
1354
|
+
ending={ending}
|
|
1355
|
+
flows={flows}
|
|
1356
|
+
onClose={handleCloseEditor}
|
|
1357
|
+
onNodeUpdate={handleNodeUpdate}
|
|
1358
|
+
/>
|
|
1359
|
+
</Box>
|
|
1360
|
+
);
|
|
1361
|
+
};
|
|
1362
|
+
|
|
1363
|
+
export default FlowEditor;
|