diff --git a/packages/elements/src/components/API/__tests__/utils.test.ts b/packages/elements/src/components/API/__tests__/utils.test.ts index d32b67dfd..da5a1acd3 100644 --- a/packages/elements/src/components/API/__tests__/utils.test.ts +++ b/packages/elements/src/components/API/__tests__/utils.test.ts @@ -1047,6 +1047,120 @@ describe.each([ }, ]); }); + + it('generates API ToC tree with x-tagGroups', () => { + const apiDocument: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'some api', + version: '1.0.0', + description: 'some description', + 'x-tagGroups': [ + { + name: 'User Management', + tags: ['Users', 'Authentication'], + }, + { + name: 'Product Catalog', + tags: ['Products', 'Categories'], + }, + ], + }, + paths: { + '/users': { + get: { + tags: ['Users'], + }, + }, + '/products': { + get: { + tags: ['Products'], + }, + }, + '/auth/login': { + post: { + tags: ['Authentication'], + }, + }, + '/categories': { + get: { + tags: ['Categories'], + }, + }, + }, + }; + + expect(computeAPITree(transformOasToServiceNode(apiDocument)!)).toEqual([ + { + id: '/', + meta: '', + slug: '/', + title: 'Overview', + type: 'overview', + }, + { + title: 'Endpoints', + }, + { + title: 'User Management', + }, + { + title: 'Users', + items: [ + { + id: '/paths/users/get', + meta: 'get', + slug: '/paths/users/get', + title: '/users', + type: NodeType.HttpOperation, + }, + ], + itemsType: NodeType.HttpOperation, + }, + { + title: 'Authentication', + items: [ + { + id: '/paths/auth-login/post', + meta: 'post', + slug: '/paths/auth-login/post', + title: '/auth/login', + type: NodeType.HttpOperation, + }, + ], + itemsType: NodeType.HttpOperation, + }, + { + title: 'Product Catalog', + }, + { + title: 'Products', + items: [ + { + id: '/paths/products/get', + meta: 'get', + slug: '/paths/products/get', + title: '/products', + type: NodeType.HttpOperation, + }, + ], + itemsType: NodeType.HttpOperation, + }, + { + title: 'Categories', + items: [ + { + id: '/paths/categories/get', + meta: 'get', + slug: '/paths/categories/get', + title: '/categories', + type: NodeType.HttpOperation, + }, + ], + itemsType: NodeType.HttpOperation, + }, + ]); + }); }); }); diff --git a/packages/elements/src/components/API/utils.ts b/packages/elements/src/components/API/utils.ts index 66d8d9e0b..3be199c1a 100644 --- a/packages/elements/src/components/API/utils.ts +++ b/packages/elements/src/components/API/utils.ts @@ -5,6 +5,7 @@ import { resolveUrl, TableOfContentsGroup, TableOfContentsItem, + TableOfContentsNode, } from '@stoplight/elements-core'; import { NodeType } from '@stoplight/types'; import { JSONSchema7 } from 'json-schema'; @@ -85,24 +86,34 @@ export const computeAPITree = (serviceNode: ServiceNode, config: ComputeAPITreeC meta: '', }); + const xTagGroups = (serviceNode.data.infoExtensions?.['x-tagGroups'] || []) as any[]; + 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); + addTagGroupsToTree( + 'Endpoints', + groups, + ungrouped, + tree, + NodeType.HttpOperation, + mergedConfig.hideInternal, + xTagGroups, + ); } 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); + addTagGroupsToTree( + 'Webhooks', + groups, + ungrouped, + tree, + NodeType.HttpWebhook, + mergedConfig.hideInternal, + xTagGroups, + ); } let schemaNodes = serviceNode.children.filter(node => node.type === NodeType.Model); @@ -111,12 +122,8 @@ export const computeAPITree = (serviceNode: ServiceNode, config: ComputeAPITreeC } if (!mergedConfig.hideSchemas && schemaNodes.length) { - tree.push({ - title: 'Schemas', - }); - const { groups, ungrouped } = computeTagGroups(serviceNode, NodeType.Model); - addTagGroupsToTree(groups, ungrouped, tree, NodeType.Model, mergedConfig.hideInternal); + addTagGroupsToTree('Schemas', groups, ungrouped, tree, NodeType.Model, mergedConfig.hideInternal, xTagGroups); } return tree; }; @@ -153,38 +160,91 @@ export const isInternal = (node: ServiceChildNode | ServiceNode): boolean => { }; const addTagGroupsToTree = ( + sectionTitle: string, groups: TagGroup[], ungrouped: T[], tree: TableOfContentsItem[], itemsType: TableOfContentsGroup['itemsType'], hideInternal: boolean, + xTagGroups: any[], ) => { - // Show ungrouped nodes above tag groups - ungrouped.forEach(node => { - if (hideInternal && isInternal(node)) { - return; - } - tree.push({ - id: node.uri, - slug: node.uri, - title: node.name, - type: node.type, - meta: isHttpOperation(node.data) || isHttpWebhookOperation(node.data) ? node.data.method : '', - }); + tree.push({ + title: sectionTitle, }); - groups.forEach(group => { - const items = group.items.flatMap(node => { - if (hideInternal && isInternal(node)) { - return []; + const processedItemIds = new Set(); + + // Process x-tagGroups first + xTagGroups.forEach((xTagGroup: { name: string; tags: string[] }) => { + const xTagGroupTitle = xTagGroup.name; + const xTagGroupItems: TableOfContentsGroup['items'] = []; // This will hold the nested tag groups + + xTagGroup.tags.forEach((tagName: string) => { + const individualTagGroup = groups.find(g => g.title === tagName); // Find the group for this individual tag + if (individualTagGroup) { + const nodesForThisTag: TableOfContentsNode[] = []; + individualTagGroup.items.forEach(node => { + if (!(hideInternal && isInternal(node)) && !processedItemIds.has(node.uri)) { + nodesForThisTag.push({ + id: node.uri, + slug: node.uri, + title: node.name, + type: node.type, + meta: isHttpOperation(node.data) || isHttpWebhookOperation(node.data) ? node.data.method : '', + }); + processedItemIds.add(node.uri); + } + }); + + if (nodesForThisTag.length > 0) { + // Create a nested TableOfContentsGroup for the individual tag + xTagGroupItems.push({ + title: individualTagGroup.title, // Use the individual tag name as the title + items: nodesForThisTag, + itemsType, + }); + } } - return { + }); + + if (xTagGroupItems.length > 0) { + // Push the top-level x-tagGroup as a divider + tree.push({ + title: xTagGroupTitle, + }); + // Push the nested tag groups directly to the main tree + xTagGroupItems.forEach(item => tree.push(item)); + } + }); + + // Add remaining ungrouped items (not part of any x-tagGroups) + ungrouped.forEach(node => { + if (!(hideInternal && isInternal(node)) && !processedItemIds.has(node.uri)) { + tree.push({ id: node.uri, slug: node.uri, title: node.name, type: node.type, meta: isHttpOperation(node.data) || isHttpWebhookOperation(node.data) ? node.data.method : '', - }; + }); + processedItemIds.add(node.uri); + } + }); + + // Add remaining groups (not part of any x-tagGroups) + groups.forEach(group => { + const items: TableOfContentsGroup['items'] = []; + group.items.forEach(node => { + if (!(hideInternal && isInternal(node)) && !processedItemIds.has(node.uri)) { + items.push({ + id: node.uri, + slug: node.uri, + title: node.name, + type: node.type, + meta: isHttpOperation(node.data) || isHttpWebhookOperation(node.data) ? node.data.method : '', + }); + processedItemIds.add(node.uri); + } }); if (items.length > 0) { tree.push({ diff --git a/packages/elements/src/containers/API.stories.tsx b/packages/elements/src/containers/API.stories.tsx index 2874bc585..c2fc186a6 100644 --- a/packages/elements/src/containers/API.stories.tsx +++ b/packages/elements/src/containers/API.stories.tsx @@ -115,3 +115,45 @@ WithExtensionRenderer.args = { apiDescriptionDocument: zoomApiYaml, }; WithExtensionRenderer.storyName = 'With Extension Renderer'; + +export const TagGroupingDemo = Template.bind({}); +TagGroupingDemo.args = { + apiDescriptionDocument: ` + openapi: 3.0.0 + info: + title: Tag Grouping Demo API + version: 1.0.0 + x-tagGroups: + - name: User Management + tags: ["Users", "Authentication"] + - name: Product Catalog + tags: ["Products", "Categories"] + paths: + /users: + get: + summary: Get all users + tags: ["Users"] + /users/{id}: + get: + summary: Get user by ID + tags: ["Users"] + /products: + get: + summary: Get all products + tags: ["Products"] + /products/{id}: + get: + summary: Get product by ID + tags: ["Products"] + /auth/login: + post: + summary: User login + tags: ["Authentication"] + /categories: + get: + summary: Get all categories + tags: ["Categories"] + `, + layout: 'sidebar', +}; +TagGroupingDemo.storyName = 'Tag Grouping Demo'; diff --git a/packages/elements/src/web-components/__stories__/Api.stories.tsx b/packages/elements/src/web-components/__stories__/Api.stories.tsx index 8398e3912..c8a8ab574 100644 --- a/packages/elements/src/web-components/__stories__/Api.stories.tsx +++ b/packages/elements/src/web-components/__stories__/Api.stories.tsx @@ -61,3 +61,131 @@ APIWithJSONProvidedDirectly.args = { apiDescriptionDocument: JSON.stringify(parse(zoomApiYaml), null, ' '), }; APIWithJSONProvidedDirectly.storyName = 'API With JSON Provided Directly'; + +// Tag Groups +export const TagGroups = Template.bind({}); +TagGroups.args = { + apiDescriptionDocument: JSON.stringify({ + openapi: '3.0.0', + info: { + title: 'Tag Groups', + version: '1.0.0', + description: 'An example API with x-tagGroups', + 'x-tagGroups': [ + { + name: 'User', + tags: ['User'], + }, + { + name: 'Admin', + tags: ['Admin'], + }, + ], + }, + paths: { + '/users': { + get: { + summary: 'Get all users', + tags: ['User'], + responses: { + '200': { + description: 'A list of users', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + email: { type: 'string' }, + }, + required: ['id', 'name', 'email'], + }, + }, + }, + }, + }, + }, + }, + }, + '/users/{id}': { + get: { + summary: 'Get a user by ID', + tags: ['User'], + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { + type: 'string', + }, + }, + ], + responses: { + '200': { + description: 'A user object', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + email: { type: 'string' }, + }, + required: ['id', 'name', 'email'], + }, + }, + }, + }, + '404': { + description: 'User not found', + }, + }, + }, + }, + '/admin': { + get: { + summary: 'Get admin dashboard', + tags: ['Admin'], + responses: { + '200': { + description: 'Admin dashboard data', + }, + }, + }, + }, + '/admin/users': { + get: { + summary: 'Get all admin users', + tags: ['Admin'], + responses: { + '200': { + description: 'A list of admin users', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + role: { type: 'string' }, + }, + required: ['id', 'name', 'role'], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), +}; +TagGroups.storyName = 'Tag Groups Example';