From fc6cfffc4f4a8672082b013bf5be02abf3fadd66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:42:35 +0100 Subject: [PATCH 1/4] Collocate field-type specific logic in the field type. --- packages/dataviews/CHANGELOG.md | 1 + .../dataviews-filters/input-widget.tsx | 12 +- .../src/dataform-controls/textarea.tsx | 10 +- .../utils/get-custom-validity.ts | 12 +- .../utils/validated-input.tsx | 12 +- .../utils/validated-number.tsx | 6 +- .../src/dataform-layouts/panel/modal.tsx | 19 +- packages/dataviews/src/field-types/array.tsx | 45 +-- .../dataviews/src/field-types/boolean.tsx | 37 ++- packages/dataviews/src/field-types/color.tsx | 37 ++- packages/dataviews/src/field-types/date.tsx | 12 +- .../dataviews/src/field-types/datetime.tsx | 10 +- packages/dataviews/src/field-types/email.tsx | 39 ++- packages/dataviews/src/field-types/index.tsx | 26 +- .../dataviews/src/field-types/integer.tsx | 38 ++- packages/dataviews/src/field-types/media.tsx | 8 +- .../dataviews/src/field-types/no-type.tsx | 10 +- packages/dataviews/src/field-types/number.tsx | 43 ++- .../dataviews/src/field-types/password.tsx | 16 +- .../dataviews/src/field-types/telephone.tsx | 16 +- packages/dataviews/src/field-types/text.tsx | 16 +- packages/dataviews/src/field-types/url.tsx | 16 +- .../src/field-types/utils/get-is-valid.ts | 111 +++++++ .../field-types/utils/is-valid-elements.ts | 20 ++ .../field-types/utils/is-valid-max-length.ts | 23 ++ .../src/field-types/utils/is-valid-max.ts | 23 ++ .../field-types/utils/is-valid-min-length.ts | 23 ++ .../src/field-types/utils/is-valid-min.ts | 23 ++ .../src/field-types/utils/is-valid-pattern.ts | 24 ++ .../utils/is-valid-required-for-array.ts | 18 ++ .../utils/is-valid-required-for-bool.ts | 13 + .../field-types/utils/is-valid-required.ts | 13 + .../dataviews/src/hooks/use-form-validity.ts | 280 ++++-------------- .../dataviews/src/stories/dataform.story.tsx | 4 +- .../dataviews/src/test/use-form-validity.ts | 32 +- packages/dataviews/src/types/field-api.ts | 32 +- packages/dataviews/src/types/private.ts | 19 +- 37 files changed, 675 insertions(+), 424 deletions(-) create mode 100644 packages/dataviews/src/field-types/utils/get-is-valid.ts create mode 100644 packages/dataviews/src/field-types/utils/is-valid-elements.ts create mode 100644 packages/dataviews/src/field-types/utils/is-valid-max-length.ts create mode 100644 packages/dataviews/src/field-types/utils/is-valid-max.ts create mode 100644 packages/dataviews/src/field-types/utils/is-valid-min-length.ts create mode 100644 packages/dataviews/src/field-types/utils/is-valid-min.ts create mode 100644 packages/dataviews/src/field-types/utils/is-valid-pattern.ts create mode 100644 packages/dataviews/src/field-types/utils/is-valid-required-for-array.ts create mode 100644 packages/dataviews/src/field-types/utils/is-valid-required-for-bool.ts create mode 100644 packages/dataviews/src/field-types/utils/is-valid-required.ts diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 063a6c66af184a..210f4fd786d231 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -12,6 +12,7 @@ ### Enhancements +- Field API: move validation to the field type. [#73642](https://github.com/WordPress/gutenberg/pull/73642) - DataForm: add support for `min`/`max` and `minLength`/`maxLength` validation for relevant controls. [#73465](https://github.com/WordPress/gutenberg/pull/73465) - Field API: display formats for `number` and `integer` types. [#73644](https://github.com/WordPress/gutenberg/pull/73644) diff --git a/packages/dataviews/src/components/dataviews-filters/input-widget.tsx b/packages/dataviews/src/components/dataviews-filters/input-widget.tsx index 5d5aed07085599..65ed2a4685c880 100644 --- a/packages/dataviews/src/components/dataviews-filters/input-widget.tsx +++ b/packages/dataviews/src/components/dataviews-filters/input-widget.tsx @@ -13,7 +13,12 @@ import { Flex } from '@wordpress/components'; /** * Internal dependencies */ -import type { View, NormalizedFilter, NormalizedField } from '../../types'; +import type { + View, + NormalizedFilter, + NormalizedField, + NormalizedRules, +} from '../../types'; import { getCurrentValue } from './utils'; interface UserInputWidgetProps { @@ -58,10 +63,7 @@ export default function InputWidget( { return { ...currentField, // Deactivate validation for filters. - isValid: { - required: false, - custom: () => null, - }, + isValid: {} satisfies NormalizedRules< any >, // Configure getValue/setValue as if Item was a plain object. getValue: ( { item }: { item: any } ) => item[ currentField.id ], diff --git a/packages/dataviews/src/dataform-controls/textarea.tsx b/packages/dataviews/src/dataform-controls/textarea.tsx index 69624db7f628f3..21b6d614d98afd 100644 --- a/packages/dataviews/src/dataform-controls/textarea.tsx +++ b/packages/dataviews/src/dataform-controls/textarea.tsx @@ -33,7 +33,7 @@ export default function Textarea< Item >( { return ( ( { help={ description } onChange={ onChangeControl } rows={ rows } - minLength={ isValid?.minLength } - maxLength={ isValid?.maxLength } + minLength={ + isValid.minLength ? isValid.minLength.constraint : undefined + } + maxLength={ + isValid.maxLength ? isValid.maxLength.constraint : undefined + } __next40pxDefaultSize __nextHasNoMarginBottom hideLabelFromVision={ hideLabelFromVision } diff --git a/packages/dataviews/src/dataform-controls/utils/get-custom-validity.ts b/packages/dataviews/src/dataform-controls/utils/get-custom-validity.ts index e18c09ed91fd74..ee0fa25daf819a 100644 --- a/packages/dataviews/src/dataform-controls/utils/get-custom-validity.ts +++ b/packages/dataviews/src/dataform-controls/utils/get-custom-validity.ts @@ -1,10 +1,10 @@ /** * Internal dependencies */ -import type { Rules, FieldValidity } from '../../types'; +import type { NormalizedRules, FieldValidity } from '../../types'; export default function getCustomValidity< Item >( - isValid: Rules< Item >, + isValid: NormalizedRules< Item >, validity: FieldValidity | undefined ) { let customValidity; @@ -16,6 +16,14 @@ export default function getCustomValidity< Item >( : undefined; } else if ( isValid?.pattern && validity?.pattern ) { customValidity = validity.pattern; + } else if ( isValid?.min && validity?.min ) { + customValidity = validity.min; + } else if ( isValid?.max && validity?.max ) { + customValidity = validity.max; + } else if ( isValid?.minLength && validity?.minLength ) { + customValidity = validity.minLength; + } else if ( isValid?.maxLength && validity?.maxLength ) { + customValidity = validity.maxLength; } else if ( isValid?.elements && validity?.elements ) { customValidity = validity.elements; } else if ( validity?.custom ) { diff --git a/packages/dataviews/src/dataform-controls/utils/validated-input.tsx b/packages/dataviews/src/dataform-controls/utils/validated-input.tsx index b29b3c6a01af7c..160d7be35d9234 100644 --- a/packages/dataviews/src/dataform-controls/utils/validated-input.tsx +++ b/packages/dataviews/src/dataform-controls/utils/validated-input.tsx @@ -56,7 +56,7 @@ export default function ValidatedText< Item >( { return ( ( { type={ type } prefix={ prefix } suffix={ suffix } - pattern={ isValid?.pattern } - minLength={ isValid?.minLength } - maxLength={ isValid?.maxLength } + pattern={ isValid.pattern ? isValid.pattern.constraint : undefined } + minLength={ + isValid.minLength ? isValid.minLength.constraint : undefined + } + maxLength={ + isValid.maxLength ? isValid.maxLength.constraint : undefined + } __next40pxDefaultSize /> ); diff --git a/packages/dataviews/src/dataform-controls/utils/validated-number.tsx b/packages/dataviews/src/dataform-controls/utils/validated-number.tsx index 33f7690c9d42ea..6f291cb9e493ad 100644 --- a/packages/dataviews/src/dataform-controls/utils/validated-number.tsx +++ b/packages/dataviews/src/dataform-controls/utils/validated-number.tsx @@ -157,7 +157,7 @@ export default function ValidatedNumber< Item >( { return ( ( { __next40pxDefaultSize hideLabelFromVision={ hideLabelFromVision } step={ step } - min={ isValid?.min } - max={ isValid?.max } + min={ isValid.min ? isValid.min.constraint : undefined } + max={ isValid.max ? isValid.max.constraint : undefined } /> ); } diff --git a/packages/dataviews/src/dataform-layouts/panel/modal.tsx b/packages/dataviews/src/dataform-layouts/panel/modal.tsx index df6c77fd0cabf4..26467506673dc0 100644 --- a/packages/dataviews/src/dataform-layouts/panel/modal.tsx +++ b/packages/dataviews/src/dataform-layouts/panel/modal.tsx @@ -63,11 +63,20 @@ function ModalContent< Item >( { [ field ] ); - const { validity } = useFormValidity( - modalData, - fields as Field< any >[], - form - ); + const fieldsAsFieldType: Field< Item >[] = fields.map( ( f ) => ( { + ...f, + Edit: f.Edit === null ? undefined : f.Edit, + isValid: { + required: f.isValid.required?.constraint, + elements: f.isValid.elements?.constraint, + min: f.isValid.min?.constraint, + max: f.isValid.max?.constraint, + pattern: f.isValid.pattern?.constraint?.source, + minLength: f.isValid.minLength?.constraint, + maxLength: f.isValid.maxLength?.constraint, + }, + } ) ); + const { validity } = useFormValidity( modalData, fieldsAsFieldType, form ); const onApply = () => { onChange( changes ); diff --git a/packages/dataviews/src/field-types/array.tsx b/packages/dataviews/src/field-types/array.tsx index 4120a15626d29e..eb4e740bad2735 100644 --- a/packages/dataviews/src/field-types/array.tsx +++ b/packages/dataviews/src/field-types/array.tsx @@ -6,7 +6,11 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import type { DataViewRenderFieldProps, Rules, SortDirection } from '../types'; +import type { + DataViewRenderFieldProps, + NormalizedField, + SortDirection, +} from '../types'; import type { FieldType } from '../types/private'; import { OPERATOR_IS_ALL, @@ -14,32 +18,31 @@ import { OPERATOR_IS_NONE, OPERATOR_IS_NOT_ALL, } from '../constants'; +import isValidRequiredForArray from './utils/is-valid-required-for-array'; +import isValidElements from './utils/is-valid-elements'; function render( { item, field }: DataViewRenderFieldProps< any > ) { const value = field.getValue( { item } ) || []; return value.join( ', ' ); } -const isValid: Rules< any > = { - elements: true, - custom: ( item: any, normalizedField ) => { - const value = normalizedField.getValue( { item } ); +function isValidCustom< Item >( item: Item, field: NormalizedField< Item > ) { + const value = field.getValue( { item } ); - if ( - ! [ undefined, '', null ].includes( value ) && - ! Array.isArray( value ) - ) { - return __( 'Value must be an array.' ); - } + if ( + ! [ undefined, '', null ].includes( value ) && + ! Array.isArray( value ) + ) { + return __( 'Value must be an array.' ); + } - // Only allow strings for now. Can be extended to other types in the future. - if ( ! value.every( ( v: any ) => typeof v === 'string' ) ) { - return __( 'Every value must be a string.' ); - } + // Only allow strings for now. Can be extended to other types in the future. + if ( ! value.every( ( v: any ) => typeof v === 'string' ) ) { + return __( 'Every value must be a string.' ); + } - return null; - }, -}; + return null; +} const sort = ( a: any, b: any, direction: SortDirection ) => { // Sort arrays by length, then alphabetically by joined string @@ -63,7 +66,6 @@ export default { render, Edit: 'array', sort, - isValid, enableSorting: true, enableGlobalSearch: false, defaultOperators: [ OPERATOR_IS_ANY, OPERATOR_IS_NONE ], @@ -74,4 +76,9 @@ export default { OPERATOR_IS_NOT_ALL, ], getFormat: () => ( {} ), + validate: { + required: isValidRequiredForArray, + elements: isValidElements, + custom: isValidCustom, + }, } satisfies FieldType< any >; diff --git a/packages/dataviews/src/field-types/boolean.tsx b/packages/dataviews/src/field-types/boolean.tsx index 7e2275e2ae7880..b166bdf2f31885 100644 --- a/packages/dataviews/src/field-types/boolean.tsx +++ b/packages/dataviews/src/field-types/boolean.tsx @@ -6,10 +6,16 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import type { DataViewRenderFieldProps, Rules, SortDirection } from '../types'; +import type { + DataViewRenderFieldProps, + NormalizedField, + SortDirection, +} from '../types'; import type { FieldType } from '../types/private'; import RenderFromElements from './utils/render-from-elements'; import { OPERATOR_IS, OPERATOR_IS_NOT } from '../constants'; +import isValidElements from './utils/is-valid-elements'; +import isValidRequiredForBool from './utils/is-valid-required-for-bool'; function render( { item, field }: DataViewRenderFieldProps< any > ) { if ( field.hasElements ) { @@ -27,21 +33,18 @@ function render( { item, field }: DataViewRenderFieldProps< any > ) { return null; } -const isValid: Rules< any > = { - elements: true, - custom: ( item: any, normalizedField ) => { - const value = normalizedField.getValue( { item } ); +function isValidCustom< Item >( item: Item, field: NormalizedField< Item > ) { + const value = field.getValue( { item } ); - if ( - ! [ undefined, '', null ].includes( value ) && - ! [ true, false ].includes( value ) - ) { - return __( 'Value must be true, false, or undefined' ); - } + if ( + ! [ undefined, '', null ].includes( value ) && + ! [ true, false ].includes( value ) + ) { + return __( 'Value must be true, false, or undefined' ); + } - return null; - }, -}; + return null; +} const sort = ( a: any, b: any, direction: SortDirection ) => { const boolA = Boolean( a ); @@ -65,7 +68,11 @@ export default { render, Edit: 'checkbox', sort, - isValid, + validate: { + required: isValidRequiredForBool, + elements: isValidElements, + custom: isValidCustom, + }, enableSorting: true, enableGlobalSearch: false, defaultOperators: [ OPERATOR_IS, OPERATOR_IS_NOT ], diff --git a/packages/dataviews/src/field-types/color.tsx b/packages/dataviews/src/field-types/color.tsx index 2aed013d600bd7..2690afb0a5c967 100644 --- a/packages/dataviews/src/field-types/color.tsx +++ b/packages/dataviews/src/field-types/color.tsx @@ -11,7 +11,11 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import type { DataViewRenderFieldProps, Rules, SortDirection } from '../types'; +import type { + DataViewRenderFieldProps, + NormalizedField, + SortDirection, +} from '../types'; import type { FieldType } from '../types/private'; import RenderFromElements from './utils/render-from-elements'; import { @@ -20,6 +24,8 @@ import { OPERATOR_IS_NONE, OPERATOR_IS_NOT, } from '../constants'; +import isValidElements from './utils/is-valid-elements'; +import isValidRequired from './utils/is-valid-required'; function render( { item, field }: DataViewRenderFieldProps< any > ) { if ( field.hasElements ) { @@ -50,21 +56,18 @@ function render( { item, field }: DataViewRenderFieldProps< any > ) { ); } -const isValid: Rules< any > = { - elements: true, - custom: ( item: any, normalizedField ) => { - const value = normalizedField.getValue( { item } ); +function isValidCustom< Item >( item: Item, field: NormalizedField< Item > ) { + const value = field.getValue( { item } ); - if ( - ! [ undefined, '', null ].includes( value ) && - ! colord( value ).isValid() - ) { - return __( 'Value must be a valid color.' ); - } + if ( + ! [ undefined, '', null ].includes( value ) && + ! colord( value ).isValid() + ) { + return __( 'Value must be a valid color.' ); + } - return null; - }, -}; + return null; +} const sort = ( a: any, b: any, direction: SortDirection ) => { // Convert colors to HSL for better sorting @@ -99,7 +102,6 @@ export default { render, Edit: 'color', sort, - isValid, enableSorting: true, enableGlobalSearch: false, defaultOperators: [ OPERATOR_IS_ANY, OPERATOR_IS_NONE ], @@ -110,4 +112,9 @@ export default { OPERATOR_IS_NONE, ], getFormat: () => ( {} ), + validate: { + required: isValidRequired, + elements: isValidElements, + custom: isValidCustom, + }, } satisfies FieldType< any >; diff --git a/packages/dataviews/src/field-types/date.tsx b/packages/dataviews/src/field-types/date.tsx index 11211cc8c19f5f..6da753fed35718 100644 --- a/packages/dataviews/src/field-types/date.tsx +++ b/packages/dataviews/src/field-types/date.tsx @@ -14,6 +14,7 @@ import type { } from '../types'; import type { FieldType } from '../types/private'; import RenderFromElements from './utils/render-from-elements'; +import isValidElements from './utils/is-valid-elements'; import { OPERATOR_ON, OPERATOR_NOT_ON, @@ -26,6 +27,7 @@ import { OPERATOR_BETWEEN, DAYS_OF_WEEK, } from '../constants'; +import isValidRequired from './utils/is-valid-required'; function getFormat< Item >( field: Field< Item > ): Required< FormatDate > { const fieldFormat = field.format as FormatDate | undefined; @@ -60,7 +62,7 @@ function render( { item, field }: DataViewRenderFieldProps< any > ) { // but TypeScript is unable to infer this, hence the type assertion. let format: Required< FormatDate >; if ( field.type !== 'date' ) { - format = getFormat( field as Field< any > ); + format = getFormat( {} as Field< any > ); } else { format = field.format as Required< FormatDate >; } @@ -80,10 +82,6 @@ export default { render, Edit: 'date', sort, - isValid: { - elements: true, - custom: () => null, - }, enableSorting: true, enableGlobalSearch: false, defaultOperators: [ @@ -109,4 +107,8 @@ export default { OPERATOR_BETWEEN, ], getFormat, + validate: { + required: isValidRequired, + elements: isValidElements, + }, } satisfies FieldType< any >; diff --git a/packages/dataviews/src/field-types/datetime.tsx b/packages/dataviews/src/field-types/datetime.tsx index 6edd6eb74e66cb..c8ba702e485b91 100644 --- a/packages/dataviews/src/field-types/datetime.tsx +++ b/packages/dataviews/src/field-types/datetime.tsx @@ -5,6 +5,7 @@ import type { DataViewRenderFieldProps, SortDirection } from '../types'; import type { FieldType } from '../types/private'; import RenderFromElements from './utils/render-from-elements'; import parseDateTime from './utils/parse-date-time'; +import isValidElements from './utils/is-valid-elements'; import { OPERATOR_ON, OPERATOR_NOT_ON, @@ -15,6 +16,7 @@ import { OPERATOR_IN_THE_PAST, OPERATOR_OVER, } from '../constants'; +import isValidRequired from './utils/is-valid-required'; function render( { item, field }: DataViewRenderFieldProps< any > ) { if ( field.elements ) { @@ -46,10 +48,6 @@ export default { render, Edit: 'datetime', sort, - isValid: { - elements: true, - custom: () => null, - }, enableSorting: true, enableGlobalSearch: false, defaultOperators: [ @@ -73,4 +71,8 @@ export default { OPERATOR_OVER, ], getFormat: () => ( {} ), + validate: { + required: isValidRequired, + elements: isValidElements, + }, } satisfies FieldType< any >; diff --git a/packages/dataviews/src/field-types/email.tsx b/packages/dataviews/src/field-types/email.tsx index dfb1f85f3eea85..ec0c40f76861da 100644 --- a/packages/dataviews/src/field-types/email.tsx +++ b/packages/dataviews/src/field-types/email.tsx @@ -6,7 +6,7 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import type { Rules } from '../types'; +import type { NormalizedField } from '../types'; import type { FieldType } from '../types/private'; import { OPERATOR_IS, @@ -21,34 +21,35 @@ import { } from '../constants'; import render from './utils/render-default'; import sort from './utils/sort-text'; +import isValidRequired from './utils/is-valid-required'; +import isValidMinLength from './utils/is-valid-min-length'; +import isValidMaxLength from './utils/is-valid-max-length'; +import isValidPattern from './utils/is-valid-pattern'; +import isValidElements from './utils/is-valid-elements'; // Email validation regex based on HTML5 spec // https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address const emailRegex = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; -const isValid: Rules< any > = { - elements: true, - custom: ( item: any, normalizedField ) => { - const value = normalizedField.getValue( { item } ); +function isValidCustom< Item >( item: Item, field: NormalizedField< Item > ) { + const value = field.getValue( { item } ); - if ( - ! [ undefined, '', null ].includes( value ) && - ! emailRegex.test( value ) - ) { - return __( 'Value must be a valid email address.' ); - } + if ( + ! [ undefined, '', null ].includes( value ) && + ! emailRegex.test( value ) + ) { + return __( 'Value must be a valid email address.' ); + } - return null; - }, -}; + return null; +} export default { type: 'email', render, Edit: 'email', sort, - isValid, enableSorting: true, enableGlobalSearch: false, defaultOperators: [ OPERATOR_IS_ANY, OPERATOR_IS_NONE ], @@ -65,4 +66,12 @@ export default { OPERATOR_IS_NOT_ALL, ], getFormat: () => ( {} ), + validate: { + required: isValidRequired, + pattern: isValidPattern, + minLength: isValidMinLength, + maxLength: isValidMaxLength, + elements: isValidElements, + custom: isValidCustom, + }, } satisfies FieldType< any >; diff --git a/packages/dataviews/src/field-types/index.tsx b/packages/dataviews/src/field-types/index.tsx index 70ced3904843b8..c9f138811a0f7f 100644 --- a/packages/dataviews/src/field-types/index.tsx +++ b/packages/dataviews/src/field-types/index.tsx @@ -27,6 +27,7 @@ import { default as telephone } from './telephone'; import { default as color } from './color'; import { default as url } from './url'; import { default as noType } from './no-type'; +import getIsValid from './utils/get-is-valid'; /** * @@ -70,7 +71,7 @@ export default function normalizeFields< Item >( fields: Field< Item >[] ): NormalizedField< Item >[] { return fields.map( ( field ) => { - const defaultProps = getFieldTypeByName< Item >( field.type ); + const fieldType = getFieldTypeByName< Item >( field.type ); const getValue = field.getValue || getValueFromId( field.id ); const sort = function ( a: any, b: any, direction: SortDirection ) { @@ -78,7 +79,7 @@ export default function normalizeFields< Item >( const bValue = getValue( { item: b } ); return field.sort ? field.sort( aValue, bValue, direction ) - : defaultProps.sort( aValue, bValue, direction ); + : fieldType.sort( aValue, bValue, direction ); }; return { @@ -96,23 +97,20 @@ export default function normalizeFields< Item >( enableHiding: field.enableHiding ?? true, readOnly: field.readOnly ?? false, // The type provides defaults for the following props - type: defaultProps.type, - render: field.render ?? defaultProps.render, - Edit: getControl( field, defaultProps.Edit ), + type: fieldType.type, + render: field.render ?? fieldType.render, + Edit: getControl( field, fieldType.Edit ), sort, - enableSorting: field.enableSorting ?? defaultProps.enableSorting, + enableSorting: field.enableSorting ?? fieldType.enableSorting, enableGlobalSearch: - field.enableGlobalSearch ?? defaultProps.enableGlobalSearch, - isValid: { - ...defaultProps.isValid, - ...field.isValid, - }, + field.enableGlobalSearch ?? fieldType.enableGlobalSearch, + isValid: getIsValid( field, fieldType ), filterBy: getFilterBy( field, - defaultProps.defaultOperators, - defaultProps.validOperators + fieldType.defaultOperators, + fieldType.validOperators ), - format: defaultProps.getFormat( field ), + format: fieldType.getFormat( field ), }; } ); } diff --git a/packages/dataviews/src/field-types/integer.tsx b/packages/dataviews/src/field-types/integer.tsx index d69e933af511e9..d7918d3416c1c1 100644 --- a/packages/dataviews/src/field-types/integer.tsx +++ b/packages/dataviews/src/field-types/integer.tsx @@ -10,7 +10,7 @@ import type { DataViewRenderFieldProps, Field, FormatInteger, - Rules, + NormalizedField, } from '../types'; import type { FieldType } from '../types/private'; import { @@ -28,6 +28,10 @@ import { } from '../constants'; import RenderFromElements from './utils/render-from-elements'; import sort from './utils/sort-number'; +import isValidRequired from './utils/is-valid-required'; +import isValidMin from './utils/is-valid-min'; +import isValidMax from './utils/is-valid-max'; +import isValidElements from './utils/is-valid-elements'; function getFormat< Item >( field: Field< Item > ): Required< FormatInteger > { const fieldFormat = field.format as FormatInteger | undefined; @@ -84,27 +88,22 @@ function render( { item, field }: DataViewRenderFieldProps< any > ) { return formatInteger( Number( value ), format ); } -const isValid: Rules< any > = { - elements: true, - custom: ( item: any, normalizedField ) => { - const value = normalizedField.getValue( { item } ); - if ( - ! [ undefined, '', null ].includes( value ) && - ! Number.isInteger( value ) - ) { - return __( 'Value must be an integer.' ); - } - - return null; - }, -}; +function isValidCustom< Item >( item: Item, field: NormalizedField< Item > ) { + const value = field.getValue( { item } ); + if ( + ! [ undefined, '', null ].includes( value ) && + ! Number.isInteger( value ) + ) { + return __( 'Value must be an integer.' ); + } + return null; +} export default { type: 'integer', render, Edit: 'integer', sort, - isValid, enableSorting: true, enableGlobalSearch: false, defaultOperators: [ @@ -132,4 +131,11 @@ export default { OPERATOR_IS_NOT_ALL, ], getFormat, + validate: { + required: isValidRequired, + min: isValidMin, + max: isValidMax, + elements: isValidElements, + custom: isValidCustom, + }, } satisfies FieldType< any >; diff --git a/packages/dataviews/src/field-types/media.tsx b/packages/dataviews/src/field-types/media.tsx index 9e668b7ca22437..fa1ada0849c2d4 100644 --- a/packages/dataviews/src/field-types/media.tsx +++ b/packages/dataviews/src/field-types/media.tsx @@ -8,13 +8,13 @@ export default { render: () => null, Edit: null, sort: () => 0, - isValid: { - elements: true, - custom: () => null, - }, enableSorting: false, enableGlobalSearch: false, defaultOperators: [], validOperators: [], getFormat: () => ( {} ), + // cannot validate any constraint, so + // the only available validation for the field author + // would be providing a custom validator. + validate: {}, } satisfies FieldType< any >; diff --git a/packages/dataviews/src/field-types/no-type.tsx b/packages/dataviews/src/field-types/no-type.tsx index f9c978559b9e59..c6d63d23f03f77 100644 --- a/packages/dataviews/src/field-types/no-type.tsx +++ b/packages/dataviews/src/field-types/no-type.tsx @@ -7,6 +7,8 @@ import { ALL_OPERATORS, OPERATOR_IS, OPERATOR_IS_NOT } from '../constants'; import render from './utils/render-default'; import sortText from './utils/sort-text'; import sortNumber from './utils/sort-number'; +import isValidRequired from './utils/is-valid-required'; +import isValidElements from './utils/is-valid-elements'; const sort = ( a: any, b: any, direction: SortDirection ) => { if ( typeof a === 'number' && typeof b === 'number' ) { @@ -21,13 +23,13 @@ export default { render, Edit: null, sort, - isValid: { - elements: true, - custom: () => null, - }, enableSorting: true, enableGlobalSearch: false, defaultOperators: [ OPERATOR_IS, OPERATOR_IS_NOT ], validOperators: ALL_OPERATORS, getFormat: () => ( {} ), + validate: { + required: isValidRequired, + elements: isValidElements, + }, } satisfies FieldType< any >; diff --git a/packages/dataviews/src/field-types/number.tsx b/packages/dataviews/src/field-types/number.tsx index 2564891a3edae4..32e4e9932d4da5 100644 --- a/packages/dataviews/src/field-types/number.tsx +++ b/packages/dataviews/src/field-types/number.tsx @@ -10,7 +10,7 @@ import type { DataViewRenderFieldProps, Field, FormatNumber, - Rules, + NormalizedField, } from '../types'; import type { FieldType } from '../types/private'; import { @@ -28,6 +28,10 @@ import { } from '../constants'; import RenderFromElements from './utils/render-from-elements'; import sort from './utils/sort-number'; +import isValidRequired from './utils/is-valid-required'; +import isValidMin from './utils/is-valid-min'; +import isValidMax from './utils/is-valid-max'; +import isValidElements from './utils/is-valid-elements'; function getFormat< Item >( field: Field< Item > ): Required< FormatNumber > { const fieldFormat = field.format as FormatNumber | undefined; @@ -100,25 +104,33 @@ function render( { item, field }: DataViewRenderFieldProps< any > ) { return formatNumber( Number( value ), format ); } -const isValid: Rules< any > = { - elements: true, - custom: ( item: any, normalizedField ) => { - const value = normalizedField.getValue( { item } ); +function isValidCustom< Item >( item: Item, field: NormalizedField< Item > ) { + const value = field.getValue( { item } ); - if ( ! isEmpty( value ) && ! Number.isFinite( value ) ) { - return __( 'Value must be a number.' ); - } + if ( ! isEmpty( value ) && ! Number.isFinite( value ) ) { + return __( 'Value must be a number.' ); + } - return null; - }, -}; + // If the field type is number, we've already normalized the format, + // and so it's safe to tell TypeScript to trust us ("as Required"). + // + // There're no runtime paths where this render function is called with a non-number field, + // but TypeScript is unable to infer this, hence the type assertion. + let format: Required< FormatNumber >; + if ( field.type !== 'number' ) { + format = getFormat( field as Field< any > ); + } else { + format = field.format as Required< FormatNumber >; + } + + return formatNumber( Number( value ), format ); +} export default { type: 'number', render, Edit: 'number', sort, - isValid, enableSorting: true, enableGlobalSearch: false, defaultOperators: [ @@ -146,4 +158,11 @@ export default { OPERATOR_IS_NOT_ALL, ], getFormat, + validate: { + required: isValidRequired, + min: isValidMin, + max: isValidMax, + elements: isValidElements, + custom: isValidCustom, + }, } satisfies FieldType< any >; diff --git a/packages/dataviews/src/field-types/password.tsx b/packages/dataviews/src/field-types/password.tsx index b091998e183026..3e70097c58dc5e 100644 --- a/packages/dataviews/src/field-types/password.tsx +++ b/packages/dataviews/src/field-types/password.tsx @@ -4,6 +4,11 @@ import type { DataViewRenderFieldProps } from '../types'; import type { FieldType } from '../types/private'; import RenderFromElements from './utils/render-from-elements'; +import isValidRequired from './utils/is-valid-required'; +import isValidMinLength from './utils/is-valid-min-length'; +import isValidMaxLength from './utils/is-valid-max-length'; +import isValidPattern from './utils/is-valid-pattern'; +import isValidElements from './utils/is-valid-elements'; function render( { item, field }: DataViewRenderFieldProps< any > ) { return field.hasElements ? ( @@ -18,13 +23,16 @@ export default { render, Edit: 'password', sort: () => 0, // Passwords should not be sortable for security reasons - isValid: { - elements: true, - custom: () => null, - }, enableSorting: false, enableGlobalSearch: false, defaultOperators: [], validOperators: [], getFormat: () => ( {} ), + validate: { + required: isValidRequired, + pattern: isValidPattern, + minLength: isValidMinLength, + maxLength: isValidMaxLength, + elements: isValidElements, + }, } satisfies FieldType< any >; diff --git a/packages/dataviews/src/field-types/telephone.tsx b/packages/dataviews/src/field-types/telephone.tsx index 545a324c34164d..e19dfdd124b1ef 100644 --- a/packages/dataviews/src/field-types/telephone.tsx +++ b/packages/dataviews/src/field-types/telephone.tsx @@ -15,16 +15,17 @@ import { } from '../constants'; import render from './utils/render-default'; import sort from './utils/sort-text'; +import isValidRequired from './utils/is-valid-required'; +import isValidMinLength from './utils/is-valid-min-length'; +import isValidMaxLength from './utils/is-valid-max-length'; +import isValidPattern from './utils/is-valid-pattern'; +import isValidElements from './utils/is-valid-elements'; export default { type: 'telephone', render, Edit: 'telephone', sort, - isValid: { - elements: true, - custom: () => null, - }, enableSorting: true, enableGlobalSearch: false, defaultOperators: [ OPERATOR_IS_ANY, OPERATOR_IS_NONE ], @@ -41,4 +42,11 @@ export default { OPERATOR_IS_NOT_ALL, ], getFormat: () => ( {} ), + validate: { + required: isValidRequired, + pattern: isValidPattern, + minLength: isValidMinLength, + maxLength: isValidMaxLength, + elements: isValidElements, + }, } satisfies FieldType< any >; diff --git a/packages/dataviews/src/field-types/text.tsx b/packages/dataviews/src/field-types/text.tsx index 1c1c8c17e2bf58..eb117f2cf5f65a 100644 --- a/packages/dataviews/src/field-types/text.tsx +++ b/packages/dataviews/src/field-types/text.tsx @@ -15,16 +15,17 @@ import { } from '../constants'; import render from './utils/render-default'; import sort from './utils/sort-text'; +import isValidRequired from './utils/is-valid-required'; +import isValidMinLength from './utils/is-valid-min-length'; +import isValidMaxLength from './utils/is-valid-max-length'; +import isValidPattern from './utils/is-valid-pattern'; +import isValidElements from './utils/is-valid-elements'; export default { type: 'text', render, Edit: 'text', sort, - isValid: { - elements: true, - custom: () => null, - }, enableSorting: true, enableGlobalSearch: false, defaultOperators: [ OPERATOR_IS_ANY, OPERATOR_IS_NONE ], @@ -42,4 +43,11 @@ export default { OPERATOR_IS_NOT_ALL, ], getFormat: () => ( {} ), + validate: { + required: isValidRequired, + pattern: isValidPattern, + minLength: isValidMinLength, + maxLength: isValidMaxLength, + elements: isValidElements, + }, } satisfies FieldType< any >; diff --git a/packages/dataviews/src/field-types/url.tsx b/packages/dataviews/src/field-types/url.tsx index efcd07ab21143a..f8218e7e26ebfb 100644 --- a/packages/dataviews/src/field-types/url.tsx +++ b/packages/dataviews/src/field-types/url.tsx @@ -15,16 +15,17 @@ import { } from '../constants'; import render from './utils/render-default'; import sort from './utils/sort-text'; +import isValidRequired from './utils/is-valid-required'; +import isValidMinLength from './utils/is-valid-min-length'; +import isValidMaxLength from './utils/is-valid-max-length'; +import isValidPattern from './utils/is-valid-pattern'; +import isValidElements from './utils/is-valid-elements'; export default { type: 'url', render, Edit: 'url', sort, - isValid: { - elements: true, - custom: () => null, - }, enableSorting: true, enableGlobalSearch: false, defaultOperators: [ OPERATOR_IS_ANY, OPERATOR_IS_NONE ], @@ -41,4 +42,11 @@ export default { OPERATOR_IS_NOT_ALL, ], getFormat: () => ( {} ), + validate: { + required: isValidRequired, + pattern: isValidPattern, + minLength: isValidMinLength, + maxLength: isValidMaxLength, + elements: isValidElements, + }, } satisfies FieldType< any >; diff --git a/packages/dataviews/src/field-types/utils/get-is-valid.ts b/packages/dataviews/src/field-types/utils/get-is-valid.ts new file mode 100644 index 00000000000000..65d1ee61478e42 --- /dev/null +++ b/packages/dataviews/src/field-types/utils/get-is-valid.ts @@ -0,0 +1,111 @@ +/** + * Internal dependencies + */ +import type { Field, NormalizedRules } from '../../types'; +import type { FieldType } from '../../types/private'; + +export default function getIsValid< Item >( + field: Field< Item >, + fieldType: FieldType< Item > +): NormalizedRules< Item > { + let required; + if ( + field.isValid?.required === true && + fieldType.validate.required !== undefined + ) { + required = { + constraint: true, + validate: fieldType.validate.required, + }; + } + + let elements; + if ( + ( field.isValid?.elements === true || + // elements is enabled unless the field opts-out + ( field.isValid?.elements === undefined && + ( !! field.elements || !! field.getElements ) ) ) && + fieldType.validate.elements !== undefined + ) { + elements = { + constraint: true, + validate: fieldType.validate.elements, + }; + } + + let min; + if ( + typeof field.isValid?.min === 'number' && + fieldType.validate.min !== undefined + ) { + min = { + constraint: field.isValid.min, + validate: fieldType.validate.min, + }; + } + + let max; + if ( + typeof field.isValid?.max === 'number' && + fieldType.validate.max !== undefined + ) { + max = { + constraint: field.isValid.max, + validate: fieldType.validate.max, + }; + } + + let minLength; + if ( + typeof field.isValid?.minLength === 'number' && + fieldType.validate.minLength !== undefined + ) { + minLength = { + constraint: field.isValid.minLength, + validate: fieldType.validate.minLength, + }; + } + + let maxLength; + if ( + typeof field.isValid?.maxLength === 'number' && + fieldType.validate.maxLength !== undefined + ) { + maxLength = { + constraint: field.isValid.maxLength, + validate: fieldType.validate.maxLength, + }; + } + + let pattern; + if ( + field.isValid?.pattern !== undefined && + fieldType.validate.pattern !== undefined + ) { + try { + const regex = new RegExp( field.isValid.pattern ); + pattern = { + constraint: regex, + validate: fieldType.validate.pattern, + }; + } catch ( error ) { + pattern = { + constraint: null, + validate: () => false, + }; + } + } + + const custom = field.isValid?.custom ?? fieldType.validate.custom; + + return { + required, + elements, + min, + max, + minLength, + maxLength, + pattern, + custom, + }; +} diff --git a/packages/dataviews/src/field-types/utils/is-valid-elements.ts b/packages/dataviews/src/field-types/utils/is-valid-elements.ts new file mode 100644 index 00000000000000..03fb63f2797b76 --- /dev/null +++ b/packages/dataviews/src/field-types/utils/is-valid-elements.ts @@ -0,0 +1,20 @@ +/** + * Internal dependencies + */ +import type { NormalizedField } from '../../types'; + +export default function isValidElements< Item >( + item: Item, + field: NormalizedField< Item > +): boolean { + const elements = field.elements ?? []; + const validValues = elements.map( ( el ) => el.value ); + if ( validValues.length === 0 ) { + return true; + } + + const value = field.getValue( { item } ); + + // Covers both array and non-array values. + return [].concat( value ).every( ( v ) => validValues.includes( v ) ); +} diff --git a/packages/dataviews/src/field-types/utils/is-valid-max-length.ts b/packages/dataviews/src/field-types/utils/is-valid-max-length.ts new file mode 100644 index 00000000000000..35304e041ca9ee --- /dev/null +++ b/packages/dataviews/src/field-types/utils/is-valid-max-length.ts @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import type { NormalizedField } from '../../types'; + +export default function isValidMaxLength< Item >( + item: Item, + field: NormalizedField< Item > +): boolean { + if ( typeof field.isValid.maxLength?.constraint !== 'number' ) { + return false; + } + + const value = field.getValue( { item } ); + + // Empty values are considered valid for maxLength validation + // (use required validation to enforce non-empty values) + if ( [ undefined, '', null ].includes( value ) ) { + return true; + } + + return String( value ).length <= field.isValid.maxLength.constraint; +} diff --git a/packages/dataviews/src/field-types/utils/is-valid-max.ts b/packages/dataviews/src/field-types/utils/is-valid-max.ts new file mode 100644 index 00000000000000..4653002ae6075c --- /dev/null +++ b/packages/dataviews/src/field-types/utils/is-valid-max.ts @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import type { NormalizedField } from '../../types'; + +export default function isValidMax< Item >( + item: Item, + field: NormalizedField< Item > +): boolean { + if ( typeof field.isValid.max?.constraint !== 'number' ) { + return false; + } + + const value = field.getValue( { item } ); + + // Empty values are considered valid for max validation + // (use required validation to enforce non-empty values) + if ( [ undefined, '', null ].includes( value ) ) { + return true; + } + + return Number( value ) <= field.isValid.max.constraint; +} diff --git a/packages/dataviews/src/field-types/utils/is-valid-min-length.ts b/packages/dataviews/src/field-types/utils/is-valid-min-length.ts new file mode 100644 index 00000000000000..3899a60c0f56f6 --- /dev/null +++ b/packages/dataviews/src/field-types/utils/is-valid-min-length.ts @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import type { NormalizedField } from '../../types'; + +export default function isValidMinLength< Item >( + item: Item, + field: NormalizedField< Item > +): boolean { + if ( typeof field.isValid.minLength?.constraint !== 'number' ) { + return false; + } + + const value = field.getValue( { item } ); + + // Empty values are considered valid for minLength validation + // (use required validation to enforce non-empty values) + if ( [ undefined, '', null ].includes( value ) ) { + return true; + } + + return String( value ).length >= field.isValid.minLength.constraint; +} diff --git a/packages/dataviews/src/field-types/utils/is-valid-min.ts b/packages/dataviews/src/field-types/utils/is-valid-min.ts new file mode 100644 index 00000000000000..73b007e7560b21 --- /dev/null +++ b/packages/dataviews/src/field-types/utils/is-valid-min.ts @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import type { NormalizedField } from '../../types'; + +export default function isValidMin< Item >( + item: Item, + field: NormalizedField< Item > +): boolean { + if ( typeof field.isValid.min?.constraint !== 'number' ) { + return false; + } + + const value = field.getValue( { item } ); + + // Empty values are considered valid for min validation + // (use required validation to enforce non-empty values) + if ( [ undefined, '', null ].includes( value ) ) { + return true; + } + + return Number( value ) >= field.isValid.min.constraint; +} diff --git a/packages/dataviews/src/field-types/utils/is-valid-pattern.ts b/packages/dataviews/src/field-types/utils/is-valid-pattern.ts new file mode 100644 index 00000000000000..f7eabe632b9460 --- /dev/null +++ b/packages/dataviews/src/field-types/utils/is-valid-pattern.ts @@ -0,0 +1,24 @@ +/** + * Internal dependencies + */ +import type { NormalizedField } from '../../types'; + +export default function isValidPattern< Item >( + item: Item, + field: NormalizedField< Item > +): boolean { + // There was an issue creating the constraint (e.g., parsing the regexp pattern). + if ( ! ( field?.isValid.pattern?.constraint instanceof RegExp ) ) { + return false; + } + + const value = field.getValue( { item } ); + + // Empty values are considered valid for pattern validation + // (use required validation to enforce non-empty values) + if ( [ undefined, '', null ].includes( value ) ) { + return true; + } + + return field.isValid.pattern.constraint.test( String( value ) ); +} diff --git a/packages/dataviews/src/field-types/utils/is-valid-required-for-array.ts b/packages/dataviews/src/field-types/utils/is-valid-required-for-array.ts new file mode 100644 index 00000000000000..3d27e196a3cd62 --- /dev/null +++ b/packages/dataviews/src/field-types/utils/is-valid-required-for-array.ts @@ -0,0 +1,18 @@ +/** + * Internal dependencies + */ +import type { NormalizedField } from '../../types'; + +export default function isValidRequiredForArray< Item >( + item: Item, + field: NormalizedField< Item > +) { + const value = field.getValue( { item } ); + return ( + Array.isArray( value ) && + value.length > 0 && + value.every( + ( element: any ) => ! [ undefined, '', null ].includes( element ) + ) + ); +} diff --git a/packages/dataviews/src/field-types/utils/is-valid-required-for-bool.ts b/packages/dataviews/src/field-types/utils/is-valid-required-for-bool.ts new file mode 100644 index 00000000000000..69d76557f7d264 --- /dev/null +++ b/packages/dataviews/src/field-types/utils/is-valid-required-for-bool.ts @@ -0,0 +1,13 @@ +/** + * Internal dependencies + */ +import type { NormalizedField } from '../../types'; + +export default function isValidRequiredForBool< Item >( + item: Item, + field: NormalizedField< Item > +) { + const value = field.getValue( { item } ); + + return value === true; +} diff --git a/packages/dataviews/src/field-types/utils/is-valid-required.ts b/packages/dataviews/src/field-types/utils/is-valid-required.ts new file mode 100644 index 00000000000000..6b3360cde2ca61 --- /dev/null +++ b/packages/dataviews/src/field-types/utils/is-valid-required.ts @@ -0,0 +1,13 @@ +/** + * Internal dependencies + */ +import type { NormalizedField } from '../../types'; + +export default function isValidRequired< Item >( + item: Item, + field: NormalizedField< Item > +) { + const value = field.getValue( { item } ); + + return ! [ undefined, '', null ].includes( value ); +} diff --git a/packages/dataviews/src/hooks/use-form-validity.ts b/packages/dataviews/src/hooks/use-form-validity.ts index 263ff635fed6e3..c7d8952df3fdc7 100644 --- a/packages/dataviews/src/hooks/use-form-validity.ts +++ b/packages/dataviews/src/hooks/use-form-validity.ts @@ -23,36 +23,6 @@ import type { NormalizedField, NormalizedFormField, } from '../types'; -const isEmptyNullOrUndefined = ( value: any ) => - [ undefined, '', null ].includes( value ); - -const isArrayOrElementsEmptyNullOrUndefined = ( value: any ) => { - return ( - ! Array.isArray( value ) || - value.length === 0 || - value.every( ( element: any ) => isEmptyNullOrUndefined( element ) ) - ); -}; - -function isInvalidForRequired( fieldType: string | undefined, value: any ) { - if ( - ( fieldType === undefined && isEmptyNullOrUndefined( value ) ) || - ( fieldType === 'text' && isEmptyNullOrUndefined( value ) ) || - ( fieldType === 'email' && isEmptyNullOrUndefined( value ) ) || - ( fieldType === 'url' && isEmptyNullOrUndefined( value ) ) || - ( fieldType === 'telephone' && isEmptyNullOrUndefined( value ) ) || - ( fieldType === 'password' && isEmptyNullOrUndefined( value ) ) || - ( fieldType === 'integer' && isEmptyNullOrUndefined( value ) ) || - ( fieldType === 'number' && isEmptyNullOrUndefined( value ) ) || - ( fieldType === 'array' && - isArrayOrElementsEmptyNullOrUndefined( value ) ) || - ( fieldType === 'boolean' && value !== true ) - ) { - return true; - } - - return false; -} function isFormValid( formValidity: FormValidity | undefined ): boolean { if ( ! formValidity ) { @@ -223,57 +193,12 @@ function handleElementsValidationAsync< Item >( return; } - const validValues = result.map( ( el ) => el.value ); - if ( - !! formField.field && - formField.field.type !== 'array' && - ! validValues.includes( formField.field.getValue( { item } ) ) - ) { - setFormValidity( ( prev ) => { - const newFormValidity = setValidityAtPath( - prev, - { - elements: { - type: 'invalid', - message: __( - 'Value must be one of the elements.' - ), - }, - }, - [ ...path, formField.id ] - ); - return newFormValidity; - } ); - return; - } - - if ( - !! formField.field && - formField.field.type === 'array' && - ! Array.isArray( formField.field.getValue( { item } ) ) - ) { - setFormValidity( ( prev ) => { - const newFormValidity = setValidityAtPath( - prev, - { - elements: { - type: 'invalid', - message: __( 'Value must be an array.' ), - }, - }, - [ ...path, formField.id ] - ); - return newFormValidity; - } ); - return; - } - if ( - !! formField.field && - formField.field.type === 'array' && - formField.field - .getValue( { item } ) - .some( ( v: any ) => ! validValues.includes( v ) ) + formField.field?.isValid.elements && + ! formField.field.isValid.elements.validate( item, { + ...formField.field, + elements: result, + } ) ) { setFormValidity( ( prev ) => { const newFormValidity = setValidityAtPath( @@ -434,12 +359,8 @@ function validateFormField< Item >( ): FieldValidity | undefined { // Validate the field: isValid.required if ( - !! formField.field && - formField.field.isValid.required && - isInvalidForRequired( - formField.field.type, - formField.field.getValue( { item } ) - ) + formField.field?.isValid.required && + ! formField.field.isValid.required.validate( item, formField.field ) ) { return { required: { type: 'invalid' }, @@ -448,168 +369,83 @@ function validateFormField< Item >( // Validate the field: isValid.pattern if ( - !! formField.field && - formField.field.isValid.pattern && - ( formField.field.type === 'text' || - formField.field.type === 'email' || - formField.field.type === 'url' || - formField.field.type === 'telephone' || - formField.field.type === 'password' ) + formField.field?.isValid.pattern && + ! formField.field.isValid.pattern.validate( item, formField.field ) ) { - const value = formField.field.getValue( { item } ); - // Only validate pattern if the value is not empty - if ( ! isEmptyNullOrUndefined( value ) ) { - try { - const regex = new RegExp( formField.field.isValid.pattern ); - if ( ! regex.test( String( value ) ) ) { - return { - pattern: { - type: 'invalid', - message: __( - 'Value does not match the required pattern.' - ), - }, - }; - } - } catch ( error ) { - return { - pattern: { - type: 'invalid', - message: __( 'Invalid pattern configuration.' ), - }, - }; - } - } + return { + pattern: { + type: 'invalid', + message: __( 'Value does not match the required pattern.' ), + }, + }; } // Validate the field: isValid.min if ( - !! formField.field && - formField.field.isValid.min !== undefined && - ( formField.field.type === 'integer' || - formField.field.type === 'number' ) + formField.field?.isValid.min && + ! formField.field.isValid.min.validate( item, formField.field ) ) { - 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.' ), - }, - }; - } - } + 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' ) + formField.field?.isValid.max && + ! formField.field.isValid.max.validate( item, formField.field ) ) { - 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.' ), - }, - }; - } - } + 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' ) + formField.field?.isValid.minLength && + ! formField.field.isValid.minLength.validate( item, formField.field ) ) { - 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.' ), - }, - }; - } - } + 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' ) + formField.field?.isValid.maxLength && + ! formField.field.isValid.maxLength.validate( item, formField.field ) ) { - 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.' ), - }, - }; - } - } + return { + maxLength: { + type: 'invalid', + message: __( 'Value is too long.' ), + }, + }; } // Validate the field: isValid.elements (static) if ( - !! formField.field && - formField.field.isValid.elements && + formField.field?.isValid.elements && formField.field.hasElements && ! formField.field.getElements && - Array.isArray( formField.field.elements ) + Array.isArray( formField.field.elements ) && + ! formField.field.isValid.elements.validate( item, formField.field ) ) { - const value = formField.field.getValue( { item } ); - const validValues = formField.field.elements.map( ( el ) => el.value ); - - if ( - formField.field.type !== 'array' && - ! validValues.includes( value ) - ) { - return { - elements: { - type: 'invalid', - message: __( 'Value must be one of the elements.' ), - }, - }; - } - - if ( formField.field.type === 'array' && ! Array.isArray( value ) ) { - return { - elements: { - type: 'invalid', - message: __( 'Value must be an array.' ), - }, - }; - } - if ( - formField.field.type === 'array' && - value.some( ( v: any ) => ! validValues.includes( v ) ) - ) { - return { - elements: { - type: 'invalid', - message: __( 'Value must be one of the elements.' ), - }, - }; - } + return { + elements: { + type: 'invalid', + message: __( 'Value must be one of the elements.' ), + }, + }; } // Validate the field: isValid.elements (async) @@ -635,10 +471,10 @@ function validateFormField< Item >( // Validate the field: isValid.custom (sync) let customError; - if ( !! formField.field ) { + if ( !! formField.field && formField.field.isValid.custom ) { try { const value = formField.field.getValue( { item } ); - customError = formField.field.isValid?.custom?.( + customError = formField.field.isValid.custom( deepMerge( item, formField.field.setValue( { diff --git a/packages/dataviews/src/stories/dataform.story.tsx b/packages/dataviews/src/stories/dataform.story.tsx index b473b9397357ca..0cce48e1f639d4 100644 --- a/packages/dataviews/src/stories/dataform.story.tsx +++ b/packages/dataviews/src/stories/dataform.story.tsx @@ -26,9 +26,9 @@ import type { FieldValidity, Form, Layout, + NormalizedRules, PanelLayout, RegularLayout, - Rules, } from '../types'; import { unlock } from '../lock-unlock'; import DateControl from '../dataform-controls/date'; @@ -497,7 +497,7 @@ const LayoutPanelComponent = ( { }; function getCustomValidity< Item >( - isValid: Rules< Item >, + isValid: NormalizedRules< Item >, validity: FieldValidity | undefined ) { let customValidity; diff --git a/packages/dataviews/src/test/use-form-validity.ts b/packages/dataviews/src/test/use-form-validity.ts index cad37b8bb8eb57..e6284fec9abccb 100644 --- a/packages/dataviews/src/test/use-form-validity.ts +++ b/packages/dataviews/src/test/use-form-validity.ts @@ -501,36 +501,6 @@ describe( 'useFormValidity', () => { expect( validity?.tags ).toEqual( ELEMENTS_MESSAGE ); expect( isValid ).toBe( false ); } ); - - it( 'array is invalid when value is not an array', () => { - const item = { id: 1, tags: 'not-an-array' }; - const fields: Field< {} >[] = [ - { - id: 'tags', - type: 'array', - elements: [ - { value: 'red', label: 'Red' }, - { value: 'blue', label: 'Blue' }, - ], - isValid: { - custom: () => null, // Disable to make sure the only validation triggered is elements - }, - }, - ]; - const form = { fields: [ 'tags' ] }; - const { - result: { - current: { validity, isValid }, - }, - } = renderHook( () => useFormValidity( item, fields, form ) ); - expect( validity?.tags ).toEqual( { - elements: { - type: 'invalid', - message: 'Value must be an array.', - }, - } ); - expect( isValid ).toBe( false ); - } ); } ); describe( 'isValid.pattern', () => { @@ -792,7 +762,7 @@ describe( 'useFormValidity', () => { expect( validity?.username ).toEqual( { pattern: { type: 'invalid', - message: 'Invalid pattern configuration.', + message: 'Value does not match the required pattern.', }, } ); expect( isValid ).toBe( false ); diff --git a/packages/dataviews/src/types/field-api.ts b/packages/dataviews/src/types/field-api.ts index 193b9598da3b6e..941a994665f1ca 100644 --- a/packages/dataviews/src/types/field-api.ts +++ b/packages/dataviews/src/types/field-api.ts @@ -92,6 +92,34 @@ export type Rules< Item > = { ) => Promise< null | string > ); }; +export type Validator< Item > = ( + item: Item, + field: NormalizedField< Item > +) => boolean; + +export type CustomValidator< Item > = + | ( ( item: Item, field: NormalizedField< Item > ) => null | string ) + | ( ( + item: Item, + field: NormalizedField< Item > + ) => Promise< null | string > ); + +type NormalizedRule< Item, ConstraintType > = { + constraint: ConstraintType; + validate: Validator< Item >; +}; + +export type NormalizedRules< Item > = { + required?: NormalizedRule< Item, boolean >; + elements?: NormalizedRule< Item, boolean >; + pattern?: NormalizedRule< Item, RegExp | null >; + minLength?: NormalizedRule< Item, number >; + maxLength?: NormalizedRule< Item, number >; + min?: NormalizedRule< Item, number >; + max?: NormalizedRule< Item, number >; + custom?: CustomValidator< Item >; +}; + /** * Edit configuration for textarea controls. */ @@ -288,7 +316,7 @@ export type FormatInteger = { separatorThousand?: string; }; -type NormalizedFieldBase< Item > = Omit< Field< Item >, 'Edit' > & { +type NormalizedFieldBase< Item > = Omit< Field< Item >, 'Edit' | 'isValid' > & { label: string; header: string | ReactElement; getValue: ( args: { item: Item } ) => any; @@ -297,7 +325,7 @@ type NormalizedFieldBase< Item > = Omit< Field< Item >, 'Edit' > & { Edit: ComponentType< DataFormControlProps< Item > > | null; hasElements: boolean; sort: ( a: Item, b: Item, direction: SortDirection ) => number; - isValid: Rules< Item >; + isValid: NormalizedRules< Item >; enableHiding: boolean; enableSorting: boolean; filterBy: Required< FilterByConfig > | false; diff --git a/packages/dataviews/src/types/private.ts b/packages/dataviews/src/types/private.ts index 72fff1405acf8c..d00bb13be06cf7 100644 --- a/packages/dataviews/src/types/private.ts +++ b/packages/dataviews/src/types/private.ts @@ -2,24 +2,21 @@ * Internal dependencies */ import type { + CustomValidator, Field, FormatDate, FormatInteger, FormatNumber, NormalizedField, Operator, + Validator, } from './field-api'; export type SelectionOrUpdater = string[] | ( ( prev: string[] ) => string[] ); export type SetSelection = ( selection: SelectionOrUpdater ) => void; export type FieldType< Item > = Pick< NormalizedField< Item >, - | 'type' - | 'render' - | 'sort' - | 'isValid' - | 'enableSorting' - | 'enableGlobalSearch' + 'type' | 'render' | 'sort' | 'enableSorting' | 'enableGlobalSearch' > & { Edit: string | null; validOperators: Operator[]; @@ -31,4 +28,14 @@ export type FieldType< Item > = Pick< | Required< FormatDate > | Required< FormatNumber > | Required< FormatInteger >; + validate: { + required?: Validator< Item >; + elements?: Validator< Item >; + pattern?: Validator< Item >; + minLength?: Validator< Item >; + maxLength?: Validator< Item >; + min?: Validator< Item >; + max?: Validator< Item >; + custom?: CustomValidator< Item >; + }; }; From 937dfbec578dbe79d4b29a36a06ad940cc9685d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Tue, 2 Dec 2025 10:26:41 +0100 Subject: [PATCH 2/4] Fix rebase issue --- packages/dataviews/src/field-types/number.tsx | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/dataviews/src/field-types/number.tsx b/packages/dataviews/src/field-types/number.tsx index 32e4e9932d4da5..a152e65e807cb3 100644 --- a/packages/dataviews/src/field-types/number.tsx +++ b/packages/dataviews/src/field-types/number.tsx @@ -111,19 +111,7 @@ function isValidCustom< Item >( item: Item, field: NormalizedField< Item > ) { return __( 'Value must be a number.' ); } - // If the field type is number, we've already normalized the format, - // and so it's safe to tell TypeScript to trust us ("as Required"). - // - // There're no runtime paths where this render function is called with a non-number field, - // but TypeScript is unable to infer this, hence the type assertion. - let format: Required< FormatNumber >; - if ( field.type !== 'number' ) { - format = getFormat( field as Field< any > ); - } else { - format = field.format as Required< FormatNumber >; - } - - return formatNumber( Number( value ), format ); + return null; } export default { From 4b06f95ec4c6a4a840d2fc0eb5c38f0528fa77d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Tue, 2 Dec 2025 11:00:05 +0100 Subject: [PATCH 3/4] Match rule with description and placeholder text --- packages/dataviews/src/stories/dataform.story.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dataviews/src/stories/dataform.story.tsx b/packages/dataviews/src/stories/dataform.story.tsx index 0cce48e1f639d4..011546f10338e4 100644 --- a/packages/dataviews/src/stories/dataform.story.tsx +++ b/packages/dataviews/src/stories/dataform.story.tsx @@ -1121,7 +1121,7 @@ const ValidationComponent = ( { required, elements: elements !== 'none' ? true : false, custom: maybeCustomRule( customIntegerRule ), - min: minMax ? 0 : undefined, + min: minMax ? 10 : undefined, max: minMax ? 100 : undefined, }, }, From e3c029cb866cf903e3d9f010143730708de735e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:09:53 +0100 Subject: [PATCH 4/4] Fix issue with pattern constraint --- .../src/dataform-layouts/panel/modal.tsx | 2 +- .../src/field-types/utils/get-is-valid.ts | 16 +++--------- .../src/field-types/utils/is-valid-pattern.ts | 25 +++++++++++-------- packages/dataviews/src/types/field-api.ts | 2 +- 4 files changed, 21 insertions(+), 24 deletions(-) diff --git a/packages/dataviews/src/dataform-layouts/panel/modal.tsx b/packages/dataviews/src/dataform-layouts/panel/modal.tsx index 26467506673dc0..fe66fd1f4e0cc5 100644 --- a/packages/dataviews/src/dataform-layouts/panel/modal.tsx +++ b/packages/dataviews/src/dataform-layouts/panel/modal.tsx @@ -71,7 +71,7 @@ function ModalContent< Item >( { elements: f.isValid.elements?.constraint, min: f.isValid.min?.constraint, max: f.isValid.max?.constraint, - pattern: f.isValid.pattern?.constraint?.source, + pattern: f.isValid.pattern?.constraint, minLength: f.isValid.minLength?.constraint, maxLength: f.isValid.maxLength?.constraint, }, diff --git a/packages/dataviews/src/field-types/utils/get-is-valid.ts b/packages/dataviews/src/field-types/utils/get-is-valid.ts index 65d1ee61478e42..ad10902f026487 100644 --- a/packages/dataviews/src/field-types/utils/get-is-valid.ts +++ b/packages/dataviews/src/field-types/utils/get-is-valid.ts @@ -82,18 +82,10 @@ export default function getIsValid< Item >( field.isValid?.pattern !== undefined && fieldType.validate.pattern !== undefined ) { - try { - const regex = new RegExp( field.isValid.pattern ); - pattern = { - constraint: regex, - validate: fieldType.validate.pattern, - }; - } catch ( error ) { - pattern = { - constraint: null, - validate: () => false, - }; - } + pattern = { + constraint: field.isValid?.pattern, + validate: fieldType.validate.pattern, + }; } const custom = field.isValid?.custom ?? fieldType.validate.custom; diff --git a/packages/dataviews/src/field-types/utils/is-valid-pattern.ts b/packages/dataviews/src/field-types/utils/is-valid-pattern.ts index f7eabe632b9460..44e64c1c653746 100644 --- a/packages/dataviews/src/field-types/utils/is-valid-pattern.ts +++ b/packages/dataviews/src/field-types/utils/is-valid-pattern.ts @@ -7,18 +7,23 @@ export default function isValidPattern< Item >( item: Item, field: NormalizedField< Item > ): boolean { - // There was an issue creating the constraint (e.g., parsing the regexp pattern). - if ( ! ( field?.isValid.pattern?.constraint instanceof RegExp ) ) { - return false; + if ( field.isValid.pattern?.constraint === undefined ) { + return true; } - const value = field.getValue( { item } ); + try { + const regexp = new RegExp( field.isValid.pattern.constraint ); - // Empty values are considered valid for pattern validation - // (use required validation to enforce non-empty values) - if ( [ undefined, '', null ].includes( value ) ) { - return true; - } + const value = field.getValue( { item } ); - return field.isValid.pattern.constraint.test( String( value ) ); + // Empty values are considered valid for pattern validation + // (use required validation to enforce non-empty values) + if ( [ undefined, '', null ].includes( value ) ) { + return true; + } + + return regexp.test( String( value ) ); + } catch { + return false; + } } diff --git a/packages/dataviews/src/types/field-api.ts b/packages/dataviews/src/types/field-api.ts index 941a994665f1ca..a65c9253a4499c 100644 --- a/packages/dataviews/src/types/field-api.ts +++ b/packages/dataviews/src/types/field-api.ts @@ -112,7 +112,7 @@ type NormalizedRule< Item, ConstraintType > = { export type NormalizedRules< Item > = { required?: NormalizedRule< Item, boolean >; elements?: NormalizedRule< Item, boolean >; - pattern?: NormalizedRule< Item, RegExp | null >; + pattern?: NormalizedRule< Item, string >; minLength?: NormalizedRule< Item, number >; maxLength?: NormalizedRule< Item, number >; min?: NormalizedRule< Item, number >;