docs: add AI Assistant (#5249)

* added components

* added ai assistant button

* change styling

* improve AI assistant

* change to a drawer

* added command support into search

* add AiAssistant to all projects

* remove usage of Text component

* added error handling

* use recaptcha

* fix new configurations

* fix background color

* change suggested questions
This commit is contained in:
Shahed Nasser
2023-10-05 11:10:44 +03:00
committed by GitHub
parent b6bea74914
commit b3f75d8f21
45 changed files with 1864 additions and 437 deletions

View File

@@ -1,331 +0,0 @@
"use client"
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { InstantSearch, SearchBox } from "react-instantsearch"
import clsx from "clsx"
import { SearchEmptyQueryBoundary } from "../EmptyQueryBoundary"
import { SearchSuggestions, type SearchSuggestionType } from "../Suggestions"
import { AlgoliaProps, useSearch } from "@/providers"
import { checkArraySameElms } from "@/utils"
import { SearchHitsWrapper } from "../Hits"
import { findNextSibling, findPrevSibling } from "@/utils"
import { Button, Kbd, Modal, SelectBadge } from "@/components"
import { MagnifyingGlass, XMark } from "@medusajs/icons"
import { useKeyboardShortcut, type OptionType } from "@/hooks"
export type SearchModalProps = {
algolia: AlgoliaProps
isLoading?: boolean
suggestions: SearchSuggestionType[]
checkInternalPattern?: RegExp
filterOptions?: OptionType[]
className?: string
}
export const SearchModal = ({
algolia,
suggestions,
isLoading = false,
checkInternalPattern,
filterOptions = [],
className,
}: SearchModalProps) => {
const modalRef = useRef<HTMLDialogElement | null>(null)
const { isOpen, setIsOpen, defaultFilters, searchClient } = 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()
} else if (!isOpen) {
const focusedItem = modalRef.current?.querySelector(
":focus"
) as HTMLElement
if (
focusedItem &&
focusedItem === searchBoxRef.current?.querySelector("input")
) {
// remove focus
focusedItem.blur()
}
}
}, [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()
}
}
}
}
const shortcutKeys = useMemo(() => ["ArrowUp", "ArrowDown", "Enter"], [])
useKeyboardShortcut({
metakey: false,
shortcutKeys,
action: handleKeyAction,
checkEditing: false,
preventDefault: false,
isLoading,
})
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!isOpen) {
return
}
// check if shortcut keys were pressed
const lowerPressedKey = e.key.toLowerCase()
const pressedShortcut = [...shortcutKeys, "Escape"].some(
(s) => s.toLowerCase() === lowerPressedKey
)
if (pressedShortcut) {
return
}
const focusedItem = modalRef.current?.querySelector(
":focus"
) as HTMLElement
const searchInput = searchBoxRef.current?.querySelector(
"input"
) as HTMLInputElement
if (searchInput && focusedItem !== searchInput) {
searchInput.focus()
}
},
[shortcutKeys, isOpen]
)
useEffect(() => {
window.addEventListener("keydown", handleKeyDown)
return () => {
window.removeEventListener("keydown", handleKeyDown)
}
}, [handleKeyDown])
return (
<Modal
contentClassName={clsx(
"!p-0 overflow-hidden relative h-full",
"rounded-none md:rounded-docs_lg flex flex-col justify-between"
)}
modalContainerClassName="w-screen h-screen !rounded-none md:!rounded-docs_lg"
open={isOpen}
onClose={() => setIsOpen(false)}
passedRef={modalRef}
className={className}
>
<InstantSearch
indexName={algolia.mainIndexName}
searchClient={searchClient}
>
<div className={clsx("bg-medusa-bg-base flex")}>
<SearchBox
classNames={{
root: clsx(
"h-[56px] w-full md:rounded-t-docs_xl relative border-0 border-solid",
"border-b border-medusa-border-base",
"bg-transparent"
),
form: clsx("h-full md:rounded-t-docs_xl bg-transparent"),
input: clsx(
"w-full h-full pl-docs_3 text-medusa-fg-base",
"placeholder:text-medusa-fg-muted",
"md:rounded-t-docs_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-docs_1 btn-clear p-0"),
reset: clsx(
"absolute top-docs_0.75 right-docs_1 hover:bg-medusa-bg-base-hover",
"p-[5px] md:rounded-docs_DEFAULT btn-clear"
),
loadingIndicator: clsx("absolute top-[18px] right-docs_1"),
}}
submitIconComponent={() => (
<MagnifyingGlass className="text-medusa-fg-muted" />
)}
resetIconComponent={() => (
<XMark className="hidden md:block text-medusa-fg-subtle" />
)}
placeholder="Find something..."
autoFocus
formRef={searchBoxRef}
/>
<Button
variant="clear"
className={clsx(
"bg-medusa-bg-base block md:hidden",
"border-0 border-solid",
"border-medusa-border-base border-b",
"pr-docs_1"
)}
onClick={() => setIsOpen(false)}
>
<XMark className="text-medusa-fg-muted" />
</Button>
</div>
<div className="mx-docs_0.5 h-[calc(100%-120px)] md:h-[332px] md:flex-initial lg:max-h-[332px] lg:min-h-[332px]">
<SearchEmptyQueryBoundary
fallback={<SearchSuggestions suggestions={suggestions} />}
>
<SearchHitsWrapper
configureProps={{
filters: formattedFilters,
attributesToSnippet: [
"content",
"hierarchy.lvl1",
"hierarchy.lvl2",
"hierarchy.lvl3",
],
attributesToHighlight: [
"content",
"hierarchy.lvl1",
"hierarchy.lvl2",
"hierarchy.lvl3",
],
}}
indices={algolia.indices}
checkInternalPattern={checkInternalPattern}
/>
</SearchEmptyQueryBoundary>
</div>
</InstantSearch>
<div
className={clsx(
"py-docs_0.75 flex items-center justify-between px-docs_1",
"border-0 border-solid",
"border-medusa-border-base border-t",
"bg-medusa-bg-base"
)}
>
{filterOptions.length && (
<SelectBadge
multiple
options={filterOptions}
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(filterOptions.map((option) => option.value))
}
}}
/>
)}
<div className="hidden items-center gap-docs_1 md:flex">
<div className="flex items-center gap-docs_0.5">
<span
className={clsx("text-medusa-fg-subtle", "text-compact-x-small")}
>
Navigation
</span>
<span className="gap-docs_0.25 flex">
<Kbd></Kbd>
<Kbd></Kbd>
</span>
</div>
<div className="flex items-center gap-docs_0.5">
<span
className={clsx("text-medusa-fg-subtle", "text-compact-x-small")}
>
Open Result
</span>
<Kbd></Kbd>
</div>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,32 @@
import clsx from "clsx"
import React from "react"
export type SearchSuggestionItemType = {
onClick: () => void
} & React.AllHTMLAttributes<HTMLDivElement>
export const SearchSuggestionItem = ({
children,
onClick,
className,
...rest
}: SearchSuggestionItemType) => {
return (
<div
className={clsx(
"flex items-center",
"cursor-pointer rounded-docs_sm p-docs_0.5",
"hover:bg-medusa-bg-base-hover",
"focus:bg-medusa-bg-base-hover",
"focus:outline-none last:mb-docs_1",
"text-medusa-fg-base text-compact-small",
className
)}
onClick={onClick}
data-hit
{...rest}
>
{children}
</div>
)
}

