Skip to content

Commit 8234138

Browse files
committed
feat: support workflow transitions from all statuses
1 parent d25a380 commit 8234138

29 files changed

Lines changed: 574 additions & 141 deletions
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<script>
2+
import { BaseEdge, getBezierPath, Position } from '@xyflow/svelte';
3+
import { t } from '../../stores/i18n.svelte.js';
4+
5+
let {
6+
id,
7+
sourceX,
8+
sourceY,
9+
targetX,
10+
targetY,
11+
sourcePosition,
12+
targetPosition,
13+
selected = false,
14+
data = {},
15+
...rest
16+
} = $props();
17+
18+
// The all-statuses arrow loops out of the top of the status and re-enters
19+
// on its left side, so it reads as one special incoming transition instead
20+
// of one arrow per source status.
21+
const LOOP_GAP = 22;
22+
23+
let pathResult = $derived(getBezierPath({
24+
sourceX,
25+
sourceY: sourceY - LOOP_GAP,
26+
sourcePosition: sourcePosition || Position.Top,
27+
targetX: targetX - LOOP_GAP,
28+
targetY,
29+
targetPosition: targetPosition || Position.Left,
30+
curvature: 0.8,
31+
}));
32+
let edgePath = $derived(pathResult[0]);
33+
let labelX = $derived(pathResult[1]);
34+
let labelY = $derived(pathResult[2]);
35+
</script>
36+
37+
<BaseEdge
38+
id={id}
39+
path={edgePath}
40+
markerEnd="url(#workflow-all-arrowhead)"
41+
class="all-incoming-path"
42+
style={`stroke: var(--workflow-accent, #3b82f6); stroke-width: ${selected ? 2 : 1.5}; stroke-dasharray: 5 3; fill: none;`}
43+
/>
44+
45+
<foreignObject x={labelX - 24} y={labelY - 10} width="48" height="20" style="overflow: visible;">
46+
<div class="all-incoming-label" class:all-incoming-label-selected={selected} title={t('workflows.fromAllStatuses')}>
47+
{t('workflows.allStatuses')}
48+
</div>
49+
</foreignObject>
50+
51+
<style>
52+
.all-incoming-label {
53+
width: 48px;
54+
text-align: center;
55+
font-size: 9px;
56+
line-height: 1;
57+
padding: 3px 0;
58+
border-radius: 999px;
59+
border: 1px solid var(--workflow-accent, #3b82f6);
60+
background: var(--workflow-panel, #fff);
61+
color: var(--workflow-accent, #3b82f6);
62+
cursor: default;
63+
user-select: none;
64+
}
65+
66+
.all-incoming-label-selected {
67+
background: var(--workflow-accent, #3b82f6);
68+
color: #fff;
69+
}
70+
</style>

frontend/src/lib/features/workflows/StatusNode.svelte

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@
1616
if (!statusId) return;
1717
window.dispatchEvent(new CustomEvent('workflow-set-initial', { detail: { statusId } }));
1818
}
19+
20+
function handleToggleAllIncoming(event) {
21+
event.stopPropagation();
22+
const statusId = data.statusId;
23+
if (!statusId) return;
24+
window.dispatchEvent(
25+
new CustomEvent('workflow-toggle-all-incoming', { detail: { statusId } })
26+
);
27+
}
1928
</script>
2029

2130
<div
@@ -51,6 +60,26 @@
5160
{/if}
5261
</button>
5362

63+
<!-- All-statuses checkbox: allow transitions from every other status -->
64+
<button
65+
class="all-incoming-chip"
66+
class:all-incoming-active={data.fromAll}
67+
role="checkbox"
68+
aria-checked={data.fromAll ? 'true' : 'false'}
69+
data-testid="status-all-toggle"
70+
onclick={handleToggleAllIncoming}
71+
title={t('workflows.fromAllStatuses')}
72+
>
73+
<span class="all-incoming-box" aria-hidden="true">
74+
{#if data.fromAll}
75+
<svg viewBox="0 0 10 10" width="8" height="8">
76+
<path d="M1.5 5.5 L4 8 L8.5 2" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
77+
</svg>
78+
{/if}
79+
</span>
80+
{t('workflows.allStatuses')}
81+
</button>
82+
5483
<!-- Remove button - positioned in top-right corner -->
5584
<button
5685
class="absolute top-1 right-1 opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 p-1 transition-opacity duration-200 z-10"
@@ -127,6 +156,55 @@
127156
border-color: var(--workflow-accent);
128157
}
129158
159+
.all-incoming-chip {
160+
position: absolute;
161+
bottom: -12px;
162+
left: -8px;
163+
display: flex;
164+
align-items: center;
165+
gap: 4px;
166+
font-size: 9px;
167+
line-height: 1;
168+
padding: 2px 6px;
169+
border-radius: 999px;
170+
border: 1px solid var(--workflow-border);
171+
background: var(--workflow-panel);
172+
color: var(--workflow-text-subtle);
173+
cursor: pointer;
174+
opacity: 0;
175+
transition: opacity 0.15s ease, background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
176+
pointer-events: auto;
177+
z-index: 12;
178+
}
179+
180+
.status-node:hover .all-incoming-chip {
181+
opacity: 1;
182+
}
183+
184+
.all-incoming-chip:hover {
185+
background: var(--workflow-panel-hover);
186+
color: var(--workflow-accent);
187+
border-color: var(--workflow-accent);
188+
}
189+
190+
.all-incoming-active {
191+
opacity: 1 !important;
192+
background: rgba(59, 130, 246, 0.12);
193+
color: var(--workflow-accent);
194+
border-color: var(--workflow-accent);
195+
}
196+
197+
.all-incoming-box {
198+
display: inline-flex;
199+
align-items: center;
200+
justify-content: center;
201+
width: 9px;
202+
height: 9px;
203+
border: 1px solid currentColor;
204+
border-radius: 2px;
205+
flex-shrink: 0;
206+
}
207+
130208
/* Source handles - visible, higher z-index to capture clicks first */
131209
:global(.handle-source) {
132210
width: 12px !important;

frontend/src/lib/features/workflows/SvelteFlowDesigner.svelte

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,16 @@
1212
import '@xyflow/svelte/dist/style.css';
1313
import StatusNode from './StatusNode.svelte';
1414
import ReconnectableEdge from './ReconnectableEdge.svelte';
15+
import AllIncomingEdge from './AllIncomingEdge.svelte';
1516
import {
1617
statusesToNodes,
1718
transitionsToEdges,
1819
nodesToStatuses,
1920
edgesToTransitions,
21+
allIncomingEdgesToTransitions,
2022
createEdge,
23+
createAllIncomingEdge,
24+
isAllIncomingEdge,
2125
addPreservationTransitions,
2226
positionPersistence,
2327
DEFAULT_WORKFLOW_POSITIONS
@@ -42,7 +46,8 @@
4246
};
4347
4448
const edgeTypes = {
45-
reconnectable: ReconnectableEdge
49+
reconnectable: ReconnectableEdge,
50+
'all-incoming': AllIncomingEdge
4651
};
4752
4853
// Flow options
@@ -60,6 +65,7 @@
6065
useEventListener(() => window, 'workflow-edge-swap', (event) => handleEdgeSwap(event));
6166
useEventListener(() => window, 'workflow-set-initial', (/** @type {CustomEvent<{statusId?: any}>} */ event) => handleSetInitial(event.detail?.statusId));
6267
useEventListener(() => window, 'workflow-status-remove', (event) => onStatusRemove(event));
68+
useEventListener(() => window, 'workflow-toggle-all-incoming', (/** @type {CustomEvent<{statusId?: any}>} */ event) => handleToggleAllIncoming(event.detail?.statusId));
6369
6470
$effect(() => {
6571
const w = workflow;
@@ -92,18 +98,36 @@
9298
};
9399
}).filter(Boolean);
94100
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+
);
97106
initialStatusId = initialTransition?.to_status_id || null;
98107
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+
99115
// Load existing transitions (exclude self-preservation transitions)
100-
workingTransitions = workflow.transitions?.filter(t =>
116+
workingTransitions = workflow.transitions?.filter(t =>
101117
t.from_status_id !== null && t.from_status_id !== t.to_status_id
102118
) || [];
103119
104120
// 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+
];
107131
108132
// If no initial status set, default to first node (if any)
109133
if (!initialStatusId && nodes.length > 0) {
@@ -168,6 +192,8 @@
168192
});
169193
// Recalculate offsets after edge changes
170194
edges = calculateEdgeOffsets(edges);
195+
// Deleting the special arrow (e.g. via Delete key) unticks the checkbox
196+
nodes = syncFromAllNodes(nodes, edges);
171197
}
172198
173199
function handleEdgeSwap(event) {
@@ -211,11 +237,14 @@
211237
function calculateEdgeOffsets(edgeList) {
212238
const OFFSET_STEP = 12; // pixels between parallel edges
213239
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.
215242
const sourceGroups = {}; // key: "nodeId-handle" -> array of edge indices
216243
const targetGroups = {};
217244
218245
edgeList.forEach((edge, index) => {
246+
if (isAllIncomingEdge(edge)) return;
247+
219248
const sourceKey = `${edge.source}-${edge.sourceHandle}`;
220249
const targetKey = `${edge.target}-${edge.targetHandle}`;
221250
@@ -228,6 +257,10 @@
228257
229258
// Calculate offsets for each edge
230259
return edgeList.map((edge, index) => {
260+
if (isAllIncomingEdge(edge)) {
261+
return { ...edge, data: { ...edge.data, offset: 0 } };
262+
}
263+
231264
const sourceKey = `${edge.source}-${edge.sourceHandle}`;
232265
const targetKey = `${edge.target}-${edge.targetHandle}`;
233266
@@ -269,7 +302,8 @@
269302
name: status.name,
270303
category_color: status.category_color,
271304
category_name: status.category_name,
272-
description: status.description
305+
description: status.description,
306+
fromAll: false
273307
}
274308
};
275309
@@ -306,6 +340,29 @@
306340
removeStatusFromWorkflow(event.detail.statusId);
307341
}
308342
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+
309366
async function saveWorkflowDesign() {
310367
if (!workflow) return;
311368
@@ -323,10 +380,17 @@
323380
workflow.id
324381
);
325382
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+
326390
// Add preservation transitions for disconnected statuses
327391
const allTransitions = addPreservationTransitions(
328-
currentStatuses,
329-
transitionsWithInitial,
392+
currentStatuses,
393+
transitionsWithAll,
330394
workflow.id
331395
);
332396
@@ -403,13 +467,15 @@
403467
<div class="text-xs hint-body">
404468
{t('workflows.transitionHint1')}<br/>
405469
{t('workflows.transitionHint2')}<br/>
406-
{t('workflows.transitionHint3')}
470+
{t('workflows.transitionHint3')}<br/>
471+
{t('workflows.transitionHint4')}
407472
</div>
408473
</div>
409474
<div class="space-y-3">
410475
{#each availableStatuses as status}
411476
<button
412477
type="button"
478+
data-testid={`workflow-status-option-${status.id}`}
413479
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"
414480
onclick={() => addStatusToWorkflow(status)}
415481
>
@@ -479,6 +545,20 @@
479545
stroke-width="1"
480546
/>
481547
</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>
482562
</defs>
483563
</svg>
484564
</SvelteFlow>
@@ -504,6 +584,7 @@
504584
{t('common.cancel')}
505585
</button>
506586
<button
587+
data-testid="workflow-save"
507588
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"
508589
onclick={saveWorkflowDesign}
509590
disabled={savingTransitions || nodes.length === 0}

frontend/src/lib/locales/ar/workflows.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ export default {
102102
setStart: 'تعيين البداية',
103103
removeFromWorkflow: 'إزالة من سير العمل',
104104
swapDirection: 'عكس الاتجاه',
105+
allStatuses: 'الكل',
106+
fromAllStatuses: 'السماح بالانتقالات من كل حالة أخرى',
107+
transitionHint4: 'حدد "الكل" على حالة للسماح بالانتقالات من كل حالة أخرى',
105108
},
106109

107110
screens: {

0 commit comments

Comments
 (0)