docs: added support for tests in www workspace (#14415)

* tests for components (WIP)

* finished adding tests to components

* added tests for providers

* add test command to doc tests

* fix imports

* exclude test files

* remove import

* add vitest as dev dependency

* fix build error

* ignore test files from eslint

* fix test from docs-ui
This commit is contained in:
Shahed Nasser
2025-12-30 13:19:57 +02:00
committed by GitHub
parent e110c08970
commit 40db4c22d3
302 changed files with 27578 additions and 531 deletions

View File

@@ -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

View File

@@ -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/*/"],
},
},
}];
},
]

View File

@@ -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"

View File

@@ -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": "*",

View File

@@ -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(<AiAssistantChatWindowHeader />)
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(<AiAssistantChatWindowHeader />)
const button = container.querySelector("button")
expect(button).toBeInTheDocument()
fireEvent.click(button!)
expect(AiAssistantMocks.mockSetChatOpened).toHaveBeenCalledTimes(1)
expect(AiAssistantMocks.mockSetChatOpened).toHaveBeenCalledWith(false)
})

View File

@@ -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()

View File

@@ -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 }) => (
<div>{children}</div>
),
}))
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
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(
<AiAssistantChatWindowInput
chatWindowRef={React.createRef<HTMLDivElement>()}
/>
)
const deepThinkingButton = container.querySelector("button")
const icon = deepThinkingButton?.querySelector("svg")
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass("text-medusa-tag-orange-icon")
})
})

View File

@@ -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"

View File

@@ -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 }) => (
<div>{children}</div>
),
}))
vi.mock("@/components/AiAssistant/ChatWindow/Header", () => ({
AiAssistantChatWindowHeader: () => <div>Header</div>,
}))
vi.mock("@/components/AiAssistant/ChatWindow/Input", () => ({
AiAssistantChatWindowInput: () => <div>Input</div>,
}))
vi.mock("@/components/AiAssistant/ChatWindow/Footer", () => ({
AiAssistantChatWindowFooter: () => <div>Footer</div>,
}))
vi.mock("@/components/AiAssistant/Suggestions", () => ({
AiAssistantSuggestions: () => <div>Suggestions</div>,
}))
vi.mock("@/components/AiAssistant/ThreadItem", () => ({
AiAssistantThreadItem: ({ item }: AiAssistantThreadItemProps) => (
<div className="thread-item">ThreadItem - type: {item.type}</div>
),
}))
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(<AiAssistantChatWindow />)
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(<AiAssistantChatWindow />)
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(<AiAssistantChatWindow />)
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(<AiAssistantChatWindow />)
expect(container).toBeInTheDocument()
expect(container).toHaveTextContent("Suggestions")
})
test("show thread items when conversation is not empty", () => {
AiAssistantMocks.mockConversation.length = 2
const { container } = render(<AiAssistantChatWindow />)
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(<AiAssistantChatWindow />)
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(<AiAssistantChatWindow />)
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(<AiAssistantChatWindow />)
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(<AiAssistantChatWindow />)
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")
})
})

View File

@@ -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"

View File

@@ -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 = () => {

View File

@@ -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"
}) => (
<a
href={href}
className={
variant === "content"
? "text-medusa-fg-content"
: "text-medusa-fg-muted"
}
>
{children}
</a>
),
}))
vi.mock("@/components/Search/Hits/GroupName", () => ({
SearchHitGroupName: ({ name }: { name: string }) => <div>{name}</div>,
}))
vi.mock("@/components/Search/Suggestions/Item", () => ({
SearchSuggestionItem: ({
children,
onClick,
}: {
children: React.ReactNode
onClick: () => void
}) => (
<div onClick={onClick} className="suggestion-item">
{children}
</div>
),
}))
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(<AiAssistantSuggestions />)
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(<AiAssistantSuggestions />)
expect(container).toBeInTheDocument()
const suggestionItem = container.querySelector(".suggestion-item")
expect(suggestionItem).toBeInTheDocument()
fireEvent.click(suggestionItem!)
expect(AiAssistantMocks.mockSubmitQuery).toHaveBeenCalledWith(
suggestionItem!.textContent
)
})
})

View File

@@ -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<HTMLDivElement>

View File

@@ -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) => <div {...props} />,
}))
vi.mock("@/components/Button", () => ({
Button: (props: ButtonProps) => <button {...props} />,
}))
vi.mock("@/components/Link", () => ({
Link: (props: LinkProps) => <a {...props} />,
}))
vi.mock("@/hooks/use-copy", () => ({
useCopy: () => useCopyMock(),
}))
import { AiAssistantThreadItemActions } from "../../Actions"
beforeEach(() => {
AiAssistantMocks.mockUseChat.mockReturnValue(
AiAssistantMocks.defaultUseChatReturn
)
AiAssistantMocks.mockAddFeedback.mockClear()
AiAssistantMocks.mockSubmitQuery.mockClear()
AiAssistantMocks.mockStopGeneration.mockClear()
AiAssistantMocks.mockConversation.length = 1
mockHandleCopy.mockClear()
useCopyMock.mockReturnValue(defaultUseCopyReturn)
})
describe("rendering", () => {
test("renders question thread item actions", () => {
const { container } = render(
<AiAssistantThreadItemActions item={mockQuestionThreadItem} />
)
expect(container).toBeInTheDocument()
const wrapper = container.querySelector("div")
expect(wrapper).toHaveClass("justify-end")
const linkCopyButton = container.querySelector(
"[data-testid='link-copy-button']"
)
expect(linkCopyButton).toBeInTheDocument()
})
test("renders answer thread item actions", () => {
const { container } = render(
<AiAssistantThreadItemActions item={mockAnswerThreadItem} />
)
expect(container).toBeInTheDocument()
const wrapper = container.querySelector("div")
expect(wrapper).toHaveClass("justify-between")
const answerCopyButton = container.querySelector(
"[data-testid='answer-copy-button']"
)
expect(answerCopyButton).toBeInTheDocument()
const upvoteButton = container.querySelector(
"[data-testid='upvote-button']"
)
expect(upvoteButton).toBeInTheDocument()
const downvoteButton = container.querySelector(
"[data-testid='downvote-button']"
)
expect(downvoteButton).toBeInTheDocument()
})
})
describe("question interactions", () => {
test("clicking link copy button should copy the link", () => {
const { container } = render(
<AiAssistantThreadItemActions item={mockQuestionThreadItem} />
)
expect(container).toBeInTheDocument()
const linkCopyButton = container.querySelector(
"[data-testid='link-copy-button']"
)
expect(linkCopyButton).toBeInTheDocument()
fireEvent.click(linkCopyButton!)
expect(mockHandleCopy).toHaveBeenCalledTimes(1)
})
})
describe("answer interactions", () => {
test("clicking answer copy button should copy the answer", () => {
const { container } = render(
<AiAssistantThreadItemActions item={mockAnswerThreadItem} />
)
expect(container).toBeInTheDocument()
const answerCopyButton = container.querySelector(
"[data-testid='answer-copy-button']"
)
expect(answerCopyButton).toBeInTheDocument()
fireEvent.click(answerCopyButton!)
expect(mockHandleCopy).toHaveBeenCalledTimes(1)
})
test("clicking upvote button should upvote the answer", () => {
const { container } = render(
<AiAssistantThreadItemActions item={mockAnswerThreadItem} />
)
expect(container).toBeInTheDocument()
const upvoteButton = container.querySelector(
"[data-testid='upvote-button']"
)
expect(upvoteButton).toBeInTheDocument()
fireEvent.click(upvoteButton!)
expect(AiAssistantMocks.mockAddFeedback).toHaveBeenCalledTimes(1)
expect(AiAssistantMocks.mockAddFeedback).toHaveBeenCalledWith(
mockAnswerThreadItem.question_id,
"upvote"
)
})
test("clicking downvote button should downvote the answer", () => {
const { container } = render(
<AiAssistantThreadItemActions item={mockAnswerThreadItem} />
)
expect(container).toBeInTheDocument()
const downvoteButton = container.querySelector(
"[data-testid='downvote-button']"
)
expect(downvoteButton).toBeInTheDocument()
fireEvent.click(downvoteButton!)
expect(AiAssistantMocks.mockAddFeedback).toHaveBeenCalledTimes(1)
expect(AiAssistantMocks.mockAddFeedback).toHaveBeenCalledWith(
mockAnswerThreadItem.question_id,
"downvote"
)
})
test("downvote button should be hidden if the answer has been upvoted", () => {
const { container } = render(
<AiAssistantThreadItemActions item={mockAnswerThreadItem} />
)
expect(container).toBeInTheDocument()
const downvoteButton = container.querySelector(
"[data-testid='downvote-button']"
)
expect(downvoteButton).toBeInTheDocument()
const upvoteButton = container.querySelector(
"[data-testid='upvote-button']"
)
expect(upvoteButton).toBeInTheDocument()
fireEvent.click(upvoteButton!)
expect(downvoteButton).not.toBeInTheDocument()
})
test("upvote button should be hidden if the answer has been downvoted", () => {
const { container } = render(
<AiAssistantThreadItemActions item={mockAnswerThreadItem} />
)
expect(container).toBeInTheDocument()
const upvoteButton = container.querySelector(
"[data-testid='upvote-button']"
)
expect(upvoteButton).toBeInTheDocument()
const downvoteButton = container.querySelector(
"[data-testid='downvote-button']"
)
expect(downvoteButton).toBeInTheDocument()
fireEvent.click(downvoteButton!)
expect(upvoteButton).not.toBeInTheDocument()
})
})

View File

@@ -1,6 +1,8 @@
import React, { useState } from "react"
import clsx from "clsx"
import { Badge, Button, Link, type ButtonProps } from "@/components"
import { Badge } from "@/components/Badge"
import { Button, type ButtonProps } from "@/components/Button"
import { Link } from "@/components/Link"
import {
ThumbDown,
ThumbUp,
@@ -8,12 +10,10 @@ import {
CheckCircle,
SquareTwoStack,
} from "@medusajs/icons"
import {
AiAssistantThreadItem as AiAssistantThreadItemType,
useSiteConfig,
} from "../../../../providers"
import { useSiteConfig } from "../../../../providers/SiteConfig"
import { AiAssistantThreadItem as AiAssistantThreadItemType } from "../../../../providers/AiAssistant"
import { Reaction, useChat } from "@kapaai/react-sdk"
import { useCopy } from "../../../../hooks"
import { useCopy } from "../../../../hooks/use-copy"
export type AiAssistantThreadItemActionsProps = {
item: AiAssistantThreadItemType
@@ -59,7 +59,7 @@ export const AiAssistantThreadItemActions = ({
>
{item.type === "question" && (
<div className="flex gap-docs_0.25 items-center text-medusa-fg-muted">
<ActionButton onClick={handleLinkCopy}>
<ActionButton onClick={handleLinkCopy} data-testid="link-copy-button">
{isLinkCopied ? <CheckCircle /> : <LinkIcon />}
</ActionButton>
</div>
@@ -78,12 +78,16 @@ export const AiAssistantThreadItemActions = ({
</div>
)}
<div className="flex gap-docs_0.25 items-center text-medusa-fg-muted">
<ActionButton onClick={handleAnswerCopy}>
<ActionButton
onClick={handleAnswerCopy}
data-testid="answer-copy-button"
>
{isAnswerCopied ? <CheckCircle /> : <SquareTwoStack />}
</ActionButton>
{(feedback === null || feedback === "upvote") && (
<ActionButton
onClick={async () => 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"
)}

View File

@@ -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: () => <span>AiAssistantIcon</span>,
}))
vi.mock("@/components/CodeMdx", () => ({
CodeMdx: (props: CodeMdxProps) => <code {...props} />,
}))
vi.mock("@/components/MarkdownContent", () => ({
MarkdownContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}))
vi.mock("@/components/MDXComponents", () => ({
MDXComponents: () => {
return {}
},
}))
vi.mock("@/components/AiAssistant/ThreadItem/Actions", () => ({
AiAssistantThreadItemActions: () => <div>AiAssistantThreadItemActions</div>,
}))
vi.mock("@/components/Loading/Dots", () => ({
DotsLoading: () => <div>DotsLoading</div>,
}))
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(
<AiAssistantThreadItem item={mockQuestionThreadItem} />
)
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(
<AiAssistantThreadItem item={mockAnswerWithQuestionThreadItem} />
)
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(
<AiAssistantThreadItem item={mockAnswerWithoutQuestionThreadItem} />
)
expect(container).toBeInTheDocument()
expect(container).not.toHaveTextContent("AiAssistantThreadItemActions")
})
test("renders error thread item", () => {
const { container } = render(
<AiAssistantThreadItem item={mockErrorThreadItem} />
)
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(
<AiAssistantThreadItem item={mockEmptyAnswerThreadItem} />
)
expect(container).toBeInTheDocument()
expect(container).toHaveTextContent("DotsLoading")
})
test("hide loading when answer has question_id and no content", () => {
const { container } = render(
<AiAssistantThreadItem
item={{
...mockAnswerWithQuestionThreadItem,
content: "",
}}
/>
)
expect(container).toBeInTheDocument()
expect(container).not.toHaveTextContent("DotsLoading")
})
test("hide loading when answer has content", () => {
const { container } = render(
<AiAssistantThreadItem item={mockAnswerWithoutQuestionThreadItem} />
)
expect(container).toBeInTheDocument()
expect(container).not.toHaveTextContent("DotsLoading")
})
test("hide loading when error is not empty", () => {
const { container } = render(
<AiAssistantThreadItem item={mockErrorThreadItem} />
)
expect(container).toBeInTheDocument()
expect(container).not.toHaveTextContent("DotsLoading")
})
})

View File

@@ -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 = {

View File

@@ -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) => <button {...props} />,
}))
vi.mock("@/components/Tooltip", () => ({
Tooltip: (props: TooltipProps) => (
<div>
{/* @ts-expect-error - props.render is not typed properly */}
<div>{props.render?.()}</div>
<div>{props.tooltipChildren}</div>
<div>{props.children}</div>
</div>
),
}))
vi.mock("@/components/Kbd", () => ({
Kbd: (props: KbdProps) => <kbd {...props} />,
}))
vi.mock("@/providers/AiAssistant", () => ({
useAiAssistant: () => AiAssistantMocks.mockUseAiAssistant(),
}))
vi.mock("@/providers/Search", () => ({
useSearch: () => {
return {
setIsOpen: mockSetIsSearchOpen,
}
},
}))
vi.mock("@/providers/SiteConfig", () => ({
useSiteConfig: () => {
return {
config: {
basePath: "https://docs.medusajs.com",
},
}
},
}))
vi.mock("@/utils/os-browser-utils", () => ({
getOsShortcut: () => mockGetOsShortcut(),
}))
vi.mock("@/hooks/use-keyboard-shortcut", () => ({
useKeyboardShortcut: () => mockUseKeyboardShortcut(),
}))
beforeEach(() => {
mockSetIsSearchOpen.mockClear()
mockGetOsShortcut.mockClear()
mockUseKeyboardShortcut.mockReturnValue(defaultUseKeyboardShortcutReturn)
AiAssistantMocks.mockSetChatOpened.mockClear()
AiAssistantMocks.mockUseAiAssistant.mockReturnValue(
AiAssistantMocks.defaultUseAiAssistantReturn
)
})
import { AiAssistantTriggerButton } from "../../TriggerButton"
describe("rendering", () => {
test("renders the trigger button", () => {
const { container } = render(<AiAssistantTriggerButton />)
expect(container).toBeInTheDocument()
expect(container).toHaveTextContent("Ask AI")
})
test("renders the trigger button with correct OS shortcut", () => {
const { container } = render(<AiAssistantTriggerButton />)
expect(container).toBeInTheDocument()
const kbd = container.querySelectorAll("kbd")
expect(kbd.length).toBe(2)
expect(kbd[0]).toBeInTheDocument()
expect(kbd[0]).toHaveTextContent("Ctrl")
expect(kbd[1]).toBeInTheDocument()
expect(kbd[1]).toHaveTextContent("i")
})
})
describe("interactions", () => {
test("clicking the trigger button should toggle the chat", () => {
const { container } = render(<AiAssistantTriggerButton />)
expect(container).toBeInTheDocument()
const button = container.querySelector("button")
expect(button).toBeInTheDocument()
fireEvent.click(button!)
expect(AiAssistantMocks.mockSetChatOpened).toHaveBeenCalledTimes(1)
})
})

View File

@@ -4,9 +4,11 @@ import React from "react"
import { Button } from "../../Button"
import { Tooltip } from "../../Tooltip"
import { Kbd } from "../../Kbd"
import { getOsShortcut } from "../../../utils"
import { useAiAssistant, useSearch, useSiteConfig } from "../../../providers"
import { getOsShortcut } from "../../../utils/os-browser-utils"
import { useAiAssistant } from "../../../providers/AiAssistant"
import { useKeyboardShortcut } from "../../../hooks"
import { useSearch } from "../../../providers/Search"
import { useSiteConfig } from "../../../providers/SiteConfig"
import Image from "next/image"
const AI_ASSISTANT_ICON_ACTIVE = "/images/ai-assistent.png"

View File

