From 55ad31e2f897d360e2ab95ebde78eb8f34fe7ad5 Mon Sep 17 00:00:00 2001 From: Maria Violante Date: Mon, 3 Aug 2026 10:05:30 -0400 Subject: [PATCH 1/6] Create form-builder-property-editor.js module (extracted from form-builder.js) --- .../js/form-builder-property-editor.js | 728 ++++++++++++++++++ .../django_forms_workflows/js/form-builder.js | 718 +---------------- .../propertyEditorMethods.test.js} | 0 3 files changed, 730 insertions(+), 716 deletions(-) create mode 100644 django_forms_workflows/static/django_forms_workflows/js/form-builder-property-editor.js rename tests_js/{form-builder/initializePropertyFormTabs.test.js => form-builder-property-editor/propertyEditorMethods.test.js} (100%) diff --git a/django_forms_workflows/static/django_forms_workflows/js/form-builder-property-editor.js b/django_forms_workflows/static/django_forms_workflows/js/form-builder-property-editor.js new file mode 100644 index 0000000..d6be275 --- /dev/null +++ b/django_forms_workflows/static/django_forms_workflows/js/form-builder-property-editor.js @@ -0,0 +1,728 @@ +/** + * Property editor for the Form Builder: the field-properties modal (Basic/ + * Conditional Logic/Validation/Dependencies tabs) and saving those changes + * back onto the field. + * + * Mixed onto FormBuilder.prototype in form-builder.js (Object.assign), not a + * standalone class of its own - these methods read/write a large number of + * page DOM fields directly, plus `this.fields`/`this.currentFieldIndex`/ + * `this.isNewField`/`this.config`, and call back into + * deleteFieldSilently()/escapeHtml()/renderCanvas()/updatePreview(), which + * still live on the single FormBuilder instance. + */ +export const propertyEditorMethods = { + editField(index, isNew = false) { + this.currentFieldIndex = index; + this.isNewField = isNew; + const field = this.fields[index]; + + // Build property form + const form = this.buildPropertyForm(field); + document.getElementById('fieldPropertyForm').innerHTML = form; + this.initializePropertyFormTabs(field); + + // Show modal + const modalElement = document.getElementById('fieldPropertyModal'); + const modal = new bootstrap.Modal(modalElement); + + // Handle modal close/cancel - remove field if it's new and not saved + const handleModalClose = () => { + if (this.isNewField) { + // Field was not saved, remove it + this.deleteFieldSilently(this.currentFieldIndex); + } + this.isNewField = false; + // Remove event listener to avoid memory leaks + modalElement.removeEventListener('hidden.bs.modal', handleModalClose); + }; + + // Add event listener for modal close + modalElement.addEventListener('hidden.bs.modal', handleModalClose); + + modal.show(); + }, + + buildPropertyForm(field) { + const prefillOptions = this.config.prefillSources.map(source => + `` + ).join(''); + + const widthChoices = [ + { value: 'full', label: 'Full Width' }, + { value: 'half', label: 'Half (50%)' }, + { value: 'third', label: 'One Third (33%)' }, + { value: 'fourth', label: 'One Quarter (25%)' } + ]; + const widthOptions = widthChoices.map(w => + `` + ).join(''); + + return ` + + + + +
+ +
+ ${this.buildBasicPropertiesTab(field, prefillOptions, widthOptions)} +
+ + +
+ ${this.buildConditionalLogicTab(field)} +
+ + +
+ ${this.buildValidationTab(field)} +
+ + +
+ ${this.buildDependenciesTab(field)} +
+
+ `; + }, + + buildBasicPropertiesTab(field, prefillOptions, widthOptions) { + return ` +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+ ${['select', 'radio', 'checkbox_multiple', 'multiselect', 'multiselect_list', 'checkboxes'].includes(field.field_type) ? ` +
+ + + Enter each option on a new line. Use value|Label format for separate values and display text. +
+ ` : ''} + ${field.field_type === 'calculated' ? ` +
+ + + Use {field_name} to reference other fields. Evaluated live on the client and re-validated on the server. +
+ ` : ''} + ${field.field_type === 'display_text' ? ` +
+ + + Supports Markdown: **bold**, *italic*, [links](url), lists, etc. This text is shown read-only on the form. +
+ ` : ''} + ${field.field_type === 'rating' ? ` +
+ + + Number of stars to display (3-10) +
+ ` : ''} + ${field.field_type === 'slider' ? ` +
+ + +
+
+ + +
+
+ + + Increment value +
+ ` : ''} + ${field.field_type === 'matrix' ? ` +
+ + + Define rows and columns as JSON: {"rows": [...], "columns": [...]} +
+ ` : ''} +
+ + +
+ ${['select', 'radio', 'checkbox_multiple', 'multiselect', 'multiselect_list', 'checkboxes'].includes(field.field_type) ? ` +
+ + + Centrally managed list — updates apply to all forms using it. +
+ ` : ''} +
+ + +
+
+ + + Assign to an approval step for sequential approval workflows +
+
+ `; + }, + + buildConditionalLogicTab(field) { + // Initialize conditional_rules if not present + if (!field.conditional_rules) { + field.conditional_rules = null; + } + + const conditionalRulesJson = field.conditional_rules ? JSON.stringify(field.conditional_rules, null, 2) : ''; + + // Get list of other fields for dropdown + const otherFields = this.fields.filter(f => f.field_name !== field.field_name); + const fieldOptions = otherFields.map(f => + `` + ).join(''); + + return ` +
+
+
+ + Conditional Logic allows you to show/hide or require/unrequire this field based on other field values. +
+
+ +
+
+ + +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ + + You can edit the JSON directly for advanced configurations +
+
+
+ `; + }, + + buildValidationTab(field) { + // Initialize validation_rules if not present + if (!field.validation_rules) { + field.validation_rules = []; + } + + const validationRulesJson = field.validation_rules.length > 0 ? JSON.stringify(field.validation_rules, null, 2) : ''; + + return ` +
+
+
+ + Validation Rules provide real-time client-side validation with custom error messages. +
+
+ +
+ +
+ +
+ +
+ + + You can edit the JSON directly for advanced configurations +
+
+ `; + }, + + buildDependenciesTab(field) { + // Initialize field_dependencies if not present + if (!field.field_dependencies) { + field.field_dependencies = []; + } + + const dependenciesJson = field.field_dependencies.length > 0 ? JSON.stringify(field.field_dependencies, null, 2) : ''; + + // Get list of other fields for dropdown + const otherFields = this.fields.filter(f => f.field_name !== field.field_name); + const fieldOptions = otherFields.map(f => + `` + ).join(''); + + return ` +
+
+
+ + Field Dependencies allow this field's options to update based on other field values (cascade updates). +
+
+ +
+ +
+ +
+ +
+ + + You can edit the JSON directly for advanced configurations +
+
+ `; + }, + + initializePropertyFormTabs(field) { + // Wires up the interactive bits of the Conditional Logic, Validation, + // and Dependencies tabs. + const enableConditional = document.getElementById('propEnableConditional'); + const conditionalRulesContainer = document.getElementById('conditionalRulesContainer'); + if (enableConditional && conditionalRulesContainer) { + enableConditional.addEventListener('change', (e) => { + conditionalRulesContainer.style.display = e.target.checked ? 'block' : 'none'; + }); + } + this.initializeConditionsList(field.conditional_rules?.conditions || []); + this.initializeValidationRulesList(field.validation_rules || []); + this.initializeDependenciesList(field.field_dependencies || []); + }, + + initializeConditionsList(conditions) { + const container = document.getElementById('conditionsList'); + if (!container) return; + + container.innerHTML = ''; + conditions.forEach((condition, index) => { + this.addConditionRow(condition, index); + }); + + // Add event listener for add button + const btnAdd = document.getElementById('btnAddCondition'); + if (btnAdd) { + btnAdd.addEventListener('click', () => this.addConditionRow({}, conditions.length)); + } + }, + + addConditionRow(condition, index) { + const container = document.getElementById('conditionsList'); + if (!container) return; + + const otherFields = this.fields.filter(f => f.field_name !== this.fields[this.currentFieldIndex].field_name); + const fieldOptions = otherFields.map(f => + `` + ).join(''); + + const row = document.createElement('div'); + row.className = 'card mb-2'; + row.innerHTML = ` +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ `; + container.appendChild(row); + }, + + initializeValidationRulesList(rules) { + const container = document.getElementById('validationRulesList'); + if (!container) return; + + container.innerHTML = ''; + rules.forEach((rule, index) => { + this.addValidationRuleRow(rule, index); + }); + + // Add event listener for add button + const btnAdd = document.getElementById('btnAddValidation'); + if (btnAdd) { + btnAdd.addEventListener('click', () => this.addValidationRuleRow({}, rules.length)); + } + }, + + addValidationRuleRow(rule, index) { + const container = document.getElementById('validationRulesList'); + if (!container) return; + + const row = document.createElement('div'); + row.className = 'card mb-2'; + row.innerHTML = ` +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ `; + container.appendChild(row); + }, + + initializeDependenciesList(dependencies) { + const container = document.getElementById('dependenciesList'); + if (!container) return; + + container.innerHTML = ''; + dependencies.forEach((dep, index) => { + this.addDependencyRow(dep, index); + }); + + // Add event listener for add button + const btnAdd = document.getElementById('btnAddDependency'); + if (btnAdd) { + btnAdd.addEventListener('click', () => this.addDependencyRow({}, dependencies.length)); + } + }, + + addDependencyRow(dependency, index) { + const container = document.getElementById('dependenciesList'); + if (!container) return; + + const otherFields = this.fields.filter(f => f.field_name !== this.fields[this.currentFieldIndex].field_name); + const fieldOptions = otherFields.map(f => + `` + ).join(''); + + const row = document.createElement('div'); + row.className = 'card mb-2'; + row.innerHTML = ` +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ `; + container.appendChild(row); + }, + + saveFieldProperties() { + if (this.currentFieldIndex === null) return; + + const field = this.fields[this.currentFieldIndex]; + + // Update basic field properties + field.field_label = document.getElementById('propFieldLabel').value; + field.field_name = document.getElementById('propFieldName').value; + field.required = document.getElementById('propRequired').checked; + field.help_text = document.getElementById('propHelpText').value; + const showHelpCheckbox = document.getElementById('propShowHelpTextInDetail'); + field.show_help_text_in_detail = showHelpCheckbox ? showHelpCheckbox.checked : false; + field.placeholder = document.getElementById('propPlaceholder').value; + field.width = document.getElementById('propWidth').value; + field.css_class = document.getElementById('propCssClass').value; + + const prefillSelect = document.getElementById('propPrefillSource'); + field.prefill_source_id = prefillSelect.value ? parseInt(prefillSelect.value) : null; + + const sharedListSelect = document.getElementById('propSharedOptionList'); + field.shared_option_list_id = sharedListSelect && sharedListSelect.value ? parseInt(sharedListSelect.value) : null; + + // Save approval step + const approvalStepSelect = document.getElementById('propApprovalStep'); + field.approval_step = approvalStepSelect.value ? parseInt(approvalStepSelect.value) : null; + + const choicesEl = document.getElementById('propChoices'); + if (choicesEl) { + field.choices = choicesEl.value; + } + + const defaultValueEl = document.getElementById('propDefaultValue'); + if (defaultValueEl) { + field.default_value = defaultValueEl.value; + } + + // Save min/max values for rating, slider, etc. + const minValEl = document.getElementById('propMinValue'); + if (minValEl) { + if (!field.validation) field.validation = {}; + field.validation.min_value = minValEl.value ? parseFloat(minValEl.value) : null; + } + const maxValEl = document.getElementById('propMaxValue'); + if (maxValEl) { + if (!field.validation) field.validation = {}; + field.validation.max_value = maxValEl.value ? parseFloat(maxValEl.value) : null; + } + + // For matrix, try to parse choices as JSON + if (field.field_type === 'matrix' && choicesEl) { + try { + field.choices = JSON.parse(choicesEl.value); + } catch (e) { + // Keep as string if not valid JSON + } + } + + // Save conditional logic + const enableConditional = document.getElementById('propEnableConditional'); + if (enableConditional && enableConditional.checked) { + const conditions = []; + document.querySelectorAll('.condition-field').forEach((el, index) => { + const fieldName = el.value; + const operator = document.querySelector(`.condition-operator[data-index="${index}"]`).value; + const value = document.querySelector(`.condition-value[data-index="${index}"]`).value; + + if (fieldName && operator) { + conditions.push({ field: fieldName, operator, value }); + } + }); + + if (conditions.length > 0) { + field.conditional_rules = { + operator: document.getElementById('propConditionalOperator').value, + action: document.getElementById('propConditionalAction').value, + conditions: conditions + }; + } else { + field.conditional_rules = null; + } + + // Also check if JSON was edited directly + const jsonEl = document.getElementById('propConditionalRulesJson'); + if (jsonEl && jsonEl.value.trim()) { + try { + field.conditional_rules = JSON.parse(jsonEl.value); + } catch (e) { + console.warn('Invalid conditional rules JSON, using UI values'); + } + } + } else { + field.conditional_rules = null; + } + + // Save validation rules + const validationRules = []; + document.querySelectorAll('.validation-type').forEach((el, index) => { + const type = el.value; + const value = document.querySelector(`.validation-value[data-index="${index}"]`)?.value; + const message = document.querySelector(`.validation-message[data-index="${index}"]`)?.value; + + if (type) { + const rule = { type }; + if (value) rule.value = value; + if (message) rule.message = message; + validationRules.push(rule); + } + }); + field.validation_rules = validationRules.length > 0 ? validationRules : null; + + // Also check if JSON was edited directly + const validationJsonEl = document.getElementById('propValidationRulesJson'); + if (validationJsonEl && validationJsonEl.value.trim()) { + try { + field.validation_rules = JSON.parse(validationJsonEl.value); + } catch (e) { + console.warn('Invalid validation rules JSON, using UI values'); + } + } + + // Save field dependencies + const dependencies = []; + document.querySelectorAll('.dependency-source').forEach((el, index) => { + const sourceField = el.value; + const endpoint = document.querySelector(`.dependency-endpoint[data-index="${index}"]`)?.value; + + if (sourceField && endpoint) { + dependencies.push({ + sourceField: sourceField, + targetField: field.field_name, + apiEndpoint: endpoint + }); + } + }); + field.field_dependencies = dependencies.length > 0 ? dependencies : null; + + // Also check if JSON was edited directly + const dependenciesJsonEl = document.getElementById('propDependenciesJson'); + if (dependenciesJsonEl && dependenciesJsonEl.value.trim()) { + try { + field.field_dependencies = JSON.parse(dependenciesJsonEl.value); + } catch (e) { + console.warn('Invalid dependencies JSON, using UI values'); + } + } + + // Mark field as saved (no longer new) + this.isNewField = false; + + // Close modal + bootstrap.Modal.getInstance(document.getElementById('fieldPropertyModal')).hide(); + + // Re-render + this.renderCanvas(); + 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 9c25b5e..c5ecdc1 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 @@ -7,6 +7,7 @@ import { historyMethods } from './form-builder-history.js'; import { apiMethods } from './form-builder-api.js'; +import { propertyEditorMethods } from './form-builder-property-editor.js'; import { createBuilderStore } from './form-builder-store.js'; export class FormBuilder { @@ -644,564 +645,6 @@ export class FormBuilder { return div; } - editField(index, isNew = false) { - this.currentFieldIndex = index; - this.isNewField = isNew; - const field = this.fields[index]; - - // Build property form - const form = this.buildPropertyForm(field); - document.getElementById('fieldPropertyForm').innerHTML = form; - this.initializePropertyFormTabs(field); - - // Show modal - const modalElement = document.getElementById('fieldPropertyModal'); - const modal = new bootstrap.Modal(modalElement); - - // Handle modal close/cancel - remove field if it's new and not saved - const handleModalClose = () => { - if (this.isNewField) { - // Field was not saved, remove it - this.deleteFieldSilently(this.currentFieldIndex); - } - this.isNewField = false; - // Remove event listener to avoid memory leaks - modalElement.removeEventListener('hidden.bs.modal', handleModalClose); - }; - - // Add event listener for modal close - modalElement.addEventListener('hidden.bs.modal', handleModalClose); - - modal.show(); - } - - buildPropertyForm(field) { - const prefillOptions = this.config.prefillSources.map(source => - `` - ).join(''); - - const widthChoices = [ - { value: 'full', label: 'Full Width' }, - { value: 'half', label: 'Half (50%)' }, - { value: 'third', label: 'One Third (33%)' }, - { value: 'fourth', label: 'One Quarter (25%)' } - ]; - const widthOptions = widthChoices.map(w => - `` - ).join(''); - - return ` - - - - -
- -
- ${this.buildBasicPropertiesTab(field, prefillOptions, widthOptions)} -
- - -
- ${this.buildConditionalLogicTab(field)} -
- - -
- ${this.buildValidationTab(field)} -
- - -
- ${this.buildDependenciesTab(field)} -
-
- `; - } - - buildBasicPropertiesTab(field, prefillOptions, widthOptions) { - return ` -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
- - -
- - -
-
-
- - -
- ${['select', 'radio', 'checkbox_multiple', 'multiselect', 'multiselect_list', 'checkboxes'].includes(field.field_type) ? ` -
- - - Enter each option on a new line. Use value|Label format for separate values and display text. -
- ` : ''} - ${field.field_type === 'calculated' ? ` -
- - - Use {field_name} to reference other fields. Evaluated live on the client and re-validated on the server. -
- ` : ''} - ${field.field_type === 'display_text' ? ` -
- - - Supports Markdown: **bold**, *italic*, [links](url), lists, etc. This text is shown read-only on the form. -
- ` : ''} - ${field.field_type === 'rating' ? ` -
- - - Number of stars to display (3-10) -
- ` : ''} - ${field.field_type === 'slider' ? ` -
- - -
-
- - -
-
- - - Increment value -
- ` : ''} - ${field.field_type === 'matrix' ? ` -
- - - Define rows and columns as JSON: {"rows": [...], "columns": [...]} -
- ` : ''} -
- - -
- ${['select', 'radio', 'checkbox_multiple', 'multiselect', 'multiselect_list', 'checkboxes'].includes(field.field_type) ? ` -
- - - Centrally managed list — updates apply to all forms using it. -
- ` : ''} -
- - -
-
- - - Assign to an approval step for sequential approval workflows -
-
- `; - } - - buildConditionalLogicTab(field) { - // Initialize conditional_rules if not present - if (!field.conditional_rules) { - field.conditional_rules = null; - } - - const conditionalRulesJson = field.conditional_rules ? JSON.stringify(field.conditional_rules, null, 2) : ''; - - // Get list of other fields for dropdown - const otherFields = this.fields.filter(f => f.field_name !== field.field_name); - const fieldOptions = otherFields.map(f => - `` - ).join(''); - - return ` -
-
-
- - Conditional Logic allows you to show/hide or require/unrequire this field based on other field values. -
-
- -
-
- - -
-
- -
-
- - -
- -
- - -
- -
- -
- -
- -
- - - You can edit the JSON directly for advanced configurations -
-
-
- `; - } - - buildValidationTab(field) { - // Initialize validation_rules if not present - if (!field.validation_rules) { - field.validation_rules = []; - } - - const validationRulesJson = field.validation_rules.length > 0 ? JSON.stringify(field.validation_rules, null, 2) : ''; - - return ` -
-
-
- - Validation Rules provide real-time client-side validation with custom error messages. -
-
- -
- -
- -
- -
- - - You can edit the JSON directly for advanced configurations -
-
- `; - } - - buildDependenciesTab(field) { - // Initialize field_dependencies if not present - if (!field.field_dependencies) { - field.field_dependencies = []; - } - - const dependenciesJson = field.field_dependencies.length > 0 ? JSON.stringify(field.field_dependencies, null, 2) : ''; - - // Get list of other fields for dropdown - const otherFields = this.fields.filter(f => f.field_name !== field.field_name); - const fieldOptions = otherFields.map(f => - `` - ).join(''); - - return ` -
-
-
- - Field Dependencies allow this field's options to update based on other field values (cascade updates). -
-
- -
- -
- -
- -
- - - You can edit the JSON directly for advanced configurations -
-
- `; - } - - initializePropertyFormTabs(field) { - // Wires up the interactive bits of the Conditional Logic, Validation, - // and Dependencies tabs. - const enableConditional = document.getElementById('propEnableConditional'); - const conditionalRulesContainer = document.getElementById('conditionalRulesContainer'); - if (enableConditional && conditionalRulesContainer) { - enableConditional.addEventListener('change', (e) => { - conditionalRulesContainer.style.display = e.target.checked ? 'block' : 'none'; - }); - } - this.initializeConditionsList(field.conditional_rules?.conditions || []); - this.initializeValidationRulesList(field.validation_rules || []); - this.initializeDependenciesList(field.field_dependencies || []); - } - - initializeConditionsList(conditions) { - const container = document.getElementById('conditionsList'); - if (!container) return; - - container.innerHTML = ''; - conditions.forEach((condition, index) => { - this.addConditionRow(condition, index); - }); - - // Add event listener for add button - const btnAdd = document.getElementById('btnAddCondition'); - if (btnAdd) { - btnAdd.addEventListener('click', () => this.addConditionRow({}, conditions.length)); - } - } - - addConditionRow(condition, index) { - const container = document.getElementById('conditionsList'); - if (!container) return; - - const otherFields = this.fields.filter(f => f.field_name !== this.fields[this.currentFieldIndex].field_name); - const fieldOptions = otherFields.map(f => - `` - ).join(''); - - const row = document.createElement('div'); - row.className = 'card mb-2'; - row.innerHTML = ` -
-
-
- -
-
- -
-
- -
-
- -
-
-
- `; - container.appendChild(row); - } - - initializeValidationRulesList(rules) { - const container = document.getElementById('validationRulesList'); - if (!container) return; - - container.innerHTML = ''; - rules.forEach((rule, index) => { - this.addValidationRuleRow(rule, index); - }); - - // Add event listener for add button - const btnAdd = document.getElementById('btnAddValidation'); - if (btnAdd) { - btnAdd.addEventListener('click', () => this.addValidationRuleRow({}, rules.length)); - } - } - - addValidationRuleRow(rule, index) { - const container = document.getElementById('validationRulesList'); - if (!container) return; - - const row = document.createElement('div'); - row.className = 'card mb-2'; - row.innerHTML = ` -
-
-
- -
-
- -
-
- -
-
- -
-
-
- `; - container.appendChild(row); - } - - initializeDependenciesList(dependencies) { - const container = document.getElementById('dependenciesList'); - if (!container) return; - - container.innerHTML = ''; - dependencies.forEach((dep, index) => { - this.addDependencyRow(dep, index); - }); - - // Add event listener for add button - const btnAdd = document.getElementById('btnAddDependency'); - if (btnAdd) { - btnAdd.addEventListener('click', () => this.addDependencyRow({}, dependencies.length)); - } - } - - addDependencyRow(dependency, index) { - const container = document.getElementById('dependenciesList'); - if (!container) return; - - const otherFields = this.fields.filter(f => f.field_name !== this.fields[this.currentFieldIndex].field_name); - const fieldOptions = otherFields.map(f => - `` - ).join(''); - - const row = document.createElement('div'); - row.className = 'card mb-2'; - row.innerHTML = ` -
-
-
- - -
-
- - -
-
- -
-
-
- `; - container.appendChild(row); - } - toggleMultiStepMode(enabled) { const singleCanvas = document.getElementById('singleStepCanvas'); const multiCanvas = document.getElementById('multiStepCanvas'); @@ -1646,163 +1089,6 @@ export class FormBuilder { - saveFieldProperties() { - if (this.currentFieldIndex === null) return; - - const field = this.fields[this.currentFieldIndex]; - - // Update basic field properties - field.field_label = document.getElementById('propFieldLabel').value; - field.field_name = document.getElementById('propFieldName').value; - field.required = document.getElementById('propRequired').checked; - field.help_text = document.getElementById('propHelpText').value; - const showHelpCheckbox = document.getElementById('propShowHelpTextInDetail'); - field.show_help_text_in_detail = showHelpCheckbox ? showHelpCheckbox.checked : false; - field.placeholder = document.getElementById('propPlaceholder').value; - field.width = document.getElementById('propWidth').value; - field.css_class = document.getElementById('propCssClass').value; - - const prefillSelect = document.getElementById('propPrefillSource'); - field.prefill_source_id = prefillSelect.value ? parseInt(prefillSelect.value) : null; - - const sharedListSelect = document.getElementById('propSharedOptionList'); - field.shared_option_list_id = sharedListSelect && sharedListSelect.value ? parseInt(sharedListSelect.value) : null; - - // Save approval step - const approvalStepSelect = document.getElementById('propApprovalStep'); - field.approval_step = approvalStepSelect.value ? parseInt(approvalStepSelect.value) : null; - - const choicesEl = document.getElementById('propChoices'); - if (choicesEl) { - field.choices = choicesEl.value; - } - - const defaultValueEl = document.getElementById('propDefaultValue'); - if (defaultValueEl) { - field.default_value = defaultValueEl.value; - } - - // Save min/max values for rating, slider, etc. - const minValEl = document.getElementById('propMinValue'); - if (minValEl) { - if (!field.validation) field.validation = {}; - field.validation.min_value = minValEl.value ? parseFloat(minValEl.value) : null; - } - const maxValEl = document.getElementById('propMaxValue'); - if (maxValEl) { - if (!field.validation) field.validation = {}; - field.validation.max_value = maxValEl.value ? parseFloat(maxValEl.value) : null; - } - - // For matrix, try to parse choices as JSON - if (field.field_type === 'matrix' && choicesEl) { - try { - field.choices = JSON.parse(choicesEl.value); - } catch (e) { - // Keep as string if not valid JSON - } - } - - // Save conditional logic - const enableConditional = document.getElementById('propEnableConditional'); - if (enableConditional && enableConditional.checked) { - const conditions = []; - document.querySelectorAll('.condition-field').forEach((el, index) => { - const fieldName = el.value; - const operator = document.querySelector(`.condition-operator[data-index="${index}"]`).value; - const value = document.querySelector(`.condition-value[data-index="${index}"]`).value; - - if (fieldName && operator) { - conditions.push({ field: fieldName, operator, value }); - } - }); - - if (conditions.length > 0) { - field.conditional_rules = { - operator: document.getElementById('propConditionalOperator').value, - action: document.getElementById('propConditionalAction').value, - conditions: conditions - }; - } else { - field.conditional_rules = null; - } - - // Also check if JSON was edited directly - const jsonEl = document.getElementById('propConditionalRulesJson'); - if (jsonEl && jsonEl.value.trim()) { - try { - field.conditional_rules = JSON.parse(jsonEl.value); - } catch (e) { - console.warn('Invalid conditional rules JSON, using UI values'); - } - } - } else { - field.conditional_rules = null; - } - - // Save validation rules - const validationRules = []; - document.querySelectorAll('.validation-type').forEach((el, index) => { - const type = el.value; - const value = document.querySelector(`.validation-value[data-index="${index}"]`)?.value; - const message = document.querySelector(`.validation-message[data-index="${index}"]`)?.value; - - if (type) { - const rule = { type }; - if (value) rule.value = value; - if (message) rule.message = message; - validationRules.push(rule); - } - }); - field.validation_rules = validationRules.length > 0 ? validationRules : null; - - // Also check if JSON was edited directly - const validationJsonEl = document.getElementById('propValidationRulesJson'); - if (validationJsonEl && validationJsonEl.value.trim()) { - try { - field.validation_rules = JSON.parse(validationJsonEl.value); - } catch (e) { - console.warn('Invalid validation rules JSON, using UI values'); - } - } - - // Save field dependencies - const dependencies = []; - document.querySelectorAll('.dependency-source').forEach((el, index) => { - const sourceField = el.value; - const endpoint = document.querySelector(`.dependency-endpoint[data-index="${index}"]`)?.value; - - if (sourceField && endpoint) { - dependencies.push({ - sourceField: sourceField, - targetField: field.field_name, - apiEndpoint: endpoint - }); - } - }); - field.field_dependencies = dependencies.length > 0 ? dependencies : null; - - // Also check if JSON was edited directly - const dependenciesJsonEl = document.getElementById('propDependenciesJson'); - if (dependenciesJsonEl && dependenciesJsonEl.value.trim()) { - try { - field.field_dependencies = JSON.parse(dependenciesJsonEl.value); - } catch (e) { - console.warn('Invalid dependencies JSON, using UI values'); - } - } - - // Mark field as saved (no longer new) - this.isNewField = false; - - // Close modal - bootstrap.Modal.getInstance(document.getElementById('fieldPropertyModal')).hide(); - - // Re-render - this.renderCanvas(); - this.updatePreview(); - } - deleteField(index) { if (confirm('Are you sure you want to delete this field?')) { this.pushUndo(); @@ -1944,4 +1230,4 @@ export class FormBuilder { } } -Object.assign(FormBuilder.prototype, historyMethods, apiMethods); +Object.assign(FormBuilder.prototype, historyMethods, apiMethods, propertyEditorMethods); diff --git a/tests_js/form-builder/initializePropertyFormTabs.test.js b/tests_js/form-builder-property-editor/propertyEditorMethods.test.js similarity index 100% rename from tests_js/form-builder/initializePropertyFormTabs.test.js rename to tests_js/form-builder-property-editor/propertyEditorMethods.test.js From 0546ead8e9a0734b259801c7926110577a45c316 Mon Sep 17 00:00:00 2001 From: Maria Violante Date: Mon, 3 Aug 2026 10:17:33 -0400 Subject: [PATCH 2/6] add test battery for module --- .../propertyEditorMethods.test.js | 658 +++++++++++++++++- 1 file changed, 637 insertions(+), 21 deletions(-) diff --git a/tests_js/form-builder-property-editor/propertyEditorMethods.test.js b/tests_js/form-builder-property-editor/propertyEditorMethods.test.js index a9b44ab..e4f206b 100644 --- a/tests_js/form-builder-property-editor/propertyEditorMethods.test.js +++ b/tests_js/form-builder-property-editor/propertyEditorMethods.test.js @@ -1,15 +1,44 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { FormBuilder } from '../../django_forms_workflows/static/django_forms_workflows/js/form-builder.js'; - -function createInstance(FormBuilder) { - const instance = Object.create(FormBuilder.prototype); - instance.initializeConditionsList = vi.fn(); - instance.initializeValidationRulesList = vi.fn(); - instance.initializeDependenciesList = vi.fn(); +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { propertyEditorMethods } from '../../django_forms_workflows/static/django_forms_workflows/js/form-builder-property-editor.js'; +import { createBuilderStore } from '../../django_forms_workflows/static/django_forms_workflows/js/form-builder-store.js'; + +function createContext({ fields = [], config = {} } = {}) { + return { + store: createBuilderStore({ fields }), + config: { prefillSources: [], sharedOptionLists: [], ...config }, + currentFieldIndex: null, + isNewField: false, + deleteFieldSilently: vi.fn(), + renderCanvas: vi.fn(), + updatePreview: vi.fn(), + // Real implementation (matches form-builder.js#escapeHtml) rather than a + // stub - it's cheap under jsdom and several assertions below depend on + // actual escaping behavior. + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }, + get fields() { return this.store.fields; }, + set fields(value) { this.store.setFields(value); }, + ...propertyEditorMethods, + }; +} + +function stubBootstrapModal() { + const instance = { show: vi.fn(), hide: vi.fn() }; + function Modal() { return instance; } + Modal.getInstance = vi.fn(() => instance); + vi.stubGlobal('bootstrap', { Modal }); return instance; } -describe('FormBuilder#initializePropertyFormTabs', () => { +afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); +}); + +describe('propertyEditorMethods.initializePropertyFormTabs', () => { beforeEach(() => { document.body.innerHTML = ` @@ -18,10 +47,13 @@ describe('FormBuilder#initializePropertyFormTabs', () => { }); it('wires the conditional-logic toggle to show/hide its section', () => { - const instance = createInstance(FormBuilder); + const ctx = createContext(); + ctx.initializeConditionsList = vi.fn(); + ctx.initializeValidationRulesList = vi.fn(); + ctx.initializeDependenciesList = vi.fn(); const field = { conditional_rules: null, validation_rules: [], field_dependencies: [] }; - instance.initializePropertyFormTabs(field); + ctx.initializePropertyFormTabs(field); const checkbox = document.getElementById('propEnableConditional'); const container = document.getElementById('conditionalRulesContainer'); @@ -36,28 +68,612 @@ describe('FormBuilder#initializePropertyFormTabs', () => { }); it("initializes all three tabs' lists with the field's current data", () => { - const instance = createInstance(FormBuilder); + const ctx = createContext(); + ctx.initializeConditionsList = vi.fn(); + ctx.initializeValidationRulesList = vi.fn(); + ctx.initializeDependenciesList = vi.fn(); const field = { conditional_rules: { conditions: [{ field: 'x', operator: 'equals', value: '1' }] }, validation_rules: [{ type: 'required' }], field_dependencies: [{ source: 'a', target: 'b' }], }; - instance.initializePropertyFormTabs(field); + ctx.initializePropertyFormTabs(field); - expect(instance.initializeConditionsList).toHaveBeenCalledWith(field.conditional_rules.conditions); - expect(instance.initializeValidationRulesList).toHaveBeenCalledWith(field.validation_rules); - expect(instance.initializeDependenciesList).toHaveBeenCalledWith(field.field_dependencies); + expect(ctx.initializeConditionsList).toHaveBeenCalledWith(field.conditional_rules.conditions); + expect(ctx.initializeValidationRulesList).toHaveBeenCalledWith(field.validation_rules); + expect(ctx.initializeDependenciesList).toHaveBeenCalledWith(field.field_dependencies); }); it('defaults to empty lists when the field has no rules/dependencies yet', () => { - const instance = createInstance(FormBuilder); + const ctx = createContext(); + ctx.initializeConditionsList = vi.fn(); + ctx.initializeValidationRulesList = vi.fn(); + ctx.initializeDependenciesList = vi.fn(); + + ctx.initializePropertyFormTabs({}); + + expect(ctx.initializeConditionsList).toHaveBeenCalledWith([]); + expect(ctx.initializeValidationRulesList).toHaveBeenCalledWith([]); + expect(ctx.initializeDependenciesList).toHaveBeenCalledWith([]); + }); +}); + +describe('propertyEditorMethods.editField', () => { + beforeEach(() => { + document.body.innerHTML = ` +
+ `; + }); + + it('sets the current/new field state, renders the property form, and shows the modal', () => { + const modal = stubBootstrapModal(); + const ctx = createContext({ fields: [{ field_name: 'a' }, { field_name: 'b' }] }); + ctx.buildPropertyForm = vi.fn(() => '

form html

'); + ctx.initializePropertyFormTabs = vi.fn(); + + ctx.editField(1, true); + + expect(ctx.currentFieldIndex).toBe(1); + expect(ctx.isNewField).toBe(true); + expect(ctx.buildPropertyForm).toHaveBeenCalledWith(ctx.fields[1]); + expect(document.getElementById('fieldPropertyForm').innerHTML).toBe('

form html

'); + expect(ctx.initializePropertyFormTabs).toHaveBeenCalledWith(ctx.fields[1]); + expect(modal.show).toHaveBeenCalledTimes(1); + }); + + it('deletes the field silently on modal close when it was new and never saved', () => { + stubBootstrapModal(); + const ctx = createContext({ fields: [{ field_name: 'a' }] }); + ctx.buildPropertyForm = vi.fn(() => ''); + ctx.initializePropertyFormTabs = vi.fn(); + + ctx.editField(0, true); + document.getElementById('fieldPropertyModal').dispatchEvent(new Event('hidden.bs.modal')); + + expect(ctx.deleteFieldSilently).toHaveBeenCalledWith(0); + expect(ctx.isNewField).toBe(false); + }); + + it('does not delete the field on modal close when it was not new', () => { + stubBootstrapModal(); + const ctx = createContext({ fields: [{ field_name: 'a' }] }); + ctx.buildPropertyForm = vi.fn(() => ''); + ctx.initializePropertyFormTabs = vi.fn(); + + ctx.editField(0, false); + document.getElementById('fieldPropertyModal').dispatchEvent(new Event('hidden.bs.modal')); + + expect(ctx.deleteFieldSilently).not.toHaveBeenCalled(); + }); +}); + +describe('propertyEditorMethods.buildPropertyForm', () => { + it('delegates to each tab builder with the field and stitches their output into the tabbed form', () => { + const ctx = createContext({ config: { prefillSources: [] } }); + ctx.buildBasicPropertiesTab = vi.fn(() => 'BASIC'); + ctx.buildConditionalLogicTab = vi.fn(() => 'CONDITIONAL'); + ctx.buildValidationTab = vi.fn(() => 'VALIDATION'); + ctx.buildDependenciesTab = vi.fn(() => 'DEPENDENCIES'); + const field = { field_type: 'text', width: 'full' }; + + const html = ctx.buildPropertyForm(field); + + expect(ctx.buildBasicPropertiesTab).toHaveBeenCalledWith(field, expect.any(String), expect.any(String)); + expect(ctx.buildConditionalLogicTab).toHaveBeenCalledWith(field); + expect(ctx.buildValidationTab).toHaveBeenCalledWith(field); + expect(ctx.buildDependenciesTab).toHaveBeenCalledWith(field); + expect(html).toContain('BASIC'); + expect(html).toContain('CONDITIONAL'); + expect(html).toContain('VALIDATION'); + expect(html).toContain('DEPENDENCIES'); + }); + + it('marks the prefill source matching the field as selected', () => { + const ctx = createContext({ + config: { prefillSources: [{ id: 1, name: 'Student ID' }, { id: 2, name: 'Email' }] }, + }); + const field = { field_type: 'text', width: 'full', prefill_source_id: 2 }; + + const html = ctx.buildPropertyForm(field); + + expect(html).toMatch(/', ''); + + expect(html).toContain('value="My Label"'); + expect(html).toContain('value="my_field"'); + expect(html).not.toContain('id="propChoices"'); + expect(html).not.toContain('id="propMaxValue"'); + }); + + it('renders a choices textarea for a select field', () => { + const ctx = createContext(); + const field = { ...baseField, field_type: 'select', choices: 'a\nb' }; + + const html = ctx.buildBasicPropertiesTab(field, '', ''); + + expect(html).toContain('id="propChoices"'); + expect(html).toContain('>a\nb<'); + }); + + it('renders the shared option list dropdown for a select field, marking the matching one selected', () => { + const ctx = createContext({ + config: { sharedOptionLists: [{ id: 5, name: 'Counties', itemCount: 83 }] }, + }); + const field = { ...baseField, field_type: 'select', choices: '', shared_option_list_id: 5 }; + + const html = ctx.buildBasicPropertiesTab(field, '', ''); + + expect(html).toContain('id="propSharedOptionList"'); + expect(html).toContain('value="5" selected'); + expect(html).toContain('Counties (83 options)'); + }); + + it('renders min/max/step inputs for a slider field', () => { + const ctx = createContext(); + const field = { ...baseField, field_type: 'slider', validation: { min_value: 2, max_value: 8 } }; + + const html = ctx.buildBasicPropertiesTab(field, '', ''); + + expect(html).toContain('id="propMinValue" value="2"'); + expect(html).toContain('id="propMaxValue" value="8"'); + }); + + it('renders a JSON textarea pre-filled from an object for a matrix field', () => { + const ctx = createContext(); + const field = { ...baseField, field_type: 'matrix', choices: { rows: ['R1'], columns: ['C1'] } }; + + const html = ctx.buildBasicPropertiesTab(field, '', ''); + + expect(html).toContain('id="propChoices"'); + expect(html).toContain('"rows": [\n "R1"\n ]'); + }); +}); + +describe('propertyEditorMethods.buildConditionalLogicTab', () => { + it('defaults to disabled/hidden with no existing rules', () => { + const ctx = createContext(); + const field = { field_name: 'target' }; + + const html = ctx.buildConditionalLogicTab(field); + + expect(html).not.toContain('id="propEnableConditional" checked'); + expect(html).toContain('style="display: none;"'); + expect(field.conditional_rules).toBeNull(); + }); + + it('reflects existing conditional rules as checked/visible with selected operator and action', () => { + const ctx = createContext({ + fields: [{ field_name: 'target' }, { field_name: 'other', field_label: 'Other' }], + }); + const field = { + field_name: 'target', + conditional_rules: { operator: 'OR', action: 'hide', conditions: [{ field: 'other', operator: 'equals', value: '1' }] }, + }; + + const html = ctx.buildConditionalLogicTab(field); + + expect(html).toContain('id="propEnableConditional" checked'); + expect(html).toContain('style="display: block;"'); + expect(html).toMatch(/value="OR" selected/); + expect(html).toMatch(/value="hide" selected/); + }); + + it('excludes the field itself from the "other fields" dropdown', () => { + const ctx = createContext({ + fields: [{ field_name: 'target', field_label: 'Target' }, { field_name: 'other', field_label: 'Other' }], + }); + + const html = ctx.buildConditionalLogicTab({ field_name: 'target' }); + + expect(html).not.toContain('Target'); + }); +}); + +describe('propertyEditorMethods.buildValidationTab', () => { + it('defaults validation_rules to an empty array and leaves the JSON preview blank', () => { + const ctx = createContext(); const field = {}; - instance.initializePropertyFormTabs(field); + const html = ctx.buildValidationTab(field); + + expect(field.validation_rules).toEqual([]); + expect(html).toContain('id="propValidationRulesJson"'); + expect(html).toMatch(/id="propValidationRulesJson"[^>]*>\s* { + const ctx = createContext(); + const field = { validation_rules: [{ type: 'required' }] }; + + const html = ctx.buildValidationTab(field); + + expect(html).toContain('"type": "required"'); + }); +}); + +describe('propertyEditorMethods.buildDependenciesTab', () => { + it('defaults field_dependencies to an empty array and leaves the JSON preview blank', () => { + const ctx = createContext(); + const field = { field_name: 'target' }; + + const html = ctx.buildDependenciesTab(field); + + expect(field.field_dependencies).toEqual([]); + expect(html).toContain('id="propDependenciesJson"'); + }); + + it('pre-fills the JSON preview from existing dependencies', () => { + const ctx = createContext({ fields: [{ field_name: 'target' }, { field_name: 'source' }] }); + const field = { field_name: 'target', field_dependencies: [{ sourceField: 'source', targetField: 'target', apiEndpoint: '/api/x/' }] }; + + const html = ctx.buildDependenciesTab(field); + + expect(html).toContain('"sourceField": "source"'); + }); +}); + +describe('propertyEditorMethods.initializeConditionsList / addConditionRow', () => { + beforeEach(() => { + document.body.innerHTML = ` +
+ + `; + }); + + it('does nothing when the conditions-list container is missing', () => { + document.body.innerHTML = ''; + const ctx = createContext(); + expect(() => ctx.initializeConditionsList([{ field: 'a', operator: 'equals', value: '1' }])).not.toThrow(); + }); + + it('renders one row per existing condition', () => { + const ctx = createContext({ fields: [{ field_name: 'a', field_label: 'A' }, { field_name: 'b', field_label: 'B' }] }); + ctx.currentFieldIndex = 1; // editing 'b', so 'a' remains selectable as a condition source + + ctx.initializeConditionsList([{ field: 'a', operator: 'equals', value: '1' }]); + + expect(document.getElementById('conditionsList').children.length).toBe(1); + expect(document.getElementById('conditionsList').innerHTML).toContain('value="a" selected'); + }); + + it('appends a new blank row when the add button is clicked', () => { + const ctx = createContext({ fields: [{ field_name: 'a', field_label: 'A' }] }); + ctx.currentFieldIndex = 0; + + ctx.initializeConditionsList([]); + document.getElementById('btnAddCondition').dispatchEvent(new Event('click')); + + expect(document.getElementById('conditionsList').children.length).toBe(1); + }); + + it('addConditionRow excludes the field currently being edited from the field dropdown', () => { + const ctx = createContext({ + fields: [{ field_name: 'current', field_label: 'Current' }, { field_name: 'other', field_label: 'Other' }], + }); + ctx.currentFieldIndex = 0; + + ctx.addConditionRow({}, 0); + + const html = document.getElementById('conditionsList').innerHTML; + expect(html).not.toContain('>Current'); + expect(html).toContain('>Other'); + }); + + it('addConditionRow does nothing when its container is missing', () => { + document.body.innerHTML = ''; + const ctx = createContext({ fields: [{ field_name: 'a' }] }); + ctx.currentFieldIndex = 0; + expect(() => ctx.addConditionRow({}, 0)).not.toThrow(); + }); +}); + +describe('propertyEditorMethods.initializeValidationRulesList / addValidationRuleRow', () => { + beforeEach(() => { + document.body.innerHTML = ` +
+ + `; + }); + + it('does nothing when the validation-rules container is missing', () => { + document.body.innerHTML = ''; + const ctx = createContext(); + expect(() => ctx.initializeValidationRulesList([{ type: 'required' }])).not.toThrow(); + }); + + it('renders one row per existing rule, selecting its type', () => { + const ctx = createContext(); + + ctx.initializeValidationRulesList([{ type: 'email', message: 'Bad email' }]); + + const html = document.getElementById('validationRulesList').innerHTML; + expect(document.getElementById('validationRulesList').children.length).toBe(1); + expect(html).toContain('value="email" selected'); + expect(html).toContain('value="Bad email"'); + }); + + it('appends a new blank row when the add button is clicked', () => { + const ctx = createContext(); + + ctx.initializeValidationRulesList([]); + document.getElementById('btnAddValidation').dispatchEvent(new Event('click')); + + expect(document.getElementById('validationRulesList').children.length).toBe(1); + }); + + it('addValidationRuleRow does nothing when its container is missing', () => { + document.body.innerHTML = ''; + const ctx = createContext(); + expect(() => ctx.addValidationRuleRow({}, 0)).not.toThrow(); + }); +}); + +describe('propertyEditorMethods.initializeDependenciesList / addDependencyRow', () => { + beforeEach(() => { + document.body.innerHTML = ` +
+ + `; + }); + + it('does nothing when the dependencies container is missing', () => { + document.body.innerHTML = ''; + const ctx = createContext(); + expect(() => ctx.initializeDependenciesList([{ sourceField: 'a' }])).not.toThrow(); + }); + + it('renders one row per existing dependency', () => { + const ctx = createContext({ + fields: [{ field_name: 'current', field_label: 'Current' }, { field_name: 'source', field_label: 'Source' }], + }); + ctx.currentFieldIndex = 0; + + ctx.initializeDependenciesList([{ sourceField: 'source', apiEndpoint: '/api/x/' }]); + + const html = document.getElementById('dependenciesList').innerHTML; + expect(document.getElementById('dependenciesList').children.length).toBe(1); + expect(html).toContain('value="source" selected'); + expect(html).toContain('value="/api/x/"'); + }); + + it('appends a new blank row when the add button is clicked', () => { + const ctx = createContext({ fields: [{ field_name: 'current' }] }); + ctx.currentFieldIndex = 0; + + ctx.initializeDependenciesList([]); + document.getElementById('btnAddDependency').dispatchEvent(new Event('click')); + + expect(document.getElementById('dependenciesList').children.length).toBe(1); + }); + + it('addDependencyRow excludes the field currently being edited from the source dropdown', () => { + const ctx = createContext({ + fields: [{ field_name: 'current', field_label: 'Current' }, { field_name: 'other', field_label: 'Other' }], + }); + ctx.currentFieldIndex = 0; + + ctx.addDependencyRow({}, 0); + + const html = document.getElementById('dependenciesList').innerHTML; + expect(html).not.toContain('>Current'); + expect(html).toContain('>Other'); + }); +}); + +describe('propertyEditorMethods.saveFieldProperties', () => { + function setupDOM({ fieldType = 'text' } = {}) { + document.body.innerHTML = ` +
+ + + + + + + + + + + ${fieldType === 'matrix' ? '' : ''} + + + + + + + `; + } + + it('does nothing when there is no field currently being edited', () => { + const ctx = createContext(); + ctx.currentFieldIndex = null; + + expect(() => ctx.saveFieldProperties()).not.toThrow(); + expect(ctx.renderCanvas).not.toHaveBeenCalled(); + }); + + it('writes basic properties back onto the field, re-renders, and closes the modal', () => { + setupDOM(); + const modal = stubBootstrapModal(); + const field = { field_type: 'text' }; + const ctx = createContext({ fields: [field] }); + ctx.currentFieldIndex = 0; + ctx.isNewField = true; + + ctx.saveFieldProperties(); + + expect(field.field_label).toBe('New Label'); + expect(field.field_name).toBe('new_name'); + expect(field.required).toBe(true); + expect(field.help_text).toBe('Help'); + expect(field.placeholder).toBe('Placeholder'); + expect(field.width).toBe('half'); + expect(field.css_class).toBe('my-class'); + expect(field.approval_step).toBe(2); + expect(ctx.isNewField).toBe(false); + expect(modal.hide).toHaveBeenCalledTimes(1); + expect(ctx.renderCanvas).toHaveBeenCalledTimes(1); + expect(ctx.updatePreview).toHaveBeenCalledTimes(1); + }); + + it('parses matrix choices as JSON when the field type is matrix', () => { + setupDOM({ fieldType: 'matrix' }); + stubBootstrapModal(); + const field = { field_type: 'matrix' }; + const ctx = createContext({ fields: [field] }); + ctx.currentFieldIndex = 0; + + ctx.saveFieldProperties(); + + expect(field.choices).toEqual({ rows: ['R1'], columns: ['C1'] }); + }); + + it('builds conditional_rules from the condition rows in the DOM when enabled', () => { + setupDOM(); + document.getElementById('propEnableConditional').checked = true; + document.body.insertAdjacentHTML('beforeend', ` + + + + `); + stubBootstrapModal(); + const field = { field_type: 'text' }; + const ctx = createContext({ fields: [field] }); + ctx.currentFieldIndex = 0; + + ctx.saveFieldProperties(); + + expect(field.conditional_rules).toEqual({ + operator: 'AND', + action: 'show', + conditions: [{ field: 'other_field', operator: 'equals', value: '42' }], + }); + }); + + it('prefers a manually-edited conditional-rules JSON blob over the UI rows', () => { + setupDOM(); + document.getElementById('propEnableConditional').checked = true; + document.getElementById('propConditionalRulesJson').value = JSON.stringify({ operator: 'OR', action: 'hide', conditions: [] }); + stubBootstrapModal(); + const field = { field_type: 'text' }; + const ctx = createContext({ fields: [field] }); + ctx.currentFieldIndex = 0; + + ctx.saveFieldProperties(); + + expect(field.conditional_rules).toEqual({ operator: 'OR', action: 'hide', conditions: [] }); + }); + + it('clears conditional_rules when the enable checkbox is off', () => { + setupDOM(); + stubBootstrapModal(); + const field = { field_type: 'text', conditional_rules: { operator: 'AND', action: 'show', conditions: [] } }; + const ctx = createContext({ fields: [field] }); + ctx.currentFieldIndex = 0; + + ctx.saveFieldProperties(); + + expect(field.conditional_rules).toBeNull(); + }); + + it('builds validation_rules from the validation rows in the DOM', () => { + setupDOM(); + document.body.insertAdjacentHTML('beforeend', ` + + + + `); + stubBootstrapModal(); + const field = { field_type: 'text' }; + const ctx = createContext({ fields: [field] }); + ctx.currentFieldIndex = 0; + + ctx.saveFieldProperties(); + + expect(field.validation_rules).toEqual([{ type: 'min', value: '3', message: 'Too short' }]); + }); + + it('prefers a manually-edited validation-rules JSON blob over the UI rows', () => { + setupDOM(); + document.getElementById('propValidationRulesJson').value = JSON.stringify([{ type: 'required' }]); + stubBootstrapModal(); + const field = { field_type: 'text' }; + const ctx = createContext({ fields: [field] }); + ctx.currentFieldIndex = 0; + + ctx.saveFieldProperties(); + + expect(field.validation_rules).toEqual([{ type: 'required' }]); + }); + + it('builds field_dependencies from the dependency rows in the DOM', () => { + setupDOM(); + document.body.insertAdjacentHTML('beforeend', ` + + + `); + stubBootstrapModal(); + // saveFieldProperties overwrites field_name from #propFieldName (set to + // "new_name" by setupDOM) before it reaches the dependencies section, so + // that's the targetField dependencies end up carrying, not the field's + // original name. + const field = { field_type: 'text', field_name: 'target_field' }; + const ctx = createContext({ fields: [field] }); + ctx.currentFieldIndex = 0; + + ctx.saveFieldProperties(); + + expect(field.field_dependencies).toEqual([ + { sourceField: 'source_field', targetField: 'new_name', apiEndpoint: '/api/options/' }, + ]); + }); + + it('prefers a manually-edited dependencies JSON blob over the UI rows', () => { + setupDOM(); + document.getElementById('propDependenciesJson').value = JSON.stringify([{ sourceField: 'x', targetField: 'y', apiEndpoint: '/z/' }]); + stubBootstrapModal(); + const field = { field_type: 'text' }; + const ctx = createContext({ fields: [field] }); + ctx.currentFieldIndex = 0; + + ctx.saveFieldProperties(); + + expect(field.field_dependencies).toEqual([{ sourceField: 'x', targetField: 'y', apiEndpoint: '/z/' }]); + }); + + it('saves min/max validation bounds when rating/slider inputs are present', () => { + setupDOM(); + document.body.insertAdjacentHTML('beforeend', ` + + + `); + stubBootstrapModal(); + const field = { field_type: 'slider' }; + const ctx = createContext({ fields: [field] }); + ctx.currentFieldIndex = 0; + + ctx.saveFieldProperties(); - expect(instance.initializeConditionsList).toHaveBeenCalledWith([]); - expect(instance.initializeValidationRulesList).toHaveBeenCalledWith([]); - expect(instance.initializeDependenciesList).toHaveBeenCalledWith([]); + expect(field.validation).toEqual({ min_value: 1, max_value: 10 }); }); }); From 3aadcf124914fb36b8585474fefd18abc1ad3674 Mon Sep 17 00:00:00 2001 From: Maria Violante Date: Mon, 3 Aug 2026 10:30:54 -0400 Subject: [PATCH 3/6] form-builder-property-editor.js: field.field_type now goes through this.escapeHtml(...) before landing in the disabled Field Type input, matching every other field value on that tab. --- .../js/form-builder-property-editor.js | 2 +- .../propertyEditorMethods.test.js | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/django_forms_workflows/static/django_forms_workflows/js/form-builder-property-editor.js b/django_forms_workflows/static/django_forms_workflows/js/form-builder-property-editor.js index d6be275..adc61f6 100644 --- a/django_forms_workflows/static/django_forms_workflows/js/form-builder-property-editor.js +++ b/django_forms_workflows/static/django_forms_workflows/js/form-builder-property-editor.js @@ -122,7 +122,7 @@ export const propertyEditorMethods = {
- +
diff --git a/tests_js/form-builder-property-editor/propertyEditorMethods.test.js b/tests_js/form-builder-property-editor/propertyEditorMethods.test.js index e4f206b..ab5b66c 100644 --- a/tests_js/form-builder-property-editor/propertyEditorMethods.test.js +++ b/tests_js/form-builder-property-editor/propertyEditorMethods.test.js @@ -205,6 +205,16 @@ describe('propertyEditorMethods.buildBasicPropertiesTab', () => { expect(html).not.toContain('id="propMaxValue"'); }); + it('escapes the field type before rendering it into the disabled type input', () => { + const ctx = createContext(); + const field = { ...baseField, field_type: '">' }; + + const html = ctx.buildBasicPropertiesTab(field, '', ''); + + expect(html).not.toContain('">'); + expect(html).toContain(ctx.escapeHtml(field.field_type)); + }); + it('renders a choices textarea for a select field', () => { const ctx = createContext(); const field = { ...baseField, field_type: 'select', choices: 'a\nb' }; From bfc668a2ab3b1bc3d69253210f643c900c7174d0 Mon Sep 17 00:00:00 2001 From: Maria Violante Date: Mon, 3 Aug 2026 11:39:30 -0400 Subject: [PATCH 4/6] Sync this.fields to step order after add/move/reorder so the live preview matches The live preview always sends fields: this.fields (flat array order), but handleFieldDroppedToStep/handleFieldMovedToStep/updateFieldOrderInStep only kept formSteps in sync, never this.fields itself - so a field added or moved within a step could render correctly on the canvas but show up in the wrong position (or last) in the preview. Wires in the existing but previously-uncalled updateFieldOrderFromSteps() after each mutation. Since data-field-index on every rendered field-item is derived from this.fields' current array index, the reorder has to happen before re-rendering, and every step's canvas has to be re-rendered (not just the touched one) - a flattened-array reorder can shift indices for fields in other steps too. Rendering first (or only re-rendering the touched step) left stale indices behind, which a subsequent drag-reorder would read and silently corrupt this.fields/formSteps - the actual cause of a 500 from the preview endpoint found via manual testing. --- .../django_forms_workflows/js/form-builder.js | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) 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 c5ecdc1..3833222 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 @@ -859,12 +859,16 @@ export class FormBuilder { this.formSteps[stepIndex].fields.push(fieldName); } - // Re-render this step - this.renderSingleStep(stepIndex); + // 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.length - 1; + const fieldIndex = this.fields.findIndex(f => f.field_name === fieldName); this.editField(fieldIndex, true); // true = isNew } @@ -948,11 +952,12 @@ export class FormBuilder { this.formSteps[stepIndex].fields.splice(insertPosition, 0, field.field_name); - // Re-render both source and target steps - if (sourceStepIndex !== -1 && sourceStepIndex !== stepIndex) { - this.renderSingleStep(sourceStepIndex); - } - this.renderSingleStep(stepIndex); + // 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(); @@ -974,6 +979,13 @@ export class FormBuilder { }); 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(); } From 180a13e79bf6605692ae231545a4430e3a21ea93 Mon Sep 17 00:00:00 2001 From: Maria Violante Date: Mon, 3 Aug 2026 11:39:49 -0400 Subject: [PATCH 5/6] Add regression tests for the multi-step field-order sync fix Extends handleFieldDroppedToStep.test.js and updateFieldOrderInStep.test.js to assert on this.fields, not just formSteps. Adds handleFieldMovedToStep.test.js (previously zero coverage) and multiStepFieldIndexSync.test.js, which specifically reproduces the cross-step index corruption reported after the initial fix: a field added to one step shifting data-field-index for fields in a different, unrendered step, then a follow-up reorder reading those stale indices and silently dropping a field. Verified each new/extended assertion actually fails against both the pre-fix code and the intermediate (render-before-reorder) attempt before landing the real fix in the previous commit. --- .../handleFieldDroppedToStep.test.js | 23 ++++++ .../handleFieldMovedToStep.test.js | 75 ++++++++++++++++++ .../multiStepFieldIndexSync.test.js | 78 +++++++++++++++++++ .../updateFieldOrderInStep.test.js | 13 ++++ 4 files changed, 189 insertions(+) create mode 100644 tests_js/form-builder/handleFieldMovedToStep.test.js create mode 100644 tests_js/form-builder/multiStepFieldIndexSync.test.js diff --git a/tests_js/form-builder/handleFieldDroppedToStep.test.js b/tests_js/form-builder/handleFieldDroppedToStep.test.js index c609fb5..3fc93e5 100644 --- a/tests_js/form-builder/handleFieldDroppedToStep.test.js +++ b/tests_js/form-builder/handleFieldDroppedToStep.test.js @@ -46,4 +46,27 @@ describe('FormBuilder#handleFieldDroppedToStep', () => { expect(instance.undoStack).toHaveLength(0); expect(instance.fields).toHaveLength(0); }); + + it('reorders this.fields to match the step position when dropped before an existing field, not just appended', () => { + const instance = createInstance(); + instance.fields = [{ field_name: 'existing' }]; + instance.formSteps[0].fields = ['existing']; + + // Drop at position 0 -> new field belongs before 'existing' in the step. + instance.handleFieldDroppedToStep('text', 0, 0); + + expect(instance.formSteps[0].fields[0]).not.toBe('existing'); + expect(instance.fields.map(f => f.field_name)).toEqual(instance.formSteps[0].fields); + }); + + it('opens the property editor for the newly-added field even after this.fields gets reordered', () => { + const instance = createInstance(); + instance.fields = [{ field_name: 'existing' }]; + instance.formSteps[0].fields = ['existing']; + + instance.handleFieldDroppedToStep('text', 0, 0); + + const newFieldIndex = instance.fields.findIndex(f => f.field_name !== 'existing'); + expect(instance.editField).toHaveBeenCalledWith(newFieldIndex, true); + }); }); diff --git a/tests_js/form-builder/handleFieldMovedToStep.test.js b/tests_js/form-builder/handleFieldMovedToStep.test.js new file mode 100644 index 0000000..f0823ef --- /dev/null +++ b/tests_js/form-builder/handleFieldMovedToStep.test.js @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FormBuilder } from '../../django_forms_workflows/static/django_forms_workflows/js/form-builder.js'; +import { createBuilderStore } from '../../django_forms_workflows/static/django_forms_workflows/js/form-builder-store.js'; + +function createInstance({ fields, formSteps }) { + const instance = Object.create(FormBuilder.prototype); + instance.store = createBuilderStore({ fields, formSteps }); + instance.renderSingleStep = vi.fn(); + instance.updatePreview = vi.fn(); + return instance; +} + +// The drag-drop library has already moved the dragged node into the target +// step's canvas by the time this handler runs; handleFieldMovedToStep reads +// its position back out of the DOM. +function buildFieldElement(fieldIndex) { + const el = document.createElement('div'); + el.className = 'field-item'; + el.dataset.fieldIndex = String(fieldIndex); + return el; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('FormBuilder#handleFieldMovedToStep', () => { + it('moves the field name out of its source step and into the target step at the dropped DOM position', () => { + const instance = createInstance({ + fields: [{ field_name: 'a' }, { field_name: 'b' }, { field_name: 'c' }], + formSteps: [{ title: 'Step 1', fields: ['a', 'b'] }, { title: 'Step 2', fields: ['c'] }], + }); + document.body.innerHTML = '
'; + const canvas = document.getElementById('step-canvas-1'); + const cEl = buildFieldElement(2); + const aEl = buildFieldElement(0); + canvas.appendChild(cEl); + canvas.appendChild(aEl); // 'a' dropped after 'c' in step 2 + + instance.handleFieldMovedToStep(aEl, 1); + + expect(instance.formSteps[0].fields).toEqual(['b']); + expect(instance.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 instance = createInstance({ + fields: [{ field_name: 'a' }, { field_name: 'b' }, { field_name: 'c' }], + formSteps: [{ title: 'Step 1', fields: ['a', 'b'] }, { title: 'Step 2', fields: ['c'] }], + }); + document.body.innerHTML = '
'; + const canvas = document.getElementById('step-canvas-1'); + const cEl = buildFieldElement(2); + const aEl = buildFieldElement(0); + canvas.appendChild(cEl); + canvas.appendChild(aEl); + + instance.handleFieldMovedToStep(aEl, 1); + + expect(instance.fields.map(f => f.field_name)).toEqual(['b', 'c', 'a']); + }); + + it('does nothing when the dragged element has no matching field', () => { + const instance = createInstance({ + fields: [{ field_name: 'a' }], + formSteps: [{ title: 'Step 1', fields: ['a'] }], + }); + const orphanEl = buildFieldElement(99); + + instance.handleFieldMovedToStep(orphanEl, 0); + + expect(instance.formSteps[0].fields).toEqual(['a']); + expect(instance.renderSingleStep).not.toHaveBeenCalled(); + }); +}); diff --git a/tests_js/form-builder/multiStepFieldIndexSync.test.js b/tests_js/form-builder/multiStepFieldIndexSync.test.js new file mode 100644 index 0000000..698088b --- /dev/null +++ b/tests_js/form-builder/multiStepFieldIndexSync.test.js @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FormBuilder } from '../../django_forms_workflows/static/django_forms_workflows/js/form-builder.js'; +import { createBuilderStore } from '../../django_forms_workflows/static/django_forms_workflows/js/form-builder-store.js'; + +// 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. +// A subsequent drag-reorder that trusted those stale indices corrupted +// formSteps/this.fields (duplicate/missing field names), which the backend +// preview endpoint then 500'd on. +function createInstance({ fields = [], formSteps = [] } = {}) { + const instance = Object.create(FormBuilder.prototype); + instance.store = createBuilderStore({ fields, formSteps }); + instance.fieldIdCounter = 1; + instance.undoStack = []; + instance.redoStack = []; + instance.maxUndoSteps = 50; + instance.fieldTypes = [{ type: 'text' }]; + instance.editField = vi.fn(); // don't actually open the property modal + instance.updatePreview = vi.fn(); + return instance; +} + +function setupStepCanvases(stepIndexes) { + document.body.innerHTML = stepIndexes.map(i => `
`).join(''); +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('multi-step field-index sync across steps', () => { + it('keeps every rendered field-item across every step pointing at the right field after a cross-step add', () => { + const instance = createInstance({ + 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'] }, + ], + }); + setupStepCanvases([0, 1]); + + // Drop a new field into step 0 before 'a' - this pushes 'a' and 'b' one + // slot later in the flattened this.fields array, including 'b' which + // lives in a step this handler never explicitly re-renders by name. + instance.handleFieldDroppedToStep('text', 0, 0); + + document.querySelectorAll('.field-item').forEach(el => { + const idx = parseInt(el.dataset.fieldIndex); + const field = instance.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 instance = createInstance({ + 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'] }, + ], + }); + setupStepCanvases([0, 1]); + + instance.handleFieldDroppedToStep('text', 0, 0); // shifts every later index + + // Simulate the very next user action from the bug report: reordering + // within step 0, which reads data-field-index back out of the DOM. + instance.updateFieldOrderInStep(0); + + const allNames = instance.fields.map(f => f.field_name); + expect(new Set(allNames).size).toBe(allNames.length); // no duplicates + expect(allNames).toHaveLength(3); // no fields silently dropped + expect(instance.formSteps[1].fields).toEqual(['b']); // step 2 untouched + }); +}); diff --git a/tests_js/form-builder/updateFieldOrderInStep.test.js b/tests_js/form-builder/updateFieldOrderInStep.test.js index 863ad1b..e76eaf2 100644 --- a/tests_js/form-builder/updateFieldOrderInStep.test.js +++ b/tests_js/form-builder/updateFieldOrderInStep.test.js @@ -39,4 +39,17 @@ describe('FormBuilder#updateFieldOrderInStep', () => { expect(instance.formSteps[0].fields).toEqual(['b', 'a']); expect(instance.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 instance = createInstance({ + fields: [{ field_name: 'a' }, { field_name: 'b' }], + formSteps: [{ title: 'Step 1', fields: ['a', 'b'] }], + }); + instance.updatePreview = vi.fn(); + setStepCanvasOrder(0, [1, 0]); // dragged 'b' above 'a' + + instance.updateFieldOrderInStep(0); + + expect(instance.fields.map(f => f.field_name)).toEqual(['b', 'a']); + }); }); From 5c6deb596ba104508c6e282a7d64175a35ca9e9f Mon Sep 17 00:00:00 2001 From: Maria Violante Date: Mon, 3 Aug 2026 11:48:43 -0400 Subject: [PATCH 6/6] Push an undo snapshot before moving/reordering fields within multi-step mode handleFieldMovedToStep (dragging an existing field between steps) and updateFieldOrderInStep (reordering within a step) never called pushUndo(), unlike every other field-mutating action (add, duplicate, delete, drop from palette). Ctrl+Z had nothing on the undo stack to restore to after either action - pre-existing gap, not introduced by the field-order sync fix, just surfaced by testing that fix. Both call sites (Sortable's onAdd/onUpdate) only fire when something actually changed, so this doesn't spam the undo stack on no-op drags. --- .../django_forms_workflows/js/form-builder.js | 4 +++ .../handleFieldMovedToStep.test.js | 36 +++++++++++++++++++ .../updateFieldOrderInStep.test.js | 20 +++++++++++ 3 files changed, 60 insertions(+) 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 3833222..0ca94c8 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 @@ -919,6 +919,8 @@ export class FormBuilder { if (!field) return; + this.pushUndo(); + // Find which step the field was in before let sourceStepIndex = -1; this.formSteps.forEach((step, idx) => { @@ -967,6 +969,8 @@ export class FormBuilder { const canvas = document.getElementById(`step-canvas-${stepIndex}`); if (!canvas) return; + this.pushUndo(); + const fieldElements = canvas.querySelectorAll('.field-item'); const fieldNames = []; diff --git a/tests_js/form-builder/handleFieldMovedToStep.test.js b/tests_js/form-builder/handleFieldMovedToStep.test.js index f0823ef..a27e04e 100644 --- a/tests_js/form-builder/handleFieldMovedToStep.test.js +++ b/tests_js/form-builder/handleFieldMovedToStep.test.js @@ -7,6 +7,9 @@ function createInstance({ fields, formSteps }) { instance.store = createBuilderStore({ fields, formSteps }); instance.renderSingleStep = vi.fn(); instance.updatePreview = vi.fn(); + instance.undoStack = []; + instance.redoStack = []; + instance.maxUndoSteps = 50; return instance; } @@ -72,4 +75,37 @@ describe('FormBuilder#handleFieldMovedToStep', () => { expect(instance.formSteps[0].fields).toEqual(['a']); expect(instance.renderSingleStep).not.toHaveBeenCalled(); }); + + it('pushes an undo snapshot before moving the field, so Ctrl+Z can restore the pre-move step assignment', () => { + const instance = createInstance({ + fields: [{ field_name: 'a' }, { field_name: 'b' }, { field_name: 'c' }], + formSteps: [{ title: 'Step 1', fields: ['a', 'b'] }, { title: 'Step 2', fields: ['c'] }], + }); + document.body.innerHTML = '
'; + const canvas = document.getElementById('step-canvas-1'); + const cEl = buildFieldElement(2); + const aEl = buildFieldElement(0); + canvas.appendChild(cEl); + canvas.appendChild(aEl); + + instance.handleFieldMovedToStep(aEl, 1); + + expect(instance.undoStack).toHaveLength(1); + expect(JSON.parse(instance.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 instance = createInstance({ + fields: [{ field_name: 'a' }], + formSteps: [{ title: 'Step 1', fields: ['a'] }], + }); + const orphanEl = buildFieldElement(99); + + instance.handleFieldMovedToStep(orphanEl, 0); + + expect(instance.undoStack).toHaveLength(0); + }); }); diff --git a/tests_js/form-builder/updateFieldOrderInStep.test.js b/tests_js/form-builder/updateFieldOrderInStep.test.js index e76eaf2..b8877a3 100644 --- a/tests_js/form-builder/updateFieldOrderInStep.test.js +++ b/tests_js/form-builder/updateFieldOrderInStep.test.js @@ -5,6 +5,9 @@ import { createBuilderStore } from '../../django_forms_workflows/static/django_f function createInstance({ fields, formSteps }) { const instance = Object.create(FormBuilder.prototype); instance.store = createBuilderStore({ fields, formSteps }); + instance.undoStack = []; + instance.redoStack = []; + instance.maxUndoSteps = 50; return instance; } @@ -52,4 +55,21 @@ describe('FormBuilder#updateFieldOrderInStep', () => { expect(instance.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 instance = createInstance({ + fields: [{ field_name: 'a' }, { field_name: 'b' }], + formSteps: [{ title: 'Step 1', fields: ['a', 'b'] }], + }); + instance.updatePreview = vi.fn(); + setStepCanvasOrder(0, [1, 0]); + + instance.updateFieldOrderInStep(0); + + expect(instance.undoStack).toHaveLength(1); + expect(JSON.parse(instance.undoStack[0])).toEqual({ + fields: [{ field_name: 'a' }, { field_name: 'b' }], + formSteps: [{ title: 'Step 1', fields: ['a', 'b'] }], + }); + }); });