Skip to content
Merged
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
9 changes: 8 additions & 1 deletion packages/bruno-api-docs/e2e/components/base.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@ import type { Page, Locator } from '@playwright/test';

export abstract class BaseComponent {
readonly root: Locator;
private dragX = 0;
private dragY = 0;

constructor(protected readonly page: Page, root?: Locator) {
this.root = root ?? page.locator(':root');
}

/** Press the pointer on a resize handle; the drag y is kept for later moves. */
/** Press the pointer on a resize handle; the grab point is kept for later moves. */
protected async grabHandle(handle: Locator): Promise<void> {
const box = await handle.boundingBox();
this.dragX = (box?.x ?? 0) + (box?.width ?? 0) / 2;
this.dragY = (box?.y ?? 0) + (box?.height ?? 0) / 2;
await handle.hover();
await this.page.mouse.down();
Expand All @@ -21,6 +23,11 @@ export abstract class BaseComponent {
await this.page.mouse.move(x, this.dragY, { steps: 10 });
}

/** Move the held pointer to an absolute y (keeps the grabbed x). */
async movePointerToY(y: number): Promise<void> {
await this.page.mouse.move(this.dragX, y, { steps: 10 });
}

async releasePointer(): Promise<void> {
await this.page.mouse.up();
}
Expand Down
20 changes: 20 additions & 0 deletions packages/bruno-api-docs/e2e/components/playground.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ export class PlaygroundComponent extends BaseComponent {
readonly closeButton = this.page.getByTestId('playground-close');
readonly collapseButton = this.page.getByTestId('playground-collapse');
readonly inlinePanel = this.page.getByTestId('playground-dock-inline-panel');
readonly inlineResizer = this.page.getByTestId('playground-dock-inline-resizer');
readonly bottomPanel = this.page.getByTestId('playground-dock-bottom-panel');
readonly bottomResizer = this.page.getByTestId('playground-dock-bottom-resizer');
readonly modalPanel = this.page.getByTestId('playground-dock-modal-panel');
readonly mobilePanel = this.page.getByTestId('playground-dock-mobile-panel');
readonly divider = this.page.getByTestId('playground-divider');
Expand Down Expand Up @@ -148,4 +150,22 @@ export class PlaygroundComponent extends BaseComponent {
async grabSidebarResizer(): Promise<void> {
await this.grabHandle(this.sidebarResizer);
}

async bottomPanelHeight(): Promise<number> {
const box = await this.bottomPanel.boundingBox();
return box?.height ?? 0;
}

async inlinePanelWidth(): Promise<number> {
const box = await this.inlinePanel.boundingBox();
return box?.width ?? 0;
}

async grabBottomResizer(): Promise<void> {
await this.grabHandle(this.bottomResizer);
}

async grabInlineResizer(): Promise<void> {
await this.grabHandle(this.inlineResizer);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { test, expect } from '../../playwright';

const DESKTOP = { width: 1280, height: 900 };
const openAt = (dock: string): string => `/#/?pg=1&dock=${dock}`;
const REQUEST_PATH = ['billing', 'customers', 'Get Customers - Filter by Date Range'];

test.describe('playground layout persistence (desktop)', () => {
test.use({ viewport: DESKTOP });

test('restores the bottom sheet height across a reload', async ({ page, playground }) => {
await playground.open('bottom');
await expect(playground.bottomPanel).toBeVisible();

await playground.grabBottomResizer();
await playground.movePointerToY(300);
await playground.releasePointer();
const resized = await playground.bottomPanelHeight();
expect(resized).toBeGreaterThan(560);

await page.reload();
await expect(playground.bottomPanel).toBeVisible();
expect(Math.abs((await playground.bottomPanelHeight()) - resized)).toBeLessThan(5);
});

test('restores the inline panel width across a reload', async ({ page, playground }) => {
await playground.open('inline');
await expect(playground.inlinePanel).toBeVisible();

await playground.grabInlineResizer();
await playground.movePointerToX(500);
await playground.releasePointer();
const resized = await playground.inlinePanelWidth();
expect(resized).toBeGreaterThan(700);

await page.reload();
await expect(playground.inlinePanel).toBeVisible();
expect(Math.abs((await playground.inlinePanelWidth()) - resized)).toBeLessThan(5);
});

test('reopens in the last-used dock after closing (fresh open, no dock in URL)', async ({
requestPage,
playground
}) => {
await requestPage.open(REQUEST_PATH);
await requestPage.urlBar.tryButton.click();
await expect(playground.bottomPanel).toBeVisible();

await playground.selectDock('inline');
await expect(playground.inlinePanel).toBeVisible();

await playground.close();
await expect(playground.header).toHaveCount(0);

await requestPage.urlBar.tryButton.click();
await expect(playground.inlinePanel).toBeVisible();
await expect(playground.bottomPanel).toHaveCount(0);
});

test('a dock in the URL wins over the stored dock', async ({ page, playground }) => {
await page.addInitScript(() => {
sessionStorage.setItem('oc-docs:playgroundDock', 'inline');
});

await page.goto(openAt('modal'));
await expect(playground.modalPanel).toBeVisible();
await expect(playground.inlinePanel).toHaveCount(0);
});

test('an invalid stored dock falls back to the default on a fresh open', async ({
page,
requestPage,
playground
}) => {
await page.addInitScript(() => {
sessionStorage.setItem('oc-docs:playgroundDock', 'sideways');
});

await requestPage.open(REQUEST_PATH);
await requestPage.urlBar.tryButton.click();
await expect(playground.bottomPanel).toBeVisible();
});

test('a collapsed bottom sheet reopens expanded after a reload', async ({ page, playground }) => {
await playground.open('bottom');
await expect(playground.bottomPanel).toBeVisible();
const expanded = await playground.bottomPanelHeight();

await playground.grabBottomResizer();
await playground.movePointerToY(890);
await playground.releasePointer();
expect(await playground.bottomPanelHeight()).toBeLessThan(100);

await page.reload();
await expect(playground.bottomPanel).toBeVisible();
expect(Math.abs((await playground.bottomPanelHeight()) - expanded)).toBeLessThan(5);
});
});
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import PlaygroundHeader from '../../PlaygroundHeader/PlaygroundHeader';
import { useDockResize } from '@/hooks/useDockResize';
import { areaFor, readStoredNumber, writeStored } from '@/hooks/useStorage';
import type { DockMode } from '@/utils/playgroundDock';
import { StyledWrapper } from './StyledWrapper';

const HEADER_HEIGHT = 52;
// Treat a drag-down to (roughly) the header height as collapsed, so dragging the
// sheet down to the header behaves like the collapse button.
const COLLAPSE_EPSILON = 8;
const getDefaultHeight = () => Math.round(window.innerHeight * 0.6);
const HEIGHT_STORAGE_KEY = 'oc-docs:playgroundBottomHeight';

interface BottomSheetDockProps {
dock: DockMode;
onDockChange: (dock: DockMode) => void;
sidebarOpen: boolean;
onToggleSidebar: () => void;
onClose: () => void;
/** Bumped on each Try click; re-expands the sheet when it is collapsed. */
openNonce?: number;
children: React.ReactNode;
}
Expand All @@ -29,32 +29,37 @@ const BottomSheetDock: React.FC<BottomSheetDockProps> = ({
openNonce,
children
}) => {
// Opens to 60% of the viewport by default (and re-expands to it from collapsed),
// and can be dragged up to full screen or down to collapse.
const defaultHeight = Math.round(window.innerHeight * 0.6);
const defaultHeight = getDefaultHeight();
const initialHeight = useMemo(
() => readStoredNumber(areaFor('session'), HEIGHT_STORAGE_KEY, getDefaultHeight()),
[]
);
const { size, dragging, startDrag, setSize } = useDockResize({
axis: 'y',
initial: defaultHeight,
initial: initialHeight,
min: HEADER_HEIGHT,
max: () => window.innerHeight
});
// Height drives everything: dragging down to the header collapses it, and the
// collapse button just snaps between the header height and the last size.
const lastExpanded = useRef<number>(defaultHeight);

const lastExpanded = useRef<number>(initialHeight);
const collapsed = size <= HEADER_HEIGHT + COLLAPSE_EPSILON;

// A Try click re-opens the playground; if the sheet is currently collapsed,
// expand it back to the default height. Read size/target through refs so this
// fires only on the Try signal, not when the user collapses it themselves.
const sizeRef = useRef(size);
sizeRef.current = size;
const defaultHeightRef = useRef(defaultHeight);
defaultHeightRef.current = defaultHeight;

useEffect(() => {
if (openNonce === undefined) return;
if (sizeRef.current <= HEADER_HEIGHT + COLLAPSE_EPSILON) setSize(defaultHeightRef.current);
}, [openNonce, setSize]);

useEffect(() => {
if (!dragging && size > HEADER_HEIGHT + COLLAPSE_EPSILON) {
writeStored(areaFor('session'), HEIGHT_STORAGE_KEY, size);
}
}, [dragging, size]);

const toggleCollapse = () => {
if (collapsed) {
setSize(lastExpanded.current > HEADER_HEIGHT + COLLAPSE_EPSILON ? lastExpanded.current : defaultHeight);
Expand All @@ -70,7 +75,13 @@ const BottomSheetDock: React.FC<BottomSheetDockProps> = ({
className={dragging ? 'dragging' : ''}
data-testid="playground-dock-bottom-panel"
>
<div className="resize-handle" role="separator" aria-orientation="horizontal" onPointerDown={startDrag} />
<div
className="resize-handle"
role="separator"
aria-orientation="horizontal"
onPointerDown={startDrag}
data-testid="playground-dock-bottom-resizer"
/>
<PlaygroundHeader
dock={dock}
onDockChange={onDockChange}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import React from 'react';
import React, { useEffect, useMemo } from 'react';
import PlaygroundHeader from '../../PlaygroundHeader/PlaygroundHeader';
import { useDockResize } from '@/hooks/useDockResize';
import { areaFor, readStoredNumber, writeStored } from '@/hooks/useStorage';
import type { DockMode } from '@/utils/playgroundDock';
import { StyledWrapper } from './StyledWrapper';

const WIDTH_STORAGE_KEY = 'oc-docs:playgroundInlineWidth';

interface InlineDockProps {
dock: DockMode;
onDockChange: (dock: DockMode) => void;
Expand All @@ -14,20 +17,34 @@ interface InlineDockProps {
}

const InlineDock: React.FC<InlineDockProps> = ({ dock, onDockChange, sidebarOpen, onToggleSidebar, onClose, children }) => {
const initialWidth = useMemo(
() => readStoredNumber(areaFor('session'), WIDTH_STORAGE_KEY, Math.round(window.innerWidth * 0.4)),
[]
);
const { size, dragging, startDrag } = useDockResize({
axis: 'x',
initial: Math.round(window.innerWidth * 0.4),
initial: initialWidth,
min: 360,
max: () => Math.round(window.innerWidth * 0.7)
});

useEffect(() => {
if (!dragging) writeStored(areaFor('session'), WIDTH_STORAGE_KEY, size);
}, [dragging, size]);

return (
<StyledWrapper
style={{ width: `${size}px` }}
className={dragging ? 'dragging' : ''}
data-testid="playground-dock-inline-panel"
>
<div className="resize-handle" role="separator" aria-orientation="vertical" onPointerDown={startDrag} />
<div
className="resize-handle"
role="separator"
aria-orientation="vertical"
onPointerDown={startDrag}
data-testid="playground-dock-inline-resizer"
/>
<div className="dock-body">
<PlaygroundHeader
dock={dock}
Expand Down
16 changes: 6 additions & 10 deletions packages/bruno-api-docs/src/hooks/usePlaygroundUrlState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import {
type PlaygroundUrlState,
DEFAULT_DOCK,
readPlaygroundParams,
writePlaygroundParams
readStoredDock,
writePlaygroundParams,
writeStoredDock
} from '@/utils/playgroundDock';
import { areaFor } from './useStorage';

export interface PlaygroundUrlApi extends PlaygroundUrlState {
openPlayground: (requestSlug?: string | null) => void;
Expand All @@ -23,13 +26,10 @@ export const usePlaygroundUrlState = (): PlaygroundUrlApi => {
const openPlayground = useCallback(
(requestSlug?: string | null) => {
setParams((prev) => {
// Keep the current dock when the playground is already open (e.g. Try
// clicked while docked inline/modal); only fall back to the default when
// opening fresh.
const current = readPlaygroundParams(prev);
return writePlaygroundParams(prev, {
open: true,
dock: current.open ? current.dock : DEFAULT_DOCK,
dock: current.open ? current.dock : readStoredDock(areaFor('session')) ?? DEFAULT_DOCK,
requestSlug
});
});
Expand All @@ -43,12 +43,10 @@ export const usePlaygroundUrlState = (): PlaygroundUrlApi => {

const setDock = useCallback(
(dock: DockMode) => {
writeStoredDock(areaFor('session'), dock);
setParams(
(prev) => {
const current = readPlaygroundParams(prev);
// A dock switch is presentation only: preserve the full view state,
// including the open example, so switching docks never yanks the user
// off an example (pgEx survives only when both slugs are written).
return writePlaygroundParams(prev, {
open: true,
dock,
Expand All @@ -72,8 +70,6 @@ export const usePlaygroundUrlState = (): PlaygroundUrlApi => {
[setParams]
);

// Open a specific example: writes pgReq + pgEx together so a reload / share
// restores the same example (they must move as one, see writePlaygroundParams).
const setRequestExample = useCallback(
(requestSlug?: string | null, exampleSlug?: string | null) => {
setParams((prev) => {
Expand Down
28 changes: 27 additions & 1 deletion packages/bruno-api-docs/src/hooks/useStorage.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { readStored, writeStored } from './useStorage';
import { readStored, readStoredNumber, writeStored } from './useStorage';
import { fakeStorage } from '@/test-utils/storage';

describe('readStored', () => {
Expand Down Expand Up @@ -39,3 +39,29 @@ describe('writeStored', () => {
expect(storage.length).toBe(0);
});
});

describe('readStoredNumber', () => {
it('returns a stored finite number', () => {
const storage = fakeStorage();
storage.setItem('k', '640');
expect(readStoredNumber(storage, 'k', 100)).toBe(640);
});

it('falls back when nothing is stored', () => {
expect(readStoredNumber(fakeStorage(), 'k', 100)).toBe(100);
});

it('falls back for corrupt or non-numeric values', () => {
const storage = fakeStorage();
storage.setItem('k', '{not json');
expect(readStoredNumber(storage, 'k', 100)).toBe(100);
storage.setItem('k', '"wide"');
expect(readStoredNumber(storage, 'k', 100)).toBe(100);
storage.setItem('k', 'null');
expect(readStoredNumber(storage, 'k', 100)).toBe(100);
});

it('falls back with no storage (SSR)', () => {
expect(readStoredNumber(null, 'k', 100)).toBe(100);
});
});
Loading
Loading