Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/curly-donuts-search-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopware/cms-base-layer": minor
---

`SwProductListingFilters` and `SwProductListingFiltersHorizontal` render a new `SwFilterCategories` checkbox filter when the listing response contains the category aggregations from `getCategoryFilterAggregations()`. The selection is kept in the `categories` URL param and applied as a criteria `post-filter`. Listings without these aggregations are unaffected.
5 changes: 5 additions & 0 deletions .changeset/curly-donuts-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopware/helpers": minor
---

Add `getCategoryFilterAggregations()` and `getCategoryFilterPostFilter()` to request category aggregations for product listings and filter by category without reducing the aggregations. `getListingFilters` merges the `categories` and `categories-counts` response aggregations into a single `categories` filter with a product count per category.
57 changes: 57 additions & 0 deletions apps/docs/src/guides/e-commerce/product-listing.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,63 @@ const ColorFilter: ListingFiler = {
};
```

### Category filter for search results

The Store API does not add a category aggregation on its own. You can request one through the search criteria. The `@shopware/helpers` package ships two small helpers for this.

Request the category aggregations together with your search:

```ts
import { getCategoryFilterAggregations } from "@shopware/helpers";

const { search } = useListing({ listingType: "productSearchListing" });

search({
search: "running",
aggregations: getCategoryFilterAggregations(),
});
```

The response then contains two extra aggregations: `categories` (the category entities) and `categories-counts` (a product count per category id). `getListingFilters` - and with it `getAvailableFilters` / `getInitialFilters` - merges them into a single filter with the code `categories`:

```ts
{
code: "categories",
label: "categories",
entities: [
{ id: "...", name: "Shoes", count: 248, /* other category props */ },
{ id: "...", name: "Clothing", count: 97 },
],
}
```

The entities are sorted by `count`, highest first. Note that `label` is the raw aggregation name, so give the filter your own translated heading when you render it.

On the `/store-api/search` route the `count` counts every matching **variant**, not every product. The filter UIs in this repo sort by it but do not display it. The `cms-base-layer` filters show no counts at all, and the demo store hides this one because its manufacturer and property filters do collapse variants, so an uncollapsed category count next to them would read as a bug.

The reason is that the helper keeps the counts aggregation flat on purpose: on the `/store-api/search` route, attaching any nested aggregation to a terms aggregation on `categoriesRo.id` makes it return an empty bucket list. `categoriesRo` is a `nested`-mapped field in the Elasticsearch product index, and the Elasticsearch criteria parser does not step back to the root document for the sub-aggregation field (`parentId`). The same nested aggregation works on routes backed by the database DAL, such as `/store-api/product`. If your listing runs on such a route, you can build the aggregations yourself and add a nested terms aggregation on `parentId` named `categories-parents` to the counts aggregation. The filter merge then counts all variants of one product as one, and the count is safe to display.

To filter the listing by selected categories, send a `post-filter` with the next search:

```ts
import {
getCategoryFilterAggregations,
getCategoryFilterPostFilter,
} from "@shopware/helpers";