@@ -0,0 +1,104 @@
import React from "react"
import { vi } from "vitest"
// Mock functions
const mockSetChatOpened = vi.fn()
const mockTrack = vi.fn()
const mockGetChatWindowElm = vi.fn()
const mockGetInputElm = vi.fn()
const mockFocusInput = vi.fn()
const mockSubmitQuery = vi.fn()
const mockGetLatest = vi.fn(() => ({
question: "last question",
}))
const mockAddFeedback = vi.fn()
// Create a mock conversation that behaves like an array
const createMockConversation = () => {
const conversationItems: Array<{
question: string
answer: string
id: string
sources?: unknown[]
isGenerationAborted?: boolean
}> = []
return {
get length() {
return conversationItems.length
},
set length(value: number) {
// When length is set, update the items array
if (value > conversationItems.length) {
// Add new items
for (let i = conversationItems.length; i < value; i++) {
conversationItems.push({
question: `Question ${i + 1}`,
answer: `Answer ${i + 1}`,
id: `id-${i + 1}`,
sources: [],
isGenerationAborted: false,
})
}
} else if (value < conversationItems.length) {
// Remove items
conversationItems.splice(value)
}
},
getLatest: mockGetLatest,
map(callback: (item: unknown, index: number) => React.ReactNode) {
return conversationItems.map(callback)
},
}
}
const mockConversation = createMockConversation()
// Initialize with default length
mockConversation.length = 1
const mockStopGeneration = vi.fn()
const mockToggle = vi.fn()
const defaultUseAiAssistantReturn = {
chatOpened: true,
inputRef: React.createRef<HTMLTextAreaElement>(),
contentRef: React.createRef<HTMLDivElement>(),
loading: false,
setChatOpened: mockSetChatOpened,
isCaptchaLoaded: true,
}
const defaultUseDeepThinkingReturn = {
active: false,
toggle: mockToggle,
}
const defaultUseChatReturn = {
conversation: mockConversation,
error: "",
submitQuery: mockSubmitQuery,
stopGeneration: mockStopGeneration,
addFeedback: mockAddFeedback,
isGeneratingAnswer: false,
isPreparingAnswer: false,
}
const mockUseAiAssistant = vi.fn(() => defaultUseAiAssistantReturn)
const mockUseDeepThinking = vi.fn(() => defaultUseDeepThinkingReturn)
const mockUseChat = vi.fn(() => defaultUseChatReturn)
export {
mockSetChatOpened,
mockTrack,
mockGetChatWindowElm,
mockGetInputElm,
mockFocusInput,
mockSubmitQuery,
mockGetLatest,
mockConversation,
mockStopGeneration,
mockToggle,
mockUseAiAssistant,
mockUseDeepThinking,
mockUseChat,
mockAddFeedback,
defaultUseAiAssistantReturn,
defaultUseDeepThinkingReturn,
defaultUseChatReturn,
}

View File

@@ -0,0 +1,125 @@
import React from "react"
import { describe, expect, test, vi } from "vitest"
import { fireEvent, render } from "@testing-library/react"
import { ApiRunnerParamInputProps } from "../../Default"
import { ButtonProps } from "../../../../Button"
// mock data
const mockApiRunnerParamArrayInput: ApiRunnerParamInputProps = {
paramName: "test",
paramValue: ["test"],
objPath: "test",
setValue: vi.fn(),
}
const mockApiRunnerParamArrayInput2: ApiRunnerParamInputProps = {
paramName: "test",
paramValue: ["test", "test2"],
objPath: "test",
setValue: vi.fn(),
}
const mockApiRunnerNotArrayInput: ApiRunnerParamInputProps = {
paramName: "test",
paramValue: "test",
objPath: "test",
setValue: vi.fn(),
}
// mock components
vi.mock("@/components/Button", () => ({
Button: (props: ButtonProps) => <button {...props} />,
}))
vi.mock("@/components/ApiRunner/ParamInputs/Default", () => ({
ApiRunnerParamInput: () => (
<div className="api-runner-param-input">ApiRunnerParamInput</div>
),
}))
vi.mock("@/utils/set-obj-value", () => ({
setObjValue: vi.fn(),
}))
import { ApiRunnerParamArrayInput } from "../../Array"
describe("rendering", () => {
test("renders when param value is an array", () => {
const { container } = render(
<ApiRunnerParamArrayInput {...mockApiRunnerParamArrayInput} />
)
expect(container).toBeInTheDocument()
const fieldset = container.querySelector("fieldset")
expect(fieldset).toBeInTheDocument()
const legend = fieldset?.querySelector("legend")
expect(legend).toBeInTheDocument()
expect(legend).toHaveTextContent("test Array Items")
expect(fieldset).toHaveTextContent("ApiRunnerParamInput")
const button = fieldset?.querySelector("button[data-testid='minus-button']")
expect(button).not.toBeInTheDocument()
const plusButton = fieldset?.querySelector(
"button[data-testid='plus-button']"
)
expect(plusButton).toBeInTheDocument()
const svg = plusButton?.querySelector("svg")
expect(svg).toBeInTheDocument()
})
test("renders minus button when param value is an array and has more than one item", () => {
const { container } = render(
<ApiRunnerParamArrayInput {...mockApiRunnerParamArrayInput2} />
)
expect(container).toBeInTheDocument()
const fieldset = container.querySelector("fieldset")
expect(fieldset).toBeInTheDocument()
const button = fieldset?.querySelector("button[data-testid='minus-button']")
expect(button).toBeInTheDocument()
const svg = button?.querySelector("svg")
expect(svg).toBeInTheDocument()
})
test("doesn't render when param value is not an array", () => {
const { container } = render(
<ApiRunnerParamArrayInput {...mockApiRunnerNotArrayInput} />
)
expect(container).toBeInTheDocument()
const fieldset = container.querySelector("fieldset")
expect(fieldset).not.toBeInTheDocument()
expect(container).toHaveTextContent("ApiRunnerParamInput")
})
})
describe("interactions", () => {
test("clicking minus button should remove item from array", () => {
const { container } = render(
<ApiRunnerParamArrayInput {...mockApiRunnerParamArrayInput2} />
)
expect(container).toBeInTheDocument()
const fieldset = container.querySelector("fieldset")
expect(fieldset).toBeInTheDocument()
const button = fieldset?.querySelector("button[data-testid='minus-button']")
expect(button).toBeInTheDocument()
fireEvent.click(button!)
const apiRunnerParamInput = container.querySelectorAll(
".api-runner-param-input"
)
expect(apiRunnerParamInput).toHaveLength(1)
})
test("clicking plus button should add item to array", () => {
const { container } = render(
<ApiRunnerParamArrayInput {...mockApiRunnerParamArrayInput} />
)
expect(container).toBeInTheDocument()
const fieldset = container.querySelector("fieldset")
expect(fieldset).toBeInTheDocument()
const plusButton = fieldset?.querySelector(
"button[data-testid='plus-button']"
)
expect(plusButton).toBeInTheDocument()
const initialApiRunnerParamInput = container.querySelectorAll(
".api-runner-param-input"
)
expect(initialApiRunnerParamInput).toHaveLength(1)
fireEvent.click(plusButton!)
const updatedApiRunnerParamInput = container.querySelectorAll(
".api-runner-param-input"
)
expect(updatedApiRunnerParamInput).toHaveLength(2)
})
})

View File

@@ -4,7 +4,7 @@ import React, { useEffect, useState } from "react"
import { ApiRunnerParamInput, ApiRunnerParamInputProps } from "../Default"
import clsx from "clsx"
import setObjValue from "@/utils/set-obj-value"
import { Button } from "../../../.."
import { Button } from "../../../../components/Button"
import { Minus, Plus } from "@medusajs/icons"
export const ApiRunnerParamArrayInput = ({
@@ -17,7 +17,7 @@ export const ApiRunnerParamArrayInput = ({
useEffect(() => {
setValue((prev: unknown) => {
return typeof prev === "object"
return typeof prev === "object" && !Array.isArray(prev)
? setObjValue({
obj: { ...prev },
value: itemsValue,
@@ -70,6 +70,7 @@ export const ApiRunnerParamArrayInput = ({
setItemsValue((prev: unknown[]) => prev.splice(index, 1))
}}
className="mt-0.5"
data-testid="minus-button"
>
<Minus />
</Button>
@@ -90,6 +91,7 @@ export const ApiRunnerParamArrayInput = ({
])
}}
className="mt-0.5"
data-testid="plus-button"
>
<Plus />
</Button>

View File

@@ -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) => <input {...props} />,
}))
const TestWrapper = (props: ApiRunnerParamInputProps) => {
const [value, setValue] = React.useState(props.paramValue)
return (
<ApiRunnerParamInput {...props} paramValue={value} setValue={setValue} />
)
}
import { ApiRunnerParamInput } from "../index"
describe("rendering", () => {
test("renders when param value is a string", () => {
const { container } = render(
<ApiRunnerParamInput {...mockApiRunnerParamInput} />
)
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(
<ApiRunnerParamInput {...mockApiRunnerParamArrayInput} />
)
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(
<ApiRunnerParamInput {...mockApiRunnerParamObjectInput} />
)
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(<TestWrapper {...mockApiRunnerParamInput} />)
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(
<TestWrapper {...mockApiRunnerParamArrayInput} />
)
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(
<TestWrapper {...mockApiRunnerParamObjectInput} />
)
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(
<TestWrapper
paramName=""
paramValue={{
parent: {
child: "initialValue",
},
}}
objPath=""
setValue={vi.fn()}
/>
)
// 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(
<TestWrapper
paramName=""
paramValue={{
level1: {
level2: {
level3: "deepValue",
},
},
}}
objPath=""
setValue={vi.fn()}
/>
)
// 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(
<TestWrapper
paramName="testName"
paramValue={[{ name: "item1", value: "value1" }]}
objPath=""
setValue={vi.fn()}
/>
)
// 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(
<TestWrapper
paramName="items"
paramValue={{ items: ["item1", "item2"] }}
objPath=""
setValue={vi.fn()}
/>
)
// 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(
<TestWrapper
paramName="data"
paramValue={{
data: [
{ id: 1, name: "first" },
{ id: 2, name: "second" },
],
}}
objPath=""
setValue={vi.fn()}
/>
)
// 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(
<TestWrapper
paramName=""
paramValue={{ root: "rootValue" }}
objPath=""
setValue={vi.fn()}
/>
)
const input = container.querySelector("input")
expect(input).toBeInTheDocument()
expect(input).toHaveValue("rootValue")
fireEvent.change(input!, { target: { value: "newRootValue" } })
expect(input).toHaveValue("newRootValue")
})
})

View File

@@ -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"

View File

@@ -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) => (
<div className="api-runner-param-input">
<span>ApiRunnerParamInput</span>
<span>{paramName}</span>
<span>{paramValue as string}</span>
<span>{objPath}</span>
</div>
),
}))
import { ApiRunnerParamObjectInput } from "../../Object"
describe("rendering", () => {
test("renders when param value is an object", () => {
const { container } = render(
<ApiRunnerParamObjectInput {...mockApiRunnerParamObjectInput} />
)
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(
<ApiRunnerParamObjectInput {...mockApiRunnerParamObjectInput2} />
)
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(
<ApiRunnerParamObjectInput {...mockApiRunnerNotObjectInput} />
)
expect(container).toBeInTheDocument()
expect(container).toHaveTextContent("ApiRunnerParamInput")
expect(container).toHaveTextContent("testName")
expect(container).toHaveTextContent("testValue")
})
})

View File

@@ -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) => (
<div data-badge-color={badgeColor} data-badge-label={badgeLabel}>
<pre {...props} />
</div>
),
}))
vi.mock("@/components/Button", () => ({
Button: (props: ButtonProps) => <button {...props} />,
}))
vi.mock("@/components/ApiRunner/ParamInputs", () => ({
ApiRunnerParamInputs: () => <div>ApiRunnerParamInputs</div>,
}))
vi.mock("@/components/ApiRunner/FooterBackground", () => ({
ApiRunnerFooterBackground: () => <div>ApiRunnerFooterBackground</div>,
}))
vi.mock("@/components/Icons/ArrowRightDown", () => ({
ArrowRightDownIcon: () => <div>ArrowRightDownIcon</div>,
}))
vi.mock("@/hooks/use-request-runner", () => ({
useRequestRunner: ({
pushLog,
onFinish,
}: {
pushLog: (...message: string[]) => void
onFinish: (message: string, statusCode: string) => void
replaceLog?: (message: string) => void
}) => {
capturedOnFinish = onFinish
return {
runRequest: vi.fn(() => {
pushLog("Response data")
onFinish("Finished running request.", "")
}),
}
},
}))
import { ApiRunner } from "../../ApiRunner"
import { ApiMethod } from "types"
beforeEach(() => {
capturedOnFinish = null
})
describe("rendering", () => {
test("renders manual test trigger when there is data", () => {
const { container } = render(<ApiRunner {...mockApiRunnerGet} />)
expect(container).toBeInTheDocument()
expect(container).toHaveTextContent("ApiRunnerParamInputs")
expect(container).toHaveTextContent("ApiRunnerFooterBackground")
expect(container).toHaveTextContent("ArrowRightDownIcon")
const submitButton = container.querySelector("button")
expect(submitButton).toBeInTheDocument()
expect(submitButton).toHaveTextContent("Send Request")
})
test("renders code block when request is running or has been run", () => {
const { container } = render(<ApiRunner {...mockApiRunnerGet} />)
expect(container).toBeInTheDocument()
const submitButton = container.querySelector("button")
expect(submitButton).toBeInTheDocument()
fireEvent.click(submitButton!)
const pre = container.querySelector("pre")
expect(pre).toBeInTheDocument()
})
})
describe("interactions", () => {
test("clicking submit button should trigger request", () => {
const { container } = render(<ApiRunner {...mockApiRunnerGet} />)
expect(container).toBeInTheDocument()
const submitButton = container.querySelector("button")
expect(submitButton).toBeInTheDocument()
fireEvent.click(submitButton!)
// Verify the CodeBlock appears after clicking
expect(container.querySelector("pre")).toBeInTheDocument()
})
test("clicking submit button should trigger POST request", () => {
const { container } = render(<ApiRunner {...mockApiRunnerPost} />)
expect(container).toBeInTheDocument()
const submitButton = container.querySelector("button")
expect(submitButton).toBeInTheDocument()
fireEvent.click(submitButton!)
expect(container.querySelector("pre")).toBeInTheDocument()
})
test("clicking submit button should trigger DELETE request", () => {
const { container } = render(<ApiRunner {...mockApiRunnerDelete} />)
expect(container).toBeInTheDocument()
const submitButton = container.querySelector("button")
expect(submitButton).toBeInTheDocument()
fireEvent.click(submitButton!)
expect(container.querySelector("pre")).toBeInTheDocument()
})
test("should send request automatically for simple GET request", () => {
const { container } = render(<ApiRunner {...mockApiRunnerSimpleGet} />)
expect(container).toBeInTheDocument()
// The request is automatically triggered, so we just verify the component renders
expect(container.querySelector("pre")).toBeInTheDocument()
})
})
describe("badge display", () => {
test("should show green badge with status code for successful request (2xx)", async () => {
const { container } = render(<ApiRunner {...mockApiRunnerGet} />)
const submitButton = container.querySelector("button")
fireEvent.click(submitButton!)
// Wait for the component to render the CodeBlock
await waitFor(() => {
expect(container.querySelector("pre")).toBeInTheDocument()
})
// Simulate successful response by calling onFinish with 200 status code
if (capturedOnFinish) {
capturedOnFinish("Finished running request.", "200")
}
await waitFor(() => {
const codeBlock = container.querySelector("[data-badge-color]")
expect(codeBlock).toBeInTheDocument()
expect(codeBlock).toHaveAttribute("data-badge-color", "green")
expect(codeBlock).toHaveAttribute("data-badge-label", "200")
})
})
test("should show red badge with status code for failed request (4xx)", async () => {
const { container } = render(<ApiRunner {...mockApiRunnerGet} />)
const submitButton = container.querySelector("button")
fireEvent.click(submitButton!)
await waitFor(() => {
expect(container.querySelector("pre")).toBeInTheDocument()
})
// Simulate failed response by calling onFinish with 404 status code
if (capturedOnFinish) {
capturedOnFinish("Finished running request.", "404")
}
await waitFor(() => {
const codeBlock = container.querySelector("[data-badge-color]")
expect(codeBlock).toBeInTheDocument()
expect(codeBlock).toHaveAttribute("data-badge-color", "red")
expect(codeBlock).toHaveAttribute("data-badge-label", "404")
})
})
test("should show red badge with 'Failed' label when no status code", async () => {
const { container } = render(<ApiRunner {...mockApiRunnerGet} />)
const submitButton = container.querySelector("button")
fireEvent.click(submitButton!)
await waitFor(() => {
expect(container.querySelector("pre")).toBeInTheDocument()
})
// Simulate error with no status code (empty string)
if (capturedOnFinish) {
capturedOnFinish("Finished running request.", "")
}
await waitFor(() => {
const codeBlock = container.querySelector("[data-badge-color]")
expect(codeBlock).toBeInTheDocument()
expect(codeBlock).toHaveAttribute("data-badge-color", "red")
expect(codeBlock).toHaveAttribute("data-badge-label", "Failed")
})
})
test("should show green badge for 201 status code", async () => {
const { container } = render(<ApiRunner {...mockApiRunnerPost} />)
const submitButton = container.querySelector("button")
fireEvent.click(submitButton!)
await waitFor(() => {
expect(container.querySelector("pre")).toBeInTheDocument()
})
// Simulate successful POST response with 201 status code
if (capturedOnFinish) {
capturedOnFinish("Finished running request.", "201")
}
await waitFor(() => {
const codeBlock = container.querySelector("[data-badge-color]")
expect(codeBlock).toBeInTheDocument()
expect(codeBlock).toHaveAttribute("data-badge-color", "green")
expect(codeBlock).toHaveAttribute("data-badge-label", "201")
})
})
})

View File

@@ -2,9 +2,9 @@
import React from "react"
import { useEffect, useMemo, useState } from "react"
import { useRequestRunner } from "../../hooks"
import { useRequestRunner } from "../../hooks/use-request-runner"
import { CodeBlock } from "../CodeBlock"
import { Button } from "../.."
import { Button } from "@/components/Button"
import { ApiMethod, ApiTestingOptions } from "types"
import { ApiRunnerParamInputs } from "./ParamInputs"
import clsx from "clsx"

