kopia lustrzana https://github.com/wagtail/wagtail
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 #11045pull/12641/head
rodzic
918ceebc17
commit
4438f13d5c
|
@ -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({});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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' },
|
||||
|
|
Ładowanie…
Reference in New Issue