diff --git a/django_forms_workflows/static/django_forms_workflows/js/form-builder-history.js b/django_forms_workflows/static/django_forms_workflows/js/form-builder-history.js index 5e47108..c1b931a 100644 --- a/django_forms_workflows/static/django_forms_workflows/js/form-builder-history.js +++ b/django_forms_workflows/static/django_forms_workflows/js/form-builder-history.js @@ -2,32 +2,23 @@ * Undo/redo history for the Form Builder. * * Mixed onto FormBuilder.prototype in form-builder.js (Object.assign), not a - * standalone class of its own — these methods operate on `this.fields`/ - * `this.formSteps`/`this.undoStack`/`this.redoStack`/`this.maxUndoSteps`, - * and call back into renderCanvas()/renderStepTabs()/updatePreview(), which - * still live on the single FormBuilder instance. + * standalone class of its own — these methods operate on `this.store` + * (snapshot/restore) plus `this.undoStack`/`this.redoStack`/ + * `this.maxUndoSteps`, and call back into + * renderCanvas()/renderStepTabs()/updatePreview(), which still live on the + * single FormBuilder instance. */ export const historyMethods = { pushUndo() { - this.undoStack.push(this.snapshotHistoryState()); + this.undoStack.push(this.store.snapshot()); if (this.undoStack.length > this.maxUndoSteps) { this.undoStack.shift(); } this.redoStack = []; }, - // Snapshotting `fields` alone isn't enough: multi-step assignment - // (`formSteps`) is keyed by field_name, so restoring `fields` without it - // can leave a step referencing a field that no longer exists (or lose - // the multi-step layout entirely) after an undo/redo. - snapshotHistoryState() { - return JSON.stringify({ fields: this.fields, formSteps: this.formSteps }); - }, - restoreHistoryState(snapshot) { - const { fields, formSteps } = JSON.parse(snapshot); - this.fields = fields; - this.formSteps = formSteps; + this.store.restore(snapshot); // Undo/redo previously always called renderCanvas(), even in // multi-step mode - restoring formSteps is pointless if the @@ -43,13 +34,13 @@ export const historyMethods = { undo() { if (this.undoStack.length === 0) return; - this.redoStack.push(this.snapshotHistoryState()); + this.redoStack.push(this.store.snapshot()); this.restoreHistoryState(this.undoStack.pop()); }, redo() { if (this.redoStack.length === 0) return; - this.undoStack.push(this.snapshotHistoryState()); + this.undoStack.push(this.store.snapshot()); this.restoreHistoryState(this.redoStack.pop()); }, }; diff --git a/django_forms_workflows/static/django_forms_workflows/js/form-builder-store.js b/django_forms_workflows/static/django_forms_workflows/js/form-builder-store.js new file mode 100644 index 0000000..0595ce7 --- /dev/null +++ b/django_forms_workflows/static/django_forms_workflows/js/form-builder-store.js @@ -0,0 +1,54 @@ +/** + * Single owner of Form Builder state, shared across extracted modules. + * Factory (not a singleton) since admin inlines can put two builders on one + * page. Extends EventTarget so modules can react to changes instead of + * closing over the whole FormBuilder instance. + */ +export class BuilderStore extends EventTarget { + constructor({ fields = [], formSteps = [], fieldIdCounter = 1 } = {}) { + super(); + this.fields = fields; + this.formSteps = formSteps; + this.fieldIdCounter = fieldIdCounter; + } + + setFields(fields) { + this.fields = fields; + this.dispatchEvent(new CustomEvent('fields-changed', { detail: { fields } })); + } + + setFormSteps(formSteps) { + this.formSteps = formSteps; + this.dispatchEvent(new CustomEvent('form-steps-changed', { detail: { formSteps } })); + } + + nextFieldId(prefix) { + const id = `${prefix}_${this.fieldIdCounter}`; + this.fieldIdCounter += 1; + return id; + } + + // Ports 445790f: seeds past existing field names so newly-generated + // ones can't collide (call after loading fields from a form/template). + seedFieldIdCounterFromFields(fields) { + const highest = fields.reduce((max, field) => { + const match = /_(\d+)$/.exec(field.field_name || ''); + return match ? Math.max(max, parseInt(match[1], 10)) : max; + }, 0); + this.fieldIdCounter = highest + 1; + } + + snapshot() { + return JSON.stringify({ fields: this.fields, formSteps: this.formSteps }); + } + + restore(snapshotJson) { + const { fields, formSteps } = JSON.parse(snapshotJson); + this.setFields(fields); + this.setFormSteps(formSteps); + } +} + +export function createBuilderStore(initial) { + return new BuilderStore(initial); +} 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 e5c44fb..2de218e 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 @@ -6,10 +6,12 @@ */ import { historyMethods } from './form-builder-history.js'; +import { createBuilderStore } from './form-builder-store.js'; export class FormBuilder { constructor(config) { this.config = config; + this.store = createBuilderStore(); this.fields = []; this.currentFieldIndex = null; this.fieldIdCounter = 1; @@ -25,7 +27,17 @@ export class FormBuilder { this.init(); } - + + // fields/formSteps live on this.store now (single source of truth for + // history's undo/redo snapshots); these proxy the existing this.fields/ + // this.formSteps call sites throughout this file so they don't all need + // to change in this pass. + get fields() { return this.store.fields; } + set fields(value) { this.store.setFields(value); } + + get formSteps() { return this.store.formSteps; } + set formSteps(value) { this.store.setFormSteps(value); } + init() { this.setupFieldPalette(); this.setupCanvas(); @@ -1209,6 +1221,8 @@ export class FormBuilder { // Move all fields to first step if they're not assigned this.organizeFieldsIntoSteps(); + + this.updatePreview(); } else { // Switch to single-step mode singleCanvas.style.display = 'block'; @@ -1595,6 +1609,7 @@ export class FormBuilder { // Re-render main canvas this.renderCanvas(); + this.updatePreview(); } updateFieldOrderFromSteps() { diff --git a/tests_js/form-builder-history/historyMethods.test.js b/tests_js/form-builder-history/historyMethods.test.js index 2523b77..695ebd3 100644 --- a/tests_js/form-builder-history/historyMethods.test.js +++ b/tests_js/form-builder-history/historyMethods.test.js @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { historyMethods } from '../../django_forms_workflows/static/django_forms_workflows/js/form-builder-history.js'; +import { createBuilderStore } from '../../django_forms_workflows/static/django_forms_workflows/js/form-builder-store.js'; function createContext(fields = [], formSteps = []) { return { - fields, - formSteps, + store: createBuilderStore({ fields, formSteps }), undoStack: [], redoStack: [], maxUndoSteps: 50, @@ -27,24 +27,14 @@ afterEach(() => { document.body.innerHTML = ''; }); -describe('historyMethods.snapshotHistoryState', () => { - it('snapshots both fields and formSteps together', () => { - const ctx = createContext([{ field_name: 'a' }], [{ title: 'Step 1', fields: ['a'] }]); - - expect(ctx.snapshotHistoryState()).toEqual( - JSON.stringify({ fields: [{ field_name: 'a' }], formSteps: [{ title: 'Step 1', fields: ['a'] }] }) - ); - }); -}); - describe('historyMethods.pushUndo', () => { - it('snapshots the current fields and formSteps onto the undo stack and clears redo', () => { - const ctx = createContext([{ field_name: 'a' }], [{ label: 'Step 1', fields: ['a'] }]); + it('snapshots the store onto the undo stack and clears redo', () => { + const ctx = createContext([{ field_name: 'a' }], [{ title: 'Step 1', fields: ['a'] }]); ctx.redoStack.push('stale-redo-snapshot'); ctx.pushUndo(); - expect(ctx.undoStack).toEqual([ctx.snapshotHistoryState()]); + expect(ctx.undoStack).toEqual([ctx.store.snapshot()]); expect(ctx.redoStack).toEqual([]); }); @@ -55,7 +45,7 @@ describe('historyMethods.pushUndo', () => { ctx.pushUndo(); - expect(ctx.undoStack).toEqual(['middle', ctx.snapshotHistoryState()]); + expect(ctx.undoStack).toEqual(['middle', ctx.store.snapshot()]); }); }); @@ -65,7 +55,7 @@ describe('historyMethods.undo', () => { ctx.undo(); - expect(ctx.fields).toEqual([{ field_name: 'current' }]); + expect(ctx.store.fields).toEqual([{ field_name: 'current' }]); expect(ctx.renderCanvas).not.toHaveBeenCalled(); expect(ctx.renderStepTabs).not.toHaveBeenCalled(); expect(ctx.updatePreview).not.toHaveBeenCalled(); @@ -74,17 +64,17 @@ describe('historyMethods.undo', () => { it('restores the previous fields and formSteps snapshot and pushes the current one onto redo', () => { const ctx = createContext( [{ field_name: 'current' }], - [{ label: 'Step 1', fields: ['current'] }] + [{ title: 'Step 1', fields: ['current'] }] ); - const currentSnapshot = ctx.snapshotHistoryState(); + const currentSnapshot = ctx.store.snapshot(); ctx.undoStack = [ JSON.stringify({ fields: [{ field_name: 'previous' }], formSteps: [] }), ]; ctx.undo(); - expect(ctx.fields).toEqual([{ field_name: 'previous' }]); - expect(ctx.formSteps).toEqual([]); + expect(ctx.store.fields).toEqual([{ field_name: 'previous' }]); + expect(ctx.store.formSteps).toEqual([]); expect(ctx.redoStack).toEqual([currentSnapshot]); expect(ctx.undoStack).toEqual([]); expect(ctx.renderCanvas).toHaveBeenCalledTimes(1); @@ -94,14 +84,14 @@ describe('historyMethods.undo', () => { it('re-renders the step tabs instead of the single-step canvas when multi-step mode is on', () => { setMultiStep(true); - const ctx = createContext([{ field_name: 'current' }], [{ label: 'Step 1', fields: [] }]); + const ctx = createContext([{ field_name: 'current' }], [{ title: 'Step 1', fields: [] }]); ctx.undoStack = [ - JSON.stringify({ fields: [{ field_name: 'previous' }], formSteps: [{ label: 'Step 1', fields: ['previous'] }] }), + JSON.stringify({ fields: [{ field_name: 'previous' }], formSteps: [{ title: 'Step 1', fields: ['previous'] }] }), ]; ctx.undo(); - expect(ctx.formSteps).toEqual([{ label: 'Step 1', fields: ['previous'] }]); + expect(ctx.store.formSteps).toEqual([{ title: 'Step 1', fields: ['previous'] }]); expect(ctx.renderStepTabs).toHaveBeenCalledTimes(1); expect(ctx.renderCanvas).not.toHaveBeenCalled(); }); @@ -113,7 +103,7 @@ describe('historyMethods.redo', () => { ctx.redo(); - expect(ctx.fields).toEqual([{ field_name: 'current' }]); + expect(ctx.store.fields).toEqual([{ field_name: 'current' }]); expect(ctx.renderCanvas).not.toHaveBeenCalled(); expect(ctx.renderStepTabs).not.toHaveBeenCalled(); expect(ctx.updatePreview).not.toHaveBeenCalled(); @@ -121,15 +111,15 @@ describe('historyMethods.redo', () => { it('restores the next fields and formSteps snapshot and pushes the current one onto undo', () => { const ctx = createContext([{ field_name: 'current' }], []); - const currentSnapshot = ctx.snapshotHistoryState(); + const currentSnapshot = ctx.store.snapshot(); ctx.redoStack = [ - JSON.stringify({ fields: [{ field_name: 'next' }], formSteps: [{ label: 'Step 1', fields: ['next'] }] }), + JSON.stringify({ fields: [{ field_name: 'next' }], formSteps: [{ title: 'Step 1', fields: ['next'] }] }), ]; ctx.redo(); - expect(ctx.fields).toEqual([{ field_name: 'next' }]); - expect(ctx.formSteps).toEqual([{ label: 'Step 1', fields: ['next'] }]); + expect(ctx.store.fields).toEqual([{ field_name: 'next' }]); + expect(ctx.store.formSteps).toEqual([{ title: 'Step 1', fields: ['next'] }]); expect(ctx.undoStack).toEqual([currentSnapshot]); expect(ctx.redoStack).toEqual([]); expect(ctx.renderCanvas).toHaveBeenCalledTimes(1); @@ -140,7 +130,7 @@ describe('historyMethods.redo', () => { setMultiStep(true); const ctx = createContext([{ field_name: 'current' }], []); ctx.redoStack = [ - JSON.stringify({ fields: [{ field_name: 'next' }], formSteps: [{ label: 'Step 1', fields: ['next'] }] }), + JSON.stringify({ fields: [{ field_name: 'next' }], formSteps: [{ title: 'Step 1', fields: ['next'] }] }), ]; ctx.redo(); diff --git a/tests_js/form-builder-store/createBuilderStore.test.js b/tests_js/form-builder-store/createBuilderStore.test.js new file mode 100644 index 0000000..fc25926 --- /dev/null +++ b/tests_js/form-builder-store/createBuilderStore.test.js @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { createBuilderStore } from '../../django_forms_workflows/static/django_forms_workflows/js/form-builder-store.js'; + +describe('createBuilderStore', () => { + it('is a factory, not a singleton — each call returns an independent store', () => { + const a = createBuilderStore(); + const b = createBuilderStore(); + + a.setFields([{ field_name: 'text_1' }]); + + expect(a.fields).toEqual([{ field_name: 'text_1' }]); + expect(b.fields).toEqual([]); + expect(a).not.toBe(b); + }); + + it('defaults to empty fields/formSteps and a fieldIdCounter of 1', () => { + const store = createBuilderStore(); + + expect(store.fields).toEqual([]); + expect(store.formSteps).toEqual([]); + expect(store.fieldIdCounter).toBe(1); + }); + + it('accepts initial state', () => { + const store = createBuilderStore({ + fields: [{ field_name: 'text_1' }], + formSteps: [{ title: 'Step 1', fields: ['text_1'] }], + fieldIdCounter: 5, + }); + + expect(store.fields).toEqual([{ field_name: 'text_1' }]); + expect(store.formSteps).toEqual([{ title: 'Step 1', fields: ['text_1'] }]); + expect(store.fieldIdCounter).toBe(5); + }); +}); + +describe('BuilderStore.setFields / setFormSteps', () => { + it('setFields updates state and emits fields-changed', () => { + const store = createBuilderStore(); + let received = null; + store.addEventListener('fields-changed', (e) => { received = e.detail.fields; }); + + store.setFields([{ field_name: 'text_1' }]); + + expect(store.fields).toEqual([{ field_name: 'text_1' }]); + expect(received).toEqual([{ field_name: 'text_1' }]); + }); + + it('setFormSteps updates state and emits form-steps-changed', () => { + const store = createBuilderStore(); + let received = null; + store.addEventListener('form-steps-changed', (e) => { received = e.detail.formSteps; }); + + store.setFormSteps([{ title: 'Step 1', fields: [] }]); + + expect(store.formSteps).toEqual([{ title: 'Step 1', fields: [] }]); + expect(received).toEqual([{ title: 'Step 1', fields: [] }]); + }); +}); + +describe('BuilderStore.nextFieldId', () => { + it('returns prefix_counter and increments the counter', () => { + const store = createBuilderStore({ fieldIdCounter: 1 }); + + expect(store.nextFieldId('text')).toBe('text_1'); + expect(store.nextFieldId('text')).toBe('text_2'); + expect(store.fieldIdCounter).toBe(3); + }); +}); + +describe('BuilderStore.seedFieldIdCounterFromFields', () => { + it('seeds the counter one past the highest existing numeric suffix', () => { + const store = createBuilderStore(); + store.seedFieldIdCounterFromFields([ + { field_name: 'text_1' }, + { field_name: 'email_3' }, + { field_name: 'date_2' }, + ]); + + expect(store.fieldIdCounter).toBe(4); + }); + + it('defaults to 1 when there are no fields yet', () => { + const store = createBuilderStore({ fieldIdCounter: 99 }); + store.seedFieldIdCounterFromFields([]); + + expect(store.fieldIdCounter).toBe(1); + }); + + it('ignores field names that do not end in a number', () => { + const store = createBuilderStore(); + store.seedFieldIdCounterFromFields([ + { field_name: 'customer_email' }, + { field_name: 'text_5' }, + ]); + + expect(store.fieldIdCounter).toBe(6); + }); + + it('does not crash on a field with no field_name', () => { + const store = createBuilderStore(); + store.seedFieldIdCounterFromFields([{}, { field_name: 'text_2' }]); + + expect(store.fieldIdCounter).toBe(3); + }); +}); + +describe('BuilderStore.snapshot / restore', () => { + it('snapshot captures fields and formSteps together', () => { + const store = createBuilderStore({ + fields: [{ field_name: 'a' }], + formSteps: [{ title: 'Step 1', fields: ['a'] }], + }); + + expect(store.snapshot()).toEqual( + JSON.stringify({ fields: [{ field_name: 'a' }], formSteps: [{ title: 'Step 1', fields: ['a'] }] }) + ); + }); + + it('restore replaces fields and formSteps and emits both change events', () => { + const store = createBuilderStore({ fields: [{ field_name: 'old' }], formSteps: [] }); + const fieldsChanged = []; + const formStepsChanged = []; + store.addEventListener('fields-changed', (e) => fieldsChanged.push(e.detail.fields)); + store.addEventListener('form-steps-changed', (e) => formStepsChanged.push(e.detail.formSteps)); + + const snapshot = JSON.stringify({ + fields: [{ field_name: 'restored' }], + formSteps: [{ title: 'Step 1', fields: ['restored'] }], + }); + store.restore(snapshot); + + expect(store.fields).toEqual([{ field_name: 'restored' }]); + expect(store.formSteps).toEqual([{ title: 'Step 1', fields: ['restored'] }]); + expect(fieldsChanged).toEqual([[{ field_name: 'restored' }]]); + expect(formStepsChanged).toEqual([[{ title: 'Step 1', fields: ['restored'] }]]); + }); +}); diff --git a/tests_js/form-builder/addFieldAtPosition.test.js b/tests_js/form-builder/addFieldAtPosition.test.js index 2ecb4db..23d9243 100644 --- a/tests_js/form-builder/addFieldAtPosition.test.js +++ b/tests_js/form-builder/addFieldAtPosition.test.js @@ -1,10 +1,12 @@ import { 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'; // Build an instance without running the constructor to avoid calling init() -> // setupFieldPalette()/setupCanvas()/setupEventListeners() function createInstance(FormBuilder) { const instance = Object.create(FormBuilder.prototype); + instance.store = createBuilderStore(); instance.fields = []; instance.fieldIdCounter = 1; instance.undoStack = []; diff --git a/tests_js/form-builder/moveAllFieldsToMainCanvas.test.js b/tests_js/form-builder/moveAllFieldsToMainCanvas.test.js new file mode 100644 index 0000000..8fc9873 --- /dev/null +++ b/tests_js/form-builder/moveAllFieldsToMainCanvas.test.js @@ -0,0 +1,22 @@ +import { 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(formSteps) { + const instance = Object.create(FormBuilder.prototype); + instance.store = createBuilderStore({ formSteps }); + instance.renderCanvas = vi.fn(); + instance.updatePreview = vi.fn(); + return instance; +} + +describe('FormBuilder#moveAllFieldsToMainCanvas', () => { + it('refreshes both the canvas and the live preview', () => { + const instance = createInstance([{ title: 'Step 1', fields: ['a'] }]); + + instance.moveAllFieldsToMainCanvas(); + + expect(instance.renderCanvas).toHaveBeenCalledTimes(1); + expect(instance.updatePreview).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests_js/form-builder/setupCanvas.test.js b/tests_js/form-builder/setupCanvas.test.js index 3b2de18..aee4455 100644 --- a/tests_js/form-builder/setupCanvas.test.js +++ b/tests_js/form-builder/setupCanvas.test.js @@ -1,5 +1,6 @@ import { beforeEach, 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'; // setupCanvas() calls Sortable.create(...) before registering its own drop // listener; stub it out rather than pulling in the real SortableJS @@ -9,6 +10,7 @@ function createInstance(FormBuilder) { global.Sortable = { create: vi.fn(() => ({})) }; const instance = Object.create(FormBuilder.prototype); + instance.store = createBuilderStore(); instance.fields = []; instance.dragPlaceholder = null; instance.addFieldAtPosition = vi.fn(); diff --git a/tests_js/form-builder/toggleMultiStepMode.test.js b/tests_js/form-builder/toggleMultiStepMode.test.js new file mode 100644 index 0000000..c1e35df --- /dev/null +++ b/tests_js/form-builder/toggleMultiStepMode.test.js @@ -0,0 +1,35 @@ +import { 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) { + document.body.innerHTML = ` +
+
+ `; + + const instance = Object.create(FormBuilder.prototype); + instance.store = createBuilderStore({ fields, formSteps }); + instance.renderStepTabs = vi.fn(); + instance.renderCanvas = vi.fn(); + instance.updatePreview = vi.fn(); + return instance; +} + +describe('FormBuilder#toggleMultiStepMode', () => { + it('refreshes the live preview when enabling multi-step', () => { + const instance = createInstance([{ field_name: 'a' }], []); + + instance.toggleMultiStepMode(true); + + expect(instance.updatePreview).toHaveBeenCalledTimes(1); + }); + + it('refreshes the live preview when disabling multi-step', () => { + const instance = createInstance([{ field_name: 'a' }], [{ title: 'Step 1', fields: ['a'] }]); + + instance.toggleMultiStepMode(false); + + expect(instance.updatePreview).toHaveBeenCalledTimes(1); + }); +});