Change the custom field name component to be an autocomplete (#48949)

* Moving to a Combobox

* Create CustomFieldNameControl component

* Fix combobox styles

* Change Combobox to behave as an Autocomplete

* Integrate CustomFieldNameControl within the CustomFieldCreateModal

* Fix the ref type from CustomFieldNameControl

* Integrate CustomFieldNameControl within CustomFieldEditModal

* Fix CustomFieldNameControl default/focus border style

* Fix custom field value control height to be 36px like other controls

* Add changelog file

* Fix linter errors

* Add ->esc_like to the search criteria when searching for a product custom field name

* Add changelog file

* Add comments explaining why the implamentation was made that way

* Remove non existing classname

* Fix wrong border color when the field is invalid and focused

* Fix linter errors

* Hide props from the internal input element

* Rename comboboxRef with inputElementRef

* Fix invalid empty value when the combobox has a selected value
This commit is contained in:
Maikel Perez 2024-07-08 09:14:36 -04:00 committed by GitHub
parent 7b0f9457cf
commit d849155c59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 303 additions and 10 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Change the custom field name component to be an autocomplete

View File

@ -20,6 +20,7 @@ import {
type ValidationErrors,
} from '../utils/validations';
import type { Metadata } from '../../../types';
import { CustomFieldNameControl } from '../custom-field-name-control';
import type { CreateModalProps } from './types';
const DEFAULT_CUSTOM_FIELD = {
@ -37,6 +38,7 @@ export function CreateModal( {
const [ customFields, setCustomFields ] = useState< Metadata< string >[] >(
[ DEFAULT_CUSTOM_FIELD ]
);
const [ validationError, setValidationError ] =
useState< ValidationErrors >( {} );
const inputRefs = useRef<
@ -75,7 +77,7 @@ export function CreateModal( {
customField: Metadata< string >,
prop: keyof Metadata< string >
) {
return function handleChange( value: string ) {
return function handleChange( value: string | null ) {
setCustomFields( ( current ) =>
current.map( ( field ) =>
field.id === customField.id
@ -224,11 +226,12 @@ export function CreateModal( {
{ customFields.map( ( customField ) => (
<div key={ customField.id } role="row">
<div role="cell">
<TextControl
<CustomFieldNameControl
ref={ getRef( customField, 'key' ) }
label={ '' }
aria-label={ __( 'Name', 'woocommerce' ) }
error={ getValidationError(
label={ __( 'Name', 'woocommerce' ) }
hideLabelFromVision
allowReset={ false }
help={ getValidationError(
customField,
'key'
) }
@ -238,6 +241,12 @@ export function CreateModal( {
'key'
) }
onBlur={ blurHandler( customField, 'key' ) }
className={ classNames( {
'has-error': getValidationError(
customField,
'key'
),
} ) }
/>
</div>
<div role="cell">

View File

@ -33,6 +33,10 @@
.components-input-base {
gap: 0;
.components-input-control__input {
height: 36px;
}
}
}

View File

@ -0,0 +1,194 @@
/**
* External dependencies
*/
import type { ForwardedRef } from 'react';
import apiFetch from '@wordpress/api-fetch';
import { ComboboxControl } from '@wordpress/components';
import { useDebounce, useInstanceId } from '@wordpress/compose';
import {
createElement,
forwardRef,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from '@wordpress/element';
import { addQueryArgs } from '@wordpress/url';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import type { ComboboxControlOption } from '../../attribute-combobox-field/types';
import type { CustomFieldNameControlProps } from './types';
/**
* Since the Combobox does not support an arbitrary value, the
* way to make it behave as an autocomplete, is by converting
* the arbitrary value into an option so it can be selected as
* a valid value
*
* @param search The seraching criteria.
* @return The list of filtered custom field names as a Promise.
*/
async function searchCustomFieldNames( search?: string ) {
return apiFetch< string[] >( {
path: addQueryArgs( '/wc/v3/products/custom-fields/names', {
search,
} ),
} ).then( ( customFieldNames = [] ) => {
const options: ComboboxControlOption[] = [];
if ( search && customFieldNames.indexOf( search ) === -1 ) {
options.push( { value: search, label: search } );
}
customFieldNames.forEach( ( customFieldName ) => {
options.push( {
value: customFieldName,
label: customFieldName,
} );
} );
return options;
} );
}
/**
* This is a wrapper + a work around the Combobox to
* expose important properties and events from the
* internal input element that are required when
* validating the field in the context of a form
*/
export const CustomFieldNameControl = forwardRef(
function ForwardedCustomFieldNameControl(
{
allowReset,
className,
help,
hideLabelFromVision,
label,
messages,
value,
onChange,
onBlur,
}: CustomFieldNameControlProps,
ref: ForwardedRef< HTMLInputElement >
) {
const inputElementRef = useRef< HTMLInputElement >();
const id = useInstanceId(
CustomFieldNameControl,
'woocommerce-custom-field-name'
);
const [ customFieldNames, setCustomFieldNames ] = useState<
ComboboxControl.Props[ 'options' ]
>( [] );
const options = useMemo(
/**
* Prepend the selected value as an option to let
* the Combobox know which option is the selected
* one even when an async request is being performed
*
* @return The combobox options.
*/
function prependSelectedValueAsOption() {
if ( value ) {
const isExisting = customFieldNames.some(
( customFieldName ) => customFieldName.value === value
);
if ( ! isExisting ) {
return [ { label: value, value }, ...customFieldNames ];
}
}
return customFieldNames;
},
[ customFieldNames, value ]
);
useLayoutEffect(
/**
* The Combobox component does not expose the ref to the
* internal native input element removing the ability to
* focus the element when validating it in the context
* of a form
*/
function initializeRefs() {
inputElementRef.current = document.querySelector(
`.${ id } [role="combobox"]`
) as HTMLInputElement;
if ( ref ) {
if ( typeof ref === 'function' ) {
ref( inputElementRef.current );
} else {
ref.current = inputElementRef.current;
}
}
},
[ id, ref ]
);
const handleFilterValueChange = useDebounce(
useCallback(
function onFilterValueChange( search: string ) {
searchCustomFieldNames(
search === '' ? value : search
).then( setCustomFieldNames );
},
[ value ]
),
250
);
useEffect(
function overrideBlur() {
/**
* The Combobox component clear the value of its internal
* input control when losing the focus, even when the
* selected value is set, afecting the validation behavior
* on bluring
*/
function handleBlur( event: FocusEvent ) {
setCustomFieldNames( [] );
if ( inputElementRef.current ) {
inputElementRef.current.value = value;
}
onBlur?.( event as never );
}
inputElementRef.current?.addEventListener( 'blur', handleBlur );
return () => {
inputElementRef.current?.removeEventListener(
'blur',
handleBlur
);
};
},
[ value, onBlur ]
);
return (
<ComboboxControl
allowReset={ allowReset }
help={ help }
hideLabelFromVision={ hideLabelFromVision }
label={ label }
messages={ messages }
value={ value }
options={ options }
onChange={ onChange }
onFilterValueChange={ handleFilterValueChange }
className={ classNames(
id,
'woocommerce-custom-field-name-control',
className
) }
/>
);
}
);

View File

@ -0,0 +1,2 @@
export * from './custom-field-name-control';
export * from './types';

View File

@ -0,0 +1,44 @@
.woocommerce-custom-field-name-control {
background-color: #fff;
&.has-error {
.components-combobox-control__suggestions-container {
&:focus-within > .components-flex,
> .components-flex,
> .components-form-token-field__suggestions-list {
border-color: $studio-red-50;
}
}
}
.components-combobox-control__suggestions-container {
position: relative;
border: none;
&:focus-within {
> .components-flex {
border-color: var(--wp-admin-theme-color);
box-shadow: 0 0 0 0.5px var(--wp-admin-theme-color);
}
}
> .components-flex {
height: 36px;
border: 1px solid $gray-600;
box-shadow: none;
border-radius: 2px;
}
> .components-form-token-field__suggestions-list {
position: absolute;
z-index: 1;
background-color: #fff;
border: 1px solid var(--wp-admin-theme-color);
box-shadow: 0 0 0 0.5px var(--wp-admin-theme-color);
outline: 2px solid #0000;
top: calc(100% - 1px);
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
}
}

View File

@ -0,0 +1,16 @@
/**
* External dependencies
*/
import { ComboboxControl } from '@wordpress/components';
export type CustomFieldNameControlProps = Omit<
ComboboxControl.Props,
'options' | 'onFilterValueChange'
> &
Pick<
React.DetailedHTMLProps<
React.InputHTMLAttributes< HTMLInputElement >,
HTMLInputElement
>,
'onBlur'
>;

View File

@ -2,7 +2,7 @@
* External dependencies
*/
import { Button, Modal } from '@wordpress/components';
import { createElement, useState, useRef } from '@wordpress/element';
import { createElement, useState, useRef, useEffect } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { recordEvent } from '@woocommerce/tracks';
import classNames from 'classnames';
@ -15,6 +15,7 @@ import { TRACKS_SOURCE } from '../../../constants';
import { TextControl } from '../../text-control';
import type { Metadata } from '../../../types';
import { type ValidationError, validate } from '../utils/validations';
import { CustomFieldNameControl } from '../custom-field-name-control';
import type { EditModalProps } from './types';
export function EditModal( {
@ -31,6 +32,10 @@ export function EditModal( {
const keyInputRef = useRef< HTMLInputElement >( null );
const valueInputRef = useRef< HTMLInputElement >( null );
useEffect( function focusNameInputOnMount() {
keyInputRef.current?.focus();
}, [] );
function renderTitle() {
return sprintf(
/* translators: %s: the name of the custom field */
@ -40,7 +45,7 @@ export function EditModal( {
}
function changeHandler( prop: keyof Metadata< string > ) {
return function handleChange( value: string ) {
return function handleChange( value: string | null ) {
setCustomField( ( current ) => ( {
...current,
[ prop ]: value,
@ -101,13 +106,17 @@ export function EditModal( {
props.className
) }
>
<TextControl
<CustomFieldNameControl
ref={ keyInputRef }
label={ __( 'Name', 'woocommerce' ) }
error={ validationError?.key }
allowReset={ false }
help={ validationError?.key }
value={ customField.key }
onChange={ changeHandler( 'key' ) }
onBlur={ blurHandler( 'key' ) }
className={ classNames( {
'has-error': validationError?.key,
} ) }
/>
<TextControl

View File

@ -9,6 +9,12 @@
.components-base-control {
width: 100%;
.components-input-base {
.components-input-control__input {
height: 36px;
}
}
}
.components-modal__content {

View File

@ -1,5 +1,6 @@
@import "./create-modal/style.scss";
@import "./edit-modal/style.scss";
@import "./custom-field-name-control/style.scss";
.woocommerce-product-custom-fields {
&__table {

View File

@ -0,0 +1,4 @@
Significance: minor
Type: tweak
Add $wpdb->esc_like to the search criteria when searching for a product custom field name

View File

@ -81,7 +81,7 @@ class WC_REST_Product_Custom_Fields_Controller extends WC_REST_Controller {
WHERE posts.post_type = %s AND post_metas.meta_key NOT LIKE %s AND post_metas.meta_key LIKE %s",
$this->post_type,
$wpdb->esc_like( '_' ) . '%',
"%{$search}%"
'%' . $wpdb->esc_like( $search ) . '%'
);
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $base_query has been prepared already and $order is a static value.