diff --git a/packages/oc-docs/e2e/collection-docs.spec.ts b/packages/oc-docs/e2e/collection-docs.spec.ts index 2dbabc1..7e7689c 100644 --- a/packages/oc-docs/e2e/collection-docs.spec.ts +++ b/packages/oc-docs/e2e/collection-docs.spec.ts @@ -1,6 +1,9 @@ import { test, expect } from '@playwright/test'; -test.describe('Collection-level documentation', () => { +// TODO(BRU-3188): obsoleted by page-based nav — collection docs now render in the +// overview page body, currently a placeholder. Unskip when BRU-3571 lands the overview. + +test.describe.skip('Collection-level documentation', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.collection-docs'); diff --git a/packages/oc-docs/e2e/examples.spec.ts b/packages/oc-docs/e2e/examples.spec.ts index 3b417e3..f6b52d7 100644 --- a/packages/oc-docs/e2e/examples.spec.ts +++ b/packages/oc-docs/e2e/examples.spec.ts @@ -1,6 +1,9 @@ import { test, expect } from '@playwright/test'; -test.describe('Request/response examples', () => { +// TODO(BRU-3188): obsoleted by page-based nav — examples render inside the request +// page body, now a placeholder. Unskip when BRU-3569 lands the request sections. + +test.describe.skip('Request/response examples', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.examples-container'); @@ -56,7 +59,7 @@ test.describe('Request/response examples', () => { }); }); -test.describe('Multiple examples per request (tabs)', () => { +test.describe.skip('Multiple examples per request (tabs)', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.examples-container'); @@ -118,7 +121,7 @@ test.describe('Multiple examples per request (tabs)', () => { }); }); -test.describe('Body/Headers toggle within examples', () => { +test.describe.skip('Body/Headers toggle within examples', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.examples-container'); diff --git a/packages/oc-docs/e2e/requests.spec.ts b/packages/oc-docs/e2e/requests.spec.ts index 15e19bc..a2e01eb 100644 --- a/packages/oc-docs/e2e/requests.spec.ts +++ b/packages/oc-docs/e2e/requests.spec.ts @@ -11,7 +11,10 @@ function endpointSection(page: Page, name: string) { }); } -test.describe('HTTP method badges and URLs', () => { +// TODO(BRU-3188): obsoleted by page-based nav — these assert the old single-scroll +// `.endpoint-section` + rich request bodies, which are now placeholders. Unskip and +// rewrite against the request PAGE when BRU-3569 lands the shared section library. +test.describe.skip('HTTP method badges and URLs', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.endpoint-section'); @@ -49,7 +52,10 @@ test.describe('HTTP method badges and URLs', () => { }); }); -test.describe('Request headers table', () => { +// TODO(BRU-3188): obsoleted by page-based nav — these assert the old single-scroll +// `.endpoint-section` + rich request bodies, which are now placeholders. Unskip and +// rewrite against the request PAGE when BRU-3569 lands the shared section library. +test.describe.skip('Request headers table', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.endpoint-section'); @@ -84,7 +90,10 @@ test.describe('Request headers table', () => { }); }); -test.describe('Request body rendering', () => { +// TODO(BRU-3188): obsoleted by page-based nav — these assert the old single-scroll +// `.endpoint-section` + rich request bodies, which are now placeholders. Unskip and +// rewrite against the request PAGE when BRU-3569 lands the shared section library. +test.describe.skip('Request body rendering', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.endpoint-section'); @@ -157,7 +166,10 @@ test.describe('Request body rendering', () => { }); }); -test.describe('Query parameters table', () => { +// TODO(BRU-3188): obsoleted by page-based nav — these assert the old single-scroll +// `.endpoint-section` + rich request bodies, which are now placeholders. Unskip and +// rewrite against the request PAGE when BRU-3569 lands the shared section library. +test.describe.skip('Query parameters table', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.endpoint-section'); @@ -191,7 +203,10 @@ test.describe('Query parameters table', () => { }); }); -test.describe('Request documentation', () => { +// TODO(BRU-3188): obsoleted by page-based nav — these assert the old single-scroll +// `.endpoint-section` + rich request bodies, which are now placeholders. Unskip and +// rewrite against the request PAGE when BRU-3569 lands the shared section library. +test.describe.skip('Request documentation', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.endpoint-section'); @@ -230,7 +245,10 @@ test.describe('Request documentation', () => { }); }); -test.describe('Code snippets', () => { +// TODO(BRU-3188): obsoleted by page-based nav — these assert the old single-scroll +// `.endpoint-section` + rich request bodies, which are now placeholders. Unskip and +// rewrite against the request PAGE when BRU-3569 lands the shared section library. +test.describe.skip('Code snippets', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.endpoint-section'); @@ -263,7 +281,10 @@ test.describe('Code snippets', () => { }); }); -test.describe('Examples for new request types', () => { +// TODO(BRU-3188): obsoleted by page-based nav — these assert the old single-scroll +// `.endpoint-section` + rich request bodies, which are now placeholders. Unskip and +// rewrite against the request PAGE when BRU-3569 lands the shared section library. +test.describe.skip('Examples for new request types', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.examples-container'); diff --git a/packages/oc-docs/e2e/routing.spec.ts b/packages/oc-docs/e2e/routing.spec.ts new file mode 100644 index 0000000..84ed380 --- /dev/null +++ b/packages/oc-docs/e2e/routing.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from '@playwright/test'; + +/** + * Page-based navigation (BRU-3188). Uses the nested-folder fixture mounted via + * `?fixture=folders` (see dev.tsx) so we can exercise hierarchy, nested slugs, + * auto-expand, prev/next and deep-link/reload stability. + */ +const FIXTURE = '/?fixture=folders'; + +const page$ = (s: string) => `${FIXTURE}#/${s}`; + +test.describe('page-based navigation (BRU-3188)', () => { + test('deep-link to a nested request renders only that page on a 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(); + + // Other items are NOT rendered as page bodies (no single-scroll). + 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(bc).toContainText('Create Booking'); + }); + + 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')); + // The sibling is only present in the DOM if Bookings + Lifecycle are expanded. + await expect(page.locator('aside').getByText('Cancel Booking', { exact: true })).toBeVisible(); + }); + + test('prev/next walks the hierarchy + seq order', async ({ page }) => { + await page.goto(page$('bookings/lifecycle/create-booking')); + + const next = page.getByTestId('next-link'); + await expect(next).toContainText('Cancel Booking'); + await next.click(); + + await expect(page.getByTestId('page')).toHaveAttribute( + 'data-page-slug', + 'bookings/lifecycle/cancel-booking' + ); + await expect(page.getByTestId('prev-link')).toContainText('Create 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('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..19be21d --- /dev/null +++ b/packages/oc-docs/src/components/AppShell/AppShell.tsx @@ -0,0 +1,91 @@ +/** + * AppShell — the three-region layout for page-based navigation (BRU-3188): + * - Topbar (sticky, top) — stub now, BRU-3572 replaces the body + * - Sidebar (left) — existing nav, rewired to routing (BRU-3574 owns styling) + * - Content (right, router outlet) — one active page at a time via PageRouter + * + * The playground drawer overlays the shell and is driven by the active route. + */ + +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 '../pages/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'; + +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); + + // Drive the playground item from the active route (request/folder pages). + 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), []); + const handleOpenInBruno = useCallback(() => { + if (!gitCollectionUrl) return; + window.open( + `bruno://app/collection/import/git?url=${encodeURIComponent(gitCollectionUrl)}`, + '_blank' + ); + }, [gitCollectionUrl]); + + return ( +
+ + +
+ + +
+ +
+
+ + setShowDrawer(false)} + collection={playgroundCollection} + selectedItem={playgroundItem} + onSelectItem={setPlaygroundItem} + /> +
+ ); +}; + +export default AppShell; diff --git a/packages/oc-docs/src/components/AppShell/Topbar/Topbar.tsx b/packages/oc-docs/src/components/AppShell/Topbar/Topbar.tsx new file mode 100644 index 0000000..378b141 --- /dev/null +++ b/packages/oc-docs/src/components/AppShell/Topbar/Topbar.tsx @@ -0,0 +1,80 @@ +/** + * Topbar — MINIMAL STUB (BRU-3188). + * + * BRU-3572 replaces the BODY of this component with the real top bar (search, + * env switcher, show-vars, Open-in-Bruno). This stub exists so the AppShell + * compiles and lays out correctly. The path and props below are the agreed + * cross-lane contract — do NOT change the signature without coordinating. + */ + +import React from 'react'; + +export interface TopbarProps { + collectionName: string; + version?: string; + logo?: React.ReactNode; + searchSlot?: React.ReactNode; + envSwitcherSlot?: React.ReactNode; + onOpenInBruno?: () => void; +} + +const Topbar: React.FC = ({ + collectionName, + version, + logo, + searchSlot, + envSwitcherSlot, + onOpenInBruno, +}) => { + return ( +
+
+ {logo} + + {collectionName} + + {version && ( + + {version} + + )} +
+ + {searchSlot &&
{searchSlot}
} + +
+ {envSwitcherSlot} + {onOpenInBruno && ( + + )} +
+
+ ); +}; + +export default Topbar; 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 079ea74..62c5ca3 100644 --- a/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx +++ b/packages/oc-docs/src/components/OpenCollection/OpenCollection.tsx @@ -1,25 +1,21 @@ -import React, { useRef, useState, useEffect, useCallback } from 'react'; +import React, { useRef, useEffect } from 'react'; import { Provider } from 'react-redux'; +import { HashRouter } from 'react-router-dom'; 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 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, @@ -28,7 +24,7 @@ import { setCollectionSucceeded, setCollectionFailed, resetCollectionState, - setGitCollectionUrl + setGitCollectionUrl, } from '@slices/app'; import { createOpenCollectionStore, type AppStore } from '../../store/store'; import { applyTheme } from '../../theme/applyTheme'; @@ -42,10 +38,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'); } } @@ -75,92 +71,6 @@ const resolveCollectionSource = async ( return source; }; -interface DesktopLayoutProps { - docsCollection: OpenCollectionCollection | null; - playgroundCollection: OpenCollectionCollection | null; - filteredCollectionItems: OpenCollectionItem[]; - 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 -}) => { - 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 ( -
- - - setShowPlaygroundDrawer(false)} - collection={playgroundCollection} - selectedItem={playgroundItem} - onSelectItem={handlePlaygroundItemSelect} - /> -
- ); -}; - /** * OpenCollection React component props */ @@ -170,19 +80,15 @@ export interface OpenCollectionProps { gitCollectionUrl?: string; } -const OpenCollectionContent: React.FC = ({ - collection, - gitCollectionUrl, -}) => { +const OpenCollectionContent: React.FC = ({ collection, gitCollectionUrl, logo }) => { 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)); + if (gitCollectionUrl) dispatch(setGitCollectionUrl(gitCollectionUrl)); }, [gitCollectionUrl, dispatch]); useEffect(() => { @@ -193,19 +99,15 @@ const OpenCollectionContent: React.FC = ({ try { const resolved = await resolveCollectionSource(collection); - if (!isActive) { - return; - } + if (!isActive) return; // Hydrate collection with UUIDs before saving to Redux 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()); @@ -236,14 +138,10 @@ const OpenCollectionContent: React.FC = ({ }; }, [collection, dispatch]); - const filteredCollectionItems: OpenCollectionItem[] = docsCollection?.items || []; - - const isInitialLoad = - collectionStatus === 'idle' && !docsCollection && !playgroundCollection; + const isInitialLoad = collectionStatus === 'idle' && !docsCollection && !playgroundCollection; const isLoading = collectionStatus === 'loading' || isInitialLoad; const error = collectionError; - if (isLoading) { return
Loading...
; } @@ -252,15 +150,11 @@ const OpenCollectionContent: React.FC = ({ return
Error: {error}
; } - const desktopProps = { - docsCollection, - playgroundCollection, - filteredCollectionItems, - }; - return (
- + + +
); }; @@ -279,4 +173,4 @@ const OpenCollection: React.FC = (props) => { ); }; -export default OpenCollection; \ No newline at end of file +export default OpenCollection; diff --git a/packages/oc-docs/src/components/pages/Breadcrumb.tsx b/packages/oc-docs/src/components/pages/Breadcrumb.tsx new file mode 100644 index 0000000..7ee1093 --- /dev/null +++ b/packages/oc-docs/src/components/pages/Breadcrumb.tsx @@ -0,0 +1,63 @@ +/** + * Breadcrumb chrome (BRU-3188) — owned by this lane, rendered by PageLayout. + * Shows: > > . + * The collection-name crumb links to the overview; ancestor folders link to + * their own pages; the current node is not a link. + */ + +import React from 'react'; +import { Link } from 'react-router-dom'; +import type { NavEntry } from '../../routing/types'; +import { OVERVIEW_SLUG } from '../../routing/navModel'; +import { useAppSelector } from '../../store/hooks'; +import { selectDocsCollection } from '../../store/slices/docs'; + +const toPath = (slug: string) => `/${slug}`; + +const Separator = () => ( + + › + +); + +const crumbStyle: React.CSSProperties = { + color: 'var(--oc-text-muted, var(--text-secondary))', + textDecoration: 'none', + fontSize: '0.8rem', +}; + +const Breadcrumb: React.FC<{ node: NavEntry }> = ({ node }) => { + const collection = useAppSelector(selectDocsCollection); + const collectionName = collection?.info?.name || 'Overview'; + + return ( + + ); +}; + +export default Breadcrumb; diff --git a/packages/oc-docs/src/components/pages/EnvironmentsPage.tsx b/packages/oc-docs/src/components/pages/EnvironmentsPage.tsx new file mode 100644 index 0000000..dc96312 --- /dev/null +++ b/packages/oc-docs/src/components/pages/EnvironmentsPage.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import type { PageProps } from '../../routing/types'; +import PlaceholderBody from './PlaceholderBody'; + +/** Environments page (BRU-3188 scaffold). Rich body owned by BRU-2548. */ +const EnvironmentsPage: React.FC = ({ node }) => ( + +); + +export default EnvironmentsPage; diff --git a/packages/oc-docs/src/components/pages/FolderPage.tsx b/packages/oc-docs/src/components/pages/FolderPage.tsx new file mode 100644 index 0000000..8352e74 --- /dev/null +++ b/packages/oc-docs/src/components/pages/FolderPage.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import type { PageProps } from '../../routing/types'; +import type { Folder } from '@opencollection/types/collection/item'; +import PlaceholderBody from './PlaceholderBody'; + +/** Folder page (BRU-3188 scaffold). Rich body (config sections) owned by other lanes. */ +const FolderPage: React.FC = ({ node }) => { + const count = (node.item as Folder | null)?.items?.length ?? 0; + return ( + + + {count} {count === 1 ? 'item' : 'items'} + + + ); +}; + +export default FolderPage; diff --git a/packages/oc-docs/src/components/pages/OverviewPage.tsx b/packages/oc-docs/src/components/pages/OverviewPage.tsx new file mode 100644 index 0000000..f47fc34 --- /dev/null +++ b/packages/oc-docs/src/components/pages/OverviewPage.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import type { PageProps } from '../../routing/types'; +import PlaceholderBody from './PlaceholderBody'; + +/** Overview page (BRU-3188 scaffold). Rich body owned by BRU-3571. */ +const OverviewPage: React.FC = ({ node }) => ( + +); + +export default OverviewPage; diff --git a/packages/oc-docs/src/components/pages/PageLayout.tsx b/packages/oc-docs/src/components/pages/PageLayout.tsx new file mode 100644 index 0000000..5eb6d7c --- /dev/null +++ b/packages/oc-docs/src/components/pages/PageLayout.tsx @@ -0,0 +1,44 @@ +/** + * PageLayout (BRU-3188) — the chrome around every page body. THIS lane owns + * the breadcrumb (top) and prev/next (bottom); the page BODY (children) is + * built by other lanes (BRU-3569/3571/2548). Content fills the available width + * (no centered narrow column) with fluid padding, usable mobile→large. + */ + +import React from 'react'; +import type { NavEntry, SeqNeighbor } from '../../routing/types'; +import Breadcrumb from './Breadcrumb'; +import PrevNext from './PrevNext'; + +interface PageLayoutProps { + node: NavEntry; + prev?: SeqNeighbor; + next?: SeqNeighbor; + children: React.ReactNode; +} + +const PageLayout: React.FC = ({ node, prev, next, children }) => { + const showBreadcrumb = node.type !== 'overview'; + + return ( +
+ {showBreadcrumb && } + +
{children}
+ + +
+ ); +}; + +export default PageLayout; diff --git a/packages/oc-docs/src/components/pages/PageRouter.tsx b/packages/oc-docs/src/components/pages/PageRouter.tsx new file mode 100644 index 0000000..f25ba36 --- /dev/null +++ b/packages/oc-docs/src/components/pages/PageRouter.tsx @@ -0,0 +1,58 @@ +/** + * PageRouter (BRU-3188) — selects the page component by the active node's type + * and wraps its body in PageLayout (breadcrumb + prev/next chrome). Unknown + * slugs redirect to the overview. This is the routing half of the page-component + * contract; page bodies are replaced by other lanes via the `PageType` map. + */ + +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import type { PageProps, PageType } from '../../routing/types'; +import { useActiveResolution } from '../../routing/hooks'; +import { useAppSelector } from '../../store/hooks'; +import { selectDocsCollection } from '../../store/slices/docs'; +import PageLayout from './PageLayout'; +import OverviewPage from './OverviewPage'; +import EnvironmentsPage from './EnvironmentsPage'; +import FolderPage from './FolderPage'; +import RequestPage from './RequestPage'; +import ScriptPage from './ScriptPage'; + +const PAGE_COMPONENTS: Record> = { + overview: OverviewPage, + environments: EnvironmentsPage, + folder: FolderPage, + request: RequestPage, + script: ScriptPage, +}; + +interface PageRouterProps { + onOpenPlayground?: () => void; +} + +const PageRouter: React.FC = ({ onOpenPlayground }) => { + const resolution = useActiveResolution(); + const collection = useAppSelector(selectDocsCollection); + + // Unknown slug (typo, stale link, item removed): send back to the overview. + if (!resolution) return ; + + const { entry, prev, next } = resolution; + const Page = PAGE_COMPONENTS[entry.type]; + + return ( + + {collection && ( + + )} + + ); +}; + +export default PageRouter; diff --git a/packages/oc-docs/src/components/pages/PlaceholderBody.tsx b/packages/oc-docs/src/components/pages/PlaceholderBody.tsx new file mode 100644 index 0000000..f4cb8bd --- /dev/null +++ b/packages/oc-docs/src/components/pages/PlaceholderBody.tsx @@ -0,0 +1,44 @@ +/** + * Shared placeholder body (BRU-3188). The rich page bodies are built by other + * lanes; until then these placeholders render the page title + a note naming + * the owning ticket, so the routing/shell can be exercised end-to-end. + */ + +import React from 'react'; + +const PlaceholderBody: React.FC<{ + title: string; + ownedBy: string; + children?: React.ReactNode; +}> = ({ title, ownedBy, children }) => ( +
+

+ {title} +

+ + {children} + +
+ Page body placeholder — owned by {ownedBy}. +
+
+); + +export default PlaceholderBody; diff --git a/packages/oc-docs/src/components/pages/PrevNext.tsx b/packages/oc-docs/src/components/pages/PrevNext.tsx new file mode 100644 index 0000000..77abb20 --- /dev/null +++ b/packages/oc-docs/src/components/pages/PrevNext.tsx @@ -0,0 +1,91 @@ +/** + * Prev/Next pagination chrome (BRU-3188) — owned by this lane, rendered by + * PageLayout. Walks the collection's hierarchy + seq order (the sequence is + * built in navModel), so reordering folders/requests is reflected here. + * + * Both cards share the same anatomy (label + name + method badge); only the + * alignment flips. Each card fills its half of the row so the pair spans the + * full content width. + */ + +import React from 'react'; +import { Link } from 'react-router-dom'; +import type { SeqNeighbor } from '../../routing/types'; +import { getMethodColorVar } from '../../theme/methodColors'; + +const toPath = (slug: string) => `/${slug}`; + +const cardStyle = (dir: 'prev' | 'next'): React.CSSProperties => ({ + display: 'flex', + flexDirection: 'column', + gap: 6, + width: '100%', + minHeight: 60, + padding: '12px 16px', + borderRadius: 10, + border: '1px solid var(--oc-border-border1, var(--border-color))', + textDecoration: 'none', + alignItems: dir === 'next' ? 'flex-end' : 'flex-start', + textAlign: dir === 'next' ? 'right' : 'left', +}); + +const labelStyle: React.CSSProperties = { + fontSize: '0.7rem', + textTransform: 'uppercase', + letterSpacing: '0.05em', + color: 'var(--oc-text-muted, var(--text-secondary))', +}; + +const nameStyle: React.CSSProperties = { + fontSize: '0.9rem', + fontWeight: 600, + color: 'var(--oc-text-primary, var(--text-primary))', +}; + +const MethodTag: React.FC<{ method?: string }> = ({ method }) => + method ? ( + + {method.toUpperCase()} + + ) : null; + +const Card: React.FC<{ dir: 'prev' | 'next'; neighbor: SeqNeighbor }> = ({ dir, neighbor }) => ( + + {dir === 'prev' ? '‹ Previous' : 'Next ›'} + + {/* Mirror the badge: method before name on prev, after name on next. */} + {dir === 'prev' ? ( + <> + + {neighbor.name} + + ) : ( + <> + {neighbor.name} + + + )} + + +); + +const PrevNext: React.FC<{ prev?: SeqNeighbor; next?: SeqNeighbor }> = ({ prev, next }) => { + if (!prev && !next) return null; + + return ( + + ); +}; + +export default PrevNext; diff --git a/packages/oc-docs/src/components/pages/RequestPage.tsx b/packages/oc-docs/src/components/pages/RequestPage.tsx new file mode 100644 index 0000000..cc1386c --- /dev/null +++ b/packages/oc-docs/src/components/pages/RequestPage.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import type { HttpRequest } from '@opencollection/types/requests/http'; +import type { PageProps } from '../../routing/types'; +import { getRequestUrl } from '../../utils/schemaHelpers'; +import { getMethodColorVar } from '../../theme/methodColors'; +import PlaceholderBody from './PlaceholderBody'; + +/** + * Request page (BRU-3188 scaffold). Rich body (headers/auth/params/body/ + * code-snippet sections) owned by BRU-3569's shared section library. + */ +const RequestPage: React.FC = ({ node, onOpenPlayground }) => { + const url = node.item ? getRequestUrl(node.item as HttpRequest) : ''; + return ( + +
+ {node.method && ( + + {node.method.toUpperCase()} + + )} + {url && ( + + {url} + + )} + {onOpenPlayground && ( + + )} +
+
+ ); +}; + +export default RequestPage; diff --git a/packages/oc-docs/src/components/pages/ScriptPage.tsx b/packages/oc-docs/src/components/pages/ScriptPage.tsx new file mode 100644 index 0000000..35e7f18 --- /dev/null +++ b/packages/oc-docs/src/components/pages/ScriptPage.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import type { PageProps } from '../../routing/types'; +import PlaceholderBody from './PlaceholderBody'; + +/** + * Script page (BRU-3188 scaffold). Slot only — nothing in the schema routes to + * a standalone script node yet (scripts render inside the request page). The + * 'script' PageType + this component exist so a future script-only nav entry + * has a home without touching the router. + */ +const ScriptPage: React.FC = ({ node }) => ( + +); + +export default ScriptPage; diff --git a/packages/oc-docs/src/dev.tsx b/packages/oc-docs/src/dev.tsx index 0e7bf68..a7ad075 100644 --- a/packages/oc-docs/src/dev.tsx +++ b/packages/oc-docs/src/dev.tsx @@ -14,6 +14,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 e2e (BRU-3188). +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..f3df20e --- /dev/null +++ b/packages/oc-docs/src/e2eFixtures/foldersCollection.ts @@ -0,0 +1,43 @@ +import type { OpenCollection } from '@opencollection/types'; + +/** + * E2E-only fixture: a collection WITH nested folders, so the Playwright suite + * can exercise folder hierarchy, nested slugs, auto-expand and prev/next walks + * (BRU-3188). The shared dev sample (sampleCollection.ts) is intentionally flat, + * so this lives separately and is mounted via `?fixture=folders` in dev.tsx. + */ +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' }] }], + }, + 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: 'Bookings', + type: 'folder', + seq: 2, + items: [ + { + name: 'Lifecycle', + type: 'folder', + seq: 1, + items: [ + { name: 'Create Booking', type: 'http', seq: 1, method: 'POST', url: '{{host}}/bookings' }, + { name: 'Cancel Booking', type: 'http', seq: 2, method: 'DELETE', url: '{{host}}/bookings/1' }, + ], + }, + ], + }, + { name: 'Ping', type: 'http', seq: 3, method: 'GET', url: '{{host}}/ping' }, + ], +} as unknown as OpenCollection; diff --git a/packages/oc-docs/src/routing/hooks.ts b/packages/oc-docs/src/routing/hooks.ts new file mode 100644 index 0000000..07599d5 --- /dev/null +++ b/packages/oc-docs/src/routing/hooks.ts @@ -0,0 +1,28 @@ +/** + * React hooks bridging the router (HashRouter) to the pure nav model + * (BRU-3188). Active item is derived from the URL, not from redux selection. + */ + +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]); +}; + +/** + * Resolve the current hash path to its entry + prev/next neighbours. + * Returns null when the path does not match any known slug (caller redirects). + */ +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/navModel.spec.ts b/packages/oc-docs/src/routing/navModel.spec.ts new file mode 100644 index 0000000..affabed --- /dev/null +++ b/packages/oc-docs/src/routing/navModel.spec.ts @@ -0,0 +1,88 @@ +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 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('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..0d79c4c --- /dev/null +++ b/packages/oc-docs/src/routing/navModel.ts @@ -0,0 +1,113 @@ +/** + * Builds the navigation model (ordered sequence + slug lookup) for a + * collection (BRU-3188). Pure, uuid-free, deterministic — the same collection + * always yields the same slugs, so shared URLs survive reload and re-gen. + */ + +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 — honours BRU-3403 reorder. */ +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 => + isFolder(item) ? 'folder' : '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..dbcbda9 --- /dev/null +++ b/packages/oc-docs/src/routing/resolve.ts @@ -0,0 +1,46 @@ +/** + * Slug resolution + prev/next neighbour computation against a NavModel + * (BRU-3188). Pure functions consumed by the routing hooks. + */ + +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 (possibly slash-wrapped) slug to its entry plus the prev/next + * neighbours from the ordered sequence. Returns null for an unknown slug. + */ +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..5c83108 --- /dev/null +++ b/packages/oc-docs/src/routing/slug.ts @@ -0,0 +1,47 @@ +/** + * Path-based slug helpers for shareable, stable page URLs (BRU-3188). + * + * Slugs are derived from the folder hierarchy + item names, never from the + * runtime uuid, so a URL stays stable across reloads and across re-generation + * of the docs from the same collection. + */ + +/** + * Convert a single item/folder name into one kebab-cased URL segment. + * Lowercases, replaces runs of non-alphanumerics with a single dash, trims + * dashes, and falls back to `unnamed` when nothing usable remains. + */ +export const slugifySegment = (name: string): string => { + const slug = (name || '') + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + return slug || 'unnamed'; +}; + +/** + * Disambiguate sibling segments that slugify to the same string by appending + * `-2`, `-3`, ... The input order is significant and assumed already sorted + * (seq -> name), so the result is deterministic and stable across re-gen. + */ +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..c52d20f --- /dev/null +++ b/packages/oc-docs/src/routing/types.ts @@ -0,0 +1,73 @@ +/** + * Routing contract for page-based navigation (BRU-3188). + * + * These types are the cross-lane contract: the router (this lane) resolves a + * slug to a `NavEntry` and hands a `PageProps` to the page component selected + * by `PageType`. Page BODIES are built by other tickets (BRU-3569 request, + * BRU-3571 overview, BRU-2548 environments) — they consume this contract and + * MUST NOT depend on the runtime uuid. + */ + +import type { OpenCollection } from '@opencollection/types'; +import type { Item as OpenCollectionItem } from '@opencollection/types/collection/item'; + +export type PageType = + | 'overview' + | 'environments' + | 'folder' + | 'request' + | 'script'; + +/** A single breadcrumb hop: a folder above the current node. */ +export interface BreadcrumbSegment { + name: string; + slug: string; +} + +/** + * A resolved node in the navigation model. `item` is null for the built-in + * pseudo-pages (overview, environments). `slug` is the full path-based, + * uuid-free identifier; the overview slug is the empty string ('' -> `#/`). + */ +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; +} + +/** + * The full navigation model for a collection: the ordered DFS sequence (used + * for prev/next) plus a slug -> entry lookup (used for resolution). + */ +export interface NavModel { + ordered: NavEntry[]; + bySlug: Map; +} + +/** + * What every page component receives. Other lanes replace the page BODY but + * consume exactly this shape. Breadcrumb + prev/next chrome is owned by this + * lane (rendered by PageLayout), not the page body. + */ +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 9453f1e..735f2b1 100644 --- a/packages/oc-docs/src/utils/itemUtils.ts +++ b/packages/oc-docs/src/utils/itemUtils.ts @@ -3,7 +3,7 @@ */ import type { Item as OpenCollectionItem } from '@opencollection/types/collection/item'; -import { getItemName, getItemType, isFolder } from './schemaHelpers'; +import { getItemName, isFolder } from './schemaHelpers'; /** * Generate a safe HTML ID from an item name or ID @@ -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 (see hydrateWithUUIDs). The uuid + * is not part of the schema type, so this typed accessor keeps callers free of + * `as any` casts. Returns 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