Skip to content

Commit 00c5e3c

Browse files
committed
Input Model Validation Modifiers some fixes
1 parent 36f58a7 commit 00c5e3c

File tree

5 files changed

+54
-101
lines changed

5 files changed

+54
-101
lines changed

src/LiveComponent/CHANGELOG.md

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

3-
## 2.28.0
4-
5-
- `LiveComponent` Add new `data-model` modifiers:
6-
- `minlength(N)`, `maxlength(N)`: restrict updates based on input length
7-
- `minvalue(N)`, `maxvalue(N)`: restrict updates based on numeric value
8-
- Useful with `debounce()` to avoid unnecessary Ajax requests
9-
- Example:
10-
```twig
11-
<input data-model="debounce(300)|minlength(3)|name" type="text">
12-
```
13-
143
## 2.27.0
154

165
- 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/live_controller.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2364,11 +2364,9 @@ function getModelBinding (modelDirective) {
23642364
case 'debounce':
23652365
debounce = modifier.value ? Number.parseInt(modifier.value) : true;
23662366
break;
2367-
case 'min':
23682367
case 'minlength':
23692368
minLength = modifier.value ? Number.parseInt(modifier.value) : null;
23702369
break;
2371-
case 'max':
23722370
case 'maxlength':
23732371
maxLength = modifier.value ? Number.parseInt(modifier.value) : null;
23742372
break;
@@ -3003,8 +3001,7 @@ class LiveControllerDefault extends Controller {
30033001
});
30043002
directive.modifiers.forEach((modifier) => {
30053003
if (validModifiers.has(modifier.name)) {
3006-
const callable = validModifiers.get(modifier.name) ?? (() => {
3007-
});
3004+
const callable = validModifiers.get(modifier.name) ?? (() => { });
30083005
callable(modifier);
30093006
return;
30103007
}
@@ -3176,12 +3173,17 @@ class LiveControllerDefault extends Controller {
31763173
}
31773174
}
31783175
const finalValue = getValueFromElement(element, this.component.valueStore);
3179-
if ((element instanceof HTMLInputElement && ['text', 'email', 'password', 'search', 'tel', 'url'].includes(element.type)) ||
3176+
if ((element instanceof HTMLInputElement &&
3177+
['text', 'email', 'password', 'search', 'tel', 'url'].includes(element.type)) ||
31803178
element instanceof HTMLTextAreaElement) {
3181-
if (modelBinding.minLength !== null && typeof finalValue === "string" && finalValue.length < modelBinding.minLength) {
3179+
if (modelBinding.minLength !== null &&
3180+
typeof finalValue === 'string' &&
3181+
finalValue.length < modelBinding.minLength) {
31823182
return;
31833183
}
3184-
if (modelBinding.maxLength !== null && typeof finalValue === "string" && finalValue.length > modelBinding.maxLength) {
3184+
if (modelBinding.maxLength !== null &&
3185+
typeof finalValue === 'string' &&
3186+
finalValue.length > modelBinding.maxLength) {
31853187
return;
31863188
}
31873189
}

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1-
import {Controller} from '@hotwired/stimulus';
2-
import Backend, {type BackendInterface} from './Backend/Backend';
3-
import Component, {proxifyComponent} from './Component';
4-
import {StimulusElementDriver} from './Component/ElementDriver';
1+
import { Controller } from '@hotwired/stimulus';
2+
import Backend, { type BackendInterface } from './Backend/Backend';
3+
import Component, { proxifyComponent } from './Component';
4+
import { StimulusElementDriver } from './Component/ElementDriver';
55
import ChildComponentPlugin from './Component/plugins/ChildComponentPlugin';
66
import LazyPlugin from './Component/plugins/LazyPlugin';
77
import LoadingPlugin from './Component/plugins/LoadingPlugin';
88
import PageUnloadingPlugin from './Component/plugins/PageUnloadingPlugin';
9-
import type {PluginInterface} from './Component/plugins/PluginInterface';
9+
import type { PluginInterface } from './Component/plugins/PluginInterface';
1010
import PollingPlugin from './Component/plugins/PollingPlugin';
1111
import QueryStringPlugin from './Component/plugins/QueryStringPlugin';
1212
import SetValueOntoModelFieldsPlugin from './Component/plugins/SetValueOntoModelFieldsPlugin';
1313
import ValidatedFieldsPlugin from './Component/plugins/ValidatedFieldsPlugin';
14-
import {type DirectiveModifier, parseDirectives} from './Directive/directives_parser';
14+
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 { elementBelongsToThisComponent, getModelDirectiveFromElement, getValueFromElement } from './dom_utils';
1717
import getElementAsTagText from './Util/getElementAsTagText';
1818

19-
export {Component};
20-
export {getComponent} from './ComponentRegistry';
19+
export { Component };
20+
export { getComponent } from './ComponentRegistry';
2121

2222
export interface LiveEvent extends CustomEvent {
2323
detail: {
@@ -35,15 +35,15 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
3535
static values = {
3636
name: String,
3737
url: String,
38-
props: {type: Object, default: {}},
39-
propsUpdatedFromParent: {type: Object, default: {}},
40-
listeners: {type: Array, default: []},
41-
eventsToEmit: {type: Array, default: []},
42-
eventsToDispatch: {type: Array, default: []},
43-
debounce: {type: Number, default: 150},
44-
fingerprint: {type: String, default: ''},
45-
requestMethod: {type: String, default: 'post'},
46-
queryMapping: {type: Object, default: {}},
38+
props: { type: Object, default: {} },
39+
propsUpdatedFromParent: { type: Object, default: {} },
40+
listeners: { type: Array, default: [] },
41+
eventsToEmit: { type: Array, default: [] },
42+
eventsToDispatch: { type: Array, default: [] },
43+
debounce: { type: Number, default: 150 },
44+
fingerprint: { type: String, default: '' },
45+
requestMethod: { type: String, default: 'post' },
46+
queryMapping: { type: Object, default: {} },
4747
};
4848

4949
declare readonly nameValue: string;
@@ -72,8 +72,8 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
7272
pendingActionTriggerModelElement: HTMLElement | null = null;
7373

7474
private elementEventListeners: Array<{ event: string; callback: (event: any) => void }> = [
75-
{event: 'input', callback: (event) => this.handleInputEvent(event)},
76-
{event: 'change', callback: (event) => this.handleChangeEvent(event)},
75+
{ event: 'input', callback: (event) => this.handleInputEvent(event) },
76+
{ event: 'change', callback: (event) => this.handleChangeEvent(event) },
7777
];
7878
private pendingFiles: { [key: string]: HTMLInputElement } = {};
7979

@@ -128,7 +128,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
128128
}
129129
const rawAction = params.action;
130130
// all other params are considered action arguments
131-
const actionArgs = {...params};
131+
const actionArgs = { ...params };
132132
delete actionArgs.action;
133133

134134
// data-live-action-param="debounce(1000)|save"
@@ -160,8 +160,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
160160
directive.modifiers.forEach((modifier) => {
161161
if (validModifiers.has(modifier.name)) {
162162
// variable is entirely to make ts happy
163-
const callable = validModifiers.get(modifier.name) ?? (() => {
164-
});
163+
const callable = validModifiers.get(modifier.name) ?? (() => {});
165164
callable(modifier);
166165

167166
return;
@@ -197,19 +196,19 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
197196
}
198197

199198
emit(event: any) {
200-
this.getEmitDirectives(event).forEach(({name, data, nameMatch}) => {
199+
this.getEmitDirectives(event).forEach(({ name, data, nameMatch }) => {
201200
this.component.emit(name, data, nameMatch);
202201
});
203202
}
204203

205204
emitUp(event: any) {
206-
this.getEmitDirectives(event).forEach(({name, data, nameMatch}) => {
205+
this.getEmitDirectives(event).forEach(({ name, data, nameMatch }) => {
207206
this.component.emitUp(name, data, nameMatch);
208207
});
209208
}
210209

211210
emitSelf(event: any) {
212-
this.getEmitDirectives(event).forEach(({name, data}) => {
211+
this.getEmitDirectives(event).forEach(({ name, data }) => {
213212
this.component.emitSelf(name, data);
214213
});
215214
}
@@ -245,7 +244,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
245244
}
246245
const eventInfo = params.event;
247246
// all other params are considered event arguments
248-
const eventArgs = {...params};
247+
const eventArgs = { ...params };
249248
delete eventArgs.event;
250249

251250
// data-event="name(product_list)|some_event"
@@ -317,7 +316,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
317316
attributes: true,
318317
});
319318

320-
this.elementEventListeners.forEach(({event, callback}) => {
319+
this.elementEventListeners.forEach(({ event, callback }) => {
321320
this.component.element.addEventListener(event, callback);
322321
});
323322

@@ -326,7 +325,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
326325

327326
private disconnectComponent() {
328327
this.component.disconnect();
329-
this.elementEventListeners.forEach(({event, callback}) => {
328+
this.elementEventListeners.forEach(({ event, callback }) => {
330329
this.component.element.removeEventListener(event, callback);
331330
});
332331
this.dispatchEvent('disconnect');
@@ -432,13 +431,22 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
432431
const finalValue = getValueFromElement(element, this.component.valueStore);
433432

434433
if (
435-
(element instanceof HTMLInputElement && ['text', 'email', 'password', 'search', 'tel', 'url'].includes(element.type)) ||
434+
(element instanceof HTMLInputElement &&
435+
['text', 'email', 'password', 'search', 'tel', 'url'].includes(element.type)) ||
436436
element instanceof HTMLTextAreaElement
437437
) {
438-
if (modelBinding.minLength !== null && typeof finalValue === "string" && finalValue.length < modelBinding.minLength) {
438+
if (
439+
modelBinding.minLength !== null &&
440+
typeof finalValue === 'string' &&
441+
finalValue.length < modelBinding.minLength
442+
) {
439443
return;
440444
}
441-
if (modelBinding.maxLength !== null && typeof finalValue === "string" && finalValue.length > modelBinding.maxLength) {
445+
if (
446+
modelBinding.maxLength !== null &&
447+
typeof finalValue === 'string' &&
448+
finalValue.length > modelBinding.maxLength
449+
) {
442450
return;
443451
}
444452
}
@@ -459,7 +467,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
459467
detail.controller = this;
460468
detail.component = this.proxiedComponent;
461469

462-
this.dispatch(name, {detail, prefix: 'live', cancelable, bubbles: canBubble});
470+
this.dispatch(name, { detail, prefix: 'live', cancelable, bubbles: canBubble });
463471
}
464472

465473
private onMutations(mutations: MutationRecord[]): void {

src/LiveComponent/doc/index.rst

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -384,56 +384,6 @@ This can be useful along with a button that triggers a render on click:
384384
<input data-model="norender|coupon">
385385
<button data-action="live#$render">Apply</button>
386386

387-
Input Model Validation Modifiers
388-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
389-
390-
.. versionadded:: 2.28
391-
392-
When binding data using the ``data-model`` directive, you can apply input validation modifiers such as:
393-
394-
- ``minlength(<value>)``: Ensures the input length is at least the given number of characters.
395-
- ``maxlength(<value>)``: Ensures the input length does not exceed the given number of characters.
396-
- ``minvalue(<value>)``: Ensures the input value is not less than the given numeric value.
397-
- ``maxvalue(<value>)``: Ensures the input value is not greater than the given numeric value.
398-
399-
These modifiers help reduce unnecessary server requests and provide a lightweight form of frontend validation.
400-
401-
Example:
402-
403-
.. code-block:: html
404-
405-
<input data-model="minlength(3)|username" type="text" value="" />
406-
<input data-model="minvalue(10)|maxvalue(100)|quantity" type="number" value="20" />
407-
408-
In the example above:
409-
410-
- The first input will not trigger a model update until at least 3 characters are entered.
411-
- The second input will only trigger updates when the number is between 10 and 100 (inclusive).
412-
413-
Supported Input Types
414-
---------------------
415-
416-
These modifiers are only evaluated on compatible input types:
417-
418-
- ``minlength``, ``maxlength``: Only valid for ``<input type="text">``, ``type="email"``, ``type="password"``, ``type="search"``, ``type="url"``, and ``<textarea>`` elements.
419-
- ``minvalue``, ``maxvalue``: Only valid for ``<input type="number">``, ``type="range"``, and similar numeric fields.
420-
421-
Combining with debounce
422-
-----------------------
423-
424-
These validation modifiers can be combined with ``debounce``:
425-
426-
.. code-block:: html
427-
428-
<input data-model="minlength(4)|debounce(300)|comment" type="text" />
429-
430-
This prevents sending multiple Ajax requests for short inputs and ensures only meaningful data changes are transmitted after 300ms of inactivity. It's especially useful for reducing server load and avoiding over-rendering while typing.
431-
432-
Why is this feature useful?
433-
---------------------------
434-
435-
Imagine a user typing in a search input or numeric field. Without these modifiers, each keystroke could trigger a re-render and Ajax request. With modifiers like ``minlength(3)`` or ``minvalue(5)``, you ensure that only valid, actionable data reaches the server — improving performance and user experience.
436-
437387
Forcing a Re-Render Explicitly
438388
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
439389

0 commit comments

Comments
 (0)