diff --git a/client/src/controllers/RulesController.stories.js b/client/src/controllers/RulesController.stories.js new file mode 100644 index 0000000000..c5426706e8 --- /dev/null +++ b/client/src/controllers/RulesController.stories.js @@ -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 }) => ( + +
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+
+); + +export const Enable = EnableTemplate.bind({}); diff --git a/client/src/controllers/RulesController.test.js b/client/src/controllers/RulesController.test.js new file mode 100644 index 0000000000..dfc2c68697 --- /dev/null +++ b/client/src/controllers/RulesController.test.js @@ -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 = `
${html}
`; + + 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(` +
+ + +
+ Careful with this value. +
+
+ `); + + 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(` +
+ + + + + +
`); + + 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(` +
+ + + + +
+ `); + + 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(` +
+ + + +
+ `); + + 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(` +
+ + + + +
+ `); + + // 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(` +
+
+ Choose your favorite starship + + +
+ + +
+ + + + + + +
+ +
+ `); + + // 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(` +
+ + +
`); + + 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(` +
+ +
+ +
+
`); + + 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(` +
+ + +
`); + + 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(` +
+ + +
`); + + 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(` +
+
+ + + +
+ + +
`); + + 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(` +
+ +
+ + + +
+
+ `); + + 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); + }); + }); +}); diff --git a/client/src/controllers/RulesController.ts b/client/src/controllers/RulesController.ts new file mode 100644 index 0000000000..ee1091882d --- /dev/null +++ b/client/src/controllers/RulesController.ts @@ -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 + *
+ * + * + *
+ * ``` + */ +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; + + 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; + } +} diff --git a/client/src/controllers/index.ts b/client/src/controllers/index.ts index 4536a173d4..792ff69e9a 100644 --- a/client/src/controllers/index.ts +++ b/client/src/controllers/index.ts @@ -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' },