diff --git a/django_forms_workflows/static/django_forms_workflows/js/form-builder-canvas.js b/django_forms_workflows/static/django_forms_workflows/js/form-builder-canvas.js
new file mode 100644
index 0000000..a81ee2b
--- /dev/null
+++ b/django_forms_workflows/static/django_forms_workflows/js/form-builder-canvas.js
@@ -0,0 +1,1136 @@
+/**
+ * Canvas and drag-drop controller for the Form Builder: the field palette,
+ * the single-step and multi-step canvases (SortableJS wiring, native
+ * HTML5 drag-drop for palette-to-canvas drops), field CRUD, canvas/step
+ * rendering, and the field context menu.
+ *
+ * Mixed onto FormBuilder.prototype in form-builder.js (Object.assign), not a
+ * standalone class of its own - these methods read/write `this.fields`/
+ * `this.formSteps` (proxied onto `this.store`), plus a number of
+ * FormBuilder-owned DOM/drag-state fields (`this.draggingFieldType`,
+ * `this.dragPlaceholder`, `this.contextMenu`, `this.fieldTypes`/
+ * `this.fieldTypeCategories`), and call back into
+ * pushUndo()/updatePreview()/saveForm()/editField(), which still live on
+ * historyMethods/apiMethods/propertyEditorMethods respectively - all mixed
+ * onto the same FormBuilder instance, so `this.*` resolves regardless of
+ * which module a given method came from.
+ */
+export const canvasMethods = {
+ setupFieldPalette() {
+ const palette = document.getElementById('fieldPalette');
+
+ this.fieldTypeCategories = [
+ {
+ name: 'Basic Inputs',
+ icon: 'bi-input-cursor-text',
+ types: [
+ { type: 'text', label: 'Single Line Text', icon: 'bi-input-cursor-text' },
+ { type: 'textarea', label: 'Multi-line Text', icon: 'bi-textarea-t' },
+ { type: 'email', label: 'Email Address', icon: 'bi-envelope' },
+ { type: 'phone', label: 'Phone Number', icon: 'bi-telephone' },
+ { type: 'url', label: 'Website URL', icon: 'bi-link-45deg' },
+ { type: 'number', label: 'Whole Number', icon: 'bi-123' },
+ { type: 'decimal', label: 'Decimal Number', icon: 'bi-hash' },
+ { type: 'currency', label: 'Currency ($)', icon: 'bi-currency-dollar' },
+ ]
+ },
+ {
+ name: 'Selection',
+ icon: 'bi-ui-checks',
+ types: [
+ { type: 'select', label: 'Dropdown Select', icon: 'bi-menu-button-wide' },
+ { type: 'radio', label: 'Radio Buttons', icon: 'bi-ui-radios' },
+ { type: 'checkbox', label: 'Single Checkbox', icon: 'bi-check-square' },
+ { type: 'multiselect', label: 'Checkboxes (Multi)', icon: 'bi-ui-checks' },
+ { type: 'multiselect_list', label: 'Multi-Select List', icon: 'bi-list-check' },
+ { type: 'checkboxes', label: 'Checkbox Group', icon: 'bi-ui-checks-grid' },
+ { type: 'country', label: 'Country Picker', icon: 'bi-globe' },
+ { type: 'us_state', label: 'US State Picker', icon: 'bi-geo-alt' },
+ ]
+ },
+ {
+ name: 'Date & Time',
+ icon: 'bi-calendar',
+ types: [
+ { type: 'date', label: 'Date', icon: 'bi-calendar-date' },
+ { type: 'time', label: 'Time', icon: 'bi-clock' },
+ { type: 'datetime', label: 'Date & Time', icon: 'bi-calendar-event' },
+ ]
+ },
+ {
+ name: 'Uploads & Media',
+ icon: 'bi-cloud-upload',
+ types: [
+ { type: 'file', label: 'File Upload', icon: 'bi-file-earmark-arrow-up' },
+ { type: 'multifile', label: 'Multi-File Upload', icon: 'bi-files' },
+ { type: 'spreadsheet', label: 'Spreadsheet Upload', icon: 'bi-file-earmark-spreadsheet' },
+ { type: 'signature', label: 'Signature', icon: 'bi-pen' },
+ ]
+ },
+ {
+ name: 'Advanced',
+ icon: 'bi-lightning',
+ types: [
+ { type: 'calculated', label: 'Calculated / Formula', icon: 'bi-calculator' },
+ { type: 'hidden', label: 'Hidden Field', icon: 'bi-eye-slash' },
+ { type: 'rating', label: 'Rating (Stars)', icon: 'bi-star' },
+ { type: 'slider', label: 'Slider', icon: 'bi-sliders' },
+ { type: 'matrix', label: 'Matrix / Grid', icon: 'bi-grid-3x3' },
+ { type: 'address', label: 'Address', icon: 'bi-house-door' },
+ ]
+ },
+ {
+ name: 'Layout',
+ icon: 'bi-layout-split',
+ types: [
+ { type: 'section', label: 'Section Header', icon: 'bi-layout-text-sidebar' },
+ { type: 'display_text', label: 'Display Text', icon: 'bi-card-text' },
+ ]
+ }
+ ];
+
+ // Build flat fieldTypes list for backward compatibility
+ this.fieldTypes = [];
+ this.fieldTypeCategories.forEach(cat => {
+ cat.types.forEach(ft => this.fieldTypes.push(ft));
+ });
+
+ // Render categorized palette
+ this.renderPalette('');
+
+ // Setup search
+ const searchInput = document.getElementById('paletteSearch');
+ if (searchInput) {
+ searchInput.addEventListener('input', (e) => {
+ this.renderPalette(e.target.value.toLowerCase().trim());
+ });
+ }
+
+ // Setup SortableJS for palette to work with both single-step and multi-step canvases
+ new Sortable(palette, {
+ group: {
+ name: 'step-fields',
+ pull: 'clone',
+ put: false
+ },
+ sort: false,
+ animation: 150,
+ // Keep native drag events for single-step canvas
+ forceFallback: false,
+ onStart: (evt) => {
+ // Store the field type for native drag-drop handlers
+ const fieldType = evt.item.dataset.fieldType;
+ if (fieldType) {
+ this.draggingFieldType = fieldType;
+ }
+ },
+ onEnd: (evt) => {
+ // Clear the dragging field type
+ this.draggingFieldType = null;
+ this.cleanupDragPlaceholder();
+ }
+ });
+ },
+
+ renderPalette(filter) {
+ const palette = document.getElementById('fieldPalette');
+ // Remove all items but keep the search (which is in the panel-header)
+ palette.innerHTML = '';
+
+ this.fieldTypeCategories.forEach(cat => {
+ const matchingTypes = cat.types.filter(ft =>
+ !filter || ft.label.toLowerCase().includes(filter) || ft.type.toLowerCase().includes(filter)
+ );
+ if (matchingTypes.length === 0) return;
+
+ // Category header
+ const header = document.createElement('div');
+ header.className = 'palette-category-header';
+ header.innerHTML = `
+
+ ${cat.name}
+ ${matchingTypes.length}
+ `;
+ palette.appendChild(header);
+
+ matchingTypes.forEach(fieldType => {
+ const item = document.createElement('div');
+ item.className = 'field-palette-item';
+ item.dataset.fieldType = fieldType.type;
+ item.innerHTML = `
+
+ ${fieldType.label}
+ `;
+ palette.appendChild(item);
+ });
+ });
+ },
+
+ setupCanvas() {
+ const canvas = document.getElementById('formCanvas');
+
+ // Setup Sortable for drag-and-drop reordering
+ this.sortable = Sortable.create(canvas, {
+ group: {
+ name: 'step-fields',
+ pull: true,
+ put: true
+ },
+ animation: 300,
+ easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ ghostClass: 'sortable-ghost',
+ dragClass: 'sortable-drag',
+ handle: '.field-drag-handle',
+ draggable: '.field-item', // Both .canvas-field and .canvas-section elements
+ filter: '.canvas-drop-zone', // Exclude drop zone from sorting
+ onStart: (evt) => {
+ // Add dragging class for enhanced visual feedback
+ canvas.classList.add('dragging');
+ },
+ onAdd: (evt) => {
+ // Check if this is a new field from palette
+ const isPaletteItem = evt.item.classList.contains('field-palette-item');
+
+ if (isPaletteItem) {
+ // New field from palette
+ const fieldType = evt.item.dataset.fieldType;
+ if (fieldType) {
+ this.addFieldAtPosition(fieldType, evt.newIndex);
+ evt.item.remove(); // Remove the palette clone
+ }
+ } else {
+ // Existing field moved - update order
+ this.pushUndo();
+ const movedField = this.fields.splice(evt.oldIndex, 1)[0];
+ this.fields.splice(evt.newIndex, 0, movedField);
+ this.updateFieldOrders();
+ this.updatePreview();
+ }
+ },
+ onUpdate: (evt) => {
+ // Field reordered within canvas
+ this.pushUndo();
+ const movedField = this.fields.splice(evt.oldIndex, 1)[0];
+ this.fields.splice(evt.newIndex, 0, movedField);
+ this.updateFieldOrders();
+ this.updatePreview();
+ },
+ onEnd: (evt) => {
+ // Remove dragging class
+ canvas.classList.remove('dragging');
+ }
+ });
+
+ // Allow dropping from palette with visual feedback
+ canvas.addEventListener('dragover', (e) => {
+ e.preventDefault();
+
+ // Check if we're dragging a new field from palette
+ if (this.draggingFieldType) {
+ e.dataTransfer.dropEffect = 'copy';
+
+ // Add dragging class to canvas
+ canvas.classList.add('dragging');
+
+ // Find the element we're hovering over
+ const afterElement = this.getDragAfterElement(canvas, e.clientY);
+
+ // Create or update placeholder
+ if (!this.dragPlaceholder) {
+ this.dragPlaceholder = document.createElement('div');
+ this.dragPlaceholder.className = 'canvas-field drag-placeholder';
+ this.dragPlaceholder.innerHTML = `
+
+ `;
+ }
+
+ // Insert placeholder at the correct position
+ if (afterElement == null) {
+ // Append at the end (before drop zone)
+ const dropZone = canvas.querySelector('.canvas-drop-zone');
+ if (dropZone) {
+ canvas.insertBefore(this.dragPlaceholder, dropZone);
+ } else {
+ canvas.appendChild(this.dragPlaceholder);
+ }
+ } else {
+ canvas.insertBefore(this.dragPlaceholder, afterElement);
+ }
+ } else {
+ // Allow sortable to handle reordering
+ e.dataTransfer.dropEffect = 'move';
+ }
+ });
+
+ canvas.addEventListener('dragleave', (e) => {
+ // Check if we're actually leaving the canvas (not just entering a child element)
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX;
+ const y = e.clientY;
+
+ // If mouse is outside canvas bounds, remove placeholder
+ if (this.draggingFieldType &&
+ (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom)) {
+ this.cleanupDragPlaceholder();
+ }
+ });
+
+ canvas.addEventListener('drop', (e) => {
+ e.preventDefault();
+
+
+ this.cleanupDragPlaceholder();
+ });
+ },
+
+ getDragAfterElement(container, y) {
+ const draggableElements = [...container.querySelectorAll('.canvas-field:not(.drag-placeholder):not(.sortable-drag)')];
+
+ return draggableElements.reduce((closest, child) => {
+ const box = child.getBoundingClientRect();
+ const offset = y - box.top - box.height / 2;
+
+ if (offset < 0 && offset > closest.offset) {
+ return { offset: offset, element: child };
+ } else {
+ return closest;
+ }
+ }, { offset: Number.NEGATIVE_INFINITY }).element;
+ },
+
+ cleanupDragPlaceholder() {
+ // Remove the drag placeholder and clean up canvas state
+ if (this.dragPlaceholder && this.dragPlaceholder.parentNode) {
+ this.dragPlaceholder.parentNode.removeChild(this.dragPlaceholder);
+ this.dragPlaceholder = null;
+ }
+ const canvas = document.getElementById('formCanvas');
+ if (canvas) {
+ canvas.classList.remove('dragging');
+ }
+ },
+
+ addFieldAtPosition(fieldType, position) {
+ this.pushUndo();
+ // field_name must be read before nextFieldId() advances the counter,
+ // so the id and the name it's derived from carry the same number -
+ // matching handleFieldDroppedToStep's order, which already does this.
+ const fieldName = this.getDefaultName(fieldType);
+ const field = {
+ id: this.store.nextFieldId('new'),
+ order: position + 1,
+ field_label: this.getDefaultLabel(fieldType),
+ field_name: fieldName,
+ field_type: fieldType,
+ required: false,
+ help_text: '',
+ show_help_text_in_detail: false,
+ placeholder: '',
+ width: 'full',
+ css_class: '',
+ choices: '',
+ default_value: '',
+ prefill_source_id: null,
+ prefill_source_config: {},
+ validation: {
+ min_value: null,
+ max_value: null,
+ min_length: null,
+ max_length: null,
+ regex_validation: '',
+ regex_error_message: ''
+ },
+ conditional: {
+ show_if_field: null,
+ show_if_value: ''
+ }
+ };
+
+ const insertIndex = Math.min(position, this.fields.length);
+ this.fields.splice(insertIndex, 0, field);
+ this.updateFieldOrders();
+ this.renderCanvas();
+ this.updatePreview();
+
+ // Automatically open property editor for new field
+ this.editField(insertIndex, true); // true = isNew
+ },
+
+ addField(fieldType) {
+ // Add field at the end
+ this.addFieldAtPosition(fieldType, this.fields.length);
+ },
+
+ duplicateField(index) {
+ this.pushUndo();
+ const original = this.fields[index];
+ const clone = JSON.parse(JSON.stringify(original));
+ clone.id = this.store.nextFieldId('new');
+ clone.field_name = original.field_name + '_copy';
+ clone.field_label = original.field_label + ' (Copy)';
+ clone.order = index + 2;
+
+ this.fields.splice(index + 1, 0, clone);
+ this.updateFieldOrders();
+
+ // Also add to step if in multi-step mode
+ if (this.formSteps && this.formSteps.length > 0) {
+ this.formSteps.forEach(step => {
+ if (step.fields) {
+ const pos = step.fields.indexOf(original.field_name);
+ if (pos !== -1) {
+ step.fields.splice(pos + 1, 0, clone.field_name);
+ }
+ }
+ });
+ }
+
+ const isMultiStep = document.getElementById('formEnableMultiStep')?.checked;
+ if (isMultiStep) {
+ this.renderStepTabs();
+ } else {
+ this.renderCanvas();
+ }
+ this.updatePreview();
+ },
+
+ getDefaultLabel(fieldType) {
+ const labels = {
+ 'text': 'Text Field',
+ 'email': 'Email Address',
+ 'number': 'Number',
+ 'textarea': 'Text Area',
+ 'select': 'Select Option',
+ 'radio': 'Radio Choice',
+ 'multiselect': 'Checkboxes',
+ 'multiselect_list': 'Multi-Select',
+ 'checkbox': 'Checkbox',
+ 'checkboxes': 'Checkbox Group',
+ 'checkbox_multiple': 'Checkboxes',
+ 'date': 'Date',
+ 'time': 'Time',
+ 'datetime': 'Date and Time',
+ 'file': 'File Upload',
+ 'multifile': 'File Uploads',
+ 'url': 'Website URL',
+ 'phone': 'Phone Number',
+ 'decimal': 'Decimal',
+ 'currency': 'Amount',
+ 'hidden': 'Hidden Field',
+ 'section': 'Section Header',
+ 'calculated': 'Calculated Field',
+ 'spreadsheet': 'Spreadsheet Upload',
+ 'country': 'Country',
+ 'us_state': 'State',
+ 'signature': 'Signature',
+ 'rating': 'Rating',
+ 'matrix': 'Matrix',
+ 'address': 'Address',
+ 'slider': 'Slider'
+ };
+ return labels[fieldType] || 'Field';
+ },
+
+ getDefaultName(fieldType) {
+ return fieldType + '_' + this.fieldIdCounter;
+ },
+
+ renderCanvas() {
+ const canvas = document.getElementById('formCanvas');
+
+ if (this.fields.length === 0) {
+ canvas.innerHTML = `
+
+
+
Drag fields from the left palette to start building your form
+
+ `;
+ document.getElementById('fieldCount').textContent = '0 fields';
+ return;
+ }
+
+ canvas.innerHTML = '';
+ this.fields.forEach((field, index) => {
+ const fieldEl = this.createFieldElement(field, index);
+ canvas.appendChild(fieldEl);
+ });
+
+ // Add a drop zone at the bottom for easier dragging
+ const dropZone = document.createElement('div');
+ dropZone.className = 'canvas-drop-zone';
+ dropZone.innerHTML = `
+
+
+ Drag fields from the left palette to add them here
+
+ `;
+ canvas.appendChild(dropZone);
+
+ document.getElementById('fieldCount').textContent = `${this.fields.length} field${this.fields.length !== 1 ? 's' : ''}`;
+ },
+
+ createFieldElement(field, index) {
+ const div = document.createElement('div');
+ div.dataset.index = index;
+ div.dataset.fieldIndex = index;
+
+ if (field.field_type === 'section') {
+ // Section header — render as a prominent divider
+ div.className = 'canvas-section field-item';
+ div.innerHTML = `
+
+
+
+
+ ${this.escapeHtml(field.field_label)}
+
+
+ section
+
+
+
+
+
+ `;
+ } else {
+ // Regular field
+ div.className = 'canvas-field field-item';
+ const requiredBadge = field.required ? 'REQ' : '';
+ const fieldInfo = `${this.escapeHtml(field.field_name)}`;
+ const widthBadge = field.width && field.width !== 'full' ? `${this.escapeHtml(field.width)}` : '';
+
+ div.innerHTML = `
+
+
+
+ ${this.escapeHtml(field.field_label)}
+ ${requiredBadge}${widthBadge}
+ ${fieldInfo}
+
+
+ ${this.escapeHtml(field.field_type)}
+
+
+
+
+
+ `;
+ }
+
+ // Add context menu handler for multi-step mode
+ div.addEventListener('contextmenu', (e) => {
+ e.preventDefault();
+ this.showFieldContextMenu(e, index);
+ });
+
+ return div;
+ },
+
+ toggleMultiStepMode(enabled) {
+ const singleCanvas = document.getElementById('singleStepCanvas');
+ const multiCanvas = document.getElementById('multiStepCanvas');
+ const stepTabsControls = document.getElementById('stepTabsControls');
+
+ if (enabled) {
+ // Switch to multi-step mode
+ singleCanvas.style.display = 'none';
+ multiCanvas.style.display = 'block';
+ if (stepTabsControls) stepTabsControls.style.display = 'block';
+
+ // Initialize steps if not present
+ if (!this.formSteps || this.formSteps.length === 0) {
+ this.formSteps = [
+ { title: 'Step 1', fields: [] }
+ ];
+ }
+
+ // Render step tabs
+ this.renderStepTabs();
+
+ // Move all fields to first step if they're not assigned
+ this.organizeFieldsIntoSteps();
+
+ this.updatePreview();
+ } else {
+ // Switch to single-step mode
+ singleCanvas.style.display = 'block';
+ multiCanvas.style.display = 'none';
+ if (stepTabsControls) stepTabsControls.style.display = 'none';
+
+ // Move all fields back to main canvas
+ this.moveAllFieldsToMainCanvas();
+ }
+ },
+
+ renderStepTabs() {
+ const contentContainer = document.getElementById('stepTabContent');
+
+ if (!contentContainer) return;
+
+ contentContainer.innerHTML = '';
+
+ this.formSteps.forEach((step, index) => {
+ // Create step card (no tabs, just stacked vertically)
+ const stepCard = document.createElement('div');
+ stepCard.className = 'step-card mb-3';
+ stepCard.innerHTML = `
+
+
+
+
+
Drag fields here for ${this.escapeHtml(step.title)}
+
+
+ `;
+ contentContainer.appendChild(stepCard);
+
+ // Setup sortable for this step canvas
+ this.setupStepCanvasSortable(index);
+
+ // Setup drag-and-drop from palette
+ this.setupStepCanvasDragDrop(index);
+ });
+
+ // Render fields in their respective steps
+ this.renderFieldsInSteps();
+ },
+
+ setupStepCanvasSortable(stepIndex) {
+ const canvas = document.getElementById(`step-canvas-${stepIndex}`);
+ if (!canvas) return;
+
+ new Sortable(canvas, {
+ group: {
+ name: 'step-fields',
+ pull: true,
+ put: true
+ },
+ animation: 150,
+ handle: '.field-drag-handle',
+ draggable: '.field-item', // Only field-item elements can be dragged
+ filter: '.empty-canvas', // Exclude empty canvas placeholder
+ ghostClass: 'field-ghost',
+ dragClass: 'field-dragging',
+ chosenClass: 'field-chosen',
+ onAdd: (evt) => {
+ // Check if this is a new field from palette or moved from another step
+ const isPaletteItem = evt.item.classList.contains('field-palette-item');
+
+ if (isPaletteItem) {
+ // New field from palette
+ const fieldType = evt.item.dataset.fieldType;
+ if (fieldType) {
+ this.handleFieldDroppedToStep(fieldType, stepIndex, evt.newIndex);
+ evt.item.remove(); // Remove the palette clone
+ }
+ } else {
+ // Existing field moved from another canvas
+ this.handleFieldMovedToStep(evt.item, stepIndex);
+ }
+ },
+ onUpdate: (evt) => {
+ this.updateFieldOrderInStep(stepIndex);
+ },
+ onRemove: (evt) => {
+ // Field was moved to another step, handled by onAdd of target
+ }
+ });
+ },
+
+ setupStepCanvasDragDrop(stepIndex) {
+ const canvas = document.getElementById(`step-canvas-${stepIndex}`);
+ if (!canvas) return;
+
+ // Allow dropping from palette
+ canvas.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'copy';
+ canvas.classList.add('drag-over');
+ });
+
+ canvas.addEventListener('dragleave', (e) => {
+ if (e.target === canvas) {
+ canvas.classList.remove('drag-over');
+ }
+ });
+
+ canvas.addEventListener('drop', (e) => {
+ e.preventDefault();
+ canvas.classList.remove('drag-over');
+
+ const fieldType = e.dataTransfer.getData('fieldType');
+ if (fieldType) {
+ // Field dropped from palette
+ this.handleFieldDroppedToStep(fieldType, stepIndex);
+ }
+ });
+ },
+
+ handleFieldDroppedToStep(fieldType, stepIndex, position) {
+ // Create a new field when dropped from palette
+ const fieldConfig = this.fieldTypes.find(ft => ft.type === fieldType);
+ if (!fieldConfig) return;
+
+ this.pushUndo();
+
+ const fieldName = this.getDefaultName(fieldType);
+ const newField = {
+ id: this.store.nextFieldId('new'),
+ field_type: fieldType,
+ field_name: fieldName,
+ field_label: this.getDefaultLabel(fieldType),
+ required: false,
+ help_text: '',
+ show_help_text_in_detail: false,
+ placeholder: '',
+ choices: '',
+ width: 'full',
+ css_class: '',
+ prefill_source_id: null,
+ order: this.fields.length,
+ conditional_rules: null,
+ validation_rules: null,
+ field_dependencies: null,
+ default_value: '',
+ prefill_source_config: {},
+ validation: {
+ min_value: null,
+ max_value: null,
+ min_length: null,
+ max_length: null,
+ regex_validation: '',
+ regex_error_message: ''
+ },
+ conditional: {
+ show_if_field: null,
+ show_if_value: ''
+ }
+ };
+
+ this.fields.push(newField);
+
+ // Add to step's field list
+ if (!this.formSteps[stepIndex].fields) {
+ this.formSteps[stepIndex].fields = [];
+ }
+
+ // Insert at the correct position
+ if (position !== undefined && position < this.formSteps[stepIndex].fields.length) {
+ this.formSteps[stepIndex].fields.splice(position, 0, fieldName);
+ } else {
+ this.formSteps[stepIndex].fields.push(fieldName);
+ }
+
+ // Reorder this.fields to match step order *before* rendering - every
+ // step's data-field-index is derived from this.fields' current index,
+ // so rendering first and reordering after leaves stale indices behind
+ // on any step whose fields shifted position in the flattened array.
+ this.updateFieldOrderFromSteps();
+ this.renderFieldsInSteps();
+ this.updatePreview();
+
+ // Automatically open property editor for new field
+ const fieldIndex = this.fields.findIndex(f => f.field_name === fieldName);
+ this.editField(fieldIndex, true); // true = isNew
+ },
+
+ addStepTab() {
+ const newIndex = this.formSteps.length;
+ this.formSteps.push({
+ title: `Step ${newIndex + 1}`,
+ fields: []
+ });
+ this.renderStepTabs();
+ },
+
+ removeStepTab(index) {
+ if (this.formSteps.length === 1) {
+ alert('Cannot remove the last step. Disable multi-step mode instead.');
+ return;
+ }
+
+ if (confirm(`Remove "${this.formSteps[index].title}"? Fields in this step will be moved to Step 1.`)) {
+ // Move fields from this step to step 0
+ const fieldsToMove = this.formSteps[index].fields || [];
+ this.formSteps[0].fields = [...(this.formSteps[0].fields || []), ...fieldsToMove];
+
+ // Remove the step
+ this.formSteps.splice(index, 1);
+
+ // Re-render
+ this.renderStepTabs();
+ }
+ },
+
+ updateStepTitle(index, newTitle) {
+ if (this.formSteps[index]) {
+ this.formSteps[index].title = newTitle;
+ // Update tab text
+ const tab = document.querySelector(`#step-tab-${index}`);
+ if (tab) {
+ const icon = tab.querySelector('i').outerHTML;
+ const deleteBtn = tab.querySelector('button').outerHTML;
+ tab.innerHTML = `${icon} ${this.escapeHtml(newTitle)} ${deleteBtn}`;
+ }
+ }
+ },
+
+ handleFieldMovedToStep(fieldElement, stepIndex) {
+ const fieldIndex = parseInt(fieldElement.dataset.fieldIndex);
+ const field = this.fields[fieldIndex];
+
+ if (!field) return;
+
+ this.pushUndo();
+
+
+ // Remove field from all steps
+ this.formSteps.forEach(step => {
+ if (step.fields) {
+ step.fields = step.fields.filter(name => name !== field.field_name);
+ }
+ });
+
+ // Add to target step at the correct position
+ if (!this.formSteps[stepIndex].fields) {
+ this.formSteps[stepIndex].fields = [];
+ }
+
+ // Get the position from the DOM
+ const canvas = document.getElementById(`step-canvas-${stepIndex}`);
+ const fieldElements = canvas.querySelectorAll('.field-item');
+ let insertPosition = this.formSteps[stepIndex].fields.length;
+
+ fieldElements.forEach((el, idx) => {
+ if (el === fieldElement) {
+ insertPosition = idx;
+ }
+ });
+
+ this.formSteps[stepIndex].fields.splice(insertPosition, 0, field.field_name);
+
+ // Reorder this.fields to match step order *before* rendering - every
+ // step's data-field-index is derived from this.fields' current index,
+ // so rendering first and reordering after leaves stale indices behind
+ // on any step whose fields shifted position in the flattened array.
+ this.updateFieldOrderFromSteps();
+ this.renderFieldsInSteps();
+
+ // Update preview
+ this.updatePreview();
+ },
+
+ updateFieldOrderInStep(stepIndex) {
+ const canvas = document.getElementById(`step-canvas-${stepIndex}`);
+ if (!canvas) return;
+
+ this.pushUndo();
+
+ const fieldElements = canvas.querySelectorAll('.field-item');
+ const fieldNames = [];
+
+ fieldElements.forEach(el => {
+ const fieldIndex = parseInt(el.dataset.fieldIndex);
+ const field = this.fields[fieldIndex];
+ if (field) {
+ fieldNames.push(field.field_name);
+ }
+ });
+
+ this.formSteps[stepIndex].fields = fieldNames;
+
+ // Reorder this.fields to match, then re-render every step so
+ // data-field-index stays in sync everywhere - a reorder in this step
+ // can shift indices for fields in other steps too, since this.fields
+ // is one flattened array, not scoped per step.
+ this.updateFieldOrderFromSteps();
+ this.renderFieldsInSteps();
+ this.updatePreview();
+ },
+
+ updateStepFieldCount(stepIndex) {
+ const panel = document.getElementById(`step-panel-${stepIndex}`);
+ if (panel) {
+ const badge = panel.querySelector('.badge');
+ if (badge) {
+ const count = this.formSteps[stepIndex].fields ? this.formSteps[stepIndex].fields.length : 0;
+ badge.textContent = `${count} fields`;
+ }
+ }
+ },
+
+ organizeFieldsIntoSteps() {
+ // Assign all fields to their respective steps based on formSteps configuration
+ // If a field isn't assigned to any step, put it in step 0
+ const assignedFields = new Set();
+
+ this.formSteps.forEach(step => {
+ if (step.fields) {
+ step.fields.forEach(fieldName => assignedFields.add(fieldName));
+ }
+ });
+
+ // Add unassigned fields to first step
+ this.fields.forEach(field => {
+ if (!assignedFields.has(field.field_name)) {
+ if (!this.formSteps[0].fields) {
+ this.formSteps[0].fields = [];
+ }
+ this.formSteps[0].fields.push(field.field_name);
+ }
+ });
+
+ this.renderFieldsInSteps();
+ },
+
+ renderFieldsInSteps() {
+ // Render fields in their respective steps
+ this.formSteps.forEach((step, stepIndex) => {
+ this.renderSingleStep(stepIndex);
+ });
+ },
+
+ renderSingleStep(stepIndex) {
+ const step = this.formSteps[stepIndex];
+ const canvas = document.getElementById(`step-canvas-${stepIndex}`);
+ if (!canvas) return;
+
+ canvas.innerHTML = '';
+
+ if (!step.fields || step.fields.length === 0) {
+ canvas.innerHTML = `
+
+
+
Drag fields here for ${this.escapeHtml(step.title)}
+
+ `;
+ this.updateStepFieldCount(stepIndex);
+ return;
+ }
+
+ step.fields.forEach(fieldName => {
+ const fieldIndex = this.fields.findIndex(f => f.field_name === fieldName);
+ if (fieldIndex !== -1) {
+ const fieldElement = this.createFieldElement(this.fields[fieldIndex], fieldIndex);
+ canvas.appendChild(fieldElement);
+ }
+ });
+
+ this.updateStepFieldCount(stepIndex);
+ },
+
+ moveAllFieldsToMainCanvas() {
+ // Collect all fields from all steps
+ const allFields = [];
+ this.formSteps.forEach(step => {
+ if (step.fields) {
+ allFields.push(...step.fields);
+ }
+ });
+
+ // Re-render main canvas
+ this.renderCanvas();
+ this.updatePreview();
+ },
+
+ updateFieldOrderFromSteps() {
+ // Update field order based on step order
+ // Fields should be ordered by step, then by position within step
+ const orderedFields = [];
+
+ this.formSteps.forEach(step => {
+ if (step.fields) {
+ step.fields.forEach(fieldName => {
+ const field = this.fields.find(f => f.field_name === fieldName);
+ if (field) {
+ orderedFields.push(field);
+ }
+ });
+ }
+ });
+
+ // Update this.fields with new order
+ this.fields = orderedFields;
+
+ // Update order property
+ this.fields.forEach((field, index) => {
+ field.order = index;
+ });
+ },
+
+ deleteField(index) {
+ if (confirm('Are you sure you want to delete this field?')) {
+ this.pushUndo();
+ this.deleteFieldSilently(index);
+ }
+ },
+
+ deleteFieldSilently(index) {
+ // Delete field without confirmation (used when canceling new field)
+ const fieldToDelete = this.fields[index];
+
+ // Remove from fields array
+ this.fields.splice(index, 1);
+
+ // Remove from step fields if in multi-step mode
+ if (this.formSteps && this.formSteps.length > 0) {
+ this.formSteps.forEach(step => {
+ if (step.fields) {
+ const fieldIndex = step.fields.indexOf(fieldToDelete.field_name);
+ if (fieldIndex !== -1) {
+ step.fields.splice(fieldIndex, 1);
+ }
+ }
+ });
+ }
+
+ this.updateFieldOrders();
+
+ // Re-render appropriate canvas
+ const isMultiStep = document.getElementById('formEnableMultiStep')?.checked;
+ if (isMultiStep) {
+ this.renderStepTabs();
+ } else {
+ this.renderCanvas();
+ }
+
+ this.updatePreview();
+ },
+
+ updateFieldOrders() {
+ this.fields.forEach((field, index) => {
+ field.order = index + 1;
+ });
+ },
+
+ escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ },
+
+ showFieldContextMenu(event, fieldIndex) {
+ // Only show context menu in multi-step mode
+ const isMultiStep = document.getElementById('formEnableMultiStep')?.checked;
+ if (!isMultiStep || !this.formSteps || this.formSteps.length <= 1) {
+ return; // Don't show menu if not in multi-step mode or only one step
+ }
+
+ // Remove any existing context menu
+ this.hideFieldContextMenu();
+
+ // Create context menu
+ const menu = document.createElement('div');
+ menu.className = 'field-context-menu';
+ menu.style.position = 'fixed';
+ menu.style.left = `${event.clientX}px`;
+ menu.style.top = `${event.clientY}px`;
+ menu.style.zIndex = '10000';
+
+ // Build menu items
+ let menuHTML = '';
+
+ this.formSteps.forEach((step, stepIndex) => {
+ menuHTML += `
+
+ `;
+ });
+
+ menu.innerHTML = menuHTML;
+ document.body.appendChild(menu);
+ this.contextMenu = menu;
+
+ // Close menu when clicking outside
+ setTimeout(() => {
+ document.addEventListener('click', () => this.hideFieldContextMenu(), { once: true });
+ }, 10);
+ },
+
+ hideFieldContextMenu() {
+ if (this.contextMenu) {
+ this.contextMenu.remove();
+ this.contextMenu = null;
+ }
+ },
+
+ moveFieldToStepFromMenu(fieldIndex, targetStepIndex) {
+ this.hideFieldContextMenu();
+
+ const field = this.fields[fieldIndex];
+ if (!field) return;
+
+ // Find which step the field is currently in
+ let sourceStepIndex = -1;
+ this.formSteps.forEach((step, idx) => {
+ if (step.fields && step.fields.includes(field.field_name)) {
+ sourceStepIndex = idx;
+ }
+ });
+
+ // If already in target step, do nothing
+ if (sourceStepIndex === targetStepIndex) {
+ return;
+ }
+
+ // Remove field from all steps
+ this.formSteps.forEach(step => {
+ if (step.fields) {
+ step.fields = step.fields.filter(name => name !== field.field_name);
+ }
+ });
+
+ // Add to target step
+ if (!this.formSteps[targetStepIndex].fields) {
+ this.formSteps[targetStepIndex].fields = [];
+ }
+ this.formSteps[targetStepIndex].fields.push(field.field_name);
+
+ // Re-render both steps
+ if (sourceStepIndex !== -1) {
+ this.renderSingleStep(sourceStepIndex);
+ }
+ this.renderSingleStep(targetStepIndex);
+
+ // Update preview
+ this.updatePreview();
+ },
+};
diff --git a/django_forms_workflows/static/django_forms_workflows/js/form-builder.js b/django_forms_workflows/static/django_forms_workflows/js/form-builder.js
index 0ca94c8..23d118e 100644
--- a/django_forms_workflows/static/django_forms_workflows/js/form-builder.js
+++ b/django_forms_workflows/static/django_forms_workflows/js/form-builder.js
@@ -8,6 +8,7 @@
import { historyMethods } from './form-builder-history.js';
import { apiMethods } from './form-builder-api.js';
import { propertyEditorMethods } from './form-builder-property-editor.js';
+import { canvasMethods } from './form-builder-canvas.js';
import { createBuilderStore } from './form-builder-store.js';
export class FormBuilder {
@@ -59,364 +60,25 @@ export class FormBuilder {
this.updatePreview();
}
}
-
- setupFieldPalette() {
- const palette = document.getElementById('fieldPalette');
- this.fieldTypeCategories = [
- {
- name: 'Basic Inputs',
- icon: 'bi-input-cursor-text',
- types: [
- { type: 'text', label: 'Single Line Text', icon: 'bi-input-cursor-text' },
- { type: 'textarea', label: 'Multi-line Text', icon: 'bi-textarea-t' },
- { type: 'email', label: 'Email Address', icon: 'bi-envelope' },
- { type: 'phone', label: 'Phone Number', icon: 'bi-telephone' },
- { type: 'url', label: 'Website URL', icon: 'bi-link-45deg' },
- { type: 'number', label: 'Whole Number', icon: 'bi-123' },
- { type: 'decimal', label: 'Decimal Number', icon: 'bi-hash' },
- { type: 'currency', label: 'Currency ($)', icon: 'bi-currency-dollar' },
- ]
- },
- {
- name: 'Selection',
- icon: 'bi-ui-checks',
- types: [
- { type: 'select', label: 'Dropdown Select', icon: 'bi-menu-button-wide' },
- { type: 'radio', label: 'Radio Buttons', icon: 'bi-ui-radios' },
- { type: 'checkbox', label: 'Single Checkbox', icon: 'bi-check-square' },
- { type: 'multiselect', label: 'Checkboxes (Multi)', icon: 'bi-ui-checks' },
- { type: 'multiselect_list', label: 'Multi-Select List', icon: 'bi-list-check' },
- { type: 'checkboxes', label: 'Checkbox Group', icon: 'bi-ui-checks-grid' },
- { type: 'country', label: 'Country Picker', icon: 'bi-globe' },
- { type: 'us_state', label: 'US State Picker', icon: 'bi-geo-alt' },
- ]
- },
- {
- name: 'Date & Time',
- icon: 'bi-calendar',
- types: [
- { type: 'date', label: 'Date', icon: 'bi-calendar-date' },
- { type: 'time', label: 'Time', icon: 'bi-clock' },
- { type: 'datetime', label: 'Date & Time', icon: 'bi-calendar-event' },
- ]
- },
- {
- name: 'Uploads & Media',
- icon: 'bi-cloud-upload',
- types: [
- { type: 'file', label: 'File Upload', icon: 'bi-file-earmark-arrow-up' },
- { type: 'multifile', label: 'Multi-File Upload', icon: 'bi-files' },
- { type: 'spreadsheet', label: 'Spreadsheet Upload', icon: 'bi-file-earmark-spreadsheet' },
- { type: 'signature', label: 'Signature', icon: 'bi-pen' },
- ]
- },
- {
- name: 'Advanced',
- icon: 'bi-lightning',
- types: [
- { type: 'calculated', label: 'Calculated / Formula', icon: 'bi-calculator' },
- { type: 'hidden', label: 'Hidden Field', icon: 'bi-eye-slash' },
- { type: 'rating', label: 'Rating (Stars)', icon: 'bi-star' },
- { type: 'slider', label: 'Slider', icon: 'bi-sliders' },
- { type: 'matrix', label: 'Matrix / Grid', icon: 'bi-grid-3x3' },
- { type: 'address', label: 'Address', icon: 'bi-house-door' },
- ]
- },
- {
- name: 'Layout',
- icon: 'bi-layout-split',
- types: [
- { type: 'section', label: 'Section Header', icon: 'bi-layout-text-sidebar' },
- { type: 'display_text', label: 'Display Text', icon: 'bi-card-text' },
- ]
- }
- ];
-
- // Build flat fieldTypes list for backward compatibility
- this.fieldTypes = [];
- this.fieldTypeCategories.forEach(cat => {
- cat.types.forEach(ft => this.fieldTypes.push(ft));
- });
-
- // Render categorized palette
- this.renderPalette('');
-
- // Setup search
- const searchInput = document.getElementById('paletteSearch');
- if (searchInput) {
- searchInput.addEventListener('input', (e) => {
- this.renderPalette(e.target.value.toLowerCase().trim());
- });
- }
-
- // Setup SortableJS for palette to work with both single-step and multi-step canvases
- new Sortable(palette, {
- group: {
- name: 'step-fields',
- pull: 'clone',
- put: false
- },
- sort: false,
- animation: 150,
- // Keep native drag events for single-step canvas
- forceFallback: false,
- onStart: (evt) => {
- // Store the field type for native drag-drop handlers
- const fieldType = evt.item.dataset.fieldType;
- if (fieldType) {
- this.draggingFieldType = fieldType;
- }
- },
- onEnd: (evt) => {
- // Clear the dragging field type
- this.draggingFieldType = null;
- this.cleanupDragPlaceholder();
- }
- });
- }
-
- renderPalette(filter) {
- const palette = document.getElementById('fieldPalette');
- // Remove all items but keep the search (which is in the panel-header)
- palette.innerHTML = '';
-
- this.fieldTypeCategories.forEach(cat => {
- const matchingTypes = cat.types.filter(ft =>
- !filter || ft.label.toLowerCase().includes(filter) || ft.type.toLowerCase().includes(filter)
- );
- if (matchingTypes.length === 0) return;
-
- // Category header
- const header = document.createElement('div');
- header.className = 'palette-category-header';
- header.innerHTML = `
-
- ${cat.name}
- ${matchingTypes.length}
- `;
- palette.appendChild(header);
-
- matchingTypes.forEach(fieldType => {
- const item = document.createElement('div');
- item.className = 'field-palette-item';
- item.dataset.fieldType = fieldType.type;
- item.innerHTML = `
-
- ${fieldType.label}
- `;
- palette.appendChild(item);
- });
- });
- }
-
- setupCanvas() {
- const canvas = document.getElementById('formCanvas');
-
- // Setup Sortable for drag-and-drop reordering
- this.sortable = Sortable.create(canvas, {
- group: {
- name: 'step-fields',
- pull: true,
- put: true
- },
- animation: 300,
- easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
- ghostClass: 'sortable-ghost',
- dragClass: 'sortable-drag',
- handle: '.field-drag-handle',
- draggable: '.field-item', // Both .canvas-field and .canvas-section elements
- filter: '.canvas-drop-zone', // Exclude drop zone from sorting
- onStart: (evt) => {
- // Add dragging class for enhanced visual feedback
- canvas.classList.add('dragging');
- },
- onAdd: (evt) => {
- // Check if this is a new field from palette
- const isPaletteItem = evt.item.classList.contains('field-palette-item');
-
- if (isPaletteItem) {
- // New field from palette
- const fieldType = evt.item.dataset.fieldType;
- if (fieldType) {
- this.addFieldAtPosition(fieldType, evt.newIndex);
- evt.item.remove(); // Remove the palette clone
- }
- } else {
- // Existing field moved - update order
- const movedField = this.fields.splice(evt.oldIndex, 1)[0];
- this.fields.splice(evt.newIndex, 0, movedField);
- this.updateFieldOrders();
- this.updatePreview();
- }
- },
- onUpdate: (evt) => {
- // Field reordered within canvas
- const movedField = this.fields.splice(evt.oldIndex, 1)[0];
- this.fields.splice(evt.newIndex, 0, movedField);
- this.updateFieldOrders();
- this.updatePreview();
- },
- onEnd: (evt) => {
- // Remove dragging class
- canvas.classList.remove('dragging');
- }
- });
-
- // Allow dropping from palette with visual feedback
- canvas.addEventListener('dragover', (e) => {
- e.preventDefault();
-
- // Check if we're dragging a new field from palette
- if (this.draggingFieldType) {
- e.dataTransfer.dropEffect = 'copy';
-
- // Add dragging class to canvas
- canvas.classList.add('dragging');
-
- // Find the element we're hovering over
- const afterElement = this.getDragAfterElement(canvas, e.clientY);
-
- // Create or update placeholder
- if (!this.dragPlaceholder) {
- this.dragPlaceholder = document.createElement('div');
- this.dragPlaceholder.className = 'canvas-field drag-placeholder';
- this.dragPlaceholder.innerHTML = `
-
- `;
- }
-
- // Insert placeholder at the correct position
- if (afterElement == null) {
- // Append at the end (before drop zone)
- const dropZone = canvas.querySelector('.canvas-drop-zone');
- if (dropZone) {
- canvas.insertBefore(this.dragPlaceholder, dropZone);
- } else {
- canvas.appendChild(this.dragPlaceholder);
- }
- } else {
- canvas.insertBefore(this.dragPlaceholder, afterElement);
- }
- } else {
- // Allow sortable to handle reordering
- e.dataTransfer.dropEffect = 'move';
- }
- });
-
- canvas.addEventListener('dragleave', (e) => {
- // Check if we're actually leaving the canvas (not just entering a child element)
- const rect = canvas.getBoundingClientRect();
- const x = e.clientX;
- const y = e.clientY;
-
- // If mouse is outside canvas bounds, remove placeholder
- if (this.draggingFieldType &&
- (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom)) {
- this.cleanupDragPlaceholder();
- }
- });
-
- canvas.addEventListener('drop', (e) => {
- e.preventDefault();
-
-
- this.cleanupDragPlaceholder();
- });
- }
-
- getDragAfterElement(container, y) {
- const draggableElements = [...container.querySelectorAll('.canvas-field:not(.drag-placeholder):not(.sortable-drag)')];
-
- return draggableElements.reduce((closest, child) => {
- const box = child.getBoundingClientRect();
- const offset = y - box.top - box.height / 2;
-
- if (offset < 0 && offset > closest.offset) {
- return { offset: offset, element: child };
- } else {
- return closest;
- }
- }, { offset: Number.NEGATIVE_INFINITY }).element;
- }
-
- cleanupDragPlaceholder() {
- // Remove the drag placeholder and clean up canvas state
- if (this.dragPlaceholder && this.dragPlaceholder.parentNode) {
- this.dragPlaceholder.parentNode.removeChild(this.dragPlaceholder);
- this.dragPlaceholder = null;
- }
- const canvas = document.getElementById('formCanvas');
- if (canvas) {
- canvas.classList.remove('dragging');
- }
- }
-
- addFieldAtPosition(fieldType, position) {
- this.pushUndo();
- const field = {
- id: `new_${this.fieldIdCounter++}`,
- order: position + 1,
- field_label: this.getDefaultLabel(fieldType),
- field_name: this.getDefaultName(fieldType),
- field_type: fieldType,
- required: false,
- help_text: '',
- show_help_text_in_detail: false,
- placeholder: '',
- width: 'full',
- css_class: '',
- choices: '',
- default_value: '',
- prefill_source_id: null,
- prefill_source_config: {},
- validation: {
- min_value: null,
- max_value: null,
- min_length: null,
- max_length: null,
- regex_validation: '',
- regex_error_message: ''
- },
- conditional: {
- show_if_field: null,
- show_if_value: ''
- }
- };
-
- const insertIndex = Math.min(position, this.fields.length);
- this.fields.splice(insertIndex, 0, field);
- this.updateFieldOrders();
- this.renderCanvas();
- this.updatePreview();
-
- // Automatically open property editor for new field
- this.editField(insertIndex, true); // true = isNew
- }
-
setupEventListeners() {
// Save button
document.getElementById('btnSave').addEventListener('click', () => {
this.saveForm();
});
-
+
// Cancel button
document.getElementById('btnCancel').addEventListener('click', () => {
if (confirm('Are you sure you want to cancel? Unsaved changes will be lost.')) {
window.location.href = '/admin/django_forms_workflows/formdefinition/';
}
});
-
+
// Save field button in modal
document.getElementById('btnSaveField').addEventListener('click', () => {
this.saveFieldProperties();
});
-
+
// Auto-generate slug from name
document.getElementById('formName').addEventListener('input', (e) => {
const slug = e.target.value
@@ -462,788 +124,6 @@ export class FormBuilder {
});
}
}
-
- addField(fieldType) {
- // Add field at the end
- this.addFieldAtPosition(fieldType, this.fields.length);
- }
-
- duplicateField(index) {
- this.pushUndo();
- const original = this.fields[index];
- const clone = JSON.parse(JSON.stringify(original));
- clone.id = `new_${this.fieldIdCounter++}`;
- clone.field_name = original.field_name + '_copy';
- clone.field_label = original.field_label + ' (Copy)';
- clone.order = index + 2;
-
- this.fields.splice(index + 1, 0, clone);
- this.updateFieldOrders();
-
- // Also add to step if in multi-step mode
- if (this.formSteps && this.formSteps.length > 0) {
- this.formSteps.forEach(step => {
- if (step.fields) {
- const pos = step.fields.indexOf(original.field_name);
- if (pos !== -1) {
- step.fields.splice(pos + 1, 0, clone.field_name);
- }
- }
- });
- }
-
- const isMultiStep = document.getElementById('formEnableMultiStep')?.checked;
- if (isMultiStep) {
- this.renderStepTabs();
- } else {
- this.renderCanvas();
- }
- this.updatePreview();
- }
-
- getDefaultLabel(fieldType) {
- const labels = {
- 'text': 'Text Field',
- 'email': 'Email Address',
- 'number': 'Number',
- 'textarea': 'Text Area',
- 'select': 'Select Option',
- 'radio': 'Radio Choice',
- 'multiselect': 'Checkboxes',
- 'multiselect_list': 'Multi-Select',
- 'checkbox': 'Checkbox',
- 'checkboxes': 'Checkbox Group',
- 'checkbox_multiple': 'Checkboxes',
- 'date': 'Date',
- 'time': 'Time',
- 'datetime': 'Date and Time',
- 'file': 'File Upload',
- 'multifile': 'File Uploads',
- 'url': 'Website URL',
- 'phone': 'Phone Number',
- 'decimal': 'Decimal',
- 'currency': 'Amount',
- 'hidden': 'Hidden Field',
- 'section': 'Section Header',
- 'calculated': 'Calculated Field',
- 'spreadsheet': 'Spreadsheet Upload',
- 'country': 'Country',
- 'us_state': 'State',
- 'signature': 'Signature',
- 'rating': 'Rating',
- 'matrix': 'Matrix',
- 'address': 'Address',
- 'slider': 'Slider'
- };
- return labels[fieldType] || 'Field';
- }
-
- getDefaultName(fieldType) {
- return fieldType + '_' + this.fieldIdCounter;
- }
-
- renderCanvas() {
- const canvas = document.getElementById('formCanvas');
-
- if (this.fields.length === 0) {
- canvas.innerHTML = `
-
-
-
Drag fields from the left palette to start building your form
-
- `;
- document.getElementById('fieldCount').textContent = '0 fields';
- return;
- }
-
- canvas.innerHTML = '';
- this.fields.forEach((field, index) => {
- const fieldEl = this.createFieldElement(field, index);
- canvas.appendChild(fieldEl);
- });
-
- // Add a drop zone at the bottom for easier dragging
- const dropZone = document.createElement('div');
- dropZone.className = 'canvas-drop-zone';
- dropZone.innerHTML = `
-
-
- Drag fields from the left palette to add them here
-
- `;
- canvas.appendChild(dropZone);
-
- document.getElementById('fieldCount').textContent = `${this.fields.length} field${this.fields.length !== 1 ? 's' : ''}`;
- }
-
- createFieldElement(field, index) {
- const div = document.createElement('div');
- div.dataset.index = index;
- div.dataset.fieldIndex = index;
-
- if (field.field_type === 'section') {
- // Section header — render as a prominent divider
- div.className = 'canvas-section field-item';
- div.innerHTML = `
-
-
-
-
- ${this.escapeHtml(field.field_label)}
-
-
- section
-
-
-
-
-
- `;
- } else {
- // Regular field
- div.className = 'canvas-field field-item';
- const requiredBadge = field.required ? 'REQ' : '';
- const fieldInfo = `${field.field_name}`;
- const widthBadge = field.width && field.width !== 'full' ? `${field.width}` : '';
-
- div.innerHTML = `
-
-
-
- ${this.escapeHtml(field.field_label)}
- ${requiredBadge}${widthBadge}
- ${fieldInfo}
-
-
- ${field.field_type}
-
-
-
-
-
- `;
- }
-
- // Add context menu handler for multi-step mode
- div.addEventListener('contextmenu', (e) => {
- e.preventDefault();
- this.showFieldContextMenu(e, index);
- });
-
- return div;
- }
-
- toggleMultiStepMode(enabled) {
- const singleCanvas = document.getElementById('singleStepCanvas');
- const multiCanvas = document.getElementById('multiStepCanvas');
- const stepTabsControls = document.getElementById('stepTabsControls');
-
- if (enabled) {
- // Switch to multi-step mode
- singleCanvas.style.display = 'none';
- multiCanvas.style.display = 'block';
- if (stepTabsControls) stepTabsControls.style.display = 'block';
-
- // Initialize steps if not present
- if (!this.formSteps || this.formSteps.length === 0) {
- this.formSteps = [
- { title: 'Step 1', fields: [] }
- ];
- }
-
- // Render step tabs
- this.renderStepTabs();
-
- // Move all fields to first step if they're not assigned
- this.organizeFieldsIntoSteps();
-
- this.updatePreview();
- } else {
- // Switch to single-step mode
- singleCanvas.style.display = 'block';
- multiCanvas.style.display = 'none';
- if (stepTabsControls) stepTabsControls.style.display = 'none';
-
- // Move all fields back to main canvas
- this.moveAllFieldsToMainCanvas();
- }
- }
-
- renderStepTabs() {
- const contentContainer = document.getElementById('stepTabContent');
-
- if (!contentContainer) return;
-
- contentContainer.innerHTML = '';
-
- this.formSteps.forEach((step, index) => {
- // Create step card (no tabs, just stacked vertically)
- const stepCard = document.createElement('div');
- stepCard.className = 'step-card mb-3';
- stepCard.innerHTML = `
-
-
-
-
-
Drag fields here for ${this.escapeHtml(step.title)}
-
-
- `;
- contentContainer.appendChild(stepCard);
-
- // Setup sortable for this step canvas
- this.setupStepCanvasSortable(index);
-
- // Setup drag-and-drop from palette
- this.setupStepCanvasDragDrop(index);
- });
-
- // Render fields in their respective steps
- this.renderFieldsInSteps();
- }
-
- setupStepCanvasSortable(stepIndex) {
- const canvas = document.getElementById(`step-canvas-${stepIndex}`);
- if (!canvas) return;
-
- new Sortable(canvas, {
- group: {
- name: 'step-fields',
- pull: true,
- put: true
- },
- animation: 150,
- handle: '.field-drag-handle',
- draggable: '.field-item', // Only field-item elements can be dragged
- filter: '.empty-canvas', // Exclude empty canvas placeholder
- ghostClass: 'field-ghost',
- dragClass: 'field-dragging',
- chosenClass: 'field-chosen',
- onAdd: (evt) => {
- // Check if this is a new field from palette or moved from another step
- const isPaletteItem = evt.item.classList.contains('field-palette-item');
-
- if (isPaletteItem) {
- // New field from palette
- const fieldType = evt.item.dataset.fieldType;
- if (fieldType) {
- this.handleFieldDroppedToStep(fieldType, stepIndex, evt.newIndex);
- evt.item.remove(); // Remove the palette clone
- }
- } else {
- // Existing field moved from another canvas
- this.handleFieldMovedToStep(evt.item, stepIndex);
- }
- },
- onUpdate: (evt) => {
- this.updateFieldOrderInStep(stepIndex);
- },
- onRemove: (evt) => {
- // Field was moved to another step, handled by onAdd of target
- }
- });
- }
-
- setupStepCanvasDragDrop(stepIndex) {
- const canvas = document.getElementById(`step-canvas-${stepIndex}`);
- if (!canvas) return;
-
- // Allow dropping from palette
- canvas.addEventListener('dragover', (e) => {
- e.preventDefault();
- e.dataTransfer.dropEffect = 'copy';
- canvas.classList.add('drag-over');
- });
-
- canvas.addEventListener('dragleave', (e) => {
- if (e.target === canvas) {
- canvas.classList.remove('drag-over');
- }
- });
-
- canvas.addEventListener('drop', (e) => {
- e.preventDefault();
- canvas.classList.remove('drag-over');
-
- const fieldType = e.dataTransfer.getData('fieldType');
- if (fieldType) {
- // Field dropped from palette
- this.handleFieldDroppedToStep(fieldType, stepIndex);
- }
- });
- }
-
- handleFieldDroppedToStep(fieldType, stepIndex, position) {
- // Create a new field when dropped from palette
- const fieldConfig = this.fieldTypes.find(ft => ft.type === fieldType);
- if (!fieldConfig) return;
-
- this.pushUndo();
-
- const fieldName = this.getDefaultName(fieldType);
- const newField = {
- id: `new_${this.fieldIdCounter++}`,
- field_type: fieldType,
- field_name: fieldName,
- field_label: this.getDefaultLabel(fieldType),
- required: false,
- help_text: '',
- show_help_text_in_detail: false,
- placeholder: '',
- choices: '',
- width: 'full',
- css_class: '',
- prefill_source_id: null,
- order: this.fields.length,
- conditional_rules: null,
- validation_rules: null,
- field_dependencies: null,
- default_value: '',
- prefill_source_config: {},
- validation: {
- min_value: null,
- max_value: null,
- min_length: null,
- max_length: null,
- regex_validation: '',
- regex_error_message: ''
- },
- conditional: {
- show_if_field: null,
- show_if_value: ''
- }
- };
-
- this.fields.push(newField);
-
- // Add to step's field list
- if (!this.formSteps[stepIndex].fields) {
- this.formSteps[stepIndex].fields = [];
- }
-
- // Insert at the correct position
- if (position !== undefined && position < this.formSteps[stepIndex].fields.length) {
- this.formSteps[stepIndex].fields.splice(position, 0, fieldName);
- } else {
- this.formSteps[stepIndex].fields.push(fieldName);
- }
-
- // Reorder this.fields to match step order *before* rendering - every
- // step's data-field-index is derived from this.fields' current index,
- // so rendering first and reordering after leaves stale indices behind
- // on any step whose fields shifted position in the flattened array.
- this.updateFieldOrderFromSteps();
- this.renderFieldsInSteps();
- this.updatePreview();
-
- // Automatically open property editor for new field
- const fieldIndex = this.fields.findIndex(f => f.field_name === fieldName);
- this.editField(fieldIndex, true); // true = isNew
- }
-
- addStepTab() {
- const newIndex = this.formSteps.length;
- this.formSteps.push({
- title: `Step ${newIndex + 1}`,
- fields: []
- });
- this.renderStepTabs();
- }
-
- removeStepTab(index) {
- if (this.formSteps.length === 1) {
- alert('Cannot remove the last step. Disable multi-step mode instead.');
- return;
- }
-
- if (confirm(`Remove "${this.formSteps[index].title}"? Fields in this step will be moved to Step 1.`)) {
- // Move fields from this step to step 0
- const fieldsToMove = this.formSteps[index].fields || [];
- this.formSteps[0].fields = [...(this.formSteps[0].fields || []), ...fieldsToMove];
-
- // Remove the step
- this.formSteps.splice(index, 1);
-
- // Re-render
- this.renderStepTabs();
- }
- }
-
- updateStepTitle(index, newTitle) {
- if (this.formSteps[index]) {
- this.formSteps[index].title = newTitle;
- // Update tab text
- const tab = document.querySelector(`#step-tab-${index}`);
- if (tab) {
- const icon = tab.querySelector('i').outerHTML;
- const deleteBtn = tab.querySelector('button').outerHTML;
- tab.innerHTML = `${icon} ${this.escapeHtml(newTitle)} ${deleteBtn}`;
- }
- }
- }
-
- handleFieldMovedToStep(fieldElement, stepIndex) {
- const fieldIndex = parseInt(fieldElement.dataset.fieldIndex);
- const field = this.fields[fieldIndex];
-
- if (!field) return;
-
- this.pushUndo();
-
- // Find which step the field was in before
- let sourceStepIndex = -1;
- this.formSteps.forEach((step, idx) => {
- if (step.fields && step.fields.includes(field.field_name)) {
- sourceStepIndex = idx;
- }
- });
-
- // Remove field from all steps
- this.formSteps.forEach(step => {
- if (step.fields) {
- step.fields = step.fields.filter(name => name !== field.field_name);
- }
- });
-
- // Add to target step at the correct position
- if (!this.formSteps[stepIndex].fields) {
- this.formSteps[stepIndex].fields = [];
- }
-
- // Get the position from the DOM
- const canvas = document.getElementById(`step-canvas-${stepIndex}`);
- const fieldElements = canvas.querySelectorAll('.field-item');
- let insertPosition = this.formSteps[stepIndex].fields.length;
-
- fieldElements.forEach((el, idx) => {
- if (el === fieldElement) {
- insertPosition = idx;
- }
- });
-
- this.formSteps[stepIndex].fields.splice(insertPosition, 0, field.field_name);
-
- // Reorder this.fields to match step order *before* rendering - every
- // step's data-field-index is derived from this.fields' current index,
- // so rendering first and reordering after leaves stale indices behind
- // on any step whose fields shifted position in the flattened array.
- this.updateFieldOrderFromSteps();
- this.renderFieldsInSteps();
-
- // Update preview
- this.updatePreview();
- }
-
- updateFieldOrderInStep(stepIndex) {
- const canvas = document.getElementById(`step-canvas-${stepIndex}`);
- if (!canvas) return;
-
- this.pushUndo();
-
- const fieldElements = canvas.querySelectorAll('.field-item');
- const fieldNames = [];
-
- fieldElements.forEach(el => {
- const fieldIndex = parseInt(el.dataset.fieldIndex);
- const field = this.fields[fieldIndex];
- if (field) {
- fieldNames.push(field.field_name);
- }
- });
-
- this.formSteps[stepIndex].fields = fieldNames;
-
- // Reorder this.fields to match, then re-render every step so
- // data-field-index stays in sync everywhere - a reorder in this step
- // can shift indices for fields in other steps too, since this.fields
- // is one flattened array, not scoped per step.
- this.updateFieldOrderFromSteps();
- this.renderFieldsInSteps();
- this.updatePreview();
- }
-
- updateStepFieldCount(stepIndex) {
- const panel = document.getElementById(`step-panel-${stepIndex}`);
- if (panel) {
- const badge = panel.querySelector('.badge');
- if (badge) {
- const count = this.formSteps[stepIndex].fields ? this.formSteps[stepIndex].fields.length : 0;
- badge.textContent = `${count} fields`;
- }
- }
- }
-
- organizeFieldsIntoSteps() {
- // Assign all fields to their respective steps based on formSteps configuration
- // If a field isn't assigned to any step, put it in step 0
- const assignedFields = new Set();
-
- this.formSteps.forEach(step => {
- if (step.fields) {
- step.fields.forEach(fieldName => assignedFields.add(fieldName));
- }
- });
-
- // Add unassigned fields to first step
- this.fields.forEach(field => {
- if (!assignedFields.has(field.field_name)) {
- if (!this.formSteps[0].fields) {
- this.formSteps[0].fields = [];
- }
- this.formSteps[0].fields.push(field.field_name);
- }
- });
-
- this.renderFieldsInSteps();
- }
-
- renderFieldsInSteps() {
- // Render fields in their respective steps
- this.formSteps.forEach((step, stepIndex) => {
- this.renderSingleStep(stepIndex);
- });
- }
-
- renderSingleStep(stepIndex) {
- const step = this.formSteps[stepIndex];
- const canvas = document.getElementById(`step-canvas-${stepIndex}`);
- if (!canvas) return;
-
- canvas.innerHTML = '';
-
- if (!step.fields || step.fields.length === 0) {
- canvas.innerHTML = `
-
-
-
Drag fields here for ${this.escapeHtml(step.title)}
-
- `;
- this.updateStepFieldCount(stepIndex);
- return;
- }
-
- step.fields.forEach(fieldName => {
- const fieldIndex = this.fields.findIndex(f => f.field_name === fieldName);
- if (fieldIndex !== -1) {
- const fieldElement = this.createFieldElement(this.fields[fieldIndex], fieldIndex);
- canvas.appendChild(fieldElement);
- }
- });
-
- this.updateStepFieldCount(stepIndex);
- }
-
- moveAllFieldsToMainCanvas() {
- // Collect all fields from all steps
- const allFields = [];
- this.formSteps.forEach(step => {
- if (step.fields) {
- allFields.push(...step.fields);
- }
- });
-
- // Re-render main canvas
- this.renderCanvas();
- this.updatePreview();
- }
-
- updateFieldOrderFromSteps() {
- // Update field order based on step order
- // Fields should be ordered by step, then by position within step
- const orderedFields = [];
-
- this.formSteps.forEach(step => {
- if (step.fields) {
- step.fields.forEach(fieldName => {
- const field = this.fields.find(f => f.field_name === fieldName);
- if (field) {
- orderedFields.push(field);
- }
- });
- }
- });
-
- // Update this.fields with new order
- this.fields = orderedFields;
-
- // Update order property
- this.fields.forEach((field, index) => {
- field.order = index;
- });
- }
-
-
-
- deleteField(index) {
- if (confirm('Are you sure you want to delete this field?')) {
- this.pushUndo();
- this.deleteFieldSilently(index);
- }
- }
-
- deleteFieldSilently(index) {
- // Delete field without confirmation (used when canceling new field)
- const fieldToDelete = this.fields[index];
-
- // Remove from fields array
- this.fields.splice(index, 1);
-
- // Remove from step fields if in multi-step mode
- if (this.formSteps && this.formSteps.length > 0) {
- this.formSteps.forEach(step => {
- if (step.fields) {
- const fieldIndex = step.fields.indexOf(fieldToDelete.field_name);
- if (fieldIndex !== -1) {
- step.fields.splice(fieldIndex, 1);
- }
- }
- });
- }
-
- this.updateFieldOrders();
-
- // Re-render appropriate canvas
- const isMultiStep = document.getElementById('formEnableMultiStep')?.checked;
- if (isMultiStep) {
- this.renderStepTabs();
- } else {
- this.renderCanvas();
- }
-
- this.updatePreview();
- }
-
- updateFieldOrders() {
- this.fields.forEach((field, index) => {
- field.order = index + 1;
- });
- }
-
- escapeHtml(text) {
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
- }
-
- showFieldContextMenu(event, fieldIndex) {
- // Only show context menu in multi-step mode
- const isMultiStep = document.getElementById('formEnableMultiStep')?.checked;
- if (!isMultiStep || !this.formSteps || this.formSteps.length <= 1) {
- return; // Don't show menu if not in multi-step mode or only one step
- }
-
- // Remove any existing context menu
- this.hideFieldContextMenu();
-
- // Create context menu
- const menu = document.createElement('div');
- menu.className = 'field-context-menu';
- menu.style.position = 'fixed';
- menu.style.left = `${event.clientX}px`;
- menu.style.top = `${event.clientY}px`;
- menu.style.zIndex = '10000';
-
- // Build menu items
- let menuHTML = '';
-
- this.formSteps.forEach((step, stepIndex) => {
- menuHTML += `
-
- `;
- });
-
- menu.innerHTML = menuHTML;
- document.body.appendChild(menu);
- this.contextMenu = menu;
-
- // Close menu when clicking outside
- setTimeout(() => {
- document.addEventListener('click', () => this.hideFieldContextMenu(), { once: true });
- }, 10);
- }
-
- hideFieldContextMenu() {
- if (this.contextMenu) {
- this.contextMenu.remove();
- this.contextMenu = null;
- }
- }
-
- moveFieldToStepFromMenu(fieldIndex, targetStepIndex) {
- this.hideFieldContextMenu();
-
- const field = this.fields[fieldIndex];
- if (!field) return;
-
- // Find which step the field is currently in
- let sourceStepIndex = -1;
- this.formSteps.forEach((step, idx) => {
- if (step.fields && step.fields.includes(field.field_name)) {
- sourceStepIndex = idx;
- }
- });
-
- // If already in target step, do nothing
- if (sourceStepIndex === targetStepIndex) {
- return;
- }
-
- // Remove field from all steps
- this.formSteps.forEach(step => {
- if (step.fields) {
- step.fields = step.fields.filter(name => name !== field.field_name);
- }
- });
-
- // Add to target step
- if (!this.formSteps[targetStepIndex].fields) {
- this.formSteps[targetStepIndex].fields = [];
- }
- this.formSteps[targetStepIndex].fields.push(field.field_name);
-
- // Re-render both steps
- if (sourceStepIndex !== -1) {
- this.renderSingleStep(sourceStepIndex);
- }
- this.renderSingleStep(targetStepIndex);
-
- // Update preview
- this.updatePreview();
- }
}
-Object.assign(FormBuilder.prototype, historyMethods, apiMethods, propertyEditorMethods);
+Object.assign(FormBuilder.prototype, historyMethods, apiMethods, propertyEditorMethods, canvasMethods);
diff --git a/tests_js/form-builder-canvas/canvasMethods.test.js b/tests_js/form-builder-canvas/canvasMethods.test.js
new file mode 100644
index 0000000..765df78
--- /dev/null
+++ b/tests_js/form-builder-canvas/canvasMethods.test.js
@@ -0,0 +1,1171 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { canvasMethods } from '../../django_forms_workflows/static/django_forms_workflows/js/form-builder-canvas.js';
+import { historyMethods } from '../../django_forms_workflows/static/django_forms_workflows/js/form-builder-history.js';
+import { createBuilderStore } from '../../django_forms_workflows/static/django_forms_workflows/js/form-builder-store.js';
+
+function createContext({ fields = [], formSteps = [], fieldIdCounter = 1, config = {} } = {}) {
+ return {
+ store: createBuilderStore({ fields, formSteps, fieldIdCounter }),
+ config,
+ undoStack: [],
+ redoStack: [],
+ maxUndoSteps: 50,
+ draggingFieldType: null,
+ dragPlaceholder: null,
+ contextMenu: null,
+ // Real pushUndo (not a stub) - several assertions below check undoStack
+ // contents directly, the way the pre-extraction tests did.
+ pushUndo: historyMethods.pushUndo,
+ editField: vi.fn(),
+ updatePreview: vi.fn(),
+ saveForm: vi.fn(),
+ get fields() { return this.store.fields; },
+ set fields(value) { this.store.setFields(value); },
+ get formSteps() { return this.store.formSteps; },
+ set formSteps(value) { this.store.setFormSteps(value); },
+ get fieldIdCounter() { return this.store.fieldIdCounter; },
+ set fieldIdCounter(value) { this.store.fieldIdCounter = value; },
+ ...canvasMethods,
+ };
+}
+
+// Captures every Sortable instance created via `new Sortable(...)` or
+// `Sortable.create(...)`, so tests can invoke the config's onAdd/onUpdate/
+// onStart/onEnd callbacks directly rather than pulling in the real library.
+function stubSortable() {
+ const instances = [];
+ function Sortable(container, config) {
+ instances.push({ container, config });
+ return {};
+ }
+ Sortable.create = vi.fn((container, config) => {
+ instances.push({ container, config });
+ return {};
+ });
+ vi.stubGlobal('Sortable', Sortable);
+ return instances;
+}
+
+function paletteItemElement(fieldType) {
+ const el = document.createElement('div');
+ el.className = 'field-palette-item';
+ el.dataset.fieldType = fieldType;
+ return el;
+}
+
+function fieldItemElement(fieldIndex) {
+ const el = document.createElement('div');
+ el.className = 'field-item';
+ el.dataset.fieldIndex = String(fieldIndex);
+ return el;
+}
+
+afterEach(() => {
+ document.body.innerHTML = '';
+ vi.unstubAllGlobals();
+});
+
+describe('canvasMethods.setupFieldPalette', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('builds a flat fieldTypes list from the categorized field types and renders the palette', () => {
+ stubSortable();
+ const ctx = createContext();
+
+ ctx.setupFieldPalette();
+
+ expect(ctx.fieldTypeCategories.length).toBeGreaterThan(0);
+ expect(ctx.fieldTypes.length).toBe(
+ ctx.fieldTypeCategories.reduce((sum, cat) => sum + cat.types.length, 0)
+ );
+ expect(ctx.fieldTypes.some(ft => ft.type === 'text')).toBe(true);
+ expect(document.getElementById('fieldPalette').children.length).toBeGreaterThan(0);
+ });
+
+ it('re-renders the palette, filtered, when the search input changes', () => {
+ document.body.innerHTML = '';
+ stubSortable();
+ const ctx = createContext();
+ ctx.setupFieldPalette();
+ ctx.renderPalette = vi.fn();
+
+ document.getElementById('paletteSearch').value = ' Email ';
+ document.getElementById('paletteSearch').dispatchEvent(new Event('input'));
+
+ expect(ctx.renderPalette).toHaveBeenCalledWith('email');
+ });
+
+ it('tracks the dragged field type on Sortable onStart and clears it (plus the placeholder) on onEnd', () => {
+ const instances = stubSortable();
+ const ctx = createContext();
+ ctx.cleanupDragPlaceholder = vi.fn();
+ ctx.setupFieldPalette();
+ const { config } = instances[0];
+
+ config.onStart({ item: paletteItemElement('text') });
+ expect(ctx.draggingFieldType).toBe('text');
+
+ config.onEnd({});
+ expect(ctx.draggingFieldType).toBeNull();
+ expect(ctx.cleanupDragPlaceholder).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('canvasMethods.renderPalette', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ function ctxWithCategories() {
+ const ctx = createContext();
+ ctx.fieldTypeCategories = [
+ { name: 'Basic', icon: 'bi-x', types: [{ type: 'text', label: 'Single Line Text', icon: 'bi-x' }] },
+ { name: 'Selection', icon: 'bi-x', types: [{ type: 'select', label: 'Dropdown Select', icon: 'bi-x' }] },
+ ];
+ return ctx;
+ }
+
+ it('renders every category and item when there is no filter', () => {
+ const ctx = ctxWithCategories();
+
+ ctx.renderPalette('');
+
+ const palette = document.getElementById('fieldPalette');
+ expect(palette.querySelectorAll('.palette-category-header').length).toBe(2);
+ expect(palette.querySelectorAll('.field-palette-item').length).toBe(2);
+ });
+
+ it('matches on label or type, case-insensitively, and hides categories with no matches', () => {
+ const ctx = ctxWithCategories();
+
+ ctx.renderPalette('select');
+
+ const palette = document.getElementById('fieldPalette');
+ expect(palette.querySelectorAll('.palette-category-header').length).toBe(1);
+ expect(palette.querySelector('.field-palette-item').dataset.fieldType).toBe('select');
+ });
+});
+
+describe('canvasMethods.setupCanvas', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('does not call addFieldAtPosition a second time on drop (Sortable\'s onAdd already handles insertion)', () => {
+ stubSortable();
+ const ctx = createContext();
+ ctx.addFieldAtPosition = vi.fn();
+ ctx.draggingFieldType = 'text';
+ const canvas = document.getElementById('formCanvas');
+
+ ctx.setupCanvas();
+ canvas.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
+
+ expect(ctx.addFieldAtPosition).not.toHaveBeenCalled();
+ });
+
+ it('still cleans up the drag placeholder and dragging class on drop', () => {
+ stubSortable();
+ const ctx = createContext();
+ ctx.draggingFieldType = 'text';
+ const canvas = document.getElementById('formCanvas');
+ canvas.classList.add('dragging');
+ ctx.dragPlaceholder = document.createElement('div');
+ canvas.appendChild(ctx.dragPlaceholder);
+
+ ctx.setupCanvas();
+ canvas.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
+
+ expect(ctx.dragPlaceholder).toBeNull();
+ expect(canvas.classList.contains('dragging')).toBe(false);
+ });
+
+ it('Sortable onAdd inserts a new field from the palette and removes the clone', () => {
+ const instances = stubSortable();
+ const ctx = createContext();
+ ctx.addFieldAtPosition = vi.fn();
+ ctx.setupCanvas();
+ const { config } = instances[0];
+ const item = paletteItemElement('text');
+ document.getElementById('formCanvas').appendChild(item);
+
+ config.onAdd({ item, newIndex: 2 });
+
+ expect(ctx.addFieldAtPosition).toHaveBeenCalledWith('text', 2);
+ expect(item.parentNode).toBeNull();
+ });
+
+ it('Sortable onAdd reorders an existing field when the dropped item is not from the palette', () => {
+ const instances = stubSortable();
+ const ctx = createContext({ fields: [{ field_name: 'a' }, { field_name: 'b' }] });
+ ctx.setupCanvas();
+ const { config } = instances[0];
+ const item = fieldItemElement(0);
+
+ config.onAdd({ item, oldIndex: 0, newIndex: 1 });
+
+ expect(ctx.fields.map(f => f.field_name)).toEqual(['b', 'a']);
+ expect(ctx.updatePreview).toHaveBeenCalledTimes(1);
+ });
+
+ it('Sortable onAdd pushes an undo snapshot before reordering an existing field (regression: single-step canvas move used to skip history)', () => {
+ const instances = stubSortable();
+ const ctx = createContext({ fields: [{ field_name: 'a' }, { field_name: 'b' }] });
+ ctx.setupCanvas();
+ const { config } = instances[0];
+ const item = fieldItemElement(0);
+
+ config.onAdd({ item, oldIndex: 0, newIndex: 1 });
+
+ expect(ctx.undoStack).toHaveLength(1);
+ expect(JSON.parse(ctx.undoStack[0]).fields.map(f => f.field_name)).toEqual(['a', 'b']);
+ });
+
+ it('Sortable onUpdate reorders fields within the canvas', () => {
+ const instances = stubSortable();
+ const ctx = createContext({ fields: [{ field_name: 'a' }, { field_name: 'b' }] });
+ ctx.setupCanvas();
+ const { config } = instances[0];
+
+ config.onUpdate({ oldIndex: 1, newIndex: 0 });
+
+ expect(ctx.fields.map(f => f.field_name)).toEqual(['b', 'a']);
+ expect(ctx.updatePreview).toHaveBeenCalledTimes(1);
+ });
+
+ it('Sortable onUpdate pushes an undo snapshot before reordering (regression: single-step canvas reorder used to skip history)', () => {
+ const instances = stubSortable();
+ const ctx = createContext({ fields: [{ field_name: 'a' }, { field_name: 'b' }] });
+ ctx.setupCanvas();
+ const { config } = instances[0];
+
+ config.onUpdate({ oldIndex: 1, newIndex: 0 });
+
+ expect(ctx.undoStack).toHaveLength(1);
+ expect(JSON.parse(ctx.undoStack[0]).fields.map(f => f.field_name)).toEqual(['a', 'b']);
+ });
+
+ it('Sortable onStart/onEnd toggle the dragging class on the canvas', () => {
+ const instances = stubSortable();
+ const ctx = createContext();
+ ctx.setupCanvas();
+ const { config } = instances[0];
+ const canvas = document.getElementById('formCanvas');
+
+ config.onStart({});
+ expect(canvas.classList.contains('dragging')).toBe(true);
+
+ config.onEnd({});
+ expect(canvas.classList.contains('dragging')).toBe(false);
+ });
+});
+
+describe('canvasMethods.getDragAfterElement', () => {
+ it('returns the element whose vertical midpoint is just below the cursor', () => {
+ document.body.innerHTML = '';
+ const canvas = document.getElementById('formCanvas');
+ const above = document.createElement('div');
+ above.className = 'canvas-field';
+ above.getBoundingClientRect = () => ({ top: 0, height: 100 });
+ const below = document.createElement('div');
+ below.className = 'canvas-field';
+ below.getBoundingClientRect = () => ({ top: 100, height: 100 });
+ canvas.append(above, below);
+ const ctx = createContext();
+
+ expect(ctx.getDragAfterElement(canvas, 120)).toBe(below);
+ });
+
+ it('returns undefined (append at the end) when the cursor is below every field', () => {
+ document.body.innerHTML = '';
+ const canvas = document.getElementById('formCanvas');
+ const only = document.createElement('div');
+ only.className = 'canvas-field';
+ only.getBoundingClientRect = () => ({ top: 0, height: 100 });
+ canvas.appendChild(only);
+ const ctx = createContext();
+
+ expect(ctx.getDragAfterElement(canvas, 500)).toBeUndefined();
+ });
+});
+
+describe('canvasMethods.cleanupDragPlaceholder', () => {
+ it('removes the placeholder and clears the dragging class', () => {
+ document.body.innerHTML = '';
+ const canvas = document.getElementById('formCanvas');
+ const ctx = createContext();
+ ctx.dragPlaceholder = document.createElement('div');
+ canvas.appendChild(ctx.dragPlaceholder);
+
+ ctx.cleanupDragPlaceholder();
+
+ expect(ctx.dragPlaceholder).toBeNull();
+ expect(canvas.classList.contains('dragging')).toBe(false);
+ });
+
+ it('is a no-op when there is no placeholder or canvas', () => {
+ const ctx = createContext();
+ expect(() => ctx.cleanupDragPlaceholder()).not.toThrow();
+ });
+});
+
+describe('canvasMethods.addFieldAtPosition', () => {
+ it('inserts correctly on an empty canvas, when SortableJS reports an out-of-range drop position', () => {
+ const ctx = createContext();
+ ctx.renderCanvas = vi.fn();
+
+ ctx.addFieldAtPosition('text', 1);
+
+ expect(ctx.fields).toHaveLength(1);
+ expect(ctx.editField).toHaveBeenCalledWith(0, true);
+ });
+
+ it('inserts at the requested position when it is already in range', () => {
+ const ctx = createContext({ fields: [{ field_name: 'existing' }] });
+ ctx.renderCanvas = vi.fn();
+
+ ctx.addFieldAtPosition('text', 0);
+
+ expect(ctx.fields).toHaveLength(2);
+ expect(ctx.fields[1].field_name).toBe('existing');
+ expect(ctx.editField).toHaveBeenCalledWith(0, true);
+ });
+
+ it('derives the id and the default field_name from the same counter value (regression: id used to be one ahead of field_name)', () => {
+ const ctx = createContext({ fieldIdCounter: 5 });
+ ctx.renderCanvas = vi.fn();
+
+ ctx.addFieldAtPosition('text', 0);
+
+ expect(ctx.fields[0].id).toBe('new_5');
+ expect(ctx.fields[0].field_name).toBe('text_5');
+ });
+
+ it('pushes an undo snapshot before inserting', () => {
+ const ctx = createContext();
+ ctx.renderCanvas = vi.fn();
+
+ ctx.addFieldAtPosition('text', 0);
+
+ expect(ctx.undoStack).toHaveLength(1);
+ });
+});
+
+describe('canvasMethods.addField', () => {
+ it('delegates to addFieldAtPosition, appending at the end', () => {
+ const ctx = createContext({ fields: [{ field_name: 'a' }] });
+ ctx.addFieldAtPosition = vi.fn();
+
+ ctx.addField('text');
+
+ expect(ctx.addFieldAtPosition).toHaveBeenCalledWith('text', 1);
+ });
+});
+
+describe('canvasMethods.duplicateField', () => {
+ it('clones the field with a new id/name/label, inserted right after the original', () => {
+ const ctx = createContext({ fields: [{ id: 'f1', field_name: 'a', field_label: 'A', order: 1 }] });
+ ctx.renderCanvas = vi.fn();
+
+ ctx.duplicateField(0);
+
+ expect(ctx.fields).toHaveLength(2);
+ expect(ctx.fields[1].field_name).toBe('a_copy');
+ expect(ctx.fields[1].field_label).toBe('A (Copy)');
+ expect(ctx.fields[1].id).not.toBe('f1');
+ });
+
+ it('also inserts the clone into the original\'s step, right after it, in multi-step mode', () => {
+ const ctx = createContext({
+ fields: [{ field_name: 'a' }, { field_name: 'b' }],
+ formSteps: [{ title: 'Step 1', fields: ['a', 'b'] }],
+ });
+ document.body.innerHTML = '';
+ ctx.renderStepTabs = vi.fn();
+
+ ctx.duplicateField(0);
+
+ expect(ctx.formSteps[0].fields).toEqual(['a', 'a_copy', 'b']);
+ expect(ctx.renderStepTabs).toHaveBeenCalledTimes(1);
+ });
+
+ it('re-renders the single-step canvas and preview when not in multi-step mode', () => {
+ const ctx = createContext({ fields: [{ field_name: 'a' }] });
+ ctx.renderCanvas = vi.fn();
+
+ ctx.duplicateField(0);
+
+ expect(ctx.renderCanvas).toHaveBeenCalledTimes(1);
+ expect(ctx.updatePreview).toHaveBeenCalledTimes(1);
+ });
+
+ it('pushes an undo snapshot before duplicating', () => {
+ const ctx = createContext({ fields: [{ field_name: 'a' }] });
+ ctx.renderCanvas = vi.fn();
+
+ ctx.duplicateField(0);
+
+ expect(ctx.undoStack).toHaveLength(1);
+ });
+});
+
+describe('canvasMethods.getDefaultLabel / getDefaultName', () => {
+ it('returns a human-readable label for known types and a generic fallback otherwise', () => {
+ const ctx = createContext();
+
+ expect(ctx.getDefaultLabel('email')).toBe('Email Address');
+ expect(ctx.getDefaultLabel('totally_unknown')).toBe('Field');
+ });
+
+ it('builds a name from the field type and the current (not-yet-incremented) counter', () => {
+ const ctx = createContext({ fieldIdCounter: 7 });
+
+ expect(ctx.getDefaultName('text')).toBe('text_7');
+ expect(ctx.fieldIdCounter).toBe(7); // reading the name must not itself advance the counter
+ });
+});
+
+describe('canvasMethods.renderCanvas / createFieldElement', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('shows the empty-canvas placeholder and a "0 fields" count when there are no fields', () => {
+ const ctx = createContext();
+
+ ctx.renderCanvas();
+
+ expect(document.querySelector('.empty-canvas')).not.toBeNull();
+ expect(document.getElementById('fieldCount').textContent).toBe('0 fields');
+ });
+
+ it('renders one element per field plus a trailing drop zone, and updates the count', () => {
+ const ctx = createContext({ fields: [{ field_name: 'a', field_label: 'A', field_type: 'text' }, { field_name: 'b', field_label: 'B', field_type: 'text' }] });
+
+ ctx.renderCanvas();
+
+ const canvas = document.getElementById('formCanvas');
+ expect(canvas.querySelectorAll('.field-item').length).toBe(2);
+ expect(canvas.querySelector('.canvas-drop-zone')).not.toBeNull();
+ expect(document.getElementById('fieldCount').textContent).toBe('2 fields');
+ });
+
+ it('renders a section field with the section-specific class/badge', () => {
+ const ctx = createContext();
+
+ const el = ctx.createFieldElement({ field_type: 'section', field_label: 'My Section' }, 0);
+
+ expect(el.className).toContain('canvas-section');
+ expect(el.querySelector('.section-badge')).not.toBeNull();
+ });
+
+ it('escapes the field label before rendering it', () => {
+ const ctx = createContext();
+
+ const el = ctx.createFieldElement({ field_type: 'text', field_name: 'f', field_label: '
' }, 0);
+
+ expect(el.innerHTML).not.toContain('
');
+ expect(el.innerHTML).toContain(ctx.escapeHtml('
'));
+ });
+
+ it('escapes field_name, width, and field_type before rendering them (regression: these rendered raw while field_label was already escaped)', () => {
+ const ctx = createContext();
+ const field = {
+ field_type: '',
+ field_name: '',
+ field_label: 'F',
+ width: '',
+ };
+
+ const el = ctx.createFieldElement(field, 0);
+
+ expect(el.querySelector('script')).toBeNull();
+ expect(el.innerHTML).toContain(ctx.escapeHtml(field.field_name));
+ expect(el.innerHTML).toContain(ctx.escapeHtml(field.width));
+ expect(el.innerHTML).toContain(ctx.escapeHtml(field.field_type));
+ });
+
+ it('routes right-click on a rendered field to showFieldContextMenu with its index', () => {
+ const ctx = createContext();
+ ctx.showFieldContextMenu = vi.fn();
+ const el = ctx.createFieldElement({ field_type: 'text', field_name: 'f', field_label: 'F' }, 3);
+ document.body.appendChild(el);
+
+ el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }));
+
+ expect(ctx.showFieldContextMenu).toHaveBeenCalledWith(expect.anything(), 3);
+ });
+});
+
+describe('canvasMethods.toggleMultiStepMode', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('refreshes the live preview when enabling multi-step', () => {
+ const ctx = createContext({ fields: [{ field_name: 'a' }], formSteps: [] });
+ ctx.renderStepTabs = vi.fn();
+
+ ctx.toggleMultiStepMode(true);
+
+ expect(ctx.updatePreview).toHaveBeenCalledTimes(1);
+ });
+
+ it('refreshes both the canvas and the live preview when disabling multi-step', () => {
+ const ctx = createContext({ fields: [{ field_name: 'a' }], formSteps: [{ title: 'Step 1', fields: ['a'] }] });
+ ctx.renderCanvas = vi.fn();
+
+ ctx.toggleMultiStepMode(false);
+
+ expect(ctx.renderCanvas).toHaveBeenCalledTimes(1);
+ expect(ctx.updatePreview).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('canvasMethods.renderStepTabs', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('renders one step card per step, escaping the title, and disables removal when only one step exists', () => {
+ stubSortable();
+ const ctx = createContext({
+ fields: [],
+ formSteps: [{ title: 'Step 1', fields: [] }],
+ });
+
+ ctx.renderStepTabs();
+
+ const content = document.getElementById('stepTabContent');
+ expect(content.querySelectorAll('.step-card').length).toBe(1);
+ // The title must never be parsed as markup - no element should
+ // exist anywhere in the rendered output, whether it landed in the
+ // input's value attribute or the "Drag fields here for..." text.
+ expect(content.querySelector('b')).toBeNull();
+ expect(content.querySelector('.step-canvas p').innerHTML).toContain(ctx.escapeHtml('Step 1'));
+ expect(content.querySelector('button[onclick^="formBuilder.removeStepTab"]').disabled).toBe(true);
+ });
+
+ it('leaves the remove button enabled when there is more than one step', () => {
+ stubSortable();
+ const ctx = createContext({
+ fields: [],
+ formSteps: [{ title: 'Step 1', fields: [] }, { title: 'Step 2', fields: [] }],
+ });
+
+ ctx.renderStepTabs();
+
+ const buttons = document.querySelectorAll('button[onclick^="formBuilder.removeStepTab"]');
+ expect(Array.from(buttons).every(b => !b.disabled)).toBe(true);
+ });
+});
+
+describe('canvasMethods.setupStepCanvasSortable / setupStepCanvasDragDrop', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('routes a palette drop to handleFieldDroppedToStep and an existing-field drop to handleFieldMovedToStep', () => {
+ const instances = stubSortable();
+ const ctx = createContext();
+ ctx.handleFieldDroppedToStep = vi.fn();
+ ctx.handleFieldMovedToStep = vi.fn();
+
+ ctx.setupStepCanvasSortable(0);
+ const { config } = instances[0];
+
+ const paletteItem = paletteItemElement('text');
+ document.getElementById('step-canvas-0').appendChild(paletteItem);
+ config.onAdd({ item: paletteItem, newIndex: 1 });
+ expect(ctx.handleFieldDroppedToStep).toHaveBeenCalledWith('text', 0, 1);
+ expect(paletteItem.parentNode).toBeNull();
+
+ const fieldItem = fieldItemElement(2);
+ config.onAdd({ item: fieldItem });
+ expect(ctx.handleFieldMovedToStep).toHaveBeenCalledWith(fieldItem, 0);
+ });
+
+ it('routes a Sortable reorder within the step to updateFieldOrderInStep', () => {
+ const instances = stubSortable();
+ const ctx = createContext();
+ ctx.updateFieldOrderInStep = vi.fn();
+
+ ctx.setupStepCanvasSortable(0);
+ instances[0].config.onUpdate({});
+
+ expect(ctx.updateFieldOrderInStep).toHaveBeenCalledWith(0);
+ });
+
+ it('native drop from the palette (fallback path) also routes to handleFieldDroppedToStep', () => {
+ const ctx = createContext();
+ ctx.handleFieldDroppedToStep = vi.fn();
+ ctx.setupStepCanvasDragDrop(0);
+ const canvas = document.getElementById('step-canvas-0');
+
+ const dropEvent = new Event('drop', { bubbles: true, cancelable: true });
+ dropEvent.dataTransfer = { getData: () => 'text' };
+ canvas.dispatchEvent(dropEvent);
+
+ expect(ctx.handleFieldDroppedToStep).toHaveBeenCalledWith('text', 0);
+ });
+});
+
+describe('canvasMethods.handleFieldDroppedToStep', () => {
+ function createDropContext() {
+ return createContext({ formSteps: [{ title: 'Step 1', fields: [] }] });
+ }
+
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('pushes an undo snapshot before adding the field', () => {
+ const ctx = createDropContext();
+ ctx.fieldTypes = [{ type: 'text' }];
+ ctx.renderFieldsInSteps = vi.fn();
+
+ ctx.handleFieldDroppedToStep('text', 0);
+
+ expect(ctx.undoStack).toHaveLength(1);
+ expect(JSON.parse(ctx.undoStack[0]).fields).toEqual([]);
+ });
+
+ it('adds the field to both this.fields and the target step', () => {
+ const ctx = createDropContext();
+ ctx.fieldTypes = [{ type: 'text' }];
+ ctx.renderFieldsInSteps = vi.fn();
+
+ ctx.handleFieldDroppedToStep('text', 0);
+
+ expect(ctx.fields).toHaveLength(1);
+ expect(ctx.formSteps[0].fields).toEqual([ctx.fields[0].field_name]);
+ });
+
+ it('does nothing when the field type is unknown', () => {
+ const ctx = createDropContext();
+ ctx.fieldTypes = [{ type: 'text' }];
+
+ ctx.handleFieldDroppedToStep('bogus', 0);
+
+ expect(ctx.undoStack).toHaveLength(0);
+ expect(ctx.fields).toHaveLength(0);
+ });
+
+ it('reorders this.fields to match the step position when dropped before an existing field, not just appended', () => {
+ const ctx = createContext({
+ fields: [{ field_name: 'existing' }],
+ formSteps: [{ title: 'Step 1', fields: ['existing'] }],
+ });
+ ctx.fieldTypes = [{ type: 'text' }];
+ ctx.renderFieldsInSteps = vi.fn();
+
+ ctx.handleFieldDroppedToStep('text', 0, 0);
+
+ expect(ctx.formSteps[0].fields[0]).not.toBe('existing');
+ expect(ctx.fields.map(f => f.field_name)).toEqual(ctx.formSteps[0].fields);
+ });
+
+ it('opens the property editor for the newly-added field even after this.fields gets reordered', () => {
+ const ctx = createContext({
+ fields: [{ field_name: 'existing' }],
+ formSteps: [{ title: 'Step 1', fields: ['existing'] }],
+ });
+ ctx.fieldTypes = [{ type: 'text' }];
+ ctx.renderFieldsInSteps = vi.fn();
+
+ ctx.handleFieldDroppedToStep('text', 0, 0);
+
+ const newFieldIndex = ctx.fields.findIndex(f => f.field_name !== 'existing');
+ expect(ctx.editField).toHaveBeenCalledWith(newFieldIndex, true);
+ });
+});
+
+describe('canvasMethods.addStepTab / removeStepTab / updateStepTitle', () => {
+ it('appends a new, sequentially-titled step and re-renders the tabs', () => {
+ const ctx = createContext({ formSteps: [{ title: 'Step 1', fields: [] }] });
+ ctx.renderStepTabs = vi.fn();
+
+ ctx.addStepTab();
+
+ expect(ctx.formSteps).toHaveLength(2);
+ expect(ctx.formSteps[1]).toEqual({ title: 'Step 2', fields: [] });
+ expect(ctx.renderStepTabs).toHaveBeenCalledTimes(1);
+ });
+
+ it('refuses to remove the last remaining step', () => {
+ vi.stubGlobal('alert', vi.fn());
+ const ctx = createContext({ formSteps: [{ title: 'Step 1', fields: [] }] });
+ ctx.renderStepTabs = vi.fn();
+
+ ctx.removeStepTab(0);
+
+ expect(ctx.formSteps).toHaveLength(1);
+ expect(alert).toHaveBeenCalled();
+ });
+
+ it('moves the removed step\'s fields to step 0 and removes it, once confirmed', () => {
+ vi.stubGlobal('confirm', vi.fn(() => true));
+ const ctx = createContext({
+ formSteps: [{ title: 'Step 1', fields: ['a'] }, { title: 'Step 2', fields: ['b'] }],
+ });
+ ctx.renderStepTabs = vi.fn();
+
+ ctx.removeStepTab(1);
+
+ expect(ctx.formSteps).toEqual([{ title: 'Step 1', fields: ['a', 'b'] }]);
+ expect(ctx.renderStepTabs).toHaveBeenCalledTimes(1);
+ });
+
+ it('leaves formSteps untouched when removal is not confirmed', () => {
+ vi.stubGlobal('confirm', vi.fn(() => false));
+ const ctx = createContext({
+ formSteps: [{ title: 'Step 1', fields: ['a'] }, { title: 'Step 2', fields: ['b'] }],
+ });
+ ctx.renderStepTabs = vi.fn();
+
+ ctx.removeStepTab(1);
+
+ expect(ctx.formSteps).toHaveLength(2);
+ expect(ctx.renderStepTabs).not.toHaveBeenCalled();
+ });
+
+ it('updates the step title in state and, escaped, in the DOM tab if present', () => {
+ document.body.innerHTML = `
+
+ `;
+ const ctx = createContext({ formSteps: [{ title: 'Old', fields: [] }] });
+
+ ctx.updateStepTitle(0, 'New');
+
+ expect(ctx.formSteps[0].title).toBe('New');
+ const tab = document.getElementById('step-tab-0');
+ expect(tab.innerHTML).not.toContain('New');
+ expect(tab.innerHTML).toContain(ctx.escapeHtml('New'));
+ });
+
+ it('does not throw when the corresponding DOM tab is absent', () => {
+ const ctx = createContext({ formSteps: [{ title: 'Old', fields: [] }] });
+ expect(() => ctx.updateStepTitle(0, 'New')).not.toThrow();
+ expect(ctx.formSteps[0].title).toBe('New');
+ });
+});
+
+describe('canvasMethods.handleFieldMovedToStep', () => {
+ function createInstance({ fields, formSteps }) {
+ return createContext({ fields, formSteps });
+ }
+
+ it('moves the field name out of its source step and into the target step at the dropped DOM position', () => {
+ const ctx = createInstance({
+ fields: [{ field_name: 'a' }, { field_name: 'b' }, { field_name: 'c' }],
+ formSteps: [{ title: 'Step 1', fields: ['a', 'b'] }, { title: 'Step 2', fields: ['c'] }],
+ });
+ ctx.renderFieldsInSteps = vi.fn();
+ document.body.innerHTML = '';
+ const canvas = document.getElementById('step-canvas-1');
+ const cEl = fieldItemElement(2);
+ const aEl = fieldItemElement(0);
+ canvas.appendChild(cEl);
+ canvas.appendChild(aEl);
+
+ ctx.handleFieldMovedToStep(aEl, 1);
+
+ expect(ctx.formSteps[0].fields).toEqual(['b']);
+ expect(ctx.formSteps[1].fields).toEqual(['c', 'a']);
+ });
+
+ it('reorders this.fields to match the new cross-step order (regression: preview used to keep the stale array order)', () => {
+ const ctx = createInstance({
+ fields: [{ field_name: 'a' }, { field_name: 'b' }, { field_name: 'c' }],
+ formSteps: [{ title: 'Step 1', fields: ['a', 'b'] }, { title: 'Step 2', fields: ['c'] }],
+ });
+ ctx.renderFieldsInSteps = vi.fn();
+ document.body.innerHTML = '';
+ const canvas = document.getElementById('step-canvas-1');
+ const cEl = fieldItemElement(2);
+ const aEl = fieldItemElement(0);
+ canvas.appendChild(cEl);
+ canvas.appendChild(aEl);
+
+ ctx.handleFieldMovedToStep(aEl, 1);
+
+ expect(ctx.fields.map(f => f.field_name)).toEqual(['b', 'c', 'a']);
+ });
+
+ it('does nothing when the dragged element has no matching field', () => {
+ const ctx = createInstance({
+ fields: [{ field_name: 'a' }],
+ formSteps: [{ title: 'Step 1', fields: ['a'] }],
+ });
+ ctx.renderFieldsInSteps = vi.fn();
+ const orphanEl = fieldItemElement(99);
+
+ ctx.handleFieldMovedToStep(orphanEl, 0);
+
+ expect(ctx.formSteps[0].fields).toEqual(['a']);
+ expect(ctx.renderFieldsInSteps).not.toHaveBeenCalled();
+ });
+
+ it('pushes an undo snapshot before moving the field, so Ctrl+Z can restore the pre-move step assignment', () => {
+ const ctx = createInstance({
+ fields: [{ field_name: 'a' }, { field_name: 'b' }, { field_name: 'c' }],
+ formSteps: [{ title: 'Step 1', fields: ['a', 'b'] }, { title: 'Step 2', fields: ['c'] }],
+ });
+ ctx.renderFieldsInSteps = vi.fn();
+ document.body.innerHTML = '';
+ const canvas = document.getElementById('step-canvas-1');
+ const cEl = fieldItemElement(2);
+ const aEl = fieldItemElement(0);
+ canvas.appendChild(cEl);
+ canvas.appendChild(aEl);
+
+ ctx.handleFieldMovedToStep(aEl, 1);
+
+ expect(ctx.undoStack).toHaveLength(1);
+ expect(JSON.parse(ctx.undoStack[0])).toEqual({
+ fields: [{ field_name: 'a' }, { field_name: 'b' }, { field_name: 'c' }],
+ formSteps: [{ title: 'Step 1', fields: ['a', 'b'] }, { title: 'Step 2', fields: ['c'] }],
+ });
+ });
+
+ it('does not push an undo snapshot when the dragged element has no matching field', () => {
+ const ctx = createInstance({
+ fields: [{ field_name: 'a' }],
+ formSteps: [{ title: 'Step 1', fields: ['a'] }],
+ });
+ const orphanEl = fieldItemElement(99);
+
+ ctx.handleFieldMovedToStep(orphanEl, 0);
+
+ expect(ctx.undoStack).toHaveLength(0);
+ });
+});
+
+describe('canvasMethods.updateFieldOrderInStep', () => {
+ function setStepCanvasOrder(stepIndex, fieldIndexesInOrder) {
+ document.body.innerHTML = ``;
+ const canvas = document.getElementById(`step-canvas-${stepIndex}`);
+ fieldIndexesInOrder.forEach((fieldIndex) => canvas.appendChild(fieldItemElement(fieldIndex)));
+ }
+
+ it('rewrites the step field order from the DOM order and refreshes the preview', () => {
+ const ctx = createContext({
+ fields: [{ field_name: 'a' }, { field_name: 'b' }],
+ formSteps: [{ title: 'Step 1', fields: ['a', 'b'] }],
+ });
+ ctx.renderFieldsInSteps = vi.fn();
+ setStepCanvasOrder(0, [1, 0]);
+
+ ctx.updateFieldOrderInStep(0);
+
+ expect(ctx.formSteps[0].fields).toEqual(['b', 'a']);
+ expect(ctx.updatePreview).toHaveBeenCalledTimes(1);
+ });
+
+ it('reorders this.fields to match the new step order, not just formSteps (regression: preview used to keep the stale array order)', () => {
+ const ctx = createContext({
+ fields: [{ field_name: 'a' }, { field_name: 'b' }],
+ formSteps: [{ title: 'Step 1', fields: ['a', 'b'] }],
+ });
+ ctx.renderFieldsInSteps = vi.fn();
+ setStepCanvasOrder(0, [1, 0]);
+
+ ctx.updateFieldOrderInStep(0);
+
+ expect(ctx.fields.map(f => f.field_name)).toEqual(['b', 'a']);
+ });
+
+ it('pushes an undo snapshot before reordering, so Ctrl+Z can restore the pre-drag order', () => {
+ const ctx = createContext({
+ fields: [{ field_name: 'a' }, { field_name: 'b' }],
+ formSteps: [{ title: 'Step 1', fields: ['a', 'b'] }],
+ });
+ ctx.renderFieldsInSteps = vi.fn();
+ setStepCanvasOrder(0, [1, 0]);
+
+ ctx.updateFieldOrderInStep(0);
+
+ expect(ctx.undoStack).toHaveLength(1);
+ expect(JSON.parse(ctx.undoStack[0])).toEqual({
+ fields: [{ field_name: 'a' }, { field_name: 'b' }],
+ formSteps: [{ title: 'Step 1', fields: ['a', 'b'] }],
+ });
+ });
+});
+
+describe('multi-step field-index sync across steps (regression)', () => {
+ // Regression coverage for a bug found after the this.fields/formSteps sync
+ // fix: reordering this.fields *after* rendering (or only re-rendering the
+ // touched step) left other steps' data-field-index attributes pointing at
+ // the wrong entries once this.fields' array order changed underneath them.
+ function setupStepCanvases(stepIndexes) {
+ document.body.innerHTML = stepIndexes.map(i => ``).join('');
+ }
+
+ it('keeps every rendered field-item across every step pointing at the right field after a cross-step add', () => {
+ const ctx = createContext({
+ fields: [{ field_name: 'a', field_label: 'A', field_type: 'text' }, { field_name: 'b', field_label: 'B', field_type: 'text' }],
+ formSteps: [{ title: 'Step 1', fields: ['a'] }, { title: 'Step 2', fields: ['b'] }],
+ });
+ ctx.fieldTypes = [{ type: 'text' }];
+ setupStepCanvases([0, 1]);
+
+ ctx.handleFieldDroppedToStep('text', 0, 0);
+
+ document.querySelectorAll('.field-item').forEach(el => {
+ const idx = parseInt(el.dataset.fieldIndex);
+ const field = ctx.fields[idx];
+ expect(field).toBeDefined();
+ expect(el.querySelector('.field-label').textContent).toBe(field.field_label);
+ });
+ });
+
+ it('does not corrupt formSteps/this.fields when a step is reordered right after a cross-step field-index shift', () => {
+ const ctx = createContext({
+ fields: [{ field_name: 'a', field_label: 'A', field_type: 'text' }, { field_name: 'b', field_label: 'B', field_type: 'text' }],
+ formSteps: [{ title: 'Step 1', fields: ['a'] }, { title: 'Step 2', fields: ['b'] }],
+ });
+ ctx.fieldTypes = [{ type: 'text' }];
+ setupStepCanvases([0, 1]);
+
+ ctx.handleFieldDroppedToStep('text', 0, 0);
+ ctx.updateFieldOrderInStep(0);
+
+ const allNames = ctx.fields.map(f => f.field_name);
+ expect(new Set(allNames).size).toBe(allNames.length);
+ expect(allNames).toHaveLength(3);
+ expect(ctx.formSteps[1].fields).toEqual(['b']);
+ });
+});
+
+describe('canvasMethods.updateStepFieldCount', () => {
+ it('writes the step\'s field count into its panel badge', () => {
+ document.body.innerHTML = '
';
+ const ctx = createContext({ formSteps: [{ title: 'Step 1', fields: ['a', 'b'] }] });
+
+ ctx.updateStepFieldCount(0);
+
+ expect(document.querySelector('#step-panel-0 .badge').textContent).toBe('2 fields');
+ });
+
+ it('does not throw when the panel is absent', () => {
+ const ctx = createContext({ formSteps: [{ title: 'Step 1', fields: [] }] });
+ expect(() => ctx.updateStepFieldCount(0)).not.toThrow();
+ });
+});
+
+describe('canvasMethods.organizeFieldsIntoSteps / renderFieldsInSteps / renderSingleStep', () => {
+ it('assigns every field not already listed in some step to step 0', () => {
+ const ctx = createContext({
+ fields: [{ field_name: 'a' }, { field_name: 'b' }],
+ formSteps: [{ title: 'Step 1', fields: ['a'] }],
+ });
+ ctx.renderFieldsInSteps = vi.fn();
+
+ ctx.organizeFieldsIntoSteps();
+
+ expect(ctx.formSteps[0].fields).toEqual(['a', 'b']);
+ expect(ctx.renderFieldsInSteps).toHaveBeenCalledTimes(1);
+ });
+
+ it('renderFieldsInSteps renders every step by delegating to renderSingleStep', () => {
+ const ctx = createContext({ formSteps: [{ title: 'Step 1', fields: [] }, { title: 'Step 2', fields: [] }] });
+ ctx.renderSingleStep = vi.fn();
+
+ ctx.renderFieldsInSteps();
+
+ expect(ctx.renderSingleStep).toHaveBeenCalledTimes(2);
+ expect(ctx.renderSingleStep).toHaveBeenCalledWith(0);
+ expect(ctx.renderSingleStep).toHaveBeenCalledWith(1);
+ });
+
+ it('renderSingleStep shows an escaped empty-state message when the step has no fields', () => {
+ document.body.innerHTML = '';
+ const ctx = createContext({ formSteps: [{ title: 'Step 1', fields: [] }] });
+
+ ctx.renderSingleStep(0);
+
+ const canvas = document.getElementById('step-canvas-0');
+ expect(canvas.innerHTML).not.toContain('Step 1');
+ expect(canvas.innerHTML).toContain(ctx.escapeHtml('Step 1'));
+ });
+
+ it('renderSingleStep renders each of the step\'s fields, looked up by name', () => {
+ document.body.innerHTML = '';
+ const ctx = createContext({
+ fields: [{ field_name: 'a', field_label: 'A', field_type: 'text' }],
+ formSteps: [{ title: 'Step 1', fields: ['a'] }],
+ });
+
+ ctx.renderSingleStep(0);
+
+ expect(document.querySelectorAll('#step-canvas-0 .field-item').length).toBe(1);
+ });
+});
+
+describe('canvasMethods.moveAllFieldsToMainCanvas', () => {
+ it('refreshes both the canvas and the live preview', () => {
+ const ctx = createContext({ formSteps: [{ title: 'Step 1', fields: ['a'] }] });
+ ctx.renderCanvas = vi.fn();
+
+ ctx.moveAllFieldsToMainCanvas();
+
+ expect(ctx.renderCanvas).toHaveBeenCalledTimes(1);
+ expect(ctx.updatePreview).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('canvasMethods.updateFieldOrderFromSteps', () => {
+ it('reorders this.fields to step order (step by step, field by field within each step) and renumbers order', () => {
+ const ctx = createContext({
+ fields: [{ field_name: 'a', order: 0 }, { field_name: 'b', order: 1 }, { field_name: 'c', order: 2 }],
+ formSteps: [{ title: 'Step 1', fields: ['c', 'a'] }, { title: 'Step 2', fields: ['b'] }],
+ });
+
+ ctx.updateFieldOrderFromSteps();
+
+ expect(ctx.fields.map(f => f.field_name)).toEqual(['c', 'a', 'b']);
+ expect(ctx.fields.map(f => f.order)).toEqual([0, 1, 2]);
+ });
+});
+
+describe('canvasMethods.deleteField / deleteFieldSilently / updateFieldOrders', () => {
+ it('deleteField pushes undo and deletes only when confirmed', () => {
+ vi.stubGlobal('confirm', vi.fn(() => true));
+ const ctx = createContext({ fields: [{ field_name: 'a' }] });
+ ctx.renderCanvas = vi.fn();
+
+ ctx.deleteField(0);
+
+ expect(ctx.undoStack).toHaveLength(1);
+ expect(ctx.fields).toHaveLength(0);
+ });
+
+ it('deleteField does nothing when the confirmation is declined', () => {
+ vi.stubGlobal('confirm', vi.fn(() => false));
+ const ctx = createContext({ fields: [{ field_name: 'a' }] });
+
+ ctx.deleteField(0);
+
+ expect(ctx.undoStack).toHaveLength(0);
+ expect(ctx.fields).toHaveLength(1);
+ });
+
+ it('deleteFieldSilently removes the field from this.fields and from every step that referenced it', () => {
+ const ctx = createContext({
+ fields: [{ field_name: 'a' }, { field_name: 'b' }],
+ formSteps: [{ title: 'Step 1', fields: ['a', 'b'] }],
+ });
+ ctx.renderStepTabs = vi.fn();
+ document.body.innerHTML = '';
+
+ ctx.deleteFieldSilently(0);
+
+ expect(ctx.fields.map(f => f.field_name)).toEqual(['b']);
+ expect(ctx.formSteps[0].fields).toEqual(['b']);
+ expect(ctx.renderStepTabs).toHaveBeenCalledTimes(1);
+ });
+
+ it('deleteFieldSilently re-renders the single-step canvas when not in multi-step mode', () => {
+ const ctx = createContext({ fields: [{ field_name: 'a' }] });
+ ctx.renderCanvas = vi.fn();
+
+ ctx.deleteFieldSilently(0);
+
+ expect(ctx.renderCanvas).toHaveBeenCalledTimes(1);
+ expect(ctx.updatePreview).toHaveBeenCalledTimes(1);
+ });
+
+ it('updateFieldOrders renumbers order 1-based, matching array position', () => {
+ const ctx = createContext({ fields: [{ field_name: 'a', order: 99 }, { field_name: 'b', order: 1 }] });
+
+ ctx.updateFieldOrders();
+
+ expect(ctx.fields.map(f => f.order)).toEqual([1, 2]);
+ });
+});
+
+describe('canvasMethods.escapeHtml', () => {
+ it('escapes HTML-significant characters', () => {
+ const ctx = createContext();
+
+ expect(ctx.escapeHtml('')).not.toContain('