diff --git a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Callout/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Callout/__tests__/index.test.tsx new file mode 100644 index 0000000000..a74277cfbd --- /dev/null +++ b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Callout/__tests__/index.test.tsx @@ -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 }) => ( +
{props.title}
+ ), +})) +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() + + 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: () =>
Icon
, + } + mockUseMedusaSuggestions.mockReturnValueOnce(mockCardProps) + + const { getByTestId } = render() + + 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: () =>
Icon
, + }) + + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + test("should pass correct keywords to useMedusaSuggestions", () => { + render() + + 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: () =>
Icon
, + } + mockUseMedusaSuggestions.mockReturnValueOnce(mockCardProps) + + const { getByTestId } = render() + + 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, + }, + }, + }) + }) +}) \ No newline at end of file diff --git a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Callout/index.tsx b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Callout/index.tsx new file mode 100644 index 0000000000..ac14544d5d --- /dev/null +++ b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Callout/index.tsx @@ -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 ( +
+ { + track({ + event: { + event: DocsTrackingEvents.AI_ASSISTANT_CALLOUT_CLICK, + options: { + user_keywords: lastQuestion || "", + callout_title: matchedCallout.title || "", + callout_href: matchedCallout.href || "", + }, + }, + }) + }} + /> +
+ ) +} diff --git a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/__tests__/index.test.tsx index 9174924fc1..0743f05f10 100644 --- a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/__tests__/index.test.tsx +++ b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/__tests__/index.test.tsx @@ -52,6 +52,9 @@ vi.mock("@/components/AiAssistant/ThreadItem", () => ({
ThreadItem - type: {item.type}
), })) +vi.mock("@/components/AiAssistant/ChatWindow/Callout", () => ({ + AiAssistantChatWindowCallout: () =>
Callout
, +})) 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", () => { diff --git a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/index.tsx b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/index.tsx index 62405a9487..2ca82dfe26 100644 --- a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/index.tsx +++ b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/index.tsx @@ -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 = () => { )} > + diff --git a/www/packages/docs-ui/src/components/AiAssistant/__mocks__/index.tsx b/www/packages/docs-ui/src/components/AiAssistant/__mocks__/index.tsx index a4612b8ea6..b72ef6b393 100644 --- a/www/packages/docs-ui/src/components/AiAssistant/__mocks__/index.tsx +++ b/www/packages/docs-ui/src/components/AiAssistant/__mocks__/index.tsx @@ -46,6 +46,7 @@ const createMockConversation = () => { } }, getLatest: mockGetLatest, + getLatestCompleted: mockGetLatest, map(callback: (item: unknown, index: number) => React.ReactNode) { return conversationItems.map(callback) }, diff --git a/www/packages/docs-ui/src/components/Card/Layout/Default/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Card/Layout/Default/__tests__/index.test.tsx index 13226e67c1..7383976a32 100644 --- a/www/packages/docs-ui/src/components/Card/Layout/Default/__tests__/index.test.tsx +++ b/www/packages/docs-ui/src/components/Card/Layout/Default/__tests__/index.test.tsx @@ -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( + Click me + ) + expect(container).toBeInTheDocument() + const link = container.querySelector("a") + expect(link).toBeInTheDocument() + fireEvent.click(link!) + expect(handleClick).toHaveBeenCalledTimes(1) + }) +}) \ No newline at end of file diff --git a/www/packages/docs-ui/src/components/Card/Layout/Default/index.tsx b/www/packages/docs-ui/src/components/Card/Layout/Default/index.tsx index 147f0d6e79..47b8e9263c 100644 --- a/www/packages/docs-ui/src/components/Card/Layout/Default/index.tsx +++ b/www/packages/docs-ui/src/components/Card/Layout/Default/index.tsx @@ -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} /> )} diff --git a/www/packages/docs-ui/src/components/Card/Layout/Large/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Card/Layout/Large/__tests__/index.test.tsx index 1fa4afe2b2..b4a9f40c09 100644 --- a/www/packages/docs-ui/src/components/Card/Layout/Large/__tests__/index.test.tsx +++ b/www/packages/docs-ui/src/components/Card/Layout/Large/__tests__/index.test.tsx @@ -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 + }) => ( + { + // can't perform actual navigation in tests + e.preventDefault() + onClick?.() + }} + aria-label={ariaLabel} + > + {children} + + ), +})) + 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( + Click me + ) + expect(container).toBeInTheDocument() + const linkElement = container.querySelector("a") + expect(linkElement).toBeInTheDocument() + fireEvent.click(linkElement!) + expect(handleClick).toHaveBeenCalledTimes(1) + }) +}) \ No newline at end of file diff --git a/www/packages/docs-ui/src/components/Card/Layout/Large/index.tsx b/www/packages/docs-ui/src/components/Card/Layout/Large/index.tsx index d935fd51ed..5ffd193239 100644 --- a/www/packages/docs-ui/src/components/Card/Layout/Large/index.tsx +++ b/www/packages/docs-ui/src/components/Card/Layout/Large/index.tsx @@ -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} /> )} diff --git a/www/packages/docs-ui/src/components/Card/Layout/Mini/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Card/Layout/Mini/__tests__/index.test.tsx index 878dd0f1c3..bce8a15bb9 100644 --- a/www/packages/docs-ui/src/components/Card/Layout/Mini/__tests__/index.test.tsx +++ b/www/packages/docs-ui/src/components/Card/Layout/Mini/__tests__/index.test.tsx @@ -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", () => ({ ), })) -vi.mock("@/components/Link", () => ({ - Link: (props: LinkProps) => , +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 + }) => ( + { + // can't perform actual navigation in tests + e.preventDefault() + onClick?.() + }} + aria-label={ariaLabel} + > + {children} + + ), })) vi.mock("@/components/ThemeImage", () => ({ ThemeImage: (props: ThemeImageProps) => ( @@ -26,7 +56,32 @@ vi.mock("@/components/ThemeImage", () => ({ ), })) - +vi.mock("next/image", () => ({ + default: ({ + src, + alt, + width, + height, + className, + style, + }: { + src: string + alt?: string + width?: number + height?: number + className?: string + style?: React.CSSProperties + }) => ( + {alt + ), +})) 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 = () =>
Icon
@@ -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( + Click me + ) + expect(container).toBeInTheDocument() + const link = container.querySelector("a") + expect(link).toBeInTheDocument() + fireEvent.click(link!) + expect(onClick).toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/www/packages/docs-ui/src/components/Card/Layout/Mini/index.tsx b/www/packages/docs-ui/src/components/Card/Layout/Mini/index.tsx index 8f64366279..c2fa03fed7 100644 --- a/www/packages/docs-ui/src/components/Card/Layout/Mini/index.tsx +++ b/www/packages/docs-ui/src/components/Card/Layout/Mini/index.tsx @@ -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 && ( diff --git a/www/packages/docs-ui/src/components/Card/index.tsx b/www/packages/docs-ui/src/components/Card/index.tsx index 791c322ec3..546a307a12 100644 --- a/www/packages/docs-ui/src/components/Card/index.tsx +++ b/www/packages/docs-ui/src/components/Card/index.tsx @@ -31,6 +31,7 @@ export type CardProps = { highlightText?: string[] closeable?: boolean onClose?: () => void + onClick?: () => void hrefProps?: Partial> cardRef?: React.Ref } diff --git a/www/packages/docs-ui/src/components/Icons/ColoredMedusa/index.tsx b/www/packages/docs-ui/src/components/Icons/ColoredMedusa/index.tsx new file mode 100644 index 0000000000..90a674e759 --- /dev/null +++ b/www/packages/docs-ui/src/components/Icons/ColoredMedusa/index.tsx @@ -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 ( + + ) +} diff --git a/www/packages/docs-ui/src/components/Icons/index.tsx b/www/packages/docs-ui/src/components/Icons/index.tsx index 9009740c8a..d500452ac4 100644 --- a/www/packages/docs-ui/src/components/Icons/index.tsx +++ b/www/packages/docs-ui/src/components/Icons/index.tsx @@ -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" diff --git a/www/packages/docs-ui/src/components/Search/Callout/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Search/Callout/__tests__/index.test.tsx new file mode 100644 index 0000000000..abe6a4a2af --- /dev/null +++ b/www/packages/docs-ui/src/components/Search/Callout/__tests__/index.test.tsx @@ -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 }) => ( +
{props.title}
+ ), +})) +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() + + 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: () =>
Icon
, + } + mockUseMedusaSuggestions.mockReturnValueOnce(mockCardProps) + + const { getByTestId } = render() + + 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() + + expect(container.firstChild).toBeNull() + }) + + test("should call useMedusaSuggestions with the correct query", () => { + render() + + 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: () =>
Icon
, + } + mockUseMedusaSuggestions.mockReturnValueOnce(mockCardProps) + + const { getByTestId } = render() + + 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, + }, + }, + }) + }) +}) \ No newline at end of file diff --git a/www/packages/docs-ui/src/components/Search/Callout/index.tsx b/www/packages/docs-ui/src/components/Search/Callout/index.tsx new file mode 100644 index 0000000000..29c9c28f49 --- /dev/null +++ b/www/packages/docs-ui/src/components/Search/Callout/index.tsx @@ -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 ( +
+ { + track({ + event: { + event: DocsTrackingEvents.SEARCH_CALLOUT_CLICK, + options: { + user_keywords: query, + callout_title: matchedCallout.title, + callout_href: matchedCallout.href, + }, + }, + }) + }} + /> +
+ ) +} diff --git a/www/packages/docs-ui/src/components/Search/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Search/__tests__/index.test.tsx index 5e006e0384..b1245091d0 100644 --- a/www/packages/docs-ui/src/components/Search/__tests__/index.test.tsx +++ b/www/packages/docs-ui/src/components/Search/__tests__/index.test.tsx @@ -109,6 +109,9 @@ vi.mock("@/components/Search/Footer", () => ({ vi.mock("@/components/Loading/Spinner", () => ({ SpinnerLoading: () =>
Loading
, })) +vi.mock("@/components/Search/Callout", () => ({ + SearchCallout: () =>
Callout
, +})) import { Search } from "../../Search" @@ -209,6 +212,15 @@ describe("rendering", () => { expect(footer).toBeInTheDocument() }) + test("renders SearchCallout", () => { + const suggestions: SearchSuggestionType[] = [] + const { container } = render( + + ) + const callout = container.querySelector("[data-testid='search-callout']") + expect(callout).toBeInTheDocument() + }) + test("passes checkInternalPattern to SearchHitsWrapper", () => { const suggestions: SearchSuggestionType[] = [] const pattern = /\/docs\// diff --git a/www/packages/docs-ui/src/components/Search/index.tsx b/www/packages/docs-ui/src/components/Search/index.tsx index 5d8d055918..bebf26fcd5 100644 --- a/www/packages/docs-ui/src/components/Search/index.tsx +++ b/www/packages/docs-ui/src/components/Search/index.tsx @@ -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 = ({ } > + ({ + Card: (props: { title: string }) =>
{props.title}
, +})) + +import { useMedusaSuggestions } from "../index" + +const TestComponent = ({ keywords }: { keywords: string | string[] }) => { + const suggestion = useMedusaSuggestions({ keywords }) + return suggestion ? ( +
{suggestion.title}
+ ) : null +} + +beforeEach(() => { + vi.clearAllMocks() + cleanup() +}) + +describe("keywords", () => { + test("should handle empty keyword string", () => { + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + test("should handle empty keyword array", () => { + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + test("should return null for no matches", () => { + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + test("should return suggestion for matching keyword string", () => { + const { getByTestId } = render() + + 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( + + ) + + const suggestion = getByTestId("suggestion") + + expect(suggestion).toBeInTheDocument() + expect(suggestion).toHaveTextContent("Deploy to Cloud") + }) + + test("should be case insensitive and ignore punctuation", () => { + const { getByTestId } = render() + + const suggestion = getByTestId("suggestion") + + expect(suggestion).toBeInTheDocument() + expect(suggestion).toHaveTextContent("Deploy to Cloud") + }) +}) \ No newline at end of file diff --git a/www/packages/docs-ui/src/hooks/use-medusa-suggestions/index.tsx b/www/packages/docs-ui/src/hooks/use-medusa-suggestions/index.tsx new file mode 100644 index 0000000000..28133d9a4e --- /dev/null +++ b/www/packages/docs-ui/src/hooks/use-medusa-suggestions/index.tsx @@ -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, "") +} diff --git a/www/packages/docs-ui/src/hooks/use-medusa-suggestions/suggestions.ts b/www/packages/docs-ui/src/hooks/use-medusa-suggestions/suggestions.ts new file mode 100644 index 0000000000..a12f8bdd38 --- /dev/null +++ b/www/packages/docs-ui/src/hooks/use-medusa-suggestions/suggestions.ts @@ -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 + +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], +])