Cherry pick 49929 into release/9.2 (#50094)

* Add validation to select fields and add placeholder option to additional fields API (#49929)

* Add validation and empty value to select components

* address feedback

* Add changefile(s) from automation for the following project(s): woocommerce-blocks, woocommerce

* remove console log

* Update plugins/woocommerce-blocks/docs/third-party-developers/extensibility/checkout-block/additional-checkout-fields.md

Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>

* Update plugins/woocommerce-blocks/docs/third-party-developers/extensibility/checkout-block/additional-checkout-fields.md

Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>

* remove test

* Fix tests

* also move select to parent

* fix tests

* add empty enum to schema

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>

* Prep for cherry pick 49929

---------

Co-authored-by: Seghir Nadir <nadir.seghir@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>
Co-authored-by: WooCommerce Bot <no-reply@woocommerce.com>
This commit is contained in:
github-actions[bot] 2024-07-29 18:43:06 +02:00 committed by GitHub
parent 289e6b9652
commit 05cf21536d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 248 additions and 358 deletions

View File

@ -34,7 +34,11 @@ import prepareFormFields from './prepare-form-fields';
import validateCountry from './validate-country';
import customValidationHandler from './custom-validation-handler';
import AddressLineFields from './address-line-fields';
import { createFieldProps, getFieldData } from './utils';
import {
createFieldProps,
createCheckboxFieldProps,
getFieldData,
} from './utils';
import { Select } from '../../select';
import { validateState } from './validate-state';
@ -161,6 +165,8 @@ const Form = < T extends AddressFormValues | ContactFormValues >( {
}
const fieldProps = createFieldProps( field, id, addressType );
const checkboxFieldProps =
createCheckboxFieldProps( fieldProps );
if ( field.key === 'email' ) {
fieldProps.id = 'email';
@ -178,7 +184,7 @@ const Form = < T extends AddressFormValues | ContactFormValues >( {
[ field.key ]: checked,
} );
} }
{ ...fieldProps }
{ ...checkboxFieldProps }
/>
);
}
@ -292,6 +298,10 @@ const Form = < T extends AddressFormValues | ContactFormValues >( {
} );
} }
options={ field.options }
required={ field.required }
errorMessage={
fieldProps.errorMessage || undefined
}
/>
);
}

View File

