From af8adaf85591c973cc877ae9da2293bfebad0cd4 Mon Sep 17 00:00:00 2001 From: alexeyzvegintcev Date: Tue, 20 May 2025 15:09:34 +0300 Subject: [PATCH 1/3] Added tagGroup support --- examples/static-html/9ci/api.yml | 65 +++++ examples/static-html/9ci/index.html | 28 ++ .../simpleApiWithTagGroups.ts | 268 +++++++++++++++++ .../API/APIWithResponsiveSidebarLayout.tsx | 3 +- .../components/API/APIWithSidebarLayout.tsx | 3 +- .../components/API/APIWithStackedLayout.tsx | 53 +++- .../src/components/API/computeAPITree.ts | 105 +++++++ packages/elements/src/components/API/utils.ts | 269 ++++++++++++------ 8 files changed, 692 insertions(+), 102 deletions(-) create mode 100644 examples/static-html/9ci/api.yml create mode 100644 examples/static-html/9ci/index.html create mode 100644 packages/elements/src/__fixtures__/api-descriptions/simpleApiWithTagGroups.ts create mode 100644 packages/elements/src/components/API/computeAPITree.ts diff --git a/examples/static-html/9ci/api.yml b/examples/static-html/9ci/api.yml new file mode 100644 index 000000000..418520512 --- /dev/null +++ b/examples/static-html/9ci/api.yml @@ -0,0 +1,65 @@ +openapi: 3.0.3 +info: + title: Sample API + version: 1.0.0 + description: API documentation with Stoplight-compatible x-codeSamples + +paths: + /users: + get: + summary: Get all usersww + x-codeSamples: + - lang: shell + label: JavaScript (fetch22) + lib: curl + source: > + fetch("https://api.example.com/users") + .then(res => res.json()) + .then(data => console.log(data)); + tags: [User] + responses: + '200': + description: List of userslll + + /payments: + post: + summary: Make a payment + tags: [Payment] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + amount: + type: number + responses: + '201': + description: Payment created + x-code-samples: + - lang: curl + label: Curl11 + source: | + curl -X POST "https://api.example.com/payments" \ + -H "Content-Type: application/json" \ + -d '{"amount": 100}' + - lang: python + label: Python (requests) + source: | + import requests + response = requests.post( + "https://api.example.com/payments", + json={"amount": 100} + ) + print(response.json()) +tags: + - name: User + description: Operations about users + - name: Payment + description: Payment processing +x-taggroups: + - name: Users + tags: [User] + - name: Payments + tags: [Payment] \ No newline at end of file diff --git a/examples/static-html/9ci/index.html b/examples/static-html/9ci/index.html new file mode 100644 index 000000000..701b0a6e7 --- /dev/null +++ b/examples/static-html/9ci/index.html @@ -0,0 +1,28 @@ + + + + + + + Stoplight Elements example + + + + + + + + + + + + + + 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/APIWithResponsiveSidebarLayout.tsx b/packages/elements/src/components/API/APIWithResponsiveSidebarLayout.tsx index 2d049acc2..d5ab64d4f 100644 --- a/packages/elements/src/components/API/APIWithResponsiveSidebarLayout.tsx +++ b/packages/elements/src/components/API/APIWithResponsiveSidebarLayout.tsx @@ -11,7 +11,8 @@ import * as React from 'react'; import { Navigate, useLocation } from 'react-router-dom'; import { ServiceNode } from '../../utils/oas/types'; -import { computeAPITree, findFirstNodeSlug, isInternal, resolveRelativePath } from './utils'; +import { computeAPITree, findFirstNodeSlug } from './computeAPITree'; +import { isInternal, resolveRelativePath } 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 904880ec8..eadb5c95f 100644 --- a/packages/elements/src/components/API/APIWithSidebarLayout.tsx +++ b/packages/elements/src/components/API/APIWithSidebarLayout.tsx @@ -17,7 +17,8 @@ import * as React from 'react'; import { Link, Navigate, useLocation } from 'react-router-dom'; import { ServiceNode } from '../../utils/oas/types'; -import { computeAPITree, findFirstNodeSlug, isInternal, resolveRelativePath } from './utils'; +import { computeAPITree, findFirstNodeSlug } from './computeAPITree'; +import { isInternal, resolveRelativePath } from './utils'; type SidebarLayoutProps = { serviceNode: ServiceNode; diff --git a/packages/elements/src/components/API/APIWithStackedLayout.tsx b/packages/elements/src/components/API/APIWithStackedLayout.tsx index 1eaffc71f..edb48700c 100644 --- a/packages/elements/src/components/API/APIWithStackedLayout.tsx +++ b/packages/elements/src/components/API/APIWithStackedLayout.tsx @@ -8,7 +8,7 @@ import { } from '@stoplight/elements-core'; import { 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 { Extensions, HttpMethod, NodeType } from '@stoplight/types'; import cn from 'classnames'; import * as React from 'react'; @@ -44,9 +44,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 +91,17 @@ export const APIWithStackedLayout: React.FC = ({ showPoweredByLink = true, location, }) => { - const { groups: operationGroups } = computeTagGroups(serviceNode, NodeType.HttpOperation); - const { groups: webhookGroups } = computeTagGroups(serviceNode, NodeType.HttpWebhook); + const rootVendorExtensions = serviceNode.data.extensions ?? ({} as Extensions); + const rootVendorExtensionNames = Object.keys(rootVendorExtensions).map(item => item.toLowerCase()); + const isHavingTagGroupsExtension = + typeof rootVendorExtensions['x-tagGroups'] !== 'undefined' && rootVendorExtensionNames.length > 0; + const { groups: operationGroups } = computeTagGroups(serviceNode, NodeType.HttpOperation, { + useTagGroups: isHavingTagGroupsExtension, + }); + const { groups: webhookGroups } = computeTagGroups(serviceNode, NodeType.HttpWebhook, { + useTagGroups: isHavingTagGroupsExtension, + }); return ( = ({ /> {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} + + ) : ( + + ), + )} @@ -139,7 +159,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(() => { @@ -150,7 +173,7 @@ const Group = React.memo<{ group: TagGroup }>(({ gr window.scrollTo(0, scrollRef.current.offsetTop); } } - }, [shouldExpand, urlHashMatches, group, hash]); + }, [shouldExpand, urlHashMatches]); return ( @@ -173,7 +196,7 @@ const Group = React.memo<{ group: TagGroup }>(({ gr - {group.items.map(item => { + {(group.items ?? []).map(item => { return ; })} @@ -221,7 +244,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 new file mode 100644 index 000000000..add2aceaf --- /dev/null +++ b/packages/elements/src/components/API/computeAPITree.ts @@ -0,0 +1,105 @@ +import type { TableOfContentsItem } from '@stoplight/elements-core'; +import type { Extensions } from '@stoplight/types'; +import { NodeType } from '@stoplight/types'; +import { defaults } from 'lodash'; + +import type { OperationNode, SchemaNode, ServiceNode, WebhookNode } from '../../utils/oas/types'; +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[] = []; + + // check if spec has x-tagGroups extension + const rootVendorExtensions = serviceNode.data.extensions ?? ({} as Extensions); + const rootVendorExtensionNames = Object.keys(rootVendorExtensions).map(item => item.toLowerCase()); + const isHavingTagGroupsExtension = + typeof rootVendorExtensions['x-taggroups'] !== 'undefined' && rootVendorExtensionNames.length > 0; + + 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; +}; \ No newline at end of file diff --git a/packages/elements/src/components/API/utils.ts b/packages/elements/src/components/API/utils.ts index 66d8d9e0b..347fc6cb4 100644 --- a/packages/elements/src/components/API/utils.ts +++ b/packages/elements/src/components/API/utils.ts @@ -6,44 +6,138 @@ import { TableOfContentsGroup, TableOfContentsItem, } from '@stoplight/elements-core'; -import { NodeType } from '@stoplight/types'; +import { INodeTag } from '@stoplight/types'; import { JSONSchema7 } from 'json-schema'; -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; isDivider: boolean; 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) { for (const tagName of node.tags) { 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: 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']; + } groupsByTagId[tagId] = { title: serviceTagName || tagName, + isDivider: false, 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) as + | (INodeTag & { + 'x-displayName': string | undefined; + }) + | undefined; + + 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, + isDivider: false, + items: [node], + }; + } + } + } } if (node.tags.length === 0) { 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) { + let groupTitle = tagGroup.name; + + 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']; + } + + grouped.push({ + title: groupTitle, + isDivider: true, + }); + + 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(); @@ -63,80 +157,80 @@ 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; -}; +// 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; @@ -152,13 +246,14 @@ export const isInternal = (node: ServiceChildNode | ServiceNode): boolean => { return !!data['x-internal' as keyof JSONSchema7]; }; -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)) { @@ -174,7 +269,7 @@ const addTagGroupsToTree = ( }); groups.forEach(group => { - const items = group.items.flatMap(node => { + const items = group.items?.flatMap(node => { if (hideInternal && isInternal(node)) { return []; } @@ -186,12 +281,16 @@ 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 if (group.isDivider) { + tree.push({ + title: group.title, + }); } }); }; From db1ddacd7fd6f1ea16dd255d251da102460aedbc Mon Sep 17 00:00:00 2001 From: alexeyzvegintcev Date: Tue, 20 May 2025 16:05:17 +0300 Subject: [PATCH 2/3] Show schemas sorted --- packages/elements/src/components/API/utils.ts | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/elements/src/components/API/utils.ts b/packages/elements/src/components/API/utils.ts index 347fc6cb4..ccfd2f590 100644 --- a/packages/elements/src/components/API/utils.ts +++ b/packages/elements/src/components/API/utils.ts @@ -255,18 +255,20 @@ export const addTagGroupsToTree = ( ) => { const { hideInternal = false } = config ?? {}; // 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 : '', + ungrouped + .sort((a, b) => a.name.localeCompare(b.name)) + .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 : '', + }); }); - }); groups.forEach(group => { const items = group.items?.flatMap(node => { From 5e959129e6aadb51a6cf325f1f7160bba5e084b7 Mon Sep 17 00:00:00 2001 From: alexeyzvegintcev Date: Tue, 20 May 2025 16:56:13 +0300 Subject: [PATCH 3/3] Capitalize endpoints --- .../src/components/RequestSamples/requestSampleConfigs.ts | 1 + .../src/components/TableOfContents/TableOfContents.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/elements-core/src/components/RequestSamples/requestSampleConfigs.ts b/packages/elements-core/src/components/RequestSamples/requestSampleConfigs.ts index 5b36c64a0..679195eb5 100644 --- a/packages/elements-core/src/components/RequestSamples/requestSampleConfigs.ts +++ b/packages/elements-core/src/components/RequestSamples/requestSampleConfigs.ts @@ -13,6 +13,7 @@ export interface LanguageConfig { } export type RequestSampleConfigs = Dictionary; +//XXX: List of supported languages export const requestSampleConfigs: RequestSampleConfigs = { Shell: { mosaicCodeViewerLanguage: 'bash', diff --git a/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx b/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx index e0cfd7485..8f19ecd98 100644 --- a/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx +++ b/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx @@ -1,5 +1,6 @@ import { Box, Flex, Icon, ITextColorProps } from '@stoplight/mosaic'; import { HttpMethod, NodeType } from '@stoplight/types'; +import { capitalize } from 'lodash'; import * as React from 'react'; import { useFirstRender } from '../../hooks/useFirstRender'; @@ -258,7 +259,7 @@ const Group = React.memo<{ elem = (