Add common options to the variations options empty state (#44001)

* Always show Variation options and Variations sections within the Variations tab

* Remove woocommerce/product-variations-fields block since it's not needed anymore

* Create ProductTShirt image for variation options empty state

* Add renderCustomEmptyState to the attribute control component to be able to set a not default empty state

* Render a custom empty state for variation options

* Adds defaultSearch prop to NewAttributeModal so it can be used to start searching right after the modal is shown

* Let the empty state adds an attribute that matches a given text

* Add changelog files

* Fix linter errors
This commit is contained in:
Maikel Perez 2024-02-01 11:18:23 -03:00 committed by GitHub
parent 42a2d00cba
commit 861cc7cc02
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 221 additions and 322 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Create attribute control custom empty state

View File

@ -23,7 +23,6 @@ export { init as initTag } from './product-fields/tag';
export { init as initInventoryQuantity } from './product-fields/inventory-quantity';
export { init as initToggle } from './generic/toggle';
export { init as attributesInit } from './product-fields/attributes';
export { init as initVariations } from './product-fields/variations';
export { init as initRequirePassword } from './product-fields/password';
export { init as initProductDetailsSectionDescription } from './product-fields/product-details-section-description';
export { init as initProductList } from './product-fields/product-list';

View File

@ -3,6 +3,7 @@
*/
import { __ } from '@wordpress/i18n';
import { BlockAttributes } from '@wordpress/blocks';
import { Button } from '@wordpress/components';
import {
createElement,
createInterpolateElement,
@ -26,9 +27,13 @@ import { useEntityProp, useEntityId } from '@wordpress/core-data';
* Internal dependencies
*/
import { useProductAttributes } from '../../../hooks/use-product-attributes';
import { AttributeControl } from '../../../components/attribute-control';
import {
AttributeControl,
AttributeControlEmptyStateProps,
} from '../../../components/attribute-control';
import { useProductVariationsHelper } from '../../../hooks/use-product-variations-helper';
import { ProductEditorBlockEditProps } from '../../../types';
import { ProductTShirt } from './images';
export function Edit( {
attributes: blockAttributes,
@ -113,6 +118,49 @@ export function Edit( {
} ) );
}
function renderCustomEmptyState( {
addAttribute,
}: AttributeControlEmptyStateProps ) {
return (
<div className="wp-block-woocommerce-product-variations-options-field__empty-state">
<div className="wp-block-woocommerce-product-variations-options-field__empty-state-image">
<ProductTShirt className="wp-block-woocommerce-product-variations-options-field__empty-state-image-product" />
<ProductTShirt className="wp-block-woocommerce-product-variations-options-field__empty-state-image-product" />
<ProductTShirt className="wp-block-woocommerce-product-variations-options-field__empty-state-image-product" />
</div>
<p className="wp-block-woocommerce-product-variations-options-field__empty-state-description">
{ __(
'Sell your product in multiple variations like size or color.',
'woocommerce'
) }
</p>
<div className="wp-block-woocommerce-product-variations-options-field__empty-state-actions">
<Button variant="primary" onClick={ () => addAttribute() }>
{ __( 'Add options', 'woocommerce' ) }
</Button>
<Button
variant="secondary"
onClick={ () =>
addAttribute( __( 'Size', 'woocommerce' ) )
}
>
{ __( 'Add sizes', 'woocommerce' ) }
</Button>
<Button
variant="secondary"
onClick={ () =>
addAttribute( __( 'Color', 'woocommerce' ) )
}
>
{ __( 'Add colors', 'woocommerce' ) }
</Button>
</div>
</div>
);
}
return (
<div { ...blockProps }>
<AttributeControl
@ -160,6 +208,7 @@ export function Edit( {
'product_remove_option_confirmation_cancel_click'
)
}
renderCustomEmptyState={ renderCustomEmptyState }
disabledAttributeIds={ entityAttributes
.filter( ( attr ) => ! attr.variation )
.map( ( attr ) => attr.id ) }

View File

@ -1,4 +1,45 @@
.wp-block-woocommerce-product-variations-options-field {
&__empty-state {
border: 1px dashed $gray-400;
border-radius: 2px;
padding: $grid-unit-60 $grid-unit-20;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $grid-unit-30;
&-image {
display: inline-flex;
gap: $grid-unit-05;
&-product {
height: 66px;
aspect-ratio: 1 / 1;
color: $gray-100;
&:first-child {
height: $grid-unit-70;
color: $gray-200;
}
&:last-child {
height: 50px;
color: $gray-300;
}
}
}
&-description {
margin: 0;
}
&-actions {
display: inline-flex;
gap: 12px;
}
}
.woocommerce-sortable {
padding: 0;
}

View File

@ -0,0 +1,41 @@
/**
* External dependencies
*/
import { useInstanceId } from '@wordpress/compose';
import { createElement } from '@wordpress/element';
export function ProductTShirt( props: React.SVGProps< SVGSVGElement > ) {
const clipPathId = useInstanceId( ProductTShirt, 'clip-path' ) as string;
return (
<svg
{ ...props }
viewBox="0 0 56 56"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
focusable={ false }
>
<g clipPath={ `url(#${ clipPathId })` }>
<path
d="M18.7261 9.37008H26.8168V5.47626H28.4106C29.4938 5.47626 29.9499 4.92889 29.9499 3.91198C29.9499 2.89508 29.4938 2.34771 28.4106 2.34771C27.8689 2.34771 25.6325 2.32955 25.6325 2.32955V0L28.9263 0.0181591C31.2664 0.0181591 32.6244 1.59022 32.6244 3.91198C32.6244 6.23375 31.339 7.72539 29.1206 7.811V9.37008H37.2761C37.2761 9.37008 46.6289 13.7438 46.6289 14.0136H9.31112C9.31112 13.7438 18.7287 9.37008 18.7287 9.37008H18.7261Z"
fill="#F0F0F0"
/>
<path
d="M0 21.0152C0 21.0152 9.19987 12.1613 10.6356 11.0484C11.8717 10.0912 13.3826 9.34668 16.3213 9.34668H18.7263C19.0943 14.2315 23.023 18.076 28.0013 18.076C32.9796 18.076 36.9083 14.2315 37.2763 9.34668H39.6812C42.62 9.34668 44.1309 10.0886 45.367 11.0484C46.8001 12.1613 56 21.0152 56 21.0152L52.8202 30.3541H44.3822L44.39 56.0025H11.6074L11.6152 30.3541H3.17719L-0.00259399 21.0152H0Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id={ clipPathId }>
<rect
width="56"
height="56"
fill="white"
transform="matrix(-1 0 0 1 56 0)"
/>
</clipPath>
</defs>
</svg>
);
}

View File

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

View File

@ -1,26 +0,0 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "woocommerce/product-variations-fields",
"title": "Product variations fields",
"category": "woocommerce",
"description": "The product variations.",
"keywords": [ "products", "variations" ],
"textdomain": "default",
"attributes": {
"description": {
"type": "string",
"__experimentalRole": "content"
}
},
"supports": {
"align": false,
"html": false,
"multiple": false,
"reusable": false,
"inserter": false,
"lock": false,
"__experimentalToolbar": false
},
"editorStyle": "file:./editor.css"
}

View File

@ -1,164 +0,0 @@
/**
* External dependencies
*/
import classNames from 'classnames';
import { Button } from '@wordpress/components';
import { useWooBlockProps } from '@woocommerce/block-templates';
import { Product, ProductAttribute } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { createElement, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import {
// @ts-expect-error no exported member.
useInnerBlocksProps,
} from '@wordpress/block-editor';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore No types for this exist yet.
// eslint-disable-next-line @woocommerce/dependency-group
import { useEntityProp, useEntityId } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import { sanitizeHTML } from '../../../utils/sanitize-html';
import { VariationsBlockAttributes } from './types';
import { EmptyVariationsImage } from '../../../images/empty-variations-image';
import { NewAttributeModal } from '../../../components/attribute-control/new-attribute-modal';
import {
EnhancedProductAttribute,
useProductAttributes,
} from '../../../hooks/use-product-attributes';
import { getAttributeId } from '../../../components/attribute-control/utils';
import { useProductVariationsHelper } from '../../../hooks/use-product-variations-helper';
import { hasAttributesUsedForVariations } from '../../../utils';
import { TRACKS_SOURCE } from '../../../constants';
import { ProductEditorBlockEditProps } from '../../../types';
export function Edit( {
attributes,
}: ProductEditorBlockEditProps< VariationsBlockAttributes > ) {
const { description } = attributes;
const { generateProductVariations } = useProductVariationsHelper();
const [ isNewModalVisible, setIsNewModalVisible ] = useState( false );
const [ productAttributes, setProductAttributes ] = useEntityProp<
Product[ 'attributes' ]
>( 'postType', 'product', 'attributes' );
const [ , setDefaultProductAttributes ] = useEntityProp<
Product[ 'default_attributes' ]
>( 'postType', 'product', 'default_attributes' );
const { attributes: variationOptions, handleChange } = useProductAttributes(
{
allAttributes: productAttributes,
isVariationAttributes: true,
productId: useEntityId( 'postType', 'product' ),
onChange( values, defaultAttributes ) {
setProductAttributes( values );
setDefaultProductAttributes( defaultAttributes );
generateProductVariations( values, defaultAttributes );
},
}
);
const hasAttributes = hasAttributesUsedForVariations( productAttributes );
const blockProps = useWooBlockProps( attributes, {
className: classNames( {
'wp-block-woocommerce-product-variations-fields--has-attributes':
hasAttributes,
} ),
} );
const innerBlockProps = useInnerBlocksProps(
{
className:
'wp-block-woocommerce-product-variations-fields__content',
},
{ templateLock: 'all' }
);
const openNewModal = () => {
setIsNewModalVisible( true );
recordEvent( 'product_options_add_first_option' );
};
const closeNewModal = () => {
setIsNewModalVisible( false );
};
const handleAdd = ( newOptions: EnhancedProductAttribute[] ) => {
const addedAttributesOnly = newOptions.filter(
( newAttr ) =>
! variationOptions.some(
( attr: ProductAttribute ) =>
getAttributeId( newAttr ) === getAttributeId( attr )
)
);
recordEvent( 'product_options_add', {
source: TRACKS_SOURCE,
options: addedAttributesOnly.map( ( attribute ) => ( {
attribute: attribute.name,
values: attribute.options,
} ) ),
} );
handleChange( addedAttributesOnly );
closeNewModal();
};
return (
<div { ...blockProps }>
<div className="wp-block-woocommerce-product-variations-fields__heading">
<div className="wp-block-woocommerce-product-variations-fields__heading-image-container">
<EmptyVariationsImage />
</div>
<p
className="wp-block-woocommerce-product-variations-fields__heading-description"
dangerouslySetInnerHTML={ sanitizeHTML( description ) }
/>
<div className="wp-block-woocommerce-product-variations-fields__heading-actions">
<Button variant="primary" onClick={ openNewModal }>
{ __( 'Add variation options', 'woocommerce' ) }
</Button>
</div>
</div>
<div { ...innerBlockProps } />
{ isNewModalVisible && (
<NewAttributeModal
title={ __( 'Add variation options', 'woocommerce' ) }
description={ __(
'Select from existing attributes or create new ones to add new variations for your product. You can change the order later.',
'woocommerce'
) }
createNewAttributesAsGlobal={ true }
notice={ '' }
onCancel={ () => {
recordEvent(
'product_options_modal_cancel_button_click'
);
closeNewModal();
} }
onAdd={ handleAdd }
onAddAnother={ () => {
recordEvent(
'product_add_options_modal_add_another_option_button_click'
);
} }
onRemoveItem={ () => {
recordEvent(
'product_add_options_modal_remove_option_button_click'
);
} }
selectedAttributeIds={ variationOptions.map(
( attr ) => attr.id
) }
disabledAttributeIds={ productAttributes
.filter( ( attr ) => ! attr.variation )
.map( ( attr ) => attr.id ) }
termsAutoSelection="all"
/>
) }
</div>
);
}

View File

@ -1,31 +0,0 @@
.wp-block-woocommerce-product-variations-fields {
margin-top: $grid-unit-80;
&--has-attributes {
.wp-block-woocommerce-product-variations-fields__heading {
display: none;
}
.wp-block-woocommerce-product-variations-fields__content {
display: block;
}
}
&__heading {
text-align: center;
}
&__heading-image-container {
padding-top: $grid-unit-60;
padding-bottom: $grid-unit-60;
}
&__heading-description {
margin: 0;
padding-bottom: $grid-unit-30;
}
&__content {
display: none;
}
}

View File

@ -1,23 +0,0 @@
/**
* Internal dependencies
*/
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { registerProductEditorBlockType } from '../../../utils';
const { name, ...metadata } = blockConfiguration;
export { metadata, name };
export const settings = {
example: {},
edit: Edit,
};
export function init() {
return registerProductEditorBlockType( {
name,
metadata: metadata as never,
settings: settings as never,
} );
}

View File

@ -1,8 +0,0 @@
/**
* External dependencies
*/
import { BlockAttributes } from '@wordpress/blocks';
export interface VariationsBlockAttributes extends BlockAttributes {
description: string;
}

View File

@ -15,7 +15,6 @@
@import "product-fields/shipping-dimensions/editor.scss";
@import "product-fields/summary/editor.scss";
@import "generic/tab/editor.scss";
@import "product-fields/variations/editor.scss";
@import "product-fields/password/editor.scss";
@import "product-fields/product-list/editor.scss";
@import "product-fields/product-details-section-description/editor.scss";

View File

@ -35,43 +35,7 @@ import { RemoveConfirmationModal } from '../remove-confirmation-modal';
import { TRACKS_SOURCE } from '../../constants';
import { AttributeEmptyStateSkeleton } from './attribute-empty-state-skeleton';
import { SectionActions } from '../block-slot-fill';
type AttributeControlProps = {
value: EnhancedProductAttribute[];
onAdd?: ( attribute: EnhancedProductAttribute[] ) => void;
onAddAnother?: () => void;
onRemoveItem?: () => void;
onChange: ( value: ProductAttribute[] ) => void;
onEdit?: ( attribute: ProductAttribute ) => void;
onRemove?: ( attribute: ProductAttribute ) => void;
onRemoveCancel?: ( attribute: ProductAttribute ) => void;
onNewModalCancel?: () => void;
onNewModalClose?: () => void;
onNewModalOpen?: () => void;
onEditModalCancel?: ( attribute?: ProductAttribute ) => void;
onEditModalClose?: ( attribute?: ProductAttribute ) => void;
onEditModalOpen?: ( attribute?: ProductAttribute ) => void;
onNoticeDismiss?: () => void;
createNewAttributesAsGlobal?: boolean;
useRemoveConfirmationModal?: boolean;
disabledAttributeIds?: number[];
termsAutoSelection?: 'first' | 'all';
defaultVisibility?: boolean;
uiStrings?: {
notice?: string | React.ReactElement;
emptyStateSubtitle?: string;
newAttributeListItemLabel?: string;
newAttributeModalTitle?: string;
newAttributeModalDescription?: string | React.ReactElement;
newAttributeModalNotice?: string;
customAttributeHelperMessage?: string;
attributeRemoveLabel?: string;
attributeRemoveConfirmationMessage?: string;
attributeRemoveConfirmationModalMessage?: string;
globalAttributeHelperMessage?: string;
disabledAttributeMessage?: string;
};
};
import { AttributeControlProps } from './types';
export const AttributeControl: React.FC< AttributeControlProps > = ( {
value,
@ -89,6 +53,7 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
onRemove = () => {},
onRemoveCancel = () => {},
onNoticeDismiss = () => {},
renderCustomEmptyState = () => <AttributeEmptyStateSkeleton />,
uiStrings,
createNewAttributesAsGlobal = false,
useRemoveConfirmationModal = false,
@ -109,6 +74,8 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
...uiStrings,
};
const [ isNewModalVisible, setIsNewModalVisible ] = useState( false );
const [ defaultAttributeSearch, setDefaultAttributeSearch ] =
useState< string >();
const [ removingAttribute, setRemovingAttribute ] =
useState< null | ProductAttribute >();
const [ currentAttributeId, setCurrentAttributeId ] = useState<
@ -161,6 +128,7 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
const closeNewModal = () => {
setIsNewModalVisible( false );
setDefaultAttributeSearch( undefined );
onNewModalClose();
};
@ -311,6 +279,7 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
}
termsAutoSelection={ termsAutoSelection }
defaultVisibility={ defaultVisibility }
defaultSearch={ defaultAttributeSearch }
/>
) }
<SelectControlMenuSlot />
@ -376,9 +345,14 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
} }
/>
) }
{ ! isMobileViewport && value.length === 0 && (
<AttributeEmptyStateSkeleton />
) }
{ ! isMobileViewport &&
value.length === 0 &&
renderCustomEmptyState( {
addAttribute( search ) {
setDefaultAttributeSearch( search );
openNewModal();
},
} ) }
</div>
);
};

