diff --git a/.circleci/config.yml b/.circleci/config.yml index fc1fe9515..667b1f796 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -25,7 +25,7 @@ jobs: working_directory: /mnt/ramdisk/project docker: - image: cimg/node:18.20 - resource_class: xlarge + resource_class: large environment: CYPRESS_CACHE_FOLDER: /mnt/ramdisk/.cache/Cypress YARN_CACHE_FOLDER: /mnt/ramdisk/.cache/yarn @@ -47,7 +47,7 @@ jobs: - run: yarn build.docs run-e2e-tests: executor: cypress-default - resource_class: xlarge + resource_class: large working_directory: /mnt/ramdisk/project environment: CYPRESS_CACHE_FOLDER: /mnt/ramdisk/.cache/Cypress diff --git a/packages/elements-core/package.json b/packages/elements-core/package.json index 006776595..4e5a4fefa 100644 --- a/packages/elements-core/package.json +++ b/packages/elements-core/package.json @@ -1,6 +1,6 @@ { "name": "@stoplight/elements-core", - "version": "9.0.12", + "version": "9.0.13", "sideEffects": [ "web-components.min.js", "src/web-components/**", diff --git a/packages/elements-core/src/components/TableOfContents/TableOfContents.spec.tsx b/packages/elements-core/src/components/TableOfContents/TableOfContents.spec.tsx index 284e0de59..f886c9f9d 100644 --- a/packages/elements-core/src/components/TableOfContents/TableOfContents.spec.tsx +++ b/packages/elements-core/src/components/TableOfContents/TableOfContents.spec.tsx @@ -27,6 +27,7 @@ describe('TableOfContents', () => { slug: 'target', type: 'article', meta: '', + index: '0-', }, ], }, @@ -55,6 +56,7 @@ describe('TableOfContents', () => { slug: 'target', type: 'article', meta: '', + index: '0-', }, ], }, @@ -87,8 +89,10 @@ describe('TableOfContents', () => { slug: 'target', type: 'article', meta: '', + index: '0-0-', }, ], + index: '0-', }, ], }, @@ -119,6 +123,7 @@ describe('TableOfContents', () => { slug: 'target', type: 'article', meta: '', + index: '0-', }, ], }, @@ -154,6 +159,7 @@ describe('TableOfContents', () => { slug: 'target', type: 'article', meta: '', + index: '0-', }, ], }, @@ -191,6 +197,7 @@ describe('TableOfContents', () => { items: [], meta: '', version: '2', + index: '0-', }, { id: 'def', @@ -199,6 +206,7 @@ describe('TableOfContents', () => { type: 'model', meta: '', version: '1.0.1', + index: '1-', }, { id: 'ghi', @@ -207,6 +215,7 @@ describe('TableOfContents', () => { type: 'http_operation', meta: 'get', version: '1.0.2', + index: '2-', }, ], }, @@ -238,6 +247,7 @@ describe('utils', () => { type: 'article', slug: 'abc-doc', meta: '', + index: '0-', }, { id: 'targetId', @@ -245,6 +255,7 @@ describe('utils', () => { slug: 'target', type: 'article', meta: '', + index: '1-', }, ], }, @@ -258,6 +269,7 @@ describe('utils', () => { type: 'article', slug: 'abc-doc', meta: '', + index: '0-', }); }); @@ -276,6 +288,7 @@ describe('utils', () => { slug: 'def-get-todo', type: 'http_operation', meta: 'get', + index: '0-', }, { id: 'ghi', @@ -283,6 +296,7 @@ describe('utils', () => { slug: 'ghi-add-todo', type: 'http_operation', meta: 'post', + index: '1-', }, ], }, @@ -295,6 +309,7 @@ describe('utils', () => { slug: 'def-get-todo', type: 'http_operation', meta: 'get', + index: '0-', }); }); }); diff --git a/packages/elements-core/src/components/TableOfContents/TableOfContents.stories.tsx b/packages/elements-core/src/components/TableOfContents/TableOfContents.stories.tsx index 2cce2179b..9d5e1af72 100644 --- a/packages/elements-core/src/components/TableOfContents/TableOfContents.stories.tsx +++ b/packages/elements-core/src/components/TableOfContents/TableOfContents.stories.tsx @@ -27,9 +27,11 @@ Playground.args = { title: 'Overview', type: 'overview', meta: '', + index: '0-', }, { title: 'Endpoints', + index: '1-', }, { id: '/operations/get-todos', @@ -37,6 +39,7 @@ Playground.args = { title: 'List Todos', type: 'http_operation', meta: 'get', + index: '2-', }, { id: '/operations/post-todos', @@ -44,6 +47,7 @@ Playground.args = { title: 'Create Todo', type: 'http_operation', meta: 'post', + index: '3-', }, { id: '/operations/get-todos-id', @@ -51,6 +55,7 @@ Playground.args = { title: 'Get Todo', type: 'http_operation', meta: 'get', + index: '4-', }, { id: '/operations/put-todos-id', @@ -58,6 +63,7 @@ Playground.args = { title: 'Replace Todo', type: 'http_operation', meta: 'put', + index: '5-', }, { id: '/operations/delete-todos-id', @@ -65,6 +71,7 @@ Playground.args = { title: 'Delete Todo', type: 'http_operation', meta: 'delete', + index: '6-', }, { id: '/operations/patch-todos-id', @@ -72,6 +79,7 @@ Playground.args = { title: 'Update Todo', type: 'http_operation', meta: 'patch', + index: '7-', }, { title: 'Users', @@ -82,6 +90,7 @@ Playground.args = { title: 'Get User', type: 'http_operation', meta: 'get', + index: '8-0-', }, { id: '/operations/delete-users-userID', @@ -89,6 +98,7 @@ Playground.args = { title: 'Delete User', type: 'http_operation', meta: 'delete', + index: '8-1-', }, { id: '/operations/post-users-userID', @@ -96,11 +106,14 @@ Playground.args = { title: 'Create User', type: 'http_operation', meta: 'post', + index: '8-2-', }, ], + index: '8-', }, { title: 'Schemas', + index: '9-', }, { id: '/schemas/Todos', @@ -109,6 +122,7 @@ Playground.args = { type: 'model', meta: '', version: '1.0.2', + index: '10-', }, { id: '/schemas/User', @@ -116,6 +130,7 @@ Playground.args = { title: 'User', type: 'model', meta: '', + index: '11-', }, ], }; diff --git a/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx b/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx index e0cfd7485..1842ad1eb 100644 --- a/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx +++ b/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx @@ -1,6 +1,7 @@ import { Box, Flex, Icon, ITextColorProps } from '@stoplight/mosaic'; import { HttpMethod, NodeType } from '@stoplight/types'; import * as React from 'react'; +import { useState } from 'react'; import { useFirstRender } from '../../hooks/useFirstRender'; import { resolveRelativeLink } from '../../utils/string'; @@ -14,17 +15,19 @@ import { NODE_TYPE_TITLE_ICON, } from './constants'; import { + ActiveItemContextType, CustomLinkComponent, TableOfContentsDivider, TableOfContentsGroup, TableOfContentsGroupItem, + TableOfContentsItem, TableOfContentsNode, TableOfContentsNodeGroup, TableOfContentsProps, } from './types'; import { + findFirstNode, getHtmlIdFromItemId, - hasActiveItem, isDivider, isExternalLink, isGroup, @@ -33,7 +36,11 @@ import { isNodeGroup, } from './utils'; -const ActiveIdContext = React.createContext(undefined); +const ActiveItemContext = React.createContext({ + activeId: undefined, + lastActiveIndex: '', + setLastActiveIndex: () => {}, +}); const LinkContext = React.createContext(undefined); LinkContext.displayName = 'LinkContext'; @@ -47,6 +54,36 @@ export const TableOfContents = React.memo( isInResponsiveMode = false, onLinkClick, }) => { + const [lastActiveIndex, setLastActiveIndex] = useState(''); + const value = React.useMemo( + () => ({ + lastActiveIndex, + setLastActiveIndex, + activeId, + }), + [lastActiveIndex, activeId], + ); + + const updateTocTree = React.useCallback((arr: TableOfContentsItem[], parentId: string): any[] => { + return arr.map((item, key) => { + let newItem: TableOfContentsItem = { + ...item, + index: parentId + key + '-', + }; + + // Process items array if it exists + if (isGroup(item) || isNodeGroup(item)) { + (newItem as TableOfContentsGroup | TableOfContentsNodeGroup).items = updateTocTree( + item.items, + parentId + key + '-', + ); + } + + return newItem; + }); + }, []); + const updatedTree = updateTocTree(tree, ''); + const container = React.useRef(null); const child = React.useRef(null); const firstRender = useFirstRender(); @@ -76,24 +113,26 @@ export const TableOfContents = React.memo( - - {tree.map((item, key) => { - if (isDivider(item)) { - return ; - } - - return ( - - ); - })} - + + + {updatedTree.map((item, key: number) => { + if (isDivider(item)) { + return ; + } + + return ( + + ); + })} + + @@ -123,6 +162,22 @@ const Divider = React.memo<{ }); Divider.displayName = 'Divider'; +const TOCContainer = React.memo<{ + updatedTree: TableOfContentsGroupItem[]; + children: React.ReactNode; +}>(({ children, updatedTree }) => { + const { setLastActiveIndex } = React.useContext(ActiveItemContext); + React.useEffect(() => { + const firstNode = findFirstNode(updatedTree); + if (firstNode) { + setLastActiveIndex(firstNode.index); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return {children}; +}); +TOCContainer.displayName = 'TOCContainer'; const GroupItem = React.memo<{ depth: number; item: TableOfContentsGroupItem; @@ -197,9 +252,32 @@ const Group = React.memo<{ isInResponsiveMode?: boolean; onLinkClick?(): void; }>(({ depth, item, maxDepthOpenByDefault, isInResponsiveMode, onLinkClick = () => {} }) => { - const activeId = React.useContext(ActiveIdContext); + const { activeId, lastActiveIndex } = React.useContext(ActiveItemContext); const [isOpen, setIsOpen] = React.useState(() => isGroupOpenByDefault(depth, item, activeId, maxDepthOpenByDefault)); - const hasActive = !!activeId && hasActiveItem(item.items, activeId); + const isActiveGroup = React.useCallback( + (items: TableOfContentsGroupItem[], activeId: string | undefined, contextIndex: string): boolean => { + return items.some(element => { + const hasSlugOrId = 'slug' in element || 'id' in element; + const hasItems = 'items' in element && Array.isArray((element as any).items); + + if (!hasSlugOrId && !hasItems) return false; + + if ( + activeId && + 'index' in element && + ((element as any).slug === activeId || (element as any).id === activeId) && + (element as any).index === contextIndex + ) { + return true; + } + + return hasItems ? isActiveGroup((element as any).items, activeId, contextIndex) : false; + }); + }, + [], + ); + + const hasActive = isActiveGroup(item.items, activeId, lastActiveIndex); // If maxDepthOpenByDefault changes, we want to update all the isOpen states (used in live preview mode) React.useEffect(() => { @@ -351,8 +429,10 @@ const Node = React.memo<{ onClick?: (e: React.MouseEvent, forceOpen?: boolean) => void; onLinkClick?(): void; }>(({ item, depth, meta, showAsActive, isInResponsiveMode, onClick, onLinkClick = () => {} }) => { - const activeId = React.useContext(ActiveIdContext); - const isActive = activeId === item.slug || activeId === item.id; + const { activeId, lastActiveIndex, setLastActiveIndex } = React.useContext(ActiveItemContext); + const { index } = item; + const isSlugMatched = activeId === item.slug || activeId === item.id; + const isActive = lastActiveIndex === index && isSlugMatched; const LinkComponent = React.useContext(LinkContext); const handleClick = (e: React.MouseEvent) => { @@ -361,6 +441,7 @@ const Node = React.memo<{ e.stopPropagation(); e.preventDefault(); } else { + setLastActiveIndex(index); onLinkClick(); } @@ -390,7 +471,7 @@ const Node = React.memo<{ } meta={meta} isInResponsiveMode={isInResponsiveMode} - onClick={handleClick} + onClick={e => handleClick(e)} /> ); diff --git a/packages/elements-core/src/components/TableOfContents/types.ts b/packages/elements-core/src/components/TableOfContents/types.ts index e3c69e15d..69f433cfd 100644 --- a/packages/elements-core/src/components/TableOfContents/types.ts +++ b/packages/elements-core/src/components/TableOfContents/types.ts @@ -30,6 +30,7 @@ export type TableOfContentsGroup = { title: string; items: TableOfContentsGroupItem[]; itemsType?: 'article' | 'http_operation' | 'http_webhook' | 'model'; + index: string; }; export type TableOfContentsExternalLink = { @@ -46,6 +47,13 @@ export type TableOfContentsNode< type: T; meta: string; version?: string; + index: string; }; export type TableOfContentsNodeGroup = TableOfContentsNode<'http_service'> & TableOfContentsGroup; + +export type ActiveItemContextType = { + activeId: string | undefined; + lastActiveIndex: string; + setLastActiveIndex: React.Dispatch>; +}; diff --git a/packages/elements-core/src/components/TableOfContents/utils.ts b/packages/elements-core/src/components/TableOfContents/utils.ts index 3617712dc..97f719282 100644 --- a/packages/elements-core/src/components/TableOfContents/utils.ts +++ b/packages/elements-core/src/components/TableOfContents/utils.ts @@ -68,7 +68,7 @@ export function findFirstNode(items: TableOfContentsItem[]): TableOfContentsNode } export function isDivider(item: TableOfContentsItem): item is TableOfContentsDivider { - return Object.keys(item).length === 1 && 'title' in item; + return Object.keys(item).length === 2 && 'title' in item && 'index' in item; } export function isGroup(item: TableOfContentsItem): item is TableOfContentsGroup { return Object.keys(item).length >= 2 && 'title' in item && 'items' in item; @@ -80,5 +80,5 @@ export function isNode(item: TableOfContentsItem): item is TableOfContentsNode { return 'title' in item && 'slug' in item && 'id' in item && 'meta' in item && 'type' in item; } export function isExternalLink(item: TableOfContentsItem): item is TableOfContentsExternalLink { - return Object.keys(item).length === 2 && 'title' in item && 'url' in item; + return Object.keys(item).length === 3 && 'title' in item && 'url' in item && 'index' in item; } diff --git a/packages/elements-dev-portal/package.json b/packages/elements-dev-portal/package.json index 1d795b5ff..0088440c0 100644 --- a/packages/elements-dev-portal/package.json +++ b/packages/elements-dev-portal/package.json @@ -1,6 +1,6 @@ { "name": "@stoplight/elements-dev-portal", - "version": "3.0.12", + "version": "3.0.13", "description": "UI components for composing beautiful developer documentation.", "keywords": [], "sideEffects": [ @@ -66,7 +66,7 @@ "dependencies": { "@stoplight/markdown-viewer": "^5.7.1", "@stoplight/mosaic": "^1.53.5", - "@stoplight/elements-core": "~9.0.12", + "@stoplight/elements-core": "~9.0.13", "@stoplight/path": "^1.3.2", "@stoplight/types": "^14.0.0", "classnames": "^2.2.6", diff --git a/packages/elements/package.json b/packages/elements/package.json index b8eeaec7f..cee4d949f 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -1,6 +1,6 @@ { "name": "@stoplight/elements", - "version": "9.0.12", + "version": "9.0.13", "description": "UI components for composing beautiful developer documentation.", "keywords": [], "sideEffects": [ @@ -63,7 +63,7 @@ ] }, "dependencies": { - "@stoplight/elements-core": "~9.0.12", + "@stoplight/elements-core": "~9.0.13", "@stoplight/http-spec": "^7.1.0", "@stoplight/json": "^3.18.1", "@stoplight/mosaic": "^1.53.5", @@ -109,4 +109,4 @@ "release": { "extends": "@stoplight/scripts/release" } -} \ No newline at end of file +} diff --git a/packages/elements/src/components/API/__tests__/utils.test.ts b/packages/elements/src/components/API/__tests__/utils.test.ts index d32b67dfd..4a568b8ad 100644 --- a/packages/elements/src/components/API/__tests__/utils.test.ts +++ b/packages/elements/src/components/API/__tests__/utils.test.ts @@ -898,6 +898,7 @@ describe.each([ slug: `/${pathProp}/something/get`, title: '/something', type: nodeType, + index: '0-', }, ], }, @@ -979,6 +980,7 @@ describe.each([ title: 'a', type: 'model', meta: '', + index: '0-', }, ], }, @@ -1042,6 +1044,7 @@ describe.each([ slug: `/${pathProp}/something-else/post`, title: '/something-else', type: nodeType, + index: '0-', }, ], }, diff --git a/packages/elements/src/components/API/utils.ts b/packages/elements/src/components/API/utils.ts index 66d8d9e0b..143002a96 100644 --- a/packages/elements/src/components/API/utils.ts +++ b/packages/elements/src/components/API/utils.ts @@ -161,9 +161,8 @@ const addTagGroupsToTree = ( ) => { // Show ungrouped nodes above tag groups ungrouped.forEach(node => { - if (hideInternal && isInternal(node)) { - return; - } + if (hideInternal && isInternal(node)) return; + tree.push({ id: node.uri, slug: node.uri, @@ -184,8 +183,10 @@ const addTagGroupsToTree = ( title: node.name, type: node.type, meta: isHttpOperation(node.data) || isHttpWebhookOperation(node.data) ? node.data.method : '', + index: '0-', }; }); + if (items.length > 0) { tree.push({ title: group.title,