Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions packages/elements/src/components/API/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
]);
});
});
});

Expand Down
126 changes: 93 additions & 33 deletions packages/elements/src/components/API/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
resolveUrl,
TableOfContentsGroup,
TableOfContentsItem,
TableOfContentsNode,
} from '@stoplight/elements-core';
import { NodeType } from '@stoplight/types';
import { JSONSchema7 } from 'json-schema';
Expand Down Expand Up @@ -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<OperationNode>(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<WebhookNode>(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);
Expand All @@ -111,12 +122,8 @@ export const computeAPITree = (serviceNode: ServiceNode, config: ComputeAPITreeC
}

if (!mergedConfig.hideSchemas && schemaNodes.length) {
tree.push({
title: 'Schemas',
});

const { groups, ungrouped } = computeTagGroups<SchemaNode>(serviceNode, NodeType.Model);
addTagGroupsToTree(groups, ungrouped, tree, NodeType.Model, mergedConfig.hideInternal);
addTagGroupsToTree('Schemas', groups, ungrouped, tree, NodeType.Model, mergedConfig.hideInternal, xTagGroups);
}
return tree;
};
Expand Down Expand Up @@ -153,38 +160,91 @@ export const isInternal = (node: ServiceChildNode | ServiceNode): boolean => {
};

const addTagGroupsToTree = <T extends GroupableNode>(
sectionTitle: string,
groups: TagGroup<T>[],
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<string>();

// 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({
Expand Down
42 changes: 42 additions & 0 deletions packages/elements/src/containers/API.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading