diff --git a/.changeset/lazy-tables-search.md b/.changeset/lazy-tables-search.md new file mode 100644 index 00000000..afdbab3c --- /dev/null +++ b/.changeset/lazy-tables-search.md @@ -0,0 +1,14 @@ +--- +"@usebruno/api-docs": patch +--- + +Bugfixes: +19. The folder and collection pages now have an Execution Context section, separate from the configuration section. Headers and auth stay under Folder Configuration and Collection Configuration; vars, script and tests move into Execution Context, which collapses and expands like the one on request pages. The environment switcher is hidden from the docs header when the collection has no environments. + +25. A long environment name is cut short with an ellipsis in the switcher and in its dropdown, and the full name shows in a tooltip when it is cut. + +32. Empty Post-Response variables now read "None." on the folder and collection pages, the same as on request pages, instead of the section disappearing. + +33. The playground folder settings Vars tab now has a Post Response table, like the Bruno app. Edits are saved with the folder. + +Also, when a collection has no environments, the playground switcher no longer opens a dropdown whose only entry repeats "No environments". It shows the text with no arrow. diff --git a/packages/bruno-api-docs/e2e/components/folder/folder-configuration.component.ts b/packages/bruno-api-docs/e2e/components/folder/folder-configuration.component.ts index ed8c0199..6137579b 100644 --- a/packages/bruno-api-docs/e2e/components/folder/folder-configuration.component.ts +++ b/packages/bruno-api-docs/e2e/components/folder/folder-configuration.component.ts @@ -2,6 +2,7 @@ import { BaseComponent } from '../base.component'; export class FolderConfigurationComponent extends BaseComponent { readonly root = this.page.getByTestId('folder-config'); + readonly executionContext = this.page.getByTestId('folder-execution-context'); readonly headers = this.page.getByTestId('folder-config-headers'); readonly auth = this.page.getByTestId('folder-config-auth'); readonly script = this.page.getByTestId('folder-config-script'); diff --git a/packages/bruno-api-docs/e2e/components/layout/env-switcher.component.ts b/packages/bruno-api-docs/e2e/components/layout/env-switcher.component.ts index 70cfe96e..791c88e9 100644 --- a/packages/bruno-api-docs/e2e/components/layout/env-switcher.component.ts +++ b/packages/bruno-api-docs/e2e/components/layout/env-switcher.component.ts @@ -15,7 +15,6 @@ export class EnvSwitcherComponent extends BaseComponent { readonly trigger: Locator; readonly menu: Locator; readonly surface: Locator; - readonly emptyOption: Locator; constructor( page: Page, @@ -25,7 +24,6 @@ export class EnvSwitcherComponent extends BaseComponent { this.trigger = this.root.getByTestId(base); this.menu = this.page.getByTestId(`${base}-dropdown`); this.surface = this.menu.locator('xpath=ancestor::div[@data-tippy-root]'); - this.emptyOption = this.option('no-environments'); } option(name: string): Locator { diff --git a/packages/bruno-api-docs/e2e/components/overview/collection-configuration.component.ts b/packages/bruno-api-docs/e2e/components/overview/collection-configuration.component.ts index 1267ab31..9dd4744f 100644 --- a/packages/bruno-api-docs/e2e/components/overview/collection-configuration.component.ts +++ b/packages/bruno-api-docs/e2e/components/overview/collection-configuration.component.ts @@ -1,25 +1,28 @@ -import type { Locator } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; import { BaseComponent } from '../base.component'; import { SecretValueComponent } from '../secret-value.component'; -export class ConfigurationSection extends BaseComponent { - readonly root = this.page.getByTestId('collection-config'); +export type ConfigurationGroup = 'headers' | 'auth' | 'vars' | 'script' | 'tests'; - readonly copyButton = this.root.getByTestId('collection-config-tests-copy'); +export class ConfigurationSection extends BaseComponent { + readonly copyButton: Locator; + readonly secret: SecretValueComponent; + readonly disabledRows: Locator; - readonly secret = new SecretValueComponent( - this.page, - 'collection-config-auth-token-secret' - ); + private readonly testId: string; - readonly disabledRows = this.root - .getByTestId('property-value-line') - .filter({ has: this.page.getByTestId('disabled-badge') }); + constructor(page: Page, testId = 'collection-config') { + super(page, page.getByTestId(testId)); + this.testId = testId; + this.copyButton = this.root.getByTestId(`${testId}-tests-copy`); + this.secret = new SecretValueComponent(page, `${testId}-auth-token-secret`); + this.disabledRows = this.root + .getByTestId('property-value-line') + .filter({ has: page.getByTestId('disabled-badge') }); + } - subHeading(name: string): Locator { - return this.root - .getByTestId('collection-config-subheading') - .filter({ hasText: name }); + subHeading(group: ConfigurationGroup): Locator { + return this.root.getByTestId(`${this.testId}-${group}-subheading`); } async copyToClipboard(): Promise { diff --git a/packages/bruno-api-docs/e2e/components/playground.component.ts b/packages/bruno-api-docs/e2e/components/playground.component.ts index 2b6b19ae..b737714d 100644 --- a/packages/bruno-api-docs/e2e/components/playground.component.ts +++ b/packages/bruno-api-docs/e2e/components/playground.component.ts @@ -12,6 +12,7 @@ import type { DockMode } from '../../src/utils/playgroundDock'; export class PlaygroundComponent extends BaseComponent { readonly keyValueTable = new KeyValueTableComponent(this.page); readonly preRequestVars = new KeyValueTableComponent(this.page, 'variables-pre-request'); + readonly postResponseVars = new KeyValueTableComponent(this.page, 'variables-post-response'); readonly pathParams = new KeyValueTableComponent(this.page, 'params-path'); // The Auth tab lives inside the playground request pane; open it with selectTab('auth'). readonly auth = new RequestAuthComponent(this.page); diff --git a/packages/bruno-api-docs/e2e/pages/folder.page.ts b/packages/bruno-api-docs/e2e/pages/folder.page.ts index ca31e8bf..8b894188 100644 --- a/packages/bruno-api-docs/e2e/pages/folder.page.ts +++ b/packages/bruno-api-docs/e2e/pages/folder.page.ts @@ -1,9 +1,12 @@ +import type { Locator } from '@playwright/test'; import { BasePage } from './base.page'; import { SidebarComponent } from '../components/sidebar.component'; import { BreadcrumbComponent } from '../components/breadcrumb.component'; import { FolderConfigurationComponent } from '../components/folder/folder-configuration.component'; import { VariableCardComponent } from '../components/variable-card/variable-card.component'; +export type FolderConfigGroup = 'headers' | 'auth' | 'vars' | 'script' | 'tests'; + export class FolderPage extends BasePage { readonly root = this.page.getByTestId('folder-page'); readonly title = this.page.getByTestId('folder-title'); @@ -11,14 +14,25 @@ export class FolderPage extends BasePage { readonly folderMarkdownDocs = this.page.getByTestId('folder-docs'); readonly folderMarkdownDocsToggle = this.page.getByTestId('folder-docs-toggle'); readonly emptyState = this.page.getByTestId('folder-config-empty'); + readonly configurationSection = this.page.getByTestId('folder-section-configuration'); + readonly executionContextSection = this.page.getByTestId('folder-section-execution-context'); + readonly executionContextEmptyState = this.page.getByTestId('folder-execution-context-empty'); readonly sidebar = new SidebarComponent(this.page); readonly breadcrumb = new BreadcrumbComponent(this.page, 'folder-breadcrumb'); readonly configuration = new FolderConfigurationComponent(this.page); readonly variableCard = new VariableCardComponent(this.page, this.root); - async open(trail: string[]): Promise { - await this.navigate('/'); + configurationGroup(group: FolderConfigGroup): Locator { + return this.configurationSection.getByTestId(`folder-config-${group}`); + } + + executionContextGroup(group: FolderConfigGroup): Locator { + return this.executionContextSection.getByTestId(`folder-config-${group}`); + } + + async open(trail: string[], path = '/'): Promise { + await this.navigate(path); await this.sidebar.open(trail); await this.root.waitFor({ state: 'visible' }); } diff --git a/packages/bruno-api-docs/e2e/pages/overview.page.ts b/packages/bruno-api-docs/e2e/pages/overview.page.ts index a8919c86..954713a3 100644 --- a/packages/bruno-api-docs/e2e/pages/overview.page.ts +++ b/packages/bruno-api-docs/e2e/pages/overview.page.ts @@ -14,6 +14,8 @@ export class OverviewPage extends BasePage { readonly stats = new StatsSection(this.page); readonly environments = new EnvironmentsSection(this.page); readonly configuration = new ConfigurationSection(this.page); + readonly executionContext = new ConfigurationSection(this.page, 'collection-execution-context'); + readonly executionContextEmptyState = this.page.getByTestId('collection-execution-context-empty'); readonly docMarkdown = new MarkdownComponent(this.page, this.page.getByTestId('overview-markdown-documentation')); readonly variableCard = new VariableCardComponent(this.page, this.root); diff --git a/packages/bruno-api-docs/e2e/tests/environments/env-switcher-empty.spec.ts b/packages/bruno-api-docs/e2e/tests/environments/env-switcher-empty.spec.ts new file mode 100644 index 00000000..db9f2eac --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/environments/env-switcher-empty.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '../../playwright'; + +const NO_ENVIRONMENTS = '/?fixture=descriptions'; + +test.describe('Environment switcher with no environments', () => { + test('is hidden in the docs header', async ({ page, envSwitcher }) => { + await page.goto(NO_ENVIRONMENTS); + await expect(envSwitcher.showVarsToggle).toBeVisible(); + await expect(envSwitcher.root).toHaveCount(0); + }); + + test('still shows its empty state in the playground sidebar', async ({ page, playground }) => { + await page.goto(`${NO_ENVIRONMENTS}#/?pg=1&dock=bottom`); + await playground.ensureSidebarOpen(); + await expect(playground.envSwitcher.trigger).toBeVisible(); + await expect(playground.envSwitcher.trigger).toContainText('No environments'); + }); + + test('shows plain text in the playground, with no caret and nothing to open', async ({ page, playground }) => { + await page.goto(`${NO_ENVIRONMENTS}#/?pg=1&dock=bottom`); + await playground.ensureSidebarOpen(); + const { trigger } = playground.envSwitcher; + + await expect(trigger).toContainText('No environments'); + await expect(playground.envSwitcher.root.getByRole('button')).toHaveCount(0); + await expect(trigger.locator('.env-switcher-chevron')).toHaveCount(0); + + await trigger.click(); + await expect(playground.envSwitcher.menu).toHaveCount(0); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/environments/env-switcher-truncation.spec.ts b/packages/bruno-api-docs/e2e/tests/environments/env-switcher-truncation.spec.ts new file mode 100644 index 00000000..70532268 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/environments/env-switcher-truncation.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '../../playwright'; + +const LONG_ENV = 'staging-regression-automation-eu-west-1'; +const FIXTURE = '/?fixture=folders'; + +const isClipped = (el: HTMLElement): boolean => el.scrollWidth > el.clientWidth + 1; + +test.describe('Environment switcher name truncation', () => { + test('clips a long name in the trigger and reveals it in a tooltip on hover', async ({ + page, + envSwitcher, + tooltip + }) => { + await page.goto(FIXTURE); + await envSwitcher.selectEnvironment(LONG_ENV); + + const name = envSwitcher.trigger.getByTestId('truncated-text'); + await expect(name).toHaveText(LONG_ENV); + expect(await name.evaluate(isClipped)).toBe(true); + + await name.hover(); + await expect(tooltip.popup).toBeVisible(); + await expect(tooltip.popup).toHaveText(LONG_ENV); + }); + + test('clips the same long name inside the open dropdown, revealing it via the native title', async ({ + page, + envSwitcher + }) => { + await page.goto(FIXTURE); + await envSwitcher.open(); + + const option = envSwitcher.option(LONG_ENV); + await expect(option).toHaveAttribute('title', LONG_ENV); + const name = option.locator('.environment-label-name--clamped'); + await expect(name).toHaveText(LONG_ENV); + expect(await name.evaluate(isClipped)).toBe(true); + }); + + test('shows no tooltip for a short name, which is not clipped', async ({ page, envSwitcher, tooltip }) => { + await page.goto(FIXTURE); + await envSwitcher.selectEnvironment('Dev'); + + const name = envSwitcher.trigger.getByTestId('truncated-text'); + await expect(name).toHaveText('Dev'); + expect(await name.evaluate(isClipped)).toBe(false); + + await name.hover(); + await page.waitForTimeout(400); + await expect(tooltip.popup).toHaveCount(0); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/folder/folder.spec.ts b/packages/bruno-api-docs/e2e/tests/folder/folder.spec.ts index 99c39221..c7a90ee3 100644 --- a/packages/bruno-api-docs/e2e/tests/folder/folder.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/folder/folder.spec.ts @@ -69,3 +69,68 @@ test.describe('Folder page', () => { }); }); }); + +test.describe('Folder page sections', () => { + test.beforeEach(async ({ folderPage }) => { + await folderPage.open(['billing', 'customers']); + }); + + test('Folder Configuration holds the headers and auth', async ({ folderPage }) => { + await expect(folderPage.configurationSection).toContainText('Folder Configuration'); + await expect(folderPage.configurationGroup('headers')).toBeVisible(); + await expect(folderPage.configurationGroup('auth')).toBeVisible(); + + await test.step('vars, script and tests are not under Folder Configuration', async () => { + await expect(folderPage.configurationGroup('vars')).toHaveCount(0); + await expect(folderPage.configurationGroup('script')).toHaveCount(0); + await expect(folderPage.configurationGroup('tests')).toHaveCount(0); + }); + }); + + test('Execution Context holds the variables, script and tests', async ({ folderPage }) => { + await expect(folderPage.executionContextSection).toContainText('Execution Context'); + await expect(folderPage.executionContextGroup('vars')).toBeVisible(); + await expect(folderPage.executionContextGroup('script')).toBeVisible(); + await expect(folderPage.executionContextGroup('tests')).toBeVisible(); + + await test.step('headers and auth are not under Execution Context', async () => { + await expect(folderPage.executionContextGroup('headers')).toHaveCount(0); + await expect(folderPage.executionContextGroup('auth')).toHaveCount(0); + }); + }); +}); + +test.describe('Folder page empty sections', () => { + test('shows an empty state in both sections when the folder has nothing configured', async ({ folderPage }) => { + await folderPage.open(['Authentication'], '/?fixture=folders'); + await expect(folderPage.emptyState).toBeVisible(); + await expect(folderPage.executionContextEmptyState).toBeVisible(); + await expect(folderPage.executionContextEmptyState).toContainText('No execution context'); + }); +}); + +test.describe('Folder variable phases', () => { + test('shows both Pre-Request and Post-Response, marking the empty one "None."', async ({ folderPage }) => { + await folderPage.open(['Realtime']); + await expect(folderPage.configuration.vars).toContainText('Pre-Request'); + await expect(folderPage.configuration.vars).toContainText('Post-Response'); + await expect(folderPage.configuration.vars).toContainText('None.'); + }); +}); + +test.describe('Folder execution context accordion', () => { + test('collapses the Execution Context section and remembers it across a reload', async ({ folderPage, page }) => { + await folderPage.open(['Realtime']); + const toggle = folderPage.executionContextSection.getByRole('button', { name: /Execution Context/i }); + + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await expect(folderPage.configuration.vars).toBeVisible(); + + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect(folderPage.configuration.vars).toBeHidden(); + + await page.reload(); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/overview/overview.spec.ts b/packages/bruno-api-docs/e2e/tests/overview/overview.spec.ts index aab31cee..ed9b1a28 100644 --- a/packages/bruno-api-docs/e2e/tests/overview/overview.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/overview/overview.spec.ts @@ -36,32 +36,68 @@ test.describe('Collection Overview', () => { }); test.describe('Collection Configuration', () => { - test('shows the Headers, Auth, Variables, Script and Tests groups with their values', async ({ overviewPage }) => { + test('shows the Headers and Auth groups with their values', async ({ overviewPage }) => { const { configuration } = overviewPage; await expect(overviewPage.sectionLabel('Collection Configuration')).toBeVisible(); await test.step('the Headers group lists the collection-level header and its value', async () => { - await expect(configuration.subHeading('Headers')).toBeVisible(); + await expect(configuration.subHeading('headers')).toBeVisible(); await expect(configuration.root.getByText('collection-header-value')).toBeVisible(); }); await test.step('the Auth group shows the resolved auth mode (Bearer Token)', async () => { - await expect(configuration.subHeading('Auth')).toBeVisible(); + await expect(configuration.subHeading('auth')).toBeVisible(); await expect(configuration.root.getByText('Bearer Token')).toBeVisible(); }); + }); + + test('shows the Variables, Script and Tests groups under Execution Context', async ({ overviewPage }) => { + const { executionContext } = overviewPage; + await expect(overviewPage.sectionLabel('Execution Context')).toBeVisible(); await test.step('the Variables group lists the collection-level pre-request variables', async () => { - await expect(configuration.subHeading('Variables')).toBeVisible(); - await expect(configuration.root.getByText('collection_pre_var_value', { exact: true })).toBeVisible(); - await expect(configuration.root.getByText('collection-var-value', { exact: true })).toBeVisible(); + await expect(executionContext.subHeading('vars')).toBeVisible(); + await expect(executionContext.root.getByText('collection_pre_var_value', { exact: true })).toBeVisible(); + await expect(executionContext.root.getByText('collection-var-value', { exact: true })).toBeVisible(); }); await test.step('the Script and Tests groups are present', async () => { - await expect(configuration.subHeading('Script')).toBeVisible(); - await expect(configuration.subHeading('Tests')).toBeVisible(); + await expect(executionContext.subHeading('script')).toBeVisible(); + await expect(executionContext.subHeading('tests')).toBeVisible(); }); }); + test('collapses the Execution Context section and remembers it across a reload', async ({ + overviewPage, + page + }) => { + const toggle = () => overviewPage.sectionLabel('Execution Context').getByRole('button', { name: /Execution Context/i }); + await expect(toggle()).toHaveAttribute('aria-expanded', 'true'); + await expect(overviewPage.executionContext.root).toBeVisible(); + + await toggle().click(); + await expect(toggle()).toHaveAttribute('aria-expanded', 'false'); + await expect(overviewPage.executionContext.root).toBeHidden(); + + await page.reload(); + await expect(toggle()).toHaveAttribute('aria-expanded', 'false'); + }); + + test('shows the Execution Context empty state when the collection has no vars, scripts or tests', async ({ + overviewPage + }) => { + await overviewPage.goto('/?fixture=folders'); + await expect(overviewPage.sectionLabel('Execution Context')).toBeVisible(); + await expect(overviewPage.executionContextEmptyState).toContainText('No execution context'); + await expect(overviewPage.executionContext.root).toHaveCount(0); + }); + + test('keeps headers and auth out of the Execution Context section', async ({ overviewPage }) => { + await expect(overviewPage.executionContext.subHeading('headers')).toHaveCount(0); + await expect(overviewPage.executionContext.subHeading('auth')).toHaveCount(0); + await expect(overviewPage.configuration.subHeading('script')).toHaveCount(0); + }); + test('keeps the auth token masked until the reveal toggle is clicked', async ({ overviewPage }) => { const { secret } = overviewPage.configuration; @@ -78,11 +114,11 @@ test.describe('Collection Overview', () => { test('copies a config code snippet and confirms with a "Copied" label', async ({ overviewPage, context }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']); - const { configuration } = overviewPage; + const { executionContext } = overviewPage; await test.step('clicking the copy button switches its label to "Copied"', async () => { - await configuration.copyToClipboard(); - await expect(configuration.copyButton).toHaveAttribute('aria-label', 'Copied'); + await executionContext.copyToClipboard(); + await expect(executionContext.copyButton).toHaveAttribute('aria-label', 'Copied'); }); }); }); @@ -92,13 +128,13 @@ test.describe('Collection Overview', () => { page }) => { await page.setViewportSize({ width: 360, height: 800 }); - const { configuration } = overviewPage; + const { executionContext } = overviewPage; - const rows = configuration.disabledRows; + const rows = executionContext.disabledRows; const count = await rows.count(); expect(count).toBeGreaterThan(0); - const card = await boundingBoxOf(configuration.root); + const card = await boundingBoxOf(executionContext.root); for (let i = 0; i < count; i += 1) { const row = rows.nth(i); @@ -116,7 +152,7 @@ test.describe('Collection Overview', () => { } await test.step('the table does not scroll on mobile — the value truncates instead', async () => { - const info = await configuration.root + const info = await executionContext.root .locator('.property-table') .first() .evaluate((el) => ({ scrollWidth: el.scrollWidth, clientWidth: el.clientWidth })); diff --git a/packages/bruno-api-docs/e2e/tests/playground/folder-settings-vars.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/folder-settings-vars.spec.ts new file mode 100644 index 00000000..74f68162 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/playground/folder-settings-vars.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '../../playwright'; + +test.describe('Playground folder settings, Vars tab', () => { + test.beforeEach(async ({ playground }) => { + await playground.open('bottom'); + await playground.openTreeItem(['billing']); + await playground.folderSettingsTab('variables').click(); + }); + + test('offers both a Pre Request and a Post Response table', async ({ playground }) => { + await expect(playground.preRequestVars.root).toBeVisible(); + await expect(playground.postResponseVars.root).toBeVisible(); + }); + + test('keeps an edited post-response variable across a tab switch', async ({ playground }) => { + const { postResponseVars } = playground; + const blankRowIndex = (await postResponseVars.nameInputs.count()) - 1; + await postResponseVars.nameInputs.nth(blankRowIndex).fill('sessionId'); + await postResponseVars.valueInputs.nth(blankRowIndex).fill('res.body.id'); + + await playground.folderSettingsTab('headers').click(); + await playground.folderSettingsTab('variables').click(); + + await expect(postResponseVars.nameInputs.nth(blankRowIndex)).toHaveValue('sessionId'); + await expect(postResponseVars.valueInputs.nth(blankRowIndex)).toHaveValue('res.body.id'); + }); +}); diff --git a/packages/bruno-api-docs/src/components/AppShell/AppShell.tsx b/packages/bruno-api-docs/src/components/AppShell/AppShell.tsx index 14609054..0b564c90 100644 --- a/packages/bruno-api-docs/src/components/AppShell/AppShell.tsx +++ b/packages/bruno-api-docs/src/components/AppShell/AppShell.tsx @@ -127,7 +127,7 @@ const AppShell: React.FC = ({ logo, testId = 'app-shell' }) => { envSwitcherSlot={( <> - + )} openInBrunoHref={openInBrunoHref} diff --git a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.spec.tsx b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.spec.tsx index 4a07b627..476ec2be 100644 --- a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.spec.tsx +++ b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.spec.tsx @@ -8,8 +8,8 @@ import type { Environment } from '@opencollection/types/config/environments'; import { createOpenCollectionStore } from '@/store/store'; import { setDocsCollection } from '@/store/slices/docs'; import { setActiveEnv } from '@/store/slices/env'; -import { getByTestId } from '@/test-utils/dom'; -import EnvSwitcher from './EnvSwitcher'; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; +import EnvSwitcher, { type EnvSwitcherProps } from './EnvSwitcher'; const collectionWith = (environments: Environment[]): OpenCollection => ({ config: { environments } }); @@ -18,7 +18,7 @@ const withEnvs = collectionWith([{ name: 'Dev' }, { name: 'Prod' }]); const render = ( collection: OpenCollection, configure?: (s: ReturnType) => void, - props?: { testId?: string } + props?: Partial ) => { const store = createOpenCollectionStore(); store.dispatch(setDocsCollection(collection)); @@ -63,11 +63,25 @@ describe('EnvSwitcher', () => { expect(trigger.text).not.toContain('Ghost'); }); - it('renders an empty state when there are no environments', () => { - const trigger = getByTestId(render(collectionWith([])), 'env-switcher'); + it('renders the empty state as plain text, not a button, when there are no environments', () => { + const root = render(collectionWith([])); + const trigger = getByTestId(root, 'env-switcher'); expect(trigger.text).toContain('No environments'); + expect(trigger.tagName.toLowerCase()).toBe('span'); expect(trigger.classList.contains('env-switcher-trigger--empty')).toBe(true); expect(trigger.getAttribute('title')).toBeFalsy(); + expect(trigger.getAttribute('aria-label')).toBeFalsy(); + expect(trigger.getAttribute('aria-haspopup')).toBeFalsy(); + expect(root.querySelector('button')).toBeNull(); + expect(root.querySelector('.env-switcher-chevron')).toBeFalsy(); + }); + + it('keeps the caret and the menu affordance when there are environments', () => { + const root = render(withEnvs, (s) => s.dispatch(setActiveEnv('Dev'))); + const trigger = getByTestId(root, 'env-switcher'); + expect(trigger.getAttribute('aria-haspopup')).toBe('menu'); + expect(trigger.getAttribute('aria-disabled')).toBeFalsy(); + expect(root.querySelector('.env-switcher-chevron')).toBeTruthy(); }); it('carries the empty modifier only when there are no environments', () => { @@ -84,16 +98,12 @@ describe('EnvSwitcher', () => { expect(dot?.classList.contains('environment-label-dot--empty')).toBe(false); }); - it('exposes the full env name in the trigger title (truncation is CSS)', () => { + it('renders the full env name in the trigger, clamped for the tooltip to reveal', () => { const long = 'development-staging-area-testing-qa-automation-local-setup-1'; - const trigger = getByTestId( - render(collectionWith([{ name: long }]), (s) => s.dispatch(setActiveEnv(long))), - 'env-switcher' - ); - // The name renders in full in the DOM; the trigger button carries the full - // name as its title (CSS clamps the visible width with an ellipsis). + const root = render(collectionWith([{ name: long }]), (s) => s.dispatch(setActiveEnv(long))); + const trigger = getByTestId(root, 'env-switcher'); expect(trigger.text).toContain(long); - expect(trigger.getAttribute('title')).toBe(long); + expect(getByTestId(root, 'truncated-text').classList.contains('environment-label-name--clamped')).toBe(true); }); it('derives the root and trigger test ids from a custom testId', () => { @@ -102,3 +112,28 @@ describe('EnvSwitcher', () => { expect(getByTestId(root, 'playground-env-switcher')).toBeTruthy(); }); }); + +describe('EnvSwitcher hideWhenEmpty', () => { + it('renders nothing when the collection has no environments', () => { + const root = render(collectionWith([]), undefined, { hideWhenEmpty: true }); + expect(queryByTestId(root, 'env-switcher-root')).toBeNull(); + }); + + it('still renders the switcher when the collection has environments', () => { + const root = render(withEnvs, undefined, { hideWhenEmpty: true }); + expect(getByTestId(root, 'env-switcher').text).toContain('Dev'); + }); + + it('keeps the empty state by default, for the playground sidebar', () => { + const trigger = getByTestId(render(collectionWith([])), 'env-switcher'); + expect(trigger.text).toContain('No environments'); + }); +}); + +describe('EnvSwitcher name truncation', () => { + it('drops the native title in favour of the styled tooltip anchor', () => { + const root = render(withEnvs, (s) => s.dispatch(setActiveEnv('Dev'))); + expect(getByTestId(root, 'env-switcher').getAttribute('title')).toBeFalsy(); + expect(queryByTestId(root, 'truncated-text')).toBeTruthy(); + }); +}); diff --git a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx index b783fe0d..cc2f97e3 100644 --- a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx +++ b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx @@ -9,6 +9,7 @@ import MenuDropdown, { type MenuDropdownItem } from '@/ui/MenuDropdown'; import { StyledWrapper } from './StyledWrapper'; export interface EnvSwitcherProps { + hideWhenEmpty?: boolean; testId?: string; } @@ -22,7 +23,7 @@ export interface EnvSwitcherProps { * or dismissal logic of its own. Tippy's `--z-popover` surface keeps the menu * above every playground dock. */ -const EnvSwitcher: React.FC = ({ testId = 'env-switcher' }) => { +const EnvSwitcher: React.FC = ({ hideWhenEmpty = false, testId = 'env-switcher' }) => { const dispatch = useAppDispatch(); const collection = useAppSelector(selectDocsCollection); const activeEnvName = useAppSelector(selectActiveEnvName); @@ -47,21 +48,39 @@ const EnvSwitcher: React.FC = ({ testId = 'env-switcher' }) => } }, [activeEnv, activeEnvName, dispatch]); + if (hideWhenEmpty && !hasEnvironments) return null; + const items: MenuDropdownItem[] = hasEnvironments ? environments.map((environment) => ({ id: environment.name, - label: , + label: ( + + ), ariaLabel: environment.name, title: environment.name, onClick: () => dispatch(setActiveEnv(environment.name)) })) - : [{ id: 'no-environments', label: 'No environments', disabled: true }]; + : []; + + if (!hasEnvironments) { + return ( + + + + + + ); + } return ( = ({ testId = 'env-switcher' }) => >