Skip to content

Commit 36f58a7

Browse files
committed
Input Model Validation Modifiers
1 parent 9e5f700 commit 36f58a7

File tree

7 files changed

+371
-46
lines changed

7 files changed

+371
-46
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
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+
314
## 2.27.0
415

516
- Add events assertions in `InteractsWithLiveComponents`:

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2343,6 +2343,10 @@ function getModelBinding (modelDirective) {
23432343
let shouldRender = true;
23442344
let targetEventName = null;
23452345
let debounce = false;
2346+
let minLength = null;
2347+
let maxLength = null;
2348+
let minValue = null;
2349+
let maxValue = null;
23462350
modelDirective.modifiers.forEach((modifier) => {
23472351
switch (modifier.name) {
23482352
case 'on':
@@ -2360,6 +2364,20 @@ function getModelBinding (modelDirective) {
23602364
case 'debounce':
23612365
debounce = modifier.value ? Number.parseInt(modifier.value) : true;
23622366
break;
2367+
case 'min':
2368+
case 'minlength':
2369+
minLength = modifier.value ? Number.parseInt(modifier.value) : null;
2370+
break;
2371+
case 'max':
2372+
case 'maxlength':
2373+
maxLength = modifier.value ? Number.parseInt(modifier.value) : null;
2374+
break;
2375+
case 'minvalue':
2376+
minValue = modifier.value ? Number.parseFloat(modifier.value) : null;
2377+
break;
2378+
case 'maxvalue':
2379+
maxValue = modifier.value ? Number.parseFloat(modifier.value) : null;
2380+
break;
23632381
default:
23642382
throw new Error(`Unknown modifier "${modifier.name}" in data-model="${modelDirective.getString()}".`);
23652383
}
@@ -2371,6 +2389,10 @@ function getModelBinding (modelDirective) {
23712389
shouldRender,
23722390
debounce,
23732391
targetEventName,
2392+
minLength,
2393+
maxLength,
2394+
minValue,
2395+
maxValue,
23742396
};
23752397
}
23762398

@@ -2981,7 +3003,8 @@ class LiveControllerDefault extends Controller {
29813003
});
29823004
directive.modifiers.forEach((modifier) => {
29833005
if (validModifiers.has(modifier.name)) {
2984-
const callable = validModifiers.get(modifier.name) ?? (() => { });
3006+
const callable = validModifiers.get(modifier.name) ?? (() => {
3007+
});
29853008
callable(modifier);
29863009
return;
29873010
}
@@ -3153,6 +3176,23 @@ class LiveControllerDefault extends Controller {
31533176
}
31543177
}
31553178
const finalValue = getValueFromElement(element, this.component.valueStore);
3179+
if ((element instanceof HTMLInputElement && ['text', 'email', 'password', 'search', 'tel', 'url'].includes(element.type)) ||
3180+
element instanceof HTMLTextAreaElement) {
3181+
if (modelBinding.minLength !== null && typeof finalValue === "string" && finalValue.length < modelBinding.minLength) {
3182+
return;
3183+
}
3184+
if (modelBinding.maxLength !== null && typeof finalValue === "string" && finalValue.length > modelBinding.maxLength) {
3185+
return;
3186+
}
3187+
}
3188+
if (element instanceof HTMLInputElement && element.type === 'number') {
3189+
if (modelBinding.minValue !== null && Number(finalValue) < modelBinding.minValue) {
3190+
return;
3191+
}
3192+
if (modelBinding.maxValue !== null && Number(finalValue) > modelBinding.maxValue) {
3193+
return;
3194+
}
3195+
}
31563196
this.component.set(modelBinding.modelName, finalValue, modelBinding.shouldRender, modelBinding.debounce);
31573197
}
31583198
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 'minlength':
52+
minLength = modifier.value ? Number.parseInt(modifier.value) : null;
53+
54+
break;
55+
56+
case 'maxlength':
57+
maxLength = modifier.value ? Number.parseInt(modifier.value) : null;
58+
59+
break;
60+
61+
case 'minvalue':
62+
minValue = modifier.value ? Number.parseFloat(modifier.value) : null;
63+
64+
break;
65+
66+
case 'maxvalue':
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/live_controller.ts

Lines changed: 52 additions & 29 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: {
@@ -30,19 +30,20 @@ export interface LiveController {
3030
element: HTMLElement;
3131
component: Component;
3232
}
33+
3334
export default class LiveControllerDefault extends Controller<HTMLElement> implements LiveController {
3435
static values = {
3536
name: String,
3637
url: String,
37-
props: { type: Object, default: {} },
38-
propsUpdatedFromParent: { type: Object, default: {} },
39-
listeners: { type: Array, default: [] },
40-
eventsToEmit: { type: Array, default: [] },
41-
eventsToDispatch: { type: Array, default: [] },
42-
debounce: { type: Number, default: 150 },
43-
fingerprint: { type: String, default: '' },
44-
requestMethod: { type: String, default: 'post' },
45-
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: {}},
4647
};
4748

4849
declare readonly nameValue: string;
@@ -71,8 +72,8 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
7172
pendingActionTriggerModelElement: HTMLElement | null = null;
7273

7374
private elementEventListeners: Array<{ event: string; callback: (event: any) => void }> = [
74-
{ event: 'input', callback: (event) => this.handleInputEvent(event) },
75-
{ event: 'change', callback: (event) => this.handleChangeEvent(event) },
75+
{event: 'input', callback: (event) => this.handleInputEvent(event)},
76+
{event: 'change', callback: (event) => this.handleChangeEvent(event)},
7677
];
7778
private pendingFiles: { [key: string]: HTMLInputElement } = {};
7879

@@ -127,7 +128,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
127128
}
128129
const rawAction = params.action;
129130
// all other params are considered action arguments
130-
const actionArgs = { ...params };
131+
const actionArgs = {...params};
131132
delete actionArgs.action;
132133

133134
// data-live-action-param="debounce(1000)|save"
@@ -159,7 +160,8 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
159160
directive.modifiers.forEach((modifier) => {
160161
if (validModifiers.has(modifier.name)) {
161162
// variable is entirely to make ts happy
162-
const callable = validModifiers.get(modifier.name) ?? (() => {});
163+
const callable = validModifiers.get(modifier.name) ?? (() => {
164+
});
163165
callable(modifier);
164166

165167
return;
@@ -195,19 +197,19 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
195197
}
196198

197199
emit(event: any) {
198-
this.getEmitDirectives(event).forEach(({ name, data, nameMatch }) => {
200+
this.getEmitDirectives(event).forEach(({name, data, nameMatch}) => {
199201
this.component.emit(name, data, nameMatch);
200202
});
201203
}
202204

203205
emitUp(event: any) {
204-
this.getEmitDirectives(event).forEach(({ name, data, nameMatch }) => {
206+
this.getEmitDirectives(event).forEach(({name, data, nameMatch}) => {
205207
this.component.emitUp(name, data, nameMatch);
206208
});
207209
}
208210

209211
emitSelf(event: any) {
210-
this.getEmitDirectives(event).forEach(({ name, data }) => {
212+
this.getEmitDirectives(event).forEach(({name, data}) => {
211213
this.component.emitSelf(name, data);
212214
});
213215
}
@@ -243,7 +245,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
243245
}
244246
const eventInfo = params.event;
245247
// all other params are considered event arguments
246-
const eventArgs = { ...params };
248+
const eventArgs = {...params};
247249
delete eventArgs.event;
248250

249251
// data-event="name(product_list)|some_event"
@@ -315,7 +317,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
315317
attributes: true,
316318
});
317319

318-
this.elementEventListeners.forEach(({ event, callback }) => {
320+
this.elementEventListeners.forEach(({event, callback}) => {
319321
this.component.element.addEventListener(event, callback);
320322
});
321323

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

325327
private disconnectComponent() {
326328
this.component.disconnect();
327-
this.elementEventListeners.forEach(({ event, callback }) => {
329+
this.elementEventListeners.forEach(({event, callback}) => {
328330
this.component.element.removeEventListener(event, callback);
329331
});
330332
this.dispatchEvent('disconnect');
@@ -429,14 +431,35 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
429431

430432
const finalValue = getValueFromElement(element, this.component.valueStore);
431433

434+
if (
435+
(element instanceof HTMLInputElement && ['text', 'email', 'password', 'search', 'tel', 'url'].includes(element.type)) ||
436+
element instanceof HTMLTextAreaElement
437+
) {
438+
if (modelBinding.minLength !== null && typeof finalValue === "string" && finalValue.length < modelBinding.minLength) {
439+
return;
440+
}
441+
if (modelBinding.maxLength !== null && typeof finalValue === "string" && finalValue.length > modelBinding.maxLength) {
442+
return;
443+
}
444+
}
445+
446+
if (element instanceof HTMLInputElement && element.type === 'number') {
447+
if (modelBinding.minValue !== null && Number(finalValue) < modelBinding.minValue) {
448+
return;
449+
}
450+
if (modelBinding.maxValue !== null && Number(finalValue) > modelBinding.maxValue) {
451+
return;
452+
}
453+
}
454+
432455
this.component.set(modelBinding.modelName, finalValue, modelBinding.shouldRender, modelBinding.debounce);
433456
}
434457

435458
private dispatchEvent(name: string, detail: any = {}, canBubble = true, cancelable = false) {
436459
detail.controller = this;
437460
detail.component = this.proxiedComponent;
438461

439-
this.dispatch(name, { detail, prefix: 'live', cancelable, bubbles: canBubble });
462+
this.dispatch(name, {detail, prefix: 'live', cancelable, bubbles: canBubble});
440463
}
441464

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

0 commit comments

Comments
 (0)