View File

@@ -0,0 +1,148 @@
import React from "react"
import { expect, test } from "vitest"
import { render, screen } from "@testing-library/react"
import { Badge } from "../../Badge"
test("renders children", () => {
render(<Badge variant="purple">Test Badge</Badge>)
expect(screen.getByText("Test Badge")).toBeInTheDocument()
})
test("renders purple badge", () => {
const { container } = render(<Badge variant="purple">Test Badge</Badge>)
const badge = container.querySelector(".badge")
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass(
"bg-medusa-tag-purple-bg",
"text-medusa-tag-purple-text",
"border-medusa-tag-purple-border"
)
})
test("renders orange badge", () => {
const { container } = render(<Badge variant="orange">Test Badge</Badge>)
const badge = container.querySelector(".badge")
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass(
"bg-medusa-tag-orange-bg",
"text-medusa-tag-orange-text",
"border-medusa-tag-orange-border"
)
})
test("renders green badge", () => {
const { container } = render(<Badge variant="green">Test Badge</Badge>)
const badge = container.querySelector(".badge")
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass(
"bg-medusa-tag-green-bg",
"text-medusa-tag-green-text",
"border-medusa-tag-green-border"
)
})
test("renders blue badge", () => {
const { container } = render(<Badge variant="blue">Test Badge</Badge>)
const badge = container.querySelector(".badge")
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass(
"bg-medusa-tag-blue-bg",
"text-medusa-tag-blue-text",
"border-medusa-tag-blue-border"
)
})
test("renders red badge", () => {
const { container } = render(<Badge variant="red">Test Badge</Badge>)
const badge = container.querySelector(".badge")
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass(
"bg-medusa-tag-red-bg",
"text-medusa-tag-red-text",
"border-medusa-tag-red-border"
)
})
test("renders neutral badge", () => {
const { container } = render(<Badge variant="neutral">Test Badge</Badge>)
const badge = container.querySelector(".badge")
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass(
"bg-medusa-tag-neutral-bg",
"text-medusa-tag-neutral-text",
"border-medusa-tag-neutral-border"
)
})
test("renders code badge", () => {
const { container } = render(<Badge variant="code">Test Badge</Badge>)
const badge = container.querySelector(".badge")
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass(
"bg-medusa-contrast-bg-subtle",
"text-medusa-contrast-fg-secondary",
"border-medusa-contrast-border-bot"
)
})
test("renders shaded badge", () => {
const { container } = render(
<Badge variant="purple" badgeType="shaded">
Test Badge
</Badge>
)
const badge = container.querySelector(".badge")
const shadedBgIcon = container.querySelector("svg")
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass("px-[3px]", "!bg-transparent", "relative")
expect(shadedBgIcon).toBeInTheDocument()
expect(shadedBgIcon).toHaveClass(
"absolute",
"top-0",
"left-0",
"w-full",
"h-full"
)
const rect = shadedBgIcon?.querySelector("rect")
expect(rect).toBeInTheDocument()
expect(rect).toHaveAttribute("fill", "var(--docs-tags-purple-border)")
})
test("doesn't render shaded badge if badgeType is not shaded", () => {
const { container } = render(<Badge variant="purple">Test Badge</Badge>)
const badge = container.querySelector(".badge")
expect(badge).toBeInTheDocument()
expect(badge).not.toHaveClass("px-[3px]", "!bg-transparent", "relative")
const shadedBgIcon = container.querySelector("svg")
expect(shadedBgIcon).not.toBeInTheDocument()
})
test("renders with correct class name", () => {
const { container } = render(
<Badge variant="purple" className="test-class">
Test Badge
</Badge>
)
const badge = container.querySelector(".test-class")
expect(badge).toBeInTheDocument()
})
test("renders children wrapper with correct class name", () => {
const { container } = render(
<Badge variant="purple" childrenWrapperClassName="test-class">
Test Badge
</Badge>
)
const childrenWrapper = container.querySelector(".test-class")
expect(childrenWrapper).toBeInTheDocument()
})
test("passes HTML attributes to the badge", () => {
const { container } = render(
<Badge variant="purple" data-testid="test-id">
Test Badge
</Badge>
)
const badge = container.querySelector("[data-testid='test-id']")
expect(badge).toBeInTheDocument()
})

View File

@@ -1,6 +1,6 @@
import React from "react"
import clsx from "clsx"
import { ShadedBgIcon } from "../.."
import { ShadedBgIcon } from "../Icons/ShadedBg"
export type BadgeVariant =
| "purple"
@@ -26,6 +26,7 @@ export const Badge = ({
badgeType = "default",
children,
childrenWrapperClassName,
...props
}: BadgeProps) => {
return (
<span
@@ -52,6 +53,7 @@ export const Badge = ({
"badge",
className
)}
{...props}
>
{badgeType === "shaded" && (
<ShadedBgIcon

View File

@@ -1,5 +1,6 @@
import React from "react"
import { Badge, Tooltip } from "../.."
import { Badge } from "@/components/Badge"
import { Tooltip } from "@/components/Tooltip"
type BetaBadgeProps = {
text?: string

View File

@@ -0,0 +1,133 @@
import React from "react"
import { describe, expect, test, vi } from "vitest"
import { render } from "@testing-library/react"
import { BorderedIcon } from ".."
const TestIcon = ({ className }: { className?: string }) => {
return (
<svg
data-testid="test-icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M12 2L2 22h20L12 2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinejoin="round"
/>
</svg>
)
}
const exampleDataImageUrl =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAACV0lEQVR4nO2Wz2/SURjH/72769q9u9N7757e5f3oYsQgL2p4EUNFURFFEUVxU1ExYiD6gMREjEFE4qJiEBFRERERETGQmD/O/9/H/T8O93rXm+c5zzkzZ855zpnB3Tt37gQAAAAAAABg6U8AADwA4wEAAAAASUVORK5CYII="
describe("rendering", () => {
test("renders image when provided as icon", () => {
const { container } = render(<BorderedIcon icon={exampleDataImageUrl} />)
expect(container).toBeInTheDocument()
const image = container.querySelector("img")
expect(image).toBeInTheDocument()
expect(image).toHaveAttribute("src", exampleDataImageUrl)
expect(image).toHaveAttribute("alt", "")
expect(image).toHaveClass("bordered-icon")
expect(image).toHaveClass("rounded-docs_xs")
expect(image).toHaveAttribute("width", "28")
expect(image).toHaveAttribute("height", "28")
})
test("renders icon component when provided as IconComponent", () => {
const { container } = render(<BorderedIcon IconComponent={TestIcon} />)
expect(container).toBeInTheDocument()
const icon = container.querySelector("svg")
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass("bordered-icon")
expect(icon).toHaveClass("rounded-docs_xs")
expect(icon).toHaveClass("text-medusa-fg-subtle")
})
test("render IconComponent even when icon is provided", () => {
const { container } = render(
<BorderedIcon icon={exampleDataImageUrl} IconComponent={TestIcon} />
)
expect(container).toBeInTheDocument()
const icon = container.querySelector("svg")
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass("bordered-icon")
expect(icon).toHaveClass("rounded-docs_xs")
expect(icon).toHaveClass("text-medusa-fg-subtle")
const image = container.querySelector("img")
expect(image).not.toBeInTheDocument()
})
test("render icon with custom width and height", () => {
const { container } = render(
<BorderedIcon icon={exampleDataImageUrl} iconWidth={32} iconHeight={32} />
)
expect(container).toBeInTheDocument()
const image = container.querySelector("img")
expect(image).toBeInTheDocument()
expect(image).toHaveAttribute("width", "32")
expect(image).toHaveAttribute("height", "32")
})
test("render icon with custom icon wrapper className", () => {
const { container } = render(
<BorderedIcon
icon={exampleDataImageUrl}
iconWrapperClassName="test-icon-wrapper"
/>
)
expect(container).toBeInTheDocument()
const span = container.querySelector("span.test-icon-wrapper")
expect(span).toBeInTheDocument()
})
test("render icon with custom wrapper className", () => {
const { container } = render(
<BorderedIcon
icon={exampleDataImageUrl}
wrapperClassName="test-wrapper"
/>
)
expect(container).toBeInTheDocument()
const span = container.querySelector("span.test-wrapper")
expect(span).toBeInTheDocument()
})
test("render image with custom icon className", () => {
const { container } = render(
<BorderedIcon icon={exampleDataImageUrl} iconClassName="test-icon" />
)
expect(container).toBeInTheDocument()
const image = container.querySelector("img.test-icon")
expect(image).toBeInTheDocument()
})
test("render icon component with custom icon className", () => {
const { container } = render(
<BorderedIcon IconComponent={TestIcon} iconClassName="test-icon" />
)
expect(container).toBeInTheDocument()
const icon = container.querySelector("svg.test-icon")
expect(icon).toBeInTheDocument()
})
test("render icon with custom icon color className", () => {
const { container } = render(
<BorderedIcon
IconComponent={TestIcon}
iconColorClassName="test-icon-color"
/>
)
expect(container).toBeInTheDocument()
const icon = container.querySelector("svg")
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass("test-icon-color")
})
})

View File

@@ -23,6 +23,7 @@ export const BorderedIcon = ({
wrapperClassName,
iconWidth = 28,
iconHeight = 28,
...props
}: BorderedIconProps) => {
return (
<span
@@ -31,6 +32,7 @@ export const BorderedIcon = ({
"shadow-border-base dark:shadow-border-base-dark",
iconWrapperClassName
)}
{...props}
>
<span className={clsx("rounded-docs_xs", wrapperClassName)}>
{!IconComponent && (

View File

@@ -0,0 +1,164 @@
import React from "react"
import { beforeEach, describe, expect, test, vi } from "vitest"
import { render } from "@testing-library/react"
// mock data
const sidebarHistory = ["sidebar_1", "sidebar_2", "sidebar_3"]
const sidebars = [
{
sidebar_id: "sidebar_1",
title: "sidebar_1",
items: [
{
type: "link",
title: "sidebar_1_item_1",
link: "/sidebar_1_item_1",
isPathHref: true,
path: "/sidebar_1_item_1",
},
],
},
{
sidebar_id: "sidebar_2",
title: "sidebar_2",
items: [
{
type: "link",
title: "sidebar_2_item_1",
link: "/sidebar_2_item_1",
isPathHref: true,
path: "/sidebar_2_item_1",
},
],
},
]
const baseUrl = "https://example.com"
const basePath = "/docs"
// mock functions
const getSidebar = (sidebar_id: string) => {
return sidebars.find((sidebar) => sidebar.sidebar_id === sidebar_id)
}
const getSidebarFirstLinkChild = (sidebar: Sidebar.Sidebar) => {
return sidebar.items[0] as Sidebar.SidebarItemLink | undefined
}
const mockUseSidebar = vi.fn(() => ({
sidebarHistory,
getSidebarFirstLinkChild,
getSidebar,
}))
// mock components
vi.mock("@/providers/Sidebar", () => ({
useSidebar: () => mockUseSidebar(),
}))
vi.mock("@/providers/SiteConfig", () => ({
useSiteConfig: () => ({
config: {
breadcrumbOptions: {
startItems: [{ title: "start", link: "/start" }],
},
baseUrl,
basePath,
},
}),
}))
import { Breadcrumbs } from ".."
import { Sidebar } from "types"
beforeEach(() => {
mockUseSidebar.mockReturnValue({
sidebarHistory,
getSidebarFirstLinkChild,
getSidebar,
})
})
describe("rendering", () => {
test("renders config start item only when no sidebar history is provided", () => {
mockUseSidebar.mockReturnValue({
sidebarHistory: [],
getSidebarFirstLinkChild: () => undefined,
getSidebar,
})
const { container } = render(<Breadcrumbs />)
expect(container).toBeInTheDocument()
const startItem = container.querySelector("a[href='/start']")
expect(startItem).toBeInTheDocument()
})
test("renders breadcrumbs for sidebar with one history item", () => {
mockUseSidebar.mockReturnValue({
sidebarHistory: ["sidebar_1"],
getSidebarFirstLinkChild,
getSidebar,
})
const { container } = render(<Breadcrumbs />)
expect(container).toBeInTheDocument()
const breadcrumbs = container.querySelectorAll("a")
expect(breadcrumbs).toHaveLength(2)
expect(breadcrumbs[0]).toHaveTextContent("start")
expect(breadcrumbs[1]).toHaveTextContent("sidebar_1")
expect(breadcrumbs[1]).toHaveAttribute("href", "/sidebar_1_item_1")
})
test("renders breadcrumbs for sidebar with two history items", () => {
mockUseSidebar.mockReturnValue({
sidebarHistory: ["sidebar_1", "sidebar_2"],
getSidebarFirstLinkChild,
getSidebar,
})
const { container } = render(<Breadcrumbs />)
expect(container).toBeInTheDocument()
const breadcrumbs = container.querySelectorAll("a")
expect(breadcrumbs).toHaveLength(3)
expect(breadcrumbs[0]).toHaveTextContent("start")
expect(breadcrumbs[1]).toHaveTextContent("sidebar_1")
expect(breadcrumbs[1]).toHaveAttribute("href", "/sidebar_1_item_1")
expect(breadcrumbs[2]).toHaveTextContent("sidebar_2")
expect(breadcrumbs[2]).toHaveAttribute("href", "/sidebar_2_item_1")
})
test("renders json-ld breadcrumb list", () => {
mockUseSidebar.mockReturnValue({
sidebarHistory: ["sidebar_1", "sidebar_2"],
getSidebarFirstLinkChild,
getSidebar,
})
const { container } = render(<Breadcrumbs />)
expect(container).toBeInTheDocument()
const jsonLd = container.querySelector("script[type='application/ld+json']")
expect(jsonLd).toBeInTheDocument()
expect(jsonLd).toHaveAttribute("type", "application/ld+json")
const content = jsonLd?.textContent
expect(content).toBeDefined()
const parsedContent = JSON.parse(content!)
const baseLink = `${baseUrl}${basePath}`.replace(/\/+$/, "")
expect(parsedContent).toEqual({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "start",
item: `${baseLink}/start`,
},
{
"@type": "ListItem",
position: 2,
name: "sidebar_1",
item: `${baseLink}/sidebar_1_item_1`,
},
{
"@type": "ListItem",
position: 3,
name: "sidebar_2",
item: `${baseLink}/sidebar_2_item_1`,
},
],
})
})
})

View File

@@ -3,11 +3,12 @@
import React, { useMemo } from "react"
import clsx from "clsx"
import Link from "next/link"
import { useSidebar, useSiteConfig } from "../../providers"
import { useSidebar } from "@/providers/Sidebar"
import { useSiteConfig } from "@/providers/SiteConfig"
import { Button } from "../Button"
import { TriangleRightMini } from "@medusajs/icons"
import { Sidebar } from "types"
import { getJsonLd } from "../../utils"
import { getJsonLd } from "@/utils/get-json-ld"
import type { BreadcrumbList } from "schema-dts"
type BreadcrumbItems = {

View File

@@ -0,0 +1,90 @@
import React from "react"
import { describe, expect, test, vi } from "vitest"
import { render } from "@testing-library/react"
import { Button } from "../../Button"
describe("rendering", () => {
test("renders primary button", () => {
const { container } = render(<Button variant="primary">Click me</Button>)
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(<Button variant="secondary">Click me</Button>)
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(
<Button variant="transparent">Click me</Button>
)
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(
<Button variant="transparent-clear">Click me</Button>
)
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(
<Button variant="primary" buttonType="icon">
Click me
</Button>
)
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(
<Button className="custom-class">Click me</Button>
)
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(<Button buttonRef={buttonRef}>Click me</Button>)
expect(buttonRef).toHaveBeenCalled()
})
})

View File

@@ -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) => (
<div>
BorderedIcon {props.icon} {props.IconComponent && <props.IconComponent />}
</div>
),
}))
vi.mock("@/components/Link", () => ({
Link: (props: LinkProps) => <a {...props} />,
}))
vi.mock("@/components/Badge", () => ({
Badge: (props: BadgeProps) => (
<div>
Badge {props.variant} - {props.children}
</div>
),
}))
describe("rendering", () => {
test("renders card default layout", () => {
const { container } = render(
<CardDefaultLayout>Click me</CardDefaultLayout>
)
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) => <div data-testid="icon">Icon</div>
const { container } = render(
<CardDefaultLayout icon={icon}>Click me</CardDefaultLayout>
)
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(
<CardDefaultLayout image={image}>Click me</CardDefaultLayout>
)
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(
<CardDefaultLayout title={title}>Click me</CardDefaultLayout>
)
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(
<CardDefaultLayout text={text}>Click me</CardDefaultLayout>
)
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(
<CardDefaultLayout badge={badge}>Click me</CardDefaultLayout>
)
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 = () => <div data-testid="right-icon">RightIcon</div>
const { container } = render(
<CardDefaultLayout rightIcon={rightIcon}>Click me</CardDefaultLayout>
)
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(
<CardDefaultLayout href={href}>Click me</CardDefaultLayout>
)
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(
<CardDefaultLayout href={href}>Click me</CardDefaultLayout>
)
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(
<CardDefaultLayout title="Title with highlight">
Click me
</CardDefaultLayout>
)
expect(container).toBeInTheDocument()
const highlightTextElement = container.querySelector(
"[data-testid='highlight-text']"
)
expect(highlightTextElement).not.toBeInTheDocument()
})
test("highlight text in title", () => {
const { container } = render(
<CardDefaultLayout
title="Title with highlight"
highlightText={["highlight"]}
>
Click me
</CardDefaultLayout>
)
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(
<CardDefaultLayout
title="Title without highlight"
highlightText={["not-highlight"]}
>
Click me
</CardDefaultLayout>
)
expect(container).toBeInTheDocument()
const highlightTextElement = container.querySelector(
"[data-testid='highlight-text']"
)
expect(highlightTextElement).not.toBeInTheDocument()
})
test("highlight text in text", () => {
const { container } = render(
<CardDefaultLayout
text="Text with highlight"
highlightText={["highlight"]}
>
Click me
</CardDefaultLayout>
)
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(
<CardDefaultLayout
text="Text without highlight"
highlightText={["not-highlight"]}
>
Click me
</CardDefaultLayout>
)
expect(container).toBeInTheDocument()
const highlightTextElement = container.querySelector(
"[data-testid='highlight-text']"
)
expect(highlightTextElement).not.toBeInTheDocument()
})
})

View File

@@ -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 = ({
<span
key={index}
className="bg-medusa-tag-blue-bg px-px rounded-s-docs_xxs"
data-testid="highlight-text"
>
{part}
</span>
@@ -77,12 +80,18 @@ export const CardDefaultLayout = ({
className={clsx("flex flex-col flex-1 overflow-auto", contentClassName)}
>
{title && (
<div className="text-small-plus text-medusa-fg-base truncate">
<div
className="text-small-plus text-medusa-fg-base truncate"
data-testid="title"
>
{getHighlightedText(title)}
</div>
)}
{text && (
<span className="text-small-plus text-medusa-fg-subtle">
<span
className="text-small-plus text-medusa-fg-subtle"
data-testid="text"
>
{getHighlightedText(text)}
</span>
)}
@@ -91,8 +100,12 @@ export const CardDefaultLayout = ({
{badge && <Badge {...badge} />}
<span className="text-medusa-fg-subtle">
{RightIconComponent && <RightIconComponent />}
{!RightIconComponent && isExternal && <ArrowUpRightOnBox />}
{!RightIconComponent && !isExternal && <TriangleRightMini />}
{!RightIconComponent && isExternal && (
<ArrowUpRightOnBox data-testid="external-icon" />
)}
{!RightIconComponent && !isExternal && (
<TriangleRightMini data-testid="internal-icon" />
)}
</span>
{href && (

View File

@@ -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(
<CardLargeLayout title={title}>Click me</CardLargeLayout>
)
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(
<CardLargeLayout text={text}>Click me</CardLargeLayout>
)
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(
<CardLargeLayout href={href}>Click me</CardLargeLayout>
)
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(
<CardLargeLayout href={href}>Click me</CardLargeLayout>
)
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 = () => <div data-testid="icon">Icon</div>
const { container } = render(
<CardLargeLayout icon={icon}>Click me</CardLargeLayout>
)
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(
<CardLargeLayout image={image}>Click me</CardLargeLayout>
)
expect(container).toBeInTheDocument()
const imageElement = container.querySelector("img")
expect(imageElement).toBeInTheDocument()
})
})

View File

@@ -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 = ({
</div>
<div className="flex flex-col">
<div className="flex gap-docs_0.25 items-center text-medusa-fg-base">
{title && <span className="text-compact-small-plus">{title}</span>}
{href && isExternal && <ArrowUpRightOnBox />}
{title && (
<span className="text-compact-small-plus" data-testid="title">
{title}
</span>
)}
{href && isExternal && (
<ArrowUpRightOnBox data-testid="external-icon" />
)}
{href && !isExternal && (
<TriangleRightMini className="group-hover:translate-x-docs_0.125 transition-transform" />
<TriangleRightMini
className="group-hover:translate-x-docs_0.125 transition-transform"
data-testid="internal-icon"
/>
)}
</div>
{text && (
<span className="text-small-plus text-medusa-fg-subtle">{text}</span>
<span
className="text-small-plus text-medusa-fg-subtle"
data-testid="text"
>
{text}
</span>
)}
</div>
{href && (

View File

@@ -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) => (
<div data-testid="bordered-icon">
BorderedIcon {props.icon} {props.IconComponent && <props.IconComponent />}
</div>
),
}))
vi.mock("@/components/Link", () => ({
Link: (props: LinkProps) => <a {...props} />,
}))
vi.mock("@/components/ThemeImage", () => ({
ThemeImage: (props: ThemeImageProps) => (
<div data-testid="theme-image" className={props.className}>
ThemeImage {props.light} {props.dark} {props.className}
</div>
),
}))
import { CardLayoutMini } from "../../Mini"
import { ThemeImageProps } from "../../../../ThemeImage"
describe("rendering", () => {
test("renders card mini layout with title", () => {
const title = "Title"
const { container } = render(
<CardLayoutMini title={title}>Click me</CardLayoutMini>
)
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(
<CardLayoutMini text={text}>Click me</CardLayoutMini>
)
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(
<CardLayoutMini href={href}>Click me</CardLayoutMini>
)
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(
<CardLayoutMini href={href}>Click me</CardLayoutMini>
)
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 = () => <div data-testid="icon">Icon</div>
const { container } = render(
<CardLayoutMini icon={icon}>Click me</CardLayoutMini>
)
expect(container).toBeInTheDocument()
const iconElement = container.querySelector("[data-testid='icon']")
expect(iconElement).toBeInTheDocument()
})
test("renders card mini layout with image", () => {
const { container } = render(
<CardLayoutMini image={exampleDataImageUrl}>Click me</CardLayoutMini>
)
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(
<CardLayoutMini themeImage={themeImage}>Click me</CardLayoutMini>
)
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(
<CardLayoutMini closeable>Click me</CardLayoutMini>
)
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(
<CardLayoutMini closeable onClose={onClose}>
Click me
</CardLayoutMini>
)
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(
<CardLayoutMini className={className}>Click me</CardLayoutMini>
)
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(
<CardLayoutMini
imageDimensions={imageDimensions}
image={exampleDataImageUrl}
>
Click me
</CardLayoutMini>
)
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(
<CardLayoutMini image={exampleDataImageUrl} iconClassName={iconClassName}>
Click me
</CardLayoutMini>
)
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(
<CardLayoutMini themeImage={themeImage} iconClassName={iconClassName}>
Click me
</CardLayoutMini>
)
expect(container).toBeInTheDocument()
const themeImageElement = container.querySelector(
"[data-testid='theme-image']"
)
expect(themeImageElement).toBeInTheDocument()
expect(themeImageElement).toHaveClass(iconClassName)
})
})

View File

@@ -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 = ({
)}
<div className="flex flex-col">
{title && (
<span className="text-x-small-plus text-medusa-fg-base">
<span
className="text-x-small-plus text-medusa-fg-base"
data-testid="title"
>
{title}
</span>
)}
{text && (
<span className="text-x-small-plus text-medusa-fg-subtle">
<span
className="text-x-small-plus text-medusa-fg-subtle"
data-testid="text"
>
{text}
</span>
)}
</div>
{!closeable && (
<span className="text-medusa-fg-subtle">
{isExternal ? <ArrowUpRightOnBox /> : <TriangleRightMini />}
{isExternal ? (
<ArrowUpRightOnBox data-testid="external-icon" />
) : (
<TriangleRightMini data-testid="internal-icon" />
)}
</span>
)}
{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"
>
<XMark />
<XMark data-testid="close-icon" />
</Button>
)}
</div>

View File

@@ -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: () => <div>Badge</div>,
}))
vi.mock("@/components/Card/Layout/Default", () => ({
CardDefaultLayout: () => <div>CardDefaultLayout</div>,
}))
vi.mock("@/components/Card/Layout/Large", () => ({
CardLargeLayout: () => <div>CardLargeLayout</div>,
}))
vi.mock("@/components/Card/Layout/Filler", () => ({
CardFillerLayout: () => <div>CardFillerLayout</div>,
}))
vi.mock("@/components/Card/Layout/Mini", () => ({
CardLayoutMini: () => <div>CardLayoutMini</div>,
}))
import { Card } from "../../Card"
describe("rendering", () => {
test("renders default card", () => {
const { container } = render(<Card>Click me</Card>)
expect(container).toBeInTheDocument()
const card = container.querySelector("div")
expect(card).toBeInTheDocument()
expect(card).toHaveTextContent("CardDefaultLayout")
})
test("renders large card", () => {
const { container } = render(<Card type="large">Click me</Card>)
expect(container).toBeInTheDocument()
const card = container.querySelector("div")
expect(card).toBeInTheDocument()
expect(card).toHaveTextContent("CardLargeLayout")
})
test("renders filler card", () => {
const { container } = render(<Card type="filler">Click me</Card>)
expect(container).toBeInTheDocument()
const card = container.querySelector("div")
expect(card).toBeInTheDocument()
expect(card).toHaveTextContent("CardFillerLayout")
})
test("renders mini card", () => {
const { container } = render(<Card type="mini">Click me</Card>)
expect(container).toBeInTheDocument()
const card = container.querySelector("div")
expect(card).toBeInTheDocument()
expect(card).toHaveTextContent("CardLayoutMini")
})
})

View File

@@ -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"

View File

@@ -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: () => <div data-testid="card">Card</div>,
}))
import { CardList } from "../../CardList"
describe("rendering", () => {
test("render card list with one item", () => {
const items: CardProps[] = [{ title: "Item 1" }]
const { container } = render(<CardList items={items} />)
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(<CardList items={items} />)
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(<CardList items={items} />)
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(
<CardList items={items} defaultItemsPerRow={2} />
)
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(<CardList items={items} itemsPerRow={2} />)
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(
<CardList items={items} defaultItemsPerRow={2} />
)
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(
<CardList items={items} itemsPerRow={2} defaultItemsPerRow={3} />
)
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(
<CardList items={items} className={className} />
)
expect(container).toBeInTheDocument()
const section = container.querySelector("section")
expect(section).toBeInTheDocument()
expect(section).toHaveClass(className)
})
})

