Add initial implementation of w-rules/RulesController

- Allows a controlled form to have targets that are conditionally enabled based on a rule to match against the form data
- Partial progress for #11045
pull/12641/head
LB Johnston 2023-10-23 08:35:19 +10:00 zatwierdzone przez LB (Ben Johnston)
rodzic 918ceebc17
commit 4438f13d5c
4 zmienionych plików z 823 dodań i 0 usunięć

Wyświetl plik

@ -0,0 +1,82 @@
import React from 'react';
import { StimulusWrapper } from '../../storybook/StimulusWrapper';
import { RulesController } from './RulesController';
export default {
title: 'Stimulus / RulesController',
argTypes: {
debug: { control: 'boolean', defaultValue: false },
},
};
const definitions = [
{ identifier: 'w-rules', controllerConstructor: RulesController },
];
const EnableTemplate = ({ debug = false }) => (
<StimulusWrapper debug={debug} definitions={definitions}>
<form
method="get"
data-controller="w-rules"
// avoid accidental submissions with preventing submit
data-action="change->w-rules#resolve submit->w-rules#resolve:prevent"
>
<div className="w-field__wrapper">
<label className="w-field__label" htmlFor="link-field">
Enter a link:
<div className="w-field">
<div className="w-field__input">
<input type="url" name="link" id="link-field" />
</div>
</div>
</label>
</div>
<div className="w-field__wrapper">
<label className="w-field__label" htmlFor="email-field">
Or enter an Email address:
<div className="w-field">
<div className="w-field__input">
<input type="email" name="email" id="email-field" />
</div>
</div>
</label>
</div>
<hr />
<div>
<label className="w-field__label" htmlFor="drink">
Enter an email subject:
<div className="w-field">
<div className="w-field__input">
<input
type="text"
name="subject"
className="w-field__wrapper"
data-w-rules='{"link":""}'
data-w-rules-target="enable"
/>
</div>
</div>
</label>
</div>
<div className="w-field__wrapper">
<label className="w-field__label" htmlFor="subject-field">
Enter a link label:
<div className="w-field">
<div className="w-field__input">
<input
type="email"
name="subject"
id="subject-field"
data-w-rules='{"email":""}'
data-w-rules-target="enable"
/>
</div>
</div>
</label>
</div>
</form>
</StimulusWrapper>
);
export const Enable = EnableTemplate.bind({});

Wyświetl plik

