Commit 22e4ee90c2 for woocommerce
commit 22e4ee90c24640c2865c58117887c1137c8dd360
Author: Karol Manijak <20098064+kmanijak@users.noreply.github.com>
Date: Wed Jan 28 15:08:19 2026 +0100
Product Collection: Handpicked Products should start with product picker (#62989)
* Foundation for product picker
* Updated handpicked product picker
* Move the collection specific controls to the top of filters
* Add changelog
* Update docs etc
* Bring back the comment
* Prefix editor side CSS classes with .wc-block-editor-
* Rename product picker files to be less confusing
* Make Done button always visible but only disabled when no products is chosen
* Update selector in E2E tests
* Rename files to follow kebab-case convention
diff --git a/plugins/woocommerce/changelog/add-58403 b/plugins/woocommerce/changelog/add-58403
new file mode 100644
index 0000000000..f90b0d1a70
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-58403
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Handpicked Products: journey starts with product picker
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/index.tsx
index 701c9be955..115bed117d 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/index.tsx
@@ -2,7 +2,7 @@
* External dependencies
*/
import { store as blockEditorStore } from '@wordpress/block-editor';
-import { useState } from '@wordpress/element';
+import { useState, useEffect } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { useGetLocation } from '@woocommerce/blocks/product-template/utils';
import { Spinner, Flex } from '@wordpress/components';
@@ -11,6 +11,7 @@ import { Spinner, Flex } from '@wordpress/components';
* Internal dependencies
*/
import {
+ CoreCollectionNames,
ProductCollectionContentProps,
ProductCollectionEditComponentProps,
ProductCollectionUIStatesInEditor,
@@ -19,7 +20,8 @@ import ProductCollectionPlaceholder from './product-collection-placeholder';
import ProductCollectionContent from './product-collection-content';
import CollectionSelectionModal from './collection-selection-modal';
import { useProductCollectionUIState } from '../utils';
-import ProductPicker from './ProductPicker';
+import SingleProductPicker from './single-product-picker';
+import MultiProductPicker from './multi-product-picker';
import { useTracksLocation } from '../tracks-utils';
import { useRegisterEmailCollections } from '../hooks/use-register-email-collections';
@@ -32,6 +34,24 @@ const Edit = ( props: ProductCollectionEditComponentProps ) => {
useRegisterEmailCollections();
const [ isSelectionModalOpen, setIsSelectionModalOpen ] = useState( false );
+
+ // Track if the hand-picked products picker is active.
+ // This allows multi-select before clicking "Done".
+ const [ isHandPickedPickerActive, setIsHandPickedPickerActive ] =
+ useState( false );
+
+ const isHandPickedCollection =
+ attributes.collection === CoreCollectionNames.HAND_PICKED;
+ const hasHandPickedProducts =
+ ( attributes.query?.woocommerceHandPickedProducts?.length ?? 0 ) > 0;
+
+ // Activate the picker when Hand-Picked collection is selected with no products
+ useEffect( () => {
+ if ( isHandPickedCollection && ! hasHandPickedProducts ) {
+ setIsHandPickedPickerActive( true );
+ }
+ }, [ isHandPickedCollection, hasHandPickedProducts ] );
+
const hasInnerBlocks = useSelect(
( select ) =>
!! select( blockEditorStore ).getBlocks( clientId ).length,
@@ -65,6 +85,20 @@ const Edit = ( props: ProductCollectionEditComponentProps ) => {
};
const renderComponent = () => {
+ // Show the hand-picked products picker if it's active (local state).
+ // This allows multi-select before clicking "Done".
+ // The inspector controls (HandPickedProductsControlField) are inside
+ // ProductCollectionContent, so they're automatically hidden while
+ // the picker is shown.
+ if ( isHandPickedCollection && isHandPickedPickerActive ) {
+ return (
+ <MultiProductPicker
+ { ...props }
+ onDone={ () => setIsHandPickedPickerActive( false ) }
+ />
+ );
+ }
+
switch ( productCollectionUIStateInEditor ) {
case ProductCollectionUIStatesInEditor.COLLECTION_PICKER:
return (
@@ -75,18 +109,27 @@ const Edit = ( props: ProductCollectionEditComponentProps ) => {
);
case ProductCollectionUIStatesInEditor.PRODUCT_REFERENCE_PICKER:
return (
- <ProductPicker
+ <SingleProductPicker
{ ...props }
isDeletedProductReference={ false }
/>
);
case ProductCollectionUIStatesInEditor.DELETED_PRODUCT_REFERENCE:
return (
- <ProductPicker
+ <SingleProductPicker
{ ...props }
isDeletedProductReference={ true }
/>
);
+ case ProductCollectionUIStatesInEditor.HAND_PICKED_PRODUCTS_PICKER:
+ // This case is hit when no products are selected
+ // and the picker was previously dismissed but products were removed
+ return (
+ <MultiProductPicker
+ { ...props }
+ onDone={ () => setIsHandPickedPickerActive( false ) }
+ />
+ );
case ProductCollectionUIStatesInEditor.VALID:
case ProductCollectionUIStatesInEditor.VALID_WITH_PREVIEW:
return (
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx
index 34ab885334..8b7f55829f 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/index.tsx
@@ -159,6 +159,21 @@ const ProductCollectionInspectorControls = (
location={ props.location }
/>
+ {
+ /**
+ * "Hand-Picked" collection-specific control.
+ * Placed at the top for easy access when editing product selection.
+ * Only rendered when ProductCollectionContent is shown (not during picker).
+ */
+ collection === CoreCollectionNames.HAND_PICKED && (
+ <PanelBody>
+ <HandPickedProductsControlField
+ { ...queryControlProps }
+ />
+ </PanelBody>
+ )
+ }
+
<ToolsPanel
label={ __( 'Settings', 'woocommerce' ) }
resetAll={ () => {
@@ -265,12 +280,14 @@ const isProductCollection = ( blockName: string ) =>
const CollectionSpecificControls = (
props: ProductCollectionEditComponentProps
) => {
- const { collection } = props.attributes;
+ const { attributes, context } = props;
+ const { collection } = attributes;
+
const setQueryAttributeBind = useMemo(
() => setQueryAttribute.bind( null, props ),
[ props ]
);
- const tracksLocation = useTracksLocation( props.context.templateSlug );
+ const tracksLocation = useTracksLocation( context.templateSlug );
const trackInteraction = ( filter: FilterName ) => {
return recordEvent(
'blocks_product_collection_inspector_control_clicked',
@@ -284,7 +301,7 @@ const CollectionSpecificControls = (
const queryControlProps = {
setQueryAttribute: setQueryAttributeBind,
trackInteraction,
- query: props.attributes.query,
+ query: attributes.query,
};
const isByTaxonomy =
@@ -294,18 +311,6 @@ const CollectionSpecificControls = (
return (
<InspectorControls>
- {
- /**
- * "Hand-Picked" collection-specific controls.
- */
- collection === CoreCollectionNames.HAND_PICKED && (
- <PanelBody>
- <HandPickedProductsControlField
- { ...queryControlProps }
- />
- </PanelBody>
- )
- }
{
/**
* "Related Products" collection-specific controls.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/multi-product-picker.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/multi-product-picker.tsx
new file mode 100644
index 0000000000..a029a41839
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/multi-product-picker.tsx
@@ -0,0 +1,80 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Icon, info } from '@wordpress/icons';
+import ProductsControl from '@woocommerce/editor-components/products-control';
+import { Placeholder, Button } from '@wordpress/components';
+import { useBlockProps } from '@wordpress/block-editor';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductCollectionEditComponentProps } from '../types';
+import { getCollectionByName } from '../collections';
+import { setQueryAttribute } from '../utils';
+
+interface MultiProductPickerProps extends ProductCollectionEditComponentProps {
+ onDone: () => void;
+}
+
+const MultiProductPicker = ( props: MultiProductPickerProps ) => {
+ const { attributes, onDone } = props;
+ const blockProps = useBlockProps();
+
+ const collection = getCollectionByName( attributes.collection );
+
+ // Convert string IDs to numbers for ProductsControl.
+ const selectedProductIds = (
+ attributes.query?.woocommerceHandPickedProducts || []
+ ).map( Number );
+
+ const hasSelectedProducts = selectedProductIds.length > 0;
+
+ if ( ! collection ) {
+ return null;
+ }
+
+ return (
+ <div { ...blockProps }>
+ <Placeholder className="wc-block-editor-product-collection__product-picker">
+ <div className="wc-block-editor-product-collection__product-picker-info">
+ { /* @ts-expect-error Icon types are incomplete */ }
+ <Icon
+ icon={ info }
+ className="wc-block-editor-product-collection__info-icon"
+ />
+ <span>
+ { __(
+ 'Select products to display in this collection.',
+ 'woocommerce'
+ ) }
+ </span>
+ </div>
+ <div className="wc-block-editor-product-collection__product-picker-selection">
+ { /* @ts-expect-error Props provided by withSearchedProducts HOC */ }
+ <ProductsControl
+ selected={ selectedProductIds }
+ onChange={ ( value = [] ) => {
+ const ids = value.map( ( { id }: { id: number } ) =>
+ String( id )
+ );
+ setQueryAttribute( props, {
+ woocommerceHandPickedProducts: ids,
+ } );
+ } }
+ />
+ <Button
+ variant="primary"
+ onClick={ onDone }
+ disabled={ ! hasSelectedProducts }
+ >
+ { __( 'Done', 'woocommerce' ) }
+ </Button>
+ </div>
+ </Placeholder>
+ </div>
+ );
+};
+
+export default MultiProductPicker;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/ProductPicker.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/single-product-picker.tsx
similarity index 90%
rename from plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/ProductPicker.tsx
rename to plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/single-product-picker.tsx
index e04e48741a..5a39de6a2d 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/ProductPicker.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/single-product-picker.tsx
@@ -19,7 +19,7 @@ import { useBlockProps } from '@wordpress/block-editor';
import type { ProductCollectionEditComponentProps } from '../types';
import { getCollectionByName } from '../collections';
-const ProductPicker = (
+const SingleProductPicker = (
props: ProductCollectionEditComponentProps & {
isDeletedProductReference: boolean;
}
@@ -52,11 +52,11 @@ const ProductPicker = (
return (
<div { ...blockProps }>
- <Placeholder className="wc-blocks-product-collection__editor-product-picker">
+ <Placeholder className="wc-block-editor-product-collection__product-picker">
<HStack alignment="center">
<Icon
icon={ info }
- className="wc-blocks-product-collection__info-icon"
+ className="wc-block-editor-product-collection__info-icon"
/>
<Text>{ infoText }</Text>
</HStack>
@@ -84,4 +84,4 @@ const ProductPicker = (
);
};
-export default ProductPicker;
+export default SingleProductPicker;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/editor.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/editor.scss
index 06c87fbfb7..86ea6382c3 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/editor.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/editor.scss
@@ -192,14 +192,29 @@ $max-button-width: calc(100% / #{$max-button-columns});
}
// Editor Product Picker
-.wc-blocks-product-collection__editor-product-picker {
- .wc-blocks-product-collection__info-icon {
+.wc-block-editor-product-collection__product-picker {
+ .wc-block-editor-product-collection__info-icon {
fill: var(--wp--preset--color--luminous-vivid-orange, #e26f56);
}
.woocommerce-search-list__search {
margin: 0;
}
+
+ .wc-block-editor-product-collection__product-picker-info {
+ display: flex;
+ align-items: center;
+ gap: $gap-small;
+ }
+
+ .wc-block-editor-product-collection__product-picker-selection {
+ width: 100%;
+ }
+}
+
+// Editor Taxonomy Picker
+.wc-block-editor-product-collection__taxonomy-picker-selection {
+ width: 100%;
}
// Product to Show Radio Control
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts
index 655f5e116d..e8fab2ec71 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/types.ts
@@ -12,6 +12,7 @@ import { WooCommerceBlockLocation } from '../product-template/utils';
export enum ProductCollectionUIStatesInEditor {
COLLECTION_PICKER = 'collection_chooser',
PRODUCT_REFERENCE_PICKER = 'product_context_picker',
+ HAND_PICKED_PRODUCTS_PICKER = 'hand_picked_products_picker',
VALID_WITH_PREVIEW = 'uses_reference_preview_mode',
VALID = 'valid',
DELETED_PRODUCT_REFERENCE = 'deleted_product_reference',
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/utils.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/utils.tsx
index b2e46209c1..6c270f3462 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/utils.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/utils.tsx
@@ -34,6 +34,7 @@ import {
PreviewState,
SetPreviewState,
ProductCollectionUIStatesInEditor,
+ CoreCollectionNames,
} from './types';
import {
coreQueryPaginationBlockName,
@@ -265,7 +266,26 @@ export const useProductCollectionUIState = ( {
}
/**
- * Case 3: Preview mode - based on `usesReference` value
+ * Case 3: Hand-picked products picker
+ * Show the product picker when the Hand-Picked collection is selected
+ * but no products have been chosen yet.
+ */
+ const isHandPickedCollection =
+ attributes.collection === CoreCollectionNames.HAND_PICKED;
+ const hasHandPickedProducts =
+ ( attributes.query?.woocommerceHandPickedProducts?.length ?? 0 ) >
+ 0;
+
+ if (
+ isCollectionSelected &&
+ isHandPickedCollection &&
+ ! hasHandPickedProducts
+ ) {
+ return ProductCollectionUIStatesInEditor.HAND_PICKED_PRODUCTS_PICKER;
+ }
+
+ /**
+ * Case 4: Preview mode - based on `usesReference` value
*/
if ( isInRequiredLocation ) {
/**
@@ -310,6 +330,7 @@ export const useProductCollectionUIState = ( {
product,
hasInnerBlocks,
attributes.query?.productReference,
+ attributes.query?.woocommerceHandPickedProducts,
] );
return { productCollectionUIStateInEditor, isLoading: ! hasResolved };
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-collection/product-collection.page.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-collection/product-collection.page.ts
index 2b24d74ea6..1d16de918f 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-collection/product-collection.page.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-collection/product-collection.page.ts
@@ -56,7 +56,7 @@ export const SELECTORS = {
previewButtonTestID: 'product-collection-preview-button',
collectionPlaceholder:
'[data-type="woocommerce/product-collection"] .components-placeholder',
- productPicker: '.wc-blocks-product-collection__editor-product-picker',
+ productPicker: '.wc-block-editor-product-collection__product-picker',
linkedProductControl: {
button: '.wc-block-product-collection-linked-product-control__button',
popoverContent: