Skip to content

Commit ffba216

Browse files
committed
Release 0.51.0
1 parent de6e0d8 commit ffba216

File tree

7 files changed

+317
-16
lines changed

7 files changed

+317
-16
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
run: |
3333
python -m pip install --upgrade pip
3434
pip install Django==${{ matrix.django-version }}.*
35-
pip install -e .[dev]
35+
pip install -e .[dev,qr]
3636
3737
- name: Run migrations check
3838
run: |

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

Lines changed: 210 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ class WorkflowBuilder {
2424
this.isDirty = false;
2525
this.isSaving = false;
2626
this.lastSavedWorkflowSnapshot = null;
27+
this.nodeStackOrder = new Map();
28+
this.nextNodeStackOrder = 1;
29+
this.draggingNodeId = null;
2730

2831
// Pan & zoom state
2932
this.panX = 0;
@@ -253,6 +256,10 @@ class WorkflowBuilder {
253256

254257
setupEventListeners() {
255258
document.getElementById('btnSave').addEventListener('click', () => this.saveWorkflow());
259+
const autoArrangeBtn = document.getElementById('btnAutoArrange');
260+
if (autoArrangeBtn) {
261+
autoArrangeBtn.addEventListener('click', () => this.autoArrangeNodes());
262+
}
256263
const deleteConnectionBtn = document.getElementById('btnDeleteConnection');
257264
if (deleteConnectionBtn) {
258265
deleteConnectionBtn.addEventListener('click', () => this.deleteSelectedConnection());
@@ -457,6 +464,11 @@ class WorkflowBuilder {
457464
}));
458465
this.nodeIdCounter = maxId + 1;
459466
}
467+
468+
this.initializeNodeStackOrder();
469+
if (this.layoutNeedsNormalization()) {
470+
this.autoArrangeNodes({ suppressRender: true, silent: true });
471+
}
460472
} else {
461473
console.error('Failed to load workflow:', data.error);
462474
this.setBuilderMessage('danger', 'Failed to load workflow builder data.', [data.error || 'Unknown error']);
@@ -570,6 +582,7 @@ class WorkflowBuilder {
570582
data: {}
571583
};
572584
this.nodes.push(node);
585+
this.bringNodeToFront(node.id);
573586
this.render();
574587
}
575588

@@ -582,9 +595,194 @@ class WorkflowBuilder {
582595
data: this.getDefaultNodeData(type)
583596
};
584597
this.nodes.push(node);
598+
this.bringNodeToFront(node.id);
585599
this.render();
586600
}
587601

