Skip to content

Commit 7c93466

Browse files
committed
More tests, Form Types Guard, Documentation
1 parent b1d6fa2 commit 7c93466

File tree

8 files changed

+239
-42
lines changed

8 files changed

+239
-42
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@
22

33
## 2.28.0
44

5-
- `LiveComponent` Add new `data-model` modifiers:
6-
- `min_length(N)`, `max_length(N)`: restrict updates based on input length
7-
- `min_value(N)`, `max_value(N)`: restrict updates based on numeric value
8-
- Useful with `debounce()` to avoid unnecessary Ajax requests
5+
- Add new modifiers for input validations, useful to prevent uneccessary HTTP requests:
6+
- `min_length(n)` and `max_length(n)`: validate length from textual input elements
7+
- `min_value(Nn` and `max_value(n)`: validate value from numeral input elements
98

109
```twig
11-
<input data-model="debounce(300)|min_length(3)|name" type="text">
10+
<input data-model="min_length(3)|debounce(300)|name" type="text">
1211
```
1312

1413
## 2.27.0

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: 14 additions & 6 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() {
@@ -3173,9 +3182,7 @@ class LiveControllerDefault extends Controller {
31733182
}
31743183
}
31753184
const finalValue = getValueFromElement(element, this.component.valueStore);
3176-
if ((element instanceof HTMLInputElement &&
3177-
['text', 'email', 'password', 'search', 'tel', 'url'].includes(element.type)) ||
3178-
element instanceof HTMLTextAreaElement) {
3185+
if (isTextualInputElement(element) || isTextareaElement(element)) {
31793186
if (modelBinding.minLength !== null &&
31803187
typeof finalValue === 'string' &&
31813188
finalValue.length < modelBinding.minLength) {
@@ -3187,11 +3194,12 @@ class LiveControllerDefault extends Controller {
31873194
return;
31883195
}
31893196
}
3190-
if (element instanceof HTMLInputElement && element.type === 'number') {
3191-
if (modelBinding.minValue !== null && Number(finalValue) < modelBinding.minValue) {
3197+
if (isNumericalInputElement(element)) {
3198+
const numericValue = Number(finalValue);
3199+
if (modelBinding.minValue !== null && numericValue < modelBinding.minValue) {
31923200
return;
31933201
}
3194-
if (modelBinding.maxValue !== null && Number(finalValue) > modelBinding.maxValue) {
3202+
if (modelBinding.maxValue !== null && numericValue > modelBinding.maxValue) {
31953203
return;
31963204
}
31973205
}

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: 16 additions & 9 deletions
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 };
@@ -430,18 +437,15 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
430437

431438
const finalValue = getValueFromElement(element, this.component.valueStore);
432439

433-
if (
434-
(element instanceof HTMLInputElement &&
435-
['text', 'email', 'password', 'search', 'tel', 'url'].includes(element.type)) ||
436-
element instanceof HTMLTextAreaElement
437-
) {
440+
if (isTextualInputElement(element) || isTextareaElement(element)) {
438441
if (
439442
modelBinding.minLength !== null &&
440443
typeof finalValue === 'string' &&
441444
finalValue.length < modelBinding.minLength
442445
) {
443446
return;
444447
}
448+
445449
if (
446450
modelBinding.maxLength !== null &&
447451
typeof finalValue === 'string' &&
@@ -451,11 +455,14 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
451455
}
452456
}
453457

454-
if (element instanceof HTMLInputElement && element.type === 'number') {
455-
if (modelBinding.minValue !== null && Number(finalValue) < modelBinding.minValue) {
458+
if (isNumericalInputElement(element)) {
459+
const numericValue = Number(finalValue);
460+
461+
if (modelBinding.minValue !== null && numericValue < modelBinding.minValue) {
456462
return;
457463
}
458-
if (modelBinding.maxValue !== null && Number(finalValue) > modelBinding.maxValue) {
464+
465+
if (modelBinding.maxValue !== null && numericValue > modelBinding.maxValue) {
459466
return;
460467
}
461468
}

src/LiveComponent/assets/test/controller/model.test.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -989,8 +989,6 @@ describe('LiveController data-model Tests', () => {
989989
);
990990

991991
await userEvent.type(test.queryByDataModel('username'), 'ab');
992-
993-
// model değeri güncellenmedi
994992
expect(test.component.valueStore.getOriginalProps()).toEqual({ username: '' });
995993
});
996994

@@ -1081,4 +1079,61 @@ describe('LiveController data-model Tests', () => {
10811079

10821080
expect(test.component.valueStore.getOriginalProps()).toEqual({ age: '' });
10831081
});
1082+
1083+
it('does not update model if value is shorter than min_length or longer than max_length', async () => {
1084+
const test = await createTest(
1085+
{ username: '' },
1086+
(data: any) => `
1087+
<div ${initComponent(data)}>
1088+
<input data-model="min_length(3)|max_length(5)|username" value="${data.username}" />
1089+
Username: ${data.username}
1090+
</div>
1091+
`
1092+
);
1093+
1094+
// too short
1095+
await userEvent.type(test.queryByDataModel('username'), 'ab');
1096+
expect(test.component.valueStore.getOriginalProps()).toEqual({ username: '' });
1097+
1098+
// too long
1099+
await userEvent.clear(test.queryByDataModel('username'));
1100+
await userEvent.type(test.queryByDataModel('username'), 'abcdef');
1101+
expect(test.component.valueStore.getOriginalProps()).toEqual({ username: '' });
1102+
1103+
// valid
1104+
test.expectsAjaxCall().expectUpdatedData({ username: 'abc' });
1105+
await userEvent.clear(test.queryByDataModel('username'));
1106+
await userEvent.type(test.queryByDataModel('username'), 'abc');
1107+
await waitFor(() => expect(test.element).toHaveTextContent('Username: abc'));
1108+
});
1109+
1110+
it('does not update model if number is less than min_value or greater than max_value', async () => {
1111+
const test = await createTest(
1112+
{ age: '' },
1113+
(data: any) => `
1114+
<div ${initComponent(data)}>
1115+
<input data-model="min_value(18)|max_value(65)|age" type="number" />
1116+
Age: ${data.age}
1117+
</div>
1118+
`
1119+
);
1120+
1121+
const input = test.queryByDataModel('age');
1122+
1123+
// too low
1124+
await userEvent.clear(input);
1125+
await userEvent.type(input, '17');
1126+
expect(test.component.valueStore.getOriginalProps()).toEqual({ age: '' });
1127+
1128+
// too high
1129+
await userEvent.clear(input);
1130+
await userEvent.type(input, '70');
1131+
expect(test.component.valueStore.getOriginalProps()).toEqual({ age: '' });
1132+
1133+
// valid
1134+
test.expectsAjaxCall().expectUpdatedData({ age: '30' });
1135+
await userEvent.clear(input);
1136+
await userEvent.type(input, '30');
1137+
await waitFor(() => expect(test.element).toHaveTextContent('Age: 30'));
1138+
});
10841139
});

src/LiveComponent/assets/test/dom_utils.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import {
77
getModelDirectiveFromElement,
88
getValueFromElement,
99
htmlToElement,
10+
isNumericalInputElement,
11+
isTextareaElement,
12+
isTextualInputElement,
1013
setValueOnElement,
1114
} from '../src/dom_utils';
1215
import { noopElementDriver } from './tools';
@@ -324,3 +327,92 @@ describe('cloneHTMLElement', () => {
324327
expect(clone.outerHTML).toEqual('<div class="foo"></div>');
325328
});
326329
});
330+
331+
describe('isTextualInputElement', () => {
332+
describe.each([
333+
['text', true],
334+
['email', true],
335+
['password', true],
336+
['search', true],
337+
['tel', true],
338+
['url', true],
339+
['number', false],
340+
['range', false],
341+
['file', false],
342+
['date', false],
343+
['checkbox', false],
344+
['radio', false],
345+
['submit', false],
346+
['reset', false],
347+
['color', false],
348+
['datetime-local', false],
349+
['hidden', false],
350+
['image', false],
351+
['month', false],
352+
['time', false],
353+
['week', false],
354+
])('input[type="%s"] should return %s', (type, expected) => {
355+
it(`returns ${expected}`, () => {
356+
const input = document.createElement('input');
357+
if (typeof type === 'string') {
358+
input.type = type;
359+
}
360+
expect(isTextualInputElement(input)).toBe(expected);
361+
});
362+
});
363+
364+
it('returns false for <textarea>', () => {
365+
const textarea = document.createElement('textarea');
366+
expect(isTextualInputElement(textarea)).toBe(false);
367+
});
368+
369+
it('returns false for non-input elements', () => {
370+
const div = document.createElement('div');
371+
expect(isTextualInputElement(div)).toBe(false);
372+
});
373+
});
374+
375+
describe('isTextareaElement', () => {
376+
it('returns true for <textarea>', () => {
377+
const textarea = document.createElement('textarea');
378+
expect(isTextareaElement(textarea)).toBe(true);
379+
});
380+
381+
it('returns false for <input>', () => {
382+
const input = document.createElement('input');
383+
input.type = 'text';
384+
expect(isTextareaElement(input)).toBe(false);
385+
});
386+
387+
it('returns false for other elements', () => {
388+
const span = document.createElement('span');
389+
expect(isTextareaElement(span)).toBe(false);
390+
});
391+
});
392+
393+
describe('isNumericalInputElement', () => {
394+
describe.each([
395+
['number', true],
396+
['range', true],
397+
['text', false],
398+
['email', false],
399+
['checkbox', false],
400+
['submit', false],
401+
])('input[type="%s"] should return %s', (type, expected) => {
402+
it(`returns ${expected}`, () => {
403+
const input = document.createElement('input');
404+
if (typeof type === 'string') {
405+
input.type = type;
406+
}
407+
expect(isNumericalInputElement(input)).toBe(expected);
408+
});
409+
});
410+
411+
it('returns false for non-input elements', () => {
412+
const div = document.createElement('div');
413+
expect(isNumericalInputElement(div)).toBe(false);
414+
415+
const textarea = document.createElement('textarea');
416+
expect(isNumericalInputElement(textarea)).toBe(false);
417+
});
418+
});

0 commit comments

Comments
 (0)