Skip to content

Commit b233a7b

Browse files
committed
feature symfony#2926 [LiveComponent] Add validation modifiers (min_length, max_length, min_value, max_value) to data-model inputs (xDeSwa)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [LiveComponent] Add validation modifiers (min_length, max_length, min_value, max_value) to data-model inputs | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Docs? | yes | Issues | | License | MIT **[LiveComponent]** This PR introduces support for input validation modifiers used in data-model, including: - min_length(x) - max_length(x) - min_value(x) - max_value(x) These modifiers prevent model updates and AJAX calls if the user input doesn't meet the validation constraints. It helps avoid unnecessary re-renders and network traffic. They can be combined with other modifiers like debounce. ```twig <input data-model="name|min_length(3)" /> <input data-model="price|min_value(1)|max_value(100)" type="number" /> ``` Commits ------- 7c015e3 [LiveComponent] Add validation modifiers (min_length, max_length, min_value, max_value) to data-model inputs
2 parents 2e2e3e8 + 7c015e3 commit b233a7b

File tree

11 files changed

+562
-17
lines changed

11 files changed

+562
-17
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# CHANGELOG
22

3+
## 2.28.0
4+
5+
- Add new modifiers for input validations, useful to prevent uneccessary HTTP requests:
6+
- `min_length` and `max_length`: validate length from textual input elements
7+
- `min_value` and `max_value`: validate value from numeral input elements
8+
9+
```twig
10+
<!-- Do not trigger model update until 3 characters are typed -->
11+
<input data-model="min_length(3)|username" type="text" value="" />
12+
13+
<!-- Only trigger updates when value number is between 10 and 100 -->
14+
<input data-model="min_value(10)|max_value(100)|quantity" type="number" value="20" />
15+
```
16+
317
## 2.27.0
418

519
- Add events assertions in `InteractsWithLiveComponents`:

src/LiveComponent/assets/dist/Directive/get_model_binding.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,9 @@ export interface ModelBinding {
55
shouldRender: boolean;
66
debounce: number | boolean;
77
targetEventName: string | null;
8+
minLength: number | null;
9+
maxLength: number | null;
10+
minValue: number | null;
11+
maxValue: number | null;
812
}
913
export default function (modelDirective: Directive): ModelBinding;

