Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
50 changes: 39 additions & 11 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@storybook/addon-actions": "8.4.7",
"@storybook/addon-controls": "8.4.7",
"@storybook/addon-docs": "8.4.7",
"@storybook/addon-interactions": "8.4.7",
"@storybook/addon-toolbars": "8.4.7",
"@storybook/addon-viewport": "8.4.7",
"@storybook/addon-webpack5-compiler-babel": "3.0.3",
Expand Down Expand Up @@ -109,7 +110,7 @@
"eslint-plugin-prettier": "5.0.0",
"eslint-plugin-react-compiler": "19.0.0-beta-0dec889-20241115",
"eslint-plugin-ssr-friendly": "1.0.6",
"eslint-plugin-storybook": "0.6.13",
"eslint-plugin-storybook": "0.9.0",
"eslint-plugin-testing-library": "6.0.2",
"execa": "4.0.2",
"fast-glob": "3.2.7",
Expand Down
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
### Internal

- Validated form controls: Add support for async validation. This is a breaking API change that splits the `customValidator` prop into an `onValidate` callback and a `customValidity` object. ([#71184](https://github.com/WordPress/gutenberg/pull/71184)).
- Validated form controls: Fix bug where "validating" state was not shown when transitioning from error state ([#71260](https://github.com/WordPress/gutenberg/pull/71260)).
- `DateCalendar`, `DateRangeCalendar`: use `px` instead of `rem` units. ([#71248](https://github.com/WordPress/gutenberg/pull/71248)).

## 30.1.0 (2025-08-07)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
/**
* WordPress dependencies
* External dependencies
*/
import { useRef, useCallback, useState } from '@wordpress/element';
import type { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, waitFor, within } from '@storybook/test';

/**
* External dependencies
* WordPress dependencies
*/
import type { Meta, StoryObj } from '@storybook/react';
import { useRef, useCallback, useState } from '@wordpress/element';
import { debounce } from '@wordpress/compose';

/**
* Internal dependencies
*/
import { ValidatedInputControl } from '..';
import { formDecorator } from './story-utils';
import type { ControlWithError } from '../../control-with-error';
import { debounce } from '@wordpress/compose';

const meta: Meta< typeof ControlWithError > = {
title: 'Components/Selection & Input/Validated Form Controls/Overview',
Expand Down Expand Up @@ -166,24 +167,19 @@ export const AsyncValidation: StoryObj< typeof ValidatedInputControl > = {
} );

clearTimeout( timeoutRef.current );
timeoutRef.current = setTimeout(
() => {
if ( v?.toString().toLowerCase() === 'error' ) {
setCustomValidity( {
type: 'invalid',
message: 'The word "error" is not allowed.',
} );
} else {
setCustomValidity( {
type: 'valid',
message: 'Validated',
} );
}
},
// Mimics a random server response time.
// eslint-disable-next-line no-restricted-syntax
Math.random() < 0.5 ? 1500 : 300
);
timeoutRef.current = setTimeout( () => {
if ( v?.toString().toLowerCase() === 'error' ) {
setCustomValidity( {
type: 'invalid',
message: 'The word "error" is not allowed.',
} );
} else {
setCustomValidity( {
type: 'valid',
message: 'Validated',
} );
}
}, 1500 );
}, 500 ),
[]
);
Expand All @@ -200,9 +196,95 @@ export const AsyncValidation: StoryObj< typeof ValidatedInputControl > = {
/>
);
},
args: {
label: 'Text',
help: 'The word "error" will trigger an error asynchronously.',
required: true,
},
};
AsyncValidation.args = {
label: 'Text',
help: 'The word "error" will trigger an error asynchronously.',
required: true,

// Not exported - Only for testing purposes.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const AsyncValidationWithTest: StoryObj< typeof ValidatedInputControl > = {
...AsyncValidation,
play: async ( { canvasElement } ) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is nice and weird 😅 The first time I encountered I felt it was a bug. It'd be nice to trigger this intentionally from a button instead of executing it on load.

It makes it impossible to test/learn how this component works: for example, due to having this setup, the first user validation will trigger on change, not on blur. So I think I'd rather not have this if this can't be triggered by users.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fair 😅 Unfortunately there's no way to disable the autoplay on the individual story pages. I'll keep the story unexported so it's still available for test purposes (Jest was a bit unreliable for this one).

const canvas = within( canvasElement );
await userEvent.click( canvas.getByRole( 'textbox' ) );
await userEvent.type( canvas.getByRole( 'textbox' ), 'valid text', {
delay: 10,
} );
await userEvent.tab();

await waitFor(
() => {
expect( canvas.getByText( 'Validated' ) ).toBeVisible();
},
{ timeout: 2500 }
);

await new Promise( ( resolve ) => setTimeout( resolve, 500 ) );
await userEvent.clear( canvas.getByRole( 'textbox' ) );

// Should show validating state when transitioning from valid to invalid.
await waitFor(
() => {
expect( canvas.getByText( 'Validating...' ) ).toBeVisible();
},
{ timeout: 2500 }
);

await waitFor(
() => {
expect(
canvas.getByText( 'Please fill out this field.' )
).toBeVisible();
},
{ timeout: 2500 }
);

// Should not show validating state if there were no changes
// after a valid/invalid state was already shown.
await new Promise( ( resolve ) => setTimeout( resolve, 1500 ) );
await expect(
canvas.queryByText( 'Validating...' )
).not.toBeInTheDocument();

await userEvent.type( canvas.getByRole( 'textbox' ), 'e', {
delay: 10,
} );

// Should not show valid state if server has not yet responded.
await expect(
canvas.queryByText( 'Validated' )
).not.toBeInTheDocument();

// Should show validating state when transitioning from invalid to valid.
await waitFor(
() => {
expect( canvas.getByText( 'Validating...' ) ).toBeVisible();
},
{ timeout: 2500 }
);

await waitFor(
() => {
expect( canvas.getByText( 'Validated' ) ).toBeVisible();
},
{ timeout: 2500 }
);

await new Promise( ( resolve ) => setTimeout( resolve, 1000 ) );
await userEvent.type( canvas.getByRole( 'textbox' ), 'rror', {
delay: 10,
} );

await waitFor(
() => {
expect(
canvas.getByText( 'The word "error" is not allowed.' )
).toBeVisible();
},
{ timeout: 2500 }
);
},
};
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
/**
* WordPress dependencies
*/
import { usePrevious } from '@wordpress/compose';
import { __ } from '@wordpress/i18n';