View File

@@ -1,9 +1,11 @@
"use client"
import React from "react"
import clsx from "clsx"
import { useInstantSearch } from "react-instantsearch"
import { SearchHitGroupName } from "../Hits/GroupName"
import { useSearch } from "@/providers"
import { SearchSuggestionItem } from "./Item"
import { Badge } from "@/components"
export type SearchSuggestionType = {
title: string
@@ -16,35 +18,44 @@ export type SearchSuggestionsProps = {
export const SearchSuggestions = ({ suggestions }: SearchSuggestionsProps) => {
const { setIndexUiState } = useInstantSearch()
const { commands, setCommand } = useSearch()
return (
<div className="h-full overflow-auto">
{commands.length > 0 && (
<>
<SearchHitGroupName name={"Commands"} />
{commands.map((command, index) => (
<SearchSuggestionItem
onClick={() => setCommand(command)}
key={index}
tabIndex={index}
className="gap-docs_0.75"
>
<>
{command.icon}
<span>{command.title}</span>
{command.badge && <Badge {...command.badge} />}
</>
</SearchSuggestionItem>
))}
</>
)}
{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-docs_sm p-docs_0.5",
"hover:bg-medusa-bg-base-hover",
"focus:bg-medusa-bg-base-hover",
"focus:outline-none last:mb-docs_1"
)}
<SearchSuggestionItem
onClick={() =>
setIndexUiState({
query: item,
})
}
key={itemIndex}
tabIndex={itemIndex}
data-hit
tabIndex={commands.length + itemIndex}
>
<span
className={clsx("text-medusa-fg-base", "text-compact-small")}
>
{item}
</span>
</div>
{item}
</SearchSuggestionItem>
))}
</React.Fragment>
))}

View File

