docs: improved search results (#11909)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user