602+
initializeNodeStackOrder() {
603+
this.nodeStackOrder = new Map();
604+
this.nextNodeStackOrder = 1;
605+
this.nodes.forEach((node) => {
606+
this.nodeStackOrder.set(node.id, this.nextNodeStackOrder++);
607+
});
608+
}
609+
610+
bringNodeToFront(nodeId) {
611+
if (!nodeId) return;
612+
this.nodeStackOrder.set(nodeId, this.nextNodeStackOrder++);
613+
}
614+
615+
getEstimatedNodeWidth(type) {
616+
switch (type) {
617+
case 'workflow_settings':
618+
return 320;
619+
case 'form':
620+
case 'sub_workflow':
621+
return 300;
622+
case 'stage':
623+
case 'approval':
624+
case 'approval_config':
625+
return 280;
626+
case 'action':
627+
case 'email':
628+
case 'condition':
629+
return 260;
630+
case 'join':
631+
return 140;
632+
case 'start':
633+
case 'end':
634+
return 180;
635+
default:
636+
return 240;
637+
}
638+
}
639+
640+
getEstimatedNodeHeight(type) {
641+
switch (type) {
642+
case 'workflow_settings':
643+
return 180;
644+
case 'form':
645+
case 'sub_workflow':
646+
return 170;
647+
case 'stage':
648+
case 'approval':
649+
case 'approval_config':
650+
return 160;
651+
case 'action':
652+
case 'email':
653+
case 'condition':
654+
return 150;
655+
case 'join':
656+
return 96;
657+
default:
658+
return 140;
659+
}
660+
}
661+
662+
nodesOverlap(a, b, padding = 16) {
663+
const aWidth = this.getEstimatedNodeWidth(a.type);
664+
const aHeight = this.getEstimatedNodeHeight(a.type);
665+
const bWidth = this.getEstimatedNodeWidth(b.type);
666+
const bHeight = this.getEstimatedNodeHeight(b.type);
667+
668+
return !(
669+
a.x + aWidth + padding <= b.x
670+
|| b.x + bWidth + padding <= a.x
671+
|| a.y + aHeight + padding <= b.y
672+
|| b.y + bHeight + padding <= a.y
673+
);
674+
}
675+
676+
layoutNeedsNormalization() {
677+
const nodes = [...this.nodes];
678+
const sameLaneThreshold = 110;
679+
const minimumGap = 56;
680+
681+
for (let i = 0; i < nodes.length; i++) {
682+
for (let j = i + 1; j < nodes.length; j++) {
683+
if (this.nodesOverlap(nodes[i], nodes[j], 20)) {
684+
return true;
685+
}
686+
}
687+
}
688+
689+
const byLane = [...nodes].sort((a, b) => (a.y - b.y) || (a.x - b.x));
690+
for (let i = 1; i < byLane.length; i++) {
691+
const prev = byLane[i - 1];
692+
const current = byLane[i];
693+
if (Math.abs(current.y - prev.y) > sameLaneThreshold || current.x < prev.x) {
694+
continue;
695+
}
696+
const requiredX = prev.x + this.getEstimatedNodeWidth(prev.type) + minimumGap;
697+
if (current.x < requiredX) {
698+
return true;
699+
}
700+
}
701+
702+
return false;
703+
}
704+
705+
resolveNodeCollisions() {
706+
const sortedNodes = [...this.nodes].sort((a, b) => (a.x - b.x) || (a.y - b.y));
707+
708+
sortedNodes.forEach((node, index) => {
709+
let attempts = 0;
710+
while (attempts < 24) {
711+
const blockingNode = sortedNodes
712+
.slice(0, index)
713+
.find((candidate) => this.nodesOverlap(candidate, node, 12));
714+
if (!blockingNode) {
715+
break;
716+
}
717+
718+
const sameLane = Math.abs(blockingNode.y - node.y) <= 110;
719+
if (sameLane) {
720+
node.x = Math.max(
721+
node.x,
722+
blockingNode.x + this.getEstimatedNodeWidth(blockingNode.type) + 72,
723+
);
724+
} else {
725+
node.y = Math.max(
726+
node.y,
727+
blockingNode.y + this.getEstimatedNodeHeight(blockingNode.type) + 52,
728+
);
729+
}
730+
attempts += 1;
731+
}
732+
});
733+
}
734+
735+
autoArrangeNodes(options = {}) {
736+
const { suppressRender = false, silent = false } = options;
737+
if (!this.nodes.length) return;
738+
739+
const laneThreshold = 120;
740+
const horizontalGap = 84;
741+
const sortedByY = [...this.nodes].sort((a, b) => (a.y - b.y) || (a.x - b.x));
742+
const lanes = [];
743+
744+
sortedByY.forEach((node) => {
745+
let lane = lanes.find((candidate) => Math.abs(candidate.centerY - node.y) <= laneThreshold);
746+
if (!lane) {
747+
lane = { centerY: node.y, nodes: [] };
748+
lanes.push(lane);
749+
}
750+
lane.nodes.push(node);
751+
lane.centerY = Math.round(lane.nodes.reduce((total, entry) => total + entry.y, 0) / lane.nodes.length);
752+
});
753+
754+
lanes
755+
.sort((a, b) => a.centerY - b.centerY)
756+
.forEach((lane) => {
757+
lane.nodes.sort((a, b) => a.x - b.x);
758+
let cursorX = Math.max(80, lane.nodes[0]?.x || 80);
759+
lane.nodes.forEach((node, index) => {
760+
if (index === 0) {
761+
node.x = Math.max(80, node.x);
762+
} else {
763+
node.x = Math.max(node.x, cursorX);
764+
}
765+
cursorX = node.x + this.getEstimatedNodeWidth(node.type) + horizontalGap;
766+
});
767+
});
768+
769+
this.resolveNodeCollisions();
770+
this.updateWorkspaceBounds();
771+
772+
if (!silent) {
773+
this.setBuilderMessage(
774+
'info',
775+
'Workflow layout auto-arranged.',
776+
['Nodes were spaced out to reduce overlap and make dragging easier.'],
777+
true,
778+
);
779+
}
780+
781+
if (!suppressRender) {
782+
this.render();
783+
}
784+
}
785+
588786
getDefaultNodeData(type) {
589787
switch (type) {
590788
case 'form':
@@ -2474,7 +2672,10 @@ class WorkflowBuilder {
24742672
this.transformWrapper.querySelectorAll('.workflow-node').forEach(n => n.remove());
24752673

24762674
// Render each node into the transform wrapper (alongside the SVG)
2477-
this.nodes.forEach(node => {
2675+
const orderedNodes = [...this.nodes].sort((a, b) => {
2676+
return (this.nodeStackOrder.get(a.id) || 0) - (this.nodeStackOrder.get(b.id) || 0);
2677+
});
2678+
orderedNodes.forEach(node => {
24782679
console.log('Creating node element for:', node);
24792680
const nodeEl = this.createNodeElement(node);
24802681
this.transformWrapper.appendChild(nodeEl);
@@ -2500,6 +2701,9 @@ class WorkflowBuilder {
25002701
if (this.selectedNode === node.id) {
25012702
div.className += ' selected';
25022703
}
2704+
if (this.draggingNodeId === node.id) {
2705+
div.className += ' dragging';
2706+
}
25032707
div.style.left = `${node.x}px`;
25042708
div.style.top = `${node.y}px`;
25052709
div.dataset.nodeId = node.id;
@@ -2696,6 +2900,9 @@ class WorkflowBuilder {
26962900

26972901
startDragNode(e, node) {
26982902
this.isDraggingNode = true;
2903+
this.draggingNodeId = node.id;
2904+
this.bringNodeToFront(node.id);
2905+
this.render();
26992906
const startX = e.clientX;
27002907
const startY = e.clientY;
27012908
const nodeStartX = node.x;
@@ -2712,6 +2919,8 @@ class WorkflowBuilder {
27122919

27132920
const onMouseUp = () => {
27142921
this.isDraggingNode = false;
2922+
this.draggingNodeId = null;
2923+
this.render();
27152924
document.removeEventListener('mousemove', onMouseMove);
27162925
document.removeEventListener('mouseup', onMouseUp);
27172926
};

django_forms_workflows/templates/admin/django_forms_workflows/formdef_change_form.html

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@
55
{{ block.super }}
66
{% if original.pk %}
77
<li>
8-
<a href="{% url 'admin:form_builder_edit' original.pk %}" class="btn btn-high btn-success" target="_blank" style="background-color: #28a745; color: white; padding: 10px 15px; border-radius: 4px; text-decoration: none; display: inline-block;">
8+
<a href="{% url 'forms_workflows:analytics_dashboard' %}?form={{ original.slug|urlencode }}" class="admin-shortcut-link admin-shortcut-link--analytics" target="_blank">
9+
<i class="bi bi-graph-up-arrow"></i> View Analytics
10+
</a>
11+
</li>
12+
<li>
13+
<a href="{% url 'admin:form_builder_edit' original.pk %}" class="admin-shortcut-link admin-shortcut-link--builder" target="_blank">
914
<i class="bi bi-pencil-square"></i> Open Visual Form Builder
1015
</a>
1116
</li>
1217
{% else %}
1318
<li>
14-
<span class="text-muted" style="padding: 10px 15px; display: inline-block;">
19+
<span class="admin-shortcut-muted">
1520
Save form first to use Visual Builder
1621
</span>
1722
</li>
@@ -23,9 +28,29 @@
2328
<!-- Bootstrap Icons for the builder link -->
2429
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
2530
<style>
26-
.btn-success:hover {
31+
.admin-shortcut-link {
32+
color: white !important;
33+
padding: 10px 15px;
34+
border-radius: 4px;
35+
text-decoration: none;
36+
display: inline-block;
37+
}
38+
.admin-shortcut-link--builder {
39+
background-color: #28a745;
40+
}
41+
.admin-shortcut-link--builder:hover {
2742
background-color: #218838 !important;
2843
}
44+
.admin-shortcut-link--analytics {
45+
background-color: #0d6efd;
46+
}
47+
.admin-shortcut-link--analytics:hover {
48+
background-color: #0b5ed7 !important;
49+
}
50+
.admin-shortcut-muted {
51+
padding: 10px 15px;
52+
display: inline-block;
53+
}
2954
.qr-share-panel {
3055
background: #f8f9fa;
3156
border: 1px solid #dee2e6;

0 commit comments

Comments
 (0)