docs: added callouts in search and AI assistant (#14421)
* docs: added callouts in search and AI assistant * added more suggestions * fix search tests * add mock for links
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest"
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react"
|
||||
import * as AiAssistantMocks from "../../../__mocks__"
|
||||
|
||||
// mock functions
|
||||
const mockUseMedusaSuggestions = vi.fn((options) => null as unknown)
|
||||
const mockTrack = vi.fn()
|
||||
|
||||
// mock components and hooks
|
||||
vi.mock("@/providers/AiAssistant", () => ({
|
||||
useAiAssistant: () => AiAssistantMocks.mockUseAiAssistant(),
|
||||
}))
|
||||
vi.mock("@kapaai/react-sdk", () => ({
|
||||
useChat: () => AiAssistantMocks.mockUseChat(),
|
||||
}))
|
||||
vi.mock("@/hooks/use-medusa-suggestions", () => ({
|
||||
useMedusaSuggestions: (options: unknown) => mockUseMedusaSuggestions(options),
|
||||
}))
|
||||
vi.mock("@/components/Card", () => ({
|
||||
Card: (props: { title: string, onClick: () => void }) => (
|
||||
<div data-testid="card" onClick={props.onClick}>{props.title}</div>
|
||||
),
|
||||
}))
|
||||
vi.mock("@/providers/Analytics", () => ({
|
||||
useAnalytics: () => ({
|
||||
track: mockTrack,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { AiAssistantChatWindowCallout } from "../index"
|
||||
import { DocsTrackingEvents } from "../../../../../constants"
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe("render", () => {
|
||||
test("should not render when there is no matched suggestion", () => {
|
||||
mockUseMedusaSuggestions.mockReturnValueOnce(null)
|
||||
|
||||
const { container } = render(<AiAssistantChatWindowCallout />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
test("should render when there is a matched suggestion", () => {
|
||||
const mockCardProps = {
|
||||
title: "Test Card",
|
||||
text: "This is a test card.",
|
||||
href: "https://example.com",
|
||||
icon: () => <div>Icon</div>,
|
||||
}
|
||||
mockUseMedusaSuggestions.mockReturnValueOnce(mockCardProps)
|
||||
|
||||
const { getByTestId } = render(<AiAssistantChatWindowCallout />)
|
||||
|
||||
expect(getByTestId("card")).toBeInTheDocument()
|
||||
expect(getByTestId("card")).toHaveTextContent("Test Card")
|
||||
})
|
||||
|
||||
test("should not render when loading is true", () => {
|
||||
AiAssistantMocks.mockUseAiAssistant.mockReturnValueOnce({
|
||||
...AiAssistantMocks.defaultUseAiAssistantReturn,
|
||||
loading: true,
|
||||
})
|
||||
|
||||
mockUseMedusaSuggestions.mockReturnValueOnce({
|
||||
title: "Test Card",
|
||||
text: "This is a test card.",
|
||||
href: "https://example.com",
|
||||
icon: () => <div>Icon</div>,
|
||||
})
|
||||
|
||||
const { container } = render(<AiAssistantChatWindowCallout />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
test("should pass correct keywords to useMedusaSuggestions", () => {
|
||||
render(<AiAssistantChatWindowCallout />)
|
||||
|
||||
expect(mockUseMedusaSuggestions).toHaveBeenCalledWith({
|
||||
keywords: AiAssistantMocks.mockConversation.getLatestCompleted()?.question || "",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("interactions", () => {
|
||||
test("should track event on card click", () => {
|
||||
const mockCardProps = {
|
||||
title: "Test Card",
|
||||
text: "This is a test card.",
|
||||
href: "https://example.com",
|
||||
icon: () => <div>Icon</div>,
|
||||
}
|
||||
mockUseMedusaSuggestions.mockReturnValueOnce(mockCardProps)
|
||||
|
||||
const { getByTestId } = render(<AiAssistantChatWindowCallout />)
|
||||
|
||||
const cardElement = getByTestId("card")
|
||||
expect(cardElement).toBeInTheDocument()
|
||||
|
||||
// Simulate click
|
||||
fireEvent.click(cardElement!)
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith({
|
||||
event: {
|
||||
event: DocsTrackingEvents.AI_ASSISTANT_CALLOUT_CLICK,
|
||||
options: {
|
||||
user_keywords: AiAssistantMocks.mockConversation.getLatestCompleted()?.question || "",
|
||||
callout_title: mockCardProps.title,
|
||||
callout_href: mockCardProps.href,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { Card } from "../../../Card"
|
||||
import { useChat } from "@kapaai/react-sdk"
|
||||
import { useAiAssistant } from "../../../../providers/AiAssistant"
|
||||
import { useMedusaSuggestions } from "../../../../hooks/use-medusa-suggestions"
|
||||
import { useAnalytics } from "../../../../providers/Analytics"
|
||||
import { DocsTrackingEvents } from "../../../../constants"
|
||||
|
||||
export const AiAssistantChatWindowCallout = () => {
|
||||
const { conversation } = useChat()
|
||||
const { loading } = useAiAssistant()
|
||||
const { track } = useAnalytics()
|
||||
|
||||
const lastQuestion = conversation.getLatestCompleted()?.question
|
||||
|
||||
const matchedCallout = useMedusaSuggestions({
|
||||
keywords: lastQuestion || "",
|
||||
})
|
||||
|
||||
if (loading || !matchedCallout) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="m-docs_1 flex justify-center items-center">
|
||||
<Card
|
||||
{...matchedCallout}
|
||||
onClick={() => {
|
||||
track({
|
||||
event: {
|
||||
event: DocsTrackingEvents.AI_ASSISTANT_CALLOUT_CLICK,
|
||||
options: {
|
||||
user_keywords: lastQuestion || "",
|
||||
callout_title: matchedCallout.title || "",
|
||||
callout_href: matchedCallout.href || "",
|
||||
},
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -52,6 +52,9 @@ vi.mock("@/components/AiAssistant/ThreadItem", () => ({
|
||||
<div className="thread-item">ThreadItem - type: {item.type}</div>
|
||||
),
|
||||
}))
|
||||
vi.mock("@/components/AiAssistant/ChatWindow/Callout", () => ({
|
||||
AiAssistantChatWindowCallout: () => <div data-testid="callout">Callout</div>,
|
||||
}))
|
||||
|
||||
import { AiAssistantChatWindow } from "../../ChatWindow"
|
||||
|
||||
@@ -81,6 +84,7 @@ describe("rendering", () => {
|
||||
expect(container).toHaveTextContent("Header")
|
||||
expect(container).toHaveTextContent("Input")
|
||||
expect(container).toHaveTextContent("Footer")
|
||||
expect(container).toHaveTextContent("Callout")
|
||||
})
|
||||
|
||||
test("chat is hidden when chatOpened is false", () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { AiAssistantChatWindowInput } from "./Input"
|
||||
import { useKeyboardShortcut } from "../../../hooks/use-keyboard-shortcut"
|
||||
import { AiAssistantChatWindowFooter } from "./Footer"
|
||||
import { useChat } from "@kapaai/react-sdk"
|
||||
import { AiAssistantChatWindowCallout } from "./Callout"
|
||||
|
||||
const DEFAULT_HEIGHT = "calc(100% - 8px)"
|
||||
|
||||
@@ -204,6 +205,7 @@ export const AiAssistantChatWindow = () => {
|
||||
)}
|
||||
></span>
|
||||
</div>
|
||||
<AiAssistantChatWindowCallout />
|
||||
<AiAssistantChatWindowInput chatWindowRef={chatWindowRef} />
|
||||
<AiAssistantChatWindowFooter />
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,7 @@ const createMockConversation = () => {
|
||||
}
|
||||
},
|
||||
getLatest: mockGetLatest,
|
||||
getLatestCompleted: mockGetLatest,
|
||||
map(callback: (item: unknown, index: number) => React.ReactNode) {
|
||||
return conversationItems.map(callback)
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react"
|
||||
import { describe, expect, test, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { fireEvent, render } from "@testing-library/react"
|
||||
import { CardDefaultLayout } from "../index"
|
||||
import { IconProps } from "@medusajs/icons/dist/types"
|
||||
import { LinkProps } from "../../../../Link"
|
||||
@@ -108,6 +108,8 @@ describe("rendering", () => {
|
||||
const linkElement = container.querySelector("a")
|
||||
expect(linkElement).toBeInTheDocument()
|
||||
expect(linkElement).toHaveAttribute("href", href)
|
||||
expect(linkElement).toHaveAttribute("target", "_blank")
|
||||
expect(linkElement).toHaveAttribute("rel", "noopener noreferrer")
|
||||
const arrowUpRightOnBoxElement = container.querySelector(
|
||||
"[data-testid='external-icon']"
|
||||
)
|
||||
@@ -126,6 +128,8 @@ describe("rendering", () => {
|
||||
const linkElement = container.querySelector("a")
|
||||
expect(linkElement).toBeInTheDocument()
|
||||
expect(linkElement).toHaveAttribute("href", href)
|
||||
expect(linkElement).not.toHaveAttribute("target")
|
||||
expect(linkElement).not.toHaveAttribute("rel")
|
||||
const internalIconElement = container.querySelector(
|
||||
"[data-testid='internal-icon']"
|
||||
)
|
||||
@@ -213,3 +217,17 @@ describe("highlight text", () => {
|
||||
expect(highlightTextElement).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("interaction", () => {
|
||||
test("onClick is called when card's link is clicked", () => {
|
||||
const handleClick = vi.fn()
|
||||
const { container } = render(
|
||||
<CardDefaultLayout onClick={handleClick} href="#">Click me</CardDefaultLayout>
|
||||
)
|
||||
expect(container).toBeInTheDocument()
|
||||
const link = container.querySelector("a")
|
||||
expect(link).toBeInTheDocument()
|
||||
fireEvent.click(link!)
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,7 @@ export const CardDefaultLayout = ({
|
||||
badge,
|
||||
rightIcon: RightIconComponent,
|
||||
highlightText = [],
|
||||
onClick,
|
||||
}: CardProps) => {
|
||||
const isExternal = useIsExternalLink({ href })
|
||||
|
||||
@@ -113,6 +114,10 @@ export const CardDefaultLayout = ({
|
||||
href={href}
|
||||
className="absolute left-0 top-0 h-full w-full rounded"
|
||||
prefetch={false}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
aria-label={title}
|
||||
onClick={onClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,44 @@
|
||||
import React from "react"
|
||||
import { describe, expect, test, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { fireEvent, render } from "@testing-library/react"
|
||||
import { CardLargeLayout } from "../index"
|
||||
|
||||
// mock components
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
target,
|
||||
rel,
|
||||
onClick,
|
||||
"aria-label": ariaLabel,
|
||||
}: {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
onClick?: () => void
|
||||
"aria-label"?: string
|
||||
}) => (
|
||||
<a
|
||||
href={href}
|
||||
className={className}
|
||||
target={target}
|
||||
rel={rel}
|
||||
onClick={(e) => {
|
||||
// can't perform actual navigation in tests
|
||||
e.preventDefault()
|
||||
onClick?.()
|
||||
}}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
|
||||
describe("rendering", () => {
|
||||
test("renders card large layout with title", () => {
|
||||
const title = "Title"
|
||||
@@ -33,6 +69,8 @@ describe("rendering", () => {
|
||||
const linkElement = container.querySelector("a")
|
||||
expect(linkElement).toBeInTheDocument()
|
||||
expect(linkElement).toHaveAttribute("href", href)
|
||||
expect(linkElement).toHaveAttribute("rel", "noopener noreferrer")
|
||||
expect(linkElement).toHaveAttribute("target", "_blank")
|
||||
const arrowUpRightOnBoxElement = container.querySelector(
|
||||
"[data-testid='external-icon']"
|
||||
)
|
||||
@@ -51,6 +89,8 @@ describe("rendering", () => {
|
||||
const linkElement = container.querySelector("a")
|
||||
expect(linkElement).toBeInTheDocument()
|
||||
expect(linkElement).toHaveAttribute("href", href)
|
||||
expect(linkElement).not.toHaveAttribute("target")
|
||||
expect(linkElement).not.toHaveAttribute("rel")
|
||||
const internalIconElement = container.querySelector(
|
||||
"[data-testid='internal-icon']"
|
||||
)
|
||||
@@ -79,3 +119,17 @@ describe("rendering", () => {
|
||||
expect(imageElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("interaction", () => {
|
||||
test("calls onClick when card with link is clicked", () => {
|
||||
const handleClick = vi.fn()
|
||||
const { container } = render(
|
||||
<CardLargeLayout onClick={handleClick} href="#">Click me</CardLargeLayout>
|
||||
)
|
||||
expect(container).toBeInTheDocument()
|
||||
const linkElement = container.querySelector("a")
|
||||
expect(linkElement).toBeInTheDocument()
|
||||
fireEvent.click(linkElement!)
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ export const CardLargeLayout = ({
|
||||
icon,
|
||||
href,
|
||||
className,
|
||||
onClick,
|
||||
}: CardProps) => {
|
||||
const isExternal = useIsExternalLink({ href })
|
||||
const IconComponent = icon
|
||||
@@ -76,6 +77,10 @@ export const CardLargeLayout = ({
|
||||
href={href}
|
||||
className="absolute left-0 top-0 h-full w-full rounded"
|
||||
prefetch={false}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
aria-label={title}
|
||||
onClick={onClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from "react"
|
||||
import { describe, expect, test, vi } from "vitest"
|
||||
import { describe, expect, test, vi } from "vitest"
|
||||
import { fireEvent, render } from "@testing-library/react"
|
||||
import { BorderedIconProps } from "../../../../BorderedIcon"
|
||||
import { LinkProps } from "../../../../Link"
|
||||
|
||||
// mock data
|
||||
const exampleDataImageUrl =
|
||||
@@ -16,8 +15,39 @@ vi.mock("@/components/BorderedIcon", () => ({
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
vi.mock("@/components/Link", () => ({
|
||||
Link: (props: LinkProps) => <a {...props} />,
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
target,
|
||||
rel,
|
||||
onClick,
|
||||
"aria-label": ariaLabel,
|
||||
}: {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
onClick?: () => void
|
||||
"aria-label"?: string
|
||||
}) => (
|
||||
<a
|
||||
href={href}
|
||||
className={className}
|
||||
target={target}
|
||||
rel={rel}
|
||||
onClick={(e) => {
|
||||
// can't perform actual navigation in tests
|
||||
e.preventDefault()
|
||||
onClick?.()
|
||||
}}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
vi.mock("@/components/ThemeImage", () => ({
|
||||
ThemeImage: (props: ThemeImageProps) => (
|
||||
@@ -26,7 +56,32 @@ vi.mock("@/components/ThemeImage", () => ({
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock("next/image", () => ({
|
||||
default: ({
|
||||
src,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
src: string
|
||||
alt?: string
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ""}
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
style={style}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
import { CardLayoutMini } from "../../Mini"
|
||||
import { ThemeImageProps } from "../../../../ThemeImage"
|
||||
|
||||
@@ -60,6 +115,8 @@ describe("rendering", () => {
|
||||
const linkElement = container.querySelector("a")
|
||||
expect(linkElement).toBeInTheDocument()
|
||||
expect(linkElement).toHaveAttribute("href", href)
|
||||
expect(linkElement).toHaveAttribute("rel", "noopener noreferrer")
|
||||
expect(linkElement).toHaveAttribute("target", "_blank")
|
||||
})
|
||||
test("renders card mini layout with internal href", () => {
|
||||
const href = "/example"
|
||||
@@ -70,6 +127,8 @@ describe("rendering", () => {
|
||||
const linkElement = container.querySelector("a")
|
||||
expect(linkElement).toBeInTheDocument()
|
||||
expect(linkElement).toHaveAttribute("href", href)
|
||||
expect(linkElement).not.toHaveAttribute("target")
|
||||
expect(linkElement).not.toHaveAttribute("rel")
|
||||
})
|
||||
test("renders card mini layout with icon", () => {
|
||||
const icon = () => <div data-testid="icon">Icon</div>
|
||||
@@ -202,3 +261,17 @@ describe("rendering", () => {
|
||||
expect(themeImageElement).toHaveClass(iconClassName)
|
||||
})
|
||||
})
|
||||
|
||||
describe("interaction", () => {
|
||||
test("calls onClick when card mini layout with link is clicked", () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<CardLayoutMini onClick={onClick} href="#">Click me</CardLayoutMini>
|
||||
)
|
||||
expect(container).toBeInTheDocument()
|
||||
const link = container.querySelector("a")
|
||||
expect(link).toBeInTheDocument()
|
||||
fireEvent.click(link!)
|
||||
expect(onClick).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,7 @@ export const CardLayoutMini = ({
|
||||
imageDimensions = { width: 45, height: 36 },
|
||||
iconClassName,
|
||||
cardRef,
|
||||
onClick,
|
||||
}: CardProps) => {
|
||||
const isExternal = useIsExternalLink({ href })
|
||||
|
||||
@@ -120,6 +121,10 @@ export const CardLayoutMini = ({
|
||||
className="absolute left-0 top-0 w-full h-full z-[1]"
|
||||
prefetch={false}
|
||||
{...hrefProps}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
aria-label={title}
|
||||
onClick={onClick}
|
||||
/>
|
||||
)}
|
||||
{closeable && (
|
||||
|
||||
@@ -31,6 +31,7 @@ export type CardProps = {
|
||||
highlightText?: string[]
|
||||
closeable?: boolean
|
||||
onClose?: () => void
|
||||
onClick?: () => void
|
||||
hrefProps?: Partial<LinkProps & React.AllHTMLAttributes<HTMLAnchorElement>>
|
||||
cardRef?: React.Ref<HTMLDivElement>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Medusa } from "@medusajs/icons"
|
||||
import { IconProps } from "@medusajs/icons/dist/types"
|
||||
import clsx from "clsx"
|
||||
import React from "react"
|
||||
|
||||
export const ColoredMedusaIcon = ({ className, ...props }: IconProps) => {
|
||||
return (
|
||||
<Medusa
|
||||
{...props}
|
||||
className={clsx(className, "[&_path]:fill-medusa-fg-subtle")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export * from "./CalendarRefresh"
|
||||
export * from "./ChefHat"
|
||||
export * from "./CircleDottedLine"
|
||||
export * from "./CloudSolid"
|
||||
export * from "./ColoredMedusa"
|
||||
export * from "./DecisionProcess"
|
||||
export * from "./Erp"
|
||||
export * from "./ImageBinary"
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest"
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react"
|
||||
|
||||
// mock functions
|
||||
const mockUseMedusaSuggestions = vi.fn((options) => null as unknown)
|
||||
const mockUseInstantSearch = vi.fn(() => ({
|
||||
results: {
|
||||
query: "test",
|
||||
},
|
||||
}))
|
||||
const mockTrack = vi.fn()
|
||||
|
||||
// mock components and hooks
|
||||
vi.mock("@/hooks/use-medusa-suggestions", () => ({
|
||||
useMedusaSuggestions: (options: unknown) => mockUseMedusaSuggestions(options),
|
||||
}))
|
||||
vi.mock("@/components/Card", () => ({
|
||||
Card: (props: { title: string, onClick: () => void }) => (
|
||||
<div data-testid="card" onClick={props.onClick}>{props.title}</div>
|
||||
),
|
||||
}))
|
||||
vi.mock("react-instantsearch", () => ({
|
||||
useInstantSearch: () => mockUseInstantSearch(),
|
||||
}))
|
||||
vi.mock("@/providers/Analytics", () => ({
|
||||
useAnalytics: () => ({
|
||||
track: mockTrack,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { SearchCallout } from "../index"
|
||||
import { DocsTrackingEvents } from "../../../../constants"
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe("render", () => {
|
||||
test("should not render when there is no matched suggestion", () => {
|
||||
mockUseMedusaSuggestions.mockReturnValueOnce(null)
|
||||
|
||||
const { container } = render(<SearchCallout />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
test("should render when there is a matched suggestion", () => {
|
||||
const mockCardProps = {
|
||||
title: "Test Card",
|
||||
text: "This is a test card.",
|
||||
href: "https://example.com",
|
||||
icon: () => <div>Icon</div>,
|
||||
}
|
||||
mockUseMedusaSuggestions.mockReturnValueOnce(mockCardProps)
|
||||
|
||||
const { getByTestId } = render(<SearchCallout />)
|
||||
|
||||
expect(getByTestId("card")).toBeInTheDocument()
|
||||
expect(getByTestId("card")).toHaveTextContent("Test Card")
|
||||
})
|
||||
|
||||
test("should not render when there is no query", () => {
|
||||
mockUseInstantSearch.mockReturnValueOnce({
|
||||
results: {
|
||||
query: "",
|
||||
},
|
||||
})
|
||||
mockUseMedusaSuggestions.mockReturnValueOnce(null)
|
||||
|
||||
const { container } = render(<SearchCallout />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
test("should call useMedusaSuggestions with the correct query", () => {
|
||||
render(<SearchCallout />)
|
||||
|
||||
expect(mockUseMedusaSuggestions).toHaveBeenCalledWith({
|
||||
keywords: "test",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("interaction", () => {
|
||||
test("should track click event when card is clicked", () => {
|
||||
const mockCardProps = {
|
||||
title: "Test Card",
|
||||
text: "This is a test card.",
|
||||
href: "https://example.com",
|
||||
icon: () => <div>Icon</div>,
|
||||
}
|
||||
mockUseMedusaSuggestions.mockReturnValueOnce(mockCardProps)
|
||||
|
||||
const { getByTestId } = render(<SearchCallout />)
|
||||
|
||||
const cardElement = getByTestId("card")
|
||||
expect(cardElement).toBeInTheDocument()
|
||||
|
||||
// Simulate click
|
||||
fireEvent.click(cardElement!)
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith({
|
||||
event: {
|
||||
event: DocsTrackingEvents.SEARCH_CALLOUT_CLICK,
|
||||
options: {
|
||||
user_keywords: "test",
|
||||
callout_title: mockCardProps.title,
|
||||
callout_href: mockCardProps.href,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
39
www/packages/docs-ui/src/components/Search/Callout/index.tsx
Normal file
39
www/packages/docs-ui/src/components/Search/Callout/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { useInstantSearch } from "react-instantsearch"
|
||||
import { useMedusaSuggestions } from "../../../hooks/use-medusa-suggestions"
|
||||
import { Card } from "../../Card"
|
||||
import { useAnalytics } from "../../../providers/Analytics"
|
||||
import { DocsTrackingEvents } from "../../../constants"
|
||||
|
||||
export const SearchCallout = () => {
|
||||
const { results } = useInstantSearch()
|
||||
const query = results?.query || ""
|
||||
const matchedCallout = useMedusaSuggestions({ keywords: query })
|
||||
const { track } = useAnalytics()
|
||||
|
||||
if (!matchedCallout) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="m-docs_1 flex justify-center items-center">
|
||||
<Card
|
||||
{...matchedCallout}
|
||||
onClick={() => {
|
||||
track({
|
||||
event: {
|
||||
event: DocsTrackingEvents.SEARCH_CALLOUT_CLICK,
|
||||
options: {
|
||||
user_keywords: query,
|
||||
callout_title: matchedCallout.title,
|
||||
callout_href: matchedCallout.href,
|
||||
},
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -109,6 +109,9 @@ vi.mock("@/components/Search/Footer", () => ({
|
||||
vi.mock("@/components/Loading/Spinner", () => ({
|
||||
SpinnerLoading: () => <div data-testid="spinner-loading">Loading</div>,
|
||||
}))
|
||||
vi.mock("@/components/Search/Callout", () => ({
|
||||
SearchCallout: () => <div data-testid="search-callout">Callout</div>,
|
||||
}))
|
||||
|
||||
import { Search } from "../../Search"
|
||||
|
||||
@@ -209,6 +212,15 @@ describe("rendering", () => {
|
||||
expect(footer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test("renders SearchCallout", () => {
|
||||
const suggestions: SearchSuggestionType[] = []
|
||||
const { container } = render(
|
||||
<Search algolia={defaultAlgoliaProps} suggestions={suggestions} />
|
||||
)
|
||||
const callout = container.querySelector("[data-testid='search-callout']")
|
||||
expect(callout).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test("passes checkInternalPattern to SearchHitsWrapper", () => {
|
||||
const suggestions: SearchSuggestionType[] = []
|
||||
const pattern = /\/docs\//
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useSearchNavigation } from "@/hooks/use-search-navigation"
|
||||
import { OptionType } from "@/hooks/use-select"
|
||||
import { SearchFooter } from "./Footer"
|
||||
import { SearchFilters } from "./Filters"
|
||||
import { SearchCallout } from "./Callout"
|
||||
|
||||
export type SearchProps = {
|
||||
algolia: AlgoliaProps
|
||||
@@ -112,6 +113,7 @@ export const Search = ({
|
||||
<SearchEmptyQueryBoundary
|
||||
fallback={<SearchSuggestions suggestions={suggestions} />}
|
||||
>
|
||||
<SearchCallout />
|
||||
<SearchHitsWrapper
|
||||
configureProps={{}}
|
||||
checkInternalPattern={checkInternalPattern}
|
||||
|
||||
@@ -510,4 +510,6 @@ export enum DocsTrackingEvents {
|
||||
SURVEY_API = "survey_api-ref",
|
||||
CODE_BLOCK_COPY = "code_block_copy",
|
||||
AI_ASSISTANT_START_CHAT = "ai_assistant_start_chat",
|
||||
AI_ASSISTANT_CALLOUT_CLICK = "ai_assistant_callout_click",
|
||||
SEARCH_CALLOUT_CLICK = "search_callout_click",
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export * from "./use-current-learning-path"
|
||||
export * from "./use-is-external-link"
|
||||
export * from "./use-mutation-observer"
|
||||
export * from "./use-keyboard-shortcut"
|
||||
export * from "./use-medusa-suggestions"
|
||||
export * from "./use-page-scroll-manager"
|
||||
export * from "./use-request-runner"
|
||||
export * from "./use-scroll-utils"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest"
|
||||
import { cleanup, render } from "@testing-library/react"
|
||||
|
||||
// mock components
|
||||
vi.mock("@/componets/Card", () => ({
|
||||
Card: (props: { title: string }) => <div data-testid="card">{props.title}</div>,
|
||||
}))
|
||||
|
||||
import { useMedusaSuggestions } from "../index"
|
||||
|
||||
const TestComponent = ({ keywords }: { keywords: string | string[] }) => {
|
||||
const suggestion = useMedusaSuggestions({ keywords })
|
||||
return suggestion ? (
|
||||
<div data-testid="suggestion">{suggestion.title}</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe("keywords", () => {
|
||||
test("should handle empty keyword string", () => {
|
||||
const { container } = render(<TestComponent keywords="" />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
test("should handle empty keyword array", () => {
|
||||
const { container } = render(<TestComponent keywords={[]} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
test("should return null for no matches", () => {
|
||||
const { container } = render(<TestComponent keywords="nonexistentkeyword" />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
test("should return suggestion for matching keyword string", () => {
|
||||
const { getByTestId } = render(<TestComponent keywords="railway" />)
|
||||
|
||||
const suggestion = getByTestId("suggestion")
|
||||
|
||||
expect(suggestion).toBeInTheDocument()
|
||||
expect(suggestion).toHaveTextContent("Deploy to Cloud")
|
||||
})
|
||||
|
||||
test("should return suggestion for matching keyword in array", () => {
|
||||
const { getByTestId } = render(
|
||||
<TestComponent keywords={["nonexistent", "railway", "anothernonexistent"]} />
|
||||
)
|
||||
|
||||
const suggestion = getByTestId("suggestion")
|
||||
|
||||
expect(suggestion).toBeInTheDocument()
|
||||
expect(suggestion).toHaveTextContent("Deploy to Cloud")
|
||||
})
|
||||
|
||||
test("should be case insensitive and ignore punctuation", () => {
|
||||
const { getByTestId } = render(<TestComponent keywords="Railway!" />)
|
||||
|
||||
const suggestion = getByTestId("suggestion")
|
||||
|
||||
expect(suggestion).toBeInTheDocument()
|
||||
expect(suggestion).toHaveTextContent("Deploy to Cloud")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CardProps } from "../../components/Card"
|
||||
import { medusaSuggestions } from "./suggestions"
|
||||
|
||||
const PUNCTIONATION = /[.,?;!]/g
|
||||
|
||||
type UseSuggestionsProps = {
|
||||
keywords: string | string[]
|
||||
}
|
||||
|
||||
export const useMedusaSuggestions = ({ keywords }: UseSuggestionsProps) => {
|
||||
if (!keywords.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const keywordsArray = Array.isArray(keywords)
|
||||
? keywords.map(formatWord)
|
||||
: keywords.split(" ").map(formatWord)
|
||||
|
||||
let matchedSuggestion: CardProps | null = null
|
||||
|
||||
keywordsArray.some((word) => {
|
||||
if (medusaSuggestions.has(word)) {
|
||||
matchedSuggestion = medusaSuggestions.get(word) || null
|
||||
}
|
||||
|
||||
return matchedSuggestion !== null
|
||||
})
|
||||
|
||||
return matchedSuggestion as CardProps | null
|
||||
}
|
||||
|
||||
const formatWord = (word: string) => {
|
||||
return word.toLowerCase().trim().replace(PUNCTIONATION, "")
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ColoredMedusaIcon } from "../../components/Icons/ColoredMedusa"
|
||||
import { CardProps } from "../../components/Card"
|
||||
|
||||
const CLOUD_SUGGESTION: CardProps = {
|
||||
title: "Deploy to Cloud",
|
||||
text: "Deploy and manage production-ready Medusa applications with zero-configuration deployments, automatic scaling, and GitHub integration, and more.",
|
||||
href: "https://cloud.medusajs.com/signup",
|
||||
icon: ColoredMedusaIcon,
|
||||
}
|
||||
|
||||
const CLOUD_MAIL_SUGGESTION: CardProps = {
|
||||
title: "Deploy to Cloud",
|
||||
text: "Deploy to Cloud with email sending support out-of-the-box.",
|
||||
href: "https://cloud.medusajs.com/signup",
|
||||
icon: ColoredMedusaIcon,
|
||||
}
|
||||
|
||||
const CLOUD_S3_SUGGESTION: CardProps = {
|
||||
title: "Deploy to Cloud",
|
||||
text: "Deploy to Cloud with S3 storage support out-of-the-box.",
|
||||
href: "https://cloud.medusajs.com/signup",
|
||||
icon: ColoredMedusaIcon,
|
||||
}
|
||||
|
||||
const CLOUD_CACHE_SUGGESTION: CardProps = {
|
||||
title: "Deploy to Cloud",
|
||||
text: "Deploy to Cloud with caching support out-of-the-box.",
|
||||
href: "https://cloud.medusajs.com/signup",
|
||||
icon: ColoredMedusaIcon,
|
||||
}
|
||||
|
||||
type Suggestions = Map<string, CardProps>
|
||||
|
||||
export const medusaSuggestions: Suggestions = new Map([
|
||||
["railway", CLOUD_SUGGESTION],
|
||||
["heroku", CLOUD_SUGGESTION],
|
||||
["aws", CLOUD_SUGGESTION],
|
||||
["coolify", CLOUD_SUGGESTION],
|
||||
["resend", CLOUD_MAIL_SUGGESTION],
|
||||
["sendgrid", CLOUD_MAIL_SUGGESTION],
|
||||
["s3", CLOUD_S3_SUGGESTION],
|
||||
["minio", CLOUD_S3_SUGGESTION],
|
||||
["cache", CLOUD_CACHE_SUGGESTION],
|
||||
["caching", CLOUD_CACHE_SUGGESTION],
|
||||
])
|
||||
Reference in New Issue
Block a user