@heyframe/composables
Version:
HeyFrame Frontends composables for Vue
595 lines (543 loc) • 17.5 kB
text/typescript
import { getListingFilters } from "@heyframe/helpers";
import { createInjectionState, createSharedComposable } from "@vueuse/core";
import { computed, inject, provide, ref } from "vue";
import type { ComputedRef, Ref } from "vue";
import { useCategory, useHeyFrameContext } from "#imports";
import type { Schemas, operations } from "#heyframe";
function isObject<T>(item: T): boolean {
return item && typeof item === "object" && !Array.isArray(item);
}
function merge<T extends { [key in keyof T]: unknown }>(
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<Schemas["ProductListingResult"] | null>;
/**
* Sets the initial listing - available synchronously
* @param {@link initialListing} - initial listing to set
* @returns
*/
setInitialListing(
initialListing: Schemas["ProductListingResult"],
): Promise<void>;
/**
* @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<Schemas["ProductListingResult"]>;
/**
* 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<void>;
/**
* Loads more (next page) elements to the listing
*/
loadMore(
criteria?: operations["searchPage post /search"]["body"],
): Promise<void>;
/**
* Listing that is currently set
*/
getCurrentListing: ComputedRef<Schemas["ProductListingResult"] | null>;
/**
* Listing elements ({@link Product}) that are currently set
*/
getElements: ComputedRef<Schemas["ProductListingResult"]["elements"]>;
/**
* Available sorting orders
*/
getSortingOrders: ComputedRef<
Schemas["ProductSorting"][] | { key: string; label: string }[] | undefined
>;
/**
* Current sorting order
*/
getCurrentSortingOrder: ComputedRef<string | undefined>;
/**
* Changes the current sorting order
* @param order - i.e. "name-asc"
* @returns
*/
changeCurrentSortingOrder(
order: string,
query?: operations["searchPage post /search"]["body"],
): Promise<void>;
/**
* Current page number
*/
getCurrentPage: ComputedRef<number>;
/**
* Changes the current page number
* @param pageNumber - page number to change to
* @returns
*/
changeCurrentPage(
page: number,
query?: operations["searchPage post /search"]["body"],
): Promise<void>;
/**
* Total number of elements found for the current search criteria
*/
getTotal: ComputedRef<number>;
/**
* Total number of pages found for the current search criteria
*/
getTotalPagesCount: ComputedRef<number>;
/**
* Number of elements per page
*/
getLimit: ComputedRef<number>;
/**
* Initial filters
*/
getInitialFilters: ComputedRef<ReturnType<typeof getListingFilters>>;
/**
* All available filters
*/
getAvailableFilters: ComputedRef<ReturnType<typeof getListingFilters>>;
/**
* 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<void>;
/**
* Indicates if the listing is being fetched
*/
loading: ComputedRef<boolean>;
/**
* Indicates if the listing is being fetched via `loadMore` method
*/
loadingMore: ComputedRef<boolean>;
/**
* Resets the filters - clears the current filters
*/
resetFilters(): Promise<void>;
/**
* Change selected filters to the query object
*/
filtersToQuery(
filters: Schemas["ProductListingCriteria"],
): Record<string, unknown>;
};
/**
* @public
* @category Product
*/
export function useListing(params?: {
listingType: ListingType;
categoryId?: string;
defaultSearchCriteria?: operations["searchPage post /search"]["body"];
}): UseListingReturn {
const listingType = params?.listingType || "categoryListing";
let categoryId = params?.categoryId || null;
// const { getDefaults } = useDefaults({ defaultsKey: contextName });
const { apiClient } = useHeyFrameContext();
let searchMethod: typeof listingType extends "productSearchListing"
? (
searchParams: operations["readProductListing post /product-listing/{categoryId}"]["body"],
) => Promise<Schemas["ProductListingResult"]>
: (
searchParams: operations["searchPage post /search"]["body"],
) => Promise<Schemas["ProductListingResult"]>;
if (listingType === "productSearchListing") {
searchMethod = async (
searchCriteria: operations["searchPage post /search"]["body"],
) => {
const { data } = await apiClient.invoke("searchPage post /search", {
headers: {
"sw-include-seo-urls": true,
},
body: searchCriteria,
});
return data;
};
} else {
if (!categoryId) {
const { category } = useCategory();
categoryId = category.value?.id;
}
searchMethod = async (
searchCriteria: operations["readProductListing post /product-listing/{categoryId}"]["body"],
) => {
const { data } = await apiClient.invoke(
"readProductListing post /product-listing/{categoryId}",
{
headers: {
"sw-include-seo-urls": true,
},
pathParams: {
categoryId: categoryId as string, // null exception in useCategory,
},
body: searchCriteria,
},
);
return data;
};
}
return createListingComposable({
listingKey: listingType,
searchMethod,
searchDefaults:
params?.defaultSearchCriteria ||
({} as operations["searchPage post /search"]["body"]), //getDefaults(),
});
}
const [_createCategoryListingContext, _categoryListingContext] =
createInjectionState(
() => {
return useListing({ listingType: "categoryListing" });
},
{
injectionKey: "categoryListing",
},
);
export const createCategoryListingContext = _createCategoryListingContext;
/**
* Temporary workaround over `useListing` to support shared data. This composable API will change in the future.
*
* You need to call `createCategoryListingContext` before this composable.
*/
export const useCategoryListing = () => {
const listingContext = _categoryListingContext();
if (!listingContext) {
throw new Error(
"[useCategoryListing] Please call `createCategoryListingContext` on the appropriate parent component",
);
}
return listingContext;
};
/**
* Temporary workaround over `useListing` to support shared data. This composable API will change in the future.
*/
export const useProductSearchListing = createSharedComposable(() =>
useListing({ listingType: "productSearchListing" }),
);
/**
* Factory to create your own listing.
*
* By default you can use useListing composable, which provides you predefined listings for category(cms) listing and product search listing.
* Using factory you can provide our own compatible search method and use it for example for creating listing of orders in my account.
*
* @public
*/
export function createListingComposable({
searchMethod,
searchDefaults,
listingKey,
}: {
searchMethod(
searchParams:
| operations["readProductListing post /product-listing/{categoryId}"]["body"]
| operations["searchPage post /search"]["body"],
): Promise<Schemas["ProductListingResult"]>;
searchDefaults: operations["searchPage post /search"]["body"];
listingKey: string;
}): 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<Schemas["ProductListingResult"] | null>
>(`useListingInitial-${listingKey}`, ref(null));
provide(`useListingInitial-${listingKey}`, _storeInitialListing);
// const _storeInitialListing = sharedRef<Schemas["ProductListingResult"]>(
// `${cacheKey}-initialListing-${listingKey}`
// );
// const _storeAppliedListing = sharedRef<Partial<Schemas["ProductListingResult"]>>(
// `${cacheKey}-appliedListing-${listingKey}`
// );
const _storeAppliedListing = inject<
Ref<Schemas["ProductListingResult"] | null>
>(`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<Schemas["ProductListingResult"]> => {
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<void> => {
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 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 getAvailableFilters = computed(() => {
return getListingFilters(
_storeAppliedListing.value?.aggregations ||
getCurrentListing.value?.aggregations,
);
});
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"];
});
// this function sets the current filters as shortcut filters @see https://heyframe.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<string, unknown> = {};
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,
};
}