From 5f01b633403f326249b0b7c9fd1cb1fdc646cbc6 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Fri, 26 Jun 2026 23:51:03 +0530 Subject: [PATCH] feat(oc-docs): page-based routing with slug navigation --- packages/oc-docs/e2e/tests/app/app.spec.ts | 7 + .../oc-docs/e2e/tests/routing/routing.spec.ts | 75 +++++++ .../src/components/AppShell/AppShell.tsx | 68 ++++++ .../src/components/AppShell/StyledWrapper.ts | 37 +++ .../oc-docs/src/components/Docs/Item/Item.tsx | 2 +- .../src/components/Docs/Sidebar/Sidebar.tsx | 210 ++++++++++-------- .../OpenCollection/OpenCollection.tsx | 181 ++------------- .../src/components/PageRouter/PageRouter.tsx | 54 +++++ .../components/PageRouter/StyledWrapper.ts | 22 ++ .../src/components/PrevNext/PrevNext.tsx | 59 +++++ .../src/components/PrevNext/StyledWrapper.ts | 125 +++++++++++ packages/oc-docs/src/dev.tsx | 7 +- .../src/e2eFixtures/foldersCollection.ts | 95 ++++++++ .../src/pages/Environments/Environments.tsx | 12 + .../oc-docs/src/pages/Request/Request.tsx | 29 +++ packages/oc-docs/src/routing/hooks.ts | 20 ++ packages/oc-docs/src/routing/index.ts | 5 + packages/oc-docs/src/routing/navModel.spec.ts | 99 +++++++++ packages/oc-docs/src/routing/navModel.ts | 110 +++++++++ packages/oc-docs/src/routing/resolve.spec.ts | 56 +++++ packages/oc-docs/src/routing/resolve.ts | 38 ++++ packages/oc-docs/src/routing/slug.spec.ts | 53 +++++ packages/oc-docs/src/routing/slug.ts | 31 +++ packages/oc-docs/src/routing/types.ts | 52 +++++ packages/oc-docs/src/store/slices/docs.ts | 14 +- packages/oc-docs/src/utils/itemUtils.ts | 8 + 26 files changed, 1207 insertions(+), 262 deletions(-) create mode 100644 packages/oc-docs/e2e/tests/app/app.spec.ts create mode 100644 packages/oc-docs/e2e/tests/routing/routing.spec.ts create mode 100644 packages/oc-docs/src/components/AppShell/AppShell.tsx create mode 100644 packages/oc-docs/src/components/AppShell/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/PageRouter/PageRouter.tsx create mode 100644 packages/oc-docs/src/components/PageRouter/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/PrevNext/PrevNext.tsx create mode 100644 packages/oc-docs/src/components/PrevNext/StyledWrapper.ts create mode 100644 packages/oc-docs/src/e2eFixtures/foldersCollection.ts create mode 100644 packages/oc-docs/src/pages/Environments/Environments.tsx create mode 100644 packages/oc-docs/src/pages/Request/Request.tsx create mode 100644 packages/oc-docs/src/routing/hooks.ts create mode 100644 packages/oc-docs/src/routing/index.ts create mode 100644 packages/oc-docs/src/routing/navModel.spec.ts create mode 100644 packages/oc-docs/src/routing/navModel.ts create mode 100644 packages/oc-docs/src/routing/resolve.spec.ts create mode 100644 packages/oc-docs/src/routing/resolve.ts create mode 100644 packages/oc-docs/src/routing/slug.spec.ts create mode 100644 packages/oc-docs/src/routing/slug.ts create mode 100644 packages/oc-docs/src/routing/types.ts diff --git a/packages/oc-docs/e2e/tests/app/app.spec.ts b/packages/oc-docs/e2e/tests/app/app.spec.ts new file mode 100644 index 0000000..f950dcf --- /dev/null +++ b/packages/oc-docs/e2e/tests/app/app.spec.ts @@ -0,0 +1,7 @@ +import { test, expect } from '@playwright/test'; + +test('app loads and renders the collection', async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('app-shell')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Bruno Testbench' }).first()).toBeVisible(); +}); diff --git a/packages/oc-docs/e2e/tests/routing/routing.spec.ts b/packages/oc-docs/e2e/tests/routing/routing.spec.ts new file mode 100644 index 0000000..18277ef --- /dev/null +++ b/packages/oc-docs/e2e/tests/routing/routing.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; + +const FIXTURE = '/?fixture=folders'; +const page$ = (s: string) => `${FIXTURE}#/${s}`; + +test.describe('page-based navigation', () => { + test('deep-link to a nested request renders only that page on fresh load', async ({ page }) => { + await page.goto(page$('bookings/lifecycle/create-booking')); + + const active = page.getByTestId('page'); + await expect(active).toHaveAttribute('data-page-slug', 'bookings/lifecycle/create-booking'); + await expect(active).toHaveAttribute('data-page-type', 'request'); + await expect(page.getByRole('heading', { name: 'Create Booking', level: 1 })).toBeVisible(); + + await expect(page.getByRole('heading', { name: 'Login', level: 1 })).toHaveCount(0); + }); + + test('breadcrumb reflects the folder hierarchy', async ({ page }) => { + await page.goto(page$('bookings/lifecycle/create-booking')); + const bc = page.getByTestId('breadcrumb'); + await expect(bc).toContainText('Hotel API'); + await expect(bc).toContainText('Bookings'); + await expect(bc).toContainText('Lifecycle'); + await expect(page.getByRole('heading', { name: 'Create Booking', level: 1 })).toBeVisible(); + }); + + test('auto-expands ancestor folders so the deep-linked item is visible in the sidebar', async ({ page }) => { + await page.goto(page$('bookings/lifecycle/create-booking')); + await expect(page.getByTestId('sidebar-item').filter({ hasText: 'Cancel Booking' })).toBeVisible(); + }); + + test('prev/next walks the hierarchy in sequence order', async ({ page }) => { + await page.goto(page$('bookings/lifecycle/create-booking')); + + const next = page.getByTestId('next-link'); + await expect(next).toContainText('Confirm Booking'); + await next.click(); + + await expect(page.getByTestId('page')).toHaveAttribute( + 'data-page-slug', + 'bookings/lifecycle/confirm-booking' + ); + await expect(page.getByTestId('prev-link')).toContainText('Create Booking'); + await expect(page.getByTestId('next-link')).toContainText('Cancel Booking'); + }); + + test('slug URL is stable across reload', async ({ page }) => { + await page.goto(page$('authentication/login')); + await expect(page.getByRole('heading', { name: 'Login', level: 1 })).toBeVisible(); + + await page.reload(); + await expect(page).toHaveURL(/#\/authentication\/login$/); + await expect(page.getByRole('heading', { name: 'Login', level: 1 })).toBeVisible(); + }); + + test('a script item renders as its own script page', async ({ page }) => { + await page.goto(page$('setup-script')); + const active = page.getByTestId('page'); + await expect(active).toHaveAttribute('data-page-slug', 'setup-script'); + await expect(active).toHaveAttribute('data-page-type', 'script'); + await expect(page.getByRole('heading', { name: 'Setup Script', level: 1 })).toBeVisible(); + }); + + test('unknown slug redirects to the overview', async ({ page }) => { + await page.goto(page$('does/not/exist')); + await expect(page.getByTestId('page')).toHaveAttribute('data-page-type', 'overview'); + }); + + test('clicking a sidebar item navigates to its slug route', async ({ page }) => { + await page.goto(FIXTURE); + await page.locator('[data-testid="sidebar-item"][data-slug="authentication"]').click(); + await expect(page.getByTestId('page')).toHaveAttribute('data-page-slug', 'authentication'); + await expect(page).toHaveURL(/#\/authentication$/); + }); +}); diff --git a/packages/oc-docs/src/components/AppShell/AppShell.tsx b/packages/oc-docs/src/components/AppShell/AppShell.tsx new file mode 100644 index 0000000..cbece3b --- /dev/null +++ b/packages/oc-docs/src/components/AppShell/AppShell.tsx @@ -0,0 +1,68 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import type { HttpRequest } from '@opencollection/types/requests/http'; +import type { Folder } from '@opencollection/types/collection/item'; +import Topbar from '../Topbar/Topbar'; +import Sidebar from '../Docs/Sidebar/Sidebar'; +import PageRouter from '../PageRouter/PageRouter'; +import PlaygroundDrawer from '../PlaygroundDrawer/PlaygroundDrawer'; +import { useAppSelector } from '../../store/hooks'; +import { selectDocsCollection } from '../../store/slices/docs'; +import { selectPlaygroundCollection } from '../../store/slices/playground'; +import { selectGitCollectionUrl } from '../../store/slices/app'; +import { useActiveResolution } from '../../routing/hooks'; +import { buildBrunoDeepLink } from '../../utils/buildBrunoDeepLink'; +import { StyledWrapper } from './StyledWrapper'; + +interface AppShellProps { + logo?: React.ReactNode; +} + +const AppShell: React.FC = ({ logo }) => { + const collection = useAppSelector(selectDocsCollection); + const playgroundCollection = useAppSelector(selectPlaygroundCollection); + const gitCollectionUrl = useAppSelector(selectGitCollectionUrl); + const resolution = useActiveResolution(); + + const [showDrawer, setShowDrawer] = useState(false); + const [playgroundItem, setPlaygroundItem] = useState(null); + + const activeItem = resolution?.entry.item ?? null; + const activeType = resolution?.entry.type; + useEffect(() => { + if (activeItem && (activeType === 'request' || activeType === 'folder')) { + setPlaygroundItem(activeItem as HttpRequest | Folder); + } + }, [activeItem, activeType]); + + const handleOpenPlayground = useCallback(() => setShowDrawer(true), []); + + return ( + + + +
+ +
+ +
+
+ + setShowDrawer(false)} + collection={playgroundCollection} + selectedItem={playgroundItem} + onSelectItem={setPlaygroundItem} + /> +
+ ); +}; + +export default AppShell; diff --git a/packages/oc-docs/src/components/AppShell/StyledWrapper.ts b/packages/oc-docs/src/components/AppShell/StyledWrapper.ts new file mode 100644 index 0000000..0af835c --- /dev/null +++ b/packages/oc-docs/src/components/AppShell/StyledWrapper.ts @@ -0,0 +1,37 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + display: flex; + flex-direction: column; + height: 100vh; + box-sizing: border-box; + + .appshell-row { + display: flex; + flex: 1; + min-height: 0; + } + + .appshell-sidebar { + width: var(--sidebar-width); + flex-shrink: 0; + height: 100%; + overflow: hidden; + border-right: 1px solid var(--oc-border-border1, var(--border-color)); + background-color: var(--oc-sidebar-bg); + } + + .appshell-content { + flex: 1; + min-width: 0; + height: 100%; + overflow-y: auto; + overscroll-behavior-y: contain; + } + + @media (max-width: 768px) { + .appshell-sidebar { + display: none; + } + } +`; diff --git a/packages/oc-docs/src/components/Docs/Item/Item.tsx b/packages/oc-docs/src/components/Docs/Item/Item.tsx index f2b417d..a99312a 100644 --- a/packages/oc-docs/src/components/Docs/Item/Item.tsx +++ b/packages/oc-docs/src/components/Docs/Item/Item.tsx @@ -62,7 +62,7 @@ const Item = memo(({ const renderBreadcrumb = () => { if (breadcrumb.length === 0) return null; return ( -
+
{breadcrumb.map((segment, i) => ( {i > 0 && /} diff --git a/packages/oc-docs/src/components/Docs/Sidebar/Sidebar.tsx b/packages/oc-docs/src/components/Docs/Sidebar/Sidebar.tsx index 65ea7da..1f95c27 100644 --- a/packages/oc-docs/src/components/Docs/Sidebar/Sidebar.tsx +++ b/packages/oc-docs/src/components/Docs/Sidebar/Sidebar.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import type { OpenCollection } from '@opencollection/types'; +import React, { useEffect, useMemo } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; import type { Item as OpenCollectionItem, Folder } from '@opencollection/types/collection/item'; import type { HttpRequest } from '@opencollection/types/requests/http'; import Method from '../Method/Method'; @@ -7,148 +7,170 @@ import OpenCollectionLogo from '../../../assets/opencollection-logo.svg'; import { SidebarContainer, SidebarItems, SidebarItem } from './StyledWrapper'; import ThemeToggle from '../../ThemeToggle/ThemeToggle'; import { useAppDispatch, useAppSelector } from '../../../store/hooks'; -import { toggleItem, selectItem, selectSelectedItemId, selectDocsCollection } from '../../../store/slices/docs'; -import { getItemType, getItemName, getHttpMethod, isFolder, isHttpRequest } from '../../../utils/schemaHelpers'; +import { toggleItem, expandFolders, selectDocsCollection } from '../../../store/slices/docs'; +import { getItemType, getItemName, getHttpMethod, isFolder } from '../../../utils/schemaHelpers'; +import { getItemUuid } from '../../../utils/itemUtils'; +import { useNavModel } from '../../../routing/hooks'; +import { normalizeSlug } from '../../../routing/resolve'; +import { OVERVIEW_SLUG, ENVIRONMENTS_SLUG, sortSiblings } from '../../../routing/navModel'; -export interface SidebarProps { -} - -const Sidebar: React.FC = () => { +const Sidebar: React.FC = () => { const dispatch = useAppDispatch(); - const selectedItemId = useAppSelector(selectSelectedItemId); const collection = useAppSelector(selectDocsCollection); + const model = useNavModel(); + const navigate = useNavigate(); + const { pathname } = useLocation(); + const activeSlug = normalizeSlug(pathname); - const toggleFolder = (itemUuid: string) => { - dispatch(toggleItem(itemUuid)); - }; + // Active item is derived from the URL (not from redux selection). + const goTo = (slug: string) => navigate(`/${slug}`); - const handleItemSelect = (uuid: string) => { - dispatch(selectItem(uuid)); - }; + // Map each item's runtime uuid -> its stable slug for click navigation. + const uuidToSlug = useMemo(() => { + const map = new Map(); + for (const entry of model.ordered) { + const uuid = getItemUuid(entry.item); + if (uuid) map.set(uuid, entry.slug); + } + return map; + }, [model]); + + // Auto-expand (expand-only) the active node's ancestor folders — and the + // active folder itself — so a deep-linked item is always visible. + useEffect(() => { + const entry = model.bySlug.get(activeSlug); + if (!entry) return; + + const uuids: string[] = []; + for (const ancestor of entry.ancestors) { + const uuid = getItemUuid(model.bySlug.get(ancestor.slug)?.item); + if (uuid) uuids.push(uuid); + } + if (entry.type === 'folder') { + const uuid = getItemUuid(entry.item); + if (uuid) uuids.push(uuid); + } + if (uuids.length) dispatch(expandFolders(uuids)); + }, [activeSlug, model, dispatch]); const renderFolderIcon = (isExpanded: boolean) => ( - - + ); - const renderItem = (item: any, level = 0, parentPath = '') => { - + const renderItem = (item: OpenCollectionItem, level = 0) => { const itemIsFolder = isFolder(item); const itemType = getItemType(item); const itemName = getItemName(item); - const itemUuid = (item as any).uuid; - // Use UUID for active state comparison - const isActive = !itemIsFolder && selectedItemId === itemUuid; - - // Read isCollapsed from the item itself (defaults to true if not set) - const isExpanded = itemIsFolder ? !((item as any).isCollapsed ?? true) : false; - + const itemUuid = getItemUuid(item); + const itemSlug = itemUuid !== undefined ? uuidToSlug.get(itemUuid) : undefined; + const isActive = itemSlug !== undefined && itemSlug === activeSlug; + const isExpanded = itemIsFolder ? !((item as { isCollapsed?: boolean }).isCollapsed ?? true) : false; + return ( -
+
itemIsFolder ? toggleFolder(itemUuid) : handleItemSelect(itemUuid)} + style={{ paddingLeft: `${level * 16 + 8}px` }} + onClick={() => itemSlug !== undefined && goTo(itemSlug)} > - {level > 0 && ( -
)} - + {itemIsFolder ? ( -
+
{ + // Chevron toggles collapse independently of navigating to the folder page. + e.stopPropagation(); + if (itemUuid) dispatch(toggleItem(itemUuid)); + }} + > {renderFolderIcon(isExpanded)}
) : ( - + )} - - -
- {itemName} -
+ +
{itemName}
- - + {itemIsFolder && isExpanded && (item as Folder).items && (
- -
- - {((item as Folder).items || []).map((child: OpenCollectionItem) => renderItem(child, level + 1))} + {sortSiblings((item as Folder).items || []).map((child: OpenCollectionItem) => renderItem(child, level + 1))}
)}
); }; + const hasEnvironments = Boolean( + (collection as { config?: { environments?: unknown[] } })?.config?.environments?.length + ); + return ( - {/* Collection name at top */}
-

+

{collection?.info?.name || 'API Collection'}

- + - {collection?.items?.length && ( - collection.items.map((item) => renderItem(item)) + {/* Built-in pages */} + goTo(OVERVIEW_SLUG)} + > +
Overview
+
+ + {hasEnvironments && ( + goTo(ENVIRONMENTS_SLUG)} + > +
Environments
+
)} + + {collection?.items?.length ? sortSiblings(collection.items).map((item) => renderItem(item)) : null}
- - {/* Footer: Powered-by logo (left) + theme toggle (right) */} -
+ + @@ -172,4 +187,3 @@ const Sidebar: React.FC = () => { }; export default Sidebar; - diff --git a/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx b/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx index 4d3c397..7f7c3b8 100644 --- a/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx +++ b/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx @@ -1,27 +1,20 @@ -import React, { useRef, useState, useEffect, useCallback } from 'react'; +import React, { useRef, useEffect } from 'react'; +import { HashRouter } from 'react-router-dom'; import { Provider } from 'react-redux'; import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types'; -import type { Item as OpenCollectionItem, Folder } from '@opencollection/types/collection/item'; -import type { HttpRequest } from '@opencollection/types/requests/http'; import type { OpenCollection as IOpenCollection } from '@opencollection/types'; -import PlaygroundDrawer from '../PlaygroundDrawer/PlaygroundDrawer'; -import Docs from '../Docs/Docs'; -import Topbar from '../Topbar/Topbar'; -import { buildBrunoDeepLink } from '../../utils/buildBrunoDeepLink'; +import AppShell from '../AppShell/AppShell'; import { parseYaml } from '../../utils/yamlUtils'; import { hydrateWithUUIDs } from '../../utils/items'; -import { getItemType, isFolder } from '../../utils/schemaHelpers'; import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { selectDocsCollection, setDocsCollection, clearDocsCollection, - selectSelectedItemId } from '@slices/docs'; import { - selectPlaygroundCollection, setPlaygroundCollection, - clearPlaygroundCollection + clearPlaygroundCollection, } from '@slices/playground'; import { selectCollectionStatus, @@ -44,10 +37,10 @@ const isFileInstance = (value: unknown): value is File => const parseCollectionContent = (content: string): OpenCollectionCollection => { try { return parseYaml(content) as OpenCollectionCollection; - } catch (yamlError) { + } catch { try { return JSON.parse(content) as OpenCollectionCollection; - } catch (jsonError) { + } catch { throw new Error('Failed to parse collection as YAML or JSON'); } } @@ -77,115 +70,6 @@ const resolveCollectionSource = async ( return source; }; -interface DesktopLayoutProps { - docsCollection: OpenCollectionCollection | null; - playgroundCollection: OpenCollectionCollection | null; - filteredCollectionItems: OpenCollectionItem[]; - collectionName: string; - version?: string; - logo?: React.ReactNode; - openInBrunoHref?: string; - children?: React.ReactNode; -} - -const findItemByUuid = (items: OpenCollectionItem[] | undefined, uuid: string): OpenCollectionItem | null => { - if (!items) return null; - - for (const item of items) { - const itemUuid = (item as any).uuid; - if (itemUuid === uuid) { - return item; - } - if (isFolder(item) && (item as Folder).items) { - const found = findItemByUuid((item as Folder).items, uuid); - if (found) return found; - } - } - return null; -}; - -const DesktopLayout: React.FC = ({ - docsCollection, - playgroundCollection, - filteredCollectionItems, - collectionName, - version, - logo, - openInBrunoHref -}) => { - const selectedItemId = useAppSelector(selectSelectedItemId); - const [playgroundItem, setPlaygroundItem] = useState(null); - const [showPlaygroundDrawer, setShowPlaygroundDrawer] = useState(false); - const prevSelectedItemIdRef = useRef(selectedItemId); - const playgroundItemUuidRef = useRef(undefined); - const dispatch = useAppDispatch(); - - useEffect(() => { - playgroundItemUuidRef.current = (playgroundItem as any)?.uuid; - }, [playgroundItem]); - - // Update playground item when selected item changes (but don't open drawer) - useEffect(() => { - const selectionChanged = prevSelectedItemIdRef.current !== selectedItemId; - prevSelectedItemIdRef.current = selectedItemId; - - if (selectedItemId && playgroundCollection) { - if (!selectionChanged && playgroundItemUuidRef.current && playgroundItemUuidRef.current !== selectedItemId) { - return; - } - - const item = findItemByUuid(playgroundCollection.items, selectedItemId); - const itemType = item ? getItemType(item) : undefined; - if (item && (itemType === 'http' || itemType === 'folder')) { - setPlaygroundItem(item as HttpRequest | Folder); - // Don't open drawer automatically - only open when "Try" is clicked - } - } - }, [selectedItemId, playgroundCollection]); - - const handlePlaygroundItemSelect = useCallback((item: HttpRequest | Folder) => { - // Only update the playground item, don't affect the docs view - setPlaygroundItem(item); - }, []); - - const handleOpenPlayground = useCallback(() => { - setShowPlaygroundDrawer(true); - }, []); - - return ( -
- {/* searchSlot, envSwitcherSlot and onToggleSidebar are wired up - separately; the header renders empty slots / an inert hamburger - until then. */} - - -
- - - setShowPlaygroundDrawer(false)} - collection={playgroundCollection} - selectedItem={playgroundItem} - onSelectItem={handlePlaygroundItemSelect} - /> -
-
- ); -}; - -/** - * OpenCollection React component props - */ export interface OpenCollectionProps { collection: IOpenCollection | string | File; logo?: React.ReactNode; @@ -199,10 +83,8 @@ const OpenCollectionContent: React.FC = ({ }) => { const dispatch = useAppDispatch(); const docsCollection = useAppSelector(selectDocsCollection); - const playgroundCollection = useAppSelector(selectPlaygroundCollection); const collectionStatus = useAppSelector(selectCollectionStatus); const collectionError = useAppSelector(selectCollectionError); - const selectedItemId = useAppSelector((state) => state.docs.selectedItemId); useEffect(() => { gitCollectionUrl && dispatch(setGitCollectionUrl(gitCollectionUrl)); @@ -216,19 +98,13 @@ const OpenCollectionContent: React.FC = ({ try { const resolved = await resolveCollectionSource(collection); - if (!isActive) { - return; - } - // Hydrate collection with UUIDs before saving to Redux + if (!isActive) return; const hydrated = hydrateWithUUIDs(resolved); - dispatch(setDocsCollection(hydrated)); dispatch(setPlaygroundCollection(hydrated)); dispatch(setCollectionSucceeded()); } catch (err) { - if (!isActive) { - return; - } + if (!isActive) return; const message = err instanceof Error ? err.message : 'Failed to load API collection'; dispatch(setCollectionFailed(message)); dispatch(clearDocsCollection()); @@ -240,9 +116,7 @@ const OpenCollectionContent: React.FC = ({ dispatch(clearDocsCollection()); dispatch(clearPlaygroundCollection()); dispatch(resetCollectionState()); - return () => { - isActive = false; - }; + return () => { isActive = false; }; } if (isFileInstance(collection) || typeof collection === 'string') { @@ -254,40 +128,23 @@ const OpenCollectionContent: React.FC = ({ dispatch(setCollectionSucceeded()); } - return () => { - isActive = false; - }; + return () => { isActive = false; }; }, [collection, dispatch]); - const filteredCollectionItems: OpenCollectionItem[] = docsCollection?.items || []; - - const isInitialLoad = - collectionStatus === 'idle' && !docsCollection && !playgroundCollection; + const isInitialLoad = collectionStatus === 'idle' && !docsCollection; const isLoading = collectionStatus === 'loading' || isInitialLoad; - const error = collectionError; - if (isLoading) { return
Loading...
; } - if (error) { - return
Error: {error}
; + if (collectionError) { + return
Error: {collectionError}
; } - const desktopProps = { - docsCollection, - playgroundCollection, - filteredCollectionItems, - collectionName: docsCollection?.info?.name ?? '', - version: docsCollection?.info?.version, - logo, - openInBrunoHref: buildBrunoDeepLink(gitCollectionUrl), - }; - return (
- +
); }; @@ -300,10 +157,12 @@ const OpenCollection: React.FC = (props) => { } return ( - - - + + + + + ); }; -export default OpenCollection; \ No newline at end of file +export default OpenCollection; diff --git a/packages/oc-docs/src/components/PageRouter/PageRouter.tsx b/packages/oc-docs/src/components/PageRouter/PageRouter.tsx new file mode 100644 index 0000000..4db6253 --- /dev/null +++ b/packages/oc-docs/src/components/PageRouter/PageRouter.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useActiveResolution } from '../../routing/hooks'; +import { useAppSelector } from '../../store/hooks'; +import { selectDocsCollection } from '../../store/slices/docs'; +import PrevNext from '../PrevNext/PrevNext'; +import { PageWrapper } from '../PageWrapper/PageWrapper'; +import { StyledWrapper } from './StyledWrapper'; +import { Overview } from '../../pages/Overview/Overview'; +import Request from '../../pages/Request/Request'; +import Environments from '../../pages/Environments/Environments'; +import type { PageProps } from '../../routing/types'; + +interface PageRouterProps { + onOpenPlayground?: () => void; +} + +const PageRouter: React.FC = ({ onOpenPlayground }) => { + const resolution = useActiveResolution(); + const collection = useAppSelector(selectDocsCollection); + + if (!resolution) return ; + if (!collection) return null; + + const { entry, prev, next } = resolution; + const pageProps: PageProps = { node: entry, prev, next, collection, onOpenPlayground }; + + const renderBody = () => { + switch (entry.type) { + case 'overview': + return ; + case 'environments': + return ; + case 'request': + case 'folder': + case 'script': + default: + return ; + } + }; + + return ( + +
{renderBody()}
+
+ + + +
+
+ ); +}; + +export default PageRouter; diff --git a/packages/oc-docs/src/components/PageRouter/StyledWrapper.ts b/packages/oc-docs/src/components/PageRouter/StyledWrapper.ts new file mode 100644 index 0000000..d86d484 --- /dev/null +++ b/packages/oc-docs/src/components/PageRouter/StyledWrapper.ts @@ -0,0 +1,22 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: column; + min-height: 100%; + max-width: 1280px; + margin: 0 auto; + padding: 40px 48px; + + @media (max-width: 1024px) { + padding: 32px 28px; + } + @media (max-width: 768px) { + padding: 24px 18px; + } + + .page-body { + flex: 1 0 auto; + } +`; diff --git a/packages/oc-docs/src/components/PrevNext/PrevNext.tsx b/packages/oc-docs/src/components/PrevNext/PrevNext.tsx new file mode 100644 index 0000000..370fd90 --- /dev/null +++ b/packages/oc-docs/src/components/PrevNext/PrevNext.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import type { SeqNeighbor } from '../../routing/types'; +import { getMethodColorVar } from '../../theme/methodColors'; +import { StyledWrapper } from './StyledWrapper'; + +const toPath = (slug: string) => `/${slug}`; + +const MethodTag: React.FC<{ method?: string }> = ({ method }) => + method ? ( + + {method.toUpperCase()} + + ) : null; + +const Card: React.FC<{ dir: 'prev' | 'next'; neighbor: SeqNeighbor }> = ({ dir, neighbor }) => ( + + {dir === 'prev' && ( + + ‹ + + )} + + {dir === 'prev' ? 'Previous' : 'Next'} + + + {neighbor.name} + + + {dir === 'next' && ( + + › + + )} + +); + +export interface PrevNextProps { + prev?: SeqNeighbor; + next?: SeqNeighbor; +} + +const PrevNext: React.FC = ({ prev, next }) => { + if (!prev && !next) return null; + return ( + +
{prev && }
+
+ {next && } +
+
+ ); +}; + +export default PrevNext; diff --git a/packages/oc-docs/src/components/PrevNext/StyledWrapper.ts b/packages/oc-docs/src/components/PrevNext/StyledWrapper.ts new file mode 100644 index 0000000..dec321a --- /dev/null +++ b/packages/oc-docs/src/components/PrevNext/StyledWrapper.ts @@ -0,0 +1,125 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.nav` + display: flex; + align-items: stretch; + gap: 16px; + margin-top: 20px; + + .prevnext-half { + display: flex; + flex: 1; + min-width: 0; + } + .prevnext-half--next { + justify-content: flex-end; + } + + .prevnext-card { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + width: 100%; + min-height: 65px; + padding: 14px 18px; + border-radius: 8px; + border: 1px solid var(--oc-border-border1, var(--border-color)); + text-decoration: none; + transition: border-color 0.12s ease, background-color 0.12s ease; + } + @media (hover: hover) { + .prevnext-card:hover { + background-color: color-mix(in srgb, var(--oc-text) 6%, transparent); + } + } + .prevnext-card:active { + background-color: color-mix(in srgb, var(--oc-text) 6%, transparent); + } + + .prevnext-chevron { + flex-shrink: 0; + font-size: 1.1rem; + line-height: 1; + color: var(--oc-colors-text-subtext2); + } + + .prevnext-textcol { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; + } + .prevnext-card--next .prevnext-textcol { + align-items: flex-end; + text-align: right; + } + + .prevnext-label { + font-size: 0.7rem; + line-height: 1.2; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--oc-colors-text-subtext2); + } + .prevnext-name { + display: flex; + align-items: baseline; + gap: 0.5rem; + max-width: 100%; + min-width: 0; + font-size: 0.9rem; + line-height: 1.2; + font-weight: 600; + color: var(--oc-text); + } + .prevnext-card--next .prevnext-name { + justify-content: flex-end; + } + .prevnext-name-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .prevnext-method { + flex-shrink: 0; + font-size: 0.7rem; + font-weight: 700; + line-height: 1; + font-family: 'Fira Code', var(--font-mono); + } + + @media (max-width: 1024px) { + .prevnext-card { + min-height: 56px; + padding: 10px 14px; + } + } + + @media (max-width: 768px) { + gap: 8px; + padding-top: 20px; + + .prevnext-card { + min-height: 48px; + padding: 8px 10px; + gap: 6px; + } + .prevnext-label, + .prevnext-name { + font-size: 12px; + } + .prevnext-method { + font-size: 11px; + } + .prevnext-name, + .prevnext-method { + font-weight: 600; + } + .prevnext-label { + font-weight: 400; + } + } +`; diff --git a/packages/oc-docs/src/dev.tsx b/packages/oc-docs/src/dev.tsx index ff2e9c5..43dc68e 100644 --- a/packages/oc-docs/src/dev.tsx +++ b/packages/oc-docs/src/dev.tsx @@ -15,6 +15,11 @@ import 'prismjs/components/prism-graphql'; import OpenCollection from './components/OpenCollection/OpenCollection'; import { createOpenCollectionStore } from './store/store'; import { sampleCollectionYaml } from './sampleCollection'; +import { foldersFixtureCollection } from './e2eFixtures/foldersCollection'; + +// `?fixture=folders` mounts a nested-folder collection for routing e2e tests. +const fixture = new URLSearchParams(window.location.search).get('fixture'); +const devCollection = fixture === 'folders' ? foldersFixtureCollection : sampleCollectionYaml; // Ensure Prism is available globally for any code that might access it if (typeof window !== 'undefined') { @@ -29,7 +34,7 @@ const DevApp: React.FC = () => {
diff --git a/packages/oc-docs/src/e2eFixtures/foldersCollection.ts b/packages/oc-docs/src/e2eFixtures/foldersCollection.ts new file mode 100644 index 0000000..7f020fb --- /dev/null +++ b/packages/oc-docs/src/e2eFixtures/foldersCollection.ts @@ -0,0 +1,95 @@ +import type { OpenCollection } from '@opencollection/types'; + +export const foldersFixtureCollection = { + opencollection: '1.0.0', + info: { name: 'Hotel API', version: '1.0.0' }, + config: { + environments: [ + { name: 'Dev', variables: [{ name: 'host', value: 'https://api.hotel.dev' }, { name: 'api_key', value: 'dev-key-123' }] }, + { name: 'Prod', variables: [{ name: 'host', value: 'https://api.hotel.com' }, { name: 'api_key', value: 'prod-key-abc' }] }, + ], + }, + items: [ + { + name: 'Authentication', + type: 'folder', + seq: 1, + items: [ + { name: 'Login', type: 'http', seq: 1, method: 'POST', url: '{{host}}/auth/login' }, + { name: 'Refresh Token', type: 'http', seq: 2, method: 'POST', url: '{{host}}/auth/refresh' }, + { name: 'Logout', type: 'http', seq: 3, method: 'POST', url: '{{host}}/auth/logout' }, + { name: 'Get Current User', type: 'http', seq: 4, method: 'GET', url: '{{host}}/auth/me' }, + ], + }, + { + name: 'Rooms', + type: 'folder', + seq: 2, + items: [ + { name: 'List Rooms', type: 'http', seq: 1, method: 'GET', url: '{{host}}/rooms' }, + { name: 'Get Room', type: 'http', seq: 2, method: 'GET', url: '{{host}}/rooms/:id' }, + { name: 'Create Room', type: 'http', seq: 3, method: 'POST', url: '{{host}}/rooms' }, + { name: 'Update Room', type: 'http', seq: 4, method: 'PUT', url: '{{host}}/rooms/:id' }, + { name: 'Delete Room', type: 'http', seq: 5, method: 'DELETE', url: '{{host}}/rooms/:id' }, + { + name: 'Availability', + type: 'folder', + seq: 6, + items: [ + { name: 'Check Availability', type: 'http', seq: 1, method: 'GET', url: '{{host}}/rooms/:id/availability' }, + { name: 'Block Dates', type: 'http', seq: 2, method: 'POST', url: '{{host}}/rooms/:id/block' }, + { name: 'Unblock Dates', type: 'http', seq: 3, method: 'DELETE', url: '{{host}}/rooms/:id/block' }, + ], + }, + ], + }, + { + name: 'Bookings', + type: 'folder', + seq: 3, + items: [ + { name: 'List Bookings', type: 'http', seq: 1, method: 'GET', url: '{{host}}/bookings' }, + { name: 'Get Booking', type: 'http', seq: 2, method: 'GET', url: '{{host}}/bookings/:id' }, + { + name: 'Lifecycle', + type: 'folder', + seq: 3, + items: [ + { name: 'Create Booking', type: 'http', seq: 1, method: 'POST', url: '{{host}}/bookings' }, + { name: 'Confirm Booking', type: 'http', seq: 2, method: 'PATCH', url: '{{host}}/bookings/:id/confirm' }, + { name: 'Cancel Booking', type: 'http', seq: 3, method: 'DELETE', url: '{{host}}/bookings/:id' }, + ], + }, + { + name: 'Payments', + type: 'folder', + seq: 4, + items: [ + { name: 'Get Payment', type: 'http', seq: 1, method: 'GET', url: '{{host}}/bookings/:id/payment' }, + { name: 'Charge Payment', type: 'http', seq: 2, method: 'POST', url: '{{host}}/bookings/:id/payment/charge' }, + { name: 'Refund Payment', type: 'http', seq: 3, method: 'POST', url: '{{host}}/bookings/:id/payment/refund' }, + ], + }, + ], + }, + { + name: 'Guests', + type: 'folder', + seq: 4, + items: [ + { name: 'List Guests', type: 'http', seq: 1, method: 'GET', url: '{{host}}/guests' }, + { name: 'Get Guest', type: 'http', seq: 2, method: 'GET', url: '{{host}}/guests/:id' }, + { name: 'Create Guest', type: 'http', seq: 3, method: 'POST', url: '{{host}}/guests' }, + { name: 'Update Guest', type: 'http', seq: 4, method: 'PUT', url: '{{host}}/guests/:id' }, + { name: 'Delete Guest', type: 'http', seq: 5, method: 'DELETE', url: '{{host}}/guests/:id' }, + ], + }, + { name: 'Health Check', type: 'http', seq: 5, method: 'GET', url: '{{host}}/ping' }, + { + name: 'Setup Script', + type: 'script', + seq: 6, + script: "bru.setVar('requestedAt', Date.now());\nconsole.log('Hotel API docs loaded');", + }, + ], +} as unknown as OpenCollection; diff --git a/packages/oc-docs/src/pages/Environments/Environments.tsx b/packages/oc-docs/src/pages/Environments/Environments.tsx new file mode 100644 index 0000000..c4aff90 --- /dev/null +++ b/packages/oc-docs/src/pages/Environments/Environments.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import type { PageProps } from '../../routing/types'; +import { PageWrapper } from '../../components/PageWrapper/PageWrapper'; +import EnvironmentsView from '../../components/PlaygroundDrawer/DrawerContent/Views/EnvironmentsView/EnvironmentsView'; + +export const Environments: React.FC = ({ collection }) => ( + + + +); + +export default Environments; diff --git a/packages/oc-docs/src/pages/Request/Request.tsx b/packages/oc-docs/src/pages/Request/Request.tsx new file mode 100644 index 0000000..9ed9919 --- /dev/null +++ b/packages/oc-docs/src/pages/Request/Request.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import Item from '../../components/Docs/Item/Item'; +import type { PageProps } from '../../routing/types'; +import { OVERVIEW_SLUG } from '../../routing/navModel'; + +// Breadcrumb segments carry the target slug in `uuid` so onBreadcrumbClick +// navigates directly without a uuid→slug lookup. +export const Request: React.FC = ({ node, collection, onOpenPlayground }) => { + const navigate = useNavigate(); + const collectionName = collection?.info?.name || 'Overview'; + + const breadcrumb = [ + { name: collectionName, uuid: OVERVIEW_SLUG }, + ...node.ancestors.map((a) => ({ name: a.name, uuid: a.slug })), + ]; + + return ( + navigate(`/${slug}`)} + onTryClick={onOpenPlayground} + /> + ); +}; + +export default Request; diff --git a/packages/oc-docs/src/routing/hooks.ts b/packages/oc-docs/src/routing/hooks.ts new file mode 100644 index 0000000..4bbae24 --- /dev/null +++ b/packages/oc-docs/src/routing/hooks.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useAppSelector } from '../store/hooks'; +import { selectDocsCollection } from '../store/slices/docs'; +import { buildNavModel } from './navModel'; +import { resolveSlug, type Resolution } from './resolve'; +import type { NavModel } from './types'; + +/** Memoised nav model for the currently loaded collection. */ +export const useNavModel = (): NavModel => { + const collection = useAppSelector(selectDocsCollection); + return useMemo(() => buildNavModel(collection), [collection]); +}; + +/** Current hash path resolved to its entry + prev/next; null when slug is unknown. */ +export const useActiveResolution = (): Resolution | null => { + const model = useNavModel(); + const { pathname } = useLocation(); + return useMemo(() => resolveSlug(model, pathname), [model, pathname]); +}; diff --git a/packages/oc-docs/src/routing/index.ts b/packages/oc-docs/src/routing/index.ts new file mode 100644 index 0000000..1ecd176 --- /dev/null +++ b/packages/oc-docs/src/routing/index.ts @@ -0,0 +1,5 @@ +export * from './types'; +export * from './slug'; +export { buildNavModel, sortSiblings, OVERVIEW_SLUG, ENVIRONMENTS_SLUG } from './navModel'; +export { resolveSlug, normalizeSlug, type Resolution } from './resolve'; +export { useNavModel, useActiveResolution } from './hooks'; diff --git a/packages/oc-docs/src/routing/navModel.spec.ts b/packages/oc-docs/src/routing/navModel.spec.ts new file mode 100644 index 0000000..c6c1bee --- /dev/null +++ b/packages/oc-docs/src/routing/navModel.spec.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import type { OpenCollection } from '@opencollection/types'; +import { buildNavModel, OVERVIEW_SLUG, ENVIRONMENTS_SLUG } from './navModel'; + +// --- factories (new-schema info-block shape) ------------------------------- +const req = (name: string, seq: number, method = 'GET') => ({ + info: { name, type: 'http', seq }, + http: { method, url: `https://x/${name}` }, +}); + +const folder = (name: string, seq: number, items: unknown[] = []) => ({ + info: { name, type: 'folder', seq }, + items, +}); + +const script = (name: string, seq: number) => ({ + info: { name, type: 'script', seq }, + script: '// noop', +}); + +const collection = (items: unknown[], withEnvs = true): OpenCollection => + ({ + opencollection: '1.0.0', + info: { name: 'Hotel API', version: '1.0.0' }, + ...(withEnvs + ? { config: { environments: [{ name: 'Dev', variables: [] }] } } + : {}), + items, + }) as unknown as OpenCollection; + +const sample = () => + collection([ + folder('Authentication', 1, [req('Login', 1, 'GET'), req('Register', 2, 'POST')]), + folder('Hotels', 2, [folder('Browse & Search', 1)]), + req('Ping', 3, 'GET'), + ]); + +const slugs = (c: OpenCollection) => buildNavModel(c).ordered.map((e) => e.slug); + +describe('buildNavModel — ordered sequence', () => { + it('puts overview then environments at the front, then DFS of items', () => { + expect(slugs(sample())).toEqual([ + OVERVIEW_SLUG, + ENVIRONMENTS_SLUG, + 'authentication', + 'authentication/login', + 'authentication/register', + 'hotels', + 'hotels/browse-search', + 'ping', + ]); + }); + + it('omits the environments entry when the collection has none', () => { + const c = collection([req('Ping', 1)], false); + expect(slugs(c)).toEqual([OVERVIEW_SLUG, 'ping']); + }); + + it('orders siblings by seq then name (honours reordering)', () => { + const c = collection([req('Zebra', 1), req('Apple', 2)]); + // seq wins over alphabetical + expect(slugs(c)).toEqual([OVERVIEW_SLUG, ENVIRONMENTS_SLUG, 'zebra', 'apple']); + }); +}); + +describe('buildNavModel — slugs & metadata', () => { + it('builds full path-based slugs from the folder hierarchy', () => { + const model = buildNavModel(sample()); + expect(model.bySlug.has('authentication/login')).toBe(true); + expect(model.bySlug.has('hotels/browse-search')).toBe(true); + }); + + it('exposes ancestors (folder chain above the node)', () => { + const model = buildNavModel(sample()); + expect(model.bySlug.get('authentication/login')!.ancestors).toEqual([ + { name: 'Authentication', slug: 'authentication' }, + ]); + expect(model.bySlug.get('authentication')!.ancestors).toEqual([]); + }); + + it('tags entry type and http method', () => { + const model = buildNavModel(sample()); + expect(model.bySlug.get('authentication')!.type).toBe('folder'); + const login = model.bySlug.get('authentication/login')!; + expect(login.type).toBe('request'); + expect(login.method).toBe('GET'); + }); + + it('tags a script item as its own page type (not request)', () => { + const c = collection([script('Setup', 1)]); + const model = buildNavModel(c); + expect(model.bySlug.get('setup')!.type).toBe('script'); + }); + + it('dedupes colliding sibling slugs deterministically', () => { + const c = collection([folder('Auth', 1), folder('Auth', 2)]); + expect(slugs(c)).toEqual([OVERVIEW_SLUG, ENVIRONMENTS_SLUG, 'auth', 'auth-2']); + }); +}); diff --git a/packages/oc-docs/src/routing/navModel.ts b/packages/oc-docs/src/routing/navModel.ts new file mode 100644 index 0000000..c7531f4 --- /dev/null +++ b/packages/oc-docs/src/routing/navModel.ts @@ -0,0 +1,110 @@ +import type { OpenCollection } from '@opencollection/types'; +import type { Item as OpenCollectionItem, Folder } from '@opencollection/types/collection/item'; +import type { HttpRequest } from '@opencollection/types/requests/http'; +import { getItemName, getItemSeq, getItemType, isFolder, getHttpMethod } from '../utils/schemaHelpers'; +import { slugifySegment, dedupeSiblingSlugs } from './slug'; +import type { BreadcrumbSegment, NavEntry, NavModel, PageType } from './types'; + +/** Overview lives at the hash root (`#/`). */ +export const OVERVIEW_SLUG = ''; +/** Built-ins are tilde-prefixed so user content can never collide with them. */ +export const ENVIRONMENTS_SLUG = '~environments'; + +const hasEnvironments = (collection: OpenCollection): boolean => { + const envs = (collection as { config?: { environments?: unknown[] } })?.config?.environments; + return Array.isArray(envs) && envs.length > 0; +}; + +/** Sort siblings by seq (ascending), then by name. */ +export const sortSiblings = (items: OpenCollectionItem[]): OpenCollectionItem[] => + [...items].filter(Boolean).sort((a, b) => { + const seqA = getItemSeq(a); + const seqB = getItemSeq(b); + if (seqA !== undefined && seqB !== undefined && seqA !== seqB) { + return seqA - seqB; + } + if (seqA !== undefined && seqB === undefined) return -1; + if (seqA === undefined && seqB !== undefined) return 1; + return (getItemName(a) || '').localeCompare(getItemName(b) || ''); + }); + +const pageTypeOf = (item: OpenCollectionItem): PageType => { + if (isFolder(item)) return 'folder'; + if (getItemType(item) === 'script') return 'script'; + return 'request'; +}; + +const walk = ( + items: OpenCollectionItem[] | undefined, + parentSlug: string, + ancestors: BreadcrumbSegment[], + depth: number, + out: NavEntry[] +): void => { + if (!items || items.length === 0) return; + + const sorted = sortSiblings(items); + const segments = dedupeSiblingSlugs( + sorted.map((item) => slugifySegment(getItemName(item) || '')) + ); + + sorted.forEach((item, i) => { + const slug = parentSlug ? `${parentSlug}/${segments[i]}` : segments[i]; + const name = getItemName(item) || 'Untitled'; + const type = pageTypeOf(item); + + const entry: NavEntry = { + slug, + type, + name, + item, + ancestors, + depth, + ...(getItemType(item) === 'http' ? { method: getHttpMethod(item as HttpRequest) } : {}), + }; + out.push(entry); + + if (isFolder(item)) { + walk( + (item as Folder).items, + slug, + [...ancestors, { name, slug }], + depth + 1, + out + ); + } + }); +}; + +export const buildNavModel = (collection: OpenCollection | null | undefined): NavModel => { + const ordered: NavEntry[] = []; + + ordered.push({ + slug: OVERVIEW_SLUG, + type: 'overview', + name: collection?.info?.name || 'Overview', + item: null, + ancestors: [], + depth: -1, + }); + + if (collection && hasEnvironments(collection)) { + ordered.push({ + slug: ENVIRONMENTS_SLUG, + type: 'environments', + name: 'Environments', + item: null, + ancestors: [], + depth: -1, + }); + } + + walk(collection?.items, '', [], 0, ordered); + + const bySlug = new Map(); + for (const entry of ordered) { + bySlug.set(entry.slug, entry); + } + + return { ordered, bySlug }; +}; diff --git a/packages/oc-docs/src/routing/resolve.spec.ts b/packages/oc-docs/src/routing/resolve.spec.ts new file mode 100644 index 0000000..51ccbf7 --- /dev/null +++ b/packages/oc-docs/src/routing/resolve.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import type { OpenCollection } from '@opencollection/types'; +import { buildNavModel, OVERVIEW_SLUG, ENVIRONMENTS_SLUG } from './navModel'; +import { normalizeSlug, resolveSlug } from './resolve'; + +const req = (name: string, seq: number, method = 'GET') => ({ + info: { name, type: 'http', seq }, + http: { method, url: `https://x/${name}` }, +}); +const folder = (name: string, seq: number, items: unknown[] = []) => ({ + info: { name, type: 'folder', seq }, + items, +}); +const model = () => + buildNavModel({ + opencollection: '1.0.0', + info: { name: 'Hotel API' }, + config: { environments: [{ name: 'Dev', variables: [] }] }, + items: [folder('Authentication', 1, [req('Login', 1, 'POST'), req('Register', 2)])], + } as unknown as OpenCollection); + +describe('normalizeSlug', () => { + it('strips leading and trailing slashes', () => { + expect(normalizeSlug('/authentication/login/')).toBe('authentication/login'); + }); + it('maps the bare root to the overview slug', () => { + expect(normalizeSlug('/')).toBe(OVERVIEW_SLUG); + expect(normalizeSlug('')).toBe(OVERVIEW_SLUG); + }); +}); + +describe('resolveSlug', () => { + it('resolves overview with no prev and environments as next', () => { + const r = resolveSlug(model(), '')!; + expect(r.entry.type).toBe('overview'); + expect(r.prev).toBeUndefined(); + expect(r.next).toMatchObject({ slug: ENVIRONMENTS_SLUG, type: 'environments' }); + }); + + it('resolves a slug with a leading slash and gives folder/request neighbours', () => { + const r = resolveSlug(model(), '/authentication')!; + expect(r.entry.type).toBe('folder'); + expect(r.prev).toMatchObject({ slug: ENVIRONMENTS_SLUG }); + expect(r.next).toMatchObject({ slug: 'authentication/login', method: 'POST' }); + }); + + it('gives no next for the last entry in the sequence', () => { + const r = resolveSlug(model(), 'authentication/register')!; + expect(r.next).toBeUndefined(); + expect(r.prev).toMatchObject({ slug: 'authentication/login' }); + }); + + it('returns null for an unknown slug', () => { + expect(resolveSlug(model(), 'does/not/exist')).toBeNull(); + }); +}); diff --git a/packages/oc-docs/src/routing/resolve.ts b/packages/oc-docs/src/routing/resolve.ts new file mode 100644 index 0000000..2c1a3a3 --- /dev/null +++ b/packages/oc-docs/src/routing/resolve.ts @@ -0,0 +1,38 @@ +import { OVERVIEW_SLUG } from './navModel'; +import type { NavEntry, NavModel, SeqNeighbor } from './types'; + +export interface Resolution { + entry: NavEntry; + prev?: SeqNeighbor; + next?: SeqNeighbor; +} + +/** Strip leading/trailing slashes; the bare root maps to the overview slug. */ +export const normalizeSlug = (raw: string): string => { + const trimmed = (raw || '').replace(/^\/+|\/+$/g, ''); + return trimmed || OVERVIEW_SLUG; +}; + +const toNeighbor = (entry: NavEntry): SeqNeighbor => ({ + slug: entry.slug, + name: entry.name, + type: entry.type, + ...(entry.method ? { method: entry.method } : {}), +}); + +/** Resolve a slug to its entry + prev/next neighbours; null for unknown slugs. */ +export const resolveSlug = (model: NavModel, raw: string): Resolution | null => { + const slug = normalizeSlug(raw); + const entry = model.bySlug.get(slug); + if (!entry) return null; + + const i = model.ordered.indexOf(entry); + const prev = i > 0 ? model.ordered[i - 1] : undefined; + const next = i < model.ordered.length - 1 ? model.ordered[i + 1] : undefined; + + return { + entry, + ...(prev ? { prev: toNeighbor(prev) } : {}), + ...(next ? { next: toNeighbor(next) } : {}), + }; +}; diff --git a/packages/oc-docs/src/routing/slug.spec.ts b/packages/oc-docs/src/routing/slug.spec.ts new file mode 100644 index 0000000..2fac310 --- /dev/null +++ b/packages/oc-docs/src/routing/slug.spec.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { slugifySegment, dedupeSiblingSlugs } from './slug'; + +describe('slugifySegment', () => { + it('kebab-cases a plain name', () => { + expect(slugifySegment('Create Booking')).toBe('create-booking'); + }); + + it('lowercases and collapses non-alphanumerics to single dashes', () => { + expect(slugifySegment('Browse & Search')).toBe('browse-search'); + }); + + it('trims leading and trailing dashes', () => { + expect(slugifySegment(' /Login/ ')).toBe('login'); + }); + + it('keeps existing hyphens and underscores', () => { + expect(slugifySegment('refresh_token-v2')).toBe('refresh_token-v2'); + }); + + it('falls back for empty / unnamed input', () => { + expect(slugifySegment('')).toBe('unnamed'); + expect(slugifySegment('***')).toBe('unnamed'); + }); +}); + +describe('dedupeSiblingSlugs', () => { + it('leaves unique segments untouched', () => { + expect(dedupeSiblingSlugs(['login', 'register', 'refresh'])).toEqual([ + 'login', + 'register', + 'refresh', + ]); + }); + + it('suffixes -2, -3 for repeated segments in order', () => { + expect(dedupeSiblingSlugs(['login', 'login', 'login'])).toEqual([ + 'login', + 'login-2', + 'login-3', + ]); + }); + + it('does not let a suffixed slug collide with an existing one', () => { + // 'login' already taken by index 0; the literal 'login-2' at index 1 must + // not be silently re-handed to a later duplicate of 'login'. + expect(dedupeSiblingSlugs(['login', 'login-2', 'login'])).toEqual([ + 'login', + 'login-2', + 'login-3', + ]); + }); +}); diff --git a/packages/oc-docs/src/routing/slug.ts b/packages/oc-docs/src/routing/slug.ts new file mode 100644 index 0000000..6bdf9d1 --- /dev/null +++ b/packages/oc-docs/src/routing/slug.ts @@ -0,0 +1,31 @@ +/** Convert a single item/folder name into a kebab-case URL segment; falls back to 'unnamed'. */ +export const slugifySegment = (name: string): string => { + const slug = (name || '') + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + return slug || 'unnamed'; +}; + +/** Append -2, -3, … to duplicate sibling segments; input must be pre-sorted for deterministic output. */ +export const dedupeSiblingSlugs = (segments: string[]): string[] => { + const used = new Set(); + + return segments.map((segment) => { + if (!used.has(segment)) { + used.add(segment); + return segment; + } + + let n = 2; + let candidate = `${segment}-${n}`; + while (used.has(candidate)) { + n += 1; + candidate = `${segment}-${n}`; + } + used.add(candidate); + return candidate; + }); +}; diff --git a/packages/oc-docs/src/routing/types.ts b/packages/oc-docs/src/routing/types.ts new file mode 100644 index 0000000..9458dee --- /dev/null +++ b/packages/oc-docs/src/routing/types.ts @@ -0,0 +1,52 @@ +import type { OpenCollection } from '@opencollection/types'; +import type { Item as OpenCollectionItem } from '@opencollection/types/collection/item'; + +export type PageType = + | 'overview' + | 'environments' + | 'folder' + | 'script' + | 'request'; + +/** A single breadcrumb hop: a folder above the current node. */ +export interface BreadcrumbSegment { + name: string; + slug: string; +} + +/** A resolved nav node; item is null for built-in pages; slug is '' for the overview (#/). */ +export interface NavEntry { + slug: string; + type: PageType; + name: string; + item: OpenCollectionItem | null; + /** Folder chain strictly above this node (excludes self & the overview crumb). */ + ancestors: BreadcrumbSegment[]; + /** Depth in the item tree (0 = top level). Built-ins are -1. */ + depth: number; + /** HTTP method, for request entries only. */ + method?: string; +} + +/** A prev/next neighbour in the ordered sequence. */ +export interface SeqNeighbor { + slug: string; + name: string; + type: PageType; + method?: string; +} + +/** Ordered DFS sequence (prev/next) plus slug → entry lookup. */ +export interface NavModel { + ordered: NavEntry[]; + bySlug: Map; +} + +/** What every page component receives. */ +export interface PageProps { + node: NavEntry; + prev?: SeqNeighbor; + next?: SeqNeighbor; + collection: OpenCollection; + onOpenPlayground?: () => void; +} diff --git a/packages/oc-docs/src/store/slices/docs.ts b/packages/oc-docs/src/store/slices/docs.ts index d7280c3..e82b00e 100644 --- a/packages/oc-docs/src/store/slices/docs.ts +++ b/packages/oc-docs/src/store/slices/docs.ts @@ -65,10 +65,22 @@ const docsSlice = createSlice({ selectItem: (state: DocsState, action: PayloadAction) => { state.selectedItemId = action.payload; }, + // Expand-only: force the given folders open (used to reveal the active + // item's ancestors on navigation/deep-link). Never collapses, so it does + // not fight a folder the user manually closed. + expandFolders: (state: DocsState, action: PayloadAction) => { + if (!state.collection?.items || action.payload.length === 0) return; + const targets = new Set(action.payload); + for (const uuid of targets) { + findAndUpdateItem(state.collection.items, uuid, (item) => { + (item as { isCollapsed?: boolean }).isCollapsed = false; + }); + } + }, } }); -export const { setDocsCollection, clearDocsCollection, toggleItem, selectItem } = docsSlice.actions; +export const { setDocsCollection, clearDocsCollection, toggleItem, selectItem, expandFolders } = docsSlice.actions; export default docsSlice.reducer; export const selectDocsCollection = (state: RootState) => state.docs.collection; diff --git a/packages/oc-docs/src/utils/itemUtils.ts b/packages/oc-docs/src/utils/itemUtils.ts index 6765669..677e39c 100644 --- a/packages/oc-docs/src/utils/itemUtils.ts +++ b/packages/oc-docs/src/utils/itemUtils.ts @@ -30,6 +30,14 @@ export const getItemId = (item: any): string => { return item.id || item.uid || getItemName(item) || item.name || 'unnamed-item'; }; +/** + * Read the runtime uuid hydrated onto an item. + * @param item The OpenCollection item + * @returns The uuid string, or undefined before hydration + */ +export const getItemUuid = (item: OpenCollectionItem | null | undefined): string | undefined => + (item as { uuid?: string } | null | undefined)?.uuid; + /** * Generate a section ID for use in HTML elements * @param item The OpenCollection item