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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Last verified in the Nix shell on PHP 8.5.6 / Node 24.16.0:
- `composer test`: 95 tests, 268 assertions.
- `composer phpstan`: no errors.
- `composer coverage`: PHP line coverage ~97%, threshold `80%`.
- `npm run test:unit`: Jest 608 tests.
- `npm run test:unit`: Jest 615 tests.
- `npm run test:coverage`: JavaScript line coverage ~95%, threshold `80%`.
- `npm test`: the above plus the Cypress e2e suite, 24 tests.

Expand Down
37 changes: 35 additions & 2 deletions src/Resources/doc/2_3.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,44 @@ init_js_validation(form = null, onLoad = true, wrapped = true)
- `form`: By default, all queued forms are processed. Pass a form name or
`FormView` instance to process only one form.
- `onLoad`: When `true`, validation models are initialized on document ready.
Pass `false` when you want to initialize them yourself.
Pass `false` when you want to initialize them yourself. A model rendered into
a document that already finished loading - a form fragment an application
fetched and injected after the initial load - is initialized right away,
because the document ready event it would otherwise wait for has already been
dispatched.
- `wrapped`: When `true`, the generated code is wrapped in a
`<script type="text/javascript">...</script>` tag.

For example, to initialize validation after an Ajax request:
#### Forms loaded after the page

A form fragment the application fetches and injects - a modal that loads its
form, a step of a wizard, a single page CRUD - carries the `addModel()` call
the bundle rendered with it, and that call initializes the form as soon as it
runs. The default `onLoad` is enough, there is nothing to pass:

```twig
{{ form(user) }}
{{ init_js_validation(user) }}
```

The one thing the application has to take care of is that the call runs at
all. `innerHTML` does not execute a `<script>` tag it inserts, so a fragment
dropped in that way is rendered without ever being initialized. Insert it with
something that runs the scripts it contains - jQuery's `.html()`, Turbo, or
recreating the `<script>` nodes by hand - and make sure the form is in the
document by then, because the model looks its nodes up there.

A render that is taken out of the document again is forgotten the next time the
same form is initialized, so reopening a modal does not pile up instances in
`SvarohJsFormValidator.getFormInstances()`.

The library has to be loaded and `js_validator_config()` rendered before any of
this, which the layout does once.

#### Initializing the models yourself

To initialize the queued models at a moment of your own choosing, pass
`onLoad = false`:

