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
*/
import { __ } from '@wordpress/i18n';
import { __, sprintf } from '@wordpress/i18n';
import { useState, useCallback, useEffect } from '@wordpress/element';
import {
ProductAttribute,
@ -12,8 +12,11 @@ import { resolveSelect } from '@wordpress/data';
import {
Sortable,
__experimentalSelectControlMenuSlot as SelectControlMenuSlot,
Link,
} from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
import interpolateComponents from '@automattic/interpolate-components';
import { getAdminLink } from '@woocommerce/settings';
/**
* Internal dependencies
@ -33,23 +36,24 @@ type AttributeFieldProps = {
value: ProductAttribute[];
onChange: ( value: ProductAttribute[] ) => void;
productId?: number;
// TODO: should we support an 'any' option to show all attributes?
attributeType?: 'regular' | 'for-variations';
};
export type HydratedAttributeType = Omit< ProductAttribute, 'options' > & {
options?: string[];
terms?: ProductAttributeTerm[];
visible?: boolean;
};
export const AttributeField: React.FC< AttributeFieldProps > = ( {
value,
onChange,
productId,
attributeType = 'regular',
} ) => {
const [ showAddAttributeModal, setShowAddAttributeModal ] =
useState( false );
const [ hydrationComplete, setHydrationComplete ] = useState< boolean >(
value ? false : true
);
const [ hydratedAttributes, setHydratedAttributes ] = useState<
HydratedAttributeType[]
>( [] );
@ -57,8 +61,13 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
null | string
>( null );
const CANCEL_BUTTON_EVENT_NAME =
'product_add_attributes_modal_cancel_button_click';
const isOnlyForVariations = attributeType === 'for-variations';
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(
( attributeId: number ) => {
@ -82,7 +91,19 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
);
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;
}
@ -94,19 +115,26 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
).then( ( allResults ) => {
setHydratedAttributes( [
...globalAttributes.map( ( attr, index ) => {
const fetchedTerms = allResults[ index ];
const newAttr = {
...attr,
terms: allResults[ index ],
options: undefined,
// I'm not sure this is quite right for handling unpersisted terms,
// but this gets things kinda working for now
terms:
fetchedTerms.length > 0 ? fetchedTerms : undefined,
options:
fetchedTerms.length === 0
? attr.options
: undefined,
};
return newAttr;
} ),
...customAttributes,
] );
setHydrationComplete( true );
} );
}, [ productId, value, hydrationComplete ] );
}, [ fetchTerms, productId, value ] );
const fetchAttributeId = ( attribute: { id: number; name: string } ) =>
`${ attribute.id }-${ attribute.name }`;
@ -121,6 +149,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
? attr.terms.map( ( term ) => term.name )
: ( attr.options as string[] ),
terms: undefined,
visible: attr.visible || false,
};
} )
);
@ -157,24 +186,48 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
)
)
.map( ( newAttr, index ) => {
newAttr.position = ( value || [] ).length + index;
return newAttr;
return {
...newAttributeProps,
...newAttr,
position: ( value || [] ).length + index,
};
} ),
] );
recordEvent( 'product_add_attributes_modal_add_button_click' );
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 (
<>
<AttributeEmptyState
addNewLabel={
isOnlyForVariations
? __( 'Add options', 'woocommerce' )
: undefined
}
onNewClick={ () => {
recordEvent(
'product_add_first_attribute_button_click'
);
setShowAddAttributeModal( true );
} }
subtitle={
isOnlyForVariations
? __( 'No options yet', 'woocommerce' )
: undefined
}
/>
{ showAddAttributeModal && (
<AddAttributeModal
@ -183,7 +236,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
setShowAddAttributeModal( false );
} }
onAdd={ onAddNewAttributes }
selectedAttributeIds={ ( value || [] ).map(
selectedAttributeIds={ ( filteredAttributes || [] ).map(
( attr ) => attr.id
) }
/>
@ -193,8 +246,10 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
);
}
const sortedAttributes = value.sort( ( a, b ) => a.position - b.position );
const attributeKeyValues = value.reduce(
const sortedAttributes = filteredAttributes.sort(
( a, b ) => a.position - b.position
);
const attributeKeyValues = filteredAttributes.reduce(
(
keyValue: Record< number, ProductAttribute >,
attribute: ProductAttribute
@ -205,6 +260,20 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
{} 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 (
<div className="woocommerce-attribute-field">
<Sortable
@ -217,27 +286,39 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
);
} }
>
{ sortedAttributes.map( ( attribute ) => (
{ sortedAttributes.map( ( attr ) => (
<AttributeListItem
attribute={ attribute }
key={ fetchAttributeId( attribute ) }
attribute={ attr }
key={ fetchAttributeId( attr ) }
onEditClick={ () =>
setEditingAttributeId(
fetchAttributeId( attribute )
)
setEditingAttributeId( fetchAttributeId( attr ) )
}
onRemoveClick={ () => onRemove( attribute ) }
onRemoveClick={ () => onRemove( attr ) }
/>
) ) }
</Sortable>
<AddAttributeListItem
label={
isOnlyForVariations
? __( 'Add option', 'woocommerce' )
: undefined
}
onAddClick={ () => {
recordEvent( 'product_add_attribute_button' );
recordEvent(
isOnlyForVariations
? 'product_add_option_button'
: 'product_add_attribute_button'
);
setShowAddAttributeModal( true );
} }
/>
{ showAddAttributeModal && (
<AddAttributeModal
title={
isOnlyForVariations
? __( 'Add options', 'woocommerce' )
: undefined
}
onCancel={ () => {
recordEvent( CANCEL_BUTTON_EVENT_NAME );
setShowAddAttributeModal( false );
@ -249,6 +330,29 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
<SelectControlMenuSlot />
{ editingAttributeId && (
<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 ) }
onEdit={ ( changedAttribute ) => {
const newAttributesSet = [ ...hydratedAttributes ];
@ -266,12 +370,7 @@ export const AttributeField: React.FC< AttributeFieldProps > = ( {
updateAttributes( newAttributesSet );
setEditingAttributeId( null );
} }
attribute={
hydratedAttributes.find(
( attr ) =>
fetchAttributeId( attr ) === editingAttributeId
) as HydratedAttributeType
}
attribute={ attribute }
/>
) }
</div>

View File

@ -9,12 +9,7 @@ import {
TextControl,
} from '@wordpress/components';
import { useState } from '@wordpress/element';
import {
__experimentalTooltip as Tooltip,
Link,
} from '@woocommerce/components';
import interpolateComponents from '@automattic/interpolate-components';
import { getAdminLink } from '@woocommerce/settings';
import { __experimentalTooltip as Tooltip } from '@woocommerce/components';
/**
* Internal dependencies
@ -30,14 +25,12 @@ import './edit-attribute-modal.scss';
type EditAttributeModalProps = {
title?: string;
nameLabel?: string;
globalAttributeHelperMessage?: string;
globalAttributeHelperMessage?: JSX.Element;
customAttributeHelperMessage?: string;
termsLabel?: string;
termsPlaceholder?: string;
visibleLabel?: string;
visibleTooltip?: string;
filtersLabel?: string;
filtersTooltip?: string;
cancelAccessibleLabel?: string;
cancelLabel?: string;
updateAccessibleLabel?: string;
@ -50,25 +43,7 @@ type EditAttributeModalProps = {
export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
title = __( 'Edit attribute', 'woocommerce' ),
nameLabel = __( 'Name', 'woocommerce' ),
globalAttributeHelperMessage = interpolateComponents( {
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>
),
},
} ),
globalAttributeHelperMessage,
customAttributeHelperMessage = __(
'Your customers will see this on the product page',
'woocommerce'
@ -80,11 +55,6 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
'Show or hide this attribute on the product page',
'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' ),
cancelLabel = __( 'Cancel', 'woocommerce' ),
updateAccessibleLabel = __( 'Edit attribute', 'woocommerce' ),
@ -165,19 +135,6 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
/>
<Tooltip text={ visibleTooltip } />
</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 className="woocommerce-add-attribute-modal__buttons">
<Button

View File

@ -26,7 +26,7 @@ const attributeList: ProductAttribute[] = [
visible: true,
variation: true,
options: [
'Beige',
'beige',
'black',
'Blue',
'brown',
@ -134,23 +134,24 @@ describe( 'AttributeField', () => {
await screen.findByText( attributeList[ 0 ].name )
).toBeInTheDocument();
expect(
await screen.findByText( attributeList[ 1 ].name )
).toBeInTheDocument();
await screen.queryByText( attributeList[ 1 ].name )
).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( () => {
render(
<AttributeField
value={ [ ...attributeList ] }
onChange={ () => {} }
attributeType="for-variations"
/>
);
} );
expect(
await screen.findByText( attributeList[ 0 ].options[ 0 ] )
).toBeInTheDocument();
await screen.queryByText( attributeList[ 0 ].options[ 0 ] )
).not.toBeInTheDocument();
expect(
await screen.findByText( attributeList[ 1 ].options[ 0 ] )
).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 { validate } from './product-validation';
import { AttributesSection } from './sections/attributes-section';
import { OptionsSection } from './sections/options-section';
import { ProductFormFooter } from './layout/product-form-footer';
import { ProductFormTab } from './product-form-tab';
@ -69,6 +70,7 @@ export const ProductForm: React.FC< {
<ProductShippingSection product={ product } />
</ProductFormTab>
<ProductFormTab name="options" title="Options">
<OptionsSection />
<ProductVariationsSection />
</ProductFormTab>
</ProductFormLayout>

View File

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