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:
parent
7b0f9457cf
commit
d849155c59
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Change the custom field name component to be an autocomplete
|
|
@ -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">
|
||||
|
|
|
@ -33,6 +33,10 @@
|
|||
|
||||
.components-input-base {
|
||||
gap: 0;
|
||||
|
||||
.components-input-control__input {
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
) }
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -0,0 +1,2 @@
|
|||
export * from './custom-field-name-control';
|
||||
export * from './types';
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
>;
|
|
@ -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
|
||||
|
|
|
@ -9,6 +9,12 @@
|
|||
|
||||
.components-base-control {
|
||||
width: 100%;
|
||||
|
||||
.components-input-base {
|
||||
.components-input-control__input {
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.components-modal__content {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: tweak
|
||||
|
||||
Add $wpdb->esc_like to the search criteria when searching for a product custom field name
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue