/**
* WordPress dependencies
*/
import { useCallback, useMemo, useState } from '@wordpress/element';
import { Button, privateApis } from '@wordpress/components';
import { Stack } from '@wordpress/ui';
/**
* Internal dependencies
*/
import DataForm from '../index';
import useFormValidity from '../../hooks/use-form-validity';
import type {
DataFormControlProps,
Field,
FieldValidity,
NormalizedRules,
} from '../../types';
import DateControl from '../../components/dataform-controls/date';
import { unlock } from '../../lock-unlock';
const { ValidatedTextControl } = unlock( privateApis );
function getCustomValidity< Item >(
isValid: NormalizedRules< Item >,
validity: FieldValidity | undefined
) {
let customValidity;
if ( isValid?.required && validity?.required ) {
// If the consumer provides a message for required,
// use it instead of the native built-in message.
customValidity = validity?.required?.message
? validity.required
: undefined;
} else if ( isValid?.elements && validity?.elements ) {
customValidity = validity.elements;
} else if ( validity?.custom ) {
customValidity = validity.custom;
}
return customValidity;
}
function CustomEditControl< Item >( {
data,
field,
onChange,
hideLabelFromVision,
validity,
}: DataFormControlProps< Item > ) {
const { label, placeholder, description, getValue, setValue, isValid } =
field;
const value = getValue( { item: data } );
const onChangeControl = useCallback(
( newValue: string ) =>
onChange( setValue( { item: data, value: newValue } ) ),
[ data, onChange, setValue ]
);
return (
);
}
const ValidationComponent = ( {
required,
elements,
custom,
pattern,
minMax,
layout,
}: {
required: boolean;
elements: 'sync' | 'async' | 'none';
custom: 'sync' | 'async' | 'none';
pattern: boolean;
minMax: boolean;
layout:
| 'regular'
| 'panel-dropdown'
| 'panel-modal'
| 'card-collapsible'
| 'card-not-collapsible'
| 'details';
} ) => {
type ValidatedItem = {
text: string;
select?: string;
textWithRadio?: string;
textarea: string;
email: string;
telephone: string;
url: string;
color: string;
integer: number;
number: number;
boolean: boolean;
customEdit: string;
categories: string[];
countries: string[];
password: string;
toggle?: boolean;
toggleGroup?: string;
combobox?: string;
date?: string;
dateRange?: string;
datetime?: string;
};
const [ post, setPost ] = useState< ValidatedItem >( {
text: 'Can have letters and spaces',
select: undefined,
textWithRadio: undefined,
textarea: 'Can have letters and spaces',
email: 'hi@example.com',
telephone: '+306978241796',
url: 'https://example.com',
color: '#ff6600',
integer: 2,
number: 3.14,
boolean: true,
categories: [ 'astronomy' ],
countries: [ 'us' ],
customEdit: 'custom control',
password: 'secretpassword123',
toggle: undefined,
toggleGroup: undefined,
combobox: undefined,
date: undefined,
dateRange: undefined,
datetime: undefined,
} );
// Cache for getElements functions - ensures promises are only created once
const getElements = useMemo( () => {
const promiseCache: Record< string, Promise< any > > = {};
return ( fieldId: string ) => {
return () => {
if ( fieldId in promiseCache ) {
return promiseCache[ fieldId ];
}
switch ( fieldId ) {
case 'select':
promiseCache[ fieldId ] = new Promise( ( resolve ) =>
setTimeout(
() =>
resolve( [
{
value: 'option1',
label: 'Option 1',
},
{
value: 'option2',
label: 'Option 2',
},
] ),
3500
)
);
break;
case 'textWithRadio':
promiseCache[ fieldId ] = new Promise( ( resolve ) =>
setTimeout(
() =>
resolve( [
{
value: 'item1',
label: 'Item 1',
},
{
value: 'item2',
label: 'Item 2',
},
] ),
3500
)
);
break;
case 'countries':
promiseCache[ fieldId ] = new Promise( ( resolve ) =>
setTimeout(
() =>
resolve( [
{
value: 'us',
label: 'United States',
},
{
value: 'ca',
label: 'Canada',
},
{
value: 'uk',
label: 'United Kingdom',
},
{
value: 'fr',
label: 'France',
},
{
value: 'de',
label: 'Germany',
},
{ value: 'jp', label: 'Japan' },
{
value: 'au',
label: 'Australia',
},
] ),
3500
)
);
break;
case 'toggleGroup':
promiseCache[ fieldId ] = new Promise( ( resolve ) =>
setTimeout(
() =>
resolve( [
{
value: 'option1',
label: 'Option 1',
},
{
value: 'option2',
label: 'Option 2',
},
{
value: 'option3',
label: 'Option 3',
},
] ),
3500
)
);
break;
case 'combobox':
promiseCache[ fieldId ] = new Promise( ( resolve ) =>
setTimeout(
() =>
resolve( [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'cherry', label: 'Cherry' },
{ value: 'date', label: 'Date' },
{
value: 'elderberry',
label: 'Elderberry',
},
] ),
3500
)
);
break;
default:
throw new Error( `Unknown field ID: ${ fieldId }` );
}
return promiseCache[ fieldId ];
};
};
}, [] );
const _fields: Field< ValidatedItem >[] = useMemo( () => {
const DateRangeEdit = (
props: DataFormControlProps< ValidatedItem >
) => {
return ;
};
const makeAsync = (
rule: ( item: ValidatedItem ) => null | string
) => {
return async ( value: ValidatedItem ) => {
return await new Promise< string | null >( ( resolve ) => {
setTimeout( () => {
const validationResult = rule( value );
resolve( validationResult );
}, 2000 );
} );
};
};
const customTextRule = ( value: ValidatedItem ) => {
if ( ! /^[a-zA-Z ]+$/.test( value.text ) ) {
return 'Value must only contain letters and spaces.';
}
return null;
};
const customSelectRule = ( value: ValidatedItem ) => {
if ( value.select !== 'option1' ) {
return 'Value must be Option 1.';
}
return null;
};
const customTextRadioRule = ( value: ValidatedItem ) => {
if ( value.textWithRadio !== 'item1' ) {
return 'Value must be Item 1.';
}
return null;
};
const customTextareaRule = ( value: ValidatedItem ) => {
if ( ! /^[a-zA-Z ]+$/.test( value.textarea ) ) {
return 'Value must only contain letters and spaces.';
}
return null;
};
const customEmailRule = ( value: ValidatedItem ) => {
if ( ! /^[a-zA-Z0-9._%+-]+@example\.com$/.test( value.email ) ) {
return 'Email address must be from @example.com domain.';
}
return null;
};
const customTelephoneRule = ( value: ValidatedItem ) => {
if ( ! /^\+30\d{10}$/.test( value.telephone ) ) {
return 'Telephone number must start with +30 and have 10 digits after.';
}
return null;
};
const customUrlRule = ( value: ValidatedItem ) => {
if ( ! /^https:\/\/example\.com$/.test( value.url ) ) {
return 'URL must be from https://example.com domain.';
}
return null;
};
const customColorRule = ( value: ValidatedItem ) => {
if ( ! /^#[0-9A-Fa-f]{6}$/.test( value.color ) ) {
return 'Color must be a valid hex format (e.g., #ff6600).';
}
return null;
};
const customIntegerRule = ( value: ValidatedItem ) => {
if ( value.integer % 2 !== 0 ) {
return 'Integer must be an even number.';
}
return null;
};
const customNumberRule = ( value: ValidatedItem ) => {
if ( ! /^\d+\.\d{2}$/.test( value?.number?.toString() ) ) {
return 'Number must have exactly two decimal places.';
}
return null;
};
const customBooleanRule = ( value: ValidatedItem ) => {
if ( value.boolean !== true ) {
return 'Boolean must be active.';
}
return null;
};
const customToggleRule = ( value: ValidatedItem ) => {
if ( value.toggle !== true ) {
return 'Toggle must be checked.';
}
return null;
};
const customToggleGroupRule = ( value: ValidatedItem ) => {
if ( value.toggleGroup !== 'option1' ) {
return 'Value must be Option 1.';
}
return null;
};
const customComboboxRule = ( value: ValidatedItem ) => {
if ( value.combobox !== 'apple' ) {
return 'Value must be Apple.';
}
return null;
};
const customPasswordRule = ( value: ValidatedItem ) => {
if ( value.password.length < 8 ) {
return 'Password must be at least 8 characters long.';
}
if ( ! /[A-Z]/.test( value.password ) ) {
return 'Password must contain at least one uppercase letter.';
}
if ( ! /[0-9]/.test( value.password ) ) {
return 'Password must contain at least one number.';
}
return null;
};
const customDateRule = ( value: ValidatedItem ) => {
if ( ! value.date ) {
return null;
}
const selectedDate = new Date( value.date );
const today = new Date();
today.setHours( 0, 0, 0, 0 );
if ( selectedDate < today ) {
return 'Date must not be in the past.';
}
return null;
};
const customDateTimeRule = ( value: ValidatedItem ) => {
if ( ! value.datetime ) {
return null;
}
const selectedDateTime = new Date( value.datetime );
const now = new Date();
if ( selectedDateTime < now ) {
return 'Date and time must not be in the past.';
}
return null;
};
const customDateRangeRule = ( value: ValidatedItem ) => {
if ( ! value.dateRange ) {
return null;
}
const [ fromDate, toDate ] = value.dateRange;
if ( ! fromDate || ! toDate ) {
return null;
}
const from = new Date( fromDate );
const to = new Date( toDate );
const daysDiff = Math.ceil(
( to.getTime() - from.getTime() ) / ( 1000 * 60 * 60 * 24 )
);
if ( daysDiff > 30 ) {
return 'Date range must not exceed 30 days.';
}
return null;
};
const maybeCustomRule = (
rule: ( item: ValidatedItem ) => null | string
) => {
if ( custom === 'sync' ) {
return rule;
}
if ( custom === 'async' ) {
return makeAsync( rule );
}
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: 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,
},
},
{
id: 'select',
type: 'text',
label: 'Select',
elements:
elements === 'async'
? undefined
: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
],
getElements:
elements === 'async' ? getElements( 'select' ) : undefined,
isValid: {
required,
elements: elements !== 'none' ? true : false,
custom: maybeCustomRule( customSelectRule ),
},
},
{
id: 'textWithRadio',
type: 'text',
Edit: 'radio',
label: 'Text with radio',
elements:
elements === 'async'
? undefined
: [
{ value: 'item1', label: 'Item 1' },
{ value: 'item2', label: 'Item 2' },
],
getElements:
elements === 'async'
? getElements( 'textWithRadio' )
: undefined,
isValid: {
required,
elements: elements !== 'none' ? true : false,
custom: maybeCustomRule( customTextRadioRule ),
},
},
{
id: 'textarea',
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: 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,
minLength: minMax ? 15 : undefined,
maxLength: minMax ? 100 : undefined,
},
},
{
id: 'telephone',
type: 'telephone',
label: 'telephone',
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}-[0-9-]*$' : undefined,
minLength: minMax ? 10 : undefined,
maxLength: minMax ? 20 : undefined,
},
},
{
id: 'url',
type: 'url',
label: 'URL',
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\\/.+$'
: undefined,
minLength: minMax ? 25 : undefined,
maxLength: minMax ? 255 : undefined,
},
},
{
id: 'color',
type: 'color',
label: 'Color',
isValid: {
required,
elements: elements !== 'none' ? true : false,
custom: maybeCustomRule( customColorRule ),
},
},
{
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 ? 10 : 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,
},
},
{
id: 'boolean',
type: 'boolean',
label: 'Boolean',
isValid: {
required,
elements: elements !== 'none' ? true : false,
custom: maybeCustomRule( customBooleanRule ),
},
},
{
id: 'array',
label: 'Array',
type: 'array',
placeholder: 'Select countries',
description: 'Countries you have visited',
isValid: {
required,
elements: elements !== 'none' ? true : false,
},
elements:
elements === 'async'
? undefined
: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
{ value: 'uk', label: 'United Kingdom' },
{ value: 'fr', label: 'France' },
{ value: 'de', label: 'Germany' },
{ value: 'jp', label: 'Japan' },
{ value: 'au', label: 'Australia' },
],
getElements:
elements === 'async'
? getElements( 'countries' )
: undefined,
},
{
id: 'customEdit',
label: 'Custom Control',
Edit: CustomEditControl,
isValid: {
required,
elements: elements !== 'none' ? true : false,
},
},
{
id: 'password',
type: 'password',
label: 'Password',
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 ? '^[a-zA-Z0-9]{8,}$' : undefined,
minLength: minMax ? 10 : undefined,
maxLength: minMax ? 20 : undefined,
},
},
{
id: 'toggle',
type: 'boolean',
label: 'Toggle',
Edit: 'toggle',
isValid: {
required,
elements: elements !== 'none' ? true : false,
custom: maybeCustomRule( customToggleRule ),
},
},
{
id: 'toggleGroup',
type: 'text',
label: 'Toggle Group',
Edit: 'toggleGroup',
elements:
elements === 'async'
? undefined
: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' },
],
getElements:
elements === 'async'
? getElements( 'toggleGroup' )
: undefined,
isValid: {
required,
elements: elements !== 'none' ? true : false,
custom: maybeCustomRule( customToggleGroupRule ),
},
},
{
id: 'combobox',
type: 'text',
Edit: 'combobox',
label: 'Combobox',
placeholder: 'Search and select a fruit',
elements:
elements === 'async'
? undefined
: [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'blueberry', label: 'Blueberry' },
{ value: 'cherry', label: 'Cherry' },
{ value: 'date', label: 'Date' },
{ value: 'elderberry', label: 'Elderberry' },
{ value: 'fig', label: 'Fig' },
{ value: 'grape', label: 'Grape' },
{ value: 'honeydew', label: 'Honeydew' },
{ value: 'kiwi', label: 'Kiwi' },
{ value: 'lemon', label: 'Lemon' },
{ value: 'mango', label: 'Mango' },
{ value: 'nectarine', label: 'Nectarine' },
{ value: 'orange', label: 'Orange' },
{ value: 'papaya', label: 'Papaya' },
{ value: 'pear', label: 'Pear' },
{ value: 'quince', label: 'Quince' },
{ value: 'raspberry', label: 'Raspberry' },
{ value: 'strawberry', label: 'Strawberry' },
{ value: 'tangerine', label: 'Tangerine' },
{ value: 'watermelon', label: 'Watermelon' },
],
getElements:
elements === 'async'
? getElements( 'combobox' )
: undefined,
isValid: {
required,
elements: elements !== 'none' ? true : false,
custom: maybeCustomRule( customComboboxRule ),
},
},
{
id: 'date',
type: 'date',
label: 'Date',
isValid: {
required,
elements: elements !== 'none' ? true : false,
custom: maybeCustomRule( customDateRule ),
},
},
{
id: 'dateRange',
type: 'date',
label: 'Date Range',
Edit: DateRangeEdit,
isValid: {
required,
elements: elements !== 'none' ? true : false,
custom: maybeCustomRule( customDateRangeRule ),
},
},
{
id: 'datetime',
type: 'datetime',
label: 'Date Time',
isValid: {
required,
elements: elements !== 'none' ? true : false,
custom: maybeCustomRule( customDateTimeRule ),
},
},
];
}, [ elements, custom, pattern, minMax, getElements, required ] );
const form = useMemo( () => {
if ( layout === 'regular' ) {
return {
fields: [
'text',
{ id: 'customEdit' },
{ id: 'level1Integer', children: [ 'integer' ] },
{
id: 'level1Number',
children: [
{ id: 'level2Number', children: [ 'number' ] },
],
},
{
id: 'level1Email',
children: [
{
id: 'level2Email',
children: [
{
id: 'level3Email',
children: [ 'email' ],
},
],
},
],
},
{
id: 'level1Telephone',
children: [
{
id: 'level2Telephone',
children: [
{
id: 'level3Telephone',
children: [
{
id: 'level4Telephone',
children: [ 'telephone' ],
},
],
},
],
},
],
},
'url',
'color',
'password',
'textarea',
'select',
'combobox',
'textWithRadio',
'boolean',
'toggle',
'toggleGroup',
'array',
'date',
'dateRange',
'datetime',
],
};
}
// Panel and card layouts share the same grouped structure
const groupedFields = [
{
id: 'textFields',
label: 'Text Fields',
children: [ 'text', 'textarea', 'password', 'customEdit' ],
},
{
id: 'numberFields',
label: 'Number Fields',
children: [ 'integer', 'number' ],
},
{
id: 'contactFields',
label: 'Contact Fields',
children: [ 'email', 'telephone', 'url' ],
},
{
id: 'selectFields',
label: 'Selection Fields',
children: [ 'select', 'combobox', 'textWithRadio' ],
},
{
id: 'booleanFields',
label: 'Boolean Fields',
children: [ 'boolean', 'toggle', 'toggleGroup' ],
},
{ id: 'color' },
{ id: 'array' },
{
id: 'dateFields',
label: 'Date Fields',
children: [ 'date', 'dateRange', 'datetime' ],
},
];
if ( layout === 'panel-dropdown' ) {
return {
layout: { type: 'panel' as const, openAs: 'dropdown' as const },
fields: groupedFields,
};
}
if ( layout === 'panel-modal' ) {
return {
layout: { type: 'panel' as const, openAs: 'modal' as const },
fields: groupedFields,
};
}
if ( layout === 'details' ) {
return {
layout: { type: 'details' as const },
fields: groupedFields,
};
}
if ( layout === 'card-collapsible' ) {
return {
layout: { type: 'card' as const },
fields: groupedFields,
};
}
// card-not-collapsible
return {
layout: { type: 'card' as const, isCollapsible: false },
fields: groupedFields,
};
}, [ layout ] );
const { validity, isValid } = useFormValidity( post, _fields, form );
return (
);
};
export default ValidationComponent;