From 98e630bce5ed4133db22d063610c117113423244 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Wed, 19 Mar 2025 15:31:01 +0200 Subject: [PATCH] docs: improved search results (#11909) --- www/apps/book/providers/search.tsx | 1 - www/apps/resources/providers/search.tsx | 1 - .../resources/utils/get-sidebar-for-path.ts | 1 + www/apps/ui/src/providers/search.tsx | 1 - www/apps/user-guide/providers/search.tsx | 2 - .../src/components/Search/Hits/index.tsx | 151 +++++++++--------- .../docs-ui/src/components/Search/index.tsx | 31 +++- .../src/components/Select/Badge/index.tsx | 2 +- www/packages/docs-ui/src/constants.tsx | 15 +- .../docs-ui/src/hooks/use-select/index.tsx | 1 + .../docs-ui/src/layouts/main-content.tsx | 1 + .../docs-ui/src/providers/Search/index.tsx | 141 +++++++++------- 12 files changed, 196 insertions(+), 152 deletions(-) diff --git a/www/apps/book/providers/search.tsx b/www/apps/book/providers/search.tsx index 1e07e81fcd..514d84c3b5 100644 --- a/www/apps/book/providers/search.tsx +++ b/www/apps/book/providers/search.tsx @@ -47,7 +47,6 @@ const SearchProvider = ({ children }: SearchProviderProps) => { ), filterOptions: searchFilters, }} - initialDefaultFilters={["guides"]} > {children} diff --git a/www/apps/resources/providers/search.tsx b/www/apps/resources/providers/search.tsx index ad9a7a1b4f..b30bdf5808 100644 --- a/www/apps/resources/providers/search.tsx +++ b/www/apps/resources/providers/search.tsx @@ -36,7 +36,6 @@ const SearchProvider = ({ children }: SearchProviderProps) => { checkInternalPattern: new RegExp(`^${config.baseUrl}/resources/.*`), filterOptions: searchFilters, }} - initialDefaultFilters={["guides"]} > {children} diff --git a/www/apps/resources/utils/get-sidebar-for-path.ts b/www/apps/resources/utils/get-sidebar-for-path.ts index b3a8ad265c..dd5f8299c6 100644 --- a/www/apps/resources/utils/get-sidebar-for-path.ts +++ b/www/apps/resources/utils/get-sidebar-for-path.ts @@ -57,6 +57,7 @@ const sidebarMappings: { "/medusa-cli", "/js-sdk", "/nextjs-starter", + "/references/js-sdk", ], }, { diff --git a/www/apps/ui/src/providers/search.tsx b/www/apps/ui/src/providers/search.tsx index 59b134caed..14189a54d8 100644 --- a/www/apps/ui/src/providers/search.tsx +++ b/www/apps/ui/src/providers/search.tsx @@ -31,7 +31,6 @@ const SearchProvider = ({ children }: SearchProviderProps) => { checkInternalPattern: new RegExp(`^${absoluteUrl()}/ui`), filterOptions: searchFilters, }} - initialDefaultFilters={["ui"]} > {children} diff --git a/www/apps/user-guide/providers/search.tsx b/www/apps/user-guide/providers/search.tsx index f97179ef91..d019dd3032 100644 --- a/www/apps/user-guide/providers/search.tsx +++ b/www/apps/user-guide/providers/search.tsx @@ -39,8 +39,6 @@ const SearchProvider = ({ children }: SearchProviderProps) => { checkInternalPattern: new RegExp(`^${config.baseUrl}/user-guide`), filterOptions: searchFilters, }} - // TODO change later when we have a user guide filter - initialDefaultFilters={["guides"]} > {children} diff --git a/www/packages/docs-ui/src/components/Search/Hits/index.tsx b/www/packages/docs-ui/src/components/Search/Hits/index.tsx index 7e26fd30e5..c5277986a2 100644 --- a/www/packages/docs-ui/src/components/Search/Hits/index.tsx +++ b/www/packages/docs-ui/src/components/Search/Hits/index.tsx @@ -28,11 +28,13 @@ export type HitType = { } _tags: string[] url: string + url_without_anchor: string type?: "lvl1" | "lvl2" | "lvl3" | "lvl4" | "lvl5" | "content" content?: string __position: number __queryID?: string objectID: string + description?: string } export type GroupedHitType = { @@ -103,7 +105,7 @@ export const SearchHits = ({ const { setIsOpen } = useSearch() // group by lvl0 - const grouped = useMemo(() => { + const grouped: GroupedHitType = useMemo(() => { const grouped: GroupedHitType = {} hits.forEach((hit) => { if (hit.hierarchy.lvl0) { @@ -114,7 +116,16 @@ export const SearchHits = ({ } }) - return grouped + // sort groups by number of hits + const sortedGroups = Object.fromEntries( + Object.entries(grouped).sort(([, a], [, b]) => { + const lvl1CountA = a.filter((hit) => hit.type === "lvl1").length + const lvl1CountB = b.filter((hit) => hit.type === "lvl1").length + + return lvl1CountB - lvl1CountA + }) + ) + return sortedGroups }, [hits]) useEffect(() => { @@ -123,18 +134,6 @@ export const SearchHits = ({ } }, [hits, status]) - const getLastAvailableHeirarchy = (item: HitType) => { - return ( - Object.keys(item.hierarchy) - .reverse() - .find( - (key) => - item.hierarchy[key as Hierarchy] !== null && - item.hierarchy[key as Hierarchy] !== item.content - ) || "" - ) - } - const checkIfInternal = (url: string): boolean => { if (!checkInternalPattern) { return false @@ -153,75 +152,71 @@ export const SearchHits = ({ {Object.keys(grouped).map((groupName, index) => ( - {grouped[groupName].map((item, index) => ( -
{ - const target = e.target as Element - if (target.tagName.toLowerCase() === "div") { - target.querySelector("a")?.click() - } - }} - > - { + const hierarchies = Object.values(item.hierarchy) + .filter(Boolean) + .join(" › ") + return ( +
- {/* @ts-expect-error React v19 doesn't see this type as a React element */} - - - {item.type !== "lvl1" && ( - - {/* @ts-expect-error React v19 doesn't see this type as a React element */} - - - )} - { - if (checkIfInternal(item.url)) { - e.preventDefault() - window.location.href = item.url - setIsOpen(false) + const target = e.target as Element + if (target.tagName.toLowerCase() === "div") { + target.querySelector("a")?.click() } }} - /> -
- ))} + > + + {/* @ts-expect-error React v19 doesn't see this type as a React element */} + + + + {item.type === "content" && ( + <> + {/* @ts-expect-error React v19 doesn't see this type as a React element */} + + + )} + {item.type !== "content" && item.description} + + + + {hierarchies} + + { + if (checkIfInternal(item.url)) { + e.preventDefault() + window.location.href = item.url + setIsOpen(false) + } + }} + /> +
+ ) + })}
))} diff --git a/www/packages/docs-ui/src/components/Search/index.tsx b/www/packages/docs-ui/src/components/Search/index.tsx index 2e7bbeebb0..2641f70c57 100644 --- a/www/packages/docs-ui/src/components/Search/index.tsx +++ b/www/packages/docs-ui/src/components/Search/index.tsx @@ -1,6 +1,6 @@ "use client" -import React, { useEffect, useRef, useState } from "react" +import React, { useEffect, useMemo, useRef, useState } from "react" import { InstantSearch, SearchBox } from "react-instantsearch" import clsx from "clsx" import { SearchEmptyQueryBoundary } from "./EmptyQueryBoundary" @@ -8,8 +8,7 @@ import { SearchSuggestions, type SearchSuggestionType } from "./Suggestions" import { AlgoliaProps, useSearch } from "@/providers" import { checkArraySameElms } from "@/utils" import { SearchHitsWrapper } from "./Hits" -import { Button, SelectBadge, SpinnerLoading } from "@/components" -import { XMark } from "@medusajs/icons" +import { SelectBadge, SpinnerLoading } from "@/components" import { useSearchNavigation, type OptionType } from "@/hooks" import { SearchFooter } from "./Footer" @@ -28,8 +27,7 @@ export const Search = ({ checkInternalPattern, filterOptions = [], }: SearchProps) => { - const { isOpen, setIsOpen, defaultFilters, searchClient, modalRef } = - useSearch() + const { isOpen, defaultFilters, searchClient, modalRef } = useSearch() const [filters, setFilters] = useState(defaultFilters) const searchBoxRef = useRef(null) @@ -59,6 +57,14 @@ export const Search = ({ } }, [isOpen]) + const facetFilters = useMemo(() => { + const filtersToUse = + !filters.length || filters[0] === "all" + ? filterOptions.map((option) => option.value) + : filters + return filtersToUse.map((filter) => "_tags:" + filter) + }, [filters, defaultFilters]) + useSearchNavigation({ getInputElm: () => searchBoxRef.current?.querySelector("input") as HTMLInputElement, @@ -70,7 +76,7 @@ export const Search = ({ return (
- {filterOptions.length && ( + {filterOptions.length > 0 && ( } />
-
+
0 && + "h-[calc(100%-95px)] lg:max-h-[calc(100%-140px)] lg:min-h-[calc(100%-140px)]", + filterOptions.length === 0 && + "h-[calc(100%-75px)] lg:max-h-[calc(100%-100px)] lg:min-h-[calc(100%-100px)]" + )} + > } > @@ -143,7 +157,8 @@ export const Search = ({ // filters array has to be wrapped // in another array for an OR condition // to be applied between the items. - tagFilters: [filters], + facetFilters: [facetFilters], + getRankingInfo: true, }} indices={algolia.indices} checkInternalPattern={checkInternalPattern} diff --git a/www/packages/docs-ui/src/components/Select/Badge/index.tsx b/www/packages/docs-ui/src/components/Select/Badge/index.tsx index 0ef4bc1c33..7588afa02c 100644 --- a/www/packages/docs-ui/src/components/Select/Badge/index.tsx +++ b/www/packages/docs-ui/src/components/Select/Badge/index.tsx @@ -50,7 +50,7 @@ export const SelectBadge = ({ (!Array.isArray(value) && !value) || (Array.isArray(value) && !value.length) ) { - str = "None selected" + str = "No Filters Selected" } else { str = selectedOptions[0].label } diff --git a/www/packages/docs-ui/src/constants.tsx b/www/packages/docs-ui/src/constants.tsx index 241947d6f4..f01605a801 100644 --- a/www/packages/docs-ui/src/constants.tsx +++ b/www/packages/docs-ui/src/constants.tsx @@ -168,11 +168,12 @@ export const navDropdownItems: NavigationItem[] = [ export const searchFilters: OptionType[] = [ { - value: "guides", - label: "Guides", + value: "concepts-guides", + label: "Concepts & Guides", + hitsPerPage: 8, }, { - value: "references-v2", + value: "references", label: "References", }, { @@ -184,7 +185,11 @@ export const searchFilters: OptionType[] = [ label: "Store API", }, { - value: "ui", - label: "Medusa UI", + value: "user-guide", + label: "User Guide", + }, + { + value: "troubleshooting", + label: "Troubleshooting", }, ] diff --git a/www/packages/docs-ui/src/hooks/use-select/index.tsx b/www/packages/docs-ui/src/hooks/use-select/index.tsx index 1d9a25c13a..efd9959ee6 100644 --- a/www/packages/docs-ui/src/hooks/use-select/index.tsx +++ b/www/packages/docs-ui/src/hooks/use-select/index.tsx @@ -5,6 +5,7 @@ export type OptionType = { label: string index?: string isAllOption?: boolean + hitsPerPage?: number } export type SelectOptions = { diff --git a/www/packages/docs-ui/src/layouts/main-content.tsx b/www/packages/docs-ui/src/layouts/main-content.tsx index 61b56d74a3..2b96343c53 100644 --- a/www/packages/docs-ui/src/layouts/main-content.tsx +++ b/www/packages/docs-ui/src/layouts/main-content.tsx @@ -61,6 +61,7 @@ export const MainContentLayout = ({ "pt-docs_4 lg:pt-docs_6 pb-docs_8 lg:pb-docs_4", contentClassName )} + id="content" > {children}
diff --git a/www/packages/docs-ui/src/providers/Search/index.tsx b/www/packages/docs-ui/src/providers/Search/index.tsx index 1c461b6611..73cfe20a91 100644 --- a/www/packages/docs-ui/src/providers/Search/index.tsx +++ b/www/packages/docs-ui/src/providers/Search/index.tsx @@ -20,6 +20,7 @@ import { import clsx from "clsx" // @ts-expect-error can't install the types package because it doesn't support React v19 import { CSSTransition, SwitchTransition } from "react-transition-group" +import { searchFilters } from "../.." export type SearchCommand = { name: string @@ -87,36 +88,8 @@ export const SearchProvider = ({ "requests" in searchParams ? searchParams.requests : searchParams // always send this request, which is the main request with no filters const mainRequest = requests[0] - - // retrieve only requests that have filters - // this is to ensure that we show no result if no filter is selected - const requestsWithFilters = requests.filter((item) => { - if ( - !item.params || - typeof item.params !== "object" || - !("tagFilters" in item.params) - ) { - return false - } - - const tagFilters = item.params.tagFilters as string[] - - // if no tag filters are specified, there's still one item, - // which is an empty array - return tagFilters.length >= 1 && tagFilters[0].length > 0 - }) - - // check whether a query is entered in the search box - const noQueries = requestsWithFilters.every( - (item) => - !item.facetQuery && - (!item.params || - typeof item.params !== "object" || - !("query" in item.params) || - !item.params.query) - ) - - if (noQueries) { + const params = (mainRequest.params || {}) as Record + if (!params.query) { return Promise.resolve({ results: requests.map(() => ({ hits: [], @@ -132,36 +105,75 @@ export const SearchProvider = ({ }) } - // split requests per tags - const newRequests: typeof requestsWithFilters = [mainRequest] - for (const request of requestsWithFilters) { - const params = request.params as Record - const tagFilters = (params.tagFilters as string[][])[0] - - // if only one tag is selected, keep the request as-is - if (tagFilters.length === 1) { - newRequests.push(request) - - continue + // retrieve only requests that have filters + // this is to ensure that we show no result if no filter is selected + const requestsWithFilters = requests.filter((item) => { + if ( + !item.params || + typeof item.params !== "object" || + !("facetFilters" in item.params) + ) { + return false } - // if multiple tags are selected, split the tags - // to retrieve a small subset of results per each tag. - newRequests.push( - ...tagFilters.map((tag) => ({ - ...request, - params: { - ...params, - tagFilters: [tag], - }, - hitsPerPage: 4, - })) - ) + const facetFilters = item.params.facetFilters as string[] + + // if no tag filters are specified, there's still one item, + // which is an empty array + return facetFilters.length >= 1 && facetFilters[0].length > 0 + }) + + // check whether a query is entered in the search box + const noQueries = requestsWithFilters.every( + (item) => + !item.facetQuery && + (!item.params || + typeof item.params !== "object" || + !("query" in item.params) || + !item.params.query) + ) + + const newRequests: typeof requestsWithFilters = [mainRequest] + if (!noQueries) { + // split requests per tags + for (const request of requestsWithFilters) { + const params = request.params as Record + const facetFilters = (params.facetFilters as string[][])[0] + + // if only one tag is selected, keep the request as-is + if (facetFilters.length === 1) { + newRequests.push(request) + + continue + } + + // if multiple tags are selected, split the tags + // to retrieve a small subset of results per each tag. + newRequests.push( + ...facetFilters.map((tag) => { + // get the filter's details in case it has custom hitsPerPage + const filterDetails = searchFilters.find( + (item) => `_tags:${item.value}` === tag + ) + return { + ...request, + params: { + ...params, + facetFilters: [tag], + }, + hitsPerPage: filterDetails?.hitsPerPage || 3, + } + }) + ) + } } return algoliaClient .search(newRequests) .then((response) => { + if (newRequests.length === 1) { + return response + } // combine results of the same index and return the results const resultsByIndex: { [indexName: string]: SearchResponse @@ -194,9 +206,28 @@ export const SearchProvider = ({ } }) + const newResults = Object.values(resultsByIndex).flatMap( + (result) => ({ + ...result, + hits: ("hits" in result ? result.hits : []).sort((a, b) => { + const typosA = a._rankingInfo?.nbTypos || 0 + const typosB = b._rankingInfo?.nbTypos || 0 + if (a.type === "lvl1" && typosA <= typosB) { + return -1 + } + + if (b.type === "lvl1" && typosB <= typosA) { + return 1 + } + + return 0 + }), + }) + ) + return { // append the results with the main request's results - results: [mainResult, ...Object.values(resultsByIndex)], + results: [mainResult, ...newResults], } as SearchResponses }) }, @@ -239,7 +270,7 @@ export const SearchProvider = ({ "!p-0 overflow-hidden relative h-full", "flex flex-col justify-between" )} - modalContainerClassName="!h-[480px] max-h-[480px]" + modalContainerClassName="!h-[95%] max-h-[95%] md:!h-[480px] md:max-h-[480px]" open={isOpen} onClose={() => setIsOpen(false)} passedRef={modalRef}