Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
26b113b
Make validation controlled and async
oandregal Aug 29, 2025
9b1fa80
Remove isItemValid
oandregal Oct 15, 2025
1acbc8f
Document useIsFormValid hook
oandregal Oct 15, 2025
c965fa8
Test useIsFormValid: port all existing test for isItemValid
oandregal Oct 15, 2025
219dd24
Update FieldValidity and FormValidity types
oandregal Oct 15, 2025
3cbe36d
useIsFormValid: update tests
oandregal Oct 15, 2025
744e219
Update docs
oandregal Oct 15, 2025
81579ac
Create getCustomValidity utility
oandregal Oct 15, 2025
9ff587b
Update tests
oandregal Oct 15, 2025
16676bb
Rename useIsFormValid to useFormValidity
oandregal Oct 15, 2025
522dd49
useFormValidity returns validity and isValid
oandregal Oct 15, 2025
b2600d0
Add a test for isValid
oandregal Oct 15, 2025
af2bc15
DataForm: datetime control uses validity prop
oandregal Oct 15, 2025
2121abb
DataForm: date control uses validity prop
oandregal Oct 15, 2025
e067e62
Add changelog
oandregal Oct 15, 2025
f8d2252
DataForm: date control attaches different class depending on type
oandregal Oct 15, 2025
1851f70
Fix unmounting issue with panel dropdown/modal
oandregal Oct 16, 2025
10862ef
getCustomValidity: fall through to elements or custom if validity.req…
oandregal Oct 16, 2025
e80ff81
Update changelog
oandregal Oct 16, 2025
84b0a5d
Update README
oandregal Oct 16, 2025
224089f
Story: better custom Edit
oandregal Oct 16, 2025
b1979b1
Support combined fields.
oandregal Oct 16, 2025
1027148
Make linter happy
oandregal Oct 16, 2025
375c4a8
story: remove unnecessary flags
oandregal Oct 16, 2025
22c8cb7
Move useFormValidity test to proper place
oandregal Oct 16, 2025
12f88bf
Improve useFormValidity hook
oandregal Oct 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/dataviews/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
- Remove `Data< Item >` type, as it is no longer used internally for a long time. [#72051](https://github.com/WordPress/gutenberg/pull/72051)
- Remove `isDestructive` prop from actions API. Destructive actions should be communicated via flow (opens modal to confirm) and color should be used in the modal. [#72111](https://github.com/WordPress/gutenberg/pull/72111)
- The `isValid.custom` default function that comes with the field type no longer checks for elements. This is now the `isValid.elements` responsibility and can be toggle on/off separately. [#72325](https://github.com/WordPress/gutenberg/pull/72325)
- DataForm: make validation controlled by leveraging a `validity` prop. This also removes `isItemValid` and introduces `useFormValidity` hook to calculate the `validity` prop. [#71412](https://github.com/WordPress/gutenberg/pull/71412)

### Features

Expand Down
82 changes: 76 additions & 6 deletions packages/dataviews/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,54 @@ return (
);
```

### validity

Object that determines the validation status of each field. There's a `useFormValidity` hook that can be used to create the validity object — see the utility below. This section documents the `validity` object in case you want to create it via other means.

The top-level props of the `validity` object are the field IDs. Fields declare their validity status for each of the validation rules supported: `required`, `elements`, `custom`. If a rule is valid, it should not be present in the object; if a field is valid for all the rules, it should not be present in the object either.

For example:

```json
{
"title": {
"required": {
"type": "invalid"
}
},
"author": {
"elements": {
"type": "invalid",
"message": "Value must be one of the elements."
}
},
"publisher": {
"custom": {
"type": "validating",
"message": "Validating..."
}
},
"isbn": {
"custom": {
"type": "valid",
"message": "Valid."
}
}
}
```

Each rule, can have a `type` and a `message`.

The `message` is the text to be displayed in the UI controls. The message for the `required` rule is optional, and the built-in browser message will be used if not provided.

The `type` can be:

- `validating`: when the value is being validated (e.g., custom async rule)
- `invalid`: when the value is invalid according to the rule
- `valid`: when the value _became_ valid after having been invalid (e.g., custom async rule)

Note the `valid` status. This is useful for displaying a "Valid." message when the field transitions from invalid to valid. The `useFormValidity` hook implements this only for the custom async validation.

## Utilities

### `filterSortAndPaginate`
Expand All @@ -716,17 +764,39 @@ Returns an object containing:
- `totalItems`: total number of items for the current view config.
- `totalPages`: total number of pages for the current view config.

### `isItemValid`
### `useFormValidity`

Utility is used to determine whether or not the given item's value is valid according to the current fields and form configuration.
Hook to determine the form validation status.

Parameters:

- `item`: the item, as described in the "data" property of DataForm.
- `fields`: the fields config, as described in the "fields" property of DataForm.
- `form`: the form config, as described in the "form" property of DataForm.
- `item`: the item being edited.
- `fields`: the fields config, as described in the "fields" property of DataViews.
- `form`: the form config, as described in the "form" property of DataViews.

Returns an object containing:

Returns a boolean indicating if the item is valid (true) or not (false).
- `isValid`: a boolean indicating if the form is valid.
- `validity`: an object containing the errors. Each property is a field ID, containing a description of each error type. See `validity` prop for more info. For example:

```js
{
fieldId: {
required: {
type: 'invalid',
message: 'Required.' // Optional
},
elements: {
type: 'invalid',
message: 'Value must be one of the elements.' // Optional
},
custom: {
type: 'validating',
message: 'Validating...'
}
}
}
```

## Actions API

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ DataFormContext.displayName = 'DataFormContext';
export function DataFormProvider< Item >( {
fields,
children,
}: React.PropsWithChildren< { fields: NormalizedField< Item >[] } > ) {
}: React.PropsWithChildren< {
fields: NormalizedField< Item >[];
} > ) {
return (
<DataFormContext.Provider value={ { fields } }>
{ children }
Expand Down
8 changes: 7 additions & 1 deletion packages/dataviews/src/components/dataform/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default function DataForm< Item >( {
form,
fields,
onChange,
validity,
}: DataFormProps< Item > ) {
const normalizedFields = useMemo(
() => normalizeFields( fields ),
Expand All @@ -28,7 +29,12 @@ export default function DataForm< Item >( {

return (
<DataFormProvider fields={ normalizedFields }>
<DataFormLayout data={ data } form={ form } onChange={ onChange } />
<DataFormLayout
data={ data }
form={ form }
onChange={ onChange }
validity={ validity }
/>
</DataFormProvider>
);
}
88 changes: 6 additions & 82 deletions packages/dataviews/src/dataform-controls/array.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
/**
* External dependencies
*/
import deepMerge from 'deepmerge';

/**
* WordPress dependencies
*/
import { privateApis } from '@wordpress/components';
import { useCallback, useMemo, useState } from '@wordpress/element';
import { _n, sprintf } from '@wordpress/i18n';
import { useCallback, useMemo } from '@wordpress/element';

/**
* Internal dependencies
*/
import type { DataFormControlProps } from '../types';
import { unlock } from '../lock-unlock';
import getCustomValidity from './utils/get-custom-validity';

const { ValidatedFormTokenField } = unlock( privateApis );

Expand All @@ -23,18 +18,11 @@ export default function ArrayControl< Item >( {
field,
onChange,
hideLabelFromVision,
validity,
}: DataFormControlProps< Item > ) {
const { label, placeholder, elements, getValue, setValue } = field;
const { label, placeholder, elements, getValue, setValue, isValid } = field;
const value = getValue( { item: data } );

const [ customValidity, setCustomValidity ] = useState<
| {
type: 'validating' | 'valid' | 'invalid';
message: string;
}
| undefined
>( undefined );

// Convert stored values to element objects for the token field
const arrayValueAsElements = useMemo(
() =>
Expand All @@ -49,69 +37,6 @@ export default function ArrayControl< Item >( {
[ value, elements ]
);

const validateTokens = useCallback(
( tokens: ( string | { value: string; label?: string } )[] ) => {
// Extract actual values from tokens for validation
const tokenValues = tokens.map( ( token ) => {
if ( typeof token === 'object' && 'value' in token ) {
return token.value;
}
return token;
} );

// First, check if elements validation is required and any tokens are invalid
if ( field.isValid?.elements && elements ) {
const invalidTokens = tokenValues.filter( ( tokenValue ) => {
return ! elements.some(
( element ) => element.value === tokenValue
);
} );

if ( invalidTokens.length > 0 ) {
setCustomValidity( {
type: 'invalid',
message: sprintf(
/* translators: %s: list of invalid tokens */
_n(
'Please select from the available options: %s is invalid.',
'Please select from the available options: %s are invalid.',
invalidTokens.length
),
invalidTokens.join( ', ' )
),
} );
return;
}
}

// Then check custom validation if provided.
if ( field.isValid?.custom ) {
const result = field.isValid?.custom?.(
deepMerge(
data,
setValue( {
item: data,
value: tokenValues,
} ) as Partial< Item >
),
field
);

if ( result ) {
setCustomValidity( {
type: 'invalid',
message: result,
} );
return;
}
}

// If no validation errors, clear custom validity
setCustomValidity( undefined );
},
[ elements, data, field, setValue ]
);

const onChangeControl = useCallback(
( tokens: ( string | { value: string; label?: string } )[] ) => {
const valueTokens = tokens.map( ( token ) => {
Expand All @@ -129,9 +54,8 @@ export default function ArrayControl< Item >( {

return (
<ValidatedFormTokenField
required={ !! field.isValid?.required }
onValidate={ validateTokens }
customValidity={ customValidity }
required={ !! isValid?.required }
customValidity={ getCustomValidity( isValid, validity ) }
label={ hideLabelFromVision ? undefined : label }
value={ arrayValueAsElements }
onChange={ onChangeControl }
Expand Down
46 changes: 5 additions & 41 deletions packages/dataviews/src/dataform-controls/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
/**
* External dependencies
*/
import deepMerge from 'deepmerge';

/**
* WordPress dependencies
*/
import { privateApis } from '@wordpress/components';
import { useState, useCallback } from '@wordpress/element';
import { useCallback } from '@wordpress/element';

/**
* Internal dependencies
*/
import type { DataFormControlProps } from '../types';
import { unlock } from '../lock-unlock';
import getCustomValidity from './utils/get-custom-validity';

const { ValidatedCheckboxControl } = unlock( privateApis );

Expand All @@ -22,52 +18,20 @@ export default function Checkbox< Item >( {
onChange,
data,
hideLabelFromVision,
validity,
}: DataFormControlProps< Item > ) {
const { getValue, setValue, label, description } = field;
const [ customValidity, setCustomValidity ] =
useState<
React.ComponentProps<
typeof ValidatedCheckboxControl
>[ 'customValidity' ]
>( undefined );
const { getValue, setValue, label, description, isValid } = field;

const onChangeControl = useCallback( () => {
onChange(
setValue( { item: data, value: ! getValue( { item: data } ) } )
);
}, [ data, getValue, onChange, setValue ] );

const onValidateControl = useCallback(
( newValue: any ) => {
const message = field.isValid?.custom?.(
deepMerge(
data,
setValue( {
item: data,
value: newValue,
} ) as Partial< Item >
),
field
);

if ( message ) {
setCustomValidity( {
type: 'invalid',
message,
} );
return;
}

setCustomValidity( undefined );
},
[ data, field, setValue ]
);

return (
<ValidatedCheckboxControl
required={ !! field.isValid?.required }
onValidate={ onValidateControl }
customValidity={ customValidity }
customValidity={ getCustomValidity( isValid, validity ) }
hidden={ hideLabelFromVision }
label={ label }
help={ description }
Expand Down
Loading
Loading