@ -21,6 +21,7 @@ export interface FieldProps {
autoComplete: string | undefined;
errorMessage: string | undefined;
required: boolean | undefined;
placeholder: string | undefined;
className: string;
}
@ -36,6 +37,7 @@ export const createFieldProps = (
autoComplete: field?.autocomplete,
errorMessage: field?.errorMessage,
required: field?.required,
placeholder: field?.placeholder,
className: `wc-block-components-address-form__${ field?.key }`.replaceAll(
'/',
'-'
@ -43,6 +45,17 @@ export const createFieldProps = (
...field?.attributes,
} );
export const createCheckboxFieldProps = ( fieldProps: FieldProps ) => {
const {
errorId,
errorMessage,
autoCapitalize,
autoComplete,
placeholder,
...rest
} = fieldProps;
return rest;
};
export const getFieldData = < T extends AddressFormValues | ContactFormValues >(
key: 'address_1' | 'address_2',
fields: AddressFormFields[ 'fields' ],

View File

@ -4,10 +4,6 @@
import { useMemo } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
import clsx from 'clsx';
import { __, sprintf } from '@wordpress/i18n';
import { useSelect } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
import { ValidationInputError } from '@woocommerce/blocks-components';
/**
* Internal dependencies
@ -25,58 +21,27 @@ export const CountryInput = ( {
value = '',
autoComplete = 'off',
required = false,
errorId,
}: CountryInputWithCountriesProps ): JSX.Element => {
const emptyCountryOption: SelectOption = {
value: '',
label: sprintf(
// translators: %s will be label of the country input. For example "country/region".
__( 'Select a %s', 'woocommerce' ),
label?.toLowerCase()
),
disabled: true,
};
const options = useMemo< SelectOption[] >( () => {
return [ emptyCountryOption ].concat(
Object.entries( countries ).map(
( [ countryCode, countryName ] ) => ( {
value: countryCode,
label: decodeEntities( countryName ),
} )
)
return Object.entries( countries ).map(
( [ countryCode, countryName ] ) => ( {
value: countryCode,
label: decodeEntities( countryName ),
} )
);
}, [ countries ] );
const validationError = useSelect( ( select ) => {
const store = select( VALIDATION_STORE_KEY );
return (
store.getValidationError( errorId || '' ) || {
hidden: true,
}
);
} );
return (
<div
className={ clsx( className, 'wc-block-components-country-input', {
'has-error': ! validationError.hidden,
} ) }
>
<Select
id={ id }
label={ label || '' }
onChange={ onChange }
options={ options }
value={ value }
required={ required }
autoComplete={ autoComplete }
/>
{ validationError && validationError.hidden !== true && (
<ValidationInputError
errorMessage={ validationError.message }
/>
) }
</div>
<Select
className={ clsx( className, 'wc-block-components-country-input' ) }
id={ id }
label={ label || '' }
onChange={ onChange }
options={ options }
value={ value }
required={ required }
autoComplete={ autoComplete }
/>
);
};

View File

@ -2,7 +2,12 @@
* External dependencies
*/
import { Icon, chevronDown } from '@wordpress/icons';
import { useCallback, useId } from '@wordpress/element';
import { useCallback, useId, useMemo, useEffect } from '@wordpress/element';
import { sprintf, __ } from '@wordpress/i18n';
import { useSelect, useDispatch } from '@wordpress/data';
import clsx from 'clsx';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
import { ValidationInputError } from '@woocommerce/blocks-components';
/**
* Internal dependencies
@ -22,12 +27,25 @@ type SelectProps = Omit<
options: SelectOption[];
label: string;
onChange: ( newVal: string ) => void;
errorId?: string;
required?: boolean;
errorMessage?: string;
};
export const Select = ( props: SelectProps ) => {
const { onChange, options, label, value, className, size, ...restOfProps } =
props;
const {
onChange,
options,
label,
value = '',
className,
size,
errorId: incomingErrorId,
required,
errorMessage = __( 'Please select a valid option', 'woocommerce' ),
placeholder,
...restOfProps
} = props;
const selectOnChange = useCallback(
( event: React.ChangeEvent< HTMLSelectElement > ) => {
onChange( event.target.value );
@ -35,48 +53,124 @@ export const Select = ( props: SelectProps ) => {
[ onChange ]
);
const generatedId = useId();
const emptyOption: SelectOption = useMemo(
() => ( {
value: '',
label:
placeholder ??
sprintf(
// translators: %s will be label of the field. For example "country/region".
__( 'Select a %s', 'woocommerce' ),
label?.toLowerCase()
),
disabled: !! required,
} ),
[ label, placeholder, required ]
);
const generatedId = useId();
const inputId =
restOfProps.id || `wc-blocks-components-select-${ generatedId }`;
const errorId = incomingErrorId || inputId;
const optionsWithEmpty = useMemo< SelectOption[] >( () => {
if ( required && value ) {
return options;
}
return [ emptyOption ].concat( options );
}, [ required, value, emptyOption, options ] );
const { setValidationErrors, clearValidationError } =
useDispatch( VALIDATION_STORE_KEY );
const { error, validationErrorId } = useSelect( ( select ) => {
const store = select( VALIDATION_STORE_KEY );
return {
error: store.getValidationError( errorId ),
validationErrorId: store.getValidationErrorId( errorId ),
};
} );
useEffect( () => {
if ( ! required || value ) {
clearValidationError( errorId );
} else {
setValidationErrors( {
[ errorId ]: {
message: errorMessage,
hidden: true,
},
} );
}
return () => {
clearValidationError( errorId );
};
}, [
clearValidationError,
value,
errorId,
errorMessage,
required,
setValidationErrors,
] );
const validationError = useSelect( ( select ) => {
const store = select( VALIDATION_STORE_KEY );
return (
store.getValidationError( errorId || '' ) || {
hidden: true,
}
);
} );
return (
<div className={ `wc-blocks-components-select ${ className || '' }` }>
<div className="wc-blocks-components-select__container">
<label
htmlFor={ inputId }
className="wc-blocks-components-select__label"
>
{ label }
</label>
<select
className="wc-blocks-components-select__select"
id={ inputId }
size={ size !== undefined ? size : 1 }
onChange={ selectOnChange }
value={ value }
{ ...restOfProps }
>
{ options.map( ( option ) => (
<option
key={ option.value }
value={ option.value }
data-alternate-values={ `[${ option.label }]` }
disabled={
option.disabled !== undefined
? option.disabled
: false
}
>
{ option.label }
</option>
) ) }
</select>
<Icon
className="wc-blocks-components-select__expand"
icon={ chevronDown }
/>
<div
className={ clsx( className, {
'has-error': ! validationError.hidden,
} ) }
>
<div className="wc-blocks-components-select">
<div className="wc-blocks-components-select__container">
<label
htmlFor={ inputId }
className="wc-blocks-components-select__label"
>
{ label }
</label>
<select
className="wc-blocks-components-select__select"
id={ inputId }
size={ size !== undefined ? size : 1 }
onChange={ selectOnChange }
value={ value }
aria-invalid={
error?.message && ! error?.hidden ? true : false
}
aria-errormessage={ validationErrorId }
{ ...restOfProps }
>
{ optionsWithEmpty.map( ( option ) => (
<option
key={ option.value }
value={ option.value }
data-alternate-values={ `[${ option.label }]` }
disabled={
option.disabled !== undefined
? option.disabled
: false
}
>
{ option.label }
</option>
) ) }
</select>
<Icon
className="wc-blocks-components-select__expand"
icon={ chevronDown }
/>
</div>
</div>
<ValidationInputError propertyName={ errorId } />
</div>
);
};

View File

@ -3,13 +3,7 @@
*/
import { decodeEntities } from '@wordpress/html-entities';
import { useCallback, useMemo, useEffect, useRef } from '@wordpress/element';
import {
ValidatedTextInput,
ValidationInputError,
} from '@woocommerce/blocks-components';
import { __, sprintf } from '@wordpress/i18n';
import { useSelect } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
import { ValidatedTextInput } from '@woocommerce/blocks-components';
import { clsx } from 'clsx';
/**
@ -41,40 +35,17 @@ const StateInput = ( {
autoComplete = 'off',
value = '',
required = false,
errorId,
}: StateInputWithStatesProps ): JSX.Element => {
const countryStates = states[ country ];
const options = useMemo< SelectOption[] >( () => {
if ( countryStates && Object.keys( countryStates ).length > 0 ) {
const emptyStateOption: SelectOption = {
value: '',
label: sprintf(
/* translators: %s will be the type of province depending on country, e.g "state" or "state/county" or "department" */
__( 'Select a %s', 'woocommerce' ),
label?.toLowerCase()
),
disabled: true,
};
return [
emptyStateOption,
...Object.keys( countryStates ).map( ( key ) => ( {
value: key,
label: decodeEntities( countryStates[ key ] ),
} ) ),
];
return Object.keys( countryStates ).map( ( key ) => ( {
value: key,
label: decodeEntities( countryStates[ key ] ),
} ) );
}
return [];
}, [ countryStates, label ] );
const validationError = useSelect( ( select ) => {
const store = select( VALIDATION_STORE_KEY );
return (
store.getValidationError( errorId || '' ) || {
hidden: true,
}
);
} );
}, [ countryStates ] );
/**
* Handles state selection onChange events. Finds a matching state by key or value.
@ -117,35 +88,19 @@ const StateInput = ( {
if ( options.length > 0 ) {
return (
<div
<Select
className={ clsx(
className,
'wc-block-components-state-input',
{
'has-error': ! validationError.hidden,
}
'wc-block-components-state-input'
) }
>
<Select
options={ options }
label={ label || '' }
className={ `${ className || '' }` }
id={ id }
onChange={ ( newValue ) => {
if ( required ) {
}
onChangeState( newValue );
} }
value={ value }
autoComplete={ autoComplete }
required={ required }
/>
{ validationError && validationError.hidden !== true && (
<ValidationInputError
errorMessage={ validationError.message }
/>
) }
</div>
options={ options }
label={ label || '' }
id={ id }
onChange={ onChangeState }
value={ value }
autoComplete={ autoComplete }
required={ required }
/>
);
}

View File

@ -40,6 +40,8 @@ export interface FormField {
type?: string;
// The options if this is a select field
options?: ComboboxControlOption[];
// The placeholder for the field, only applicable for select fields.
placeholder?: string;
// Additional attributes added when registering a field. String in key is required for data attributes.
attributes?: Record< keyof CustomFieldAttributes, string >;
}

View File

@ -292,12 +292,15 @@ As well as the options above, text fields also support a `required` option. If t
As well as the options above, select fields must also be registered with an `options` option. This is used to specify what options the shopper can select.
Select fields can also be marked as required. If they are not (i.e. they are optional), then an empty entry will be added to allow the shopper to unset the field.
Select fields will mount with no value selected by default, if the field is required, the user will be required to select a value.
You can set a placeholder to be shown on the select by passing a `placeholder` value when registering the field. This will be the first option in the select and will not be selectable if the field is required.
| Option name | Description | Required? | Example | Default value |
|-----|-----|-----|----------------|--------------|
| `options` | An array of options to show in the select input. Each options must be an array containing a `label` and `value` property. Each entry must have a unique `value`. Any duplicate options will be removed. The `value` is what gets submitted to the server during checkout and the `label` is simply a user-friendly representation of this value. It is not transmitted to the server in any way. | Yes | see below | No default - this must be provided. |
| `required` | If this is `true` then the shopper _must_ provide a value for this field during the checkout process. | No | `true` | `false` |
| `placeholder` | If this value is set, the shopper will see this option in the select. If the select is required, the shopper cannot select this option. | No | `Select a role | Select a $label |
##### Example of `options` value
@ -425,11 +428,12 @@ add_action(
function() {
woocommerce_register_additional_checkout_field(
array(
'id' => 'namespace/how-did-you-hear-about-us',
'label' => 'How did you hear about us?',
'location' => 'order',
'type' => 'select',
'options' => [
'id' => 'namespace/how-did-you-hear-about-us',
'label' => 'How did you hear about us?',
'placeholder' => 'Select a source',
'location' => 'order',
'type' => 'select',
'options' => [
[
'value' => 'google',
'label' => 'Google'
@ -463,7 +467,7 @@ This results in the order information section being rendered like so:
![The select input when focused](https://github.com/woocommerce/woocommerce/assets/5656702/bd943906-621b-404f-aa84-b951323e25d3)
If it is undesirable to force the shopper to select a value, providing a value such as "None of the above" may help.
If it is undesirable to force the shopper to select a value, mark the select as optional by setting the `required` option to `false`.
## Validation and sanitization

View File

@ -32,6 +32,8 @@ export const CheckboxControl = ( {
hasError = false,
checked = false,
disabled = false,
errorId,
errorMessage,
...rest
}: CheckboxControlProps & Record< string, unknown > ): JSX.Element => {
const instanceId = useInstanceId( CheckboxControl );

View File

@ -96,39 +96,21 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
shipping: {
'Government ID': '12345',
'Confirm government ID': '12345',
'How wide is your road? (optional)': 'wide',
},
billing: {
'Government ID': '54321',
'Confirm government ID': '54321',
'How wide is your road? (optional)': 'narrow',
},
},
order: {
'How did you hear about us?': 'Other',
'How did you hear about us? (optional)': 'other',
'What is your favourite colour?': 'Blue',
},
}
);
// Fill select fields "manually" (Not part of "fillInCheckoutWithTestData"). This is a workaround for select
// fields until we recreate th Combobox component. This is because the aria-label includes the value so getting
// by label alone is not reliable unless we know the value.
await checkoutPageObject.page
.getByRole( 'group', {
name: 'Shipping address',
} )
.getByLabel( 'How wide is your road?' )
.selectOption( 'wide' );
await checkoutPageObject.page
.getByRole( 'group', {
name: 'Billing address',
} )
.getByLabel( 'How wide is your road?' )
.selectOption( 'narrow' );
await checkoutPageObject.page.evaluate(
'document.activeElement.blur()'
);
await checkoutPageObject.page
.getByLabel( 'Would you like a free gift with your order?' )
.check();
@ -246,7 +228,7 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
.getByRole( 'group', {
name: 'Shipping address',
} )
.getByLabel( 'How wide is your road?' )
.getByLabel( 'How wide is your road? (optional)' )
).toHaveValue( 'wide' );
await expect(
checkoutPageObject.page
@ -274,7 +256,7 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
.getByRole( 'group', {
name: 'Billing address',
} )
.getByLabel( 'How wide is your road?' )
.getByLabel( 'How wide is your road? (optional)' )
).toHaveValue( 'narrow' );
} );
} );

View File

@ -52,10 +52,12 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
shipping: {
'Government ID': '12345',
'Confirm government ID': '12345',
'How wide is your road? (optional)': 'wide',
},
billing: {
'Government ID': '54321',
'Confirm government ID': '54321',
'How wide is your road? (optional)': 'narrow',
},
},
order: {
@ -65,26 +67,6 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
}
);
// Fill select fields "manually" (Not part of "fillInCheckoutWithTestData"). This is a workaround for select
// fields until we recreate th Combobox component. This is because the aria-label includes the value so getting
// by label alone is not reliable unless we know the value.
await checkoutPageObject.page
.getByRole( 'group', {
name: 'Shipping address',
} )
.getByLabel( 'How wide is your road?' )
.selectOption( 'wide' );
await checkoutPageObject.page
.getByRole( 'group', {
name: 'Billing address',
} )
.getByLabel( 'How wide is your road?' )
.selectOption( 'narrow' );
await checkoutPageObject.page.evaluate(
'document.activeElement.blur()'
);
await checkoutPageObject.page
.getByLabel( 'Would you like a free gift with your order?' )
.check();
@ -189,7 +171,7 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
.getByRole( 'group', {
name: 'Shipping address',
} )
.getByLabel( 'How wide is your road?' )
.getByLabel( 'How wide is your road? (optional)' )
).toHaveValue( 'wide' );
await expect(
checkoutPageObject.page
@ -217,7 +199,7 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
.getByRole( 'group', {
name: 'Billing address',
} )
.getByLabel( 'How wide is your road?' )
.getByLabel( 'How wide is your road? (optional)' )
).toHaveValue( 'narrow' );
} );
@ -240,10 +222,12 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
shipping: {
'Government ID': '12345',
'Confirm government ID': '12345',
'How wide is your road? (optional)': 'wide',
},
billing: {
'Government ID': '54321',
'Confirm government ID': '54321',
'How wide is your road? (optional)': 'narrow',
},
},
order: {
@ -253,36 +237,23 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
}
);
// Fill select fields "manually" (Not part of "fillInCheckoutWithTestData"). This is a workaround for select
// fields until we recreate the Combobox component. This is because the aria-label includes the value so getting
// by label alone is not reliable unless we know the value.
await checkoutPageObject.page
.getByRole( 'group', {
name: 'Shipping address',
} )
.getByLabel( 'How wide is your road?' )
.selectOption( 'wide' );
await checkoutPageObject.page
.getByRole( 'group', {
name: 'Billing address',
} )
.getByLabel( 'How wide is your road?' )
.selectOption( 'narrow' );
await checkoutPageObject.waitForCustomerDataUpdate();
// Change the shipping and billing select fields again.
await checkoutPageObject.page
.getByRole( 'group', {
name: 'Billing address',
} )
.getByLabel( 'How wide is your road?' )
.selectOption( 'wide' );
await checkoutPageObject.page
.getByRole( 'group', {
name: 'Shipping address',
} )
.getByLabel( 'How wide is your road?' )
.selectOption( 'super-wide' );
await checkoutPageObject.fillInCheckoutWithTestData(
{},
{
address: {
shipping: {
'How wide is your road? (optional)': 'super-wide',
},
billing: {
'How wide is your road? (optional)': 'wide',
},
},
}
);
await checkoutPageObject.waitForCustomerDataUpdate();
await checkoutPageObject.page
@ -341,22 +312,12 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
},
order: {
'What is your favourite colour?': 'Red',
'How did you hear about us?':
'Select a how did you hear about us? (optional)',
},
}
);
await checkoutPageObject.page
.getByRole( 'group', {
name: 'Additional order information',
} )
.getByLabel( 'How did you hear about us?' )
.click();
await checkoutPageObject.page
.getByRole( 'group', {
name: 'Additional order information',
} )
.locator( 'select' )
.first()
.selectOption( { index: 0 } );
await checkoutPageObject.waitForCustomerDataUpdate();
await checkoutPageObject.placeOrder();
@ -422,10 +383,12 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
shipping: {
'Government ID': ' 1. 2 3 4 5 ',
'Confirm government ID': '1 2345',
'How wide is your road? (optional)': 'wide',
},
billing: {
'Government ID': ' 5. 4 3 2 1 ',
'Confirm government ID': '543 21',
'How wide is your road? (optional)': 'narrow',
},
},
order: {
@ -435,26 +398,6 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
}
);
// Fill select fields "manually" (Not part of "fillInCheckoutWithTestData"). This is a workaround for select
// fields until we recreate th Combobox component. This is because the aria-label includes the value so getting
// by label alone is not reliable unless we know the value.
await checkoutPageObject.page
.getByRole( 'group', {
name: 'Shipping address',
} )
.getByLabel( 'How wide is your road?' )
.selectOption( 'wide' );
await checkoutPageObject.page
.getByRole( 'group', {
name: 'Billing address',
} )
.getByLabel( 'How wide is your road?' )
.selectOption( 'narrow' );
await checkoutPageObject.page.evaluate(
'document.activeElement.blur()'
);
await checkoutPageObject.page
.getByLabel( 'Would you like a free gift with your order?' )
.check();
@ -559,7 +502,7 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
.getByRole( 'group', {
name: 'Shipping address',
} )
.getByLabel( 'How wide is your road?' )
.getByLabel( 'How wide is your road? (optional)' )
).toHaveValue( 'wide' );
await expect(
checkoutPageObject.page
@ -587,7 +530,7 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
.getByRole( 'group', {
name: 'Billing address',
} )
.getByLabel( 'How wide is your road?' )
.getByLabel( 'How wide is your road? (optional)' )
).toHaveValue( 'narrow' );
} );
@ -662,35 +605,18 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
shipping: {
'Government ID': '12345',
'Confirm government ID': '12345',
'How wide is your road? (optional)': 'wide',
},
billing: {
'Government ID': '54321',
'Confirm government ID': '54321',
'How wide is your road? (optional)': 'narrow',
},
},
order: { 'How did you hear about us?': 'Other' },
}
);
// Fill select fields "manually" (Not part of "fillInCheckoutWithTestData"). This is a workaround for select
// fields until we recreate th Combobox component. This is because the aria-label includes the value so getting
// by label alone is not reliable unless we know the value.
await checkoutPageObject.page
.getByRole( 'group', {
name: 'Shipping address',
} )
.getByLabel( 'How wide is your road?' )
.selectOption( 'wide' );
await checkoutPageObject.page
.getByRole( 'group', {
name: 'Billing address',
} )
.getByLabel( 'How wide is your road?' )
.selectOption( 'narrow' );
await checkoutPageObject.page.evaluate(
'document.activeElement.blur()'
);
await checkoutPageObject.placeOrder();
expect(
@ -723,10 +649,12 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
shipping: {
'Government ID': '12345',
'Confirm government ID': '12345',
'How wide is your road? (optional)': 'wide',
},
billing: {
'Government ID': '54321',
'Confirm government ID': '54321',
'How wide is your road? (optional)': 'narrow',
},
},
order: { 'How did you hear about us?': 'Other' },
@ -756,26 +684,6 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
.getByLabel( 'Can a truck fit down your road?' )
.uncheck();
// Fill select fields manually. (Not part of "fillInCheckoutWithTestData"). This is a workaround for select
// fields until we recreate th Combobox component. This is because the aria-label includes the value so getting
// by label alone is not reliable unless we know the value.
await checkoutPageObject.page
.getByRole( 'group', {
name: 'Shipping address',
} )
.getByLabel( 'How wide is your road?' )
.selectOption( 'wide' );
await checkoutPageObject.page
.getByRole( 'group', {
name: 'Billing address',
} )
.getByLabel( 'How wide is your road?' )
.selectOption( 'narrow' );
// Blur after editing the select fields since they need to be blurred to save.
await checkoutPageObject.page.evaluate(
'document.activeElement.blur()'
);
await checkoutPageObject.placeOrder();
expect(
@ -853,7 +761,7 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
// Check select in edit mode match the expected value.
const roadSizeSelect = checkoutPageObject.page.getByLabel(
'How wide is your road?'
'How wide is your road? (optional)'
);
await expect( roadSizeSelect ).toHaveValue( 'narrow' );
@ -916,7 +824,7 @@ test.describe( 'Shopper → Additional Checkout Fields', () => {
// Check select in edit mode match the expected value.
const shippingRoadSizeSelect = checkoutPageObject.page.getByLabel(
'How wide is your road?'
'How wide is your road? (optional)'
);
await expect( shippingRoadSizeSelect ).toHaveValue( 'wide' );

View File

@ -386,17 +386,8 @@ class CheckoutFields {
$field_data['options'] = $cleaned_options;
// If the field is not required, inject an empty option at the start.
if ( isset( $field_data['required'] ) && false === $field_data['required'] && ! in_array( '', $added_values, true ) ) {
$field_data['options'] = array_merge(
[
[
'value' => '',
'label' => '',
],
],
$field_data['options']
);
if ( isset( $field_data['placeholder'] ) ) {
$field_data['placeholder'] = sanitize_text_field( $field_data['placeholder'] );
}
return $field_data;

View File

@ -319,6 +319,9 @@ class CheckoutSchema extends AbstractSchema {
},
$field['options']
);
if ( true !== $field['required'] ) {
$field_schema['enum'][] = '';
}
}
if ( 'checkbox' === $field['type'] ) {

View File

@ -1021,45 +1021,6 @@ class AdditionalFields extends MockeryTestCase {
$this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) );
}
/**
* Ensure a select has an extra empty option if it's optional.
*/
public function test_optional_select_has_empty_value() {
$id = 'plugin-namespace/optional-select';
\woocommerce_register_additional_checkout_field(
array(
'id' => $id,
'label' => 'Optional Select',
'location' => 'order',
'type' => 'select',
'options' => array(
array(
'label' => 'Option 1',
'value' => 'option-1',
),
array(
'label' => 'Option 2',
'value' => 'option-2',
),
),
)
);
$request = new \WP_REST_Request( 'OPTIONS', '/wc/store/v1/checkout' );
$response = rest_get_server()->dispatch( $request );
$data = $response->get_data();
$this->assertEquals(
array( '', 'option-1', 'option-2' ),
$data['schema']['properties']['additional_fields']['properties'][ $id ]['enum'],
print_r( $data['schema']['properties']['additional_fields']['properties'][ $id ], true )
);
\__internal_woocommerce_blocks_deregister_checkout_field( $id );
// Ensures the field isn't registered.
$this->assertFalse( $this->controller->is_field( $id ), \sprintf( '%s is still registered', $id ) );
}
/**
* Ensure an error is triggered when a checkbox is registered as required.
*/