src/LiveComponent/assets/dist/dom_utils.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ export declare function getModelDirectiveFromElement(element: HTMLElement, throw
88
export declare function elementBelongsToThisComponent(element: Element, component: Component): boolean;
99
export declare function cloneHTMLElement(element: HTMLElement): HTMLElement;
1010
export declare function htmlToElement(html: string): HTMLElement;
11+
export declare function isTextualInputElement(el: HTMLElement): el is HTMLInputElement;
12+
export declare function isTextareaElement(el: HTMLElement): el is HTMLTextAreaElement;
13+
export declare function isNumericalInputElement(element: Element): element is HTMLInputElement;

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,15 @@ const getMultipleCheckboxValue = (element, currentValues) => {
475475
return finalValues;
476476
};
477477
const inputValue = (element) => element.dataset.value ? element.dataset.value : element.value;
478+
function isTextualInputElement(el) {
479+
return el instanceof HTMLInputElement && ['text', 'email', 'password', 'search', 'tel', 'url'].includes(el.type);
480+
}
481+
function isTextareaElement(el) {
482+
return el instanceof HTMLTextAreaElement;
483+
}
484+
function isNumericalInputElement(element) {
485+
return element instanceof HTMLInputElement && ['number', 'range'].includes(element.type);
486+
}
478487

479488
class HookManager {
480489
constructor() {
@@ -2343,6 +2352,10 @@ function getModelBinding (modelDirective) {
23432352
let shouldRender = true;
23442353
let targetEventName = null;
23452354
let debounce = false;
2355+
let minLength = null;
2356+
let maxLength = null;
2357+
let minValue = null;
2358+
let maxValue = null;
23462359
modelDirective.modifiers.forEach((modifier) => {
23472360
switch (modifier.name) {
23482361
case 'on':
@@ -2360,6 +2373,18 @@ function getModelBinding (modelDirective) {
23602373
case 'debounce':
23612374
debounce = modifier.value ? Number.parseInt(modifier.value) : true;
23622375
break;
2376+
case 'min_length':
2377+
minLength = modifier.value ? Number.parseInt(modifier.value) : null;
2378+
break;
2379+
case 'max_length':
2380+
maxLength = modifier.value ? Number.parseInt(modifier.value) : null;
2381+
break;
2382+
case 'min_value':
2383+
minValue = modifier.value ? Number.parseFloat(modifier.value) : null;
2384+
break;
2385+
case 'max_value':
2386+
maxValue = modifier.value ? Number.parseFloat(modifier.value) : null;
2387+
break;
23632388
default:
23642389
throw new Error(`Unknown modifier "${modifier.name}" in data-model="${modelDirective.getString()}".`);
23652390
}
@@ -2371,6 +2396,10 @@ function getModelBinding (modelDirective) {
23712396
shouldRender,
23722397
debounce,
23732398
targetEventName,
2399+
minLength,
2400+
maxLength,
2401+
minValue,
2402+
maxValue,
23742403
};
23752404
}
23762405

@@ -3153,6 +3182,27 @@ class LiveControllerDefault extends Controller {
31533182
}
31543183
}
31553184
const finalValue = getValueFromElement(element, this.component.valueStore);
3185+
if (isTextualInputElement(element) || isTextareaElement(element)) {
3186+
if (modelBinding.minLength !== null &&
3187+
typeof finalValue === 'string' &&
3188+
finalValue.length < modelBinding.minLength) {
3189+
return;
3190+
}
3191+
if (modelBinding.maxLength !== null &&
3192+
typeof finalValue === 'string' &&
3193+
finalValue.length > modelBinding.maxLength) {
3194+
return;
3195+
}
3196+
}
3197+
if (isNumericalInputElement(element)) {
3198+
const numericValue = Number(finalValue);
3199+
if (modelBinding.minValue !== null && numericValue < modelBinding.minValue) {
3200+
return;
3201+
}
3202+
if (modelBinding.maxValue !== null && numericValue > modelBinding.maxValue) {
3203+
return;
3204+
}
3205+
}
31563206
this.component.set(modelBinding.modelName, finalValue, modelBinding.shouldRender, modelBinding.debounce);
31573207
}
31583208
dispatchEvent(name, detail = {}, canBubble = true, cancelable = false) {

src/LiveComponent/assets/src/Directive/get_model_binding.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,20 @@ export interface ModelBinding {
66
shouldRender: boolean;
77
debounce: number | boolean;
88
targetEventName: string | null;
9+
minLength: number | null;
10+
maxLength: number | null;
11+
minValue: number | null;
12+
maxValue: number | null;
913
}
1014

1115
export default function (modelDirective: Directive): ModelBinding {
1216
let shouldRender = true;
1317
let targetEventName = null;
1418
let debounce: number | boolean = false;
19+
let minLength: number | null = null;
20+
let maxLength: number | null = null;
21+
let minValue: number | null = null;
22+
let maxValue: number | null = null;
1523

1624
modelDirective.modifiers.forEach((modifier) => {
1725
switch (modifier.name) {
@@ -38,6 +46,26 @@ export default function (modelDirective: Directive): ModelBinding {
3846
case 'debounce':
3947
debounce = modifier.value ? Number.parseInt(modifier.value) : true;
4048

49+
break;
50+
51+
case 'min_length':
52+
minLength = modifier.value ? Number.parseInt(modifier.value) : null;
53+
54+
break;
55+
56+
case 'max_length':
57+
maxLength = modifier.value ? Number.parseInt(modifier.value) : null;
58+
59+
break;
60+
61+
case 'min_value':
62+
minValue = modifier.value ? Number.parseFloat(modifier.value) : null;
63+
64+
break;
65+
66+
case 'max_value':
67+
maxValue = modifier.value ? Number.parseFloat(modifier.value) : null;
68+
4169
break;
4270
default:
4371
throw new Error(`Unknown modifier "${modifier.name}" in data-model="${modelDirective.getString()}".`);
@@ -52,5 +80,9 @@ export default function (modelDirective: Directive): ModelBinding {
5280
shouldRender,
5381
debounce,
5482
targetEventName,
83+
minLength,
84+
maxLength,
85+
minValue,
86+
maxValue,
5587
};
5688
}

src/LiveComponent/assets/src/dom_utils.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,3 +262,24 @@ const getMultipleCheckboxValue = (element: HTMLInputElement, currentValues: Arra
262262

263263
const inputValue = (element: HTMLInputElement): string =>
264264
element.dataset.value ? element.dataset.value : element.value;
265+
266+
/**
267+
* Checks whether the given element is a textual input (input[type=text/email/...]).
268+
*/
269+
export function isTextualInputElement(el: HTMLElement): el is HTMLInputElement {
270+
return el instanceof HTMLInputElement && ['text', 'email', 'password', 'search', 'tel', 'url'].includes(el.type);
271+
}
272+
273+
/**
274+
* Checks whether the given element is a textarea.
275+
*/
276+
export function isTextareaElement(el: HTMLElement): el is HTMLTextAreaElement {
277+
return el instanceof HTMLTextAreaElement;
278+
}
279+
280+
/**
281+
* Checks whether the given element is a numerical input (input[type=number] or input[type=range]).
282+
*/
283+
export function isNumericalInputElement(element: Element): element is HTMLInputElement {
284+
return element instanceof HTMLInputElement && ['number', 'range'].includes(element.type);
285+
}

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@ import SetValueOntoModelFieldsPlugin from './Component/plugins/SetValueOntoModel
1313
import ValidatedFieldsPlugin from './Component/plugins/ValidatedFieldsPlugin';
1414
import { type DirectiveModifier, parseDirectives } from './Directive/directives_parser';
1515
import getModelBinding from './Directive/get_model_binding';
16-
import { elementBelongsToThisComponent, getModelDirectiveFromElement, getValueFromElement } from './dom_utils';
16+
import {
17+
elementBelongsToThisComponent,
18+
getModelDirectiveFromElement,
19+
getValueFromElement,
20+
isNumericalInputElement,
21+
isTextareaElement,
22+
isTextualInputElement,
23+
} from './dom_utils';
1724
import getElementAsTagText from './Util/getElementAsTagText';
1825

1926
export { Component };
@@ -30,6 +37,7 @@ export interface LiveController {
3037
element: HTMLElement;
3138
component: Component;
3239
}
40+
3341
export default class LiveControllerDefault extends Controller<HTMLElement> implements LiveController {
3442
static values = {
3543
name: String,
@@ -429,6 +437,36 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
429437

430438
const finalValue = getValueFromElement(element, this.component.valueStore);
431439

440+
if (isTextualInputElement(element) || isTextareaElement(element)) {
441+
if (
442+
modelBinding.minLength !== null &&
443+
typeof finalValue === 'string' &&
444+
finalValue.length < modelBinding.minLength
445+
) {
446+
return;
447+
}
448+
449+
if (
450+
modelBinding.maxLength !== null &&
451+
typeof finalValue === 'string' &&
452+
finalValue.length > modelBinding.maxLength
453+
) {
454+
return;
455+
}
456+
}
457+
458+
if (isNumericalInputElement(element)) {
459+
const numericValue = Number(finalValue);
460+
461+
if (modelBinding.minValue !== null && numericValue < modelBinding.minValue) {
462+
return;
463+
}
464+
465+
if (modelBinding.maxValue !== null && numericValue > modelBinding.maxValue) {
466+
return;
467+
}
468+
}
469+
432470
this.component.set(modelBinding.modelName, finalValue, modelBinding.shouldRender, modelBinding.debounce);
433471
}
434472

src/LiveComponent/assets/test/Directive/get_model_binding.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,105 @@ import getModelBinding from '../../src/Directive/get_model_binding';
44
describe('get_model_binding', () => {
55
it('returns correctly with simple directive', () => {
66
const directive = parseDirectives('firstName')[0];
7+
78
expect(getModelBinding(directive)).toEqual({
89
modelName: 'firstName',
910
innerModelName: null,
1011
shouldRender: true,
1112
debounce: false,
1213
targetEventName: null,
14+
minLength: null,
15+
maxLength: null,
16+
minValue: null,
17+
maxValue: null,
1318
});
1419
});
1520

1621
it('returns all modifiers correctly', () => {
1722
const directive = parseDirectives('on(change)|norender|debounce(100)|firstName')[0];
23+
1824
expect(getModelBinding(directive)).toEqual({
1925
modelName: 'firstName',
2026
innerModelName: null,
2127
shouldRender: false,
2228
debounce: 100,
2329
targetEventName: 'change',
30+
minLength: null,
31+
maxLength: null,
32+
minValue: null,
33+
maxValue: null,
2434
});
2535
});
2636

2737
it('parses the parent:inner model name correctly', () => {
2838
const directive = parseDirectives('firstName:first')[0];
39+
2940
expect(getModelBinding(directive)).toEqual({
3041
modelName: 'firstName',
3142
innerModelName: 'first',
3243
shouldRender: true,
3344
debounce: false,
3445
targetEventName: null,
46+
minLength: null,
47+
maxLength: null,
48+
minValue: null,
49+
maxValue: null,
50+
});
51+
});
52+
53+
it('parses min_length and max_length modifiers', () => {
54+
const directive = parseDirectives('min_length(3)|max_length(20)|username')[0];
55+
56+
expect(getModelBinding(directive)).toEqual({
57+
modelName: 'username',
58+
innerModelName: null,
59+
shouldRender: true,
60+
debounce: false,
61+
targetEventName: null,
62+
minLength: 3,
63+
maxLength: 20,
64+
minValue: null,
65+
maxValue: null,
66+
});
67+
});
68+
69+
it('parses min_value and max_value modifiers', () => {
70+
const directive = parseDirectives('min_value(18)|max_value(65)|age')[0];
71+
72+
expect(getModelBinding(directive)).toEqual({
73+
modelName: 'age',
74+
innerModelName: null,
75+
shouldRender: true,
76+
debounce: false,
77+
targetEventName: null,
78+
minLength: null,
79+
maxLength: null,
80+
minValue: 18,
81+
maxValue: 65,
82+
});
83+
});
84+
85+
it('handles mixed modifiers correctly', () => {
86+
const directive = parseDirectives('on(change)|norender|debounce(100)|min_value(18)|max_value(65)|age:years')[0];
87+
88+
expect(getModelBinding(directive)).toEqual({
89+
modelName: 'age',
90+
innerModelName: 'years',
91+
shouldRender: false,
92+
debounce: 100,
93+
targetEventName: 'change',
94+
minLength: null,
95+
maxLength: null,
96+
minValue: 18,
97+
maxValue: 65,
3598
});
3699
});
100+
101+
it('handles empty modifier values gracefully', () => {
102+
const directive = parseDirectives('min_length|max_length|username')[0];
103+
const binding = getModelBinding(directive);
104+
105+
expect(binding.minLength).toBeNull();
106+
expect(binding.maxLength).toBeNull();
107+
});
37108
});

0 commit comments

Comments
 (0)