Implement useSearch

This commit is contained in:
Michael Bromley 2021-01-25 15:25:41 +01:00
parent d0ed97466c
commit 8d48bc98ac
3 changed files with 146 additions and 96 deletions

View File

@ -27,7 +27,7 @@ const fetchGraphqlApi: GraphQLFetcher = async (
const json = await res.json() const json = await res.json()
if (json.errors) { if (json.errors) {
throw new FetcherError({ throw new FetcherError({
errors: json.errors ?? [{ message: 'Failed to fetch Bigcommerce API' }], errors: json.errors ?? [{ message: 'Failed to fetch Vendure API' }],
status: res.status, status: res.status,
}) })
} }

View File

@ -1,106 +1,117 @@
import type { GetSiteInfoQuery, GetSiteInfoQueryVariables } from '../schema'
import type { RecursivePartial, RecursiveRequired } from '../api/utils/types' import type { RecursivePartial, RecursiveRequired } from '../api/utils/types'
import filterEdges from '../api/utils/filter-edges' import filterEdges from '../api/utils/filter-edges'
import { VendureConfig, getConfig } from '../api' import { VendureConfig, getConfig } from '../api'
import { categoryTreeItemFragment } from '../api/fragments/category-tree'
// Get 3 levels of categories export const getCollectionsQuery = /* GraphQL */ `
export const getSiteInfoQuery = /* GraphQL */ ` query getCollections {
query getSiteInfo { collections {
site { items {
categoryTree { id
...categoryTreeItem
children {
...categoryTreeItem
children {
...categoryTreeItem
}
}
}
brands {
pageInfo {
startCursor
endCursor
}
edges {
cursor
node {
entityId
name name
defaultImage { description
urlOriginal slug
altText productVariants {
totalItems
} }
pageTitle parent {
metaDesc id
metaKeywords }
searchKeywords children {
path id
} }
} }
} }
} }
} `;
${categoryTreeItemFragment}
`
export type CategoriesTree = NonNullable<
GetSiteInfoQuery['site']['categoryTree']
>
export type BrandEdge = NonNullable<
NonNullable<GetSiteInfoQuery['site']['brands']['edges']>[0]
>
export type Brands = BrandEdge[]
export type GetSiteInfoResult<
T extends { categories: any[]; brands: any[] } = {
categories: CategoriesTree
brands: Brands
}
> = T
async function getSiteInfo(opts?: {
variables?: GetSiteInfoQueryVariables
config?: VendureConfig
preview?: boolean
}): Promise<GetSiteInfoResult>
async function getSiteInfo<
T extends { categories: any[]; brands: any[] },
V = any
>(opts: {
query: string
variables?: V
config?: VendureConfig
preview?: boolean
}): Promise<GetSiteInfoResult<T>>
async function getSiteInfo({ async function getSiteInfo({
query = getSiteInfoQuery, query = getCollectionsQuery,
variables, variables,
config, config,
}: { }: {
query?: string query?: string
variables?: GetSiteInfoQueryVariables variables?: any
config?: VendureConfig config?: VendureConfig
preview?: boolean preview?: boolean
} = {}): Promise<GetSiteInfoResult> { } = {}): Promise<any> {
config = getConfig(config) config = getConfig(config)
// RecursivePartial forces the method to check for every prop in the data, which is // RecursivePartial forces the method to check for every prop in the data, which is
// required in case there's a custom `query` // required in case there's a custom `query`
const { data } = await config.fetch<RecursivePartial<GetSiteInfoQuery>>( const { data } = await config.fetch<any>(
query, query,
{ variables } { variables }
) )
const categories = data.site?.categoryTree const categories = arrayToTree(data.collections?.items.map(i => ({
...i,
entityId: i.id,
name: i.name,
path: i.slug,
description: i.description,
productCount: i.productVariants.totalItems,
}))).children;
const brands = data.site?.brands?.edges const brands = data.site?.brands?.edges
return { return {
categories: (categories as RecursiveRequired<typeof categories>) ?? [], categories: (categories as RecursiveRequired<typeof categories>) ?? [],
brands: filterEdges(brands as RecursiveRequired<typeof brands>), brands: [],
} }
} }
export default getSiteInfo export default getSiteInfo
export type HasParent = { id: string; parent?: { id: string } | null };
export type TreeNode<T extends HasParent> = T & { children: Array<TreeNode<T>>; expanded: boolean };
export type RootNode<T extends HasParent> = { id?: string; children: Array<TreeNode<T>> };
export function arrayToTree<T extends HasParent>(nodes: T[], currentState?: RootNode<T>): RootNode<T> {
const topLevelNodes: Array<TreeNode<T>> = [];
const mappedArr: { [id: string]: TreeNode<T> } = {};
const currentStateMap = treeToMap(currentState);
// First map the nodes of the array to an object -> create a hash table.
for (const node of nodes) {
mappedArr[node.id] = { ...(node as any), children: [] };
}
for (const id of nodes.map(n => n.id)) {
if (mappedArr.hasOwnProperty(id)) {
const mappedElem = mappedArr[id];
mappedElem.expanded = currentStateMap.get(id)?.expanded ?? false;
const parent = mappedElem.parent;
if (!parent) {
continue;
}
// If the element is not at the root level, add it to its parent array of children.
const parentIsRoot = !mappedArr[parent.id];
if (!parentIsRoot) {
if (mappedArr[parent.id]) {
mappedArr[parent.id].children.push(mappedElem);
} else {
mappedArr[parent.id] = { children: [mappedElem] } as any;
}
} else {
topLevelNodes.push(mappedElem);
}
}
}
// tslint:disable-next-line:no-non-null-assertion
const rootId = topLevelNodes.length ? topLevelNodes[0].parent!.id : undefined;
return { id: rootId, children: topLevelNodes };
}
/**
* Converts an existing tree (as generated by the arrayToTree function) into a flat
* Map. This is used to persist certain states (e.g. `expanded`) when re-building the
* tree.
*/
function treeToMap<T extends HasParent>(tree?: RootNode<T>): Map<string, TreeNode<T>> {
const nodeMap = new Map<string, TreeNode<T>>();
function visit(node: TreeNode<T>) {
nodeMap.set(node.id, node);
node.children.forEach(visit);
}
if (tree) {
visit(tree as TreeNode<T>);
}
return nodeMap;
}

View File

@ -2,11 +2,33 @@ import type { HookFetcher } from '@commerce/utils/types'
import type { SwrOptions } from '@commerce/utils/use-data' import type { SwrOptions } from '@commerce/utils/use-data'
import useCommerceSearch from '@commerce/products/use-search' import useCommerceSearch from '@commerce/products/use-search'
import type { SearchProductsData } from '../api/catalog/products' import type { SearchProductsData } from '../api/catalog/products'
import useResponse from '@commerce/utils/use-response'
const defaultOpts = { export const searchQuery = /* GraphQL */ `
url: '/api/bigcommerce/catalog/products', query search($input: SearchInput!) {
method: 'GET', search(input: $input) {
} items {
productId
currencyCode
productName
description
priceWithTax {
...on SinglePrice {
value
}
...on PriceRange {
min max
}
}
productAsset {
preview
}
slug
}
totalItems
}
}
`
export type SearchProductsInput = { export type SearchProductsInput = {
search?: string search?: string
@ -20,40 +42,57 @@ export const fetcher: HookFetcher<SearchProductsData, SearchProductsInput> = (
{ search, categoryId, brandId, sort }, { search, categoryId, brandId, sort },
fetch fetch
) => { ) => {
// Use a dummy base as we only care about the relative path
const url = new URL(options?.url ?? defaultOpts.url, 'http://a')
if (search) url.searchParams.set('search', search)
if (Number.isInteger(categoryId))
url.searchParams.set('category', String(categoryId))
if (Number.isInteger(categoryId))
url.searchParams.set('brand', String(brandId))
if (sort) url.searchParams.set('sort', sort)
return fetch({ return fetch({
url: url.pathname + url.search, query: searchQuery,
method: options?.method ?? defaultOpts.method, variables: {
input: {
term: search,
collectionId: categoryId,
groupByProduct: true
}
}
}) })
} }
export function extendHook( export function extendHook(
customFetcher: typeof fetcher, customFetcher: typeof fetcher,
swrOptions?: SwrOptions<SearchProductsData, SearchProductsInput> swrOptions?: SwrOptions<any, SearchProductsInput>
) { ) {
const useSearch = (input: SearchProductsInput = {}) => { const useSearch = (input: SearchProductsInput = {}) => {
const response = useCommerceSearch( const response = useCommerceSearch(
defaultOpts, {},
[ [
['search', input.search], ['search', input.search],
['categoryId', input.categoryId], ['categoryId', input.categoryId],
['brandId', input.brandId], ['brandId', input.brandId],
['sort', input.sort], ['sort', input.sort]
], ],
customFetcher, customFetcher,
{ revalidateOnFocus: false, ...swrOptions } { revalidateOnFocus: false, ...swrOptions }
) )
return response return useResponse(response, {
normalizer: data => {
return {
found: data?.search.totalItems > 0,
products: data?.search.items.map((item: any) => ({
id: item.productId,
name: item.productName,
description: item.description,
slug: item.slug,
path: item.slug,
images: [{ url: item.productAsset?.preview }],
variants: [],
price: {
value: (item.priceWithTax.min / 100),
currencyCode: item.currencyCode
},
options: [],
sku: item.sku
})) ?? [],
}
}
})
} }
useSearch.extend = extendHook useSearch.extend = extendHook