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..d1f5578 --- /dev/null +++ b/src/Resources/doc/3_24.md @@ -0,0 +1,55 @@ +### 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 markup 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. + +`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: + +```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 - 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 8e4adc0..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 = {}; @@ -677,6 +680,208 @@ 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 + * + * 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) { + if (!element) { + return; + } + + for (var name in element.children) { + this.detachElement(element.children[name]); + } + + 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; + } + + if (domNode.jsFormValidator === element) { + delete domNode.jsFormValidator; + } + + // 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; + } + }; + + /** + * 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.replaceFormInstances = 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.replaceFormInstances(id, []); + + return instances.length; + }; + + /** + * 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 + * + * @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 || instances[i].widgetDomNode === domNode) { + this.detachElement(instances[i]); + removed = true; + } else { + kept.push(instances[i]); + } + } + + // 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; + }; + + /** + * 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++) { + if (this.isElementInDocument(instances[i])) { + kept.push(instances[i]); + } else { + this.detachElement(instances[i]); + removed++; + } + } + + if (kept.length !== instances.length) { + this.replaceFormInstances(id, kept); + } + } + + return removed; + }; + this.onDocumentReady = function (callback) { var addListener = document.addEventListener || document.attachEvent; var removeListener = document.removeEventListener || document.detachEvent; @@ -702,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); @@ -1377,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]; } } @@ -1391,9 +1607,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..559c1d0 100644 --- a/src/Resources/public/js/SvarohJsFormValidator.test.js +++ b/src/Resources/public/js/SvarohJsFormValidator.test.js @@ -1278,6 +1278,303 @@ 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); + }); + + 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', () => { beforeEach(() => { document.body.innerHTML = '';