View File

@ -1 +1,2 @@
export * from './attribute-control';
export * from './types';

View File

@ -51,6 +51,7 @@ type NewAttributeModalProps = {
disabledAttributeMessage?: string;
termsAutoSelection?: 'first' | 'all';
defaultVisibility?: boolean;
defaultSearch?: string;
};
type AttributeForm = {
@ -84,6 +85,7 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
),
termsAutoSelection,
defaultVisibility = false,
defaultSearch,
} ) => {
const scrollAttributeIntoView = ( index: number ) => {
setTimeout( () => {
@ -202,11 +204,15 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
return () => clearTimeout( timeoutId );
}, [] );
const initialAttribute = {
name: defaultSearch,
} as EnhancedProductAttribute;
return (
<>
<Form< AttributeForm >
initialValues={ {
attributes: [ null ],
attributes: [ defaultSearch ? initialAttribute : null ],
} }
>
{ ( {

View File

@ -0,0 +1,51 @@
/**
* External dependencies
*/
import { ProductAttribute } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { EnhancedProductAttribute } from '../../hooks/use-product-attributes';
export type AttributeControlEmptyStateProps = {
addAttribute: ( search?: string ) => void;
};
export type AttributeControlProps = {
value: EnhancedProductAttribute[];
onAdd?: ( attribute: EnhancedProductAttribute[] ) => void;
onAddAnother?: () => void;
onRemoveItem?: () => void;
onChange: ( value: ProductAttribute[] ) => void;
onEdit?: ( attribute: ProductAttribute ) => void;
onRemove?: ( attribute: ProductAttribute ) => void;
onRemoveCancel?: ( attribute: ProductAttribute ) => void;
onNewModalCancel?: () => void;
onNewModalClose?: () => void;
onNewModalOpen?: () => void;
onEditModalCancel?: ( attribute?: ProductAttribute ) => void;
onEditModalClose?: ( attribute?: ProductAttribute ) => void;
onEditModalOpen?: ( attribute?: ProductAttribute ) => void;
onNoticeDismiss?: () => void;
renderCustomEmptyState?: ( props: AttributeControlEmptyStateProps ) => void;
createNewAttributesAsGlobal?: boolean;
useRemoveConfirmationModal?: boolean;
disabledAttributeIds?: number[];
termsAutoSelection?: 'first' | 'all';
defaultVisibility?: boolean;
uiStrings?: {
notice?: string | React.ReactElement;
emptyStateSubtitle?: string;
newAttributeListItemLabel?: string;
newAttributeModalTitle?: string;
newAttributeModalDescription?: string | React.ReactElement;
newAttributeModalNotice?: string;
customAttributeHelperMessage?: string;
attributeRemoveLabel?: string;
attributeRemoveConfirmationMessage?: string;
attributeRemoveConfirmationModalMessage?: string;
globalAttributeHelperMessage?: string;
disabledAttributeMessage?: string;
};
};

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Always show the variation options and variations section

View File

@ -61,7 +61,6 @@ class BlockRegistry {
'woocommerce/product-tag-field',
'woocommerce/product-inventory-quantity-field',
'woocommerce/product-variation-items-field',
'woocommerce/product-variations-fields',
'woocommerce/product-password-field',
'woocommerce/product-list-field',
'woocommerce/product-has-variations-notice',

View File

@ -1083,51 +1083,34 @@ class SimpleProductTemplate extends AbstractProductFormTemplate implements Produ
if ( ! $variation_group ) {
return;
}
$variation_fields = $variation_group->add_block(
array(
'id' => 'product_variation-field-group',
'blockName' => 'woocommerce/product-variations-fields',
'order' => 10,
'attributes' => array(
'description' => sprintf(
/* translators: %1$s: Sell your product in multiple variations like size or color. strong opening tag. %2$s: Sell your product in multiple variations like size or color. strong closing tag.*/
__( '%1$sSell your product in multiple variations like size or color.%2$s Get started by adding options for the buyers to choose on the product page.', 'woocommerce' ),
'<strong>',
'</strong>'
),
),
)
);
$variation_options_section = $variation_fields->add_block(
$variation_group->add_section(
array(
'id' => 'product-variation-options-section',
'blockName' => 'woocommerce/product-section',
'order' => 10,
'attributes' => array(
'title' => __( 'Variation options', 'woocommerce' ),
'description' => __( 'Add and manage attributes used for product options, such as size and color.', 'woocommerce' ),
),
)
);
$variation_options_section->add_block(
)->add_block(
array(
'id' => 'product-variation-options',
'blockName' => 'woocommerce/product-variations-options-field',
'order' => 10,
)
);
$variation_section = $variation_fields->add_block(
$variation_group->add_section(
array(
'id' => 'product-variation-section',
'blockName' => 'woocommerce/product-section',
'order' => 20,
'attributes' => array(
'title' => __( 'Variations', 'woocommerce' ),
'description' => __( 'Manage individual product combinations created from options.', 'woocommerce' ),
),
)
);
$variation_section->add_block(
)->add_block(
array(
'id' => 'product-variation-items',
'blockName' => 'woocommerce/product-variation-items-field',

View File

@ -56,7 +56,6 @@ class BlockRegistryTest extends WC_Unit_Test_Case {
$this->assertTrue( $block_registry->is_registered( 'woocommerce/product-tag-field' ), 'Tag field not registered.' );
$this->assertTrue( $block_registry->is_registered( 'woocommerce/product-inventory-quantity-field' ), 'Inventory quantity field not registered.' );
$this->assertTrue( $block_registry->is_registered( 'woocommerce/product-variation-items-field' ), 'Variation items field not registered.' );
$this->assertTrue( $block_registry->is_registered( 'woocommerce/product-variations-fields' ), 'Variation fields not registered.' );
$this->assertTrue( $block_registry->is_registered( 'woocommerce/product-password-field', 'Password field not registered.' ) );
$this->assertTrue( $block_registry->is_registered( 'woocommerce/product-list-field', 'List field not registered.' ) );
$this->assertTrue( $block_registry->is_registered( 'woocommerce/product-has-variations-notice', 'Has variation notice not registered.' ) );