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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Page, Locator } from '@playwright/test';
import { BaseComponent } from '../base.component';

/**
* The endpoint search palette. A page-wide control (its panel is a fixed
* The collection search palette. A page-wide control (its panel is a fixed
* overlay), so it omits a container root. Parts are found by accessible role or
* test id, never by class.
*/
Expand All @@ -12,7 +12,7 @@ export class SearchComponent extends BaseComponent {
}

/** Inline combobox field (desktop / once revealed below desktop). */
readonly field = this.root.getByRole('combobox', { name: 'Search endpoints' });
readonly field = this.root.getByRole('combobox', { name: 'Search requests and folders' });
/** The panel element (open state is reflected by its `data-open` attribute). */
readonly panel = this.root.getByTestId('search-panel');
readonly openPanel = this.root.locator('[data-testid="search-panel"][data-open="true"]');
Expand All @@ -22,6 +22,12 @@ export class SearchComponent extends BaseComponent {
readonly resultsScroll = this.root.getByTestId('search-scroll');
readonly results = this.root.getByTestId('search-result');
readonly resultMethods = this.root.getByTestId('search-result-method');
readonly resultNames = this.root.getByTestId('search-result-name');
readonly resultBreadcrumbs = this.root.getByTestId('search-result-breadcrumb');
/** Portalled bubble showing a clipped result name in full. */
readonly nameTooltip = this.root.getByTestId('search-result-name-tooltip');
readonly breadcrumbTooltip = this.root.getByTestId('search-result-breadcrumb-tooltip');
readonly folderResults = this.results.filter({ has: this.page.getByTestId('search-result-folder-icon') });
/** The keyboard-highlighted result option. */
readonly activeOption = this.root.locator('[role="option"][aria-selected="true"]');
readonly clearButton = this.root.getByRole('button', { name: 'Clear search' });
Expand Down
179 changes: 174 additions & 5 deletions packages/bruno-api-docs/e2e/tests/search/search.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { test, expect } from '../../playwright';

/**
* The endpoint search palette: a header-anchored combobox whose listbox drops
* The collection search palette: a header-anchored combobox whose listbox drops
* below the field. Inline on desktop; revealed by a Topbar icon below desktop.
*/
test.use({ colorScheme: 'light' });
Expand Down Expand Up @@ -63,7 +63,7 @@ test.describe('Search palette', () => {
await search.field.click();

await expect(search.panel).toContainText('Search the collection');
await expect(search.panel).toContainText('Find any request by name, endpoint, or folder.');
await expect(search.panel).toContainText('Find by name or endpoint.');
});

test('typing fuzzy-matches over request names', async ({ page, search }) => {
Expand All @@ -86,14 +86,170 @@ test.describe('Search palette', () => {
await expect(search.panel).toContainText('Login');
});

test('matches on the folder chain typed as text (not only the filter dropdown)', async ({ page, search }) => {
test('a folder matches by name and renders as its own result', async ({ page, search }) => {
await page.setViewportSize(DESKTOP);
await page.goto(FIXTURE);
await search.field.click();
await search.field.fill('availability'); // a folder nested under Rooms

await expect(search.folderResults).toHaveCount(1);
await expect(search.folderResults.first()).toContainText('Availability');
await expect(search.folderResults.first()).toContainText('3 requests');
// The chain is what separates two folders sharing a name.
await expect(search.folderResults.first().getByTestId('search-result-breadcrumb')).toHaveText('Rooms');
});

test('hovering an elided breadcrumb reveals the chain it hides', async ({ page, search }) => {
await page.setViewportSize(DESKTOP);
await page.goto(FIXTURE);
await search.field.click();
await search.field.fill('snapshots'); // Guests / Profiles / Archive / Legacy

const crumb = search.folderResults.first().getByTestId('search-result-breadcrumb');
await expect(crumb).toHaveText('Guests / … / Legacy');
await crumb.hover();
await expect(search.breadcrumbTooltip).toHaveText('Guests / Profiles / Archive / Legacy');
});

test('leaving a breadcrumb before the dwell elapses cancels the tooltip', async ({ page, search }) => {
await page.setViewportSize(DESKTOP);
await page.goto(FIXTURE);
await search.field.click();
await search.field.fill('snapshots');

const crumb = search.folderResults.first().getByTestId('search-result-breadcrumb');
await expect(crumb).toHaveText('Guests / … / Legacy');
await crumb.hover();
await search.field.hover(); // away again well inside the 500ms dwell

// Past the dwell: an uncancelled timer would open a bubble with the pointer
// elsewhere, and nothing left to close it.
await page.waitForTimeout(900);
await expect(search.breadcrumbTooltip).toHaveCount(0);
});

test('a long name and a long chain never overflow the results list', async ({ page, search }) => {
await page.setViewportSize(DESKTOP);
await page.goto(FIXTURE);
await search.field.click();
await search.field.fill('retention'); // deep folder with a very long name

const row = search.results.first();
await expect(row).toBeVisible();

// Overflowing here used to scroll the list sideways, which stranded the
// row's hover background at the container edge.
const { scrollWidth, clientWidth } = await search.resultsScroll.evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth
}));
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);

// The name is the primary label, so the chain yields the width, not it.
const nameBox = await row.getByTestId('search-result-name').boundingBox();
const crumbBox = await row.getByTestId('search-result-breadcrumb').boundingBox();
expect(nameBox, 'name has no bounding box').not.toBeNull();
expect(crumbBox, 'breadcrumb has no bounding box').not.toBeNull();
expect(nameBox!.width).toBeGreaterThan(crumbBox!.width);
});

test('hovering a clipped result name reveals it in full', async ({ page, search }) => {
await page.setViewportSize(MOBILE); // narrow enough that the name cannot fit
await page.goto(FIXTURE);
await search.toggleIcon.click();
await search.field.fill('retention');

const name = search.results.first().getByTestId('search-result-name');
await expect(name).toBeVisible();
await name.hover();
await expect(search.nameTooltip).toHaveText('Consolidated Retention and Deletion Policy Configuration');
});

test('a result name shown whole gets no tooltip on hover', async ({ page, search }) => {
await page.setViewportSize(DESKTOP);
await page.goto(FIXTURE);
await search.field.click();
await search.field.fill('retention'); // same row, but the width is there for it

const name = search.results.first().getByTestId('search-result-name');
await expect(name).toBeVisible();
await name.hover();

// Past the dwell, or the assertion passes on the first poll simply because
// nothing has opened yet and a regression would ship uncaught.
await page.waitForTimeout(900);
await expect(search.nameTooltip).toHaveCount(0);
});

test('a breadcrumb shown whole gets no tooltip on hover', async ({ page, search }) => {
await page.setViewportSize(DESKTOP);
await page.goto(FIXTURE);
await search.field.click();
await search.field.fill('check availability'); // sits at Rooms / Availability

const crumb = search.results.first().getByTestId('search-result-breadcrumb');
await expect(crumb).toHaveText('Rooms / Availability');
await crumb.hover();

// Nothing is hidden, so a bubble would only repeat the visible text. Wait
// past the dwell first: asserting straight after hover passes trivially.
await page.waitForTimeout(900);
await expect(search.breadcrumbTooltip).toHaveCount(0);
});

test('a top-level folder shows no breadcrumb (it has no chain)', async ({ page, search }) => {
await page.setViewportSize(DESKTOP);
await page.goto(FIXTURE);
await search.field.click();
await search.field.fill('guests');

await expect(search.folderResults.first()).toContainText('Guests');
await expect(search.folderResults.first().getByTestId('search-result-breadcrumb')).toHaveCount(0);
});

test('a folder name no longer surfaces the requests inside it', async ({ page, search }) => {
await page.setViewportSize(DESKTOP);
await page.goto(FIXTURE);
await search.field.click();
await search.field.fill('authentication'); // the folder Login lives under

await expect(search.folderResults.first()).toContainText('Authentication');
await expect(search.panel).not.toContainText('Login');
});

test('folders rank above requests that match the same query', async ({ page, search }) => {
await page.setViewportSize(DESKTOP);
await page.goto(FIXTURE);
await search.field.click();
await search.field.fill('bookings');

await expect(search.results.first()).toBeVisible();
await expect(search.panel).toContainText('Login');
await expect(search.results.first()).toContainText('Bookings');
await expect(search.results.first()).toContainText('8 requests');
});

test('selecting a folder result opens that folder page', async ({ page, search, folderPage }) => {
await page.setViewportSize(DESKTOP);
await page.goto(FIXTURE);
await search.field.click();
await search.field.fill('availability');
await search.folderResults.first().click();

await expect(search.openPanel).toHaveCount(0);
await expect(folderPage.title).toHaveText('Availability');
await expect(folderPage.requestCount).toHaveText('3 requests');
});

test('an active method chip hides folders (a folder has no method)', async ({ page, search }) => {
await page.setViewportSize(DESKTOP);
await page.goto(FIXTURE);
await search.field.click();
await search.field.fill('bookings');
await expect(search.folderResults.first()).toBeVisible();

await search.methodChip('GET').click();
await expect(search.results.first()).toBeVisible();
await expect(search.folderResults).toHaveCount(0);
});

test('a single character keeps the initial prompt (below the match threshold)', async ({ page, search }) => {
Expand Down Expand Up @@ -137,7 +293,7 @@ test.describe('Search palette', () => {
await search.field.click();
await search.field.fill('zzzqqq-nomatch');

await expect(search.panel).toContainText('No matching requests');
await expect(search.panel).toContainText('No matches');
await expect(search.resultsList).toHaveCount(0);
});

Expand Down Expand Up @@ -239,6 +395,19 @@ test.describe('Search palette', () => {
await expect(search.panel).not.toContainText('Create Booking');
});

test('the filtered folder appears as its own result, ahead of its contents', async ({ page, search }) => {
await page.setViewportSize(DESKTOP);
await page.goto(FIXTURE);
await search.field.click();

await search.folderButton.click();
await search.folderOption('Bookings').click();

await expect(search.folderResults).toHaveCount(3);
await expect(search.folderResults.nth(0)).toContainText('Bookings');
await expect(search.results.first()).toContainText('Bookings');
});

test('tablet: the toggle reveals a panel that stays within the viewport', async ({ page, search }) => {
await page.setViewportSize(TABLET);
await page.goto('/');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const renderBar = () =>
describe('SearchBar', () => {
it('renders a collapsed combobox search field by default (no panel)', () => {
const html = renderBar();
expect(html).toContain('placeholder="Search endpoints');
expect(html).toContain('placeholder="Search requests, folders');
expect(html).toContain('role="combobox"');
expect(html).toContain('aria-expanded="false"');
// Closed: no filter row / results listbox rendered yet.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
collectMethods,
createSearchIndex,
searchHits,
orderFoldersFirst,
type SearchHit,
type SearchRecord,
} from '../searchIndex';
Expand Down Expand Up @@ -40,9 +41,10 @@ interface SearchBarProps {
}

/**
* Header-anchored endpoint search. Typo-tolerant (Fuse/Bitap) search over name,
* URL and folder chain plus palette-local method + folder filters. Results
* render in the palette itself and selecting one navigates via the slug route.
* Header-anchored collection search. Typo-tolerant (Fuse/Bitap) search over
* request names and URLs and over folder names, plus palette-local method +
* folder filters. Results render in the palette itself and selecting one
* navigates via the slug route, to a request or a folder page.
*
* Expands in place (a combobox whose listbox drops directly below the field)
* rather than opening a centered modal. Open state is controlled so the Topbar
Expand Down Expand Up @@ -80,11 +82,17 @@ export const SearchBar: React.FC<SearchBarProps> = ({ open, onOpenChange, focusN
: hasFilter
? records.map((record) => ({ record, matches: {} }))
: [];
return base.filter(
({ record: r }) =>
(methods.size === 0 || (r.method ? methods.has(r.method.toUpperCase()) : false)) &&
(folder === null || r.ancestorSlugs.includes(folder)),
);
const filtered = base.filter(({ record: r }) => {
// A folder carries no method, so any active method chip excludes them all.
const passesMethod
= methods.size === 0 || (r.type === 'request' && !!r.method && methods.has(r.method.toUpperCase()));
// The filtered folder matches itself, not only the items beneath it.
const passesFolder = folder === null || r.ancestorSlugs.includes(folder) || r.slug === folder;
return passesMethod && passesFolder;
});
// `searchHits` already groups folders first; the filter-only list is raw nav
// order, so it needs the same grouping to rank consistently.
return hasQuery ? filtered : orderFoldersFirst(filtered);
}, [query, methods, folder, records, fuse, hasQuery, hasFilter]);

useEffect(() => setActiveIdx(-1), [results]);
Expand Down Expand Up @@ -198,14 +206,14 @@ export const SearchBar: React.FC<SearchBarProps> = ({ open, onOpenChange, focusN
ref={inputRef}
className="search-input"
type="text"
placeholder="Search endpoints…"
placeholder="Search requests, folders…"
value={query}
role="combobox"
aria-expanded={open}
aria-controls={RESULTS_ID}
aria-activedescendant={activeIdx >= 0 ? optionId(activeIdx) : undefined}
aria-autocomplete="list"
aria-label="Search endpoints"
aria-label="Search requests and folders"
autoComplete="off"
spellCheck={false}
onFocus={() => onOpenChange(true)}
Expand Down Expand Up @@ -242,14 +250,14 @@ export const SearchBar: React.FC<SearchBarProps> = ({ open, onOpenChange, focusN
<SearchIcon />
</span>
<p className="search-empty-title">Search the collection</p>
<p className="search-empty-text">Find any request by name, endpoint, or folder.</p>
<p className="search-empty-text">Find by name or endpoint.</p>
</div>
) : results.length === 0 ? (
<div className="search-empty">
<span className="search-empty-icon" data-tone="muted" aria-hidden="true">
<SearchIcon />
</span>
<p className="search-empty-title">No matching requests</p>
<p className="search-empty-title">No matches</p>
<p className="search-empty-text">
Nothing matches {hasQuery ? <>“<b>{query}</b>”</> : 'these filters'}. Try a different
term or clear the filters.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export const StyledWrapper = styled.div`
.search-results {
max-height: calc(var(--search-panel-max) - 5rem);
overflow-y: auto;
overflow-x: hidden;
padding: 0.25rem;
scroll-padding: 0.25rem;
}
Expand Down
Loading
Loading