diff --git a/.github/workflows/docs-test.yml b/.github/workflows/docs-test.yml index 1b671bf5a2..016fa53614 100644 --- a/.github/workflows/docs-test.yml +++ b/.github/workflows/docs-test.yml @@ -34,6 +34,10 @@ jobs: working-directory: www run: yarn install + - name: Run Tests + working-directory: www + run: yarn test + - name: Build Doc Apps working-directory: www run: yarn build diff --git a/www/eslint.config.mjs b/www/eslint.config.mjs index 30d6e317f4..d960fad5f6 100644 --- a/www/eslint.config.mjs +++ b/www/eslint.config.mjs @@ -1,171 +1,204 @@ -import prettier from "eslint-plugin-prettier/recommended"; -import globals from "globals"; -import babelParser from "@babel/eslint-parser"; -import typescriptEslintEslintPlugin from "@typescript-eslint/eslint-plugin"; -import tsParser from "@typescript-eslint/parser"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import js from "@eslint/js"; -import { FlatCompat } from "@eslint/eslintrc"; +import prettier from "eslint-plugin-prettier/recommended" +import globals from "globals" +import babelParser from "@babel/eslint-parser" +import typescriptEslintEslintPlugin from "@typescript-eslint/eslint-plugin" +import tsParser from "@typescript-eslint/parser" +import path from "node:path" +import { fileURLToPath } from "node:url" +import js from "@eslint/js" +import { FlatCompat } from "@eslint/eslintrc" -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) const compat = new FlatCompat({ - baseDirectory: __dirname, - recommendedConfig: js.configs.recommended, - allConfig: js.configs.all -}); + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}) export default [ - prettier, - { + prettier, + { ignores: [ - "**/dist", - "**/build", - "**/global-config.ts", - "**/next.config.js", - "**/spec", - "**/node_modules", - "**/public", - "**/.eslintrc.js", - "**/generated", - "packages/tags/src/tags" + "**/dist", + "**/build", + "**/global-config.ts", + "**/next.config.js", + "**/spec", + "**/node_modules", + "**/public", + "**/.eslintrc.js", + "**/generated", + "packages/tags/src/tags", + "**/__tests__", + "**/__mocks__", ], -}, ...compat.extends( + }, + ...compat.extends( "eslint:recommended", "plugin:react/recommended", - "plugin:react/jsx-runtime", -), { - + "plugin:react/jsx-runtime" + ), + { languageOptions: { - globals: { - ...globals.node, - ...globals.jest, - ...globals.browser, + globals: { + ...globals.node, + ...globals.jest, + ...globals.browser, + }, + + parser: babelParser, + ecmaVersion: 13, + sourceType: "module", + + parserOptions: { + requireConfigFile: false, + + ecmaFeatures: { + experimentalDecorators: true, + jsx: true, + modules: true, }, - parser: babelParser, - ecmaVersion: 13, - sourceType: "module", - - parserOptions: { - requireConfigFile: false, - - ecmaFeatures: { - experimentalDecorators: true, - jsx: true, - modules: true - }, - - project: true, - }, + project: true, + }, }, settings: { - react: { - version: "detect", - }, + react: { + version: "detect", + }, }, rules: { - curly: ["error", "all"], - "new-cap": "off", - "require-jsdoc": "off", - "no-unused-expressions": "off", - "no-unused-vars": "off", - camelcase: "off", - "no-invalid-this": "off", + curly: ["error", "all"], + "new-cap": "off", + "require-jsdoc": "off", + "no-unused-expressions": "off", + "no-unused-vars": "off", + camelcase: "off", + "no-invalid-this": "off", - "max-len": ["error", { - code: 80, - ignoreStrings: true, - ignoreRegExpLiterals: true, - ignoreComments: true, - ignoreTrailingComments: true, - ignoreUrls: true, - ignoreTemplateLiterals: true, - }], + "max-len": [ + "error", + { + code: 80, + ignoreStrings: true, + ignoreRegExpLiterals: true, + ignoreComments: true, + ignoreTrailingComments: true, + ignoreUrls: true, + ignoreTemplateLiterals: true, + }, + ], - semi: ["error", "never"], + semi: ["error", "never"], - quotes: ["error", "double", { - allowTemplateLiterals: true, - }], + quotes: [ + "error", + "double", + { + allowTemplateLiterals: true, + }, + ], - "comma-dangle": ["error", { - arrays: "always-multiline", - objects: "always-multiline", - imports: "always-multiline", - exports: "always-multiline", - functions: "never", - }], + "comma-dangle": [ + "error", + { + arrays: "always-multiline", + objects: "always-multiline", + imports: "always-multiline", + exports: "always-multiline", + functions: "never", + }, + ], - "object-curly-spacing": ["error", "always"], - "arrow-parens": ["error", "always"], - "linebreak-style": 0, + "object-curly-spacing": ["error", "always"], + "arrow-parens": ["error", "always"], + "linebreak-style": 0, - "no-confusing-arrow": ["error", { - allowParens: false, - }], + "no-confusing-arrow": [ + "error", + { + allowParens: false, + }, + ], - "space-before-function-paren": ["error", { - anonymous: "always", - named: "never", - asyncArrow: "always", - }], + "space-before-function-paren": [ + "error", + { + anonymous: "always", + named: "never", + asyncArrow: "always", + }, + ], - "space-infix-ops": "error", - "eol-last": ["error", "always"], + "space-infix-ops": "error", + "eol-last": ["error", "always"], - "no-console": ["error", { - allow: ["error", "warn"], - }], + "no-console": [ + "error", + { + allow: ["error", "warn"], + }, + ], - "react/prop-types": "off", + "react/prop-types": "off", }, -}, ...compat.extends("plugin:@typescript-eslint/recommended", "plugin:react/recommended").map(config => ({ - ...config, - files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], -})), { + }, + ...compat + .extends( + "plugin:@typescript-eslint/recommended", + "plugin:react/recommended" + ) + .map((config) => ({ + ...config, + files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], + })), + { files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], plugins: { - "@typescript-eslint": typescriptEslintEslintPlugin, + "@typescript-eslint": typescriptEslintEslintPlugin, }, languageOptions: { - parser: tsParser, - ecmaVersion: 13, - sourceType: "module", + parser: tsParser, + ecmaVersion: 13, + sourceType: "module", - parserOptions: { - project: "./tsconfig.json", - }, + parserOptions: { + project: "./tsconfig.json", + }, }, rules: { - "valid-jsdoc": "off", - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-floating-promises": "error", - "@typescript-eslint/await-thenable": "error", - "@typescript-eslint/promise-function-async": "error", - "@/keyword-spacing": "error", + "valid-jsdoc": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/await-thenable": "error", + "@typescript-eslint/promise-function-async": "error", + "@/keyword-spacing": "error", - "@/space-before-function-paren": ["error", { - anonymous: "always", - named: "never", - asyncArrow: "always", - }], + "@/space-before-function-paren": [ + "error", + { + anonymous: "always", + named: "never", + asyncArrow: "always", + }, + ], - "@/space-infix-ops": "error", - "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/no-unused-vars": "warn", + "@/space-infix-ops": "error", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": "warn", - "react/prop-types": "off", + "react/prop-types": "off", }, settings: { - next: { - rootDir: ["apps/*/"], - }, + next: { + rootDir: ["apps/*/"], + }, }, -}]; \ No newline at end of file + }, +] diff --git a/www/package.json b/www/package.json index 6c6ee7b807..99c553377d 100644 --- a/www/package.json +++ b/www/package.json @@ -20,6 +20,7 @@ "lint:content": "turbo run lint:content", "watch": "turbo run watch", "prep": "turbo run prep", + "test": "turbo run test -- run", "up:medusa": "yarn workspaces foreach -v --topological-dev --recursive exec yarn up @medusajs/icons @medusajs/ui @medusajs/ui-preset --exact" }, "dependencies": { @@ -34,14 +35,21 @@ "devDependencies": { "@babel/eslint-parser": "^7.25.9", "@eslint/js": "9.13.0", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", "@types/eslint__js": "8.42.3", + "@vitejs/plugin-react": "^4.3.4", "eslint-config-next": "15.3.6", "eslint-config-prettier": "9.1.0", "eslint-config-turbo": "2.2.3", "eslint-plugin-markdown": "5.1.0", "eslint-plugin-prettier": "5.2.1", "eslint-plugin-react": "7.37.2", - "typescript-eslint": "8.11.0" + "jsdom": "^25.0.1", + "typescript-eslint": "8.11.0", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^2.1.8" }, "engines": { "node": ">=18.17.0" diff --git a/www/packages/docs-ui/package.json b/www/packages/docs-ui/package.json index f6335bf03d..2ec69f4437 100644 --- a/www/packages/docs-ui/package.json +++ b/www/packages/docs-ui/package.json @@ -31,7 +31,8 @@ "build:js:esm": "tsc --project tsconfig.esm.json && tsc-alias -p tsconfig.esm.json", "clean": "rimraf dist", "dev": "yarn build:js:cjs && yarn build:js:esm", - "lint": "eslint src --fix" + "lint": "eslint src --fix", + "test": "vitest" }, "devDependencies": { "@types/react": "19.2.3", @@ -47,7 +48,9 @@ "tsc-alias": "^1.8.7", "tsup": "^5.10.1", "types": "*", - "typescript": "^5.1.6" + "typescript": "^5.1.6", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^2.1.8" }, "peerDependencies": { "@types/react": "*", diff --git a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Header/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Header/__tests__/index.test.tsx new file mode 100644 index 0000000000..de4edb0eaa --- /dev/null +++ b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Header/__tests__/index.test.tsx @@ -0,0 +1,36 @@ +import React from "react" +import { expect, test, vi } from "vitest" +import { fireEvent, render } from "@testing-library/react" +import * as AiAssistantMocks from "../../../__mocks__" + +vi.mock("@/providers/AiAssistant", () => ({ + useAiAssistant: () => AiAssistantMocks.mockUseAiAssistant(), +})) + +import { AiAssistantChatWindowHeader } from "../../Header" + +test("handles chat window close", () => { + const { container } = render() + const button = container.querySelector("button") + const xMark = button?.querySelector("svg") + expect(button).toBeInTheDocument() + expect(xMark).toBeInTheDocument() + expect(xMark).toHaveClass("text-medusa-fg-muted") + expect(xMark).toHaveAttribute("height", "15") + expect(xMark).toHaveAttribute("width", "15") +}) + +test("calls setChatOpened(false) when button is clicked", () => { + // Reset the mock before each test + AiAssistantMocks.mockSetChatOpened.mockClear() + + const { container } = render() + const button = container.querySelector("button") + + expect(button).toBeInTheDocument() + + fireEvent.click(button!) + + expect(AiAssistantMocks.mockSetChatOpened).toHaveBeenCalledTimes(1) + expect(AiAssistantMocks.mockSetChatOpened).toHaveBeenCalledWith(false) +}) diff --git a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Header/index.tsx b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Header/index.tsx index 46f2e45d4f..35cf4134fb 100644 --- a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Header/index.tsx +++ b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Header/index.tsx @@ -6,7 +6,7 @@ import { Tooltip } from "../../../Tooltip" import { Link } from "../../../Link" import { ShieldCheck, XMark } from "@medusajs/icons" import { Button } from "../../../Button" -import { useAiAssistant } from "../../../../providers" +import { useAiAssistant } from "../../../../providers/AiAssistant" export const AiAssistantChatWindowHeader = () => { const { setChatOpened } = useAiAssistant() diff --git a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Input/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Input/__tests__/index.test.tsx new file mode 100644 index 0000000000..d07f7e3674 --- /dev/null +++ b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Input/__tests__/index.test.tsx @@ -0,0 +1,414 @@ +import React from "react" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { fireEvent, render } from "@testing-library/react" +import * as AiAssistantMocks from "../../../__mocks__" + +vi.mock("@/providers/AiAssistant", () => ({ + useAiAssistant: () => AiAssistantMocks.mockUseAiAssistant(), +})) +vi.mock("@/providers/Analytics", () => ({ + useAnalytics: () => ({ + track: AiAssistantMocks.mockTrack, + }), +})) +vi.mock("@/providers/BrowserProvider", () => ({ + useIsBrowser: () => ({ + isBrowser: true, + }), +})) +vi.mock("@/hooks/use-ai-assistant-chat-navigation", () => ({ + useAiAssistantChatNavigation: () => ({ + getChatWindowElm: AiAssistantMocks.mockGetChatWindowElm, + getInputElm: AiAssistantMocks.mockGetInputElm, + focusInput: AiAssistantMocks.mockFocusInput, + question: "", + }), +})) +vi.mock("@kapaai/react-sdk", () => ({ + useChat: () => AiAssistantMocks.mockUseChat(), + useDeepThinking: () => AiAssistantMocks.mockUseDeepThinking(), +})) +vi.mock("@/components/Tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +import { AiAssistantChatWindowInput } from "../../Input" +import { DocsTrackingEvents } from "../../../../.." + +// Reset mock before each test to ensure clean state +beforeEach(() => { + AiAssistantMocks.mockUseAiAssistant.mockReturnValue( + AiAssistantMocks.defaultUseAiAssistantReturn + ) + AiAssistantMocks.mockSetChatOpened.mockClear() + AiAssistantMocks.mockTrack.mockClear() + AiAssistantMocks.mockAddFeedback.mockClear() + AiAssistantMocks.mockSubmitQuery.mockClear() + AiAssistantMocks.mockStopGeneration.mockClear() + AiAssistantMocks.mockConversation.length = 1 + AiAssistantMocks.mockUseChat.mockReturnValue( + AiAssistantMocks.defaultUseChatReturn + ) + AiAssistantMocks.mockUseDeepThinking.mockReturnValue( + AiAssistantMocks.defaultUseDeepThinkingReturn + ) +}) + +describe("rendering", () => { + test("renders the chat window input", () => { + const { container } = render( + ()} + /> + ) + const input = container.querySelector("textarea") + expect(input).toBeInTheDocument() + expect(input).toHaveValue("") + }) + + test("should focus input when chat is opened", () => { + AiAssistantMocks.mockUseAiAssistant.mockReturnValueOnce({ + ...AiAssistantMocks.defaultUseAiAssistantReturn, + chatOpened: true, + }) + const { container } = render( + ()} + /> + ) + const input = container.querySelector("textarea") + expect(input).toBeInTheDocument() + expect(input).toHaveFocus() + }) +}) + +describe("form submission", () => { + test("submits the question when the form is submitted", () => { + const { container } = render( + ()} + /> + ) + const input = container.querySelector("textarea") + expect(input).toBeInTheDocument() + fireEvent.change(input!, { target: { value: "test" } }) + fireEvent.submit(input!) + expect(AiAssistantMocks.mockSubmitQuery).toHaveBeenCalledWith("test") + }) +}) + +describe("stop generation", () => { + test("should stop generation when loading is true", () => { + // Set loading to true for this test + AiAssistantMocks.mockUseAiAssistant.mockReturnValueOnce({ + ...AiAssistantMocks.defaultUseAiAssistantReturn, + loading: true, + }) + + const { container } = render( + ()} + /> + ) + + const input = container.querySelector("textarea") + const form = container.querySelector("form") + const submitButton = form?.querySelector("button[type=submit]") + + expect(input).toBeInTheDocument() + expect(submitButton).toBeInTheDocument() + + // Verify loading state - button should show stop icon and be enabled + const stopIcon = submitButton?.querySelector("svg") + expect(stopIcon).toBeInTheDocument() + expect(submitButton).not.toBeDisabled() + + // When form is submitted while loading, it should call stopGeneration + AiAssistantMocks.mockStopGeneration.mockClear() + fireEvent.submit(input!.closest("form")!) + expect(AiAssistantMocks.mockStopGeneration).toHaveBeenCalledTimes(1) + }) + + test("should not stop generation when loading is false", () => { + const { container } = render( + ()} + /> + ) + const input = container.querySelector("textarea") + const form = container.querySelector("form") + const submitButton = form?.querySelector("button[type=submit]") + + expect(input).toBeInTheDocument() + expect(submitButton).toBeInTheDocument() + + fireEvent.change(input!, { target: { value: "test" } }) + expect(input).toHaveValue("test") + expect(submitButton).not.toBeDisabled() + AiAssistantMocks.mockStopGeneration.mockClear() + fireEvent.submit(input!.closest("form")!) + expect(AiAssistantMocks.mockStopGeneration).not.toHaveBeenCalled() + }) +}) + +describe("analytics tracking", () => { + test("should track start chat event when no conversation", () => { + AiAssistantMocks.mockConversation.length = 0 + const { container } = render( + ()} + /> + ) + const input = container.querySelector("textarea") + expect(input).toBeInTheDocument() + fireEvent.change(input!, { target: { value: "test" } }) + fireEvent.submit(input!.closest("form")!) + expect(AiAssistantMocks.mockTrack).toHaveBeenCalledWith({ + event: { + event: DocsTrackingEvents.AI_ASSISTANT_START_CHAT, + }, + }) + }) + + test("should not track start chat event when conversation is not empty", () => { + AiAssistantMocks.mockConversation.length = 1 + const { container } = render( + ()} + /> + ) + const input = container.querySelector("textarea") + expect(input).toBeInTheDocument() + fireEvent.change(input!, { target: { value: "test" } }) + fireEvent.submit(input!.closest("form")!) + expect(AiAssistantMocks.mockTrack).not.toHaveBeenCalled() + }) +}) + +describe("keyboard interactions", () => { + test("should set question state to last question when arrow up is pressed and question is empty", () => { + AiAssistantMocks.mockConversation.getLatest.mockReturnValue({ + question: "last question", + }) + const { container } = render( + ()} + /> + ) + const input = container.querySelector("textarea") + expect(input).toBeInTheDocument() + fireEvent.keyDown(input!, { key: "ArrowUp" }) + expect(input).toHaveValue("last question") + }) + + test("should not set question state to last question when arrow up is pressed and question is not empty", () => { + AiAssistantMocks.mockConversation.getLatest.mockReturnValue({ + question: "last question", + }) + const { container } = render( + ()} + /> + ) + const input = container.querySelector("textarea") + expect(input).toBeInTheDocument() + fireEvent.change(input!, { target: { value: "test" } }) + fireEvent.keyDown(input!, { key: "ArrowUp" }) + expect(input).toHaveValue("test") + }) + + test("should add new line when shift + enter are pressed and question is not empty", () => { + AiAssistantMocks.mockConversation.getLatest.mockReturnValue({ + question: "last question", + }) + const { container } = render( + ()} + /> + ) + const input = container.querySelector("textarea") + expect(input).toBeInTheDocument() + fireEvent.change(input!, { target: { value: "test" } }) + fireEvent.keyDown(input!, { key: "Enter", shiftKey: true }) + expect(input).toHaveValue("test\n") + }) +}) + +describe("search query parameters", () => { + const originalLocation = window.location + + beforeEach(() => { + // Reset location mock before each test + delete (window as unknown as { location?: Location }).location + }) + + afterEach(() => { + // Restore original location after each test + Object.defineProperty(window, "location", { + value: originalLocation, + writable: true, + configurable: true, + }) + }) + + test("should set question from query parameter when query is present", () => { + // Mock window.location with query parameter + Object.defineProperty(window, "location", { + value: { + ...originalLocation, + search: "?query=test%20question", + }, + writable: true, + configurable: true, + }) + + const { container } = render( + ()} + /> + ) + + const input = container.querySelector("textarea") + expect(input).toBeInTheDocument() + expect(input).toHaveValue("test question") + expect(AiAssistantMocks.mockSetChatOpened).toHaveBeenCalledWith(true) + }) + + test("should set question and submit when queryType is submit", () => { + // Mock window.location with query parameter and queryType=submit + Object.defineProperty(window, "location", { + value: { + ...originalLocation, + search: "?query=test%20question&queryType=submit", + }, + writable: true, + configurable: true, + }) + + AiAssistantMocks.mockSubmitQuery.mockClear() + const { container } = render( + ()} + /> + ) + + const input = container.querySelector("textarea") + expect(input).toBeInTheDocument() + expect(AiAssistantMocks.mockSetChatOpened).toHaveBeenCalledWith(true) + expect(AiAssistantMocks.mockSubmitQuery).toHaveBeenCalledWith( + "test question" + ) + }) + + test("should not set question when query parameter is not present", () => { + // Mock window.location without query parameter + Object.defineProperty(window, "location", { + value: { + ...originalLocation, + search: "", + }, + writable: true, + configurable: true, + }) + + const { container } = render( + ()} + /> + ) + + const input = container.querySelector("textarea") + expect(input).toBeInTheDocument() + expect(input).toHaveValue("") + }) + + test("should not set question when isCaptchaLoaded is false", () => { + // Mock window.location with query parameter + Object.defineProperty(window, "location", { + value: { + ...originalLocation, + search: "?query=test%20question", + }, + writable: true, + configurable: true, + }) + + // Set isCaptchaLoaded to false + AiAssistantMocks.mockUseAiAssistant.mockReturnValueOnce({ + ...AiAssistantMocks.defaultUseAiAssistantReturn, + isCaptchaLoaded: false, + }) + + const { container } = render( + ()} + /> + ) + + const input = container.querySelector("textarea") + expect(input).toBeInTheDocument() + expect(input).toHaveValue("") + expect(AiAssistantMocks.mockSetChatOpened).not.toHaveBeenCalled() + }) +}) + +describe("deep thinking", () => { + test("should toggle deep thinking when button is clicked", () => { + const { container } = render( + ()} + /> + ) + + const deepThinkingButton = container.querySelector("button") + expect(deepThinkingButton).toBeInTheDocument() + fireEvent.click(deepThinkingButton!) + expect(AiAssistantMocks.mockToggle).toHaveBeenCalled() + }) + + test("button should be disabled when loading is true", () => { + AiAssistantMocks.mockUseAiAssistant.mockReturnValueOnce({ + ...AiAssistantMocks.defaultUseAiAssistantReturn, + loading: true, + }) + + const { container } = render( + ()} + /> + ) + const deepThinkingButton = container.querySelector("button") + expect(deepThinkingButton).toBeInTheDocument() + expect(deepThinkingButton).toBeDisabled() + }) + + test("button should have light bulb icon when deep thinking is inactive", () => { + const { container } = render( + ()} + /> + ) + const deepThinkingButton = container.querySelector("button") + const icon = deepThinkingButton?.querySelector("svg") + expect(icon).toBeInTheDocument() + expect(icon).not.toHaveClass("text-medusa-tag-orange-icon") + }) + + test("button should have light bulb solid icon when deep thinking is active", () => { + AiAssistantMocks.mockUseDeepThinking.mockReturnValueOnce({ + active: true, + toggle: AiAssistantMocks.mockToggle, + }) + const { container } = render( + ()} + /> + ) + const deepThinkingButton = container.querySelector("button") + const icon = deepThinkingButton?.querySelector("svg") + expect(icon).toBeInTheDocument() + expect(icon).toHaveClass("text-medusa-tag-orange-icon") + }) +}) diff --git a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Input/index.tsx b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Input/index.tsx index 23f15cef83..ad86c2da37 100644 --- a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Input/index.tsx +++ b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/Input/index.tsx @@ -6,13 +6,11 @@ import { LightBulbSolid, StopCircleSolid, } from "@medusajs/icons" -import { - useAiAssistant, - useAnalytics, - useIsBrowser, -} from "../../../../providers" +import { useAiAssistant } from "@/providers/AiAssistant" +import { useAnalytics } from "@/providers/Analytics" +import { useIsBrowser } from "@/providers/BrowserProvider" import { useChat, useDeepThinking } from "@kapaai/react-sdk" -import { useAiAssistantChatNavigation } from "../../../../hooks" +import { useAiAssistantChatNavigation } from "../../../../hooks/use-ai-assistant-chat-navigation" import { Tooltip } from "../../../Tooltip" import { DocsTrackingEvents } from "../../../../constants" 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 new file mode 100644 index 0000000000..9174924fc1 --- /dev/null +++ b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/__tests__/index.test.tsx @@ -0,0 +1,271 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { fireEvent, render } from "@testing-library/react" +import * as AiAssistantMocks from "../../__mocks__" +import { AiAssistantThreadItemProps } from "../../ThreadItem" + +// Mock components and hooks +vi.mock("@/providers/AiAssistant", () => ({ + useAiAssistant: () => AiAssistantMocks.mockUseAiAssistant(), +})) +vi.mock("@/providers/Analytics", () => ({ + useAnalytics: () => ({ + track: AiAssistantMocks.mockTrack, + }), +})) +vi.mock("@/providers/BrowserProvider", () => ({ + useIsBrowser: () => ({ + isBrowser: true, + }), +})) +vi.mock("@/hooks/use-ai-assistant-chat-navigation", () => ({ + useAiAssistantChatNavigation: () => ({ + getChatWindowElm: AiAssistantMocks.mockGetChatWindowElm, + getInputElm: AiAssistantMocks.mockGetInputElm, + focusInput: AiAssistantMocks.mockFocusInput, + question: "", + }), +})) +vi.mock("@kapaai/react-sdk", () => ({ + useChat: () => AiAssistantMocks.mockUseChat(), + useDeepThinking: () => AiAssistantMocks.mockUseDeepThinking(), +})) +vi.mock("@/components/Tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) +vi.mock("@/components/AiAssistant/ChatWindow/Header", () => ({ + AiAssistantChatWindowHeader: () =>
Header
, +})) +vi.mock("@/components/AiAssistant/ChatWindow/Input", () => ({ + AiAssistantChatWindowInput: () =>
Input
, +})) +vi.mock("@/components/AiAssistant/ChatWindow/Footer", () => ({ + AiAssistantChatWindowFooter: () =>
Footer
, +})) +vi.mock("@/components/AiAssistant/Suggestions", () => ({ + AiAssistantSuggestions: () =>
Suggestions
, +})) +vi.mock("@/components/AiAssistant/ThreadItem", () => ({ + AiAssistantThreadItem: ({ item }: AiAssistantThreadItemProps) => ( +
ThreadItem - type: {item.type}
+ ), +})) + +import { AiAssistantChatWindow } from "../../ChatWindow" + +// Reset mock before each test to ensure clean state +beforeEach(() => { + AiAssistantMocks.mockUseAiAssistant.mockReturnValue( + AiAssistantMocks.defaultUseAiAssistantReturn + ) + AiAssistantMocks.mockSetChatOpened.mockClear() + AiAssistantMocks.mockTrack.mockClear() + AiAssistantMocks.mockAddFeedback.mockClear() + AiAssistantMocks.mockSubmitQuery.mockClear() + AiAssistantMocks.mockStopGeneration.mockClear() + AiAssistantMocks.mockConversation.length = 1 + AiAssistantMocks.mockUseChat.mockReturnValue( + AiAssistantMocks.defaultUseChatReturn + ) + AiAssistantMocks.mockUseDeepThinking.mockReturnValue( + AiAssistantMocks.defaultUseDeepThinkingReturn + ) +}) + +describe("rendering", () => { + test("renders chat window", () => { + const { container } = render() + expect(container).toBeInTheDocument() + expect(container).toHaveTextContent("Header") + expect(container).toHaveTextContent("Input") + expect(container).toHaveTextContent("Footer") + }) + + test("chat is hidden when chatOpened is false", () => { + AiAssistantMocks.mockUseAiAssistant.mockReturnValueOnce({ + ...AiAssistantMocks.defaultUseAiAssistantReturn, + chatOpened: false, + }) + const { container } = render() + expect(container).toBeInTheDocument() + const overlay = container.querySelector(".bg-medusa-bg-overlay") + expect(overlay).toBeInTheDocument() + expect(overlay).toHaveClass("hidden") + const chatWindow = container.querySelector(".z-50.flex") + expect(chatWindow).toBeInTheDocument() + expect(chatWindow).toHaveClass("!fixed") + }) + + test("chat is shown when chatOpened is true", () => { + AiAssistantMocks.mockUseAiAssistant.mockReturnValueOnce({ + ...AiAssistantMocks.defaultUseAiAssistantReturn, + chatOpened: true, + }) + const { container } = render() + expect(container).toBeInTheDocument() + const overlay = container.querySelector(".bg-medusa-bg-overlay") + expect(overlay).toBeInTheDocument() + expect(overlay).toHaveClass("block") + const chatWindow = container.querySelector(".z-50.flex") + expect(chatWindow).toBeInTheDocument() + expect(chatWindow).toHaveClass("!right-0") + }) +}) + +describe("conversation", () => { + test("show suggestions when conversation is empty", () => { + AiAssistantMocks.mockConversation.length = 0 + const { container } = render() + expect(container).toBeInTheDocument() + expect(container).toHaveTextContent("Suggestions") + }) + + test("show thread items when conversation is not empty", () => { + AiAssistantMocks.mockConversation.length = 2 + const { container } = render() + expect(container).toBeInTheDocument() + const threadItems = container.querySelectorAll(".thread-item") + expect(threadItems).toHaveLength(4) + expect(threadItems[0]).toHaveTextContent("ThreadItem - type: question") + expect(threadItems[1]).toHaveTextContent("ThreadItem - type: answer") + expect(threadItems[2]).toHaveTextContent("ThreadItem - type: question") + expect(threadItems[3]).toHaveTextContent("ThreadItem - type: answer") + }) + + test("show error message when error is not empty", () => { + AiAssistantMocks.mockUseChat.mockReturnValueOnce({ + ...AiAssistantMocks.defaultUseChatReturn, + error: "error", + }) + const { container } = render() + expect(container).toBeInTheDocument() + expect(container).toHaveTextContent("ThreadItem - type: error") + }) +}) + +describe("keyboard shortcuts", () => { + test("escape key should close chat window", () => { + AiAssistantMocks.mockUseAiAssistant.mockReturnValueOnce({ + ...AiAssistantMocks.defaultUseAiAssistantReturn, + chatOpened: true, + }) + const { container } = render() + expect(container).toBeInTheDocument() + + // Focus an element inside the chat window to satisfy the contains check + // The chat window ref needs to contain the active element + const chatWindow = container.querySelector(".z-50.flex") as HTMLElement + expect(chatWindow).toBeInTheDocument() + + // Create a focusable element inside the chat window and focus it + const focusableElement = document.createElement("div") + focusableElement.setAttribute("tabindex", "0") + chatWindow.appendChild(focusableElement) + focusableElement.focus() + + // Fire Escape key on window (where useKeyboardShortcut listens) + fireEvent.keyDown(window, { key: "Escape" }) + + // Verify that setChatOpened(false) was called + expect(AiAssistantMocks.mockSetChatOpened).toHaveBeenCalledWith(false) + }) +}) + +describe("scroll", () => { + test("show fade when scroll is not at the bottom", () => { + AiAssistantMocks.mockUseAiAssistant.mockReturnValueOnce({ + ...AiAssistantMocks.defaultUseAiAssistantReturn, + chatOpened: true, + loading: false, + }) + const { container } = render() + expect(container).toBeInTheDocument() + + // Find the fade element + const fade = container.querySelector(".bg-ai-assistant-bottom") + expect(fade).toBeInTheDocument() + + // Find the scrollable parent element (contentRef.current.parentElement) + // This is the div with class "overflow-y-auto flex-auto px-docs_0.5 pt-docs_0.25 pb-docs_2" + const scrollableParent = container.querySelector( + ".overflow-y-auto.flex-auto" + ) as HTMLElement + expect(scrollableParent).toBeInTheDocument() + + // Initially, fade should not be shown (opacity-0) + expect(fade).toHaveClass("opacity-0") + expect(fade).not.toHaveClass("opacity-100") + + // Set up scroll dimensions to simulate not being at the bottom + // The condition is: offsetHeight + scrollTop < scrollHeight - 1 + // So if scrollHeight is 1000, offsetHeight is 500, scrollTop is 0 + // Then 500 + 0 < 1000 - 1 = true, so fade should show + Object.defineProperty(scrollableParent, "scrollHeight", { + value: 1000, + writable: true, + configurable: true, + }) + Object.defineProperty(scrollableParent, "offsetHeight", { + value: 500, + writable: true, + configurable: true, + }) + Object.defineProperty(scrollableParent, "scrollTop", { + value: 0, + writable: true, + configurable: true, + }) + + // Fire scroll event on the parent element + fireEvent.scroll(scrollableParent) + + // Fade should now be shown (opacity-100) + expect(fade).toHaveClass("opacity-100") + }) + + test("hide fade when scroll is at the bottom", () => { + AiAssistantMocks.mockUseAiAssistant.mockReturnValueOnce({ + ...AiAssistantMocks.defaultUseAiAssistantReturn, + chatOpened: true, + loading: false, + }) + const { container } = render() + expect(container).toBeInTheDocument() + + const fade = container.querySelector(".bg-ai-assistant-bottom") + expect(fade).toBeInTheDocument() + + const scrollableParent = container.querySelector( + ".overflow-y-auto.flex-auto" + ) as HTMLElement + expect(scrollableParent).toBeInTheDocument() + + // Set up scroll dimensions to simulate being at the bottom + // offsetHeight + scrollTop >= scrollHeight - 1 + // So if scrollHeight is 1000, offsetHeight is 500, scrollTop is 500 + // Then 500 + 500 >= 1000 - 1 = true, so fade should hide + Object.defineProperty(scrollableParent, "scrollHeight", { + value: 1000, + writable: true, + configurable: true, + }) + Object.defineProperty(scrollableParent, "offsetHeight", { + value: 500, + writable: true, + configurable: true, + }) + Object.defineProperty(scrollableParent, "scrollTop", { + value: 500, + writable: true, + configurable: true, + }) + + // Fire scroll event + fireEvent.scroll(scrollableParent) + + // Fade should be hidden + expect(fade).not.toHaveClass("opacity-100") + }) +}) 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 dd1e816215..62405a9487 100644 --- a/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/index.tsx +++ b/www/packages/docs-ui/src/components/AiAssistant/ChatWindow/index.tsx @@ -8,12 +8,13 @@ import React, { useRef, useState, } from "react" -import { useAiAssistant, useIsBrowser } from "../../../providers" +import { useAiAssistant } from "../../../providers/AiAssistant" +import { useIsBrowser } from "../../../providers/BrowserProvider" import { AiAssistantChatWindowHeader } from "./Header" import { AiAssistantSuggestions } from "../Suggestions" import { AiAssistantThreadItem } from "../ThreadItem" import { AiAssistantChatWindowInput } from "./Input" -import { useKeyboardShortcut } from "../../.." +import { useKeyboardShortcut } from "../../../hooks/use-keyboard-shortcut" import { AiAssistantChatWindowFooter } from "./Footer" import { useChat } from "@kapaai/react-sdk" diff --git a/www/packages/docs-ui/src/components/AiAssistant/SearchWindow/index.tsx b/www/packages/docs-ui/src/components/AiAssistant/SearchWindow/index.tsx index 80d76771a9..01baf44891 100644 --- a/www/packages/docs-ui/src/components/AiAssistant/SearchWindow/index.tsx +++ b/www/packages/docs-ui/src/components/AiAssistant/SearchWindow/index.tsx @@ -1,13 +1,19 @@ "use client" import React, { Fragment, useCallback, useState } from "react" -import { Badge, Button, InputText, Kbd, Tooltip, Link } from "@/components" -import { useAiAssistant, useSearch } from "@/providers" +import { Badge } from "../../Badge" +import { Button } from "../../Button" +import { InputText } from "../../Input/Text" +import { Kbd } from "../../Kbd" +import { Tooltip } from "../../Tooltip" +import { Link } from "../../Link" +import { useAiAssistant } from "@/providers/AiAssistant" +import { useSearch } from "@/providers/Search" import { ArrowUturnLeft } from "@medusajs/icons" import clsx from "clsx" import { AiAssistantThreadItem } from "../ThreadItem" import { AiAssistantSuggestions } from "../Suggestions" -import { useSearchNavigation } from "../../.." +import { useSearchNavigation } from "../../../hooks/use-search-navigation" import { useChat } from "@kapaai/react-sdk" export const AiAssistantSearchWindow = () => { diff --git a/www/packages/docs-ui/src/components/AiAssistant/Suggestions/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/AiAssistant/Suggestions/__tests__/index.test.tsx new file mode 100644 index 0000000000..57fcf87a6f --- /dev/null +++ b/www/packages/docs-ui/src/components/AiAssistant/Suggestions/__tests__/index.test.tsx @@ -0,0 +1,108 @@ +import React from "react" +import { beforeAll, describe, expect, test, vi } from "vitest" +import { fireEvent, render } from "@testing-library/react" +import * as AiAssistantMocks from "../../__mocks__" + +// Mock components and hooks +vi.mock("@kapaai/react-sdk", () => ({ + useChat: () => AiAssistantMocks.mockUseChat(), +})) +vi.mock("@/providers/SiteConfig", () => ({ + useSiteConfig: () => ({ + config: { + baseUrl: "https://docs.medusajs.com", + }, + }), +})) +vi.mock("@/components/Link", () => ({ + Link: ({ + children, + href, + variant, + }: { + children: React.ReactNode + href: string + variant: "content" + }) => ( + + {children} + + ), +})) + +vi.mock("@/components/Search/Hits/GroupName", () => ({ + SearchHitGroupName: ({ name }: { name: string }) =>
{name}
, +})) +vi.mock("@/components/Search/Suggestions/Item", () => ({ + SearchSuggestionItem: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick: () => void + }) => ( +
+ {children} +
+ ), +})) +import { AiAssistantSuggestions } from "../index" + +beforeAll(() => { + AiAssistantMocks.mockUseChat.mockReturnValue( + AiAssistantMocks.defaultUseChatReturn + ) + AiAssistantMocks.mockAddFeedback.mockClear() + AiAssistantMocks.mockSubmitQuery.mockClear() + AiAssistantMocks.mockStopGeneration.mockClear() + AiAssistantMocks.mockConversation.length = 1 +}) + +describe("rendering", () => { + test("renders suggestions", () => { + const { container } = render() + expect(container).toBeInTheDocument() + expect(container).toHaveTextContent("FAQ") + expect(container).toHaveTextContent("Recipes") + expect(container).toHaveTextContent("What is Medusa?") + expect(container).toHaveTextContent("How can I create a module?") + expect(container).toHaveTextContent("How can I create a data model?") + expect(container).toHaveTextContent("How do I create a workflow?") + expect(container).toHaveTextContent( + "How can I extend a data model in the Product Module?" + ) + expect(container).toHaveTextContent( + "How do I build a marketplace with Medusa?" + ) + expect(container).toHaveTextContent( + "How do I build digital products with Medusa?" + ) + expect(container).toHaveTextContent( + "How do I build subscription-based purchases with Medusa?" + ) + expect(container).toHaveTextContent( + "What other recipes are available in the Medusa documentation?" + ) + expect(container).toHaveTextContent("Medusa MCP server") + }) +}) + +describe("interaction", () => { + test("clicking a suggestion item should submit the query", () => { + const { container } = render() + expect(container).toBeInTheDocument() + const suggestionItem = container.querySelector(".suggestion-item") + expect(suggestionItem).toBeInTheDocument() + fireEvent.click(suggestionItem!) + expect(AiAssistantMocks.mockSubmitQuery).toHaveBeenCalledWith( + suggestionItem!.textContent + ) + }) +}) diff --git a/www/packages/docs-ui/src/components/AiAssistant/Suggestions/index.tsx b/www/packages/docs-ui/src/components/AiAssistant/Suggestions/index.tsx index 1da871ce1b..c0e7342462 100644 --- a/www/packages/docs-ui/src/components/AiAssistant/Suggestions/index.tsx +++ b/www/packages/docs-ui/src/components/AiAssistant/Suggestions/index.tsx @@ -6,7 +6,7 @@ import { SearchHitGroupName } from "../../Search/Hits/GroupName" import { SearchSuggestionItem } from "../../Search/Suggestions/Item" import { useChat } from "@kapaai/react-sdk" import { Link } from "../../Link" -import { useSiteConfig } from "../../../providers" +import { useSiteConfig } from "../../../providers/SiteConfig" type AiAssistantSuggestionsProps = React.AllHTMLAttributes diff --git a/www/packages/docs-ui/src/components/AiAssistant/ThreadItem/Actions/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/AiAssistant/ThreadItem/Actions/__tests__/index.test.tsx new file mode 100644 index 0000000000..ac68dfd680 --- /dev/null +++ b/www/packages/docs-ui/src/components/AiAssistant/ThreadItem/Actions/__tests__/index.test.tsx @@ -0,0 +1,203 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { fireEvent, render } from "@testing-library/react" +import { ButtonProps } from "../../../../Button" +import { LinkProps } from "../../../../Link" +import { BadgeProps } from "../../../../Badge" +import { AiAssistantThreadItem } from "../../../../../providers/AiAssistant" +import * as AiAssistantMocks from "../../../__mocks__" + +// mock data +const mockQuestionThreadItem: AiAssistantThreadItem = { + type: "question", + content: "test content", +} +const mockAnswerThreadItem: AiAssistantThreadItem = { + type: "answer", + content: "test answer", + question_id: "123", +} +const mockSiteConfig = { + config: { + baseUrl: "https://docs.medusajs.com", + }, +} + +// Mock functions +const mockHandleCopy = vi.fn() +const defaultUseCopyReturn = { + handleCopy: mockHandleCopy, + isCopied: false, +} +const useCopyMock = vi.fn(() => defaultUseCopyReturn) + +// Mock components and hooks +vi.mock("@kapaai/react-sdk", () => ({ + useChat: () => AiAssistantMocks.mockUseChat(), +})) +vi.mock("@/providers/SiteConfig", () => ({ + useSiteConfig: () => mockSiteConfig, +})) +vi.mock("@/components/Badge", () => ({ + Badge: (props: BadgeProps) =>
, +})) +vi.mock("@/components/Button", () => ({ + Button: (props: ButtonProps) =>
)}
- + {isAnswerCopied ? : } {(feedback === null || feedback === "upvote") && ( handleFeedback("upvote", item.question_id)} + data-testid="upvote-button" className={clsx( feedback === "upvote" && "!text-medusa-fg-muted" )} @@ -96,6 +100,7 @@ export const AiAssistantThreadItemActions = ({ onClick={async () => handleFeedback("downvote", item.question_id) } + data-testid="downvote-button" className={clsx( feedback === "downvote" && "!text-medusa-fg-muted" )} diff --git a/www/packages/docs-ui/src/components/AiAssistant/ThreadItem/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/AiAssistant/ThreadItem/__tests__/index.test.tsx new file mode 100644 index 0000000000..cccb2ce382 --- /dev/null +++ b/www/packages/docs-ui/src/components/AiAssistant/ThreadItem/__tests__/index.test.tsx @@ -0,0 +1,153 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { render } from "@testing-library/react" +import * as AiAssistantMocks from "../../__mocks__" +import { AiAssistantThreadItem as AiAssistantThreadItemType } from "../../../../providers/AiAssistant" +import { CodeMdxProps } from "../../../CodeMdx" + +// mock data +const mockQuestionThreadItem: AiAssistantThreadItemType = { + type: "question", + content: "test content", +} +const mockEmptyAnswerThreadItem: AiAssistantThreadItemType = { + type: "answer", + content: "", +} +const mockAnswerWithQuestionThreadItem: AiAssistantThreadItemType = { + type: "answer", + content: "test answer", + question_id: "123", +} +const mockAnswerWithoutQuestionThreadItem: AiAssistantThreadItemType = { + type: "answer", + content: "test answer", +} +const mockErrorThreadItem: AiAssistantThreadItemType = { + type: "error", + content: "test error", +} + +// Mock components and hooks +vi.mock("@kapaai/react-sdk", () => ({ + useChat: () => AiAssistantMocks.mockUseChat(), +})) +vi.mock("@/components/Icons/AiAssistant", () => ({ + AiAssistantIcon: () => AiAssistantIcon, +})) +vi.mock("@/components/CodeMdx", () => ({ + CodeMdx: (props: CodeMdxProps) => , +})) +vi.mock("@/components/MarkdownContent", () => ({ + MarkdownContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) +vi.mock("@/components/MDXComponents", () => ({ + MDXComponents: () => { + return {} + }, +})) +vi.mock("@/components/AiAssistant/ThreadItem/Actions", () => ({ + AiAssistantThreadItemActions: () =>
AiAssistantThreadItemActions
, +})) +vi.mock("@/components/Loading/Dots", () => ({ + DotsLoading: () =>
DotsLoading
, +})) + +import { AiAssistantThreadItem } from "../../ThreadItem" + +beforeEach(() => { + AiAssistantMocks.mockConversation.length = 1 + AiAssistantMocks.mockAddFeedback.mockClear() + AiAssistantMocks.mockSubmitQuery.mockClear() + AiAssistantMocks.mockStopGeneration.mockClear() + AiAssistantMocks.mockUseChat.mockReturnValue( + AiAssistantMocks.defaultUseChatReturn + ) +}) + +describe("rendering", () => { + test("renders question thread item", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const wrapper = container.querySelector("div") + expect(wrapper).toHaveClass("justify-end") + const aiAssistantIcon = container.querySelector("span") + expect(aiAssistantIcon).not.toBeInTheDocument() + expect(container).toHaveTextContent(mockQuestionThreadItem.content) + expect(container).toHaveTextContent("AiAssistantThreadItemActions") + }) + test("renders answer thread item", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const wrapper = container.querySelector("div") + expect(wrapper).toHaveClass("!pr-[20px]") + const aiAssistantIcon = container.querySelector("span") + expect(aiAssistantIcon).toBeInTheDocument() + expect(aiAssistantIcon).toHaveTextContent("AiAssistantIcon") + expect(container).toHaveTextContent( + mockAnswerWithQuestionThreadItem.content + ) + expect(container).toHaveTextContent("AiAssistantThreadItemActions") + }) + test("renders answer thread item without actions if answer has no question_id", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + expect(container).not.toHaveTextContent("AiAssistantThreadItemActions") + }) + test("renders error thread item", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const aiAssistantIcon = container.querySelector("span") + expect(aiAssistantIcon).toBeInTheDocument() + expect(aiAssistantIcon).toHaveTextContent("AiAssistantIcon") + const span = container.querySelector("span.text-medusa-fg-error") + expect(span).toBeInTheDocument() + expect(span).toHaveTextContent(mockErrorThreadItem.content) + }) +}) + +describe("loading", () => { + test("shows loading when answer has no question_id and no content", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + expect(container).toHaveTextContent("DotsLoading") + }) + test("hide loading when answer has question_id and no content", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + expect(container).not.toHaveTextContent("DotsLoading") + }) + test("hide loading when answer has content", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + expect(container).not.toHaveTextContent("DotsLoading") + }) + test("hide loading when error is not empty", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + expect(container).not.toHaveTextContent("DotsLoading") + }) +}) diff --git a/www/packages/docs-ui/src/components/AiAssistant/ThreadItem/index.tsx b/www/packages/docs-ui/src/components/AiAssistant/ThreadItem/index.tsx index 4397c18c72..00c0fc2a66 100644 --- a/www/packages/docs-ui/src/components/AiAssistant/ThreadItem/index.tsx +++ b/www/packages/docs-ui/src/components/AiAssistant/ThreadItem/index.tsx @@ -1,15 +1,12 @@ import clsx from "clsx" import React, { useMemo } from "react" -import { - AiAssistantIcon, - CodeMdx, - CodeMdxProps, - DotsLoading, - MarkdownContent, - MDXComponents, -} from "@/components" +import { AiAssistantIcon } from "../../Icons/AiAssistant" +import { CodeMdx, CodeMdxProps } from "../../CodeMdx" +import { DotsLoading } from "../../Loading/Dots" +import { MarkdownContent } from "../../MarkdownContent" +import { MDXComponents } from "../../MDXComponents" import { AiAssistantThreadItemActions } from "./Actions" -import { AiAssistantThreadItem as AiAssistantThreadItemType } from "../../../providers" +import { AiAssistantThreadItem as AiAssistantThreadItemType } from "../../../providers/AiAssistant" import { useChat } from "@kapaai/react-sdk" export type AiAssistantThreadItemProps = { diff --git a/www/packages/docs-ui/src/components/AiAssistant/TriggerButton/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/AiAssistant/TriggerButton/__tests__/index.test.tsx new file mode 100644 index 0000000000..5dfbf44b53 --- /dev/null +++ b/www/packages/docs-ui/src/components/AiAssistant/TriggerButton/__tests__/index.test.tsx @@ -0,0 +1,103 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { fireEvent, render } from "@testing-library/react" +import * as AiAssistantMocks from "../../__mocks__" +import { TooltipProps } from "../../../Tooltip" +import { ButtonProps } from "../../../Button" +import { KbdProps } from "../../../Kbd" + +// mock functions +const mockSetIsSearchOpen = vi.fn() +const mockGetOsShortcut = vi.fn(() => "Ctrl") +const defaultUseKeyboardShortcutReturn = { + metakey: true, + shortcutKeys: ["i"], + action: vi.fn(), + checkEditing: false, +} +const mockUseKeyboardShortcut = vi.fn(() => defaultUseKeyboardShortcutReturn) + +// Mock components and hooks +vi.mock("@/components/Button", () => ({ + Button: (props: ButtonProps) => @@ -90,6 +91,7 @@ export const ApiRunnerParamArrayInput = ({ ]) }} className="mt-0.5" + data-testid="plus-button" > diff --git a/www/packages/docs-ui/src/components/ApiRunner/ParamInputs/Default/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/ApiRunner/ParamInputs/Default/__tests__/index.test.tsx new file mode 100644 index 0000000000..e4b9c20c24 --- /dev/null +++ b/www/packages/docs-ui/src/components/ApiRunner/ParamInputs/Default/__tests__/index.test.tsx @@ -0,0 +1,250 @@ +import React from "react" +import { describe, expect, test, vi } from "vitest" +import { fireEvent, render } from "@testing-library/react" +import { ApiRunnerParamInputProps } from "../index" +import { InputTextProps } from "../../../../Input/Text" + +// mock data +const mockApiRunnerParamInput: ApiRunnerParamInputProps = { + paramName: "testName", + paramValue: "testValue", + objPath: "test", + setValue: vi.fn(), +} +const mockApiRunnerParamArrayInput: ApiRunnerParamInputProps = { + paramName: "testName", + paramValue: ["testValue"], + objPath: "", + setValue: vi.fn(), +} +const mockApiRunnerParamObjectInput: ApiRunnerParamInputProps = { + paramName: "", + paramValue: { test: "testValue" }, + objPath: "", + setValue: vi.fn(), +} + +// mock components +vi.mock("@/components/Input/Text", () => ({ + InputText: (props: InputTextProps) => , +})) + +const TestWrapper = (props: ApiRunnerParamInputProps) => { + const [value, setValue] = React.useState(props.paramValue) + return ( + + ) +} + +import { ApiRunnerParamInput } from "../index" + +describe("rendering", () => { + test("renders when param value is a string", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const input = container.querySelector("input") + expect(input).toBeInTheDocument() + expect(input).toHaveAttribute("name", "testName") + expect(input).toHaveValue("testValue") + }) + test("renders when param value is an array", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const input = container.querySelector("input") + expect(input).toBeInTheDocument() + expect(input).toHaveValue("testValue") + }) + test("renders when param value is an object", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const input = container.querySelector("input") + expect(input).toBeInTheDocument() + expect(input).toHaveValue("testValue") + }) +}) + +describe("interactions", () => { + test("typing input for string value should update param value", () => { + const { container } = render() + expect(container).toBeInTheDocument() + const input = container.querySelector("input") + expect(input).toBeInTheDocument() + expect(input).toHaveValue("testValue") + + fireEvent.change(input!, { target: { value: "testValue2" } }) + expect(input).toHaveValue("testValue2") + }) + + test("typing input for array item should update array value", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const input = container.querySelector("input") + expect(input).toBeInTheDocument() + expect(input).toHaveValue("testValue") + + fireEvent.change(input!, { target: { value: "testValue2" } }) + expect(input).toHaveValue("testValue2") + }) + + test("typing input for object property should update object value", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const input = container.querySelector("input") + expect(input).toBeInTheDocument() + expect(input).toHaveValue("testValue") + + fireEvent.change(input!, { target: { value: "testValue2" } }) + expect(input).toHaveValue("testValue2") + }) +}) + +describe("object path handling", () => { + test("should update nested object property with objPath", () => { + const { container } = render( + + ) + // Find the input for the "child" property (nested under "parent") + const inputs = container.querySelectorAll("input") + const input = Array.from(inputs).find( + (input) => input.getAttribute("name") === "child" + ) + expect(input).toBeInTheDocument() + expect(input).toHaveValue("initialValue") + + fireEvent.change(input!, { target: { value: "updatedValue" } }) + expect(input).toHaveValue("updatedValue") + }) + + test("should update deeply nested object property", () => { + const { container } = render( + + ) + // Find the input for the "level3" property (deeply nested) + const inputs = container.querySelectorAll("input") + const input = Array.from(inputs).find( + (input) => input.getAttribute("name") === "level3" + ) + expect(input).toBeInTheDocument() + expect(input).toHaveValue("deepValue") + + fireEvent.change(input!, { target: { value: "newDeepValue" } }) + expect(input).toHaveValue("newDeepValue") + }) + + test("should update object property within array", () => { + const { container } = render( + + ) + // Find the input for the "value" property within the object in the array + const inputs = container.querySelectorAll("input") + expect(inputs.length).toBeGreaterThan(0) + const valueInput = Array.from(inputs).find( + (input) => input.getAttribute("name") === "value" + ) + expect(valueInput).toBeInTheDocument() + expect(valueInput).toHaveValue("value1") + + fireEvent.change(valueInput!, { target: { value: "updatedValue1" } }) + expect(valueInput).toHaveValue("updatedValue1") + }) + + test("should update array item with objPath", () => { + const { container } = render( + + ) + // Find the input for the first array item (paramName will be "[0]") + const inputs = container.querySelectorAll("input") + expect(inputs.length).toBeGreaterThan(0) + // The first input should be for the first array item + const firstInput = inputs[0] + expect(firstInput).toHaveValue("item1") + + fireEvent.change(firstInput, { target: { value: "updatedItem1" } }) + expect(firstInput).toHaveValue("updatedItem1") + }) + + test("should update nested object property within array", () => { + const { container } = render( + + ) + // Find the input for the name property of the first object in the array + const inputs = container.querySelectorAll("input") + expect(inputs.length).toBeGreaterThan(0) + // The name input should be one of the inputs + const nameInput = Array.from(inputs).find( + (input) => input.getAttribute("name") === "name" + ) + expect(nameInput).toBeInTheDocument() + expect(nameInput).toHaveValue("first") + + fireEvent.change(nameInput!, { target: { value: "updatedFirst" } }) + expect(nameInput).toHaveValue("updatedFirst") + }) + + test("should handle empty objPath correctly", () => { + const { container } = render( + + ) + const input = container.querySelector("input") + expect(input).toBeInTheDocument() + expect(input).toHaveValue("rootValue") + + fireEvent.change(input!, { target: { value: "newRootValue" } }) + expect(input).toHaveValue("newRootValue") + }) +}) diff --git a/www/packages/docs-ui/src/components/ApiRunner/ParamInputs/Default/index.tsx b/www/packages/docs-ui/src/components/ApiRunner/ParamInputs/Default/index.tsx index 24f869a1af..e134871407 100644 --- a/www/packages/docs-ui/src/components/ApiRunner/ParamInputs/Default/index.tsx +++ b/www/packages/docs-ui/src/components/ApiRunner/ParamInputs/Default/index.tsx @@ -1,5 +1,5 @@ import React from "react" -import { InputText } from "../../../.." +import { InputText } from "../../../../components/Input/Text" import setObjValue from "../../../../utils/set-obj-value" import { ApiRunnerParamObjectInput } from "../Object" import { ApiRunnerParamArrayInput } from "../Array" diff --git a/www/packages/docs-ui/src/components/ApiRunner/ParamInputs/Object/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/ApiRunner/ParamInputs/Object/__tests__/index.test.tsx new file mode 100644 index 0000000000..53854602f4 --- /dev/null +++ b/www/packages/docs-ui/src/components/ApiRunner/ParamInputs/Object/__tests__/index.test.tsx @@ -0,0 +1,88 @@ +import React from "react" +import { describe, expect, test, vi } from "vitest" +import { render } from "@testing-library/react" +import { ApiRunnerParamInputProps } from "../../Default" + +// mock data +const mockApiRunnerParamObjectInput: ApiRunnerParamInputProps = { + paramName: "", + paramValue: { test: "testValue" }, + objPath: "", + setValue: vi.fn(), +} +const mockApiRunnerParamObjectInput2: ApiRunnerParamInputProps = { + paramName: "", + paramValue: { test: "testValue", test2: "testValue2" }, + objPath: "", + setValue: vi.fn(), +} +const mockApiRunnerNotObjectInput: ApiRunnerParamInputProps = { + paramName: "testName", + paramValue: "testValue", + objPath: "", + setValue: vi.fn(), +} + +// mock components +vi.mock("@/components/ApiRunner/ParamInputs/Default", () => ({ + ApiRunnerParamInput: ({ + paramName, + paramValue, + objPath, + }: ApiRunnerParamInputProps) => ( +
+ ApiRunnerParamInput + {paramName} + {paramValue as string} + {objPath} +
+ ), +})) +import { ApiRunnerParamObjectInput } from "../../Object" + +describe("rendering", () => { + test("renders when param value is an object", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const fieldset = container.querySelector("fieldset") + expect(fieldset).toBeInTheDocument() + const apiRunnerParamInputs = fieldset?.querySelectorAll( + ".api-runner-param-input" + ) + expect(apiRunnerParamInputs).toHaveLength(1) + expect(apiRunnerParamInputs?.[0]).toHaveTextContent("ApiRunnerParamInput") + expect(apiRunnerParamInputs?.[0]).toHaveTextContent("test") + expect(apiRunnerParamInputs?.[0]).toHaveTextContent("testValue") + }) + + test("renders when param value is an object with multiple properties", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const fieldset = container.querySelector("fieldset") + expect(fieldset).toBeInTheDocument() + const apiRunnerParamInputs = fieldset?.querySelectorAll( + ".api-runner-param-input" + ) + expect(apiRunnerParamInputs).toHaveLength(2) + expect(apiRunnerParamInputs?.[0]).toHaveTextContent("ApiRunnerParamInput") + expect(apiRunnerParamInputs?.[0]).toHaveTextContent("test") + expect(apiRunnerParamInputs?.[0]).toHaveTextContent("testValue") + expect(apiRunnerParamInputs?.[1]).toHaveTextContent("ApiRunnerParamInput") + expect(apiRunnerParamInputs?.[1]).toHaveTextContent("test2") + expect(apiRunnerParamInputs?.[1]).toHaveTextContent("testValue2") + }) + + test("renders when param value is not an object", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + expect(container).toHaveTextContent("ApiRunnerParamInput") + expect(container).toHaveTextContent("testName") + expect(container).toHaveTextContent("testValue") + }) +}) diff --git a/www/packages/docs-ui/src/components/ApiRunner/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/ApiRunner/__tests__/index.test.tsx new file mode 100644 index 0000000000..49381f85e2 --- /dev/null +++ b/www/packages/docs-ui/src/components/ApiRunner/__tests__/index.test.tsx @@ -0,0 +1,233 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { fireEvent, render, waitFor } from "@testing-library/react" +import { CodeBlockProps } from "../../CodeBlock" +import { ButtonProps } from "../../Button" + +// mock data +const mockApiRunnerSimpleGet = { + apiMethod: "GET" as ApiMethod, + apiUrl: "https://api.example.com", +} +const mockApiRunnerGet = { + apiMethod: "GET" as ApiMethod, + apiUrl: "https://api.example.com", + pathData: { + id: "123", + }, + queryData: { + limit: 10, + }, +} +const mockApiRunnerPost = { + apiMethod: "POST" as ApiMethod, + apiUrl: "https://api.example.com", + bodyData: { + name: "John Doe", + }, +} +const mockApiRunnerDelete = { + apiMethod: "DELETE" as ApiMethod, + apiUrl: "https://api.example.com", + pathData: { + id: "123", + }, +} + +// mock functions +let capturedOnFinish: ((message: string, statusCode: string) => void) | null = + null + +// mock components and hooks +vi.mock("@/components/CodeBlock", () => ({ + CodeBlock: ({ badgeColor, badgeLabel, ...props }: CodeBlockProps) => ( +
+
+    
+ ), +})) +vi.mock("@/components/Button", () => ({ + Button: (props: ButtonProps) => ) + expect(container).toBeInTheDocument() + const button = container.querySelector("button") + expect(button).toBeInTheDocument() + expect(button).toHaveClass("bg-medusa-button-inverted") + expect(button).toHaveClass("hover:bg-medusa-button-inverted-hover") + expect(button).toHaveClass("active:bg-medusa-button-inverted-pressed") + expect(button).toHaveClass("focus:bg-medusa-button-inverted") + expect(button).toHaveClass("shadow-button-inverted") + expect(button).toHaveClass("dark:shadow-button-inverted-dark") + expect(button).toHaveClass("dark:focus:shadow-button-inverted-focused-dark") + expect(button).toHaveClass("disabled:bg-medusa-bg-disabled") + expect(button).toHaveClass("disabled:shadow-button-neutral") + expect(button).toHaveClass("disabled:shadow-button-neutral") + }) + + test("renders secondary button", () => { + const { container } = render() + expect(container).toBeInTheDocument() + const button = container.querySelector("button") + expect(button).toBeInTheDocument() + expect(button).toHaveClass("bg-medusa-button-neutral") + expect(button).toHaveClass("hover:bg-medusa-button-neutral-hover") + expect(button).toHaveClass("active:bg-medusa-button-neutral-pressed") + expect(button).toHaveClass("focus:bg-medusa-button-neutral") + expect(button).toHaveClass("shadow-button-neutral") + expect(button).toHaveClass("dark:shadow-button-neutral") + }) + + test("renders transparent button", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const button = container.querySelector("button") + expect(button).toBeInTheDocument() + expect(button).toHaveClass("bg-transparent") + expect(button).toHaveClass("shadow-none") + expect(button).toHaveClass("border-0") + expect(button).toHaveClass("outline-none") + }) + + test("renders transparent clear button", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const button = container.querySelector("button") + expect(button).toBeInTheDocument() + expect(button).toHaveClass("bg-transparent") + expect(button).toHaveClass("shadow-none") + expect(button).toHaveClass("border-0") + expect(button).toHaveClass("outline-none") + }) + + test("renders icon button", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const button = container.querySelector("button") + expect(button).toBeInTheDocument() + expect(button).toHaveClass("!px-docs_0.25") + }) + + test("renders button with custom className", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const button = container.querySelector("button") + expect(button).toBeInTheDocument() + expect(button).toHaveClass("custom-class") + }) + + test("renders button with custom buttonRef", () => { + const buttonRef = vi.fn() + render() + expect(buttonRef).toHaveBeenCalled() + }) +}) 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 new file mode 100644 index 0000000000..13226e67c1 --- /dev/null +++ b/www/packages/docs-ui/src/components/Card/Layout/Default/__tests__/index.test.tsx @@ -0,0 +1,215 @@ +import React from "react" +import { describe, expect, test, vi } from "vitest" +import { render } from "@testing-library/react" +import { CardDefaultLayout } from "../index" +import { IconProps } from "@medusajs/icons/dist/types" +import { LinkProps } from "../../../../Link" +import { BorderedIconProps } from "../../../../BorderedIcon" +import { BadgeProps } from "../../../../Badge" + +// mock components +vi.mock("@/components/BorderedIcon", () => ({ + BorderedIcon: (props: BorderedIconProps) => ( +
+ BorderedIcon {props.icon} {props.IconComponent && } +
+ ), +})) +vi.mock("@/components/Link", () => ({ + Link: (props: LinkProps) =>
, +})) +vi.mock("@/components/Badge", () => ({ + Badge: (props: BadgeProps) => ( +
+ Badge {props.variant} - {props.children} +
+ ), +})) + +describe("rendering", () => { + test("renders card default layout", () => { + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const cardContent = container.querySelector("div") + expect(cardContent).toBeInTheDocument() + expect(cardContent).toHaveClass("flex") + expect(cardContent).toHaveTextContent("Click me") + }) + test("renders card default layout with icon", () => { + const icon = (props: IconProps) =>
Icon
+ const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const borderedIcon = container.querySelector("[data-testid='icon']") + expect(borderedIcon).toBeInTheDocument() + expect(borderedIcon).toHaveTextContent("Icon") + }) + test("renders card default layout with image", () => { + const image = "https://example.com/image.png" + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const borderedIcon = container.querySelector("div") + expect(borderedIcon).toBeInTheDocument() + expect(borderedIcon).toHaveTextContent(image) + }) + test("renders card default layout with title", () => { + const title = "Title" + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const titleElement = container.querySelector("[data-testid='title']") + expect(titleElement).toBeInTheDocument() + expect(titleElement).toHaveTextContent(title) + }) + test("renders card default layout with text", () => { + const text = "Text" + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const textElement = container.querySelector("[data-testid='text']") + expect(textElement).toBeInTheDocument() + expect(textElement).toHaveTextContent(text) + }) + test("renders card default layout with badge", () => { + const badge = { variant: "blue", children: "Badge" } as BadgeProps + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const badgeElement = container.querySelector("div") + expect(badgeElement).toBeInTheDocument() + expect(badgeElement).toHaveTextContent("Badge blue - Badge") + }) + test("renders card default layout with right icon", () => { + const rightIcon = () =>
RightIcon
+ const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const rightIconElement = container.querySelector( + "[data-testid='right-icon']" + ) + expect(rightIconElement).toBeInTheDocument() + expect(rightIconElement).toHaveTextContent("RightIcon") + }) + test("renders card default layout with external href", () => { + const href = "https://example.com" + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const linkElement = container.querySelector("a") + expect(linkElement).toBeInTheDocument() + expect(linkElement).toHaveAttribute("href", href) + const arrowUpRightOnBoxElement = container.querySelector( + "[data-testid='external-icon']" + ) + expect(arrowUpRightOnBoxElement).toBeInTheDocument() + const internalIconElement = container.querySelector( + "[data-testid='internal-icon']" + ) + expect(internalIconElement).not.toBeInTheDocument() + }) + test("renders card default with internal link", () => { + const href = "/example" + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const linkElement = container.querySelector("a") + expect(linkElement).toBeInTheDocument() + expect(linkElement).toHaveAttribute("href", href) + const internalIconElement = container.querySelector( + "[data-testid='internal-icon']" + ) + expect(internalIconElement).toBeInTheDocument() + const arrowUpRightOnBoxElement = container.querySelector( + "[data-testid='external-icon']" + ) + expect(arrowUpRightOnBoxElement).not.toBeInTheDocument() + }) +}) + +describe("highlight text", () => { + test("highlight text not provided", () => { + const { container } = render( + + Click me + + ) + expect(container).toBeInTheDocument() + const highlightTextElement = container.querySelector( + "[data-testid='highlight-text']" + ) + expect(highlightTextElement).not.toBeInTheDocument() + }) + test("highlight text in title", () => { + const { container } = render( + + Click me + + ) + expect(container).toBeInTheDocument() + const highlightTextElement = container.querySelector( + "[data-testid='highlight-text']" + ) + expect(highlightTextElement).toBeInTheDocument() + expect(highlightTextElement).toHaveTextContent("highlight") + }) + test("highlight text not in title", () => { + const { container } = render( + + Click me + + ) + expect(container).toBeInTheDocument() + const highlightTextElement = container.querySelector( + "[data-testid='highlight-text']" + ) + expect(highlightTextElement).not.toBeInTheDocument() + }) + test("highlight text in text", () => { + const { container } = render( + + Click me + + ) + expect(container).toBeInTheDocument() + const highlightTextElement = container.querySelector( + "[data-testid='highlight-text']" + ) + expect(highlightTextElement).toBeInTheDocument() + expect(highlightTextElement).toHaveTextContent("highlight") + }) + test("highlight text not in text", () => { + const { container } = render( + + Click me + + ) + expect(container).toBeInTheDocument() + const highlightTextElement = container.querySelector( + "[data-testid='highlight-text']" + ) + expect(highlightTextElement).not.toBeInTheDocument() + }) +}) 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 21303adf19..147f0d6e79 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 @@ -1,6 +1,8 @@ import React from "react" import clsx from "clsx" -import { Badge, BorderedIcon, Link } from "@/components" +import { Badge } from "@/components/Badge" +import { BorderedIcon } from "@/components/BorderedIcon" +import { Link } from "@/components/Link" import { ArrowUpRightOnBox, TriangleRightMini } from "@medusajs/icons" import { CardProps } from "../../.." import { useIsExternalLink } from "../../../.." @@ -37,6 +39,7 @@ export const CardDefaultLayout = ({ {part} @@ -77,12 +80,18 @@ export const CardDefaultLayout = ({ className={clsx("flex flex-col flex-1 overflow-auto", contentClassName)} > {title && ( -
+
{getHighlightedText(title)}
)} {text && ( - + {getHighlightedText(text)} )} @@ -91,8 +100,12 @@ export const CardDefaultLayout = ({ {badge && } {RightIconComponent && } - {!RightIconComponent && isExternal && } - {!RightIconComponent && !isExternal && } + {!RightIconComponent && isExternal && ( + + )} + {!RightIconComponent && !isExternal && ( + + )} {href && ( 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 new file mode 100644 index 0000000000..1fa4afe2b2 --- /dev/null +++ b/www/packages/docs-ui/src/components/Card/Layout/Large/__tests__/index.test.tsx @@ -0,0 +1,81 @@ +import React from "react" +import { describe, expect, test, vi } from "vitest" +import { render } from "@testing-library/react" +import { CardLargeLayout } from "../index" + +describe("rendering", () => { + test("renders card large layout with title", () => { + const title = "Title" + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const titleElement = container.querySelector("[data-testid='title']") + expect(titleElement).toBeInTheDocument() + expect(titleElement).toHaveTextContent(title) + }) + test("renders card large layout with text", () => { + const text = "Text" + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const textElement = container.querySelector("[data-testid='text']") + expect(textElement).toBeInTheDocument() + expect(textElement).toHaveTextContent(text) + }) + test("renders card large layout with external href", () => { + const href = "https://example.com" + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const linkElement = container.querySelector("a") + expect(linkElement).toBeInTheDocument() + expect(linkElement).toHaveAttribute("href", href) + const arrowUpRightOnBoxElement = container.querySelector( + "[data-testid='external-icon']" + ) + expect(arrowUpRightOnBoxElement).toBeInTheDocument() + const internalIconElement = container.querySelector( + "[data-testid='internal-icon']" + ) + expect(internalIconElement).not.toBeInTheDocument() + }) + test("renders card large layout with internal href", () => { + const href = "/example" + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const linkElement = container.querySelector("a") + expect(linkElement).toBeInTheDocument() + expect(linkElement).toHaveAttribute("href", href) + const internalIconElement = container.querySelector( + "[data-testid='internal-icon']" + ) + expect(internalIconElement).toBeInTheDocument() + const arrowUpRightOnBoxElement = container.querySelector( + "[data-testid='external-icon']" + ) + expect(arrowUpRightOnBoxElement).not.toBeInTheDocument() + }) + test("renders card large layout with icon", () => { + const icon = () =>
Icon
+ const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const iconElement = container.querySelector("[data-testid='icon']") + expect(iconElement).toBeInTheDocument() + }) + test("renders card large layout with image", () => { + const image = "https://example.com/image.png" + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const imageElement = container.querySelector("img") + expect(imageElement).toBeInTheDocument() + }) +}) 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 eb0b3ef8d8..d935fd51ed 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 @@ -1,6 +1,6 @@ import React from "react" -import { CardProps } from "../.." -import { useIsExternalLink } from "../../../.." +import { CardProps } from "@/components/Card" +import { useIsExternalLink } from "@/hooks/use-is-external-link" import clsx from "clsx" import { ArrowUpRightOnBox, TriangleRightMini } from "@medusajs/icons" import Link from "next/link" @@ -47,14 +47,28 @@ export const CardLargeLayout = ({
- {title && {title}} - {href && isExternal && } + {title && ( + + {title} + + )} + {href && isExternal && ( + + )} {href && !isExternal && ( - + )}
{text && ( - {text} + + {text} + )}
{href && ( 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 new file mode 100644 index 0000000000..878dd0f1c3 --- /dev/null +++ b/www/packages/docs-ui/src/components/Card/Layout/Mini/__tests__/index.test.tsx @@ -0,0 +1,204 @@ +import React from "react" +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 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAACV0lEQVR4nO2Wz2/SURjH/72769q9u9N7757e5f3oYsQgL2p4EUNFURFFEUVxU1ExYiD6gMREjEFE4qJiEBFRERERETGQmD/O/9/H/T8O93rXm+c5zzkzZ855zpnB3Tt37gQAAAAAAABg6U8AADwA4wEAAAAASUVORK5CYII=" + +// mock components +vi.mock("@/components/BorderedIcon", () => ({ + BorderedIcon: (props: BorderedIconProps) => ( +
+ BorderedIcon {props.icon} {props.IconComponent && } +
+ ), +})) +vi.mock("@/components/Link", () => ({ + Link: (props: LinkProps) =>
, +})) +vi.mock("@/components/ThemeImage", () => ({ + ThemeImage: (props: ThemeImageProps) => ( +
+ ThemeImage {props.light} {props.dark} {props.className} +
+ ), +})) + +import { CardLayoutMini } from "../../Mini" +import { ThemeImageProps } from "../../../../ThemeImage" + +describe("rendering", () => { + test("renders card mini layout with title", () => { + const title = "Title" + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const titleElement = container.querySelector("[data-testid='title']") + expect(titleElement).toBeInTheDocument() + expect(titleElement).toHaveTextContent(title) + }) + test("renders card mini layout with text", () => { + const text = "Text" + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const textElement = container.querySelector("[data-testid='text']") + expect(textElement).toBeInTheDocument() + expect(textElement).toHaveTextContent(text) + }) + test("renders card mini layout with external href", () => { + const href = "https://example.com" + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const linkElement = container.querySelector("a") + expect(linkElement).toBeInTheDocument() + expect(linkElement).toHaveAttribute("href", href) + }) + test("renders card mini layout with internal href", () => { + const href = "/example" + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const linkElement = container.querySelector("a") + expect(linkElement).toBeInTheDocument() + expect(linkElement).toHaveAttribute("href", href) + }) + test("renders card mini layout with icon", () => { + const icon = () =>
Icon
+ const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const iconElement = container.querySelector("[data-testid='icon']") + expect(iconElement).toBeInTheDocument() + }) + test("renders card mini layout with image", () => { + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const imageElement = container.querySelector("img") + expect(imageElement).toBeInTheDocument() + expect(imageElement).toHaveAttribute("src", exampleDataImageUrl) + }) + test("renders card mini layout with theme image", () => { + const themeImage = { + light: exampleDataImageUrl, + dark: exampleDataImageUrl, + } + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const themeImageElement = container.querySelector( + "[data-testid='theme-image']" + ) + expect(themeImageElement).toBeInTheDocument() + expect(themeImageElement).toHaveTextContent( + "ThemeImage " + exampleDataImageUrl + " " + exampleDataImageUrl + ) + }) + test("renders card mini layout with closeable", () => { + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const closeButtonElement = container.querySelector( + "[data-testid='close-button']" + ) + expect(closeButtonElement).toBeInTheDocument() + const closeIconElement = container.querySelector( + "[data-testid='close-icon']" + ) + expect(closeIconElement).toBeInTheDocument() + }) + test("renders card mini layout with onClose", () => { + const onClose = vi.fn() + const { container } = render( + + Click me + + ) + expect(container).toBeInTheDocument() + const closeButtonElement = container.querySelector( + "[data-testid='close-button']" + ) + expect(closeButtonElement).toBeInTheDocument() + fireEvent.click(closeButtonElement!) + expect(onClose).toHaveBeenCalled() + }) + test("renders card mini layout with className", () => { + const className = "test-class" + const { container } = render( + Click me + ) + expect(container).toBeInTheDocument() + const cardElement = container.querySelector("div") + expect(cardElement).toBeInTheDocument() + expect(cardElement).toHaveClass(className) + }) + test("renders card mini layout with imageDimensions", () => { + const imageDimensions = { width: 100, height: 100 } + const { container } = render( + + Click me + + ) + expect(container).toBeInTheDocument() + const imageElement = container.querySelector("img") + expect(imageElement).toBeInTheDocument() + expect(imageElement).toHaveAttribute( + "width", + imageDimensions.width.toString() + ) + expect(imageElement).toHaveAttribute( + "height", + imageDimensions.height.toString() + ) + expect(imageElement).toHaveStyle({ + width: `${imageDimensions.width}px`, + height: `${imageDimensions.height}px`, + }) + }) + test("renders card mini layout with iconClassName and image", () => { + const iconClassName = "test-class" + const { container } = render( + + Click me + + ) + expect(container).toBeInTheDocument() + const imageElement = container.querySelector("img") + expect(imageElement).toBeInTheDocument() + expect(imageElement).toHaveClass(iconClassName) + }) + test("renders card mini layout with iconClassName and theme image", () => { + const iconClassName = "test-class" + const themeImage = { + light: exampleDataImageUrl, + dark: exampleDataImageUrl, + } + const { container } = render( + + Click me + + ) + expect(container).toBeInTheDocument() + const themeImageElement = container.querySelector( + "[data-testid='theme-image']" + ) + expect(themeImageElement).toBeInTheDocument() + expect(themeImageElement).toHaveClass(iconClassName) + }) +}) 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 92305b2c9c..8f64366279 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 @@ -2,13 +2,11 @@ import React from "react" import clsx from "clsx" -import { CardProps } from "../.." -import { - BorderedIcon, - Button, - ThemeImage, - useIsExternalLink, -} from "../../../.." +import { CardProps } from "@/components/Card" +import { BorderedIcon } from "@/components/BorderedIcon" +import { Button } from "@/components/Button" +import { ThemeImage } from "@/components/ThemeImage" +import { useIsExternalLink } from "@/hooks/use-is-external-link" import Link from "next/link" import Image from "next/image" import { ArrowUpRightOnBox, TriangleRightMini, XMark } from "@medusajs/icons" @@ -91,19 +89,29 @@ export const CardLayoutMini = ({ )}
{title && ( - + {title} )} {text && ( - + {text} )}
{!closeable && ( - {isExternal ? : } + {isExternal ? ( + + ) : ( + + )} )} {href && ( @@ -119,8 +127,9 @@ export const CardLayoutMini = ({ variant="transparent-clear" onClick={onClose} className="!p-[2.5px] z-[2] hover:!bg-medusa-button-transparent-hover focus:!shadow-none focus:!bg-transparent" + data-testid="close-button" > - + )}
diff --git a/www/packages/docs-ui/src/components/Card/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Card/__tests__/index.test.tsx new file mode 100644 index 0000000000..0d4c6ea160 --- /dev/null +++ b/www/packages/docs-ui/src/components/Card/__tests__/index.test.tsx @@ -0,0 +1,53 @@ +import React from "react" +import { describe, expect, test, vi } from "vitest" +import { render } from "@testing-library/react" + +// mock components +vi.mock("@/components/Badge", () => ({ + Badge: () =>
Badge
, +})) +vi.mock("@/components/Card/Layout/Default", () => ({ + CardDefaultLayout: () =>
CardDefaultLayout
, +})) +vi.mock("@/components/Card/Layout/Large", () => ({ + CardLargeLayout: () =>
CardLargeLayout
, +})) +vi.mock("@/components/Card/Layout/Filler", () => ({ + CardFillerLayout: () =>
CardFillerLayout
, +})) +vi.mock("@/components/Card/Layout/Mini", () => ({ + CardLayoutMini: () =>
CardLayoutMini
, +})) + +import { Card } from "../../Card" + +describe("rendering", () => { + test("renders default card", () => { + const { container } = render(Click me) + expect(container).toBeInTheDocument() + const card = container.querySelector("div") + expect(card).toBeInTheDocument() + expect(card).toHaveTextContent("CardDefaultLayout") + }) + test("renders large card", () => { + const { container } = render(Click me) + expect(container).toBeInTheDocument() + const card = container.querySelector("div") + expect(card).toBeInTheDocument() + expect(card).toHaveTextContent("CardLargeLayout") + }) + test("renders filler card", () => { + const { container } = render(Click me) + expect(container).toBeInTheDocument() + const card = container.querySelector("div") + expect(card).toBeInTheDocument() + expect(card).toHaveTextContent("CardFillerLayout") + }) + test("renders mini card", () => { + const { container } = render(Click me) + expect(container).toBeInTheDocument() + const card = container.querySelector("div") + expect(card).toBeInTheDocument() + expect(card).toHaveTextContent("CardLayoutMini") + }) +}) diff --git a/www/packages/docs-ui/src/components/Card/index.tsx b/www/packages/docs-ui/src/components/Card/index.tsx index eee365a9d2..791c322ec3 100644 --- a/www/packages/docs-ui/src/components/Card/index.tsx +++ b/www/packages/docs-ui/src/components/Card/index.tsx @@ -1,5 +1,5 @@ import React from "react" -import { BadgeProps } from "@/components" +import { BadgeProps } from "@/components/Badge" import { CardDefaultLayout } from "./Layout/Default" import { IconProps } from "@medusajs/icons/dist/types" import { CardLargeLayout } from "./Layout/Large" diff --git a/www/packages/docs-ui/src/components/CardList/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/CardList/__tests__/index.test.tsx new file mode 100644 index 0000000000..4a91fae584 --- /dev/null +++ b/www/packages/docs-ui/src/components/CardList/__tests__/index.test.tsx @@ -0,0 +1,117 @@ +import React from "react" +import { describe, expect, test, vi } from "vitest" +import { render } from "@testing-library/react" +import { CardProps } from "../../Card" + +// mock components +vi.mock("@/components/Card", () => ({ + Card: () =>
Card
, +})) + +import { CardList } from "../../CardList" + +describe("rendering", () => { + test("render card list with one item", () => { + const items: CardProps[] = [{ title: "Item 1" }] + const { container } = render() + expect(container).toBeInTheDocument() + const section = container.querySelector("section") + expect(section).toBeInTheDocument() + expect(section).toHaveClass("grid-cols-1") + const cards = container.querySelectorAll("[data-testid='card']") + expect(cards).toHaveLength(items.length) + }) + test("render card list even number of items", () => { + const items: CardProps[] = [{ title: "Item 1" }, { title: "Item 2" }] + const { container } = render() + expect(container).toBeInTheDocument() + const section = container.querySelector("section") + expect(section).toBeInTheDocument() + expect(section).toHaveClass("md:grid-cols-2 grid-cols-1") + const cards = container.querySelectorAll("[data-testid='card']") + expect(cards).toHaveLength(items.length) + }) + test("render card list odd number of items", () => { + const items: CardProps[] = [ + { title: "Item 1" }, + { title: "Item 2" }, + { title: "Item 3" }, + ] + const { container } = render() + expect(container).toBeInTheDocument() + const section = container.querySelector("section") + expect(section).toBeInTheDocument() + expect(section).toHaveClass("lg:grid-cols-3 md:grid-col-2 grid-cols-1") + const cards = container.querySelectorAll("[data-testid='card']") + expect(cards).toHaveLength(items.length) + }) + test("render card list with default items per row", () => { + const items: CardProps[] = [ + { title: "Item 1" }, + { title: "Item 2" }, + { title: "Item 3" }, + ] + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const section = container.querySelector("section") + expect(section).toBeInTheDocument() + expect(section).toHaveClass("md:grid-cols-2 grid-cols-1") + const cards = container.querySelectorAll("[data-testid='card']") + expect(cards).toHaveLength(items.length) + }) + test("render card list with items per row", () => { + const items: CardProps[] = [ + { title: "Item 1" }, + { title: "Item 2" }, + { title: "Item 3" }, + ] + const { container } = render() + expect(container).toBeInTheDocument() + const section = container.querySelector("section") + expect(section).toBeInTheDocument() + expect(section).toHaveClass("md:grid-cols-2 grid-cols-1") + const cards = container.querySelectorAll("[data-testid='card']") + expect(cards).toHaveLength(items.length) + }) + test("render card list with one item and default items per row", () => { + const items: CardProps[] = [{ title: "Item 1" }] + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const section = container.querySelector("section") + expect(section).toBeInTheDocument() + expect(section).toHaveClass("grid-cols-1") + const cards = container.querySelectorAll("[data-testid='card']") + expect(cards).toHaveLength(items.length) + }) + test("render card list with items per row, ignoring default items per row", () => { + const items: CardProps[] = [ + { title: "Item 1" }, + { title: "Item 2" }, + { title: "Item 3" }, + ] + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const section = container.querySelector("section") + expect(section).toBeInTheDocument() + expect(section).toHaveClass("md:grid-cols-2 grid-cols-1") + const cards = container.querySelectorAll("[data-testid='card']") + expect(cards).toHaveLength(items.length) + }) + test("render card list with className", () => { + const className = "test-class" + const items: CardProps[] = [{ title: "Item 1" }] + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const section = container.querySelector("section") + expect(section).toBeInTheDocument() + expect(section).toHaveClass(className) + }) +}) diff --git a/www/packages/docs-ui/src/components/CardList/index.tsx b/www/packages/docs-ui/src/components/CardList/index.tsx index ce85565149..db35da0f54 100644 --- a/www/packages/docs-ui/src/components/CardList/index.tsx +++ b/www/packages/docs-ui/src/components/CardList/index.tsx @@ -1,5 +1,5 @@ import React from "react" -import { Card, CardProps } from "@/components" +import { Card, CardProps } from "@/components/Card" import clsx from "clsx" type CardListProps = { diff --git a/www/packages/docs-ui/src/components/ChildDocs/index.tsx b/www/packages/docs-ui/src/components/ChildDocs/index.tsx index 724c3748ed..8e44e78414 100644 --- a/www/packages/docs-ui/src/components/ChildDocs/index.tsx +++ b/www/packages/docs-ui/src/components/ChildDocs/index.tsx @@ -1,7 +1,7 @@ "use client" import React from "react" -import { useChildDocs, UseChildDocsProps } from "../.." +import { useChildDocs, UseChildDocsProps } from "@/hooks/use-child-docs" export const ChildDocs = (props: UseChildDocsProps) => { const { component } = useChildDocs(props) diff --git a/www/packages/docs-ui/src/components/CodeBlock/Actions/AskAi/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/CodeBlock/Actions/AskAi/__tests__/index.test.tsx new file mode 100644 index 0000000000..dc8300b62c --- /dev/null +++ b/www/packages/docs-ui/src/components/CodeBlock/Actions/AskAi/__tests__/index.test.tsx @@ -0,0 +1,121 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { fireEvent, render } from "@testing-library/react" +import * as AiAssistantMocks from "../../../../AiAssistant/__mocks__" + +// mock components +vi.mock("@/providers/AiAssistant", () => ({ + useAiAssistant: () => AiAssistantMocks.mockUseAiAssistant(), +})) +vi.mock("@/providers/SiteConfig", () => ({ + useSiteConfig: () => ({ + config: { + basePath: "http://example.com", + }, + }), +})) +vi.mock("@kapaai/react-sdk", () => ({ + useChat: () => AiAssistantMocks.mockUseChat(), +})) +vi.mock("@/components/Tooltip", () => ({ + Tooltip: ({ + children, + innerClassName, + }: { + children: React.ReactNode + innerClassName: string + }) => ( +
+ {children} - {innerClassName} +
+ ), +})) + +import { CodeBlockAskAiAction } from "../../AskAi" + +beforeEach(() => { + AiAssistantMocks.mockSetChatOpened.mockClear() + AiAssistantMocks.mockUseAiAssistant.mockReturnValue( + AiAssistantMocks.defaultUseAiAssistantReturn + ) + AiAssistantMocks.mockSubmitQuery.mockClear() + AiAssistantMocks.mockUseChat.mockReturnValue( + AiAssistantMocks.defaultUseChatReturn + ) +}) + +describe("rendering", () => { + test("render code block ask ai action in header", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const tooltip = container.querySelector("[data-testid='tooltip']") + expect(tooltip).toBeInTheDocument() + expect(tooltip).toHaveTextContent("flex") + const span = tooltip?.querySelector("span") + expect(span).toBeInTheDocument() + expect(span).toHaveClass("p-[4.5px]") + expect(span).toHaveClass("cursor-pointer") + const image = span?.querySelector("img") + expect(image).toBeInTheDocument() + expect(image).toHaveAttribute("width", "15") + expect(image).toHaveAttribute("height", "15") + expect(image).toHaveAttribute("alt", "Ask AI") + }) + + test("render code block ask ai action not in header", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const tooltip = container.querySelector("[data-testid='tooltip']") + expect(tooltip).toBeInTheDocument() + const span = tooltip?.querySelector("span") + expect(span).toBeInTheDocument() + expect(span).toHaveClass("p-[6px]") + }) +}) + +describe("interactions", () => { + test("click code block ask ai action", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const span = container.querySelector("span") + expect(span).toBeInTheDocument() + fireEvent.click(span!) + expect(AiAssistantMocks.mockSetChatOpened).toHaveBeenCalledWith(true) + expect(AiAssistantMocks.mockSubmitQuery).toHaveBeenCalledWith( + "```tsx\nconsole.log('Hello, world!');\n```\n\nExplain the code above" + ) + }) + test("click code block ask ai action when loading", () => { + AiAssistantMocks.mockUseAiAssistant.mockReturnValue({ + ...AiAssistantMocks.defaultUseAiAssistantReturn, + loading: true, + }) + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const span = container.querySelector("span") + expect(span).toBeInTheDocument() + fireEvent.click(span!) + expect(AiAssistantMocks.mockSetChatOpened).not.toHaveBeenCalled() + expect(AiAssistantMocks.mockSubmitQuery).not.toHaveBeenCalled() + }) +}) diff --git a/www/packages/docs-ui/src/components/CodeBlock/Actions/AskAi/index.tsx b/www/packages/docs-ui/src/components/CodeBlock/Actions/AskAi/index.tsx index 38576a587f..5782ec3be1 100644 --- a/www/packages/docs-ui/src/components/CodeBlock/Actions/AskAi/index.tsx +++ b/www/packages/docs-ui/src/components/CodeBlock/Actions/AskAi/index.tsx @@ -1,7 +1,8 @@ "use client" import React from "react" -import { useAiAssistant, useSiteConfig } from "../../../../providers" +import { useAiAssistant } from "../../../../providers/AiAssistant" +import { useSiteConfig } from "../../../../providers/SiteConfig" import clsx from "clsx" import { Tooltip } from "../../../Tooltip" import Image from "next/image" diff --git a/www/packages/docs-ui/src/components/CodeBlock/Actions/Copy/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/CodeBlock/Actions/Copy/__tests__/index.test.tsx new file mode 100644 index 0000000000..680a874e0b --- /dev/null +++ b/www/packages/docs-ui/src/components/CodeBlock/Actions/Copy/__tests__/index.test.tsx @@ -0,0 +1,88 @@ +import React, { useState } from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { fireEvent, render } from "@testing-library/react" +import { CopyButtonProps } from "../../../../CopyButton" + +// mock functions +const mockTrack = vi.fn() + +// mock components +vi.mock("@/providers/Analytics", () => ({ + useAnalytics: () => ({ + track: mockTrack, + }), +})) +vi.mock("@/components/CopyButton", () => ({ + CopyButton: ({ + text, + buttonClassName, + children, + onCopy, + }: CopyButtonProps) => { + const [isCopied, setIsCopied] = useState(false) + return ( +
{ + setIsCopied(true) + onCopy?.(e) + }} + > + {text} + {typeof children === "function" ? children({ isCopied }) : children} +
+ ) + }, +})) + +import { CodeBlockCopyAction } from ".." + +beforeEach(() => { + mockTrack.mockClear() +}) + +describe("rendering", () => { + test("render code block copy action not in header", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const span = container.querySelector("[data-testid='copy-button']") + expect(span).toBeInTheDocument() + expect(span).toHaveClass("p-[6px]") + }) + + test("render code block copy action in header", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const span = container.querySelector("[data-testid='copy-button']") + expect(span).toBeInTheDocument() + expect(span).toHaveClass("p-[4.5px]") + }) +}) + +describe("interactions", () => { + test("click code block copy action", async () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const span = container.querySelector("[data-testid='copy-button']") + expect(span).toBeInTheDocument() + fireEvent.click(span!) + const copiedIcon = container.querySelector("[data-testid='copied-icon']") + expect(copiedIcon).toBeInTheDocument() + }) +}) diff --git a/www/packages/docs-ui/src/components/CodeBlock/Actions/Copy/index.tsx b/www/packages/docs-ui/src/components/CodeBlock/Actions/Copy/index.tsx index 8ab206df26..9bbef803ae 100644 --- a/www/packages/docs-ui/src/components/CodeBlock/Actions/Copy/index.tsx +++ b/www/packages/docs-ui/src/components/CodeBlock/Actions/Copy/index.tsx @@ -1,7 +1,9 @@ "use client" import React, { useEffect, useState } from "react" -import { CopyButton, DocsTrackingEvents, useAnalytics } from "../../../.." +import { CopyButton } from "../../../CopyButton" +import { DocsTrackingEvents } from "../../../../constants" +import { useAnalytics } from "../../../../providers/Analytics" import clsx from "clsx" import { CheckMini, SquareTwoStack } from "@medusajs/icons" @@ -18,12 +20,14 @@ export const CodeBlockCopyAction = ({ const { track } = useAnalytics() useEffect(() => { - if (copied) { - setTimeout(() => { - setCopied(false) - }, 1000) + if (!copied) { + return } + setTimeout(() => { + setCopied(false) + }, 1000) + track({ event: { event: DocsTrackingEvents.CODE_BLOCK_COPY, @@ -50,8 +54,15 @@ export const CodeBlockCopyAction = ({ )} onCopy={() => setCopied(true)} > - {!copied && } - {copied && } + {!copied && ( + + )} + {copied && ( + + )} ) } diff --git a/www/packages/docs-ui/src/components/CodeBlock/Actions/index.tsx b/www/packages/docs-ui/src/components/CodeBlock/Actions/index.tsx index f615544e55..27ffc1656a 100644 --- a/www/packages/docs-ui/src/components/CodeBlock/Actions/index.tsx +++ b/www/packages/docs-ui/src/components/CodeBlock/Actions/index.tsx @@ -2,7 +2,8 @@ import clsx from "clsx" import React from "react" -import { Link, Tooltip } from "@/components" +import { Link } from "@/components/Link" +import { Tooltip } from "@/components/Tooltip" import { ExclamationCircle, PlaySolid } from "@medusajs/icons" import { GITHUB_ISSUES_LINK } from "@/constants" import { CodeBlockCopyAction } from "./Copy" diff --git a/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Button/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Button/__tests__/index.test.tsx new file mode 100644 index 0000000000..8976764c17 --- /dev/null +++ b/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Button/__tests__/index.test.tsx @@ -0,0 +1,96 @@ +import React from "react" +import { describe, expect, test, vi } from "vitest" +import { render } from "@testing-library/react" + +// mock functions +const mockSetCollapsed = vi.fn() + +import { CodeBlockCollapsibleButton } from "../../Button" + +describe("render", () => { + test("render collapsible button start and collapsed", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const button = container.querySelector( + "[data-testid='collapsible-button-start']" + ) + expect(button).toBeInTheDocument() + expect(button).toHaveTextContent("Show imports") + }) + test("render collapsible button end and collapsed", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const button = container.querySelector( + "[data-testid='collapsible-button-end']" + ) + expect(button).toBeInTheDocument() + expect(button).toHaveTextContent("Show imports") + }) + test("render when not collapsed", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const button = container.querySelector( + "[data-testid='collapsible-button-start']" + ) + expect(button).not.toBeInTheDocument() + const buttonEnd = container.querySelector( + "[data-testid='collapsible-button-end']" + ) + expect(buttonEnd).not.toBeInTheDocument() + }) + test("render with type start and className", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const button = container.querySelector( + "[data-testid='collapsible-button-start']" + ) + expect(button).toBeInTheDocument() + expect(button).toHaveClass("bg-red-500") + }) + test("render with type end and className", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const button = container.querySelector( + "[data-testid='collapsible-button-end']" + ) + expect(button).toBeInTheDocument() + expect(button).toHaveClass("bg-red-500") + }) +}) diff --git a/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Button/index.tsx b/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Button/index.tsx index 337dd35a95..df421ef32e 100644 --- a/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Button/index.tsx +++ b/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Button/index.tsx @@ -2,7 +2,7 @@ import clsx from "clsx" import React from "react" -import { CollapsibleReturn } from "../../../../hooks" +import { CollapsibleReturn } from "../../../../hooks/use-collapsible" import { Button } from "@medusajs/ui" export type CodeBlockCollapsibleButtonProps = { @@ -33,6 +33,7 @@ export const CodeBlockCollapsibleButton = ({ type === "start" && "rounded-t-docs_DEFAULT rounded-b-none", className )} + data-testid="collapsible-button-start" onClick={() => setCollapsed(false)} > {expandButtonLabel} @@ -47,6 +48,7 @@ export const CodeBlockCollapsibleButton = ({ "rounded-t-none rounded-b-docs_DEFAULT", className )} + data-testid="collapsible-button-end" onClick={() => setCollapsed(false)} > {expandButtonLabel} diff --git a/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Fade/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Fade/__tests__/index.test.tsx new file mode 100644 index 0000000000..0ba482e0bb --- /dev/null +++ b/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Fade/__tests__/index.test.tsx @@ -0,0 +1,72 @@ +import React from "react" +import { describe, expect, test } from "vitest" +import { render } from "@testing-library/react" + +import { CodeBlockCollapsibleFade } from "../../Fade" + +describe("render", () => { + test("render collapsible fade start and collapsed", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const wrapper = container.querySelector("span") + expect(wrapper).toBeInTheDocument() + expect(wrapper).toHaveClass("w-full left-0") + expect(wrapper).toHaveClass("top-[36px]") + const fade = container.querySelector( + "[data-testid='collapsible-fade-start']" + ) + expect(fade).toBeInTheDocument() + }) + test("render collapsible fade end and collapsed", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const wrapper = container.querySelector("span") + expect(wrapper).toBeInTheDocument() + expect(wrapper).toHaveClass("w-full left-0") + expect(wrapper).toHaveClass("bottom-[36px]") + const fade = container.querySelector("[data-testid='collapsible-fade-end']") + expect(fade).toBeInTheDocument() + }) + test("render when not collapsed", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const fade = container.querySelector( + "[data-testid='collapsible-fade-start']" + ) + expect(fade).not.toBeInTheDocument() + const fadeEnd = container.querySelector( + "[data-testid='collapsible-fade-end']" + ) + expect(fadeEnd).not.toBeInTheDocument() + }) + test("render with type start and hasHeader", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const wrapper = container.querySelector("span") + expect(wrapper).toBeInTheDocument() + expect(wrapper).toHaveClass("left-[6px] w-[calc(100%-12px)]") + expect(wrapper).toHaveClass("top-[44px]") + }) + test("render with type end and hasHeader", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const wrapper = container.querySelector("span") + expect(wrapper).toBeInTheDocument() + expect(wrapper).toHaveClass("left-[6px] w-[calc(100%-12px)]") + expect(wrapper).toHaveClass("bottom-[44px]") + }) +}) diff --git a/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Fade/index.tsx b/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Fade/index.tsx index 199d5caeff..8e72510fd1 100644 --- a/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Fade/index.tsx +++ b/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Fade/index.tsx @@ -1,6 +1,6 @@ import clsx from "clsx" import React from "react" -import { CollapsibleReturn } from "../../../../hooks" +import { CollapsibleReturn } from "../../../../hooks/use-collapsible" export type CodeBlockCollapsibleFadeProps = { type: "start" | "end" @@ -38,6 +38,7 @@ export const CodeBlockCollapsibleFade = ({ "w-full h-[56px]", "bg-code-fade-bottom-to-top dark:bg-code-fade-bottom-to-top-dark" )} + data-testid="collapsible-fade-end" /> )} {type === "start" && ( @@ -46,6 +47,7 @@ export const CodeBlockCollapsibleFade = ({ "w-full h-[56px]", "bg-code-fade-top-to-bottom dark:bg-code-fade-top-to-bottom-dark" )} + data-testid="collapsible-fade-start" /> )} diff --git a/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Lines/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Lines/__tests__/index.test.tsx new file mode 100644 index 0000000000..976cb4da5d --- /dev/null +++ b/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Lines/__tests__/index.test.tsx @@ -0,0 +1,64 @@ +import React from "react" +import { describe, expect, test } from "vitest" +import { render } from "@testing-library/react" + +import { CodeBlockCollapsibleLines } from "../../Lines" + +describe("render", () => { + test("render with children, type start, and collapsed", () => { + const { container } = render( + +
Hello
+
World
+
!
+
...
+
+ ) + expect(container).toBeInTheDocument() + expect(container.childElementCount).toBe(2) + expect(container.firstChild).toHaveTextContent("!") + expect(container.lastChild).toHaveTextContent("...") + }) + test("render with children, type end, and collapsed", () => { + const { container } = render( + +
Hello
+
World
+
!
+
...
+
+ ) + expect(container).toBeInTheDocument() + expect(container.childElementCount).toBe(2) + expect(container.firstChild).toHaveTextContent("Hello") + expect(container.lastChild).toHaveTextContent("World") + }) + test("render with children, type start, and not collapsed", () => { + const { container } = render( + +
Hello
+
World
+
!
+
...
+
+ ) + expect(container).toBeInTheDocument() + expect(container.childElementCount).toBe(4) + expect(container.firstChild).toHaveTextContent("Hello") + expect(container.lastChild).toHaveTextContent("...") + }) + test("render with children, type end, and not collapsed", () => { + const { container } = render( + +
Hello
+
World
+
!
+
...
+
+ ) + expect(container).toBeInTheDocument() + expect(container.childElementCount).toBe(4) + expect(container.firstChild).toHaveTextContent("Hello") + expect(container.lastChild).toHaveTextContent("...") + }) +}) diff --git a/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Lines/index.tsx b/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Lines/index.tsx index 29db163782..4868f34b06 100644 --- a/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Lines/index.tsx +++ b/www/packages/docs-ui/src/components/CodeBlock/Collapsible/Lines/index.tsx @@ -1,10 +1,10 @@ import React from "react" -import { CollapsibleReturn } from "../../../../hooks" +import { CollapsibleReturn } from "../../../../hooks/use-collapsible" export type CodeBlockCollapsibleLinesProps = { children: React.ReactNode type: "start" | "end" -} & Omit +} & Pick export const CodeBlockCollapsibleLines = ({ children, diff --git a/www/packages/docs-ui/src/components/CodeBlock/Header/Wrapper/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/CodeBlock/Header/Wrapper/__tests__/index.test.tsx new file mode 100644 index 0000000000..723c2931ec --- /dev/null +++ b/www/packages/docs-ui/src/components/CodeBlock/Header/Wrapper/__tests__/index.test.tsx @@ -0,0 +1,69 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { render } from "@testing-library/react" + +// mock data +const mockColorMode = "light" + +// mock functions +const mockUseColorMode = vi.fn(() => ({ + colorMode: mockColorMode, +})) + +// mock components +vi.mock("@/providers/ColorMode", () => ({ + useColorMode: () => mockUseColorMode(), +})) + +import { CodeBlockHeaderWrapper } from "../index" + +beforeEach(() => { + mockUseColorMode.mockReturnValue({ + colorMode: mockColorMode, + }) +}) + +describe("render", () => { + test("render with children, blockStyle loud, and colorMode light", () => { + const { container } = render( + Hello + ) + expect(container).toBeInTheDocument() + const wrapper = container.querySelector("div") + expect(wrapper).toBeInTheDocument() + expect(wrapper).toHaveClass("bg-medusa-contrast-bg-base") + expect(wrapper).not.toHaveClass("bg-medusa-bg-component") + expect(wrapper).not.toHaveClass("border-medusa-border-base") + }) + + test("render with children, blockStyle subtle, and colorMode light", () => { + const { container } = render( + Hello + ) + expect(container).toBeInTheDocument() + const wrapper = container.querySelector("div") + expect(wrapper).toBeInTheDocument() + expect(wrapper).toHaveClass("bg-medusa-bg-component") + expect(wrapper).not.toHaveClass("bg-medusa-code-bg-header") + expect(wrapper).not.toHaveClass("bg-medusa-contrast-bg-base") + expect(wrapper).toHaveClass("border-medusa-border-base") + expect(wrapper).not.toHaveClass("border-medusa-code-border") + }) + + test("render with children, blockStyle subtle, and colorMode dark", () => { + mockUseColorMode.mockReturnValue({ + colorMode: "dark", + }) + const { container } = render( + Hello + ) + expect(container).toBeInTheDocument() + const wrapper = container.querySelector("div") + expect(wrapper).toBeInTheDocument() + expect(wrapper).toHaveClass("bg-medusa-code-bg-header") + expect(wrapper).not.toHaveClass("bg-medusa-bg-component") + expect(wrapper).not.toHaveClass("bg-medusa-contrast-bg-base") + expect(wrapper).not.toHaveClass("border-medusa-border-base") + expect(wrapper).toHaveClass("border-medusa-code-border") + }) +}) diff --git a/www/packages/docs-ui/src/components/CodeBlock/Header/Wrapper/index.tsx b/www/packages/docs-ui/src/components/CodeBlock/Header/Wrapper/index.tsx index 5f7b1d9549..c73a0672a1 100644 --- a/www/packages/docs-ui/src/components/CodeBlock/Header/Wrapper/index.tsx +++ b/www/packages/docs-ui/src/components/CodeBlock/Header/Wrapper/index.tsx @@ -1,9 +1,9 @@ import clsx from "clsx" import React, { useMemo } from "react" -import { useColorMode } from "../../../../providers" -import { CodeBlockStyle } from "../../../.." +import { useColorMode } from "../../../../providers/ColorMode" +import { CodeBlockStyle } from "../../../CodeBlock" -type CodeBlockHeaderWrapperProps = { +export type CodeBlockHeaderWrapperProps = { blockStyle?: CodeBlockStyle children: React.ReactNode } diff --git a/www/packages/docs-ui/src/components/CodeBlock/Header/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/CodeBlock/Header/__tests__/index.test.tsx new file mode 100644 index 0000000000..181320f422 --- /dev/null +++ b/www/packages/docs-ui/src/components/CodeBlock/Header/__tests__/index.test.tsx @@ -0,0 +1,197 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { render } from "@testing-library/react" +import { BadgeProps } from "../../../Badge" + +// mock data +const mockColorMode = "light" +const mockActionsProps: CodeBlockActionsProps = { + source: "console.log('Hello, world!')", + inHeader: true, + isCollapsed: false, +} + +// mock functions +const mockUseColorMode = vi.fn(() => ({ + colorMode: mockColorMode, +})) + +// mock components +vi.mock("@/providers/ColorMode", () => ({ + useColorMode: () => mockUseColorMode(), +})) +vi.mock("@/components/Badge", () => ({ + Badge: ({ children, variant }: BadgeProps) => ( +
+ {variant} + {children} +
+ ), +})) +vi.mock("@/components/CodeBlock/Actions", () => ({ + CodeBlockActions: () =>
Actions
, +})) +vi.mock("@/components/CodeBlock/Header/Wrapper", () => ({ + CodeBlockHeaderWrapper: ({ + children, + blockStyle, + }: CodeBlockHeaderWrapperProps) => ( +
+ + {blockStyle} + + {children} +
+ ), +})) + +import { CodeBlockHeader } from "../index" +import { CodeBlockHeaderWrapperProps } from "../Wrapper" +import { CodeBlockActionsProps } from "../../Actions" + +beforeEach(() => { + mockUseColorMode.mockReturnValue({ + colorMode: mockColorMode, + }) +}) + +describe("render", () => { + test("default render", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const header = container.querySelector("[data-testid='code-block-header']") + expect(header).toBeInTheDocument() + const actions = container.querySelector( + "[data-testid='code-block-actions']" + ) + expect(actions).toBeInTheDocument() + expect(actions).toHaveTextContent("Actions") + }) + + test("render with title and loud block style", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const header = container.querySelector("[data-testid='code-block-header']") + expect(header).toBeInTheDocument() + const title = header?.querySelector( + "[data-testid='code-block-header-title']" + ) + expect(title).toBeInTheDocument() + expect(title).toHaveTextContent("Title") + expect(title).toHaveClass("text-medusa-contrast-fg-secondary") + expect(title).not.toHaveClass("text-medusa-fg-subtle") + const codeBlockHeaderWrapperBlockStyle = container.querySelector( + "[data-testid='code-block-header-wrapper-blockStyle']" + ) + expect(codeBlockHeaderWrapperBlockStyle).toBeInTheDocument() + expect(codeBlockHeaderWrapperBlockStyle).toHaveTextContent("loud") + }) + + test("render with title and subtle block style", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const header = container.querySelector("[data-testid='code-block-header']") + expect(header).toBeInTheDocument() + const title = header?.querySelector( + "[data-testid='code-block-header-title']" + ) + expect(title).toBeInTheDocument() + expect(title).toHaveTextContent("Title") + expect(title).toHaveClass("text-medusa-fg-subtle") + expect(title).not.toHaveClass("text-medusa-contrast-fg-secondary") + const codeBlockHeaderWrapperBlockStyle = container.querySelector( + "[data-testid='code-block-header-wrapper-blockStyle']" + ) + expect(codeBlockHeaderWrapperBlockStyle).toBeInTheDocument() + expect(codeBlockHeaderWrapperBlockStyle).toHaveTextContent("subtle") + }) + + test("render with title and subtle block style and colorMode dark", () => { + mockUseColorMode.mockReturnValue({ + colorMode: "dark", + }) + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const header = container.querySelector("[data-testid='code-block-header']") + expect(header).toBeInTheDocument() + const title = header?.querySelector( + "[data-testid='code-block-header-title']" + ) + expect(title).toBeInTheDocument() + expect(title).toHaveTextContent("Title") + expect(title).toHaveClass("text-medusa-contrast-fg-secondary") + expect(title).not.toHaveClass("text-medusa-fg-subtle") + const codeBlockHeaderWrapperBlockStyle = container.querySelector( + "[data-testid='code-block-header-wrapper-blockStyle']" + ) + expect(codeBlockHeaderWrapperBlockStyle).toBeInTheDocument() + expect(codeBlockHeaderWrapperBlockStyle).toHaveTextContent("subtle") + }) + + test("render with badge label", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const header = container.querySelector("[data-testid='code-block-header']") + expect(header).toBeInTheDocument() + const badge = header?.querySelector("[data-testid='badge-children']") + expect(badge).toBeInTheDocument() + expect(badge).toHaveTextContent("Badge") + const badgeVariant = header?.querySelector("[data-testid='badge-variant']") + expect(badgeVariant).toBeInTheDocument() + expect(badgeVariant).toHaveTextContent("code") + }) + + test("render with badge label and color", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const header = container.querySelector("[data-testid='code-block-header']") + expect(header).toBeInTheDocument() + const badge = header?.querySelector("[data-testid='badge-children']") + expect(badge).toBeInTheDocument() + expect(badge).toHaveTextContent("Badge") + const badgeVariant = header?.querySelector("[data-testid='badge-variant']") + expect(badgeVariant).toBeInTheDocument() + expect(badgeVariant).toHaveTextContent("blue") + }) + + test("render with hideActions", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const header = container.querySelector("[data-testid='code-block-header']") + expect(header).toBeInTheDocument() + const actions = container.querySelector( + "[data-testid='code-block-actions']" + ) + expect(actions).not.toBeInTheDocument() + }) +}) diff --git a/www/packages/docs-ui/src/components/CodeBlock/Header/index.tsx b/www/packages/docs-ui/src/components/CodeBlock/Header/index.tsx index 4217da2ebe..f4f19a96f5 100644 --- a/www/packages/docs-ui/src/components/CodeBlock/Header/index.tsx +++ b/www/packages/docs-ui/src/components/CodeBlock/Header/index.tsx @@ -2,9 +2,9 @@ import React, { useMemo } from "react" import clsx from "clsx" -import { CodeBlockStyle } from ".." -import { useColorMode } from "@/providers" -import { Badge, BadgeVariant } from "@/components" +import { CodeBlockStyle } from "../../CodeBlock" +import { useColorMode } from "@/providers/ColorMode" +import { Badge, BadgeVariant } from "@/components/Badge" import { CodeBlockActions, CodeBlockActionsProps } from "../Actions" import { CodeBlockHeaderWrapper } from "./Wrapper" @@ -13,7 +13,7 @@ export type CodeBlockHeaderMeta = { badgeColor?: BadgeVariant } -type CodeBlockHeaderProps = { +export type CodeBlockHeaderProps = { title?: string blockStyle?: CodeBlockStyle actionsProps: CodeBlockActionsProps @@ -44,14 +44,20 @@ export const CodeBlockHeader = ({ return ( -
+
{badgeLabel && ( {badgeLabel} )} {title && ( -
+
{title}
)} diff --git a/www/packages/docs-ui/src/components/CodeBlock/Line/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/CodeBlock/Line/__tests__/index.test.tsx new file mode 100644 index 0000000000..d2c7245542 --- /dev/null +++ b/www/packages/docs-ui/src/components/CodeBlock/Line/__tests__/index.test.tsx @@ -0,0 +1,403 @@ +import React from "react" +import { describe, expect, test, vi } from "vitest" +import { render } from "@testing-library/react" +import { MarkdownContentProps } from "../../../MarkdownContent" +import { TooltipProps } from "../../../Tooltip" + +// mock data +const mockLine: Token[] = [ + { + types: ["variable"], + content: "console", + }, + { + types: ["punctuation"], + content: ".", + }, + { + types: ["function"], + content: "log", + }, + { + types: ["delimiter"], + content: "(", + }, + { + types: ["string"], + content: "'", + }, + { + types: ["string"], + content: "Hello, world!", + }, + { + types: ["string"], + content: "'", + }, + { + types: ["delimiter"], + content: ")", + }, +] + +const mockHighlights: Highlight[] = [ + { + line: 1, + text: "console", + }, +] +const mockHighlightsInvalidText: Highlight[] = [ + { + line: 1, + text: "John", + }, +] +const mockHighlighWithTooltipText: Highlight[] = [ + { + line: 1, + text: "console", + tooltipText: "This is a tooltip text", + }, +] + +// mock functions +const mockGetLineProps = ({ line, key }: { line: Token[]; key?: number }) => ({ + className: "text-red-500", +}) +const mockGetTokenProps = ({ token, key }: { token: Token; key?: number }) => ({ + className: "text-red-500", + children: token.content, +}) + +// mock components +vi.mock("@/components/MarkdownContent", () => ({ + MarkdownContent: ({ children }: MarkdownContentProps) => ( +
{children}
+ ), +})) +vi.mock("@/components/Tooltip", () => ({ + Tooltip: ({ children, text, render }: TooltipProps) => ( +
+ {/* @ts-expect-error - render is not typed properly */} + {children || render?.()} + {text} +
+ ), +})) + +import { CodeBlockLine } from "../index" +import { Token } from "prism-react-renderer" +import { Highlight } from "../.." + +describe("render", () => { + test("render without highlights", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const codeBlockLine = container.querySelector( + "[data-testid='code-block-line']" + ) + expect(codeBlockLine).toBeInTheDocument() + expect(codeBlockLine).toHaveClass("text-red-500") + expect(codeBlockLine).not.toHaveClass("bg-medusa-alpha-white-alpha-6") + const lineNumber = codeBlockLine?.querySelector( + "[data-testid='line-number']" + ) + expect(lineNumber).toBeInTheDocument() + expect(lineNumber).toHaveTextContent("2") + expect(lineNumber).toHaveClass("text-red-500") + expect(lineNumber).toHaveClass("bg-red-500") + const codeBlockLineTokens = codeBlockLine?.querySelector( + "[data-testid='code-block-line-tokens']" + ) + expect(codeBlockLineTokens).toBeInTheDocument() + expect(codeBlockLineTokens).not.toHaveClass("relative") + expect(codeBlockLineTokens).toHaveTextContent( + "console.log('Hello, world!')" + ) + const codeBlockLineToken = codeBlockLineTokens?.querySelectorAll( + "[data-testid='code-block-line-token']" + ) + expect(codeBlockLineToken).toHaveLength(mockLine.length) + expect(codeBlockLineToken![0]).toBeInTheDocument() + expect(codeBlockLineToken![0]).toHaveClass("text-red-500") + expect(codeBlockLineToken![0]).toHaveTextContent("console") + expect(codeBlockLineToken![0]).not.toHaveClass("relative") + expect(codeBlockLineToken![1]).toBeInTheDocument() + expect(codeBlockLineToken![1]).toHaveClass("text-red-500") + expect(codeBlockLineToken![1]).toHaveTextContent(".") + expect(codeBlockLineToken![1]).not.toHaveClass("relative") + expect(codeBlockLineToken![2]).toBeInTheDocument() + expect(codeBlockLineToken![2]).toHaveClass("text-red-500") + expect(codeBlockLineToken![2]).toHaveTextContent("log") + expect(codeBlockLineToken![2]).not.toHaveClass("relative") + expect(codeBlockLineToken![3]).toBeInTheDocument() + expect(codeBlockLineToken![3]).toHaveClass("text-red-500") + expect(codeBlockLineToken![3]).toHaveTextContent("(") + expect(codeBlockLineToken![3]).not.toHaveClass("relative") + expect(codeBlockLineToken![4]).toBeInTheDocument() + expect(codeBlockLineToken![4]).toHaveClass("text-red-500") + expect(codeBlockLineToken![4]).toHaveTextContent("'") + expect(codeBlockLineToken![4]).not.toHaveClass("relative") + expect(codeBlockLineToken![5]).toBeInTheDocument() + expect(codeBlockLineToken![5]).toHaveClass("text-red-500") + expect(codeBlockLineToken![5]).toHaveTextContent("Hello, world!") + expect(codeBlockLineToken![5]).not.toHaveClass("relative") + expect(codeBlockLineToken![6]).toBeInTheDocument() + expect(codeBlockLineToken![6]).toHaveClass("text-red-500") + expect(codeBlockLineToken![6]).toHaveTextContent("'") + expect(codeBlockLineToken![6]).not.toHaveClass("relative") + expect(codeBlockLineToken![7]).toBeInTheDocument() + expect(codeBlockLineToken![7]).toHaveClass("text-red-500") + expect(codeBlockLineToken![7]).toHaveTextContent(")") + expect(codeBlockLineToken![7]).not.toHaveClass("relative") + }) + + test("render with highlights", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const codeBlockLine = container.querySelector( + "[data-testid='code-block-line']" + ) + expect(codeBlockLine).toBeInTheDocument() + expect(codeBlockLine).toHaveClass("text-red-500") + // since token is highlighted, the line will not be highlighted + expect(codeBlockLine).not.toHaveClass("bg-medusa-alpha-white-alpha-6") + const lineTokens = codeBlockLine?.querySelectorAll( + "[data-testid='code-block-line-tokens']" + ) + expect(lineTokens).toHaveLength(2) + expect(lineTokens![0]).toHaveClass("relative") + expect(lineTokens![1]).not.toHaveClass("relative") + expect(lineTokens![0]).toHaveTextContent("console") + expect(lineTokens![1]).toHaveTextContent(".log('Hello, world!')") + const lineHighlight = lineTokens![0].querySelector( + "[data-testid='code-block-line-highlight']" + ) + expect(lineHighlight).toBeInTheDocument() + expect(lineHighlight).not.toHaveClass( + "animate-fast animate-growWidth animation-fill-forwards" + ) + expect(lineHighlight).toHaveClass("w-full") + const highlightedLineTokens = lineTokens![0].querySelectorAll( + "[data-testid='code-block-line-token']" + ) + expect(highlightedLineTokens).toHaveLength(1) + // highlighted text + expect(highlightedLineTokens![0]).toHaveTextContent("console") + expect(highlightedLineTokens![0]).toHaveClass("relative z-[1]") + // not highlighted text + const notHighlightedLineTokens = lineTokens![1].querySelectorAll( + "[data-testid='code-block-line-token']" + ) + expect(notHighlightedLineTokens).toHaveLength(mockLine.length - 1) + expect(notHighlightedLineTokens![0]).toHaveTextContent(".") + expect(notHighlightedLineTokens![0]).not.toHaveClass("relative z-[1]") + expect(notHighlightedLineTokens![1]).toHaveTextContent("log") + expect(notHighlightedLineTokens![1]).not.toHaveClass("relative z-[1]") + expect(notHighlightedLineTokens![2]).toHaveTextContent("(") + expect(notHighlightedLineTokens![2]).not.toHaveClass("relative z-[1]") + expect(notHighlightedLineTokens![3]).toHaveTextContent("'") + expect(notHighlightedLineTokens![3]).not.toHaveClass("relative z-[1]") + expect(notHighlightedLineTokens![4]).toHaveTextContent("Hello, world!") + expect(notHighlightedLineTokens![4]).not.toHaveClass("relative z-[1]") + expect(notHighlightedLineTokens![5]).toHaveTextContent("'") + expect(notHighlightedLineTokens![5]).not.toHaveClass("relative z-[1]") + expect(notHighlightedLineTokens![6]).toHaveTextContent(")") + expect(notHighlightedLineTokens![6]).not.toHaveClass("relative z-[1]") + }) + + test("render with highlights and tooltip text", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const codeBlockLine = container.querySelector( + "[data-testid='code-block-line']" + ) + expect(codeBlockLine).toBeInTheDocument() + const tooltip = container.querySelector("[data-testid='tooltip']") + expect(tooltip).toBeInTheDocument() + expect(tooltip).toHaveTextContent("This is a tooltip text") + const tooltipChildren = tooltip?.querySelector( + "[data-testid='tooltip-children']" + ) + expect(tooltipChildren).toBeInTheDocument() + expect(tooltipChildren).toHaveTextContent("console") + }) + + test("render with highlights and animateTokenHighlights", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const codeBlockLine = container.querySelector( + "[data-testid='code-block-line']" + ) + expect(codeBlockLine).toBeInTheDocument() + const codeBlockLineTokens = codeBlockLine?.querySelectorAll( + "[data-testid='code-block-line-tokens']" + ) + expect(codeBlockLineTokens).toHaveLength(2) + expect(codeBlockLineTokens![0]).toHaveClass("relative") + expect(codeBlockLineTokens![1]).not.toHaveClass("relative") + expect(codeBlockLineTokens![0]).toHaveTextContent("console") + expect(codeBlockLineTokens![1]).toHaveTextContent(".log('Hello, world!')") + const lineHighlight = codeBlockLineTokens![0].querySelector( + "[data-testid='code-block-line-highlight']" + ) + expect(lineHighlight).toBeInTheDocument() + expect(lineHighlight).toHaveClass( + "animate-fast animate-growWidth animation-fill-forwards" + ) + expect(lineHighlight).not.toHaveClass("w-full") + }) + + test("render with invalid highlight text", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const codeBlockLine = container.querySelector( + "[data-testid='code-block-line']" + ) + expect(codeBlockLine).toBeInTheDocument() + // line will be highlighted, but not the tokens + expect(codeBlockLine).toHaveClass("bg-medusa-alpha-white-alpha-6") + const highlights = codeBlockLine?.querySelectorAll( + "[data-testid='code-block-line-highlight']" + ) + expect(highlights).toHaveLength(0) + }) + + test("render with isTerminal", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const codeBlockLine = container.querySelector( + "[data-testid='code-block-line']" + ) + expect(codeBlockLine).toBeInTheDocument() + const lineNumber = codeBlockLine?.querySelector( + "[data-testid='line-number']" + ) + expect(lineNumber).toBeInTheDocument() + expect(lineNumber).toHaveTextContent("❯") + }) + + test("render with false showLineNumber", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const codeBlockLine = container.querySelector( + "[data-testid='code-block-line']" + ) + expect(codeBlockLine).toBeInTheDocument() + const lineNumber = codeBlockLine?.querySelector( + "[data-testid='line-number']" + ) + expect(lineNumber).not.toBeInTheDocument() + }) + + test("render with false showLineNumber and isTerminal", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const codeBlockLine = container.querySelector( + "[data-testid='code-block-line']" + ) + expect(codeBlockLine).toBeInTheDocument() + const lineNumber = codeBlockLine?.querySelector( + "[data-testid='line-number']" + ) + expect(lineNumber).toBeInTheDocument() + expect(lineNumber).toHaveTextContent("❯") + }) +}) diff --git a/www/packages/docs-ui/src/components/CodeBlock/Line/index.tsx b/www/packages/docs-ui/src/components/CodeBlock/Line/index.tsx index d5dc5dfa54..31a44cd7e8 100644 --- a/www/packages/docs-ui/src/components/CodeBlock/Line/index.tsx +++ b/www/packages/docs-ui/src/components/CodeBlock/Line/index.tsx @@ -1,8 +1,17 @@ import React, { useMemo } from "react" -import { Highlight } from ".." +import { Highlight } from "../../CodeBlock" import { RenderProps, Token } from "prism-react-renderer" import clsx from "clsx" -import { MarkdownContent, Tooltip } from "@/components" +import { Tooltip } from "@/components/Tooltip" +import dynamic from "next/dynamic" + +const MarkdownContent = dynamic( + async () => + import("@/components/MarkdownContent").then((mod) => mod.MarkdownContent), + { + ssr: false, + } +) type HighlightedTokens = { start: number @@ -215,7 +224,10 @@ export const CodeBlockLine = ({ isTokenHighlighted: boolean offset: number }) => ( - + {isTokenHighlighted && ( )} {tokens.map((token, key) => { @@ -242,6 +255,7 @@ export const CodeBlockLine = ({ tokenClassName, isTokenHighlighted && "relative z-[1]" )} + data-testid="code-block-line-token" {...rest} /> ) @@ -263,6 +277,7 @@ export const CodeBlockLine = ({ isHighlightedLine && "bg-medusa-alpha-white-alpha-6", lineProps.className )} + data-testid="code-block-line" > {(showLineNumber || isTerminal) && ( {isTerminal ? "❯" : showLineNumber ? lineNumber + 1 : ""} diff --git a/www/packages/docs-ui/src/components/CodeBlock/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/CodeBlock/__tests__/index.test.tsx new file mode 100644 index 0000000000..31661652d0 --- /dev/null +++ b/www/packages/docs-ui/src/components/CodeBlock/__tests__/index.test.tsx @@ -0,0 +1,450 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { fireEvent, render } from "@testing-library/react" +import { Token } from "prism-react-renderer" + +// mock data +const mockSource = "console.log('Hello, world!')" +const mockMultiLineSource = `console.log('Line 1') +console.log('Line 2') +console.log('Line 3')` +const mockCurlSource = "curl -X GET https://api.example.com/data" +const mockColorMode = "light" +const mockUseColorMode = vi.fn(() => ({ + colorMode: mockColorMode, +})) +const mockTrack = vi.fn() + +// mock components +const ApiRunnerMock = React.forwardRef< + HTMLDivElement, + { apiMethod: string; apiUrl: string } +>((props, ref) => ( +
+ ApiRunner +
+)) +ApiRunnerMock.displayName = "ApiRunner" + +vi.mock("@/components/ApiRunner", () => ({ + ApiRunner: () => ApiRunnerMock, +})) +vi.mock("@/providers/Analytics", () => ({ + useAnalytics: () => ({ + track: mockTrack, + }), +})) +vi.mock("@/providers/ColorMode", () => ({ + useColorMode: () => mockUseColorMode(), +})) +vi.mock("@/components/CodeBlock/Line", () => ({ + CodeBlockLine: ({ line }: { line: Token[] }) => ( +
+ {line.map((token) => token.content).join("")} +
+ ), +})) +vi.mock("@/components/CodeBlock/Header", () => ({ + CodeBlockHeader: ({ title }: CodeBlockHeaderProps) => ( +
{title}
+ ), +})) +vi.mock("@/components/CodeBlock/Actions", () => ({ + CodeBlockActions: () =>
Actions
, +})) +vi.mock("@/components/CodeBlock/Collapsible/Button", () => ({ + CodeBlockCollapsibleButton: () => ( +
CollapsibleButton
+ ), +})) +vi.mock("@/components/CodeBlock/Collapsible/Fade", () => ({ + CodeBlockCollapsibleFade: () => ( +
CollapsibleFade
+ ), +})) +vi.mock("@/components/CodeBlock/Inline", () => ({ + CodeBlockInline: () =>
Inline
, +})) + +import { CodeBlock } from "../../CodeBlock" +import { CodeBlockHeaderProps } from "../Header" +import { DocsTrackingEvents } from "../../../constants" + +beforeEach(() => { + mockUseColorMode.mockReturnValue({ + colorMode: mockColorMode, + }) + mockTrack.mockClear() +}) + +describe("render", () => { + test("render with source", () => { + const { container } = render() + expect(container).toBeInTheDocument() + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + expect(codeBlock).toHaveTextContent(mockSource) + const codeBlockHeader = container.querySelector( + "[data-testid='code-block-header']" + ) + expect(codeBlockHeader).toBeInTheDocument() + expect(codeBlockHeader).toHaveTextContent("Code") + }) + + test("render with lang not bash", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + expect(codeBlock).toHaveTextContent(mockSource) + const codeBlockHeader = container.querySelector( + "[data-testid='code-block-header']" + ) + expect(codeBlockHeader).toBeInTheDocument() + expect(codeBlockHeader).toHaveTextContent("Code") + }) + + test("render with lang bash", () => { + const { container } = render() + expect(container).toBeInTheDocument() + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + expect(codeBlock).toHaveTextContent(mockSource) + const codeBlockHeader = container.querySelector( + "[data-testid='code-block-header']" + ) + expect(codeBlockHeader).toBeInTheDocument() + expect(codeBlockHeader).toHaveTextContent("Terminal") + }) + + test("render with lang bash and source is curl", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + expect(codeBlock).toHaveTextContent(mockCurlSource) + const codeBlockHeader = container.querySelector( + "[data-testid='code-block-header']" + ) + expect(codeBlockHeader).toBeInTheDocument() + expect(codeBlockHeader).toHaveTextContent("Code") + }) + + test("render with isTerminal", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + expect(codeBlock).toHaveTextContent(mockSource) + const codeBlockHeader = container.querySelector( + "[data-testid='code-block-header']" + ) + expect(codeBlockHeader).toBeInTheDocument() + expect(codeBlockHeader).toHaveTextContent("Terminal") + }) + + test("render with isTermina false", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + expect(codeBlock).toHaveTextContent(mockSource) + const codeBlockHeader = container.querySelector( + "[data-testid='code-block-header']" + ) + expect(codeBlockHeader).toBeInTheDocument() + expect(codeBlockHeader).toHaveTextContent("Code") + }) + + test("render with hasTabs", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + expect(codeBlock).toHaveTextContent(mockSource) + const codeBlockHeader = container.querySelector( + "[data-testid='code-block-header']" + ) + expect(codeBlockHeader).not.toBeInTheDocument() + }) + + test("render with title prop", () => { + const { container } = render( + + ) + const codeBlockHeader = container.querySelector( + "[data-testid='code-block-header']" + ) + expect(codeBlockHeader).toBeInTheDocument() + expect(codeBlockHeader).toHaveTextContent("Custom Title") + }) + + test("render with forceNoTitle", () => { + const { container } = render( + + ) + const codeBlockHeader = container.querySelector( + "[data-testid='code-block-header']" + ) + expect(codeBlockHeader).not.toBeInTheDocument() + }) + + test("render with blockStyle inline", () => { + const { container } = render( + + ) + const inlineBlock = container.querySelector( + "[data-testid='code-block-inline']" + ) + expect(inlineBlock).toBeInTheDocument() + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).not.toBeInTheDocument() + }) + + test("render with blockStyle subtle", () => { + const { container } = render( + + ) + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + }) + + test("render with children when source is empty", () => { + const { container } = render(test children) + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + expect(codeBlock).toHaveTextContent("test children") + }) + + test("render with empty source returns empty fragment", () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + test("render with lang json converts to plain", () => { + const { container } = render() + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + }) + + test("render with className prop", () => { + const { container } = render( + + ) + const codeBlock = container.querySelector( + "[data-testid='code-block-inner']" + ) + expect(codeBlock).toBeInTheDocument() + expect(codeBlock).toHaveClass("custom-class") + }) + + test("render with wrapperClassName prop", () => { + const { container } = render( + + ) + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toHaveClass("wrapper-class") + }) + + test("render with innerClassName prop", () => { + const { container } = render( + + ) + const innerCode = container.querySelector(".inner-class") + expect(innerCode).toBeInTheDocument() + }) + + test("render with style prop", () => { + const { container } = render( + + ) + const codeBlock = container.querySelector(".code-block-elm") + expect(codeBlock).toHaveStyle({ marginTop: "10px" }) + }) + + test("render with collapsed prop", () => { + const { container } = render( + + ) + const codeBlock = container.querySelector(".code-block-elm") + expect(codeBlock).toHaveClass("max-h-[400px]") + }) + + test("render with noLineNumbers prop", () => { + const { container } = render( + + ) + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + }) + + test("render with highlights prop", () => { + const { container } = render( + + ) + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + }) + + test("render with overrideColors prop", () => { + const { container } = render( + + ) + const codeBlock = container.querySelector(".code-block-elm") + expect(codeBlock).toHaveClass("bg-red-500", "border-green-500") + }) + + test("render with dark color mode", () => { + mockUseColorMode.mockReturnValueOnce({ + colorMode: "dark", + }) + const { container } = render( + + ) + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + }) +}) + +describe("api testing", () => { + test("render with apiTesting prop shows ApiRunner when toggled", () => { + const { container } = render( + + ) + const apiRunner = container.querySelector("[data-testid='api-runner']") + // ApiRunner is initially hidden (showTesting starts as false) + expect(apiRunner).not.toBeInTheDocument() + }) + + test("render with apiTesting but missing required props does not show ApiRunner", () => { + const { container } = render( + + ) + const apiRunner = container.querySelector("[data-testid='api-runner']") + expect(apiRunner).not.toBeInTheDocument() + }) +}) + +describe("collapsible lines", () => { + test("render with collapsibleLines prop at start", () => { + const { container } = render( + + ) + const collapsibleButton = container.querySelector( + "[data-testid='code-block-collapsible-button']" + ) + const collapsibleFade = container.querySelector( + "[data-testid='code-block-collapsible-fade']" + ) + expect(collapsibleButton).toBeInTheDocument() + expect(collapsibleFade).toBeInTheDocument() + }) + + test("render with collapsibleLines prop at end", () => { + const { container } = render( + + ) + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + }) +}) + +describe("actions", () => { + test("render with noCopy prop hides copy action", () => { + const { container } = render( + + ) + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + }) + + test("render with noReport prop hides report action", () => { + const { container } = render( + + ) + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + }) + + test("render with noAskAi prop hides ask AI action", () => { + const { container } = render( + + ) + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + }) + + test("render with all action flags disabled hides actions", () => { + const { container } = render( + + ) + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + }) +}) + +describe("interaction", () => { + test("tracks copy event when code is copied", () => { + const { container } = render() + const pre = container.querySelector("pre") + expect(pre).toBeInTheDocument() + + fireEvent.copy(pre!) + expect(mockTrack).toHaveBeenCalledTimes(1) + expect(mockTrack).toHaveBeenCalledWith({ + event: { + event: DocsTrackingEvents.CODE_BLOCK_COPY, + }, + }) + }) +}) + +describe("single line vs multi-line", () => { + test("render single line code", () => { + const { container } = render() + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + }) + + test("render multi-line code", () => { + const { container } = render() + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + const lines = container.querySelectorAll("[data-testid='code-block-line']") + expect(lines.length).toBeGreaterThan(1) + }) +}) diff --git a/www/packages/docs-ui/src/components/CodeBlock/index.tsx b/www/packages/docs-ui/src/components/CodeBlock/index.tsx index 8d2d10508c..3c4d5112d0 100644 --- a/www/packages/docs-ui/src/components/CodeBlock/index.tsx +++ b/www/packages/docs-ui/src/components/CodeBlock/index.tsx @@ -3,15 +3,17 @@ import React, { useEffect, useMemo, useRef, useState } from "react" import clsx from "clsx" import { Highlight, HighlightProps, themes, Token } from "prism-react-renderer" -import { ApiRunner } from "@/components" -import { useAnalytics, useColorMode } from "@/providers" +import { ApiRunner } from "@/components/ApiRunner" +import { useAnalytics } from "@/providers/Analytics" +import { useColorMode } from "@/providers/ColorMode" import { CodeBlockHeader, CodeBlockHeaderMeta } from "./Header" import { CodeBlockLine } from "./Line" import { ApiAuthType, ApiDataOptions, ApiMethod } from "types" // @ts-expect-error can't install the types package because it doesn't support React v19 import { CSSTransition } from "react-transition-group" -import { DocsTrackingEvents, useCollapsibleCodeLines } from "../.." -import { HighlightProps as CollapsibleHighlightProps } from "@/hooks" +import { DocsTrackingEvents } from "@/constants" +import { useCollapsibleCodeLines } from "@/hooks/use-collapsible-code-lines" +import { HighlightProps as CollapsibleHighlightProps } from "@/hooks/use-collapsible-code-lines" import { CodeBlockActions, CodeBlockActionsProps } from "./Actions" import { CodeBlockCollapsibleButton } from "./Collapsible/Button" import { CodeBlockCollapsibleFade } from "./Collapsible/Fade" @@ -367,6 +369,7 @@ export const CodeBlock = ({ "code-block-highlight-light", wrapperClassName )} + data-testid="code-block" > {codeTitle && ( ({ + CodeBlock: ({ source, ...codeBlockProps }: CodeBlockProps) => ( +
+
{source}
+
+ {JSON.stringify(codeBlockProps as Record)} +
+
+ ), +})) +vi.mock("@/components/InlineCode", () => ({ + InlineCode: ({ children, ...inlineCodeProps }: InlineCodeProps) => ( +
+
{children}
+
+ {JSON.stringify(inlineCodeProps)} +
+
+ ), +})) +vi.mock("@/components/MermaidDiagram", () => ({ + MermaidDiagram: ({ diagramContent }: { diagramContent: string }) => ( +
{diagramContent}
+ ), +})) +vi.mock("@/components/Npm2YarnCode", () => ({ + Npm2YarnCode: ({ npmCode }: { npmCode: string }) => ( +
{npmCode}
+ ), +})) + +import { CodeMdx } from "../../CodeMdx" + +describe("render", () => { + test("renders without children", () => { + const { container } = render() + expect(container).toBeInTheDocument() + expect(container).toBeEmptyDOMElement() + }) + + test("renders with children", () => { + const { container } = render({mockSource}) + expect(container).toBeInTheDocument() + const inlineCodeChildren = container.querySelector( + "[data-testid='inline-code-children']" + ) + expect(inlineCodeChildren).toBeInTheDocument() + expect(inlineCodeChildren).toHaveTextContent(mockSource) + }) + + test("renders with className", () => { + const { container } = render( + {mockSource} + ) + expect(container).toBeInTheDocument() + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + const codeBlockSource = container.querySelector( + "[data-testid='code-block-source']" + ) + expect(codeBlockSource).toBeInTheDocument() + expect(codeBlockSource).toHaveTextContent(mockSource) + }) + + test("renders with npm2yarn", () => { + const { container } = render( + + {mockSource} + + ) + expect(container).toBeInTheDocument() + const npm2yarnCode = container.querySelector( + "[data-testid='npm2yarn-code']" + ) + expect(npm2yarnCode).toBeInTheDocument() + expect(npm2yarnCode).toHaveTextContent(mockSource) + }) + + test("renders with mermaid", () => { + const { container } = render( + {mockSource} + ) + expect(container).toBeInTheDocument() + const mermaidDiagram = container.querySelector( + "[data-testid='mermaid-diagram']" + ) + expect(mermaidDiagram).toBeInTheDocument() + expect(mermaidDiagram).toHaveTextContent(mockSource) + }) + + test("renders with codeBlockProps", () => { + const codeBlockProps = { className: "language-javascript" } + const { container } = render( + + {mockSource} + + ) + expect(container).toBeInTheDocument() + const codeBlockPropsElement = container.querySelector( + "[data-testid='code-block-props']" + ) + expect(codeBlockPropsElement).toBeInTheDocument() + const parsedContent = JSON.parse(codeBlockPropsElement!.textContent || "{}") + expect(parsedContent).toEqual({ + ...codeBlockProps, + lang: "javascript", + }) + }) + + test("renders with inlineCodeProps", () => { + const inlineCodeProps: Partial = { variant: "grey-bg" } + const { container } = render( + {mockSource} + ) + expect(container).toBeInTheDocument() + const inlineCodePropsElement = container.querySelector( + "[data-testid='inline-code-props']" + ) + expect(inlineCodePropsElement).toBeInTheDocument() + expect(inlineCodePropsElement).toHaveTextContent( + JSON.stringify(inlineCodeProps) + ) + }) +}) diff --git a/www/packages/docs-ui/src/components/CodeMdx/index.tsx b/www/packages/docs-ui/src/components/CodeMdx/index.tsx index 83d644c223..7d96d57f73 100644 --- a/www/packages/docs-ui/src/components/CodeMdx/index.tsx +++ b/www/packages/docs-ui/src/components/CodeMdx/index.tsx @@ -3,10 +3,9 @@ import { CodeBlock, CodeBlockMetaFields, CodeBlockProps, - InlineCode, - InlineCodeProps, - MermaidDiagram, -} from "@/components" +} from "@/components/CodeBlock" +import { InlineCode, InlineCodeProps } from "@/components/InlineCode" +import { MermaidDiagram } from "@/components/MermaidDiagram" import { Npm2YarnCode } from "../Npm2YarnCode" export type CodeMdxProps = { diff --git a/www/packages/docs-ui/src/components/CodeTabs/Item/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/CodeTabs/Item/__tests__/index.test.tsx new file mode 100644 index 0000000000..210f239eb1 --- /dev/null +++ b/www/packages/docs-ui/src/components/CodeTabs/Item/__tests__/index.test.tsx @@ -0,0 +1,261 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { fireEvent, render } from "@testing-library/react" + +// mock data +const mockLabel = "Code Tab" +const mockValue = "code-tab" +const mockColorMode = "light" + +// mock functions +const mockChangeSelectedTab = vi.fn() +const mockPushRef = vi.fn() +const mockUseColorMode = vi.fn(() => ({ + colorMode: mockColorMode, +})) +const mockUseScrollPositionBlocker = vi.fn(() => ({ + blockElementScrollPositionUntilNextRender: vi.fn(), +})) + +// mock components and hooks +vi.mock("@/providers/ColorMode", () => ({ + useColorMode: () => mockUseColorMode(), +})) +vi.mock("@/hooks/use-scroll-utils", () => ({ + useScrollPositionBlocker: () => mockUseScrollPositionBlocker(), +})) + +import { CodeTab } from "../../Item" + +beforeEach(() => { + mockUseColorMode.mockReturnValue({ + colorMode: mockColorMode, + }) + mockUseScrollPositionBlocker.mockReturnValue({ + blockElementScrollPositionUntilNextRender: vi.fn(), + }) +}) + +describe("render", () => { + test("default render (loud blockStyle, not selected, light color mode)", () => { + const { container } = render( + +
Children
+
+ ) + expect(container).toBeInTheDocument() + const button = container.querySelector("button") + expect(button).toBeInTheDocument() + expect(button).toHaveTextContent(mockLabel) + expect(button).toHaveAttribute("aria-selected", "false") + expect(button).toHaveAttribute("role", "tab") + expect(button).toHaveClass("text-medusa-contrast-fg-secondary") + expect(button).not.toHaveClass("text-medusa-contrast-fg-primary") + expect(button).not.toHaveClass("xs:border-medusa-border-base") + expect(button).not.toHaveClass("xs:border-medusa-code-border") + expect(button).not.toHaveClass("text-medusa-contrast-fg-primary") + expect(button).not.toHaveClass("xs:border-medusa-border-base") + expect(button).not.toHaveClass("xs:border-medusa-code-border") + expect(button).not.toHaveClass("text-medusa-contrast-fg-primary") + expect(button).not.toHaveClass("xs:border-medusa-border-base") + expect(button).not.toHaveClass("xs:border-medusa-code-border") + }) + + test("render with selected (loud blockStyle, light color mode)", () => { + const { container } = render( + +
Children
+
+ ) + expect(container).toBeInTheDocument() + const button = container.querySelector("button") + expect(button).toBeInTheDocument() + expect(button).toHaveTextContent(mockLabel) + expect(button).toHaveAttribute("aria-selected", "true") + expect(button).toHaveAttribute("role", "tab") + expect(button).toHaveClass("text-medusa-contrast-fg-primary") + expect(button).not.toHaveClass("text-medusa-contrast-fg-secondary") + expect(button).not.toHaveClass("xs:border-medusa-border-base") + expect(button).not.toHaveClass("xs:border-medusa-code-border") + }) + + test("render with subtle blockStyle (not selected, light color mode)", () => { + const { container } = render( + +
Children
+
+ ) + expect(container).toBeInTheDocument() + const button = container.querySelector("button") + expect(button).toBeInTheDocument() + expect(button).toHaveTextContent(mockLabel) + expect(button).toHaveAttribute("aria-selected", "false") + expect(button).toHaveAttribute("role", "tab") + expect(button).toHaveClass("text-medusa-fg-subtle hover:bg-medusa-bg-base") + expect(button).not.toHaveClass("text-medusa-contrast-fg-secondary") + expect(button).not.toHaveClass("xs:border-medusa-code-border") + expect(button).not.toHaveClass("text-medusa-contrast-fg-primary") + expect(button).not.toHaveClass("hover:bg-medusa-code-bg-base") + }) + + test("render with subtle blockStyle and dark color mode", () => { + mockUseColorMode.mockReturnValue({ + colorMode: "dark", + }) + const { container } = render( + +
Children
+
+ ) + expect(container).toBeInTheDocument() + const button = container.querySelector("button") + expect(button).toBeInTheDocument() + expect(button).toHaveTextContent(mockLabel) + expect(button).toHaveAttribute("aria-selected", "false") + expect(button).toHaveAttribute("role", "tab") + expect(button).toHaveClass("text-medusa-contrast-fg-secondary") + expect(button).not.toHaveClass("text-medusa-contrast-fg-primary") + expect(button).not.toHaveClass("xs:border-medusa-border-base") + expect(button).not.toHaveClass("xs:border-medusa-code-border") + expect(button).not.toHaveClass("text-medusa-contrast-fg-primary") + expect(button).not.toHaveClass("xs:border-medusa-border-base") + expect(button).not.toHaveClass("xs:border-medusa-code-border") + expect(button).not.toHaveClass("text-medusa-contrast-fg-primary") + expect(button).not.toHaveClass("xs:border-medusa-border-base") + expect(button).not.toHaveClass("xs:border-medusa-code-border") + }) + + test("render with subtle blockStyle and selected (light color mode)", () => { + const { container } = render( + +
Children
+
+ ) + expect(container).toBeInTheDocument() + const button = container.querySelector("button") + expect(button).toBeInTheDocument() + expect(button).toHaveTextContent(mockLabel) + expect(button).toHaveAttribute("aria-selected", "true") + expect(button).toHaveAttribute("role", "tab") + expect(button).toHaveClass( + "xs:border-medusa-border-base text-medusa-contrast-fg-primary" + ) + expect(button).not.toHaveClass("text-medusa-contrast-fg-secondary") + expect(button).not.toHaveClass("hover:bg-medusa-code-bg-base") + expect(button).not.toHaveClass("text-medusa-contrast-fg-secondary") + }) + + test("render with subtle blockStyle and dark color mode and selected", () => { + mockUseColorMode.mockReturnValue({ + colorMode: "dark", + }) + const { container } = render( + +
Children
+
+ ) + expect(container).toBeInTheDocument() + const button = container.querySelector("button") + expect(button).toBeInTheDocument() + expect(button).toHaveTextContent(mockLabel) + expect(button).toHaveAttribute("aria-selected", "true") + expect(button).toHaveAttribute("role", "tab") + expect(button).toHaveClass( + "xs:border-medusa-code-border text-medusa-contrast-fg-primary" + ) + expect(button).not.toHaveClass("text-medusa-contrast-fg-secondary") + expect(button).not.toHaveClass("hover:bg-medusa-code-bg-base") + expect(button).not.toHaveClass("text-medusa-contrast-fg-secondary") + }) + + test("render with loud blockStyle and not selected and dark color mode", () => { + mockUseColorMode.mockReturnValue({ + colorMode: "dark", + }) + const { container } = render( + +
Children
+
+ ) + expect(container).toBeInTheDocument() + const button = container.querySelector("button") + expect(button).toBeInTheDocument() + expect(button).toHaveTextContent(mockLabel) + expect(button).toHaveAttribute("aria-selected", "false") + expect(button).toHaveAttribute("role", "tab") + expect(button).toHaveClass("text-medusa-contrast-fg-secondary") + expect(button).not.toHaveClass("text-medusa-contrast-fg-primary") + expect(button).not.toHaveClass("xs:border-medusa-border-base") + expect(button).not.toHaveClass("xs:border-medusa-code-border") + expect(button).not.toHaveClass("text-medusa-contrast-fg-primary") + expect(button).not.toHaveClass("xs:border-medusa-border-base") + expect(button).not.toHaveClass("xs:border-medusa-code-border") + expect(button).not.toHaveClass("text-medusa-contrast-fg-primary") + expect(button).not.toHaveClass("xs:border-medusa-border-base") + expect(button).not.toHaveClass("xs:border-medusa-code-border") + }) + + test("render with pushRef", () => { + const { container } = render( + +
Children
+
+ ) + expect(container).toBeInTheDocument() + const button = container.querySelector("button") + expect(button).toBeInTheDocument() + expect(mockPushRef).toHaveBeenCalledWith(button) + }) +}) + +describe("interactions", () => { + test("click on tab without changeSelectedTab", () => { + const { container } = render( + +
Children
+
+ ) + expect(container).toBeInTheDocument() + const button = container.querySelector("button") + expect(button).toBeInTheDocument() + + fireEvent.click(button!) + expect( + mockUseScrollPositionBlocker().blockElementScrollPositionUntilNextRender + ).toHaveBeenCalled() + expect(mockChangeSelectedTab).not.toHaveBeenCalled() + }) + + test("click on tab with changeSelectedTab", () => { + const { container } = render( + +
Children
+
+ ) + expect(container).toBeInTheDocument() + const button = container.querySelector("button") + expect(button).toBeInTheDocument() + fireEvent.click(button!) + expect( + mockUseScrollPositionBlocker().blockElementScrollPositionUntilNextRender + ).toHaveBeenCalled() + expect(mockChangeSelectedTab).toHaveBeenCalledWith({ + label: mockLabel, + value: mockValue, + }) + }) +}) diff --git a/www/packages/docs-ui/src/components/CodeTabs/Item/index.tsx b/www/packages/docs-ui/src/components/CodeTabs/Item/index.tsx index 187e75709b..98b8020de1 100644 --- a/www/packages/docs-ui/src/components/CodeTabs/Item/index.tsx +++ b/www/packages/docs-ui/src/components/CodeTabs/Item/index.tsx @@ -1,11 +1,13 @@ "use client" import React from "react" -import { BaseTabType, useScrollPositionBlocker } from "@/hooks" -import { useColorMode } from "@/providers" +import { useColorMode } from "@/providers/ColorMode" import clsx from "clsx" +import { BaseTabType } from "../../../hooks/use-tabs" +import { useScrollPositionBlocker } from "../../../hooks/use-scroll-utils" type CodeTabProps = BaseTabType & { + // Children are handled in MDX to allow for code blocks and inline code to be rendered children: React.ReactNode isSelected?: boolean blockStyle?: string diff --git a/www/packages/docs-ui/src/components/CodeTabs/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/CodeTabs/__tests__/index.test.tsx new file mode 100644 index 0000000000..dc83458a0f --- /dev/null +++ b/www/packages/docs-ui/src/components/CodeTabs/__tests__/index.test.tsx @@ -0,0 +1,413 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { fireEvent, render } from "@testing-library/react" +import { CodeBlock } from "@/components/CodeBlock" + +// mock data +const mockColorMode = "light" +const mockUseColorMode = vi.fn(() => ({ + colorMode: mockColorMode, +})) + +// mock hooks +const mockSelectedTab = { + label: "Tab 1", + value: "tab1", + codeProps: { source: "code1" }, +} +const mockChangeSelectedTab = vi.fn() +const mockUseTabs = vi.fn(() => ({ + selectedTab: mockSelectedTab, + changeSelectedTab: mockChangeSelectedTab, +})) + +// mock components +vi.mock("@/providers/ColorMode", () => ({ + useColorMode: () => mockUseColorMode(), +})) + +vi.mock("@/hooks/use-tabs", () => ({ + useTabs: () => mockUseTabs(), +})) + +vi.mock("@/components/Badge", () => ({ + Badge: ({ + children, + variant, + }: { + children: React.ReactNode + variant?: string + }) => ( +
+ {children} +
+ ), +})) + +vi.mock("@/components/CodeBlock", () => ({ + CodeBlock: ({ source, hasTabs }: { source: string; hasTabs?: boolean }) => ( +
+ {source} +
+ ), +})) + +vi.mock("@/components/CodeBlock/Actions", () => ({ + CodeBlockActions: ({ source }: { source: string }) => ( +
+ Actions +
+ ), +})) + +vi.mock("../Item", () => ({ + CodeTab: ({ + label, + value, + isSelected, + blockStyle, + changeSelectedTab, + pushRef, + children, + }: { + label: string + value: string + isSelected?: boolean + blockStyle?: string + changeSelectedTab?: (tab: { label: string; value: string }) => void + pushRef?: (tabButton: HTMLButtonElement | null) => void + children: React.ReactNode + }) => ( +
  • + + {children} +
  • + ), +})) + +import { CodeTab } from "../Item" +import { CodeTabs } from "../../CodeTabs" + +beforeEach(() => { + mockUseColorMode.mockReturnValue({ + colorMode: mockColorMode, + }) + mockUseTabs.mockReturnValue({ + selectedTab: mockSelectedTab, + changeSelectedTab: mockChangeSelectedTab, + }) + mockChangeSelectedTab.mockClear() +}) + +describe("rendering", () => { + test("renders with default props", () => { + const { container } = render( + + + + + + ) + const codeTabs = container.querySelector("[data-testid='code-tabs']") + expect(codeTabs).toBeInTheDocument() + }) + + test("renders with className", () => { + const { container } = render( + + + + + + ) + const wrapper = container.querySelector("[data-testid='code-tabs']") + expect(wrapper).toHaveClass("custom-class") + }) + + test("renders with blockStyle loud", () => { + const { container } = render( + + + + + + ) + const wrapper = container.querySelector("[data-testid='code-tabs']") + expect(wrapper).toHaveClass("bg-medusa-contrast-bg-base") + expect(wrapper).toHaveClass( + "shadow-elevation-code-block dark:shadow-elevation-code-block-dark" + ) + }) + + test("renders with blockStyle subtle (light color mode)", () => { + const { container } = render( + + + + + + ) + const wrapper = container.querySelector("[data-testid='code-tabs']") + expect(wrapper).toHaveClass("bg-medusa-bg-component") + expect(wrapper).toHaveClass("shadow-none") + }) + + test("renders with blockStyle subtle (dark color mode)", () => { + mockUseColorMode.mockReturnValueOnce({ + colorMode: "dark", + }) + const { container } = render( + + + + + + ) + const wrapper = container.querySelector("[data-testid='code-tabs']") + expect(wrapper).toHaveClass("bg-medusa-code-bg-header") + expect(wrapper).toHaveClass("shadow-none") + }) + + test("renders multiple tabs", () => { + const { container } = render( + + + + + + + + + ) + const buttons = container.querySelectorAll("button[role='tab']") + expect(buttons.length).toBe(2) + expect(buttons[0]).toHaveTextContent("Tab 1") + expect(buttons[1]).toHaveTextContent("Tab 2") + }) + + test("renders selected tab code block", () => { + mockUseTabs.mockReturnValueOnce({ + selectedTab: { + label: "Tab 1", + value: "tab1", + codeProps: { source: "code1" }, + codeBlock: , + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + changeSelectedTab: mockChangeSelectedTab, + }) + const { container } = render( + + + + + + ) + const codeBlock = container.querySelector("[data-testid='code-block']") + expect(codeBlock).toBeInTheDocument() + expect(codeBlock).toHaveAttribute("data-source", "code1") + }) + + test("renders badge when selected tab has badgeLabel", () => { + mockUseTabs.mockReturnValueOnce({ + selectedTab: { + label: "Tab 1", + value: "tab1", + codeProps: { + source: "code1", + badgeLabel: "New", + badgeColor: "green", + }, + codeBlock: ( + + ), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + changeSelectedTab: mockChangeSelectedTab, + }) + const { container } = render( + + + + + + ) + const badge = container.querySelector("[data-testid='badge']") + expect(badge).toBeInTheDocument() + expect(badge).toHaveAttribute("data-variant", "green") + expect(badge).toHaveTextContent("New") + }) + + test("does not render badge when selected tab has no badgeLabel", () => { + mockUseTabs.mockReturnValueOnce({ + selectedTab: { + label: "Tab 1", + value: "tab1", + codeProps: { source: "code1" }, + codeBlock: , + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + changeSelectedTab: mockChangeSelectedTab, + }) + const { container } = render( + + + + + + ) + const badge = container.querySelector("[data-testid='badge']") + expect(badge).not.toBeInTheDocument() + }) + + test("renders code block actions when selected tab exists", () => { + mockUseTabs.mockReturnValueOnce({ + selectedTab: { + label: "Tab 1", + value: "tab1", + codeProps: { source: "code1" }, + codeBlock: , + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + changeSelectedTab: mockChangeSelectedTab, + }) + const { container } = render( + + + + + + ) + const actions = container.querySelector( + "[data-testid='code-block-actions']" + ) + expect(actions).toBeInTheDocument() + expect(actions).toHaveAttribute("data-source", "code1") + }) + + test("does not render code block actions when no selected tab", () => { + mockUseTabs.mockReturnValueOnce({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectedTab: null as any, + changeSelectedTab: mockChangeSelectedTab, + }) + const { container } = render( + + + + + + ) + const actions = container.querySelector( + "[data-testid='code-block-actions']" + ) + expect(actions).not.toBeInTheDocument() + }) + + test("renders tab selector span", () => { + const { container } = render( + + + + + + ) + const selector = container.querySelector("span.xs\\:absolute") + expect(selector).toBeInTheDocument() + }) + + test("filters out invalid children", () => { + const { container } = render( + + + + + {null} +
    Invalid child
    + + + +
    + ) + const buttons = container.querySelectorAll("button[role='tab']") + expect(buttons.length).toBe(2) + }) +}) + +describe("tab selection", () => { + test("calls changeSelectedTab when tab is clicked", () => { + const { container } = render( + + + + + + + + + ) + const buttons = container.querySelectorAll("button[role='tab']") + fireEvent.click(buttons[1]!) + expect(mockChangeSelectedTab).toHaveBeenCalledWith({ + label: "Tab 2", + value: "tab2", + }) + }) + + test("marks first tab as selected when no selectedTab", () => { + mockUseTabs.mockReturnValueOnce({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectedTab: null as any, + changeSelectedTab: mockChangeSelectedTab, + }) + const { container } = render( + + + + + + + + + ) + const buttons = container.querySelectorAll("button[role='tab']") + expect(buttons[0]).toHaveAttribute("aria-selected", "true") + expect(buttons[1]).toHaveAttribute("aria-selected", "false") + }) + + test("marks correct tab as selected based on selectedTab value", () => { + mockUseTabs.mockReturnValueOnce({ + selectedTab: { + label: "Tab 2", + value: "tab2", + codeProps: { source: "code2" }, + codeBlock: , + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + changeSelectedTab: mockChangeSelectedTab, + }) + const { container } = render( + + + + + + + + + ) + const buttons = container.querySelectorAll("button[role='tab']") + expect(buttons[0]).toHaveAttribute("aria-selected", "false") + expect(buttons[1]).toHaveAttribute("aria-selected", "true") + }) +}) diff --git a/www/packages/docs-ui/src/components/CodeTabs/index.tsx b/www/packages/docs-ui/src/components/CodeTabs/index.tsx index e59e96cba9..6ffbf9a1aa 100644 --- a/www/packages/docs-ui/src/components/CodeTabs/index.tsx +++ b/www/packages/docs-ui/src/components/CodeTabs/index.tsx @@ -1,17 +1,13 @@ "use client" import React, { Children, useCallback, useEffect, useMemo, useRef } from "react" -import { - Badge, - BaseTabType, - CodeBlockProps, - CodeBlockStyle, - useColorMode, - useTabs, -} from "../.." +import { Badge } from "@/components/Badge" +import { CodeBlockProps, CodeBlockStyle } from "@/components/CodeBlock" +import { useColorMode } from "@/providers/ColorMode" import clsx from "clsx" import { CodeBlockActions, CodeBlockActionsProps } from "../CodeBlock/Actions" import { CodeBlockHeaderWrapper } from "../CodeBlock/Header/Wrapper" +import { BaseTabType, useTabs } from "../../hooks/use-tabs" type CodeTab = BaseTabType & { codeProps: CodeBlockProps @@ -262,6 +258,7 @@ export const CodeTabs = ({ boxShadow, className )} + data-testid="code-tabs" > ({ + config: { + baseUrl: "https://docs.medusajs.com", + basePath: "", + }, +})) +const mockUsePathname = vi.fn(() => "") + +// mock components +vi.mock("@/providers/AiAssistant", () => ({ + useAiAssistant: () => AiAssistantMocks.mockUseAiAssistant(), +})) +vi.mock("@/providers/SiteConfig", () => ({ + useSiteConfig: () => mockUseSiteConfig(), +})) +vi.mock("@kapaai/react-sdk", () => ({ + useChat: () => AiAssistantMocks.mockUseChat(), +})) +vi.mock("next/navigation", () => ({ + usePathname: () => mockUsePathname(), +})) + +import { ContentMenuActions } from "../../Actions" + +beforeEach(() => { + AiAssistantMocks.mockSetChatOpened.mockClear() + AiAssistantMocks.mockUseAiAssistant.mockReturnValue( + AiAssistantMocks.defaultUseAiAssistantReturn + ) + AiAssistantMocks.mockSubmitQuery.mockClear() + AiAssistantMocks.mockUseChat.mockReturnValue( + AiAssistantMocks.defaultUseChatReturn + ) +}) + +describe("render", () => { + test("render action menu", () => { + const { container } = render() + expect(container).toBeInTheDocument() + const markdownLink = container.querySelector( + "a[data-testid='markdown-link']" + ) + expect(markdownLink).toBeInTheDocument() + expect(markdownLink).toHaveAttribute( + "href", + "https://docs.medusajs.com/index.html.md" + ) + expect(markdownLink).toHaveTextContent("View as Markdown") + const aiAssistantButton = container.querySelector( + "button[data-testid='ai-assistant-button']" + ) + expect(aiAssistantButton).toBeInTheDocument() + expect(aiAssistantButton).toHaveTextContent("Explain with AI Assistant") + }) +}) + +describe("interactions", () => { + test("handle ai assistant click", () => { + const { container } = render() + expect(container).toBeInTheDocument() + const aiAssistantButton = container.querySelector( + "button[data-testid='ai-assistant-button']" + ) + expect(aiAssistantButton).toBeInTheDocument() + fireEvent.click(aiAssistantButton!) + expect(AiAssistantMocks.mockSetChatOpened).toHaveBeenCalledWith(true) + expect(AiAssistantMocks.mockSubmitQuery).toHaveBeenCalledWith( + "Explain the page https://docs.medusajs.com" + ) + }) + + test("handle ai assistant click when loading", () => { + AiAssistantMocks.mockUseChat.mockReturnValue({ + ...AiAssistantMocks.defaultUseChatReturn, + isGeneratingAnswer: true, + }) + const { container } = render() + expect(container).toBeInTheDocument() + const aiAssistantButton = container.querySelector( + "button[data-testid='ai-assistant-button']" + ) + expect(aiAssistantButton).toBeInTheDocument() + fireEvent.click(aiAssistantButton!) + expect(AiAssistantMocks.mockSetChatOpened).not.toHaveBeenCalled() + expect(AiAssistantMocks.mockSubmitQuery).not.toHaveBeenCalled() + }) +}) diff --git a/www/packages/docs-ui/src/components/ContentMenu/Actions/index.tsx b/www/packages/docs-ui/src/components/ContentMenu/Actions/index.tsx index 490e644137..2ce51cda84 100644 --- a/www/packages/docs-ui/src/components/ContentMenu/Actions/index.tsx +++ b/www/packages/docs-ui/src/components/ContentMenu/Actions/index.tsx @@ -3,7 +3,8 @@ import Link from "next/link" import React, { useMemo } from "react" import { MarkdownIcon } from "../../Icons/Markdown" -import { useAiAssistant, useSiteConfig } from "../../../providers" +import { useSiteConfig } from "@/providers/SiteConfig" +import { useAiAssistant } from "@/providers/AiAssistant" import { usePathname } from "next/navigation" import { BroomSparkle } from "@medusajs/icons" import { useChat } from "@kapaai/react-sdk" @@ -34,6 +35,7 @@ export const ContentMenuActions = () => { View as Markdown @@ -41,6 +43,7 @@ export const ContentMenuActions = () => { + )} + {href && ( +
    )} + > + Link + + )} +
    + ) + }, +})) + +import { ContentMenuVersion, LOCAL_STORAGE_KEY } from "../../Version" + +beforeEach(() => { + // Clear localStorage before each test + localStorage.clear() + mockUseSiteConfig.mockReturnValue(defaultUseSiteConfigReturn) + mockUseIsBrowser.mockReturnValue({ + isBrowser: true, + }) + vi.clearAllMocks() +}) + +describe("rendering", () => { + test("renders card when version is new (not in localStorage)", async () => { + const { container } = render() + const card = container.querySelector("[data-testid='version-card']") + expect(card).toBeInTheDocument() + await waitFor(() => { + expect(card).toHaveClass("animate animate-fadeInDown") + }) + }) + + test("does not render card when version matches localStorage", async () => { + localStorage.setItem(LOCAL_STORAGE_KEY, mockVersion.number) + const { container } = render() + const card = container.querySelector("[data-testid='version-card']") + expect(card).toBeInTheDocument() + await waitFor(() => { + expect(card).not.toHaveClass("animate animate-fadeInDown") + }) + }) + + test("does not check localStorage when isBrowser is false", async () => { + mockUseIsBrowser.mockReturnValue({ + isBrowser: false, + }) + const { container } = render() + const card = container.querySelector("[data-testid='version-card']") + expect(card).toBeInTheDocument() + await waitFor(() => { + expect(card).not.toHaveClass("animate animate-fadeInDown") + }) + }) + + test("renders card with correct props", async () => { + const { container } = render() + await waitFor(() => { + const card = container.querySelector("[data-testid='version-card']") + expect(card).toBeInTheDocument() + }) + + const card = container.querySelector("[data-testid='version-card']") + expect(card).toHaveAttribute("data-title", "New version") + expect(card).toHaveAttribute("data-text", `v${mockVersion.number} details`) + expect(card).toHaveAttribute("data-href", mockVersion.releaseUrl) + expect(card).toHaveAttribute("data-closeable", "true") + expect(card).toHaveAttribute( + "data-theme-image-light", + mockVersion.bannerImage.light + ) + expect(card).toHaveAttribute( + "data-theme-image-dark", + mockVersion.bannerImage.dark + ) + expect(card).toHaveAttribute("data-image-width", "64") + expect(card).toHaveAttribute("data-image-height", "40") + }) + + test("renders card link with correct href props", async () => { + const { container } = render() + await waitFor(() => { + const card = container.querySelector("[data-testid='version-card']") + expect(card).toBeInTheDocument() + }) + + const link = container.querySelector("[data-testid='card-link']") + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute("href", mockVersion.releaseUrl) + expect(link).toHaveAttribute("target", "_blank") + expect(link).toHaveAttribute("rel", "noopener noreferrer") + }) + + test("renders card with correct className", async () => { + const { container } = render() + await waitFor(() => { + const card = container.querySelector("[data-testid='version-card']") + expect(card).toBeInTheDocument() + }) + + const card = container.querySelector("[data-testid='version-card']") + expect(card).toHaveClass("!border-0") + expect(card).toHaveClass("!bg-medusa-bg-component") + expect(card).toHaveClass("hover:!bg-medusa-bg-component-hover") + expect(card).toHaveClass("animation-fill-forwards") + expect(card).toHaveClass("opacity-0") + }) + + test("renders card with correct iconClassName", async () => { + const { container } = render() + await waitFor(() => { + const card = container.querySelector("[data-testid='version-card']") + expect(card).toBeInTheDocument() + }) + + const card = container.querySelector("[data-testid='version-card']") + expect(card).toHaveAttribute( + "data-icon-class-name", + "!shadow-none border-[0.5px] border-medusa-alphas-alpha-250" + ) + }) +}) + +describe("interactions", () => { + test("closes card and saves version to localStorage when close button is clicked", () => { + const { container } = render() + const card = container.querySelector("[data-testid='version-card']") + expect(card).toBeInTheDocument() + + const closeButton = container.querySelector("[data-testid='card-close']") + expect(closeButton).toBeInTheDocument() + + fireEvent.click(closeButton!) + expect(localStorage.getItem(LOCAL_STORAGE_KEY)).toBe(mockVersion.number) + }) + + test("does not close card if showNewVersion is false", async () => { + localStorage.setItem(LOCAL_STORAGE_KEY, mockVersion.number) + const { container } = render() + const card = container.querySelector("[data-testid='version-card']") + expect(card).toBeInTheDocument() + await waitFor(() => { + expect(card).not.toHaveClass("animate animate-fadeInDown") + }) + + // Card should not have close button visible when hidden + const closeButton = container.querySelector("[data-testid='card-close']") + expect(closeButton).toBeInTheDocument() + fireEvent.click(closeButton!) + expect(localStorage.getItem(LOCAL_STORAGE_KEY)).toBe(mockVersion.number) + }) +}) + +describe("animations", () => { + test("does not add animation classes when version.hide is true", async () => { + mockUseSiteConfig.mockReturnValue({ + ...defaultUseSiteConfigReturn, + config: { + version: { + ...mockVersion, + hide: true, + }, + }, + }) + const { container } = render() + const card = container.querySelector("[data-testid='version-card']") + expect(card).toBeInTheDocument() + await waitFor(() => { + // Animation classes should not be added when hide is true + expect(card).not.toHaveClass("animate") + expect(card).not.toHaveClass("animate-fadeInDown") + }) + }) + + test("does not add animation classes when showNewVersion is false", async () => { + localStorage.setItem(LOCAL_STORAGE_KEY, mockVersion.number) + const { container } = render() + const card = container.querySelector("[data-testid='version-card']") + expect(card).toBeInTheDocument() + await waitFor(() => { + // Animation classes should not be added when showNewVersion is false + expect(card).not.toHaveClass("animate") + expect(card).not.toHaveClass("animate-fadeInDown") + }) + }) +}) + +describe("version configuration", () => { + test("renders with different version number", async () => { + const newVersion = { + ...mockVersion, + number: "3.0.0", + } + mockUseSiteConfig.mockReturnValue({ + ...defaultUseSiteConfigReturn, + config: { + version: newVersion, + }, + }) + const { container } = render() + const card = container.querySelector("[data-testid='version-card']") + expect(card).toBeInTheDocument() + expect(card).toHaveAttribute("data-text", `v${newVersion.number} details`) + }) +}) diff --git a/www/packages/docs-ui/src/components/ContentMenu/Version/index.tsx b/www/packages/docs-ui/src/components/ContentMenu/Version/index.tsx index cf04fa2317..77ba264e0b 100644 --- a/www/packages/docs-ui/src/components/ContentMenu/Version/index.tsx +++ b/www/packages/docs-ui/src/components/ContentMenu/Version/index.tsx @@ -1,9 +1,10 @@ import React, { useEffect, useState } from "react" import { Card } from "../../Card" -import { useIsBrowser, useSiteConfig } from "../../../providers" +import { useIsBrowser } from "../../../providers/BrowserProvider" +import { useSiteConfig } from "../../../providers/SiteConfig" import clsx from "clsx" -const LOCAL_STORAGE_KEY = "last-version" +export const LOCAL_STORAGE_KEY = "last-version" export const ContentMenuVersion = () => { const { diff --git a/www/packages/docs-ui/src/components/ContentMenu/index.tsx b/www/packages/docs-ui/src/components/ContentMenu/index.tsx index fb2ed4ed84..1df04b3159 100644 --- a/www/packages/docs-ui/src/components/ContentMenu/index.tsx +++ b/www/packages/docs-ui/src/components/ContentMenu/index.tsx @@ -6,7 +6,7 @@ import { ContentMenuVersion } from "./Version" import { ContentMenuToc } from "./Toc" import { ContentMenuActions } from "./Actions" import { ContentMenuProducts } from "./Products" -import { useLayout } from "../../providers" +import { useLayout } from "../../providers/Layout" export const ContentMenu = () => { const { showCollapsedNavbar } = useLayout() diff --git a/www/packages/docs-ui/src/components/CopyButton/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/CopyButton/__tests__/index.test.tsx new file mode 100644 index 0000000000..e6bff2a49d --- /dev/null +++ b/www/packages/docs-ui/src/components/CopyButton/__tests__/index.test.tsx @@ -0,0 +1,196 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { fireEvent, render, waitFor } from "@testing-library/react" +import { TooltipProps } from "../../Tooltip" + +// mock functions +const mockHandleCopy = vi.fn() +const defaultUseCopyReturn = { + isCopied: false, + handleCopy: mockHandleCopy, +} +const mockUseCopy = vi.fn(() => defaultUseCopyReturn) +const mockOnCopy = vi.fn() +// mock components +vi.mock("@/components/Tooltip", () => ({ + Tooltip: ({ + children, + text, + tooltipClassName, + innerClassName, + }: TooltipProps) => ( +
    + {children} +
    + ), +})) +vi.mock("@/hooks/use-copy", () => ({ + useCopy: () => mockUseCopy(), +})) + +import { CopyButton } from "../index" + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("render", () => { + test("renders copy button", () => { + const { container } = render() + const copyButton = container.querySelector("[data-testid='copy-button']") + expect(copyButton).toBeInTheDocument() + const tooltip = container.querySelector("[data-testid='tooltip']") + expect(tooltip).toBeInTheDocument() + expect(tooltip).toHaveAttribute("data-text", "Copy to Clipboard") + }) + + test("renders copy button with tooltip text", () => { + const { container } = render( + + ) + const copyButton = container.querySelector("[data-testid='copy-button']") + expect(copyButton).toBeInTheDocument() + const tooltip = container.querySelector("[data-testid='tooltip']") + expect(tooltip).toBeInTheDocument() + expect(tooltip).toHaveAttribute("data-text", "Custom Copy") + }) + + test("renders copy button with tooltip class name", () => { + const { container } = render( + + ) + const copyButton = container.querySelector("[data-testid='copy-button']") + expect(copyButton).toBeInTheDocument() + const tooltip = container.querySelector("[data-testid='tooltip']") + expect(tooltip).toBeInTheDocument() + expect(tooltip).toHaveAttribute("data-tooltip-class-name", "custom-tooltip") + }) + + test("renders copy button with tooltip inner class name", () => { + const { container } = render( + + ) + const copyButton = container.querySelector("[data-testid='copy-button']") + expect(copyButton).toBeInTheDocument() + const tooltip = container.querySelector("[data-testid='tooltip']") + expect(tooltip).toBeInTheDocument() + expect(tooltip).toHaveAttribute( + "data-inner-class-name", + "custom-tooltip-inner" + ) + }) + + test("renders copy button with button class name", () => { + const { container } = render( + + ) + const copyButton = container.querySelector("[data-testid='copy-button']") + expect(copyButton).toBeInTheDocument() + expect(copyButton).toHaveClass("custom-button") + }) + + test("renders copy button with handleTouch", () => { + const { container } = render( + + ) + const copyButton = container.querySelector("[data-testid='copy-button']") + expect(copyButton).toBeInTheDocument() + const tooltip = container.querySelector("[data-testid='tooltip']") + expect(tooltip).toBeInTheDocument() + expect(tooltip).toHaveAttribute("data-tooltip-class-name", "!block") + }) + + test("tooltip text changes to 'Copied!' when isCopied is true", () => { + mockUseCopy.mockReturnValue({ + ...defaultUseCopyReturn, + isCopied: true, + }) + const { container } = render() + const tooltip = container.querySelector("[data-testid='tooltip']") + expect(tooltip).toBeInTheDocument() + expect(tooltip).toHaveAttribute("data-text", "Copied!") + }) +}) + +describe("interactions", () => { + test("calls handleCopy when copy button is clicked", () => { + const { container } = render() + const copyButton = container.querySelector("[data-testid='copy-button']") + expect(copyButton).toBeInTheDocument() + fireEvent.click(copyButton!) + expect(mockHandleCopy).toHaveBeenCalled() + }) + + test("calls handleCopy when copy button is clicked with handleTouch", () => { + const { container } = render( + + ) + const copyButton = container.querySelector("[data-testid='copy-button']") + expect(copyButton).toBeInTheDocument() + fireEvent.click(copyButton!) + expect(mockHandleCopy).toHaveBeenCalled() + }) + + test("doesn't call handleCopy when copy button is touched then clicked, with handleTouch", async () => { + const { container } = render( + + ) + const copyButton = container.querySelector("[data-testid='copy-button']") + expect(copyButton).toBeInTheDocument() + fireEvent.touchEnd(copyButton!) + fireEvent.click(copyButton!) + expect(mockHandleCopy).toHaveBeenCalledTimes(0) + }) + + test("doesn't call handleCopy when button is touched and handleTouch is true and touchCount is 0", () => { + const { container } = render( + + ) + const copyButton = container.querySelector("[data-testid='copy-button']") + expect(copyButton).toBeInTheDocument() + fireEvent.touchEnd(copyButton!) + expect(mockHandleCopy).not.toHaveBeenCalled() + }) + + test("calls handleCopy when button is touched and handleTouch is true and touchCount is 1", () => { + const { container } = render( + + ) + const copyButton = container.querySelector("[data-testid='copy-button']") + expect(copyButton).toBeInTheDocument() + fireEvent.touchEnd(copyButton!) + expect(mockHandleCopy).not.toHaveBeenCalled() + fireEvent.touchEnd(copyButton!) + expect(mockHandleCopy).toHaveBeenCalled() + }) + + test("calls onCopy when copy button is clicked", () => { + const { container } = render( + + ) + const copyButton = container.querySelector("[data-testid='copy-button']") + expect(copyButton).toBeInTheDocument() + fireEvent.click(copyButton!) + expect(mockOnCopy).toHaveBeenCalled() + }) + + test("calls onCopy when button is touched and handleTouch is true and touchCount is 1", () => { + const { container } = render( + + ) + const copyButton = container.querySelector("[data-testid='copy-button']") + expect(copyButton).toBeInTheDocument() + fireEvent.touchEnd(copyButton!) + expect(mockOnCopy).not.toHaveBeenCalled() + fireEvent.touchEnd(copyButton!) + expect(mockOnCopy).toHaveBeenCalled() + }) +}) diff --git a/www/packages/docs-ui/src/components/CopyButton/index.tsx b/www/packages/docs-ui/src/components/CopyButton/index.tsx index 4a63bbc316..b6c25eecb4 100644 --- a/www/packages/docs-ui/src/components/CopyButton/index.tsx +++ b/www/packages/docs-ui/src/components/CopyButton/index.tsx @@ -2,8 +2,8 @@ import React, { useState } from "react" import clsx from "clsx" -import { Tooltip } from "@/components" -import { useCopy } from "../../hooks" +import { Tooltip } from "@/components/Tooltip" +import { useCopy } from "../../hooks/use-copy" export type CopyButtonChildFn = (props: { isCopied: boolean @@ -68,6 +68,7 @@ export const CopyButton = ({ setTouchCount(0) } }} + data-testid="copy-button" > {typeof children === "function" ? children({ isCopied }) : children} diff --git a/www/packages/docs-ui/src/components/CopyGeneratedSnippetButton/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/CopyGeneratedSnippetButton/__tests__/index.test.tsx new file mode 100644 index 0000000000..432ffbf568 --- /dev/null +++ b/www/packages/docs-ui/src/components/CopyGeneratedSnippetButton/__tests__/index.test.tsx @@ -0,0 +1,52 @@ +import React from "react" +import { describe, expect, test, vi } from "vitest" +import { render } from "@testing-library/react" +import { CopyButtonProps } from "../../CopyButton" +import { UseGenerateSnippet } from "../../../hooks/use-generate-snippet" + +// mock data +const mockUseGenerateSnippetProps: UseGenerateSnippet = { + type: "subscriber", + options: { + event: "order.placed", + payload: { + id: "order_123", + }, + }, +} +const defaultUseGenerateSnippetReturn = { + snippet: "const snippet = 'snippet'", +} + +// mock functions +const mockUseGenerateSnippet = vi.fn(() => defaultUseGenerateSnippetReturn) + +// mock components +vi.mock("@/hooks/use-generate-snippet", () => ({ + useGenerateSnippet: () => mockUseGenerateSnippet(), +})) +vi.mock("@/components/CopyButton", () => ({ + CopyButton: ({ children, text }: CopyButtonProps) => ( +
    + {typeof children === "function" + ? children({ isCopied: false }) + : children} +
    + ), +})) + +import { CopyGeneratedSnippetButton } from "../../CopyGeneratedSnippetButton" + +describe("render", () => { + test("renders copy generated snippet button", () => { + const { container } = render( + + ) + const copyButton = container.querySelector("[data-testid='copy-button']") + expect(copyButton).toBeInTheDocument() + expect(copyButton).toHaveAttribute( + "data-text", + defaultUseGenerateSnippetReturn.snippet + ) + }) +}) diff --git a/www/packages/docs-ui/src/components/CopyGeneratedSnippetButton/index.tsx b/www/packages/docs-ui/src/components/CopyGeneratedSnippetButton/index.tsx index 6774753cd8..01939a4522 100644 --- a/www/packages/docs-ui/src/components/CopyGeneratedSnippetButton/index.tsx +++ b/www/packages/docs-ui/src/components/CopyGeneratedSnippetButton/index.tsx @@ -1,7 +1,11 @@ "use client" import React from "react" -import { CopyButton, useGenerateSnippet, UseGenerateSnippet } from "../.." +import { CopyButton } from "@/components/CopyButton" +import { + useGenerateSnippet, + UseGenerateSnippet, +} from "@/hooks/use-generate-snippet" import { SquareTwoStack, CheckCircle } from "@medusajs/icons" export type CopyGeneratedSnippetButtonProps = UseGenerateSnippet & { diff --git a/www/packages/docs-ui/src/components/Details/Summary/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Details/Summary/__tests__/index.test.tsx new file mode 100644 index 0000000000..8f717dc9bd --- /dev/null +++ b/www/packages/docs-ui/src/components/Details/Summary/__tests__/index.test.tsx @@ -0,0 +1,132 @@ +import React from "react" +import { describe, expect, test, vi } from "vitest" +import { render } from "@testing-library/react" +import { DetailsSummary } from "../index" + +describe("render", () => { + test("renders details summary with children", () => { + const { container } = render(Test) + const summary = container.querySelector("summary") + expect(summary).toBeInTheDocument() + const title = container.querySelector( + "[data-testid='details-summary-title']" + ) + expect(title).toBeInTheDocument() + expect(title).toHaveTextContent("Test") + }) + + test("renders details summary with title", () => { + const { container } = render() + const summary = container.querySelector("summary") + expect(summary).toBeInTheDocument() + const title = container.querySelector( + "[data-testid='details-summary-title']" + ) + expect(title).toBeInTheDocument() + expect(title).toHaveTextContent("Test") + }) + + test("renders details summary with subtitle", () => { + const { container } = render() + const summary = container.querySelector("summary") + expect(summary).toBeInTheDocument() + const subtitle = container.querySelector( + "[data-testid='details-summary-subtitle']" + ) + expect(subtitle).toBeInTheDocument() + expect(subtitle).toHaveTextContent("Test") + }) + + test("renders details summary with badge", () => { + const badge =
    Test Badge
    + const { container } = render() + const summary = container.querySelector("summary") + expect(summary).toBeInTheDocument() + const extra = container.querySelector( + "[data-testid='details-summary-extra']" + ) + expect(extra).toBeInTheDocument() + const badgeElement = extra?.querySelector("[data-testid='test-badge']") + expect(badgeElement).toBeInTheDocument() + expect(badgeElement).toHaveTextContent("Test Badge") + }) + + test("renders details summary with expandable", () => { + const { container } = render() + const summary = container.querySelector("summary") + expect(summary).toBeInTheDocument() + expect(summary).toHaveClass("cursor-pointer") + expect(summary).toHaveClass("gap-0.5") + const extra = container.querySelector( + "[data-testid='details-summary-extra']" + ) + expect(extra).toBeInTheDocument() + const icon = extra?.querySelector("svg") + expect(icon).toBeInTheDocument() + expect(icon).toHaveClass("transition-transform") + expect(icon).not.toHaveClass("rotate-45") + }) + + test("renders details summary with hideExpandableIcon", () => { + const { container } = render() + const summary = container.querySelector("summary") + expect(summary).toBeInTheDocument() + const extra = container.querySelector( + "[data-testid='details-summary-extra']" + ) + expect(extra).toBeInTheDocument() + const icon = extra?.querySelector("svg") + expect(icon).not.toBeInTheDocument() + }) + + test("renders details summary with className", () => { + const { container } = render() + const summary = container.querySelector("summary") + expect(summary).toBeInTheDocument() + expect(summary).toHaveClass("test-class") + }) + + test("renders details summary with titleClassName", () => { + const { container } = render( + + ) + const summary = container.querySelector("summary") + expect(summary).toBeInTheDocument() + const title = container.querySelector( + "[data-testid='details-summary-title']" + ) + expect(title).toBeInTheDocument() + expect(title).toHaveClass("test-title-class") + }) + + test("renders details summary with summaryRef", () => { + const summaryRef = vi.fn() + const { container } = render() + const summary = container.querySelector("summary") + expect(summary).toBeInTheDocument() + expect(summaryRef).toHaveBeenCalled() + }) + + test("renders details summary with rest props", () => { + const { container } = render() + const summary = container.querySelector("[data-testid='test-summary']") + expect(summary).toBeInTheDocument() + expect(summary).toHaveAttribute("data-testid", "test-summary") + }) + + test("renders with expandable and open", () => { + const { container } = render() + const summary = container.querySelector("summary") + expect(summary).toBeInTheDocument() + expect(summary).toHaveClass("cursor-pointer") + expect(summary).toHaveClass("gap-0.5") + const extra = container.querySelector( + "[data-testid='details-summary-extra']" + ) + expect(extra).toBeInTheDocument() + const icon = extra?.querySelector("svg") + expect(icon).toBeInTheDocument() + expect(icon).toHaveClass("transition-transform") + expect(icon).toHaveClass("rotate-45") + }) +}) diff --git a/www/packages/docs-ui/src/components/Details/Summary/index.tsx b/www/packages/docs-ui/src/components/Details/Summary/index.tsx index 3a45282c4c..e4b0b47afe 100644 --- a/www/packages/docs-ui/src/components/Details/Summary/index.tsx +++ b/www/packages/docs-ui/src/components/Details/Summary/index.tsx @@ -47,17 +47,21 @@ export const DetailsSummary = ({ "text-compact-medium-plus text-medusa-fg-base", titleClassName )} + data-testid="details-summary-title" > {title || children} {subtitle && ( - + {subtitle} )} {(badge || expandable) && ( - + {badge} {expandable && !hideExpandableIcon && ( children +) +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const mockUseCollapsible = vi.fn((_options: CollapsibleProps) => ({ + getCollapsibleElms: mockGetCollapsibleElms, + setCollapsed: mockSetCollapsed, +})) + +// mock components +vi.mock("@/hooks/use-collapsible", () => ({ + useCollapsible: (options: CollapsibleProps) => mockUseCollapsible(options), +})) + +vi.mock("@/components/Loading", () => ({ + Loading: ({ className }: { className?: string }) => ( +
    + Loading... +
    + ), +})) + +vi.mock("@/components/Details/Summary", () => ({ + DetailsSummary: ({ title, onClick }: DetailsSummaryProps) => ( + + {title} + + ), +})) + +// Mock Suspense to avoid act warnings - render children directly +vi.mock("react", async () => { + const actual = await vi.importActual("react") + return { + ...actual, + Suspense: ({ children }: { children: React.ReactNode }) => <>{children}, + } +}) + +import { Details } from "../../Details" +import { DetailsSummary, DetailsSummaryProps } from "../Summary" + +beforeEach(() => { + mockUseCollapsible.mockClear() + mockUseCollapsible.mockReturnValue({ + getCollapsibleElms: mockGetCollapsibleElms, + setCollapsed: mockSetCollapsed, + }) + mockSetCollapsed.mockClear() + mockGetCollapsibleElms.mockImplementation( + (children: React.ReactNode): React.ReactNode => children + ) +}) + +describe("rendering", () => { + test("renders details element", () => { + const { container } = render( +
    +
    Content
    +
    + ) + const details = container.querySelector("details") + expect(details).toBeInTheDocument() + expect(details).toHaveTextContent("Content") + }) + + test("renders with summaryContent", () => { + const { container } = render( +
    +
    Content
    +
    + ) + const summary = container.querySelector("summary") + expect(summary).toBeInTheDocument() + const title = container.querySelector( + "[data-testid='details-summary-title']" + ) + expect(title).toBeInTheDocument() + expect(title).toHaveTextContent("Summary Title") + }) + + test("renders with summaryElm", () => { + const summaryElm = + const { container } = render( +
    +
    Content
    +
    + ) + const summary = container.querySelector("summary") + expect(summary).toBeInTheDocument() + const title = container.querySelector( + "[data-testid='details-summary-title']" + ) + expect(title).toBeInTheDocument() + expect(title).toHaveTextContent("Custom Summary") + }) + + test("renders with className", () => { + const { container } = render( +
    +
    Content
    +
    + ) + const details = container.querySelector("details") + expect(details).toHaveClass("custom-class") + }) +}) + +describe("initial state", () => { + test("renders closed by default when openInitial is false", () => { + const { container } = render( +
    +
    Content
    +
    + ) + const details = container.querySelector("details") + expect(details).not.toHaveAttribute("open") + }) + + test("renders open when openInitial is true", () => { + const { container } = render( +
    +
    Content
    +
    + ) + const details = container.querySelector("details") + expect(details).toHaveAttribute("open") + }) + + test("passes correct initialValue to useCollapsible when openInitial is false", () => { + render( +
    +
    Content
    +
    + ) + expect(mockUseCollapsible).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: true, // !openInitial = !false = true + }) + ) + }) + + test("passes correct initialValue to useCollapsible when openInitial is true", () => { + render( +
    +
    Content
    +
    + ) + expect(mockUseCollapsible).toHaveBeenCalledWith( + expect.objectContaining({ + initialValue: false, // !openInitial = !true = false + }) + ) + }) +}) + +describe("height animation", () => { + test("passes heightAnimation to useCollapsible when true", () => { + render( +
    +
    Content
    +
    + ) + expect(mockUseCollapsible).toHaveBeenCalledWith( + expect.objectContaining({ + heightAnimation: true, + }) + ) + }) + + test("passes heightAnimation to useCollapsible when false", () => { + render( +
    +
    Content
    +
    + ) + expect(mockUseCollapsible).toHaveBeenCalledWith( + expect.objectContaining({ + heightAnimation: false, + }) + ) + }) +}) + +describe("interactions", () => { + test("toggles open state when summary is clicked", () => { + const { container } = render( +
    +
    Content
    +
    + ) + const details = container.querySelector("details") + const summary = container.querySelector("summary") + expect(details).not.toHaveAttribute("open") + + fireEvent.click(summary!) + expect(details).toHaveAttribute("open") + expect(mockSetCollapsed).toHaveBeenCalledWith(false) + }) + + test("closes when summary is clicked and already open", () => { + mockUseCollapsible.mockReturnValue({ + getCollapsibleElms: mockGetCollapsibleElms, + setCollapsed: mockSetCollapsed, + }) + const { container } = render( +
    +
    Content
    +
    + ) + const details = container.querySelector("details") + const summary = container.querySelector("summary") + expect(details).toHaveAttribute("open") + + fireEvent.click(summary!) + expect(mockSetCollapsed).toHaveBeenCalledWith(true) + }) + + test("prevents default on details click", () => { + const { container } = render( +
    +
    Content
    +
    + ) + const details = container.querySelector("details") + const clickEvent = new MouseEvent("click", { + bubbles: true, + cancelable: true, + }) + const preventDefaultSpy = vi.spyOn(clickEvent, "preventDefault") + + fireEvent(details!, clickEvent) + expect(preventDefaultSpy).toHaveBeenCalled() + }) + + test("stops propagation on toggle event", () => { + const { container } = render( +
    +
    Content
    +
    + ) + const details = container.querySelector("details") + const toggleEvent = new Event("toggle", { bubbles: true, cancelable: true }) + const stopPropagationSpy = vi.spyOn(toggleEvent, "stopPropagation") + + fireEvent(details!, toggleEvent) + expect(stopPropagationSpy).toHaveBeenCalled() + }) + + test("navigates to link when link is clicked in summary", () => { + const originalLocation = window.location + delete (window as unknown as { location?: Location }).location + Object.defineProperty(window, "location", { + value: { href: "http://localhost" }, + writable: true, + configurable: true, + }) + + const { container } = render( +
    Link}> +
    Content
    +
    + ) + const link = container.querySelector("a") + expect(link).toBeInTheDocument() + + fireEvent.click(link!) + expect(window.location.href).toBe("/test") + + Object.defineProperty(window, "location", { + value: originalLocation, + writable: true, + configurable: true, + }) + }) + + test("does not toggle when code element is clicked", () => { + const { container } = render( +
    Code}> +
    Content
    +
    + ) + const details = container.querySelector("details") + const code = container.querySelector("code") + expect(details).not.toHaveAttribute("open") + + mockSetCollapsed.mockClear() + fireEvent.click(code!) + // Should not toggle when code is clicked + expect(mockSetCollapsed).not.toHaveBeenCalled() + }) +}) + +describe("summary element cloning", () => { + test("clones summaryElm with open and onClick props", () => { + const summaryElm = + const { container } = render( +
    +
    Content
    +
    + ) + const summary = container.querySelector("summary") + expect(summary).toBeInTheDocument() + // The cloned element should receive open prop + const title = container.querySelector( + "[data-testid='details-summary-title']" + ) + expect(title).toBeInTheDocument() + }) + + test("clones summaryElm with onClick handler", () => { + const summaryElm = + const { container } = render( +
    +
    Content
    +
    + ) + const summary = container.querySelector("summary") + expect(summary).toBeInTheDocument() + + fireEvent.click(summary!) + expect(mockSetCollapsed).toHaveBeenCalled() + }) +}) + +describe("collapsible integration", () => { + test("calls onClose callback when collapsed", () => { + mockUseCollapsible.mockReturnValue({ + getCollapsibleElms: mockGetCollapsibleElms, + setCollapsed: mockSetCollapsed, + }) + + render( +
    +
    Content
    +
    + ) + + expect(mockUseCollapsible).toHaveBeenCalledWith( + expect.objectContaining({ + onClose: expect.any(Function), + }) + ) + }) +}) diff --git a/www/packages/docs-ui/src/components/Details/index.tsx b/www/packages/docs-ui/src/components/Details/index.tsx index 97b6499c39..c1cde53915 100644 --- a/www/packages/docs-ui/src/components/Details/index.tsx +++ b/www/packages/docs-ui/src/components/Details/index.tsx @@ -1,10 +1,10 @@ "use client" import React, { Suspense, cloneElement, useRef, useState } from "react" -import { Loading } from "@/components" +import { Loading } from "@/components/Loading" import clsx from "clsx" import { DetailsSummary, DetailsSummaryProps } from "./Summary" -import { useCollapsible } from "../../hooks" +import { useCollapsible } from "../../hooks/use-collapsible" export type DetailsProps = { openInitial?: boolean diff --git a/www/packages/docs-ui/src/components/DetailsList/index.tsx b/www/packages/docs-ui/src/components/DetailsList/index.tsx index 6971aada94..91c72906e7 100644 --- a/www/packages/docs-ui/src/components/DetailsList/index.tsx +++ b/www/packages/docs-ui/src/components/DetailsList/index.tsx @@ -1,6 +1,7 @@ import React from "react" import clsx from "clsx" -import { Details, MarkdownContent } from "@/components" +import { Details } from "@/components/Details" +import { MarkdownContent } from "@/components/MarkdownContent" type TroubleshootingSection = { title: string diff --git a/www/packages/docs-ui/src/components/DottedSeparator/index.tsx b/www/packages/docs-ui/src/components/DottedSeparator/index.tsx index f8011455fc..4f180c8eda 100644 --- a/www/packages/docs-ui/src/components/DottedSeparator/index.tsx +++ b/www/packages/docs-ui/src/components/DottedSeparator/index.tsx @@ -6,14 +6,19 @@ import React from "react" export type DottedSeparatorProps = { wrapperClassName?: string className?: string + "data-testid"?: string } export const DottedSeparator = ({ className, wrapperClassName, + "data-testid": testId, }: DottedSeparatorProps) => { return ( -
    +
    ({ + EditDate: ({ date }: { date: string }) => ( +
    {date}
    + ), +})) + +import { EditButton } from "../../EditButton" + +describe("render", () => { + test("renders edit button", () => { + const { container } = render() + expect(container).toBeInTheDocument() + const editButton = container.querySelector("[data-testid='edit-button']") + expect(editButton).toBeInTheDocument() + expect(editButton).toHaveTextContent("Edit this page") + expect(editButton).toHaveAttribute( + "href", + "https://github.com/medusajs/medusa/edit/develop/test.md" + ) + }) + + test("renders edit button with edit date", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const editDate = container.querySelector("[data-testid='edit-date']") + expect(editDate).toBeInTheDocument() + expect(editDate).toHaveTextContent("2021-01-01") + }) +}) diff --git a/www/packages/docs-ui/src/components/EditButton/index.tsx b/www/packages/docs-ui/src/components/EditButton/index.tsx index a07725cd7c..3a72542def 100644 --- a/www/packages/docs-ui/src/components/EditButton/index.tsx +++ b/www/packages/docs-ui/src/components/EditButton/index.tsx @@ -21,6 +21,7 @@ export const EditButton = ({ filePath, editDate }: EditButtonProps) => { "text-medusa-fg-subtle hover:text-medusa-fg-base", "text-compact-small-plus" )} + data-testid="edit-button" > Edit this page diff --git a/www/packages/docs-ui/src/components/EditDate/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/EditDate/__tests__/index.test.tsx new file mode 100644 index 0000000000..cab6fb3ba7 --- /dev/null +++ b/www/packages/docs-ui/src/components/EditDate/__tests__/index.test.tsx @@ -0,0 +1,35 @@ +import React from "react" +import { describe, expect, test } from "vitest" +import { render } from "@testing-library/react" +import { EditDate } from "../../EditDate" + +// mock data +const today = new Date() + +describe("render", () => { + test("renders edit date", () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + const editDate = container.querySelector("[data-testid='edit-date']") + expect(editDate).toBeInTheDocument() + expect(editDate).toHaveTextContent("Edited Jan 1") + }) + + test("renders edit date with different year", () => { + const lastYear = today.getFullYear() - 1 + const { container } = render() + expect(container).toBeInTheDocument() + const editDate = container.querySelector("[data-testid='edit-date']") + expect(editDate).toBeInTheDocument() + expect(editDate).toHaveTextContent(`Edited Jan 1, ${lastYear}`) + }) + + test("doesn't render edit date if date is invalid", () => { + const { container } = render() + expect(container).toBeInTheDocument() + const editDate = container.querySelector("[data-testid='edit-date']") + expect(editDate).not.toBeInTheDocument() + }) +}) diff --git a/www/packages/docs-ui/src/components/EditDate/index.tsx b/www/packages/docs-ui/src/components/EditDate/index.tsx index e4c31f554e..498af151a5 100644 --- a/www/packages/docs-ui/src/components/EditDate/index.tsx +++ b/www/packages/docs-ui/src/components/EditDate/index.tsx @@ -7,8 +7,11 @@ type EditDateProps = { } export const EditDate = ({ date }: EditDateProps) => { - const today = new Date(date) + const today = new Date() const dateObj = new Date(date) + if (isNaN(dateObj.getTime())) { + return <> + } const formattedDate = dateObj.toString() const dateMatch = DATE_REGEX.exec(formattedDate) @@ -18,7 +21,7 @@ export const EditDate = ({ date }: EditDateProps) => { return ( <> - + Edited {dateMatch.groups.month} {dateObj.getDate()} {dateObj.getFullYear() !== today.getFullYear() ? `, ${dateObj.getFullYear()}` diff --git a/www/packages/docs-ui/src/components/Feedback/Solutions/__tests__/index.test.tsx b/www/packages/docs-ui/src/components/Feedback/Solutions/__tests__/index.test.tsx new file mode 100644 index 0000000000..37f07c2b27 --- /dev/null +++ b/www/packages/docs-ui/src/components/Feedback/Solutions/__tests__/index.test.tsx @@ -0,0 +1,158 @@ +import React from "react" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { render, waitFor } from "@testing-library/react" +import { GitHubSearchItem } from "../../.." + +// mock data +const mockGitHubSearchItem: GitHubSearchItem = { + url: "https://github.com/medusajs/medusa/issues/123", + html_url: "https://github.com/medusajs/medusa/issues/123", + title: "Test Issue", +} + +// mock hooks +const mockSearchGitHub = vi.fn( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async (_input: RequestInfo | URL, _init?: RequestInit | undefined) => { + const response = new Response( + JSON.stringify({ items: [mockGitHubSearchItem] }) + ) + // Mock the json() method to return the parsed data + response.json = vi.fn().mockResolvedValue({ items: [mockGitHubSearchItem] }) + return Promise.resolve(response) + } +) + +// mock components +vi.mock("@/components/Link", () => ({ + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})) +vi.mock("@/components/MDXComponents", () => ({ + MDXComponents: { + ul: (props: React.HTMLAttributes) =>
      , + li: (props: React.HTMLAttributes) =>
    • , + }, +})) + +// helper function +const getGitHubSearchUrl = (query: string) => + `https://api.github.com/search/issues?q=${encodeURIComponent(`${query} repo:medusajs/medusa is:closed is:issue`)}&sort=updated&per_page=3&advanced_search=true` + +import { Solutions } from "../../Solutions" + +beforeEach(() => { + window.fetch = mockSearchGitHub + mockSearchGitHub.mockClear() +}) + +describe("render", () => { + test("doesn't render solutions if feedback is true (positive)", () => { + const { container } = render() + expect(container).toBeInTheDocument() + const solutionItems = container.querySelectorAll( + "[data-testid='solution-item']" + ) + expect(solutionItems).toHaveLength(0) + expect(mockSearchGitHub).not.toHaveBeenCalled() + }) + + test("renders solutions if feedback is false using document title", async () => { + document.title = "Test Page" + const { container } = render() + expect(container).toBeInTheDocument() + await waitFor(() => { + expect(mockSearchGitHub).toHaveBeenCalledWith( + getGitHubSearchUrl("Test Page"), + { + headers: { + Accept: "application/vnd.github.v3+json", + }, + } + ) + }) + const solutionItems = container.querySelectorAll( + "[data-testid='solution-item']" + ) + expect(solutionItems).toHaveLength(1) + expect(solutionItems[0]).toBeInTheDocument() + expect(solutionItems[0]).toHaveTextContent("Test Issue") + }) + + test("renders solutions if feedback is false using message", async () => { + const { container } = render( + + ) + expect(container).toBeInTheDocument() + await waitFor(() => { + expect(mockSearchGitHub).toHaveBeenCalledWith( + getGitHubSearchUrl("Test Message"), + { + headers: { + Accept: "application/vnd.github.v3+json", + }, + } + ) + }) + const solutionItems = container.querySelectorAll( + "[data-testid='solution-item']" + ) + expect(solutionItems).toHaveLength(1) + expect(solutionItems[0]).toBeInTheDocument() + expect(solutionItems[0]).toHaveTextContent("Test Issue") + }) + + test("uses first 256 characters of message if feedback is false and it is longer than 256 characters", () => { + const longMessage = "a".repeat(257) + const shortMessage = longMessage.substring(0, 256) + const { container } = render( + + ) + expect(container).toBeInTheDocument() + expect(mockSearchGitHub).toHaveBeenCalledWith( + getGitHubSearchUrl(shortMessage), + { + headers: { + Accept: "application/vnd.github.v3+json", + }, + } + ) + }) + + test("gets query from document title if feedback is false and no results are provided for message", async () => { + document.title = "Test Page" + mockSearchGitHub.mockImplementation(async () => { + const response = new Response(JSON.stringify({ items: [] })) + response.json = vi.fn().mockResolvedValue({ items: [] }) + return response + }) + const { container } = render( + + ) + expect(container).toBeInTheDocument() + await waitFor(() => { + expect(mockSearchGitHub).toHaveBeenCalledTimes(2) + }) + const solutionItems = container.querySelectorAll( + "[data-testid='solution-item']" + ) + expect(solutionItems).toHaveLength(0) + expect(mockSearchGitHub).toHaveBeenCalledWith( + getGitHubSearchUrl("Test Message"), + { + headers: { + Accept: "application/vnd.github.v3+json", + }, + } + ) + expect(mockSearchGitHub).toHaveBeenCalledWith( + getGitHubSearchUrl("Test Page"), + { + headers: { + Accept: "application/vnd.github.v3+json", + }, + } + ) + }) +}) diff --git a/www/packages/docs-ui/src/components/Feedback/Solutions/index.tsx b/www/packages/docs-ui/src/components/Feedback/Solutions/index.tsx index e0441690da..9817043e2b 100644 --- a/www/packages/docs-ui/src/components/Feedback/Solutions/index.tsx +++ b/www/packages/docs-ui/src/components/Feedback/Solutions/index.tsx @@ -1,7 +1,8 @@ "use client" import React, { useEffect, useState } from "react" -import { Link, MDXComponents } from "@/components" +import { Link } from "@/components/Link" +import { MDXComponents } from "@/components/MDXComponents" export type SolutionsProps = { feedback: boolean @@ -75,7 +76,7 @@ export const Solutions = ({ feedback, message }: SolutionsProps) => {
        {possibleSolutions.map((solution) => ( -
      • +
      • void } }) => { + // Call callback immediately to simulate synchronous behavior + event.callback?.() +}) +const mockUseSiteConfig = vi.fn(() => defaultUseSiteConfigReturn) + +// mock components +vi.mock("@/providers/Analytics", () => ({ + useAnalytics: () => ({ + track: mockTrack, + }), +})) + +vi.mock("@/providers/SiteConfig", () => ({ + useSiteConfig: () => mockUseSiteConfig(), +})) + +vi.mock("@/components/Button", () => ({ + Button: (props: ButtonProps) =>