View File

@@ -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 = {

View File

@@ -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)

View File

@@ -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
}) => (
<div data-testid="tooltip">
{children} - {innerClassName}
</div>
),
}))
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(
<CodeBlockAskAiAction
source="console.log('Hello, world!');"
inHeader={true}
/>
)
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(
<CodeBlockAskAiAction
source="console.log('Hello, world!');"
inHeader={false}
/>
)
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(
<CodeBlockAskAiAction
source="console.log('Hello, world!');"
inHeader={false}
/>
)
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(
<CodeBlockAskAiAction
source="console.log('Hello, world!');"
inHeader={false}
/>
)
expect(container).toBeInTheDocument()
const span = container.querySelector("span")
expect(span).toBeInTheDocument()
fireEvent.click(span!)
expect(AiAssistantMocks.mockSetChatOpened).not.toHaveBeenCalled()
expect(AiAssistantMocks.mockSubmitQuery).not.toHaveBeenCalled()
})
})

View File

@@ -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"

View File

@@ -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 (
<div
data-testid="copy-button"
className={buttonClassName}
onClick={(e) => {
setIsCopied(true)
onCopy?.(e)
}}
>
{text}
{typeof children === "function" ? children({ isCopied }) : children}
</div>
)
},
}))
import { CodeBlockCopyAction } from ".."
beforeEach(() => {
mockTrack.mockClear()
})
describe("rendering", () => {
test("render code block copy action not in header", () => {
const { container } = render(
<CodeBlockCopyAction
source="console.log('Hello, world!');"
inHeader={false}
/>
)
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(
<CodeBlockCopyAction
source="console.log('Hello, world!');"
inHeader={true}
/>
)
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(
<CodeBlockCopyAction
source="console.log('Hello, world!');"
inHeader={false}
/>
)
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()
})
})

View File

@@ -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 && <SquareTwoStack className={clsx(iconClassName)} />}
{copied && <CheckMini className={clsx(iconClassName)} />}
{!copied && (
<SquareTwoStack
className={clsx(iconClassName)}
data-testid="not-copied-icon"
/>
)}
{copied && (
<CheckMini className={clsx(iconClassName)} data-testid="copied-icon" />
)}
</CopyButton>
)
}

View File

@@ -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"

View File

@@ -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(
<CodeBlockCollapsibleButton
type="start"
collapsed={true}
setCollapsed={mockSetCollapsed}
expandButtonLabel="Show imports"
/>
)
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(
<CodeBlockCollapsibleButton
type="end"
collapsed={true}
setCollapsed={mockSetCollapsed}
expandButtonLabel="Show imports"
/>
)
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(
<CodeBlockCollapsibleButton
type="start"
collapsed={false}
setCollapsed={mockSetCollapsed}
expandButtonLabel="Show imports"
/>
)
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(
<CodeBlockCollapsibleButton
type="start"
collapsed={true}
setCollapsed={mockSetCollapsed}
expandButtonLabel="Show imports"
className="bg-red-500"
/>
)
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(
<CodeBlockCollapsibleButton
type="end"
collapsed={true}
setCollapsed={mockSetCollapsed}
expandButtonLabel="Show imports"
className="bg-red-500"
/>
)
expect(container).toBeInTheDocument()
const button = container.querySelector(
"[data-testid='collapsible-button-end']"
)
expect(button).toBeInTheDocument()
expect(button).toHaveClass("bg-red-500")
})
})

View File

@@ -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}

View File

@@ -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(
<CodeBlockCollapsibleFade type="start" collapsed={true} />
)
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(
<CodeBlockCollapsibleFade type="end" collapsed={true} />
)
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(
<CodeBlockCollapsibleFade type="start" collapsed={false} />
)
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(
<CodeBlockCollapsibleFade
type="start"
collapsed={true}
hasHeader={true}
/>
)
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(
<CodeBlockCollapsibleFade type="end" collapsed={true} hasHeader={true} />
)
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]")
})
})

View File

@@ -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"
/>
)}
</span>

View File

@@ -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(
<CodeBlockCollapsibleLines type="start" collapsed={true}>
<div>Hello</div>
<div>World</div>
<div>!</div>
<div>...</div>
</CodeBlockCollapsibleLines>
)
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(
<CodeBlockCollapsibleLines type="end" collapsed={true}>
<div>Hello</div>
<div>World</div>
<div>!</div>
<div>...</div>
</CodeBlockCollapsibleLines>
)
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(
<CodeBlockCollapsibleLines type="start" collapsed={false}>
<div>Hello</div>
<div>World</div>
<div>!</div>
<div>...</div>
</CodeBlockCollapsibleLines>
)
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(
<CodeBlockCollapsibleLines type="end" collapsed={false}>
<div>Hello</div>
<div>World</div>
<div>!</div>
<div>...</div>
</CodeBlockCollapsibleLines>
)
expect(container).toBeInTheDocument()
expect(container.childElementCount).toBe(4)
expect(container.firstChild).toHaveTextContent("Hello")
expect(container.lastChild).toHaveTextContent("...")
})
})

View File

@@ -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<CollapsibleReturn, "setCollapsed">
} & Pick<CollapsibleReturn, "collapsed">
export const CodeBlockCollapsibleLines = ({
children,

View File

@@ -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(
<CodeBlockHeaderWrapper blockStyle="loud">Hello</CodeBlockHeaderWrapper>
)
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(
<CodeBlockHeaderWrapper blockStyle="subtle">Hello</CodeBlockHeaderWrapper>
)
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(
<CodeBlockHeaderWrapper blockStyle="subtle">Hello</CodeBlockHeaderWrapper>
)
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")
})
})

View File

@@ -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
}

View File

@@ -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) => (
<div>
<span data-testid="badge-variant">{variant}</span>
<span data-testid="badge-children">{children}</span>
</div>
),
}))
vi.mock("@/components/CodeBlock/Actions", () => ({
CodeBlockActions: () => <div data-testid="code-block-actions">Actions</div>,
}))
vi.mock("@/components/CodeBlock/Header/Wrapper", () => ({
CodeBlockHeaderWrapper: ({
children,
blockStyle,
}: CodeBlockHeaderWrapperProps) => (
<div data-testid="code-block-header-wrapper">
<span data-testid="code-block-header-wrapper-blockStyle">
{blockStyle}
</span>
<span data-testid="code-block-header-wrapper-children">{children}</span>
</div>
),
}))
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(
<CodeBlockHeader actionsProps={mockActionsProps} />
)
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(
<CodeBlockHeader
actionsProps={mockActionsProps}
title="Title"
blockStyle="loud"
/>
)
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(
<CodeBlockHeader
actionsProps={mockActionsProps}
title="Title"
blockStyle="subtle"
/>
)
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(
<CodeBlockHeader
actionsProps={mockActionsProps}
title="Title"
blockStyle="subtle"
/>
)
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(
<CodeBlockHeader actionsProps={mockActionsProps} badgeLabel="Badge" />
)
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(
<CodeBlockHeader
actionsProps={mockActionsProps}
badgeLabel="Badge"
badgeColor="blue"
/>
)
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(
<CodeBlockHeader actionsProps={mockActionsProps} hideActions={true} />
)
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()
})
})

View File

