From f8dc43524d4b138f8ebf809370e2d05e105f3d50 Mon Sep 17 00:00:00 2001 From: mkucmus Date: Tue, 24 Mar 2026 11:58:31 +0100 Subject: [PATCH 1/4] refactor(composables): modular composables --- .../composables/src/useListing/useListing.ts | 511 ++---------------- .../src/useListing/useListingCore.ts | 139 +++++ .../useListing/useProductListingFilters.ts | 112 ++++ .../useListing/useProductListingPagination.ts | 43 ++ .../useListing/useProductListingSorting.ts | 41 ++ packages/composables/src/useListing/utils.ts | 173 ++++++ 6 files changed, 566 insertions(+), 453 deletions(-) create mode 100644 packages/composables/src/useListing/useListingCore.ts create mode 100644 packages/composables/src/useListing/useProductListingFilters.ts create mode 100644 packages/composables/src/useListing/useProductListingPagination.ts create mode 100644 packages/composables/src/useListing/useProductListingSorting.ts create mode 100644 packages/composables/src/useListing/utils.ts diff --git a/packages/composables/src/useListing/useListing.ts b/packages/composables/src/useListing/useListing.ts index 722e58ad7..4e451e45d 100644 --- a/packages/composables/src/useListing/useListing.ts +++ b/packages/composables/src/useListing/useListing.ts @@ -1,179 +1,18 @@ -import { getListingFilters } from "@shopware/helpers"; import { createInjectionState, createSharedComposable } from "@vueuse/core"; -import { computed, inject, provide, ref } from "vue"; -import type { ComputedRef, Ref } from "vue"; +import { computed } from "vue"; import { useCategory, useShopwareContext } from "#imports"; import type { Schemas, operations } from "#shopware"; - -function isObject(item: T): boolean { - return item && typeof item === "object" && !Array.isArray(item); -} - -function merge( - target: T, - ...sources: T[] -): T { - if (!sources.length) return target; - const source = sources.shift(); - - if (source === undefined) { - return target; - } - - if (isObject(target) && isObject(source)) { - for (const key in source) { - if (isObject(source[key])) { - if (!target[key]) Object.assign(target, { [key]: {} }); - merge(target[key], source[key]); - } else { - Object.assign(target, { [key]: source[key] }); - } - } - } - - return merge(target, ...sources); -} - -export type ListingType = "productSearchListing" | "categoryListing"; - -export type ShortcutFilterParam< - T extends - keyof Schemas["ProductListingCriteria"] = keyof Schemas["ProductListingCriteria"], -> = { - code: T; - value: Schemas["ProductListingCriteria"][T]; -}; - -export type UseListingReturn = { - /** - * Listing that is currently set - * {@link ListingResult} object - */ - getInitialListing: ComputedRef; - /** - * Sets the initial listing - available synchronously - * @param {@link initialListing} - initial listing to set - * @returns - */ - setInitialListing( - initialListing: Schemas["ProductListingResult"], - ): Promise; - /** - * @deprecated - use `search` instead - * Searches for the listing based on the criteria - * @param criteria {@link Schemas['Criteria']} - * @returns - */ - initSearch( - criteria: operations["searchPage post /search"]["body"], - ): Promise; - /** - * Searches for the listing based on the criteria - * @param criteria - * @returns - */ - search( - criteria: - | operations["readProductListing post /product-listing/{categoryId}"]["body"] - | operations["searchPage post /search"]["body"], - ): Promise; - /** - * Loads more (next page) elements to the listing - */ - loadMore( - criteria?: operations["searchPage post /search"]["body"], - ): Promise; - /** - * Listing that is currently set - */ - getCurrentListing: ComputedRef; - /** - * Listing elements ({@link Product}) that are currently set - */ - getElements: ComputedRef; - /** - * Available sorting orders - */ - getSortingOrders: ComputedRef< - Schemas["ProductSorting"][] | { key: string; label: string }[] | undefined - >; - /** - * Current sorting order - */ - getCurrentSortingOrder: ComputedRef; - /** - * Changes the current sorting order - * @param order - i.e. "name-asc" - * @returns - */ - changeCurrentSortingOrder( - order: string, - query?: operations["searchPage post /search"]["body"], - ): Promise; - /** - * Current page number - */ - getCurrentPage: ComputedRef; - /** - * Changes the current page number - * @param pageNumber - page number to change to - * @returns - */ - changeCurrentPage( - page: number, - query?: operations["searchPage post /search"]["body"], - ): Promise; - /** - * Total number of elements found for the current search criteria - */ - getTotal: ComputedRef; - /** - * Total number of pages found for the current search criteria - */ - getTotalPagesCount: ComputedRef; - /** - * Number of elements per page - */ - getLimit: ComputedRef; - /** - * Initial filters - */ - getInitialFilters: ComputedRef>; - /** - * All available filters - */ - getAvailableFilters: ComputedRef>; - /** - * Filters that are currently set - */ - getCurrentFilters: ComputedRef< - Schemas["ProductListingResult"]["currentFilters"] - >; - /** - * Sets the filters to be applied for the listing - * @param filters - * @returns - */ - setCurrentFilters(filters: ShortcutFilterParam[]): Promise; - /** - * Indicates if the listing is being fetched - */ - loading: ComputedRef; - /** - * Indicates if the listing is being fetched via `loadMore` method - */ - loadingMore: ComputedRef; - /** - * Resets the filters - clears the current filters - */ - resetFilters(): Promise; - /** - * Change selected filters to the query object - */ - filtersToQuery( - filters: Schemas["ProductListingCriteria"], - ): Record; -}; +import { useListingCore } from "./useListingCore"; +import { useProductListingFilters } from "./useProductListingFilters"; +import { useProductListingPagination } from "./useProductListingPagination"; +import { useProductListingSorting } from "./useProductListingSorting"; + +export type { + ListingType, + ShortcutFilterParam, + UseListingReturn, +} from "./utils"; +import type { ListingType, UseListingReturn } from "./utils"; /** * @public @@ -308,294 +147,60 @@ export function createListingComposable({ listingKey: string; initialListing?: Schemas["ProductListingResult"] | null; }): UseListingReturn { - // const COMPOSABLE_NAME = "createListingComposable"; - // const contextName = COMPOSABLE_NAME; - - // const router = useRouter(); - - // Handle CMS context to be able to show different breadcrumbs for different CMS pages. - // const { isVueComponent } = useVueContext(); - // const cmsContext = isVueComponent && inject("swCmsContext", null); - // const cacheKey = cmsContext - // ? `${contextName}(cms-${cmsContext})` - // : contextName; - - const loading = ref(false); - const loadingMore = ref(false); - - // const { sharedRef } = useSharedState(); - const _storeInitialListing = inject< - Ref - >(`useListingInitial-${listingKey}`, ref(initialListing ?? null)); - provide(`useListingInitial-${listingKey}`, _storeInitialListing); - // const _storeInitialListing = sharedRef( - // `${cacheKey}-initialListing-${listingKey}` - // ); - // const _storeAppliedListing = sharedRef>( - // `${cacheKey}-appliedListing-${listingKey}` - // ); - const _storeAppliedListing = inject< - Ref - >(`useListingApplied-${listingKey}`, ref(null)); - provide(`useListingApplied-${listingKey}`, _storeAppliedListing); - - const getInitialListing = computed(() => _storeInitialListing.value); - const setInitialListing = async ( - initialListing: Schemas["ProductListingResult"], - ) => { - _storeInitialListing.value = initialListing; - _storeAppliedListing.value = null; - }; - - const initSearch = async ( - criteria: operations["searchPage post /search"]["body"], - ): Promise => { - loading.value = true; - try { - const searchCriteria = merge( - {} as operations["searchPage post /search"]["body"], - searchDefaults, - criteria, - ); - const result = await searchMethod(searchCriteria); - return result; - } finally { - loading.value = false; - } - }; - - async function search( - criteria: operations["searchPage post /search"]["body"], - ) { - loading.value = true; - try { - const searchCriteria = merge( - {} as operations["searchPage post /search"]["body"], - searchDefaults, - criteria, - ); - const result = await searchMethod(searchCriteria); - - _storeAppliedListing.value = result; - } finally { - loading.value = false; - } - } - - const loadMore = async ( - criteria?: operations["searchPage post /search"]["body"], - ): Promise => { - loadingMore.value = true; - try { - const q = criteria - ? criteria - : { - // ...router.currentRoute.query, - p: getCurrentPage.value + 1, - }; - - const searchCriteria = merge( - {} as operations["searchPage post /search"]["body"], - searchDefaults, - q, - ) as operations["searchPage post /search"]["body"]; - const result = await searchMethod(searchCriteria); - _storeAppliedListing.value = { - ...(getCurrentListing.value || {}), - page: result.page, - elements: [ - ...(getCurrentListing.value?.elements || []), - ...(result.elements ?? []), - ], - } as Schemas["ProductListingResult"]; - } finally { - loadingMore.value = false; - } - }; - - const getCurrentListing = computed(() => { - return _storeAppliedListing.value || getInitialListing.value; - }); - - const getElements = computed(() => { - return getCurrentListing.value?.elements || []; - }); - const getTotal = computed(() => { - return getCurrentListing.value?.total || 0; - }); - const getLimit = computed(() => { - return getCurrentListing.value?.limit || searchDefaults?.limit || 10; - }); - - const getTotalPagesCount = computed(() => - Math.ceil(getTotal.value / getLimit.value), - ); - - const getSortingOrders = computed(() => { - return getCurrentListing.value?.availableSortings; + const core = useListingCore({ + listingKey, + searchMethod, + searchDefaults, + initialListing, }); - const getCurrentSortingOrder = computed( - () => getCurrentListing.value?.sorting, - ); - async function changeCurrentSortingOrder( - order: string, - query?: operations["searchPage post /search"]["body"], - ) { - await search( - Object.assign( - { - order, - }, - query, - ), - ); - } - - const getCurrentPage = computed(() => getCurrentListing.value?.page || 1); - - const changeCurrentPage = async ( - page: number, - query?: operations["searchPage post /search"]["body"], - ) => { - await search( - Object.assign( - { - page, - }, - query, - ), - ); - }; - - const getInitialFilters = computed(() => { - return getListingFilters(getInitialListing.value?.aggregations); + const pagination = useProductListingPagination({ + getCurrentListing: core.getCurrentListing, + getLimit: core.getLimit, + getTotal: core.getTotal, + search: core.search, }); - const getAvailableFilters = computed(() => { - return getListingFilters( - _storeAppliedListing.value?.aggregations || - getCurrentListing.value?.aggregations, - ); + const sorting = useProductListingSorting({ + getCurrentListing: core.getCurrentListing, + search: core.search, }); - const getCurrentFilters = computed(() => { - // const currentFiltersResult: Schemas["ProductListingResult"]["currentFilters"] = - // {}; - // const currentFilters = { - // ...getCurrentListing.value?.currentFilters, - // // ...router.currentRoute.query, - // }; - // Object.keys(currentFilters).forEach( - // (objectKey: keyof Schemas["ProductListingResult"]["currentFilters"]) => { - // if (!currentFilters[objectKey]) return; - // if (objectKey === "navigationId") return; - // if (objectKey === "price") { - // if (currentFilters.price?.min) - // currentFiltersResult.price.min = currentFilters[objectKey].min; - // if (currentFilters[objectKey].max) - // currentFiltersResult["max-price"] = currentFilters[objectKey].max; - // return; - // } - // if (objectKey === "p") return; - // currentFiltersResult[objectKey] = currentFilters[objectKey]; - // }, - // ); - return getCurrentListing.value - ?.currentFilters as Schemas["ProductListingResult"]["currentFilters"]; + const filters = useProductListingFilters({ + getInitialListing: core.getInitialListing, + getCurrentListing: core.getCurrentListing, + _storeAppliedListing: core._storeAppliedListing, + search: core.search, + searchDefaults, }); - // this function sets the current filters as shortcut filters @see https://shopware.stoplight.io/docs/store-api/b56ebe18277c6-searching-for-products#product-listing-criteria - // the downside is that this does not filter the aggregations, so the aggregations are not reduced by the filter (!) - const setCurrentFilters = (filters: ShortcutFilterParam[]) => { - const newFilters = {}; - for (const filter of filters) { - Object.assign(newFilters, { [filter.code]: filter.value }); - } - - const appliedFilters = Object.assign( - {}, - getCurrentFilters.value, - { - query: getCurrentFilters.value?.search, - manufacturer: getCurrentFilters.value?.manufacturer?.join("|"), - properties: getCurrentFilters.value?.properties?.join("|"), - }, - { ...newFilters }, - ); - - if (_storeAppliedListing.value) { - _storeAppliedListing.value.currentFilters = { - ...appliedFilters, - manufacturer: appliedFilters.manufacturer?.split("|"), - properties: appliedFilters.properties?.split("|"), - }; - } - - return search( - appliedFilters as operations["searchPage post /search"]["body"], - ); - }; - - const resetFilters = () => { - const defaultFilters = Object.assign( - { - manufacturer: [], - properties: [], - price: { min: 0, max: 0 }, - search: getCurrentFilters.value?.search, - }, - searchDefaults, - ); - - if (_storeAppliedListing.value) { - _storeAppliedListing.value.currentFilters = - defaultFilters as unknown as Schemas["ProductListingResult"]["currentFilters"]; - } - return search({ search: getCurrentFilters.value?.search || "" }); - }; - - const filtersToQuery = (filters: Schemas["ProductListingCriteria"]) => { - const queryObject: Record = {}; - - for (const filter in filters) { - const currentFilter = - filters[filter as keyof Schemas["ProductListingCriteria"]]; - if (currentFilter) { - if (Array.isArray(currentFilter) && currentFilter.length) { - queryObject[filter] = currentFilter.join("|"); - } else if (!Array.isArray(currentFilter)) { - queryObject[filter] = currentFilter; - } - } - } - - return queryObject; - }; - return { - changeCurrentPage, - changeCurrentSortingOrder, - filtersToQuery, - getAvailableFilters, - getCurrentFilters, - getCurrentListing, - getCurrentPage, - getCurrentSortingOrder, - getElements, - getInitialFilters, - getInitialListing, - getLimit, - getSortingOrders, - getTotal, - getTotalPagesCount, - initSearch, - loadMore, - loading: computed(() => loading.value), - loadingMore: computed(() => loadingMore.value), - resetFilters, - search, - setCurrentFilters, - setInitialListing, + // Core + getInitialListing: core.getInitialListing, + setInitialListing: core.setInitialListing, + initSearch: core.initSearch, + search: core.search, + loadMore: core.loadMore, + getCurrentListing: core.getCurrentListing, + getElements: core.getElements, + getTotal: core.getTotal, + getLimit: core.getLimit, + loading: computed(() => core.loading.value), + loadingMore: computed(() => core.loadingMore.value), + // Pagination + getCurrentPage: pagination.getCurrentPage, + changeCurrentPage: pagination.changeCurrentPage, + getTotalPagesCount: pagination.getTotalPagesCount, + // Sorting + getSortingOrders: sorting.getSortingOrders, + getCurrentSortingOrder: sorting.getCurrentSortingOrder, + changeCurrentSortingOrder: sorting.changeCurrentSortingOrder, + // Filters + getInitialFilters: filters.getInitialFilters, + getAvailableFilters: filters.getAvailableFilters, + getCurrentFilters: filters.getCurrentFilters, + setCurrentFilters: filters.setCurrentFilters, + resetFilters: filters.resetFilters, + filtersToQuery: filters.filtersToQuery, }; } diff --git a/packages/composables/src/useListing/useListingCore.ts b/packages/composables/src/useListing/useListingCore.ts new file mode 100644 index 000000000..a0d5664a2 --- /dev/null +++ b/packages/composables/src/useListing/useListingCore.ts @@ -0,0 +1,139 @@ +import { computed, inject, provide, ref } from "vue"; +import type { Ref } from "vue"; +import type { Schemas, operations } from "#shopware"; +import { merge } from "./utils"; + +export function useListingCore({ + listingKey, + searchMethod, + searchDefaults, + initialListing, +}: { + listingKey: string; + searchMethod( + searchParams: + | operations["readProductListing post /product-listing/{categoryId}"]["body"] + | operations["searchPage post /search"]["body"], + ): Promise; + searchDefaults: operations["searchPage post /search"]["body"]; + initialListing?: Schemas["ProductListingResult"] | null; +}) { + const loading = ref(false); + const loadingMore = ref(false); + + const _storeInitialListing = inject< + Ref + >(`useListingInitial-${listingKey}`, ref(initialListing ?? null)); + provide(`useListingInitial-${listingKey}`, _storeInitialListing); + + const _storeAppliedListing = inject< + Ref + >(`useListingApplied-${listingKey}`, ref(null)); + provide(`useListingApplied-${listingKey}`, _storeAppliedListing); + + const getInitialListing = computed(() => _storeInitialListing.value); + + const setInitialListing = async ( + initialListing: Schemas["ProductListingResult"], + ) => { + _storeInitialListing.value = initialListing; + _storeAppliedListing.value = null; + }; + + const getCurrentListing = computed(() => { + return _storeAppliedListing.value || getInitialListing.value; + }); + + const getElements = computed(() => { + return getCurrentListing.value?.elements || []; + }); + + const getTotal = computed(() => { + return getCurrentListing.value?.total || 0; + }); + + const getLimit = computed(() => { + return getCurrentListing.value?.limit || searchDefaults?.limit || 10; + }); + + const initSearch = async ( + criteria: operations["searchPage post /search"]["body"], + ): Promise => { + loading.value = true; + try { + const searchCriteria = merge( + {} as operations["searchPage post /search"]["body"], + searchDefaults, + criteria, + ); + const result = await searchMethod(searchCriteria); + return result; + } finally { + loading.value = false; + } + }; + + async function search( + criteria: operations["searchPage post /search"]["body"], + ) { + loading.value = true; + try { + const searchCriteria = merge( + {} as operations["searchPage post /search"]["body"], + searchDefaults, + criteria, + ); + const result = await searchMethod(searchCriteria); + + _storeAppliedListing.value = result; + } finally { + loading.value = false; + } + } + + const loadMore = async ( + criteria?: operations["searchPage post /search"]["body"], + ): Promise => { + loadingMore.value = true; + try { + const q = criteria + ? criteria + : { + p: (getCurrentListing.value?.page || 1) + 1, + }; + + const searchCriteria = merge( + {} as operations["searchPage post /search"]["body"], + searchDefaults, + q, + ) as operations["searchPage post /search"]["body"]; + const result = await searchMethod(searchCriteria); + _storeAppliedListing.value = { + ...(getCurrentListing.value || {}), + page: result.page, + elements: [ + ...(getCurrentListing.value?.elements || []), + ...(result.elements ?? []), + ], + } as Schemas["ProductListingResult"]; + } finally { + loadingMore.value = false; + } + }; + + return { + _storeInitialListing, + _storeAppliedListing, + loading, + loadingMore, + getInitialListing, + setInitialListing, + getCurrentListing, + getElements, + getTotal, + getLimit, + initSearch, + search, + loadMore, + }; +} diff --git a/packages/composables/src/useListing/useProductListingFilters.ts b/packages/composables/src/useListing/useProductListingFilters.ts new file mode 100644 index 000000000..b5197ee23 --- /dev/null +++ b/packages/composables/src/useListing/useProductListingFilters.ts @@ -0,0 +1,112 @@ +import { getListingFilters } from "@shopware/helpers"; +import { computed } from "vue"; +import type { ComputedRef, Ref } from "vue"; +import type { Schemas, operations } from "#shopware"; +import type { ShortcutFilterParam } from "./utils"; + +export function useProductListingFilters({ + getInitialListing, + getCurrentListing, + _storeAppliedListing, + search, + searchDefaults, +}: { + getInitialListing: ComputedRef; + getCurrentListing: ComputedRef; + _storeAppliedListing: Ref; + search( + criteria: operations["searchPage post /search"]["body"], + ): Promise; + searchDefaults: operations["searchPage post /search"]["body"]; +}) { + const getInitialFilters = computed(() => { + return getListingFilters(getInitialListing.value?.aggregations); + }); + + const getAvailableFilters = computed(() => { + return getListingFilters( + _storeAppliedListing.value?.aggregations || + getCurrentListing.value?.aggregations, + ); + }); + + const getCurrentFilters = computed(() => { + return getCurrentListing.value + ?.currentFilters as Schemas["ProductListingResult"]["currentFilters"]; + }); + + const setCurrentFilters = (filters: ShortcutFilterParam[]) => { + const newFilters = {}; + for (const filter of filters) { + Object.assign(newFilters, { [filter.code]: filter.value }); + } + + const appliedFilters = Object.assign( + {}, + getCurrentFilters.value, + { + query: getCurrentFilters.value?.search, + manufacturer: getCurrentFilters.value?.manufacturer?.join("|"), + properties: getCurrentFilters.value?.properties?.join("|"), + }, + { ...newFilters }, + ); + + if (_storeAppliedListing.value) { + _storeAppliedListing.value.currentFilters = { + ...appliedFilters, + manufacturer: appliedFilters.manufacturer?.split("|"), + properties: appliedFilters.properties?.split("|"), + }; + } + + return search( + appliedFilters as operations["searchPage post /search"]["body"], + ); + }; + + const resetFilters = () => { + const defaultFilters = Object.assign( + { + manufacturer: [], + properties: [], + price: { min: 0, max: 0 }, + search: getCurrentFilters.value?.search, + }, + searchDefaults, + ); + + if (_storeAppliedListing.value) { + _storeAppliedListing.value.currentFilters = + defaultFilters as unknown as Schemas["ProductListingResult"]["currentFilters"]; + } + return search({ search: getCurrentFilters.value?.search || "" }); + }; + + const filtersToQuery = (filters: Schemas["ProductListingCriteria"]) => { + const queryObject: Record = {}; + + for (const filter in filters) { + const currentFilter = + filters[filter as keyof Schemas["ProductListingCriteria"]]; + if (currentFilter) { + if (Array.isArray(currentFilter) && currentFilter.length) { + queryObject[filter] = currentFilter.join("|"); + } else if (!Array.isArray(currentFilter)) { + queryObject[filter] = currentFilter; + } + } + } + + return queryObject; + }; + + return { + getInitialFilters, + getAvailableFilters, + getCurrentFilters, + setCurrentFilters, + resetFilters, + filtersToQuery, + }; +} diff --git a/packages/composables/src/useListing/useProductListingPagination.ts b/packages/composables/src/useListing/useProductListingPagination.ts new file mode 100644 index 000000000..f697fe55c --- /dev/null +++ b/packages/composables/src/useListing/useProductListingPagination.ts @@ -0,0 +1,43 @@ +import { computed } from "vue"; +import type { ComputedRef } from "vue"; +import type { Schemas, operations } from "#shopware"; + +export function useProductListingPagination({ + getCurrentListing, + getLimit, + getTotal, + search, +}: { + getCurrentListing: ComputedRef; + getLimit: ComputedRef; + getTotal: ComputedRef; + search( + criteria: operations["searchPage post /search"]["body"], + ): Promise; +}) { + const getCurrentPage = computed(() => getCurrentListing.value?.page || 1); + + const getTotalPagesCount = computed(() => + Math.ceil(getTotal.value / getLimit.value), + ); + + const changeCurrentPage = async ( + page: number, + query?: operations["searchPage post /search"]["body"], + ) => { + await search( + Object.assign( + { + page, + }, + query, + ), + ); + }; + + return { + getCurrentPage, + getTotalPagesCount, + changeCurrentPage, + }; +} diff --git a/packages/composables/src/useListing/useProductListingSorting.ts b/packages/composables/src/useListing/useProductListingSorting.ts new file mode 100644 index 000000000..d8c47fa68 --- /dev/null +++ b/packages/composables/src/useListing/useProductListingSorting.ts @@ -0,0 +1,41 @@ +import { computed } from "vue"; +import type { ComputedRef } from "vue"; +import type { Schemas, operations } from "#shopware"; + +export function useProductListingSorting({ + getCurrentListing, + search, +}: { + getCurrentListing: ComputedRef; + search( + criteria: operations["searchPage post /search"]["body"], + ): Promise; +}) { + const getSortingOrders = computed(() => { + return getCurrentListing.value?.availableSortings; + }); + + const getCurrentSortingOrder = computed( + () => getCurrentListing.value?.sorting, + ); + + async function changeCurrentSortingOrder( + order: string, + query?: operations["searchPage post /search"]["body"], + ) { + await search( + Object.assign( + { + order, + }, + query, + ), + ); + } + + return { + getSortingOrders, + getCurrentSortingOrder, + changeCurrentSortingOrder, + }; +} diff --git a/packages/composables/src/useListing/utils.ts b/packages/composables/src/useListing/utils.ts new file mode 100644 index 000000000..8d48ee747 --- /dev/null +++ b/packages/composables/src/useListing/utils.ts @@ -0,0 +1,173 @@ +import type { getListingFilters } from "@shopware/helpers"; +import type { ComputedRef } from "vue"; +import type { Schemas, operations } from "#shopware"; + +export function isObject(item: T): boolean { + return item && typeof item === "object" && !Array.isArray(item); +} + +export function merge( + target: T, + ...sources: T[] +): T { + if (!sources.length) return target; + const source = sources.shift(); + + if (source === undefined) { + return target; + } + + if (isObject(target) && isObject(source)) { + for (const key in source) { + if (isObject(source[key])) { + if (!target[key]) Object.assign(target, { [key]: {} }); + merge(target[key], source[key]); + } else { + Object.assign(target, { [key]: source[key] }); + } + } + } + + return merge(target, ...sources); +} + +export type ListingType = "productSearchListing" | "categoryListing"; + +export type ShortcutFilterParam< + T extends + keyof Schemas["ProductListingCriteria"] = keyof Schemas["ProductListingCriteria"], +> = { + code: T; + value: Schemas["ProductListingCriteria"][T]; +}; + +export type UseListingReturn = { + /** + * Listing that is currently set + * {@link ListingResult} object + */ + getInitialListing: ComputedRef; + /** + * Sets the initial listing - available synchronously + * @param {@link initialListing} - initial listing to set + * @returns + */ + setInitialListing( + initialListing: Schemas["ProductListingResult"], + ): Promise; + /** + * @deprecated - use `search` instead + * Searches for the listing based on the criteria + * @param criteria {@link Schemas['Criteria']} + * @returns + */ + initSearch( + criteria: operations["searchPage post /search"]["body"], + ): Promise; + /** + * Searches for the listing based on the criteria + * @param criteria + * @returns + */ + search( + criteria: + | operations["readProductListing post /product-listing/{categoryId}"]["body"] + | operations["searchPage post /search"]["body"], + ): Promise; + /** + * Loads more (next page) elements to the listing + */ + loadMore( + criteria?: operations["searchPage post /search"]["body"], + ): Promise; + /** + * Listing that is currently set + */ + getCurrentListing: ComputedRef; + /** + * Listing elements ({@link Product}) that are currently set + */ + getElements: ComputedRef; + /** + * Available sorting orders + */ + getSortingOrders: ComputedRef< + Schemas["ProductSorting"][] | { key: string; label: string }[] | undefined + >; + /** + * Current sorting order + */ + getCurrentSortingOrder: ComputedRef; + /** + * Changes the current sorting order + * @param order - i.e. "name-asc" + * @returns + */ + changeCurrentSortingOrder( + order: string, + query?: operations["searchPage post /search"]["body"], + ): Promise; + /** + * Current page number + */ + getCurrentPage: ComputedRef; + /** + * Changes the current page number + * @param pageNumber - page number to change to + * @returns + */ + changeCurrentPage( + page: number, + query?: operations["searchPage post /search"]["body"], + ): Promise; + /** + * Total number of elements found for the current search criteria + */ + getTotal: ComputedRef; + /** + * Total number of pages found for the current search criteria + */ + getTotalPagesCount: ComputedRef; + /** + * Number of elements per page + */ + getLimit: ComputedRef; + /** + * Initial filters + */ + getInitialFilters: ComputedRef>; + /** + * All available filters + */ + getAvailableFilters: ComputedRef>; + /** + * Filters that are currently set + */ + getCurrentFilters: ComputedRef< + Schemas["ProductListingResult"]["currentFilters"] + >; + /** + * Sets the filters to be applied for the listing + * @param filters + * @returns + */ + setCurrentFilters(filters: ShortcutFilterParam[]): Promise; + /** + * Indicates if the listing is being fetched + */ + loading: ComputedRef; + /** + * Indicates if the listing is being fetched via `loadMore` method + */ + loadingMore: ComputedRef; + /** + * Resets the filters - clears the current filters + */ + resetFilters(): Promise; + /** + * Change selected filters to the query object + */ + filtersToQuery( + filters: Schemas["ProductListingCriteria"], + ): Record; +}; From f3aaf6cbf9873014b0abbcab3f55480176431785 Mon Sep 17 00:00:00 2001 From: mkucmus Date: Tue, 24 Mar 2026 14:19:19 +0100 Subject: [PATCH 2/4] feat(templates): use new listing logic in templates and dependent packages --- .../components/SwProductListingFilters.vue | 20 ++++++------- .../SwProductListingFiltersHorizontal.vue | 19 +++++++------ .../cms/element/CmsElementProductListing.vue | 19 ++++++------- .../composables/src/useListing/useListing.ts | 4 +++ .../src/useListing/useListingCore.ts | 21 +++++++++++++- .../useListing/useProductListingFilters.ts | 28 +++++++++++-------- .../useListing/useProductListingPagination.ts | 17 ++++++----- .../useListing/useProductListingSorting.ts | 15 ++++++---- .../listing-filters/ListingFilters.vue | 12 ++++---- templates/vue-demo-store/app/pages/search.vue | 14 +++++----- .../vue-starter-template/app/pages/search.vue | 12 +++----- 11 files changed, 106 insertions(+), 75 deletions(-) diff --git a/packages/cms-base-layer/app/components/SwProductListingFilters.vue b/packages/cms-base-layer/app/components/SwProductListingFilters.vue index f102bba42..5b3001569 100644 --- a/packages/cms-base-layer/app/components/SwProductListingFilters.vue +++ b/packages/cms-base-layer/app/components/SwProductListingFilters.vue @@ -3,12 +3,16 @@ import type { CmsElementProductListing, CmsElementSidebarFilter, } from "@shopware/composables"; -import { useCmsTranslations } from "@shopware/composables"; +import { + useCmsTranslations, + useListingCoreContext, + useProductListingFilters, + useProductListingSorting, +} from "@shopware/composables"; import { defu } from "defu"; import { computed, reactive } from "vue"; import type { ComputedRef, UnwrapNestedRefs } from "vue"; import { type LocationQueryRaw, useRoute, useRouter } from "vue-router"; -import { useCategoryListing } from "#imports"; import type { Schemas, operations } from "#shopware"; const props = defineProps<{ @@ -46,14 +50,10 @@ translations = defu(useCmsTranslations(), translations) as Translations; const route = useRoute(); const router = useRouter(); -const { - changeCurrentSortingOrder, - getCurrentFilters, - getCurrentSortingOrder, - getInitialFilters, - getSortingOrders, - search, -} = useCategoryListing(); +const { search } = useListingCoreContext(); +const { getInitialFilters, getCurrentFilters } = useProductListingFilters(); +const { getSortingOrders, getCurrentSortingOrder, changeCurrentSortingOrder } = + useProductListingSorting(); const sidebarSelectedFilters: UnwrapNestedRefs = reactive({ diff --git a/packages/cms-base-layer/app/components/SwProductListingFiltersHorizontal.vue b/packages/cms-base-layer/app/components/SwProductListingFiltersHorizontal.vue index a2152d6ea..48f5b360b 100644 --- a/packages/cms-base-layer/app/components/SwProductListingFiltersHorizontal.vue +++ b/packages/cms-base-layer/app/components/SwProductListingFiltersHorizontal.vue @@ -3,12 +3,16 @@ import type { CmsElementProductListing, CmsElementSidebarFilter, } from "@shopware/composables"; -import { useCmsTranslations } from "@shopware/composables"; +import { + useCmsTranslations, + useListingCoreContext, + useProductListingFilters, + useProductListingSorting, +} from "@shopware/composables"; import { defu } from "defu"; import { computed, reactive } from "vue"; import type { ComputedRef, UnwrapNestedRefs } from "vue"; import { type LocationQueryRaw, useRoute, useRouter } from "vue-router"; -import { useCategoryListing } from "#imports"; import type { Schemas, operations } from "#shopware"; const props = defineProps<{ @@ -46,13 +50,10 @@ translations = defu(useCmsTranslations(), translations) as Translations; const route = useRoute(); const router = useRouter(); -const { - changeCurrentSortingOrder, - getCurrentSortingOrder, - getInitialFilters, - getSortingOrders, - search, -} = useCategoryListing(); +const { search } = useListingCoreContext(); +const { getInitialFilters } = useProductListingFilters(); +const { getSortingOrders, getCurrentSortingOrder, changeCurrentSortingOrder } = + useProductListingSorting(); const sidebarSelectedFilters: UnwrapNestedRefs = reactive({ diff --git a/packages/cms-base-layer/app/components/public/cms/element/CmsElementProductListing.vue b/packages/cms-base-layer/app/components/public/cms/element/CmsElementProductListing.vue index c0d174eba..1f1201f9f 100644 --- a/packages/cms-base-layer/app/components/public/cms/element/CmsElementProductListing.vue +++ b/packages/cms-base-layer/app/components/public/cms/element/CmsElementProductListing.vue @@ -1,10 +1,14 @@ +``` + +**Child component** — uses the modular composables to access specific concerns: + +```vue + +``` + +**Search page** — for product search, use the shared composable: + +```vue + +``` + ## Listing context Product listing is a structure related to the predefined areas and it has always the same interface: `ProductListingResult`: @@ -25,6 +79,10 @@ Product listing is a structure related to the predefined areas and it has always ## Listing type and context +:::warning Deprecated +`useListing` is deprecated. Use `useListingCore` with the modular composables (`useProductListingFilters`, `useProductListingPagination`, `useProductListingSorting`) instead. +::: + Before using the composable, define the type related to the context: - `categoryListing` for navigation/category/cms pages @@ -105,11 +163,28 @@ search({ // invoke search() method ## Sorting -Available methods of `useListing` to manage sorting order: +Available methods to manage sorting order: + +- `getSortingOrders` - returns all available sorting options +- `getCurrentSortingOrder` - returns the current order, available in the response +- `changeCurrentSortingOrder` - sets the new order, invoking a `search` method internally + +Using the modular composable (recommended): + +```ts +// part of