Add Custom Fields for New Product Editor (#45484)

* Create the Add new button

* Integrate the CreateModal with the CustomFields component to add fields

* Add validation utils

* Fix validation errors in the edit modal component

* Fix linter error

* Add changelog file
This commit is contained in:
Maikel Perez 2024-03-12 10:02:24 -03:00 committed by GitHub
parent 28e7351180
commit 88f1187928
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 432 additions and 35 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add Custom Fields for New Product Editor

View File

@ -7,6 +7,7 @@ import { useWooBlockProps } from '@woocommerce/block-templates';
/**
* Internal dependencies
*/
import { SectionActions } from '../../../components/block-slot-fill';
import { CustomFields } from '../../../components/custom-fields';
import { ProductEditorBlockEditProps } from '../../../types';
import { CustomFieldsBlockAttributes } from './types';
@ -18,7 +19,11 @@ export function Edit( {
return (
<div { ...blockProps }>
<CustomFields />
<CustomFields
renderActionButtonsWrapper={ ( buttons ) => (
<SectionActions>{ buttons }</SectionActions>
) }
/>
</div>
);
}

View File

@ -0,0 +1,236 @@
/**
* External dependencies
*/
import { Button, Modal } from '@wordpress/components';
import { createElement, useState, useRef, useEffect } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { closeSmall } from '@wordpress/icons';
import classNames from 'classnames';
import type { FocusEvent } from 'react';
/**
* Internal dependencies
*/
import { TextControl } from '../../text-control';
import { validate, type ValidationErrors } from '../utils/validations';
import type { Metadata } from '../../../types';
import type { CreateModalProps } from './types';
const DEFAULT_CUSTOM_FIELD = {
id: 1,
key: '',
value: '',
} satisfies Metadata< string >;
export function CreateModal( {
onCreate,
onCancel,
...props
}: CreateModalProps ) {
const [ customFields, setCustomFields ] = useState< Metadata< string >[] >(
[ DEFAULT_CUSTOM_FIELD ]
);
const [ validationError, setValidationError ] =
useState< ValidationErrors >( {} );
const inputRefs = useRef<
Record<
string,
Record< keyof Metadata< string >, HTMLInputElement | null >
>
>( {} );
useEffect( function focusFirstNameInputOnMount() {
const firstRef = inputRefs.current[ DEFAULT_CUSTOM_FIELD.id ];
firstRef?.key?.focus();
}, [] );
function getRef(
customField: Metadata< string >,
prop: keyof Metadata< string >
) {
return function setRef( element: HTMLInputElement ) {
const id = String( customField.id );
inputRefs.current[ id ] = {
...inputRefs.current[ id ],
[ prop ]: element,
};
};
}
function getValidationError(
customField: Metadata< string >,
prop: keyof Metadata< string >
) {
return validationError[ String( customField.id ) ]?.[ prop ];
}
function changeHandler(
customField: Metadata< string >,
prop: keyof Metadata< string >
) {
return function handleChange( value: string ) {
setCustomFields( ( current ) =>
current.map( ( field ) =>
field.id === customField.id
? { ...field, [ prop ]: value }
: field
)
);
};
}
function blurHandler(
customField: Metadata< string >,
prop: keyof Metadata< string >
) {
return function handleBlur( event: FocusEvent< HTMLInputElement > ) {
const error = validate( {
...customField,
[ prop ]: event.target.value,
} );
const id = String( customField.id );
setValidationError( ( current ) => ( {
...current,
[ id ]: error,
} ) );
};
}
function removeCustomFieldButtonClickHandler(
customField: Metadata< string >
) {
if ( customFields.length <= 1 ) {
return undefined;
}
return function handleRemoveCustomFieldButtonClick() {
setCustomFields( ( current ) =>
current.filter( ( { id } ) => customField.id !== id )
);
setValidationError( ( current ) => ( {
...current,
[ `${ customField.id }` ]: undefined,
} ) );
};
}
function handleAddAnotherButtonClick() {
setCustomFields( ( current ) => {
const lastField = current[ current.length - 1 ];
return [
...current,
{ ...DEFAULT_CUSTOM_FIELD, id: ( lastField.id ?? 0 ) + 1 },
];
} );
}
function handleAddButtonClick() {
const { errors, hasErrors } = customFields.reduce(
( prev, customField ) => {
const _errors = validate( customField );
prev.errors[ String( customField.id ) ] = _errors;
if ( _errors.key ) {
if ( ! prev.hasErrors ) {
inputRefs.current[
String( customField.id )
]?.key?.focus();
}
prev.hasErrors = true;
}
if ( _errors.value ) {
if ( ! prev.hasErrors ) {
inputRefs.current[
String( customField.id )
]?.value?.focus();
}
prev.hasErrors = true;
}
return prev;
},
{ errors: {} as ValidationErrors, hasErrors: false }
);
if ( hasErrors ) {
setValidationError( errors );
return;
}
onCreate(
customFields.map( ( { id, ...customField } ) => customField )
);
}
return (
<Modal
shouldCloseOnClickOutside={ false }
title={ __( 'Add custom fields', 'woocommerce' ) }
onRequestClose={ onCancel }
{ ...props }
className={ classNames(
'woocommerce-product-custom-fields__create-modal',
props.className
) }
>
<ul className="woocommerce-product-custom-fields__create-modal-list">
{ customFields.map( ( customField ) => (
<li
key={ customField.id }
className="woocommerce-product-custom-fields__create-modal-list-item"
>
<TextControl
ref={ getRef( customField, 'key' ) }
label={ __( 'Name', 'woocommerce' ) }
error={ getValidationError( customField, 'key' ) }
value={ customField.key }
onChange={ changeHandler( customField, 'key' ) }
onBlur={ blurHandler( customField, 'key' ) }
/>
<TextControl
ref={ getRef( customField, 'value' ) }
label={ __( 'Value', 'woocommerce' ) }
error={ getValidationError( customField, 'value' ) }
value={ customField.value }
onChange={ changeHandler( customField, 'value' ) }
onBlur={ blurHandler( customField, 'value' ) }
/>
<Button
icon={ closeSmall }
disabled={ customFields.length <= 1 }
aria-label={ __(
'Remove custom field',
'woocommerce'
) }
onClick={ removeCustomFieldButtonClickHandler(
customField
) }
/>
</li>
) ) }
</ul>
<div className="woocommerce-product-custom-fields__create-modal-add-another">
<Button
variant="tertiary"
onClick={ handleAddAnotherButtonClick }
>
{ __( '+ Add another', 'woocommerce' ) }
</Button>
</div>
<div className="woocommerce-product-custom-fields__create-modal-actions">
<Button variant="secondary" onClick={ onCancel }>
{ __( 'Cancel', 'woocommerce' ) }
</Button>
<Button variant="primary" onClick={ handleAddButtonClick }>
{ __( 'Add', 'woocommerce' ) }
</Button>
</div>
</Modal>
);
}

View File

@ -0,0 +1,2 @@
export * from './create-modal';
export * from './types';

View File

@ -0,0 +1,34 @@
.woocommerce-product-custom-fields__create-modal {
min-width: 75%;
&-list {
&-item {
display: flex;
gap: $grid-unit-20;
border-bottom: 1px solid $gray-200;
padding-top: $grid-unit-30;
padding-bottom: $grid-unit;
margin-bottom: 0;
&:first-child {
padding-top: 0;
}
.components-base-control {
width: 100%;
}
.components-button {
margin-top: 20px;
}
}
}
&-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: $grid-unit-40;
}
}

View File

@ -0,0 +1,17 @@
/**
* External dependencies
*/
import { Modal } from '@wordpress/components';
/**
* Internal dependencies
*/
import type { Metadata } from '../../../types';
export type CreateModalProps = Omit<
Modal.Props,
'title' | 'onRequestClose' | 'children'
> & {
onCreate( value: Metadata< string >[] ): void;
onCancel(): void;
};

View File

@ -10,13 +10,21 @@ import classNames from 'classnames';
* Internal dependencies
*/
import { useCustomFields } from '../../hooks/use-custom-fields';
import { CreateModal } from './create-modal';
import { EditModal } from './edit-modal';
import { EmptyState } from './empty-state';
import type { Metadata } from '../../types';
import type { CustomFieldsProps } from './types';
export function CustomFields( { className, ...props }: CustomFieldsProps ) {
const { customFields, updateCustomField } = useCustomFields();
export function CustomFields( {
className,
renderActionButtonsWrapper = ( buttons ) => buttons,
...props
}: CustomFieldsProps ) {
const { customFields, addCustomFields, updateCustomField } =
useCustomFields();
const [ showCreateModal, setShowCreateModal ] = useState( false );
const [ selectedCustomField, setSelectedCustomField ] =
useState< Metadata< string > >();
@ -24,6 +32,10 @@ export function CustomFields( { className, ...props }: CustomFieldsProps ) {
return <EmptyState />;
}
function handleAddNewButtonClick() {
setShowCreateModal( true );
}
function customFieldEditButtonClickHandler(
customField: Metadata< string >
) {
@ -32,6 +44,15 @@ export function CustomFields( { className, ...props }: CustomFieldsProps ) {
};
}
function handleCreateModalCreate( value: Metadata< string >[] ) {
addCustomFields( value );
setShowCreateModal( false );
}
function handleCreateModalCancel() {
setShowCreateModal( false );
}
function handleEditModalUpdate( customField: Metadata< string > ) {
updateCustomField( customField );
setSelectedCustomField( undefined );
@ -43,6 +64,12 @@ export function CustomFields( { className, ...props }: CustomFieldsProps ) {
return (
<>
{ renderActionButtonsWrapper(
<Button variant="secondary" onClick={ handleAddNewButtonClick }>
{ __( 'Add new', 'woocommerce' ) }
</Button>
) }
<table
{ ...props }
className={ classNames(
@ -84,6 +111,13 @@ export function CustomFields( { className, ...props }: CustomFieldsProps ) {
</tbody>
</table>
{ showCreateModal && (
<CreateModal
onCreate={ handleCreateModalCreate }
onCancel={ handleCreateModalCancel }
/>
) }
{ selectedCustomField && (
<EditModal
initialValue={ selectedCustomField }

View File

@ -12,17 +12,9 @@ import type { FocusEvent } from 'react';
*/
import { TextControl } from '../../text-control';
import type { Metadata } from '../../../types';
import { type ValidationError, validate } from '../utils/validations';
import type { EditModalProps } from './types';
function validateName( value: string ) {
if ( value.startsWith( '_' ) ) {
return __(
'The name cannot begin with the underscore (_) character.',
'woocommerce'
);
}
}
export function EditModal( {
initialValue,
onUpdate,
@ -31,8 +23,10 @@ export function EditModal( {
}: EditModalProps ) {
const [ customField, setCustomField ] =
useState< Metadata< string > >( initialValue );
const [ validationError, setValidationError ] = useState< string >();
const nameTextRef = useRef< HTMLInputElement >( null );
const [ validationError, setValidationError ] =
useState< ValidationError >();
const keyInputRef = useRef< HTMLInputElement >( null );
const valueInputRef = useRef< HTMLInputElement >( null );
function renderTitle() {
return sprintf(
@ -42,24 +36,37 @@ export function EditModal( {
);
}
function handleNameChange( key: string ) {
setCustomField( ( current ) => ( { ...current, key } ) );
function changeHandler( prop: keyof Metadata< string > ) {
return function handleChange( value: string ) {
setCustomField( ( current ) => ( {
...current,
[ prop ]: value,
} ) );
};
}
function handleNameBlur( event: FocusEvent< HTMLInputElement > ) {
const error = validateName( event.target.value );
setValidationError( error );
}
function handleValueChange( value: string ) {
setCustomField( ( current ) => ( { ...current, value } ) );
function blurHandler( prop: keyof Metadata< string > ) {
return function handleBlur( event: FocusEvent< HTMLInputElement > ) {
const error = validate( {
...customField,
[ prop ]: event.target.value,
} );
setValidationError( error );
};
}
function handleUpdateButtonClick() {
const error = validateName( customField.key );
if ( error ) {
setValidationError( error );
nameTextRef.current?.focus();
const errors = validate( customField );
if ( errors.key || errors.value ) {
setValidationError( errors );
if ( errors.key ) {
keyInputRef.current?.focus();
return;
}
valueInputRef.current?.focus();
return;
}
@ -78,18 +85,21 @@ export function EditModal( {
) }
>
<TextControl
ref={ nameTextRef }
ref={ keyInputRef }
label={ __( 'Name', 'woocommerce' ) }
error={ validationError }
error={ validationError?.key }
value={ customField.key }
onChange={ handleNameChange }
onBlur={ handleNameBlur }
onChange={ changeHandler( 'key' ) }
onBlur={ blurHandler( 'key' ) }
/>
<TextControl
ref={ valueInputRef }
label={ __( 'Value', 'woocommerce' ) }
error={ validationError?.value }
value={ customField.value }
onChange={ handleValueChange }
onChange={ changeHandler( 'value' ) }
onBlur={ blurHandler( 'value' ) }
/>
<div className="woocommerce-product-custom-fields__edit-modal-actions">

View File

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

View File

@ -1,4 +1,6 @@
export type CustomFieldsProps = React.DetailedHTMLProps<
React.TableHTMLAttributes< HTMLTableElement >,
HTMLTableElement
>;
> & {
renderActionButtonsWrapper?( buttons: React.ReactNode ): React.ReactNode;
};

View File

@ -0,0 +1,2 @@
export * from './validate';
export * from './types';

View File

@ -0,0 +1,10 @@
/**
* Internal dependencies
*/
import type { Metadata } from '../../../../types';
export type ValidationError = Record< keyof Metadata< string >, string >;
export type ValidationErrors = {
[ id: string ]: ValidationError | undefined;
};

View File

@ -0,0 +1,31 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import type { Metadata } from '../../../../types';
import type { ValidationError } from './types';
export function validate(
customField: Partial< Metadata< string > >
): ValidationError {
const errors = {} as ValidationError;
if ( ! customField.key ) {
errors.key = __( 'The name is required.', 'woocommerce' );
} else if ( customField.key.startsWith( '_' ) ) {
errors.key = __(
'The name cannot begin with the underscore (_) character.',
'woocommerce'
);
}
if ( ! customField.value ) {
errors.value = __( 'The value is required.', 'woocommerce' );
}
return errors;
}

View File

@ -37,6 +37,10 @@ export function useCustomFields<
setMetas( [ ...internalMetas, ...newValue ] );
}
function addCustomFields( value: T[] ) {
setCustomFields( ( current ) => [ ...current, ...value ] );
}
function updateCustomField( customField: T ) {
setCustomFields( ( current ) =>
current.map( ( field ) => {
@ -48,5 +52,10 @@ export function useCustomFields<
);
}
return { customFields, setCustomFields, updateCustomField };
return {
customFields,
addCustomFields,
setCustomFields,
updateCustomField,
};
}