|
12 | 12 | import '@xyflow/svelte/dist/style.css'; |
13 | 13 | import StatusNode from './StatusNode.svelte'; |
14 | 14 | import ReconnectableEdge from './ReconnectableEdge.svelte'; |
| 15 | + import AllIncomingEdge from './AllIncomingEdge.svelte'; |
15 | 16 | import { |
16 | 17 | statusesToNodes, |
17 | 18 | transitionsToEdges, |
18 | 19 | nodesToStatuses, |
19 | 20 | edgesToTransitions, |
| 21 | + allIncomingEdgesToTransitions, |
20 | 22 | createEdge, |
| 23 | + createAllIncomingEdge, |
| 24 | + isAllIncomingEdge, |
21 | 25 | addPreservationTransitions, |
22 | 26 | positionPersistence, |
23 | 27 | DEFAULT_WORKFLOW_POSITIONS |
|
42 | 46 | }; |
43 | 47 |
|
44 | 48 | const edgeTypes = { |
45 | | - reconnectable: ReconnectableEdge |
| 49 | + reconnectable: ReconnectableEdge, |
| 50 | + 'all-incoming': AllIncomingEdge |
46 | 51 | }; |
47 | 52 |
|
48 | 53 | // Flow options |
|
60 | 65 | useEventListener(() => window, 'workflow-edge-swap', (event) => handleEdgeSwap(event)); |
61 | 66 | useEventListener(() => window, 'workflow-set-initial', (/** @type {CustomEvent<{statusId?: any}>} */ event) => handleSetInitial(event.detail?.statusId)); |
62 | 67 | useEventListener(() => window, 'workflow-status-remove', (event) => onStatusRemove(event)); |
| 68 | + useEventListener(() => window, 'workflow-toggle-all-incoming', (/** @type {CustomEvent<{statusId?: any}>} */ event) => handleToggleAllIncoming(event.detail?.statusId)); |
63 | 69 |
|
64 | 70 | $effect(() => { |
65 | 71 | const w = workflow; |
|
92 | 98 | }; |
93 | 99 | }).filter(Boolean); |
94 | 100 |
|
95 | | - // Detect initial status from transition where from_status_id is NULL |
96 | | - const initialTransition = (workflow.transitions || []).find(t => t.from_status_id === null); |
| 101 | + // Detect initial status from transition where from_status_id is NULL. |
| 102 | + // From-all rows also have a NULL from status but are not initial. |
| 103 | + const initialTransition = (workflow.transitions || []).find( |
| 104 | + (t) => t.from_status_id === null && !t.from_all_statuses |
| 105 | + ); |
97 | 106 | initialStatusId = initialTransition?.to_status_id || null; |
98 | 107 |
|
| 108 | + // Statuses reachable from every other status get the special loop arrow |
| 109 | + const allIncomingStatusIds = new Set( |
| 110 | + (workflow.transitions || []) |
| 111 | + .filter((t) => t.from_all_statuses) |
| 112 | + .map((t) => t.to_status_id) |
| 113 | + ); |
| 114 | +
|
99 | 115 | // Load existing transitions (exclude self-preservation transitions) |
100 | | - workingTransitions = workflow.transitions?.filter(t => |
| 116 | + workingTransitions = workflow.transitions?.filter(t => |
101 | 117 | t.from_status_id !== null && t.from_status_id !== t.to_status_id |
102 | 118 | ) || []; |
103 | 119 |
|
104 | 120 | // Convert to Svelte Flow format |
105 | | - nodes = statusesToNodes(workflowStatuses, initialStatusId); |
106 | | - edges = calculateEdgeOffsets(transitionsToEdges(workingTransitions)); |
| 121 | + nodes = statusesToNodes(workflowStatuses, initialStatusId).map((node) => ({ |
| 122 | + ...node, |
| 123 | + data: { ...node.data, fromAll: allIncomingStatusIds.has(node.data.statusId) } |
| 124 | + })); |
| 125 | + edges = [ |
| 126 | + ...calculateEdgeOffsets(transitionsToEdges(workingTransitions)), |
| 127 | + ...[...allIncomingStatusIds] |
| 128 | + .filter((statusId) => nodes.some((node) => node.data.statusId === statusId)) |
| 129 | + .map((statusId) => createAllIncomingEdge(statusId, workflow.id)) |
| 130 | + ]; |
107 | 131 |
|
108 | 132 | // If no initial status set, default to first node (if any) |
109 | 133 | if (!initialStatusId && nodes.length > 0) { |
|
168 | 192 | }); |
169 | 193 | // Recalculate offsets after edge changes |
170 | 194 | edges = calculateEdgeOffsets(edges); |
| 195 | + // Deleting the special arrow (e.g. via Delete key) unticks the checkbox |
| 196 | + nodes = syncFromAllNodes(nodes, edges); |
171 | 197 | } |
172 | 198 |
|
173 | 199 | function handleEdgeSwap(event) { |
|
211 | 237 | function calculateEdgeOffsets(edgeList) { |
212 | 238 | const OFFSET_STEP = 12; // pixels between parallel edges |
213 | 239 |
|
214 | | - // Group edges by their connection points |
| 240 | + // Group edges by their connection points. The special all-statuses loop |
| 241 | + // stays out of the grouping so it does not displace regular arrows. |
215 | 242 | const sourceGroups = {}; // key: "nodeId-handle" -> array of edge indices |
216 | 243 | const targetGroups = {}; |
217 | 244 |
|
218 | 245 | edgeList.forEach((edge, index) => { |
| 246 | + if (isAllIncomingEdge(edge)) return; |
| 247 | +
|
219 | 248 | const sourceKey = `${edge.source}-${edge.sourceHandle}`; |
220 | 249 | const targetKey = `${edge.target}-${edge.targetHandle}`; |
221 | 250 |
|
|
228 | 257 |
|
229 | 258 | // Calculate offsets for each edge |
230 | 259 | return edgeList.map((edge, index) => { |
| 260 | + if (isAllIncomingEdge(edge)) { |
| 261 | + return { ...edge, data: { ...edge.data, offset: 0 } }; |
| 262 | + } |
| 263 | +
|
231 | 264 | const sourceKey = `${edge.source}-${edge.sourceHandle}`; |
232 | 265 | const targetKey = `${edge.target}-${edge.targetHandle}`; |
233 | 266 |
|
|
269 | 302 | name: status.name, |
270 | 303 | category_color: status.category_color, |
271 | 304 | category_name: status.category_name, |
272 | | - description: status.description |
| 305 | + description: status.description, |
| 306 | + fromAll: false |
273 | 307 | } |
274 | 308 | }; |
275 | 309 |
|
|
306 | 340 | removeStatusFromWorkflow(event.detail.statusId); |
307 | 341 | } |
308 | 342 |
|
| 343 | + // Keep the node checkbox in sync with the presence of its special arrow. |
| 344 | + function syncFromAllNodes(nodeList, edgeList) { |
| 345 | + const allEdgeTargets = new Set( |
| 346 | + edgeList.filter(isAllIncomingEdge).map((edge) => parseInt(edge.target.replace('status-', ''), 10)) |
| 347 | + ); |
| 348 | + return nodeList.map((node) => ({ |
| 349 | + ...node, |
| 350 | + data: { ...node.data, fromAll: allEdgeTargets.has(node.data.statusId) } |
| 351 | + })); |
| 352 | + } |
| 353 | +
|
| 354 | + function handleToggleAllIncoming(statusId) { |
| 355 | + if (!statusId) return; |
| 356 | + const nodeId = `status-${statusId}`; |
| 357 | + const existing = edges.find((edge) => isAllIncomingEdge(edge) && edge.target === nodeId); |
| 358 | + if (existing) { |
| 359 | + edges = edges.filter((edge) => edge !== existing); |
| 360 | + } else { |
| 361 | + edges = [...edges, createAllIncomingEdge(statusId, workflow.id)]; |
| 362 | + } |
| 363 | + nodes = syncFromAllNodes(nodes, edges); |
| 364 | + } |
| 365 | +
|
309 | 366 | async function saveWorkflowDesign() { |
310 | 367 | if (!workflow) return; |
311 | 368 |
|
|
323 | 380 | workflow.id |
324 | 381 | ); |
325 | 382 |
|
| 383 | + // From-all rows power the special "every other status" arrows and are |
| 384 | + // appended after the initial row so they are never stripped as NULL-from |
| 385 | + const transitionsWithAll = [ |
| 386 | + ...transitionsWithInitial, |
| 387 | + ...allIncomingEdgesToTransitions(edges, workflow.id) |
| 388 | + ]; |
| 389 | +
|
326 | 390 | // Add preservation transitions for disconnected statuses |
327 | 391 | const allTransitions = addPreservationTransitions( |
328 | | - currentStatuses, |
329 | | - transitionsWithInitial, |
| 392 | + currentStatuses, |
| 393 | + transitionsWithAll, |
330 | 394 | workflow.id |
331 | 395 | ); |
332 | 396 |
|
|
403 | 467 | <div class="text-xs hint-body"> |
404 | 468 | {t('workflows.transitionHint1')}<br/> |
405 | 469 | {t('workflows.transitionHint2')}<br/> |
406 | | - {t('workflows.transitionHint3')} |
| 470 | + {t('workflows.transitionHint3')}<br/> |
| 471 | + {t('workflows.transitionHint4')} |
407 | 472 | </div> |
408 | 473 | </div> |
409 | 474 | <div class="space-y-3"> |
410 | 475 | {#each availableStatuses as status} |
411 | 476 | <button |
412 | 477 | type="button" |
| 478 | + data-testid={`workflow-status-option-${status.id}`} |
413 | 479 | class="appearance-none bg-transparent border-none font-[inherit] text-[inherit] text-left w-full p-3 rounded border status-card cursor-pointer transition-colors" |
414 | 480 | onclick={() => addStatusToWorkflow(status)} |
415 | 481 | > |
|
479 | 545 | stroke-width="1" |
480 | 546 | /> |
481 | 547 | </marker> |
| 548 | + <marker |
| 549 | + id="workflow-all-arrowhead" |
| 550 | + markerWidth="5" |
| 551 | + markerHeight="5" |
| 552 | + refX="4" |
| 553 | + refY="2.5" |
| 554 | + orient="auto" |
| 555 | + markerUnits="strokeWidth" |
| 556 | + > |
| 557 | + <polygon |
| 558 | + points="0,0.4 4.4,2.5 0,4.6 1.2,2.5" |
| 559 | + fill="var(--workflow-accent, #3b82f6)" |
| 560 | + /> |
| 561 | + </marker> |
482 | 562 | </defs> |
483 | 563 | </svg> |
484 | 564 | </SvelteFlow> |
|
504 | 584 | {t('common.cancel')} |
505 | 585 | </button> |
506 | 586 | <button |
| 587 | + data-testid="workflow-save" |
507 | 588 | class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50" |
508 | 589 | onclick={saveWorkflowDesign} |
509 | 590 | disabled={savingTransitions || nodes.length === 0} |
|
0 commit comments