@ -0,0 +1,587 @@
import { Application } from '@hotwired/stimulus';
import { RulesController } from './RulesController';
import { escapeHtml } from '../utils/text';
jest.useFakeTimers();
describe('RulesController', () => {
const _ = (value) => escapeHtml(JSON.stringify(value));
let application;
let errors = [];
const setup = async (html) => {
document.body.innerHTML = `<main>${html}</main>`;
application = Application.start();
application.register('w-rules', RulesController);
application.handleError = (error, message) => {
errors.push({ error, message });
};
await jest.runAllTimersAsync();
};
afterEach(() => {
application?.stop();
jest.clearAllMocks();
errors = [];
});
describe('the ability to parse different data-w-rules attributes', () => {
it('should throw an error if the rule is malformed', async () => {
expect(errors.length).toBe(0);
await setup(`
<form data-controller="w-rules" data-action="change->w-rules#resolve">
<input type="text" name="title" value="bad" />
<input type="text" name="subtitle" data-w-rules-target="enable" data-w-rules="{title:''}" />
<div role="alert" data-w-rules-target="enable" data-w-rules="title=bad">
Careful with this value.
</div>
</form>
`);
expect(
Array.from(document.querySelectorAll('[data-w-rules-target]')).every(
(target) => !target.disabled,
),
).toBe(true);
expect(errors.length).toBeGreaterThan(1);
expect(errors).toHaveProperty(
'0.error.message',
expect.stringContaining("Expected property name or '}' in JSON"),
);
});
it('should gracefully handle different empty structures', async () => {
await setup(`
<form data-controller="w-rules" data-action="change->w-rules#resolve">
<input name="a" type="text" data-w-rules-target="enable" data-w-rules="''" />
<input name="a" type="text" data-w-rules-target="enable" data-w-rules="${_({})}" />
<input name="b" type="text" data-w-rules-target="enable" data-w-rules="${_({ '': '' })}" />
<input name="c" type="text" data-w-rules-target="enable" data-w-rules="${_([])}" />
<input name="d" type="text" data-w-rules-target="enable" data-w-rules="${_([[]])}" />
</form>`);
document
.querySelector('form')
.dispatchEvent(new Event('change', { bubbles: true }));
const handleEffect = jest.fn();
document.addEventListener('w-rules:effect', handleEffect);
expect(handleEffect).toHaveBeenCalledTimes(0);
await jest.runAllTimersAsync();
// check that no errors were thrown & no changes made due to empty-ish rules
expect(
[...document.querySelectorAll('input')].every(
(input) => input.disabled,
),
).toBe(false);
expect(handleEffect).toHaveBeenCalledTimes(0);
});
it('should support an entries style array of key/value pairs to be used as an object', async () => {
await setup(`
<form data-controller="w-rules" data-action="change->w-rules#resolve">
<input type="text" name="title" value="bad" />
<input type="text" name="subtitle" value="bad" />
<input type="checkbox" name="agreement" id="agreement" checked />
<textarea
id="signature"
data-w-rules-target="enable"
data-w-rules="${_([
['title', 'bad'],
['subtitle', ['bad']],
['agreement', []],
])}"
>
</textarea>
</form>
`);
expect(document.getElementById('signature').disabled).toBe(true);
document.getElementById('agreement').checked = false;
document
.getElementById('agreement')
.dispatchEvent(new CustomEvent('change', { bubbles: true }));
await jest.runAllTimersAsync();
expect(document.getElementById('signature').disabled).toBe(false);
});
it('should support checkboxes with an empty value', async () => {
await setup(`
<form data-controller="w-rules" data-action="change->w-rules#resolve">
<input type="checkbox" name="agreement" value="" id="agreement" />
<input
id="enable-if-unchecked"
data-w-rules-target="enable"
data-w-rules="${_({ agreement: [] })}"
/>
<input
id="enable-if-checked"
data-w-rules-target="enable"
data-w-rules="${_({ agreement: [''] })}"
/>
</form>
`);
expect(document.getElementById('enable-if-unchecked').disabled).toBe(
false,
);
expect(document.getElementById('enable-if-checked').disabled).toBe(true);
// check the agreement checkbox
document.getElementById('agreement').checked = true;
document
.getElementById('agreement')
.dispatchEvent(new CustomEvent('change', { bubbles: true }));
await jest.runAllTimersAsync();
expect(document.getElementById('enable-if-unchecked').disabled).toBe(
true,
);
expect(document.getElementById('enable-if-checked').disabled).toBe(false);
});
it('should treat false/null as their string equivalents', async () => {
// Note: We may revisit this in the future, for now we want to confirm all values are resolved to strings for consistency.
await setup(`
<form data-controller="w-rules" data-action="change->w-rules#resolve">
<input id="confirm" type="checkbox" name="confirm" value="false" checked />
<input id="signUp" type="text" name="signUp" value="null" />
<input
class="test"
type="text"
name="a"
data-w-rules-target="enable"
data-w-rules='${_({ confirm: false })}' />
<input
class="test"
type="text"
name="b"
data-w-rules-target="enable"
data-w-rules="${_({ signUp: null })}" />
</form>
`);
// set to disabled when initializing based on rules
expect(
Array.from(document.querySelectorAll('.test')).map(
(target) => target.disabled,
),
).toEqual([false, false]);
document.getElementById('confirm').checked = false;
document
.getElementById('confirm')
.dispatchEvent(new CustomEvent('change', { bubbles: true }));
document.getElementById('signUp').value = '';
document
.getElementById('confirm')
.dispatchEvent(new CustomEvent('change', { bubbles: true }));
await jest.runAllTimersAsync();
expect(
Array.from(document.querySelectorAll('.test')).map(
(target) => target.disabled,
),
).toEqual([true, true]);
});
it('should support multiple values for the same field name correctly', async () => {
await setup(`
<form data-controller="w-rules" data-action="change->w-rules#resolve">
<fieldset>
<legend>Choose your favorite starship</legend>
<input type="checkbox" id="NCC-1701" name="starship" value="NCC-1701" />
<label for=""NCC-1701">Enterprise</label><br />
<input type="checkbox" id="NCC-1701-D" name="starship" value="NCC-1701-D" />
<label for="NCC-1701-D">Enterprise D</label><br />
<input type="checkbox" id="NCC-1701-E" name="starship" value="NCC-1701-E" />
<label for="NCC-1701-E">Enterprise E</label>
<input type="checkbox" id="NCC-71201" name="starship" value="NCC-71201" />
<label for="NCC-71201">Prometheus</label>
</fieldset>
<input
id="continue"
type="button"
name="continue"
data-w-rules-target="enable"
data-w-rules="${_({ starship: ['NCC-1701-D', 'NCC-1701-E'] })}" />
</form>
`);
// set to disabled when initializing based on rules
expect(document.getElementById('continue').disabled).toBe(true);
// 1 - Check rule against a value that is not in the enable rules
document.getElementById('NCC-1701').checked = true;
document
.getElementById('NCC-1701')
.dispatchEvent(new CustomEvent('change', { bubbles: true }));
document.getElementById('NCC-71201').checked = true;
document
.getElementById('NCC-71201')
.dispatchEvent(new CustomEvent('change', { bubbles: true }));
await jest.runAllTimersAsync();
expect(document.getElementById('continue').disabled).toBe(true);
// 2 - Check rule against values that are in the the enable rules
document.getElementById('NCC-1701').checked = false;
document
.getElementById('NCC-1701')
.dispatchEvent(new CustomEvent('change', { bubbles: true }));
document.getElementById('NCC-1701-D').checked = true;
document
.getElementById('NCC-1701-D')
.dispatchEvent(new CustomEvent('change', { bubbles: true }));
document.getElementById('NCC-1701-E').checked = true;
document
.getElementById('NCC-1701-E')
.dispatchEvent(new CustomEvent('change', { bubbles: true }));
await jest.runAllTimersAsync();
expect(document.getElementById('continue').disabled).toBe(false);
});
});
describe('the ability to find the most suitable form for formData', () => {
it('should resolve the form from the controlled element input if the form is not the controlled element', async () => {
await setup(`
<form>
<input type="text" name="title" value="bad" />
<input
name="enter"
type="text"
data-controller="w-rules"
data-action="change@document->w-rules#resolve"
data-w-rules-target="enable"
data-w-rules="${_([['title', 'good']])}"
>
</form>`);
const input = document.querySelector('input[name="title"]');
const enableInput = document.querySelector('input[name="enter"]');
expect(enableInput.disabled).toBeTruthy();
input.value = 'good';
input.dispatchEvent(new Event('change', { bubbles: true }));
await jest.runAllTimersAsync();
expect(enableInput.disabled).toBeFalsy();
});
it('should resolve the form from the controlled element closest form if it cannot find it on the controlled element', async () => {
await setup(`
<form>
<input type="text" name="title" value="bad" />
<div data-controller="w-rules" data-action="change@document->w-rules#resolve">
<input name="enter" type="text" data-w-rules-target="enable" data-w-rules="${_([['title', 'good']])}" >
</div>
</form>`);
const input = document.querySelector('input[name="title"]');
const enableInput = document.querySelector('input[name="enter"]');
expect(enableInput.disabled).toBeTruthy();
input.value = 'good';
input.dispatchEvent(new Event('change', { bubbles: true }));
await jest.runAllTimersAsync();
expect(enableInput.disabled).toBeFalsy();
});
});
describe('the ability for the controller to avoid unnecessary resolving', () => {
it('should not check for the form data if there are no targets', async () => {
const handleResolved = jest.fn();
document.addEventListener('w-rules:resolved', handleResolved);
await setup(`
<form data-controller="w-rules" data-action="change->w-rules#resolve">
<input type="checkbox" name="ignored" />
<input type="text" id="note" name="note" />
</form>`);
const noteField = document.getElementById('note');
expect(
document.querySelector('form').getAttribute('data-controller'),
).toBeTruthy();
expect(handleResolved).not.toHaveBeenCalled();
document
.querySelector('input')
.dispatchEvent(new Event('change', { bubbles: true }));
await jest.runAllTimersAsync();
expect(handleResolved).not.toHaveBeenCalled();
// add a target & trigger a change event
noteField.setAttribute('data-w-rules-target', 'enable');
await jest.runAllTimersAsync();
expect(handleResolved).toHaveBeenCalledTimes(1);
noteField.dispatchEvent(new Event('change', { bubbles: true }));
await jest.runAllTimersAsync();
expect(handleResolved).toHaveBeenCalledTimes(2);
// now remove the target and check that the event no longer fires
noteField.remove();
document
.querySelector('input')
.dispatchEvent(new Event('change', { bubbles: true }));
await jest.runAllTimersAsync();
expect(handleResolved).toHaveBeenCalledTimes(2);
});
});
describe('conditionally enabling a target', () => {
it('should provide a way to conditionally enable a target and dispatch events', async () => {
const handleResolved = jest.fn();
const handleEffect = jest.fn();
document.addEventListener('w-rules:effect', handleEffect);
document.addEventListener('w-rules:resolved', handleResolved);
await setup(`
<form data-controller="w-rules" data-action="change->w-rules#resolve">
<input type="checkbox" id="agreement-field" name="agreement">
<button
id="continue"
type="button"
disabled
data-w-rules-target="enable"
data-w-rules="${_({ agreement: 'on' })}"
>
Continue
</button>
</form>`);
const checkbox = document.querySelector('#agreement-field');
const button = document.querySelector('[data-w-rules-target="enable"]');
expect(checkbox.checked).toBe(false);
expect(button.disabled).toBe(true);
expect(handleResolved).toHaveBeenCalledTimes(1);
expect(handleEffect).toHaveBeenCalledTimes(0); // no changes actually made to elements
checkbox.click();
checkbox.dispatchEvent(
new Event('change', { bubbles: true, cancelable: false }),
);
await jest.runAllTimersAsync();
expect(checkbox.checked).toBe(true);
expect(button.disabled).toBe(false);
checkbox.click();
checkbox.dispatchEvent(
new Event('change', { bubbles: true, cancelable: false }),
);
await jest.runAllTimersAsync();
expect(checkbox.checked).toBe(false);
expect(button.disabled).toBe(true);
expect(handleResolved).toHaveBeenCalledTimes(3); // rules are resolved two additional times
expect(handleEffect).toHaveBeenCalledTimes(2); // two changes made to elements
expect(handleEffect.mock.calls[0][0]).toEqual(
expect.objectContaining({
target: document.getElementById('continue'),
detail: {
effect: 'enable',
enable: true,
},
}),
);
expect(handleEffect.mock.calls[1][0]).toEqual(
expect.objectContaining({
target: document.getElementById('continue'),
detail: {
effect: 'enable',
enable: false,
},
}),
);
});
it('should ensure that the enabled/disabled attributes sync once connected', async () => {
await setup(`
<form id="form" data-controller="w-rules">
<fieldset>
<input type="password" name="password" />
<input type="email" name="email" />
<input type="checkbox" name="remember" />
</fieldset>
<label for="">This is my device.</label>
<input
type="checkbox"
id="my-device-check"
name="my-device"
data-w-rules-target="enable"
data-w-rules="${_({ remember: 'on' })}"
/>
</form>`);
expect(document.getElementById('my-device-check').disabled).toBe(true);
});
it('should support conditional enabling of sets of fields based on a select field', async () => {
await setup(`
<form class="w-mb-10" data-controller="w-rules" data-action="change->w-rules#resolve">
<select name="filter_method" id="id_filter_method" value="original">
<option value="original">Original size</option>
<option value="width">Resize to width</option>
<option value="height">Resize to height</option>
<option value="min">Resize to min</option>
<option value="max">Resize to max</option>
<option value="fill">Resize to fill</option>
</select>
<fieldset>
<input
type="number"
name="width"
value="150"
id="id_width"
disabled
data-w-rules-target="enable"
data-w-rules="${_({
filter_method: ['fill', 'max', 'min', 'width'],
})}"
/>
<input
type="number"
name="height"
value="162"
id="id_height"
disabled
data-w-rules-target="enable"
data-w-rules="${_({
filter_method: ['fill', 'height', 'max', 'min'],
})}"
/>
<input
type="number"
name="closeness"
value="0"
id="id_closeness"
disabled
data-w-rules-target="enable"
data-w-rules="${_({ filter_method: ['fill'] })}"
/>
</fieldset>
</form>
`);
const filterField = document.getElementById('id_filter_method');
const widthField = document.getElementById('id_width');
const heightField = document.getElementById('id_height');
const closenessField = document.getElementById('id_closeness');
expect(filterField.value).toEqual('original');
// should all be disabled at the start (original selected)
expect(widthField.disabled).toBe(true);
expect(heightField.disabled).toBe(true);
expect(closenessField.disabled).toBe(true);
// now change the filter to width
filterField.value = 'width';
filterField.dispatchEvent(new Event('change', { bubbles: true }));
await jest.runAllTimersAsync();
expect(widthField.disabled).toBe(false);
expect(heightField.disabled).toBe(true);
expect(closenessField.disabled).toBe(true);
// now change the filter to height
filterField.value = 'height';
filterField.dispatchEvent(new Event('change', { bubbles: true }));
await jest.runAllTimersAsync();
expect(widthField.disabled).toBe(true);
expect(heightField.disabled).toBe(false);
expect(closenessField.disabled).toBe(true);
// now change the filter to max
filterField.value = 'max';
filterField.dispatchEvent(new Event('change', { bubbles: true }));
await jest.runAllTimersAsync();
expect(widthField.disabled).toBe(false);
expect(heightField.disabled).toBe(false);
expect(closenessField.disabled).toBe(true);
// now change the filter to fill
filterField.value = 'fill';
filterField.dispatchEvent(new Event('change', { bubbles: true }));
await jest.runAllTimersAsync();
expect(widthField.disabled).toBe(false);
expect(heightField.disabled).toBe(false);
expect(closenessField.disabled).toBe(false);
// set back to original
filterField.value = 'original';
filterField.dispatchEvent(new Event('change', { bubbles: true }));
await jest.runAllTimersAsync();
expect(filterField.value).toEqual('original');
expect(widthField.disabled).toBe(true);
expect(heightField.disabled).toBe(true);
expect(closenessField.disabled).toBe(true);
});
});
});

