docs,api-ref: added search filters (#4830)

* initial implementation of search modal

* added hit and search suggestions

* added support for multiple indices

* updated sample env

* added close when click outside dropdown

* test for mobile

* added mobile design

* added shortcut

* dark mode fixes

* added search to docs

* added plugins filter

* added React import

* moved filters to configurations

* handled error on page load

* change suggestion text

* removed hits limit

* handle select all

* open link in current tab

* change highlight colors

* added support for shortcuts + auto focus

* change header and footer

* redesigned search ui
This commit is contained in:
Shahed Nasser
2023-08-24 18:36:06 +03:00
committed by GitHub
parent f4bf9ee169
commit f07dc0384f
109 changed files with 4555 additions and 1648 deletions

View File

@@ -0,0 +1,22 @@
import React from "react"
import { useInstantSearch } from "react-instantsearch"
type SearchEmptyQueryBoundaryProps = {
children: React.ReactElement
fallback: React.ReactElement
}
const SearchEmptyQueryBoundary = ({
children,
fallback,
}: SearchEmptyQueryBoundaryProps) => {
const { indexUiState } = useInstantSearch()
if (!indexUiState.query) {
return fallback
}
return children
}
export default SearchEmptyQueryBoundary

View File

@@ -0,0 +1,22 @@
import React from "react"
import clsx from "clsx"
type SearchHitGroupNameProps = {
name: string
}
const SearchHitGroupName = ({ name }: SearchHitGroupNameProps) => {
return (
<span
className={clsx(
"pb-0.25 flex px-0.5 pt-1",
"text-medusa-fg-muted dark:text-medusa-fg-muted-dark",
"text-compact-x-small-plus"
)}
>
{name}
</span>
)
}
export default SearchHitGroupName

View File

@@ -0,0 +1,190 @@
import React from "react"
import clsx from "clsx"
import { Fragment, useEffect, useMemo, useState } from "react"
import {
Configure,
ConfigureProps,
Index,
Snippet,
useHits,
useInstantSearch,
} from "react-instantsearch"
import SearchNoResult from "../NoResults"
import SearchHitGroupName from "./GroupName"
import Link from "@docusaurus/Link"
import { useThemeConfig } from "@docusaurus/theme-common"
import { ThemeConfig } from "@medusajs/docs"
type Hierarchy = "lvl0" | "lvl1" | "lvl2" | "lvl3" | "lvl4" | "lvl5"
export type HitType = {
hierarchy: {
lvl0: string | null
lvl1: string | null
lvl2: string | null
lvl3: string | null
lvl4: string | null
lvl5: string | null
}
_tags: string[]
url: string
type?: "lvl1" | "lvl2" | "lvl3" | "lvl4" | "lvl5" | "content"
content?: string
__position: number
__queryID?: string
objectID: string
}
type GroupedHitType = {
[k: string]: HitType[]
}
type SearchHitWrapperProps = {
configureProps: ConfigureProps
}
type IndexResults = {
[k: string]: boolean
}
const SearchHitsWrapper = ({ configureProps }: SearchHitWrapperProps) => {
const { status } = useInstantSearch()
const { algoliaConfig: algolia } = useThemeConfig() as ThemeConfig
const indices = useMemo(() => Object.values(algolia.indexNames), [])
const [hasNoResults, setHashNoResults] = useState<IndexResults>({
[indices[0]]: false,
[indices[1]]: false,
})
const showNoResults = useMemo(() => {
return Object.values(hasNoResults).every((value) => value === true)
}, [hasNoResults])
const setNoResults = (index: string, value: boolean) => {
setHashNoResults((prev: IndexResults) => ({
...prev,
[index]: value,
}))
}
return (
<div className="h-full overflow-auto">
{status !== "loading" && showNoResults && <SearchNoResult />}
{indices.map((indexName, index) => (
<Index indexName={indexName} key={index}>
<SearchHits indexName={indexName} setNoResults={setNoResults} />
<Configure {...configureProps} />
</Index>
))}
</div>
)
}
type SearchHitsProps = {
indexName: string
setNoResults: (index: string, value: boolean) => void
}
const SearchHits = ({ indexName, setNoResults }: SearchHitsProps) => {
const { hits } = useHits<HitType>()
const { status } = useInstantSearch()
// group by lvl0
const grouped = useMemo(() => {
const grouped: GroupedHitType = {}
hits.forEach((hit) => {
if (hit.hierarchy.lvl0) {
if (!grouped[hit.hierarchy.lvl0]) {
grouped[hit.hierarchy.lvl0] = []
}
grouped[hit.hierarchy.lvl0].push(hit)
}
})
return grouped
}, [hits])
useEffect(() => {
if (status !== "loading" && status !== "stalled") {
setNoResults(indexName, hits.length === 0)
}
}, [hits, status])
const getLastAvailableHeirarchy = (item: HitType) => {
return (
Object.keys(item.hierarchy)
.reverse()
.find((key) => item.hierarchy[key as Hierarchy] !== null) || ""
)
}
return (
<div className="overflow-auto">
{Object.keys(grouped).map((groupName, index) => (
<Fragment key={index}>
<SearchHitGroupName name={groupName} />
{grouped[groupName].map((item, index) => (
<div
className={clsx(
"gap-0.25 relative flex flex-1 flex-col p-0.5",
"overflow-x-hidden text-ellipsis whitespace-nowrap break-words",
"hover:bg-medusa-bg-base-hover dark:hover:bg-medusa-bg-base-hover-dark",
"focus:bg-medusa-bg-base-hover dark:focus:bg-medusa-bg-base-hover-dark",
"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
className={clsx(
"text-compact-small-plus text-medusa-fg-base dark:text-medusa-fg-base-dark",
"max-w-full"
)}
>
<Snippet
attribute={[
"hierarchy",
item.type && item.type !== "content"
? item.type
: item.hierarchy.lvl1
? "lvl1"
: getLastAvailableHeirarchy(item),
]}
hit={item}
/>
</span>
{item.type !== "lvl1" && (
<span className="text-compact-small text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark">
<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"
/>
</div>
))}
</Fragment>
))}
</div>
)
}
export default SearchHitsWrapper

View File

@@ -0,0 +1,312 @@
import React, { useEffect, useMemo, useRef, useState } from "react"
import algoliasearch, { SearchClient } from "algoliasearch/lite"
import { InstantSearch, SearchBox } from "react-instantsearch"
import Modal from "../../Modal"
import clsx from "clsx"
import SearchEmptyQueryBoundary from "../EmptyQueryBoundary"
import SearchSuggestions from "../Suggestions"
import checkArraySameElms from "../../../utils/array-same-elms"
import SearchHitsWrapper from "../Hits"
import Button from "../../Button"
import useKeyboardShortcut from "../../../hooks/use-keyboard-shortcut"
import { OptionType } from "@medusajs/docs"
import { useSearch } from "../../../providers/Search"
import { findNextSibling, findPrevSibling } from "../../../utils/dom-utils"
import IconMagnifyingGlass from "../../../theme/Icon/MagnifyingGlass"
import IconXMark from "../../../theme/Icon/XMark"
import SelectBadge from "../../Select/Badge"
import Kbd from "../../../theme/MDXComponents/Kbd"
import { useThemeConfig } from "@docusaurus/theme-common"
import { ThemeConfig } from "@medusajs/docs"
const SearchModal = () => {
const modalRef = useRef<HTMLDialogElement | null>(null)
const { algoliaConfig: algolia } = useThemeConfig() as ThemeConfig
if (!algolia) {
throw new Error("Algolia configuration is not defined")
}
const algoliaClient = useMemo(() => {
return algoliasearch(algolia.appId, algolia.apiKey)
}, [])
const searchClient: SearchClient = useMemo(() => {
return {
...algoliaClient,
async search(requests) {
if (requests.every(({ params }) => !params?.query)) {
return Promise.resolve({
results: requests.map(() => ({
hits: [],
nbHits: 0,
nbPages: 0,
page: 0,
processingTimeMS: 0,
hitsPerPage: 0,
exhaustiveNbHits: false,
query: "",
params: "",
})),
})
}
return algoliaClient.search(requests)
},
}
}, [])
const options: OptionType[] = algolia.filters
const { isOpen, setIsOpen, defaultFilters } = useSearch()
const [filters, setFilters] = useState<string[]>(defaultFilters)
const formattedFilters: string = useMemo(() => {
let formatted = ""
filters.forEach((filter) => {
const split = filter.split("_")
split.forEach((f) => {
if (formatted.length) {
formatted += " OR "
}
formatted += `_tags:${f}`
})
})
return formatted
}, [filters])
const searchBoxRef = useRef<HTMLFormElement>(null)
const focusSearchInput = () =>
searchBoxRef.current?.querySelector("input")?.focus()
useEffect(() => {
if (!checkArraySameElms(defaultFilters, filters)) {
setFilters(defaultFilters)
}
}, [defaultFilters])
useEffect(() => {
if (isOpen && searchBoxRef.current) {
focusSearchInput()
}
}, [isOpen])
const handleKeyAction = (e: KeyboardEvent) => {
if (!isOpen) {
return
}
e.preventDefault()
const focusedItem = modalRef.current?.querySelector(":focus") as HTMLElement
if (!focusedItem) {
// focus the first data-hit
const nextItem = modalRef.current?.querySelector(
"[data-hit]"
) as HTMLElement
nextItem?.focus()
return
}
const isHit = focusedItem.hasAttribute("data-hit")
const isInput = focusedItem.tagName.toLowerCase() === "input"
if (!isHit && !isInput) {
// ignore if focused items aren't input/data-hit
return
}
const lowerPressedKey = e.key.toLowerCase()
if (lowerPressedKey === "enter") {
if (isHit) {
// trigger click event of the focused element
focusedItem.click()
}
return
}
if (lowerPressedKey === "arrowup") {
// only hit items has action on arrow up
if (isHit) {
// find if there's a data-hit item before this one
const beforeItem = findPrevSibling(focusedItem, "[data-hit]")
if (!beforeItem) {
// focus the input
focusSearchInput()
} else {
// focus the previous item
beforeItem.focus()
}
}
} else if (lowerPressedKey === "arrowdown") {
// check if item is input or hit
if (isInput) {
// go to the first data-hit item
const nextItem = modalRef.current?.querySelector(
"[data-hit]"
) as HTMLElement
nextItem?.focus()
} else {
// handle go down for hit items
// find if there's a data-hit item after this one
const afterItem = findNextSibling(focusedItem, "[data-hit]")
if (afterItem) {
// focus the next item
afterItem.focus()
}
}
}
}
useKeyboardShortcut({
metakey: false,
shortcutKeys: ["ArrowUp", "ArrowDown", "Enter"],
action: handleKeyAction,
checkEditing: false,
preventDefault: false,
})
return (
<Modal
contentClassName={clsx(
"!p-0 overflow-hidden relative h-full",
"rounded-none md:rounded-lg flex flex-col justify-between"
)}
modalContainerClassName="w-screen h-screen !rounded-none md:!rounded-lg"
open={isOpen}
onClose={() => setIsOpen(false)}
ref={modalRef}
>
<InstantSearch
indexName={algolia.indexNames.docs}
searchClient={searchClient}
>
<div
className={clsx("bg-medusa-bg-base dark:bg-medusa-bg-base-dark flex")}
>
<SearchBox
classNames={{
root: clsx(
"h-[56px] w-full md:rounded-t-xl relative border-0 border-solid",
"border-b border-medusa-border-base dark:border-medusa-border-base-dark",
"bg-transparent"
),
form: clsx("h-full md:rounded-t-xl bg-transparent"),
input: clsx(
"w-full h-full pl-3 text-medusa-fg-base dark:text-medusa-fg-base-dark",
"placeholder:text-medusa-fg-muted dark:placeholder:text-medusa-fg-muted-dark",
"md:rounded-t-xl text-compact-medium bg-transparent",
"appearance-none search-cancel:hidden border-0 active:outline-none focus:outline-none"
),
submit: clsx("absolute top-[18px] left-1 btn-clear p-0"),
reset: clsx(
"absolute top-0.75 right-1 hover:bg-medusa-bg-base-hover dark:hover:bg-medusa-bg-base-hover-dark",
"p-[5px] md:rounded btn-clear"
),
loadingIndicator: clsx("absolute top-[18px] right-1"),
}}
submitIconComponent={() => (
<IconMagnifyingGlass iconColorClassName="stroke-medusa-fg-muted dark:stroke-medusa-fg-muted-dark" />
)}
resetIconComponent={() => (
<IconXMark
iconColorClassName="stroke-medusa-fg-subtle dark:stroke-medusa-fg-subtle-dark"
className="hidden md:block"
/>
)}
placeholder="Find something..."
autoFocus
formRef={searchBoxRef}
/>
<Button
variant="clear"
className={clsx(
"bg-medusa-bg-base dark:bg-medusa-bg-base-dark block md:hidden",
"border-0 border-solid",
"border-medusa-border-base dark:border-medusa-border-base-dark border-b",
"pr-1"
)}
onClick={() => setIsOpen(false)}
>
<IconXMark iconColorClassName="stroke-medusa-fg-muted dark:stroke-medusa-fg-muted-dark" />
</Button>
</div>
<div className="mx-0.5 h-[calc(100%-120px)] md:h-[332px] md:flex-initial lg:max-h-[332px] lg:min-h-[332px]">
<SearchEmptyQueryBoundary fallback={<SearchSuggestions />}>
<SearchHitsWrapper
configureProps={{
filters: formattedFilters,
attributesToSnippet: [
"content",
"hierarchy.lvl1",
"hierarchy.lvl2",
],
attributesToHighlight: [
"content",
"hierarchy.lvl1",
"hierarchy.lvl2",
],
}}
/>
</SearchEmptyQueryBoundary>
</div>
</InstantSearch>
<div
className={clsx(
"py-0.75 flex items-center justify-between px-1",
"border-0 border-solid",
"border-medusa-border-base dark:border-medusa-border-base-dark border-t",
"bg-medusa-bg-base dark:bg-medusa-bg-base-dark"
)}
>
<SelectBadge
multiple
options={options}
value={filters}
setSelected={(value) =>
setFilters(Array.isArray(value) ? [...value] : [value])
}
addSelected={(value) => setFilters((prev) => [...prev, value])}
removeSelected={(value) =>
setFilters((prev) => prev.filter((v) => v !== value))
}
showClearButton={false}
placeholder="Filters"
handleAddAll={(isAllSelected: boolean) => {
if (isAllSelected) {
setFilters(defaultFilters)
} else {
setFilters(options.map((option) => option.value))
}
}}
/>
<div className="hidden items-center gap-1 md:flex">
<div className="flex items-center gap-0.5">
<span
className={clsx(
"text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark",
"text-compact-x-small"
)}
>
Navigation
</span>
<span className="gap-0.25 flex">
<Kbd></Kbd>
<Kbd></Kbd>
</span>
</div>
<div className="flex items-center gap-0.5">
<span
className={clsx(
"text-medusa-fg-subtle dark:text-medusa-fg-subtle-dark",
"text-compact-x-small"
)}
>
Open Result
</span>
<Kbd></Kbd>
</div>
</div>
</div>
</Modal>
)
}
export default SearchModal

View File

@@ -0,0 +1,78 @@
import React from "react"
import clsx from "clsx"
import InputText from "../../Input/Text"
import { MouseEvent, useMemo } from "react"
import { useSearch } from "../../../providers/Search"
import { useWindowSize } from "@docusaurus/theme-common"
import Button from "../../Button"
import IconMagnifyingGlass from "../../../theme/Icon/MagnifyingGlass"
import Kbd from "../../../theme/MDXComponents/Kbd"
import useKeyboardShortcut from "../../../hooks/use-keyboard-shortcut"
const SearchModalOpener = () => {
const { setIsOpen } = useSearch()
const windowSize = useWindowSize()
const isApple = useMemo(() => {
return typeof navigator !== "undefined"
? navigator.userAgent.toLowerCase().indexOf("mac") !== 0
: true
}, [])
useKeyboardShortcut({
shortcutKeys: ["k"],
action: () => setIsOpen((prev) => !prev),
})
const handleOpen = (
e:
| MouseEvent<HTMLDivElement, globalThis.MouseEvent>
| MouseEvent<HTMLInputElement, globalThis.MouseEvent>
| MouseEvent<HTMLButtonElement, globalThis.MouseEvent>
) => {
e.preventDefault()
if ("blur" in e.target && typeof e.target.blur === "function") {
e.target.blur()
}
setIsOpen(true)
}
return (
<>
{windowSize !== "desktop" && (
<Button variant="clear" onClick={handleOpen}>
<IconMagnifyingGlass iconColorClassName="stroke-medusa-fg-muted dark:stroke-medusa-fg-muted-dark" />
</Button>
)}
{windowSize === "desktop" && (
<div
className={clsx("relative w-min hover:cursor-pointer")}
onClick={handleOpen}
>
<IconMagnifyingGlass
iconColorClassName="stroke-medusa-fg-muted dark:stroke-medusa-fg-muted-dark"
className={clsx("absolute left-0.5 top-[5px]")}
/>
<InputText
type="search"
className={clsx(
"placeholder:text-compact-small",
"!py-[5px] !pl-[36px] !pr-[8px]",
"cursor-pointer select-none"
)}
placeholder="Find something"
onClick={handleOpen}
onFocus={(e) => e.target.blur()}
tabIndex={-1}
/>
<span
className={clsx("gap-0.25 flex", "absolute right-0.5 top-[5px]")}
>
<Kbd>{isApple ? "⌘" : "Ctrl"}</Kbd>
<Kbd>K</Kbd>
</span>
</div>
)}
</>
)
}
export default SearchModalOpener

View File

@@ -0,0 +1,15 @@
import React from "react"
import IconExclamationCircleSolid from "../../../theme/Icon/ExclamationCircleSolid"
const SearchNoResult = () => {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1">
<IconExclamationCircleSolid iconColorClassName="fill-medusa-fg-muted dark:fill-medusa-fg-muted-dark" />
<span className="text-compact-small text-medusa-fg-muted dark:text-medusa-fg-muted-dark">
No results found. Try changing selected filters.
</span>
</div>
)
}
export default SearchNoResult

View File

@@ -0,0 +1,69 @@
import React from "react"
import clsx from "clsx"
import { useInstantSearch } from "react-instantsearch"
import SearchHitGroupName from "../Hits/GroupName"
const SearchSuggestions = () => {
const { setIndexUiState } = useInstantSearch()
const suggestions = [
{
title: "Getting started? Try one of the following terms.",
items: [
"Install Medusa with create-medusa-app",
"Next.js quickstart",
"Admin dashboard quickstart",
"Commerce modules",
"Medusa architecture",
],
},
{
title: "Developing with Medusa",
items: [
"Recipes",
"How to create endpoints",
"How to create an entity",
"How to create a plugin",
"How to create an admin widget",
],
},
]
return (
<div className="h-full overflow-auto">
{suggestions.map((suggestion, index) => (
<React.Fragment key={index}>
<SearchHitGroupName name={suggestion.title} />
{suggestion.items.map((item, itemIndex) => (
<div
className={clsx(
"flex items-center justify-between",
"cursor-pointer rounded-sm p-0.5",
"hover:bg-medusa-bg-base-hover dark:hover:bg-medusa-bg-base-hover-dark",
"focus:bg-medusa-bg-base-hover dark:focus:bg-medusa-bg-base-hover-dark",
"focus:outline-none"
)}
onClick={() =>
setIndexUiState({
query: item,
})
}
key={itemIndex}
tabIndex={itemIndex}
data-hit
>
<span
className={clsx(
"text-medusa-fg-base dark:text-medusa-fg-base-dark",
"text-compact-small"
)}
>
{item}
</span>
</div>
))}
</React.Fragment>
))}
</div>
)
}
export default SearchSuggestions