@@ -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 (
<CodeBlockHeaderWrapper blockStyle={blockStyle}>
<div className={clsx("flex-1", "flex gap-docs_0.75 items-start")}>
<div
className={clsx("flex-1", "flex gap-docs_0.75 items-start")}
data-testid="code-block-header"
>
{badgeLabel && (
<Badge variant={badgeColor || "code"} className="font-base">
{badgeLabel}
</Badge>
)}
{title && (
<div className={clsx("text-compact-x-small font-base", titleColor)}>
<div
className={clsx("text-compact-x-small font-base", titleColor)}
data-testid="code-block-header-title"
>
{title}
</div>
)}

View File

@@ -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) => (
<div data-testid="markdown-content">{children}</div>
),
}))
vi.mock("@/components/Tooltip", () => ({
Tooltip: ({ children, text, render }: TooltipProps) => (
<div data-testid="tooltip">
{/* @ts-expect-error - render is not typed properly */}
<span data-testid="tooltip-children">{children || render?.()}</span>
<span data-testid="tooltip-text">{text}</span>
</div>
),
}))
import { CodeBlockLine } from "../index"
import { Token } from "prism-react-renderer"
import { Highlight } from "../.."
describe("render", () => {
test("render without highlights", () => {
const { container } = render(
<CodeBlockLine
line={mockLine}
highlights={[]}
lineNumber={1}
showLineNumber={true}
lineNumberColorClassName="text-red-500"
lineNumberBgClassName="bg-red-500"
isTerminal={false}
getLineProps={mockGetLineProps}
getTokenProps={mockGetTokenProps}
/>
)
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(
<CodeBlockLine
line={mockLine}
highlights={mockHighlights}
lineNumber={1}
showLineNumber={true}
lineNumberColorClassName="text-red-500"
lineNumberBgClassName="bg-red-500"
isTerminal={false}
getLineProps={mockGetLineProps}
getTokenProps={mockGetTokenProps}
/>
)
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(
<CodeBlockLine
line={mockLine}
highlights={mockHighlighWithTooltipText}
lineNumber={1}
showLineNumber={true}
lineNumberColorClassName="text-red-500"
lineNumberBgClassName="bg-red-500"
isTerminal={false}
getLineProps={mockGetLineProps}
getTokenProps={mockGetTokenProps}
/>
)
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(
<CodeBlockLine
line={mockLine}
highlights={mockHighlights}
lineNumber={1}
showLineNumber={true}
lineNumberColorClassName="text-red-500"
lineNumberBgClassName="bg-red-500"
isTerminal={false}
getLineProps={mockGetLineProps}
getTokenProps={mockGetTokenProps}
animateTokenHighlights={true}
/>
)
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(
<CodeBlockLine
line={mockLine}
highlights={mockHighlightsInvalidText}
lineNumber={1}
showLineNumber={true}
lineNumberColorClassName="text-red-500"
lineNumberBgClassName="bg-red-500"
isTerminal={false}
getLineProps={mockGetLineProps}
getTokenProps={mockGetTokenProps}
/>
)
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(
<CodeBlockLine
line={mockLine}
highlights={mockHighlights}
lineNumber={1}
showLineNumber={true}
lineNumberColorClassName="text-red-500"
lineNumberBgClassName="bg-red-500"
isTerminal={true}
getLineProps={mockGetLineProps}
getTokenProps={mockGetTokenProps}
/>
)
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(
<CodeBlockLine
line={mockLine}
highlights={mockHighlights}
lineNumber={1}
showLineNumber={false}
lineNumberColorClassName="text-red-500"
lineNumberBgClassName="bg-red-500"
isTerminal={false}
getLineProps={mockGetLineProps}
getTokenProps={mockGetTokenProps}
/>
)
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(
<CodeBlockLine
line={mockLine}
highlights={mockHighlights}
lineNumber={1}
showLineNumber={false}
lineNumberColorClassName="text-red-500"
lineNumberBgClassName="bg-red-500"
isTerminal={true}
getLineProps={mockGetLineProps}
getTokenProps={mockGetTokenProps}
/>
)
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("")
})
})

View File

@@ -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
}) => (
<span className={clsx(isTokenHighlighted && "relative")}>
<span
className={clsx(isTokenHighlighted && "relative")}
data-testid="code-block-line-tokens"
>
{isTokenHighlighted && (
<span
className={clsx(
@@ -227,6 +239,7 @@ export const CodeBlockLine = ({
"lg:bg-medusa-alpha-white-alpha-6 lg:border lg:border-medusa-alpha-white-alpha-12",
"lg:rounded-docs_xs scale-x-[1.05]"
)}
data-testid="code-block-line-highlight"
/>
)}
{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) && (
<span
@@ -272,6 +287,7 @@ export const CodeBlockLine = ({
lineNumberColorClassName,
lineNumberBgClassName
)}
data-testid="line-number"
>
{isTerminal ? "" : showLineNumber ? lineNumber + 1 : ""}
</span>

View File

@@ -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) => (
<div data-testid="api-runner" ref={ref}>
ApiRunner
</div>
))
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[] }) => (
<div data-testid="code-block-line">
{line.map((token) => token.content).join("")}
</div>
),
}))
vi.mock("@/components/CodeBlock/Header", () => ({
CodeBlockHeader: ({ title }: CodeBlockHeaderProps) => (
<div data-testid="code-block-header">{title}</div>
),
}))
vi.mock("@/components/CodeBlock/Actions", () => ({
CodeBlockActions: () => <div data-testid="code-block-actions">Actions</div>,
}))
vi.mock("@/components/CodeBlock/Collapsible/Button", () => ({
CodeBlockCollapsibleButton: () => (
<div data-testid="code-block-collapsible-button">CollapsibleButton</div>
),
}))
vi.mock("@/components/CodeBlock/Collapsible/Fade", () => ({
CodeBlockCollapsibleFade: () => (
<div data-testid="code-block-collapsible-fade">CollapsibleFade</div>
),
}))
vi.mock("@/components/CodeBlock/Inline", () => ({
CodeBlockInline: () => <div data-testid="code-block-inline">Inline</div>,
}))
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(<CodeBlock source={mockSource} />)
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(
<CodeBlock source={mockSource} lang="javascript" />
)
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(<CodeBlock source={mockSource} lang="bash" />)
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(
<CodeBlock source={mockCurlSource} lang="bash" />
)
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(
<CodeBlock source={mockSource} isTerminal={true} />
)
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(
<CodeBlock source={mockSource} isTerminal={false} />
)
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(
<CodeBlock source={mockSource} hasTabs={true} />
)
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(
<CodeBlock source={mockSource} title="Custom Title" />
)
const codeBlockHeader = container.querySelector(
"[data-testid='code-block-header']"
)
expect(codeBlockHeader).toBeInTheDocument()
expect(codeBlockHeader).toHaveTextContent("Custom Title")
})
test("render with forceNoTitle", () => {
const { container } = render(
<CodeBlock source={mockSource} forceNoTitle={true} />
)
const codeBlockHeader = container.querySelector(
"[data-testid='code-block-header']"
)
expect(codeBlockHeader).not.toBeInTheDocument()
})
test("render with blockStyle inline", () => {
const { container } = render(
<CodeBlock source={mockSource} blockStyle="inline" />
)
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(
<CodeBlock source={mockSource} blockStyle="subtle" />
)
const codeBlock = container.querySelector("[data-testid='code-block']")
expect(codeBlock).toBeInTheDocument()
})
test("render with children when source is empty", () => {
const { container } = render(<CodeBlock source="">test children</CodeBlock>)
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(<CodeBlock source="" />)
expect(container.firstChild).toBeNull()
})
test("render with lang json converts to plain", () => {
const { container } = render(<CodeBlock source={mockSource} lang="json" />)
const codeBlock = container.querySelector("[data-testid='code-block']")
expect(codeBlock).toBeInTheDocument()
})
test("render with className prop", () => {
const { container } = render(
<CodeBlock source={mockSource} className="custom-class" />
)
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(
<CodeBlock source={mockSource} wrapperClassName="wrapper-class" />
)
const codeBlock = container.querySelector("[data-testid='code-block']")
expect(codeBlock).toHaveClass("wrapper-class")
})
test("render with innerClassName prop", () => {
const { container } = render(
<CodeBlock source={mockSource} innerClassName="inner-class" />
)
const innerCode = container.querySelector(".inner-class")
expect(innerCode).toBeInTheDocument()
})
test("render with style prop", () => {
const { container } = render(
<CodeBlock source={mockSource} style={{ marginTop: "10px" }} />
)
const codeBlock = container.querySelector(".code-block-elm")
expect(codeBlock).toHaveStyle({ marginTop: "10px" })
})
test("render with collapsed prop", () => {
const { container } = render(
<CodeBlock source={mockMultiLineSource} collapsed={true} />
)
const codeBlock = container.querySelector(".code-block-elm")
expect(codeBlock).toHaveClass("max-h-[400px]")
})
test("render with noLineNumbers prop", () => {
const { container } = render(
<CodeBlock source={mockMultiLineSource} noLineNumbers={true} />
)
const codeBlock = container.querySelector("[data-testid='code-block']")
expect(codeBlock).toBeInTheDocument()
})
test("render with highlights prop", () => {
const { container } = render(
<CodeBlock
source={mockMultiLineSource}
highlights={[["1", "highlight text", "tooltip"]]}
/>
)
const codeBlock = container.querySelector("[data-testid='code-block']")
expect(codeBlock).toBeInTheDocument()
})
test("render with overrideColors prop", () => {
const { container } = render(
<CodeBlock
source={mockSource}
overrideColors={{
bg: "bg-red-500",
innerBg: "bg-blue-500",
border: "border-green-500",
}}
/>
)
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(
<CodeBlock source={mockSource} blockStyle="subtle" />
)
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(
<CodeBlock
source={mockSource}
apiTesting={true}
testApiMethod="GET"
testApiUrl="https://api.example.com"
/>
)
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(
<CodeBlock source={mockSource} apiTesting={true} />
)
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(
<CodeBlock
source={mockMultiLineSource}
collapsibleLines="1-2"
expandButtonLabel="Show more"
/>
)
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(
<CodeBlock source={mockMultiLineSource} collapsibleLines="2-3" />
)
const codeBlock = container.querySelector("[data-testid='code-block']")
expect(codeBlock).toBeInTheDocument()
})
})
describe("actions", () => {
test("render with noCopy prop hides copy action", () => {
const { container } = render(
<CodeBlock source={mockSource} noCopy={true} />
)
const codeBlock = container.querySelector("[data-testid='code-block']")
expect(codeBlock).toBeInTheDocument()
})
test("render with noReport prop hides report action", () => {
const { container } = render(
<CodeBlock source={mockSource} noReport={true} />
)
const codeBlock = container.querySelector("[data-testid='code-block']")
expect(codeBlock).toBeInTheDocument()
})
test("render with noAskAi prop hides ask AI action", () => {
const { container } = render(
<CodeBlock source={mockSource} noAskAi={true} />
)
const codeBlock = container.querySelector("[data-testid='code-block']")
expect(codeBlock).toBeInTheDocument()
})
test("render with all action flags disabled hides actions", () => {
const { container } = render(
<CodeBlock
source={mockSource}
noCopy={true}
noReport={true}
noAskAi={true}
/>
)
const codeBlock = container.querySelector("[data-testid='code-block']")
expect(codeBlock).toBeInTheDocument()
})
})
describe("interaction", () => {
test("tracks copy event when code is copied", () => {
const { container } = render(<CodeBlock source={mockSource} />)
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(<CodeBlock source={mockSource} />)
const codeBlock = container.querySelector("[data-testid='code-block']")
expect(codeBlock).toBeInTheDocument()
})
test("render multi-line code", () => {
const { container } = render(<CodeBlock source={mockMultiLineSource} />)
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)
})
})

View File

@@ -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 && (
<CodeBlockHeader
@@ -393,6 +396,7 @@ export const CodeBlock = ({
className
)}
style={style}
data-testid="code-block-inner"
>
<Highlight
theme={codeTheme}

View File

@@ -0,0 +1,134 @@
import React from "react"
import { describe, expect, test, vi } from "vitest"
import { render } from "@testing-library/react"
import { CodeBlockProps } from "../../CodeBlock"
import { InlineCodeProps } from "../../InlineCode"
// mock data
const mockSource = "console.log('Hello, world!')"
// mock components
vi.mock("@/components/CodeBlock", () => ({
CodeBlock: ({ source, ...codeBlockProps }: CodeBlockProps) => (
<div data-testid="code-block">
<div data-testid="code-block-source">{source}</div>
<div data-testid="code-block-props">
{JSON.stringify(codeBlockProps as Record<string, unknown>)}
</div>
</div>
),
}))
vi.mock("@/components/InlineCode", () => ({
InlineCode: ({ children, ...inlineCodeProps }: InlineCodeProps) => (
<div data-testid="inline-code">
<div data-testid="inline-code-children">{children}</div>
<div data-testid="inline-code-props">
{JSON.stringify(inlineCodeProps)}
</div>
</div>
),
}))
vi.mock("@/components/MermaidDiagram", () => ({
MermaidDiagram: ({ diagramContent }: { diagramContent: string }) => (
<div data-testid="mermaid-diagram">{diagramContent}</div>
),
}))
vi.mock("@/components/Npm2YarnCode", () => ({
Npm2YarnCode: ({ npmCode }: { npmCode: string }) => (
<div data-testid="npm2yarn-code">{npmCode}</div>
),
}))
import { CodeMdx } from "../../CodeMdx"
describe("render", () => {
test("renders without children", () => {
const { container } = render(<CodeMdx />)
expect(container).toBeInTheDocument()
expect(container).toBeEmptyDOMElement()
})
test("renders with children", () => {
const { container } = render(<CodeMdx>{mockSource}</CodeMdx>)
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(
<CodeMdx className="language-javascript">{mockSource}</CodeMdx>
)
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(
<CodeMdx npm2yarn={true} className="language-bash">
{mockSource}
</CodeMdx>
)
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(
<CodeMdx className="language-mermaid">{mockSource}</CodeMdx>
)
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(
<CodeMdx codeBlockProps={codeBlockProps} className="language-javascript">
{mockSource}
</CodeMdx>
)
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<InlineCodeProps> = { variant: "grey-bg" }
const { container } = render(
<CodeMdx inlineCodeProps={inlineCodeProps}>{mockSource}</CodeMdx>
)
expect(container).toBeInTheDocument()
const inlineCodePropsElement = container.querySelector(
"[data-testid='inline-code-props']"
)
expect(inlineCodePropsElement).toBeInTheDocument()
expect(inlineCodePropsElement).toHaveTextContent(
JSON.stringify(inlineCodeProps)
)
})
})

View File

@@ -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 = {

View File

@@ -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(
<CodeTab label={mockLabel} value={mockValue}>
<div data-testid="children">Children</div>
</CodeTab>
)
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(
<CodeTab label={mockLabel} value={mockValue} isSelected={true}>
<div data-testid="children">Children</div>
</CodeTab>
)
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(
<CodeTab label={mockLabel} value={mockValue} blockStyle="subtle">
<div data-testid="children">Children</div>
</CodeTab>
)
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(
<CodeTab label={mockLabel} value={mockValue} blockStyle="subtle">
<div data-testid="children">Children</div>
</CodeTab>
)
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(
<CodeTab
label={mockLabel}
value={mockValue}
blockStyle="subtle"
isSelected={true}
>
<div data-testid="children">Children</div>
</CodeTab>
)
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(
<CodeTab
label={mockLabel}
value={mockValue}
blockStyle="subtle"
isSelected={true}
>
<div data-testid="children">Children</div>
</CodeTab>
)
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(
<CodeTab label={mockLabel} value={mockValue} blockStyle="loud">
<div data-testid="children">Children</div>
</CodeTab>
)
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(
<CodeTab label={mockLabel} value={mockValue} pushRef={mockPushRef}>
<div data-testid="children">Children</div>
</CodeTab>
)
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(
<CodeTab label={mockLabel} value={mockValue}>
<div data-testid="children">Children</div>
</CodeTab>
)
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(
<CodeTab
label={mockLabel}
value={mockValue}
changeSelectedTab={mockChangeSelectedTab}
>
<div data-testid="children">Children</div>
</CodeTab>
)
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,
})
})
})

View File

@@ -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

View File

