Skip to content

Commit ce06f37

Browse files
committed
v0.18.0: Sub-workflow support, parallel stage fork/join, zoom fix
- Add sub-workflow node type to workflow builder (palette, properties, load/save via SubWorkflowDefinition model) - Render parallel approval stages (same order) as fork/join layout with auto-generated join nodes - Fix arrow/node zoom misalignment by using single transform wrapper - Add 'Helpful Info' label next to info icon in form instructions
1 parent 3eb45fb commit ce06f37

5 files changed

Lines changed: 356 additions & 40 deletions

File tree

django_forms_workflows/static/django_forms_workflows/js/workflow-builder.js

Lines changed: 173 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,20 @@ class WorkflowBuilder {
5555
this.canvas = document.getElementById('workflowCanvas');
5656
this.svg = document.getElementById('connectionsSvg');
5757

58+
// Create a single transform wrapper that holds both SVG and nodes.
59+
// Applying pan/zoom to one wrapper keeps arrows and nodes aligned.
60+
this.transformWrapper = document.createElement('div');
61+
this.transformWrapper.style.position = 'absolute';
62+
this.transformWrapper.style.top = '0';
63+
this.transformWrapper.style.left = '0';
64+
this.transformWrapper.style.width = '100%';
65+
this.transformWrapper.style.height = '100%';
66+
this.transformWrapper.style.transformOrigin = '0 0';
67+
68+
// Move SVG into the wrapper, then add wrapper to canvas
69+
this.canvas.appendChild(this.transformWrapper);
70+
this.transformWrapper.appendChild(this.svg);
71+
5872
// Make canvas droppable
5973
this.canvas.addEventListener('dragover', (e) => {
6074
e.preventDefault();
@@ -72,14 +86,16 @@ class WorkflowBuilder {
7286

7387
// Click on canvas background to deselect
7488
this.canvas.addEventListener('click', (e) => {
75-
if (e.target === this.canvas || e.target === this.svg) {
89+
if (e.target === this.canvas || e.target === this.svg
90+
|| e.target === this.transformWrapper) {
7691
this.deselectAll();
7792
}
7893
});
7994

8095
// ── Pan (middle-click or Ctrl+left-click on background) ─────────
8196
this.canvas.addEventListener('mousedown', (e) => {
8297
const isBackground = e.target === this.canvas || e.target === this.svg
98+
|| e.target === this.transformWrapper
8399
|| e.target.tagName === 'svg' || e.target.closest('.connections-svg');
84100
const shouldPan = isBackground && (e.button === 1 || (e.button === 0 && (e.ctrlKey || e.metaKey || e.shiftKey)));
85101
if (!shouldPan) return;
@@ -138,19 +154,10 @@ class WorkflowBuilder {
138154
return [x, y];
139155
}
140156

141-
/** Apply the current pan/zoom transform to nodes and SVG. */
157+
/** Apply the current pan/zoom transform to the single wrapper (nodes + SVG). */
142158
applyTransform() {
143159
const t = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`;
144-
// Apply to every node
145-
this.canvas.querySelectorAll('.workflow-node').forEach(el => {
146-
el.style.transformOrigin = '0 0';
147-
el.style.transform = t;
148-
});
149-
// Apply to SVG connections
150-
if (this.svg) {
151-
this.svg.style.transformOrigin = '0 0';
152-
this.svg.style.transform = t;
153-
}
160+
this.transformWrapper.style.transform = t;
154161
}
155162

156163
/** Update the zoom percentage indicator. */
@@ -376,6 +383,20 @@ class WorkflowBuilder {
376383
template: '',
377384
trigger: 'on_approve'
378385
};
386+
case 'sub_workflow':
387+
return {
388+
sub_workflow_def_id: null,
389+
sub_workflow_id: null,
390+
sub_workflow_name: '',
391+
count_field: '',
392+
label_template: 'Sub-workflow {index}',
393+
trigger: 'on_approval',
394+
data_prefix: '',
395+
detached: false,
396+
reject_parent: false,
397+
};
398+
case 'join':
399+
return {};
379400
case 'end':
380401
return {
381402
status: 'approved'
@@ -473,6 +494,14 @@ class WorkflowBuilder {
473494
html += this.buildEmailProperties(node);
474495
break;
475496

497+
case 'sub_workflow':
498+
html += this.buildSubWorkflowProperties(node);
499+
break;
500+
501+
case 'join':
502+
html += '<div class="alert alert-secondary"><i class="bi bi-info-circle"></i> This join node automatically merges parallel approval stages. It cannot be edited or removed independently.</div>';
503+
break;
504+
476505
case 'end':
477506
html += this.buildEndProperties(node);
478507
break;
@@ -585,6 +614,7 @@ class WorkflowBuilder {
585614
<input type="number" class="form-control" name="order" min="1"
586615
value="${data.order || 1}"
587616
onchange="workflowBuilder.updateStageConfig('${node.id}')" />
617+
<small class="text-muted">Stages with the same order number run in parallel (fork/join).</small>
588618
</div>
589619
590620
<div class="mb-3">
@@ -1032,6 +1062,118 @@ class WorkflowBuilder {
10321062
`;
10331063
}
10341064

1065+
buildSubWorkflowProperties(node) {
1066+
const data = node.data || {};
1067+
1068+
// Build workflow options from this.forms (available workflows)
1069+
let workflowOptions = '<option value="">-- Select a workflow --</option>';
1070+
this.forms.forEach(f => {
1071+
const selected = (data.sub_workflow_id == f.id) ? 'selected' : '';
1072+
workflowOptions += `<option value="${f.id}" ${selected}>${this.escapeHtml(f.name)} (${f.field_count} fields)</option>`;
1073+
});
1074+
1075+
// Build count field options from this.fields
1076+
let fieldOptions = '<option value="">-- Select a field --</option>';
1077+
this.fields.forEach(f => {
1078+
const selected = (data.count_field === f.field_name) ? 'selected' : '';
1079+
fieldOptions += `<option value="${f.field_name}" ${selected}>${this.escapeHtml(f.field_label)} (${f.field_name})</option>`;
1080+
});
1081+
1082+
return `
1083+
<div class="alert alert-info">
1084+
<i class="bi bi-info-circle"></i> Configure a sub-workflow that spawns child approval processes based on a form field value.
1085+
</div>
1086+
1087+
<div class="mb-3">
1088+
<label class="form-label"><strong>Target Workflow</strong></label>
1089+
<select class="form-select" name="sub_workflow_id"
1090+
onchange="workflowBuilder.updateSubWorkflowConfig('${node.id}')">
1091+
${workflowOptions}
1092+
</select>
1093+
<small class="text-muted">The workflow definition used for each sub-workflow instance</small>
1094+
</div>
1095+
1096+
<div class="mb-3">
1097+
<label class="form-label"><strong>Count Field</strong></label>
1098+
<select class="form-select" name="count_field"
1099+
onchange="workflowBuilder.updateSubWorkflowConfig('${node.id}')">
1100+
${fieldOptions}
1101+
</select>
1102+
<small class="text-muted">Form field whose value determines how many sub-workflows to spawn</small>
1103+
</div>
1104+
1105+
<div class="mb-3">
1106+
<label class="form-label"><strong>Label Template</strong></label>
1107+
<input type="text" class="form-control" name="label_template"
1108+
value="${this.escapeHtml(data.label_template || 'Sub-workflow {index}')}"
1109+
onchange="workflowBuilder.updateSubWorkflowConfig('${node.id}')" />
1110+
<small class="text-muted">Use {index} as placeholder (e.g. "Payment {index}")</small>
1111+
</div>
1112+
1113+
<div class="mb-3">
1114+
<label class="form-label"><strong>Trigger</strong></label>
1115+
<select class="form-select" name="trigger"
1116+
onchange="workflowBuilder.updateSubWorkflowConfig('${node.id}')">
1117+
<option value="on_submission" ${data.trigger === 'on_submission' ? 'selected' : ''}>On Submission</option>
1118+
<option value="on_approval" ${data.trigger === 'on_approval' ? 'selected' : ''}>After Parent Approval</option>
1119+
</select>
1120+
</div>
1121+
1122+
<div class="mb-3">
1123+
<label class="form-label"><strong>Data Prefix</strong></label>
1124+
<input type="text" class="form-control" name="data_prefix"
1125+
value="${this.escapeHtml(data.data_prefix || '')}"
1126+
onchange="workflowBuilder.updateSubWorkflowConfig('${node.id}')" />
1127+
<small class="text-muted">Field prefix to scope data per instance (e.g. "payment" matches payment_type_1, payment_amount_1 …)</small>
1128+
</div>
1129+
1130+
<div class="mb-3">
1131+
<div class="form-check form-switch">
1132+
<input class="form-check-input" type="checkbox" id="sub_wf_detached_${node.id}"
1133+
name="detached" ${data.detached ? 'checked' : ''}
1134+
onchange="workflowBuilder.updateSubWorkflowConfig('${node.id}')">
1135+
<label class="form-check-label" for="sub_wf_detached_${node.id}">
1136+
<strong>Detached</strong>
1137+
</label>
1138+
</div>
1139+
<small class="text-muted">When enabled, sub-workflows run independently and don't affect parent status</small>
1140+
</div>
1141+
1142+
<div class="mb-3">
1143+
<div class="form-check form-switch">
1144+
<input class="form-check-input" type="checkbox" id="sub_wf_reject_parent_${node.id}"
1145+
name="reject_parent" ${data.reject_parent ? 'checked' : ''}
1146+
onchange="workflowBuilder.updateSubWorkflowConfig('${node.id}')">
1147+
<label class="form-check-label" for="sub_wf_reject_parent_${node.id}">
1148+
<strong>Reject Parent on Failure</strong>
1149+
</label>
1150+
</div>
1151+
<small class="text-muted">When enabled, rejecting any sub-workflow rejects the parent and cancels siblings</small>
1152+
</div>
1153+
`;
1154+
}
1155+
1156+
updateSubWorkflowConfig(nodeId) {
1157+
const node = this.nodes.find(n => n.id === nodeId);
1158+
if (!node) return;
1159+
1160+
const container = document.getElementById('propertiesContent');
1161+
const subWfSelect = container.querySelector('select[name="sub_workflow_id"]');
1162+
node.data.sub_workflow_id = subWfSelect.value ? parseInt(subWfSelect.value) : null;
1163+
const selectedOption = subWfSelect.selectedOptions[0];
1164+
node.data.sub_workflow_name = selectedOption && selectedOption.value ? selectedOption.text : '';
1165+
1166+
node.data.count_field = container.querySelector('select[name="count_field"]').value;
1167+
node.data.label_template = container.querySelector('input[name="label_template"]').value;
1168+
node.data.trigger = container.querySelector('select[name="trigger"]').value;
1169+
node.data.data_prefix = container.querySelector('input[name="data_prefix"]').value;
1170+
node.data.detached = container.querySelector(`#sub_wf_detached_${nodeId}`).checked;
1171+
node.data.reject_parent = container.querySelector(`#sub_wf_reject_parent_${nodeId}`).checked;
1172+
1173+
this.render();
1174+
this.selectNode(nodeId);
1175+
}
1176+
10351177
buildEndProperties(node) {
10361178
const data = node.data || {};
10371179
return `
@@ -1181,6 +1323,8 @@ class WorkflowBuilder {
11811323
condition: 'Condition',
11821324
action: 'Action',
11831325
email: 'Email Notification',
1326+
sub_workflow: 'Sub-Workflow',
1327+
join: 'Join',
11841328
end: 'End'
11851329
};
11861330
return labels[type] || type;
@@ -1202,13 +1346,13 @@ class WorkflowBuilder {
12021346
renderNodes() {
12031347
console.log('Rendering nodes...');
12041348
// Remove existing nodes
1205-
this.canvas.querySelectorAll('.workflow-node').forEach(n => n.remove());
1349+
this.transformWrapper.querySelectorAll('.workflow-node').forEach(n => n.remove());
12061350

1207-
// Render each node
1351+
// Render each node into the transform wrapper (alongside the SVG)
12081352
this.nodes.forEach(node => {
12091353
console.log('Creating node element for:', node);
12101354
const nodeEl = this.createNodeElement(node);
1211-
this.canvas.appendChild(nodeEl);
1355+
this.transformWrapper.appendChild(nodeEl);
12121356
});
12131357
console.log('Nodes rendered');
12141358
}
@@ -1238,6 +1382,7 @@ class WorkflowBuilder {
12381382
// - all others: deletable
12391383
const canDelete = node.type !== 'start' &&
12401384
node.type !== 'workflow_settings' &&
1385+
node.type !== 'join' &&
12411386
!(node.type === 'form' && node.data.is_initial !== false);
12421387

12431388
div.innerHTML = `
@@ -1309,6 +1454,8 @@ class WorkflowBuilder {
13091454
condition: 'diagram-3',
13101455
action: 'lightning',
13111456
email: 'envelope',
1457+
sub_workflow: 'diagram-2',
1458+
join: 'sign-merge-right',
13121459
end: 'flag'
13131460
};
13141461
return icons[type] || 'circle';
@@ -1403,6 +1550,17 @@ class WorkflowBuilder {
14031550
return node.data.action_type ? `${node.data.action_type.toUpperCase()}: ${node.data.trigger || ''}` : 'Configure action';
14041551
case 'email':
14051552
return node.data.to ? `Send to: ${node.data.to}` : 'Configure email';
1553+
case 'join':
1554+
return '<small class="text-muted">Parallel stages merge here</small>';
1555+
case 'sub_workflow':
1556+
const swParts = [];
1557+
if (node.data.sub_workflow_name) swParts.push(node.data.sub_workflow_name);
1558+
if (node.data.count_field) swParts.push(`Count: ${node.data.count_field}`);
1559+
if (node.data.trigger) swParts.push(node.data.trigger === 'on_approval' ? 'After approval' : 'On submission');
1560+
if (node.data.detached) swParts.push('Detached');
1561+
return swParts.length > 0 ?
1562+
`<span class="badge bg-info">Sub-Workflow</span><br><small class="text-muted">${swParts.join(' • ')}</small>` :
1563+
'<span class="badge bg-secondary">Sub-Workflow</span><br><small class="text-muted">Not configured</small>';
14061564
case 'end':
14071565
return 'Workflow end';
14081566
default:

django_forms_workflows/templates/admin/django_forms_workflows/workflow_builder.html

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
.palette-node.condition { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); }
6565
.palette-node.action { background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); }
6666
.palette-node.email { background: linear-gradient(135deg, #fa8bff 0%, #2bd2ff 100%); }
67+
.palette-node.sub_workflow { background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%); }
6768
.palette-node.end { background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); }
6869

6970
/* Workflow Canvas */
@@ -158,6 +159,20 @@
158159
background: linear-gradient(135deg, #f0f9ff 0%, #e6f5ff 100%);
159160
}
160161

162+
.workflow-node.sub_workflow {
163+
border-color: #a18cd1;
164+
background: linear-gradient(135deg, #f5f0ff 0%, #fdf2ff 100%);
165+
min-width: 220px;
166+
}
167+
168+
.workflow-node.join {
169+
border-color: #6c757d;
170+
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
171+
min-width: 100px;
172+
max-width: 140px;
173+
text-align: center;
174+
}
175+
161176
.workflow-node.end {
162177
border-color: #fee140;
163178
background: linear-gradient(135deg, #fffef0 0%, #fffce6 100%);
@@ -190,6 +205,8 @@
190205
.node-icon.condition { background: #00f2fe; color: white; }
191206
.node-icon.action { background: #38f9d7; color: white; }
192207
.node-icon.email { background: #2bd2ff; color: white; }
208+
.node-icon.sub_workflow { background: #a18cd1; color: white; }
209+
.node-icon.join { background: #6c757d; color: white; }
193210
.node-icon.end { background: #fee140; color: #333; }
194211

195212
.node-content {
@@ -459,6 +476,11 @@
459476
<span>Email</span>
460477
</div>
461478

479+
<div class="palette-node sub_workflow" draggable="true" data-node-type="sub_workflow">
480+
<i class="bi bi-diagram-2"></i>
481+
<span>Sub-Workflow</span>
482+
</div>
483+
462484
<div class="palette-node end" draggable="true" data-node-type="end">
463485
<i class="bi bi-flag"></i>
464486
<span>End</span>

django_forms_workflows/templates/django_forms_workflows/form_submit.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ <h1>{{ form_def.name }}</h1>
1515
</div>
1616
{% if form_def.instructions %}
1717
<div class="alert alert-info">
18-
<i class="bi bi-info-circle"></i>
18+
<i class="bi bi-info-circle"></i> <strong>Helpful Info</strong>
1919
{{ form_def.instructions|safe }}
2020
</div>
2121
{% endif %}

0 commit comments

Comments
 (0)