Skip to content
Open
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
@@ -0,0 +1,33 @@
import type { Locator } from '@playwright/test';
import { BaseComponent } from '../base.component';

export class EnvironmentTableComponent extends BaseComponent {
readonly root = this.page.getByTestId('environment-table');
readonly variableRows = this.page.getByTestId('environment-variable-row');
readonly secretVariableRows = this.page.getByTestId('environment-secret-variable-row');
readonly externalSecretRows = this.page.getByTestId('environment-external-secret-row');

columnHeader(key: string): Locator {
return this.root.getByTestId(`table-header-${key}`);
}

variableRow(name: string): Locator {
return this.variableRows.filter({ hasText: name });
}

secretVariableRow(name: string): Locator {
return this.secretVariableRows.filter({ hasText: name });
}

externalSecretRow(name: string): Locator {
return this.externalSecretRows.filter({ hasText: name });
}

valueOf(name: string): Locator {
return this.variableRow(name).getByTestId('table-cell-value');
}

dataTypeOf(name: string): Locator {
return this.variableRow(name).getByTestId('table-cell-type');
}
}
27 changes: 27 additions & 0 deletions packages/oc-docs/e2e/pages/environments.page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './base.page';
import { SidebarComponent } from '../components/sidebar.component';
import { EnvironmentTableComponent } from '../components/environments/environment-table.component';

export class EnvironmentsPage extends BasePage {
readonly root = this.page.getByTestId('environments-page');

readonly sidebar = new SidebarComponent(this.page);
readonly title = this.page.getByTestId('environments-title');
readonly tabs = this.page.getByTestId('environment-tab');
readonly table = new EnvironmentTableComponent(this.page);
readonly variablesGroup = this.page.getByTestId('environment-variables');
readonly secretVariablesGroup = this.page.getByTestId('environment-secret-variables');
readonly externalSecretsGroup = this.page.getByTestId('environment-external-secrets');
readonly emptyState = this.page.getByTestId('environments-empty');

async open(): Promise<void> {
await this.navigate('/');
await this.page.getByTestId('sidebar-environments-link').click();
await this.root.waitFor({ state: 'visible' });
}

tab(name: string): Locator {
return this.tabs.filter({ hasText: name });
}
}
29 changes: 29 additions & 0 deletions packages/oc-docs/e2e/playwright/pages.fixture.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { test as base } from '@playwright/test';
import { OverviewPage } from '../pages/overview.page';
import { EnvironmentsPage } from '../pages/environments.page';
import { RequestPage } from '../pages/request.page';
import { ScriptPage } from '../pages/script.page';
import { UnsupportedRequestPage } from '../pages/unsupported-request.page';
import { SidebarComponent } from '../components/sidebar.component';
import { ThemeToggleComponent } from '../components/theme-toggle.component';

/**
Expand All @@ -8,13 +13,37 @@ import { ThemeToggleComponent } from '../components/theme-toggle.component';
*/
type Fixtures = {
overviewPage: OverviewPage;
environmentsPage: EnvironmentsPage;
requestPage: RequestPage;
scriptPage: ScriptPage;
unsupportedRequestPage: UnsupportedRequestPage;
sidebar: SidebarComponent;
pageHeader: PageHeaderComponent;
themeToggle: ThemeToggleComponent;
};

