@@ -33,10 +33,45 @@ Joomla = window.Joomla || {};
3333 return makeRequest ( `&task=graph.getTransitions&workflow_id=${ workflowId } &format=json` ) ;
3434 }
3535
36+ function filterWorkflow ( stages , transitions ) {
37+ // Step 1: Filter transitions by run permission
38+ let filteredTransitions = transitions . filter ( tr => tr . permissions ?. run_transition ) ;
39+
40+ // Step 2: Collect stage IDs that are connected by accessible transitions
41+ const connectedStageIds = new Set ( ) ;
42+ filteredTransitions . forEach ( tr => {
43+ if ( tr . from_stage_id !== - 1 ) connectedStageIds . add ( tr . from_stage_id ) ;
44+ connectedStageIds . add ( tr . to_stage_id ) ;
45+ } ) ;
46+
47+ // Step 3: Filter stages by edit/delete permission OR connectivity
48+ let filteredStages = stages . filter ( st => {
49+ const editable = st . permissions ?. edit || st . permissions ?. delete ;
50+ const connected = connectedStageIds . has ( st . id ) ;
51+ return editable || connected ;
52+ } ) ;
53+
54+ // Step 4: Remove transitions pointing to removed stages
55+ const validStageIds = new Set ( filteredStages . map ( st => st . id ) ) ;
56+ filteredTransitions = filteredTransitions . filter ( tr =>
57+ ( tr . from_stage_id === - 1 || validStageIds . has ( tr . from_stage_id ) ) &&
58+ validStageIds . has ( tr . to_stage_id )
59+ ) ;
60+
61+ return { stages : filteredStages , transitions : filteredTransitions } ;
62+ }
63+
64+
3665 function calculateAutoLayout ( stages , transitions ) {
3766 const needsPosition = stages . filter ( ( stage ) => ! stage . position || isNaN ( stage . position . x ) || isNaN ( stage . position . y ) ) ;
3867 if ( needsPosition . length === 0 ) return stages ;
3968
69+ // Place "From Any" at fixed position if present
70+ const fromAnyStage = stages . find ( ( s ) => s . id === 'From Any' ) ;
71+ if ( fromAnyStage && ( ! fromAnyStage . position || isNaN ( fromAnyStage . position . x ) || isNaN ( fromAnyStage . position . y ) ) ) {
72+ fromAnyStage . position = { x : 600 , y : - 200 } ;
73+ }
74+
4075 const outgoing = new Map ( stages . map ( ( s ) => [ s . id , [ ] ] ) ) ;
4176 const inDegree = new Map ( stages . map ( ( s ) => [ s . id , 0 ] ) ) ;
4277
@@ -50,14 +85,14 @@ Joomla = window.Joomla || {};
5085 } ) ;
5186
5287 const levels = [ ] ;
53- let queue = stages . filter ( ( s ) => inDegree . get ( s . id ) === 0 ) ;
88+ let queue = stages . filter ( ( s ) => inDegree . get ( s . id ) === 0 && s . id !== 'From Any' ) ;
5489 while ( queue . length > 0 ) {
5590 levels . push ( queue ) ;
5691 const nextQueue = [ ] ;
5792 for ( const stage of queue ) {
5893 for ( const targetId of outgoing . get ( stage . id ) || [ ] ) {
5994 inDegree . set ( targetId , inDegree . get ( targetId ) - 1 ) ;
60- if ( inDegree . get ( targetId ) === 0 ) {
95+ if ( inDegree . get ( targetId ) === 0 && targetId !== 'From Any' ) {
6196 nextQueue . push ( stages . find ( ( s ) => s . id === targetId ) ) ;
6297 }
6398 }
@@ -90,7 +125,7 @@ Joomla = window.Joomla || {};
90125 stage . position . y = parseFloat ( stage . position . y ) ;
91126 }
92127 } ) ;
93-
128+
94129 const hasStart = transitions . some ( ( tr ) => tr . from_stage_id === - 1 ) ;
95130 if ( hasStart && ! stages . find ( ( s ) => s . id === 'From Any' ) ) stages . unshift ( { id : 'From Any' , title : 'From Any' } ) ;
96131
@@ -104,9 +139,14 @@ Joomla = window.Joomla || {};
104139 data : stage ,
105140 className : `stage ${ stage . default ? 'default' : '' } ${ isVirtual ? 'virtual' : '' } ` ,
106141 innerHTML : `
107- <div class="stage-title">${ stage . title } </div>
108- ${ stage . description ? `<div class="stage-description">${ stage . description } </div>` : '' }
109- ${ stage . default ? '<div class="badge bg-warning bg-opacity-10 rounded-pill p-1">DEFAULT</div>' : '' }
142+ <div class="stage-title text-truncate" style="max-width: 180px;" title="${ stage . title } ">${ stage . title } </div>
143+ ${ stage . description ? `<div class="stage-description text-truncate small text-white" style="max-width: 180px;" title="${ stage . description } ">${ stage . description } </div>` : '' }
144+ <div style="display: flex; gap: 4px; align-items: center; margin-top: 2px;">
145+ ${ stage . default ? '<div class="badge bg-warning bg-opacity-10 rounded-pill p-1">DEFAULT</div>' : '' }
146+ ${ typeof stage . published !== 'undefined'
147+ ? `<div class="badge ${ stage . published == 1 ? 'bg-success' : 'bg-warning' } rounded-pill p-1">${ stage . published == 1 ? 'ENABLED' : 'DISABLED' } </div>`
148+ : '' }
149+ </div>
110150 ` ,
111151 } ;
112152 } ) ;
@@ -117,16 +157,16 @@ Joomla = window.Joomla || {};
117157 */
118158 function generateEdges ( transitions , stages ) {
119159 const STAGE_WIDTH = 200 ;
120- const STAGE_HEIGHT = 80 ;
160+ const STAGE_HEIGHT = 100 ;
121161
122162 const getConnectionPoint = ( fromStage , toStage , isSource ) => {
123163 const node = isSource ? fromStage : toStage ;
124164 const center = { x : node . position . x + STAGE_WIDTH / 2 , y : node . position . y + STAGE_HEIGHT / 2 } ;
125- const otherCenter = {
126- x : ( isSource ? toStage : fromStage ) . position . x + STAGE_WIDTH / 2 ,
127- y : ( isSource ? toStage : fromStage ) . position . y + STAGE_HEIGHT / 2
165+ const otherCenter = {
166+ x : ( isSource ? toStage : fromStage ) . position . x + STAGE_WIDTH / 2 ,
167+ y : ( isSource ? toStage : fromStage ) . position . y + STAGE_HEIGHT / 2
128168 } ;
129-
169+
130170 const dx = otherCenter . x - center . x ;
131171 const dy = otherCenter . y - center . y ;
132172
@@ -174,8 +214,6 @@ Joomla = window.Joomla || {};
174214
175215 function renderNodes ( nodes , container , onDrag ) {
176216 container . innerHTML = '' ;
177- const STAGE_WIDTH = 200 ;
178- const STAGE_HEIGHT = 80 ;
179217
180218 nodes . forEach ( ( node ) => {
181219 const div = document . createElement ( 'div' ) ;
@@ -184,39 +222,65 @@ Joomla = window.Joomla || {};
184222 div . innerHTML = node . innerHTML ;
185223 div . style . left = `${ node . position . x } px` ;
186224 div . style . top = `${ node . position . y } px` ;
187-
225+
188226 div . addEventListener ( 'mousedown' , ( e ) => {
189227 if ( e . button !== 0 ) return ;
190228 e . stopPropagation ( ) ;
191229 onDrag ( e , node . data ) ;
192230 } ) ;
193-
231+
194232 container . appendChild ( div ) ;
195233 } ) ;
196234 }
197-
235+
236+ function highlightTransition ( edgeId ) {
237+ // Reset all first
238+ document . querySelectorAll ( '.transition-path' ) . forEach ( p => {
239+ p . classList . remove ( 'highlighted' ) ;
240+ } ) ;
241+ document . querySelectorAll ( '.transition-label-content' ) . forEach ( l => {
242+ l . classList . remove ( 'highlighted' ) ;
243+ } ) ;
244+
245+ // Highlight selected
246+ const path = document . querySelector ( `.transition-path[data-edge-id="${ edgeId } "]` ) ;
247+ const label = document . querySelector ( `.transition-label-content[data-edge-id="${ edgeId } "]` ) ;
248+
249+ if ( path ) path . classList . add ( 'highlighted' ) ;
250+ if ( label ) label . classList . add ( 'highlighted' ) ;
251+ }
252+
253+
198254 function renderEdges ( edges , svg ) {
199255 svg . querySelectorAll ( 'path, foreignObject' ) . forEach ( ( el ) => el . remove ( ) ) ;
200-
256+
201257 edges . forEach ( ( edge ) => {
202258 const path = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'path' ) ;
203259 path . setAttribute ( 'd' , edge . pathData ) ;
204260 path . setAttribute ( 'class' , 'transition-path' ) ;
261+ path . setAttribute ( 'data-edge-id' , edge . id ) ; // track edge
205262 path . setAttribute ( 'marker-end' , 'url(#arrowhead)' ) ;
206- svg . appendChild ( path ) ;
207263
208264 const foreignObject = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'foreignObject' ) ;
209265 foreignObject . setAttribute ( 'class' , 'transition-label' ) ;
210266 foreignObject . setAttribute ( 'width' , '120' ) ;
211267 foreignObject . setAttribute ( 'height' , '24' ) ;
212268 foreignObject . setAttribute ( 'x' , edge . labelPosition . x - 60 ) ;
213269 foreignObject . setAttribute ( 'y' , edge . labelPosition . y - 12 ) ;
214-
270+
215271 const labelDiv = document . createElement ( 'div' ) ;
216272 labelDiv . className = 'transition-label-content' ;
217273 labelDiv . textContent = edge . label ;
274+ labelDiv . dataset . edgeId = edge . id ;
275+ labelDiv . addEventListener ( 'click' , ( e ) => {
276+ e . stopPropagation ( ) ;
277+ highlightTransition ( edge . id ) ;
278+ } ) ;
279+
218280 foreignObject . appendChild ( labelDiv ) ;
281+ svg . appendChild ( path ) ;
219282 svg . appendChild ( foreignObject ) ;
283+
220284 } ) ;
221285 }
222286
@@ -249,16 +313,16 @@ Joomla = window.Joomla || {};
249313 // Pan & Zoom state
250314 svg . innerHTML = `
251315 <defs>
252- <marker
253- id="arrowhead"
254- markerWidth="12 "
255- markerHeight="12 "
256- refX="11 "
257- refY="6 "
258- orient="auto"
259- markerUnits="strokeWidth">
260- <polygon points="0 0, 12 6 , 0 12 " class="arrow-marker" />
261- </marker>
316+ <marker
317+ id="arrowhead"
318+ markerWidth="8 "
319+ markerHeight="8 "
320+ refX="7 "
321+ refY="4 "
322+ orient="auto"
323+ markerUnits="strokeWidth">
324+ <polygon points="0 0, 8 4 , 0 8 " class="arrow-marker" />
325+ </marker>
262326 </defs>` ;
263327 let state = { stages : [ ] , transitions : [ ] , scale : 1 , panX : 0 , panY : 0 , isDraggingStage : false } ;
264328
@@ -280,18 +344,18 @@ Joomla = window.Joomla || {};
280344 stageY : draggedStage . position . y ,
281345 } ;
282346 stageElement . classList . add ( 'dragging' ) ;
283-
347+
284348 const onMouseMove = ( moveEvent ) => {
285349 draggedStage . position . x = dragStart . stageX + ( moveEvent . clientX - dragStart . x ) / state . scale ;
286350 draggedStage . position . y = dragStart . stageY + ( moveEvent . clientY - dragStart . y ) / state . scale ;
287-
351+
288352 stageElement . style . left = `${ draggedStage . position . x } px` ;
289353 stageElement . style . top = `${ draggedStage . position . y } px` ;
290354
291355 const edges = generateEdges ( state . transitions , state . stages ) ;
292356 renderEdges ( edges , svg ) ;
293357 } ;
294-
358+
295359 const onMouseUp = ( ) => {
296360 document . removeEventListener ( 'mousemove' , onMouseMove ) ;
297361 document . removeEventListener ( 'mouseup' , onMouseUp ) ;
@@ -347,16 +411,25 @@ Joomla = window.Joomla || {};
347411
348412 updateTransform ( ) ;
349413 }
350-
414+
351415
352416 Promise . all ( [
353417 getWorkflow ( workflowId ) ,
354418 getStages ( workflowId ) ,
355419 getTransitions ( workflowId )
356420 ] ) . then ( ( [ workflowData , stagesData , transitionsData ] ) => {
357421 const workflow = workflowData ?. data || { } ;
358- state . stages = stagesData ?. data || [ ] ;
359- state . transitions = transitionsData ?. data || [ ] ;
422+ let stages = stagesData ?. data || [ ] ;
423+ let transitions = transitionsData ?. data || [ ] ;
424+
425+ ( { stages, transitions } = filterWorkflow ( stages , transitions ) ) ;
426+
427+ console . log ( 'stages:' , stages ) ;
428+ console . log ( 'transitions:' , transitions ) ;
429+ state . stages = stages ;
430+ state . transitions = transitions ;
431+
432+
360433
361434 if ( ! state . stages . length ) {
362435 stageContainer . innerHTML = "<p>No stages defined.</p>" ;
@@ -396,9 +469,59 @@ Joomla = window.Joomla || {};
396469 zoomControls = document . createElement ( 'div' ) ;
397470 zoomControls . className = 'zoom-controls' ;
398471 zoomControls . innerHTML = `
399- <button class="zoom-btn zoom-in" title="Zoom In (+)">+</button>
400- <button class="zoom-btn zoom-out" title="Zoom Out (-)">−</button>
401- <button class="zoom-btn fit-screen" title="Fit to Screen (F)">⌂</button>
472+ <div
473+ ref="controlsContainer"
474+ class="custom-controls z-10"
475+ role="group"
476+ aria-labelledby="canvas-controls-title"
477+ >
478+ <h2 id="canvas-controls-title" class="visually-hidden">Canvas View Controls</h2>
479+
480+ <ul class="d-flex flex-column gap-1 list-unstyled mb-0" role="group">
481+ <li>
482+ <button
483+ class="zoom-btn zoom-in"
484+ tabindex="0"
485+ type="button"
486+ aria-label="Zoom in"
487+ title="Zoom in (+ key)"
488+ >
489+ <span class="icon icon-plus" aria-hidden="true" />
490+ <span class="visually-hidden">Zoom In</span>
491+ </button>
492+ </li>
493+ <li>
494+ <button
495+ class="zoom-btn zoom-out"
496+ tabindex="0"
497+ type="button"
498+ aria-label="Zoom out"
499+ title="Zoom out (- key)"
500+ @click="zoomOut"
501+ @keydown.enter="zoomOut"
502+ @keydown.space.prevent="zoomOut"
503+ >
504+ <span class="icon icon-minus" aria-hidden="true" />
505+ <span class="visually-hidden">Zoom Out</span>
506+ </button>
507+ </li>
508+ <li>
509+ <button
510+ class="zoom-btn fit-screen"
511+ tabindex="0"
512+ type="button"
513+ aria-label="Fit view"
514+ title="Fit view (F key)"
515+ @click="customFitView"
516+ @keydown.enter="customFitView"
517+ @keydown.space.prevent="customFitView"
518+ >
519+ <span class="icon icon-expand" aria-hidden="true" />
520+ <span class="visually-hidden">Fit View</span>
521+ </button>
522+ </li>
523+ </ul>
524+ </div>
402525 ` ;
403526 container . appendChild ( zoomControls ) ;
404527
0 commit comments