@@ -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
}) => (
<div data-testid="badge" data-variant={variant}>
{children}
</div>
),
}))
vi.mock("@/components/CodeBlock", () => ({
CodeBlock: ({ source, hasTabs }: { source: string; hasTabs?: boolean }) => (
<div data-testid="code-block" data-source={source} data-has-tabs={hasTabs}>
{source}
</div>
),
}))
vi.mock("@/components/CodeBlock/Actions", () => ({
CodeBlockActions: ({ source }: { source: string }) => (
<div data-testid="code-block-actions" data-source={source}>
Actions
</div>
),
}))
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
}) => (
<li>
<button
data-testid={`code-tab-${value}`}
data-selected={isSelected}
data-block-style={blockStyle}
ref={(tabButton) => pushRef?.(tabButton)}
onClick={() => changeSelectedTab?.({ label, value })}
aria-selected={isSelected}
role="tab"
>
{label}
</button>
{children}
</li>
),
}))
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(
<CodeTabs>
<CodeTab label="Tab 1" value="tab1">
<CodeBlock source="console.log('test')" />
</CodeTab>
</CodeTabs>
)
const codeTabs = container.querySelector("[data-testid='code-tabs']")
expect(codeTabs).toBeInTheDocument()
})
test("renders with className", () => {
const { container } = render(
<CodeTabs className="custom-class">
<CodeTab label="Tab 1" value="tab1">
<CodeBlock source="console.log('test')" />
</CodeTab>
</CodeTabs>
)
const wrapper = container.querySelector("[data-testid='code-tabs']")
expect(wrapper).toHaveClass("custom-class")
})
test("renders with blockStyle loud", () => {
const { container } = render(
<CodeTabs blockStyle="loud">
<CodeTab label="Tab 1" value="tab1">
<CodeBlock source="console.log('test')" />
</CodeTab>
</CodeTabs>
)
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(
<CodeTabs blockStyle="subtle">
<CodeTab label="Tab 1" value="tab1">
<CodeBlock source="console.log('test')" />
</CodeTab>
</CodeTabs>
)
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(
<CodeTabs blockStyle="subtle">
<CodeTab label="Tab 1" value="tab1">
<CodeBlock source="console.log('test')" />
</CodeTab>
</CodeTabs>
)
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(
<CodeTabs>
<CodeTab label="Tab 1" value="tab1">
<CodeBlock source="console.log('test1')" />
</CodeTab>
<CodeTab label="Tab 2" value="tab2">
<CodeBlock source="console.log('test2')" />
</CodeTab>
</CodeTabs>
)
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: <CodeBlock source="code1" />,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
changeSelectedTab: mockChangeSelectedTab,
})
const { container } = render(
<CodeTabs>
<CodeTab label="Tab 1" value="tab1">
<CodeBlock source="code1" />
</CodeTab>
</CodeTabs>
)
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: (
<CodeBlock source="code1" badgeLabel="New" badgeColor="green" />
),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
changeSelectedTab: mockChangeSelectedTab,
})
const { container } = render(
<CodeTabs>
<CodeTab label="Tab 1" value="tab1">
<CodeBlock source="code1" badgeLabel="New" badgeColor="green" />
</CodeTab>
</CodeTabs>
)
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: <CodeBlock source="code1" />,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
changeSelectedTab: mockChangeSelectedTab,
})
const { container } = render(
<CodeTabs>
<CodeTab label="Tab 1" value="tab1">
<CodeBlock source="code1" />
</CodeTab>
</CodeTabs>
)
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: <CodeBlock source="code1" />,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
changeSelectedTab: mockChangeSelectedTab,
})
const { container } = render(
<CodeTabs>
<CodeTab label="Tab 1" value="tab1">
<CodeBlock source="code1" />
</CodeTab>
</CodeTabs>
)
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(
<CodeTabs>
<CodeTab label="Tab 1" value="tab1">
<CodeBlock source="code1" />
</CodeTab>
</CodeTabs>
)
const actions = container.querySelector(
"[data-testid='code-block-actions']"
)
expect(actions).not.toBeInTheDocument()
})
test("renders tab selector span", () => {
const { container } = render(
<CodeTabs>
<CodeTab label="Tab 1" value="tab1">
<CodeBlock source="console.log('test')" />
</CodeTab>
</CodeTabs>
)
const selector = container.querySelector("span.xs\\:absolute")
expect(selector).toBeInTheDocument()
})
test("filters out invalid children", () => {
const { container } = render(
<CodeTabs>
<CodeTab label="Tab 1" value="tab1">
<CodeBlock source="code1" />
</CodeTab>
{null}
<div>Invalid child</div>
<CodeTab label="Tab 2" value="tab2">
<CodeBlock source="code2" />
</CodeTab>
</CodeTabs>
)
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(
<CodeTabs>
<CodeTab label="Tab 1" value="tab1">
<CodeBlock source="code1" />
</CodeTab>
<CodeTab label="Tab 2" value="tab2">
<CodeBlock source="code2" />
</CodeTab>
</CodeTabs>
)
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(
<CodeTabs>
<CodeTab label="Tab 1" value="tab1">
<CodeBlock source="code1" />
</CodeTab>
<CodeTab label="Tab 2" value="tab2">
<CodeBlock source="code2" />
</CodeTab>
</CodeTabs>
)
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: <CodeBlock source="code2" />,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
changeSelectedTab: mockChangeSelectedTab,
})
const { container } = render(
<CodeTabs>
<CodeTab label="Tab 1" value="tab1">
<CodeBlock source="code1" />
</CodeTab>
<CodeTab label="Tab 2" value="tab2">
<CodeBlock source="code2" />
</CodeTab>
</CodeTabs>
)
const buttons = container.querySelectorAll("button[role='tab']")
expect(buttons[0]).toHaveAttribute("aria-selected", "false")
expect(buttons[1]).toHaveAttribute("aria-selected", "true")
})
})

View File

@@ -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"
>
<CodeBlockHeaderWrapper blockStyle={blockStyle} ref={codeTabsWrapperRef}>
<span

View File

@@ -0,0 +1,93 @@
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 functions
const mockUseSiteConfig = vi.fn(() => ({
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(<ContentMenuActions />)
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(<ContentMenuActions />)
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(<ContentMenuActions />)
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()
})
})

View File

@@ -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 = () => {
<Link
className="flex items-center gap-docs_0.5 text-medusa-fg-subtle text-x-small-plus hover:text-medusa-fg-base"
href={`${pageUrl}/index.html.md`}
data-testid="markdown-link"
>
<MarkdownIcon width={15} height={15} />
View as Markdown
@@ -41,6 +43,7 @@ export const ContentMenuActions = () => {
<button
className="appearance-none p-0 flex items-center gap-docs_0.5 text-medusa-fg-subtle text-x-small-plus hover:text-medusa-fg-base"
onClick={handleAiAssistantClick}
data-testid="ai-assistant-button"
>
<BroomSparkle width={15} height={15} />
Explain with AI Assistant

View File

@@ -0,0 +1,61 @@
import React from "react"
import { beforeEach, describe, expect, test, vi } from "vitest"
import { render } from "@testing-library/react"
// mock data
const mockConfig = {
baseUrl: "https://docs.medusajs.com",
basePath: "",
}
const defaultUseSiteConfigReturn = {
frontmatter: {
products: ["product", "cart"],
},
config: mockConfig,
}
const productProduct = products.find((p) => p.name === "product")
const cartProduct = products.find((p) => p.name === "cart")
// mock functions
const mockUseSiteConfig = vi.fn(() => defaultUseSiteConfigReturn)
// mock components
vi.mock("@/providers/SiteConfig", () => ({
useSiteConfig: () => mockUseSiteConfig(),
}))
vi.mock("@/components/BorderedIcon", () => ({
BorderedIcon: ({ children }: { children: React.ReactNode }) => (
<div data-testid="bordered-icon">{children}</div>
),
}))
import { ContentMenuProducts } from "../../Products"
import { products } from "../../../../constants"
beforeEach(() => {
mockUseSiteConfig.mockReturnValue(defaultUseSiteConfigReturn)
})
describe("render", () => {
test("render product menu", () => {
const { container } = render(<ContentMenuProducts />)
expect(container).toBeInTheDocument()
const productLinks = container.querySelectorAll(
"a[data-testid='product-link']"
)
expect(productLinks).toHaveLength(2)
expect(productLinks[0]).toBeInTheDocument()
expect(productLinks[0]).toHaveAttribute(
"href",
`https://docs.medusajs.com${cartProduct?.path}`
)
expect(productLinks[0]).toHaveTextContent(cartProduct!.title)
expect(productLinks[1]).toBeInTheDocument()
expect(productLinks[1]).toHaveAttribute(
"href",
`https://docs.medusajs.com${productProduct?.path}`
)
expect(productLinks[1]).toHaveTextContent(productProduct!.title)
})
})

View File

@@ -1,7 +1,7 @@
"use client"
import React, { useMemo } from "react"
import { useSiteConfig } from "../../../providers"
import { useSiteConfig } from "@/providers/SiteConfig"
import { products } from "../../../constants"
import { Product } from "types"
import { BorderedIcon } from "../../BorderedIcon"
@@ -43,6 +43,7 @@ export const ContentMenuProducts = () => {
key={index}
href={getProductUrl(product)}
className="flex gap-docs_0.5 items-center group"
data-testid="product-link"
>
<BorderedIcon
wrapperClassName={clsx("bg-medusa-bg-base")}

View File

@@ -0,0 +1,222 @@
import React from "react"
import { beforeEach, describe, expect, test, vi } from "vitest"
import { fireEvent, render, waitFor } from "@testing-library/react"
import { ToCItem } from "types"
// mock data
const mockToc: ToCItem[] = [
{
title: "Overview",
id: "overview",
level: 2,
},
{
title: "Getting Started",
id: "getting-started",
level: 2,
},
]
const mockTocWithChildren: ToCItem[] = [
{
title: "Overview",
id: "overview",
level: 2,
},
{
title: "Getting Started",
id: "getting-started",
level: 2,
children: [
{
title: "Installation",
id: "installation",
level: 3,
},
],
},
]
let currentToc: ToCItem[] | null = mockToc as ToCItem[] | null
let currentFrontmatter = {
generate_toc: false,
}
const mockHeading = document.createElement("h2")
mockHeading.id = "overview"
mockHeading.textContent = "Overview"
const mockUseActiveOnScroll = vi.fn(() => ({
items: [
{
heading: mockHeading,
},
],
activeItemId: "overview",
}))
// mock functions
const mockSetToc = vi.fn((toc: ToCItem[] | null) => {
currentToc = toc
})
// Create a getter function that always returns the latest values
const getUseSiteConfigReturn = () => ({
toc: currentToc,
frontmatter: currentFrontmatter,
setToc: mockSetToc,
})
const mockUseSiteConfig = vi.fn(() => getUseSiteConfigReturn())
const mockScrollToElement = vi.fn()
const mockUseScrollController = vi.fn(() => ({
scrollToElement: mockScrollToElement,
}))
// mock components
vi.mock("@/providers/SiteConfig", () => ({
useSiteConfig: () => mockUseSiteConfig(),
}))
vi.mock("@/hooks/use-scroll-utils", () => ({
useScrollController: () => mockUseScrollController(),
}))
vi.mock("@/hooks/use-active-on-scroll", () => ({
useActiveOnScroll: () => mockUseActiveOnScroll(),
}))
vi.mock("@/components/Loading", () => ({
Loading: () => <div data-testid="loading">Loading...</div>,
}))
import { ContentMenuToc } from "../../Toc"
beforeEach(() => {
// scrollIntoView is not available in the testing environment, so we need to mock it
window.HTMLElement.prototype.scrollIntoView = function () {}
// Reset toc and frontmatter to default values
currentToc = mockToc as ToCItem[] | null
currentFrontmatter = {
generate_toc: false,
}
// Always use the getter function to return fresh values
mockUseSiteConfig.mockImplementation(() => getUseSiteConfigReturn())
mockSetToc.mockClear()
mockUseScrollController.mockClear()
mockUseActiveOnScroll.mockClear()
})
describe("render", () => {
test("render toc", () => {
const { container } = render(<ContentMenuToc />)
expect(container).toBeInTheDocument()
const tocList = container.querySelector("[data-testid='toc-list']")
expect(tocList).toBeInTheDocument()
const tocItems = container.querySelectorAll("[data-testid='toc-item']")
expect(tocItems).toHaveLength(2)
expect(tocItems[0]).toBeInTheDocument()
expect(tocItems[0]).toHaveTextContent("Overview")
const tocItemLink0 = tocItems[0].querySelector("a")
expect(tocItemLink0).toBeInTheDocument()
expect(tocItemLink0).toHaveAttribute("href", "#overview")
expect(tocItems[1]).toBeInTheDocument()
expect(tocItems[1]).toHaveTextContent("Getting Started")
const tocItemLink1 = tocItems[1].querySelector("a")
expect(tocItemLink1).toBeInTheDocument()
expect(tocItemLink1).toHaveAttribute("href", "#getting-started")
})
test("render toc with children", () => {
mockUseSiteConfig.mockReturnValue({
...getUseSiteConfigReturn(),
toc: mockTocWithChildren,
})
const { container } = render(<ContentMenuToc />)
expect(container).toBeInTheDocument()
const tocLists = container.querySelectorAll("[data-testid='toc-list']")
expect(tocLists).toHaveLength(2)
expect(tocLists[0]).toBeInTheDocument()
const firstTocItems = tocLists[0].querySelectorAll(
"[data-testid='toc-item']"
)
// the nested item will be included in the first toc list
expect(firstTocItems).toHaveLength(3)
expect(firstTocItems[0]).toBeInTheDocument()
expect(firstTocItems[0]).toHaveTextContent("Overview")
const firstTocItemLink0 = firstTocItems[0].querySelector("a")
expect(firstTocItemLink0).toBeInTheDocument()
expect(firstTocItemLink0).toHaveAttribute("href", "#overview")
expect(firstTocItems[1]).toBeInTheDocument()
expect(firstTocItems[1]).toHaveTextContent("Getting Started")
const firstTocItemLink1 = firstTocItems[1].querySelector("a")
expect(firstTocItemLink1).toBeInTheDocument()
expect(firstTocItemLink1).toHaveAttribute("href", "#getting-started")
const secondTocItems = tocLists[1].querySelectorAll(
"[data-testid='toc-item']"
)
expect(secondTocItems).toHaveLength(1)
expect(secondTocItems[0]).toBeInTheDocument()
expect(secondTocItems[0]).toHaveTextContent("Installation")
const secondTocItemLink0 = secondTocItems[0].querySelector("a")
expect(secondTocItemLink0).toBeInTheDocument()
expect(secondTocItemLink0).toHaveAttribute("href", "#installation")
})
test("render toc with no items", () => {
currentToc = []
const { container } = render(<ContentMenuToc />)
expect(container).toBeInTheDocument()
expect(container.childElementCount).toBe(0)
})
test("render toc with empty items", () => {
currentToc = null
const { container } = render(<ContentMenuToc />)
expect(container).toBeInTheDocument()
const emptyTocItems = container.querySelector(
"[data-testid='empty-toc-items']"
)
expect(emptyTocItems).toBeInTheDocument()
})
})
describe("toc generation", () => {
test("generate toc", async () => {
currentToc = null
currentFrontmatter = {
generate_toc: true,
}
const { container, rerender } = render(<ContentMenuToc />)
expect(container).toBeInTheDocument()
// Wait for useEffect to run and call setToc
await waitFor(() => {
expect(mockSetToc).toHaveBeenCalled()
})
// After setToc is called, currentToc should be updated
// Rerender to see the updated toc
rerender(<ContentMenuToc />)
const tocList = container.querySelector("[data-testid='toc-list']")
expect(tocList).toBeInTheDocument()
const tocItems = container.querySelectorAll("[data-testid='toc-item']")
expect(tocItems.length).toBeGreaterThan(0)
if (tocItems.length > 0) {
expect(tocItems[0]).toBeInTheDocument()
expect(tocItems[0]).toHaveTextContent("Overview")
const tocItemLink0 = tocItems[0].querySelector("a")
expect(tocItemLink0).toBeInTheDocument()
expect(tocItemLink0).toHaveAttribute("href", "#overview")
}
})
})
describe("toc scrolling", () => {
test("scroll to toc item", () => {
currentToc = mockToc as ToCItem[] | null
const { container } = render(<ContentMenuToc />)
expect(container).toBeInTheDocument()
const tocItems = container.querySelectorAll("[data-testid='toc-item']")
expect(tocItems).toHaveLength(2)
const tocItemLink0 = tocItems[0].querySelector("a")
expect(tocItemLink0).toBeInTheDocument()
fireEvent.click(tocItemLink0!)
expect(mockScrollToElement).toHaveBeenCalled()
})
})

View File

@@ -2,14 +2,14 @@
import React, { useEffect } from "react"
import { ToCItem, ToCItemUi } from "types"
import { useScrollController } from "../../../hooks/use-scroll-utils"
import {
ActiveOnScrollItem,
useActiveOnScroll,
useScrollController,
} from "../../../hooks"
} from "../../../hooks/use-active-on-scroll"
import clsx from "clsx"
import Link from "next/link"
import { useSiteConfig } from "../../../providers"
import { useSiteConfig } from "../../../providers/SiteConfig"
import { Loading } from "../../Loading"
export const ContentMenuToc = () => {
@@ -92,7 +92,7 @@ type TocListProps = {
const TocList = ({ items, activeItemId, className }: TocListProps) => {
return (
<ul className={className}>
<ul className={className} data-testid="toc-list">
{items.map((item) => (
<TocItem item={item} key={item.id} activeItemId={activeItemId} />
))}
@@ -108,7 +108,7 @@ type TocItemProps = {
const TocItem = ({ item, activeItemId }: TocItemProps) => {
const { scrollToElement } = useScrollController()
return (
<li className="w-full pt-docs_0.5 toc-item">
<li className="w-full pt-docs_0.5 toc-item" data-testid="toc-item">
<Link
href={`#${item.id}`}
className={clsx(
@@ -143,7 +143,7 @@ const TocItem = ({ item, activeItemId }: TocItemProps) => {
const EmptyTocItems = () => {
return (
<div className="animate-pulse">
<div className="animate-pulse" data-testid="empty-toc-items">
<Loading count={5} className="pt-docs_0.5 px-docs_0.75 !my-0" />
</div>
)

View File

@@ -0,0 +1,300 @@
import React from "react"
import { beforeEach, describe, expect, test, vi } from "vitest"
import { fireEvent, render, waitFor } from "@testing-library/react"
// mock data
const mockVersion = {
number: "2.0.0",
releaseUrl: "https://github.com/example/releases/v2.0.0",
hide: false,
bannerImage: {
light: "/banner-light.png",
dark: "/banner-dark.png",
},
}
const defaultUseSiteConfigReturn = {
config: {
version: mockVersion,
},
setConfig: vi.fn(),
frontmatter: {},
setFrontmatter: vi.fn(),
toc: null,
setToc: vi.fn(),
}
// mock functions
const mockUseSiteConfig = vi.fn(() => defaultUseSiteConfigReturn)
const mockUseIsBrowser = vi.fn(() => ({
isBrowser: true,
}))
// mock components
vi.mock("@/providers/SiteConfig", () => ({
useSiteConfig: () => mockUseSiteConfig(),
}))
vi.mock("@/providers/BrowserProvider", () => ({
useIsBrowser: () => mockUseIsBrowser(),
}))
vi.mock("@/components/Card", () => ({
Card: ({
title,
text,
closeable,
onClose,
href,
hrefProps,
themeImage,
imageDimensions,
className,
iconClassName,
cardRef,
}: {
title?: string
text?: string
closeable?: boolean
onClose?: () => void
href?: string
hrefProps?: Record<string, unknown>
themeImage?: { light: string; dark: string }
imageDimensions?: { width: number; height: number }
className?: string
iconClassName?: string
cardRef?: React.Ref<HTMLDivElement>
}) => {
const ref = React.useRef<HTMLDivElement>(null)
React.useImperativeHandle(cardRef, () => ref.current as HTMLDivElement)
return (
<div
data-testid="version-card"
ref={ref}
className={className}
data-title={title}
data-text={text}
data-href={href}
data-closeable={closeable}
data-theme-image-light={themeImage?.light}
data-theme-image-dark={themeImage?.dark}
data-image-width={imageDimensions?.width}
data-image-height={imageDimensions?.height}
data-icon-class-name={iconClassName}
>
{title && <div data-testid="card-title">{title}</div>}
{text && <div data-testid="card-text">{text}</div>}
{closeable && onClose && (
<button data-testid="card-close" onClick={onClose}>
Close
</button>
)}
{href && (
<a
data-testid="card-link"
href={href}
{...(hrefProps as Record<string, unknown>)}
>
Link
</a>
)}
</div>
)
},
}))
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(<ContentMenuVersion />)
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(<ContentMenuVersion />)
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(<ContentMenuVersion />)
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(<ContentMenuVersion />)
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(<ContentMenuVersion />)
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(<ContentMenuVersion />)
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(<ContentMenuVersion />)
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(<ContentMenuVersion />)
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(<ContentMenuVersion />)
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(<ContentMenuVersion />)
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(<ContentMenuVersion />)
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(<ContentMenuVersion />)
const card = container.querySelector("[data-testid='version-card']")
expect(card).toBeInTheDocument()
expect(card).toHaveAttribute("data-text", `v${newVersion.number} details`)
})
})

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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) => (
<div
data-testid="tooltip"
data-text={text}
data-tooltip-class-name={tooltipClassName}
data-inner-class-name={innerClassName}
>
{children}
</div>
),
}))
vi.mock("@/hooks/use-copy", () => ({
useCopy: () => mockUseCopy(),
}))
import { CopyButton } from "../index"
beforeEach(() => {
vi.clearAllMocks()
})
describe("render", () => {
test("renders copy button", () => {
const { container } = render(<CopyButton text="Copy to Clipboard" />)
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(
<CopyButton text="Copy to Clipboard" tooltipText="Custom Copy" />
)
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(
<CopyButton text="Copy to Clipboard" tooltipClassName="custom-tooltip" />
)
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(
<CopyButton
text="Copy to Clipboard"
tooltipInnerClassName="custom-tooltip-inner"
/>
)
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(
<CopyButton text="Copy to Clipboard" buttonClassName="custom-button" />
)
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(
<CopyButton text="Copy to Clipboard" handleTouch />
)
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(<CopyButton text="Copy to Clipboard" />)
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(<CopyButton text="Copy to Clipboard" />)
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(
<CopyButton text="Copy to Clipboard" handleTouch />
)
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(
<CopyButton text="Copy to Clipboard" handleTouch />
)
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(
<CopyButton text="Copy to Clipboard" handleTouch />
)
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(
<CopyButton text="Copy to Clipboard" handleTouch />
)
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(
<CopyButton text="Copy to Clipboard" onCopy={mockOnCopy} />
)
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(
<CopyButton text="Copy to Clipboard" handleTouch onCopy={mockOnCopy} />
)
const copyButton = container.querySelector("[data-testid='copy-button']")
expect(copyButton).toBeInTheDocument()
fireEvent.touchEnd(copyButton!)
expect(mockOnCopy).not.toHaveBeenCalled()
fireEvent.touchEnd(copyButton!)
expect(mockOnCopy).toHaveBeenCalled()
})
})

View File

@@ -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}
</span>

View File

@@ -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) => (
<div data-testid="copy-button" data-text={text}>
{typeof children === "function"
? children({ isCopied: false })
: children}
</div>
),
}))
import { CopyGeneratedSnippetButton } from "../../CopyGeneratedSnippetButton"
describe("render", () => {
test("renders copy generated snippet button", () => {
const { container } = render(
<CopyGeneratedSnippetButton {...mockUseGenerateSnippetProps} />
)
const copyButton = container.querySelector("[data-testid='copy-button']")
expect(copyButton).toBeInTheDocument()
expect(copyButton).toHaveAttribute(
"data-text",
defaultUseGenerateSnippetReturn.snippet
)
})
})

