diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 5657bf284277b5..d54e47d4afa91c 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Enhancements + +- DataForm: add support for `min`/`max` and `minLength`/`maxLength` validation for relevant controls. [#73465](https://github.com/WordPress/gutenberg/pull/73465) + ## 11.0.0 (2025-11-26) ### Enhancements diff --git a/packages/dataviews/src/dataform-controls/textarea.tsx b/packages/dataviews/src/dataform-controls/textarea.tsx index d74e7c837c38f1..69624db7f628f3 100644 --- a/packages/dataviews/src/dataform-controls/textarea.tsx +++ b/packages/dataviews/src/dataform-controls/textarea.tsx @@ -41,6 +41,8 @@ export default function Textarea< Item >( { help={ description } onChange={ onChangeControl } rows={ rows } + minLength={ isValid?.minLength } + maxLength={ isValid?.maxLength } __next40pxDefaultSize __nextHasNoMarginBottom hideLabelFromVision={ hideLabelFromVision } diff --git a/packages/dataviews/src/dataform-controls/utils/validated-input.tsx b/packages/dataviews/src/dataform-controls/utils/validated-input.tsx index 3d497a643c23e4..b29b3c6a01af7c 100644 --- a/packages/dataviews/src/dataform-controls/utils/validated-input.tsx +++ b/packages/dataviews/src/dataform-controls/utils/validated-input.tsx @@ -68,6 +68,8 @@ export default function ValidatedText< Item >( { prefix={ prefix } suffix={ suffix } pattern={ isValid?.pattern } + minLength={ isValid?.minLength } + maxLength={ isValid?.maxLength } __next40pxDefaultSize /> ); diff --git a/packages/dataviews/src/dataform-controls/utils/validated-number.tsx b/packages/dataviews/src/dataform-controls/utils/validated-number.tsx index 0c77e26fc99066..33f7690c9d42ea 100644 --- a/packages/dataviews/src/dataform-controls/utils/validated-number.tsx +++ b/packages/dataviews/src/dataform-controls/utils/validated-number.tsx @@ -166,6 +166,8 @@ export default function ValidatedNumber< Item >( { __next40pxDefaultSize hideLabelFromVision={ hideLabelFromVision } step={ step } + min={ isValid?.min } + max={ isValid?.max } /> ); } diff --git a/packages/dataviews/src/hooks/use-form-validity.ts b/packages/dataviews/src/hooks/use-form-validity.ts index d068bfca50ceee..263ff635fed6e3 100644 --- a/packages/dataviews/src/hooks/use-form-validity.ts +++ b/packages/dataviews/src/hooks/use-form-validity.ts @@ -482,6 +482,92 @@ function validateFormField< Item >( } } + // Validate the field: isValid.min + if ( + !! formField.field && + formField.field.isValid.min !== undefined && + ( formField.field.type === 'integer' || + formField.field.type === 'number' ) + ) { + const value = formField.field.getValue( { item } ); + if ( ! isEmptyNullOrUndefined( value ) ) { + if ( Number( value ) < formField.field.isValid.min ) { + return { + min: { + type: 'invalid', + message: __( 'Value is below the minimum.' ), + }, + }; + } + } + } + + // Validate the field: isValid.max + if ( + !! formField.field && + formField.field.isValid.max !== undefined && + ( formField.field.type === 'integer' || + formField.field.type === 'number' ) + ) { + const value = formField.field.getValue( { item } ); + if ( ! isEmptyNullOrUndefined( value ) ) { + if ( Number( value ) > formField.field.isValid.max ) { + return { + max: { + type: 'invalid', + message: __( 'Value is above the maximum.' ), + }, + }; + } + } + } + + // Validate the field: isValid.minLength + if ( + !! formField.field && + formField.field.isValid.minLength !== undefined && + ( formField.field.type === 'text' || + formField.field.type === 'email' || + formField.field.type === 'url' || + formField.field.type === 'telephone' || + formField.field.type === 'password' ) + ) { + const value = formField.field.getValue( { item } ); + if ( ! isEmptyNullOrUndefined( value ) ) { + if ( String( value ).length < formField.field.isValid.minLength ) { + return { + minLength: { + type: 'invalid', + message: __( 'Value is too short.' ), + }, + }; + } + } + } + + // Validate the field: isValid.maxLength + if ( + !! formField.field && + formField.field.isValid.maxLength !== undefined && + ( formField.field.type === 'text' || + formField.field.type === 'email' || + formField.field.type === 'url' || + formField.field.type === 'telephone' || + formField.field.type === 'password' ) + ) { + const value = formField.field.getValue( { item } ); + if ( ! isEmptyNullOrUndefined( value ) ) { + if ( String( value ).length > formField.field.isValid.maxLength ) { + return { + maxLength: { + type: 'invalid', + message: __( 'Value is too long.' ), + }, + }; + } + } + } + // Validate the field: isValid.elements (static) if ( !! formField.field && diff --git a/packages/dataviews/src/stories/dataform.story.tsx b/packages/dataviews/src/stories/dataform.story.tsx index 2d47fa3a94476f..b473b9397357ca 100644 --- a/packages/dataviews/src/stories/dataform.story.tsx +++ b/packages/dataviews/src/stories/dataform.story.tsx @@ -554,11 +554,13 @@ const ValidationComponent = ( { elements, custom, pattern, + minMax, }: { required: boolean; elements: 'sync' | 'async' | 'none'; custom: 'sync' | 'async' | 'none'; pattern: boolean; + minMax: boolean; } ) => { type ValidatedItem = { text: string; @@ -909,20 +911,63 @@ const ValidationComponent = ( { return undefined; }; + // Helper functions to avoid nested ternary expressions + const getValidationPlaceholder = ( + basePattern: string, + baseMinMax: string, + bothPattern: string + ) => { + if ( pattern && minMax ) { + return bothPattern; + } + if ( pattern ) { + return basePattern; + } + if ( minMax ) { + return baseMinMax; + } + return undefined; + }; + + const getValidationDescription = ( + patternDesc: string, + minMaxDesc: string, + bothDesc: string + ) => { + if ( pattern && minMax ) { + return bothDesc; + } + if ( pattern ) { + return patternDesc; + } + if ( minMax ) { + return minMaxDesc; + } + return undefined; + }; + return [ { id: 'text', type: 'text', label: 'Text', - placeholder: pattern ? 'user_name123' : undefined, - description: pattern - ? 'Must contain only letters, numbers, and underscores' - : undefined, + placeholder: getValidationPlaceholder( + 'user_name (alphanumeric+underscore)', + 'Min 5, max 20 characters', + 'user_name (5-20 chars, alphanumeric+underscore)' + ), + description: getValidationDescription( + 'Must contain only letters, numbers, and underscores', + 'Must be between 5 and 20 characters', + 'Letters, numbers, underscores only AND 5-20 characters' + ), isValid: { required, elements: elements !== 'none' ? true : false, custom: maybeCustomRule( customTextRule ), pattern: pattern ? '^[a-zA-Z0-9_]+$' : undefined, + minLength: minMax ? 5 : undefined, + maxLength: minMax ? 20 : undefined, }, }, { @@ -971,61 +1016,89 @@ const ValidationComponent = ( { type: 'text', Edit: 'textarea', label: 'Textarea', + placeholder: minMax ? 'Min 10, max 200 characters' : undefined, + description: minMax + ? 'Must be between 10 and 200 characters' + : undefined, isValid: { required, elements: elements !== 'none' ? true : false, custom: maybeCustomRule( customTextareaRule ), + minLength: minMax ? 10 : undefined, + maxLength: minMax ? 200 : undefined, }, }, { id: 'email', type: 'email', label: 'e-mail', - placeholder: pattern ? 'user@company.com' : undefined, - description: pattern - ? 'Email must be from @company.com domain' - : undefined, + placeholder: getValidationPlaceholder( + 'user@company.com', + 'Min 15, max 100 characters', + 'user@company.com (15-100 chars)' + ), + description: getValidationDescription( + 'Email must be from @company.com domain', + 'Must be between 15 and 100 characters', + 'Must be @company.com domain AND 15-100 characters' + ), isValid: { required, elements: elements !== 'none' ? true : false, custom: maybeCustomRule( customEmailRule ), - pattern: pattern ? '^[a-zA-Z0-9]+@company.com$' : undefined, + pattern: pattern + ? '^[a-zA-Z0-9._%+-]+@company\\.com$' + : undefined, + minLength: minMax ? 15 : undefined, + maxLength: minMax ? 100 : undefined, }, }, { id: 'telephone', type: 'telephone', label: 'telephone', - placeholder: pattern ? '+1-555-123-4567' : undefined, - description: pattern - ? 'US phone format with country code' - : undefined, + placeholder: getValidationPlaceholder( + '+1-555-123-4567', + 'Min 10, max 20 characters', + '+1-555-123-4567 (10-20 chars)' + ), + description: getValidationDescription( + 'US phone format with country code', + 'Must be between 10 and 20 characters', + 'US format +1-XXX... AND 10-20 characters' + ), isValid: { required, elements: elements !== 'none' ? true : false, custom: maybeCustomRule( customTelephoneRule ), - pattern: pattern - ? '^\\+1-\\d{3}-\\d{3}-\\d{4}$' - : undefined, + pattern: pattern ? '^\\+1-\\d{3}-[0-9-]*$' : undefined, + minLength: minMax ? 10 : undefined, + maxLength: minMax ? 20 : undefined, }, }, { id: 'url', type: 'url', label: 'URL', - placeholder: pattern - ? 'https://github.com/user/repo' - : undefined, - description: pattern - ? 'Must be a GitHub repository URL' - : undefined, + placeholder: getValidationPlaceholder( + 'https://github.com/user/repo', + 'Min 25, max 255 characters', + 'https://github.com/user/repo (10-255 chars)' + ), + description: getValidationDescription( + 'Must be a GitHub repository URL', + 'Must be between 25 and 255 characters', + 'GitHub repository URL AND 25-255 characters' + ), isValid: { required, elements: elements !== 'none' ? true : false, custom: maybeCustomRule( customUrlRule ), pattern: pattern - ? '^https:\\/\\/github\\.com\\/.*' + ? '^https:\\/\\/github\\.com\\/.+$' : undefined, + minLength: minMax ? 25 : undefined, + maxLength: minMax ? 255 : undefined, }, }, { @@ -1042,20 +1115,28 @@ const ValidationComponent = ( { id: 'integer', type: 'integer', label: 'Integer', + placeholder: minMax ? 'Min 10, max 100' : undefined, + description: minMax ? 'Must be between 10 and 100' : undefined, isValid: { required, elements: elements !== 'none' ? true : false, custom: maybeCustomRule( customIntegerRule ), + min: minMax ? 0 : undefined, + max: minMax ? 100 : undefined, }, }, { id: 'number', type: 'number', label: 'Number', + placeholder: minMax ? 'Min 10, max 100' : undefined, + description: minMax ? 'Must be between 0 and 100' : undefined, isValid: { required, elements: elements !== 'none' ? true : false, custom: maybeCustomRule( customNumberRule ), + min: minMax ? 10 : undefined, + max: minMax ? 100 : undefined, }, }, { @@ -1108,14 +1189,23 @@ const ValidationComponent = ( { id: 'password', type: 'password', label: 'Password', - description: pattern - ? 'Must have 8 numbers or letters' - : undefined, + placeholder: getValidationPlaceholder( + 'Must be 8+ alphanumeric', + 'Min 10, max 20 characters', + 'abc12345 (10-20 chars alphanumeric)' + ), + description: getValidationDescription( + 'Must contain only letters and numbers (8+ chars)', + 'Must be between 10 and 20 characters', + 'alphanumeric chars AND 10-20 characters' + ), isValid: { required, elements: elements !== 'none' ? true : false, custom: maybeCustomRule( customPasswordRule ), - pattern: pattern ? '^[0-9a-zA-Z]{8}$' : undefined, + pattern: pattern ? '^[a-zA-Z0-9]{8,}$' : undefined, + minLength: minMax ? 10 : undefined, + maxLength: minMax ? 20 : undefined, }, }, { @@ -1184,7 +1274,7 @@ const ValidationComponent = ( { }, }, ]; - }, [ elements, custom, required, getElements ] ); + }, [ elements, custom, required, pattern, minMax, getElements ] ); const form = useMemo( () => ( { @@ -2121,12 +2211,18 @@ export const Validation = { description: 'Whether or not the pattern validation rule is active.', }, + minMax: { + control: { type: 'boolean' }, + description: + 'Whether or not the min/max validation rule is active.', + }, }, args: { required: true, elements: 'sync', custom: 'sync', pattern: false, + minMax: false, }, }; diff --git a/packages/dataviews/src/test/use-form-validity.ts b/packages/dataviews/src/test/use-form-validity.ts index 4648dae8632ff6..cad37b8bb8eb57 100644 --- a/packages/dataviews/src/test/use-form-validity.ts +++ b/packages/dataviews/src/test/use-form-validity.ts @@ -799,6 +799,801 @@ describe( 'useFormValidity', () => { } ); } ); + describe( 'isValid.min', () => { + const MIN_MESSAGE = { + min: { + type: 'invalid', + message: 'Value is below the minimum.', + }, + }; + + it( 'integer is valid when value is at min', () => { + const item = { id: 1, quantity: 5 }; + const fields: Field< {} >[] = [ + { + id: 'quantity', + type: 'integer', + isValid: { + min: 5, + }, + }, + ]; + const form = { fields: [ 'quantity' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'integer is valid when value is above min', () => { + const item = { id: 1, quantity: 10 }; + const fields: Field< {} >[] = [ + { + id: 'quantity', + type: 'integer', + isValid: { + min: 5, + }, + }, + ]; + const form = { fields: [ 'quantity' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'integer is invalid when value is below min', () => { + const item = { id: 1, quantity: 3 }; + const fields: Field< {} >[] = [ + { + id: 'quantity', + type: 'integer', + isValid: { + min: 5, + }, + }, + ]; + const form = { fields: [ 'quantity' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity?.quantity ).toEqual( MIN_MESSAGE ); + expect( isValid ).toBe( false ); + } ); + + it( 'integer is valid when value is empty and min is defined', () => { + const item = { id: 1, quantity: undefined }; + const fields: Field< {} >[] = [ + { + id: 'quantity', + type: 'integer', + isValid: { + min: 5, + }, + }, + ]; + const form = { fields: [ 'quantity' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'number is valid when value is at or above min', () => { + const item = { id: 1, price: 9.99 }; + const fields: Field< {} >[] = [ + { + id: 'price', + type: 'number', + isValid: { + min: 5.5, + }, + }, + ]; + const form = { fields: [ 'price' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'number is invalid when value is below min', () => { + const item = { id: 1, price: 2.5 }; + const fields: Field< {} >[] = [ + { + id: 'price', + type: 'number', + isValid: { + min: 5.5, + }, + }, + ]; + const form = { fields: [ 'price' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity?.price ).toEqual( MIN_MESSAGE ); + expect( isValid ).toBe( false ); + } ); + } ); + + describe( 'isValid.max', () => { + const MAX_MESSAGE = { + max: { + type: 'invalid', + message: 'Value is above the maximum.', + }, + }; + + it( 'integer is valid when value is at max', () => { + const item = { id: 1, quantity: 100 }; + const fields: Field< {} >[] = [ + { + id: 'quantity', + type: 'integer', + isValid: { + max: 100, + }, + }, + ]; + const form = { fields: [ 'quantity' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'integer is valid when value is below max', () => { + const item = { id: 1, quantity: 50 }; + const fields: Field< {} >[] = [ + { + id: 'quantity', + type: 'integer', + isValid: { + max: 100, + }, + }, + ]; + const form = { fields: [ 'quantity' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'integer is invalid when value is above max', () => { + const item = { id: 1, quantity: 150 }; + const fields: Field< {} >[] = [ + { + id: 'quantity', + type: 'integer', + isValid: { + max: 100, + }, + }, + ]; + const form = { fields: [ 'quantity' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity?.quantity ).toEqual( MAX_MESSAGE ); + expect( isValid ).toBe( false ); + } ); + + it( 'integer is valid when value is empty and max is defined', () => { + const item = { id: 1, quantity: undefined }; + const fields: Field< {} >[] = [ + { + id: 'quantity', + type: 'integer', + isValid: { + max: 100, + }, + }, + ]; + const form = { fields: [ 'quantity' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'number is valid when value is at or below max', () => { + const item = { id: 1, price: 99.99 }; + const fields: Field< {} >[] = [ + { + id: 'price', + type: 'number', + isValid: { + max: 100.0, + }, + }, + ]; + const form = { fields: [ 'price' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'number is invalid when value is above max', () => { + const item = { id: 1, price: 150.5 }; + const fields: Field< {} >[] = [ + { + id: 'price', + type: 'number', + isValid: { + max: 100.0, + }, + }, + ]; + const form = { fields: [ 'price' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity?.price ).toEqual( MAX_MESSAGE ); + expect( isValid ).toBe( false ); + } ); + } ); + + describe( 'isValid.minLength', () => { + const MIN_LENGTH_MESSAGE = { + minLength: { + type: 'invalid', + message: 'Value is too short.', + }, + }; + + it( 'text is valid when value length is at minLength', () => { + const item = { id: 1, username: 'abcde' }; + const fields: Field< {} >[] = [ + { + id: 'username', + type: 'text', + isValid: { + minLength: 5, + }, + }, + ]; + const form = { fields: [ 'username' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'text is valid when value length is above minLength', () => { + const item = { id: 1, username: 'abcdefghij' }; + const fields: Field< {} >[] = [ + { + id: 'username', + type: 'text', + isValid: { + minLength: 5, + }, + }, + ]; + const form = { fields: [ 'username' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'text is invalid when value length is below minLength', () => { + const item = { id: 1, username: 'abc' }; + const fields: Field< {} >[] = [ + { + id: 'username', + type: 'text', + isValid: { + minLength: 5, + }, + }, + ]; + const form = { fields: [ 'username' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity?.username ).toEqual( MIN_LENGTH_MESSAGE ); + expect( isValid ).toBe( false ); + } ); + + it( 'text is valid when value is empty and minLength is defined', () => { + const item = { id: 1, username: '' }; + const fields: Field< {} >[] = [ + { + id: 'username', + type: 'text', + isValid: { + minLength: 5, + }, + }, + ]; + const form = { fields: [ 'username' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'email is valid when value length meets minLength', () => { + const item = { id: 1, email: 'user@example.com' }; + const fields: Field< {} >[] = [ + { + id: 'email', + type: 'email', + isValid: { + minLength: 10, + }, + }, + ]; + const form = { fields: [ 'email' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'email is invalid when value length is below minLength', () => { + const item = { id: 1, email: 'a@b.co' }; + const fields: Field< {} >[] = [ + { + id: 'email', + type: 'email', + isValid: { + minLength: 10, + }, + }, + ]; + const form = { fields: [ 'email' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity?.email ).toEqual( MIN_LENGTH_MESSAGE ); + expect( isValid ).toBe( false ); + } ); + + it( 'url is valid when value length meets minLength', () => { + const item = { id: 1, website: 'https://example.com' }; + const fields: Field< {} >[] = [ + { + id: 'website', + type: 'url', + isValid: { + minLength: 10, + }, + }, + ]; + const form = { fields: [ 'website' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'url is invalid when value length is below minLength', () => { + const item = { id: 1, website: 'http://a' }; + const fields: Field< {} >[] = [ + { + id: 'website', + type: 'url', + isValid: { + minLength: 15, + }, + }, + ]; + const form = { fields: [ 'website' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity?.website ).toEqual( MIN_LENGTH_MESSAGE ); + expect( isValid ).toBe( false ); + } ); + + it( 'telephone is valid when value length meets minLength', () => { + const item = { id: 1, phone: '+1-555-123-4567' }; + const fields: Field< {} >[] = [ + { + id: 'phone', + type: 'telephone', + isValid: { + minLength: 10, + }, + }, + ]; + const form = { fields: [ 'phone' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'telephone is invalid when value length is below minLength', () => { + const item = { id: 1, phone: '555' }; + const fields: Field< {} >[] = [ + { + id: 'phone', + type: 'telephone', + isValid: { + minLength: 10, + }, + }, + ]; + const form = { fields: [ 'phone' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity?.phone ).toEqual( MIN_LENGTH_MESSAGE ); + expect( isValid ).toBe( false ); + } ); + + it( 'password is valid when value length meets minLength', () => { + const item = { id: 1, password: 'securepassword123' }; + const fields: Field< {} >[] = [ + { + id: 'password', + type: 'password', + isValid: { + minLength: 8, + }, + }, + ]; + const form = { fields: [ 'password' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'password is invalid when value length is below minLength', () => { + const item = { id: 1, password: 'short' }; + const fields: Field< {} >[] = [ + { + id: 'password', + type: 'password', + isValid: { + minLength: 8, + }, + }, + ]; + const form = { fields: [ 'password' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity?.password ).toEqual( MIN_LENGTH_MESSAGE ); + expect( isValid ).toBe( false ); + } ); + } ); + + describe( 'isValid.maxLength', () => { + const MAX_LENGTH_MESSAGE = { + maxLength: { + type: 'invalid', + message: 'Value is too long.', + }, + }; + + it( 'text is valid when value length is at maxLength', () => { + const item = { id: 1, username: 'abcdefghij' }; + const fields: Field< {} >[] = [ + { + id: 'username', + type: 'text', + isValid: { + maxLength: 10, + }, + }, + ]; + const form = { fields: [ 'username' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'text is valid when value length is below maxLength', () => { + const item = { id: 1, username: 'abc' }; + const fields: Field< {} >[] = [ + { + id: 'username', + type: 'text', + isValid: { + maxLength: 10, + }, + }, + ]; + const form = { fields: [ 'username' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'text is invalid when value length is above maxLength', () => { + const item = { id: 1, username: 'abcdefghijklmnop' }; + const fields: Field< {} >[] = [ + { + id: 'username', + type: 'text', + isValid: { + maxLength: 10, + }, + }, + ]; + const form = { fields: [ 'username' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity?.username ).toEqual( MAX_LENGTH_MESSAGE ); + expect( isValid ).toBe( false ); + } ); + + it( 'text is valid when value is empty and maxLength is defined', () => { + const item = { id: 1, username: '' }; + const fields: Field< {} >[] = [ + { + id: 'username', + type: 'text', + isValid: { + maxLength: 10, + }, + }, + ]; + const form = { fields: [ 'username' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'email is valid when value length meets maxLength', () => { + const item = { id: 1, email: 'user@example.com' }; + const fields: Field< {} >[] = [ + { + id: 'email', + type: 'email', + isValid: { + maxLength: 50, + }, + }, + ]; + const form = { fields: [ 'email' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'email is invalid when value length is above maxLength', () => { + const item = { id: 1, email: 'verylongemailaddress@example.com' }; + const fields: Field< {} >[] = [ + { + id: 'email', + type: 'email', + isValid: { + maxLength: 20, + }, + }, + ]; + const form = { fields: [ 'email' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity?.email ).toEqual( MAX_LENGTH_MESSAGE ); + expect( isValid ).toBe( false ); + } ); + + it( 'url is valid when value length meets maxLength', () => { + const item = { id: 1, website: 'https://example.com' }; + const fields: Field< {} >[] = [ + { + id: 'website', + type: 'url', + isValid: { + maxLength: 50, + }, + }, + ]; + const form = { fields: [ 'website' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'url is invalid when value length is above maxLength', () => { + const item = { + id: 1, + website: 'https://verylongdomainname.example.com/path', + }; + const fields: Field< {} >[] = [ + { + id: 'website', + type: 'url', + isValid: { + maxLength: 30, + }, + }, + ]; + const form = { fields: [ 'website' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity?.website ).toEqual( MAX_LENGTH_MESSAGE ); + expect( isValid ).toBe( false ); + } ); + + it( 'telephone is valid when value length meets maxLength', () => { + const item = { id: 1, phone: '+1-555-123-4567' }; + const fields: Field< {} >[] = [ + { + id: 'phone', + type: 'telephone', + isValid: { + maxLength: 20, + }, + }, + ]; + const form = { fields: [ 'phone' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'telephone is invalid when value length is above maxLength', () => { + const item = { id: 1, phone: '+1-555-123-4567-extension-12345' }; + const fields: Field< {} >[] = [ + { + id: 'phone', + type: 'telephone', + isValid: { + maxLength: 15, + }, + }, + ]; + const form = { fields: [ 'phone' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity?.phone ).toEqual( MAX_LENGTH_MESSAGE ); + expect( isValid ).toBe( false ); + } ); + + it( 'password is valid when value length meets maxLength', () => { + const item = { id: 1, password: 'secure123' }; + const fields: Field< {} >[] = [ + { + id: 'password', + type: 'password', + isValid: { + maxLength: 20, + }, + }, + ]; + const form = { fields: [ 'password' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity ).toEqual( undefined ); + expect( isValid ).toBe( true ); + } ); + + it( 'password is invalid when value length is above maxLength', () => { + const item = { id: 1, password: 'verylongsecurepassword123456' }; + const fields: Field< {} >[] = [ + { + id: 'password', + type: 'password', + isValid: { + maxLength: 20, + }, + }, + ]; + const form = { fields: [ 'password' ] }; + const { + result: { + current: { validity, isValid }, + }, + } = renderHook( () => useFormValidity( item, fields, form ) ); + expect( validity?.password ).toEqual( MAX_LENGTH_MESSAGE ); + expect( isValid ).toBe( false ); + } ); + } ); + describe( 'isValid.custom', () => { it( 'integer is valid if value is integer', () => { const item = { id: 1, order: 2, title: 'hi' }; diff --git a/packages/dataviews/src/types/field-api.ts b/packages/dataviews/src/types/field-api.ts index bb3fcf52da5be8..c4ec209afbd559 100644 --- a/packages/dataviews/src/types/field-api.ts +++ b/packages/dataviews/src/types/field-api.ts @@ -80,6 +80,10 @@ export type Rules< Item > = { required?: boolean; elements?: boolean; pattern?: string; + minLength?: number; + maxLength?: number; + min?: number; + max?: number; custom?: | ( ( item: Item, field: NormalizedField< Item > ) => null | string ) | ( ( @@ -298,6 +302,22 @@ export type FieldValidity = { type: 'valid' | 'invalid' | 'validating'; message: string; }; + min?: { + type: 'valid' | 'invalid' | 'validating'; + message: string; + }; + max?: { + type: 'valid' | 'invalid' | 'validating'; + message: string; + }; + minLength?: { + type: 'valid' | 'invalid' | 'validating'; + message: string; + }; + maxLength?: { + type: 'valid' | 'invalid' | 'validating'; + message: string; + }; elements?: { type: 'valid' | 'invalid' | 'validating'; message: string;