Add Options section to new product experience (#35910)

* Support passing in filter and new attribute properties to AttributeField

* Changelog

* Pass addButtonLabel as prop

* Add OptionsSection to options tab

* Refactor more to create Attributes and Options fields

* Refactor a couple of things

* Refactor globalAttributeHelperMessage

* Remove `Used for filters` checkbox

* Remove `hydrationComplete`

* Add subtitle to empty state component

* Fix 'Add option' button

* Fix tests

Co-authored-by: Fernando Marichal <contacto@fernandomarichal.com>
This commit is contained in:
Matt Sherman 2022-12-23 14:57:28 -05:00 committed by GitHub
parent 5b3b5dab59
commit a9b46d51b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 275 additions and 86 deletions

View File

@ -1,7 +1,7 @@
/** /**
* External dependencies * External dependencies
*/ */
import { __ } from '@wordpress/i18n'; import { __, sprintf } from '@wordpress/i18n';
import { useState, useCallback, useEffect } from '@wordpress/element'; import { useState, useCallback, useEffect } from '@wordpress/element';
import { import {
ProductAttribute, ProductAttribute,
@ -12,8 +12,11 @@ import { resolveSelect } from '@wordpress/data';
import { import {
Sortable, Sortable,
__experimentalSelectControlMenuSlot as SelectControlMenuSlot, __experimentalSelectControlMenuSlot as SelectControlMenuSlot,
Link,
} from '@woocommerce/components'; } from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks'; import { recordEvent } from '@woocommerce/tracks';
import interpolateComponents from '@automattic/interpolate-components';
import { getAdminLink } from '@woocommerce/settings';
/** /**
* Internal dependencies * Internal dependencies
@ -33,23 +36,24 @@ type AttributeFieldProps = {
value: ProductAttribute[]; value: ProductAttribute[];
onChange: ( value: ProductAttribute[] ) => void; onChange: ( value: ProductAttribute[] ) => void;
productId?: number; productId?: number;
// TODO: should we support an 'any' option to show all attributes?
attributeType?: 'regular' | 'for-variations';
}; };
export type HydratedAttributeType = Omit< ProductAttribute, 'options' > & { export type HydratedAttributeType = Omit< ProductAttribute, 'options' > & {
options?: string[]; options?: string[];
terms?: ProductAttributeTerm[]; terms?: ProductAttributeTerm[];
visible?: boolean;
}; };
export const AttributeField: React.FC< AttributeFieldProps > = ( { export const AttributeField: React.FC< AttributeFieldProps > = ( {
value, value,
onChange, onChange,
productId, productId,
attributeType = 'regular',
} ) => { } ) => {
const [ showAddAttributeModal, setShowAddAttributeModal ] = const [ showAddAttributeModal, setShowAddAttributeModal ] =
useState( false ); useState( false );
const [ hydrationComplete, setHydrationComplete ] = useState< boolean >(
value ? false : true
);
const [ hydratedAttributes, setHydratedAttributes ] = useState< const [ hydratedAttributes, setHydratedAttributes ] = useState<
HydratedAttributeType[] HydratedAttributeType[]
>( [] ); >( [] );
@ -57,8 +61,13 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
null | string null | string
>( null ); >( null );
const CANCEL_BUTTON_EVENT_NAME = const isOnlyForVariations = attributeType === 'for-variations';
'product_add_attributes_modal_cancel_button_click';
const newAttributeProps = { variation: isOnlyForVariations };
const CANCEL_BUTTON_EVENT_NAME = isOnlyForVariations
? 'product_add_options_modal_cancel_button_click'
: 'product_add_attributes_modal_cancel_button_click';
const fetchTerms = useCallback( const fetchTerms = useCallback(
( attributeId: number ) => { ( attributeId: number ) => {
@ -82,7 +91,19 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
); );
useEffect( () => { useEffect( () => {
if ( ! value || hydrationComplete ) { // Temporarily always doing hydration, since otherwise new attributes
// get removed from Options and Attributes when the other list is then
// modified
//
// This is because the hydration is out of date -- the logic currently
// assumes modifications are only made from within the component
//
// I think we'll need to move the hydration out of the individual component
// instance. To where, I do not yet know... maybe in the form context
// somewhere so that a single hydration source can be shared between multiple
// instances? Something like a simple key-value store in the form context
// would be handy.
if ( ! value ) {
return; return;
} }
@ -94,19 +115,26 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
).then( ( allResults ) => { ).then( ( allResults ) => {
setHydratedAttributes( [ setHydratedAttributes( [
...globalAttributes.map( ( attr, index ) => { ...globalAttributes.map( ( attr, index ) => {
const fetchedTerms = allResults[ index ];
const newAttr = { const newAttr = {
...attr, ...attr,
terms: allResults[ index ], // I'm not sure this is quite right for handling unpersisted terms,
options: undefined, // but this gets things kinda working for now
terms:
fetchedTerms.length > 0 ? fetchedTerms : undefined,
options:
fetchedTerms.length === 0
? attr.options
: undefined,
}; };
return newAttr; return newAttr;
} ), } ),
...customAttributes, ...customAttributes,
] ); ] );
setHydrationComplete( true );
} ); } );
}, [ productId, value, hydrationComplete ] ); }, [ fetchTerms, productId, value ] );
const fetchAttributeId = ( attribute: { id: number; name: string } ) => const fetchAttributeId = ( attribute: { id: number; name: string } ) =>
`${ attribute.id }-${ attribute.name }`; `${ attribute.id }-${ attribute.name }`;
@ -121,6 +149,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
? attr.terms.map( ( term ) => term.name ) ? attr.terms.map( ( term ) => term.name )
: ( attr.options as string[] ), : ( attr.options as string[] ),
terms: undefined, terms: undefined,
visible: attr.visible || false,
}; };
} ) } )
); );
@ -157,24 +186,48 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
) )
) )
.map( ( newAttr, index ) => { .map( ( newAttr, index ) => {
newAttr.position = ( value || [] ).length + index; return {
return newAttr; ...newAttributeProps,
...newAttr,
position: ( value || [] ).length + index,
};
} ), } ),
] ); ] );
recordEvent( 'product_add_attributes_modal_add_button_click' ); recordEvent( 'product_add_attributes_modal_add_button_click' );
setShowAddAttributeModal( false ); setShowAddAttributeModal( false );
}; };
if ( ! value || value.length === 0 || hydratedAttributes.length === 0 ) { const filteredAttributes = value
? value.filter(
( attribute: ProductAttribute ) =>
attribute.variation === isOnlyForVariations
)
: false;
if (
! filteredAttributes ||
filteredAttributes.length === 0 ||
hydratedAttributes.length === 0
) {
return ( return (
<> <>
<AttributeEmptyState <AttributeEmptyState
addNewLabel={
isOnlyForVariations
? __( 'Add options', 'woocommerce' )
: undefined
}
onNewClick={ () => { onNewClick={ () => {
recordEvent( recordEvent(
'product_add_first_attribute_button_click' 'product_add_first_attribute_button_click'
); );
setShowAddAttributeModal( true ); setShowAddAttributeModal( true );
} } } }
subtitle={
isOnlyForVariations
? __( 'No options yet', 'woocommerce' )
: undefined
}
/> />
{ showAddAttributeModal && ( { showAddAttributeModal && (
<AddAttributeModal <AddAttributeModal
@ -183,7 +236,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
setShowAddAttributeModal( false ); setShowAddAttributeModal( false );
} } } }
onAdd={ onAddNewAttributes } onAdd={ onAddNewAttributes }
selectedAttributeIds={ ( value || [] ).map( selectedAttributeIds={ ( filteredAttributes || [] ).map(
( attr ) => attr.id ( attr ) => attr.id
) } ) }
/> />
@ -193,8 +246,10 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
); );
} }
const sortedAttributes = value.sort( ( a, b ) => a.position - b.position ); const sortedAttributes = filteredAttributes.sort(
const attributeKeyValues = value.reduce( ( a, b ) => a.position - b.position
);
const attributeKeyValues = filteredAttributes.reduce(
( (
keyValue: Record< number, ProductAttribute >, keyValue: Record< number, ProductAttribute >,
attribute: ProductAttribute attribute: ProductAttribute
@ -205,6 +260,20 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
{} as Record< number, ProductAttribute > {} as Record< number, ProductAttribute >
); );
const attribute = hydratedAttributes.find(
( attr ) => fetchAttributeId( attr ) === editingAttributeId
) as HydratedAttributeType;
const editAttributeCopy = isOnlyForVariations
? __(
`You can change the option's name in {{link}}Attributes{{/link}}.`,
'woocommerce'
)
: __(
`You can change the attribute's name in {{link}}Attributes{{/link}}.`,
'woocommerce'
);
return ( return (
<div className="woocommerce-attribute-field"> <div className="woocommerce-attribute-field">
<Sortable <Sortable
@ -217,27 +286,39 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
); );
} } } }
> >
{ sortedAttributes.map( ( attribute ) => ( { sortedAttributes.map( ( attr ) => (
<AttributeListItem <AttributeListItem
attribute={ attribute } attribute={ attr }
key={ fetchAttributeId( attribute ) } key={ fetchAttributeId( attr ) }
onEditClick={ () => onEditClick={ () =>
setEditingAttributeId( setEditingAttributeId( fetchAttributeId( attr ) )
fetchAttributeId( attribute )
)
} }
onRemoveClick={ () => onRemove( attribute ) } onRemoveClick={ () => onRemove( attr ) }
/> />
) ) } ) ) }
</Sortable> </Sortable>
<AddAttributeListItem <AddAttributeListItem
label={
isOnlyForVariations
? __( 'Add option', 'woocommerce' )
: undefined
}
onAddClick={ () => { onAddClick={ () => {
recordEvent( 'product_add_attribute_button' ); recordEvent(
isOnlyForVariations
? 'product_add_option_button'
: 'product_add_attribute_button'
);
setShowAddAttributeModal( true ); setShowAddAttributeModal( true );
} } } }
/> />
{ showAddAttributeModal && ( { showAddAttributeModal && (
<AddAttributeModal <AddAttributeModal
title={
isOnlyForVariations
? __( 'Add options', 'woocommerce' )
: undefined
}
onCancel={ () => { onCancel={ () => {
recordEvent( CANCEL_BUTTON_EVENT_NAME ); recordEvent( CANCEL_BUTTON_EVENT_NAME );
setShowAddAttributeModal( false ); setShowAddAttributeModal( false );
@ -249,6 +330,29 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
<SelectControlMenuSlot /> <SelectControlMenuSlot />
{ editingAttributeId && ( { editingAttributeId && (
<EditAttributeModal <EditAttributeModal
title={
/* translators: %s is the attribute name */
sprintf(
__( 'Edit %s', 'woocommerce' ),
attribute.name
)
}
globalAttributeHelperMessage={ interpolateComponents( {
mixedString: editAttributeCopy,
components: {
link: (
<Link
href={ getAdminLink(
'edit.php?post_type=product&page=product_attributes'
) }
target="_blank"
type="wp-admin"
>
<></>
</Link>
),
},
} ) }
onCancel={ () => setEditingAttributeId( null ) } onCancel={ () => setEditingAttributeId( null ) }
onEdit={ ( changedAttribute ) => { onEdit={ ( changedAttribute ) => {
const newAttributesSet = [ ...hydratedAttributes ]; const newAttributesSet = [ ...hydratedAttributes ];
@ -266,12 +370,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
updateAttributes( newAttributesSet ); updateAttributes( newAttributesSet );
setEditingAttributeId( null ); setEditingAttributeId( null );
} } } }
attribute={ attribute={ attribute }
hydratedAttributes.find(
( attr ) =>
fetchAttributeId( attr ) === editingAttributeId
) as HydratedAttributeType
}
/> />
) } ) }
</div> </div>

