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:
parent
28e7351180
commit
88f1187928
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add Custom Fields for New Product Editor
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export * from './create-modal';
|
||||
export * from './types';
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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 }
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
export type CustomFieldsProps = React.DetailedHTMLProps<
|
||||
React.TableHTMLAttributes< HTMLTableElement >,
|
||||
HTMLTableElement
|
||||
>;
|
||||
> & {
|
||||
renderActionButtonsWrapper?( buttons: React.ReactNode ): React.ReactNode;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
export * from './validate';
|
||||
export * from './types';
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue