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 `
'; + 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 "'; + 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 = + '