search({
search: "running",
aggregations: getCategoryFilterAggregations(),
"post-filter": [getCategoryFilterPostFilter([selectedCategoryId])],
});
```

A post-filter narrows the result set but does not reduce the aggregations. All category options stay visible while the products are filtered. This is the same mechanism the Store API uses internally for the manufacturer and properties filters.

:::info
The `SwProductListingFilters` component from `@shopware/cms-base-layer` renders the category filter out of the box when the aggregations are present in the listing. See the search page of `vue-starter-template` for a full example.
:::

### Apply filter value

In order to apply a specific filter you need to be aware of:
Expand Down
2 changes: 2 additions & 0 deletions examples/sanity-cms/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
"@types/node": "22.13.14",
"fflate": "^0.8.3",
"nuxt": "4.4.8",
"oxfmt": "0.58.0",
"oxlint": "1.73.0",
"typescript": "5.9.3",
"vue": "3.5.39",
"vue-tsc": "3.3.7"
Expand Down
18 changes: 18 additions & 0 deletions packages/cms-base-layer/app/components/SwFilterChips.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Schemas } from "#shopware";
type FilterState = {
manufacturer: Set<string>;
properties: Set<string>;
categories: Set<string>;
"min-price": number | undefined;
"max-price": number | undefined;
rating: number | undefined;
Expand Down Expand Up @@ -88,6 +89,23 @@ const activeChips = computed(() => {
}
}

// Add category filters
const categories = Array.from(props.filters.categories);
for (const categoryId of categories) {
const filter = props.availableFilters.find((f) => f.code === "categories");
if (filter && "entities" in filter && filter.entities) {
const entity = filter.entities.find((e) => e.id === categoryId);
const name = getTranslatedName(entity);
if (name) {
chips.push({
label: name,
code: "categories",
value: categoryId,
});
}
}
}

// Add price filters
if (props.filters["min-price"] || props.filters["max-price"]) {
const min = props.filters["min-price"] || 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { computed } from "vue";
import type { Component } from "vue";

import SwFilterCategoriesVue from "./listing-filters/SwFilterCategories.vue";
import SwFilterPriceVue from "./listing-filters/SwFilterPrice.vue";
import SwFilterPropertiesVue from "./listing-filters/SwFilterProperties.vue";
import SwFilterRatingVue from "./listing-filters/SwFilterRating.vue";
Expand All @@ -11,6 +12,7 @@ const {
filter,
selectedManufacturer,
selectedProperties,
selectedCategories = new Set<string>(),
selectedMinPrice,
selectedMaxPrice,
selectedRating,
Expand All @@ -20,6 +22,7 @@ const {
filter: ListingFilter;
selectedManufacturer: Set<string>;
selectedProperties: Set<string>;
selectedCategories?: Set<string>;
selectedMinPrice: number | undefined;
selectedMaxPrice: number | undefined;
selectedRating: number | undefined;
Expand All @@ -40,11 +43,13 @@ const transformedFilters = computed(() => ({
"shipping-free": selectedShippingFree,
manufacturer: [...selectedManufacturer],
properties: [...selectedProperties],
categories: [...selectedCategories],
}));

const filterComponent = computed<Component | undefined>(() => {
const componentMap: Record<string, Component> = {
manufacturer: SwFilterPropertiesVue,
categories: SwFilterCategoriesVue,
price: SwFilterPriceVue,
rating: SwFilterRatingVue,
"shipping-free": SwFilterShippingFreeVue,
Expand Down
33 changes: 31 additions & 2 deletions packages/cms-base-layer/app/components/SwProductListingFilters.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
CmsElementSidebarFilter,
} from "@shopware/composables";
import { useCmsTranslations } from "@shopware/composables";
import { getCategoryFilterPostFilter } from "@shopware/helpers";
import { defu } from "defu";
import { computed } from "vue";
import type { ComputedRef } from "vue";
Expand Down Expand Up @@ -63,6 +64,7 @@ const showResetFiltersButton = computed<boolean>(() => {
if (
sidebarSelectedFilters.manufacturer.size !== 0 ||
sidebarSelectedFilters.properties.size !== 0 ||
(isProductSearch && sidebarSelectedFilters.categories.size !== 0) ||
sidebarSelectedFilters["max-price"] ||
sidebarSelectedFilters["min-price"] ||
sidebarSelectedFilters.rating ||
Expand All @@ -82,6 +84,19 @@ const searchCriteriaForRequest: ComputedRef<Schemas["ProductListingCriteria"]> =
properties: [...(sidebarSelectedFilters.properties as Set<string>)]?.join(
"|",
),
// Category selection travels as a post-filter so the category
// aggregation itself is not reduced (faceted behavior). Search pages
// only: category pages never offer this filter, so a stale
// ?categories= param must not silently narrow them.
...(isProductSearch && sidebarSelectedFilters.categories.size > 0
? {
"post-filter": [
getCategoryFilterPostFilter([
...(sidebarSelectedFilters.categories as Set<string>),
]),
],
}
: {}),
"min-price": sidebarSelectedFilters["min-price"] as number,
"max-price": sidebarSelectedFilters["max-price"] as number,
order: getCurrentSortingOrder.value as string,
Expand All @@ -98,7 +113,11 @@ const handleFilterChange = async (event: {
try {
const { code, value } = event;

if (code === "manufacturer" || code === "properties") {
if (
code === "manufacturer" ||
code === "properties" ||
code === "categories"
) {
const filterSet = sidebarSelectedFilters[code];
const stringValue = String(value);

Expand Down Expand Up @@ -137,6 +156,10 @@ const executeSearch = async () => {

if (criteria.manufacturer) query.manufacturer = criteria.manufacturer;
if (criteria.properties) query.properties = criteria.properties;
if (isProductSearch && sidebarSelectedFilters.categories.size > 0)
query.categories = [
...(sidebarSelectedFilters.categories as Set<string>),
].join("|");
if (criteria["min-price"]) query["min-price"] = criteria["min-price"];
if (criteria["max-price"]) query["max-price"] = criteria["max-price"];
if (criteria.rating) query.rating = criteria.rating;
Expand All @@ -161,6 +184,7 @@ const executeSearch = async () => {
const clearFilters = () => {
(sidebarSelectedFilters.manufacturer as Set<string>).clear();
(sidebarSelectedFilters.properties as Set<string>).clear();
(sidebarSelectedFilters.categories as Set<string>).clear();
sidebarSelectedFilters["min-price"] = undefined;
sidebarSelectedFilters["max-price"] = undefined;
sidebarSelectedFilters.rating = undefined;
Expand Down Expand Up @@ -208,7 +232,11 @@ const handleRemoveFilterChip = async (chip: {
code: string;
value: string | number;
}) => {
if (chip.code === "properties" || chip.code === "manufacturer") {
if (
chip.code === "properties" ||
chip.code === "manufacturer" ||
chip.code === "categories"
) {
const filterSet = sidebarSelectedFilters[chip.code] as Set<string>;
filterSet.delete(String(chip.value));
} else if (chip.code === "price") {
Expand Down Expand Up @@ -259,6 +287,7 @@ const handleRemoveFilterChip = async (chip: {
:filter="filter"
:selected-manufacturer="sidebarSelectedFilters.manufacturer"
:selected-properties="sidebarSelectedFilters.properties"
:selected-categories="sidebarSelectedFilters.categories"
:selected-min-price="sidebarSelectedFilters['min-price']"
:selected-max-price="sidebarSelectedFilters['max-price']"
:selected-rating="sidebarSelectedFilters.rating"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
CmsElementSidebarFilter,
} from "@shopware/composables";
import { useCmsTranslations } from "@shopware/composables";
import { getCategoryFilterPostFilter } from "@shopware/helpers";
import { defu } from "defu";
import { computed } from "vue";
import type { ComputedRef } from "vue";
Expand Down Expand Up @@ -62,6 +63,7 @@ const showResetFiltersButton = computed<boolean>(() => {
if (
sidebarSelectedFilters.manufacturer.size !== 0 ||
sidebarSelectedFilters.properties.size !== 0 ||
(isProductSearch && sidebarSelectedFilters.categories.size !== 0) ||
sidebarSelectedFilters["max-price"] ||
sidebarSelectedFilters["min-price"] ||
sidebarSelectedFilters.rating ||
Expand All @@ -81,6 +83,19 @@ const searchCriteriaForRequest: ComputedRef<Schemas["ProductListingCriteria"]> =
properties: [...(sidebarSelectedFilters.properties as Set<string>)]?.join(
"|",
),
// Category selection travels as a post-filter so the category
// aggregation itself is not reduced (faceted behavior). Search pages
// only: category pages never offer this filter, so a stale
// ?categories= param must not silently narrow them.
...(isProductSearch && sidebarSelectedFilters.categories.size > 0
? {
"post-filter": [
getCategoryFilterPostFilter([
...(sidebarSelectedFilters.categories as Set<string>),
]),
],
}
: {}),
"min-price": sidebarSelectedFilters["min-price"] as number,
"max-price": sidebarSelectedFilters["max-price"] as number,
order: getCurrentSortingOrder.value as string,
Expand All @@ -97,7 +112,11 @@ const handleFilterChange = async (event: {
try {
const { code, value } = event;

if (code === "manufacturer" || code === "properties") {
if (
code === "manufacturer" ||
code === "properties" ||
code === "categories"
) {
const filterSet = sidebarSelectedFilters[code];
const stringValue = String(value);

Expand Down Expand Up @@ -136,6 +155,10 @@ const executeSearch = async () => {

if (criteria.manufacturer) query.manufacturer = criteria.manufacturer;
if (criteria.properties) query.properties = criteria.properties;
if (isProductSearch && sidebarSelectedFilters.categories.size > 0)
query.categories = [
...(sidebarSelectedFilters.categories as Set<string>),
].join("|");
if (criteria["min-price"]) query["min-price"] = criteria["min-price"];
if (criteria["max-price"]) query["max-price"] = criteria["max-price"];
if (criteria.rating) query.rating = criteria.rating;
Expand All @@ -160,6 +183,7 @@ const executeSearch = async () => {
const clearFilters = () => {
(sidebarSelectedFilters.manufacturer as Set<string>).clear();
(sidebarSelectedFilters.properties as Set<string>).clear();
(sidebarSelectedFilters.categories as Set<string>).clear();
sidebarSelectedFilters["min-price"] = undefined;
sidebarSelectedFilters["max-price"] = undefined;
sidebarSelectedFilters.rating = undefined;
Expand Down Expand Up @@ -208,6 +232,9 @@ const hasActiveFilter = (filter: { code: string }) => {
if (filter.code === "manufacturer") {
return sidebarSelectedFilters.manufacturer.size > 0;
}
if (filter.code === "categories") {
return sidebarSelectedFilters.categories.size > 0;
}
if (filter.code === "price") {
return (
sidebarSelectedFilters["min-price"] !== undefined ||
Expand Down Expand Up @@ -241,6 +268,7 @@ const hasActiveFilter = (filter: { code: string }) => {
display-mode="dropdown"
:selected-manufacturer="sidebarSelectedFilters.manufacturer"
:selected-properties="sidebarSelectedFilters.properties"
:selected-categories="sidebarSelectedFilters.categories"
:selected-min-price="sidebarSelectedFilters['min-price']"
:selected-max-price="sidebarSelectedFilters['max-price']"
:selected-rating="sidebarSelectedFilters.rating"
Expand Down
Loading