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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
55 changes: 55 additions & 0 deletions src/Resources/doc/3_24.md
Original file line number Diff line number Diff line change
@@ -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 `<form>` 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 `<form>` 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 `<form>` 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.
237 changes: 232 additions & 5 deletions src/Resources/public/js/SvarohJsFormValidator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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];
}
}

Expand All @@ -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);
};

/**
Expand Down
Loading