From 789d0f01b5fb2b1879516921b6f362916f90846e Mon Sep 17 00:00:00 2001 From: Ton Sharp <45160296+66Ton99@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:49:01 +0300 Subject: [PATCH 1/2] feat(js): take a form registration back A page that swaps rendered forms in and out - a single page CRUD, a modal that loads its form - registered a model per render and had no way to remove one. The registry kept every element, including the ones whose markup was already gone, and "getFormInstances" answered with them. "removeModel", "removeForm" and "removeDetachedForms" remove a registration, detach the model from its DOM nodes and take the submit listener off the form. Initializing the same markup again now replaces that listener instead of stacking a second one, which used to run the whole validation twice for one submit. --- README.md | 1 + src/Resources/doc/3_24.md | 44 +++++ .../public/js/SvarohJsFormValidator.js | 152 +++++++++++++++++- .../public/js/SvarohJsFormValidator.test.js | 123 ++++++++++++++ 4 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 src/Resources/doc/3_24.md diff --git a/README.md b/README.md index a8c52c4..850f157 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,7 @@ If your form rendering is customized, start with 21. [Validation events](src/Resources/doc/3_21.md) 22. [File uploads](src/Resources/doc/3_22.md) 23. [Pluralized messages](src/Resources/doc/3_23.md) +24. [Forms that come and go](src/Resources/doc/3_24.md) ## Development diff --git a/src/Resources/doc/3_24.md b/src/Resources/doc/3_24.md new file mode 100644 index 0000000..11d4862 --- /dev/null +++ b/src/Resources/doc/3_24.md @@ -0,0 +1,44 @@ +### 3.24 Forms that come and go + +A page that fetches rendered forms and swaps them into the document - a single +page CRUD, a modal that loads its form, a wizard step - initializes a model per +render. `addModel()` keeps every render in the registry, so the ones whose +markup the application has removed have to be taken back out, otherwise the +registry keeps growing and `getFormInstances()` keeps answering with elements +of nodes that are gone. + +Three methods take a registration back. Each one detaches the model from the +DOM nodes it was attached to and removes the submit listener this library put +on the form, so a node that is dropped afterwards leaves nothing behind. + +```js +// Every render of one model id +SvarohJsFormValidator.removeModel('user'); + +// One rendered form, when the same model is on the page more than once +SvarohJsFormValidator.removeForm(document.getElementById('user')); + +// Every registration whose form is no longer in the document +SvarohJsFormValidator.removeDetachedForms(); +``` + +`removeModel()` answers with the number of removed registrations, +`removeForm()` with whether it removed one, and `removeDetachedForms()` with +the number it removed. + +A typical swap removes what the old markup registered, replaces the markup, and +initializes the new render: + +```js +SvarohJsFormValidator.removeModel('user'); +panel.innerHTML = html; +// The script the fragment carries calls addModel() for the new render +``` + +`removeDetachedForms()` is the variant for code that does not know which model +ids it just dropped: replace the markup first, then call it. + +Initializing the same markup again without removing it first is safe: the +second initialization replaces the registration of that node instead of adding +one next to it, and the form is still validated once per submit. It does leave +the previous element attached to nothing, so prefer removing it. diff --git a/src/Resources/public/js/SvarohJsFormValidator.js b/src/Resources/public/js/SvarohJsFormValidator.js index 8e4adc0..be7876e 100644 --- a/src/Resources/public/js/SvarohJsFormValidator.js +++ b/src/Resources/public/js/SvarohJsFormValidator.js @@ -677,6 +677,143 @@ var SvarohJsFormValidator = new function () { return this.formInstances[id] ? this.formInstances[id] : []; }; + /** + * Undoes what "attachElement" and "attachDefaultEvent" did to the DOM + * nodes of an element and of its children, so a node an application + * dropped from the document keeps no reference back to the model and no + * listener of this library + * + * @param {SvarohJsFormElement} element + */ + this.detachElement = function (element) { + if (!element) { + return; + } + + for (var name in element.children) { + this.detachElement(element.children[name]); + } + + var domNode = element.domNode; + if (!domNode) { + return; + } + + if (domNode.jsFormValidator === element) { + delete domNode.jsFormValidator; + } + + if (domNode.__svarohJsFormValidatorSubmitListener) { + domNode.removeEventListener('submit', domNode.__svarohJsFormValidatorSubmitListener); + delete domNode.__svarohJsFormValidatorSubmitListener; + } + }; + + /** + * Keeps the given registrations of a model id and drops the id entirely + * when none are left, so "forms" never answers with an element the + * registry no longer holds + * + * @param {String} id + * @param {Array} instances + */ + this.keepFormInstances = function (id, instances) { + if (!instances.length) { + delete this.formInstances[id]; + delete this.forms[id]; + + return; + } + + this.formInstances[id] = instances; + for (var i = 0; i < instances.length; i++) { + if (instances[i] === this.forms[id]) { + return; + } + } + + this.forms[id] = instances[instances.length - 1]; + }; + + /** + * Removes every registration of a model id + * + * @param {String} id + * + * @return {Number} the number of removed registrations + */ + this.removeModel = function (id) { + var instances = this.getFormInstances(id); + for (var i = 0; i < instances.length; i++) { + this.detachElement(instances[i]); + } + + this.keepFormInstances(id, []); + + return instances.length; + }; + + /** + * Removes the registration attached to one form node, which is what an + * application replacing a single rendered form needs + * + * @param {HTMLElement} domNode + * + * @return {Boolean} whether a registration was removed + */ + this.removeForm = function (domNode) { + if (!domNode) { + return false; + } + + var removed = false; + for (var id in this.formInstances) { + var kept = []; + var instances = this.formInstances[id]; + for (var i = 0; i < instances.length; i++) { + if (instances[i].domNode === domNode) { + this.detachElement(instances[i]); + removed = true; + } else { + kept.push(instances[i]); + } + } + + this.keepFormInstances(id, kept); + } + + return removed; + }; + + /** + * Removes every registration whose form is no longer in the document. An + * application that swaps rendered forms in and out without naming them + * calls this after a swap, otherwise the registry grows with elements of + * nodes that are gone + * + * @return {Number} the number of removed registrations + */ + this.removeDetachedForms = function () { + var removed = 0; + for (var id in this.formInstances) { + var kept = []; + var instances = this.formInstances[id]; + for (var i = 0; i < instances.length; i++) { + var domNode = instances[i].domNode; + if (domNode && document.contains(domNode)) { + kept.push(instances[i]); + } else { + this.detachElement(instances[i]); + removed++; + } + } + + this.keepFormInstances(id, kept); + } + + return removed; + }; + this.onDocumentReady = function (callback) { var addListener = document.addEventListener || document.attachEvent; var removeListener = document.removeEventListener || document.detachEvent; @@ -1391,9 +1528,20 @@ var SvarohJsFormValidator = new function () { * @param {HTMLFormElement} form */ this.attachDefaultEvent = function (element, form) { - form.addEventListener('submit', function (event) { + // The same markup can be initialized more than once - an application + // that renders a form fragment again - and a second listener would run + // the whole validation twice for one submit, so the listener is kept + // on the node and replaced instead of stacked + if (form.__svarohJsFormValidatorSubmitListener) { + form.removeEventListener('submit', form.__svarohJsFormValidatorSubmitListener); + } + + var listener = function (event) { SvarohJsFormValidator.customize(form, 'submitForm', event); - }); + }; + + form.__svarohJsFormValidatorSubmitListener = listener; + form.addEventListener('submit', listener); }; /** diff --git a/src/Resources/public/js/SvarohJsFormValidator.test.js b/src/Resources/public/js/SvarohJsFormValidator.test.js index c36ba02..7ffab0a 100644 --- a/src/Resources/public/js/SvarohJsFormValidator.test.js +++ b/src/Resources/public/js/SvarohJsFormValidator.test.js @@ -1278,6 +1278,129 @@ describe('SvarohJsFormValidator model registration', () => { }); }); +// An application that swaps rendered forms in and out of one page - a single +// page CRUD - registers a model per render, and nothing used to take one back +describe('SvarohJsFormValidator model teardown', () => { + afterEach(() => { + document.body.innerHTML = ''; + window.SvarohJsFormValidator.forms = {}; + window.SvarohJsFormValidator.formInstances = {}; + window.SvarohJsFormValidator.constraintsCounter = 0; + jest.restoreAllMocks(); + }); + + function buildModel(id, name, children) { + return { + id: id, + name: name, + type: '', + invalidMessage: '', + bubbling: false, + disabled: false, + transformers: [], + data: {}, + children: children === undefined ? [] : children, + }; + } + + function profileModel() { + return buildModel('profile', 'profile', { + email: buildModel('profile_email', 'profile[email]'), + }); + } + + function renderProfile() { + document.body.innerHTML = '
'; + window.SvarohJsFormValidator.addModel(profileModel(), false); + } + + test('removeModel forgets the registration and the nodes it was attached to', () => { + renderProfile(); + const form = document.getElementById('profile'); + const input = document.getElementById('profile_email'); + + expect(window.SvarohJsFormValidator.removeModel('profile')).toBe(1); + + expect(window.SvarohJsFormValidator.forms.profile).toBeUndefined(); + expect(window.SvarohJsFormValidator.getFormInstances('profile')).toEqual([]); + expect(form.jsFormValidator).toBeUndefined(); + expect(input.jsFormValidator).toBeUndefined(); + }); + + test('removeModel of an unknown id removes nothing', () => { + renderProfile(); + + expect(window.SvarohJsFormValidator.removeModel('ghost')).toBe(0); + expect(window.SvarohJsFormValidator.forms.profile).toBeDefined(); + }); + + test('a removed form no longer runs validation on submit', () => { + renderProfile(); + const form = document.getElementById('profile'); + window.SvarohJsFormValidator.removeModel('profile'); + + const customize = jest.spyOn(window.SvarohJsFormValidator, 'customize'); + form.dispatchEvent(new Event('submit', { cancelable: true })); + + expect(customize).not.toHaveBeenCalled(); + }); + + test('removeForm keeps the other render of the same form', () => { + document.body.innerHTML = + '
' + + '
'; + window.SvarohJsFormValidator.addModel(profileModel(), false); + window.SvarohJsFormValidator.addModel(profileModel(), false); + + const [first, second] = Array.from(document.querySelectorAll('form')); + expect(window.SvarohJsFormValidator.getFormInstances('profile')).toHaveLength(2); + + expect(window.SvarohJsFormValidator.removeForm(first)).toBe(true); + + const instances = window.SvarohJsFormValidator.getFormInstances('profile'); + expect(instances).toHaveLength(1); + expect(instances[0].domNode).toBe(second); + expect(window.SvarohJsFormValidator.forms.profile.domNode).toBe(second); + expect(first.jsFormValidator).toBeUndefined(); + expect(second.jsFormValidator).toBeDefined(); + }); + + test('removeForm answers false for a node it holds no registration for', () => { + renderProfile(); + + expect(window.SvarohJsFormValidator.removeForm(document.createElement('form'))).toBe(false); + expect(window.SvarohJsFormValidator.removeForm(null)).toBe(false); + expect(window.SvarohJsFormValidator.getFormInstances('profile')).toHaveLength(1); + }); + + test('removeDetachedForms drops the registrations of nodes that left the document', () => { + renderProfile(); + const gone = document.getElementById('profile'); + gone.remove(); + renderProfile(); + const kept = document.getElementById('profile'); + + expect(window.SvarohJsFormValidator.getFormInstances('profile')).toHaveLength(2); + expect(window.SvarohJsFormValidator.removeDetachedForms()).toBe(1); + + const instances = window.SvarohJsFormValidator.getFormInstances('profile'); + expect(instances).toHaveLength(1); + expect(instances[0].domNode).toBe(kept); + expect(window.SvarohJsFormValidator.forms.profile.domNode).toBe(kept); + }); + + test('initializing the same markup again does not validate the form twice', () => { + renderProfile(); + const form = document.getElementById('profile'); + window.SvarohJsFormValidator.addModel(profileModel(), false); + + const customize = jest.spyOn(window.SvarohJsFormValidator, 'customize'); + form.dispatchEvent(new Event('submit', { cancelable: true })); + + expect(customize).toHaveBeenCalledTimes(1); + }); +}); + describe('SvarohJsFormValidator property paths', () => { beforeEach(() => { document.body.innerHTML = ''; From e75a67a5f36aeedd65c99b972f8dd374f7c3354f Mon Sep 17 00:00:00 2001 From: 66Ton99 <66ton99@gmail.com> Date: Fri, 28 Aug 2026 00:28:21 +0300 Subject: [PATCH 2/2] fix(js): detach a registration from every node it was attached to An element is attached to two nodes, not one: "createElement" attaches it to the node the model id matched, and "initModel" moves the root element to the form it resolved afterwards. The default rendering makes that the common case - "form_start" writes "
" without an id and "form_widget" puts the id of the model on a container inside it - and "detachElement" only took the second node back. What that left behind: - the container kept the removed element, and the next initialization of the same markup read it back through "attachElement", which copies the keys of the attached element and repoints "domNode" mid-loop, so the documented "remove, then initialize again" flow threw a TypeError; - "removeForm(document.getElementById(id))", the call the documentation shows, answered with the container while the registry held the form, so it removed nothing; - "removeDetachedForms()" read the root node alone, so a model whose fields are rendered outside of any form tag - "initModel" allows that - counted as detached and was dropped while its widgets were still in the document; - the submit listener went with the first element removed from a form, so a second model rooted in the same form silently stopped being validated. "detachElement" now takes both nodes through "detachNode", which keeps the listener for as long as an element is attached to the node. "removeForm" answers to either node. "removeDetachedForms" asks "isElementInDocument", which looks at the whole element and its children. "attachElement" reads the attached element once instead of re-reading it through a "domNode" it has already overwritten. Also: "keepFormInstances" is "replaceFormInstances", which is what it does; the internal helpers say so in their docblocks; and an id nothing was removed from keeps the array the registry already handed out. Tests: ten more, on the markup Symfony actually renders. 625 pass, JavaScript line coverage 94.96%. PHP is untouched by this change. Co-Authored-By: Claude Opus 5 --- src/Resources/doc/3_24.md | 15 +- .../public/js/SvarohJsFormValidator.js | 113 ++++++++++-- .../public/js/SvarohJsFormValidator.test.js | 174 ++++++++++++++++++ 3 files changed, 283 insertions(+), 19 deletions(-) diff --git a/src/Resources/doc/3_24.md b/src/Resources/doc/3_24.md index 11d4862..d1f5578 100644 --- a/src/Resources/doc/3_24.md +++ b/src/Resources/doc/3_24.md @@ -18,7 +18,7 @@ SvarohJsFormValidator.removeModel('user'); // One rendered form, when the same model is on the page more than once SvarohJsFormValidator.removeForm(document.getElementById('user')); -// Every registration whose form is no longer in the document +// Every registration whose markup is no longer in the document SvarohJsFormValidator.removeDetachedForms(); ``` @@ -26,6 +26,16 @@ SvarohJsFormValidator.removeDetachedForms(); `removeForm()` with whether it removed one, and `removeDetachedForms()` with the number it removed. +`removeForm()` takes either node of a render: the `` tag, or the node the +id of the model is on. The default rendering puts that id on the container +`form_widget()` writes inside the form and leaves the `` tag with a +`name` only, so `document.getElementById('user')` answers with the container - +which is why both are accepted. + +`removeDetachedForms()` looks at the whole render, not only at the form tag: a +fragment of fields rendered outside of any `` is kept for as long as its +widgets are in the document. + A typical swap removes what the old markup registered, replaces the markup, and initializes the new render: @@ -41,4 +51,5 @@ ids it just dropped: replace the markup first, then call it. Initializing the same markup again without removing it first is safe: the second initialization replaces the registration of that node instead of adding one next to it, and the form is still validated once per submit. It does leave -the previous element attached to nothing, so prefer removing it. +the previous element attached to nothing, so prefer removing it - removing and +initializing the same markup again registers it from scratch. diff --git a/src/Resources/public/js/SvarohJsFormValidator.js b/src/Resources/public/js/SvarohJsFormValidator.js index be7876e..7eb82e3 100644 --- a/src/Resources/public/js/SvarohJsFormValidator.js +++ b/src/Resources/public/js/SvarohJsFormValidator.js @@ -51,6 +51,9 @@ export function SvarohJsFormElement() { this.children = {}; this.parent = null; this.domNode = null; + // The node the model id matched, when the element was moved to the form + // afterwards, see SvarohJsFormValidator.initModel() + this.widgetDomNode = null; this.callbacks = {}; this.errors = {}; @@ -683,6 +686,16 @@ var SvarohJsFormValidator = new function () { * dropped from the document keeps no reference back to the model and no * listener of this library * + * An element can be attached to two nodes: "createElement" attaches it to + * the node the model id matched, which in the default rendering is the + * widget container inside the form, and "initModel" attaches the root + * element to the form itself afterwards. Both are taken back, otherwise + * the node that is left attached hands its stale element to the next + * initialization of the same markup + * + * Internal, the entry points are "removeModel", "removeForm" and + * "removeDetachedForms" + * * @param {SvarohJsFormElement} element */ this.detachElement = function (element) { @@ -694,7 +707,17 @@ var SvarohJsFormValidator = new function () { this.detachElement(element.children[name]); } - var domNode = element.domNode; + this.detachNode(element.widgetDomNode, element); + this.detachNode(element.domNode, element); + }; + + /** + * Internal, see "detachElement" + * + * @param {HTMLElement} domNode + * @param {SvarohJsFormElement} element + */ + this.detachNode = function (domNode, element) { if (!domNode) { return; } @@ -703,21 +726,56 @@ var SvarohJsFormValidator = new function () { delete domNode.jsFormValidator; } - if (domNode.__svarohJsFormValidatorSubmitListener) { + // Another model can be rooted in the same form, and the listener reads + // the element off the node when the form is submitted, so the listener + // only goes with the last element the node was attached to + if (domNode.__svarohJsFormValidatorSubmitListener && undefined === domNode.jsFormValidator) { domNode.removeEventListener('submit', domNode.__svarohJsFormValidatorSubmitListener); delete domNode.__svarohJsFormValidatorSubmitListener; } }; /** - * Keeps the given registrations of a model id and drops the id entirely - * when none are left, so "forms" never answers with an element the - * registry no longer holds + * Whether the element or any of its children still has a node in the + * document. A model whose form tag was not found has no node of its own - + * "initModel" allows that - and is still rendered through its children, + * so the root node alone does not answer the question + * + * Internal, see "removeDetachedForms" + * + * @param {SvarohJsFormElement} element + * + * @return {Boolean} + */ + this.isElementInDocument = function (element) { + if (element.domNode && document.contains(element.domNode)) { + return true; + } + + if (element.widgetDomNode && document.contains(element.widgetDomNode)) { + return true; + } + + for (var name in element.children) { + if (this.isElementInDocument(element.children[name])) { + return true; + } + } + + return false; + }; + + /** + * Replaces the registrations of a model id with the given ones and drops + * the id entirely when none are left, so "forms" never answers with an + * element the registry no longer holds + * + * Internal, see "removeModel", "removeForm" and "removeDetachedForms" * * @param {String} id * @param {Array} instances */ - this.keepFormInstances = function (id, instances) { + this.replaceFormInstances = function (id, instances) { if (!instances.length) { delete this.formInstances[id]; delete this.forms[id]; @@ -748,14 +806,18 @@ var SvarohJsFormValidator = new function () { this.detachElement(instances[i]); } - this.keepFormInstances(id, []); + this.replaceFormInstances(id, []); return instances.length; }; /** - * Removes the registration attached to one form node, which is what an - * application replacing a single rendered form needs + * Removes the registration attached to one rendered form, which is what an + * application replacing a single render needs. The node is either the form + * tag or the node the model id matched - the default rendering puts the id + * of the model on a container inside the form, so + * "document.getElementById(id)" answers with that container and not with + * the form * * @param {HTMLElement} domNode * @@ -771,7 +833,7 @@ var SvarohJsFormValidator = new function () { var kept = []; var instances = this.formInstances[id]; for (var i = 0; i < instances.length; i++) { - if (instances[i].domNode === domNode) { + if (instances[i].domNode === domNode || instances[i].widgetDomNode === domNode) { this.detachElement(instances[i]); removed = true; } else { @@ -779,7 +841,12 @@ var SvarohJsFormValidator = new function () { } } - this.keepFormInstances(id, kept); + // An id nothing was removed from keeps the list it already has, + // otherwise a caller holding the answer of "getFormInstances" + // would be left with an array the registry no longer uses + if (kept.length !== instances.length) { + this.replaceFormInstances(id, kept); + } } return removed; @@ -799,8 +866,7 @@ var SvarohJsFormValidator = new function () { var kept = []; var instances = this.formInstances[id]; for (var i = 0; i < instances.length; i++) { - var domNode = instances[i].domNode; - if (domNode && document.contains(domNode)) { + if (this.isElementInDocument(instances[i])) { kept.push(instances[i]); } else { this.detachElement(instances[i]); @@ -808,7 +874,9 @@ var SvarohJsFormValidator = new function () { } } - this.keepFormInstances(id, kept); + if (kept.length !== instances.length) { + this.replaceFormInstances(id, kept); + } } return removed; @@ -839,8 +907,18 @@ var SvarohJsFormValidator = new function () { } var form = this.findFormElement(element); + // "createElement" attached the element to the node its model id + // matched, which in the default rendering is the widget container + // inside the form and not the form itself. Both nodes point back at + // the element, so both have to be taken back in "detachElement". The + // container is written after "attachElement", which answers with what + // the form node carried before and would otherwise hand this element + // the container of another model rooted in the same form + var widgetDomNode = element.domNode && element.domNode !== form ? element.domNode : null; + element.domNode = form; this.attachElement(element); + element.widgetDomNode = widgetDomNode; if (form) { this.disableNativeValidationUi(form); this.attachDefaultEvent(element, form); @@ -1514,9 +1592,10 @@ var SvarohJsFormValidator = new function () { return; } - if (undefined !== element.domNode.jsFormValidator) { - for (var key in element.domNode.jsFormValidator) { - element[key] = element.domNode.jsFormValidator[key]; + var attached = element.domNode.jsFormValidator; + if (undefined !== attached) { + for (var key in attached) { + element[key] = attached[key]; } } diff --git a/src/Resources/public/js/SvarohJsFormValidator.test.js b/src/Resources/public/js/SvarohJsFormValidator.test.js index 7ffab0a..559c1d0 100644 --- a/src/Resources/public/js/SvarohJsFormValidator.test.js +++ b/src/Resources/public/js/SvarohJsFormValidator.test.js @@ -1399,6 +1399,180 @@ describe('SvarohJsFormValidator model teardown', () => { expect(customize).toHaveBeenCalledTimes(1); }); + + test('removeForm repoints "forms" when the last render is removed', () => { + document.body.innerHTML = + '' + + '
'; + window.SvarohJsFormValidator.addModel(profileModel(), false); + window.SvarohJsFormValidator.addModel(profileModel(), false); + + const [first, second] = Array.from(document.querySelectorAll('form')); + expect(window.SvarohJsFormValidator.forms.profile.domNode).toBe(second); + + expect(window.SvarohJsFormValidator.removeForm(second)).toBe(true); + + expect(window.SvarohJsFormValidator.getFormInstances('profile')).toHaveLength(1); + expect(window.SvarohJsFormValidator.forms.profile.domNode).toBe(first); + }); + + test('detaching nothing leaves the registry alone', () => { + renderProfile(); + + expect(() => window.SvarohJsFormValidator.detachElement(null)).not.toThrow(); + expect(window.SvarohJsFormValidator.getFormInstances('profile')).toHaveLength(1); + }); +}); + +// Symfony renders a form as "
", which carries no id, and puts +// the id of the model on the container that "form_widget" writes inside it. The +// element of a root model is then attached to two nodes: the container +// "createElement" matched and the form "initModel" resolved afterwards +describe('SvarohJsFormValidator model teardown of the default rendering', () => { + afterEach(() => { + document.body.innerHTML = ''; + window.SvarohJsFormValidator.forms = {}; + window.SvarohJsFormValidator.formInstances = {}; + window.SvarohJsFormValidator.constraintsCounter = 0; + jest.restoreAllMocks(); + }); + + function buildModel(id, name, children) { + return { + id: id, + name: name, + type: '', + invalidMessage: '', + bubbling: false, + disabled: false, + transformers: [], + data: {}, + children: children === undefined ? [] : children, + }; + } + + function profileModel() { + return buildModel('profile', 'profile', { + email: buildModel('profile_email', 'profile[email]'), + }); + } + + function renderProfile() { + document.body.innerHTML = + '' + + '
' + + '
'; + window.SvarohJsFormValidator.addModel(profileModel(), false); + } + + test('the element is attached to the form and to the container of the id', () => { + renderProfile(); + + const element = window.SvarohJsFormValidator.getFormInstances('profile')[0]; + expect(element.domNode).toBe(document.querySelector('form')); + expect(element.widgetDomNode).toBe(document.getElementById('profile')); + }); + + test('removeModel detaches the form and the container alike', () => { + renderProfile(); + const form = document.querySelector('form'); + const container = document.getElementById('profile'); + const input = document.getElementById('profile_email'); + + expect(window.SvarohJsFormValidator.removeModel('profile')).toBe(1); + + expect(form.jsFormValidator).toBeUndefined(); + expect(container.jsFormValidator).toBeUndefined(); + expect(input.jsFormValidator).toBeUndefined(); + }); + + // The container used to keep the removed element, which the next + // initialization of the same markup read back and choked on + test('the same markup is initialized again after removeModel', () => { + renderProfile(); + window.SvarohJsFormValidator.removeModel('profile'); + + window.SvarohJsFormValidator.addModel(profileModel(), false); + + const instances = window.SvarohJsFormValidator.getFormInstances('profile'); + expect(instances).toHaveLength(1); + expect(instances[0].domNode).toBe(document.querySelector('form')); + expect(instances[0].children.email.domNode).toBe(document.getElementById('profile_email')); + }); + + test('removeForm answers to the node the model id is on', () => { + renderProfile(); + + expect(window.SvarohJsFormValidator.removeForm(document.getElementById('profile'))).toBe(true); + expect(window.SvarohJsFormValidator.getFormInstances('profile')).toEqual([]); + }); + + // A fragment of fields rendered outside of any form tag has no form node at + // all, and its widgets are what tells whether it is still rendered + test('removeDetachedForms keeps a model whose fields are not inside a form', () => { + document.body.innerHTML = '
'; + window.SvarohJsFormValidator.addModel(profileModel(), false); + const input = document.getElementById('profile_email'); + expect(window.SvarohJsFormValidator.getFormInstances('profile')[0].domNode).toBeNull(); + + expect(window.SvarohJsFormValidator.removeDetachedForms()).toBe(0); + + expect(window.SvarohJsFormValidator.getFormInstances('profile')).toHaveLength(1); + expect(input.jsFormValidator).toBeDefined(); + }); + + // A form rendered row by row has no container of its own either, and then + // only its children answer whether it is still rendered + test('removeDetachedForms keeps a model rendered as separate rows', () => { + document.body.innerHTML = '
'; + window.SvarohJsFormValidator.addModel(profileModel(), false); + const input = document.getElementById('profile_email'); + const element = window.SvarohJsFormValidator.getFormInstances('profile')[0]; + expect(element.domNode).toBeNull(); + expect(element.widgetDomNode).toBeNull(); + + expect(window.SvarohJsFormValidator.removeDetachedForms()).toBe(0); + + expect(window.SvarohJsFormValidator.getFormInstances('profile')).toHaveLength(1); + expect(input.jsFormValidator).toBeDefined(); + }); + + test('removeDetachedForms drops that model once its markup left the document', () => { + document.body.innerHTML = + '
'; + window.SvarohJsFormValidator.addModel(profileModel(), false); + document.getElementById('panel').innerHTML = ''; + + expect(window.SvarohJsFormValidator.removeDetachedForms()).toBe(1); + expect(window.SvarohJsFormValidator.getFormInstances('profile')).toEqual([]); + }); + + // The submit listener reads the element off the form when the form is + // submitted, so it belongs to the node and not to one of its models + test('removing one of two models rooted in the same form keeps the other validated', () => { + document.body.innerHTML = + '
' + + '
' + + '
' + + '
'; + window.SvarohJsFormValidator.addModel(profileModel(), false); + window.SvarohJsFormValidator.addModel(buildModel('address', 'address', { + city: buildModel('address_city', 'address[city]'), + }), false); + + // Each model keeps the container of its own render, although both + // elements were attached to the same form node + const address = window.SvarohJsFormValidator.getFormInstances('address')[0]; + expect(address.widgetDomNode).toBe(document.getElementById('address')); + + expect(window.SvarohJsFormValidator.removeModel('profile')).toBe(1); + + const customize = jest.spyOn(window.SvarohJsFormValidator, 'customize'); + document.querySelector('form').dispatchEvent(new Event('submit', { cancelable: true })); + + expect(window.SvarohJsFormValidator.getFormInstances('address')).toHaveLength(1); + expect(customize).toHaveBeenCalledTimes(1); + }); }); describe('SvarohJsFormValidator property paths', () => {