From e2ffea149d33af695a6850fc452354665708ebf7 Mon Sep 17 00:00:00 2001 From: mkucmus Date: Wed, 15 Jul 2026 10:43:22 +0200 Subject: [PATCH 1/3] feat(cms-base-layer): add filter by category --- .changeset/curly-donuts-search-ui.md | 5 + .changeset/curly-donuts-search.md | 5 + .../e-commerce/product-listing.md | 53 ++++++ examples/sanity-cms/package.json | 2 + .../app/components/SwFilterChips.vue | 18 ++ .../app/components/SwProductListingFilter.vue | 5 + .../components/SwProductListingFilters.vue | 33 +++- .../SwProductListingFiltersHorizontal.vue | 30 +++- .../listing-filters/SwFilterCategories.vue | 161 ++++++++++++++++++ .../utils/useSelectedListingFilters.test.ts | 16 +- .../app/utils/useSelectedListingFilters.ts | 5 +- packages/helpers/src/index.test.ts | 2 + .../src/listing/categoryFilter.test.ts | 81 +++++++++ .../helpers/src/listing/categoryFilter.ts | 92 ++++++++++ packages/helpers/src/listing/filters.test.ts | 112 ++++++++++++ packages/helpers/src/listing/filters.ts | 59 ++++++- packages/helpers/src/listing/index.ts | 4 + pnpm-lock.yaml | 6 + .../listing-filters/ListingFilter.vue | 21 ++- .../listing-filters/ListingFilters.vue | 65 ++++--- templates/vue-demo-store/app/pages/search.vue | 34 ++-- .../vue-starter-template/app/pages/search.vue | 11 ++ 22 files changed, 774 insertions(+), 46 deletions(-) create mode 100644 .changeset/curly-donuts-search-ui.md create mode 100644 .changeset/curly-donuts-search.md create mode 100644 packages/cms-base-layer/app/components/listing-filters/SwFilterCategories.vue create mode 100644 packages/helpers/src/listing/categoryFilter.test.ts create mode 100644 packages/helpers/src/listing/categoryFilter.ts diff --git a/.changeset/curly-donuts-search-ui.md b/.changeset/curly-donuts-search-ui.md new file mode 100644 index 000000000..ebae04b36 --- /dev/null +++ b/.changeset/curly-donuts-search-ui.md @@ -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. diff --git a/.changeset/curly-donuts-search.md b/.changeset/curly-donuts-search.md new file mode 100644 index 000000000..0362fe2e1 --- /dev/null +++ b/.changeset/curly-donuts-search.md @@ -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. diff --git a/apps/docs/src/getting-started/e-commerce/product-listing.md b/apps/docs/src/getting-started/e-commerce/product-listing.md index 82acced42..252b2fb8a 100644 --- a/apps/docs/src/getting-started/e-commerce/product-listing.md +++ b/apps/docs/src/getting-started/e-commerce/product-listing.md @@ -365,6 +365,59 @@ 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 }, + ], +} +``` + +Every matching variant counts as one product. 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. + +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: diff --git a/examples/sanity-cms/package.json b/examples/sanity-cms/package.json index 0b5f165ff..f8ca772da 100644 --- a/examples/sanity-cms/package.json +++ b/examples/sanity-cms/package.json @@ -22,6 +22,8 @@ "@types/node": "22.13.14", "fflate": "^0.8.3", "nuxt": "4.4.8", + "oxfmt": "0.57.0", + "oxlint": "1.72.0", "typescript": "5.9.3", "vue": "3.5.39", "vue-tsc": "3.3.6" diff --git a/packages/cms-base-layer/app/components/SwFilterChips.vue b/packages/cms-base-layer/app/components/SwFilterChips.vue index 5d85cd8e7..4163e8407 100644 --- a/packages/cms-base-layer/app/components/SwFilterChips.vue +++ b/packages/cms-base-layer/app/components/SwFilterChips.vue @@ -6,6 +6,7 @@ import type { Schemas } from "#shopware"; type FilterState = { manufacturer: Set; properties: Set; + categories: Set; "min-price": number | undefined; "max-price": number | undefined; rating: number | undefined; @@ -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; diff --git a/packages/cms-base-layer/app/components/SwProductListingFilter.vue b/packages/cms-base-layer/app/components/SwProductListingFilter.vue index 4d1b88c62..f9fe3fc3e 100644 --- a/packages/cms-base-layer/app/components/SwProductListingFilter.vue +++ b/packages/cms-base-layer/app/components/SwProductListingFilter.vue @@ -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"; @@ -11,6 +12,7 @@ const { filter, selectedManufacturer, selectedProperties, + selectedCategories = new Set(), selectedMinPrice, selectedMaxPrice, selectedRating, @@ -20,6 +22,7 @@ const { filter: ListingFilter; selectedManufacturer: Set; selectedProperties: Set; + selectedCategories?: Set; selectedMinPrice: number | undefined; selectedMaxPrice: number | undefined; selectedRating: number | undefined; @@ -40,11 +43,13 @@ const transformedFilters = computed(() => ({ "shipping-free": selectedShippingFree, manufacturer: [...selectedManufacturer], properties: [...selectedProperties], + categories: [...selectedCategories], })); const filterComponent = computed(() => { const componentMap: Record = { manufacturer: SwFilterPropertiesVue, + categories: SwFilterCategoriesVue, price: SwFilterPriceVue, rating: SwFilterRatingVue, "shipping-free": SwFilterShippingFreeVue, diff --git a/packages/cms-base-layer/app/components/SwProductListingFilters.vue b/packages/cms-base-layer/app/components/SwProductListingFilters.vue index ec8635664..1497cd2c8 100644 --- a/packages/cms-base-layer/app/components/SwProductListingFilters.vue +++ b/packages/cms-base-layer/app/components/SwProductListingFilters.vue @@ -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"; @@ -63,6 +64,7 @@ const showResetFiltersButton = computed(() => { if ( sidebarSelectedFilters.manufacturer.size !== 0 || sidebarSelectedFilters.properties.size !== 0 || + (isProductSearch && sidebarSelectedFilters.categories.size !== 0) || sidebarSelectedFilters["max-price"] || sidebarSelectedFilters["min-price"] || sidebarSelectedFilters.rating || @@ -82,6 +84,19 @@ const searchCriteriaForRequest: ComputedRef = properties: [...(sidebarSelectedFilters.properties as Set)]?.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), + ]), + ], + } + : {}), "min-price": sidebarSelectedFilters["min-price"] as number, "max-price": sidebarSelectedFilters["max-price"] as number, order: getCurrentSortingOrder.value as string, @@ -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); @@ -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), + ].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; @@ -161,6 +184,7 @@ const executeSearch = async () => { const clearFilters = () => { (sidebarSelectedFilters.manufacturer as Set).clear(); (sidebarSelectedFilters.properties as Set).clear(); + (sidebarSelectedFilters.categories as Set).clear(); sidebarSelectedFilters["min-price"] = undefined; sidebarSelectedFilters["max-price"] = undefined; sidebarSelectedFilters.rating = undefined; @@ -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; filterSet.delete(String(chip.value)); } else if (chip.code === "price") { @@ -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" diff --git a/packages/cms-base-layer/app/components/SwProductListingFiltersHorizontal.vue b/packages/cms-base-layer/app/components/SwProductListingFiltersHorizontal.vue index 062b53762..bd60907d6 100644 --- a/packages/cms-base-layer/app/components/SwProductListingFiltersHorizontal.vue +++ b/packages/cms-base-layer/app/components/SwProductListingFiltersHorizontal.vue @@ -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"; @@ -62,6 +63,7 @@ const showResetFiltersButton = computed(() => { if ( sidebarSelectedFilters.manufacturer.size !== 0 || sidebarSelectedFilters.properties.size !== 0 || + (isProductSearch && sidebarSelectedFilters.categories.size !== 0) || sidebarSelectedFilters["max-price"] || sidebarSelectedFilters["min-price"] || sidebarSelectedFilters.rating || @@ -81,6 +83,19 @@ const searchCriteriaForRequest: ComputedRef = properties: [...(sidebarSelectedFilters.properties as Set)]?.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), + ]), + ], + } + : {}), "min-price": sidebarSelectedFilters["min-price"] as number, "max-price": sidebarSelectedFilters["max-price"] as number, order: getCurrentSortingOrder.value as string, @@ -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); @@ -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), + ].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; @@ -160,6 +183,7 @@ const executeSearch = async () => { const clearFilters = () => { (sidebarSelectedFilters.manufacturer as Set).clear(); (sidebarSelectedFilters.properties as Set).clear(); + (sidebarSelectedFilters.categories as Set).clear(); sidebarSelectedFilters["min-price"] = undefined; sidebarSelectedFilters["max-price"] = undefined; sidebarSelectedFilters.rating = undefined; @@ -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 || @@ -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" diff --git a/packages/cms-base-layer/app/components/listing-filters/SwFilterCategories.vue b/packages/cms-base-layer/app/components/listing-filters/SwFilterCategories.vue new file mode 100644 index 000000000..97dcac14d --- /dev/null +++ b/packages/cms-base-layer/app/components/listing-filters/SwFilterCategories.vue @@ -0,0 +1,161 @@ + + + diff --git a/packages/cms-base-layer/app/utils/useSelectedListingFilters.test.ts b/packages/cms-base-layer/app/utils/useSelectedListingFilters.test.ts index 4c0624e4d..28ea5c523 100644 --- a/packages/cms-base-layer/app/utils/useSelectedListingFilters.test.ts +++ b/packages/cms-base-layer/app/utils/useSelectedListingFilters.test.ts @@ -8,17 +8,27 @@ import { describe("applyQueryToFilters", () => { it("populates sets from pipe-joined values", () => { const state = createEmptyFilterState(); - applyQueryToFilters(state, { manufacturer: "a|b", properties: "x" }); + applyQueryToFilters(state, { + manufacturer: "a|b", + properties: "x", + categories: "c1|c2", + }); expect([...state.manufacturer]).toEqual(["a", "b"]); expect([...state.properties]).toEqual(["x"]); + expect([...state.categories]).toEqual(["c1", "c2"]); }); it("clears removed filters when the query no longer has them (Back/Forward)", () => { const state = createEmptyFilterState(); - applyQueryToFilters(state, { manufacturer: "a|b", search: "shirt" }); - applyQueryToFilters(state, { search: "shirt" }); // manufacturer removed + applyQueryToFilters(state, { + manufacturer: "a|b", + categories: "c1", + search: "shirt", + }); + applyQueryToFilters(state, { search: "shirt" }); // manufacturer + categories removed expect(state.manufacturer.size).toBe(0); expect(state.properties.size).toBe(0); + expect(state.categories.size).toBe(0); }); it("clears removed scalar filters on resync", () => { diff --git a/packages/cms-base-layer/app/utils/useSelectedListingFilters.ts b/packages/cms-base-layer/app/utils/useSelectedListingFilters.ts index 30b6745c5..1375e55a3 100644 --- a/packages/cms-base-layer/app/utils/useSelectedListingFilters.ts +++ b/packages/cms-base-layer/app/utils/useSelectedListingFilters.ts @@ -8,6 +8,7 @@ import { firstQueryValue, toNumber } from "./routeQuery"; export type FilterState = { manufacturer: Set; properties: Set; + categories: Set; "min-price": number | undefined; "max-price": number | undefined; rating: number | undefined; @@ -18,6 +19,7 @@ export type FilterState = { export const createEmptyFilterState = (): FilterState => ({ manufacturer: new Set(), properties: new Set(), + categories: new Set(), "min-price": undefined, "max-price": undefined, rating: undefined, @@ -46,13 +48,14 @@ export const applyQueryToFilters = ( // (props bound to them stay reactive); replace scalars with undefined. state.manufacturer.clear(); state.properties.clear(); + state.categories.clear(); state["min-price"] = undefined; state["max-price"] = undefined; state.rating = undefined; state["shipping-free"] = undefined; // 2) Repopulate from the current query. - for (const code of ["manufacturer", "properties"] as const) { + for (const code of ["manufacturer", "properties", "categories"] as const) { const value = firstQueryValue(query[code]); if (!value) continue; for (const element of value.split("|")) { diff --git a/packages/helpers/src/index.test.ts b/packages/helpers/src/index.test.ts index 38e0fc936..ba46cd933 100644 --- a/packages/helpers/src/index.test.ts +++ b/packages/helpers/src/index.test.ts @@ -15,6 +15,8 @@ describe("helpers - test global API", () => { "getBackgroundImageUrl": [Function], "getBiggestThumbnailUrl": [Function], "getCategoryBreadcrumbs": [Function], + "getCategoryFilterAggregations": [Function], + "getCategoryFilterPostFilter": [Function], "getCategoryImageUrl": [Function], "getCategoryRoute": [Function], "getCategoryUrl": [Function], diff --git a/packages/helpers/src/listing/categoryFilter.test.ts b/packages/helpers/src/listing/categoryFilter.test.ts new file mode 100644 index 000000000..5def58521 --- /dev/null +++ b/packages/helpers/src/listing/categoryFilter.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { + getCategoryFilterAggregations, + getCategoryFilterPostFilter, + resolveCategoryBucketCount, +} from "./categoryFilter"; + +describe("getCategoryFilterAggregations", () => { + it("should return the entity and counts aggregations", () => { + expect(getCategoryFilterAggregations()).toEqual([ + { + name: "categories", + type: "entity", + definition: "category", + field: "categoriesRo.id", + }, + { + name: "categories-counts", + type: "terms", + field: "categoriesRo.id", + }, + ]); + }); + + it("should return a fresh array on each call", () => { + const first = getCategoryFilterAggregations(); + const second = getCategoryFilterAggregations(); + expect(first).not.toBe(second); + expect(first[0]).not.toBe(second[0]); + }); +}); + +describe("getCategoryFilterPostFilter", () => { + it("should build an equalsAny post filter with pipe-separated ids", () => { + expect(getCategoryFilterPostFilter(["id-1", "id-2"])).toEqual({ + field: "categoriesRo.id", + type: "equalsAny", + value: "id-1|id-2", + }); + }); + + it("should handle a single id", () => { + expect(getCategoryFilterPostFilter(["id-1"]).value).toBe("id-1"); + }); +}); + +describe("resolveCategoryBucketCount", () => { + it("should return the bucket count when no nested aggregation is present", () => { + expect(resolveCategoryBucketCount({ key: "id-1", count: 7 })).toBe(7); + }); + + it("should count each parent product once and standalone products individually", () => { + const bucket = { + key: "id-1", + count: 12, + "categories-parents": { + buckets: [ + // 5 variants of one parent product -> counts as 1 + { key: "parent-1", count: 5 }, + // 4 variants of another parent product -> counts as 1 + { key: "parent-2", count: 4 }, + // 3 standalone products (no parent) -> count as 3 + { key: "", count: 3 }, + ], + }, + }; + expect(resolveCategoryBucketCount(bucket)).toBe(5); + }); + + it("should treat a null parent key as standalone products", () => { + const bucket = { + key: "id-1", + count: 2, + "categories-parents": { + buckets: [{ key: null, count: 2 }], + }, + }; + expect(resolveCategoryBucketCount(bucket)).toBe(2); + }); +}); diff --git a/packages/helpers/src/listing/categoryFilter.ts b/packages/helpers/src/listing/categoryFilter.ts new file mode 100644 index 000000000..e257c44c5 --- /dev/null +++ b/packages/helpers/src/listing/categoryFilter.ts @@ -0,0 +1,92 @@ +export const CATEGORY_AGGREGATION_NAME = "categories"; +export const CATEGORY_COUNTS_AGGREGATION_NAME = "categories-counts"; +export const CATEGORY_PARENTS_AGGREGATION_NAME = "categories-parents"; + +const CATEGORY_FIELD = "categoriesRo.id"; + +type CategoryEntityAggregation = { + name: string; + type: "entity"; + definition: "category"; + field: string; +}; + +type CategoryCountsAggregation = { + name: string; + type: "terms"; + field: string; +}; + +export type CategoryFilterBucket = { + key: string; + count: number; +} & { + [nestedAggregationName: string]: unknown; +}; + +/** + * Category aggregations for a listing filter: an entity `categories` agg plus a + * flat `categories-counts` terms agg. Kept flat because the ES search route + * returns no buckets once a nested sub-agg is attached, so variants overcount. + * + * @beta + */ +export function getCategoryFilterAggregations(): Array< + CategoryEntityAggregation | CategoryCountsAggregation +> { + return [ + { + name: CATEGORY_AGGREGATION_NAME, + type: "entity", + definition: "category", + field: CATEGORY_FIELD, + }, + { + name: CATEGORY_COUNTS_AGGREGATION_NAME, + type: "terms", + field: CATEGORY_FIELD, + }, + ]; +} + +/** + * Criteria `post-filter` entry narrowing a product listing to the given + * category ids. Sent as a post-filter, it does not reduce the category + * aggregations from `getCategoryFilterAggregations`, so all category options + * stay visible while results are filtered (standard faceted behavior). + * + * @beta + */ +export function getCategoryFilterPostFilter(categoryIds: string[]): { + field: string; + type: "equalsAny"; + value: string; +} { + return { + field: CATEGORY_FIELD, + type: "equalsAny", + value: categoryIds.join("|"), + }; +} + +/** + * Product count for a `categories-counts` bucket. When the nested + * `categories-parents` aggregation is present, variants collapse into their + * parent product: each parent bucket counts as one product and the empty-key + * bucket carries the standalone products. + */ +export function resolveCategoryBucketCount( + bucket: CategoryFilterBucket, +): number { + const parents = bucket[CATEGORY_PARENTS_AGGREGATION_NAME] as + | { buckets?: Array<{ key: string | null; count: number }> } + | undefined; + if (!parents?.buckets) { + return bucket.count; + } + let count = 0; + for (const parent of parents.buckets) { + count += parent.key ? 1 : parent.count; + } + return count; +} diff --git a/packages/helpers/src/listing/filters.test.ts b/packages/helpers/src/listing/filters.test.ts index d2a2ccb7b..c7d897355 100644 --- a/packages/helpers/src/listing/filters.test.ts +++ b/packages/helpers/src/listing/filters.test.ts @@ -90,6 +90,118 @@ describe("getListingFilters", () => { ]); }); + it("should merge categories and categories-counts aggregations into one filter", () => { + const aggregations = { + categories: { + name: "categories", + apiAlias: "categories_aggregation", + entities: [ + { + id: "cat-accessories", + name: "Accessories", + translated: { name: "Accessories" }, + }, + { + id: "cat-shoes", + name: "Shoes", + translated: { name: "Shoes" }, + }, + ], + }, + "categories-counts": { + name: "categories-counts", + apiAlias: "categories-counts_aggregation", + buckets: [ + { key: "cat-shoes", count: 248 }, + { + key: "cat-accessories", + count: 40, + "categories-parents": { + buckets: [ + { key: "parent-1", count: 6 }, + { key: "", count: 28 }, + ], + }, + }, + ], + }, + }; + + const result = getListingFilters(aggregations); + + expect(result).toEqual([ + { + code: "categories", + label: "categories", + id: "categories", + name: "categories", + entities: [ + { + id: "cat-shoes", + name: "Shoes", + translated: { name: "Shoes" }, + count: 248, + }, + { + id: "cat-accessories", + name: "Accessories", + translated: { name: "Accessories" }, + count: 29, + }, + ], + }, + ]); + }); + + it("should build the categories filter without counts when the counts aggregation is missing", () => { + const aggregations = { + categories: { + name: "categories", + entities: [ + { id: "cat-b", name: "B", translated: { name: "B" } }, + { id: "cat-a", name: "A", translated: { name: "A" } }, + ], + }, + }; + + const result = getListingFilters(aggregations); + + expect(result).toHaveLength(1); + expect(result[0]?.entities).toEqual([ + { id: "cat-a", name: "A", translated: { name: "A" }, count: undefined }, + { id: "cat-b", name: "B", translated: { name: "B" }, count: undefined }, + ]); + }); + + it("should skip a categories-counts aggregation without a categories aggregation", () => { + const aggregations = { + "categories-counts": { + name: "categories-counts", + buckets: [{ key: "cat-shoes", count: 248 }], + }, + }; + + expect(getListingFilters(aggregations)).toEqual([]); + }); + + it("should pass through a categories aggregation without entities", () => { + const aggregations = { + categories: { + name: "categories", + buckets: [{ key: "cat-shoes", count: 248 }], + }, + }; + + expect(getListingFilters(aggregations)).toEqual([ + { + code: "categories", + label: "categories", + name: "categories", + buckets: [{ key: "cat-shoes", count: 248 }], + }, + ]); + }); + it("should skip options aggregation", () => { const aggregations = { options: { diff --git a/packages/helpers/src/listing/filters.ts b/packages/helpers/src/listing/filters.ts index 41681820c..973fb5fc4 100644 --- a/packages/helpers/src/listing/filters.ts +++ b/packages/helpers/src/listing/filters.ts @@ -1,4 +1,10 @@ import { getTranslatedProperty } from "../getTranslatedProperty"; +import { + CATEGORY_AGGREGATION_NAME, + CATEGORY_COUNTS_AGGREGATION_NAME, + type CategoryFilterBucket, + resolveCategoryBucketCount, +} from "./categoryFilter"; type AggregationFilterEntity = { name: string; @@ -22,7 +28,11 @@ type ListingFilter = { id: string; name: string; options?: Array<{ id: string; translated?: { name?: string } }>; - entities?: Array<{ id: string; translated?: { name?: string } }>; + entities?: Array<{ + id: string; + translated?: { name?: string }; + count?: number; + }>; }; const getFilter = ( @@ -48,6 +58,37 @@ function isEntitiesAggregation( ); } +function getCategoryFilter( + aggregation: EntitiesAggregation, + countsAggregation?: { buckets?: CategoryFilterBucket[] }, +): ListingFilter { + const countByCategoryId = new Map(); + for (const bucket of countsAggregation?.buckets ?? []) { + countByCategoryId.set(bucket.key, resolveCategoryBucketCount(bucket)); + } + const entities = aggregation.entities + .map((entity) => ({ + ...entity, + // Count per category: kept on the entity and used to sort, but not + // rendered yet - it overcounts variants on the search route. + count: countByCategoryId.get(entity.id), + })) + .sort( + (a, b) => + (b.count ?? 0) - (a.count ?? 0) || + getTranslatedProperty(a, "name").localeCompare( + getTranslatedProperty(b, "name"), + ), + ); + return { + label: CATEGORY_AGGREGATION_NAME, + code: CATEGORY_AGGREGATION_NAME, + id: CATEGORY_AGGREGATION_NAME, + name: CATEGORY_AGGREGATION_NAME, + entities, + }; +} + /** * @beta */ @@ -69,7 +110,21 @@ export function getListingFilters>( for (const filterEntity of aggregation.entities) { transformedFilters.push(getFilter(aggregationName, filterEntity)); } - } else if (!["properties", "options"].includes(aggregationName)) { + } else if ( + aggregationName === CATEGORY_AGGREGATION_NAME && + isEntitiesAggregation(aggregation) + ) { + transformedFilters.push( + getCategoryFilter( + aggregation, + aggregations[CATEGORY_COUNTS_AGGREGATION_NAME], + ), + ); + } else if ( + !["properties", "options", CATEGORY_COUNTS_AGGREGATION_NAME].includes( + aggregationName, + ) + ) { transformedFilters.push(getFilter(aggregationName, aggregation)); } } diff --git a/packages/helpers/src/listing/index.ts b/packages/helpers/src/listing/index.ts index 302e3a1a6..f60a3f361 100644 --- a/packages/helpers/src/listing/index.ts +++ b/packages/helpers/src/listing/index.ts @@ -1 +1,5 @@ +export { + getCategoryFilterAggregations, + getCategoryFilterPostFilter, +} from "./categoryFilter"; export * from "./filters"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3f366a74..19363c828 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -785,6 +785,12 @@ importers: nuxt: specifier: 4.4.8 version: 4.4.8(@babel/plugin-proposal-decorators@7.25.9(@babel/core@7.29.7))(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vercel/blob@1.0.2)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(encoding@0.1.13)(esbuild@0.28.1)(ioredis@5.10.1)(less@4.2.0)(magicast@0.5.3)(oxlint@1.72.0)(rolldown@1.1.4)(rollup-plugin-visualizer@7.0.1(rolldown@1.1.4)(rollup@4.60.4))(rollup@4.60.4)(sass@1.89.2)(terser@5.48.0)(tsx@4.23.0)(typescript@5.9.3)(vite@8.1.3(@types/node@22.13.14)(esbuild@0.28.1)(jiti@1.21.7)(less@4.2.0)(sass@1.89.2)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vue-tsc@3.3.6(typescript@5.9.3))(yaml@2.9.0) + oxfmt: + specifier: 0.57.0 + version: 0.57.0 + oxlint: + specifier: 1.72.0 + version: 1.72.0 typescript: specifier: 5.9.3 version: 5.9.3 diff --git a/templates/vue-demo-store/app/components/listing-filters/ListingFilter.vue b/templates/vue-demo-store/app/components/listing-filters/ListingFilter.vue index 1c61b9ed5..2d69e05ba 100644 --- a/templates/vue-demo-store/app/components/listing-filters/ListingFilter.vue +++ b/templates/vue-demo-store/app/components/listing-filters/ListingFilter.vue @@ -1,4 +1,6 @@