diff --git a/packages/elements-core/src/components/TableOfContents/TableOfContents.spec.tsx b/packages/elements-core/src/components/TableOfContents/TableOfContents.spec.tsx index 284e0de59..97bc292a4 100644 --- a/packages/elements-core/src/components/TableOfContents/TableOfContents.spec.tsx +++ b/packages/elements-core/src/components/TableOfContents/TableOfContents.spec.tsx @@ -27,6 +27,9 @@ describe('TableOfContents', () => { slug: 'target', type: 'article', meta: '', + groupId: 0, + index: 0, + parentId: 0, }, ], }, @@ -55,6 +58,9 @@ describe('TableOfContents', () => { slug: 'target', type: 'article', meta: '', + groupId: 0, + index: 0, + parentId: 0, }, ], }, @@ -87,8 +93,14 @@ describe('TableOfContents', () => { slug: 'target', type: 'article', meta: '', + groupId: 0, + index: 0, + parentId: 0, }, ], + groupId: 0, + index: 0, + parentId: 0, }, ], }, @@ -119,6 +131,9 @@ describe('TableOfContents', () => { slug: 'target', type: 'article', meta: '', + groupId: 0, + index: 0, + parentId: 0, }, ], }, @@ -154,6 +169,9 @@ describe('TableOfContents', () => { slug: 'target', type: 'article', meta: '', + groupId: 0, + index: 0, + parentId: 0, }, ], }, @@ -191,6 +209,9 @@ describe('TableOfContents', () => { items: [], meta: '', version: '2', + groupId: 0, + index: 0, + parentId: 0, }, { id: 'def', @@ -199,6 +220,9 @@ describe('TableOfContents', () => { type: 'model', meta: '', version: '1.0.1', + groupId: 0, + index: 0, + parentId: 0, }, { id: 'ghi', @@ -207,6 +231,9 @@ describe('TableOfContents', () => { type: 'http_operation', meta: 'get', version: '1.0.2', + groupId: 0, + index: 0, + parentId: 0, }, ], }, @@ -238,6 +265,9 @@ describe('utils', () => { type: 'article', slug: 'abc-doc', meta: '', + groupId: 0, + index: 0, + parentId: 0, }, { id: 'targetId', @@ -245,6 +275,9 @@ describe('utils', () => { slug: 'target', type: 'article', meta: '', + groupId: 0, + index: 0, + parentId: 0, }, ], }, @@ -258,6 +291,9 @@ describe('utils', () => { type: 'article', slug: 'abc-doc', meta: '', + groupId: 0, + index: 0, + parentId: 0, }); }); @@ -276,6 +312,9 @@ describe('utils', () => { slug: 'def-get-todo', type: 'http_operation', meta: 'get', + groupId: 0, + index: 0, + parentId: 0, }, { id: 'ghi', @@ -283,6 +322,9 @@ describe('utils', () => { slug: 'ghi-add-todo', type: 'http_operation', meta: 'post', + groupId: 0, + index: 0, + parentId: 0, }, ], }, @@ -295,6 +337,9 @@ describe('utils', () => { slug: 'def-get-todo', type: 'http_operation', meta: 'get', + groupId: 0, + index: 0, + parentId: 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..6da4eb8a6 100644 --- a/packages/elements-core/src/components/TableOfContents/TableOfContents.stories.tsx +++ b/packages/elements-core/src/components/TableOfContents/TableOfContents.stories.tsx @@ -27,6 +27,9 @@ Playground.args = { title: 'Overview', type: 'overview', meta: '', + index: 0, + parentId: 0, + groupId: 0, }, { title: 'Endpoints', @@ -37,6 +40,9 @@ Playground.args = { title: 'List Todos', type: 'http_operation', meta: 'get', + index: 2, + parentId: 2, + groupId: 2, }, { id: '/operations/post-todos', @@ -44,6 +50,9 @@ Playground.args = { title: 'Create Todo', type: 'http_operation', meta: 'post', + index: 3, + parentId: 3, + groupId: 3, }, { id: '/operations/get-todos-id', @@ -51,6 +60,9 @@ Playground.args = { title: 'Get Todo', type: 'http_operation', meta: 'get', + index: 4, + parentId: 4, + groupId: 4, }, { id: '/operations/put-todos-id', @@ -58,6 +70,9 @@ Playground.args = { title: 'Replace Todo', type: 'http_operation', meta: 'put', + index: 5, + parentId: 5, + groupId: 5, }, { id: '/operations/delete-todos-id', @@ -65,6 +80,9 @@ Playground.args = { title: 'Delete Todo', type: 'http_operation', meta: 'delete', + index: 6, + parentId: 6, + groupId: 6, }, { id: '/operations/patch-todos-id', @@ -72,6 +90,9 @@ Playground.args = { title: 'Update Todo', type: 'http_operation', meta: 'patch', + index: 7, + parentId: 7, + groupId: 7, }, { title: 'Users', @@ -82,6 +103,9 @@ Playground.args = { title: 'Get User', type: 'http_operation', meta: 'get', + index: 0, + parentId: 8, + groupId: 8, }, { id: '/operations/delete-users-userID', @@ -89,6 +113,9 @@ Playground.args = { title: 'Delete User', type: 'http_operation', meta: 'delete', + index: 1, + parentId: 8, + groupId: 8, }, { id: '/operations/post-users-userID', @@ -96,8 +123,14 @@ Playground.args = { title: 'Create User', type: 'http_operation', meta: 'post', + index: 2, + parentId: 8, + groupId: 8, }, ], + index: 8, + parentId: 8, + groupId: 8, }, { title: 'Schemas', @@ -109,6 +142,9 @@ Playground.args = { type: 'model', meta: '', version: '1.0.2', + index: 10, + parentId: 10, + groupId: 10, }, { id: '/schemas/User', @@ -116,6 +152,9 @@ Playground.args = { title: 'User', type: 'model', meta: '', + index: 11, + parentId: 11, + groupId: 11, }, ], }; diff --git a/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx b/packages/elements-core/src/components/TableOfContents/TableOfContents.tsx index e0cfd7485..c2b7b8609 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 { createContext, useState } from 'react'; import { useFirstRender } from '../../hooks/useFirstRender'; import { resolveRelativeLink } from '../../utils/string'; @@ -15,6 +16,7 @@ import { } from './constants'; import { CustomLinkComponent, + GroupContextType, TableOfContentsDivider, TableOfContentsGroup, TableOfContentsGroupItem, @@ -24,7 +26,6 @@ import { } from './types'; import { getHtmlIdFromItemId, - hasActiveItem, isDivider, isExternalLink, isGroup, @@ -37,6 +38,35 @@ const ActiveIdContext = React.createContext(undefined); const LinkContext = React.createContext(undefined); LinkContext.displayName = 'LinkContext'; +// Create the context with a default undefined value +export const GroupContext = createContext({ + lastActiveIndex: null, + lastActiveParentId: null, + lastActiveGroupId: null, + setLastActiveIndex: () => {}, + setLastActiveParentId: () => {}, + setLastActiveGroupId: () => {}, +}); + +// Provider component +const GroupProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [lastActiveIndex, setLastActiveIndex] = useState(null); // default value 0 + const [lastActiveParentId, setLastActiveParentId] = useState(null); + const [lastActiveGroupId, setLastActiveGroupId] = useState(null); + const value = React.useMemo( + () => ({ + lastActiveIndex, + lastActiveParentId, + lastActiveGroupId, + setLastActiveIndex, + setLastActiveParentId, + setLastActiveGroupId, + }), + [lastActiveIndex, lastActiveParentId, lastActiveGroupId], + ); + + return {children}; +}; export const TableOfContents = React.memo( ({ tree, @@ -47,6 +77,58 @@ export const TableOfContents = React.memo( isInResponsiveMode = false, onLinkClick, }) => { + const groupContext = React.useContext(GroupContext); + const updateTocTree = React.useCallback((arr: any[], groupId: number | null, parentId: number | null): any[] => { + return arr.map((item, key) => { + if (isDivider(item) || isExternalLink(item)) { + return item; + } + + let newItem = { + ...item, + index: key, + parentId: parentId !== null ? parentId : key, + groupId: groupId !== null ? groupId : key, + }; + + // Process items array if it exists + if (Array.isArray(item.items)) { + newItem.items = updateTocTree( + item.items, + groupId !== null ? groupId : key, + 'itemsType' in item ? parentId : key, + ); + } + + return newItem; + }); + }, []); + + const getInitialValues = React.useCallback( + (tree: any[]): boolean => { + for (const item of tree) { + const shouldSetValues = + (Array.isArray(item?.items) && item.type === 'http_service') || Object.keys(item).length !== 1; + + if (shouldSetValues) { + groupContext?.setLastActiveGroupId(item.groupId); + groupContext?.setLastActiveParentId(item.parentId); + groupContext?.setLastActiveIndex(item.index); + return true; + } + + if (Array.isArray(item?.items)) { + const found = getInitialValues(item.items); + if (found) return true; + } + } + + return false; + }, + [groupContext], + ); + const updatedTree = updateTocTree(tree, null, null); + const container = React.useRef(null); const child = React.useRef(null); const firstRender = useFirstRender(); @@ -77,22 +159,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 +209,41 @@ const Divider = React.memo<{ }); Divider.displayName = 'Divider'; +const TOCContainer = React.memo<{ + updatedTree: TableOfContentsGroupItem[]; + children: React.ReactNode; +}>(({ children, updatedTree }) => { + const groupContext = React.useContext(GroupContext); + const getInitialValues = React.useCallback( + (tree: any[]): boolean => { + for (const item of tree) { + const shouldSetValues = + (item?.items && item.type === 'http_service') || (!item?.items && Object.keys(item).length !== 1); + + if (shouldSetValues) { + groupContext?.setLastActiveGroupId(item.groupId); + groupContext?.setLastActiveParentId(item.parentId); + groupContext?.setLastActiveIndex(item.index); + return true; + } + + if (item?.items) { + const found = getInitialValues(item.items); + if (found) return true; + } + } + + return false; + }, + [groupContext], + ); + React.useEffect(() => { + getInitialValues(updatedTree); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return {children}; +}); +TOCContainer.displayName = 'TOCContainer'; const GroupItem = React.memo<{ depth: number; item: TableOfContentsGroupItem; @@ -198,8 +319,39 @@ const Group = React.memo<{ onLinkClick?(): void; }>(({ depth, item, maxDepthOpenByDefault, isInResponsiveMode, onLinkClick = () => {} }) => { const activeId = React.useContext(ActiveIdContext); + const { lastActiveGroupId, lastActiveParentId, lastActiveIndex } = React.useContext(GroupContext); const [isOpen, setIsOpen] = React.useState(() => isGroupOpenByDefault(depth, item, activeId, maxDepthOpenByDefault)); - const hasActive = !!activeId && hasActiveItem(item.items, activeId); + function isActiveGroup( + items: any[], + activeId: string | undefined, + contextGroupId: number | null, + contextParentId: number | null, + contextIndex: number | null, + ): boolean { + for (const element of items) { + if (!('items' in element)) { + if ('slug' in element || 'id' in element) { + if ( + !!activeId && + (activeId === element.slug || activeId === element.id) && + contextGroupId === element.groupId && + contextParentId === element.parentId && + contextIndex === element.index + ) { + return true; + } + } + } else if (Array.isArray(element.items)) { + const found = isActiveGroup(element.items, activeId, contextGroupId, contextParentId, contextIndex); + if (found) { + return true; + } + } + } + return false; + } + + const hasActive = isActiveGroup(item.items, activeId, lastActiveGroupId, lastActiveParentId, lastActiveIndex); // If maxDepthOpenByDefault changes, we want to update all the isOpen states (used in live preview mode) React.useEffect(() => { @@ -352,7 +504,20 @@ const Node = React.memo<{ onLinkClick?(): void; }>(({ item, depth, meta, showAsActive, isInResponsiveMode, onClick, onLinkClick = () => {} }) => { const activeId = React.useContext(ActiveIdContext); - const isActive = activeId === item.slug || activeId === item.id; + const { + lastActiveGroupId, + lastActiveIndex, + lastActiveParentId, + setLastActiveGroupId, + setLastActiveIndex, + setLastActiveParentId, + } = React.useContext(GroupContext); + const { groupId, parentId, index } = item; + + const isIndexesMatched = + index === lastActiveIndex && groupId === lastActiveGroupId && parentId === lastActiveParentId; + const isSlugMatched = activeId === item.slug || activeId === item.id; + const isActive = isIndexesMatched && isSlugMatched; const LinkComponent = React.useContext(LinkContext); const handleClick = (e: React.MouseEvent) => { @@ -361,6 +526,10 @@ const Node = React.memo<{ e.stopPropagation(); e.preventDefault(); } else { + setLastActiveIndex(index); + setLastActiveGroupId(groupId); + setLastActiveParentId(parentId); + onLinkClick(); } @@ -390,7 +559,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..dcf8527ee 100644 --- a/packages/elements-core/src/components/TableOfContents/types.ts +++ b/packages/elements-core/src/components/TableOfContents/types.ts @@ -28,8 +28,11 @@ export type TableOfContentsGroupItem = export type TableOfContentsGroup = { title: string; + groupId: number; items: TableOfContentsGroupItem[]; itemsType?: 'article' | 'http_operation' | 'http_webhook' | 'model'; + index: number; + parentId: number; }; export type TableOfContentsExternalLink = { @@ -46,6 +49,17 @@ export type TableOfContentsNode< type: T; meta: string; version?: string; + index: number; + parentId: number; + groupId: number; }; export type TableOfContentsNodeGroup = TableOfContentsNode<'http_service'> & TableOfContentsGroup; +export type GroupContextType = { + lastActiveIndex: number | null; + lastActiveParentId: number | null; + lastActiveGroupId: number | null; + setLastActiveIndex: React.Dispatch>; + setLastActiveParentId: React.Dispatch>; + setLastActiveGroupId: React.Dispatch>; +}; diff --git a/packages/elements/src/components/API/__tests__/utils.test.ts b/packages/elements/src/components/API/__tests__/utils.test.ts index d32b67dfd..46ece2fa6 100644 --- a/packages/elements/src/components/API/__tests__/utils.test.ts +++ b/packages/elements/src/components/API/__tests__/utils.test.ts @@ -898,6 +898,9 @@ describe.each([ slug: `/${pathProp}/something/get`, title: '/something', type: nodeType, + index: 0, + groupId: 0, + parentId: 0, }, ], }, @@ -979,6 +982,9 @@ describe.each([ title: 'a', type: 'model', meta: '', + groupId: 0, + index: 0, + parentId: 0, }, ], }, @@ -1042,6 +1048,9 @@ describe.each([ slug: `/${pathProp}/something-else/post`, title: '/something-else', type: nodeType, + index: 0, + groupId: 0, + parentId: 0, }, ], }, diff --git a/packages/elements/src/components/API/utils.ts b/packages/elements/src/components/API/utils.ts index 66d8d9e0b..21bc6816d 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, @@ -174,18 +173,22 @@ const addTagGroupsToTree = ( }); groups.forEach(group => { - const items = group.items.flatMap(node => { - if (hideInternal && isInternal(node)) { - return []; - } - return { - id: node.uri, - slug: node.uri, - title: node.name, - type: node.type, - meta: isHttpOperation(node.data) || isHttpWebhookOperation(node.data) ? node.data.method : '', - }; - }); + const items = group.items + .flatMap(node => { + if (hideInternal && isInternal(node)) return []; + return { + id: node.uri, + slug: node.uri, + title: node.name, + type: node.type, + meta: isHttpOperation(node.data) || isHttpWebhookOperation(node.data) ? node.data.method : '', + index: 0, + groupId: 0, + parentId: 0, + }; + }) + .filter(Boolean); + if (items.length > 0) { tree.push({ title: group.title,