From c27de5b69be1d4f9545d4900453cc19a8afc1193 Mon Sep 17 00:00:00 2001 From: Weyert de Boer Date: Mon, 24 Jun 2024 18:46:32 +0100 Subject: [PATCH 1/6] eat: allow to use `x-tagGroups` extension to define tree structure Allow to use the `x-tagGroups` and `x-displayName` vendor extensions to define the structure of table of contents --- .../API/APIWithResponsiveSidebarLayout.tsx | 3 +- .../components/API/APIWithSidebarLayout.tsx | 3 +- .../components/API/APIWithStackedLayout.tsx | 15 +- .../computeAPITreeByTagGroups.test.ts | 623 ++++++++++++++++++ .../components/API/__tests__/utils.test.ts | 55 +- .../src/components/API/computeAPITree.ts | 102 +++ packages/elements/src/components/API/utils.ts | 178 ++--- 7 files changed, 875 insertions(+), 104 deletions(-) create mode 100644 packages/elements/src/components/API/__tests__/computeAPITreeByTagGroups.test.ts create mode 100644 packages/elements/src/components/API/computeAPITree.ts diff --git a/packages/elements/src/components/API/APIWithResponsiveSidebarLayout.tsx b/packages/elements/src/components/API/APIWithResponsiveSidebarLayout.tsx index 640493f20..59a728219 100644 --- a/packages/elements/src/components/API/APIWithResponsiveSidebarLayout.tsx +++ b/packages/elements/src/components/API/APIWithResponsiveSidebarLayout.tsx @@ -10,7 +10,8 @@ import * as React from 'react'; import { Redirect, useLocation } from 'react-router-dom'; import { ServiceNode } from '../../utils/oas/types'; -import { computeAPITree, findFirstNodeSlug, isInternal } from './utils'; +import { isInternal } from './utils'; +import { computeAPITree, findFirstNodeSlug } from './computeAPITree'; type SidebarLayoutProps = { serviceNode: ServiceNode; diff --git a/packages/elements/src/components/API/APIWithSidebarLayout.tsx b/packages/elements/src/components/API/APIWithSidebarLayout.tsx index 0d47d8d10..d53fd9869 100644 --- a/packages/elements/src/components/API/APIWithSidebarLayout.tsx +++ b/packages/elements/src/components/API/APIWithSidebarLayout.tsx @@ -15,7 +15,8 @@ import * as React from 'react'; import { Link, Redirect, useLocation } from 'react-router-dom'; import { ServiceNode } from '../../utils/oas/types'; -import { computeAPITree, findFirstNodeSlug, isInternal } from './utils'; +import { isInternal } from './utils'; +import { computeAPITree, findFirstNodeSlug } from './computeAPITree'; type SidebarLayoutProps = { serviceNode: ServiceNode; diff --git a/packages/elements/src/components/API/APIWithStackedLayout.tsx b/packages/elements/src/components/API/APIWithStackedLayout.tsx index e1fbbf425..0a596501b 100644 --- a/packages/elements/src/components/API/APIWithStackedLayout.tsx +++ b/packages/elements/src/components/API/APIWithStackedLayout.tsx @@ -79,8 +79,15 @@ export const APIWithStackedLayout: React.FC = ({ showPoweredByLink = true, location, }) => { - const { groups: operationGroups } = computeTagGroups(serviceNode, NodeType.HttpOperation); - const { groups: webhookGroups } = computeTagGroups(serviceNode, NodeType.HttpWebhook); + const rootVendorExtensions = Object.keys(serviceNode.data.extensions ?? {}).map(item => item.toLowerCase()); + const isHavingTagGroupsExtension = typeof rootVendorExtensions['x-taggroups'] !== undefined; + + const { groups: operationGroups } = computeTagGroups(serviceNode, NodeType.HttpOperation, { + useTagGroups: isHavingTagGroupsExtension, + }); + const { groups: webhookGroups } = computeTagGroups(serviceNode, NodeType.HttpWebhook, { + useTagGroups: isHavingTagGroupsExtension, + }); return ( @@ -125,7 +132,7 @@ const Group = React.memo<{ group: TagGroup }>(({ gr const onClick = React.useCallback(() => setIsExpanded(!isExpanded), [isExpanded]); const shouldExpand = React.useMemo(() => { - return urlHashMatches || group.items.some(item => itemMatchesHash(hash, item)); + return urlHashMatches || group.items!.some(item => itemMatchesHash(hash, item)); }, [group, hash, urlHashMatches]); React.useEffect(() => { @@ -159,7 +166,7 @@ const Group = React.memo<{ group: TagGroup }>(({ gr - {group.items.map(item => { + {group.items!.map(item => { return ; })} diff --git a/packages/elements/src/components/API/__tests__/computeAPITreeByTagGroups.test.ts b/packages/elements/src/components/API/__tests__/computeAPITreeByTagGroups.test.ts new file mode 100644 index 000000000..4699fcda1 --- /dev/null +++ b/packages/elements/src/components/API/__tests__/computeAPITreeByTagGroups.test.ts @@ -0,0 +1,623 @@ +import { NodeType } from '@stoplight/types'; +import { OpenAPIObject as _OpenAPIObject, PathObject } from 'openapi3-ts'; + +import { transformOasToServiceNode } from '../../../utils/oas'; +import { computeAPITree } from '../computeAPITree'; + +type OpenAPIObject = Partial<_OpenAPIObject> & { + webhooks?: PathObject; +}; +describe.each([['paths', NodeType.HttpOperation, 'path']] as const)( + 'when grouping from "%s" as %s', + (pathProp, nodeType, parentKeyProp) => { + describe('computeAPITreeByTagGroups', () => { + it('generates API ToC tree', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + 'x-tagGroups': [ + { + name: 'Accounts', + tags: ['Account Closure'], + }, + { + name: 'Processes', + tags: ['Account Processes'], + }, + ], + [pathProp]: { + '/something': { + get: { + tags: ['Account Closure'], + responses: { + 200: { + schema: { $ref: '#/definitions/schemas/ImportantSchema' }, + }, + }, + }, + }, + '/something/process': { + get: { + tags: ['Account Processes'], + responses: { + 200: { + schema: { $ref: '#/definitions/schemas/ImportantSchema' }, + }, + }, + }, + }, + }, + components: { + schemas: { + ImportantSchema: { + type: 'object', + properties: { + a: { type: 'string' }, + }, + }, + }, + }, + }; + + const apiTree = computeAPITree(transformOasToServiceNode(apiDocument)!); + expect(apiTree).toEqual([ + { + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', + }, + { + title: 'Endpoints', + }, + { + title: 'Accounts', + }, + { + title: 'Account Closure', + itemsType: 'http_operation', + items: [ + { + id: '/paths/something/get', + meta: 'get', + slug: '/paths/something/get', + title: '/something', + type: 'http_operation', + }, + ], + }, + { + title: 'Processes', + }, + { + title: 'Account Processes', + itemsType: 'http_operation', + items: [ + { + id: '/paths/something-process/get', + meta: 'get', + slug: '/paths/something-process/get', + title: '/something/process', + type: 'http_operation', + }, + ], + }, + { + title: 'Schemas', + }, + { + id: '/schemas/ImportantSchema', + meta: '', + slug: '/schemas/ImportantSchema', + title: 'ImportantSchema', + type: 'model', + }, + ]); + }); + + it('allows to customise tag name with x-displayName extension', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + 'x-tagGroups': [ + { + name: 'Accounts', + tags: ['Account Closure'], + }, + { + name: 'Processes', + tags: ['Account Processes'], + }, + ], + tags: [ + { + name: 'Account Closure', + 'x-displayName': 'Account Offboarding Processes', + }, + { + name: 'Account Processes', + 'x-displayName': 'Account Onboarding Processes', + }, + ], + [pathProp]: { + '/something': { + get: { + tags: ['Account Closure'], + responses: { + 200: { + schema: { $ref: '#/definitions/schemas/ImportantSchema' }, + }, + }, + }, + }, + '/something/process': { + get: { + tags: ['Account Processes'], + responses: { + 200: { + schema: { $ref: '#/definitions/schemas/ImportantSchema' }, + }, + }, + }, + }, + }, + components: { + schemas: { + ImportantSchema: { + type: 'object', + properties: { + a: { type: 'string' }, + }, + }, + }, + }, + }; + + const apiTree = computeAPITree(transformOasToServiceNode(apiDocument)!); + expect(apiTree).toEqual([ + { + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', + }, + { + title: 'Endpoints', + }, + { + title: 'Accounts', + }, + { + title: 'Account Offboarding Processes', + itemsType: 'http_operation', + items: [ + { + id: '/paths/something/get', + meta: 'get', + slug: '/paths/something/get', + title: '/something', + type: 'http_operation', + }, + ], + }, + { + title: 'Processes', + }, + { + title: 'Account Onboarding Processes', + itemsType: 'http_operation', + items: [ + { + id: '/paths/something-process/get', + meta: 'get', + slug: '/paths/something-process/get', + title: '/something/process', + type: 'http_operation', + }, + ], + }, + { + title: 'Schemas', + }, + { + id: '/schemas/ImportantSchema', + meta: '', + slug: '/schemas/ImportantSchema', + title: 'ImportantSchema', + type: 'model', + }, + ]); + }); + + it('allows to hide schemas from ToC', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + 'x-tagGroups': [ + { + name: 'Accounts', + tags: ['Account Closure'], + }, + { + name: 'Processes', + tags: ['Account Processes'], + }, + ], + [pathProp]: { + '/something': { + get: { + tags: ['Account Closure'], + responses: { + 200: { + schema: { $ref: '#/definitions/schemas/ImportantSchema' }, + }, + }, + }, + }, + }, + components: { + schemas: { + ImportantSchema: { + type: 'object', + properties: { + a: { type: 'string' }, + }, + }, + }, + }, + }; + + expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideSchemas: true })).toEqual([ + { + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', + }, + { + title: 'Endpoints', + }, + { + title: 'Accounts', + }, + { + title: 'Account Closure', + itemsType: 'http_operation', + items: [ + { + id: '/paths/something/get', + meta: 'get', + slug: '/paths/something/get', + title: '/something', + type: 'http_operation', + }, + ], + }, + ]); + }); + + it('allows to hide internal operations from ToC', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + 'x-tagGroups': [ + { + name: 'Accounts', + tags: ['Account Closure'], + }, + { + name: 'Processes', + tags: ['Account Processes'], + }, + ], + [pathProp]: { + '/something': { + get: { + tags: ['Account Closure'], + }, + post: { + tags: ['Account Closure'], + 'x-internal': true, + }, + }, + }, + }; + + const apiTree = computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true }); + expect(apiTree).toEqual([ + { + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', + }, + { + title: 'Endpoints', + }, + { + title: 'Accounts', + }, + { + title: 'Account Closure', + itemsType: 'http_operation', + items: [ + { + id: '/paths/something/get', + slug: '/paths/something/get', + title: '/something', + type: 'http_operation', + meta: 'get', + }, + ], + }, + ]); + }); + + it('allows to hide nested internal operations from ToC', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + 'x-tagGroups': [ + { + name: 'Accounts', + tags: ['Account Closure'], + }, + { + name: 'Processes', + tags: ['Account Processes'], + }, + ], + tags: [ + { + name: 'Account Processes', + }, + ], + [pathProp]: { + '/something': { + get: { + tags: ['Account Processes'], + }, + post: { + 'x-internal': true, + tags: ['Account Processes'], + }, + }, + }, + }; + + expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ + { + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', + }, + { + title: 'Endpoints', + }, + { + title: 'Processes', + }, + { + title: 'Account Processes', + itemsType: 'http_operation', + items: [ + { + id: '/paths/something/get', + meta: 'get', + slug: '/paths/something/get', + title: '/something', + type: 'http_operation', + }, + ], + }, + ]); + }); + + it('allows to hide internal models from ToC', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + 'x-tagGroups': [ + { + name: 'Accounts', + tags: ['Account Closure'], + }, + { + name: 'Processes', + tags: ['Account Processes'], + }, + ], + [pathProp]: {}, + components: { + schemas: { + SomeInternalSchema: { + 'x-internal': true, + }, + }, + }, + }; + + expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ + { + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', + }, + ]); + }); + + it('allows to hide nested internal models from ToC', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + 'x-tagGroups': [ + { + name: 'Accounts', + tags: ['Account Closure'], + }, + { + name: 'Processes', + tags: ['Account Processes'], + }, + ], + tags: [ + { + name: 'Account Processes', + }, + ], + [pathProp]: {}, + components: { + schemas: { + a: { + 'x-tags': ['Account Processes'], + }, + b: { + 'x-tags': ['Account Processes'], + 'x-internal': true, + }, + }, + }, + }; + + expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ + { + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', + }, + { title: 'Schemas' }, + { + title: 'Processes', + }, + { + title: 'Account Processes', + itemsType: NodeType.Model, + items: [ + { + id: '/schemas/a', + slug: '/schemas/a', + title: 'a', + type: 'model', + meta: '', + }, + ], + }, + ]); + }); + + it('excludes groups with no items', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + }, + 'x-tagGroups': [ + { + name: 'Accounts', + tags: ['Account Closure'], + }, + { + name: 'Processes', + tags: ['Account Processes'], + }, + ], + tags: [ + { + name: 'Account Processes', + }, + ], + [pathProp]: { + '/something': { + post: { + 'x-internal': true, + tags: ['Account Processes'], + }, + }, + '/something-else': { + post: { + tags: ['Account Processes'], + }, + }, + }, + components: { + schemas: { + a: { + 'x-tags': ['Account Processes'], + 'x-internal': true, + }, + }, + }, + }; + + expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ + { + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', + }, + { + title: 'Endpoints', + }, + { + title: 'Processes', + }, + { + title: 'Account Processes', + itemsType: nodeType, + items: [ + { + id: `/${pathProp}/something-else/post`, + meta: 'post', + slug: `/${pathProp}/something-else/post`, + title: '/something-else', + type: nodeType, + }, + ], + }, + ]); + }); + }); + }, +); diff --git a/packages/elements/src/components/API/__tests__/utils.test.ts b/packages/elements/src/components/API/__tests__/utils.test.ts index c9a9ec9f0..b93bf97c8 100644 --- a/packages/elements/src/components/API/__tests__/utils.test.ts +++ b/packages/elements/src/components/API/__tests__/utils.test.ts @@ -3,7 +3,8 @@ import { OpenAPIObject as _OpenAPIObject, PathObject } from 'openapi3-ts'; import { transformOasToServiceNode } from '../../../utils/oas'; import { OperationNode, SchemaNode, WebhookNode } from '../../../utils/oas/types'; -import { computeAPITree, computeTagGroups } from '../utils'; +import { computeAPITree } from '../computeAPITree'; +import { computeTagGroups } from '../utils'; type OpenAPIObject = Partial<_OpenAPIObject> & { webhooks?: PathObject; @@ -44,7 +45,11 @@ describe.each([ }; const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode, nodeType) : null).toEqual({ + expect( + serviceNode + ? computeTagGroups(serviceNode, nodeType, { useTagGroups: false }) + : null, + ).toEqual({ groups: [ { title: 'beta', @@ -153,7 +158,11 @@ describe.each([ }; const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode, nodeType) : null).toEqual({ + expect( + serviceNode + ? computeTagGroups(serviceNode, nodeType, { useTagGroups: false }) + : null, + ).toEqual({ groups: [ { title: 'beta', @@ -258,7 +267,11 @@ describe.each([ }; const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode, nodeType) : null).toEqual({ + expect( + serviceNode + ? computeTagGroups(serviceNode, nodeType, { useTagGroups: false }) + : null, + ).toEqual({ groups: [ { title: 'beta', @@ -344,7 +357,11 @@ describe.each([ }; const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode, nodeType) : null).toEqual({ + expect( + serviceNode + ? computeTagGroups(serviceNode, nodeType, { useTagGroups: false }) + : null, + ).toEqual({ groups: [], ungrouped: [], }); @@ -381,7 +398,11 @@ describe.each([ }; const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode, nodeType) : null).toEqual({ + expect( + serviceNode + ? computeTagGroups(serviceNode, nodeType, { useTagGroups: false }) + : null, + ).toEqual({ groups: [ { title: 'Beta', @@ -485,7 +506,11 @@ describe.each([ }; const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode, nodeType) : null).toEqual({ + expect( + serviceNode + ? computeTagGroups(serviceNode, nodeType, { useTagGroups: false }) + : null, + ).toEqual({ groups: [ { title: 'Beta', @@ -941,7 +966,9 @@ describe('when grouping models', () => { }; const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode, NodeType.Model) : null).toEqual({ + expect( + serviceNode ? computeTagGroups(serviceNode, NodeType.Model, { useTagGroups: false }) : null, + ).toEqual({ groups: [ { title: 'beta', @@ -1008,7 +1035,9 @@ describe('when grouping models', () => { }; const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode, NodeType.Model) : null).toEqual({ + expect( + serviceNode ? computeTagGroups(serviceNode, NodeType.Model, { useTagGroups: false }) : null, + ).toEqual({ groups: [ { title: 'beta', @@ -1081,7 +1110,9 @@ describe('when grouping models', () => { }; const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode, NodeType.Model) : null).toEqual({ + expect( + serviceNode ? computeTagGroups(serviceNode, NodeType.Model, { useTagGroups: false }) : null, + ).toEqual({ groups: [ { title: 'Beta', @@ -1145,7 +1176,9 @@ describe('when grouping models', () => { }; const serviceNode = transformOasToServiceNode(apiDocument); - expect(serviceNode ? computeTagGroups(serviceNode, NodeType.Model) : null).toEqual({ + expect( + serviceNode ? computeTagGroups(serviceNode, NodeType.Model, { useTagGroups: false }) : null, + ).toEqual({ groups: [ { title: 'Beta', diff --git a/packages/elements/src/components/API/computeAPITree.ts b/packages/elements/src/components/API/computeAPITree.ts new file mode 100644 index 000000000..679409adc --- /dev/null +++ b/packages/elements/src/components/API/computeAPITree.ts @@ -0,0 +1,102 @@ +import { OperationNode, SchemaNode, ServiceNode, WebhookNode } from '@stoplight/elements/utils/oas/types'; +import { TableOfContentsItem } from '@stoplight/elements-core'; +import { NodeType } from '@stoplight/types'; +import { defaults } from 'lodash'; + +import { addTagGroupsToTree, computeTagGroups, isInternal } from './utils'; + +export interface ComputeAPITreeConfig { + hideSchemas?: boolean; + hideInternal?: boolean; +} + +export const defaultComputerAPITreeConfig = { + hideSchemas: false, + hideInternal: false, +}; + +export const computeAPITree = (serviceNode: ServiceNode, config: ComputeAPITreeConfig = {}) => { + const mergedConfig = defaults(config, defaultComputerAPITreeConfig); + const tree: TableOfContentsItem[] = []; + + // + const rootVendorExtensions = Object.keys(serviceNode.data.extensions ?? {}).map(item => item.toLowerCase()); + const isHavingTagGroupsExtension = typeof rootVendorExtensions['x-taggroups'] !== undefined; + + tree.push({ + id: '/', + slug: '/', + title: 'Overview', + type: 'overview', + meta: '', + }); + + const hasOperationNodes = serviceNode.children.some(node => node.type === NodeType.HttpOperation); + if (hasOperationNodes) { + tree.push({ + title: 'Endpoints', + }); + + const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.HttpOperation, { + useTagGroups: isHavingTagGroupsExtension, + }); + addTagGroupsToTree(groups, ungrouped, tree, NodeType.HttpOperation, { + hideInternal: mergedConfig.hideInternal, + useTagGroups: isHavingTagGroupsExtension, + }); + } + + const hasWebhookNodes = serviceNode.children.some(node => node.type === NodeType.HttpWebhook, { + useTagGroups: isHavingTagGroupsExtension, + }); + if (hasWebhookNodes) { + tree.push({ + title: 'Webhooks', + }); + + const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.HttpWebhook, { + useTagGroups: isHavingTagGroupsExtension, + }); + addTagGroupsToTree(groups, ungrouped, tree, NodeType.HttpWebhook, { + hideInternal: mergedConfig.hideInternal, + useTagGroups: isHavingTagGroupsExtension, + }); + } + + let schemaNodes = serviceNode.children.filter(node => node.type === NodeType.Model); + if (mergedConfig.hideInternal) { + schemaNodes = schemaNodes.filter(n => !isInternal(n)); + } + + if (!mergedConfig.hideSchemas && schemaNodes.length) { + tree.push({ + title: 'Schemas', + }); + + const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.Model, { + useTagGroups: isHavingTagGroupsExtension, + }); + addTagGroupsToTree(groups, ungrouped, tree, NodeType.Model, { + hideInternal: mergedConfig.hideInternal, + useTagGroups: isHavingTagGroupsExtension, + }); + } + return tree; +}; + +export const findFirstNodeSlug = (tree: TableOfContentsItem[]): string | void => { + for (const item of tree) { + if ('slug' in item) { + return item.slug; + } + + if ('items' in item) { + const slug = findFirstNodeSlug(item.items); + if (slug) { + return slug; + } + } + } + + return; +}; diff --git a/packages/elements/src/components/API/utils.ts b/packages/elements/src/components/API/utils.ts index 8213f235e..d500cada6 100644 --- a/packages/elements/src/components/API/utils.ts +++ b/packages/elements/src/components/API/utils.ts @@ -5,44 +5,116 @@ import { TableOfContentsGroup, TableOfContentsItem, } from '@stoplight/elements-core'; -import { NodeType } from '@stoplight/types'; -import { defaults } from 'lodash'; import { OperationNode, SchemaNode, ServiceChildNode, ServiceNode, WebhookNode } from '../../utils/oas/types'; type GroupableNode = OperationNode | WebhookNode | SchemaNode; -export type TagGroup = { title: string; items: T[] }; +type OpenApiTagGroup = { name: string; tags: string[] }; -export function computeTagGroups(serviceNode: ServiceNode, nodeType: T['type']) { +export type TagGroup = { title: string; items?: T[] }; + +export function computeTagGroups( + serviceNode: ServiceNode, + nodeType: T['type'], + config: { useTagGroups: boolean }, +): { groups: TagGroup[]; ungrouped: T[] } { const groupsByTagId: { [tagId: string]: TagGroup } = {}; + const nodesByTagId: { [tagId: string]: TagGroup } = {}; const ungrouped: T[] = []; - + const { useTagGroups = false } = config ?? {}; const lowerCaseServiceTags = serviceNode.tags.map(tn => tn.toLowerCase()); const groupableNodes = serviceNode.children.filter(n => n.type === nodeType) as T[]; + const rawServiceTags = serviceNode.data.tags ?? []; + + const serviceExtensions = serviceNode.data.extensions ?? {}; + const tagGroupExtensionName = Object.keys(serviceExtensions).find(item => item.toLowerCase() === 'x-taggroups'); + const tagGroups: OpenApiTagGroup[] = tagGroupExtensionName + ? (serviceExtensions[tagGroupExtensionName] as OpenApiTagGroup[]) + : []; + for (const node of groupableNodes) { const tagName = node.tags[0]; if (tagName) { const tagId = tagName.toLowerCase(); if (groupsByTagId[tagId]) { - groupsByTagId[tagId].items.push(node); + groupsByTagId[tagId].items?.push(node); } else { const serviceTagIndex = lowerCaseServiceTags.findIndex(tn => tn === tagId); - const serviceTagName = serviceNode.tags[serviceTagIndex]; + const rawServiceTag = rawServiceTags[serviceTagIndex]; + let serviceTagName = serviceNode.tags[serviceTagIndex]; + if (rawServiceTag && typeof rawServiceTag['x-displayName'] !== 'undefined') { + serviceTagName = rawServiceTag['x-displayName']; + } + groupsByTagId[tagId] = { title: serviceTagName || tagName, items: [node], }; } + + // Only bother collecting node-groups mapping data when tag groups are used + if (useTagGroups) { + for (const nodeTag of node.tags) { + const nodeTagId = nodeTag.toLowerCase(); + const serviceTag = rawServiceTags.find(t => t.name.toLowerCase() === nodeTagId); + + let nodeTagName = nodeTag; + if (serviceTag && typeof serviceTag['x-displayName'] !== 'undefined') { + nodeTagName = serviceTag['x-displayName']; + } + + if (nodesByTagId[nodeTagId]) { + nodesByTagId[nodeTagId].items?.push(node); + } else { + nodesByTagId[nodeTagId] = { + title: nodeTagName, + items: [node], + }; + } + } + } } else { ungrouped.push(node); } } - const orderedTagGroups = Object.entries(groupsByTagId) + let orderedTagGroups: TagGroup[] = []; + if (useTagGroups) { + let grouped: TagGroup[] = []; + for (const tagGroup of tagGroups) { + if (!tagGroup.tags.length) { + continue; + } + + const tagGroups = []; + for (const tag of tagGroup.tags) { + const tagGroupTagId = tag.toLowerCase(); + const entries = nodesByTagId[tagGroupTagId]; + if (entries) { + tagGroups.push(entries); + } + } + + // + if (tagGroups.length > 0) { + grouped.push({ + title: tagGroup.name, + }); + + for (const entries of tagGroups) { + grouped.push(entries); + } + } + } + + return { groups: grouped, ungrouped }; + } + + orderedTagGroups = Object.entries(groupsByTagId) .sort(([g1], [g2]) => { const g1LC = g1.toLowerCase(); const g2LC = g2.toLowerCase(); @@ -62,81 +134,6 @@ export function computeTagGroups(serviceNode: ServiceNo return { groups: orderedTagGroups, ungrouped }; } -interface ComputeAPITreeConfig { - hideSchemas?: boolean; - hideInternal?: boolean; -} - -const defaultComputerAPITreeConfig = { - hideSchemas: false, - hideInternal: false, -}; - -export const computeAPITree = (serviceNode: ServiceNode, config: ComputeAPITreeConfig = {}) => { - const mergedConfig = defaults(config, defaultComputerAPITreeConfig); - const tree: TableOfContentsItem[] = []; - - tree.push({ - id: '/', - slug: '/', - title: 'Overview', - type: 'overview', - meta: '', - }); - - const hasOperationNodes = serviceNode.children.some(node => node.type === NodeType.HttpOperation); - if (hasOperationNodes) { - tree.push({ - title: 'Endpoints', - }); - - const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.HttpOperation); - addTagGroupsToTree(groups, ungrouped, tree, NodeType.HttpOperation, mergedConfig.hideInternal); - } - - const hasWebhookNodes = serviceNode.children.some(node => node.type === NodeType.HttpWebhook); - if (hasWebhookNodes) { - tree.push({ - title: 'Webhooks', - }); - - const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.HttpWebhook); - addTagGroupsToTree(groups, ungrouped, tree, NodeType.HttpWebhook, mergedConfig.hideInternal); - } - - let schemaNodes = serviceNode.children.filter(node => node.type === NodeType.Model); - if (mergedConfig.hideInternal) { - schemaNodes = schemaNodes.filter(n => !isInternal(n)); - } - - if (!mergedConfig.hideSchemas && schemaNodes.length) { - tree.push({ - title: 'Schemas', - }); - - const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.Model); - addTagGroupsToTree(groups, ungrouped, tree, NodeType.Model, mergedConfig.hideInternal); - } - return tree; -}; - -export const findFirstNodeSlug = (tree: TableOfContentsItem[]): string | void => { - for (const item of tree) { - if ('slug' in item) { - return item.slug; - } - - if ('items' in item) { - const slug = findFirstNodeSlug(item.items); - if (slug) { - return slug; - } - } - } - - return; -}; - export const isInternal = (node: ServiceChildNode | ServiceNode): boolean => { const data = node.data; @@ -151,13 +148,15 @@ export const isInternal = (node: ServiceChildNode | ServiceNode): boolean => { return !!data['x-internal']; }; -const addTagGroupsToTree = ( +export const addTagGroupsToTree = ( groups: TagGroup[], ungrouped: T[], tree: TableOfContentsItem[], itemsType: TableOfContentsGroup['itemsType'], - hideInternal: boolean, + config: { hideInternal: boolean; useTagGroups: boolean }, ) => { + const { hideInternal = false } = config ?? {}; + // Show ungrouped nodes above tag groups ungrouped.forEach(node => { if (hideInternal && isInternal(node)) { @@ -173,7 +172,7 @@ const addTagGroupsToTree = ( }); groups.forEach(group => { - const items = group.items.flatMap(node => { + const items = group.items?.flatMap(node => { if (hideInternal && isInternal(node)) { return []; } @@ -185,12 +184,17 @@ const addTagGroupsToTree = ( meta: isHttpOperation(node.data) || isHttpWebhookOperation(node.data) ? node.data.method : '', }; }); - if (items.length > 0) { + + if (items && items.length > 0) { tree.push({ title: group.title, items, itemsType, }); + } else { + tree.push({ + title: group.title, + }); } }); }; From d168a9b854ccba13527308695c8fd36617ee159b Mon Sep 17 00:00:00 2001 From: Weyert de Boer Date: Mon, 24 Jun 2024 19:06:12 +0000 Subject: [PATCH 2/6] test: resolve the broken unit tests --- .../simpleApiWithTagGroups.ts | 268 ++++++++++++++++++ .../components/API/APIWithStackedLayout.tsx | 34 ++- .../components/API/__tests__/utils.test.ts | 39 ++- .../src/components/API/computeAPITree.ts | 5 +- packages/elements/src/components/API/utils.ts | 16 +- packages/elements/src/containers/API.spec.tsx | 4 +- .../elements/src/containers/API.stories.tsx | 16 ++ 7 files changed, 356 insertions(+), 26 deletions(-) create mode 100644 packages/elements/src/__fixtures__/api-descriptions/simpleApiWithTagGroups.ts diff --git a/packages/elements/src/__fixtures__/api-descriptions/simpleApiWithTagGroups.ts b/packages/elements/src/__fixtures__/api-descriptions/simpleApiWithTagGroups.ts new file mode 100644 index 000000000..ad6e6a935 --- /dev/null +++ b/packages/elements/src/__fixtures__/api-descriptions/simpleApiWithTagGroups.ts @@ -0,0 +1,268 @@ +export const simpleApiWithTagGroups = { + swagger: '2.0', + info: { + title: 'To-dos', + description: 'Great API, but has internal operations.', + version: '1.0', + contact: { + name: 'Stoplight', + url: 'https://stoplight.io', + }, + license: { + name: 'MIT', + }, + }, + host: 'todos.stoplight.io', + schemes: ['https', 'http'], + consumes: ['application/json'], + produces: ['application/json'], + securityDefinitions: { + apikey: { + name: 'apikey', + type: 'apiKey', + in: 'query', + description: "Use `?apikey=123` to authenticate requests. It's super secure.", + }, + }, + tags: [ + { + name: 'Todos', + 'x-displayName': 'To-dos', + }, + { + name: 'Retrieval', + 'x-displayName': 'Retrieve To-do Items', + }, + { + name: 'Management', + 'x-displayName': 'Add/remove To-do Items', + }, + ], + 'x-tagGroups': [ + { + name: 'Todos', + tags: ['Retrieval', 'Management'], + }, + ], + paths: { + '/todos/{todoId}': { + parameters: [ + { + name: 'todoId', + in: 'path', + required: true, + type: 'string', + }, + ], + get: { + operationId: 'GET_todo', + summary: 'Get Todo', + tags: ['Retrieval'], + 'x-internal': true, + responses: { + '200': { + description: '', + schema: { + $ref: './models/todo-full.v1.json', + }, + examples: { + 'application/json': { + id: 1, + name: 'get food', + completed: false, + completed_at: '1955-04-23T13:22:52.685Z', + created_at: '1994-11-05T03:26:51.471Z', + updated_at: '1989-07-29T11:30:06.701Z', + }, + }, + }, + '404': { + $ref: '../common/openapi.v1.yaml#/responses/404', + }, + '500': { + $ref: '../common/openapi.v1.yaml#/responses/500', + }, + }, + }, + put: { + operationId: 'PUT_todos', + summary: 'Update Todo', + tags: ['Management'], + parameters: [ + { + name: 'body', + in: 'body', + schema: { + $ref: './models/todo-partial.v1.json', + example: { + name: "my todo's new name", + completed: false, + }, + }, + }, + ], + responses: { + '200': { + description: '', + schema: { + $ref: './models/todo-full.v1.json', + }, + examples: { + 'application/json': { + id: 9000, + name: "It's Over 9000!!!", + completed: true, + completed_at: null, + created_at: '2014-08-28T14:14:28.494Z', + updated_at: '2015-08-28T14:14:28.494Z', + }, + }, + }, + '401': { + $ref: '../common/openapi.v1.yaml#/responses/401', + }, + '404': { + $ref: '../common/openapi.v1.yaml#/responses/404', + }, + '500': { + $ref: '../common/openapi.v1.yaml#/responses/500', + }, + }, + security: [ + { + apikey: [], + }, + ], + }, + delete: { + operationId: 'DELETE_todo', + summary: 'Delete Todo', + tags: ['Todos'], + responses: { + '204': { + description: '', + }, + '401': { + $ref: '../common/openapi.v1.yaml#/responses/401', + }, + '404': { + $ref: '../common/openapi.v1.yaml#/responses/404', + }, + '500': { + $ref: '../common/openapi.v1.yaml#/responses/500', + }, + }, + security: [ + { + apikey: [], + }, + ], + }, + }, + '/todos': { + post: { + operationId: 'POST_todos', + summary: 'Create Todo', + tags: ['Todos'], + parameters: [ + { + name: 'body', + in: 'body', + schema: { + $ref: './models/todo-partial.v1.json', + example: { + name: "my todo's name", + completed: false, + }, + }, + }, + ], + responses: { + '201': { + description: '', + schema: { + $ref: './models/todo-full.v1.json', + }, + examples: { + 'application/json': { + id: 9000, + name: "It's Over 9000!!!", + completed: null, + completed_at: null, + created_at: '2014-08-28T14:14:28.494Z', + updated_at: '2014-08-28T14:14:28.494Z', + }, + }, + }, + '401': { + $ref: '../common/openapi.v1.yaml#/responses/401', + }, + '500': { + $ref: '../common/openapi.v1.yaml#/responses/500', + }, + }, + security: [ + { + apikey: [], + }, + ], + description: 'This creates a Todo object.\n\nTesting `inline code`.', + }, + get: { + operationId: 'GET_todos', + summary: 'List Todos', + tags: ['Todos'], + parameters: [ + { + $ref: '../common/openapi.v1.yaml#/parameters/limit', + }, + { + $ref: '../common/openapi.v1.yaml#/parameters/skip', + }, + ], + responses: { + '200': { + description: 'wefwefwef', + schema: { + type: 'array', + items: { + $ref: './models/todo-full.v1.json', + }, + }, + examples: { + 'application/json': [ + { + id: 1, + name: 'design the thingz', + completed: true, + }, + { + id: 2, + name: 'mock the thingz', + completed: true, + }, + { + id: 3, + name: 'code the thingz', + completed: false, + }, + ], + }, + }, + '500': { + $ref: '../common/openapi.v1.yaml#/responses/500', + }, + }, + description: 'This returns a list of todos.', + }, + }, + }, + definitions: { + InternalSchema: { + title: 'Internal Schema', + description: 'Fun Internal Schema', + schema: { type: 'object' }, + 'x-internal': true, + }, + }, +}; diff --git a/packages/elements/src/components/API/APIWithStackedLayout.tsx b/packages/elements/src/components/API/APIWithStackedLayout.tsx index 0a596501b..863f1430f 100644 --- a/packages/elements/src/components/API/APIWithStackedLayout.tsx +++ b/packages/elements/src/components/API/APIWithStackedLayout.tsx @@ -80,7 +80,8 @@ export const APIWithStackedLayout: React.FC = ({ location, }) => { const rootVendorExtensions = Object.keys(serviceNode.data.extensions ?? {}).map(item => item.toLowerCase()); - const isHavingTagGroupsExtension = typeof rootVendorExtensions['x-taggroups'] !== undefined; + const isHavingTagGroupsExtension = + typeof rootVendorExtensions['x-taggroups'] !== undefined && rootVendorExtensions.length > 0; const { groups: operationGroups } = computeTagGroups(serviceNode, NodeType.HttpOperation, { useTagGroups: isHavingTagGroupsExtension, @@ -107,13 +108,25 @@ export const APIWithStackedLayout: React.FC = ({ /> {operationGroups.length > 0 && webhookGroups.length > 0 ? Endpoints : null} - {operationGroups.map(group => ( - - ))} + {operationGroups.map(group => + group.isDivider ? ( + + {group.title} + + ) : ( + + ), + )} {webhookGroups.length > 0 ? Webhooks : null} - {webhookGroups.map(group => ( - - ))} + {webhookGroups.map(group => + group.isDivider ? ( + + {group.title} + + ) : ( + + ), + )} @@ -132,7 +145,10 @@ const Group = React.memo<{ group: TagGroup }>(({ gr const onClick = React.useCallback(() => setIsExpanded(!isExpanded), [isExpanded]); const shouldExpand = React.useMemo(() => { - return urlHashMatches || group.items!.some(item => itemMatchesHash(hash, item)); + const groupMatches = (group.items ?? []).some(item => { + return itemMatchesHash(hash, item); + }); + return urlHashMatches || groupMatches; }, [group, hash, urlHashMatches]); React.useEffect(() => { @@ -166,7 +182,7 @@ const Group = React.memo<{ group: TagGroup }>(({ gr - {group.items!.map(item => { + {(group.items ?? []).map(item => { return ; })} diff --git a/packages/elements/src/components/API/__tests__/utils.test.ts b/packages/elements/src/components/API/__tests__/utils.test.ts index b93bf97c8..a537cea35 100644 --- a/packages/elements/src/components/API/__tests__/utils.test.ts +++ b/packages/elements/src/components/API/__tests__/utils.test.ts @@ -45,14 +45,14 @@ describe.each([ }; const serviceNode = transformOasToServiceNode(apiDocument); - expect( - serviceNode - ? computeTagGroups(serviceNode, nodeType, { useTagGroups: false }) - : null, - ).toEqual({ + const tagGroups = serviceNode + ? computeTagGroups(serviceNode, nodeType, { useTagGroups: false }) + : null; + expect(tagGroups).toEqual({ groups: [ { title: 'beta', + isDivider: false, items: [ { type: nodeType, @@ -86,6 +86,7 @@ describe.each([ }, { title: 'alpha', + isDivider: false, items: [ { type: nodeType, @@ -158,14 +159,14 @@ describe.each([ }; const serviceNode = transformOasToServiceNode(apiDocument); - expect( - serviceNode - ? computeTagGroups(serviceNode, nodeType, { useTagGroups: false }) - : null, - ).toEqual({ + const tagGroups = serviceNode + ? computeTagGroups(serviceNode, nodeType, { useTagGroups: false }) + : null; + expect(tagGroups).toEqual({ groups: [ { title: 'beta', + isDivider: false, items: [ { type: nodeType, @@ -207,6 +208,7 @@ describe.each([ }, { title: 'alpha', + isDivider: false, items: [ { type: nodeType, @@ -275,6 +277,7 @@ describe.each([ groups: [ { title: 'beta', + isDivider: false, items: [ { type: nodeType, @@ -316,6 +319,7 @@ describe.each([ }, { title: 'alpha', + isDivider: false, items: [ { type: nodeType, @@ -406,6 +410,7 @@ describe.each([ groups: [ { title: 'Beta', + isDivider: false, items: [ { type: nodeType, @@ -439,6 +444,7 @@ describe.each([ }, { title: 'alpha', + isDivider: false, items: [ { type: nodeType, @@ -514,6 +520,7 @@ describe.each([ groups: [ { title: 'Beta', + isDivider: false, items: [ { type: nodeType, @@ -547,6 +554,7 @@ describe.each([ }, { title: 'alpha', + isDivider: false, items: [ { type: nodeType, @@ -906,7 +914,8 @@ describe.each([ }, }; - expect(computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true })).toEqual([ + const apiTree = computeAPITree(transformOasToServiceNode(apiDocument)!, { hideInternal: true }); + expect(apiTree).toEqual([ { id: '/', meta: '', @@ -972,6 +981,7 @@ describe('when grouping models', () => { groups: [ { title: 'beta', + isDivider: false, items: [ { type: NodeType.Model, @@ -986,6 +996,7 @@ describe('when grouping models', () => { }, { title: 'alpha', + isDivider: false, items: [ { type: NodeType.Model, @@ -1041,6 +1052,7 @@ describe('when grouping models', () => { groups: [ { title: 'beta', + isDivider: false, items: [ { type: NodeType.Model, @@ -1064,6 +1076,7 @@ describe('when grouping models', () => { }, { title: 'alpha', + isDivider: false, items: [ { type: NodeType.Model, @@ -1116,6 +1129,7 @@ describe('when grouping models', () => { groups: [ { title: 'Beta', + isDivider: false, items: [ { type: NodeType.Model, @@ -1130,6 +1144,7 @@ describe('when grouping models', () => { }, { title: 'alpha', + isDivider: false, items: [ { type: NodeType.Model, @@ -1182,6 +1197,7 @@ describe('when grouping models', () => { groups: [ { title: 'Beta', + isDivider: false, items: [ { type: NodeType.Model, @@ -1196,6 +1212,7 @@ describe('when grouping models', () => { }, { title: 'alpha', + isDivider: false, items: [ { type: NodeType.Model, diff --git a/packages/elements/src/components/API/computeAPITree.ts b/packages/elements/src/components/API/computeAPITree.ts index 679409adc..9dc4d9f95 100644 --- a/packages/elements/src/components/API/computeAPITree.ts +++ b/packages/elements/src/components/API/computeAPITree.ts @@ -19,9 +19,10 @@ export const computeAPITree = (serviceNode: ServiceNode, config: ComputeAPITreeC const mergedConfig = defaults(config, defaultComputerAPITreeConfig); const tree: TableOfContentsItem[] = []; - // + // check if spec has x-tagGroups extension const rootVendorExtensions = Object.keys(serviceNode.data.extensions ?? {}).map(item => item.toLowerCase()); - const isHavingTagGroupsExtension = typeof rootVendorExtensions['x-taggroups'] !== undefined; + const isHavingTagGroupsExtension = + typeof rootVendorExtensions['x-taggroups'] !== undefined && rootVendorExtensions.length > 0; tree.push({ id: '/', diff --git a/packages/elements/src/components/API/utils.ts b/packages/elements/src/components/API/utils.ts index d500cada6..ecf7642be 100644 --- a/packages/elements/src/components/API/utils.ts +++ b/packages/elements/src/components/API/utils.ts @@ -12,7 +12,7 @@ type GroupableNode = OperationNode | WebhookNode | SchemaNode; type OpenApiTagGroup = { name: string; tags: string[] }; -export type TagGroup = { title: string; items?: T[] }; +export type TagGroup = { title: string; isDivider: boolean; items?: T[] }; export function computeTagGroups( serviceNode: ServiceNode, @@ -52,6 +52,7 @@ export function computeTagGroups( groupsByTagId[tagId] = { title: serviceTagName || tagName, + isDivider: false, items: [node], }; } @@ -72,6 +73,7 @@ export function computeTagGroups( } else { nodesByTagId[nodeTagId] = { title: nodeTagName, + isDivider: false, items: [node], }; } @@ -101,8 +103,16 @@ export function computeTagGroups( // if (tagGroups.length > 0) { + let groupTitle = tagGroup.name; + + const groupTag = rawServiceTags.find(t => t.name.toLowerCase() === tagGroup.name.toLowerCase()); + if (groupTag && typeof groupTag['x-displayName'] !== 'undefined') { + groupTitle = groupTag['x-displayName']; + } + grouped.push({ - title: tagGroup.name, + title: groupTitle, + isDivider: true, }); for (const entries of tagGroups) { @@ -191,7 +201,7 @@ export const addTagGroupsToTree = ( items, itemsType, }); - } else { + } else if (group.isDivider) { tree.push({ title: group.title, }); diff --git a/packages/elements/src/containers/API.spec.tsx b/packages/elements/src/containers/API.spec.tsx index 8143e4720..cff01a37b 100644 --- a/packages/elements/src/containers/API.spec.tsx +++ b/packages/elements/src/containers/API.spec.tsx @@ -132,7 +132,9 @@ describe('API', () => { describe('stackedLayout', () => { it('shows operation path and method when collapsed', async () => { - render(); + const { container } = render( + , + ); const users = await screen.findByText('users'); act(() => userEvent.click(users)); diff --git a/packages/elements/src/containers/API.stories.tsx b/packages/elements/src/containers/API.stories.tsx index 2874bc585..9485d3e37 100644 --- a/packages/elements/src/containers/API.stories.tsx +++ b/packages/elements/src/containers/API.stories.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { badgesForSchema } from '../__fixtures__/api-descriptions/badgesForSchema'; import { simpleApiWithInternalOperations } from '../__fixtures__/api-descriptions/simpleApiWithInternalOperations'; import { simpleApiWithoutDescription } from '../__fixtures__/api-descriptions/simpleApiWithoutDescription'; +import { simpleApiWithTagGroups } from '../__fixtures__/api-descriptions/simpleApiWithTagGroups'; import { todosApiBundled } from '../__fixtures__/api-descriptions/todosApiBundled'; import { zoomApiYaml } from '../__fixtures__/api-descriptions/zoomApiYaml'; import { API, APIProps } from './API'; @@ -115,3 +116,18 @@ WithExtensionRenderer.args = { apiDescriptionDocument: zoomApiYaml, }; WithExtensionRenderer.storyName = 'With Extension Renderer'; + +export const WithTagGroups = Template.bind({}); +WithTagGroups.args = { + renderExtensionAddon: renderExtensionRenderer, + apiDescriptionDocument: simpleApiWithTagGroups, +}; +WithTagGroups.storyName = 'With Tag Groups'; + +export const WithStackedAndTagGroups = Template.bind({}); +WithStackedAndTagGroups.args = { + layout: 'stacked', + renderExtensionAddon: renderExtensionRenderer, + apiDescriptionDocument: simpleApiWithTagGroups, +}; +WithStackedAndTagGroups.storyName = 'Stacked Layout With Tag Groups'; From 1d7073afcde76b56da475416e960a1842ca624ae Mon Sep 17 00:00:00 2001 From: Weyert de Boer Date: Mon, 24 Jun 2024 19:24:11 +0000 Subject: [PATCH 3/6] build: resolve build issues in the `elements`-package --- packages/elements/src/components/API/computeAPITree.ts | 2 +- packages/elements/src/containers/API.spec.tsx | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/elements/src/components/API/computeAPITree.ts b/packages/elements/src/components/API/computeAPITree.ts index 9dc4d9f95..8de8bb53f 100644 --- a/packages/elements/src/components/API/computeAPITree.ts +++ b/packages/elements/src/components/API/computeAPITree.ts @@ -1,8 +1,8 @@ -import { OperationNode, SchemaNode, ServiceNode, WebhookNode } from '@stoplight/elements/utils/oas/types'; import { TableOfContentsItem } from '@stoplight/elements-core'; import { NodeType } from '@stoplight/types'; import { defaults } from 'lodash'; +import { OperationNode, SchemaNode, ServiceNode, WebhookNode } from '../../utils/oas/types'; import { addTagGroupsToTree, computeTagGroups, isInternal } from './utils'; export interface ComputeAPITreeConfig { diff --git a/packages/elements/src/containers/API.spec.tsx b/packages/elements/src/containers/API.spec.tsx index cff01a37b..8143e4720 100644 --- a/packages/elements/src/containers/API.spec.tsx +++ b/packages/elements/src/containers/API.spec.tsx @@ -132,9 +132,7 @@ describe('API', () => { describe('stackedLayout', () => { it('shows operation path and method when collapsed', async () => { - const { container } = render( - , - ); + render(); const users = await screen.findByText('users'); act(() => userEvent.click(users)); From 8b8c428cc6ff22d8ff0316227cd4def1b3f5e03c Mon Sep 17 00:00:00 2001 From: Weyert de Boer Date: Mon, 24 Jun 2024 19:27:46 +0000 Subject: [PATCH 4/6] style: resolve linting issues --- .../src/components/API/APIWithResponsiveSidebarLayout.tsx | 2 +- packages/elements/src/components/API/APIWithSidebarLayout.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/elements/src/components/API/APIWithResponsiveSidebarLayout.tsx b/packages/elements/src/components/API/APIWithResponsiveSidebarLayout.tsx index 59a728219..d816ade84 100644 --- a/packages/elements/src/components/API/APIWithResponsiveSidebarLayout.tsx +++ b/packages/elements/src/components/API/APIWithResponsiveSidebarLayout.tsx @@ -10,8 +10,8 @@ import * as React from 'react'; import { Redirect, useLocation } from 'react-router-dom'; import { ServiceNode } from '../../utils/oas/types'; -import { isInternal } from './utils'; import { computeAPITree, findFirstNodeSlug } from './computeAPITree'; +import { isInternal } from './utils'; type SidebarLayoutProps = { serviceNode: ServiceNode; diff --git a/packages/elements/src/components/API/APIWithSidebarLayout.tsx b/packages/elements/src/components/API/APIWithSidebarLayout.tsx index d53fd9869..feb038ceb 100644 --- a/packages/elements/src/components/API/APIWithSidebarLayout.tsx +++ b/packages/elements/src/components/API/APIWithSidebarLayout.tsx @@ -15,8 +15,8 @@ import * as React from 'react'; import { Link, Redirect, useLocation } from 'react-router-dom'; import { ServiceNode } from '../../utils/oas/types'; -import { isInternal } from './utils'; import { computeAPITree, findFirstNodeSlug } from './computeAPITree'; +import { isInternal } from './utils'; type SidebarLayoutProps = { serviceNode: ServiceNode; From b26c5504a07b4715cd3de934e645babb03cc214b Mon Sep 17 00:00:00 2001 From: Weyert de Boer <7049+weyert@users.noreply.github.com> Date: Thu, 15 Aug 2024 13:09:39 +0000 Subject: [PATCH 5/6] fix: resolve the build issues --- .../components/API/APIWithStackedLayout.tsx | 37 +++++++++---------- .../src/components/API/computeAPITree.ts | 10 +++-- packages/elements/src/components/API/utils.ts | 34 +++++++++-------- 3 files changed, 42 insertions(+), 39 deletions(-) diff --git a/packages/elements/src/components/API/APIWithStackedLayout.tsx b/packages/elements/src/components/API/APIWithStackedLayout.tsx index 8d29dd890..5464790a1 100644 --- a/packages/elements/src/components/API/APIWithStackedLayout.tsx +++ b/packages/elements/src/components/API/APIWithStackedLayout.tsx @@ -1,19 +1,15 @@ -import { - DeprecatedBadge, - Docs, - ExportButtonProps, - HttpMethodColors, - ParsedDocs, - TryItWithRequestSamples, -} from '@stoplight/elements-core'; -import { ExtensionAddonRenderer } from '@stoplight/elements-core/components/Docs'; +import type { ExportButtonProps } from '@stoplight/elements-core'; +import { DeprecatedBadge, Docs, HttpMethodColors, ParsedDocs, TryItWithRequestSamples } from '@stoplight/elements-core'; +import type { ExtensionAddonRenderer } from '@stoplight/elements-core/components/Docs'; import { Box, Flex, Heading, Icon, Tab, TabList, TabPanel, TabPanels, Tabs } from '@stoplight/mosaic'; -import { HttpMethod, NodeType } from '@stoplight/types'; +import type { Extensions, HttpMethod } from '@stoplight/types'; +import { NodeType } from '@stoplight/types'; import cn from 'classnames'; import * as React from 'react'; -import { OperationNode, ServiceNode, WebhookNode } from '../../utils/oas/types'; -import { computeTagGroups, TagGroup } from './utils'; +import type { OperationNode, ServiceNode, WebhookNode } from '../../utils/oas/types'; +import type { TagGroup } from './utils'; +import { computeTagGroups } from './utils'; type TryItCredentialsPolicy = 'omit' | 'include' | 'same-origin'; @@ -44,9 +40,9 @@ type StackedLayoutProps = { const itemMatchesHash = (hash: string, item: OperationNode | WebhookNode) => { if (item.type === NodeType.HttpOperation) { return hash.substr(1) === `${item.data.path}-${item.data.method}`; - } else { - return hash.substr(1) === `${item.data.name}-${item.data.method}`; } + + return hash.substr(1) === `${item.data.name}-${item.data.method}`; }; const TryItContext = React.createContext<{ @@ -91,9 +87,10 @@ export const APIWithStackedLayout: React.FC = ({ showPoweredByLink = true, location, }) => { - const rootVendorExtensions = Object.keys(serviceNode.data.extensions ?? {}).map(item => item.toLowerCase()); + const rootVendorExtensions = serviceNode.data.extensions ?? ({} as Extensions); + const rootVendorExtensionNames = Object.keys(rootVendorExtensions).map(item => item.toLowerCase()); const isHavingTagGroupsExtension = - typeof rootVendorExtensions['x-taggroups'] !== undefined && rootVendorExtensions.length > 0; + typeof rootVendorExtensions['x-taggroups'] !== 'undefined' && rootVendorExtensionNames.length > 0; const { groups: operationGroups } = computeTagGroups(serviceNode, NodeType.HttpOperation, { useTagGroups: isHavingTagGroupsExtension, @@ -124,7 +121,7 @@ export const APIWithStackedLayout: React.FC = ({ {operationGroups.length > 0 && webhookGroups.length > 0 ? Endpoints : null} {operationGroups.map(group => group.isDivider ? ( - + {group.title} ) : ( @@ -134,7 +131,7 @@ export const APIWithStackedLayout: React.FC = ({ {webhookGroups.length > 0 ? Webhooks : null} {webhookGroups.map(group => group.isDivider ? ( - + {group.title} ) : ( @@ -173,7 +170,7 @@ const Group = React.memo<{ group: TagGroup }>(({ gr window.scrollTo(0, scrollRef.current.offsetTop); } } - }, [shouldExpand, urlHashMatches, group, hash]); + }, [shouldExpand, urlHashMatches]); return ( @@ -244,7 +241,7 @@ const Item = React.memo<{ item: OperationNode | WebhookNode }>(({ item }) => { rounded px={2} bg="canvas" - className={cn(`sl-mr-5 sl-text-base`, `sl-text-${color}`, `sl-border-${color}`)} + className={cn('sl-mr-5 sl-text-base', `sl-text-${color}`, `sl-border-${color}`)} > {item.data.method || 'UNKNOWN'} diff --git a/packages/elements/src/components/API/computeAPITree.ts b/packages/elements/src/components/API/computeAPITree.ts index 8de8bb53f..a075c4a0c 100644 --- a/packages/elements/src/components/API/computeAPITree.ts +++ b/packages/elements/src/components/API/computeAPITree.ts @@ -1,8 +1,9 @@ -import { TableOfContentsItem } from '@stoplight/elements-core'; +import type { TableOfContentsItem } from '@stoplight/elements-core'; +import type { Extensions } from '@stoplight/types'; import { NodeType } from '@stoplight/types'; import { defaults } from 'lodash'; -import { OperationNode, SchemaNode, ServiceNode, WebhookNode } from '../../utils/oas/types'; +import type { OperationNode, SchemaNode, ServiceNode, WebhookNode } from '../../utils/oas/types'; import { addTagGroupsToTree, computeTagGroups, isInternal } from './utils'; export interface ComputeAPITreeConfig { @@ -20,9 +21,10 @@ export const computeAPITree = (serviceNode: ServiceNode, config: ComputeAPITreeC const tree: TableOfContentsItem[] = []; // check if spec has x-tagGroups extension - const rootVendorExtensions = Object.keys(serviceNode.data.extensions ?? {}).map(item => item.toLowerCase()); + const rootVendorExtensions = serviceNode.data.extensions ?? ({} as Extensions); + const rootVendorExtensionNames = Object.keys(rootVendorExtensions).map(item => item.toLowerCase()); const isHavingTagGroupsExtension = - typeof rootVendorExtensions['x-taggroups'] !== undefined && rootVendorExtensions.length > 0; + typeof rootVendorExtensions['x-taggroups'] !== 'undefined' && rootVendorExtensionNames.length > 0; tree.push({ id: '/', diff --git a/packages/elements/src/components/API/utils.ts b/packages/elements/src/components/API/utils.ts index cba375e71..7f786c05a 100644 --- a/packages/elements/src/components/API/utils.ts +++ b/packages/elements/src/components/API/utils.ts @@ -1,15 +1,9 @@ -import { - isHttpOperation, - isHttpService, - isHttpWebhookOperation, - TableOfContentsGroup, - TableOfContentsItem, -} from '@stoplight/elements-core'; -import { NodeType } from '@stoplight/types'; -import { JSONSchema7 } from 'json-schema'; -import { defaults } from 'lodash'; - -import { OperationNode, SchemaNode, ServiceChildNode, ServiceNode, WebhookNode } from '../../utils/oas/types'; +import type { TableOfContentsGroup, TableOfContentsItem } from '@stoplight/elements-core'; +import { isHttpOperation, isHttpService, isHttpWebhookOperation } from '@stoplight/elements-core'; +import type { JSONSchema7 } from 'json-schema'; + +import type { OperationNode, SchemaNode, ServiceChildNode, ServiceNode, WebhookNode } from '../../utils/oas/types'; +import { INodeTag } from '@stoplight/types'; type GroupableNode = OperationNode | WebhookNode | SchemaNode; @@ -47,7 +41,9 @@ export function computeTagGroups( groupsByTagId[tagId].items?.push(node); } else { const serviceTagIndex = lowerCaseServiceTags.findIndex(tn => tn === tagId); - const rawServiceTag = rawServiceTags[serviceTagIndex]; + const rawServiceTag: INodeTag & { 'x-displayName': string | undefined } = rawServiceTags[ + serviceTagIndex + ] as INodeTag & { 'x-displayName': string | undefined }; let serviceTagName = serviceNode.tags[serviceTagIndex]; if (rawServiceTag && typeof rawServiceTag['x-displayName'] !== 'undefined') { serviceTagName = rawServiceTag['x-displayName']; @@ -64,7 +60,11 @@ export function computeTagGroups( if (useTagGroups) { for (const nodeTag of node.tags) { const nodeTagId = nodeTag.toLowerCase(); - const serviceTag = rawServiceTags.find(t => t.name.toLowerCase() === nodeTagId); + const serviceTag = rawServiceTags.find(t => t.name.toLowerCase() === nodeTagId) as + | (INodeTag & { + 'x-displayName': string | undefined; + }) + | undefined; let nodeTagName = nodeTag; if (serviceTag && typeof serviceTag['x-displayName'] !== 'undefined') { @@ -108,7 +108,11 @@ export function computeTagGroups( if (tagGroups.length > 0) { let groupTitle = tagGroup.name; - const groupTag = rawServiceTags.find(t => t.name.toLowerCase() === tagGroup.name.toLowerCase()); + const groupTag = rawServiceTags.find(t => t.name.toLowerCase() === tagGroup.name.toLowerCase()) as + | (INodeTag & { + 'x-displayName': string | undefined; + }) + | undefined; if (groupTag && typeof groupTag['x-displayName'] !== 'undefined') { groupTitle = groupTag['x-displayName']; } From 152025271f4d9f70d6e781c79563dffb4e085428 Mon Sep 17 00:00:00 2001 From: Weyert de Boer <7049+weyert@users.noreply.github.com> Date: Thu, 15 Aug 2024 15:29:24 +0000 Subject: [PATCH 6/6] style: resolve linting issues --- packages/elements/src/components/API/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/elements/src/components/API/utils.ts b/packages/elements/src/components/API/utils.ts index 7f786c05a..dd4878325 100644 --- a/packages/elements/src/components/API/utils.ts +++ b/packages/elements/src/components/API/utils.ts @@ -1,9 +1,9 @@ import type { TableOfContentsGroup, TableOfContentsItem } from '@stoplight/elements-core'; import { isHttpOperation, isHttpService, isHttpWebhookOperation } from '@stoplight/elements-core'; +import type { INodeTag } from '@stoplight/types'; import type { JSONSchema7 } from 'json-schema'; import type { OperationNode, SchemaNode, ServiceChildNode, ServiceNode, WebhookNode } from '../../utils/oas/types'; -import { INodeTag } from '@stoplight/types'; type GroupableNode = OperationNode | WebhookNode | SchemaNode;