Skip to content

Commit c9c2ab3

Browse files
Copilotcodingjoe
andcommitted
Address review feedback: quote node IDs, fix edge duplication, include MermaidJS in output
- Quote all node IDs with single quotes to handle reserved keywords (e.g., 'end') - Include MermaidJS script directly in display_workflow_diagram output - Use mark_safe() to prevent Django from escaping HTML - Remove get_graph_mermaid() method (not needed) - Fix edge duplication by tracking edges in a dict and only adding once - Update edge styling to replace (not duplicate) gray default with active styling - Update tests to match new quoted node ID format - Remove tests for deleted get_graph_mermaid() method Co-authored-by: codingjoe <[email protected]>
1 parent b2fcced commit c9c2ab3

File tree

3 files changed

+79
-113
lines changed

3 files changed

+79
-113
lines changed

joeflow/admin.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from django.contrib.auth import get_permission_codename
33
from django.db import transaction
44
from django.utils.html import format_html
5+
from django.utils.safestring import mark_safe
56
from django.utils.translation import gettext_lazy as t
67

78
from . import forms, models
@@ -159,11 +160,17 @@ def display_workflow_diagram(self, obj):
159160
if obj.pk:
160161
# Get Mermaid diagram syntax
161162
mermaid_syntax = obj.get_instance_graph_mermaid()
162-
# Wrap in div with mermaid class for client-side rendering
163-
return format_html(
164-
'<div class="mermaid-diagram"><div class="mermaid">{}</div></div>',
165-
mermaid_syntax
166-
)
163+
# Include MermaidJS script and wrap diagram
164+
html = f"""
165+
<script type="module">
166+
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
167+
mermaid.initialize({{ startOnLoad: true }});
168+
</script>
169+
<div class="mermaid-diagram">
170+
<div class="mermaid">{mermaid_syntax}</div>
171+
</div>
172+
"""
173+
return mark_safe(html)
167174
return ""
168175

169176
@transaction.atomic()

joeflow/models.py

Lines changed: 58 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -242,40 +242,6 @@ def get_graph_svg(cls):
242242

243243
get_graph_svg.short_description = t("graph")
244244

