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 = ` +
+
+ + New field will be inserted here +
+
+ `; + } + + // 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 = ` +
+
+
+ ${index + 1} + +
+
+ ${step.fields ? step.fields.length : 0} fields + +
+
+
+
+
+ +

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 = '
Move to Step:
'; + + this.formSteps.forEach((step, stepIndex) => { + menuHTML += ` +
+ + ${this.escapeHtml(step.title)} +
+ `; + }); + + 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 = ` -
-
- - New field will be inserted here -
-
- `; - } - - // 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 = ` -
-
-
- ${index + 1} - -
-
- ${step.fields ? step.fields.length : 0} fields - -
-
-
-
-
- -

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 = '
Move to Step:
'; - - this.formSteps.forEach((step, stepIndex) => { - menuHTML += ` -
- - ${this.escapeHtml(step.title)} -
- `; - }); - - 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('