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);
}
diff --git a/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts b/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts
index cbaaaf5a42a..d2c0ee07fcd 100644
--- a/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts
+++ b/src/LiveComponent/assets/test/Directive/get_model_binding.test.ts
@@ -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();
+ });
});
diff --git a/src/LiveComponent/assets/test/controller/model.test.ts b/src/LiveComponent/assets/test/controller/model.test.ts
index 2aef9dcd068..63756d9c927 100644
--- a/src/LiveComponent/assets/test/controller/model.test.ts
+++ b/src/LiveComponent/assets/test/controller/model.test.ts
@@ -25,7 +25,7 @@ describe('LiveController data-model Tests', () => {
data-model="name"
value="${data.name}"
>
-
+
Name is: ${data.name}
`
@@ -56,7 +56,7 @@ describe('LiveController data-model Tests', () => {
data-model="norender|name"
value="${data.name}"
>
-
+
Name is: ${data.name}
`
@@ -83,7 +83,7 @@ describe('LiveController data-model Tests', () => {
data-model="on(change)|name"
value="${data.name}"
>
-
+
Name is: ${data.name}
@@ -127,7 +127,7 @@ describe('LiveController data-model Tests', () => {
data-model="name"
data-value="Dan"
>Change name to Dan
-
+
Name is: ${data.name}
`
@@ -159,7 +159,7 @@ describe('LiveController data-model Tests', () => {
value="${data.color}"
>
-
+
Favorite color: ${data.color}
`
@@ -184,7 +184,7 @@ describe('LiveController data-model Tests', () => {
data-model="firstName"
>
-
+
First name: ${data.firstName}
`
@@ -209,7 +209,7 @@ describe('LiveController data-model Tests', () => {
data-model="sport"
data-value="cross country"
>
-
+
Sport: ${data.sport}
`
@@ -232,7 +232,7 @@ describe('LiveController data-model Tests', () => {
-
+
Name: ${data.user.name}
`
@@ -287,7 +287,7 @@ describe('LiveController data-model Tests', () => {
Checkbox 2:
-
+
Checkbox 2 is ${data.form.check2 ? 'checked' : 'unchecked'}
`
@@ -326,7 +326,7 @@ describe('LiveController data-model Tests', () => {
Checkbox 2:
-
+
Checkbox 1 is ${data.form.check1 ? 'checked' : 'unchecked'}
`
@@ -359,7 +359,7 @@ describe('LiveController data-model Tests', () => {
Checkbox 2: -1 ? 'checked' : ''} />
-
+
Checkbox 2 is ${data.form.check.indexOf('bar') > -1 ? 'checked' : 'unchecked'}
`
@@ -393,7 +393,7 @@ describe('LiveController data-model Tests', () => {
Checkbox 2: -1 ? 'checked' : ''} />
-
+
Checkbox 2 is ${data.check.indexOf('bar') > -1 ? 'checked' : 'unchecked'}
`
@@ -427,7 +427,7 @@ describe('LiveController data-model Tests', () => {
Checkbox 2: -1 ? 'checked' : ''} />
-
+
Checkbox 1 is ${data.form.check.indexOf('foo') > -1 ? 'checked' : 'unchecked'}
`
@@ -459,7 +459,7 @@ describe('LiveController data-model Tests', () => {
-
+
Checkbox 1 is ${data.check.indexOf('foo') > -1 ? 'checked' : 'unchecked'}
`
@@ -493,7 +493,7 @@ describe('LiveController data-model Tests', () => {
-
+
Option 2 is ${data.form.select?.indexOf('bar') > -1 ? 'selected' : 'unselected'}
`
@@ -525,7 +525,7 @@ describe('LiveController data-model Tests', () => {
-
+
Option 2 is ${data.form.select?.indexOf('bar') > -1 ? 'selected' : 'unselected'}
`
@@ -976,4 +976,164 @@ describe('LiveController data-model Tests', () => {
expect(test.element).toHaveTextContent('Food is: Popcorn');
expect(test.element).toHaveTextContent('Rating is: 5');
});
+
+ it('does not update model if input value length is less than min_length', async () => {
+ const test = await createTest(
+ { username: '' },
+ (data: any) => `
+
+
+ Username: ${data.username}
+
+ `
+ );
+
+ await userEvent.type(test.queryByDataModel('username'), 'ab');
+ expect(test.component.valueStore.getOriginalProps()).toEqual({ username: '' });
+ });
+
+ it('updates model if input value length satisfies min_length', async () => {
+ const test = await createTest(
+ { username: '' },
+ (data: any) => `
+
+
+ Username: ${data.username}
+
+ `
+ );
+
+ test.expectsAjaxCall().expectUpdatedData({ username: 'abc' });
+
+ await userEvent.type(test.queryByDataModel('username'), 'abc');
+ await waitFor(() => expect(test.element).toHaveTextContent('Username: abc'));
+ });
+
+ it('does not update model if input value length exceeds max_length', async () => {
+ const test = await createTest(
+ { username: '' },
+ (data: any) => `
+
+
+ Username: ${data.username}
+
+ `
+ );
+
+ await userEvent.type(test.queryByDataModel('username'), 'abcdef');
+ expect(test.component.valueStore.getOriginalProps()).toEqual({ username: '' });
+ });
+
+ it('updates model if number input value is within min_value/max_value range', async () => {
+ const test = await createTest(
+ { age: '' },
+ (data: any) => `
+
+
+ Age: ${data.age}
+
+ `
+ );
+
+ test.expectsAjaxCall().expectUpdatedData({ age: '30' });
+
+ const input = test.queryByDataModel('age');
+ await userEvent.clear(input);
+ await userEvent.type(input, '30');
+
+ await waitFor(() => expect(test.element).toHaveTextContent('Age: 30'));
+ });
+
+ it('does not update model if number input value is less than min_value', async () => {
+ const test = await createTest(
+ { age: '' },
+ (data: any) => `
+
+
+ Age: ${data.age}
+
+ `
+ );
+
+ const input = test.queryByDataModel('age');
+ await userEvent.clear(input);
+ await userEvent.type(input, '15');
+
+ expect(test.component.valueStore.getOriginalProps()).toEqual({ age: '' });
+ });
+
+ it('does not update model if number input value exceeds max_value', async () => {
+ const test = await createTest(
+ { age: '' },
+ (data: any) => `
+
+
+ Age: ${data.age}
+
+ `
+ );
+
+ const input = test.queryByDataModel('age');
+ await userEvent.clear(input);
+ await userEvent.type(input, '70');
+
+ expect(test.component.valueStore.getOriginalProps()).toEqual({ age: '' });
+ });
+
+ it('does not update model if value is shorter than min_length or longer than max_length', async () => {
+ const test = await createTest(
+ { username: '' },
+ (data: any) => `
+
+
+ Username: ${data.username}
+
+ `
+ );
+
+ // too short
+ await userEvent.type(test.queryByDataModel('username'), 'ab');
+ expect(test.component.valueStore.getOriginalProps()).toEqual({ username: '' });
+
+ // too long
+ await userEvent.clear(test.queryByDataModel('username'));
+ await userEvent.type(test.queryByDataModel('username'), 'abcdef');
+ expect(test.component.valueStore.getOriginalProps()).toEqual({ username: '' });
+
+ // valid
+ test.expectsAjaxCall().expectUpdatedData({ username: 'abc' });
+ await userEvent.clear(test.queryByDataModel('username'));
+ await userEvent.type(test.queryByDataModel('username'), 'abc');
+ await waitFor(() => expect(test.element).toHaveTextContent('Username: abc'));
+ });
+
+ it('does not update model if number is less than min_value or greater than max_value', async () => {
+ const test = await createTest(
+ { age: '' },
+ (data: any) => `
+
+
+ Age: ${data.age}
+
+ `
+ );
+
+ const input = test.queryByDataModel('age');
+
+ // too low
+ await userEvent.clear(input);
+ await userEvent.type(input, '17');
+ expect(test.component.valueStore.getOriginalProps()).toEqual({ age: '' });
+
+ // too high
+ await userEvent.clear(input);
+ await userEvent.type(input, '70');
+ expect(test.component.valueStore.getOriginalProps()).toEqual({ age: '' });
+
+ // valid
+ test.expectsAjaxCall().expectUpdatedData({ age: '30' });
+ await userEvent.clear(input);
+ await userEvent.type(input, '30');
+ await waitFor(() => expect(test.element).toHaveTextContent('Age: 30'));
+ });
});
diff --git a/src/LiveComponent/assets/test/dom_utils.test.ts b/src/LiveComponent/assets/test/dom_utils.test.ts
index a1b0a17da39..0e656d5fa73 100644
--- a/src/LiveComponent/assets/test/dom_utils.test.ts
+++ b/src/LiveComponent/assets/test/dom_utils.test.ts
@@ -7,6 +7,9 @@ import {
getModelDirectiveFromElement,
getValueFromElement,
htmlToElement,
+ isNumericalInputElement,
+ isTextareaElement,
+ isTextualInputElement,
setValueOnElement,
} from '../src/dom_utils';
import { noopElementDriver } from './tools';
@@ -324,3 +327,92 @@ describe('cloneHTMLElement', () => {
expect(clone.outerHTML).toEqual('');
});
});
+
+describe('isTextualInputElement', () => {
+ describe.each([
+ ['text', true],
+ ['email', true],
+ ['password', true],
+ ['search', true],
+ ['tel', true],
+ ['url', true],
+ ['number', false],
+ ['range', false],
+ ['file', false],
+ ['date', false],
+ ['checkbox', false],
+ ['radio', false],
+ ['submit', false],
+ ['reset', false],
+ ['color', false],
+ ['datetime-local', false],
+ ['hidden', false],
+ ['image', false],
+ ['month', false],
+ ['time', false],
+ ['week', false],
+ ])('input[type="%s"] should return %s', (type, expected) => {
+ it(`returns ${expected}`, () => {
+ const input = document.createElement('input');
+ if (typeof type === 'string') {
+ input.type = type;
+ }
+ expect(isTextualInputElement(input)).toBe(expected);
+ });
+ });
+
+ it('returns false for