View File

@@ -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 & {

View File

@@ -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(<DetailsSummary>Test</DetailsSummary>)
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(<DetailsSummary title="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 subtitle", () => {
const { container } = render(<DetailsSummary subtitle="Test" />)
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 = <div data-testid="test-badge">Test Badge</div>
const { container } = render(<DetailsSummary badge={badge} />)
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(<DetailsSummary expandable />)
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(<DetailsSummary hideExpandableIcon />)
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(<DetailsSummary className="test-class" />)
const summary = container.querySelector("summary")
expect(summary).toBeInTheDocument()
expect(summary).toHaveClass("test-class")
})
test("renders details summary with titleClassName", () => {
const { container } = render(
<DetailsSummary titleClassName="test-title-class" />
)
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(<DetailsSummary summaryRef={summaryRef} />)
const summary = container.querySelector("summary")
expect(summary).toBeInTheDocument()
expect(summaryRef).toHaveBeenCalled()
})
test("renders details summary with rest props", () => {
const { container } = render(<DetailsSummary data-testid="test-summary" />)
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(<DetailsSummary expandable open />)
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")
})
})

View File

@@ -47,17 +47,21 @@ export const DetailsSummary = ({
"text-compact-medium-plus text-medusa-fg-base",
titleClassName
)}
data-testid="details-summary-title"
>
{title || children}
</span>
{subtitle && (
<span className="text-compact-medium text-medusa-fg-subtle mt-0.5">
<span
className="text-compact-medium text-medusa-fg-subtle mt-0.5"
data-testid="details-summary-subtitle"
>
{subtitle}
</span>
)}
</span>
{(badge || expandable) && (
<span className="flex gap-docs_0.5">
<span className="flex gap-docs_0.5" data-testid="details-summary-extra">
{badge}
{expandable && !hideExpandableIcon && (
<PlusMini

View File

@@ -0,0 +1,352 @@
import React from "react"
import { beforeEach, describe, expect, test, vi } from "vitest"
import { fireEvent, render } from "@testing-library/react"
import { CollapsibleProps } from "../../../hooks/use-collapsible"
// mock hooks
const mockSetCollapsed = vi.fn()
const mockGetCollapsibleElms = vi.fn(
(children: React.ReactNode): React.ReactNode => 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 }) => (
<div data-testid="loading" className={className}>
Loading...
</div>
),
}))
vi.mock("@/components/Details/Summary", () => ({
DetailsSummary: ({ title, onClick }: DetailsSummaryProps) => (
<summary data-testid="details-summary-title" onClick={onClick}>
{title}
</summary>
),
}))
// Mock Suspense to avoid act warnings - render children directly
vi.mock("react", async () => {
const actual = await vi.importActual<typeof React>("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(
<Details>
<div>Content</div>
</Details>
)
const details = container.querySelector("details")
expect(details).toBeInTheDocument()
expect(details).toHaveTextContent("Content")
})
test("renders with summaryContent", () => {
const { container } = render(
<Details summaryContent="Summary Title">
<div>Content</div>
</Details>
)
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 = <DetailsSummary title="Custom Summary" />
const { container } = render(
<Details summaryElm={summaryElm}>
<div>Content</div>
</Details>
)
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(
<Details className="custom-class">
<div>Content</div>
</Details>
)
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(
<Details openInitial={false}>
<div>Content</div>
</Details>
)
const details = container.querySelector("details")
expect(details).not.toHaveAttribute("open")
})
test("renders open when openInitial is true", () => {
const { container } = render(
<Details openInitial={true}>
<div>Content</div>
</Details>
)
const details = container.querySelector("details")
expect(details).toHaveAttribute("open")
})
test("passes correct initialValue to useCollapsible when openInitial is false", () => {
render(
<Details openInitial={false}>
<div>Content</div>
</Details>
)
expect(mockUseCollapsible).toHaveBeenCalledWith(
expect.objectContaining({
initialValue: true, // !openInitial = !false = true
})
)
})
test("passes correct initialValue to useCollapsible when openInitial is true", () => {
render(
<Details openInitial={true}>
<div>Content</div>
</Details>
)
expect(mockUseCollapsible).toHaveBeenCalledWith(
expect.objectContaining({
initialValue: false, // !openInitial = !true = false
})
)
})
})
describe("height animation", () => {
test("passes heightAnimation to useCollapsible when true", () => {
render(
<Details heightAnimation={true}>
<div>Content</div>
</Details>
)
expect(mockUseCollapsible).toHaveBeenCalledWith(
expect.objectContaining({
heightAnimation: true,
})
)
})
test("passes heightAnimation to useCollapsible when false", () => {
render(
<Details heightAnimation={false}>
<div>Content</div>
</Details>
)
expect(mockUseCollapsible).toHaveBeenCalledWith(
expect.objectContaining({
heightAnimation: false,
})
)
})
})
describe("interactions", () => {
test("toggles open state when summary is clicked", () => {
const { container } = render(
<Details summaryContent="Summary">
<div>Content</div>
</Details>
)
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(
<Details summaryContent="Summary" openInitial={true}>
<div>Content</div>
</Details>
)
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(
<Details summaryContent="Summary">
<div>Content</div>
</Details>
)
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(
<Details summaryContent="Summary">
<div>Content</div>
</Details>
)
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(
<Details summaryContent={<a href="/test">Link</a>}>
<div>Content</div>
</Details>
)
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(
<Details summaryContent={<code>Code</code>}>
<div>Content</div>
</Details>
)
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 = <DetailsSummary title="Custom Summary" />
const { container } = render(
<Details summaryElm={summaryElm} openInitial={true}>
<div>Content</div>
</Details>
)
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 = <DetailsSummary title="Custom Summary" />
const { container } = render(
<Details summaryElm={summaryElm}>
<div>Content</div>
</Details>
)
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(
<Details summaryContent="Summary">
<div>Content</div>
</Details>
)
expect(mockUseCollapsible).toHaveBeenCalledWith(
expect.objectContaining({
onClose: expect.any(Function),
})
)
})
})

View File

@@ -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

View File

@@ -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

View File

@@ -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 (
<div className={clsx("px-docs_0.75 my-docs_0.75", wrapperClassName)}>
<div
className={clsx("px-docs_0.75 my-docs_0.75", wrapperClassName)}
data-testid={testId}
>
<span
className={clsx(
"block w-full h-px relative bg-border-dotted",

View File

@@ -0,0 +1,36 @@
import React from "react"
import { describe, expect, test, vi } from "vitest"
import { render } from "@testing-library/react"
// mock components
vi.mock("@/components/EditDate", () => ({
EditDate: ({ date }: { date: string }) => (
<div data-testid="edit-date">{date}</div>
),
}))
import { EditButton } from "../../EditButton"
describe("render", () => {
test("renders edit button", () => {
const { container } = render(<EditButton filePath="/test.md" />)
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(
<EditButton filePath="/test.md" editDate="2021-01-01" />
)
expect(container).toBeInTheDocument()
const editDate = container.querySelector("[data-testid='edit-date']")
expect(editDate).toBeInTheDocument()
expect(editDate).toHaveTextContent("2021-01-01")
})
})

View File

@@ -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"
>
<span>Edit this page</span>
<ArrowUpRightOnBox />

View File

@@ -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(
<EditDate date={`${today.getFullYear()}-01-01`} />
)
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(<EditDate date={`${lastYear}-01-01`} />)
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(<EditDate date="invalid-date" />)
expect(container).toBeInTheDocument()
const editDate = container.querySelector("[data-testid='edit-date']")
expect(editDate).not.toBeInTheDocument()
})
})

View File

@@ -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 (
<>
<span className="text-compact-small-plus">
<span className="text-compact-small-plus" data-testid="edit-date">
Edited {dateMatch.groups.month} {dateObj.getDate()}
{dateObj.getFullYear() !== today.getFullYear()
? `, ${dateObj.getFullYear()}`

View File

@@ -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 }) => (
<a href={href}>{children}</a>
),
}))
vi.mock("@/components/MDXComponents", () => ({
MDXComponents: {
ul: (props: React.HTMLAttributes<HTMLUListElement>) => <ul {...props} />,
li: (props: React.HTMLAttributes<HTMLLIElement>) => <li {...props} />,
},
}))
// 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(<Solutions feedback={true} />)
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(<Solutions feedback={false} />)
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(
<Solutions feedback={false} message="Test Message" />
)
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(
<Solutions feedback={false} message={shortMessage} />
)
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(
<Solutions feedback={false} message="Test Message" />
)
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",
},
}
)
})
})

View File

