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/guides/e-commerce/product-listing.md b/apps/docs/src/guides/e-commerce/product-listing.md index 81c2f5fcf..d7729ed17 100644 --- a/apps/docs/src/guides/e-commerce/product-listing.md +++ b/apps/docs/src/guides/e-commerce/product-listing.md @@ -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: diff --git a/examples/sanity-cms/package.json b/examples/sanity-cms/package.json index 96db1447c..baca53eba 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.58.0", + "oxlint": "1.73.0", "typescript": "5.9.3", "vue": "3.5.39", "vue-tsc": "3.3.7" 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 a9923fcf1..680f4c513 100644 --- a/packages/helpers/src/index.test.ts +++ b/packages/helpers/src/index.test.ts @@ -16,6 +16,8 @@ describe("helpers - test global API", () => { "getBiggestThumbnailUrl": [Function], "getCanonicalPathForTechnicalPath": [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..be807ce85 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,38 @@ 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. Used to sort here and exposed to consumers, but on + // the ES search route it counts every variant, so the filter UIs shipped + // in this repo do not render it. + 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 +111,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 62b944213..1e44a414f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -806,6 +806,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.73.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.49.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.49.0)(tsx@4.23.0)(yaml@2.9.0))(vue-tsc@3.3.7(typescript@5.9.3))(webpack@5.105.4(esbuild@0.28.1)(postcss@8.5.22))(yaml@2.9.0) + oxfmt: + specifier: 0.58.0 + version: 0.58.0 + oxlint: + specifier: 1.73.0 + version: 1.73.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..f1a0cd96d 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 @@