docs: improved search results (#11909)

This commit is contained in:
Shahed Nasser
2025-03-19 15:31:01 +02:00
committed by GitHub
parent ee014e063e
commit 98e630bce5
12 changed files with 196 additions and 152 deletions
-1
View File
@@ -47,7 +47,6 @@ const SearchProvider = ({ children }: SearchProviderProps) => {
),
filterOptions: searchFilters,
}}
initialDefaultFilters={["guides"]}
>
{children}
</UiSearchProvider>
-1
View File
@@ -36,7 +36,6 @@ const SearchProvider = ({ children }: SearchProviderProps) => {
checkInternalPattern: new RegExp(`^${config.baseUrl}/resources/.*`),
filterOptions: searchFilters,
}}
initialDefaultFilters={["guides"]}
>
{children}
</UiSearchProvider>
@@ -57,6 +57,7 @@ const sidebarMappings: {
"/medusa-cli",
"/js-sdk",
"/nextjs-starter",
"/references/js-sdk",
],
},
{
-1
View File
@@ -31,7 +31,6 @@ const SearchProvider = ({ children }: SearchProviderProps) => {
checkInternalPattern: new RegExp(`^${absoluteUrl()}/ui`),
filterOptions: searchFilters,
}}
initialDefaultFilters={["ui"]}
>
{children}
</UiSearchProvider>
-2
View File
@@ -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}
</UiSearchProvider>
@@ -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) => (
<Fragment key={index}>
<SearchHitGroupName name={groupName} />
{grouped[groupName].map((item, index) => (
<div
className={clsx(
"gap-docs_0.25 relative flex flex-1 flex-col p-docs_0.5",
"overflow-x-hidden text-ellipsis whitespace-nowrap break-words",
"hover:bg-medusa-bg-base-hover",
"focus:bg-medusa-bg-base-hover",
"last:mb-docs_1 focus:outline-none"
)}
key={index}
tabIndex={index}
data-hit
onClick={(e) => {
const target = e.target as Element
if (target.tagName.toLowerCase() === "div") {
target.querySelector("a")?.click()
}
}}
>
<span
{grouped[groupName].map((item, index) => {
const hierarchies = Object.values(item.hierarchy)
.filter(Boolean)
.join(" ")
return (
<div
className={clsx(
"text-compact-small-plus text-medusa-fg-base",
"max-w-full"
"gap-docs_0.25 relative flex flex-1 flex-col p-docs_0.5",
"overflow-x-hidden text-ellipsis whitespace-nowrap break-words",
"hover:bg-medusa-bg-base-hover",
"focus:bg-medusa-bg-base-hover",
"last:mb-docs_1 focus:outline-none"
)}
>
{/* @ts-expect-error React v19 doesn't see this type as a React element */}
<Snippet
attribute={[
"hierarchy",
item.type && item.type !== "content"
? item.type
: getLastAvailableHeirarchy(item) ||
item.hierarchy.lvl1 ||
"",
]}
hit={item}
separator="."
/>
</span>
{item.type !== "lvl1" && (
<span className="text-compact-small text-medusa-fg-subtle">
{/* @ts-expect-error React v19 doesn't see this type as a React element */}
<Snippet
attribute={
item.content
? "content"
: [
"hierarchy",
item.type || getLastAvailableHeirarchy(item),
]
}
hit={item}
/>
</span>
)}
<Link
href={item.url}
className="absolute top-0 left-0 h-full w-full"
target="_self"
key={index}
tabIndex={index}
data-hit
onClick={(e) => {
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()
}
}}
/>
</div>
))}
>
<span
className={clsx(
"text-compact-small-plus text-medusa-fg-base",
"max-w-full"
)}
>
{/* @ts-expect-error React v19 doesn't see this type as a React element */}
<Snippet attribute={"hierarchy.lvl1"} hit={item} />
</span>
<span className="text-compact-small text-medusa-fg-subtle text-ellipsis overflow-hidden">
{item.type === "content" && (
<>
{/* @ts-expect-error React v19 doesn't see this type as a React element */}
<Snippet attribute={"content"} hit={item} />
</>
)}
{item.type !== "content" && item.description}
</span>
<span
className={clsx(
"text-ellipsis overflow-hidden",
"text-medusa-fg-muted items-center text-compact-x-small"
)}
>
{hierarchies}
</span>
<Link
href={item.url}
className="absolute top-0 left-0 h-full w-full"
target="_self"
onClick={(e) => {
if (checkIfInternal(item.url)) {
e.preventDefault()
window.location.href = item.url
setIsOpen(false)
}
}}
/>
</div>
)
})}
</Fragment>
))}
</div>
@@ -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<string[]>(defaultFilters)
const searchBoxRef = useRef<HTMLFormElement>(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 (
<div className="h-full flex flex-col">
{filterOptions.length && (
{filterOptions.length > 0 && (
<SelectBadge
multiple
options={filterOptions}
@@ -134,7 +140,15 @@ export const Search = ({
loadingIconComponent={() => <SpinnerLoading />}
/>
</div>
<div className="md:flex-initial h-[calc(100%-95px)] lg:max-h-[calc(100%-140px)] lg:min-h-[calc(100%-140px)]">
<div
className={clsx(
"md:flex-initial",
filterOptions.length > 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)]"
)}
>
<SearchEmptyQueryBoundary
fallback={<SearchSuggestions suggestions={suggestions} />}
>
@@ -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}
@@ -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
}
+10 -5
View File
@@ -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",
},
]
@@ -5,6 +5,7 @@ export type OptionType = {
label: string
index?: string
isAllOption?: boolean
hitsPerPage?: number
}
export type SelectOptions = {
@@ -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}
</div>
@@ -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<string, unknown>
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<string, unknown>
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<string, unknown>
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<SearchHits>(newRequests)
.then((response) => {
if (newRequests.length === 1) {
return response
}
// combine results of the same index and return the results
const resultsByIndex: {
[indexName: string]: SearchResponse<SearchHits>
@@ -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<any>
})
},
@@ -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}