View File

@ -9,12 +9,7 @@ import {
TextControl, TextControl,
} from '@wordpress/components'; } from '@wordpress/components';
import { useState } from '@wordpress/element'; import { useState } from '@wordpress/element';
import { import { __experimentalTooltip as Tooltip } from '@woocommerce/components';
__experimentalTooltip as Tooltip,
Link,
} from '@woocommerce/components';
import interpolateComponents from '@automattic/interpolate-components';
import { getAdminLink } from '@woocommerce/settings';
/** /**
* Internal dependencies * Internal dependencies
@ -30,14 +25,12 @@ import './edit-attribute-modal.scss';
type EditAttributeModalProps = { type EditAttributeModalProps = {
title?: string; title?: string;
nameLabel?: string; nameLabel?: string;
globalAttributeHelperMessage?: string; globalAttributeHelperMessage?: JSX.Element;
customAttributeHelperMessage?: string; customAttributeHelperMessage?: string;
termsLabel?: string; termsLabel?: string;
termsPlaceholder?: string; termsPlaceholder?: string;
visibleLabel?: string; visibleLabel?: string;
visibleTooltip?: string; visibleTooltip?: string;
filtersLabel?: string;
filtersTooltip?: string;
cancelAccessibleLabel?: string; cancelAccessibleLabel?: string;
cancelLabel?: string; cancelLabel?: string;
updateAccessibleLabel?: string; updateAccessibleLabel?: string;
@ -50,25 +43,7 @@ type EditAttributeModalProps = {
export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( { export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
title = __( 'Edit attribute', 'woocommerce' ), title = __( 'Edit attribute', 'woocommerce' ),
nameLabel = __( 'Name', 'woocommerce' ), nameLabel = __( 'Name', 'woocommerce' ),
globalAttributeHelperMessage = interpolateComponents( { globalAttributeHelperMessage,
mixedString: __(
`You can change the attribute's name in {{link}}Attributes{{/link}}.`,
'woocommerce'
),
components: {
link: (
<Link
href={ getAdminLink(
'edit.php?post_type=product&page=product_attributes'
) }
target="_blank"
type="wp-admin"
>
<></>
</Link>
),
},
} ),
customAttributeHelperMessage = __( customAttributeHelperMessage = __(
'Your customers will see this on the product page', 'Your customers will see this on the product page',
'woocommerce' 'woocommerce'
@ -80,11 +55,6 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
'Show or hide this attribute on the product page', 'Show or hide this attribute on the product page',
'woocommerce' 'woocommerce'
), ),
filtersLabel = __( 'Used for filters', 'woocommerce' ),
filtersTooltip = __(
`Show or hide this attribute in the filters section on your store's category and shop pages`,
'woocommerce'
),
cancelAccessibleLabel = __( 'Cancel', 'woocommerce' ), cancelAccessibleLabel = __( 'Cancel', 'woocommerce' ),
cancelLabel = __( 'Cancel', 'woocommerce' ), cancelLabel = __( 'Cancel', 'woocommerce' ),
updateAccessibleLabel = __( 'Edit attribute', 'woocommerce' ), updateAccessibleLabel = __( 'Edit attribute', 'woocommerce' ),
@ -165,19 +135,6 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
/> />
<Tooltip text={ visibleTooltip } /> <Tooltip text={ visibleTooltip } />
</div> </div>
<div className="woocommerce-edit-attribute-modal__option-container">
<CheckboxControl
onChange={ ( val ) =>
setEditableAttribute( {
...( editableAttribute as HydratedAttributeType ),
variation: val,
} )
}
checked={ editableAttribute?.variation }
label={ filtersLabel }
/>
<Tooltip text={ filtersTooltip } />
</div>
</div> </div>
<div className="woocommerce-add-attribute-modal__buttons"> <div className="woocommerce-add-attribute-modal__buttons">
<Button <Button

View File

@ -26,7 +26,7 @@ const attributeList: ProductAttribute[] = [
visible: true, visible: true,
variation: true, variation: true,
options: [ options: [
'Beige', 'beige',
'black', 'black',
'Blue', 'Blue',
'brown', 'brown',
@ -134,23 +134,24 @@ describe( 'AttributeField', () => {
await screen.findByText( attributeList[ 0 ].name ) await screen.findByText( attributeList[ 0 ].name )
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
await screen.findByText( attributeList[ 1 ].name ) await screen.queryByText( attributeList[ 1 ].name )
).toBeInTheDocument(); ).not.toBeInTheDocument();
} ); } );
it( 'should render the first two terms of each attribute, and show "+ n more" for the rest', async () => { it( 'should render the first two terms of each option, and show "+ n more" for the rest', async () => {
act( () => { act( () => {
render( render(
<AttributeField <AttributeField
value={ [ ...attributeList ] } value={ [ ...attributeList ] }
onChange={ () => {} } onChange={ () => {} }
attributeType="for-variations"
/> />
); );
} ); } );
expect( expect(
await screen.findByText( attributeList[ 0 ].options[ 0 ] ) await screen.queryByText( attributeList[ 0 ].options[ 0 ] )
).toBeInTheDocument(); ).not.toBeInTheDocument();
expect( expect(
await screen.findByText( attributeList[ 1 ].options[ 0 ] ) await screen.findByText( attributeList[ 1 ].options[ 0 ] )
).toBeInTheDocument(); ).toBeInTheDocument();

View File

@ -0,0 +1,30 @@
/**
* External dependencies
*/
import { ProductAttribute } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { AttributeField } from '../attribute-field';
type AttributesProps = {
value: ProductAttribute[];
onChange: ( value: ProductAttribute[] ) => void;
productId?: number;
};
export const Attributes: React.FC< AttributesProps > = ( {
value,
onChange,
productId,
} ) => {
return (
<AttributeField
attributeType="regular"
value={ value }
onChange={ onChange }
productId={ productId }
/>
);
};

