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..adc61f6
--- /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..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
@@ -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');
@@ -1416,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
}
@@ -1472,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) => {
@@ -1505,11 +954,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();
@@ -1519,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 = [];
@@ -1531,6 +983,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();
}
@@ -1646,163 +1105,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 +1246,4 @@ export class FormBuilder {
}
}
-Object.assign(FormBuilder.prototype, historyMethods, apiMethods);
+Object.assign(FormBuilder.prototype, historyMethods, apiMethods, propertyEditorMethods);
diff --git a/tests_js/form-builder-property-editor/propertyEditorMethods.test.js b/tests_js/form-builder-property-editor/propertyEditorMethods.test.js
new file mode 100644
index 0000000..ab5b66c
--- /dev/null
+++ b/tests_js/form-builder-property-editor/propertyEditorMethods.test.js
@@ -0,0 +1,689 @@
+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;
+}
+
+afterEach(() => {
+ document.body.innerHTML = '';
+ vi.unstubAllGlobals();
+});
+
+describe('propertyEditorMethods.initializePropertyFormTabs', () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+ `;
+ });
+
+ it('wires the conditional-logic toggle to show/hide its section', () => {
+ const ctx = createContext();
+ ctx.initializeConditionsList = vi.fn();
+ ctx.initializeValidationRulesList = vi.fn();
+ ctx.initializeDependenciesList = vi.fn();
+ const field = { conditional_rules: null, validation_rules: [], field_dependencies: [] };
+
+ ctx.initializePropertyFormTabs(field);
+
+ const checkbox = document.getElementById('propEnableConditional');
+ const container = document.getElementById('conditionalRulesContainer');
+
+ checkbox.checked = true;
+ checkbox.dispatchEvent(new Event('change'));
+ expect(container.style.display).toBe('block');
+
+ checkbox.checked = false;
+ checkbox.dispatchEvent(new Event('change'));
+ expect(container.style.display).toBe('none');
+ });
+
+ it("initializes all three tabs' lists with the field's current data", () => {
+ 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' }],
+ };
+
+ ctx.initializePropertyFormTabs(field);
+
+ 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 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('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' };
+
+ 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 = {};
+
+ const html = ctx.buildValidationTab(field);
+
+ expect(field.validation_rules).toEqual([]);
+ expect(html).toContain('id="propValidationRulesJson"');
+ expect(html).toMatch(/id="propValidationRulesJson"[^>]*>\s*);
+ });
+
+ it('pre-fills the JSON preview from existing rules', () => {
+ 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(field.validation).toEqual({ min_value: 1, max_value: 10 });
+ });
+});
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..a27e04e
--- /dev/null
+++ b/tests_js/form-builder/handleFieldMovedToStep.test.js
@@ -0,0 +1,111 @@
+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();
+ instance.undoStack = [];
+ instance.redoStack = [];
+ instance.maxUndoSteps = 50;
+ 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();
+ });
+
+ 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/initializePropertyFormTabs.test.js b/tests_js/form-builder/initializePropertyFormTabs.test.js
deleted file mode 100644
index a9b44ab..0000000
--- a/tests_js/form-builder/initializePropertyFormTabs.test.js
+++ /dev/null
@@ -1,63 +0,0 @@
-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();
- return instance;
-}
-
-describe('FormBuilder#initializePropertyFormTabs', () => {
- beforeEach(() => {
- document.body.innerHTML = `
-
-
- `;
- });
-
- it('wires the conditional-logic toggle to show/hide its section', () => {
- const instance = createInstance(FormBuilder);
- const field = { conditional_rules: null, validation_rules: [], field_dependencies: [] };
-
- instance.initializePropertyFormTabs(field);
-
- const checkbox = document.getElementById('propEnableConditional');
- const container = document.getElementById('conditionalRulesContainer');
-
- checkbox.checked = true;
- checkbox.dispatchEvent(new Event('change'));
- expect(container.style.display).toBe('block');
-
- checkbox.checked = false;
- checkbox.dispatchEvent(new Event('change'));
- expect(container.style.display).toBe('none');
- });
-
- it("initializes all three tabs' lists with the field's current data", () => {
- const instance = createInstance(FormBuilder);
- const field = {
- conditional_rules: { conditions: [{ field: 'x', operator: 'equals', value: '1' }] },
- validation_rules: [{ type: 'required' }],
- field_dependencies: [{ source: 'a', target: 'b' }],
- };
-
- instance.initializePropertyFormTabs(field);
-
- expect(instance.initializeConditionsList).toHaveBeenCalledWith(field.conditional_rules.conditions);
- expect(instance.initializeValidationRulesList).toHaveBeenCalledWith(field.validation_rules);
- expect(instance.initializeDependenciesList).toHaveBeenCalledWith(field.field_dependencies);
- });
-
- it('defaults to empty lists when the field has no rules/dependencies yet', () => {
- const instance = createInstance(FormBuilder);
- const field = {};
-
- instance.initializePropertyFormTabs(field);
-
- expect(instance.initializeConditionsList).toHaveBeenCalledWith([]);
- expect(instance.initializeValidationRulesList).toHaveBeenCalledWith([]);
- expect(instance.initializeDependenciesList).toHaveBeenCalledWith([]);
- });
-});
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..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;
}
@@ -39,4 +42,34 @@ 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']);
+ });
+
+ 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'] }],
+ });
+ });
});