@@ -1,6 +1,215 @@
export * from "./EmptyQueryBoundary"
export * from "./Hits"
export * from "./Modal"
export * from "./ModalOpener"
export * from "./NoResults"
export * from "./Suggestions"
"use client"
import React, { useEffect, useMemo, useRef, useState } from "react"
import { InstantSearch, SearchBox } from "react-instantsearch"
import clsx from "clsx"
import { SearchEmptyQueryBoundary } from "./EmptyQueryBoundary"
import { SearchSuggestions, type SearchSuggestionType } from "./Suggestions"
import { AlgoliaProps, useSearch } from "@/providers"
import { checkArraySameElms } from "@/utils"
import { SearchHitsWrapper } from "./Hits"
import { Button, Kbd, SelectBadge } from "@/components"
import { MagnifyingGlass, XMark } from "@medusajs/icons"
import { useSearchNavigation, type OptionType } from "@/hooks"
export type SearchProps = {
algolia: AlgoliaProps
isLoading?: boolean
suggestions: SearchSuggestionType[]
checkInternalPattern?: RegExp
filterOptions?: OptionType[]
}
export const Search = ({
algolia,
suggestions,
isLoading = false,
checkInternalPattern,
filterOptions = [],
}: SearchProps) => {
const { isOpen, setIsOpen, defaultFilters, searchClient, modalRef } =
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()
} else if (!isOpen) {
const focusedItem = modalRef.current?.querySelector(
":focus"
) as HTMLElement
if (
focusedItem &&
focusedItem === searchBoxRef.current?.querySelector("input")
) {
// remove focus
focusedItem.blur()
}
}
}, [isOpen])
useSearchNavigation({
getInputElm: () =>
searchBoxRef.current?.querySelector("input") as HTMLInputElement,
focusInput: focusSearchInput,
keyboardProps: {
isLoading,
},
})
return (
<div className="h-full">
<InstantSearch
indexName={algolia.mainIndexName}
searchClient={searchClient}
>
<div className={clsx("bg-medusa-bg-base flex")}>
<SearchBox
classNames={{
root: clsx(
"h-[57px] w-full md:rounded-t-docs_xl relative border-0 border-solid",
"border-b border-medusa-border-base",
"bg-transparent"
),
form: clsx("h-full md:rounded-t-docs_xl bg-transparent"),
input: clsx(
"w-full h-full pl-docs_3 text-medusa-fg-base",
"placeholder:text-medusa-fg-muted",
"md:rounded-t-docs_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-docs_1 btn-clear p-0"),
reset: clsx(
"absolute top-docs_0.75 right-docs_1 hover:bg-medusa-bg-base-hover",
"p-[5px] md:rounded-docs_DEFAULT btn-clear"
),
loadingIndicator: clsx("absolute top-[18px] right-docs_1"),
}}
submitIconComponent={() => (
<MagnifyingGlass className="text-medusa-fg-muted" />
)}
resetIconComponent={() => (
<XMark className="hidden md:block text-medusa-fg-subtle" />
)}
placeholder="Find something..."
autoFocus
formRef={searchBoxRef}
/>
<Button
variant="clear"
className={clsx(
"bg-medusa-bg-base block md:hidden",
"border-0 border-solid",
"border-medusa-border-base border-b",
"pr-docs_1"
)}
onClick={() => setIsOpen(false)}
>
<XMark className="text-medusa-fg-muted" />
</Button>
</div>
<div className="mx-docs_0.5 md:flex-initial h-[calc(100%-120px)] md:h-[calc(100%-114px)] lg:max-h-[calc(100%-114px)] lg:min-h-[calc(100%-114px)]">
<SearchEmptyQueryBoundary
fallback={<SearchSuggestions suggestions={suggestions} />}
>
<SearchHitsWrapper
configureProps={{
filters: formattedFilters,
attributesToSnippet: [
"content",
"hierarchy.lvl1",
"hierarchy.lvl2",
"hierarchy.lvl3",
],
attributesToHighlight: [
"content",
"hierarchy.lvl1",
"hierarchy.lvl2",
"hierarchy.lvl3",
],
}}
indices={algolia.indices}
checkInternalPattern={checkInternalPattern}
/>
</SearchEmptyQueryBoundary>
</div>
</InstantSearch>
<div
className={clsx(
"py-docs_0.75 flex items-center justify-between px-docs_1",
"border-0 border-solid h-[57px]",
"border-medusa-border-base border-t",
"bg-medusa-bg-base"
)}
>
{filterOptions.length && (
<SelectBadge
multiple
options={filterOptions}
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(filterOptions.map((option) => option.value))
}
}}
/>
)}
<div className="hidden items-center gap-docs_1 md:flex">
<div className="flex items-center gap-docs_0.5">
<span
className={clsx("text-medusa-fg-subtle", "text-compact-x-small")}
>
Navigation
</span>
<span className="gap-docs_0.25 flex">
<Kbd></Kbd>
<Kbd></Kbd>
</span>
</div>
<div className="flex items-center gap-docs_0.5">
<span
className={clsx("text-medusa-fg-subtle", "text-compact-x-small")}
>
Open Result
</span>
<Kbd></Kbd>
</div>
</div>
</div>
</div>
)
}