fix: move to vitest and fix schema behaviour

This commit is contained in:
Alex Varchuk 2025-12-15 21:00:05 +02:00
parent 10400113fb
commit e156e9d15a
No known key found for this signature in database
GPG Key ID: 8A9260AE529FF454
179 changed files with 6674 additions and 6632 deletions

View File

@ -0,0 +1,5 @@
---
"redoc": patch
---
Release 3.0.0-rc.1.

View File

@ -11,7 +11,7 @@ runs:
- name: 📦 Install Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22.17.1
node-version: 24.11.1
cache: npm
- name: ✅ Finished Node setup
shell: bash

View File

@ -13,7 +13,7 @@ runs:
id: playwright-version
shell: bash
run: |
echo "PLAYWRIGHT_VERSION=$(jq -r '.devDependencies["@playwright/test"]' package.json)" >> $GITHUB_OUTPUT
echo "PLAYWRIGHT_VERSION=$(jq -r '.devDependencies[\"@playwright/test\"]' package.json)" >> $GITHUB_OUTPUT
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
name: Check if Playwright browser is cached
id: playwright-cache
@ -25,8 +25,7 @@ runs:
shell: bash
run: |
echo "Installing Playwright in root"
echo "Installing @playwright/test@${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }} (no-save)"
npm install -D @playwright/test@${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }} --no-save
npm install @playwright/test
npx playwright install --with-deps
- name: ✅ Finished Playwright setup
shell: bash

View File

@ -1,8 +1,5 @@
name: Tests e2e
permissions:
contents: read
on:
pull_request:
branches:
@ -17,7 +14,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22.17
node-version: 24.11.1
cache: 'npm'
- name: Install Dependencies
run: npm ci --ignore-scripts --no-fund

View File

@ -1,14 +1,9 @@
name: Release
# permissions:
# id-token: write
# contents: read
on:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
@ -17,6 +12,10 @@ jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC
contents: write
pull-requests: write
outputs:
published: ${{ steps.changesets.outputs.published }}
publishedPackages: ${{ steps.changesets.outputs.publishedPackages }}
@ -26,7 +25,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22.17
node-version: 24.11.1
cache: 'npm'
- name: Install Dependencies
run: npm ci
@ -40,11 +39,13 @@ jobs:
title: 'chore: 🔖 release new versions'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
dockerhub:
needs: [release]
if: needs.release.outputs.published == 'true'
permissions:
contents: read
packages: write
uses: ./.github/workflows/docker.yml
secrets:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
@ -55,6 +56,8 @@ jobs:
needs: [release]
if: needs.release.outputs.published == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v3
@ -74,6 +77,8 @@ jobs:
runs-on: ubuntu-latest
needs: [release, publish-cdn]
if: needs.release.outputs.published == 'true'
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v3

View File

@ -4,8 +4,6 @@ on:
pull_request:
branches:
- main
permissions:
contents: read
jobs:
build-and-unit:
@ -16,7 +14,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22.17
node-version: 24.11.1
cache: 'npm'
- name: Install Dependencies
run: npm ci --ignore-scripts --no-fund

3
.npmrc Normal file
View File

@ -0,0 +1,3 @@
# When this package is built standalone (e.g., in CI),
# ensure dependencies are installed locally, not hoisted to parent workspace
shamefully-hoist=true

View File

@ -5,7 +5,7 @@
# npm i -g http-server
# http-server -p 8000 --cors
FROM node:22.20.0-alpine@sha256:dbcedd8aeab47fbc0f4dd4bffa55b7c3c729a707875968d467aaaea42d6225af AS build
FROM node:24.11.1-alpine3.22@sha256:2867d550cf9d8bb50059a0fff528741f11a84d985c732e60e19e8e75c7239c43 AS build
WORKDIR /build

View File