Wyświetl plik

@ -0,0 +1,152 @@
/* eslint no-param-reassign: ["error", { "ignorePropertyModificationsFor": ["disabled"] }] */
import { Controller } from '@hotwired/stimulus';
import { castArray } from '../utils/castArray';
import { debounce } from '../utils/debounce';
/**
* Form control elements that can support the `disabled` attribute.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/disabled
*/
type FormControlElement =
| HTMLButtonElement
| HTMLFieldSetElement
| HTMLInputElement
| HTMLOptGroupElement
| HTMLOptionElement
| HTMLSelectElement
| HTMLTextAreaElement;
/**
* Adds the ability for a controlled form element to conditionally
* enable targeted elements based on the data from the controlled form
* along with a set of rules to match against that data.
*
* @example - Enable a button if a specific value is chosen
* ```html
* <form data-controller="w-rules" data-action="change->w-rules#resolve">
* <select name="fav-drink" required>
* <option value="">Select a drink</option>
* <option value="coffee">Coffee</option>
* <option value="other">Other</option>
* </select>
* <button type="button" data-w-rules-target="enable" data-w-rules='{"fav-drink": ["coffee"]}'>
* Continue
* </button>
* </form>
* ```
*/
export class RulesController extends Controller<
HTMLFormElement | FormControlElement
> {
static targets = ['enable'];
/** Targets will be enabled if the target's rule matches the scoped form data, otherwise will be disabled. */
declare readonly enableTargets: FormControlElement[];
/** True if there is at least one enable target, used to ensure rules do not run if not needed. */
declare readonly hasEnableTarget: boolean;
declare form;
declare rulesCache: Record<string, [string, string[]][]>;
initialize() {
this.rulesCache = {};
this.resolve = debounce(this.resolve.bind(this), 50);
const element = this.element;
if (element instanceof HTMLFormElement) {
this.form = element;
} else if ('form' in element) {
this.form = element.form;
} else {
this.form = element.closest('form');
}
}
/**
* Resolve the conditional targets based on the form data and the target(s)
* rule attributes and the controlled element's form data.
*/
resolve() {
if (!this.hasEnableTarget) return;
const formData = new FormData(this.form);
this.enableTargets.forEach((target) => {
const rules = this.parseRules(target);
const enable = rules.every(([fieldName, allowedValues]) => {
// Forms can have multiple values for the same field name
const values = formData.getAll(fieldName);
// Checkbox fields will NOT appear in FormData unless checked, support this when validValues are also empty
if (allowedValues.length === 0 && values.length === 0) return true;
return allowedValues.some((validValue) => values.includes(validValue));
});
if (enable === !target.disabled) return;
const event = this.dispatch('effect', {
bubbles: true,
cancelable: true,
detail: { effect: 'enable', enable },
target,
});
if (!event.defaultPrevented) {
target.disabled = !enable;
}
});
this.dispatch('resolved', { bubbles: true, cancelable: false });
}
enableTargetDisconnected() {
this.resolve();
}
enableTargetConnected() {
this.resolve();
}
/**
* Finds & parses the rules for the provided target by the rules attribute,
* which is determined via the identifier (e.g. `data-w-rules`).
* Check the rules cache first, then parse the rules for caching if not found.
*
* When parsing the rule, assume an `Object.entries` format or convert an
* object to this format. Then ensure each value is an array of strings
* for consistent comparison to FormData values.
*/
parseRules(target: Element) {
if (!target) return [];
const rulesRaw = target.getAttribute(`data-${this.identifier}`);
if (!rulesRaw) return [];
const cachedRule = this.rulesCache[rulesRaw];
if (cachedRule) return cachedRule;
let parsedRules;
try {
parsedRules = JSON.parse(rulesRaw);
} catch (error) {
this.context.handleError(error, 'Unable to parse rule.');
return [];
}
const rules = (
Array.isArray(parsedRules) ? parsedRules : Object.entries(parsedRules)
)
.filter(Array.isArray)
.map(([fieldName = '', validValues = ''] = []) => [
fieldName,
castArray(validValues).map(String),
]) as [string, string[]][];
this.rulesCache[rulesRaw] = rules;
return rules;
}
}

Wyświetl plik

@ -18,6 +18,7 @@ import { OrderableController } from './OrderableController';
import { PreviewController } from './PreviewController';
import { ProgressController } from './ProgressController';
import { RevealController } from './RevealController';
import { RulesController } from './RulesController';
import { SessionController } from './SessionController';
import { SkipLinkController } from './SkipLinkController';
import { SlugController } from './SlugController';
@ -55,6 +56,7 @@ export const coreControllerDefinitions: Definition[] = [
{ controllerConstructor: ProgressController, identifier: 'w-progress' },
{ controllerConstructor: RevealController, identifier: 'w-breadcrumbs' },
{ controllerConstructor: RevealController, identifier: 'w-reveal' },
{ controllerConstructor: RulesController, identifier: 'w-rules' },
{ controllerConstructor: SessionController, identifier: 'w-session' },
{ controllerConstructor: SkipLinkController, identifier: 'w-skip-link' },
{ controllerConstructor: SlugController, identifier: 'w-slug' },