From 0bb21c812840999d2dd2d96da42124746440b035 Mon Sep 17 00:00:00 2001 From: Alex Varchuk Date: Wed, 26 Jul 2023 11:17:56 +0300 Subject: [PATCH] feat: add x-tags (#2355) * feat: add x-tags * chore: fix e2e tests and add new for x-tag * chore: add x-tags to demo definition * chore: update snapshots --- demo/openapi.yaml | 1 + e2e/integration/menu.e2e.ts | 139 ++++++++++-------- src/components/SideMenu/MenuItem.tsx | 3 +- src/components/SideMenu/styled.elements.ts | 24 +-- src/services/MenuBuilder.ts | 38 ++++- src/services/types.ts | 2 +- .../loadAndBundleSpec.test.ts.snap | 3 + 7 files changed, 132 insertions(+), 78 deletions(-) diff --git a/demo/openapi.yaml b/demo/openapi.yaml index 0adb69332f..8cecfd5aba 100644 --- a/demo/openapi.yaml +++ b/demo/openapi.yaml @@ -910,6 +910,7 @@ components: message: type: string Cat: + 'x-tags': ['pet'] description: A representation of a cat allOf: - $ref: '#/components/schemas/Pet' diff --git a/e2e/integration/menu.e2e.ts b/e2e/integration/menu.e2e.ts index 22212e16d0..e20a257db9 100644 --- a/e2e/integration/menu.e2e.ts +++ b/e2e/integration/menu.e2e.ts @@ -1,80 +1,89 @@ describe('Menu', () => { - beforeEach(() => { - cy.visit('e2e/standalone.html'); - }); + describe('3.0 spec', () => { + beforeEach(() => { + cy.visit('e2e/standalone.html'); + }); + it('should have valid items count', () => { + cy.get('.menu-content').find('li').should('have.length', 35); + }); - it('should have valid items count', () => { - cy.get('.menu-content').find('li').should('have.length', 34); - }); + it('should sync active menu items while scroll', () => { + cy.contains('h1', 'Introduction') + .scrollIntoView() + .get('[role=menuitem] > label.active') + .should('have.text', 'Introduction'); - it('should sync active menu items while scroll', () => { - cy.contains('h1', 'Introduction') - .scrollIntoView() - .get('[role=menuitem].active') - .should('have.text', 'Introduction'); + cy.contains('h2', 'Add a new pet to the store') + .scrollIntoView() + .wait(100) + .get('[role=menuitem] > label.active') + .children() + .last() + .should('have.text', 'Add a new pet to the store') + .should('be.visible'); + }); - cy.contains('h2', 'Add a new pet to the store') - .scrollIntoView() - .wait(100) - .get('[role=menuitem].active') - .children() - .last() - .should('have.text', 'Add a new pet to the store') - .should('be.visible'); - }); + it('should sync active menu items while scroll back and scroll again', () => { + cy.contains('h2', 'Add a new pet to the store') + .scrollIntoView() + .wait(100) + .get('[role=menuitem] > label.active') + .children() + .last() + .should('have.text', 'Add a new pet to the store') + .should('be.visible'); - it('should sync active menu items while scroll back and scroll again', () => { - cy.contains('h2', 'Add a new pet to the store') - .scrollIntoView() - .wait(100) - .get('[role=menuitem].active') - .children() - .last() - .should('have.text', 'Add a new pet to the store') - .should('be.visible'); + cy.contains('h1', 'Swagger Petstore').scrollIntoView().wait(100); - cy.contains('h1', 'Swagger Petstore').scrollIntoView().wait(100); + cy.contains('h1', 'Introduction') + .scrollIntoView() + .wait(100) + .get('[role=menuitem] > label.active') + .should('have.text', 'Introduction'); - cy.contains('h1', 'Introduction') - .scrollIntoView() - .wait(100) - .get('[role=menuitem].active') - .should('have.text', 'Introduction'); + cy.url().should('include', '#section/Introduction'); + }); - cy.url().should('include', '#section/Introduction'); - }); + it('should update URL hash when clicking on menu items', () => { + cy.contains('[role=menuitem] > label.-depth1', 'pet').click({ force: true }); + cy.get('li[data-item-id="schema/Cat"]') + .should('have.text', 'schemaCat') + .click({ force: true }); + cy.location('hash').should('equal', '#schema/Cat'); + }); - it('should update URL hash when clicking on menu items', () => { - cy.contains('[role=menuitem].-depth1', 'pet').click({ force: true }); - cy.location('hash').should('equal', '#tag/pet'); + it('should contains Cat schema in Pet using x-tags', () => { + cy.contains('[role=menuitem] > label.-depth1', 'pet').click({ force: true }); + cy.location('hash').should('equal', '#tag/pet'); - cy.contains('[role=menuitem]', 'Find pet by ID').click({ force: true }); - cy.location('hash').should('equal', '#tag/pet/operation/getPetById'); - }); + cy.contains('[role=menuitem]', 'Find pet by ID').click({ force: true }); + cy.location('hash').should('equal', '#tag/pet/operation/getPetById'); + }); - it('should deactivate tag when other is activated', () => { - const petItem = () => cy.contains('[role=menuitem].-depth1', 'pet'); + it('should deactivate tag when other is activated', () => { + const petItem = () => cy.contains('[role=menuitem] > label.-depth1', 'pet'); - petItem().click({ force: true }).should('have.class', 'active'); - cy.contains('[role=menuitem].-depth1', 'store').click({ force: true }); - petItem().should('not.have.class', 'active'); - }); + petItem().click({ force: true }).should('have.class', 'active'); + cy.contains('[role=menuitem] > label.-depth1', 'store').click({ force: true }); + petItem().should('not.have.class', 'active'); + }); - it('should be able to open a response object to see more details', () => { - cy.contains('h2', 'Find pet by ID') - .scrollIntoView() - .wait(100) - .parent() - .find('div h3') - .should('have.text', 'Responses') - .parent() - .find('div:first button') - .click() - .should('have.attr', 'aria-expanded', 'true') - .parent() - .find('div h5') - .then($h5 => $h5[0].firstChild!.nodeValue!.trim()) - .should('eq', 'Response Schema:'); + it('should be able to open a response object to see more details', () => { + cy.contains('h2', 'Find pet by ID') + .scrollIntoView() + .wait(100) + .parent() + .find('div h3') + .should('have.text', 'Responses') + .parent() + .find('div:first button') + .click() + .should('have.attr', 'aria-expanded', 'true') + .parent() + .find('div h5') + .then($h5 => $h5[0].firstChild!.nodeValue!.trim()) + .should('eq', 'Response Schema:'); + }); }); it('should be able to open the operation details when the operation IDs have quotes', () => { @@ -85,7 +94,7 @@ describe('Menu', () => { cy.url().should('include', 'deletePetBy%22Id'); }); - it.only('should encode URL when the operation IDs have backslashes', () => { + it('should encode URL when the operation IDs have backslashes', () => { cy.visit('e2e/standalone-3-1.html'); cy.get('label span[title="pet"]').click({ multiple: true, force: true }); cy.get('li').contains('OperationId with backslash').click({ multiple: true, force: true }); diff --git a/src/components/SideMenu/MenuItem.tsx b/src/components/SideMenu/MenuItem.tsx index 7548e6f49f..2b8407ebc4 100644 --- a/src/components/SideMenu/MenuItem.tsx +++ b/src/components/SideMenu/MenuItem.tsx @@ -55,7 +55,8 @@ export class MenuItem extends React.Component { ) : ( - + {item.type === 'schema' && schema} + {item.sidebarLabel} {this.props.children} diff --git a/src/components/SideMenu/styled.elements.ts b/src/components/SideMenu/styled.elements.ts index 9ebeb212cb..9f13709526 100644 --- a/src/components/SideMenu/styled.elements.ts +++ b/src/components/SideMenu/styled.elements.ts @@ -26,43 +26,47 @@ export const OperationBadge = styled.span.attrs((props: { type: string }) => ({ margin-top: 2px; &.get { - background-color: ${props => props.theme.colors.http.get}; + background-color: ${({ theme }) => theme.colors.http.get}; } &.post { - background-color: ${props => props.theme.colors.http.post}; + background-color: ${({ theme }) => theme.colors.http.post}; } &.put { - background-color: ${props => props.theme.colors.http.put}; + background-color: ${({ theme }) => theme.colors.http.put}; } &.options { - background-color: ${props => props.theme.colors.http.options}; + background-color: ${({ theme }) => theme.colors.http.options}; } &.patch { - background-color: ${props => props.theme.colors.http.patch}; + background-color: ${({ theme }) => theme.colors.http.patch}; } &.delete { - background-color: ${props => props.theme.colors.http.delete}; + background-color: ${({ theme }) => theme.colors.http.delete}; } &.basic { - background-color: ${props => props.theme.colors.http.basic}; + background-color: ${({ theme }) => theme.colors.http.basic}; } &.link { - background-color: ${props => props.theme.colors.http.link}; + background-color: ${({ theme }) => theme.colors.http.link}; } &.head { - background-color: ${props => props.theme.colors.http.head}; + background-color: ${({ theme }) => theme.colors.http.head}; } &.hook { - background-color: ${props => props.theme.colors.primary.main}; + background-color: ${({ theme }) => theme.colors.primary.main}; + } + + &.schema { + background-color: ${({ theme }) => theme.colors.http.basic}; } `; diff --git a/src/services/MenuBuilder.ts b/src/services/MenuBuilder.ts index 95b12ee3c8..4ad28f7b77 100644 --- a/src/services/MenuBuilder.ts +++ b/src/services/MenuBuilder.ts @@ -1,4 +1,4 @@ -import type { OpenAPISpec, OpenAPIPaths } from '../types'; +import type { OpenAPISpec, OpenAPIPaths, OpenAPITag, OpenAPISchema } from '../types'; import { isOperationName, JsonPointer, alphabeticallyByProp } from '../utils'; import { MarkdownRenderer } from './MarkdownRenderer'; import { GroupModel, OperationModel } from './models'; @@ -137,7 +137,14 @@ export class MenuBuilder { continue; } + const relatedSchemas = this.getTagRelatedSchema({ + parser, + tag, + parent: item, + }); + item.items = [ + ...relatedSchemas, ...MenuBuilder.addMarkdownItems(tag.description || '', item, item.depth + 1, options), ...this.getOperationsItems(parser, item, tag, item.depth + 1, options), ]; @@ -248,4 +255,33 @@ export class MenuBuilder { } return tags; } + + static getTagRelatedSchema({ + parser, + tag, + parent, + }: { + parser: OpenAPIParser; + tag: TagInfo; + parent: GroupModel; + }): GroupModel[] { + return Object.entries(parser.spec.components?.schemas || {}) + .map(([schemaName, schema]) => { + const schemaTags = schema['x-tags']; + if (!schemaTags?.includes(tag.name)) return null; + + const item = new GroupModel( + 'schema', + { + name: schemaName, + 'x-displayName': `${(schema as OpenAPISchema).title || schemaName}`, + description: ``, + } as OpenAPITag, + parent, + ); + item.depth = parent.depth + 1; + return item; + }) + .filter(Boolean) as GroupModel[]; + } } diff --git a/src/services/types.ts b/src/services/types.ts index 5cba6cc907..3829024b22 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -83,7 +83,7 @@ export interface TagGroup { tags: string[]; } -export type MenuItemGroupType = 'group' | 'tag' | 'section'; +export type MenuItemGroupType = 'group' | 'tag' | 'section' | 'schema'; export type MenuItemType = MenuItemGroupType | 'operation'; export interface IMenuItem { diff --git a/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap b/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap index 6230f44225..fce41b1777 100644 --- a/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap +++ b/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap @@ -102,6 +102,9 @@ Object { }, ], "description": "A representation of a cat", + "x-tags": Array [ + "pet", + ], }, "Category": Object { "properties": Object {