From 2433664aa8460590b636ff145600f1a3aa7b64f3 Mon Sep 17 00:00:00 2001 From: Manish Menaria Date: Mon, 2 Sep 2024 12:39:33 +0530 Subject: [PATCH] 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 * Rename isUsesReferencePreviewMode to isUsingReferencePreviewMode * Change order of conditions in getProductCollectionUIStateInEditor --------- Co-authored-by: github-actions --- .../product-collection/edit/ProductPicker.tsx | 81 +++++ .../product-collection/edit/editor.scss | 46 +++ .../blocks/product-collection/edit/index.tsx | 46 ++- .../edit/inspector-controls/index.tsx | 8 + .../linked-product-control.tsx | 166 +++++++++ .../edit/product-collection-content.tsx | 13 +- .../js/blocks/product-collection/types.ts | 13 + .../js/blocks/product-collection/utils.tsx | 177 ++++++--- .../js/blocks/product-template/edit.tsx | 2 +- .../js/blocks/product-template/utils.tsx | 70 +++- .../product-control/index.tsx | 17 +- .../product-collection.block_theme.spec.ts | 335 +++++++++++++++++- .../product-collection.page.ts | 57 ++- ...-context-linking-a-product-with-collection | 4 + ...-product-with-collection-inspector-control | 4 + 15 files changed, 953 insertions(+), 86 deletions(-) create mode 100644 plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/ProductPicker.tsx create mode 100644 plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/linked-product-control.tsx create mode 100644 plugins/woocommerce/changelog/50164-add-44877-context-linking-a-product-with-collection create mode 100644 plugins/woocommerce/changelog/50590-add-44877-context-linking-a-product-with-collection-inspector-control diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/ProductPicker.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/ProductPicker.tsx new file mode 100644 index 00000000000..3d80edf035c --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/ProductPicker.tsx @@ -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 ( +
+ + + + + { createInterpolateElement( + sprintf( + /* translators: %s: collection title */ + __( + '%s requires a product to be selected in order to display associated items.', + 'woocommerce' + ), + collection.title + ), + { + strong: , + } + ) } + + + { + const isValidId = ( value[ 0 ]?.id ?? null ) !== null; + if ( isValidId ) { + props.setAttributes( { + query: { + ...attributes.query, + productReference: value[ 0 ].id, + }, + } ); + } + } } + messages={ { + search: __( 'Select a product', 'woocommerce' ), + } } + /> + +
+ ); +}; + +export default ProductPicker; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/editor.scss b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/editor.scss index 63ecbc2f692..4dac0fe0dc5 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/editor.scss +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/editor.scss @@ -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; + } +} diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/index.tsx index de9dcb3c03d..4206a024714 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/index.tsx @@ -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 && ( ( filter: FilterName ) => { @@ -121,6 +122,13 @@ const ProductCollectionInspectorControls = ( return ( + + { diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/linked-product-control.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/linked-product-control.tsx new file mode 100644 index 00000000000..35606aff0a9 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/linked-product-control.tsx @@ -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 ; + } + + return ( + + ); +}; + +const LinkedProductPopoverContent: React.FC< { + query: ProductCollectionQuery; + setAttributes: ProductCollectionSetAttributes; + setIsDropdownOpen: React.Dispatch< React.SetStateAction< boolean > >; +} > = ( { query, setAttributes, setIsDropdownOpen } ) => ( + { + 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 ( + + + ( + + ) } + renderContent={ () => ( + + ) } + open={ isDropdownOpen } + onToggle={ () => setIsDropdownOpen( ! isDropdownOpen ) } + /> + + + ); +}; + +export default LinkedProductControl; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx index dadbddb7751..35714946c42 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx @@ -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(); diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts index 4407c682abe..55a8ee9b460 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts @@ -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'; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/utils.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/utils.tsx index bdbd882e88a..0565027bfe1 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/utils.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/utils.tsx @@ -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 }; +}; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-template/edit.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-template/edit.tsx index 5c469725163..1c09035f46e 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-template/edit.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-template/edit.tsx @@ -266,7 +266,7 @@ const ProductTemplateEdit = ( products: getEntityRecords( 'postType', postType, { ...query, ...restQueryArgs, - location, + productCollectionLocation: location, productCollectionQueryContext, previewState: __privateProductCollectionPreviewState, /** diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-template/utils.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-template/utils.tsx index 5f5344a7296..9106d24ba3c 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-template/utils.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-template/utils.tsx @@ -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; diff --git a/plugins/woocommerce-blocks/assets/js/editor-components/product-control/index.tsx b/plugins/woocommerce-blocks/assets/js/editor-components/product-control/index.tsx index bfa1e5f2a85..f86f2878dc7 100644 --- a/plugins/woocommerce-blocks/assets/js/editor-components/product-control/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/editor-components/product-control/index.tsx @@ -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 /> ); diff --git a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts index af0839c5ca1..e38db1b526a 100644 --- a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts +++ b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts @@ -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(); + } ); +} ); diff --git a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.page.ts b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.page.ts index 22a0fb3e3e1..1a87ebeb605 100644 --- a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.page.ts +++ b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.page.ts @@ -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, diff --git a/plugins/woocommerce/changelog/50164-add-44877-context-linking-a-product-with-collection b/plugins/woocommerce/changelog/50164-add-44877-context-linking-a-product-with-collection new file mode 100644 index 00000000000..1308c1e61e7 --- /dev/null +++ b/plugins/woocommerce/changelog/50164-add-44877-context-linking-a-product-with-collection @@ -0,0 +1,4 @@ +Significance: major +Type: add + +Product Collection - Show product picker in Editor when collection requires a product but not available
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. \ No newline at end of file diff --git a/plugins/woocommerce/changelog/50590-add-44877-context-linking-a-product-with-collection-inspector-control b/plugins/woocommerce/changelog/50590-add-44877-context-linking-a-product-with-collection-inspector-control new file mode 100644 index 00000000000..2db092dad41 --- /dev/null +++ b/plugins/woocommerce/changelog/50590-add-44877-context-linking-a-product-with-collection-inspector-control @@ -0,0 +1,4 @@ +Significance: major +Type: add + +Product Collection - Implement Inspector control to change selected product \ No newline at end of file