Skip to content

[LiveComponent] Add validation modifiers (min_length, max_length, min_value, max_value) to data-model inputs #2926

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
<!-- Do not trigger model update until 3 characters are typed -->
<input data-model="min_length(3)|username" type="text" value="" />

<!-- Only trigger updates when value number is between 10 and 100 -->
<input data-model="min_value(10)|max_value(100)|quantity" type="number" value="20" />
```

## 2.27.0

- Add events assertions in `InteractsWithLiveComponents`:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
3 changes: 3 additions & 0 deletions src/LiveComponent/assets/dist/dom_utils.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
50 changes: 50 additions & 0 deletions src/LiveComponent/assets/dist/live_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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':
Expand All @@ -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()}".`);
}
Expand All @@ -2371,6 +2396,10 @@ function getModelBinding (modelDirective) {
shouldRender,
debounce,
targetEventName,
minLength,
maxLength,
minValue,
maxValue,
};
}

Expand Down Expand Up @@ -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) {
Expand Down
32 changes: 32 additions & 0 deletions src/LiveComponent/assets/src/Directive/get_model_binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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()}".`);
Expand All @@ -52,5 +80,9 @@ export default function (modelDirective: Directive): ModelBinding {
shouldRender,
debounce,
targetEventName,
minLength,
maxLength,
minValue,
maxValue,
};
}
21 changes: 21 additions & 0 deletions src/LiveComponent/assets/src/dom_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
40 changes: 39 additions & 1 deletion src/LiveComponent/assets/src/live_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -30,6 +37,7 @@ export interface LiveController {
element: HTMLElement;
component: Component;
}

export default class LiveControllerDefault extends Controller<HTMLElement> implements LiveController {
static values = {
name: String,
Expand Down Expand Up @@ -429,6 +437,36 @@ export default class LiveControllerDefault extends Controller<HTMLElement> 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);
}

Expand Down
71 changes: 71 additions & 0 deletions src/LiveComponent/assets/test/Directive/get_model_binding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading
Loading