/**
* External dependencies
*/
import {
cloneElement,
forwardRef,
Expand Down Expand Up @@ -98,6 +95,7 @@ function UnforwardedControlWithError< C extends React.ReactElement >(
| undefined
>();
const [ isTouched, setIsTouched ] = useState( false );
const previousCustomValidityType = usePrevious( customValidity?.type );

// Ensure that error messages are visible after user attemps to submit a form
// with multiple invalid fields.
Expand All @@ -116,7 +114,7 @@ function UnforwardedControlWithError< C extends React.ReactElement >(
};
} );

useEffect( () => {
useEffect( (): ReturnType< React.EffectCallback > => {
if ( ! isTouched ) {
return;
}
Expand All @@ -134,6 +132,9 @@ function UnforwardedControlWithError< C extends React.ReactElement >(
case 'validating': {
// Wait before showing a validating state.
const timer = setTimeout( () => {
validityTarget?.setCustomValidity( '' );
setErrorMessage( undefined );

setStatusMessage( {
type: 'validating',
message: customValidity.message,
Expand All @@ -143,14 +144,20 @@ function UnforwardedControlWithError< C extends React.ReactElement >(
return () => clearTimeout( timer );
}
case 'valid': {
// Ensures that we wait for any async responses before showing
// a synchronously valid state.
if ( previousCustomValidityType === 'valid' ) {
break;
}

validityTarget?.setCustomValidity( '' );
setErrorMessage( validityTarget?.validationMessage );

setStatusMessage( {
type: 'valid',
message: customValidity.message,
} );
return;
break;
}
case 'invalid': {
validityTarget?.setCustomValidity(
Expand All @@ -159,35 +166,29 @@ function UnforwardedControlWithError< C extends React.ReactElement >(
setErrorMessage( validityTarget?.validationMessage );

setStatusMessage( undefined );
return undefined;
break;
}
}
}, [
isTouched,
customValidity?.type,
customValidity?.message,
getValidityTarget,
previousCustomValidityType,
] );

const onBlur = ( event: React.FocusEvent< HTMLDivElement > ) => {
if ( isTouched ) {
return;
}

// Only consider "blurred from the component" if focus has fully left the wrapping div.
// This prevents unnecessary blurs from components with multiple focusable elements.
if (
! event.relatedTarget ||
! event.currentTarget.contains( event.relatedTarget )
) {
setIsTouched( true );

const validityTarget = getValidityTarget();

// Prevents a double flash of the native error tooltip when the control is already showing one.
if ( ! validityTarget?.validity.valid ) {
if ( ! errorMessage ) {
setErrorMessage( validityTarget?.validationMessage );
}
return;
}

onValidate?.();
}
};
Expand Down
Loading
Loading