Product Collection - Show product picker in Editor when collection requires a product but it doesn't exist (#50164)

* Show product picker control in the editor when a product context is required but not provided

Enhanced the Product Collection block by introducing the `selectedReference` attribute and implementing a product picker control. This control appears in the editor when a product context is required but not provided in the current template/page/post.

1. **block.json**: Added `selectedReference` attribute of type `object`.
2. **constants.ts**: Included `selectedReference` in the `queryContextIncludes` array.
3. **EditorProductPicker.tsx**: Created a new component for selecting products within the editor.
4. **editor.scss**: Added styles for the new Editor Product Picker component.
5. **index.tsx**: Updated logic to determine the component to render, incorporating the new Editor Product Picker.
6. **types.ts**: Defined types for `selectedReference` and updated `ProductCollectionAttributes` interface.

This enhancement allows merchants to manually select a product for collections that require a product context, ensuring the block displays correctly even when the product context is not available in the current template/page/post.

- **Product Picker Control**: Utilizes the existing `ProductControl` component for selecting products. This component is displayed in the editor when a collection requires a product context but it doesn't exist in the current template/page/post.

* Update label on ProductControl component

* Implement dynamic UI state management for product collection block

- Introduced `ProductCollectionUIStatesInEditor` enum to define various UI states for the product collection block.
- Added `getProductCollectionUIStateInEditor` utility function to determine the appropriate UI state based on context.
- Updated `Edit` component to use `getProductCollectionUIStateInEditor` for dynamic state management.
- Refactored `ProductCollectionContent` to utilize the new Editor UI state management.

* Fix: Product picker isn't showing

* Fix: Preview label state isn't showing

* Add changefile(s) from automation for the following project(s): woocommerce-blocks

* Refactor WooCommerceBlockLocation type

- Introduced specific interfaces for WooCommerceBlockLocation, including ProductLocation, ArchiveLocation, CartLocation, OrderLocation, and SiteLocation, to improve type safety and code clarity.
- Updated createLocationObject function to return a BaseLocation type.
- Refactored useSetPreviewState hook in product-collection utils:
  - Extracted termId from location.sourceData for cleaner and more readable code.
  - Replaced direct access of location.sourceData?.termId with termId variable.

* Remove fallback to 0 in case there may be a product with id 0

* Use optional chaining to avoid undefined errors

* Rename  to

* Change order of arguments in  function

* Pass boolean prop instead of making further recognition of the UI state in ProductCollectionContent

* Destructure  props in  component

* Rename  to

* Update names in  enum

* Rename  to  and change the structure to single number.

* Rename location to

* Add a method to choose a product in the product picker in Editor

* Add E2E tests

* Fix failing e2e tests by changing location to productCollectionLocation

* Add changefile(s) from automation for the following project(s): woocommerce-blocks

* Don't allow selecting product variations

* Minor code refactoring

* Fix: Product control isn't showing products

**Before**
```tsx
const getRenderItemFunc = () => {
		if ( renderItem ) {
			return renderItem;
		} else if ( showVariations ) {
			return renderItemWithVariations;
		}
    return () => null;
	};
```

As you can see above, `return () => null;` is returning a function which is causing the issue. This will render nothing in the list. I changed this to `return undefined;`. This way, we will use default render item function.

* Translate text in ProductPicker component

* Improve E2E test

* Use createInterpolateElement to safely render strong HTML tag

* Fix E2E tests

* Fix E2E tests