245-
@classmethod
246-
def get_graph_mermaid(cls, color="black"):
247-
"""
248-
Return workflow graph as Mermaid diagram syntax.
249-
250-
This can be used with MermaidJS for client-side rendering in browsers.
251-
252-
Returns:
253-
(str): Mermaid diagram syntax.
254-
"""
255-
lines = [f"graph {cls.rankdir}"]
256-
257-
# Add nodes
258-
for name, node in cls.get_nodes():
259-
node_id = name.replace(" ", "_")
260-
# Keep original name with spaces for label
261-
label = name.replace("_", " ")
262-
263-
# Determine shape based on node type
264-
if node.type == HUMAN:
265-
# Rounded rectangle for human tasks
266-
lines.append(f" {node_id}({label})")
267-
else:
268-
# Rectangle for machine tasks
269-
lines.append(f" {node_id}[{label}]")
270-
271-
# Add edges
272-
for start, end in cls.edges:
273-
start_id = start.name.replace(" ", "_")
274-
end_id = end.name.replace(" ", "_")
275-
lines.append(f" {start_id} --> {end_id}")
276-
277-
return "\n".join(lines)
278-
279245
def get_instance_graph(self):
280246
"""Return workflow instance graph."""
281247
graph = self.get_graph(color="#888888")
@@ -377,8 +343,8 @@ def get_instance_graph_mermaid(self):
377343
"""
378344
lines = [f"graph {self.rankdir}"]
379345
node_styles = []
380-
edge_styles = []
381-
edge_index = 0
346+
edge_styles = {} # Map of (start, end) -> style
347+
edge_list = [] # List to maintain order of edges
382348

383349
names = dict(self.get_nodes()).keys()
384350

@@ -388,40 +354,43 @@ def get_instance_graph_mermaid(self):
388354
# Keep original name with spaces for label
389355
label = name.replace("_", " ")
390356

391-
# Determine shape based on node type
357+
# Determine shape based on node type, quote IDs to handle reserved words
392358
if node.type == HUMAN:
393-
lines.append(f" {node_id}({label})")
359+
lines.append(f" '{node_id}'({label})")
394360
else:
395-
lines.append(f" {node_id}[{label}]")
361+
lines.append(f" '{node_id}'[{label}]")
396362

397363
# Default gray styling for nodes not yet processed
398-
node_styles.append(f" style {node_id} fill:#f9f9f9,stroke:#999,color:#999")
364+
node_styles.append(f" style '{node_id}' fill:#f9f9f9,stroke:#999,color:#999")
399365

400-
# Add edges from workflow definition (gray style)
366+
# Add edges from workflow definition with default gray style
401367
for start, end in self.edges:
402368
start_id = start.name.replace(" ", "_")
403369
end_id = end.name.replace(" ", "_")
404-
lines.append(f" {start_id} --> {end_id}")
405-
edge_styles.append(f" linkStyle {edge_index} stroke:#999")
406-
edge_index += 1
370+
edge_key = (start_id, end_id)
371+
if edge_key not in edge_styles:
372+
edge_list.append(edge_key)
373+
edge_styles[edge_key] = "stroke:#999"
407374

408375
# Process actual tasks to highlight active/completed states
409376
for task in self.task_set.filter(name__in=names):
410377
node_id = task.name.replace(" ", "_")
411378

412379
# Active tasks (not completed) get bold black styling
413380
if not task.completed:
414-
node_styles.append(f" style {node_id} fill:#fff,stroke:#000,stroke-width:3px,color:#000")
381+
node_styles.append(f" style '{node_id}' fill:#fff,stroke:#000,stroke-width:3px,color:#000")
415382
else:
416383
# Completed tasks get normal black styling
417-
node_styles.append(f" style {node_id} fill:#fff,stroke:#000,stroke-width:2px,color:#000")
384+
node_styles.append(f" style '{node_id}' fill:#fff,stroke:#000,stroke-width:2px,color:#000")
418385

419-
# Add edges for actual task connections (black style)
386+
# Update edge styling for actual task connections (black style)
420387
for child in task.child_task_set.exclude(name="override"):
421388
child_id = child.name.replace(" ", "_")
422-
lines.append(f" {node_id} --> {child_id}")
423-
edge_styles.append(f" linkStyle {edge_index} stroke:#000,stroke-width:2px")
424-
edge_index += 1
389+
edge_key = (node_id, child_id)
390+
if edge_key not in edge_styles:
391+
edge_list.append(edge_key)
392+
# Update styling to black (overrides gray)
393+
edge_styles[edge_key] = "stroke:#000,stroke-width:2px"
425394

426395
# Handle override tasks
427396
for task in self.task_set.filter(name="override").prefetch_related(
@@ -430,57 +399,75 @@ def get_instance_graph_mermaid(self):
430399
override_id = f"override_{task.pk}"
431400
override_label = f"override {task.pk}"
432401

433-
# Add override node with dashed style
434-
lines.append(f" {override_id}({override_label})")
435-
node_styles.append(f" style {override_id} fill:#fff,stroke:#000,stroke-width:2px,stroke-dasharray:5 5,color:#000")
402+
# Add override node with dashed style, quote ID
403+
lines.append(f" '{override_id}'({override_label})")
404+
node_styles.append(f" style '{override_id}' fill:#fff,stroke:#000,stroke-width:2px,stroke-dasharray:5 5,color:#000")
436405

437406
# Add dashed edges for override connections
438407
for parent in task.parent_task_set.all():
439408
parent_id = parent.name.replace(" ", "_")
440-
lines.append(f" {parent_id} -.-> {override_id}")
441-
edge_styles.append(f" linkStyle {edge_index} stroke:#000,stroke-dasharray:5 5")
442-
edge_index += 1
409+
edge_key = (parent_id, override_id)
410+
if edge_key not in edge_styles:
411+
edge_list.append(edge_key)
412+
edge_styles[edge_key] = "stroke:#000,stroke-dasharray:5 5"
443413

444414
for child in task.child_task_set.all():
445415
child_id = child.name.replace(" ", "_")
446-
lines.append(f" {override_id} -.-> {child_id}")
447-
edge_styles.append(f" linkStyle {edge_index} stroke:#000,stroke-dasharray:5 5")
448-
edge_index += 1
416+
edge_key = (override_id, child_id)
417+
if edge_key not in edge_styles:
418+
edge_list.append(edge_key)
419+
edge_styles[edge_key] = "stroke:#000,stroke-dasharray:5 5"
449420

450421
# Handle obsolete/custom tasks (not in workflow definition)
451422
for task in self.task_set.exclude(name__in=names).exclude(name="override"):
452423
node_id = task.name.replace(" ", "_")
453424
# Keep original name with spaces for label
454425
label = task.name.replace("_", " ")
455426

456-
# Determine shape based on node type
427+
# Determine shape based on node type, quote IDs
457428
if task.type == HUMAN:
458-
lines.append(f" {node_id}({label})")
429+
lines.append(f" '{node_id}'({label})")
459430
else:
460-
lines.append(f" {node_id}[{label}]")
431+
lines.append(f" '{node_id}'[{label}]")
461432

462433
# Dashed styling for obsolete tasks
463434
if not task.completed:
464-
node_styles.append(f" style {node_id} fill:#fff,stroke:#000,stroke-width:3px,stroke-dasharray:5 5,color:#000")
435+
node_styles.append(f" style '{node_id}' fill:#fff,stroke:#000,stroke-width:3px,stroke-dasharray:5 5,color:#000")
465436
else:
466-
node_styles.append(f" style {node_id} fill:#fff,stroke:#000,stroke-width:2px,stroke-dasharray:5 5,color:#000")
437+
node_styles.append(f" style '{node_id}' fill:#fff,stroke:#000,stroke-width:2px,stroke-dasharray:5 5,color:#000")
467438

468439
# Add dashed edges for obsolete task connections
469440
for parent in task.parent_task_set.all():
470441
parent_id = parent.name.replace(" ", "_")
471-
lines.append(f" {parent_id} -.-> {node_id}")
472-
edge_styles.append(f" linkStyle {edge_index} stroke:#000,stroke-dasharray:5 5")
473-
edge_index += 1
442+
edge_key = (parent_id, node_id)
443+
if edge_key not in edge_styles:
444+
edge_list.append(edge_key)
445+
edge_styles[edge_key] = "stroke:#000,stroke-dasharray:5 5"
474446

475447
for child in task.child_task_set.all():
476448
child_id = child.name.replace(" ", "_")
477-
lines.append(f" {node_id} -.-> {child_id}")
478-
edge_styles.append(f" linkStyle {edge_index} stroke:#000,stroke-dasharray:5 5")
479-
edge_index += 1
449+
edge_key = (node_id, child_id)
450+
if edge_key not in edge_styles:
451+
edge_list.append(edge_key)
452+
edge_styles[edge_key] = "stroke:#000,stroke-dasharray:5 5"
453+
454+
# Add edges to output (using dotted arrow for dashed edges)
455+
for start_id, end_id in edge_list:
456+
style = edge_styles[(start_id, end_id)]
457+
if "dasharray" in style:
458+
# Use dotted arrow for dashed edges
459+
lines.append(f" '{start_id}' -.-> '{end_id}'")
460+
else:
461+
# Use solid arrow for normal edges
462+
lines.append(f" '{start_id}' --> '{end_id}'")
480463

481464
# Add all styling at the end
482465
lines.extend(node_styles)
483-
lines.extend(edge_styles)
466+
467+
# Add edge styling
468+
for idx, (start_id, end_id) in enumerate(edge_list):
469+
style = edge_styles[(start_id, end_id)]
470+
lines.append(f" linkStyle {idx} {style}")
484471

485472
return "\n".join(lines)
486473

tests/test_models.py

Lines changed: 9 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -174,34 +174,6 @@ def test_get_instance_graph_svg(self, db, fixturedir):
174174
svg = wf.get_instance_graph_svg()
175175
assert isinstance(svg, SafeString)
176176

177-
def test_get_graph_mermaid(self):
178-
"""Test that get_graph_mermaid returns valid Mermaid syntax."""
179-
mermaid = workflows.SimpleWorkflow.get_graph_mermaid()
180-
181-
# Check it's a string
182-
assert isinstance(mermaid, str)
183-
184-
# Check it starts with graph declaration
185-
assert mermaid.startswith("graph LR") or mermaid.startswith("graph TD")
186-
187-
# Check it contains nodes
188-
assert "start_method[start method]" in mermaid
189-
assert "save_the_princess(save the princess)" in mermaid # HUMAN task, rounded
190-
assert "end[end]" in mermaid
191-
192-
# Check it contains edges
193-
assert "start_method --> save_the_princess" in mermaid
194-
assert "save_the_princess --> end" in mermaid
195-
196-
def test_get_graph_mermaid_with_direction(self):
197-
"""Test that get_graph_mermaid respects rankdir."""
198-
workflows.SimpleWorkflow.rankdir = "TD"
199-
mermaid = workflows.SimpleWorkflow.get_graph_mermaid()
200-
assert mermaid.startswith("graph TD")
201-
202-
# Reset to default
203-
workflows.SimpleWorkflow.rankdir = "LR"
204-
205177
def test_get_instance_graph_mermaid(self, db):
206178
"""Test that get_instance_graph_mermaid returns valid Mermaid syntax with task states."""
207179
wf = workflows.SimpleWorkflow.start_method()
@@ -213,12 +185,12 @@ def test_get_instance_graph_mermaid(self, db):
213185
# Check it starts with graph declaration
214186
assert mermaid.startswith("graph LR") or mermaid.startswith("graph TD")
215187

216-
# Check it contains nodes
217-
assert "save_the_princess(save the princess)" in mermaid
218-
assert "start_method[start method]" in mermaid
188+
# Check it contains nodes with quoted IDs
189+
assert "'save_the_princess'(save the princess)" in mermaid
190+
assert "'start_method'[start method]" in mermaid
219191

220-
# Check it contains edges
221-
assert "start_method --> save_the_princess" in mermaid
192+
# Check it contains edges with quoted IDs
193+
assert "'start_method' --> 'save_the_princess'" in mermaid
222194

223195
# Check it contains styling (for active/completed tasks)
224196
assert "style " in mermaid
@@ -259,14 +231,14 @@ def test_get_instance_graph_mermaid_with_obsolete(self, db):
259231

260232
mermaid = workflow.get_instance_graph_mermaid()
261233

262-
# Check obsolete node exists
263-
assert "obsolete[obsolete]" in mermaid
234+
# Check obsolete node exists with quoted ID
235+
assert "'obsolete'[obsolete]" in mermaid
264236

265237
# Check dashed edges (dotted arrow notation in Mermaid)
266-
assert "-.->obsolete" in mermaid.replace(" ", "") or "obsolete-.->end" in mermaid.replace(" ", "")
238+
assert "'start_method' -.-> 'obsolete'" in mermaid or "'obsolete' -.-> 'end'" in mermaid
267239

268240
# Check obsolete task styling with dashed border
269-
assert "style obsolete" in mermaid
241+
assert "style 'obsolete'" in mermaid
270242
assert "stroke-dasharray" in mermaid
271243

272244
def test_cancel(self, db):

0 commit comments

Comments
 (0)