View File

@ -0,0 +1 @@
export * from './attributes';

View File

@ -0,0 +1 @@
export * from './options';

View File

@ -0,0 +1,31 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { ProductAttribute } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { AttributeField } from '../attribute-field';
type OptionsProps = {
value: ProductAttribute[];
onChange: ( value: ProductAttribute[] ) => void;
productId?: number;
};
export const Options: React.FC< OptionsProps > = ( {
value,
onChange,
productId,
} ) => {
return (
<AttributeField
attributeType="for-variations"
value={ value }
onChange={ onChange }
productId={ productId }
/>
);
};

View File

@ -18,6 +18,7 @@ import { ProductVariationsSection } from './sections/product-variations-section'
import { ImagesSection } from './sections/images-section'; import { ImagesSection } from './sections/images-section';
import { validate } from './product-validation'; import { validate } from './product-validation';
import { AttributesSection } from './sections/attributes-section'; import { AttributesSection } from './sections/attributes-section';
import { OptionsSection } from './sections/options-section';
import { ProductFormFooter } from './layout/product-form-footer'; import { ProductFormFooter } from './layout/product-form-footer';
import { ProductFormTab } from './product-form-tab'; import { ProductFormTab } from './product-form-tab';
@ -69,6 +70,7 @@ export const ProductForm: React.FC< {
<ProductShippingSection product={ product } /> <ProductShippingSection product={ product } />
</ProductFormTab> </ProductFormTab>
<ProductFormTab name="options" title="Options"> <ProductFormTab name="options" title="Options">
<OptionsSection />
<ProductVariationsSection /> <ProductVariationsSection />
</ProductFormTab> </ProductFormTab>
</ProductFormLayout> </ProductFormLayout>

View File

@ -11,7 +11,7 @@ import { recordEvent } from '@woocommerce/tracks';
*/ */
import './attributes-section.scss'; import './attributes-section.scss';
import { ProductSectionLayout } from '../layout/product-section-layout'; import { ProductSectionLayout } from '../layout/product-section-layout';
import { AttributeField } from '../fields/attribute-field'; import { Attributes } from '../fields/attributes';
export const AttributesSection: React.FC = () => { export const AttributesSection: React.FC = () => {
const { const {
@ -45,8 +45,10 @@ export const AttributesSection: React.FC = () => {
</> </>
} }
> >
<AttributeField <Attributes
{ ...getInputProps( 'attributes', { productId } ) } { ...getInputProps( 'attributes', {
productId,
} ) }
/> />
</ProductSectionLayout> </ProductSectionLayout>
); );

View File

@ -0,0 +1,6 @@
.woocommerce-product-options-section.woocommerce-form-section {
.woocommerce-form-section__content {
padding: 0;
border: 0;
}
}

View File

@ -0,0 +1,55 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Link, useFormContext } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import './options-section.scss';
import { ProductSectionLayout } from '../layout/product-section-layout';
import { Options } from '../fields/options';
export const OptionsSection: React.FC = () => {
const {
getInputProps,
values: { id: productId },
} = useFormContext< Product >();
return (
<ProductSectionLayout
title={ __( 'Options', 'woocommerce' ) }
className="woocommerce-product-options-section"
description={
<>
<span>
{ __(
'Add and manage options, such as size and color, for customers to choose on the product page.',
'woocommerce'
) }
</span>
<Link
className="woocommerce-form-section__header-link"
href="https://woocommerce.com/document/managing-product-taxonomies/#product-attributes"
target="_blank"
type="external"
onClick={ () => {
recordEvent( 'learn_more_about_options_help' );
} }
>
{ __( 'Learn more about options', 'woocommerce' ) }
</Link>
</>
}
>
<Options
{ ...getInputProps( 'attributes', {
productId,
} ) }
/>
</ProductSectionLayout>
);
};

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add Options section to new product experience form.