Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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());
},
};
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -1595,6 +1609,7 @@ export class FormBuilder {

// Re-render main canvas
this.renderCanvas();
this.updatePreview();
Comment on lines 1610 to +1612
}

updateFieldOrderFromSteps() {
Expand Down
50 changes: 20 additions & 30 deletions tests_js/form-builder-history/historyMethods.test.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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');
Comment on lines +31 to 33

ctx.pushUndo();

expect(ctx.undoStack).toEqual([ctx.snapshotHistoryState()]);
expect(ctx.undoStack).toEqual([ctx.store.snapshot()]);
expect(ctx.redoStack).toEqual([]);
});

Expand All @@ -55,7 +45,7 @@ describe('historyMethods.pushUndo', () => {

ctx.pushUndo();

expect(ctx.undoStack).toEqual(['middle', ctx.snapshotHistoryState()]);
expect(ctx.undoStack).toEqual(['middle', ctx.store.snapshot()]);
});
});

Expand All @@ -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();
Expand All @@ -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);
Expand All @@ -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();
});
Expand All @@ -113,23 +103,23 @@ 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();
});

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);
Expand All @@ -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();
Expand Down
Loading
Loading