* Product Collection: Inspector control to change selected product (#50590)

* Add Linked Product Control to Product Collection Block Inspector Controls

- Introduced a new `LinkedProductControl` component in the Product Collection block's Inspector Controls.
- This control allows users to link a specific product to the product collection via a dropdown with a search capability.
- Added corresponding styles to `editor.scss`.
- Integrated a `useGetProduct` hook in the `utils.tsx` to fetch and manage the state of the linked product data, including handling loading states and errors.
- Updated the Inspector Controls to include the new Linked Product Control component, enhancing the block's customization options for users.

* Add E2E tests

* Hide product picker when product context is available

* Improve logic to hide Linked Product Control

* Add changefile(s) from automation for the following project(s): woocommerce-blocks

* Remove hasError state from useGetProduct hook

* Rename isShowLinkedProductControl to showLinkedProductControl

* Convert jsxProductButton to ProductButton component

* Refactor jsxPopoverContent to LinkedProductPopoverContent component

* Improve UI of Linked Product Control

* Address PR feedback

* Fix E2E tests

---------

Co-authored-by: github-actions <github-actions@github.com>

* Rename isUsesReferencePreviewMode to isUsingReferencePreviewMode

* Change order of conditions in getProductCollectionUIStateInEditor

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Manish Menaria 2024-09-02 12:39:33 +05:30 committed by GitHub
parent 36ede651db
commit 2433664aa8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 953 additions and 86 deletions

View File

@ -0,0 +1,81 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
import { Icon, info } from '@wordpress/icons';
import ProductControl from '@woocommerce/editor-components/product-control';
import type { SelectedOption } from '@woocommerce/block-hocs';
import { createInterpolateElement } from '@wordpress/element';
import {
Placeholder,
// @ts-expect-error Using experimental features
__experimentalHStack as HStack,
// @ts-expect-error Using experimental features
__experimentalText as Text,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import type { ProductCollectionEditComponentProps } from '../types';
import { getCollectionByName } from '../collections';
const ProductPicker = ( props: ProductCollectionEditComponentProps ) => {
const blockProps = useBlockProps();
const attributes = props.attributes;
const collection = getCollectionByName( attributes.collection );
if ( ! collection ) {
return;
}
return (
<div { ...blockProps }>
<Placeholder className="wc-blocks-product-collection__editor-product-picker">
<HStack alignment="center">
<Icon
icon={ info }
className="wc-blocks-product-collection__info-icon"
/>
<Text>
{ createInterpolateElement(
sprintf(
/* translators: %s: collection title */
__(
'<strong>%s</strong> requires a product to be selected in order to display associated items.',
'woocommerce'
),
collection.title
),
{
strong: <strong />,
}
) }
</Text>
</HStack>
<ProductControl
selected={
attributes.query?.productReference as SelectedOption
}
onChange={ ( value = [] ) => {
const isValidId = ( value[ 0 ]?.id ?? null ) !== null;
if ( isValidId ) {
props.setAttributes( {
query: {
...attributes.query,
productReference: value[ 0 ].id,
},
} );
}
} }
messages={ {
search: __( 'Select a product', 'woocommerce' ),
} }
/>
</Placeholder>
</div>
);
};
export default ProductPicker;

View File

@ -168,3 +168,49 @@ $max-button-width: calc(100% / #{$max-button-columns});
color: var(--wp-components-color-accent-inverted, #fff);
}
}
// Editor Product Picker
.wc-blocks-product-collection__editor-product-picker {
.wc-blocks-product-collection__info-icon {
fill: var(--wp--preset--color--luminous-vivid-orange, #e26f56);
}
}
// Linked Product Control
.wc-block-product-collection-linked-product-control {
width: 100%;
text-align: left;
&__button {
width: 100%;
height: 100%;
padding: 10px;
border: 1px solid $gray-300;
}
&__image-container {
flex-shrink: 0;
width: 45px;
height: 45px;
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__content {
text-align: left;
}
}
.wc-block-product-collection-linked-product__popover-content .components-popover__content {
width: 100%;
.woocommerce-search-list__search {
border: 0;
padding: 0;
}
}

View File

@ -4,18 +4,25 @@
import { store as blockEditorStore } from '@wordpress/block-editor';
import { useState } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { useGetLocation } from '@woocommerce/blocks/product-template/utils';
/**
* Internal dependencies
*/
import type { ProductCollectionEditComponentProps } from '../types';
import {
ProductCollectionEditComponentProps,
ProductCollectionUIStatesInEditor,
} from '../types';
import ProductCollectionPlaceholder from './product-collection-placeholder';
import ProductCollectionContent from './product-collection-content';
import CollectionSelectionModal from './collection-selection-modal';
import './editor.scss';
import { getProductCollectionUIStateInEditor } from '../utils';
import ProductPicker from './ProductPicker';
const Edit = ( props: ProductCollectionEditComponentProps ) => {
const { clientId, attributes } = props;
const location = useGetLocation( props.context, props.clientId );
const [ isSelectionModalOpen, setIsSelectionModalOpen ] = useState( false );
const hasInnerBlocks = useSelect(
@ -24,9 +31,37 @@ const Edit = ( props: ProductCollectionEditComponentProps ) => {
[ clientId ]
);
const Component = hasInnerBlocks
? ProductCollectionContent
: ProductCollectionPlaceholder;
const productCollectionUIStateInEditor =
getProductCollectionUIStateInEditor( {
hasInnerBlocks,
location,
attributes: props.attributes,
usesReference: props.usesReference,
} );
/**
* Component to render based on the UI state.
*/
let Component,
isUsingReferencePreviewMode = false;
switch ( productCollectionUIStateInEditor ) {
case ProductCollectionUIStatesInEditor.COLLECTION_PICKER:
Component = ProductCollectionPlaceholder;
break;
case ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER:
Component = ProductPicker;
break;
case ProductCollectionUIStatesInEditor.VALID:
Component = ProductCollectionContent;
break;
case ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW:
Component = ProductCollectionContent;
isUsingReferencePreviewMode = true;
break;
default:
// By default showing collection chooser.
Component = ProductCollectionPlaceholder;
}
return (
<>
@ -35,6 +70,9 @@ const Edit = ( props: ProductCollectionEditComponentProps ) => {
openCollectionSelectionModal={ () =>
setIsSelectionModalOpen( true )
}
isUsingReferencePreviewMode={ isUsingReferencePreviewMode }
location={ location }
usesReference={ props.usesReference }
/>
{ isSelectionModalOpen && (
<CollectionSelectionModal

View File

@ -50,6 +50,7 @@ import LayoutOptionsControl from './layout-options-control';
import FeaturedProductsControl from './featured-products-control';
import CreatedControl from './created-control';
import PriceRangeControl from './price-range-control';
import LinkedProductControl from './linked-product-control';
const prepareShouldShowFilter =
( hideControls: FilterName[] ) => ( filter: FilterName ) => {
@ -121,6 +122,13 @@ const ProductCollectionInspectorControls = (
return (
<InspectorControls>
<LinkedProductControl
query={ props.attributes.query }
setAttributes={ props.setAttributes }
usesReference={ props.usesReference }
location={ props.location }
/>
<ToolsPanel
label={ __( 'Settings', 'woocommerce' ) }
resetAll={ () => {

View File

@ -0,0 +1,166 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import ProductControl from '@woocommerce/editor-components/product-control';
import { SelectedOption } from '@woocommerce/block-hocs';
import { useState, useMemo } from '@wordpress/element';
import type { WooCommerceBlockLocation } from '@woocommerce/blocks/product-template/utils';
import type { ProductResponseItem } from '@woocommerce/types';
import { decodeEntities } from '@wordpress/html-entities';
import {
PanelBody,
PanelRow,
Button,
Flex,
FlexItem,
Dropdown,
// @ts-expect-error Using experimental features
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalText as Text,
Spinner,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { useGetProduct } from '../../utils';
import type {
ProductCollectionQuery,
ProductCollectionSetAttributes,
} from '../../types';
const ProductButton: React.FC< {
isOpen: boolean;
onToggle: () => void;
product: ProductResponseItem | null;
isLoading: boolean;
} > = ( { isOpen, onToggle, product, isLoading } ) => {
if ( isLoading && ! product ) {
return <Spinner />;
}
return (
<Button
className="wc-block-product-collection-linked-product-control__button"
onClick={ onToggle }
aria-expanded={ isOpen }
disabled={ isLoading }
>
<Flex direction="row" expanded justify="flex-start">
<FlexItem className="wc-block-product-collection-linked-product-control__image-container">
<img
src={ product?.images?.[ 0 ]?.src }
alt={ product?.name }
/>
</FlexItem>
<Flex
direction="column"
align="flex-start"
gap={ 1 }
className="wc-block-product-collection-linked-product-control__content"
>
<FlexItem>
<Text color="inherit" lineHeight={ 1 }>
{ product?.name
? decodeEntities( product.name )
: '' }
</Text>
</FlexItem>
<FlexItem>
<Text color="inherit" lineHeight={ 1 }>
{ product?.sku }
</Text>
</FlexItem>
</Flex>
</Flex>
</Button>
);
};
const LinkedProductPopoverContent: React.FC< {
query: ProductCollectionQuery;
setAttributes: ProductCollectionSetAttributes;
setIsDropdownOpen: React.Dispatch< React.SetStateAction< boolean > >;
} > = ( { query, setAttributes, setIsDropdownOpen } ) => (
<ProductControl
selected={ query?.productReference as SelectedOption }
onChange={ ( value: SelectedOption[] = [] ) => {
const productId = value[ 0 ]?.id ?? null;
if ( productId !== null ) {
setAttributes( {
query: {
...query,
productReference: productId,
},
} );
setIsDropdownOpen( false );
}
} }
messages={ {
search: __( 'Select a product', 'woocommerce' ),
} }
/>
);
const LinkedProductControl = ( {
query,
setAttributes,
location,
usesReference,
}: {
query: ProductCollectionQuery;
setAttributes: ProductCollectionSetAttributes;
location: WooCommerceBlockLocation;
usesReference: string[] | undefined;
} ) => {
const [ isDropdownOpen, setIsDropdownOpen ] = useState< boolean >( false );
const { product, isLoading } = useGetProduct( query.productReference );
const showLinkedProductControl = useMemo( () => {
const isInRequiredLocation = usesReference?.includes( location.type );
const isProductContextRequired = usesReference?.includes( 'product' );
const isProductContextSelected =
( query?.productReference ?? null ) !== null;
return (
isProductContextRequired &&
! isInRequiredLocation &&
isProductContextSelected
);
}, [ location.type, query?.productReference, usesReference ] );
if ( ! showLinkedProductControl ) return null;
return (
<PanelBody title={ __( 'Linked Product', 'woocommerce' ) }>
<PanelRow>
<Dropdown
className="wc-block-product-collection-linked-product-control"
contentClassName="wc-block-product-collection-linked-product__popover-content"
popoverProps={ { placement: 'left-start' } }
renderToggle={ ( { isOpen, onToggle } ) => (
<ProductButton
isOpen={ isOpen }
onToggle={ onToggle }
product={ product }
isLoading={ isLoading }
/>
) }
renderContent={ () => (
<LinkedProductPopoverContent
query={ query }
setAttributes={ setAttributes }
setIsDropdownOpen={ setIsDropdownOpen }
/>
) }
open={ isDropdownOpen }
onToggle={ () => setIsDropdownOpen( ! isDropdownOpen ) }
/>
</PanelRow>
</PanelBody>
);
};
export default LinkedProductControl;

View File

@ -10,7 +10,6 @@ import { useInstanceId } from '@wordpress/compose';
import { useEffect, useRef, useMemo } from '@wordpress/element';
import { Button } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { useGetLocation } from '@woocommerce/blocks/product-template/utils';
import fastDeepEqual from 'fast-deep-equal/es6';
/**
@ -68,19 +67,23 @@ const useQueryId = (
const ProductCollectionContent = ( {
preview: { setPreviewState, initialPreviewState } = {},
usesReference,
...props
}: ProductCollectionEditComponentProps ) => {
const isInitialAttributesSet = useRef( false );
const { clientId, attributes, setAttributes } = props;
const location = useGetLocation( props.context, props.clientId );
const {
clientId,
attributes,
setAttributes,
location,
isUsingReferencePreviewMode,
} = props;
useSetPreviewState( {
setPreviewState,
setAttributes,
location,
attributes,
usesReference,
isUsingReferencePreviewMode,
} );
const blockProps = useBlockProps();

View File

@ -9,6 +9,16 @@ import { type AttributeMetadata } from '@woocommerce/types';
*/
import { WooCommerceBlockLocation } from '../product-template/utils';
export enum ProductCollectionUIStatesInEditor {
COLLECTION_PICKER = 'collection_chooser',
PRODUCT_REFERENCE_PICKER = 'product_context_picker',
VALID_WITH_PREVIEW = 'uses_reference_preview_mode',
VALID = 'valid',
// Future states
// INVALID = 'invalid',
// DELETED_PRODUCT_REFERENCE = 'deleted_product_reference',
}
export interface ProductCollectionAttributes {
query: ProductCollectionQuery;
queryId: number;
@ -95,6 +105,7 @@ export interface ProductCollectionQuery {
woocommerceHandPickedProducts: string[];
priceRange: undefined | PriceRange;
filterable: boolean;
productReference?: number;
}
export type ProductCollectionEditComponentProps =
@ -108,6 +119,8 @@ export type ProductCollectionEditComponentProps =
context: {
templateSlug: string;
};
isUsingReferencePreviewMode: boolean;
location: WooCommerceBlockLocation;
};
export type TProductCollectionOrder = 'asc' | 'desc';

View File

@ -6,8 +6,10 @@ import { addFilter } from '@wordpress/hooks';
import { select } from '@wordpress/data';
import { isWpVersion } from '@woocommerce/settings';
import type { BlockEditProps, Block } from '@wordpress/blocks';
import { useLayoutEffect } from '@wordpress/element';
import { useEffect, useLayoutEffect, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import type { ProductResponseItem } from '@woocommerce/types';
import { getProduct } from '@woocommerce/editor-components/utils';
import {
createBlock,
// @ts-expect-error Type definitions for this function are missing in Guteberg
@ -18,13 +20,14 @@ import {
* Internal dependencies
*/
import {
type ProductCollectionAttributes,
type TProductCollectionOrder,
type TProductCollectionOrderBy,
type ProductCollectionQuery,
type ProductCollectionDisplayLayout,
type PreviewState,
type SetPreviewState,
ProductCollectionAttributes,
TProductCollectionOrder,
TProductCollectionOrderBy,
ProductCollectionQuery,
ProductCollectionDisplayLayout,
PreviewState,
SetPreviewState,
ProductCollectionUIStatesInEditor,
} from './types';
import {
coreQueryPaginationBlockName,
@ -166,41 +169,14 @@ export const addProductCollectionToQueryPaginationParentOrAncestor = () => {
};
/**
* Get the preview message for the Product Collection block based on the usesReference.
* There are two scenarios:
* 1. When usesReference is product, the preview message will be:
* "Actual products will vary depending on the product being viewed."
* 2. For all other usesReference, the preview message will be:
* "Actual products will vary depending on the page being viewed."
*
* This message will be shown when the usesReference isn't available on the Editor side, but is available on the Frontend.
* Get the message to show in the preview label when the block is in preview mode based
* on the `usesReference` value.
*/
export const getUsesReferencePreviewMessage = (
location: WooCommerceBlockLocation,
usesReference?: string[]
isUsingReferencePreviewMode: boolean
) => {
if ( ! ( Array.isArray( usesReference ) && usesReference.length > 0 ) ) {
return '';
}
if ( usesReference.includes( location.type ) ) {
/**
* Block shouldn't be in preview mode when:
* 1. Current location is archive and termId is available.
* 2. Current location is product and productId is available.
*
* Because in these cases, we have required context on the editor side.
*/
const isArchiveLocationWithTermId =
location.type === LocationType.Archive &&
( location.sourceData?.termId ?? null ) !== null;
const isProductLocationWithProductId =
location.type === LocationType.Product &&
( location.sourceData?.productId ?? null ) !== null;
if ( isArchiveLocationWithTermId || isProductLocationWithProductId ) {
return '';
}
if ( isUsingReferencePreviewMode ) {
if ( location.type === LocationType.Product ) {
return __(
'Actual products will vary depending on the product being viewed.',
@ -217,12 +193,77 @@ export const getUsesReferencePreviewMessage = (
return '';
};
export const getProductCollectionUIStateInEditor = ( {
location,
usesReference,
attributes,
hasInnerBlocks,
}: {
location: WooCommerceBlockLocation;
usesReference?: string[] | undefined;
attributes: ProductCollectionAttributes;
hasInnerBlocks: boolean;
} ): ProductCollectionUIStatesInEditor => {
const isInRequiredLocation = usesReference?.includes( location.type );
const isCollectionSelected = !! attributes.collection;
/**
* Case 1: Product context picker
*/
const isProductContextRequired = usesReference?.includes( 'product' );
const isProductContextSelected =
( attributes.query?.productReference ?? null ) !== null;
if (
isCollectionSelected &&
isProductContextRequired &&
! isInRequiredLocation &&
! isProductContextSelected
) {
return ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER;
}
/**
* Case 2: Preview mode - based on `usesReference` value
*/
if ( isInRequiredLocation ) {
/**
* Block shouldn't be in preview mode when:
* 1. Current location is archive and termId is available.
* 2. Current location is product and productId is available.
*
* Because in these cases, we have required context on the editor side.
*/
const isArchiveLocationWithTermId =
location.type === LocationType.Archive &&
( location.sourceData?.termId ?? null ) !== null;
const isProductLocationWithProductId =
location.type === LocationType.Product &&
( location.sourceData?.productId ?? null ) !== null;
if (
! isArchiveLocationWithTermId &&
! isProductLocationWithProductId
) {
return ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW;
}
}
/**
* Case 3: Collection chooser
*/
if ( ! hasInnerBlocks && ! isCollectionSelected ) {
return ProductCollectionUIStatesInEditor.COLLECTION_PICKER;
}
return ProductCollectionUIStatesInEditor.VALID;
};
export const useSetPreviewState = ( {
setPreviewState,
location,
attributes,
setAttributes,
usesReference,
isUsingReferencePreviewMode,
}: {
setPreviewState?: SetPreviewState | undefined;
location: WooCommerceBlockLocation;
@ -231,6 +272,7 @@ export const useSetPreviewState = ( {
attributes: Partial< ProductCollectionAttributes >
) => void;
usesReference?: string[] | undefined;
isUsingReferencePreviewMode: boolean;
} ) => {
const setState = ( newPreviewState: PreviewState ) => {
setAttributes( {
@ -240,8 +282,6 @@ export const useSetPreviewState = ( {
},
} );
};
const isCollectionUsesReference =
usesReference && usesReference?.length > 0;
/**
* When usesReference is available on Frontend but not on Editor side,
@ -249,10 +289,10 @@ export const useSetPreviewState = ( {
*/
const usesReferencePreviewMessage = getUsesReferencePreviewMessage(
location,
usesReference
isUsingReferencePreviewMode
);
useLayoutEffect( () => {
if ( isCollectionUsesReference ) {
if ( isUsingReferencePreviewMode ) {
setAttributes( {
__privatePreviewState: {
isPreview: usesReferencePreviewMessage.length > 0,
@ -263,12 +303,12 @@ export const useSetPreviewState = ( {
}, [
setAttributes,
usesReferencePreviewMessage,
isCollectionUsesReference,
isUsingReferencePreviewMode,
] );
// Running setPreviewState function provided by Collection, if it exists.
useLayoutEffect( () => {
if ( ! setPreviewState && ! isCollectionUsesReference ) {
if ( ! setPreviewState && ! isUsingReferencePreviewMode ) {
return;
}
@ -294,11 +334,14 @@ export const useSetPreviewState = ( {
* - Products by tag
* - Products by attribute
*/
const termId =
location.type === LocationType.Archive
? location.sourceData?.termId
: null;
useLayoutEffect( () => {
if ( ! setPreviewState && ! isCollectionUsesReference ) {
if ( ! setPreviewState && ! isUsingReferencePreviewMode ) {
const isGenericArchiveTemplate =
location.type === LocationType.Archive &&
location.sourceData?.termId === null;
location.type === LocationType.Archive && termId === null;
setAttributes( {
__privatePreviewState: {
@ -315,11 +358,11 @@ export const useSetPreviewState = ( {
}, [
attributes?.query?.inherit,
usesReferencePreviewMessage,
location.sourceData?.termId,
termId,
location.type,
setAttributes,
setPreviewState,
isCollectionUsesReference,
isUsingReferencePreviewMode,
] );
};
@ -356,3 +399,35 @@ export const getDefaultProductCollection = () =>
},
createBlocksFromInnerBlocksTemplate( INNER_BLOCKS_TEMPLATE )
);
export const useGetProduct = ( productId: number | undefined ) => {
const [ product, setProduct ] = useState< ProductResponseItem | null >(
null
);
const [ isLoading, setIsLoading ] = useState< boolean >( false );
useEffect( () => {
const fetchProduct = async () => {
if ( productId ) {
setIsLoading( true );
try {
const fetchedProduct = ( await getProduct(
productId
) ) as ProductResponseItem;
setProduct( fetchedProduct );
} catch ( error ) {
setProduct( null );
} finally {
setIsLoading( false );
}
} else {
setProduct( null );
setIsLoading( false );
}
};
fetchProduct();
}, [ productId ] );
return { product, isLoading };
};

View File

@ -266,7 +266,7 @@ const ProductTemplateEdit = (
products: getEntityRecords( 'postType', postType, {
...query,
...restQueryArgs,
location,
productCollectionLocation: location,
productCollectionQueryContext,
previewState: __privateProductCollectionPreviewState,
/**

View File

@ -63,17 +63,65 @@ const prepareIsInGenericTemplate =
( entitySlug: string ): boolean =>
templateSlug === entitySlug;
export type WooCommerceBlockLocation = ReturnType<
typeof createLocationObject
>;
interface WooCommerceBaseLocation {
type: LocationType;
sourceData?: object | undefined;
}
const createLocationObject = (
type: LocationType,
sourceData: Record< string, unknown > = {}
) => ( {
type,
sourceData,
} );
interface ProductLocation extends WooCommerceBaseLocation {
type: LocationType.Product;
sourceData?:
| {
productId: number;
}
| undefined;
}
interface ArchiveLocation extends WooCommerceBaseLocation {
type: LocationType.Archive;
sourceData?:
| {
taxonomy: string;
termId: number;
}
| undefined;
}
interface CartLocation extends WooCommerceBaseLocation {
type: LocationType.Cart;
sourceData?:
| {
productIds: number[];
}
| undefined;
}
interface OrderLocation extends WooCommerceBaseLocation {
type: LocationType.Order;
sourceData?:
| {
orderId: number;
}
| undefined;
}
interface SiteLocation extends WooCommerceBaseLocation {
type: LocationType.Site;
sourceData?: object | undefined;
}
export type WooCommerceBlockLocation =
| ProductLocation
| ArchiveLocation
| CartLocation
| OrderLocation
| SiteLocation;
const createLocationObject = ( type: LocationType, sourceData: object = {} ) =>
( {
type,
sourceData,
} as WooCommerceBlockLocation );
type ContextProperties = {
templateSlug: string;
@ -83,7 +131,7 @@ type ContextProperties = {
export const useGetLocation = < T, >(
context: Context< T & ContextProperties >,
clientId: string
) => {
): WooCommerceBlockLocation => {
const templateSlug = context.templateSlug || '';
const postId = context.postId || null;

View File

@ -62,6 +62,16 @@ interface ProductControlProps {
* Whether to show variations in the list of items available.
*/
showVariations?: boolean;
/**
* Different messages to display in the component.
* If any of the messages are not provided, the default message will be used.
*/
messages?: {
list?: string;
noItems?: string;
search?: string;
updated?: string;
};
}
const messages = {
@ -188,7 +198,7 @@ const ProductControl = (
} else if ( showVariations ) {
return renderItemWithVariations;
}
return () => null;
return undefined;
};
if ( error ) {
@ -216,7 +226,10 @@ const ProductControl = (
onChange={ onChange }
renderItem={ getRenderItemFunc() }
onSearch={ onSearch }
messages={ messages }
messages={ {
...messages,
...props.messages,
} }
isHierarchical
/>
);

View File

@ -9,6 +9,7 @@ import { test as base, expect } from '@woocommerce/e2e-utils';
*/
import ProductCollectionPage, {
BLOCK_LABELS,
Collections,
SELECTORS,
} from './product-collection.page';
@ -402,7 +403,7 @@ test.describe( 'Product Collection', () => {
} );
} );
test.describe( 'Location is recognised', () => {
test.describe( 'Location is recognized', () => {
const filterRequest = ( request: Request ) => {
const url = request.url();
return (
@ -418,7 +419,9 @@ test.describe( 'Product Collection', () => {
return (
url.includes( 'wp/v2/product' ) &&
searchParams.get( 'isProductCollectionBlock' ) === 'true' &&
!! searchParams.get( `location[sourceData][productId]` )
!! searchParams.get(
`productCollectionLocation[sourceData][productId]`
)
);
};
@ -430,26 +433,30 @@ test.describe( 'Product Collection', () => {
if ( locationType === 'product' ) {
return {
type: searchParams.get( 'location[type]' ),
type: searchParams.get( 'productCollectionLocation[type]' ),
productId: searchParams.get(
`location[sourceData][productId]`
`productCollectionLocation[sourceData][productId]`
),
};
}
if ( locationType === 'archive' ) {
return {
type: searchParams.get( 'location[type]' ),
type: searchParams.get( 'productCollectionLocation[type]' ),
taxonomy: searchParams.get(
`location[sourceData][taxonomy]`
`productCollectionLocation[sourceData][taxonomy]`
),
termId: searchParams.get(
`productCollectionLocation[sourceData][termId]`
),
termId: searchParams.get( `location[sourceData][termId]` ),
};
}
return {
type: searchParams.get( 'location[type]' ),
sourceData: searchParams.get( `location[sourceData]` ),
type: searchParams.get( 'productCollectionLocation[type]' ),
sourceData: searchParams.get(
`productCollectionLocation[sourceData]`
),
};
};
@ -482,10 +489,10 @@ test.describe( 'Product Collection', () => {
pageObject.BLOCK_NAME
);
const locationReuqestPromise =
const locationRequestPromise =
page.waitForRequest( filterProductRequest );
await pageObject.chooseCollectionInTemplate( 'featured' );
const locationRequest = await locationReuqestPromise;
const locationRequest = await locationRequestPromise;
const { type, productId } = getLocationDetailsFromRequest(
locationRequest,
@ -961,3 +968,309 @@ test.describe( 'Product Collection', () => {
} );
} );
} );
test.describe( 'Testing "usesReference" argument in "registerProductCollection"', () => {
const MY_REGISTERED_COLLECTIONS = {
myCustomCollectionWithProductContext: {
name: 'My Custom Collection - Product Context',
label: 'Block: My Custom Collection - Product Context',
previewLabelTemplate: [ 'woocommerce/woocommerce//single-product' ],
shouldShowProductPicker: true,
},
myCustomCollectionWithCartContext: {
name: 'My Custom Collection - Cart Context',
label: 'Block: My Custom Collection - Cart Context',
previewLabelTemplate: [ 'woocommerce/woocommerce//page-cart' ],
shouldShowProductPicker: false,
},
myCustomCollectionWithOrderContext: {
name: 'My Custom Collection - Order Context',
label: 'Block: My Custom Collection - Order Context',
previewLabelTemplate: [
'woocommerce/woocommerce//order-confirmation',
],
shouldShowProductPicker: false,
},
myCustomCollectionWithArchiveContext: {
name: 'My Custom Collection - Archive Context',
label: 'Block: My Custom Collection - Archive Context',
previewLabelTemplate: [
'woocommerce/woocommerce//taxonomy-product_cat',
],
shouldShowProductPicker: false,
},
myCustomCollectionMultipleContexts: {
name: 'My Custom Collection - Multiple Contexts',
label: 'Block: My Custom Collection - Multiple Contexts',
previewLabelTemplate: [
'woocommerce/woocommerce//single-product',
'woocommerce/woocommerce//order-confirmation',
],
shouldShowProductPicker: true,
},
};
// Activate plugin which registers custom product collections
test.beforeEach( async ( { requestUtils } ) => {
await requestUtils.activatePlugin(
'register-product-collection-tester'
);
} );
Object.entries( MY_REGISTERED_COLLECTIONS ).forEach(
( [ key, collection ] ) => {
for ( const template of collection.previewLabelTemplate ) {
test( `Collection "${ collection.name }" should show preview label in "${ template }"`, async ( {
pageObject,
editor,
} ) => {
await pageObject.goToEditorTemplate( template );
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInTemplate(
key as Collections
);
const block = editor.canvas.getByLabel( collection.label );
const previewButtonLocator = block.getByTestId(
SELECTORS.previewButtonTestID
);
await expect( previewButtonLocator ).toBeVisible();
} );
}
test( `Collection "${ collection.name }" should not show preview label in a post`, async ( {
pageObject,
editor,
admin,
} ) => {
await admin.createNewPost();
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInPost( key as Collections );
// Check visibility of product picker
const editorProductPicker = editor.canvas.locator(
SELECTORS.productPicker
);
const expectedVisibility = collection.shouldShowProductPicker
? 'toBeVisible'
: 'toBeHidden';
await expect( editorProductPicker )[ expectedVisibility ]();
if ( collection.shouldShowProductPicker ) {
await pageObject.chooseProductInEditorProductPickerIfAvailable(
editor.canvas
);
}
// At this point, the product picker should be hidden
await expect( editorProductPicker ).toBeHidden();
// Check visibility of preview label
const block = editor.canvas.getByLabel( collection.label );
const previewButtonLocator = block.getByTestId(
SELECTORS.previewButtonTestID
);
await expect( previewButtonLocator ).toBeHidden();
} );
test( `Collection "${ collection.name }" should not show preview label in Product Catalog template`, async ( {
pageObject,
editor,
} ) => {
await pageObject.goToProductCatalogAndInsertCollection(
key as Collections
);
const block = editor.canvas.getByLabel( collection.label );
const previewButtonLocator = block.getByTestId(
SELECTORS.previewButtonTestID
);
await expect( previewButtonLocator ).toBeHidden();
} );
}
);
} );
test.describe( 'Product picker', () => {
const MY_REGISTERED_COLLECTIONS_THAT_NEEDS_PRODUCT = {
myCustomCollectionWithProductContext: {
name: 'My Custom Collection - Product Context',
label: 'Block: My Custom Collection - Product Context',
collection:
'woocommerce/product-collection/my-custom-collection-product-context',
},
myCustomCollectionMultipleContexts: {
name: 'My Custom Collection - Multiple Contexts',
label: 'Block: My Custom Collection - Multiple Contexts',
collection:
'woocommerce/product-collection/my-custom-collection-multiple-contexts',
},
};
// Activate plugin which registers custom product collections
test.beforeEach( async ( { requestUtils } ) => {
await requestUtils.activatePlugin(
'register-product-collection-tester'
);
} );
Object.entries( MY_REGISTERED_COLLECTIONS_THAT_NEEDS_PRODUCT ).forEach(
( [ key, collection ] ) => {
test( `For collection "${ collection.name }" - manually selected product reference should be available on Frontend in a post`, async ( {
pageObject,
admin,
page,
editor,
} ) => {
await admin.createNewPost();
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInPost( key as Collections );
// Verify that product picker is shown in Editor
const editorProductPicker = editor.canvas.locator(
SELECTORS.productPicker
);
await expect( editorProductPicker ).toBeVisible();
// Once a product is selected, the product picker should be hidden
await pageObject.chooseProductInEditorProductPickerIfAvailable(
editor.canvas
);
await expect( editorProductPicker ).toBeHidden();
// On Frontend, verify that product reference is a number
await pageObject.publishAndGoToFrontend();
const collectionWithProductContext = page.locator(
`[data-collection="${ collection.collection }"]`
);
const queryAttribute = JSON.parse(
( await collectionWithProductContext.getAttribute(
'data-query'
) ) || '{}'
);
expect( typeof queryAttribute?.productReference ).toBe(
'number'
);
} );
test( `For collection "${ collection.name }" - changing product using inspector control`, async ( {
pageObject,
admin,
page,
editor,
} ) => {
await admin.createNewPost();
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInPost( key as Collections );
// Verify that product picker is shown in Editor
const editorProductPicker = editor.canvas.locator(
SELECTORS.productPicker
);
await expect( editorProductPicker ).toBeVisible();
// Once a product is selected, the product picker should be hidden
await pageObject.chooseProductInEditorProductPickerIfAvailable(
editor.canvas
);
await expect( editorProductPicker ).toBeHidden();
// Verify that Album is selected
await expect(
admin.page.locator( SELECTORS.linkedProductControl.button )
).toContainText( 'Album' );
// Change product using inspector control to Beanie
await admin.page
.locator( SELECTORS.linkedProductControl.button )
.click();
await admin.page
.locator( SELECTORS.linkedProductControl.popoverContent )
.getByLabel( 'Beanie', { exact: true } )
.click();
await expect(
admin.page.locator( SELECTORS.linkedProductControl.button )
).toContainText( 'Beanie' );
// On Frontend, verify that product reference is a number
await pageObject.publishAndGoToFrontend();
const collectionWithProductContext = page.locator(
`[data-collection="${ collection.collection }"]`
);
const queryAttribute = JSON.parse(
( await collectionWithProductContext.getAttribute(
'data-query'
) ) || '{}'
);
expect( typeof queryAttribute?.productReference ).toBe(
'number'
);
} );
test( `For collection "${ collection.name }" - product picker shouldn't be shown in Single Product template`, async ( {
pageObject,
admin,
editor,
} ) => {
await admin.visitSiteEditor( {
postId: `woocommerce/woocommerce//single-product`,
postType: 'wp_template',
canvas: 'edit',
} );
await editor.canvas.locator( 'body' ).click();
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInTemplate(
key as Collections
);
const editorProductPicker = editor.canvas.locator(
SELECTORS.productPicker
);
await expect( editorProductPicker ).toBeHidden();
} );
}
);
test( 'Product picker should work as expected while changing collection using "Choose collection" button from Toolbar', async ( {
pageObject,
admin,
editor,
} ) => {
await admin.createNewPost();
await pageObject.insertProductCollection();
await pageObject.chooseCollectionInPost(
'myCustomCollectionWithProductContext'
);
// Verify that product picker is shown in Editor
const editorProductPicker = editor.canvas.locator(
SELECTORS.productPicker
);
await expect( editorProductPicker ).toBeVisible();
// Once a product is selected, the product picker should be hidden
await pageObject.chooseProductInEditorProductPickerIfAvailable(
editor.canvas
);
await expect( editorProductPicker ).toBeHidden();
// Change collection using Toolbar
await pageObject.changeCollectionUsingToolbar(
'myCustomCollectionMultipleContexts'
);
await expect( editorProductPicker ).toBeVisible();
// Once a product is selected, the product picker should be hidden
await pageObject.chooseProductInEditorProductPickerIfAvailable(
editor.canvas
);
await expect( editorProductPicker ).toBeHidden();
// Product picker should be hidden for collections that don't need product
await pageObject.changeCollectionUsingToolbar( 'featured' );
await expect( editorProductPicker ).toBeHidden();
} );
} );

View File

@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { Locator, Page } from '@playwright/test';
import { FrameLocator, Locator, Page } from '@playwright/test';
import { Editor, Admin } from '@woocommerce/e2e-utils';
import { BlockRepresentation } from '@wordpress/e2e-test-utils-playwright/build-types/editor/insert-block';
@ -62,6 +62,12 @@ export const SELECTORS = {
previewButtonTestID: 'product-collection-preview-button',
collectionPlaceholder:
'[data-type="woocommerce/product-collection"] .components-placeholder',
productPicker: '.wc-blocks-product-collection__editor-product-picker',
linkedProductControl: {
button: '.wc-block-product-collection-linked-product-control__button',
popoverContent:
'.wc-block-product-collection-linked-product__popover-content',
},
};
export type Collections =
@ -200,10 +206,31 @@ class ProductCollectionPage {
}
}
async chooseProductInEditorProductPickerIfAvailable(
pageReference: Page | FrameLocator
) {
const editorProductPicker = pageReference.locator(
SELECTORS.productPicker
);
if ( await editorProductPicker.isVisible() ) {
await editorProductPicker
.locator( 'label' )
.filter( {
hasText: 'Album',
} )
.click();
}
}
async createNewPostAndInsertBlock( collection?: Collections ) {
await this.admin.createNewPost();
await this.insertProductCollection();
await this.chooseCollectionInPost( collection );
// If product picker is available, choose a product.
await this.chooseProductInEditorProductPickerIfAvailable(
this.admin.page
);
await this.refreshLocators( 'editor' );
await this.editor.openDocumentSettingsSidebar();
}
@ -345,6 +372,10 @@ class ProductCollectionPage {
await this.editor.canvas.locator( 'body' ).click();
await this.insertProductCollection();
await this.chooseCollectionInTemplate( collection );
// If product picker is available, choose a product.
await this.chooseProductInEditorProductPickerIfAvailable(
this.editor.canvas
);
await this.refreshLocators( 'editor' );
}
@ -571,6 +602,30 @@ class ProductCollectionPage {
.click();
}
async changeCollectionUsingToolbar( collection: Collections ) {
// Click "Choose collection" button in the toolbar.
await this.admin.page
.getByRole( 'toolbar', { name: 'Block Tools' } )
.getByRole( 'button', { name: 'Choose collection' } )
.click();
// Select the collection from the modal.
const collectionChooserModal = this.admin.page.locator(
'.wc-blocks-product-collection__modal'
);
await collectionChooserModal
.getByRole( 'button', {
name: collectionToButtonNameMap[ collection ],
} )
.click();
await collectionChooserModal
.getByRole( 'button', {
name: 'Continue',
} )
.click();
}
async setDisplaySettings( {
itemsPerPage,
offset,

View File

@ -0,0 +1,4 @@
Significance: major
Type: add
Product Collection - Show product picker in Editor when collection requires a product but not available <details> A collection can define if it requires a product context. This can be done using `usesReference` argument i.e. ```tsx __experimentalRegisterProductCollection({ ..., usesReference: ['product'], ) ``` When product context doesn't exist in current template/page/post etc. then we show product picker in Editor. This way, merchant can manually provide a product context to the collection.

View File

@ -0,0 +1,4 @@
Significance: major
Type: add
Product Collection - Implement Inspector control to change selected product