export const test = base.extend<Fixtures>({
overviewPage: async ({ page }, use) => {
await use(new OverviewPage(page));
},
environmentsPage: async ({ page }, use) => {
await use(new EnvironmentsPage(page));
},
requestPage: async ({ page }, use) => {
await use(new RequestPage(page));
},
scriptPage: async ({ page }, use) => {
await use(new ScriptPage(page));
},
unsupportedRequestPage: async ({ page }, use) => {
await use(new UnsupportedRequestPage(page));
},
sidebar: async ({ page }, use) => {
await use(new SidebarComponent(page));
},
pageHeader: async ({ page }, use) => {
await use(new PageHeaderComponent(page));
},
themeToggle: async ({ page }, use) => {
await use(new ThemeToggleComponent(page));
}
Expand Down
88 changes: 88 additions & 0 deletions packages/oc-docs/e2e/tests/environments/environments.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { test, expect } from '../../playwright';

test.describe('Environments page', () => {
test.beforeEach(async ({ environmentsPage }) => {
await environmentsPage.open();
});

test('opens from the sidebar and shows a tab per environment', async ({ environmentsPage }) => {
await expect(environmentsPage.title).toHaveText('Environments');
await expect(environmentsPage.tabs).toHaveCount(2);
await expect(environmentsPage.tab('Local')).toBeVisible();
await expect(environmentsPage.tab('Prod')).toBeVisible();
});

test('selects the first environment by default', async ({ environmentsPage }) => {
await expect(environmentsPage.tab('Local')).toHaveAttribute('aria-selected', 'true');
await expect(environmentsPage.tab('Prod')).toHaveAttribute('aria-selected', 'false');
});

test('lists the active environment variables with their value and data type', async ({ environmentsPage }) => {
const { table } = environmentsPage;

await test.step('the Variables group is shown', async () => {
await expect(environmentsPage.variablesGroup).toBeVisible();
});

await test.step('the host variable shows its value and a String data type', async () => {
await expect(table.variableRow('host')).toBeVisible();
await expect(table.valueOf('host')).toContainText('http://localhost:8081');
await expect(table.dataTypeOf('host')).toHaveText('String');
});
});

test('shows secret variables masked under a Secret Variables group', async ({ environmentsPage }) => {
const { table } = environmentsPage;
const row = table.secretVariableRow('bearer_auth_token');

await expect(environmentsPage.secretVariablesGroup).toBeVisible();
await expect(row).toBeVisible();
await expect(row).toContainText('Secret');
await expect(row).not.toContainText('your_secret_token');
});

test('switches the table when another environment tab is selected', async ({ environmentsPage }) => {
const { table } = environmentsPage;

await expect(table.valueOf('host')).toContainText('http://localhost:8081');

await environmentsPage.tab('Prod').click();

await expect(environmentsPage.tab('Prod')).toHaveAttribute('aria-selected', 'true');
await expect(table.valueOf('host')).toContainText('https://echo.usebruno.com');
});

test('renders an accessible columnar table with Name, Value and Data Type headers', async ({ environmentsPage }) => {
const { table } = environmentsPage;
await expect(table.columnHeader('name')).toHaveText('Name');
await expect(table.columnHeader('value')).toHaveText('Value');
await expect(table.columnHeader('type')).toHaveText('Data Type');
});

test('hides the External Secret Variables section when the environment has none', async ({ environmentsPage }) => {
await expect(environmentsPage.tab('Local')).toHaveAttribute('aria-selected', 'true');
await expect(environmentsPage.externalSecretsGroup).toBeHidden();
});

test('shows external secrets with the manager label and reference for the Prod environment', async ({
environmentsPage
}) => {
const { table } = environmentsPage;

await environmentsPage.tab('Prod').click();

await test.step('the External Secret Variables group is shown with its manager', async () => {
await expect(environmentsPage.externalSecretsGroup).toBeVisible();
await expect(environmentsPage.externalSecretsGroup).toContainText('AWS Secrets Manager');
});

await test.step('each external secret lists its name and secret reference', async () => {
await expect(table.externalSecretRow('dbPassword')).toContainText('prod/db/credentials');
await expect(table.externalSecretRow('apiKey')).toContainText('prod/payment-gateway/api-key');
});

await test.step('external secrets without a type show an empty data type', async () => {
await expect(table.externalSecretRow('dbPassword')).toContainText('(empty)');
});
});
});
32 changes: 26 additions & 6 deletions packages/oc-docs/src/components/Docs/Docs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ import Sidebar from './Sidebar/Sidebar';
import Overview from '../../pages/Overview/Overview';
import { getItemId, generateSafeId } from '../../utils/itemUtils';
import { isFolder } from '../../utils/schemaHelpers';
import { useAppSelector } from '../../store/hooks';
import { selectSelectedItemId } from '../../store/slices/docs';
import Environments from '../../pages/Environments/Environments';
import Request from '../../pages/Request/Request';
import Script from '../../pages/Script/Script';
import { findItemByUuid, getAncestorsByUuid } from '../../utils/fileUtils';
import { isHttpRequest, isScriptFile, isUnsupportedRequest } from '../../utils/schemaHelpers';
import { useAppSelector, useAppDispatch } from '../../store/hooks';
import { selectSelectedItemId, selectActiveRootView, selectItem } from '../../store/slices/docs';

interface DocsProps {
docsCollection: OpenCollectionCollection | null;
Expand All @@ -19,6 +24,7 @@ const Docs: React.FC<DocsProps> = ({
}) => {
const selectedItemId = useAppSelector(selectSelectedItemId);
const isInitialMount = useRef(true);
const activeRootView = useAppSelector(selectActiveRootView);

// Scroll to selected item when it changes (but not on initial load)
useEffect(() => {
Expand Down Expand Up @@ -76,10 +82,24 @@ const Docs: React.FC<DocsProps> = ({
<Sidebar />
</div>

<div
className="playground-content h-full overflow-y-auto flex-1"
>
{docsCollection && (
<div className="playground-content h-full overflow-y-auto flex-1">
{docsCollection && (isHttpRequest(selected) || isUnsupportedRequest(selected)) ? (
<Request
item={selected}
ancestry={ancestry}
collection={docsCollection}
onTryClick={() => onOpenPlayground?.()}
onBreadcrumbClick={(uuid) => dispatch(selectItem(uuid))}
/>
) : docsCollection && isScriptFile(selected) ? (
<Script
item={selected}
ancestry={ancestry}
onBreadcrumbClick={(uuid) => dispatch(selectItem(uuid))}
/>
) : docsCollection && activeRootView === 'environments' ? (
<Environments collection={docsCollection} />
) : docsCollection ? (
<Overview collection={docsCollection} />
)}
</div>
Expand Down
37 changes: 34 additions & 3 deletions packages/oc-docs/src/components/Docs/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@
import React from 'react';
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 Method from '../Method/Method';
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,
selectItem,
selectRootView,
selectSelectedItemId,
selectActiveRootView,
selectDocsCollection
} from '../../../store/slices/docs';
import { getItemType, getItemName, getHttpMethod, isFolder, isScriptFile } from '../../../utils/schemaHelpers';
import { GlobeIcon, BookIcon } from '../../../assets/icons';

export interface SidebarProps {
}

const Sidebar: React.FC<SidebarProps> = () => {
const dispatch = useAppDispatch();
const selectedItemId = useAppSelector(selectSelectedItemId);
const activeRootView = useAppSelector(selectActiveRootView);
const collection = useAppSelector(selectDocsCollection);

const isRootView = selectedItemId === null;

const toggleFolder = (itemUuid: string) => {
dispatch(toggleItem(itemUuid));
};
Expand Down Expand Up @@ -139,6 +149,27 @@ const Sidebar: React.FC<SidebarProps> = () => {
</div>

<SidebarItems>
<div className="sidebar-root-nav">
<SidebarItem
data-testid="sidebar-overview-link"
className={`flex items-center select-none text-sm cursor-pointer ${isRootView && activeRootView === 'overview' ? 'active' : ''}`}
style={{ paddingLeft: '8px' }}
onClick={() => dispatch(selectRootView('overview'))}
>
<span className="sidebar-nav-icon"><BookIcon /></span>
<div className="truncate flex-1">Overview</div>
</SidebarItem>
<SidebarItem
data-testid="sidebar-environments-link"
className={`flex items-center select-none text-sm cursor-pointer ${isRootView && activeRootView === 'environments' ? 'active' : ''}`}
style={{ paddingLeft: '8px' }}
onClick={() => dispatch(selectRootView('environments'))}
>
<span className="sidebar-nav-icon"><GlobeIcon /></span>
<div className="truncate flex-1">Environments</div>
</SidebarItem>
</div>

{collection?.items?.length && (
collection.items.map((item) => renderItem(item))
)}
Expand Down
18 changes: 18 additions & 0 deletions packages/oc-docs/src/components/Docs/Sidebar/StyledWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,24 @@ export const SidebarItems = styled.div`
${SidebarContainer}.compact & {
padding: 0 4px;
}

.sidebar-root-nav {
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-color);
}

.sidebar-nav-icon {
display: inline-flex;
align-items: center;
margin-right: 8px;
flex-shrink: 0;
color: var(--text-tertiary);
}
.sidebar-nav-icon svg {
width: 15px;
height: 15px;
}
`;

export const SidebarItem = styled.div`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, it, expect } from 'vitest';
import { EnvironmentLabel } from './EnvironmentLabel';

describe('EnvironmentLabel', () => {
it('renders the environment name', () => {
const html = renderToStaticMarkup(<EnvironmentLabel name="Development" />);
expect(html).toContain('Development');
expect(html).toContain('environment-label-dot');
});

it('applies the environment color to the dot', () => {
const html = renderToStaticMarkup(<EnvironmentLabel name="Prod" color="#dc2626" />);
expect(html).toContain('#dc2626');
});

it('forwards custom class names', () => {
const html = renderToStaticMarkup(
<EnvironmentLabel name="Staging" className="env-tab" nameClassName="env-tab-name" />
);
expect(html).toContain('env-tab');
expect(html).toContain('env-tab-name');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';
import { StyledWrapper } from './StyledWrapper';

interface EnvironmentLabelProps {
name: string;
color?: string;
className?: string;
nameClassName?: string;
testId?: string;
}

export const EnvironmentLabel: React.FC<EnvironmentLabelProps> = ({
name,
color,
className,
nameClassName,
testId
}) => (
<StyledWrapper className={['environment-label', className].filter(Boolean).join(' ')} data-testid={testId}>
<span className="environment-label-dot" style={color ? { background: color } : undefined} />
<span className={['environment-label-name', nameClassName].filter(Boolean).join(' ')}>{name}</span>
</StyledWrapper>
);

export default EnvironmentLabel;
Loading