@@ -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) => {
</span>
<Ul>
{possibleSolutions.map((solution) => (
<Li key={solution.url}>
<Li key={solution.url} data-testid="solution-item">
<Link
href={solution.html_url}
target="_blank"

View File

@@ -0,0 +1,596 @@
import React from "react"
import { beforeEach, describe, expect, test, vi } from "vitest"
import { fireEvent, render, waitFor } from "@testing-library/react"
import { GITHUB_ISSUES_LINK } from "../../../constants"
import { ButtonProps } from "../../Button"
import { LabelProps } from "../../Label"
import { RadioItemProps } from "../../RadioItem"
// mock data
const defaultUseSiteConfigReturn = {
config: {
reportIssueLink: GITHUB_ISSUES_LINK as string | undefined,
},
}
// mock functions
const mockTrack = vi.fn(({ event }: { event: { callback?: () => 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) => <button {...props} />,
}))
vi.mock("@/components/TextArea", () => ({
TextArea: ({
value,
onChange,
placeholder,
rows,
}: {
value: string
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
placeholder?: string
rows?: number
}) => (
<textarea
data-testid="textarea"
value={value}
onChange={onChange}
placeholder={placeholder}
rows={rows}
/>
),
}))
vi.mock("@/components/Label", () => ({
Label: (props: LabelProps) => <label {...props} />,
}))
vi.mock("@/components/DottedSeparator", () => ({
DottedSeparator: ({ wrapperClassName }: { wrapperClassName?: string }) => (
<div data-testid="dotted-separator" className={wrapperClassName} />
),
}))
vi.mock("@/components/RadioItem", () => ({
RadioItem: (props: RadioItemProps) => <input {...props} />,
}))
vi.mock("@/components/Feedback/Solutions", () => ({
Solutions: ({
message,
feedback,
}: {
message: string
feedback: boolean
}) => (
<div
data-testid="solutions"
data-message={message}
data-feedback={feedback}
>
Solutions
</div>
),
}))
vi.mock("react-transition-group", () => ({
CSSTransition: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
SwitchTransition: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
}))
import { Feedback } from "../../Feedback"
beforeEach(() => {
mockTrack.mockClear()
mockUseSiteConfig.mockImplementation(() => defaultUseSiteConfigReturn)
})
describe("rendering", () => {
test("default render", () => {
const { container } = render(<Feedback event="test-event" />)
const positiveButton = container.querySelector(
"button[data-testid='positive-button']"
)
expect(positiveButton).toBeInTheDocument()
expect(positiveButton).toHaveTextContent("It was helpful")
const negativeButton = container.querySelector(
"button[data-testid='negative-button']"
)
expect(negativeButton).toBeInTheDocument()
expect(negativeButton).toHaveTextContent("It wasn't helpful")
const label = container.querySelector("label[data-testid='question-label']")
expect(label).toBeInTheDocument()
expect(label).toHaveTextContent("Was this page helpful?")
const reportIssueButton = container.querySelector(
"button[data-testid='report-issue-button']"
)
expect(reportIssueButton).toBeInTheDocument()
expect(reportIssueButton).toHaveTextContent("Report Issue")
const link = reportIssueButton?.querySelector("a")
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute("href", GITHUB_ISSUES_LINK)
const separators = container.querySelectorAll(
"[data-testid='dotted-separator']"
)
expect(separators).toHaveLength(2)
})
test("renders custom question", () => {
const { container } = render(
<Feedback event="test-event" question="Custom question?" />
)
const label = container.querySelector("label[data-testid='question-label']")
expect(label).toBeInTheDocument()
expect(label).toHaveTextContent("Custom question?")
})
test("renders custom positive button text", () => {
const { container } = render(
<Feedback event="test-event" positiveBtn="Yes!" />
)
const buttons = container.querySelector(
"button[data-testid='positive-button']"
)
expect(buttons).toBeInTheDocument()
expect(buttons).toHaveTextContent("Yes!")
})
test("renders custom negative button text", () => {
const { container } = render(
<Feedback event="test-event" negativeBtn="No!" />
)
const buttons = container.querySelector(
"button[data-testid='negative-button']"
)
expect(buttons).toBeInTheDocument()
expect(buttons).toHaveTextContent("No!")
})
test("renders report issue button when initReportLink is provided", () => {
const customReportLink = "https://custom-link.com"
const { container } = render(
<Feedback event="test-event" reportLink={customReportLink} />
)
const reportIssueButton = container.querySelector(
"button[data-testid='report-issue-button']"
)
expect(reportIssueButton).toBeInTheDocument()
expect(reportIssueButton).toHaveTextContent("Report Issue")
const link = reportIssueButton?.querySelector("a")
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute("href", customReportLink)
})
test("does not render report issue button when no reportLink is available", () => {
// Override the mock to return undefined for reportIssueLink
// Use mockImplementation (not Once) to ensure it applies to all calls during render
// const originalImplementation = mockUseSiteConfig.getMockImplementation()
mockUseSiteConfig.mockImplementation(() => ({
config: {
reportIssueLink: undefined,
},
}))
const { container } = render(<Feedback event="test-event" />)
const reportIssueButton = container.querySelector(
"button[data-testid='report-issue-button']"
)
expect(reportIssueButton).not.toBeInTheDocument()
})
test("does not render dotted separator when showDottedSeparator is false", () => {
const { container } = render(
<Feedback event="test-event" showDottedSeparator={false} />
)
const separators = container.querySelectorAll(
"[data-testid='dotted-separator']"
)
expect(separators).toHaveLength(0)
})
test("applies custom className", () => {
const { container } = render(
<Feedback event="test-event" className="custom-class" />
)
const wrapper = container.firstChild
expect(wrapper).toHaveClass("custom-class")
})
test("shows custom submit button text", () => {
const { container } = render(
<Feedback event="test-event" submitBtn="Send" />
)
const positiveButton = container.querySelector(
"button[data-testid='positive-button']"
)
fireEvent.click(positiveButton!)
const submitButton = container.querySelector(
"button[data-testid='submit-button']"
)
expect(submitButton).toBeInTheDocument()
expect(submitButton).toHaveTextContent("Send")
})
test("shows custom positive question", () => {
const { container } = render(
<Feedback event="test-event" positiveQuestion="What did you like most?" />
)
const positiveButton = container.querySelector(
"button[data-testid='positive-button']"
)
fireEvent.click(positiveButton!)
const label = container.querySelector(
"label[data-testid='submit-question-label']"
)
expect(label).toBeInTheDocument()
expect(label).toHaveTextContent("What did you like most?")
})
test("shows custom negative question", () => {
const { container } = render(
<Feedback event="test-event" negativeQuestion="What went wrong?" />
)
const negativeButton = container.querySelector(
"button[data-testid='negative-button']"
)
fireEvent.click(negativeButton!)
const label = container.querySelector(
"label[data-testid='submit-question-label']"
)
expect(label).toBeInTheDocument()
expect(label).toHaveTextContent("What went wrong?")
})
})
describe("positive feedback flow", () => {
test("shows form when positive button is clicked", () => {
const { container } = render(<Feedback event="test-event" />)
const positiveButton = container.querySelector(
"button[data-testid='positive-button']"
)
fireEvent.click(positiveButton!)
const label = container.querySelector(
"label[data-testid='submit-question-label']"
)
expect(label).toBeInTheDocument()
expect(label).toHaveTextContent("What did you like?")
})
test("shows positive feedback options", () => {
const { container } = render(<Feedback event="test-event" />)
const positiveButton = container.querySelector(
"button[data-testid='positive-button']"
)
fireEvent.click(positiveButton!)
const radioOptions = [
"Easy to understand",
"Accurate code and text",
"Exactly what I was looking for",
"Ease of use",
"Other",
]
const feedbackOptions = Array.from(
container.querySelectorAll<HTMLInputElement>(
"[data-testid='feedback-option']"
)
)
expect(feedbackOptions).toHaveLength(5)
const optionValues = feedbackOptions.map((radio) => radio.value)
radioOptions.forEach((option) => {
expect(optionValues).toContain(option)
})
})
test("tracks positive feedback when button is clicked", () => {
const { container } = render(<Feedback event="test-event" />)
const positiveButton = container.querySelector(
"button[data-testid='positive-button']"
)
expect(positiveButton).toBeInTheDocument()
fireEvent.click(positiveButton!)
expect(mockTrack).toHaveBeenCalledWith({
event: {
event: "test-event",
options: {
feedback: "yes",
message: null,
feedbackOption: "Other",
},
callback: expect.any(Function),
},
})
})
})
describe("negative feedback flow", () => {
test("shows form when negative button is clicked", () => {
const { container } = render(<Feedback event="test-event" />)
const negativeButton = container.querySelector(
"button[data-testid='negative-button']"
)
expect(negativeButton).toBeInTheDocument()
fireEvent.click(negativeButton!)
const label = container.querySelector(
"label[data-testid='submit-question-label']"
)
expect(label).toBeInTheDocument()
expect(label).toHaveTextContent("What was the problem?")
})
test("shows negative feedback options", () => {
const { container } = render(<Feedback event="test-event" />)
const negativeButton = container.querySelector(
"button[data-testid='negative-button']"
)
expect(negativeButton).toBeInTheDocument()
fireEvent.click(negativeButton!)
const radioOptions = [
"Difficult to understand",
"Inaccurate code or text",
"Didn't find what I was looking for",
"Trouble using the documentation",
"Other",
]
const feedbackOptions = Array.from(
container.querySelectorAll<HTMLInputElement>(
"[data-testid='feedback-option']"
)
)
expect(feedbackOptions).toHaveLength(5)
const optionValues = feedbackOptions.map((radio) => radio.value)
radioOptions.forEach((option) => {
expect(optionValues).toContain(option)
})
})
test("tracks negative feedback when button is clicked", () => {
const { container } = render(<Feedback event="test-event" />)
const negativeButton = container.querySelector(
"button[data-testid='negative-button']"
)
expect(negativeButton).toBeInTheDocument()
fireEvent.click(negativeButton!)
expect(mockTrack).toHaveBeenCalledWith({
event: {
event: "test-event",
options: {
feedback: "no",
message: null,
feedbackOption: "Other",
},
callback: expect.any(Function),
},
})
})
})
describe("form interaction", () => {
test("submits feedback with selected option and message", () => {
const { container } = render(<Feedback event="test-event" />)
const positiveButton = container.querySelector(
"button[data-testid='positive-button']"
)
fireEvent.click(positiveButton!)
// Select an option
const feedbackOption = container.querySelector(
"[data-testid='feedback-option']"
)
fireEvent.click(feedbackOption!)
// Enter message
const textarea = container.querySelector("textarea") as HTMLTextAreaElement
fireEvent.change(textarea, { target: { value: "Great documentation!" } })
// Clear previous track calls
mockTrack.mockClear()
// Submit
const submitButton = container.querySelector(
"button[data-testid='submit-button']"
)
expect(submitButton).toBeInTheDocument()
fireEvent.click(submitButton!)
expect(mockTrack).toHaveBeenCalledWith({
event: expect.objectContaining({
options: expect.objectContaining({
feedback: "yes",
message: "Great documentation!",
feedbackOption: (feedbackOption as HTMLInputElement)?.value,
}),
}),
})
})
})
describe("submission success", () => {
test("shows thank you message after submission", async () => {
const { container } = render(<Feedback event="test-event" />)
const positiveButton = container.querySelector(
"button[data-testid='positive-button']"
)
fireEvent.click(positiveButton!)
const submitButton = container.querySelector(
"button[data-testid='submit-button']"
)
expect(submitButton).toBeInTheDocument()
fireEvent.click(submitButton!)
await waitFor(() => {
const submittedMessage = container.querySelector(
"[data-testid='submitted-message']"
)
expect(submittedMessage).toBeInTheDocument()
expect(submittedMessage).toHaveTextContent(
"Thank you for helping improve our documentation!"
)
})
})
test("shows custom submit message", async () => {
const { container } = render(
<Feedback event="test-event" submitMessage="Custom thank you message!" />
)
const positiveButton = container.querySelector(
"button[data-testid='positive-button']"
)
fireEvent.click(positiveButton!)
const submitButton = container.querySelector(
"button[data-testid='submit-button']"
)
expect(submitButton).toBeInTheDocument()
fireEvent.click(submitButton!)
await waitFor(() => {
const submittedMessage = container.querySelector(
"[data-testid='submitted-message']"
)
expect(submittedMessage).toBeInTheDocument()
expect(submittedMessage).toHaveTextContent("Custom thank you message!")
})
})
test("shows solutions after submission when showPossibleSolutions is true", async () => {
const { container } = render(<Feedback event="test-event" />)
const positiveButton = container.querySelector(
"button[data-testid='positive-button']"
)
fireEvent.click(positiveButton!)
const textarea = container.querySelector("textarea") as HTMLTextAreaElement
fireEvent.change(textarea, { target: { value: "Test message" } })
const submitButton = container.querySelector(
"button[data-testid='submit-button']"
)
expect(submitButton).toBeInTheDocument()
fireEvent.click(submitButton!)
await waitFor(() => {
const solutions = container.querySelector("[data-testid='solutions']")
expect(solutions).toBeInTheDocument()
expect(solutions).toHaveAttribute("data-message", "Test message")
expect(solutions).toHaveAttribute("data-feedback", "true")
})
})
test("does not show solutions when showPossibleSolutions is false", async () => {
const { container } = render(
<Feedback event="test-event" showPossibleSolutions={false} />
)
const positiveButton = container.querySelector(
"button[data-testid='positive-button']"
)
fireEvent.click(positiveButton!)
const submitButton = container.querySelector(
"button[data-testid='submit-button']"
)
expect(submitButton).toBeInTheDocument()
fireEvent.click(submitButton!)
await waitFor(() => {
const solutions = container.querySelector("[data-testid='solutions']")
expect(solutions).not.toBeInTheDocument()
})
})
test("passes negative feedback to solutions", async () => {
const { container } = render(<Feedback event="test-event" />)
const negativeButton = container.querySelector(
"button[data-testid='negative-button']"
)
fireEvent.click(negativeButton!)
const textarea = container.querySelector("textarea") as HTMLTextAreaElement
fireEvent.change(textarea, { target: { value: "Issue message" } })
const submitButton = container.querySelector(
"button[data-testid='submit-button']"
)
expect(submitButton).toBeInTheDocument()
fireEvent.click(submitButton!)
await waitFor(() => {
const solutions = container.querySelector("[data-testid='solutions']")
expect(solutions).toHaveAttribute("data-feedback", "false")
})
})
})
describe("extra data tracking", () => {
test("includes extraData in tracking", () => {
const { container } = render(
<Feedback event="test-event" extraData={{ customField: "customValue" }} />
)
const positiveButton = container.querySelector(
"button[data-testid='positive-button']"
)
fireEvent.click(positiveButton!)
expect(mockTrack).toHaveBeenCalledWith({
event: expect.objectContaining({
options: expect.objectContaining({
customField: "customValue",
}),
}),
})
})
})
describe("layout", () => {
test("applies vertical layout classes when vertical is true", () => {
const { container } = render(<Feedback event="test-event" vertical />)
const feedbackForm = container.querySelector(
"[data-testid='feedback-form']"
)
expect(feedbackForm).toBeInTheDocument()
expect(feedbackForm).toHaveClass("flex-col justify-center")
})
test("applies horizontal layout classes when vertical is false", () => {
const { container } = render(
<Feedback event="test-event" vertical={false} />
)
const feedbackForm = container.querySelector(
"[data-testid='feedback-form']"
)
expect(feedbackForm).toBeInTheDocument()
expect(feedbackForm).toHaveClass("flex-col md:flex-row md:items-center")
})
})

View File

@@ -3,19 +3,17 @@
import React, { useEffect, useMemo, useRef, useState } from "react"
// @ts-expect-error can't install the types package because it doesn't support React v19
import { CSSTransition, SwitchTransition } from "react-transition-group"
import { Solutions } from "./Solutions"
import { Solutions } from "@/components/Feedback/Solutions"
import { ExtraData, useAnalytics } from "@/providers/Analytics"
import clsx from "clsx"
import {
Button,
TextArea,
Label,
DottedSeparator,
RadioItem,
} from "@/components"
import { Button } from "@/components/Button"
import { TextArea } from "@/components/TextArea"
import { Label } from "@/components/Label"
import { DottedSeparator } from "@/components/DottedSeparator"
import { RadioItem } from "@/components/RadioItem"
import { ChatBubbleLeftRight, ThumbDown, ThumbUp } from "@medusajs/icons"
import Link from "next/link"
import { useSiteConfig } from "../../providers"
import { useSiteConfig } from "@/providers/SiteConfig"
import { RadioGroup } from "@medusajs/ui"
export type FeedbackProps = {
@@ -135,7 +133,10 @@ export const Feedback = ({
return (
<div className={clsx(className)}>
{showDottedSeparator && (
<DottedSeparator wrapperClassName="!px-0 !my-docs_2" />
<DottedSeparator
wrapperClassName="!px-0 !my-docs_2"
data-testid="dotted-separator"
/>
)}
<SwitchTransition mode="out-in">
<CSSTransition
@@ -165,8 +166,12 @@ export const Feedback = ({
vertical && "flex-col justify-center"
)}
ref={inlineFeedbackRef}
data-testid="feedback-form"
>
<Label className={"text-compact-small text-medusa-fg-base"}>
<Label
className={"text-compact-small text-medusa-fg-base"}
data-testid="question-label"
>
{question}
</Label>
<div
@@ -182,6 +187,7 @@ export const Feedback = ({
"!px-docs_0.5 !py-docs_0.25 text-left md:text-center"
)}
variant="transparent-clear"
data-testid="positive-button"
>
<ThumbUp className="text-medusa-fg-subtle" />
<span className="text-medusa-fg-base text-compact-small-plus flex-1">
@@ -195,6 +201,7 @@ export const Feedback = ({
"!px-docs_0.5 !py-docs_0.25 text-left md:text-center"
)}
variant="transparent-clear"
data-testid="negative-button"
>
<ThumbDown className="text-medusa-fg-subtle" />
<span className="text-medusa-fg-base text-compact-small-plus flex-1">
@@ -210,6 +217,7 @@ export const Feedback = ({
"!justify-start md:!justify-center",
"text-left md:text-center"
)}
data-testid="report-issue-button"
>
<ChatBubbleLeftRight className="text-medusa-fg-subtle" />
<span className="text-medusa-fg-base text-compact-small-plus flex-1">
@@ -226,7 +234,7 @@ export const Feedback = ({
)}
{showForm && !submittedFeedback && (
<div className="flex flex-col gap-docs_1" ref={inlineQuestionRef}>
<Label>
<Label data-testid="submit-question-label">
{positiveFeedback ? positiveQuestion : negativeQuestion}
</Label>
<RadioGroup className="gap-docs_0.5">
@@ -247,6 +255,7 @@ export const Feedback = ({
feedbackOption !== option &&
"group-hover:bg-medusa-bg-component-hover"
)}
data-testid="feedback-option"
/>
<Label className="text-medusa-fg-base text-compact-small-plus">
{option}
@@ -265,6 +274,7 @@ export const Feedback = ({
disabled={loading}
className="w-fit"
variant="secondary"
data-testid="submit-button"
>
{submitBtn}
</Button>
@@ -275,6 +285,7 @@ export const Feedback = ({
<div
className="text-compact-large-plus flex flex-col"
ref={inlineMessageRef}
data-testid="submitted-message"
>
<span>{submitMessage}</span>
{showPossibleSolutions && (

View File

@@ -0,0 +1,34 @@
import React from "react"
import { describe, expect, test, vi } from "vitest"
import { render } from "@testing-library/react"
// mock components
vi.mock("@/components/Pagination", () => ({
Pagination: () => <div>Pagination</div>,
}))
import { Footer } from "../../Footer"
describe("rendering", () => {
test("doesn't render anything by default", () => {
const { container } = render(<Footer />)
expect(container).toBeEmptyDOMElement()
})
test("renders feedback component when provided", () => {
const { container } = render(
<Footer feedbackComponent={<div>Feedback</div>} />
)
expect(container).toContainHTML("<div>Feedback</div>")
})
test("renders pagination component when showPagination is true", () => {
const { container } = render(<Footer showPagination={true} />)
expect(container).toContainHTML("<div>Pagination</div>")
})
test("renders edit component when provided", () => {
const { container } = render(<Footer editComponent={<div>Edit</div>} />)
expect(container).toContainHTML("<div>Edit</div>")
})
})

View File

@@ -0,0 +1,69 @@
import React from "react"
import { beforeEach, describe, expect, test, vi } from "vitest"
import { render } from "@testing-library/react"
import { H2 } from "../../H2"
import { LinkProps } from "../../../Link"
// mock functions
const mockUseHeadingUrl = vi.fn(() => "https://example.com")
const mockUseLayout = vi.fn(() => ({ showCollapsedNavbar: false }))
// mock components
vi.mock("@/components/CopyButton", () => ({
CopyButton: ({ children }: { children: React.ReactNode }) => (
<div data-testid="copy-button">{children}</div>
),
}))
vi.mock("@/components/Link", () => ({
Link: (props: LinkProps) => <a {...props} />,
}))
vi.mock("@/providers/Layout", () => ({
useLayout: () => mockUseLayout(),
}))
vi.mock("@/hooks/use-heading-url", () => ({
useHeadingUrl: () => mockUseHeadingUrl(),
}))
beforeEach(() => {
vi.clearAllMocks()
})
describe("rendering", () => {
test("renders correctly", () => {
const { container } = render(<H2>Test</H2>)
const h2 = container.querySelector("h2")
expect(h2).toBeInTheDocument()
expect(h2).toHaveTextContent("Test")
})
test("renders with id", () => {
const { container } = render(<H2 id="test-id">Test</H2>)
const h2 = container.querySelector("h2")
expect(h2).toBeInTheDocument()
const copyButton = container.querySelector("[data-testid='copy-button']")
expect(copyButton).toBeInTheDocument()
const link = copyButton?.querySelector("a")
expect(link).toBeInTheDocument()
expect(link).toHaveTextContent("#")
expect(link).toHaveAttribute("href", "#test-id")
})
test("renders with collapsed navbar and id", () => {
mockUseLayout.mockReturnValue({ showCollapsedNavbar: true })
const { container } = render(<H2 id="test-id">Test</H2>)
const h2 = container.querySelector("h2")
expect(h2).toBeInTheDocument()
expect(h2).toHaveClass("scroll-m-docs_7")
})
test("renders with non-collapsed navbar and id", () => {
mockUseLayout.mockReturnValue({ showCollapsedNavbar: false })
const { container } = render(<H2 id="test-id">Test</H2>)
const h2 = container.querySelector("h2")
expect(h2).toBeInTheDocument()
expect(h2).toHaveClass("scroll-m-56")
})
})

Some files were not shown because too many files have changed in this diff Show More