From 7c015e31a6c3cf0d91b637d1480e582354e79785 Mon Sep 17 00:00:00 2001 From: Sait KURT <33868586+xDeSwa@users.noreply.github.com> Date: Tue, 15 Jul 2025 15:49:48 +0300 Subject: [PATCH] [LiveComponent] Add validation modifiers (min_length, max_length, min_value, max_value) to data-model inputs --- src/LiveComponent/CHANGELOG.md | 14 ++ .../dist/Directive/get_model_binding.d.ts | 4 + src/LiveComponent/assets/dist/dom_utils.d.ts | 3 + .../assets/dist/live_controller.js | 50 +++++ .../assets/src/Directive/get_model_binding.ts | 32 +++ src/LiveComponent/assets/src/dom_utils.ts | 21 ++ .../assets/src/live_controller.ts | 40 +++- .../test/Directive/get_model_binding.test.ts | 71 +++++++ .../assets/test/controller/model.test.ts | 192 ++++++++++++++++-- .../assets/test/dom_utils.test.ts | 92 +++++++++ src/LiveComponent/doc/index.rst | 60 ++++++ 11 files changed, 562 insertions(+), 17 deletions(-) diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 84ed16c82dc..89b02d5a10e 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -1,5 +1,19 @@ # CHANGELOG +## 2.28.0 + +- Add new modifiers for input validations, useful to prevent uneccessary HTTP requests: + - `min_length` and `max_length`: validate length from textual input elements + - `min_value` and `max_value`: validate value from numeral input elements + +```twig + + + + + +``` + ## 2.27.0 - Add events assertions in `InteractsWithLiveComponents`: diff --git a/src/LiveComponent/assets/dist/Directive/get_model_binding.d.ts b/src/LiveComponent/assets/dist/Directive/get_model_binding.d.ts index 5d36c0d49f7..daaeb36f913 100644 --- a/src/LiveComponent/assets/dist/Directive/get_model_binding.d.ts +++ b/src/LiveComponent/assets/dist/Directive/get_model_binding.d.ts @@ -5,5 +5,9 @@ export interface ModelBinding { shouldRender: boolean; debounce: number | boolean; targetEventName: string | null; + minLength: number | null; + maxLength: number | null; + minValue: number | null; + maxValue: number | null; } export default function (modelDirective: Directive): ModelBinding; diff --git a/src/LiveComponent/assets/dist/dom_utils.d.ts b/src/LiveComponent/assets/dist/dom_utils.d.ts index 72dac3db5b1..af330333c4e 100644 --- a/src/LiveComponent/assets/dist/dom_utils.d.ts +++ b/src/LiveComponent/assets/dist/dom_utils.d.ts @@ -8,3 +8,6 @@ export declare function getModelDirectiveFromElement(element: HTMLElement, throw export declare function elementBelongsToThisComponent(element: Element, component: Component): boolean; export declare function cloneHTMLElement(element: HTMLElement): HTMLElement; export declare function htmlToElement(html: string): HTMLElement; +export declare function isTextualInputElement(el: HTMLElement): el is HTMLInputElement; +export declare function isTextareaElement(el: HTMLElement): el is HTMLTextAreaElement; +export declare function isNumericalInputElement(element: Element): element is HTMLInputElement; diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 4902665e1de..7c906620a25 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -475,6 +475,15 @@ const getMultipleCheckboxValue = (element, currentValues) => { return finalValues; }; const inputValue = (element) => element.dataset.value ? element.dataset.value : element.value; +function isTextualInputElement(el) { + return el instanceof HTMLInputElement && ['text', 'email', 'password', 'search', 'tel', 'url'].includes(el.type); +} +function isTextareaElement(el) { + return el instanceof HTMLTextAreaElement; +} +function isNumericalInputElement(element) { + return element instanceof HTMLInputElement && ['number', 'range'].includes(element.type); +} class HookManager { constructor() { @@ -2343,6 +2352,10 @@ function getModelBinding (modelDirective) { let shouldRender = true; let targetEventName = null; let debounce = false; + let minLength = null; + let maxLength = null; + let minValue = null; + let maxValue = null; modelDirective.modifiers.forEach((modifier) => { switch (modifier.name) { case 'on': @@ -2360,6 +2373,18 @@ function getModelBinding (modelDirective) { case 'debounce': debounce = modifier.value ? Number.parseInt(modifier.value) : true; break; + case 'min_length': + minLength = modifier.value ? Number.parseInt(modifier.value) : null; + break; + case 'max_length': + maxLength = modifier.value ? Number.parseInt(modifier.value) : null; + break; + case 'min_value': + minValue = modifier.value ? Number.parseFloat(modifier.value) : null; + break; + case 'max_value': + maxValue = modifier.value ? Number.parseFloat(modifier.value) : null; + break; default: throw new Error(`Unknown modifier "${modifier.name}" in data-model="${modelDirective.getString()}".`); } @@ -2371,6 +2396,10 @@ function getModelBinding (modelDirective) { shouldRender, debounce, targetEventName, + minLength, + maxLength, + minValue, + maxValue, }; } @@ -3153,6 +3182,27 @@ class LiveControllerDefault extends Controller { } } const finalValue = getValueFromElement(element, this.component.valueStore); + if (isTextualInputElement(element) || isTextareaElement(element)) { + if (modelBinding.minLength !== null && + typeof finalValue === 'string' && + finalValue.length < modelBinding.minLength) { + return; + } + if (modelBinding.maxLength !== null && + typeof finalValue === 'string' && + finalValue.length > modelBinding.maxLength) { + return; + } + } + if (isNumericalInputElement(element)) { + const numericValue = Number(finalValue); + if (modelBinding.minValue !== null && numericValue < modelBinding.minValue) { + return; + } + if (modelBinding.maxValue !== null && numericValue > modelBinding.maxValue) { + return; + } + } this.component.set(modelBinding.modelName, finalValue, modelBinding.shouldRender, modelBinding.debounce); } dispatchEvent(name, detail = {}, canBubble = true, cancelable = false) { diff --git a/src/LiveComponent/assets/src/Directive/get_model_binding.ts b/src/LiveComponent/assets/src/Directive/get_model_binding.ts index 7b61a6a7fd1..e94d7baa3ca 100644 --- a/src/LiveComponent/assets/src/Directive/get_model_binding.ts +++ b/src/LiveComponent/assets/src/Directive/get_model_binding.ts @@ -6,12 +6,20 @@ export interface ModelBinding { shouldRender: boolean; debounce: number | boolean; targetEventName: string | null; + minLength: number | null; + maxLength: number | null; + minValue: number | null; + maxValue: number | null; } export default function (modelDirective: Directive): ModelBinding { let shouldRender = true; let targetEventName = null; let debounce: number | boolean = false; + let minLength: number | null = null; + let maxLength: number | null = null; + let minValue: number | null = null; + let maxValue: number | null = null; modelDirective.modifiers.forEach((modifier) => { switch (modifier.name) { @@ -38,6 +46,26 @@ export default function (modelDirective: Directive): ModelBinding { case 'debounce': debounce = modifier.value ? Number.parseInt(modifier.value) : true; + break; + + case 'min_length': + minLength = modifier.value ? Number.parseInt(modifier.value) : null; + + break; + + case 'max_length': + maxLength = modifier.value ? Number.parseInt(modifier.value) : null; + + break; + + case 'min_value': + minValue = modifier.value ? Number.parseFloat(modifier.value) : null; + + break; + + case 'max_value': + maxValue = modifier.value ? Number.parseFloat(modifier.value) : null; + break; default: throw new Error(`Unknown modifier "${modifier.name}" in data-model="${modelDirective.getString()}".`); @@ -52,5 +80,9 @@ export default function (modelDirective: Directive): ModelBinding { shouldRender, debounce, targetEventName, + minLength, + maxLength, + minValue, + maxValue, }; } diff --git a/src/LiveComponent/assets/src/dom_utils.ts b/src/LiveComponent/assets/src/dom_utils.ts index 9fb1390c639..0a8afe29b74 100644 --- a/src/LiveComponent/assets/src/dom_utils.ts +++ b/src/LiveComponent/assets/src/dom_utils.ts @@ -262,3 +262,24 @@ const getMultipleCheckboxValue = (element: HTMLInputElement, currentValues: Arra const inputValue = (element: HTMLInputElement): string => element.dataset.value ? element.dataset.value : element.value; + +/** + * Checks whether the given element is a textual input (input[type=text/email/...]). + */ +export function isTextualInputElement(el: HTMLElement): el is HTMLInputElement { + return el instanceof HTMLInputElement && ['text', 'email', 'password', 'search', 'tel', 'url'].includes(el.type); +} + +/** + * Checks whether the given element is a textarea. + */ +export function isTextareaElement(el: HTMLElement): el is HTMLTextAreaElement { + return el instanceof HTMLTextAreaElement; +} + +/** + * Checks whether the given element is a numerical input (input[type=number] or input[type=range]). + */ +export function isNumericalInputElement(element: Element): element is HTMLInputElement { + return element instanceof HTMLInputElement && ['number', 'range'].includes(element.type); +} diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 34380a8fa73..baa9a65b907 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -13,7 +13,14 @@ import SetValueOntoModelFieldsPlugin from './Component/plugins/SetValueOntoModel import ValidatedFieldsPlugin from './Component/plugins/ValidatedFieldsPlugin'; import { type DirectiveModifier, parseDirectives } from './Directive/directives_parser'; import getModelBinding from './Directive/get_model_binding'; -import { elementBelongsToThisComponent, getModelDirectiveFromElement, getValueFromElement } from './dom_utils'; +import { + elementBelongsToThisComponent, + getModelDirectiveFromElement, + getValueFromElement, + isNumericalInputElement, + isTextareaElement, + isTextualInputElement, +} from './dom_utils'; import getElementAsTagText from './Util/getElementAsTagText'; export { Component }; @@ -30,6 +37,7 @@ export interface LiveController { element: HTMLElement; component: Component; } + export default class LiveControllerDefault extends Controller implements LiveController { static values = { name: String, @@ -429,6 +437,36 @@ export default class LiveControllerDefault extends Controller imple const finalValue = getValueFromElement(element, this.component.valueStore); + if (isTextualInputElement(element) || isTextareaElement(element)) { + if ( + modelBinding.minLength !== null && + typeof finalValue === 'string' && + finalValue.length < modelBinding.minLength + ) { + return; + } + + if ( + modelBinding.maxLength !== null && + typeof finalValue === 'string' && + finalValue.length > modelBinding.maxLength + ) { + return; + } + } + + if (isNumericalInputElement(element)) { + const numericValue = Number(finalValue); + + if (modelBinding.minValue !== null && numericValue < modelBinding.minValue) { + return; + } + + if (modelBinding.maxValue !== null && numericValue > modelBinding.maxValue) { + return; + } + } + this.component.set(modelBinding.modelName, finalValue, modelBinding.shouldRender, modelBinding.debounce); } diff --git a/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts b/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts index cbaaaf5a42a..d2c0ee07fcd 100644 --- a/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts +++ b/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts @@ -4,34 +4,105 @@ import getModelBinding from '../../src/Directive/get_model_binding'; describe('get_model_binding', () => { it('returns correctly with simple directive', () => { const directive = parseDirectives('firstName')[0]; + expect(getModelBinding(directive)).toEqual({ modelName: 'firstName', innerModelName: null, shouldRender: true, debounce: false, targetEventName: null, + minLength: null, + maxLength: null, + minValue: null, + maxValue: null, }); }); it('returns all modifiers correctly', () => { const directive = parseDirectives('on(change)|norender|debounce(100)|firstName')[0]; + expect(getModelBinding(directive)).toEqual({ modelName: 'firstName', innerModelName: null, shouldRender: false, debounce: 100, targetEventName: 'change', + minLength: null, + maxLength: null, + minValue: null, + maxValue: null, }); }); it('parses the parent:inner model name correctly', () => { const directive = parseDirectives('firstName:first')[0]; + expect(getModelBinding(directive)).toEqual({ modelName: 'firstName', innerModelName: 'first', shouldRender: true, debounce: false, targetEventName: null, + minLength: null, + maxLength: null, + minValue: null, + maxValue: null, + }); + }); + + it('parses min_length and max_length modifiers', () => { + const directive = parseDirectives('min_length(3)|max_length(20)|username')[0]; + + expect(getModelBinding(directive)).toEqual({ + modelName: 'username', + innerModelName: null, + shouldRender: true, + debounce: false, + targetEventName: null, + minLength: 3, + maxLength: 20, + minValue: null, + maxValue: null, + }); + }); + + it('parses min_value and max_value modifiers', () => { + const directive = parseDirectives('min_value(18)|max_value(65)|age')[0]; + + expect(getModelBinding(directive)).toEqual({ + modelName: 'age', + innerModelName: null, + shouldRender: true, + debounce: false, + targetEventName: null, + minLength: null, + maxLength: null, + minValue: 18, + maxValue: 65, + }); + }); + + it('handles mixed modifiers correctly', () => { + const directive = parseDirectives('on(change)|norender|debounce(100)|min_value(18)|max_value(65)|age:years')[0]; + + expect(getModelBinding(directive)).toEqual({ + modelName: 'age', + innerModelName: 'years', + shouldRender: false, + debounce: 100, + targetEventName: 'change', + minLength: null, + maxLength: null, + minValue: 18, + maxValue: 65, }); }); + + it('handles empty modifier values gracefully', () => { + const directive = parseDirectives('min_length|max_length|username')[0]; + const binding = getModelBinding(directive); + + expect(binding.minLength).toBeNull(); + expect(binding.maxLength).toBeNull(); + }); }); diff --git a/src/LiveComponent/assets/test/controller/model.test.ts b/src/LiveComponent/assets/test/controller/model.test.ts index 2aef9dcd068..63756d9c927 100644 --- a/src/LiveComponent/assets/test/controller/model.test.ts +++ b/src/LiveComponent/assets/test/controller/model.test.ts @@ -25,7 +25,7 @@ describe('LiveController data-model Tests', () => { data-model="name" value="${data.name}" > - + Name is: ${data.name} ` @@ -56,7 +56,7 @@ describe('LiveController data-model Tests', () => { data-model="norender|name" value="${data.name}" > - + Name is: ${data.name} ` @@ -83,7 +83,7 @@ describe('LiveController data-model Tests', () => { data-model="on(change)|name" value="${data.name}" > - + Name is: ${data.name} @@ -127,7 +127,7 @@ describe('LiveController data-model Tests', () => { data-model="name" data-value="Dan" >Change name to Dan - + Name is: ${data.name} ` @@ -159,7 +159,7 @@ describe('LiveController data-model Tests', () => { value="${data.color}" > - + Favorite color: ${data.color} ` @@ -184,7 +184,7 @@ describe('LiveController data-model Tests', () => { data-model="firstName" > - + First name: ${data.firstName} ` @@ -209,7 +209,7 @@ describe('LiveController data-model Tests', () => { data-model="sport" data-value="cross country" > - + Sport: ${data.sport} ` @@ -232,7 +232,7 @@ describe('LiveController data-model Tests', () => { - + Name: ${data.user.name} ` @@ -287,7 +287,7 @@ describe('LiveController data-model Tests', () => { Checkbox 2: - + Checkbox 2 is ${data.form.check2 ? 'checked' : 'unchecked'} ` @@ -326,7 +326,7 @@ describe('LiveController data-model Tests', () => { Checkbox 2: - + Checkbox 1 is ${data.form.check1 ? 'checked' : 'unchecked'} ` @@ -359,7 +359,7 @@ describe('LiveController data-model Tests', () => { Checkbox 2: -1 ? 'checked' : ''} /> - + Checkbox 2 is ${data.form.check.indexOf('bar') > -1 ? 'checked' : 'unchecked'} ` @@ -393,7 +393,7 @@ describe('LiveController data-model Tests', () => { Checkbox 2: -1 ? 'checked' : ''} /> - + Checkbox 2 is ${data.check.indexOf('bar') > -1 ? 'checked' : 'unchecked'} ` @@ -427,7 +427,7 @@ describe('LiveController data-model Tests', () => { Checkbox 2: -1 ? 'checked' : ''} /> - + Checkbox 1 is ${data.form.check.indexOf('foo') > -1 ? 'checked' : 'unchecked'} ` @@ -459,7 +459,7 @@ describe('LiveController data-model Tests', () => { - + Checkbox 1 is ${data.check.indexOf('foo') > -1 ? 'checked' : 'unchecked'} ` @@ -493,7 +493,7 @@ describe('LiveController data-model Tests', () => { - + Option 2 is ${data.form.select?.indexOf('bar') > -1 ? 'selected' : 'unselected'} ` @@ -525,7 +525,7 @@ describe('LiveController data-model Tests', () => { - + Option 2 is ${data.form.select?.indexOf('bar') > -1 ? 'selected' : 'unselected'} ` @@ -976,4 +976,164 @@ describe('LiveController data-model Tests', () => { expect(test.element).toHaveTextContent('Food is: Popcorn'); expect(test.element).toHaveTextContent('Rating is: 5'); }); + + it('does not update model if input value length is less than min_length', async () => { + const test = await createTest( + { username: '' }, + (data: any) => ` +
+ + Username: ${data.username} +
+ ` + ); + + await userEvent.type(test.queryByDataModel('username'), 'ab'); + expect(test.component.valueStore.getOriginalProps()).toEqual({ username: '' }); + }); + + it('updates model if input value length satisfies min_length', async () => { + const test = await createTest( + { username: '' }, + (data: any) => ` +
+ + Username: ${data.username} +
+ ` + ); + + test.expectsAjaxCall().expectUpdatedData({ username: 'abc' }); + + await userEvent.type(test.queryByDataModel('username'), 'abc'); + await waitFor(() => expect(test.element).toHaveTextContent('Username: abc')); + }); + + it('does not update model if input value length exceeds max_length', async () => { + const test = await createTest( + { username: '' }, + (data: any) => ` +
+ + Username: ${data.username} +
+ ` + ); + + await userEvent.type(test.queryByDataModel('username'), 'abcdef'); + expect(test.component.valueStore.getOriginalProps()).toEqual({ username: '' }); + }); + + it('updates model if number input value is within min_value/max_value range', async () => { + const test = await createTest( + { age: '' }, + (data: any) => ` +
+ + Age: ${data.age} +
+ ` + ); + + test.expectsAjaxCall().expectUpdatedData({ age: '30' }); + + const input = test.queryByDataModel('age'); + await userEvent.clear(input); + await userEvent.type(input, '30'); + + await waitFor(() => expect(test.element).toHaveTextContent('Age: 30')); + }); + + it('does not update model if number input value is less than min_value', async () => { + const test = await createTest( + { age: '' }, + (data: any) => ` +
+ + Age: ${data.age} +
+ ` + ); + + const input = test.queryByDataModel('age'); + await userEvent.clear(input); + await userEvent.type(input, '15'); + + expect(test.component.valueStore.getOriginalProps()).toEqual({ age: '' }); + }); + + it('does not update model if number input value exceeds max_value', async () => { + const test = await createTest( + { age: '' }, + (data: any) => ` +
+ + Age: ${data.age} +
+ ` + ); + + const input = test.queryByDataModel('age'); + await userEvent.clear(input); + await userEvent.type(input, '70'); + + expect(test.component.valueStore.getOriginalProps()).toEqual({ age: '' }); + }); + + it('does not update model if value is shorter than min_length or longer than max_length', async () => { + const test = await createTest( + { username: '' }, + (data: any) => ` +
+ + Username: ${data.username} +
+ ` + ); + + // too short + await userEvent.type(test.queryByDataModel('username'), 'ab'); + expect(test.component.valueStore.getOriginalProps()).toEqual({ username: '' }); + + // too long + await userEvent.clear(test.queryByDataModel('username')); + await userEvent.type(test.queryByDataModel('username'), 'abcdef'); + expect(test.component.valueStore.getOriginalProps()).toEqual({ username: '' }); + + // valid + test.expectsAjaxCall().expectUpdatedData({ username: 'abc' }); + await userEvent.clear(test.queryByDataModel('username')); + await userEvent.type(test.queryByDataModel('username'), 'abc'); + await waitFor(() => expect(test.element).toHaveTextContent('Username: abc')); + }); + + it('does not update model if number is less than min_value or greater than max_value', async () => { + const test = await createTest( + { age: '' }, + (data: any) => ` +
+ + Age: ${data.age} +
+ ` + ); + + const input = test.queryByDataModel('age'); + + // too low + await userEvent.clear(input); + await userEvent.type(input, '17'); + expect(test.component.valueStore.getOriginalProps()).toEqual({ age: '' }); + + // too high + await userEvent.clear(input); + await userEvent.type(input, '70'); + expect(test.component.valueStore.getOriginalProps()).toEqual({ age: '' }); + + // valid + test.expectsAjaxCall().expectUpdatedData({ age: '30' }); + await userEvent.clear(input); + await userEvent.type(input, '30'); + await waitFor(() => expect(test.element).toHaveTextContent('Age: 30')); + }); }); diff --git a/src/LiveComponent/assets/test/dom_utils.test.ts b/src/LiveComponent/assets/test/dom_utils.test.ts index a1b0a17da39..0e656d5fa73 100644 --- a/src/LiveComponent/assets/test/dom_utils.test.ts +++ b/src/LiveComponent/assets/test/dom_utils.test.ts @@ -7,6 +7,9 @@ import { getModelDirectiveFromElement, getValueFromElement, htmlToElement, + isNumericalInputElement, + isTextareaElement, + isTextualInputElement, setValueOnElement, } from '../src/dom_utils'; import { noopElementDriver } from './tools'; @@ -324,3 +327,92 @@ describe('cloneHTMLElement', () => { expect(clone.outerHTML).toEqual('
'); }); }); + +describe('isTextualInputElement', () => { + describe.each([ + ['text', true], + ['email', true], + ['password', true], + ['search', true], + ['tel', true], + ['url', true], + ['number', false], + ['range', false], + ['file', false], + ['date', false], + ['checkbox', false], + ['radio', false], + ['submit', false], + ['reset', false], + ['color', false], + ['datetime-local', false], + ['hidden', false], + ['image', false], + ['month', false], + ['time', false], + ['week', false], + ])('input[type="%s"] should return %s', (type, expected) => { + it(`returns ${expected}`, () => { + const input = document.createElement('input'); + if (typeof type === 'string') { + input.type = type; + } + expect(isTextualInputElement(input)).toBe(expected); + }); + }); + + it('returns false for