Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/oc-docs/e2e/collection-docs.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
9 changes: 6 additions & 3 deletions packages/oc-docs/e2e/examples.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
35 changes: 28 additions & 7 deletions packages/oc-docs/e2e/requests.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
74 changes: 74 additions & 0 deletions packages/oc-docs/e2e/routing.spec.ts
Original file line number Diff line number Diff line change
@@ -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$/);
});
});
91 changes: 91 additions & 0 deletions packages/oc-docs/src/components/AppShell/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -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<AppShellProps> = ({ 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<HttpRequest | Folder | null>(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 (
<div className="oc-appshell flex flex-col h-screen">
<Topbar
collectionName={collection?.info?.name || 'API Collection'}
version={collection?.info?.version}
logo={logo}
onOpenInBruno={gitCollectionUrl ? handleOpenInBruno : undefined}
/>

<div className="flex flex-1 min-h-0">
<aside
className="oc-sidebar h-full overflow-hidden flex-shrink-0 hidden md:flex"
style={{
width: 'var(--sidebar-width)',
borderRight: '1px solid var(--oc-border-border1, var(--border-color))',
backgroundColor: 'var(--oc-sidebar-bg)',
}}
>
<Sidebar />
</aside>

<main className="oc-content flex-1 min-w-0 h-full overflow-y-auto">
<PageRouter onOpenPlayground={handleOpenPlayground} />
</main>
</div>

<PlaygroundDrawer
isOpen={showDrawer}
onClose={() => setShowDrawer(false)}
collection={playgroundCollection}
selectedItem={playgroundItem}
onSelectItem={setPlaygroundItem}
/>
</div>
);
};

export default AppShell;
80 changes: 80 additions & 0 deletions packages/oc-docs/src/components/AppShell/Topbar/Topbar.tsx
Original file line number Diff line number Diff line change
@@ -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<TopbarProps> = ({
collectionName,
version,
logo,
searchSlot,
envSwitcherSlot,
onOpenInBruno,
}) => {
return (
<header
className="oc-topbar flex items-center gap-4"
style={{
height: 'var(--oc-topbar-height, 56px)',
padding: '0 16px',
borderBottom: '1px solid var(--oc-border-border1, var(--border-color))',
backgroundColor: 'var(--oc-bg, var(--oc-sidebar-bg))',
}}
>
<div className="flex items-center gap-2 flex-shrink-0 min-w-0">
{logo}
<span
className="truncate"
style={{ color: 'var(--oc-text-primary, var(--text-primary))', fontWeight: 600, fontSize: '0.95rem' }}
>
{collectionName}
</span>
{version && (
<span style={{ color: 'var(--oc-text-muted, var(--text-secondary))', fontSize: '0.75rem' }}>
{version}
</span>
)}
</div>

{searchSlot && <div className="flex-1 min-w-0 hidden md:block">{searchSlot}</div>}

<div className="flex items-center gap-3 flex-shrink-0 ml-auto">
{envSwitcherSlot}
{onOpenInBruno && (
<button
type="button"
onClick={onOpenInBruno}
style={{
fontSize: '0.8rem',
padding: '4px 10px',
borderRadius: 6,
border: '1px solid var(--oc-border-border1, var(--border-color))',
color: 'var(--oc-text-primary, var(--text-primary))',
background: 'transparent',
cursor: 'pointer',
}}
>
Open in Bruno
</button>
)}
</div>
</header>
);
};

export default Topbar;
Loading
Loading