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 index 3d80edf035c..927d3d31abc 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/ProductPicker.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/ProductPicker.tsx @@ -21,15 +21,36 @@ import { import type { ProductCollectionEditComponentProps } from '../types'; import { getCollectionByName } from '../collections'; -const ProductPicker = ( props: ProductCollectionEditComponentProps ) => { +const ProductPicker = ( + props: ProductCollectionEditComponentProps & { + isDeletedProductReference: boolean; + } +) => { const blockProps = useBlockProps(); - const attributes = props.attributes; + const { attributes, isDeletedProductReference } = props; const collection = getCollectionByName( attributes.collection ); if ( ! collection ) { - return; + return null; } + const infoText = isDeletedProductReference + ? __( + 'Previously selected product is no longer available.', + 'woocommerce' + ) + : createInterpolateElement( + sprintf( + /* translators: %s: collection title */ + __( + '%s requires a product to be selected in order to display associated items.', + 'woocommerce' + ), + collection.title + ), + { strong: } + ); + return (
@@ -38,21 +59,7 @@ const ProductPicker = ( props: ProductCollectionEditComponentProps ) => { icon={ info } className="wc-blocks-product-collection__info-icon" /> - - { createInterpolateElement( - sprintf( - /* translators: %s: collection title */ - __( - '%s requires a product to be selected in order to display associated items.', - 'woocommerce' - ), - collection.title - ), - { - strong: , - } - ) } - + { infoText } { @@ -31,49 +33,65 @@ const Edit = ( props: ProductCollectionEditComponentProps ) => { [ clientId ] ); - const productCollectionUIStateInEditor = - getProductCollectionUIStateInEditor( { - hasInnerBlocks, + const { productCollectionUIStateInEditor, isLoading } = + useProductCollectionUIState( { location, - attributes: props.attributes, + attributes, + hasInnerBlocks, 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; + // Show spinner while calculating Editor UI state. + if ( isLoading ) { + return ( + + + + ); } + const productCollectionContentProps: ProductCollectionContentProps = { + ...props, + openCollectionSelectionModal: () => setIsSelectionModalOpen( true ), + location, + isUsingReferencePreviewMode: + productCollectionUIStateInEditor === + ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW, + }; + + const renderComponent = () => { + switch ( productCollectionUIStateInEditor ) { + case ProductCollectionUIStatesInEditor.COLLECTION_PICKER: + return ; + case ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER: + return ( + + ); + case ProductCollectionUIStatesInEditor.DELETED_PRODUCT_REFERENCE: + return ( + + ); + case ProductCollectionUIStatesInEditor.VALID: + case ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW: + return ( + + ); + default: + return ; + } + }; + return ( <> - - setIsSelectionModalOpen( true ) - } - isUsingReferencePreviewMode={ isUsingReferencePreviewMode } - location={ location } - usesReference={ props.usesReference } - /> + { renderComponent() } { isSelectionModalOpen && ( + props: ProductCollectionContentProps ) { const { clientId, attributes, setAttributes } = props; const { forcePageReload } = attributes; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx index a55d9dbb84a..cf281729f24 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx @@ -27,7 +27,7 @@ import { import metadata from '../../block.json'; import { useTracksLocation } from '../../tracks-utils'; import { - ProductCollectionEditComponentProps, + ProductCollectionContentProps, ProductCollectionAttributes, CoreFilterNames, FilterName, @@ -58,7 +58,7 @@ const prepareShouldShowFilter = }; const ProductCollectionInspectorControls = ( - props: ProductCollectionEditComponentProps + props: ProductCollectionContentProps ) => { const { attributes, context, setAttributes } = props; const { query, hideControls, displayLayout } = attributes; 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 35714946c42..4eaf299d034 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 @@ -18,7 +18,7 @@ import fastDeepEqual from 'fast-deep-equal/es6'; import type { ProductCollectionAttributes, ProductCollectionQuery, - ProductCollectionEditComponentProps, + ProductCollectionContentProps, } from '../types'; import { DEFAULT_ATTRIBUTES, INNER_BLOCKS_TEMPLATE } from '../constants'; import { @@ -68,7 +68,7 @@ const useQueryId = ( const ProductCollectionContent = ( { preview: { setPreviewState, initialPreviewState } = {}, ...props -}: ProductCollectionEditComponentProps ) => { +}: ProductCollectionContentProps ) => { const isInitialAttributesSet = useRef( false ); const { clientId, diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/index.tsx index c7252aab36e..3808dfdf120 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/edit/toolbar-controls/index.tsx @@ -11,10 +11,10 @@ import { setQueryAttribute } from '../../utils'; import DisplaySettingsToolbar from './display-settings-toolbar'; import DisplayLayoutToolbar from './display-layout-toolbar'; import CollectionChooserToolbar from './collection-chooser-toolbar'; -import type { ProductCollectionEditComponentProps } from '../../types'; +import type { ProductCollectionContentProps } from '../../types'; export default function ToolbarControls( - props: Omit< ProductCollectionEditComponentProps, 'preview' > + props: ProductCollectionContentProps ) { const { attributes, openCollectionSelectionModal, setAttributes } = props; const { query, displayLayout } = attributes; 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 55a8ee9b460..4d37c928d7e 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/types.ts @@ -14,9 +14,9 @@ export enum ProductCollectionUIStatesInEditor { PRODUCT_REFERENCE_PICKER = 'product_context_picker', VALID_WITH_PREVIEW = 'uses_reference_preview_mode', VALID = 'valid', + DELETED_PRODUCT_REFERENCE = 'deleted_product_reference', // Future states // INVALID = 'invalid', - // DELETED_PRODUCT_REFERENCE = 'deleted_product_reference', } export interface ProductCollectionAttributes { @@ -110,7 +110,6 @@ export interface ProductCollectionQuery { export type ProductCollectionEditComponentProps = BlockEditProps< ProductCollectionAttributes > & { - openCollectionSelectionModal: () => void; preview?: { initialPreviewState?: PreviewState; setPreviewState?: SetPreviewState; @@ -119,8 +118,13 @@ export type ProductCollectionEditComponentProps = context: { templateSlug: string; }; - isUsingReferencePreviewMode: boolean; + }; + +export type ProductCollectionContentProps = + ProductCollectionEditComponentProps & { location: WooCommerceBlockLocation; + isUsingReferencePreviewMode: boolean; + openCollectionSelectionModal: () => void; }; 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 0565027bfe1..4e8ebc23fab 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-collection/utils.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-collection/utils.tsx @@ -3,10 +3,16 @@ */ import { store as blockEditorStore } from '@wordpress/block-editor'; import { addFilter } from '@wordpress/hooks'; -import { select } from '@wordpress/data'; +import { select, useSelect } from '@wordpress/data'; +import { store as coreDataStore } from '@wordpress/core-data'; import { isWpVersion } from '@woocommerce/settings'; import type { BlockEditProps, Block } from '@wordpress/blocks'; -import { useEffect, useLayoutEffect, useState } from '@wordpress/element'; +import { + useEffect, + useLayoutEffect, + useState, + useMemo, +} from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import type { ProductResponseItem } from '@woocommerce/types'; import { getProduct } from '@woocommerce/editor-components/utils'; @@ -193,7 +199,7 @@ export const getUsesReferencePreviewMessage = ( return ''; }; -export const getProductCollectionUIStateInEditor = ( { +export const useProductCollectionUIState = ( { location, usesReference, attributes, @@ -203,59 +209,111 @@ export const getProductCollectionUIStateInEditor = ( { usesReference?: string[] | undefined; attributes: ProductCollectionAttributes; hasInnerBlocks: boolean; -} ): ProductCollectionUIStatesInEditor => { - const isInRequiredLocation = usesReference?.includes( location.type ); - const isCollectionSelected = !! attributes.collection; +} ) => { + // Fetch product to check if it's deleted. + // `product` will be undefined if it doesn't exist. + const productId = attributes.query?.productReference; + const { product, hasResolved } = useSelect( + ( selectFunc ) => { + if ( ! productId ) { + return { product: null, hasResolved: true }; + } - /** - * 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; - } + const { getEntityRecord, hasFinishedResolution } = + selectFunc( coreDataStore ); + const selectorArgs = [ 'postType', 'product', productId ]; + return { + product: getEntityRecord( ...selectorArgs ), + hasResolved: hasFinishedResolution( + 'getEntityRecord', + selectorArgs + ), + }; + }, + [ productId ] + ); + + const productCollectionUIStateInEditor = useMemo( () => { + const isInRequiredLocation = usesReference?.includes( location.type ); + const isCollectionSelected = !! attributes.collection; - /** - * 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. + * Case 1: Product context picker */ - const isArchiveLocationWithTermId = - location.type === LocationType.Archive && - ( location.sourceData?.termId ?? null ) !== null; - const isProductLocationWithProductId = - location.type === LocationType.Product && - ( location.sourceData?.productId ?? null ) !== null; - + const isProductContextRequired = usesReference?.includes( 'product' ); + const isProductContextSelected = + ( attributes.query?.productReference ?? null ) !== null; if ( - ! isArchiveLocationWithTermId && - ! isProductLocationWithProductId + isCollectionSelected && + isProductContextRequired && + ! isInRequiredLocation && + ! isProductContextSelected ) { - return ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW; + return ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER; } - } - /** - * Case 3: Collection chooser - */ - if ( ! hasInnerBlocks && ! isCollectionSelected ) { - return ProductCollectionUIStatesInEditor.COLLECTION_PICKER; - } + // Case 2: Deleted product reference + if ( + isCollectionSelected && + isProductContextRequired && + ! isInRequiredLocation && + isProductContextSelected + ) { + const isProductDeleted = + productId && + ( product === undefined || product?.status === 'trash' ); + if ( isProductDeleted ) { + return ProductCollectionUIStatesInEditor.DELETED_PRODUCT_REFERENCE; + } + } - return ProductCollectionUIStatesInEditor.VALID; + /** + * Case 3: 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 4: Collection chooser + */ + if ( ! hasInnerBlocks && ! isCollectionSelected ) { + return ProductCollectionUIStatesInEditor.COLLECTION_PICKER; + } + + return ProductCollectionUIStatesInEditor.VALID; + }, [ + location.type, + location.sourceData?.termId, + location.sourceData?.productId, + usesReference, + attributes.collection, + productId, + product, + hasInnerBlocks, + attributes.query?.productReference, + ] ); + + return { productCollectionUIStateInEditor, isLoading: ! hasResolved }; }; export const useSetPreviewState = ( { 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 1a87ebeb605..3b94f037eeb 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 @@ -207,7 +207,8 @@ class ProductCollectionPage { } async chooseProductInEditorProductPickerIfAvailable( - pageReference: Page | FrameLocator + pageReference: Page | FrameLocator, + productName = 'Album' ) { const editorProductPicker = pageReference.locator( SELECTORS.productPicker @@ -217,7 +218,7 @@ class ProductCollectionPage { await editorProductPicker .locator( 'label' ) .filter( { - hasText: 'Album', + hasText: productName, } ) .click(); } diff --git a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/register-product-collection-tester.block_theme.spec.ts b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/register-product-collection-tester.block_theme.spec.ts index a7ea710f8a4..6fd09e4050c 100644 --- a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/register-product-collection-tester.block_theme.spec.ts +++ b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/register-product-collection-tester.block_theme.spec.ts @@ -356,4 +356,84 @@ test.describe( 'Product Collection registration', () => { await expect( previewButtonLocator ).toBeHidden(); } ); } ); + + test( 'Product picker should be shown when selected product is deleted', async ( { + pageObject, + admin, + editor, + requestUtils, + page, + } ) => { + // Add a new test product to the database + let testProductId: number | null = null; + const newProduct = await requestUtils.rest( { + method: 'POST', + path: 'wc/v3/products', + data: { + name: 'A Test Product', + price: 10, + }, + } ); + testProductId = newProduct.id; + + 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, + 'A Test Product' + ); + await expect( editorProductPicker ).toBeHidden(); + + await editor.saveDraft(); + + // Delete the product + await requestUtils.rest( { + method: 'DELETE', + path: `wc/v3/products/${ testProductId }`, + } ); + + // Product picker should be shown in Editor + await admin.page.reload(); + const deletedProductPicker = editor.canvas.getByText( + 'Previously selected product' + ); + await expect( deletedProductPicker ).toBeVisible(); + + // Change status from "trash" to "publish" + await requestUtils.rest( { + method: 'PUT', + path: `wc/v3/products/${ testProductId }`, + data: { + status: 'publish', + }, + } ); + + // Product Picker shouldn't be shown as product is available now + await page.reload(); + await expect( editorProductPicker ).toBeHidden(); + + // Delete the product from database, instead of trashing it + await requestUtils.rest( { + method: 'DELETE', + path: `wc/v3/products/${ testProductId }`, + params: { + // Bypass trash and permanently delete the product + force: true, + }, + } ); + + // Product picker should be shown in Editor + await expect( deletedProductPicker ).toBeVisible(); + } ); } ); diff --git a/plugins/woocommerce/changelog/51114-add-44878-product-collection-handling-missing-product-context b/plugins/woocommerce/changelog/51114-add-44878-product-collection-handling-missing-product-context new file mode 100644 index 00000000000..5e5b6821ab3 --- /dev/null +++ b/plugins/woocommerce/changelog/51114-add-44878-product-collection-handling-missing-product-context @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Product Collection: Added Editor UI for missing product reference \ No newline at end of file