@@ -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 :
0 commit comments