/** * 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 (
data={ post } fields={ _fields } form={ form } validity={ validity } onChange={ ( edits ) => setPost( ( prev ) => ( { ...prev, ...edits, } ) ) } />
); }; export default ValidationComponent;