```twig
{{ form(user) }}
Expand Down
5 changes: 5 additions & 0 deletions src/Resources/doc/3_20.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ rows[2].children.name.onValidate = function (errors) {

`SvarohJsFormValidator.forms['profile']` keeps the last initialized render.

A render the application takes out of the document again - a modal that is
closed, a row a listing replaces - is dropped from the list the next time the
same form is initialized, so a page that reloads its forms does not accumulate
instances of nodes that are gone.

Forms of the same name repeat their ids, which is not valid HTML and hides all
but the first render from `document.getElementById()` in your own scripts. If
you can name the forms yourself, give each row a name of its own:
Expand Down
63 changes: 56 additions & 7 deletions src/Resources/public/js/SvarohJsFormValidator.js
Original file line number Diff line number Diff line change
Expand Up @@ -651,10 +651,19 @@ var SvarohJsFormValidator = new function () {
}

var instances = this.formInstances[model.id];
for (var i = 0; i < instances.length; i++) {
// A single page application that swaps a rendered form for a new one
// leaves the element of the node it removed behind, and every reopened
// modal or revisited wizard step would add one more
for (var i = instances.length - 1; i >= 0; i--) {
if (this.isDetachedNode(instances[i].domNode)) {
instances.splice(i, 1);
}
}

for (var j = 0; j < instances.length; j++) {
// A repeated initialization of the same markup replaces its element
if (instances[i].domNode && instances[i].domNode === element.domNode) {
instances[i] = element;
if (instances[j].domNode && instances[j].domNode === element.domNode) {
instances[j] = element;

return element;
}
Expand All @@ -665,6 +674,22 @@ var SvarohJsFormValidator = new function () {
return element;
};

/**
* Whether the node was taken out of the document again, which only a
* browser that can answer it at all reports
*
* @param {HTMLElement|null} domNode
*
* @return {boolean}
*/
this.isDetachedNode = function (domNode) {
if (!domNode || typeof document.contains !== 'function') {
return false;
}

return !document.contains(domNode);
};

/**
* All the elements initialized for the given model id, in the order they
* were added
Expand All @@ -678,14 +703,38 @@ var SvarohJsFormValidator = new function () {
};

this.onDocumentReady = function (callback) {
// A model added to a document that is already loaded - a form fragment
// that a single page application fetched and injected - would wait
// here for an event that has already been dispatched. Only "complete"
// stands for that: while the state is "interactive" the document is
// parsed but its deferred scripts, and the js_validator_config() one
// of them may carry, still run before the event
if ('complete' === document.readyState) {
callback();

return;
}

var isFallback = !document.addEventListener;
var addListener = document.addEventListener || document.attachEvent;
var removeListener = document.removeEventListener || document.detachEvent;
var eventName = document.addEventListener ? "DOMContentLoaded" : "onreadystatechange";
var eventName = isFallback ? "onreadystatechange" : "DOMContentLoaded";

// The handler has to name itself to be removed again: the first
// argument a listener receives is the event, and "attachEvent" passes
// no argument at all, so neither identifies the function to detach
var handler = function () {
// The fallback listens to every state transition, only the last
// one stands for a document that finished loading
if (isFallback && 'complete' !== document.readyState) {
return;
}

addListener.call(document, eventName, function (callee) {
removeListener.call(this, eventName, callee, false);
removeListener.call(document, eventName, handler, false);
callback();
}, false)
};

addListener.call(document, eventName, handler, false)
};

/**
Expand Down
149 changes: 143 additions & 6 deletions src/Resources/public/js/SvarohJsFormValidator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1223,6 +1223,7 @@ describe('SvarohJsFormValidator runtime helpers', () => {
describe('SvarohJsFormValidator model registration', () => {
afterEach(() => {
document.body.innerHTML = '';
window.SvarohJsFormValidator.config = {};
window.SvarohJsFormValidator.forms = {};
window.SvarohJsFormValidator.formInstances = {};
window.SvarohJsFormValidator.constraintsCounter = 0;
Expand All @@ -1242,6 +1243,21 @@ describe('SvarohJsFormValidator model registration', () => {
};
}

// jsdom reports "complete" for the whole run, a test of the deferred
// branch has to say the document is still loading itself
function withReadyState(state, run) {
Object.defineProperty(document, 'readyState', {
configurable: true,
get: () => state,
});

try {
run();
} finally {
delete document.readyState;
}
}

test('skips a model whose form is not rendered on the current page', () => {
document.body.innerHTML = '<form id="profile"><input id="profile_email" name="profile[email]"></form>';
const missing = buildModel('ghost', 'ghost');
Expand All @@ -1264,18 +1280,139 @@ describe('SvarohJsFormValidator model registration', () => {
});

test('skips a model without a DOM node on the deferred branch too', () => {
document.body.innerHTML = '<form id="profile"><input id="profile_email" name="profile[email]"></form>';
const rendered = buildModel('profile', 'profile', {
email: buildModel('profile_email', 'profile[email]'),
withReadyState('loading', () => {
document.body.innerHTML = '<form id="profile"><input id="profile_email" name="profile[email]"></form>';
const rendered = buildModel('profile', 'profile', {
email: buildModel('profile_email', 'profile[email]'),
});

window.SvarohJsFormValidator.addModel(buildModel('ghost', 'ghost'));
window.SvarohJsFormValidator.addModel(rendered);

expect(window.SvarohJsFormValidator.forms.profile).toBeUndefined();

expect(() => document.dispatchEvent(new Event('DOMContentLoaded'))).not.toThrow();
expect(Object.keys(window.SvarohJsFormValidator.forms)).toEqual(['profile']);
expect(window.SvarohJsFormValidator.forms.profile.domNode).toBe(document.getElementById('profile'));
});
});

window.SvarohJsFormValidator.addModel(buildModel('ghost', 'ghost'));
window.SvarohJsFormValidator.addModel(rendered);
// A single page application injects a form long after "DOMContentLoaded",
// and a listener for that event would never be called again
test('registers a model of a form injected after the document is ready', () => {
document.body.innerHTML = '<form id="profile"><input id="profile_email" name="profile[email]"></form>';

window.SvarohJsFormValidator.addModel(buildModel('profile', 'profile', {
email: buildModel('profile_email', 'profile[email]'),
}));

expect(() => document.dispatchEvent(new Event('DOMContentLoaded'))).not.toThrow();
expect(Object.keys(window.SvarohJsFormValidator.forms)).toEqual(['profile']);
expect(window.SvarohJsFormValidator.forms.profile.domNode).toBe(document.getElementById('profile'));
});

test('still waits for the document while it is being parsed', () => {
withReadyState('loading', () => {
document.body.innerHTML = '<form id="profile"><input id="profile_email" name="profile[email]"></form>';
window.SvarohJsFormValidator.addModel(buildModel('profile', 'profile', {
email: buildModel('profile_email', 'profile[email]'),
}));

expect(window.SvarohJsFormValidator.forms.profile).toBeUndefined();

document.dispatchEvent(new Event('DOMContentLoaded'));

expect(window.SvarohJsFormValidator.forms.profile.domNode).toBe(document.getElementById('profile'));
});
});

// A deferred script runs after the parse but before "DOMContentLoaded",
// and js_validator_config() may be one of the scripts still to come
test('still waits for the document while its deferred scripts run', () => {
withReadyState('interactive', () => {
document.body.innerHTML = '<form id="profile"><input id="profile_email" name="profile[email]"></form>';
window.SvarohJsFormValidator.addModel(buildModel('profile', 'profile', {
email: buildModel('profile_email', 'profile[email]'),
}));

expect(window.SvarohJsFormValidator.forms.profile).toBeUndefined();

document.dispatchEvent(new Event('DOMContentLoaded'));

expect(window.SvarohJsFormValidator.forms.profile.domNode).toBe(document.getElementById('profile'));
});
});

// The configuration a template prints below the model decides whether the
// native UI is turned off, so the call waiting for it must not run early
test('turns the native UI off with a configuration a deferred script sets', () => {
withReadyState('interactive', () => {
document.body.innerHTML = '<form id="profile"><input id="profile_email" name="profile[email]" required></form>';
window.SvarohJsFormValidator.config = {};

window.SvarohJsFormValidator.addModel(buildModel('profile', 'profile', {
email: buildModel('profile_email', 'profile[email]'),
}), false);

// js_validator_config() of a template that prints it further down
window.SvarohJsFormValidator.config = { html5Validation: true };
document.dispatchEvent(new Event('DOMContentLoaded'));

expect(document.getElementById('profile').getAttribute('novalidate')).toBe('novalidate');
});
});

test('leaves no listener behind once the document is ready', () => {
withReadyState('loading', () => {
const callback = jest.fn();
window.SvarohJsFormValidator.onDocumentReady(callback);

document.dispatchEvent(new Event('DOMContentLoaded'));
document.dispatchEvent(new Event('DOMContentLoaded'));

expect(callback).toHaveBeenCalledTimes(1);
});
});

// A modal that fetches its form is opened again, a wizard step is
// revisited: the node of the previous render is gone from the document
test('forgets the instance of a render that was taken out of the document', () => {
const container = document.createElement('div');
document.body.appendChild(container);

const inject = () => {
container.innerHTML = '<form id="profile"><input id="profile_email" name="profile[email]"></form>';
window.SvarohJsFormValidator.addModel(buildModel('profile', 'profile', {
email: buildModel('profile_email', 'profile[email]'),
}));
};

inject();
inject();
inject();

const instances = window.SvarohJsFormValidator.getFormInstances('profile');
expect(instances).toHaveLength(1);
expect(instances[0].domNode).toBe(document.getElementById('profile'));
expect(window.SvarohJsFormValidator.forms.profile).toBe(instances[0]);
});

test('keeps every render that is still in the document', () => {
document.body.innerHTML = '<form id="profile"><input id="profile_email" name="profile[email]"></form>'
+ '<form id="profile"><input id="profile_email" name="profile[email]"></form>';

window.SvarohJsFormValidator.addModel(buildModel('profile', 'profile', {
email: buildModel('profile_email', 'profile[email]'),
}));
window.SvarohJsFormValidator.addModel(buildModel('profile', 'profile', {
email: buildModel('profile_email', 'profile[email]'),
}));

const instances = window.SvarohJsFormValidator.getFormInstances('profile');
const rendered = document.querySelectorAll('[id="profile"]');
expect(instances).toHaveLength(2);
expect(instances[0].domNode).toBe(rendered[0]);
expect(instances[1].domNode).toBe(rendered[1]);
});
});

describe('SvarohJsFormValidator property paths', () => {
Expand Down