@ -85,7 +85,8 @@ export default tseslint.config(
devDependencies: [
'**/playwright/**',
'**/vite.config.ts',
'**/setupTests.ts',
'**/vitest.config.ts',
'**/vitest.setup.ts',
'**/__tests__/**/*.{ts,tsx}',
'**/benchmarks/*.ts',
'**/__mocks__/**/*.{ts,tsx}',

View File

@ -9,7 +9,7 @@
href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,600|Source+Code+Pro:400:700"
rel="stylesheet"
/>
<script type="module" src="../bundle/redoc.standalone.js"></script>
<script type="module" src="https://cdn.redocly.com/redoc/v3.0.0-rc.1/redoc.standalone.js"></script>
<!--
ReDoc doesn't change outer page styles

View File

@ -1,89 +0,0 @@
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
/** @type {import('jest').Config} */
export default {
verbose: true,
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
preset: 'ts-jest',
collectCoverage: true,
coverageProvider: 'v8',
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/{index,types}.ts', '!src/events/*'],
testPathIgnorePatterns: ['/node_modules/', '/bundle/', '/__mocks__/', '/src/icons/'],
coverageThreshold: {
global: {
statements: 78,
branches: 68,
functions: 76,
lines: 78,
},
},
coverageReporters: ['json', 'lcov', 'text', 'text-summary'],
coveragePathIgnorePatterns: [
'\\.d\\.ts$',
'/node_modules/',
'__mock__',
'/src/types/',
'/src/icons/',
],
modulePathIgnorePatterns: [
'/__mocks__/',
'/benchmark/',
'/coverage/',
'src/components/__tests__/mocks/*',
'src/models/__tests__/helpers.ts',
'src/icons/*',
'/bundle/',
'lib/',
'/playground/',
'/playwright/',
'/scripts/',
],
transform: {
'^.+\\.[tj]sx?$': [
'ts-jest',
{
isolatedModules: true,
useESM: true,
astTransformers: {
before: [
{
path: 'ts-jest-mock-import-meta',
options: { metaObjectReplacement: { url: '' } },
},
],
},
tsconfig: {
moduleResolution: 'node',
module: 'ESNext',
jsx: 'react-jsx',
},
},
],
'node_modules/.pnpm/@redocly.+\\.js$': [
'ts-jest',
{
isolatedModules: true,
useESM: true,
tsconfig: {
moduleResolution: 'node',
module: 'ESNext',
allowJs: true,
},
},
],
},
extensionsToTreatAsEsm: ['.ts', '.tsx'],
// Custom module mapper to handle @redocly packages
moduleNameMapper: {
'\\.(css|less)$': '<rootDir>/src/empty.js',
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transformIgnorePatterns: [
`/node_modules/.pnpm/(?!vscode-languageserver-types|vscode-languageserver-textdocument|@redocly)`,
],
prettierPath: require.resolve('prettier'),
};

6562
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "redoc",
"version": "3.0.0-rc.0",
"version": "3.0.0-rc.1",
"description": "ReDoc",
"repository": {
"type": "git",
@ -39,19 +39,19 @@
"build:standalone": "vite build --mode standalone",
"build:e2e": "vite build --mode standalone-e2e",
"minify": "node scripts/minify.js bundle",
"unit": "jest -w 2",
"unit:update": "jest -u",
"unit:watch": "jest --watch",
"unit:coverage": "jest --coverage",
"unit:coverage:html": "jest --coverage --coverageReporters html",
"unit": "vitest run",
"unit:update": "vitest run -u",
"unit:watch": "vitest",
"unit:coverage": "vitest run --coverage",
"unit:coverage:html": "vitest run --coverage --coverage.reporter=html",
"e2e:prepare": "npm run build:e2e && cp -r ./bundle ./playwright/",
"e2e:ui": "playwright test --ui",
"e2e": "npm run e2e:prepare && npx playwright test",
"test": "npm run lint && npm run unit && npm run e2e",
"test": "npm run lint && npm run ts:check && npm run unit && npm run e2e",
"lint": "NODE_OPTIONS=\"--max-old-space-size=5120\" eslint . --max-warnings=0",
"lint:fix": "NODE_OPTIONS=\"--max-old-space-size=5120\" eslint . --fix",
"ts:check": "tsc --noEmit --skipLibCheck",
"ts:dts": "tsc -p tsconfig.json --emitDeclarationOnly && api-extractor run --local --verbose",
"ts:dts": "tsc -p tsconfig.build.json --emitDeclarationOnly && api-extractor run --local --verbose",
"clean": "rimraf bin bundle lib",
"clear:cache": "scripts/clear-cache.sh",
"pack:sourcemaps": "rimraf redoc.sourcemaps.tag.gz && find lib -name \"*.js.map\" | xargs tar -czvf redoc.sourcemaps.tar.gz",
@ -62,22 +62,22 @@
"publish-cdn": "scripts/publish-cdn.sh"
},
"peerDependencies": {
"@redocly/config": "^0.36.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"@redocly/config": "^0.41.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"styled-components": "^4.1.1 || ^5.3.11 || ^6.0.0"
},
"dependencies": {
"@markdoc/markdoc": "0.5.2",
"@redocly/config": "^0.36.2",
"@redocly/openapi-core": "2.7.0",
"@redocly/redoc-opentelemetry": "0.0.4",
"@redocly/theme": "^0.59.0-next.8",
"@redocly/config": "^0.41.0",
"@redocly/openapi-core": "2.12.3",
"@redocly/redoc-opentelemetry": "0.0.5",
"@redocly/theme": "^0.61.0-next.0",
"deepmerge": "^4.2.2",
"dompurify": "3.2.7",
"fast-deep-equal": "^3.1.3",
"flexsearch": "^0.8.2",
"htmlparser2": "^10.0.0",
"htmlparser2": "8.0.2",
"jotai": "^2.12.5",
"json-pointer": "^0.6.2",
"openapi-sampler": "^1.6.2",
@ -92,58 +92,52 @@
"devDependencies": {
"@changesets/cli": "2.29.7",
"@eslint/compat": "1.4.0",
"@eslint/js": "9.36.0",
"@jest/globals": "29.5.0",
"@eslint/js": "9.38.0",
"@microsoft/api-extractor": "7.52.10",
"@playwright/test": "1.53.2",
"@swc/plugin-styled-components": "^9.1.0",
"@playwright/test": "1.56.1",
"@swc/plugin-styled-components": "^12.0.1",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/jest": "29.5.11",
"@types/jest-when": "3.5.5",
"@types/json-pointer": "1.0.34",
"@types/json-schema": "^7.0.15",
"@types/node": "22.15.3",
"@types/node": "22.18.13",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.4",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/styled-components": "5.1.34",
"@vitejs/plugin-react": "^5.0.0",
"@vitejs/plugin-react-swc": "^4.1.0",
"chromium": "3.0.3",
"@vitejs/plugin-react": "5.1.1",
"@vitejs/plugin-react-swc": "4.2.2",
"@vitest/coverage-v8": "^4.0.15",
"concurrently": "9.2.0",
"esbuild": "^0.25.5",
"esbuild": "^0.27.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-check-file": "3.3.0",
"eslint-plugin-import-x": "4.16.1",
"eslint-plugin-jest": "29.0.1",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "5.2.0",
"globals": "16.3.0",
"globals": "^16.5.0",
"http-server": "14.1.1",
"jest": "29.5.0",
"jest-environment-jsdom": "29.5.0",
"jest-styled-components": "7.2.0",
"jest-when": "3.6.0",
"js-yaml": "4.1.0",
"js-yaml": "4.1.1",
"jsdom": "^25.0.1",
"json-schema": "0.4.0",
"license-checker": "25.0.1",
"outdent": "0.8.0",
"path-browserify": "1.0.1",
"prettier": "2.8.8",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.2.1",
"react-dom": "19.2.1",
"rimraf": "5.0.7",
"rollup-plugin-bundle-stats": "^4.20.2",
"styled-components": "5.3.11",
"ts-jest": "29.1.2",
"ts-jest-mock-import-meta": "^1.3.0",
"typescript": "5.6.2",
"typescript-eslint": "8.45.0",
"typescript": "5.9.3",
"typescript-eslint": "8.46.2",
"url": "0.11.4",
"url-polyfill": "1.1.13",
"util": "0.12.5",
"vite": "^7.0.0"
"vite": "7.2.7",
"vitest": "^4.0.15"
}
}

View File

@ -1,4 +1,3 @@
import { readFileSync } from 'fs';
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
console.log(pkg.version);
console.log(JSON.parse(readFileSync('../package.json', 'utf8')).version);

View File

@ -1,25 +1,26 @@
import util from 'util';
import { render } from '@testing-library/react';
import * as Jotai from 'jotai';
import { BrowserRouter } from 'react-router-dom';
import { LayoutVariant } from '@redocly/config';
import type { OpenAPIInfo } from '../../../types';
import type { GroupModel } from '../../../models';
import type { OpenAPIInfo } from '../../../types/index.js';
import type { GroupModel } from '../../../models/index.js';
import { ApiInfo } from '../ApiInfo';
import { ApiInfo } from '../ApiInfo.js';
import { TestBrowserRouter } from '../../../testProviders.js';
Object.defineProperty(global, 'TextEncoder', {
value: util.TextEncoder,
});
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: jest.fn(),
vi.mock('jotai', async () => ({
...(await vi.importActual('jotai')),
useAtomValue: vi.fn(),
}));
describe('ApiInfo', () => {
it('ApiInfo renders correctly', () => {
jest.spyOn(Jotai, 'useAtomValue').mockReturnValue({
vi.spyOn(Jotai, 'useAtomValue').mockReturnValue({
parser: {
definition: {
info: {
@ -36,6 +37,7 @@ describe('ApiInfo', () => {
});
const result = render(
<ApiInfo
layout={LayoutVariant.THREE_PANEL}
item={
{
infoDefinition: {
@ -49,7 +51,7 @@ describe('ApiInfo', () => {
}
/>,
{
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
},
);

View File

@ -1,25 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ApiInfo ApiInfo renders correctly 1`] = `
exports[`ApiInfo > ApiInfo renders correctly 1`] = `
{
"asFragment": [Function],
"baseElement": .c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
width: 100%;
padding: var(--spacing-vertical) var(--panel-gap-horizontal);
}
.c1:empty {
display: none;
}
.c0 {
"baseElement": .c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
@ -35,7 +19,54 @@ exports[`ApiInfo ApiInfo renders correctly 1`] = `
padding: 0;
}
.c2 {
.c0 {
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
}
}
@media print {
.c0 {
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
}
}.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
width: 100%;
padding: var(--spacing-vertical) var(--panel-gap-horizontal);
}
.c1:empty {
display: none;
}
.c1 {
width: 100%;
padding-left: calc(var(--panel-gap-horizontal) * 2);
padding-right: var(--panel-gap-horizontal);
padding-top: 0;
padding-bottom: 0;
}
}
@media print {
.c1 {
width: 100%;
padding-top: var(--spacing-vertical);
padding-bottom: var(--spacing-vertical);
}
}
@media screen and (min-width:1280px) {
.c2 {
color: var(--h1-text-color);
margin: var(--h1-margin-top) 0 var(--h1-margin-bottom);
font-size: var(--h1-font-size);
@ -56,38 +87,7 @@ exports[`ApiInfo ApiInfo renders correctly 1`] = `
}
@media screen and (min-width:1280px) {
.c1 {
width: 100%;
padding-left: calc(var(--panel-gap-horizontal) * 2);
padding-right: var(--panel-gap-horizontal);
padding-top: 0;
padding-bottom: 0;
}
}
@media print {
.c1 {
width: 100%;
padding-top: var(--spacing-vertical);
padding-bottom: var(--spacing-vertical);
}
}
@media screen and (min-width:1280px) {
.c0 {
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
}
}
@media print {
.c0 {
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
}
}
<body>
<div>
@ -105,23 +105,7 @@ exports[`ApiInfo ApiInfo renders correctly 1`] = `
</div>
</div>
</body>,
"container": .c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
width: 100%;
padding: var(--spacing-vertical) var(--panel-gap-horizontal);
}
.c1:empty {
display: none;
}
.c0 {
"container": .c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
@ -137,7 +121,54 @@ exports[`ApiInfo ApiInfo renders correctly 1`] = `
padding: 0;
}
.c2 {
.c0 {
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
}
}
@media print {
.c0 {
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
}
}.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
width: 100%;
padding: var(--spacing-vertical) var(--panel-gap-horizontal);
}
.c1:empty {
display: none;
}
.c1 {
width: 100%;
padding-left: calc(var(--panel-gap-horizontal) * 2);
padding-right: var(--panel-gap-horizontal);
padding-top: 0;
padding-bottom: 0;
}
}
@media print {
.c1 {
width: 100%;
padding-top: var(--spacing-vertical);
padding-bottom: var(--spacing-vertical);
}
}
@media screen and (min-width:1280px) {
.c2 {
color: var(--h1-text-color);
margin: var(--h1-margin-top) 0 var(--h1-margin-bottom);
font-size: var(--h1-font-size);
@ -158,38 +189,7 @@ exports[`ApiInfo ApiInfo renders correctly 1`] = `
}
@media screen and (min-width:1280px) {
.c1 {
width: 100%;
padding-left: calc(var(--panel-gap-horizontal) * 2);
padding-right: var(--panel-gap-horizontal);
padding-top: 0;
padding-bottom: 0;
}
}
@media print {
.c1 {
width: 100%;
padding-top: var(--spacing-vertical);
padding-bottom: var(--spacing-vertical);
}
}
@media screen and (min-width:1280px) {
.c0 {
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
}
}
@media print {
.c0 {
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
}
}
<div>
<div

View File

@ -1,18 +1,23 @@
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { normalizeOptions, OpenAPIParser } from '../../../services';
import { getCallback } from '../../../models';
import { CallbackOperation } from '../CallbackOperation';
import { CallbacksList } from '../CallbacksList';
import { CallbackSummary } from '../CallbackSummary';
import { normalizeOptions, OpenAPIParser } from '../../../services/index.js';
import { getCallback } from '../../../models/index.js';
import { CallbackOperation } from '../CallbackOperation.js';
import { CallbacksList } from '../CallbacksList.js';
import { CallbackSummary } from '../CallbackSummary.js';
import * as simpleCallbackFixture from './fixtures/simple-callback.json';
import type { OpenAPIDefinition } from '../../../types/index.js';
import { TestBrowserRouter } from '../../../testProviders.js';
const options = normalizeOptions({});
describe('Components', () => {
describe('Callbacks', () => {
const parser = new OpenAPIParser(simpleCallbackFixture, undefined, options);
const parser = new OpenAPIParser(
simpleCallbackFixture as unknown as OpenAPIDefinition,
undefined,
options,
);
const callback = getCallback(
parser,
'Test.Callback',
@ -23,18 +28,29 @@ describe('Components', () => {
);
it('should correctly render CallbackView', () => {
const { getByText } = render(<CallbackOperation operation={callback.operations[0]} />, {
wrapper: BrowserRouter,
});
const { getByText } = render(
<CallbackOperation
operation={callback.operations[0]}
onExpand={() => {}}
selectedCallback={null}
/>,
{
wrapper: TestBrowserRouter,
},
);
expect(getByText('testCallback')).toBeInTheDocument();
});
it('should correctly render CallbackTitle', () => {
const { getByText } = render(
<CallbackSummary toggle={() => {}} callback={callback.operations[0]} />,
<CallbackSummary
toggle={() => {}}
callback={callback.operations[0]}
translate={() => ''}
/>,
{
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
},
);
@ -42,9 +58,12 @@ describe('Components', () => {
});
it('should correctly render CallbacksList', () => {
const { getByText } = render(<CallbacksList callbacks={[callback]} />, {
wrapper: BrowserRouter,
});
const { getByText } = render(
<CallbacksList callbacks={[callback]} onExpand={() => {}} selectedCallback={null} />,
{
wrapper: TestBrowserRouter,
},
);
expect(getByText('testCallback')).toBeInTheDocument();
});

View File

@ -1,30 +1,39 @@
import { renderHook } from '@testing-library/react';
import { useLocation } from 'react-router-dom';
import { jest, describe, beforeEach, test, expect, afterEach } from '@jest/globals';
import { vi, type Mock, type MockedFunction } from 'vitest';
import { useAutoScroll } from '../useAutoScroll';
import { useAutoScroll } from '../useAutoScroll.js';
jest.mock('react-router-dom', () => ({
useLocation: jest.fn(),
vi.mock('react-router-dom', () => ({
useLocation: vi.fn(),
}));
jest.mock('@redocly/theme', () => ({
IS_BROWSER: true,
useActiveSectionId: jest.fn(() => '/test'),
vi.mock('lodash.throttle', () => ({
default: (fn) => fn,
}));
vi.mock('@redocly/theme', async () => {
const actual = await vi.importActual('@redocly/theme');
return {
...actual,
IS_BROWSER: true,
useActiveSectionId: vi.fn(() => '/test'),
};
});
describe('useAutoScroll', () => {
beforeEach(() => {
jest.clearAllMocks();
vi.useFakeTimers();
vi.clearAllMocks();
(useAutoScroll as any).initialPageLoad = true;
(useAutoScroll as unknown as { initialPageLoad: boolean }).initialPageLoad = true;
window.history.replaceState = jest.fn();
window.scrollTo = jest.fn();
window.history.replaceState = vi.fn();
window.scrollTo = vi.fn();
document.getElementById = jest.fn() as jest.MockedFunction<typeof document.getElementById>;
document.querySelector = jest.fn() as jest.MockedFunction<typeof document.querySelector>;
document.getElementById = vi.fn() as MockedFunction<typeof document.getElementById>;
document.querySelector = vi.fn() as MockedFunction<typeof document.querySelector>;
Object.defineProperty(window, 'location', {
value: { pathname: '', hash: '' },
@ -33,7 +42,8 @@ describe('useAutoScroll', () => {
});
afterEach(() => {
jest.restoreAllMocks();
vi.useRealTimers();
vi.restoreAllMocks();
});
test('should processing without toLowerCase', () => {
@ -44,7 +54,7 @@ describe('useAutoScroll', () => {
key: '',
search: '',
};
(useLocation as jest.Mock).mockReturnValue(mockLocation);
(useLocation as Mock).mockReturnValue(mockLocation);
renderHook(() => useAutoScroll('/'));
@ -59,7 +69,7 @@ describe('useAutoScroll', () => {
key: '',
search: '',
};
(useLocation as jest.Mock).mockReturnValue(mockLocation);
(useLocation as Mock).mockReturnValue(mockLocation);
renderHook(() => useAutoScroll('/'));
@ -74,7 +84,7 @@ describe('useAutoScroll', () => {
key: '',
search: '',
};
(useLocation as jest.Mock).mockReturnValue(mockLocation);
(useLocation as Mock).mockReturnValue(mockLocation);
renderHook(() => useAutoScroll('/'));
@ -82,7 +92,7 @@ describe('useAutoScroll', () => {
});
test('should scroll element into view when found', () => {
const mockElement = { scrollIntoView: jest.fn() };
const mockElement = { scrollIntoView: vi.fn() };
const mockLocation = {
pathname: '/test',
hash: '#TestHash',
@ -90,8 +100,8 @@ describe('useAutoScroll', () => {
key: '',
search: '',
};
(useLocation as jest.Mock).mockReturnValue(mockLocation);
(document.getElementById as jest.Mock).mockReturnValue(mockElement);
(useLocation as Mock).mockReturnValue(mockLocation);
(document.getElementById as Mock).mockReturnValue(mockElement);
renderHook(() => useAutoScroll('/'));

View File

@ -38,7 +38,9 @@ const ContentComponentMap: Record<
section: SectionItem,
schema: (props) => <TagItem {...props} item={props.item as GroupModel} isExpanded={true} />,
operation: OperationItem,
mcp: (props) => <TagItem {...props} item={props.item as GroupModel} isExpanded={true} />,
tool: (props) => <TagItem {...props} item={props.item as GroupModel} isExpanded={true} />,
rsrc: (props) => <TagItem {...props} item={props.item as GroupModel} isExpanded={true} />,
prompt: (props) => <TagItem {...props} item={props.item as GroupModel} isExpanded={true} />,
root: ApiInfo,
};

View File

@ -1,16 +1,17 @@
import { renderHook } from '@testing-library/react';
import { useLocation } from 'react-router-dom';
import type { ContentItemModel } from '../../../models';
import type { ContentItemModel } from '../../../models/index.js';
import type { MockedFunction } from 'vitest';
import { useIsExpanded } from '../useIsExpanded';
import { useIsExpanded } from '../useIsExpanded.js';
// Mock react-router-dom
jest.mock('react-router-dom', () => ({
useLocation: jest.fn(),
vi.mock('react-router-dom', () => ({
useLocation: vi.fn(),
}));
const mockUseLocation = useLocation as jest.Mock;
const mockUseLocation = useLocation as MockedFunction<typeof useLocation>;
describe('useIsExpanded', () => {
const mockRoutingBasePath = '/docs';
@ -20,13 +21,19 @@ describe('useIsExpanded', () => {
});
it('should return false for non-tag items', () => {
mockUseLocation.mockReturnValue({ pathname: '/docs/pets', hash: '' });
mockUseLocation.mockReturnValue({
pathname: '/docs/pets',
hash: '',
state: undefined,
key: '',
search: '',
});
const item: ContentItemModel = {
type: 'operation',
href: '/pets',
items: [],
};
} as unknown as ContentItemModel;
const { result } = renderHook(() =>
useIsExpanded({ item, routingBasePath: mockRoutingBasePath }),
@ -36,13 +43,19 @@ describe('useIsExpanded', () => {
});
it('should return true when current path matches item href', () => {
mockUseLocation.mockReturnValue({ pathname: '/docs/pets', hash: '' });
mockUseLocation.mockReturnValue({
pathname: '/docs/pets',
hash: '',
state: undefined,
key: '',
search: '',
});
const item: ContentItemModel = {
type: 'tag',
href: '/pets',
items: [{ type: 'operation', href: '/pets/get', items: [] } as unknown as ContentItemModel],
};
} as unknown as ContentItemModel;
const { result } = renderHook(() =>
useIsExpanded({ item, routingBasePath: mockRoutingBasePath }),
@ -55,6 +68,9 @@ describe('useIsExpanded', () => {
mockUseLocation.mockReturnValue({
pathname: '/docs',
hash: '#tag/pets',
state: undefined,
key: '',
search: '',
});
const item: ContentItemModel = {
@ -71,7 +87,13 @@ describe('useIsExpanded', () => {
});
it('should handle nested items', () => {
mockUseLocation.mockReturnValue({ pathname: '/docs/pets/create', hash: '' });
mockUseLocation.mockReturnValue({
pathname: '/docs/pets/create',
hash: '',
state: undefined,
key: '',
search: '',
});
const item: ContentItemModel = {
type: 'tag',
@ -89,7 +111,7 @@ describe('useIsExpanded', () => {
],
} as unknown as ContentItemModel,
],
};
} as unknown as ContentItemModel;
const { result } = renderHook(() =>
useIsExpanded({ item, routingBasePath: mockRoutingBasePath }),
@ -102,6 +124,9 @@ describe('useIsExpanded', () => {
mockUseLocation.mockReturnValue({
pathname: '/docs/user%20profile',
hash: '',
state: undefined,
key: '',
search: '',
});
const item: ContentItemModel = {
@ -121,13 +146,16 @@ describe('useIsExpanded', () => {
mockUseLocation.mockReturnValue({
pathname: '/docs/users',
hash: '',
state: undefined,
key: '',
search: '',
});
const item: ContentItemModel = {
type: 'tag',
href: '/pets',
items: [{ type: 'operation', href: '/pets/list', items: [] } as unknown as ContentItemModel],
};
} as unknown as ContentItemModel;
const { result } = renderHook(() =>
useIsExpanded({ item, routingBasePath: mockRoutingBasePath }),

View File

@ -1,9 +1,11 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { Items } from '../Items';
import type { ContentItemModel } from '../../../models/index.js';
jest.mock('../../ContentItem', () => ({
import { Items } from '../Items.js';
import { TestMemoryRouter } from '../../../testProviders.js';
vi.mock('../../ContentItem', () => ({
ContentItem: ({ children, item }: any) => (
<div data-testid={`content-item-${item.id}`}>
{item.title}
@ -12,7 +14,7 @@ jest.mock('../../ContentItem', () => ({
),
}));
jest.mock('../VirtualList', () => ({
vi.mock('../VirtualList', () => ({
VirtualList: ({ items }: any) => (
<div data-testid="virtual-list">
{items.map((item: any) => (
@ -25,25 +27,25 @@ jest.mock('../VirtualList', () => ({
const mockItems = [
{ id: '1', title: 'Item 1', type: 'group', items: [] },
{ id: '2', title: 'Item 2', type: 'group', items: [] },
];
] as unknown as ContentItemModel[];
const mockMixedTypeItems = Array.from({ length: 16 }, (_, i) => ({
id: `mixed-${i}`,
title: `Item ${i}`,
type: i % 2 === 0 ? 'operation' : 'schema',
items: [],
}));
})) as unknown as ContentItemModel[];
const mockOperationItems = Array.from({ length: 16 }, (_, i) => ({
id: `op-${i}`,
title: `Operation ${i}`,
type: 'operation',
items: [],
}));
})) as unknown as ContentItemModel[];
describe('Items', () => {
const renderWithRouter = (component: React.ReactElement) => {
return render(<MemoryRouter>{component}</MemoryRouter>);
return render(<TestMemoryRouter>{component}</TestMemoryRouter>);
};
it('renders null when items array is empty', () => {
@ -81,7 +83,7 @@ describe('Items', () => {
type: 'group',
items: [{ id: '1-1', title: 'Child', type: 'group', items: [] }],
},
];
] as unknown as ContentItemModel[];
renderWithRouter(<Items items={nestedItems} routingBasePath="/docs" />);

View File

@ -1,22 +1,25 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { act } from 'react';
import { Routes, Route } from 'react-router-dom';
import { act, type ReactElement } from 'react';
import type { ContentItemModel } from '../../../models/types';
import type { ContentItemModel } from '../../../models/types.js';
import { VirtualList } from '../VirtualList';
import { VirtualList } from '../VirtualList.js';
import { TestMemoryRouter } from '../../../testProviders.js';
const mockItems: ContentItemModel[] = [
{ href: '/item1', title: 'Item 1' },
{ href: '/item2', title: 'Item 2' },
{ href: '/item3', title: 'Item 3' },
{ href: '/item4', title: 'Item 4' },
{ href: '/item5', title: 'Item 5' },
{ href: '/item1', title: 'Item 1' } as unknown as ContentItemModel,
{ href: '/item2', title: 'Item 2' } as unknown as ContentItemModel,
{ href: '/item3', title: 'Item 3' } as unknown as ContentItemModel,
{ href: '/item4', title: 'Item 4' } as unknown as ContentItemModel,
{ href: '/item5', title: 'Item 5' } as unknown as ContentItemModel,
];
const renderItem = (item: ContentItemModel) => (
<div key={item.href} data-testid={item.href.replace('/', '')}>
{item.title}
{typeof (item as any).title === 'string'
? String((item as any).title)
: String((item as any).name)}
</div>
);
@ -24,27 +27,27 @@ const renderWithRouter = (
initialPath: string,
props: {
items: ContentItemModel[];
renderItem: (item: ContentItemModel) => JSX.Element;
renderItem: (item: ContentItemModel) => ReactElement;
routingBasePath: string;
},
) => {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<TestMemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="*" element={<VirtualList {...props} />} />
</Routes>
</MemoryRouter>,
</TestMemoryRouter>,
);
};
describe('VirtualList', () => {
beforeEach(() => {
jest.useFakeTimers();
vi.useFakeTimers();
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
vi.clearAllTimers();
vi.useRealTimers();
});
it('should render empty list when no items provided', () => {
@ -121,7 +124,7 @@ describe('VirtualList', () => {
expect(screen.getByTestId('item4')).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(1000);
vi.advanceTimersByTime(1000);
});
expect(screen.getByTestId('item1')).toBeInTheDocument();
@ -151,20 +154,20 @@ describe('VirtualList', () => {
});
act(() => {
jest.advanceTimersByTime(1000);
vi.advanceTimersByTime(1000);
});
const initialRenderCount = screen.getAllByTestId(/item/).length;
rerender(
<MemoryRouter initialEntries={['/item3']}>
<TestMemoryRouter initialEntries={['/item3']}>
<Routes>
<Route
path="*"
element={<VirtualList items={mockItems} renderItem={renderItem} routingBasePath="/" />}
/>
</Routes>
</MemoryRouter>,
</TestMemoryRouter>,
);
expect(screen.getAllByTestId(/item/).length).toBe(initialRenderCount);
@ -181,7 +184,7 @@ describe('VirtualList', () => {
expect(() => {
act(() => {
jest.runAllTimers();
vi.runAllTimers();
});
}).not.toThrow();
});

View File

@ -0,0 +1,52 @@
import type { ReactElement } from 'react';
import { Tooltip } from '@redocly/theme/components/Tooltip/Tooltip';
import { DefaultMappingIcon } from '../../icons/DefaultMappingIcon/DefaultMappingIcon.js';
import { useTranslate } from '../../hooks/index.js';
import { styled } from '../../styled-components.js';
export interface DefaultMappingOptionProps {
label: string;
}
export function DefaultMappingOption({ label }: DefaultMappingOptionProps): ReactElement {
const translate = useTranslate();
return (
<Tooltip
tip={translate(
'openapi.discriminator.defaultMappingTooltip',
"OpenAPI 3.2: defaultMapping used when other mappings don't match.",
)}
placement="bottom"
>
<Wrapper>
<IconWrapper>
<DefaultMappingIcon />
</IconWrapper>
<Label>{label}</Label>
</Wrapper>
</Tooltip>
);
}
const Wrapper = styled.div`
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
`;
const IconWrapper = styled.div`
flex-shrink: 0;
display: flex;
align-items: center;
`;
const Label = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
`;

View File

@ -8,6 +8,7 @@ import { strikethroughText } from '../../utils/index.js';
import { SchemaSelection } from '../common/SchemaSelection/SchemaSelection.js';
import { SelectionTitle } from '../common/index.js';
import { styled } from '../../styled-components.js';
import { DefaultMappingOption } from './DefaultMappingOption.js';
interface DiscriminatorDropdownProps {
activeOneOfIdx: number;
@ -30,6 +31,20 @@ function DiscriminatorDropdownComponent({
'deprecated',
).toLowerCase()})`
: subSchema.title;
if (subSchema.isDefaultMapping) {
return {
label: translate('openapi.discriminator.defaultMapping', 'Default mapping'),
value: idx,
element: (
<DefaultMappingOption
label={translate('openapi.discriminator.defaultMapping', 'Default mapping')}
/>
),
...(parent.oneOf && parent.oneOf.length > 1 ? { divider: <Divider /> } : {}),
};
}
return {
label,
value: idx,
@ -62,3 +77,19 @@ const Wrapper = styled.div`
flex-direction: column;
align-items: flex-start;
`;
const Divider = styled.span<{ $orientation?: 'horizontal' | 'vertical' }>`
background-color: var(--border-color-primary);
display: block;
align-self: stretch;
[data-component-name='Dropdown/DropdownMenu'] & {
height: 1px;
margin: calc(var(--spacing-xxs) / 2) var(--spacing-xxs);
}
[data-component-name='Segmented/Segmented'] & {
width: 1px;
margin: var(--spacing-xxs) calc(var(--spacing-xxs) / 2);
}
`;

View File

@ -1,23 +1,24 @@
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import * as Jotai from 'jotai';
import type { OperationModel } from '../../../models';
import type { OperationModel } from '../../../models/index.js';
import { normalizeOptions, OpenAPIParser } from '../../../services';
import { getSchema } from '../../../models';
import { Schema } from '../../Schema';
import { normalizeOptions, OpenAPIParser } from '../../../services/index.js';
import { getSchema } from '../../../models/index.js';
import { Schema } from '../../Schema/index.js';
import * as simpleDiscriminatorFixture from './fixtures/simple-discriminator.json';
import * as discriminatorWithDefaultFixture from '../../../models/__tests__/fixtures/discriminator-with-default-mapping.json';
import { TestBrowserRouter } from '../../../testProviders.js';
const options = normalizeOptions({});
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: jest.fn(),
vi.mock('jotai', async () => ({
...(await vi.importActual('jotai')),
useAtomValue: vi.fn(),
}));
describe('DiscriminatorDropdown', () => {
jest.spyOn(Jotai, 'useAtomValue').mockReturnValue(options);
vi.spyOn(Jotai, 'useAtomValue').mockReturnValue(options);
const parser = new OpenAPIParser(simpleDiscriminatorFixture, undefined, options);
const schema = getSchema({
parser,
@ -28,7 +29,7 @@ describe('DiscriminatorDropdown', () => {
});
describe('discriminator', () => {
jest.spyOn(Jotai, 'useAtomValue').mockReturnValue({
vi.spyOn(Jotai, 'useAtomValue').mockReturnValue({
activeOneOf: {
'#/components/schemas/Pet': 0,
},
@ -36,11 +37,43 @@ describe('DiscriminatorDropdown', () => {
it('should correctly render SchemaView', () => {
const { getByText } = render(<Schema schema={schema} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
expect(getByText('type')).toBeInTheDocument();
expect(getByText('C̵a̵t̵ (deprecated)')).toBeInTheDocument();
});
});
describe('discriminator with defaultMapping', () => {
const parserWithDefault = new OpenAPIParser(
discriminatorWithDefaultFixture,
undefined,
options,
);
const schemaWithDefault = getSchema({
parser: parserWithDefault,
schemaOrRef: { $ref: '#/components/schemas/Pet' },
pointer: '#/components/schemas/Pet',
options,
deps: { operation: { pointer: 'defaultPointer' } as OperationModel },
});
it('schema has correct oneOf structure with defaultMapping', () => {
expect(schemaWithDefault.oneOf).toHaveLength(3);
expect(schemaWithDefault.oneOf?.[0].title).toBe('cat');
expect(schemaWithDefault.oneOf?.[0].isDefaultMapping).toBe(false);
expect(schemaWithDefault.oneOf?.[1].title).toBe('dog');
expect(schemaWithDefault.oneOf?.[1].isDefaultMapping).toBe(false);
expect(schemaWithDefault.oneOf?.[2].title).toBe('Default mapping');
expect(schemaWithDefault.oneOf?.[2].isDefaultMapping).toBe(true);
});
it('finds correct defaultMappingIdx', () => {
const defaultMappingIdx = schemaWithDefault.oneOf?.findIndex(
(subSchema) => subSchema.isDefaultMapping,
);
expect(defaultMappingIdx).toBe(2);
});
});
});

View File

@ -1,5 +1,7 @@
{
"openapi": "2.0.0",
"info": { "version": "1.0", "title": "Test" },
"paths": {},
"components": {
"schemas": {
"Pet": {

View File

@ -1,8 +1,8 @@
import { render, renderHook } from '@testing-library/react';
import util from 'util';
import { DownloadSpecification } from '../DownloadSpecification';
import { useDownloadInfo } from '../useDownloadInfo';
import { DownloadSpecification } from '../DownloadSpecification.js';
import { useDownloadInfo } from '../useDownloadInfo.js';
Object.defineProperty(global, 'TextEncoder', {
value: util.TextEncoder,
@ -26,7 +26,7 @@ describe('DownloadSpecification', () => {
downloadUrls,
}),
);
const downloadObjects = result.current;
const downloadObjects = result?.current ?? [];
const { container } = render(<DownloadSpecification downloadObjects={downloadObjects} />);
expect(container).toMatchSnapshot();
@ -52,7 +52,7 @@ describe('DownloadSpecification', () => {
downloadUrls,
}),
);
const downloadObjects = result.current;
const downloadObjects = result?.current ?? [];
const { container } = render(<DownloadSpecification downloadObjects={downloadObjects} />);

View File

@ -1,12 +1,86 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DownloadSpecification DownloadButton renders correctly with title 1`] = `
.c5 {
exports[`DownloadSpecification > DownloadButton renders correctly with title 1`] = `
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-align-self: flex-start;
-ms-flex-item-align: start;
align-self: flex-start;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
gap: var(--spacing-sm);
font-size: var(--font-size-base);
}.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: baseline;
-webkit-box-align: baseline;
-ms-flex-align: baseline;
align-items: baseline;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
width: 100%;
border-bottom: 1px solid var(--border-color-secondary);
padding-bottom: var(--spacing-sm);
}
.c1:last-of-type {
padding-bottom: 0px;
border-bottom: none;
}
.c2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
width: calc(100% - 25px);
gap: var(--spacing-xs);
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.c2 svg {
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.c2 a {
width: 100%;
color: var(--text-color-primary);
word-break: break-word;
-webkit-text-decoration: none;
text-decoration: none;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.c3 {
height: 16px;
width: 16px;
}
.c5 path {
.c3 path {
fill: currentColor;
}
@ -89,6 +163,15 @@ exports[`DownloadSpecification DownloadButton renders correctly with title 1`] =
border-width: var(--button-border-width-disabled);
}
.c5 {
height: 16px;
width: 16px;
}
.c5 path {
fill: currentColor;
}
.c6 {
height: 16px;
width: 16px;
@ -98,15 +181,6 @@ exports[`DownloadSpecification DownloadButton renders correctly with title 1`] =
fill: currentColor;
}
.c3 {
height: 16px;
width: 16px;
}
.c3 path {
fill: currentColor;
}
.c7 {
height: 16px;
width: 16px;
@ -116,81 +190,7 @@ exports[`DownloadSpecification DownloadButton renders correctly with title 1`] =
fill: currentColor;
}
.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: baseline;
-webkit-box-align: baseline;
-ms-flex-align: baseline;
align-items: baseline;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
width: 100%;
border-bottom: 1px solid var(--border-color-secondary);
padding-bottom: var(--spacing-sm);
}
.c1:last-of-type {
padding-bottom: 0px;
border-bottom: none;
}
.c2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
width: calc(100% - 25px);
gap: var(--spacing-xs);
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.c2 svg {
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.c2 a {
width: 100%;
color: var(--text-color-primary);
word-break: break-word;
-webkit-text-decoration: none;
text-decoration: none;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-align-self: flex-start;
-ms-flex-item-align: start;
align-self: flex-start;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
gap: var(--spacing-sm);
font-size: var(--font-size-base);
}
<div>
<div
@ -378,13 +378,87 @@ exports[`DownloadSpecification DownloadButton renders correctly with title 1`] =
</div>
`;
exports[`DownloadSpecification DownloadButton renders correctly without title 1`] = `
.c5 {
exports[`DownloadSpecification > DownloadButton renders correctly without title 1`] = `
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-align-self: flex-start;
-ms-flex-item-align: start;
align-self: flex-start;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
gap: var(--spacing-sm);
font-size: var(--font-size-base);
}.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: baseline;
-webkit-box-align: baseline;
-ms-flex-align: baseline;
align-items: baseline;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
width: 100%;
border-bottom: 1px solid var(--border-color-secondary);
padding-bottom: var(--spacing-sm);
}
.c1:last-of-type {
padding-bottom: 0px;
border-bottom: none;
}
.c2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
width: calc(100% - 25px);
gap: var(--spacing-xs);
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.c2 svg {
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.c2 a {
width: 100%;
color: var(--text-color-primary);
word-break: break-word;
-webkit-text-decoration: none;
text-decoration: none;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.c3 {
height: 16px;
width: 16px;
}
.c5 path {
.c3 path {
fill: currentColor;
}
@ -467,6 +541,15 @@ exports[`DownloadSpecification DownloadButton renders correctly without title 1`
border-width: var(--button-border-width-disabled);
}
.c5 {
height: 16px;
width: 16px;
}
.c5 path {
fill: currentColor;
}
.c6 {
height: 16px;
width: 16px;
@ -476,15 +559,6 @@ exports[`DownloadSpecification DownloadButton renders correctly without title 1`
fill: currentColor;
}
.c3 {
height: 16px;
width: 16px;
}
.c3 path {
fill: currentColor;
}
.c7 {
height: 16px;
width: 16px;
@ -494,81 +568,7 @@ exports[`DownloadSpecification DownloadButton renders correctly without title 1`
fill: currentColor;
}
.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: baseline;
-webkit-box-align: baseline;
-ms-flex-align: baseline;
align-items: baseline;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
width: 100%;
border-bottom: 1px solid var(--border-color-secondary);
padding-bottom: var(--spacing-sm);
}
.c1:last-of-type {
padding-bottom: 0px;
border-bottom: none;
}
.c2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
width: calc(100% - 25px);
gap: var(--spacing-xs);
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.c2 svg {
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.c2 a {
width: 100%;
color: var(--text-color-primary);
word-break: break-word;
-webkit-text-decoration: none;
text-decoration: none;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-align-self: flex-start;
-ms-flex-item-align: start;
align-self: flex-start;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
gap: var(--spacing-sm);
font-size: var(--font-size-base);
}
<div>
<div

View File

@ -1,38 +1,39 @@
import type { Mock } from 'vitest';
import { useAtom } from 'jotai/index';
import { render, screen, fireEvent } from '@testing-library/react';
import { ExpandAllButton } from '../ExpandAllButton';
import { useTranslate, useTelemetry } from '../../../hooks';
import { ExpandAllButton } from '../ExpandAllButton.js';
import { useTranslate, useTelemetry } from '../../../hooks/index.js';
// Mock the dependencies
jest.mock('jotai/index', () => ({
useAtom: jest.fn(),
atom: jest.fn(),
vi.mock('jotai/index', () => ({
useAtom: vi.fn(),
atom: vi.fn(),
}));
jest.mock('../../../hooks', () => ({
useTranslate: jest.fn(),
useTelemetry: jest.fn(), // Add mock for useTelemetry
vi.mock('../../../hooks', () => ({
useTranslate: vi.fn(),
useTelemetry: vi.fn(), // Add mock for useTelemetry
}));
jest.mock('@redocly/theme', () => ({
vi.mock('@redocly/theme', () => ({
Button: ({ children, ...props }) => <button {...props}>{children}</button>,
MaximizeIcon: () => <span data-testid="maximize-icon" />,
}));
describe('ExpandAllButton', () => {
const mockSetOperationState = jest.fn();
const mockTranslate = jest.fn((key, defaultValue) => defaultValue);
const mockTelemetrySend = jest.fn();
const mockSetOperationState = vi.fn();
const mockTranslate = vi.fn((_key, defaultValue) => defaultValue);
const mockTelemetrySend = vi.fn();
beforeEach(() => {
jest.clearAllMocks();
(useAtom as jest.Mock).mockReturnValue([
vi.clearAllMocks();
(useAtom as Mock).mockReturnValue([
{ request: { expandedAll: false }, response: { expandedAll: false } },
mockSetOperationState,
]);
(useTranslate as jest.Mock).mockReturnValue(mockTranslate);
(useTelemetry as jest.Mock).mockReturnValue({
(useTranslate as Mock).mockReturnValue(mockTranslate);
(useTelemetry as Mock).mockReturnValue({
sendExpandCollapseAllClickedMessage: mockTelemetrySend,
});
});
@ -56,7 +57,7 @@ describe('ExpandAllButton', () => {
});
it('displays correct text based on expanded state', () => {
(useAtom as jest.Mock).mockReturnValue([
(useAtom as Mock).mockReturnValue([
{ request: { expandedAll: true }, response: { expandedAll: false } },
mockSetOperationState,
]);

View File

@ -1,6 +1,6 @@
import { render, fireEvent, screen } from '@testing-library/react';
import { LanguageDropdown } from '../LanguageDropdown';
import { LanguageDropdown } from '../LanguageDropdown.js';
const mockSamples = [
{ key: 'js', title: 'JS', lang: 'js' },
@ -9,7 +9,7 @@ const mockSamples = [
const mockActiveTab = mockSamples[0].key;
const mockOnChange = jest.fn();
const mockOnChange = vi.fn();
describe('LanguageDropdown', () => {
test('LanguageDropdown renders correctly', () => {

View File

@ -3,19 +3,19 @@ import * as Jotai from 'jotai/index';
import { html } from '@redocly/theme/markdoc/tags/html';
import { normalizeOptions } from '../../../services';
import { Markdown } from '../Markdown';
import { withTestProviders } from '../../../testProviders';
import { normalizeOptions } from '../../../services/index.js';
import { Markdown } from '../Markdown.js';
import { withTestProviders } from '../../../testProviders.js';
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: jest.fn(),
vi.mock('jotai', async () => ({
...(await vi.importActual('jotai')),
useAtomValue: vi.fn(),
}));
describe('Components', () => {
describe('Markdown', () => {
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
});
test('Markdown renders correctly', () => {
const { getByText } = render(
@ -34,11 +34,13 @@ describe('Components', () => {
tags: {
html: html.schema,
},
nodes: {},
components: {},
},
},
);
jest.spyOn(Jotai, 'useAtomValue').mockImplementation(() => {
vi.spyOn(Jotai, 'useAtomValue').mockImplementation(() => {
return options;
});

View File

@ -1,190 +1,60 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`processHtmlTokens should process block and inline html tokens 1`] = `
{
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"_store": {},
"key": null,
"props": {
"children": [
{
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"_store": {},
"key": null,
"props": {
"children": "Heading",
},
"type": "h1",
},
{
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"_store": {},
"key": null,
"props": {
"children": {
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"_store": {},
"key": null,
"props": {
"children": [
"Inline ",
{
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"_store": {},
"key": null,
"props": {
"children": "bold",
},
"type": "strong",
},
" text",
],
},
"type": "p",
},
"id": "22",
},
"type": "div",
},
],
},
"type": "article",
}
exports[`processHtmlTokens > should process block and inline html tokens 1`] = `
<article>
<h1>
Heading
</h1>
<div
id="22"
>
<p>
Inline
<strong>
bold
</strong>
text
</p>
</div>
</article>
`;
exports[`processHtmlTokens should process inline html tokens 1`] = `
{
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"_store": {},
"key": null,
"props": {
"children": [
{
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"_store": {},
"key": null,
"props": {
"children": [
"Inline ",
{
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"_store": {},
"key": null,
"props": {
"children": "bold",
},
"type": "strong",
},
" text",
],
},
"type": "p",
},
{
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"_store": {},
"key": null,
"props": {
"children": "Heading",
},
"type": "h1",
},
{
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"_store": {},
"key": null,
"props": {
"children": [
"Inline ",
{
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"_store": {},
"key": null,
"props": {
"children": "bold",
},
"type": "strong",
},
" text",
],
},
"type": "p",
},
],
},
"type": "article",
}
exports[`processHtmlTokens > should process inline html tokens 1`] = `
<article>
<p>
Inline
<strong>
bold
</strong>
text
</p>
<h1>
Heading
</h1>
<p>
Inline
<strong>
bold
</strong>
text
</p>
</article>
`;
exports[`processHtmlTokens should report unclosed errors 1`] = `
{
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"_store": {},
"key": null,
"props": {
"children": [
{
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"_store": {},
"key": null,
"props": {
"children": {
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"_store": {},
"key": null,
"props": {
"children": " inline",
},
"type": "span",
},
},
"type": "p",
},
{
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"_store": {},
"key": null,
"props": {
"children": "Heading",
},
"type": "h1",
},
{
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"_store": {},
"key": null,
"props": {
"children": {
"$$typeof": Symbol(react.transitional.element),
"_owner": null,
"_store": {},
"key": null,
"props": {
"children": "bold",
},
"type": "p",
},
},
"type": "strong",
},
],
},
"type": "article",
}
exports[`processHtmlTokens > should report unclosed errors 1`] = `
<article>
<p>
<span>
inline
</span>
</p>
<h1>
Heading
</h1>
<strong>
<p>
bold
</p>
</strong>
</article>
`;

View File

@ -4,7 +4,7 @@ import React from 'react';
import { html } from '@redocly/theme/markdoc/tags/html';
import { processHtmlTokens } from '../processHtmlTokens';
import { processHtmlTokens } from '../processHtmlTokens.js';
const tokenizer = new markdoc.Tokenizer({
html: true,

View File

@ -1,21 +1,19 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from '@jest/globals';
import { MemoryRouter } from 'react-router-dom';
import { McpOverview } from '../McpOverview';
import { withTestProviders } from '../../../testProviders';
import { McpOverview } from '../McpOverview.js';
import { withTestProviders, TestMemoryRouter } from '../../../testProviders.js';
import mcpInfo from './fixtures/mcp-info.json';
describe('McpOverview', () => {
it('should render mcp overview', () => {
render(
withTestProviders(
<MemoryRouter>
<TestMemoryRouter>
<McpOverview
mcpInfo={mcpInfo as any}
mcpServers={[{ url: 'https://api.example.com/mcp' }]}
/>
</MemoryRouter>,
</TestMemoryRouter>,
),
);
@ -33,12 +31,12 @@ describe('McpOverview', () => {
it('should render experimental capabilities', () => {
render(
withTestProviders(
<MemoryRouter>
<TestMemoryRouter>
<McpOverview
mcpInfo={mcpInfo as any}
mcpServers={[{ url: 'https://api.example.com/mcp' }]}
/>
</MemoryRouter>,
</TestMemoryRouter>,
),
);
@ -60,12 +58,12 @@ describe('McpOverview', () => {
};
render(
withTestProviders(
<MemoryRouter>
<TestMemoryRouter>
<McpOverview
mcpInfo={mcpInfoWithExtras as any}
mcpServers={[{ url: 'https://api.example.com/mcp' }]}
/>
</MemoryRouter>,
</TestMemoryRouter>,
),
);

View File

@ -0,0 +1,157 @@
import { memo, useCallback, useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { LayoutVariant } from '@redocly/config';
import type { McpPrompt as McpPromptType } from '../../types/open-api.js';
import { getMediaContent, getSecurity, type OperationModel } from '../../models/index.js';
import { globalStoreAtom } from '../../jotai/store.js';
import { Section, Row, SamplesMiddlePanel, SamplesPanel, CodeBlockPanel } from '../common/index.js';
import { Schema } from '../Schema/index.js';
import { layoutAtom } from '../../jotai/app.js';
import { MediaTypeSamples } from '../PayloadSamples/MediaTypeSamples.js';
import { useTranslate } from '../../hooks/index.js';
import { Title } from '../common/OperationItemTitle.js';
import { LinkToField } from '../common/LinkToField.js';
import { makeDeepLink } from '../../services/index.js';
import { Security } from '../Security/Security.js';
import { styled } from '../../styled-components.js';
interface McpPromptProps {
name: string;
id: string;
}
function McpPromptComponent({ name, id }: McpPromptProps) {
const { parser } = useAtomValue(globalStoreAtom);
const translate = useTranslate();
const prompt = useMemo((): McpPromptType | undefined => {
const prompts = parser.definition['x-mcp']?.prompts || [];
return prompts.find((p: McpPromptType) => p.name === name);
}, [parser, name]);
const securityModel = useMemo(() => {
if (!prompt) {
return null;
}
return getSecurity(prompt.security, parser);
}, [parser, prompt]);
if (!prompt) {
return null;
}
return (
<Section>
<Content
id={id}
type="input"
prompt={prompt}
title={translate('openapi.mcp.inputSchema', 'Arguments')}
exampleTitle={translate('openapi.mcp.inputExample', 'Arguments example')}
>
{securityModel && <Security securities={securityModel} />}
</Content>
</Section>
);
}
function Content({
id,
type,
prompt,
title,
exampleTitle,
children,
}: {
id: string;
type: 'input' | 'output';
prompt: McpPromptType;
title: string;
exampleTitle: string;
children?: React.ReactNode;
}) {
const { parser, options } = useAtomValue(globalStoreAtom);
const layout = useAtomValue(layoutAtom);
const isStacked = layout === LayoutVariant.STACKED;
const mediaContent = useMemo(() => {
const parametersSchema = prompt.arguments && {
type: 'object',
properties: prompt.arguments.reduce((acc, argument) => {
acc[argument.name] = {
type: '',
example: argument.example || 'string',
description: argument.description,
required: argument.required,
};
return acc;
}, {}),
required: prompt.arguments
.filter((argument) => argument.required)
.map((argument) => argument.name),
};
if (!parametersSchema) {
return null;
}
return getMediaContent({
parser,
info: {
'application/json': {
schema: parametersSchema,
},
},
isRequestType: false,
options,
data: {
operation: { pointer: 'McpPrompt' } as OperationModel,
},
});
}, [options, parser, prompt]);
const nope = useCallback(() => {}, []);
return (
<McpSubRowStyled layout={layout}>
<McpToolMiddlePanel isStacked={isStacked}>
<div>
{children}
{mediaContent && (
<>
{' '}
<Title>
{id && <LinkToField to={makeDeepLink(id, type)} />}
{title}
</Title>
<Schema schema={mediaContent.mediaTypes[0].schema} />
</>
)}
</div>
</McpToolMiddlePanel>
{mediaContent && (
<SamplesPanel isStacked={isStacked}>
<CodeBlockPanel className="panel-response-samples" header={exampleTitle}>
<MediaTypeSamples mediaType={mediaContent.mediaTypes[0]} onChange={nope} />
</CodeBlockPanel>
</SamplesPanel>
)}
</McpSubRowStyled>
);
}
const McpSubRowStyled = styled(Row)`
margin: calc(var(--spacing-unit) * 2) 0;
& + & {
margin-top: calc(var(--spacing-base) * 2);
}
`;
const McpToolMiddlePanel = styled(SamplesMiddlePanel)`
padding-left: 0;
`;
export const McpPrompt = memo<McpPromptProps>(McpPromptComponent);

View File

@ -0,0 +1,151 @@
import { memo, useMemo } from 'react';
import * as React from 'react';
import { useAtomValue } from 'jotai';
import { LayoutVariant } from '@redocly/config';
import { CodeBlock } from '@redocly/theme/components/CodeBlock/CodeBlock';
import { Tag } from '@redocly/theme/components/Tag/Tag';
import { getSecurity } from '../../models/index.js';
import { globalStoreAtom } from '../../jotai/store.js';
import { Section, Row, SamplesMiddlePanel, SamplesPanel, CodeBlockPanel } from '../common/index.js';
import { layoutAtom } from '../../jotai/app.js';
import { useTranslate } from '../../hooks/index.js';
import { Security } from '../Security/Security.js';
import { styled } from '../../styled-components.js';
interface McpResourceProps {
name: string;
id: string;
}
function McpResourceComponent({ name }: McpResourceProps) {
const { parser } = useAtomValue(globalStoreAtom);
const translate = useTranslate();
const resource = useMemo(() => {
const resources = parser.definition['x-mcp']?.resources || [];
return resources.find((r) => r.name === name);
}, [parser, name]);
const securityModel = useMemo(() => {
if (!resource) {
return null;
}
return getSecurity(resource.security, parser);
}, [parser, resource]);
if (!resource) {
return <div>Resource not found: {name}</div>;
}
return (
<Section>
<LeftPanel
uriTitle={translate('openapi.mcp.uriTitle', 'Resource URI')}
mimeTypeTitle={translate('openapi.mcp.mimeTypeTitle', 'Resource MIME type')}
exampleTitle={translate('openapi.mcp.exampleTitle', 'Resource content')}
uri={resource.uri}
mimeType={resource.mimeType}
content={resource.blob ?? resource.text ?? ''}
>
{securityModel && <Security securities={securityModel} />}
</LeftPanel>
</Section>
);
}
function LeftPanel({
uriTitle,
mimeTypeTitle,
exampleTitle,
uri,
mimeType,
content,
children,
}: {
uriTitle: string;
mimeTypeTitle: string;
exampleTitle: string;
children?: React.ReactNode;
uri: string;
mimeType: string;
content: string;
}) {
const layout = useAtomValue(layoutAtom);
const isStacked = layout === LayoutVariant.STACKED;
const [copiedField, setCopiedField] = React.useState<string | null>(null);
const copyToClipboard = async (text: string, field: string, event: React.MouseEvent) => {
event.stopPropagation();
try {
await navigator.clipboard.writeText(text);
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
} catch (err) {
console.error('Failed to copy text: ', err);
}
};
return (
<McpSubRowStyled layout={layout}>
<McpToolMiddlePanel isStacked={isStacked}>
<div>
{children}
<RowLine>
{uriTitle}:{' '}
<StyledTag onClick={(e) => copyToClipboard(uri, 'uri', e)} title="Click to copy">
{copiedField === 'uri' ? '✓ Copied!' : uri}
</StyledTag>
</RowLine>
<RowLine>
{mimeTypeTitle}:{' '}
<StyledTag
onClick={(e) => copyToClipboard(mimeType, 'mimeType', e)}
title="Click to copy"
>
{copiedField === 'mimeType' ? '✓ Copied!' : mimeType}
</StyledTag>
</RowLine>
</div>
</McpToolMiddlePanel>
{content && (
<SamplesPanel isStacked={isStacked}>
<CodeBlockPanel className="panel-response-samples" header={exampleTitle}>
<StyledCodeBlock
source={content}
header={{ title: mimeType, controls: { copy: {} } }}
/>
</CodeBlockPanel>
</SamplesPanel>
)}
</McpSubRowStyled>
);
}
const McpSubRowStyled = styled(Row)`
margin: calc(var(--spacing-unit) * 2) 0;
& + & {
margin-top: calc(var(--spacing-base) * 2);
}
`;
const McpToolMiddlePanel = styled(SamplesMiddlePanel)`
padding-left: 0;
`;
export const McpResource = memo<McpResourceProps>(McpResourceComponent);
const StyledTag = styled(Tag)`
text-transform: none;
`;
const StyledCodeBlock = styled(CodeBlock)`
border: none;
padding: 0 8px;
`;
const RowLine = styled.div`
margin: 1em 0;
`;

View File

@ -19,18 +19,18 @@ import { Security } from '../Security/Security.js';
import { styled } from '../../styled-components.js';
interface McpToolProps {
toolName: string;
name: string;
id: string;
}
function McpToolComponent({ toolName, id }: McpToolProps) {
function McpToolComponent({ name, id }: McpToolProps) {
const { parser } = useAtomValue(globalStoreAtom);
const translate = useTranslate();
const tool = useMemo(() => {
const tools = parser.definition['x-mcp']?.tools || [];
return tools.find((t: any) => t.name === toolName);
}, [parser, toolName]);
return tools.find((t: any) => t.name === name);
}, [parser, name]);
const securityModel = useMemo(() => {
if (!tool) {
@ -40,7 +40,7 @@ function McpToolComponent({ toolName, id }: McpToolProps) {
}, [parser, tool]);
if (!tool) {
return <div>Tool not found: {toolName}</div>;
return <div>Tool not found: {name}</div>;
}
return (

View File

@ -0,0 +1,75 @@
import { render, screen } from '@testing-library/react';
import { McpPrompt } from '../McpPrompt.js';
import { withTestProviders, TestMemoryRouter } from '../../../testProviders.js';
const spec = {
openapi: '3.0.0',
'x-mcp': {
prompts: [
{
name: 'test-prompt',
description: 'A test prompt',
arguments: [
{
name: 'arg1',
description: 'First argument',
required: true,
example: 'value1',
},
],
security: [
{
apiKey: [],
},
],
},
],
},
components: {
securitySchemes: {
apiKey: {
type: 'apiKey',
in: 'header',
name: 'X-API-Key',
},
},
},
};
describe('McpPrompt', () => {
it('should render mcp prompt with arguments', () => {
render(
withTestProviders(
<TestMemoryRouter>
<McpPrompt name="test-prompt" id="test-prompt" />
</TestMemoryRouter>,
{
definition: spec as any,
options: {} as any,
},
),
);
expect(screen.getByText('Arguments')).toBeInTheDocument();
expect(screen.getByText('arg1')).toBeInTheDocument();
expect(screen.getByText('Security')).toBeInTheDocument();
expect(screen.getByText('apiKey')).toBeInTheDocument();
});
it('should return null if prompt not found', () => {
const { container } = render(
withTestProviders(
<TestMemoryRouter>
<McpPrompt name="not-found" id="not-found" />
</TestMemoryRouter>,
{
definition: spec as any,
options: {} as any,
},
),
);
expect(container.firstChild).toBeNull();
});
});

View File

@ -0,0 +1,112 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import { McpResource } from '../McpResource.js';
import { withTestProviders, TestMemoryRouter } from '../../../testProviders.js';
const spec = {
openapi: '3.0.0',
'x-mcp': {
resources: [
{
name: 'test-resource',
uri: 'mcp://test-resource',
mimeType: 'application/json',
text: '{"key": "value"}',
security: [
{
apiKey: [],
},
],
},
],
},
components: {
securitySchemes: {
apiKey: {
type: 'apiKey',
in: 'header',
name: 'X-API-Key',
},
},
},
};
// Mock navigator.clipboard
const mockClipboard = {
writeText: vi.fn(),
};
Object.assign(navigator, {
clipboard: mockClipboard,
});
describe('McpResource', () => {
beforeAll(() => {
mockClipboard.writeText.mockImplementation(() => Promise.resolve());
});
afterEach(() => {
vi.clearAllMocks();
});
it('should render mcp resource with content', () => {
render(
withTestProviders(
<TestMemoryRouter>
<McpResource name="test-resource" id="test-resource" />
</TestMemoryRouter>,
{
definition: spec as any,
options: {} as any,
},
),
);
expect(screen.getByText('Resource URI:')).toBeInTheDocument();
expect(screen.getByText('mcp://test-resource')).toBeInTheDocument();
expect(screen.getByText('Resource MIME type:')).toBeInTheDocument();
expect(screen.getAllByText('application/json').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('Resource content')).toBeInTheDocument();
expect(screen.getByText(/"key": "value"/)).toBeInTheDocument();
expect(screen.getByText('Security')).toBeInTheDocument();
expect(screen.getByText('apiKey')).toBeInTheDocument();
});
it('should render a message if resource not found', () => {
render(
withTestProviders(
<TestMemoryRouter>
<McpResource name="not-found" id="not-found" />
</TestMemoryRouter>,
{
definition: spec as any,
options: {} as any,
},
),
);
expect(screen.getByText('Resource not found: not-found')).toBeInTheDocument();
});
it('should copy URI to clipboard when clicked', async () => {
render(
withTestProviders(
<TestMemoryRouter>
<McpResource name="test-resource" id="test-resource" />
</TestMemoryRouter>,
{
definition: spec as any,
options: {} as any,
},
),
);
const uriElement = screen.getByText('mcp://test-resource');
fireEvent.click(uriElement);
expect(mockClipboard.writeText).toHaveBeenCalledWith('mcp://test-resource');
await waitFor(() => {
expect(screen.getByText('✓ Copied!')).toBeInTheDocument();
});
});
});

View File

@ -1,9 +1,7 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from '@jest/globals';
import { MemoryRouter } from 'react-router-dom';
import { McpTool } from '../McpTool';
import { withTestProviders } from '../../../testProviders';
import { McpTool } from '../McpTool.js';
import { withTestProviders, TestMemoryRouter } from '../../../testProviders.js';
import mcpData from './fixtures/mcp-tools.json';
const spec = {
@ -24,9 +22,9 @@ describe('McpTool', () => {
it('should render mcp tool with input and output schema', () => {
render(
withTestProviders(
<MemoryRouter>
<McpTool toolName="test-tool" id="test-tool" />
</MemoryRouter>,
<TestMemoryRouter>
<McpTool name="test-tool" id="test-tool" />
</TestMemoryRouter>,
{
definition: spec as any,
options: {} as any,
@ -45,9 +43,9 @@ describe('McpTool', () => {
it('should render a message if tool not found', () => {
render(
withTestProviders(
<MemoryRouter>
<McpTool toolName="not-found" id="not-found" />
</MemoryRouter>,
<TestMemoryRouter>
<McpTool name="not-found" id="not-found" />
</TestMemoryRouter>,
{
definition: spec as any,
options: {} as any,
@ -81,9 +79,9 @@ describe('McpTool', () => {
};
render(
withTestProviders(
<MemoryRouter>
<McpTool toolName="test-tool-no-output" id="test-tool-no-output" />
</MemoryRouter>,
<TestMemoryRouter>
<McpTool name="test-tool-no-output" id="test-tool-no-output" />
</TestMemoryRouter>,
{
definition: specWithoutOutput as any,
options: {} as any,

View File

@ -1 +1,3 @@
export * from './McpTool.js';
export * from './McpResource.js';
export * from './McpPrompt.js';

View File

@ -1,14 +1,14 @@
import { act, render } from '@testing-library/react';
import type { ReactElement } from 'react';
import type { SelectProps } from '../../common';
import type { OperationModel } from '../../../models';
import type { SelectProps } from '../../common/index.js';
import type { OperationModel } from '../../../models/index.js';
import { MediaTypesSwitch } from '../MediaTypesSwitch';
import { getOperation } from '../../../models';
import { normalizeOptions, OpenAPIParser } from '../../../services';
import { MediaTypesSwitch } from '../MediaTypesSwitch.js';
import { getOperation } from '../../../models/index.js';
import { normalizeOptions, OpenAPIParser } from '../../../services/index.js';
import testDefinition from './fixtures/test-definition.json';
import { withTestProviders } from '../../../testProviders';
import { withTestProviders } from '../../../testProviders.js';
describe('Components', () => {
describe('MediaTypesSwitch', () => {
@ -25,6 +25,7 @@ describe('Components', () => {
pathParameters: [],
pathServers: [],
isWebhook: false,
isAdditionalOperation: false,
...testDefinition.paths['/test'].get,
},
undefined,
@ -34,8 +35,8 @@ describe('Components', () => {
});
test('renders with default media type by default', () => {
const childrenFn = jest.fn((mimeType) => <>children: {mimeType.name}</>);
const renderSelectFn = jest.fn<ReactElement | null, [SelectProps]>(() => null);
const childrenFn = vi.fn((mimeType) => <>children: {mimeType.name}</>);
const renderSelectFn = vi.fn<(props: SelectProps) => ReactElement | null>(() => null);
const { getByText } = render(
withTestProviders(
@ -55,8 +56,8 @@ describe('Components', () => {
});
test('withLabel renders label', () => {
const childrenFn = jest.fn((mimeType) => <>children: {mimeType.name}</>);
const renderSelectFn = jest.fn(() => <>dropdown</>);
const childrenFn = vi.fn((mimeType) => <>children: {mimeType.name}</>);
const renderSelectFn = vi.fn(() => <>dropdown</>);
const { getByText } = render(
withTestProviders(
@ -75,8 +76,10 @@ describe('Components', () => {
});
test('should try to restore scroll on switch', async () => {
const childrenFn = jest.fn((mimeType) => <>children: {mimeType.name}</>);
const renderSelectFn = jest.fn<ReactElement | null, [SelectProps]>(() => <>dropdown</>);
const childrenFn = vi.fn((mimeType) => <>children: {mimeType.name}</>);
const renderSelectFn = vi.fn<(props: SelectProps) => ReactElement | null>(() => (
<>dropdown</>
));
const { findByText, container } = render(
withTestProviders(
@ -92,17 +95,20 @@ describe('Components', () => {
);
// Create a mock implementation of window.scrollBy
const scrollByMock = jest.fn();
const scrollByMock = vi.fn();
Object.defineProperty(window, 'scrollBy', { value: scrollByMock });
const rectMock = jest.fn(() => {
const rectMock = vi.fn(() => {
return (rectMock.mock.calls.length > 1 ? { y: 150 } : { y: 100 }) as unknown as DOMRect;
});
jest.spyOn(container.firstElementChild, 'getBoundingClientRect').mockImplementation(rectMock);
vi.spyOn(container?.firstElementChild as Element, 'getBoundingClientRect').mockImplementation(
rectMock,
);
act(() => {
renderSelectFn.mock.calls[0][0].onChange(renderSelectFn.mock.calls[0][0].options[1]);
const selectProps = renderSelectFn.mock.calls[0][0];
selectProps.onChange(selectProps.options[1]);
});
await findByText('children: application/xml');
@ -114,8 +120,8 @@ describe('Components', () => {
});
test('should not throw and render null for empty media types', () => {
const childrenFn = jest.fn((mimeType) => <>children: {mimeType.name}</>);
const renderSelectFn = jest.fn(() => null);
const childrenFn = vi.fn((mimeType) => <>children: {mimeType.name}</>);
const renderSelectFn = vi.fn(() => null);
const { container } = render(
withTestProviders(

View File

@ -10,5 +10,5 @@
}
}
},
"components": { "securitySchemes": { "test": { "type": "apiKey" } } }
"components": {}
}

View File

@ -1,18 +1,18 @@
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import type { OperationMenuItem } from '../../../models';
import type { OperationMenuItem } from '../../../models/index.js';
import { OperationItem } from '../OperationItem';
import { normalizeOptions } from '../../../services';
import { OperationItem } from '../OperationItem.js';
import { normalizeOptions } from '../../../services/index.js';
import spec from './fixtures/petstore.json';
import definition from './fixtures/operationDefinition.json';
import { globalStoreAtom } from '../../../jotai/store';
import { MockIntersectionObserver } from './__mocks__/mock-intersection-observer';
import { globalStoreAtom } from '../../../jotai/store.js';
import { MockIntersectionObserver } from './__mocks__/mock-intersection-observer.js';
import { TestBrowserRouter } from '../../../testProviders.js';
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: jest.fn((a) => {
vi.mock('jotai', async () => ({
...(await vi.importActual('jotai')),
useAtomValue: vi.fn((a) => {
if (a === globalStoreAtom) {
return {
parser: {
@ -27,7 +27,7 @@ jest.mock('jotai', () => ({
}
return {};
}),
useAtom: jest.fn(() => {
useAtom: vi.fn(() => {
return [
{
activeOneOf: { '/paths/~1pet/post': 0 },
@ -36,7 +36,7 @@ jest.mock('jotai', () => ({
response: { expandedFields: {} },
activeLanguage: 'curl',
},
jest.fn(),
vi.fn(),
];
}),
}));
@ -58,7 +58,7 @@ describe('OperationItem', () => {
}
/>,
{
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
},
);

View File

@ -1,13 +1,74 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Overview should render correctly with contact data but without a name 1`] = `
.c9 {
height: 16px;
width: 16px;
exports[`Overview > should render correctly with contact data but without a name 1`] = `
.c0 > span {
border-bottom: 1px solid var(--border-color-secondary);
margin-bottom: var(--spacing-xs);
padding-bottom: var(--spacing-xs);
}
.c9 path {
fill: currentColor;
.c0 > span:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 0;
position: relative;
width: 100%;
}
.c2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
overflow: hidden;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
}
.c3 {
color: var(--text-color-secondary);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
font-weight: var(--font-weight-medium);
}.c4 {
margin: 0;
color: var(--text-color-secondary);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
overflow: hidden;
text-overflow: ellipsis;
}
.c5 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
gap: var(--spacing-xs);
}
.c6 {
@ -89,6 +150,15 @@ exports[`Overview should render correctly with contact data but without a name 1
border-width: var(--button-border-width-disabled);
}
.c7 {
height: 16px;
width: 16px;
}
.c7 path {
fill: currentColor;
}
.c8 {
position: relative;
display: -webkit-box;
@ -97,12 +167,12 @@ exports[`Overview should render correctly with contact data but without a name 1
display: flex;
}
.c7 {
.c9 {
height: 16px;
width: 16px;
}
.c7 path {
.c9 path {
fill: currentColor;
}
@ -115,77 +185,7 @@ exports[`Overview should render correctly with contact data but without a name 1
fill: currentColor;
}
.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 0;
position: relative;
width: 100%;
}
.c2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
overflow: hidden;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
}
.c4 {
margin: 0;
color: var(--text-color-secondary);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
overflow: hidden;
text-overflow: ellipsis;
}
.c5 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
gap: var(--spacing-xs);
}
.c0 > span {
border-bottom: 1px solid var(--border-color-secondary);
margin-bottom: var(--spacing-xs);
padding-bottom: var(--spacing-xs);
}
.c0 > span:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.c3 {
color: var(--text-color-secondary);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
font-weight: var(--font-weight-medium);
}
<div>
<div
@ -319,14 +319,75 @@ exports[`Overview should render correctly with contact data but without a name 1
</div>
`;
exports[`Overview should render correctly with full data 1`] = `
.c9 {
height: 16px;
width: 16px;
exports[`Overview > should render correctly with full data 1`] = `
.c0 > span {
border-bottom: 1px solid var(--border-color-secondary);
margin-bottom: var(--spacing-xs);
padding-bottom: var(--spacing-xs);
}
.c9 path {
fill: currentColor;
.c0 > span:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 0;
position: relative;
width: 100%;
}
.c2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
overflow: hidden;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
}
.c3 {
color: var(--text-color-secondary);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
font-weight: var(--font-weight-medium);
}.c4 {
margin: 0;
color: var(--text-color-secondary);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
overflow: hidden;
text-overflow: ellipsis;
}
.c5 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
gap: var(--spacing-xs);
}
.c6 {
@ -408,6 +469,15 @@ exports[`Overview should render correctly with full data 1`] = `
border-width: var(--button-border-width-disabled);
}
.c7 {
height: 16px;
width: 16px;
}
.c7 path {
fill: currentColor;
}
.c8 {
position: relative;
display: -webkit-box;
@ -416,12 +486,12 @@ exports[`Overview should render correctly with full data 1`] = `
display: flex;
}
.c7 {
.c9 {
height: 16px;
width: 16px;
}
.c7 path {
.c9 path {
fill: currentColor;
}
@ -434,77 +504,7 @@ exports[`Overview should render correctly with full data 1`] = `
fill: currentColor;
}
.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 0;
position: relative;
width: 100%;
}
.c2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
overflow: hidden;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
}
.c4 {
margin: 0;
color: var(--text-color-secondary);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
overflow: hidden;
text-overflow: ellipsis;
}
.c5 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
gap: var(--spacing-xs);
}
.c0 > span {
border-bottom: 1px solid var(--border-color-secondary);
margin-bottom: var(--spacing-xs);
padding-bottom: var(--spacing-xs);
}
.c0 > span:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.c3 {
color: var(--text-color-secondary);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
font-weight: var(--font-weight-medium);
}
<div>
<div
@ -729,7 +729,7 @@ exports[`Overview should render correctly with full data 1`] = `
</div>
`;
exports[`Overview should render correctly with partial data 1`] = `
exports[`Overview > should render correctly with partial data 1`] = `
.c0 > span {
border-bottom: 1px solid var(--border-color-secondary);
margin-bottom: var(--spacing-xs);
@ -749,7 +749,77 @@ exports[`Overview should render correctly with partial data 1`] = `
</div>
`;
exports[`Overview should render license with name and url when identifier is not present 1`] = `
exports[`Overview > should render license with name and url when identifier is not present 1`] = `
.c0 > span {
border-bottom: 1px solid var(--border-color-secondary);
margin-bottom: var(--spacing-xs);
padding-bottom: var(--spacing-xs);
}
.c0 > span:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 0;
position: relative;
width: 100%;
}
.c2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
overflow: hidden;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
}
.c3 {
color: var(--text-color-secondary);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
font-weight: var(--font-weight-medium);
}.c4 {
margin: 0;
color: var(--text-color-secondary);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
overflow: hidden;
text-overflow: ellipsis;
}
.c5 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
gap: var(--spacing-xs);
}
.c6 {
width: auto;
display: -webkit-inline-box;
@ -838,77 +908,7 @@ exports[`Overview should render license with name and url when identifier is not
fill: currentColor;
}
.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 0;
position: relative;
width: 100%;
}
.c2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
overflow: hidden;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
}
.c4 {
margin: 0;
color: var(--text-color-secondary);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
overflow: hidden;
text-overflow: ellipsis;
}
.c5 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
gap: var(--spacing-xs);
}
.c0 > span {
border-bottom: 1px solid var(--border-color-secondary);
margin-bottom: var(--spacing-xs);
padding-bottom: var(--spacing-xs);
}
.c0 > span:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.c3 {
color: var(--text-color-secondary);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
font-weight: var(--font-weight-medium);
}
<div>
<div

View File

@ -1,11 +1,11 @@
import { render } from '@testing-library/react';
import * as Jotai from 'jotai';
import type { OpenAPIDefinition, OpenAPIExample } from '../../../types';
import type { OpenAPIDefinition, OpenAPIExample } from '../../../types/index.js';
import { Example } from '../Example';
import { normalizeOptions, OpenAPIParser } from '../../../services';
import { getExamples } from '../../../models';
import { Example } from '../Example.js';
import { normalizeOptions, OpenAPIParser } from '../../../services/index.js';
import { getExamples } from '../../../models/index.js';
import testDefinition from './fixtures/mediaTypeUrlencoded.json';
describe('Example component', () => {
@ -23,7 +23,7 @@ describe('Example component', () => {
});
const { container } = render(
<Jotai.Provider>
<Example example={example} mimeType={example.mime} onCopyClick={jest.fn()} />
<Example example={example} mimeType={example.mime} onCopyClick={vi.fn()} />
</Jotai.Provider>,
);
@ -42,7 +42,7 @@ describe('Example component', () => {
const { container } = render(
<Jotai.Provider>
<Example example={example} mimeType={example.mime} onCopyClick={jest.fn()} />
<Example example={example} mimeType={example.mime} onCopyClick={vi.fn()} />
</Jotai.Provider>,
);

View File

@ -1,12 +1,12 @@
import { render } from '@testing-library/react';
import type { OpenAPIDefinition } from '../../../types';
import type { OperationModel } from '../../../models';
import type { OpenAPIDefinition } from '../../../types/index.js';
import type { OperationModel } from '../../../models/index.js';
import { normalizeOptions, OpenAPIParser } from '../../../services';
import { getMediaContent, getOperation } from '../../../models';
import { withTestProviders } from '../../../testProviders';
import { PayloadSamples } from '../PayloadSamples';
import { normalizeOptions, OpenAPIParser } from '../../../services/index.js';
import { getMediaContent, getOperation } from '../../../models/index.js';
import { withTestProviders } from '../../../testProviders.js';
import { PayloadSamples } from '../PayloadSamples.js';
import testDefinition from './fixtures/mediaTypeUrlencoded.json';
describe('PayloadSamples', () => {
@ -15,7 +15,22 @@ describe('PayloadSamples', () => {
const parser = new OpenAPIParser(testDefinition as OpenAPIDefinition, undefined, options);
test('should renders correctly without example to application/x-www-form-urlencoded', async () => {
operation = getOperation(parser, testDefinition, undefined, options, 'tests');
operation = getOperation(
parser,
{
pointer: '#/paths/~1test/get',
pathName: '/test',
httpVerb: 'GET',
pathParameters: [],
pathServers: [],
isWebhook: false,
isAdditionalOperation: false,
...testDefinition.paths['/test'].get,
},
undefined,
options,
'tests',
);
const props = {
operation,
content: getMediaContent({
@ -25,7 +40,7 @@ describe('PayloadSamples', () => {
options,
data: { operation },
}),
onCopyClick: jest.fn(),
onCopyClick: vi.fn(),
};
const { container } = render(

View File

@ -1,13 +1,108 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Example component render component with \`externalValue\` 1`] = `
.c9 {
height: 16px;
width: 16px;
exports[`Example component > render component with \`externalValue\` 1`] = `
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
}
.c9 path {
fill: currentColor;
.c0 > div {
border: none;
margin: 0;
}
.c0 pre {
contain: content;
overflow-x: auto;
position: relative;
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
.c0 .code-block-header {
border-bottom: 0;
padding-right: var(--spacing-sm);
}.c1 {
border: 1px solid var(--border-color-secondary);
border-radius: var(--border-radius);
background-color: var(--code-block-bg-color);
margin: 0 0 var(--spacing-sm);
--md-pre-margin: 0;
}
.c2 {
display: grid;
position: relative;
}
.c3 {
display: grid;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: var(--spacing-sm);
font-size: var(--code-block-controls-font-size);
font-family: var(--code-block-controls-font-family);
background-color: var(--code-block-controls-bg-color);
padding: var(--code-block-controls-padding);
border-bottom: var(--code-block-controls-border);
line-height: var(--line-height-lg);
min-height: var(--control-height-base);
grid-template-columns: 1fr auto;
z-index: 1;
position: absolute;
right: 0;
width: auto;
border: none;
background-color: transparent;
grid-template-columns: 1fr;
margin: 0;
}
.c4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
text-align: right;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: var(--spacing-xxs);
-webkit-box-pack: end;
-webkit-justify-content: end;
-ms-flex-pack: end;
justify-content: end;
}
.c5 {
position: relative;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
}
.c6 {
display: contents;
}
.c7 {
--button-icon-size: 16px;
--button-icon-padding: 3px;
--button-backdrop-filter: blur(8px);
}
.c8 {
@ -89,6 +184,19 @@ exports[`Example component render component with \`externalValue\` 1`] = `
border-width: var(--button-border-width-disabled);
}
.c9 {
height: 16px;
width: 16px;
}
.c9 path {
fill: currentColor;
}
.c10 {
color: var(--code-block-tokens-default-color);
}
.c11.c11 {
overflow-x: auto;
font-family: var(--code-block-font-family);
@ -359,11 +467,7 @@ exports[`Example component render component with \`externalValue\` 1`] = `
content: '\\ea0c';
}
pre.c10 {
color: var(--code-block-tokens-default-color);
}
.c11 .token.comment {
pre.c11 .token.comment {
color: var(--code-block-tokens-comment-color);
}
@ -487,115 +591,11 @@ pre.c10 {
color: var(--code-block-tokens-function-color);
}
.c5 {
position: relative;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
}
.c6 {
display: contents;
}
.c3 {
display: grid;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: var(--spacing-sm);
font-size: var(--code-block-controls-font-size);
font-family: var(--code-block-controls-font-family);
background-color: var(--code-block-controls-bg-color);
padding: var(--code-block-controls-padding);
border-bottom: var(--code-block-controls-border);
line-height: var(--line-height-lg);
min-height: var(--control-height-base);
grid-template-columns: 1fr auto;
z-index: 1;
position: absolute;
right: 0;
width: auto;
border: none;
background-color: transparent;
grid-template-columns: 1fr;
margin: 0;
}
.c4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
text-align: right;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: var(--spacing-xxs);
-webkit-box-pack: end;
-webkit-justify-content: end;
-ms-flex-pack: end;
justify-content: end;
}
.c7 {
--button-icon-size: 16px;
--button-icon-padding: 3px;
--button-backdrop-filter: blur(8px);
}
.c2 {
display: grid;
position: relative;
}
.c1 {
border: 1px solid var(--border-color-secondary);
border-radius: var(--border-radius);
background-color: var(--code-block-bg-color);
margin: 0 0 var(--spacing-sm);
--md-pre-margin: 0;
}
.c12 {
position: relative;
}
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
}
.c0 > div {
border: none;
margin: 0;
}
.c0 pre {
contain: content;
overflow-x: auto;
position: relative;
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
.c0 .code-block-header {
border-bottom: 0;
padding-right: var(--spacing-sm);
}
<div>
<div
@ -672,14 +672,89 @@ pre.c10 {
</div>
`;
exports[`Example component render component without error when \`externalValue\` is pdf file 1`] = `
.c9 {
height: 16px;
width: 16px;
exports[`Example component > render component without error when \`externalValue\` is pdf file 1`] = `
.c0 {
border: 1px solid var(--border-color-secondary);
border-radius: var(--border-radius);
background-color: var(--code-block-bg-color);
margin: 0 0 var(--spacing-sm);
--md-pre-margin: 0;
}
.c9 path {
fill: currentColor;
.c1 {
border: none;
margin: 0;
}
.c1 .code-block-header {
border-bottom: 0;
padding-right: var(--spacing-sm);
}.c2 {
display: grid;
position: relative;
}
.c3 {
display: grid;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: var(--spacing-sm);
font-size: var(--code-block-controls-font-size);
font-family: var(--code-block-controls-font-family);
background-color: var(--code-block-controls-bg-color);
padding: var(--code-block-controls-padding);
border-bottom: var(--code-block-controls-border);
line-height: var(--line-height-lg);
min-height: var(--control-height-base);
grid-template-columns: 1fr auto;
z-index: 1;
position: absolute;
right: 0;
width: auto;
border: none;
background-color: transparent;
grid-template-columns: 1fr;
margin: 0;
}
.c4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
text-align: right;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: var(--spacing-xxs);
-webkit-box-pack: end;
-webkit-justify-content: end;
-ms-flex-pack: end;
justify-content: end;
}
.c5 {
position: relative;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
}
.c6 {
display: contents;
}
.c7 {
--button-icon-size: 16px;
--button-icon-padding: 3px;
--button-backdrop-filter: blur(8px);
}
.c8 {
@ -761,6 +836,19 @@ exports[`Example component render component without error when \`externalValue\`
border-width: var(--button-border-width-disabled);
}
.c9 {
height: 16px;
width: 16px;
}
.c9 path {
fill: currentColor;
}
.c10 {
color: var(--code-block-tokens-default-color);
}
.c11.c11 {
overflow-x: auto;
font-family: var(--code-block-font-family);
@ -1031,11 +1119,7 @@ exports[`Example component render component without error when \`externalValue\`
content: '\\ea0c';
}
pre.c10 {
color: var(--code-block-tokens-default-color);
}
.c11 .token.comment {
pre.c11 .token.comment {
color: var(--code-block-tokens-comment-color);
}
@ -1159,91 +1243,7 @@ pre.c10 {
color: var(--code-block-tokens-function-color);
}
.c5 {
position: relative;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
}
.c6 {
display: contents;
}
.c3 {
display: grid;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: var(--spacing-sm);
font-size: var(--code-block-controls-font-size);
font-family: var(--code-block-controls-font-family);
background-color: var(--code-block-controls-bg-color);
padding: var(--code-block-controls-padding);
border-bottom: var(--code-block-controls-border);
line-height: var(--line-height-lg);
min-height: var(--control-height-base);
grid-template-columns: 1fr auto;
z-index: 1;
position: absolute;
right: 0;
width: auto;
border: none;
background-color: transparent;
grid-template-columns: 1fr;
margin: 0;
}
.c4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
text-align: right;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: var(--spacing-xxs);
-webkit-box-pack: end;
-webkit-justify-content: end;
-ms-flex-pack: end;
justify-content: end;
}
.c7 {
--button-icon-size: 16px;
--button-icon-padding: 3px;
--button-backdrop-filter: blur(8px);
}
.c2 {
display: grid;
position: relative;
}
.c0 {
border: 1px solid var(--border-color-secondary);
border-radius: var(--border-radius);
background-color: var(--code-block-bg-color);
margin: 0 0 var(--spacing-sm);
--md-pre-margin: 0;
}
.c1 {
border: none;
margin: 0;
}
.c1 .code-block-header {
border-bottom: 0;
padding-right: var(--spacing-sm);
}
<div>
<div

View File

@ -33,8 +33,6 @@ function FieldDetailsComponent({
const rawDefault = field.in === 'header'; // having quotes around header field default values is confusing and inappropriate
const shouldHideDescription = schema.oneOfType || schema.rawSchema?.allOf?.[0];
const renderedExamples = useMemo(() => {
if (example !== undefined || examples !== undefined) {
if (examples !== undefined) {
@ -95,7 +93,7 @@ function FieldDetailsComponent({
{schema.isComplex && (
<RecursiveLabel>{translate('openapi.complex', 'Complex')}</RecursiveLabel>
)}
{!shouldHideDescription && <StyledDescription source={description} />}
<StyledDescription source={description} />
<FieldDetail
raw={rawDefault}
type="default"

View File

@ -1,18 +1,18 @@
import { render } from '@testing-library/react';
import * as Jotai from 'jotai';
import { Extensions } from '../Extensions';
import { normalizeOptions } from '../../../services';
import { Extensions } from '../Extensions.js';
import { normalizeOptions } from '../../../services/index.js';
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: jest.fn(),
vi.mock('jotai', async () => ({
...(await vi.importActual('jotai')),
useAtomValue: vi.fn(),
}));
describe('Components', () => {
describe('Extensions', () => {
it('Extensions label renders correctly', () => {
jest.spyOn(Jotai, 'useAtomValue').mockReturnValue(
vi.spyOn(Jotai, 'useAtomValue').mockReturnValue(
normalizeOptions({
showExtensions: true,
}),

View File

@ -1,6 +1,6 @@
import { render } from '@testing-library/react';
import { FieldDetail } from '../FieldDetail';
import { FieldDetail } from '../FieldDetail.js';
describe('FieldDetail', () => {
const wrapper = (label: string, value?: string) =>

View File

@ -1,27 +1,31 @@
import { render, screen } from '@testing-library/react';
import * as Jotai from 'jotai';
import { BrowserRouter } from 'react-router-dom';
import type { OperationModel } from '../../../models';
import type { OperationModel } from '../../../models/index.js';
import { normalizeOptions, OpenAPIParser } from '../../../services';
import { FieldDetails } from '../FieldDetails';
import { getField } from '../../../models';
import { normalizeOptions, OpenAPIParser } from '../../../services/index.js';
import { FieldDetails } from '../FieldDetails.js';
import { getField } from '../../../models/index.js';
import spec from './fixtures/fields.json';
import { TestBrowserRouter } from '../../../testProviders.js';
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: jest.fn(),
vi.mock('jotai', async () => ({
...(await vi.importActual('jotai')),
useAtomValue: vi.fn(),
}));
describe('FieldDetails', () => {
const options = normalizeOptions({});
const parser = new OpenAPIParser(spec, undefined, options);
const parser = new OpenAPIParser(
spec as unknown as ConstructorParameters<typeof OpenAPIParser>[0],
undefined,
options,
);
const deps = { operation: { pointer: 'defaultPointer' } as OperationModel };
jest.spyOn(Jotai, 'useAtomValue').mockReturnValue(options);
vi.spyOn(Jotai, 'useAtomValue').mockReturnValue(options);
it('should render FieldDetails with enum', async () => {
options.showAccessMode = true;
(options as unknown as Record<string, unknown>).showAccessMode = true;
const field = getField(
parser,
{
@ -39,7 +43,7 @@ describe('FieldDetails', () => {
options,
deps,
);
render(<FieldDetails field={field} />, { wrapper: BrowserRouter });
render(<FieldDetails field={field} />, { wrapper: TestBrowserRouter });
expect(screen.getByText('Enum')).toBeInTheDocument();
expect(screen.getByText('"foo"')).toBeInTheDocument();
@ -64,7 +68,7 @@ describe('FieldDetails', () => {
options,
deps,
);
render(<FieldDetails field={field} />, { wrapper: BrowserRouter });
render(<FieldDetails field={field} />, { wrapper: TestBrowserRouter });
expect(screen.getByText('Enum Value')).toBeInTheDocument();
expect(screen.getByText('foo')).toBeInTheDocument();
@ -74,9 +78,9 @@ describe('FieldDetails', () => {
});
it('should render FieldDetails with enum and x-enumDescription with hiding value', async () => {
jest
.spyOn(Jotai, 'useAtomValue')
.mockReturnValue(normalizeOptions({ maxDisplayedEnumValues: 1 }));
vi.spyOn(Jotai, 'useAtomValue').mockReturnValue(
normalizeOptions({ maxDisplayedEnumValues: 1 }),
);
const field = getField(
parser,
{
@ -94,7 +98,7 @@ describe('FieldDetails', () => {
options,
deps,
);
render(<FieldDetails field={field} />, { wrapper: BrowserRouter });
render(<FieldDetails field={field} />, { wrapper: TestBrowserRouter });
expect(screen.getByText('Value')).toBeInTheDocument();
expect(screen.getByText('foo')).toBeInTheDocument();
@ -120,7 +124,7 @@ describe('FieldDetails', () => {
deps,
);
render(<FieldDetails field={field} />, { wrapper: BrowserRouter });
render(<FieldDetails field={field} />, { wrapper: TestBrowserRouter });
const descriptions = screen.queryAllByText('This is a test anyOf schema with description');
expect(descriptions).toHaveLength(0);

View File

@ -1,24 +1,28 @@
import { render, fireEvent, screen, act } from '@testing-library/react';
import * as Jotai from 'jotai';
import { BrowserRouter } from 'react-router-dom';
import type { OperationModel } from '../../../models';
import type { OperationModel } from '../../../models/index.js';
import { PropertyDetails } from '../PropertyDetails';
import { normalizeOptions, OpenAPIParser } from '../../../services';
import { getField } from '../../../models';
import { PropertyDetails } from '../PropertyDetails.js';
import { normalizeOptions, OpenAPIParser } from '../../../services/index.js';
import { getField } from '../../../models/index.js';
import spec from './fixtures/fields.json';
import { TestBrowserRouter } from '../../../testProviders.js';
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: jest.fn(),
vi.mock('jotai', async () => ({
...(await vi.importActual('jotai')),
useAtomValue: vi.fn(),
}));
describe('Components', () => {
const options = normalizeOptions({});
const parser = new OpenAPIParser(spec, undefined, options);
const parser = new OpenAPIParser(
spec as unknown as ConstructorParameters<typeof OpenAPIParser>[0],
undefined,
options,
);
const deps = { operation: { pointer: 'defaultPointer' } as OperationModel };
jest.spyOn(Jotai, 'useAtomValue').mockReturnValue(options);
vi.spyOn(Jotai, 'useAtomValue').mockReturnValue(options);
it('should render FieldDetails without duplicate content', () => {
const field = getField(
@ -31,7 +35,7 @@ describe('Components', () => {
deps,
);
render(<PropertyDetails field={field} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
const occurrences = screen.getAllByText('any');
@ -49,23 +53,20 @@ describe('Components', () => {
deps,
);
const { getAllByText, getByRole, queryByText, getByText } = render(
<PropertyDetails field={field} />,
{
wrapper: BrowserRouter,
},
);
const { getAllByText, queryByText, getByText } = render(<PropertyDetails field={field} />, {
wrapper: TestBrowserRouter,
});
expect(queryByText('Show 2 properties')).not.toBeNull();
act(() => {
queryByText('Show 2 properties').click();
queryByText('Show 2 properties')?.click();
});
expect(getByText('status')).toBeInTheDocument();
expect(getByText('name')).toBeInTheDocument();
expect(getAllByText('string').length).toBe(2);
act(() => {
fireEvent.click(getByRole('button'));
fireEvent.click(getByText('-'));
});
expect(getAllByText('Show 2 properties').length).toBe(1);
@ -84,7 +85,7 @@ describe('Components', () => {
);
const { getByText } = render(<PropertyDetails field={field} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
expect(getByText('Array of strings')).toBeInTheDocument();
});
@ -101,17 +102,14 @@ describe('Components', () => {
deps,
);
const { getByText, getByRole, container } = render(
<PropertyDetails field={field} level={2} />,
{
wrapper: BrowserRouter,
},
);
const { getByText, container } = render(<PropertyDetails field={field} level={2} />, {
wrapper: TestBrowserRouter,
});
expect(getByText('Array of objects')).toBeInTheDocument();
expect(getByText('Show 2 array properties')).toBeInTheDocument();
act(() => {
fireEvent.click(getByRole('button'));
fireEvent.click(getByText('Show 2 array properties'));
});
expect(getByText('status')).toBeInTheDocument();
expect(getByText('name')).toBeInTheDocument();
@ -131,16 +129,13 @@ describe('Components', () => {
deps,
);
const { getByText, getByRole, container } = render(
<PropertyDetails field={field} level={2} />,
{
wrapper: BrowserRouter,
},
);
const { getByText, container } = render(<PropertyDetails field={field} level={2} />, {
wrapper: TestBrowserRouter,
});
expect(getByText('Show 2 properties')).toBeInTheDocument();
act(() => {
fireEvent.click(getByRole('button'));
fireEvent.click(getByText('Show 2 properties'));
});
expect(getByText('status')).toBeInTheDocument();
expect(getByText('name')).toBeInTheDocument();
@ -160,16 +155,13 @@ describe('Components', () => {
deps,
);
const { getByText, getByRole, container } = render(
<PropertyDetails field={field} level={2} />,
{
wrapper: BrowserRouter,
},
);
const { getByText, container } = render(<PropertyDetails field={field} level={2} />, {
wrapper: TestBrowserRouter,
});
expect(getByText('Show 2 properties')).toBeInTheDocument();
act(() => {
fireEvent.click(getByRole('button'));
fireEvent.click(getByText('Show 2 properties'));
});
expect(getByText('status')).toBeInTheDocument();
expect(getByText('name')).toBeInTheDocument();
@ -188,7 +180,7 @@ describe('Components', () => {
deps,
);
const { container } = render(<PropertyDetails field={field} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
expect(container).toHaveTextContent(/read-only/);
});
@ -203,7 +195,7 @@ describe('Components', () => {
deps,
);
const { container } = render(<PropertyDetails field={field} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
expect(container).toHaveTextContent(/write-only/);
@ -211,7 +203,7 @@ describe('Components', () => {
describe('PropertyDetails', () => {
it('PropertyDetails with read-only should renders correctly', () => {
options.showAccessMode = true;
(options as unknown as Record<string, unknown>).showAccessMode = true;
const field = getField(
parser,
{
@ -222,13 +214,13 @@ describe('Components', () => {
deps,
);
const { container } = render(<PropertyDetails field={field} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
expect(container).toHaveTextContent(/read-only/);
});
it('Field with write-only should renders correctly', () => {
options.showAccessMode = true;
(options as unknown as Record<string, unknown>).showAccessMode = true;
const field = getField(
parser,
{
@ -239,14 +231,14 @@ describe('Components', () => {
deps,
);
const { container } = render(<PropertyDetails field={field} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
expect(container).toHaveTextContent(/write-only/);
});
it('Fields with read-only and write-only in content should renders correctly', async () => {
options.showAccessMode = true;
(options as unknown as Record<string, unknown>).showAccessMode = true;
const field = getField(
parser,
{
@ -265,9 +257,9 @@ describe('Components', () => {
deps,
);
const { container } = render(<PropertyDetails field={field} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
fireEvent.click(await screen.findByRole('button'));
fireEvent.click(await screen.getByText('Show 2 properties'));
expect(container).toHaveTextContent(/read-only/);
});
});

View File

@ -1,4 +1,4 @@
import { cycleColorsByLevel } from '../cycleColorsByLevel';
import { cycleColorsByLevel } from '../cycleColorsByLevel.js';
describe('fieldColorByLevel', () => {
it('returns the correct color for a given level', () => {

View File

@ -4,6 +4,7 @@
"version": "1.0",
"title": "Foo"
},
"paths": {},
"components": {
"primitiveSchema": {
"schema": {

View File

@ -127,7 +127,11 @@ export const StoreProvider = memo(
store.set(globalStoreAtom, globalStoreValue);
store.sub(appStore, () => undefined); // force mount so init data is read from storage
store.set(layoutAtom, globalStoreValue.options?.layout || LayoutVariant.THREE_PANEL); // set layout atom to update appStore atom with correct layout value
// Set layout from withState (portal) or options (local), with correct priority
store.set(
layoutAtom,
withState?.layout || globalStoreValue.options?.layout || LayoutVariant.THREE_PANEL,
); // set layout atom to update appStore atom with correct layout value
store.set(routerAtom, router || 'hash');
store.set(disableTelemetryAtom, disableTelemetry || false);
store.set(telemetryAtom, { data: undefined, status: 'initializing' });

View File

@ -1,8 +1,8 @@
import { render } from '@testing-library/react';
import type { OpenAPIDefinition } from '../../../types';
import type { OpenAPIDefinition } from '../../../types/index.js';
import { StoreProvider } from '../Providers';
import { StoreProvider } from '../Providers.js';
describe('ProStoreProvider', () => {
test('StoreProvider should run onInit hook', async () => {

View File

@ -1,15 +1,42 @@
import type { Mock } from 'vitest';
import { parseYaml } from '@redocly/openapi-core';
import { readFileSync } from 'fs';
import { render } from '@testing-library/react';
import { render, waitFor } from '@testing-library/react';
import { useSetAtom } from 'jotai';
import type { ReactNode, ComponentType } from 'react';
import type { RedocConfig } from '@redocly/config';
import type { OpenAPIDefinition } from '../../../types';
import type { OpenAPIDefinition } from '../../../types/index.js';
import { useTelemetry } from '../../../hooks/index.js';
import { useTelemetry } from '../../../hooks';
vi.mock('../../../hooks/useTelemetry');
jest.mock('../../../hooks/useTelemetry');
vi.mock('@redocly/theme/core/openapi', async () => {
const actual = await vi.importActual('@redocly/theme/core/openapi');
return {
...actual,
getOperationColor: vi.fn(() => '#007bff'),
SearchSessionProvider: ({ children }: { children: ReactNode }) => {
const mockContextValue = {
searchSessionId: 'test-session-id',
refreshSearchSessionId: vi.fn(),
};
const Provider = (actual as { SearchSessionContext: { Provider: ComponentType<any> } })
.SearchSessionContext.Provider;
return <Provider value={mockContextValue}>{children}</Provider>;
},
};
});
import { RedoclyOpenAPIDocs } from '../RedoclyOpenAPIDocs';
vi.mock('jotai', async () => {
const originalJotai = await vi.importActual('jotai');
return {
...(originalJotai as Record<string, unknown>),
useSetAtom: vi.fn(),
};
});
import { RedoclyOpenAPIDocs } from '../RedoclyOpenAPIDocs.js';
const contents = readFileSync('./playground/openapi/petstore.yaml', 'utf8');
const definition = parseYaml(contents) as OpenAPIDefinition;
@ -17,22 +44,25 @@ const definition = parseYaml(contents) as OpenAPIDefinition;
describe('RedoclyOpenAPIDocs', () => {
const options = {} as RedocConfig;
const store = { definition, options };
window.scrollTo = jest.fn();
window.scrollTo = vi.fn();
const mockTelemetrySend = jest.fn() as Mock;
const mockTelemetrySend = vi.fn();
beforeEach(() => {
jest.clearAllMocks();
(useTelemetry as jest.Mock).mockImplementation(() => ({
sendTestEvent: mockTelemetrySend,
vi.clearAllMocks();
(useTelemetry as Mock).mockImplementation(() => ({
sendViewedMessage: mockTelemetrySend,
}));
(useSetAtom as Mock).mockReturnValue(vi.fn());
});
describe('onLoaded callback', () => {
it('should call callback on mount', () => {
const onLoaded = jest.fn();
it('should call callback on mount', async () => {
const onLoaded = vi.fn();
render(<RedoclyOpenAPIDocs store={store} onLoaded={onLoaded} />);
expect(onLoaded).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(onLoaded).toHaveBeenCalledTimes(1);
});
});
});
});

View File

@ -2,7 +2,7 @@
import { render } from '@testing-library/react';
import { RenderHook } from '../RenderHook';
import { RenderHook } from '../RenderHook.js';
describe('RenderHook', () => {
it('should render correctly', () => {

View File

@ -18,17 +18,24 @@ function ExampleComponent({ mediaType, mediaContent, renderSample }: ExampleProp
const telemetry = useTelemetry();
const pointer = mediaType.operation.pointer;
const [operation, setOperation] = useAtom(operationStore(pointer));
const examples = mediaType.examples || {};
const examples = mediaType.examples || mediaType.formExamples || {};
const examplesKeys = Object.keys(examples);
const setActivateExampleName = useActivateExample(mediaContent);
const { exampleKey } = useExampleKey(mediaType.operation, examples);
const { exampleKey: activeExampleKey } = useExampleKey(mediaType.operation, examples);
const exampleKey =
examplesKeys[
operation.activeOneOf[mediaType?.examplesPointer || mediaType.operation.pointer]
] || activeExampleKey;
if (!examplesKeys.length || examplesKeys.length === 1) {
return renderSample();
}
const handleExampleChange = (key: string) => {
const pointer =
mediaType.examplesPointer && mediaType.examplesPointer.startsWith(mediaType.operation.pointer)
? mediaType.examplesPointer
: mediaType.operation.pointer;
telemetry.sendExamplesSwitcherClickedMessage({
exampleNumber: examplesKeys.indexOf(key),
totalExamples: examplesKeys.length,
@ -36,7 +43,7 @@ function ExampleComponent({ mediaType, mediaContent, renderSample }: ExampleProp
setOperation({
...operation,
activeExampleName: key,
activeOneOf: { [pointer]: examplesKeys.indexOf(key) },
activeOneOf: { ...operation.activeOneOf, [pointer]: examplesKeys.indexOf(key) },
requestValues: { body: null },
});
setActivateExampleName(key);

View File

@ -0,0 +1,567 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { MediaContentModel, MediaTypeModel } from '../../../models/index.js';
import type { ExampleModelsMap } from '../../Samples/index.js';
import { Example } from '../Example.js';
import { withTestProviders } from '../../../testProviders.js';
// Mock the dependencies
vi.mock('../../Samples', () => ({
ExampleSwitch: ({
examples,
exampleKey,
onChange,
}: {
examples: ExampleModelsMap;
exampleKey?: string;
onChange: (key: string) => void;
}) => (
<div data-testid="example-switch">
<select
data-testid="example-selector"
value={exampleKey}
onChange={(e): void => onChange(e.target.value)}
>
{Object.keys(examples).map((key) => (
<option key={key} value={key}>
{key}
</option>
))}
</select>
</div>
),
useExampleKey: vi.fn(() => ({ exampleKey: 'bee' })),
}));
import { useTelemetry, useActivateExample } from '../../../hooks/index.js';
import { useExampleKey } from '../../Samples/index.js';
vi.mock('../../../hooks', async () => ({
...(await vi.importActual('../../../hooks')),
useActivateExample: vi.fn(() => vi.fn()),
useTelemetry: vi.fn(() => ({
sendExamplesSwitcherClickedMessage: vi.fn(),
})),
}));
vi.mock('../../../jotai/operation', () => ({
operationStore: vi.fn(() => ({
toString: (): string => 'operationStore',
})),
}));
import { useAtom } from 'jotai';
const mockUseTelemetry = vi.mocked(useTelemetry);
const mockUseActivateExample = vi.mocked(useActivateExample);
const mockUseExampleKey = vi.mocked(useExampleKey);
// Mock jotai at module level
vi.mock('jotai', async () => {
const actual = await vi.importActual('jotai');
return {
...actual,
useAtom: vi.fn(),
};
});
const mockUseAtom = vi.mocked(useAtom);
describe('Example component', () => {
const mockMediaType: MediaTypeModel = {
name: 'application/json',
examples: {
bee: {
mime: 'application/json',
value: {
petType: 'bee',
honeyPerDay: 3.14,
},
rawValue: {
petType: 'bee',
honeyPerDay: 3.14,
},
},
},
schema: undefined,
isRequestType: true,
onlyRequiredInSamples: false,
operation: {
pointer: '/paths/~1pet/post',
} as MediaTypeModel['operation'],
};
const mockMediaContent = {
mediaTypes: [mockMediaType],
} as unknown as MediaContentModel;
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, 'log').mockImplementation(() => {});
mockUseAtom.mockReturnValue([
{
activeExampleName: 'bee',
activeOneOf: {},
requestValues: { body: null },
},
vi.fn(),
] as unknown as ReturnType<typeof useAtom>);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should render component with single example without ExampleSwitch', () => {
const renderSample = vi.fn(() => <div data-testid="sample">Sample</div>);
render(
withTestProviders(
<Example
mediaType={mockMediaType}
mediaContent={mockMediaContent}
renderSample={renderSample}
/>,
),
);
expect(renderSample).toHaveBeenCalledWith();
expect(screen.queryByTestId('example-switch')).not.toBeInTheDocument();
expect(screen.getByTestId('sample')).toBeInTheDocument();
});
it('should render component without examples', () => {
const noExamplesMediaType: MediaTypeModel = {
...mockMediaType,
examples: undefined,
formExamples: undefined,
};
const renderSample = vi.fn(() => <div data-testid="sample">Sample</div>);
render(
withTestProviders(
<Example
mediaType={noExamplesMediaType}
mediaContent={mockMediaContent}
renderSample={renderSample}
/>,
),
);
expect(renderSample).toHaveBeenCalledWith();
expect(screen.queryByTestId('example-switch')).not.toBeInTheDocument();
});
it('should render component with multiple examples and ExampleSwitch', () => {
const multipleExamplesMediaType: MediaTypeModel = {
...mockMediaType,
examples: {
bee: {
mime: 'application/json',
value: { petType: 'bee' },
rawValue: { petType: 'bee' },
},
cat: {
mime: 'application/json',
value: { petType: 'cat' },
rawValue: { petType: 'cat' },
},
},
};
const renderSample = vi.fn(() => <div data-testid="sample">Sample</div>);
render(
withTestProviders(
<Example
mediaType={multipleExamplesMediaType}
mediaContent={mockMediaContent}
renderSample={renderSample}
/>,
),
);
expect(screen.getByTestId('example-switch')).toBeInTheDocument();
expect(renderSample).toHaveBeenCalledWith('bee');
});
it('should handle example change correctly', async () => {
const user = userEvent.setup();
const setOperation = vi.fn();
const sendTelemetry = vi.fn();
const setActivateExampleName = vi.fn();
mockUseAtom.mockReturnValue([
{
activeExampleName: 'bee',
activeOneOf: {},
requestValues: { body: null },
},
setOperation,
] as unknown as ReturnType<typeof useAtom>);
mockUseTelemetry.mockReturnValue({
sendExamplesSwitcherClickedMessage: sendTelemetry,
} as unknown as ReturnType<typeof useTelemetry>);
mockUseActivateExample.mockReturnValue(setActivateExampleName);
const multipleExamplesMediaType: MediaTypeModel = {
...mockMediaType,
examples: {
bee: {
mime: 'application/json',
value: { petType: 'bee' },
rawValue: { petType: 'bee' },
},
cat: {
mime: 'application/json',
value: { petType: 'cat' },
rawValue: { petType: 'cat' },
},
},
};
const renderSample = vi.fn(() => <div data-testid="sample">Sample</div>);
render(
withTestProviders(
<Example
mediaType={multipleExamplesMediaType}
mediaContent={mockMediaContent}
renderSample={renderSample}
/>,
),
);
const selector = screen.getByTestId('example-selector');
await user.selectOptions(selector, 'cat');
await waitFor(() => {
expect(sendTelemetry).toHaveBeenCalledWith({
exampleNumber: 1,
totalExamples: 2,
});
expect(setOperation).toHaveBeenCalledWith(
expect.objectContaining({
activeExampleName: 'cat',
activeOneOf: expect.any(Object),
requestValues: { body: null },
}),
);
expect(setActivateExampleName).toHaveBeenCalledWith('cat');
});
});
it('should use formExamples when examples is undefined', () => {
const formExamplesMediaType: MediaTypeModel = {
...mockMediaType,
examples: undefined,
formExamples: {
example1: {
mime: 'application/x-www-form-urlencoded',
value: { field: 'value1' },
rawValue: { field: 'value1' },
},
example2: {
mime: 'application/x-www-form-urlencoded',
value: { field: 'value2' },
rawValue: { field: 'value2' },
},
},
};
const renderSample = vi.fn(() => <div data-testid="sample">Sample</div>);
render(
withTestProviders(
<Example
mediaType={formExamplesMediaType}
mediaContent={mockMediaContent}
renderSample={renderSample}
/>,
),
);
expect(screen.getByTestId('example-switch')).toBeInTheDocument();
expect(renderSample).toHaveBeenCalledWith('bee');
});
it('should memoize the component correctly', () => {
const multipleExamplesMediaType: MediaTypeModel = {
...mockMediaType,
examples: {
bee: {
mime: 'application/json',
value: { petType: 'bee' },
rawValue: { petType: 'bee' },
},
cat: {
mime: 'application/json',
value: { petType: 'cat' },
rawValue: { petType: 'cat' },
},
},
};
const renderSample = vi.fn(() => <div data-testid="sample">Sample</div>);
const { rerender } = render(
withTestProviders(
<Example
mediaType={multipleExamplesMediaType}
mediaContent={mockMediaContent}
renderSample={renderSample}
/>,
),
);
const initialCallCount = renderSample.mock.calls.length;
// Rerender with the same props
rerender(
withTestProviders(
<Example
mediaType={multipleExamplesMediaType}
mediaContent={mockMediaContent}
renderSample={renderSample}
/>,
),
);
// The renderSample should not be called again due to memoization
expect(renderSample.mock.calls.length).toBe(initialCallCount);
});
it('should use examplesPointer when provided for activeOneOf', () => {
const customPointer = '/paths/~1pet/post/requestBody/content/application~1json';
const multipleExamplesMediaType: MediaTypeModel = {
...mockMediaType,
examplesPointer: customPointer,
examples: {
bee: {
mime: 'application/json',
value: { petType: 'bee' },
rawValue: { petType: 'bee' },
},
cat: {
mime: 'application/json',
value: { petType: 'cat' },
rawValue: { petType: 'cat' },
},
},
};
mockUseAtom.mockReturnValue([
{
activeExampleName: 'cat',
activeOneOf: { [customPointer]: 1 },
requestValues: { body: null },
},
vi.fn(),
] as unknown as ReturnType<typeof useAtom>);
const renderSample = vi.fn(() => <div data-testid="sample">Sample</div>);
render(
withTestProviders(
<Example
mediaType={multipleExamplesMediaType}
mediaContent={mockMediaContent}
renderSample={renderSample}
/>,
),
);
expect(screen.getByTestId('example-switch')).toBeInTheDocument();
// Should render with 'cat' example since activeOneOf[customPointer] = 1
expect(renderSample).toHaveBeenCalledWith('cat');
});
it('should use examplesPointer in handleExampleChange when it starts with operation pointer', async () => {
const user = userEvent.setup();
const setOperation = vi.fn();
const setActivateExampleName = vi.fn();
const sendTelemetry = vi.fn();
const operationPointer = '/paths/~1pet/post';
const examplesPointer = '/paths/~1pet/post/requestBody/content/application~1json';
const multipleExamplesMediaType: MediaTypeModel = {
...mockMediaType,
operation: {
...mockMediaType.operation,
pointer: operationPointer,
} as MediaTypeModel['operation'],
examplesPointer,
examples: {
bee: {
mime: 'application/json',
value: { petType: 'bee' },
rawValue: { petType: 'bee' },
},
cat: {
mime: 'application/json',
value: { petType: 'cat' },
rawValue: { petType: 'cat' },
},
},
};
mockUseAtom.mockReturnValue([
{
activeExampleName: 'bee',
activeOneOf: {},
requestValues: { body: null },
},
setOperation,
] as unknown as ReturnType<typeof useAtom>);
mockUseTelemetry.mockReturnValue({
sendExamplesSwitcherClickedMessage: sendTelemetry,
} as unknown as ReturnType<typeof useTelemetry>);
mockUseActivateExample.mockReturnValue(setActivateExampleName);
const renderSample = vi.fn(() => <div data-testid="sample">Sample</div>);
render(
withTestProviders(
<Example
mediaType={multipleExamplesMediaType}
mediaContent={mockMediaContent}
renderSample={renderSample}
/>,
),
);
const selector = screen.getByTestId('example-selector');
await user.selectOptions(selector, 'cat');
await waitFor(() => {
expect(setOperation).toHaveBeenCalledWith(
expect.objectContaining({
activeExampleName: 'cat',
activeOneOf: { [examplesPointer]: 1 },
requestValues: { body: null },
}),
);
});
});
it('should use operation pointer in handleExampleChange when examplesPointer does not start with operation pointer', async () => {
const user = userEvent.setup();
const setOperation = vi.fn();
const setActivateExampleName = vi.fn();
const sendTelemetry = vi.fn();
const operationPointer = '/paths/~1pet/post';
const examplesPointer = '/different/pointer';
const multipleExamplesMediaType: MediaTypeModel = {
...mockMediaType,
operation: {
...mockMediaType.operation,
pointer: operationPointer,
} as MediaTypeModel['operation'],
examplesPointer,
examples: {
bee: {
mime: 'application/json',
value: { petType: 'bee' },
rawValue: { petType: 'bee' },
},
cat: {
mime: 'application/json',
value: { petType: 'cat' },
rawValue: { petType: 'cat' },
},
},
};
mockUseAtom.mockReturnValue([
{
activeExampleName: 'bee',
activeOneOf: {},
requestValues: { body: null },
},
setOperation,
] as unknown as ReturnType<typeof useAtom>);
mockUseTelemetry.mockReturnValue({
sendExamplesSwitcherClickedMessage: sendTelemetry,
} as unknown as ReturnType<typeof useTelemetry>);
mockUseActivateExample.mockReturnValue(setActivateExampleName);
const renderSample = vi.fn(() => <div data-testid="sample">Sample</div>);
render(
withTestProviders(
<Example
mediaType={multipleExamplesMediaType}
mediaContent={mockMediaContent}
renderSample={renderSample}
/>,
),
);
const selector = screen.getByTestId('example-selector');
await user.selectOptions(selector, 'cat');
await waitFor(() => {
// Should use operation pointer instead of examplesPointer
expect(setOperation).toHaveBeenCalledWith(
expect.objectContaining({
activeExampleName: 'cat',
activeOneOf: { [operationPointer]: 1 },
requestValues: { body: null },
}),
);
});
});
it('should fallback to activeExampleKey from useExampleKey when activeOneOf is empty', () => {
mockUseExampleKey.mockReturnValue({ exampleKey: 'cat' });
const multipleExamplesMediaType: MediaTypeModel = {
...mockMediaType,
examples: {
bee: {
mime: 'application/json',
value: { petType: 'bee' },
rawValue: { petType: 'bee' },
},
cat: {
mime: 'application/json',
value: { petType: 'cat' },
rawValue: { petType: 'cat' },
},
},
};
mockUseAtom.mockReturnValue([
{
activeExampleName: undefined,
activeOneOf: {},
requestValues: { body: null },
},
vi.fn(),
] as unknown as ReturnType<typeof useAtom>);
const renderSample = vi.fn(() => <div data-testid="sample">Sample</div>);
render(
withTestProviders(
<Example
mediaType={multipleExamplesMediaType}
mediaContent={mockMediaContent}
renderSample={renderSample}
/>,
),
);
// Should use 'cat' from useExampleKey
expect(renderSample).toHaveBeenCalledWith('cat');
});
});

View File

@ -1,43 +1,55 @@
import { render, waitFor } from '@testing-library/react';
import * as Jotai from 'jotai/index';
import type { OpenAPIDefinition } from '../../../types';
import type { OperationModel } from '../../../models';
import type { OpenAPIDefinition } from '../../../types/index.js';
import type { OperationModel } from '../../../models/index.js';
import { RequestSamples } from '../RequestSamples';
import { normalizeOptions, OpenAPIParser } from '../../../services';
import { getMediaContent, getOperation } from '../../../models';
import { withTestProviders } from '../../../testProviders';
import * as useCodeSamplesModule from '../useCodeSamples.js';
import { RequestSamples } from '../RequestSamples.js';
import { normalizeOptions, OpenAPIParser } from '../../../services/index.js';
import { getMediaContent, getOperation } from '../../../models/index.js';
import { withTestProviders } from '../../../testProviders.js';
import petStore from './fixtures/petstore.json';
import type { ExtendedOpenAPIOperation } from '../../../services/index.js';
import definition from './fixtures/operationDefinition.json';
import {
languageAtom,
} from '../../../jotai/app';
import { globalStoreAtom } from '../../../jotai/store';
} from '../../../jotai/app.js';
import { globalStoreAtom } from '../../../jotai/store.js';
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: jest.fn((atom) => {
if (atom === languageAtom) {
return {
activeLanguage: 'python',
languages: ['python', 'javascript'],
};
}
return jest.requireActual('jotai').useAtomValue(atom);
}),
useAtom: jest.fn((atom) => {
if (atom === languageAtom) {
return [
{
vi.mock('jotai', async () => {
const actual = await vi.importActual<typeof Jotai>('jotai');
return {
...actual,
useAtomValue: vi.fn((atom) => {
if (atom === languageAtom) {
return {
activeLanguage: 'python',
languages: ['python', 'javascript'],
},
jest.fn(),
];
}
return jest.requireActual('jotai').useAtom(atom);
}),
};
}
return actual.useAtomValue(atom);
}),
useAtom: vi.fn((atom) => {
if (atom === languageAtom) {
return [
{
activeLanguage: 'python',
languages: ['python', 'javascript'],
},
vi.fn(),
];
}
return actual.useAtom(atom);
}),
};
});
vi.mock('@redocly/theme/core/openapi', async () => ({
...(await vi.importActual('@redocly/theme/core/openapi')),
getOperationColor: vi.fn(() => 'blue'),
}));
describe('Components', () => {
@ -45,7 +57,7 @@ describe('Components', () => {
let operation: OperationModel;
const options = normalizeOptions({});
const parser = new OpenAPIParser(petStore as OpenAPIDefinition, undefined, options);
const parser = new OpenAPIParser(petStore as unknown as OpenAPIDefinition, undefined, options);
const info = {
'application/json': {
@ -92,7 +104,13 @@ describe('Components', () => {
},
};
test('should renders correctly without mimeType selector', () => {
operation = getOperation(parser, definition, undefined, options, '');
operation = getOperation(
parser,
definition as unknown as ExtendedOpenAPIOperation,
undefined,
options,
'',
);
// @ts-ignore
operation.requestBody.content.mediaTypes = operation.requestBody?.content?.mediaTypes.filter(
(m) => m.name === 'application/json',
@ -118,20 +136,20 @@ describe('Components', () => {
});
test('should renders correctly with empty languages', async () => {
jest.spyOn(Jotai, 'useAtom').mockImplementation((atom) => {
vi.spyOn(Jotai, 'useAtom').mockImplementation((atom) => {
if (atom === languageAtom) {
return [
{
activeLanguage: undefined,
languages: [],
},
jest.fn(),
];
vi.fn(),
] as unknown as ReturnType<typeof Jotai.useAtom>;
}
return jest.requireActual('jotai').useAtom(atom);
return [{}, vi.fn()] as unknown as ReturnType<typeof Jotai.useAtom>;
});
jest.spyOn(Jotai, 'useAtomValue').mockImplementation((atom) => {
vi.spyOn(Jotai, 'useAtomValue').mockImplementation((atom) => {
if (atom === languageAtom) {
return {
activeLanguage: undefined,
@ -143,7 +161,7 @@ describe('Components', () => {
return {};
});
jest.spyOn(require('../useCodeSamples'), 'useCodeSamples').mockReturnValue({
vi.spyOn(useCodeSamplesModule, 'useCodeSamples').mockReturnValue({
samples: [], // Empty samples array
});
@ -153,7 +171,13 @@ describe('Components', () => {
},
});
operation = getOperation(parser, definition, undefined, optionsWithEmptyLanguages, '');
operation = getOperation(
parser,
definition as unknown as ExtendedOpenAPIOperation,
undefined,
optionsWithEmptyLanguages,
'',
);
const PROPS = {
operation,
content: getMediaContent({

View File

@ -54,5 +54,6 @@
}
],
"pathServers": null,
"isWebhook": false
"isWebhook": false,
"isAdditionalOperation": false
}

View File

@ -1,37 +1,45 @@
import { jest, describe, test, expect } from '@jest/globals';
import { vi } from 'vitest';
import * as Jotai from 'jotai/index';
import type { OpenAPIDefinition } from '../../../types';
import type { OpenAPIDefinition } from '../../../types/index.js';
import { getOperation } from '../../../models/index.js';
import type { ExtendedOpenAPIOperation } from '../../../services/index.js';
import { getOperation } from '../../../models';
import definition from './fixtures/operationDefinition.json';
import { useCodeSamples } from '../useCodeSamples';
import { replaceCircularJson } from '../../../models/__tests__/helpers';
import { normalizeOptions, OpenAPIParser } from '../../../services';
import { useCodeSamples } from '../useCodeSamples.js';
import { replaceCircularJson } from '../../../models/__tests__/helpers.js';
import { normalizeOptions, OpenAPIParser } from '../../../services/index.js';
import petStore from './fixtures/petstore.json';
import { globalStoreAtom } from '../../../jotai/store';
import { globalStoreAtom } from '../../../jotai/store.js';
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: jest.fn(),
vi.mock('jotai', async () => ({
...(await vi.importActual('jotai')),
useAtomValue: vi.fn(),
}));
jest.mock('../../../hooks/useTranslate', () => ({
useTranslate: jest.fn(),
vi.mock('../../../hooks/useTranslate', () => ({
useTranslate: vi.fn(),
}));
jest.mock('@redocly/theme/ext/configure', () => ({
vi.mock('@redocly/theme/ext/configure', () => ({
__esModule: true,
configure: jest.fn(),
configure: vi.fn(),
}));
describe('useCodeSamples', () => {
const options = normalizeOptions({});
const parser = new OpenAPIParser(petStore as OpenAPIDefinition, undefined, options);
const parser = new OpenAPIParser(petStore as unknown as OpenAPIDefinition, undefined, options);
test('should return all codeSamples', () => {
const operation = getOperation(parser, definition, undefined, options, '');
jest.spyOn(Jotai, 'useAtomValue').mockImplementation((atom) => {
const operation = getOperation(
parser,
definition as unknown as ExtendedOpenAPIOperation,
undefined,
options,
'',
);
vi.spyOn(Jotai, 'useAtomValue').mockImplementation((atom) => {
if (atom === globalStoreAtom) return { options, parser };
return {};
});
@ -50,8 +58,14 @@ describe('useCodeSamples', () => {
},
});
const operation = getOperation(parser, definition, undefined, options, '');
jest.spyOn(Jotai, 'useAtomValue').mockImplementation((atom) => {
const operation = getOperation(
parser,
definition as unknown as ExtendedOpenAPIOperation,
undefined,
options,
'',
);
vi.spyOn(Jotai, 'useAtomValue').mockImplementation((atom) => {
if (atom === globalStoreAtom) return { options, parser };
return {};
});

View File

@ -1,13 +1,15 @@
import { render } from '@testing-library/react';
import type { OpenAPIDefinition } from '../../../types';
import type { OperationModel } from '../../../models';
import type { OpenAPIDefinition } from '../../../types/index.js';
import type { OperationModel } from '../../../models/index.js';
import { ResponseSamples } from '../ResponseSamples';
import { normalizeOptions, OpenAPIParser } from '../../../services';
import { getOperation } from '../../../models';
import { withTestProviders } from '../../../testProviders';
import { ResponseSamples } from '../ResponseSamples.js';
import { normalizeOptions, OpenAPIParser } from '../../../services/index.js';
import { getOperation } from '../../../models/index.js';
import { withTestProviders } from '../../../testProviders.js';
import museum from './fixtures/museum.json';
import type { ExtendedOpenAPIOperation } from '../../../services/index.js';
import definitionWithContent from './fixtures/operationDefinitionWithContent.json';
import definitionWithoutContent from './fixtures/operationDefinitionWithoutContent.json';
@ -15,14 +17,20 @@ describe('ResponseSamples', () => {
let operation: OperationModel;
const options = normalizeOptions({});
const parser = new OpenAPIParser(museum as OpenAPIDefinition, undefined, options);
const parser = new OpenAPIParser(museum as unknown as OpenAPIDefinition, undefined, options);
it('renders PayloadSamples when response has a sample', () => {
operation = getOperation(parser, definitionWithContent, undefined, options, '');
operation = getOperation(
parser,
definitionWithContent as unknown as ExtendedOpenAPIOperation,
undefined,
options,
'',
);
const { container } = render(
withTestProviders(
<ResponseSamples operation={operation} activeResponseTab="200" onTabChange={jest.fn()} />,
<ResponseSamples operation={operation} activeResponseTab="200" onTabChange={vi.fn()} />,
{
definition: parser.definition,
},
@ -33,11 +41,17 @@ describe('ResponseSamples', () => {
});
it('renders StyledCodeBlock when response does not have a sample', () => {
operation = getOperation(parser, definitionWithoutContent, undefined, options, '');
operation = getOperation(
parser,
definitionWithoutContent as unknown as ExtendedOpenAPIOperation,
undefined,
options,
'',
);
const { container } = render(
withTestProviders(
<ResponseSamples operation={operation} activeResponseTab="200" onTabChange={jest.fn()} />,
<ResponseSamples operation={operation} activeResponseTab="200" onTabChange={vi.fn()} />,
{
definition: parser.definition,
},

View File

@ -25,5 +25,12 @@
}
}
}
}
},
"pointer": "#/paths/~1museum-hours/get",
"pathName": "/museum-hours",
"httpVerb": "get",
"pathParameters": [],
"pathServers": [],
"isWebhook": false,
"isAdditionalOperation": false
}

View File

@ -13,5 +13,12 @@
}
}
}
}
},
"pointer": "#/paths/~1museum-hours/get",
"pathName": "/museum-hours",
"httpVerb": "get",
"pathParameters": [],
"pathServers": [],
"isWebhook": false,
"isAdditionalOperation": false
}

View File

@ -1,14 +1,14 @@
import { render } from '@testing-library/react';
import * as Jotai from 'jotai';
import { MemoryRouter } from 'react-router-dom';
import type { OperationModel } from '../../../models';
import type { OperationModel } from '../../../models/index.js';
import { ResponseDetails } from '../ResponseDetails';
import { normalizeOptions, OpenAPIParser } from '../../../services';
import { getField, getMediaContent } from '../../../models';
import { globalOptionsAtom, globalStoreAtom } from '../../../jotai/store';
import { activeMimeNameAtom } from '../../../jotai/app';
import { ResponseDetails } from '../ResponseDetails.js';
import { normalizeOptions, OpenAPIParser } from '../../../services/index.js';
import { getField, getMediaContent } from '../../../models/index.js';
import { globalOptionsAtom, globalStoreAtom } from '../../../jotai/store.js';
import { activeMimeNameAtom } from '../../../jotai/app.js';
import { TestMemoryRouter } from '../../../testProviders.js';
const info = {
'application/json': {
@ -86,8 +86,8 @@ const response = {
{ operation: {} as OperationModel },
),
],
toggle: jest.fn(),
expand: jest.fn(),
toggle: vi.fn(),
expand: vi.fn(),
content: getMediaContent({
parser,
info,
@ -97,27 +97,27 @@ const response = {
}),
};
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: jest.fn(),
vi.mock('jotai', async () => ({
...(await vi.importActual('jotai')),
useAtomValue: vi.fn(),
}));
describe('ResponseDetails component', () => {
jest.spyOn(Jotai, 'useAtomValue').mockImplementation((atom) => {
vi.spyOn(Jotai, 'useAtomValue').mockImplementation((atom) => {
if (atom === globalOptionsAtom) {
return options;
}
if (atom === globalStoreAtom) return { options, parser };
if (atom === activeMimeNameAtom) return ['application/json', jest.fn()];
if (atom === activeMimeNameAtom) return ['application/json', vi.fn()];
return {};
});
it('should renders correctly', () => {
const { container } = render(
<MemoryRouter>
<ResponseDetails response={response} operationPointer="" operationId=" " />
</MemoryRouter>,
<TestMemoryRouter>
<ResponseDetails response={response} operationId=" " />
</TestMemoryRouter>,
);
expect(container.childNodes.length).toEqual(5);
});
@ -125,13 +125,9 @@ describe('ResponseDetails component', () => {
it('should render description as markdown', () => {
const expectedTags = ['p'];
const { container } = render(
<MemoryRouter>
<ResponseDetails
response={{ ...response, headers: [] }}
operationPointer=""
operationId=""
/>
</MemoryRouter>,
<TestMemoryRouter>
<ResponseDetails response={{ ...response, headers: [] }} operationId="" />
</TestMemoryRouter>,
);
const markdownContainer = container.querySelector('.redoc-markdown');

View File

@ -1,19 +1,19 @@
import { render } from '@testing-library/react';
import { ResponseHeaders } from '../ResponseHeaders';
import { PropertiesTable } from '../../common';
import { ResponseHeaders } from '../ResponseHeaders.js';
import { PropertiesTable } from '../../common/index.js';
jest.mock('../../common', () => ({
PropertiesTable: jest.fn(() => <div data-testid="properties-table" />),
vi.mock('../../common', () => ({
PropertiesTable: vi.fn(() => <div data-testid="properties-table" />),
}));
jest.mock('../../PropertyDetails', () => ({
Field: jest.fn(() => <div data-testid="property-details" />),
vi.mock('../../PropertyDetails', () => ({
Field: vi.fn(() => <div data-testid="property-details" />),
}));
describe('ResponseHeaders', () => {
afterEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
});
test('renders nothing if headers are not defined', () => {

View File

@ -32,7 +32,21 @@ function OneOfSchemaComponent({
const [store, setOperationStore] = useAtom(operationStore(schema.operationPointer));
const oneOfIdxLocation = useOneOfLocationIdx(oneOf, oneOfLevel);
const activeOneOfIdx = oneOfIdxLocation === -1 ? 0 : oneOfIdxLocation;
const options = oneOf.map((subSchema, idx) => ({
label: subSchema.title || subSchema.typePrefix + subSchema.displayType,
value: idx,
}));
const activeExampleNameIndex = options.findIndex(
(option) => option.label === store.activeExampleName,
);
const activeOneOfIdx =
oneOfIdxLocation === -1
? activeExampleNameIndex !== -1
? activeExampleNameIndex
: 0
: oneOfIdxLocation;
const activeSchemaIndex =
store.activeOneOf?.[schema.pointer] !== undefined
? store.activeOneOf[schema.pointer]
@ -43,11 +57,6 @@ function OneOfSchemaComponent({
return null;
}
const options = oneOf.map((subSchema, idx) => ({
label: subSchema.title || subSchema.typePrefix + subSchema.displayType,
value: idx,
}));
const handleChange = (value: number) => {
onChange?.({
pointer: schema.pointer,

View File

@ -1,27 +1,27 @@
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import * as Jotai from 'jotai';
import type { OperationModel } from '../../../models';
import type { OpenAPIDefinition } from '../../../types';
import type { OperationModel } from '../../../models/index.js';
import type { OpenAPIDefinition } from '../../../types/index.js';
import { normalizeOptions, OpenAPIParser } from '../../../services';
import { getSchema } from '../../../models';
import { normalizeOptions, OpenAPIParser } from '../../../services/index.js';
import { getSchema } from '../../../models/index.js';
import schemasExpansionLevel from './fixtures/schemaExpansionLevel.json';
import { Schema } from '../Schema';
import { Schema } from '../Schema.js';
import { TestBrowserRouter } from '../../../testProviders.js';
const options = normalizeOptions({});
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: jest.fn(),
vi.mock('jotai', async () => ({
...(await vi.importActual('jotai')),
useAtomValue: vi.fn(),
}));
describe('ArraySchema', () => {
it("should render Schema with Array when schemasExpansionLevel is 'all'", async () => {
jest
.spyOn(Jotai, 'useAtomValue')
.mockReturnValue(normalizeOptions({ schemasExpansionLevel: 'all' }));
vi.spyOn(Jotai, 'useAtomValue').mockReturnValue(
normalizeOptions({ schemasExpansionLevel: 'all' }),
);
const parser = new OpenAPIParser(
schemasExpansionLevel as OpenAPIDefinition,
undefined,
@ -41,16 +41,16 @@ describe('ArraySchema', () => {
});
const { getByText } = render(<Schema schema={schema} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
expect(getByText('Dumb Property 3')).toBeInTheDocument();
});
it("should render Schema with Array when schemasExpansionLevel is '3'", async () => {
jest
.spyOn(Jotai, 'useAtomValue')
.mockReturnValue(normalizeOptions({ schemasExpansionLevel: '3' }));
vi.spyOn(Jotai, 'useAtomValue').mockReturnValue(
normalizeOptions({ schemasExpansionLevel: '3' }),
);
const parser = new OpenAPIParser(
schemasExpansionLevel as OpenAPIDefinition,
undefined,
@ -70,7 +70,7 @@ describe('ArraySchema', () => {
});
const { getByText, queryByText } = render(<Schema schema={schema} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
expect(getByText('Dumb Property 1')).toBeInTheDocument();
@ -78,9 +78,9 @@ describe('ArraySchema', () => {
});
it('should automatically expand nested array when parent element is expanded', async () => {
jest
.spyOn(Jotai, 'useAtomValue')
.mockReturnValue(normalizeOptions({ schemasExpansionLevel: '4' }));
vi.spyOn(Jotai, 'useAtomValue').mockReturnValue(
normalizeOptions({ schemasExpansionLevel: '4' }),
);
const parser = new OpenAPIParser(
schemasExpansionLevel as OpenAPIDefinition,
undefined,
@ -100,16 +100,16 @@ describe('ArraySchema', () => {
});
const { getByText } = render(<Schema schema={schema} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
expect(getByText('Dumb Property 1 in nested array')).toBeInTheDocument();
});
it("should not render inner Schema with Array when schemasExpansionLevel is '0'", async () => {
jest
.spyOn(Jotai, 'useAtomValue')
.mockReturnValue(normalizeOptions({ schemasExpansionLevel: '0' }));
vi.spyOn(Jotai, 'useAtomValue').mockReturnValue(
normalizeOptions({ schemasExpansionLevel: '0' }),
);
const parser = new OpenAPIParser(
schemasExpansionLevel as OpenAPIDefinition,
undefined,
@ -129,7 +129,7 @@ describe('ArraySchema', () => {
});
const { queryByText } = render(<Schema schema={schema} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
expect(queryByText('Dumb Property')).toBeNull();

View File

@ -1,28 +1,28 @@
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import * as Jotai from 'jotai';
import type { OperationModel } from '../../../models';
import type { OpenAPIDefinition } from '../../../types';
import type { OperationModel } from '../../../models/index.js';
import type { OpenAPIDefinition } from '../../../types/index.js';
import { normalizeOptions, OpenAPIParser } from '../../../services';
import { getSchema } from '../../../models';
import { normalizeOptions, OpenAPIParser } from '../../../services/index.js';
import { getSchema } from '../../../models/index.js';
import schemasExpansionLevel from './fixtures/schemaExpansionLevel.json';
import requiredField from './fixtures/requiredField.json';
import { Schema } from '../Schema';
import { Schema } from '../Schema.js';
import { TestBrowserRouter } from '../../../testProviders.js';
const options = normalizeOptions({});
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: jest.fn(),
vi.mock('jotai', async () => ({
...(await vi.importActual('jotai')),
useAtomValue: vi.fn(),
}));
describe('ObjectSchema', () => {
it("should render Schema when schemasExpansionLevel is 'all'", async () => {
jest
.spyOn(Jotai, 'useAtomValue')
.mockReturnValue(normalizeOptions({ schemasExpansionLevel: 'all' }));
vi.spyOn(Jotai, 'useAtomValue').mockReturnValue(
normalizeOptions({ schemasExpansionLevel: 'all' }),
);
const parser = new OpenAPIParser(
schemasExpansionLevel as OpenAPIDefinition,
undefined,
@ -42,16 +42,16 @@ describe('ObjectSchema', () => {
});
const { getByText } = render(<Schema schema={schema} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
expect(getByText('Dumb Property 6')).toBeInTheDocument();
});
it("should render Schema when schemasExpansionLevel is '3'", async () => {
jest
.spyOn(Jotai, 'useAtomValue')
.mockReturnValue(normalizeOptions({ schemasExpansionLevel: '3' }));
vi.spyOn(Jotai, 'useAtomValue').mockReturnValue(
normalizeOptions({ schemasExpansionLevel: '3' }),
);
const parser = new OpenAPIParser(
schemasExpansionLevel as OpenAPIDefinition,
undefined,
@ -71,7 +71,7 @@ describe('ObjectSchema', () => {
});
const { getByText, queryByText } = render(<Schema schema={schema} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
expect(getByText('Dumb Property 3')).toBeInTheDocument();
@ -79,9 +79,9 @@ describe('ObjectSchema', () => {
});
it("should not render inner Schema when schemasExpansionLevel is '0'", async () => {
jest
.spyOn(Jotai, 'useAtomValue')
.mockReturnValue(normalizeOptions({ schemasExpansionLevel: '0' }));
vi.spyOn(Jotai, 'useAtomValue').mockReturnValue(
normalizeOptions({ schemasExpansionLevel: '0' }),
);
const parser = new OpenAPIParser(
schemasExpansionLevel as OpenAPIDefinition,
undefined,
@ -101,14 +101,14 @@ describe('ObjectSchema', () => {
});
const { queryByText } = render(<Schema schema={schema} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
expect(queryByText('Test Sub Category')).toBeNull();
});
it("should render Schema when required is 'category' -> 'sub'", async () => {
jest.spyOn(Jotai, 'useAtomValue').mockReturnValue(normalizeOptions({}));
vi.spyOn(Jotai, 'useAtomValue').mockReturnValue(normalizeOptions({}));
const parser = new OpenAPIParser(requiredField as OpenAPIDefinition, undefined, options);
const schema = getSchema({
@ -124,7 +124,7 @@ describe('ObjectSchema', () => {
});
const { getByText, queryByText } = render(<Schema schema={schema} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
expect(getByText('Dumb Property 2')).toBeInTheDocument();
@ -132,7 +132,7 @@ describe('ObjectSchema', () => {
});
it("should render Schema when required is 'tags' -> 'sub' at Array schema", async () => {
jest.spyOn(Jotai, 'useAtomValue').mockReturnValue(normalizeOptions({}));
vi.spyOn(Jotai, 'useAtomValue').mockReturnValue(normalizeOptions({}));
const parser = new OpenAPIParser(requiredField as OpenAPIDefinition, undefined, options);
const schema = getSchema({
@ -148,7 +148,7 @@ describe('ObjectSchema', () => {
});
const { getByText, queryByText } = render(<Schema schema={schema} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
expect(getByText('Dumb Property 1')).toBeInTheDocument();

View File

@ -1,13 +1,13 @@
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import type { OpenAPIDefinition } from '../../../types';
import type { OperationModel } from '../../../models';
import type { OpenAPIDefinition } from '../../../types/index.js';
import type { OperationModel } from '../../../models/index.js';
import { normalizeOptions, OpenAPIParser } from '../../../services';
import { getSchema } from '../../../models';
import { normalizeOptions, OpenAPIParser } from '../../../services/index.js';
import { getSchema } from '../../../models/index.js';
import oneOfWithDiscriminator from './fixtures/oneOfWithDiscriminator.json';
import { Schema } from '../Schema';
import { Schema } from '../Schema.js';
import { TestBrowserRouter } from '../../../testProviders.js';
const options = normalizeOptions({});
describe('OneOfSchema', () => {
@ -38,7 +38,7 @@ describe('OneOfSchema', () => {
);
const { getByText } = render(<Schema schema={schema} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
expect(getByText('Type of the shape - Shape')).toBeInTheDocument();

View File

@ -1,13 +1,13 @@
import { act, fireEvent, render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import type { OpenAPIDefinition } from '../../../types';
import type { OperationModel } from '../../../models';
import type { OpenAPIDefinition } from '../../../types/index.js';
import type { OperationModel } from '../../../models/index.js';
import { normalizeOptions, OpenAPIParser } from '../../../services';
import { getSchema } from '../../../models';
import { normalizeOptions, OpenAPIParser } from '../../../services/index.js';
import { getSchema } from '../../../models/index.js';
import oneOfWithDiscriminator from './fixtures/oneOfWithDiscriminator.json';
import { Schema } from '../Schema';
import { Schema } from '../Schema.js';
import { TestBrowserRouter } from '../../../testProviders.js';
const options = normalizeOptions({});
describe('SchemaView', () => {
@ -33,7 +33,7 @@ describe('SchemaView', () => {
});
const { getByText } = render(<Schema schema={schema} />, {
wrapper: BrowserRouter,
wrapper: TestBrowserRouter,
});
expect(getByText('Shape')).toBeInTheDocument();

View File

@ -1,6 +1,7 @@
{
"openapi": "3.0.0",
"info": {},
"info": { "version": "1.0", "title": "Test" },
"paths": {},
"tags": [
{
"name": "pet_model",

View File

@ -1,6 +1,7 @@
{
"openapi": "3.0.0",
"info": {},
"info": { "version": "1.0", "title": "Test" },
"paths": {},
"tags": [
{
"name": "pet_model",

View File

@ -1,9 +1,9 @@
import { renderHook } from '@testing-library/react';
import { useOneOfLocationIdx } from '../useOneOfLocationIdx';
import { useOneOfLocationIdx } from '../useOneOfLocationIdx.js';
jest.mock('../../../hooks/useLocation', () => ({
useLocation: jest.fn().mockReturnValue({
vi.mock('../../../hooks/useLocation', () => ({
useLocation: vi.fn().mockReturnValue({
pathname: '/',
hash: '#plans/postplan/t=request&path=&oneof=1/trial&oneof=0/period',
}),
@ -11,7 +11,7 @@ jest.mock('../../../hooks/useLocation', () => ({
describe('useOneOfLocationIdx', () => {
it('should return the index of the oneOf item in the hash', () => {
const oneOf = [{}, {}, {}];
const oneOf = [{}, {}, {}] as unknown as Parameters<typeof useOneOfLocationIdx>[0];
const level = 1;
const { result } = renderHook(() => useOneOfLocationIdx(oneOf, level));
@ -19,7 +19,7 @@ describe('useOneOfLocationIdx', () => {
});
it('should return -1 if the oneOf item is not found in the hash', () => {
const oneOf = [{}, {}, {}];
const oneOf = [{}, {}, {}] as unknown as Parameters<typeof useOneOfLocationIdx>[0];
const level = 3;
const { result } = renderHook(() => useOneOfLocationIdx(oneOf, level));

View File

@ -1,7 +1,7 @@
import type { OpenAPIParser } from '../../services/OpenAPIParser.js';
import type { ContentItemModel } from '../../models/types.js';
import { useSearchDialog } from '@redocly/theme/core/openapi';
import { useSearchDialog, SearchSessionProvider } from '@redocly/theme/core/openapi';
import { SearchDialog, SearchTrigger } from './index.js';
import { useSearch } from '../../hooks/useSearch.js';
@ -13,7 +13,7 @@ export type SearchProps = {
parser: OpenAPIParser;
};
export function Search({ flatItems, parser }: SearchProps) {
function SearchContent({ flatItems, parser }: SearchProps) {
const telemetry = useTelemetry();
const { isOpen, onOpen, onClose } = useSearchDialog();
const { search, isReady } = useSearch(flatItems, parser);
@ -32,6 +32,14 @@ export function Search({ flatItems, parser }: SearchProps) {
);
}
export function Search(props: SearchProps) {
return (
<SearchSessionProvider>
<SearchContent {...props} />
</SearchSessionProvider>
);
}
const SearchWrapper = styled.div`
display: flex;
margin: var(--sidebar-margin-horizontal);

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import type { ReactElement } from 'react';
import type { SecurityRequirement } from '../../models/index.js';
@ -8,13 +8,23 @@ import { useModalScrollLock } from '@redocly/theme/core/openapi';
import { SecurityButton } from './SecurityButton.js';
import { SecurityModal } from './SecurityModal.js';
import { useTelemetry } from '../../hooks/index.js';
interface SecurityProps {
securities: SecurityRequirement[];
}
interface SecurityContext {
securityTypes: string[];
schemesCount: number;
isCombined: boolean;
modalOpenTime: number;
}
export function Security({ securities }: SecurityProps): ReactElement | null {
const [isModalVisible, setIsModalVisible] = useState(false);
const telemetry = useTelemetry();
const securityContextRef = useRef<SecurityContext | null>(null);
useModalScrollLock(isModalVisible);
@ -22,12 +32,58 @@ export function Security({ securities }: SecurityProps): ReactElement | null {
return null;
}
const handleViewDetailsClick = (): void => {
const allSchemes = securities.flatMap((security) => security.schemes);
const securityTypes = [...new Set(allSchemes.map((scheme) => scheme.type))];
const schemesCount = allSchemes.length;
const isCombined = securityTypes.length > 1;
const modalOpenTime = Date.now();
securityContextRef.current = {
securityTypes,
schemesCount,
isCombined,
modalOpenTime,
};
telemetry.sendViewSecurityDetailsClickedMessage({
id: 'openapi-docs-security-button',
object: 'button',
uri: window.location.href,
securityTypes,
schemesCount,
isCombined,
});
setIsModalVisible(true);
};
const handleModalClose = (): void => {
if (securityContextRef.current) {
const timeInModalMs = Date.now() - securityContextRef.current.modalOpenTime;
telemetry.sendViewSecurityDetailsClosedMessage({
id: 'openapi-docs-security-button',
object: 'button',
uri: window.location.href,
securityTypes: securityContextRef.current.securityTypes,
schemesCount: securityContextRef.current.schemesCount,
isCombined: securityContextRef.current.isCombined,
timeInModalMs,
});
securityContextRef.current = null;
}
setIsModalVisible(false);
};
return (
<>
<SecurityButton securities={securities} onClick={() => setIsModalVisible(true)} />
<SecurityButton securities={securities} onClick={handleViewDetailsClick} />
{isModalVisible && (
<Portal mountId="api-content">
<SecurityModal securities={securities} onClose={() => setIsModalVisible(false)} />
<SecurityModal securities={securities} onClose={handleModalClose} />
</Portal>
)}
</>

View File

@ -1,16 +1,16 @@
import { act, fireEvent, render } from '@testing-library/react';
// import { act } from 'react-dom/test-utils'; // For async act
import type { SecurityRequirement } from '../../../models';
import type { SecurityRequirement } from '../../../models/index.js';
import { Security } from '../Security';
import { Security } from '../Security.js';
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtomValue: jest.fn(() => ({ options: { hideSecuritySection: false } })),
vi.mock('jotai', async () => ({
...(await vi.importActual('jotai')),
useAtomValue: vi.fn(() => ({ options: { hideSecuritySection: false } })),
}));
jest.mock('../SecurityModal', () => ({
SecurityModal: jest.fn(() => <div>Mocked SecurityModal</div>),
vi.mock('../SecurityModal', async () => ({
SecurityModal: vi.fn(() => <div>Mocked SecurityModal</div>),
}));
describe('Security Component', () => {

View File

@ -1,8 +1,8 @@
import { fireEvent, render } from '@testing-library/react';
import type { SecurityRequirement } from '../../../models';
import type { SecurityRequirement } from '../../../models/index.js';
import { SecurityButton } from '../SecurityButton';
import { SecurityButton } from '../SecurityButton.js';
describe('SecurityButton Component', () => {
const securities = [
@ -46,7 +46,7 @@ describe('SecurityButton Component', () => {
});
it('calls the onClick callback when the button is clicked', () => {
const onClickMock = jest.fn();
const onClickMock = vi.fn();
const { getByRole } = render(<SecurityButton securities={securities} onClick={onClickMock} />);
fireEvent.click(getByRole('button', { name: 'View details' }));

View File

@ -1,8 +1,8 @@
import { render, fireEvent, act } from '@testing-library/react';
import type { ExtendedOpenAPISecurityScheme, SecurityRequirement } from '../../../models';
import type { ExtendedOpenAPISecurityScheme, SecurityRequirement } from '../../../models/index.js';
import { SecurityModal } from '../SecurityModal';
import { SecurityModal } from '../SecurityModal.js';
describe('SecurityModal Component', () => {
const securities = [
@ -122,7 +122,7 @@ describe('SecurityModal Component', () => {
});
it('calls onClose callback when the Close button is clicked', () => {
const onCloseMock = jest.fn();
const onCloseMock = vi.fn();
const { getByTestId } = render(<SecurityModal securities={securities} onClose={onCloseMock} />);
fireEvent.click(getByTestId('close'));

View File

@ -1,19 +1,19 @@
import { render, screen } from '@testing-library/react';
import * as Jotai from 'jotai';
import { describe, it, expect, jest } from '@jest/globals';
import { vi } from 'vitest';
import { ServerList } from '../ServerList';
import { normalizeOptions } from '../../../services';
import { ServerList } from '../ServerList.js';
import { normalizeOptions } from '../../../services/index.js';
import type { OpenAPIServer } from '../../../types';
import type { OpenAPIServer } from '../../../types/index.js';
jest.mock('jotai', () => ({
...(jest.requireActual('jotai') as object),
useAtomValue: jest.fn(),
vi.mock('jotai', async () => ({
...(await vi.importActual('jotai')),
useAtomValue: vi.fn(),
}));
describe('ServerList component', () => {
jest.spyOn(Jotai, 'useAtomValue').mockReturnValue(normalizeOptions({}));
vi.spyOn(Jotai, 'useAtomValue').mockReturnValue(normalizeOptions({}));
const servers = [
{
url: 'http://petstore.swagger.io/v2',
@ -29,7 +29,7 @@ describe('ServerList component', () => {
name: 'Stage server',
},
];
const translateMock = jest.fn().mockImplementation((_, defaultValue) => defaultValue);
const translateMock = vi.fn().mockImplementation((_, defaultValue) => defaultValue);
it('should render copy button', () => {
render(<ServerList servers={servers} path="/pet" translate={translateMock} />);

View File

@ -2,6 +2,7 @@ import type { ReactElement } from 'react';
import type { OperationModel } from '../../models/index.js';
import { Dropdown } from '@redocly/theme/components/Dropdown/Dropdown';
import { getOperationColor } from '@redocly/theme/core/openapi';
import { HttpVerb } from '../common/index.js';
import { PathWrapper, Path } from './styled.js';
@ -15,13 +16,18 @@ export const ServerListDropdown = ({
operation,
className,
}: ServerListDropdownProps): ReactElement => {
const httpColor = getOperationColor({
isAdditionalOperation: operation.isAdditionalOperation,
httpVerb: operation.httpVerb,
});
return (
<Dropdown
className={className}
active={false}
trigger={
<PathWrapper variant="ghost">
<HttpVerb color={operation.httpVerb}>{operation.httpVerb}</HttpVerb>
<HttpVerb color={httpColor}>{operation.httpVerb}</HttpVerb>
<Path>{operation.path}</Path>
</PathWrapper>
}

View File

@ -137,6 +137,7 @@ function convertOpenAPIDocs2Sidebar(data: {
hasActiveSubItem,
modified: true,
deprecated: item.deprecated,
isAdditionalOperation: item.isAdditionalOperation,
});
break;
case 'section':

View File

@ -1,7 +1,6 @@
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import { AnimatedChevronButton } from '../AnimatedChevronButton';
import { AnimatedChevronButton } from '../AnimatedChevronButton.js';
describe('AnimatedChevronButton', () => {
it('should render without crashing', () => {

View File

@ -1,25 +1,28 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { useAtom } from 'jotai';
import { MemoryRouter } from 'react-router-dom';
import { vi } from 'vitest';
import type { Mock } from 'vitest';
import * as useIsMobile from '../../../hooks/useIsMobile';
import { FloatingButton } from '../FloatingButton.js';
import { TestMemoryRouter } from '../../../testProviders.js';
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtom: jest.fn(),
vi.mock('jotai', async () => ({
...(await vi.importActual('jotai')),
useAtom: vi.fn(),
}));
jest.mock('../../../hooks/useIsMobile');
vi.mock('../../../hooks/useIsMobile');
const useAtomMock = useAtom as jest.Mock;
const useIsMobileMock = jest.spyOn(useIsMobile, 'useIsMobile');
const useAtomMock = useAtom as Mock;
const useIsMobileMock = vi.spyOn(useIsMobile, 'useIsMobile');
describe('FloatingButton', () => {
const setIsSidebarOpened = jest.fn();
const setIsSidebarOpened = vi.fn();
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
});
it('should render button', () => {
@ -27,9 +30,9 @@ describe('FloatingButton', () => {
useIsMobileMock.mockReturnValue(false);
render(
<MemoryRouter>
<TestMemoryRouter>
<FloatingButton />
</MemoryRouter>,
</TestMemoryRouter>,
);
expect(screen.getByTestId('floating-button')).toBeInTheDocument();
});
@ -39,9 +42,9 @@ describe('FloatingButton', () => {
useIsMobileMock.mockReturnValue(false);
render(
<MemoryRouter>
<TestMemoryRouter>
<FloatingButton />
</MemoryRouter>,
</TestMemoryRouter>,
);
fireEvent.click(screen.getByTestId('floating-button'));
@ -53,17 +56,17 @@ describe('FloatingButton', () => {
useIsMobileMock.mockReturnValue(true);
const { rerender } = render(
<MemoryRouter initialEntries={['/']}>
<TestMemoryRouter initialEntries={['/']}>
<FloatingButton />
</MemoryRouter>,
</TestMemoryRouter>,
);
expect(setIsSidebarOpened).toHaveBeenCalledWith(false);
rerender(
<MemoryRouter initialEntries={['/new-path']}>
<TestMemoryRouter initialEntries={['/new-path']}>
<FloatingButton />
</MemoryRouter>,
</TestMemoryRouter>,
);
expect(setIsSidebarOpened).toHaveBeenCalledWith(false);
@ -74,15 +77,15 @@ describe('FloatingButton', () => {
useIsMobileMock.mockReturnValue(false);
const { rerender } = render(
<MemoryRouter initialEntries={['/']}>
<TestMemoryRouter initialEntries={['/']}>
<FloatingButton />
</MemoryRouter>,
</TestMemoryRouter>,
);
rerender(
<MemoryRouter initialEntries={['/new-path']}>
<TestMemoryRouter initialEntries={['/new-path']}>
<FloatingButton />
</MemoryRouter>,
</TestMemoryRouter>,
);
expect(setIsSidebarOpened).not.toHaveBeenCalled();

View File

@ -1,42 +1,45 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { useAtom } from 'jotai';
import { LayoutVariant } from '@redocly/config';
import { vi } from 'vitest';
import type { Mock } from 'vitest';
import { SidebarActions } from '../SidebarActions.js';
import * as useIsMobileModule from '../../../hooks/useIsMobile.js';
import * as useTelemetryModule from '../../../hooks/useTelemetry.js';
jest.mock('jotai', () => ({
...jest.requireActual('jotai'),
useAtom: jest.fn(),
vi.mock('jotai', async () => ({
...(await vi.importActual('jotai')),
useAtom: vi.fn(),
}));
jest.mock('../../../hooks/useIsMobile.js', () => ({
useIsMobile: jest.fn(),
vi.mock('../../../hooks/useIsMobile.js', () => ({
useIsMobile: vi.fn(),
}));
jest.mock('../../../hooks/useTelemetry.js', () => ({
useTelemetry: jest.fn(),
vi.mock('../../../hooks/useTelemetry.js', () => ({
useTelemetry: vi.fn(),
}));
const useAtomMock = useAtom as jest.Mock;
const useIsMobileMock = useIsMobileModule.useIsMobile as jest.Mock;
const useTelemetryMock = useTelemetryModule.useTelemetry as jest.Mock;
const useAtomMock = useAtom as Mock;
const useIsMobileMock = useIsMobileModule.useIsMobile as Mock;
const useTelemetryMock = useTelemetryModule.useTelemetry as Mock;
describe('SidebarActions', () => {
const setLayout = jest.fn();
const setSidebarCollapsed = jest.fn();
const setIsSidebarOpened = jest.fn();
const setLayout = vi.fn();
const setSidebarCollapsed = vi.fn();
const setIsSidebarOpened = vi.fn();
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
// Clean up any dark mode class that might be left from previous tests
document.documentElement.classList.remove('dark');
// Set up default mocks
useIsMobileMock.mockReturnValue(false);
useTelemetryMock.mockReturnValue({
sendChangeLayoutButtonClickedMessage: jest.fn(),
sendChangeLayoutButtonClickedMessage: vi.fn(),
});
});
@ -86,7 +89,7 @@ describe('SidebarActions', () => {
btn.querySelector('[data-component-name="icons/SidePanelOpenIcon/SidePanelOpenIcon"]'),
);
fireEvent.click(collapseButton);
fireEvent.click(collapseButton as Element);
expect(setIsSidebarOpened).toHaveBeenCalledWith(false);
expect(setSidebarCollapsed).toHaveBeenCalledWith(true);
@ -104,7 +107,7 @@ describe('SidebarActions', () => {
const viewModeElement =
document.querySelector('[data-component-name*="HorizontalViewIcon"]') ||
document.querySelector('[data-component-name*="VerticalViewIcon"]');
fireEvent.click(viewModeElement);
fireEvent.click(viewModeElement as Element);
expect(setLayout).toHaveBeenCalledWith(LayoutVariant.THREE_PANEL);
});

View File

@ -1,7 +1,7 @@
import { render, fireEvent } from '@testing-library/react';
import { Tabs } from '../Tabs';
import { useTabsState } from '../useTabsState';
import { Tabs } from '../Tabs.js';
import { useTabsState } from '../useTabsState.js';
describe('Tabs', () => {
const TestComponent = () => {

View File

@ -3,6 +3,8 @@ import { memo } from 'react';
import type { TFunction } from '@redocly/theme/core/openapi';
import type { OperationsNavigationProps } from './types.js';
import { getOperationColor } from '@redocly/theme/core/openapi';
import { joinWithSeparator } from '../../services/index.js';
import { encodeBackSlashes } from '../../utils/index.js';
import { HttpVerb, NavigationBadge } from '../common/index.js';
@ -26,9 +28,14 @@ function OperationNavigationItemsComponent({
<>
<Heading>{title}</Heading>
{items.map((item) => {
const { id, href, deprecated, badges } = item;
const { id, href, deprecated, badges, isAdditionalOperation } = item;
const title = item.type === 'operation' ? item.path : item.name;
const httpVerb = item.type === 'operation' ? item.httpVerb : item.type;
const httpColor = getOperationColor({
isAdditionalOperation,
deprecated,
httpVerb,
});
return (
<Item
@ -38,7 +45,7 @@ function OperationNavigationItemsComponent({
onClick={() => onClick(joinWithSeparator(routingBasePath, encodeBackSlashes(href)))}
>
<span>
<HttpVerb color={deprecated ? 'http-deprecated' : httpVerb}>{httpVerb}</HttpVerb>
<HttpVerb color={httpColor}>{httpVerb}</HttpVerb>
<Path>{title}</Path>
{deprecated && (
<NavigationBadge deprecated>

View File

@ -26,35 +26,65 @@ export function OperationsNavigationComponent({
const layout = useAtomValue(layoutAtom);
const isStacked = layout === LayoutVariant.STACKED;
const { operations, webhooks, schemas, mcpTools, showMoreCount, totalCount } = useMemo(() => {
const {
operations,
webhooks,
schemas,
mcpTools,
showMoreCount,
totalCount,
mcpResources,
mcpPrompts,
} = useMemo(() => {
const all = items.filter((item) => isRenderableMenuItem(item));
const list = isExpanded ? all : all.slice(0, MAX_OPERATIONS);
const { operations, webhooks, schemas, mcpTools } = list.reduce(
const {
operations,
additionalOperations,
webhooks,
schemas,
mcpTools,
mcpResources,
mcpPrompts,
} = list.reduce(
(acc, item) => {
if (item.type === 'operation' && (item as OperationMenuItem).isWebhook) {
acc.webhooks.push(item as OperationMenuItem);
} else if (item.type === 'operation') {
acc.operations.push(item as OperationMenuItem);
const operationItem = item as OperationMenuItem;
if (operationItem.isAdditionalOperation) {
acc.additionalOperations.push(operationItem);
} else {
acc.operations.push(operationItem);
}
} else if (item.type === 'schema') {
acc.schemas.push(item);
} else if (item.type === 'mcp') {
} else if (item.type === 'tool') {
acc.mcpTools.push(item);
} else if (item.type === 'rsrc') {
acc.mcpResources.push(item);
} else if (item.type === 'prompt') {
acc.mcpPrompts.push(item);
}
return acc;
},
{
operations: [] as OperationMenuItem[],
additionalOperations: [] as OperationMenuItem[],
webhooks: [] as OperationMenuItem[],
schemas: [] as GroupModel[],
mcpTools: [] as GroupModel[],
mcpResources: [] as GroupModel[],
mcpPrompts: [] as GroupModel[],
},
);
return {
operations,
operations: [...operations, ...additionalOperations],
schemas,
mcpTools,
webhooks,
mcpResources,
mcpPrompts,
showMoreCount: !isExpanded && all.length > MAX_OPERATIONS ? all.length - MAX_OPERATIONS : 0,
totalCount: all.length,
};
@ -86,6 +116,24 @@ export function OperationsNavigationComponent({
title={translate('openapi.mcp.tools', 'MCP Tools')}
/>
)}
{mcpResources.length > 0 && (
<OperationNavigationItems
items={mcpResources}
onClick={handleOnClick}
routingBasePath={routingBasePath}
translate={translate}
title={translate('openapi.mcp.resources', 'MCP Resources')}
/>
)}
{mcpPrompts.length > 0 && (
<OperationNavigationItems
items={mcpPrompts}
onClick={handleOnClick}
routingBasePath={routingBasePath}
translate={translate}
title={translate('openapi.mcp.prompts', 'MCP Prompts')}
/>
)}
{operations.length > 0 && (
<OperationNavigationItems
items={operations}

View File

@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import { ViewNested } from '../ViewNested';
import { ViewNested } from '../ViewNested.js';
describe('ViewNested', () => {
it('should render nested array content directly without show properties button', () => {

View File

@ -1,7 +1,7 @@
import { render } from '@testing-library/react';
import { ExampleSwitch } from '../../Samples';
import { exampleXML, examples } from '../mocks/examples';
import { ExampleSwitch } from '../../Samples/index.js';
import { exampleXML, examples } from '../mocks/examples.js';
describe('ExampleSwitch', () => {
const exampleKey = 'cat';

View File

@ -1,13 +1,22 @@
import { renderHook } from '@testing-library/react';
import { normalizeOptions, OpenAPIParser } from '../../../services';
import { getOperation } from '../../../models';
import {
normalizeOptions,
OpenAPIParser,
type ExtendedOpenAPIOperation,
} from '../../../services/index.js';
import { getOperation } from '../../../models/index.js';
import testDefinition from '../../MediaTypeSwitch/__tests__/fixtures/test-definition.json';
import { useExampleKey } from '../../Samples/use-example-key';
import { useExampleKey } from '../../Samples/use-example-key.js';
import type { OpenAPIDefinition } from '../../../types/index.js';
describe('useExampleKey method', () => {
const options = normalizeOptions({});
const parser = new OpenAPIParser(testDefinition, undefined, options);
const parser = new OpenAPIParser(
testDefinition as unknown as OpenAPIDefinition,
undefined,
options,
);
test('should take firstKey as exampleKey if activeExampleName and defaultExampleName are missing', () => {
const operation = getOperation(
@ -21,7 +30,7 @@ describe('useExampleKey method', () => {
isWebhook: false,
operationId: 'getPet',
...testDefinition.paths['/test'].get,
},
} as unknown as ExtendedOpenAPIOperation,
undefined,
options,
'',

View File

@ -1,10 +1,11 @@
import { vi } from 'vitest';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ClearButton } from '../../common/ClearButton';
import { ClearButton } from '../../common/ClearButton/index.js';
describe('ClearButton component', () => {
const handleClear = jest.fn();
const handleClear = vi.fn();
it('render component correctly', () => {
const { container } = render(<ClearButton handleClear={handleClear} />);

View File

@ -1,16 +1,16 @@
import { render, fireEvent } from '@testing-library/react';
import { Select, SimpleSelect } from '../../common/Select';
import { Select, SimpleSelect } from '../../common/Select/index.js';
describe('Select component', () => {
const onChange = jest.fn();
const onChange = vi.fn();
const options = [
{ idx: 0, title: 'First option', value: 'option1' },
{ idx: 1, title: 'Second option', value: 'option2' },
];
afterEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
});
it('render Select component correctly', () => {

View File

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ClearButton component render component correctly 1`] = `
exports[`ClearButton component > render component correctly 1`] = `
.c0 {
z-index: 1;
position: absolute;

View File

@ -1,18 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Select component render Select component correctly 1`] = `
.c1 {
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
height: 16px;
width: 16px;
}
.c1 path {
fill: currentColor;
}
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Select component > render Select component correctly 1`] = `
.c0 {
box-sizing: border-box;
outline: none;
@ -87,6 +75,18 @@ exports[`Select component render Select component correctly 1`] = `
box-shadow: none;
}
.c1 {
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
height: 16px;
width: 16px;
}
.c1 path {
fill: currentColor;
}
.c2 {
position: absolute;
right: 10px;
@ -130,19 +130,7 @@ exports[`Select component render Select component correctly 1`] = `
</div>
`;
exports[`Select component render SimpleSelect dark variant correctly 1`] = `
.c2 {
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
height: 16px;
width: 16px;
}
.c2 path {
fill: currentColor;
}
exports[`Select component > render SimpleSelect dark variant correctly 1`] = `
.c0 {
box-sizing: border-box;
outline: none;
@ -240,6 +228,18 @@ exports[`Select component render SimpleSelect dark variant correctly 1`] = `
box-shadow: none;
}
.c2 {
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
height: 16px;
width: 16px;
}
.c2 path {
fill: currentColor;
}
.c3 {
position: absolute;
right: 10px;

View File

@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import React, { useMemo, useState } from 'react';
import type { SelectOption } from '@redocly/theme/core/openapi';
import type { SegmentedOption } from '@redocly/theme/core/openapi';
import { Dropdown as DropdownTheme } from '@redocly/theme/components/Dropdown/Dropdown';
import { CheckmarkIcon } from '@redocly/theme/icons/CheckmarkIcon/CheckmarkIcon';
@ -15,8 +15,8 @@ import { useTranslate } from '../../../hooks/index.js';
export interface DropdownProps<T> {
value: T;
onChange: ({ label, value }: SelectOption<T>) => void;
options: SelectOption<T>[];
onChange: ({ label, value }: SegmentedOption<T>) => void;
options: SegmentedOption<T>[];
className?: string;
triggerVariant?: 'ghost' | 'outlined';
triggerSize?: string;
@ -35,7 +35,7 @@ function DropdownComponent<T>({
const [searchValue, setSearchValue] = useState('');
const translate = useTranslate();
const activeLabel = options.find((opt) => opt.value === value)?.label;
const activeOption = options.find((opt) => opt.value === value);
const filteredOptions = useMemo(
() =>
@ -45,7 +45,7 @@ function DropdownComponent<T>({
[options, searchValue],
);
if (options.length === 1) return <Title>{activeLabel}</Title>;
if (options.length === 1) return <Title>{activeOption?.element || activeOption?.label}</Title>;
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(event.target.value);
@ -55,15 +55,18 @@ function DropdownComponent<T>({
if (!withSearch) {
return (
<>
{options.map((opt) => (
<StyledDropdownMenuItem
key={opt.label}
active={opt.value === value}
onAction={() => onChange(opt)}
suffix={opt.value === value && <StyledCheckmarkIcon />}
>
{opt.label}
</StyledDropdownMenuItem>
{options.map((opt, index) => (
<React.Fragment key={index}>
{opt.divider}
<StyledDropdownMenuItem
key={opt.label}
active={opt.value === value}
onAction={() => onChange(opt)}
suffix={opt.value === value && <StyledCheckmarkIcon />}
>
{opt.element || opt.label}
</StyledDropdownMenuItem>
</React.Fragment>
))}
</>
);
@ -91,15 +94,18 @@ function DropdownComponent<T>({
/>
<ScrollableContainer>
{filteredOptions.length ? (
filteredOptions.map((opt) => (
<StyledDropdownMenuItem
key={opt.label}
active={opt.value === value}
onAction={() => onChange(opt)}
suffix={opt.value === value && <StyledCheckmarkIcon />}
>
{opt.label}
</StyledDropdownMenuItem>
filteredOptions.map((opt, index) => (
<React.Fragment key={index}>
{opt.divider}
<StyledDropdownMenuItem
key={opt.label}
active={opt.value === value}
onAction={() => onChange(opt)}
suffix={opt.value === value && <StyledCheckmarkIcon />}
>
{opt.element || opt.label}
</StyledDropdownMenuItem>
</React.Fragment>
))
) : (
<StyledNoResultsDropdownMenuItem
@ -116,7 +122,7 @@ function DropdownComponent<T>({
className={className}
trigger={
<Button variant={triggerVariant} size={triggerSize} type="button">
{activeLabel}
{activeOption?.element || activeOption?.label}
</Button>
}
withArrow

View File

@ -1,11 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import type { SelectOption } from '@redocly/theme/core/openapi';
import type { SegmentedOption } from '@redocly/theme/core/openapi';
import type { SchemaModel } from '../../../models/index.js';
import { Segmented } from '@redocly/theme/components/Segmented/Segmented';
import { isUndefined } from '@redocly/theme/core/openapi';
import { Segmented } from '@redocly/theme/components/Segmented/Segmented';
import { Dropdown } from '../Dropdown/index.js';
import { operationStore } from '../../../jotai/operation.js';
@ -14,7 +14,7 @@ const LIMIT_FOR_SEGMENTED = 5;
const ENABLE_SEARCH_THRESHOLD = 7;
interface SchemaSelectionProps {
options: SelectOption<number>[];
options: SegmentedOption<number>[];
onChange?: (idx: number) => void;
pointer: string;
schema: SchemaModel;

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