Commit 51c3559a79b for woocommerce
commit 51c3559a79b339ba286b445ebb938c895b43f960
Author: Tung Du <dinhtungdu@gmail.com>
Date: Thu Oct 22 11:44:18 2026 +0700
Add filter support to local Product Collections (#69888)
* feat: parse collection-scoped product filter parameters
* feat: enable filters within local product collections
* fix: preserve overlapping collection filter selections
Amp-Thread-ID: https://ampcode.com/threads/T-01a14026-0de6-715a-b1e4-01dbc9c33451
Co-authored-by: Amp <amp@ampcode.com>
* chore: add changelog for local collection filters
Amp-Thread-ID: https://ampcode.com/threads/T-01a14026-0de6-715a-b1e4-01dbc9c33451
Co-authored-by: Amp <amp@ampcode.com>
* fix: align local collection CI coverage and brand archive controls
Amp-Thread-ID: https://ampcode.com/threads/T-01a14026-0de6-715a-b1e4-01dbc9c33451
Co-authored-by: Amp <amp@ampcode.com>
* fix: preserve collection context in product filters metadata
Declare the context in block.json because metadata registration bypasses the PHP fallback declaration.
Amp-Thread-ID: https://ampcode.com/threads/T-01a14026-0de6-715a-b1e4-01dbc9c33451
Co-authored-by: Amp <amp@ampcode.com>
* test: remove redundant filter test description
Amp-Thread-ID: https://ampcode.com/threads/T-01a14026-0de6-715a-b1e4-01dbc9c33451
Co-authored-by: Amp <amp@ampcode.com>
---------
Co-authored-by: Amp <amp@ampcode.com>
diff --git a/plugins/woocommerce/changelog/add-local-product-collection-filters b/plugins/woocommerce/changelog/add-local-product-collection-filters
new file mode 100644
index 00000000000..441f3fa35a4
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-local-product-collection-filters
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Let shoppers filter individual product collections without changing other collections on the page.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart/inner-blocks/cart-cross-sells-block/test/block.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/cart/inner-blocks/cart-cross-sells-block/test/block.ts
index 72702c5e3cd..faff51c7bdd 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/cart/inner-blocks/cart-cross-sells-block/test/block.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart/inner-blocks/cart-cross-sells-block/test/block.ts
@@ -42,9 +42,7 @@ describe( 'createCrossSellsProductCollection transform function', () => {
...crossSells.attributes.query,
perPage: 3,
} );
- expect( transformedBlock.attributes.hideControls ).toEqual(
- crossSells.attributes.hideControls
- );
+ expect( transformedBlock.attributes.hideControls ).toEqual( [] );
} );
it( 'creates inner blocks from cross-sells template', () => {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/classic-template/single-product.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/classic-template/single-product.ts
index 5a8dbb06867..f20f079834b 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/classic-template/single-product.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/classic-template/single-product.ts
@@ -77,7 +77,6 @@ const getBlockifiedTemplate = () =>
perPage: 5,
pages: 1,
woocommerceStockStatus: [ 'instock', 'onbackorder' ],
- filterable: false,
},
displayLayout: {
type: 'flex',
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/best-sellers.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/best-sellers.tsx
index 95cab5cd23d..60968954b92 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/best-sellers.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/best-sellers.tsx
@@ -36,7 +36,7 @@ const attributes = {
perPage: 5,
pages: 1,
},
- hideControls: [ CoreFilterNames.ORDER, CoreFilterNames.FILTERABLE ],
+ hideControls: [ CoreFilterNames.ORDER ],
};
const heading: InnerBlockTemplate = [
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/by-brand.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/by-brand.tsx
index 2acabdfd5da..e64dabea661 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/by-brand.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/by-brand.tsx
@@ -31,7 +31,7 @@ const attributes = {
columns: 5,
shrinkColumns: true,
},
- hideControls: [ CoreFilterNames.HAND_PICKED, CoreFilterNames.FILTERABLE ],
+ hideControls: [ CoreFilterNames.HAND_PICKED ],
};
const heading: InnerBlockTemplate = [
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/by-category.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/by-category.tsx
index 5c36f36836f..eb3d3a98e97 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/by-category.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/by-category.tsx
@@ -34,7 +34,7 @@ const attributes = {
columns: 5,
shrinkColumns: true,
},
- hideControls: [ CoreFilterNames.HAND_PICKED, CoreFilterNames.FILTERABLE ],
+ hideControls: [ CoreFilterNames.HAND_PICKED ],
};
const heading: InnerBlockTemplate = [
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/by-tag.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/by-tag.tsx
index 612a0017c6d..f308d1aaa4b 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/by-tag.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/by-tag.tsx
@@ -31,7 +31,7 @@ const attributes = {
columns: 5,
shrinkColumns: true,
},
- hideControls: [ CoreFilterNames.HAND_PICKED, CoreFilterNames.FILTERABLE ],
+ hideControls: [ CoreFilterNames.HAND_PICKED ],
};
const heading: InnerBlockTemplate = [
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/cart-contents.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/cart-contents.tsx
index cb3a49c496a..1bb557519ed 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/cart-contents.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/cart-contents.tsx
@@ -50,7 +50,6 @@ const attributes = {
CoreFilterNames.STOCK_STATUS,
CoreFilterNames.HAND_PICKED,
CoreFilterNames.TAXONOMY,
- CoreFilterNames.FILTERABLE,
CoreFilterNames.CREATED,
CoreFilterNames.PRICE_RANGE,
],
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/cross-sells.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/cross-sells.tsx
index 4014ab85d5a..a3495a0fe38 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/cross-sells.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/cross-sells.tsx
@@ -16,7 +16,7 @@ import {
DEFAULT_QUERY,
DEFAULT_ATTRIBUTES,
} from '../constants';
-import { CoreCollectionNames, CoreFilterNames } from '../types';
+import { CoreCollectionNames } from '../types';
const collection = {
name: CoreCollectionNames.CROSS_SELLS,
@@ -43,7 +43,6 @@ export const attributes = {
perPage: 8,
pages: 1,
},
- hideControls: [ CoreFilterNames.FILTERABLE ],
};
const heading: InnerBlockTemplate = [
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/featured.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/featured.tsx
index a1c7d52d406..a73fe841fba 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/featured.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/featured.tsx
@@ -34,7 +34,7 @@ const attributes = {
perPage: 5,
pages: 1,
},
- hideControls: [ CoreFilterNames.FEATURED, CoreFilterNames.FILTERABLE ],
+ hideControls: [ CoreFilterNames.FEATURED ],
};
const heading: InnerBlockTemplate = [
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/hand-picked.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/hand-picked.tsx
index 1b46c5fb727..6ccf5e3916c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/hand-picked.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/hand-picked.tsx
@@ -72,11 +72,7 @@ const attributes = {
query: {
orderBy: 'post__in',
},
- hideControls: [
- CoreFilterNames.HAND_PICKED,
- CoreFilterNames.FILTERABLE,
- CoreFilterNames.ORDER,
- ],
+ hideControls: [ CoreFilterNames.HAND_PICKED, CoreFilterNames.ORDER ],
};
const heading: InnerBlockTemplate = [
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/new-arrivals.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/new-arrivals.tsx
index 5e2b84bc118..a14ad30f027 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/new-arrivals.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/new-arrivals.tsx
@@ -44,7 +44,7 @@ const attributes = {
value: '-7 days',
},
},
- hideControls: [ CoreFilterNames.ORDER, CoreFilterNames.FILTERABLE ],
+ hideControls: [ CoreFilterNames.ORDER ],
};
const heading: InnerBlockTemplate = [
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/on-sale.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/on-sale.tsx
index c67f2ed84a0..ea8860dea9e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/on-sale.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/on-sale.tsx
@@ -37,7 +37,7 @@ const attributes = {
perPage: 5,
pages: 1,
},
- hideControls: [ CoreFilterNames.ON_SALE, CoreFilterNames.FILTERABLE ],
+ hideControls: [ CoreFilterNames.ON_SALE ],
};
const heading: InnerBlockTemplate = [
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/top-rated.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/top-rated.tsx
index 5bc4590abe2..b742913da50 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/top-rated.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/top-rated.tsx
@@ -38,7 +38,7 @@ const attributes = {
perPage: 5,
pages: 1,
},
- hideControls: [ CoreFilterNames.ORDER, CoreFilterNames.FILTERABLE ],
+ hideControls: [ CoreFilterNames.ORDER ],
};
const heading: InnerBlockTemplate = [
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/upsells.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/upsells.tsx
index 707aa51edfa..1bd8efdc75c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/upsells.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/collections/upsells.tsx
@@ -16,7 +16,7 @@ import {
DEFAULT_QUERY,
DEFAULT_ATTRIBUTES,
} from '../constants';
-import { CoreCollectionNames, CoreFilterNames } from '../types';
+import { CoreCollectionNames } from '../types';
const collection = {
name: CoreCollectionNames.UPSELLS,
@@ -43,7 +43,6 @@ const attributes = {
perPage: 8,
pages: 1,
},
- hideControls: [ CoreFilterNames.FILTERABLE ],
};
const heading: InnerBlockTemplate = [
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/constants.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/constants.ts
index 8a0f856bc6b..6ba6060f014 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/constants.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/constants.ts
@@ -62,7 +62,6 @@ export const DEFAULT_QUERY: ProductCollectionQuery = {
woocommerceHandPickedProducts: [],
timeFrame: undefined,
priceRange: undefined,
- filterable: false,
relatedBy: {
categories: true,
tags: true,
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 9190fd49b0c..7ac6d944b35 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
@@ -25,10 +25,7 @@ import {
} from '../../types';
import { setQueryAttribute, getDefaultSettings } from '../../utils';
import ColumnsControl from './columns-control';
-import {
- InheritQueryControl,
- FilterableControl,
-} from './use-page-context-control';
+import { InheritQueryControl } from './use-page-context-control';
import useCarouselLayoutAdjustments from './use-carousel-layout-adjustments';
import useEmailPaginationAdjustments from './use-email-pagination-adjustments';
import useEmailColumnAdjustments from './use-email-column-adjustments';
@@ -93,8 +90,6 @@ const ProductCollectionInspectorControls = (
const showCustomQueryControls = inherit === false;
const showInheritQueryControl =
isArchiveTemplate && shouldShowFilter( CoreFilterNames.INHERIT );
- const showFilterableControl =
- ! isArchiveTemplate && shouldShowFilter( CoreFilterNames.FILTERABLE );
const showCustomOrderControl =
showCustomQueryControls && shouldShowFilter( CoreFilterNames.ORDER );
const showDefaultOrderControl = ! showCustomQueryControls;
@@ -204,9 +199,6 @@ const ProductCollectionInspectorControls = (
{ showInheritQueryControl && (
<InheritQueryControl { ...queryControlProps } />
) }
- { showFilterableControl && (
- <FilterableControl { ...queryControlProps } />
- ) }
{ showCustomOrderControl && (
<CustomQueryOrderByControl { ...queryControlProps } />
) }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/use-page-context-control.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/use-page-context-control.tsx
index 7053fe6e7e5..9f8a14a5e70 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/use-page-context-control.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/inspector-controls/use-page-context-control.tsx
@@ -25,25 +25,18 @@ import {
type QueryControlProps,
} from '../../types';
import { DEFAULT_QUERY } from '../../constants';
-import {
- getDefaultValueOfInherit,
- getDefaultValueOfFilterable,
-} from '../../utils';
+import { getDefaultValueOfInherit } from '../../utils';
const label = __( 'Query type', 'woocommerce' );
const defaultOptionLabel = __( 'Default', 'woocommerce' );
const customOptionLabel = __( 'Custom', 'woocommerce' );
const defaultInheritHelpText = __(
- 'Display products based on the current template and allow shoppers to filter.',
- 'woocommerce'
-);
-const defaultFilterableHelpText = __(
- 'Show products based on specific criteria and allow shoppers to filter.',
+ 'Display products based on the current template.',
'woocommerce'
);
const customHelpText = __(
- 'Show a list of products based on fixed criteria.',
+ 'Show products based on specific criteria.',
'woocommerce'
);
@@ -114,53 +107,4 @@ const InheritQueryControl = ( {
);
};
-const FilterableControl = ( {
- setQueryAttribute,
- trackInteraction,
- query,
-}: QueryControlProps ) => {
- const filterable = query?.filterable;
-
- const defaultValue = useMemo( () => getDefaultValueOfFilterable(), [] );
-
- return (
- <ToolsPanelItem
- label={ label }
- hasValue={ () => filterable !== defaultValue }
- isShownByDefault
- onDeselect={ () => {
- setQueryAttribute( {
- filterable: defaultValue,
- } );
- trackInteraction( CoreFilterNames.FILTERABLE );
- } }
- >
- <ToggleGroupControl
- __next40pxDefaultSize
- __nextHasNoMarginBottom
- className="wc-block-product-collection__inherit-query-control"
- isBlock
- label={ label }
- help={ filterable ? defaultFilterableHelpText : customHelpText }
- value={ !! filterable ? 'default' : 'custom' }
- onChange={ ( value: 'default' | 'custom' ) => {
- setQueryAttribute( {
- filterable: value === 'default',
- } );
- trackInteraction( CoreFilterNames.FILTERABLE );
- } }
- >
- <ToggleGroupControlOption
- value="default"
- label={ defaultOptionLabel }
- />
- <ToggleGroupControlOption
- value="custom"
- label={ customOptionLabel }
- />
- </ToggleGroupControl>
- </ToolsPanelItem>
- );
-};
-
-export { FilterableControl, InheritQueryControl };
+export { InheritQueryControl };
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx
index 5f8788102b3..4e7fc94bc3c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/edit/product-collection-content.tsx
@@ -24,11 +24,7 @@ import {
WidthOptions,
} from '../types';
import { DEFAULT_ATTRIBUTES, INNER_BLOCKS_TEMPLATE } from '../constants';
-import {
- getDefaultValueOfInherit,
- getDefaultValueOfFilterable,
- useSetPreviewState,
-} from '../utils';
+import { getDefaultValueOfInherit, useSetPreviewState } from '../utils';
import InspectorControls from './inspector-controls';
import InspectorAdvancedControls from './inspector-advanced-controls';
import ToolbarControls from './toolbar-controls';
@@ -116,7 +112,6 @@ const ProductCollectionContent = ( {
query: {
...( DEFAULT_ATTRIBUTES.query as ProductCollectionQuery ),
inherit: getDefaultValueOfInherit(),
- filterable: getDefaultValueOfFilterable(),
},
...( attributes as Partial< ProductCollectionAttributes > ),
queryId,
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/editor-contracts.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/editor-contracts.tsx
index 345553c2667..10d7b26d9f1 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/editor-contracts.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/editor-contracts.tsx
@@ -36,7 +36,6 @@ import {
} from '../constants';
import {
addProductCollectionToQueryPaginationParentOrAncestor,
- getDefaultValueOfFilterable,
getDefaultValueOfInherit,
} from '../utils';
import {
@@ -250,30 +249,6 @@ describe( 'Product Collection page-context defaults', () => {
property: 'inherit',
templateSlug: 'taxonomy-product_brand',
},
- {
- caseName: 'post filtering',
- getDefault: getDefaultValueOfFilterable,
- property: 'filterable',
- templateSlug: 'post',
- },
- {
- caseName: 'home filtering',
- getDefault: getDefaultValueOfFilterable,
- property: 'filterable',
- templateSlug: 'home',
- },
- {
- caseName: 'index filtering',
- getDefault: getDefaultValueOfFilterable,
- property: 'filterable',
- templateSlug: 'index',
- },
- {
- caseName: 'Single Product filtering',
- getDefault: getDefaultValueOfFilterable,
- property: 'filterable',
- templateSlug: 'single-product',
- },
] as const )(
'allows only the first collection to own $caseName',
( { getDefault, property, templateSlug } ) => {
@@ -316,7 +291,6 @@ describe( 'Product Collection page-context defaults', () => {
const createPreviewAttributes = (): ProductCollectionAttributes => ( {
...DEFAULT_ATTRIBUTES,
convertedFromProducts: false,
- filterable: false,
hideControls: [],
query: {
...DEFAULT_QUERY,
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/tracks-utils.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/tracks-utils.ts
index b583a7a3e8d..8c96852d5d6 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/tracks-utils.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/tracks-utils.ts
@@ -22,6 +22,7 @@ const templateSlugToTemplateMap: {
'archive-product': Locations.PRODUCT_CATALOG,
'taxonomy-product_cat': Locations.PRODUCT_ARCHIVE,
'taxonomy-product_tag': Locations.PRODUCT_ARCHIVE,
+ 'taxonomy-product_brand': Locations.PRODUCT_ARCHIVE,
'taxonomy-product_attribute': Locations.PRODUCT_ARCHIVE,
'product-search-results': Locations.PRODUCT_ARCHIVE,
'order-confirmation': Locations.ORDER_CONFIRMATION,
@@ -55,7 +56,8 @@ export const useTracksLocation = ( templateSlug: string | undefined ) => {
if (
templateSlug.includes( 'taxonomy-product_cat' ) ||
- templateSlug.includes( 'taxonomy-product_tag' )
+ templateSlug.includes( 'taxonomy-product_tag' ) ||
+ templateSlug.includes( 'taxonomy-product_brand' )
) {
return Locations.PRODUCT_ARCHIVE;
}
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 d4fdb2b4317..d1cb54043f8 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
@@ -41,7 +41,6 @@ export interface ProductCollectionAttributes {
*/
queryContextIncludes: string[];
forcePageReload: boolean;
- filterable: boolean;
}
export enum LayoutOptions {
@@ -116,7 +115,6 @@ export interface ProductCollectionQuery {
isProductCollectionBlock: boolean;
woocommerceHandPickedProducts: string[];
priceRange: undefined | PriceRange;
- filterable: boolean;
productReference?: number;
/**
* If `null`/`undefined`: Infer from location context.
@@ -207,7 +205,6 @@ export enum CoreFilterNames {
STOCK_STATUS = 'stock-status',
TAXONOMY = 'taxonomy',
PRICE_RANGE = 'price-range',
- FILTERABLE = 'filterable',
PRODUCTS_PER_PAGE = 'products-per-page',
MAX_PAGES_TO_SHOW = 'max-pages-to-show',
OFFSET = 'offset',
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 1a440a559f7..65727800303 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
@@ -125,9 +125,7 @@ const isInProductArchive = () => {
return false;
};
-const isFirstBlockThatUsesPageContext = (
- property: 'inherit' | 'filterable'
-) => {
+const isFirstBlockThatUsesPageContext = () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore No types for this exist yet, natively.
const { getBlocksByName, getBlock } = select( blockEditorStore );
@@ -139,7 +137,7 @@ const isFirstBlockThatUsesPageContext = (
( clientId ) => {
const block = getBlock( clientId );
- return block.attributes?.query?.[ property ];
+ return block.attributes?.query?.inherit;
}
);
@@ -147,15 +145,7 @@ const isFirstBlockThatUsesPageContext = (
};
export function getDefaultValueOfInherit() {
- return isInProductArchive()
- ? isFirstBlockThatUsesPageContext( 'inherit' )
- : false;
-}
-
-export function getDefaultValueOfFilterable() {
- return ! isInProductArchive()
- ? isFirstBlockThatUsesPageContext( 'filterable' )
- : false;
+ return isInProductArchive() ? isFirstBlockThatUsesPageContext() : false;
}
/**
@@ -501,7 +491,6 @@ export const getDefaultQueryForSettingsSection = (
orderBy: DEFAULT_QUERY.orderBy as TProductCollectionOrderBy,
order: DEFAULT_QUERY.order as TProductCollectionOrder,
inherit: getDefaultValueOfInherit(),
- filterable: getDefaultValueOfFilterable(),
perPage: DEFAULT_QUERY.perPage,
offset: DEFAULT_QUERY.offset,
pages: DEFAULT_QUERY.pages,
@@ -526,7 +515,6 @@ export const getDefaultProductCollection = () =>
query: {
...DEFAULT_ATTRIBUTES.query,
inherit: getDefaultValueOfInherit(),
- filterable: getDefaultValueOfFilterable(),
},
},
createBlocksFromInnerBlocksTemplate( INNER_BLOCKS_TEMPLATE )
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts
index 40142c6b57d..45a97d33510 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/__tests__/frontend.test.ts
@@ -302,19 +302,20 @@ describe( 'product filters interactivity store', () => {
// producing `%25D4%25B1%25D5%25B8%25D6%2582%25D5%25A4%25D5%25AB%25D5%25B8%25D5%25A3%25D5%25AB%25D6%2580%25D6%2584`
// instead of the intended `%D4%B1%D5%B8%D6%82%D5%A4%D5%AB%D5%B8%D5%A3%D5%AB%D6%80%D6%84`.
expectedUrl:
- 'https://example.com/shop/?color=%D4%B1%D5%B8%D6%82%D5%A4%D5%AB%D5%B8%D5%A3%D5%AB%D6%80%D6%84',
+ 'https://example.com/shop/?existing=1&color=%D4%B1%D5%B8%D6%82%D5%A4%D5%AB%D5%B8%D5%A3%D5%AB%D6%80%D6%84',
},
{
description: 'latin value',
label: 'Blue',
value: 'blue',
- expectedUrl: 'https://example.com/shop/?color=blue',
+ expectedUrl: 'https://example.com/shop/?existing=1&color=blue',
},
{
description: 'malformed encoded value',
label: 'Invalid',
value: '%E0%A4%A',
- expectedUrl: 'https://example.com/shop/?color=%25E0%25A4%25A',
+ expectedUrl:
+ 'https://example.com/shop/?existing=1&color=%25E0%25A4%25A',
expectConsoleWarn: true,
},
].forEach(
@@ -409,6 +410,171 @@ describe( 'product filters interactivity store', () => {
}
);
+ it.each( [ 0, 17 ] )(
+ 'scopes navigation and clearing to collection %i',
+ ( queryId ) => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Product filters store was not registered.' );
+ }
+ window.history.replaceState(
+ {},
+ '',
+ `https://example.com/shop/?min_price=5&wcp-${ queryId }-min_price=10&wcp-${ queryId }-maker=old&wcp-28-min_price=60&query-${ queryId }-page=3&query-28-page=2&campaign=spring`
+ );
+ const context = {
+ queryId,
+ params: { min_price: '10', maker: 'old' },
+ activeFilters: [
+ {
+ type: 'taxonomy/product_brand',
+ value: 'acme',
+ activeLabel: 'Acme',
+ },
+ ],
+ forcePageReload: true,
+ };
+ mockGetContext.mockReturnValue( context );
+ mockGetServerContext.mockReturnValue( context );
+ mockGetConfig.mockReturnValue( {
+ canonicalUrl: 'https://example.com/shop/',
+ filterParamKeys: [ 'min_price', 'maker' ],
+ taxonomyParamsMap: { product_brand: 'maker' },
+ } );
+
+ expect( mockRegisteredStore.state.params ).toEqual( {
+ maker: 'acme',
+ } );
+ mockRegisteredStore.actions.navigate().next();
+ expect( mockReload ).toHaveBeenLastCalledWith(
+ `https://example.com/shop/?min_price=5&wcp-28-min_price=60&query-28-page=2&campaign=spring&wcp-${ queryId }-maker=acme`
+ );
+
+ context.activeFilters = [];
+ mockRegisteredStore.actions.navigate().next();
+ expect( mockReload ).toHaveBeenLastCalledWith(
+ 'https://example.com/shop/?min_price=5&wcp-28-min_price=60&query-28-page=2&campaign=spring'
+ );
+ }
+ );
+
+ it.each( [ 'completion', 'failure', 'history', 'other navigation' ] )(
+ 'preserves overlapping changes and resets pending state on %s',
+ ( finish ) => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Product filters store was not registered.' );
+ }
+ const { actions } = mockRegisteredStore;
+ window.history.replaceState(
+ {},
+ '',
+ 'https://example.com/shop/?query-0-page=2&query-28-page=3'
+ );
+ mockGetConfig.mockReturnValue( {
+ filterParamKeys: [ 'categories' ],
+ taxonomyParamsMap: { product_cat: 'categories' },
+ } );
+ const routerNavigate = jest.fn();
+ const router = {
+ actions: { navigate: routerNavigate },
+ } as unknown as typeof import( '@wordpress/interactivity-router' );
+ const navigate = ( queryId: number, value: string ) => {
+ const context = {
+ queryId,
+ params: {},
+ activeFilters: value
+ ? [ { type: 'taxonomy/product_cat', value } ]
+ : [],
+ };
+ mockGetContext.mockReturnValue( context );
+ mockGetServerContext.mockReturnValue( context );
+ const iterator = actions.navigate();
+ iterator.next();
+ iterator.next( router );
+ return iterator;
+ };
+
+ const first = navigate( 0, 'shirts' );
+ const second = navigate( 28, 'hats' );
+ expect( routerNavigate ).toHaveBeenLastCalledWith(
+ 'https://example.com/shop/?wcp-0-categories=shirts&wcp-28-categories=hats'
+ );
+ first.next();
+ const clear = navigate( 0, '' );
+ expect( routerNavigate ).toHaveBeenLastCalledWith(
+ 'https://example.com/shop/?wcp-28-categories=hats'
+ );
+ second.next();
+ let error;
+ try {
+ if ( finish === 'failure' ) {
+ clear.throw( new Error( 'Navigation failed' ) );
+ }
+ } catch ( caught ) {
+ error = caught;
+ }
+ expect( error ).toEqual(
+ finish === 'failure'
+ ? new Error( 'Navigation failed' )
+ : undefined
+ );
+ if ( finish === 'history' ) {
+ window.dispatchEvent( new PopStateEvent( 'popstate' ) );
+ } else if ( finish === 'other navigation' ) {
+ window.history.replaceState( {}, '', '/shop/?campaign=summer' );
+ } else {
+ clear.next();
+ }
+ const next = navigate( 0, 'boots' );
+ expect( routerNavigate ).toHaveBeenLastCalledWith(
+ finish === 'other navigation'
+ ? 'https://example.com/shop/?campaign=summer&wcp-0-categories=boots'
+ : 'https://example.com/shop/?query-28-page=3&wcp-0-categories=boots'
+ );
+ next.next();
+ }
+ );
+
+ it( 'does not restore cleared local parameters from the global canonical URL', () => {
+ if ( ! mockRegisteredStore ) {
+ throw new Error( 'Product filters store was not registered.' );
+ }
+ window.history.replaceState( {}, '', '/shop/?wcp-0-categories=shirts' );
+ mockGetConfig.mockReturnValue( {
+ canonicalUrl: 'https://example.com/shop/?wcp-0-categories=shirts',
+ filterParamKeys: [ 'categories' ],
+ taxonomyParamsMap: { product_cat: 'categories' },
+ } );
+ mockGetContext.mockReturnValue( { activeFilters: [] } );
+ mockGetServerContext.mockReturnValue( { queryId: 0, params: {} } );
+ const local = mockRegisteredStore.actions.navigate();
+ local.next();
+ mockGetServerContext.mockReturnValue( { params: {} } );
+ mockGetContext.mockReturnValue( {
+ activeFilters: [ { type: 'taxonomy/product_cat', value: 'hats' } ],
+ } );
+ const global = mockRegisteredStore.actions.navigate();
+ global.next();
+ const navigate = jest.fn();
+ const router = {
+ actions: { navigate },
+ } as unknown as typeof import( '@wordpress/interactivity-router' );
+ global.next( router );
+ local.next( router );
+ expect( navigate.mock.calls ).toEqual( [
+ [ 'https://example.com/shop/?categories=hats' ],
+ ] );
+ window.history.replaceState( {}, '', '/shop/?categories=hats' );
+ global.next();
+ mockGetContext.mockReturnValue( { activeFilters: [] } );
+ const clear = mockRegisteredStore.actions.navigate();
+ clear.next();
+ clear.next( router );
+ expect( navigate ).toHaveBeenLastCalledWith(
+ 'https://example.com/shop/'
+ );
+ clear.next();
+ } );
+
it( 'triggers a full-page reload instead of router when forcePageReload is true', () => {
if ( ! mockRegisteredStore ) {
throw new Error( 'Product filters store was not registered.' );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/block.json
index c1aacb6e77d..59c8bd1f30e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/block.json
@@ -37,7 +37,14 @@
}
},
"textdomain": "woocommerce",
- "usesContext": [ "postId", "query", "queryId", "forcePageReload" ],
+ "usesContext": [
+ "postId",
+ "query",
+ "queryId",
+ "collection",
+ "productCollectionLocation",
+ "forcePageReload"
+ ],
"attributes": {
"isPreview": {
"type": "boolean",
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts
index 2b066fe306d..9d937799965 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/frontend.ts
@@ -30,6 +30,12 @@ const {
const BLOCK_NAME = PRODUCT_FILTERS_STORE_NAME;
+let pendingNavigation: { source: string; destination: string } | undefined;
+
+window.addEventListener( 'popstate', () => {
+ pendingNavigation = undefined;
+} );
+
type ValidFilterOptionItem = FilterOptionItem & {
type: string;
value: string;
@@ -279,11 +285,28 @@ const productFiltersStore = {
: getContext< ProductFiltersContext >();
const config = getConfig( BLOCK_NAME );
- const url = new URL( config.canonicalUrl );
+ const { queryId } = context;
+ const prefix = queryId === undefined ? '' : `wcp-${ queryId }-`;
+ const currentUrl =
+ pendingNavigation?.source === window.location.href
+ ? pendingNavigation.destination
+ : window.location.href;
+ const url = new URL(
+ queryId === undefined ? config.canonicalUrl : currentUrl
+ );
+ url.search = new URL( currentUrl ).search;
const { searchParams } = url;
- for ( const key in context.params ) {
- searchParams.delete( key );
+ if ( queryId === undefined ) {
+ searchParams.delete( 'paged' );
+ searchParams.delete( 'product-page' );
+ } else {
+ searchParams.delete( `query-${ queryId }-page` );
+ }
+
+ for ( const key of config.filterParamKeys ??
+ Object.keys( context.params ) ) {
+ searchParams.delete( `${ prefix }${ key }` );
}
for ( const key in state.params ) {
@@ -303,10 +326,10 @@ const productFiltersStore = {
}
}
- searchParams.set( key, decodedValue );
+ searchParams.set( `${ prefix }${ key }`, decodedValue );
}
- if ( window.location.href === url.href ) {
+ if ( currentUrl === url.href ) {
return;
}
@@ -323,10 +346,23 @@ const productFiltersStore = {
return;
}
- const routerModule: typeof import( '@wordpress/interactivity-router' ) =
- yield import( '@wordpress/interactivity-router' );
-
- yield routerModule.actions.navigate( url.href );
+ const navigation = {
+ source: window.location.href,
+ destination: url.href,
+ };
+ pendingNavigation = navigation;
+ try {
+ const routerModule: typeof import( '@wordpress/interactivity-router' ) =
+ yield import( '@wordpress/interactivity-router' );
+
+ if ( pendingNavigation === navigation ) {
+ yield routerModule.actions.navigate( url.href );
+ }
+ } finally {
+ if ( pendingNavigation === navigation ) {
+ pendingNavigation = undefined;
+ }
+ }
},
},
callbacks: {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/block.json
index f47cfdf2176..3dc9e97ea72 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/block.json
@@ -53,7 +53,14 @@
}
}
},
- "usesContext": [ "query", "filterParams", "productFiltersKey" ],
+ "usesContext": [
+ "query",
+ "queryId",
+ "collection",
+ "productCollectionLocation",
+ "filterParams",
+ "productFiltersKey"
+ ],
"attributes": {
"attributeId": {
"type": "number",
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/block.json
index 1852f94d674..653373cfdfa 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/price-filter/block.json
@@ -12,6 +12,13 @@
"interactivity": true,
"html": false
},
- "usesContext": [ "query", "filterParams", "productFiltersKey" ],
+ "usesContext": [
+ "query",
+ "queryId",
+ "collection",
+ "productCollectionLocation",
+ "filterParams",
+ "productFiltersKey"
+ ],
"viewScriptModule": "woocommerce/product-filter-price"
}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/rating-filter/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/rating-filter/block.json
index 303c990803d..78eb10db2c8 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/rating-filter/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/rating-filter/block.json
@@ -12,7 +12,14 @@
}
},
"ancestor": [ "woocommerce/product-filters" ],
- "usesContext": [ "query", "filterParams", "productFiltersKey" ],
+ "usesContext": [
+ "query",
+ "queryId",
+ "collection",
+ "productCollectionLocation",
+ "filterParams",
+ "productFiltersKey"
+ ],
"attributes": {
"className": {
"type": "string",
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/status-filter/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/status-filter/block.json
index 975c7ec1daa..da4c3a40bed 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/status-filter/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/status-filter/block.json
@@ -71,7 +71,14 @@
"default": true
}
},
- "usesContext": [ "query", "filterParams", "productFiltersKey" ],
+ "usesContext": [
+ "query",
+ "queryId",
+ "collection",
+ "productCollectionLocation",
+ "filterParams",
+ "productFiltersKey"
+ ],
"example": {
"attributes": {
"isPreview": true
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/block.json
index c9fb404d605..4f983aedeb4 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/taxonomy-filter/block.json
@@ -53,7 +53,14 @@
}
}
},
- "usesContext": [ "query", "filterParams", "productFiltersKey" ],
+ "usesContext": [
+ "query",
+ "queryId",
+ "collection",
+ "productCollectionLocation",
+ "filterParams",
+ "productFiltersKey"
+ ],
"attributes": {
"taxonomy": {
"type": "string",
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
index 76f8863a4bb..bcf9127ce7d 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
@@ -36,6 +36,7 @@ export type ActiveFilterItem = {
export type ProductFiltersContext = {
isOverlayOpened: boolean;
+ queryId?: number;
openButtonRef?: HTMLElement | null;
params: Record< string, string >;
activeFilters: ActiveFilterItem[];
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-registry/product-collection/test/register-product-collection.tsx b/plugins/woocommerce/client/blocks/packages/public-api/blocks-registry/product-collection/test/register-product-collection.tsx
index de780b009f4..12d3d976c24 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-registry/product-collection/test/register-product-collection.tsx
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-registry/product-collection/test/register-product-collection.tsx
@@ -278,7 +278,6 @@ describe( '__experimentalRegisterProductCollection', () => {
query: {
exclude: [],
featured: false,
- filterable: false,
inherit: false,
isProductCollectionBlock: true,
offset: 0,
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Controller.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Controller.php
index 9f965dbc9b5..b1d7a9e557d 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Controller.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Controller.php
@@ -392,10 +392,9 @@ class Controller extends AbstractBlock {
$block_context_query['tax_query'] = ! empty( $query['tax_query'] ) ? $query['tax_query'] : array();
$block_context_query['queryId'] = $block->context['queryId'] ?? null;
- $inherit = $block->context['query']['inherit'] ?? false;
- $filterable = $block->context['query']['filterable'] ?? false;
-
- $is_exclude_applied_filters = ! ( $inherit || $filterable );
+ if ( ! ( $block_context_query['inherit'] ?? false ) ) {
+ $block_context_query['filterParams'] = wc_get_container()->get( \Automattic\WooCommerce\Internal\ProductFilters\Params::class )->get_filter_params( $block_context_query['queryId'] );
+ }
$collection_args = array(
'name' => $block->context['collection'] ?? '',
@@ -406,8 +405,7 @@ class Controller extends AbstractBlock {
return $this->query_builder->get_final_frontend_query(
$collection_args,
$block_context_query,
- $page,
- $is_exclude_applied_filters
+ $page
);
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
index a0b45460736..8672a4d0f1f 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
@@ -204,6 +204,7 @@ class QueryBuilder {
'timeFrame' => $time_frame,
'priceRange' => $price_range,
'queryId' => $query_id,
+ 'filterParams' => $query['filterParams'] ?? null,
),
$is_exclude_applied_filters
);
@@ -313,8 +314,9 @@ class QueryBuilder {
if ( ! $is_exclude_applied_filters ) {
// Rating must be a tax query; the remaining filters are query vars consumed by QueryClauses.
- $final_query = $this->merge_queries( $final_query, $this->get_filter_by_rating_query() );
- $final_query = array_merge( $final_query, $this->get_applied_filter_query_vars() );
+ $filter_params = $query['filterParams'] ?? null;
+ $final_query = $this->merge_queries( $final_query, $this->get_filter_by_rating_query( $filter_params ) );
+ $final_query = array_merge( $final_query, $this->get_applied_filter_query_vars( $filter_params ) );
}
return $final_query;
@@ -512,10 +514,11 @@ class QueryBuilder {
/**
* Return a query that filters products by rating.
*
+ * @param array|null $filter_params Local selections, or null for global query vars.
* @return array
*/
- private function get_filter_by_rating_query() {
- $filter_rating_values = get_query_var( RatingFilter::RATING_QUERY_VAR );
+ private function get_filter_by_rating_query( ?array $filter_params = null ) {
+ $filter_rating_values = null === $filter_params ? get_query_var( RatingFilter::RATING_QUERY_VAR ) : ( $filter_params[ RatingFilter::RATING_QUERY_VAR ] ?? '' );
if ( empty( $filter_rating_values ) ) {
return array();
}
@@ -616,9 +619,10 @@ class QueryBuilder {
/**
* Get the active Product Filter URL query variables.
*
+ * @param array|null $filter_params Local selections, or null for global query vars.
* @return array
*/
- private function get_applied_filter_query_vars(): array {
+ private function get_applied_filter_query_vars( ?array $filter_params = null ): array {
$params = wc_get_container()->get( Params::class );
$filter_query_vars = array();
$registered_attribute_params = $params->get_param( 'attribute' );
@@ -635,14 +639,14 @@ class QueryBuilder {
continue;
}
- $value = get_query_var( $param_key );
+ $value = null === $filter_params ? get_query_var( $param_key ) : ( $filter_params[ $param_key ] ?? '' );
if ( ( is_string( $value ) || is_numeric( $value ) ) && '' !== (string) $value ) {
$filter_query_vars[ $param_key ] = (string) $value;
}
}
foreach ( $this->get_filter_by_attributes_query_vars() as $attribute_name => $query_args ) {
- $filter_value = get_query_var( $query_args['filter'] );
+ $filter_value = null === $filter_params ? get_query_var( $query_args['filter'] ) : ( $filter_params[ $query_args['filter'] ] ?? '' );
if ( ( ! is_string( $filter_value ) && ! is_numeric( $filter_value ) ) || '' === (string) $filter_value ) {
continue;
}
@@ -651,7 +655,7 @@ class QueryBuilder {
$attribute_name = preg_replace( '/^(filter_|pa_)/', '', $query_args['filter'] );
}
$attribute_name = wc_sanitize_taxonomy_name( $attribute_name );
- $query_type = get_query_var( $query_args['query_type'] );
+ $query_type = null === $filter_params ? get_query_var( $query_args['query_type'] ) : ( $filter_params[ $query_args['query_type'] ] ?? '' );
$filter_query_vars[ AttributeFilter::FILTER_QUERY_VAR_PREFIX . $attribute_name ] = (string) $filter_value;
$filter_query_vars[ AttributeFilter::QUERY_TYPE_QUERY_VAR_PREFIX . $attribute_name ] = is_string( $query_type ) && in_array( $query_type, array( 'and', 'or' ), true )
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Renderer.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Renderer.php
index 7973e7913ff..195350e4b0f 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Renderer.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductCollection/Renderer.php
@@ -86,6 +86,17 @@ class Renderer {
100,
1
);
+ add_filter(
+ 'render_block_woocommerce/product-filters',
+ function ( $html, $parsed_block, $instance ) {
+ if ( $instance->context['query']['isProductCollectionBlock'] ?? false ) {
+ $this->render_state['has_filters'] = ! empty( $html );
+ }
+ return $html;
+ },
+ 10,
+ 3
+ );
add_filter( 'render_block_core/query-pagination', array( $this, 'add_navigation_link_directives' ), 10, 3 );
add_filter( 'render_block_context', array( $this, 'handle_render_block_context' ), 11, 3 );
}
@@ -179,7 +190,7 @@ class Renderer {
* @return bool
*/
private function should_prevent_render() {
- return ! $this->render_state['has_results'] && ! $this->render_state['has_no_results_block'];
+ return ! $this->render_state['has_results'] && ! $this->render_state['has_no_results_block'] && empty( $this->render_state['has_filters'] );
}
/**
@@ -476,6 +487,11 @@ class Renderer {
return $context;
}
+ if ( 'woocommerce/product-filters' === $block_name && ( $context['query']['inherit'] ?? false ) ) {
+ // A null override also clears the saved ID when WordPress refreshes available context.
+ $context['queryId'] = null;
+ }
+
// Check parent block contexts (in order of specificity).
$is_in_single_product = isset( $context['singleProduct'] ) && ! empty( $context['postId'] );
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
index 068c018c6d5..cadce01c840 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
@@ -286,7 +286,8 @@ final class ProductFilterAttribute extends AbstractBlock {
* @param string $query_type Query type, accept 'and' or 'or'.
*/
private function get_attribute_counts( $block, $slug, $query_type ) {
- if ( ! isset( $block->context['filterParams'] ) ) {
+ $context = $block->context;
+ if ( ! isset( $context['filterParams'] ) ) {
return array();
}
@@ -303,7 +304,7 @@ final class ProductFilterAttribute extends AbstractBlock {
);
}
- if ( ! empty( $query_vars['tax_query'] ) ) {
+ if ( ! isset( $context['queryId'] ) && ! empty( $query_vars['tax_query'] ) ) {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
$query_vars['tax_query'] = ProductCollectionUtils::remove_query_array( $query_vars['tax_query'], 'taxonomy', $slug );
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
index df9e6d4dcde..0091e413de4 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterTaxonomy.php
@@ -376,7 +376,8 @@ final class ProductFilterTaxonomy extends AbstractBlock {
* @return array Term counts with term_id as key and count as value.
*/
private function get_taxonomy_term_counts( $block, $taxonomy ) {
- if ( ! isset( $block->context['filterParams'] ) ) {
+ $context = $block->context;
+ if ( ! isset( $context['filterParams'] ) ) {
return array();
}
@@ -406,7 +407,7 @@ final class ProductFilterTaxonomy extends AbstractBlock {
}
// Remove from tax_query if present.
- if ( ! empty( $query_vars['tax_query'] ) ) {
+ if ( ! isset( $context['queryId'] ) && ! empty( $query_vars['tax_query'] ) ) {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
$query_vars['tax_query'] = ProductCollectionUtils::remove_query_array( $query_vars['tax_query'], 'taxonomy', $taxonomy );
}
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilters.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilters.php
index cdaf3e120b0..8bd250501da 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilters.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilters.php
@@ -28,7 +28,7 @@ class ProductFilters extends AbstractBlock {
* @return string[]
*/
protected function get_block_type_uses_context() {
- return array( 'postId', 'query', 'queryId', 'forcePageReload' );
+ return array( 'postId', 'query', 'queryId', 'collection', 'productCollectionLocation', 'forcePageReload' );
}
/**
@@ -176,10 +176,17 @@ CSS;
wp_enqueue_script( 'wc-settings' );
- $query_id = $block->context['queryId'] ?? 0;
- $filter_params = $this->get_filter_params( $query_id );
+ $query_id = $block->context['queryId'] ?? null;
+ $params = wc_get_container()->get( Params::class );
+ $filter_params = $params->get_filter_params( $query_id );
- wp_interactivity_config( $this->get_full_block_name(), [ 'canonicalUrl' => $this->get_canonical_url_no_pagination( $filter_params ) ] );
+ wp_interactivity_config(
+ $this->get_full_block_name(),
+ array(
+ 'canonicalUrl' => $this->get_canonical_url_no_pagination( $params->get_filter_params() ),
+ 'filterParamKeys' => $params->get_param_keys(),
+ )
+ );
/**
* Filter hook to modify the selected filter items.
@@ -220,6 +227,9 @@ CSS;
// frontend can fall back to the global interactivity config.
'forcePageReload' => isset( $block->context['forcePageReload'] ) ? (bool) $block->context['forcePageReload'] : null,
);
+ if ( null !== $query_id ) {
+ $interactivity_context['queryId'] = $query_id;
+ }
$overlay_mode = $this->get_overlay_mode( $attributes );
$has_overlay = 'off' !== $overlay_mode;
@@ -362,43 +372,13 @@ CSS;
array(
'attrs' => $block->parsed_block['attrs'] ?? array(),
'innerBlocks' => $block->parsed_block['innerBlocks'] ?? array(),
+ 'queryId' => $block->context['queryId'] ?? null,
)
)
)
);
}
- /**
- * Parse the filter parameters from the URL.
- * For now we only get the global query params from the URL. In the future,
- * we should get the query params based on $query_id.
- *
- * @param int $query_id Query ID.
- * @return array Parsed filter params.
- */
- private function get_filter_params( $query_id ) {
- // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
- $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
-
- $parsed_url = wp_parse_url( esc_url_raw( $request_uri ) );
-
- if ( empty( $parsed_url['query'] ) ) {
- return array();
- }
-
- parse_str( $parsed_url['query'], $url_query_params );
-
- $filter_param_keys = wc_get_container()->get( Params::class )->get_param_keys();
-
- return array_filter(
- $url_query_params,
- function ( $key ) use ( $filter_param_keys ) {
- return in_array( $key, $filter_param_keys, true );
- },
- ARRAY_FILTER_USE_KEY
- );
- }
-
/**
* Disable the style handle for this block type. We use block.json to load the style.
*
diff --git a/plugins/woocommerce/src/Internal/ProductFilters/Params.php b/plugins/woocommerce/src/Internal/ProductFilters/Params.php
index 10b42c30263..f52fcf6f493 100644
--- a/plugins/woocommerce/src/Internal/ProductFilters/Params.php
+++ b/plugins/woocommerce/src/Internal/ProductFilters/Params.php
@@ -48,6 +48,43 @@ class Params implements FilterUrlParam {
return $keys;
}
+ /**
+ * Read one collection's filter selections from the request URL.
+ *
+ * @since 11.4.0
+ *
+ * @param int|null $query_id Collection ID, or null for global filters.
+ * @return array<string, string> Selections keyed by effective filter name.
+ */
+ public function get_filter_params( ?int $query_id = null ): array {
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Values are validated below against registered filter parameters.
+ $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
+ $query = wp_parse_url( $request_uri, PHP_URL_QUERY );
+ $url_params = array();
+ parse_str( is_string( $query ) ? $query : '', $url_params );
+ $prefix = null === $query_id ? '' : 'wcp-' . $query_id . '-';
+ $params = array();
+
+ foreach ( $this->get_param_keys() as $key ) {
+ $value = $url_params[ $prefix . $key ] ?? null;
+ if ( ! is_string( $value ) || '' === $value ) {
+ continue;
+ }
+ if ( in_array( $key, $this->get_param( 'price' ), true ) && ( ! is_numeric( $value ) || ! is_finite( (float) $value ) || (float) $value < 0 ) ) {
+ continue;
+ }
+ if ( 'rating_filter' === $key && ! preg_match( '/^[1-5](,[1-5])*$/', $value ) ) {
+ continue;
+ }
+ if ( 0 === strpos( $key, 'query_type_' ) && ! in_array( $value, array( 'and', 'or' ), true ) ) {
+ continue;
+ }
+ $params[ $key ] = $value;
+ }
+
+ return $params;
+ }
+
/**
* Get the param.
*
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/collections.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/collections.block_theme.spec.ts
index d69486af313..3b834b75731 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/collections.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/collections.block_theme.spec.ts
@@ -118,7 +118,7 @@ test.describe( 'Product Collection: Collections', () => {
await expect( pageObject.products ).toHaveCount( 4 );
} );
- test( 'Product Catalog collection can be added in post and syncs query with template', async ( {
+ test( 'Product Catalog collection can be added in post with a local query', async ( {
pageObject,
} ) => {
await pageObject.createNewPostAndInsertBlock( 'productCatalog' );
@@ -127,10 +127,7 @@ test.describe( 'Product Collection: Collections', () => {
.locateSidebarSettings()
.getByLabel( SELECTORS.usePageContextControl );
- await expect( queryTypeLocator.getByLabel( 'Default' ) ).toBeChecked();
- await expect(
- queryTypeLocator.getByLabel( 'Custom' )
- ).not.toBeChecked();
+ await expect( queryTypeLocator ).toBeHidden();
await expect( pageObject.products ).toHaveCount( 9 );
await pageObject.publishAndGoToFrontend();
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/inspector-controls.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/inspector-controls.block_theme.spec.ts
index 95b3b07e3ce..78d6dc94da9 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/inspector-controls.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/inspector-controls.block_theme.spec.ts
@@ -420,10 +420,15 @@ test.describe( 'Product Collection: Inspector Controls', () => {
await expect( pageObject.products ).toHaveCount( 9 );
- await editor.insertBlock( {
- name: 'woocommerce/filter-wrapper',
- attributes: { filterType: 'price-filter' },
- } );
+ const productCollectionBlock = await editor.getBlockByName(
+ 'woocommerce/product-collection'
+ );
+ const productCollectionClientId =
+ ( await productCollectionBlock.getAttribute( 'data-block' ) ) ?? '';
+ await editor.insertBlock(
+ { name: 'woocommerce/product-filters' },
+ { clientId: productCollectionClientId }
+ );
await pageObject.publishAndGoToFrontend();
@@ -431,14 +436,14 @@ test.describe( 'Product Collection: Inspector Controls', () => {
await page
.getByRole( 'textbox', {
- name: 'Filter products by minimum',
+ name: 'Filter products by minimum price',
} )
.dblclick();
await page.keyboard.type( '45' );
await page
.getByRole( 'textbox', {
- name: 'Filter products by maximum',
+ name: 'Filter products by maximum price',
} )
.dblclick();
await page.keyboard.type( '55' );
@@ -457,14 +462,14 @@ test.describe( 'Product Collection: Inspector Controls', () => {
} );
test.describe( '"Query Type" control', () => {
- test( 'should be visible on posts', async ( { pageObject } ) => {
+ test( 'should be hidden on posts', async ( { pageObject } ) => {
await pageObject.createNewPostAndInsertBlock();
await expect(
pageObject
.locateSidebarSettings()
.getByLabel( SELECTORS.usePageContextControl )
- ).toBeVisible();
+ ).toBeHidden();
} );
[
@@ -517,7 +522,7 @@ test.describe( 'Product Collection: Inspector Controls', () => {
`${ BLOCK_THEME_SLUG }//home`,
`${ BLOCK_THEME_SLUG }//index`,
].forEach( ( slug ) => {
- test( `should be visible in non-archive template: ${ slug }`, async ( {
+ test( `should be hidden in non-archive template: ${ slug }`, async ( {
pageObject,
editor,
} ) => {
@@ -531,7 +536,7 @@ test.describe( 'Product Collection: Inspector Controls', () => {
pageObject
.locateSidebarSettings()
.getByLabel( SELECTORS.usePageContextControl )
- ).toBeVisible();
+ ).toBeHidden();
} );
} );
@@ -635,7 +640,7 @@ test.describe( 'Product Collection: Inspector Controls', () => {
await expect( customQueryType ).not.toBeChecked();
} );
- test( 'allows filtering in non-archive context', async ( {
+ test( 'nested filters affect only their local collection', async ( {
pageObject,
editor,
page,
@@ -685,10 +690,10 @@ test.describe( 'Product Collection: Inspector Controls', () => {
await expect(
productCollection.first().locator( SELECTORS.product )
- ).toHaveCount( 1 );
+ ).toHaveCount( 9 );
await expect(
productCollection.last().locator( SELECTORS.product )
- ).toHaveCount( 9 );
+ ).toHaveCount( 1 );
} );
test( 'correctly combines editor and front-end filters', async ( {
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
index 8efcce53fd4..d8afcb2808c 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
@@ -27,6 +27,51 @@ class QueryBuilder extends \WP_UnitTestCase {
*/
private $block_instance;
+ /**
+ * @testdox Should filter only the requested collection within its saved product selection.
+ * @testWith [0, false]
+ * [17, true]
+ * @param int $query_id Collection ID.
+ * @param bool $legacy_filterable Previously saved filterable value.
+ */
+ public function test_local_filter_products( int $query_id, bool $legacy_filterable ): void {
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Restore the original request verbatim after the test.
+ $original_uri = $_SERVER['REQUEST_URI'] ?? null;
+ $products = array();
+ foreach ( array( '10.25', '15.28', '5' ) as $price ) {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( $price );
+ $product->save();
+ $products[] = $product->get_id();
+ }
+ try {
+ $_SERVER['REQUEST_URI'] = '/?min_price=999&wcp-' . $query_id . '-max_price=10.28&wcp-28-min_price=15';
+ set_query_var( 'min_price', '999' );
+ $block = Utils::get_base_parsed_block();
+ $block['attrs']['queryId'] = $query_id;
+ $block['attrs']['query']['inherit'] = false;
+ $block['attrs']['query']['filterable'] = $legacy_filterable;
+ $block['attrs']['query']['woocommerceHandPickedProducts'] = array_slice( $products, 0, 2 );
+ $query = Utils::initialize_merged_query( $this->block_instance, $block );
+ $this->assertSame( array( $products[0] ), wp_list_pluck( ( new WP_Query( $query ) )->posts, 'ID' ) );
+
+ $block['attrs']['queryId'] = 28;
+ $query = Utils::initialize_merged_query( $this->block_instance, $block );
+ $this->assertSame( array( $products[1] ), wp_list_pluck( ( new WP_Query( $query ) )->posts, 'ID' ) );
+
+ $_SERVER['REQUEST_URI'] = '/?min_price=999';
+ $query = Utils::initialize_merged_query( $this->block_instance, $block );
+ $this->assertEqualsCanonicalizing( array_slice( $products, 0, 2 ), wp_list_pluck( ( new WP_Query( $query ) )->posts, 'ID' ) );
+ } finally {
+ set_query_var( 'min_price', '' );
+ if ( null === $original_uri ) {
+ unset( $_SERVER['REQUEST_URI'] );
+ } else {
+ $_SERVER['REQUEST_URI'] = $original_uri;
+ }
+ }
+ }
+
/**
* Initiate the mock object.
*/
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductFilters.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductFilters.php
index 80973557b17..28c9fbf7cfd 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductFilters.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductFilters.php
@@ -48,6 +48,71 @@ class ProductFilters extends \WP_UnitTestCase {
*/
private $render_method;
+ /**
+ * @testdox Local filter counts preserve saved taxonomy restrictions, including empty collections.
+ */
+ public function test_nested_local_filters_preserve_saved_taxonomy_and_empty_collection(): void {
+ $red = self::factory()->term->create(
+ array(
+ 'taxonomy' => 'product_cat',
+ 'name' => 'Local red',
+ 'slug' => 'local-red',
+ )
+ );
+ $blue = self::factory()->term->create(
+ array(
+ 'taxonomy' => 'product_cat',
+ 'name' => 'Local blue',
+ 'slug' => 'local-blue',
+ )
+ );
+ foreach ( array( 10, 30, 40 ) as $price ) {
+ $product = \WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( (string) $price );
+ $product->set_category_ids( array( 40 === $price ? $blue : $red ) );
+ $product->save();
+ }
+ $attributes = array(
+ 'queryId' => 0,
+ 'query' => array(
+ 'isProductCollectionBlock' => true,
+ 'postType' => 'product',
+ 'inherit' => false,
+ 'perPage' => 10,
+ 'taxQuery' => array( 'product_cat' => array( $red ) ),
+ ),
+ );
+ $markup = '<!-- wp:woocommerce/product-collection ' . wp_json_encode( $attributes ) . ' --><div class="wp-block-woocommerce-product-collection">'
+ . '<!-- wp:woocommerce/product-filters --><div class="wp-block-woocommerce-product-filters">'
+ . '<!-- wp:woocommerce/product-filter-taxonomy {"taxonomy":"product_cat","showCounts":true} --><div>'
+ . '<!-- wp:woocommerce/product-filter-checkbox-list --><div class="wc-block-product-filter-checkbox-list"></div><!-- /wp:woocommerce/product-filter-checkbox-list -->'
+ . '</div><!-- /wp:woocommerce/product-filter-taxonomy --></div><!-- /wp:woocommerce/product-filters -->'
+ . '<!-- wp:woocommerce/product-template --><!-- wp:post-title /--><!-- /wp:woocommerce/product-template -->'
+ . '</div><!-- /wp:woocommerce/product-collection -->';
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Restore the original request verbatim after the test.
+ $original_uri = $_SERVER['REQUEST_URI'] ?? null;
+ try {
+ $_SERVER['REQUEST_URI'] = '/?wcp-0-categories=local-blue&wcp-0-min_price=20&min_price=999';
+ $html = do_blocks( $markup );
+ $this->assertStringContainsString( 'wp-block-woocommerce-product-collection', $html );
+ $this->assertStringNotContainsString( 'wp-block-post-title', $html );
+ $this->assertStringContainsString( 'aria-label="Local red"', $html );
+ $this->assertStringNotContainsString( 'aria-label="Local blue"', $html );
+ $this->assertStringContainsString( 'data-wp-text="context.item.count">1</span>', $html );
+
+ $_SERVER['REQUEST_URI'] = '/?wcp-0-categories=local-red&min_price=999';
+ $html = do_blocks( $markup );
+ $this->assertStringContainsString( 'wp-block-post-title', $html );
+ $this->assertStringContainsString( 'data-wp-text="context.item.count">2</span>', $html );
+ } finally {
+ if ( null === $original_uri ) {
+ unset( $_SERVER['REQUEST_URI'] );
+ } else {
+ $_SERVER['REQUEST_URI'] = $original_uri;
+ }
+ }
+ }
+
/**
* Set up the test subject and dependencies.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/ParamsTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/ParamsTest.php
index 6f9ed3a1d38..4e2656d7ca1 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductFilters/ParamsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFilters/ParamsTest.php
@@ -32,6 +32,39 @@ class ParamsTest extends AbstractProductFiltersTest {
*/
private $taxonomy_params_filter;
+ /**
+ * @testdox Should isolate local filter parameters, including ID zero, and reject malformed values.
+ */
+ public function test_scoped_request_parameters(): void {
+ $this->taxonomy_params_filter = static function ( $params ) {
+ $params['taxonomy']['product_brand'] = 'makers';
+ return $params;
+ };
+ add_filter( 'woocommerce_product_filter_params', $this->taxonomy_params_filter );
+
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Restore the original request verbatim after the test.
+ $original_uri = $_SERVER['REQUEST_URI'] ?? null;
+ try {
+ $_SERVER['REQUEST_URI'] = '/?min_price=5&wcp-0-min_price=10.28&wcp-17-min_price=20&wcp-0-rating_filter[]=4&wcp-0-max_price=invalid&wcp-0-unknown=value&wcp-0-filter_color=blue,red&wcp-0-query_type_color=and&wcp-00-min_price=999&wcp-0-makers=%25e6%2597%25a5';
+ $local = $this->sut->get_filter_params( 0 );
+ $this->assertSame( '%e6%97%a5', $local['makers'] );
+ $this->assertSame( '10.28', $local['min_price'] );
+ $this->assertSame( 'blue,red', $local['filter_color'] );
+ $this->assertSame( 'and', $local['query_type_color'] );
+ $this->assertArrayNotHasKey( 'rating_filter', $local );
+ $this->assertArrayNotHasKey( 'max_price', $local );
+ $this->assertArrayNotHasKey( 'unknown', $local );
+ $this->assertSame( '20', $this->sut->get_filter_params( 17 )['min_price'] );
+ $this->assertSame( '5', $this->sut->get_filter_params()['min_price'] );
+ } finally {
+ if ( null === $original_uri ) {
+ unset( $_SERVER['REQUEST_URI'] );
+ } else {
+ $_SERVER['REQUEST_URI'] = $original_uri;
+ }
+ }
+ }
+
/**
* Runs before each test.
*/