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