mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix: truncate long sidebar stack names before trailing indicators clip (#1562)
Clamp the stack list width with ScrollArea block and min-w-0 on the row flex chain so long names ellipsize instead of pushing update dots past the sidebar edge. Add E2E layout coverage for trailing-indicator edge cases.
This commit is contained in:
+13
-4
@@ -25,7 +25,7 @@ export function totpNow(secret: string): string {
|
||||
}
|
||||
|
||||
/** Selector for the dashboard - only present in EditorLayout, not on login/setup pages */
|
||||
const DASHBOARD_INDICATOR = 'img[src*="sencho-logo"]';
|
||||
const DASHBOARD_INDICATOR = 'img[src*="sencho-logo"], button:has-text("Create Stack")';
|
||||
|
||||
/** Returns true if the current page is the first-run setup screen */
|
||||
async function isSetupPage(page: Page): Promise<boolean> {
|
||||
@@ -39,7 +39,8 @@ async function isLoginPage(page: Page): Promise<boolean> {
|
||||
|
||||
/** Returns true if the dashboard (EditorLayout) is loaded */
|
||||
export async function isDashboard(page: Page): Promise<boolean> {
|
||||
return page.locator(DASHBOARD_INDICATOR).isVisible().catch(() => false);
|
||||
const indicator = page.locator(DASHBOARD_INDICATOR).first();
|
||||
return indicator.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,7 +75,7 @@ export async function loginAs(page: Page, username = TEST_USERNAME, password = T
|
||||
const enterButton = page.getByRole('button', { name: /enter sencho/i });
|
||||
await expect(enterButton).toBeVisible({ timeout: 10_000 });
|
||||
await enterButton.click();
|
||||
await expect(page.locator(DASHBOARD_INDICATOR)).toBeVisible({ timeout: 10_000 });
|
||||
await waitForStacksLoaded(page);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,7 +94,7 @@ export async function loginAs(page: Page, username = TEST_USERNAME, password = T
|
||||
await usernameField.fill(username);
|
||||
await page.locator('#password').fill(password);
|
||||
await page.locator('button:has-text("Login"), button:has-text("Sign in")').first().click();
|
||||
await expect(page.locator(DASHBOARD_INDICATOR)).toBeVisible({ timeout: 10_000 });
|
||||
await waitForStacksLoaded(page);
|
||||
return;
|
||||
}
|
||||
// Fall through to the dashboard check below.
|
||||
@@ -104,6 +105,14 @@ export async function loginAs(page: Page, username = TEST_USERNAME, password = T
|
||||
return;
|
||||
}
|
||||
|
||||
// Cookie session may still be restoring after a hard reload.
|
||||
try {
|
||||
await waitForStacksLoaded(page);
|
||||
return;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'loginAs: could not determine page state - expected setup, login, or dashboard. ' +
|
||||
'Check that E2E_USERNAME and E2E_PASSWORD are set correctly.',
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Sidebar stack-row truncation E2E tests.
|
||||
*
|
||||
* Verifies long stack names ellipsize instead of pushing trailing indicators
|
||||
* (update dot, check-failed icon, git-pending icon) past the sidebar edge.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { loginAs, waitForStacksLoaded } from './helpers';
|
||||
|
||||
const LONG_STACK = 'e2e-tick-grafana-docker-observability-monitoring';
|
||||
const SHORT_STACK = 'e2e-z';
|
||||
const UPDATE_STACK = 'e2e-long-stack-update-dot-indicator';
|
||||
const FAILED_STACK = 'e2e-long-stack-check-failed-indicator';
|
||||
const GIT_STACK = 'e2e-long-stack-git-pending-indicator';
|
||||
|
||||
const TEST_STACKS = [LONG_STACK, SHORT_STACK, UPDATE_STACK, FAILED_STACK, GIT_STACK];
|
||||
|
||||
interface RowLayoutMetrics {
|
||||
stackName: string;
|
||||
sidebarRight: number;
|
||||
rowRight: number;
|
||||
rowWithinSidebar: boolean;
|
||||
nameTruncated: boolean;
|
||||
nameOverflowHidden: boolean;
|
||||
rowHasMinWidthZero: boolean;
|
||||
trailingRight: number | null;
|
||||
trailingWithinSidebar: boolean;
|
||||
trailingKind: 'update' | 'failed' | 'git' | 'none';
|
||||
}
|
||||
|
||||
async function createStack(page: Page, stackName: string): Promise<void> {
|
||||
const res = await page.evaluate(async (name) => {
|
||||
const response = await fetch('/api/stacks', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ stackName: name }),
|
||||
});
|
||||
return { ok: response.ok, status: response.status };
|
||||
}, stackName);
|
||||
expect(res.ok || res.status === 409, `create ${stackName} failed: ${res.status}`).toBeTruthy();
|
||||
}
|
||||
|
||||
async function deleteStack(page: Page, stackName: string): Promise<void> {
|
||||
await page.evaluate(async (name) => {
|
||||
await fetch(`/api/stacks/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
}).catch(() => undefined);
|
||||
}, stackName);
|
||||
}
|
||||
|
||||
async function deleteAllTestStacks(page: Page): Promise<void> {
|
||||
for (const name of TEST_STACKS) {
|
||||
await deleteStack(page, name);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTestStacks(page: Page): Promise<void> {
|
||||
await deleteAllTestStacks(page);
|
||||
for (const name of TEST_STACKS) {
|
||||
await createStack(page, name);
|
||||
}
|
||||
await page.reload();
|
||||
await waitForStacksLoaded(page);
|
||||
}
|
||||
|
||||
function sidebarLocator(page: Page) {
|
||||
return page.locator('.w-64.border-r').first();
|
||||
}
|
||||
|
||||
async function measureRow(page: Page, stackName: string): Promise<RowLayoutMetrics> {
|
||||
const row = page.locator('[data-testid="stack-row"]').filter({ hasText: stackName });
|
||||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
return row.evaluate((rowEl) => {
|
||||
const sidebar = document.querySelector('.bg-sidebar.border-r, .w-64.border-r, .bg-sidebar');
|
||||
const sidebarRect = sidebar?.getBoundingClientRect();
|
||||
if (!sidebarRect) {
|
||||
throw new Error('Could not locate sidebar container');
|
||||
}
|
||||
|
||||
const nameEl = rowEl.querySelector('.truncate') as HTMLElement | null;
|
||||
const updateDot = rowEl.querySelector('[data-testid="stack-trailing-update"]');
|
||||
const failedIcon = rowEl.querySelector('[data-testid="stack-trailing-check-failed"]');
|
||||
const gitIcon = rowEl.querySelector('[data-testid="stack-trailing-git-pending"]');
|
||||
const trailing = updateDot ?? failedIcon ?? gitIcon;
|
||||
|
||||
let trailingKind: RowLayoutMetrics['trailingKind'] = 'none';
|
||||
if (updateDot) trailingKind = 'update';
|
||||
else if (failedIcon) trailingKind = 'failed';
|
||||
else if (gitIcon) trailingKind = 'git';
|
||||
|
||||
const rowRect = rowEl.getBoundingClientRect();
|
||||
const trailingRect = trailing?.getBoundingClientRect() ?? null;
|
||||
|
||||
return {
|
||||
stackName: nameEl?.textContent ?? '',
|
||||
sidebarRight: sidebarRect.right,
|
||||
rowRight: rowRect.right,
|
||||
rowWithinSidebar: rowRect.right <= sidebarRect.right + 0.5,
|
||||
nameTruncated: nameEl ? nameEl.scrollWidth > nameEl.clientWidth + 1 : false,
|
||||
nameOverflowHidden: nameEl ? getComputedStyle(nameEl).overflow === 'hidden' : false,
|
||||
rowHasMinWidthZero: rowEl.classList.contains('min-w-0'),
|
||||
trailingRight: trailingRect?.right ?? null,
|
||||
trailingWithinSidebar: trailingRect ? trailingRect.right <= sidebarRect.right + 0.5 : true,
|
||||
trailingKind,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function assertRowLayout(
|
||||
page: Page,
|
||||
stackName: string,
|
||||
opts: { expectTruncated: boolean; trailingKind?: RowLayoutMetrics['trailingKind'] },
|
||||
): Promise<RowLayoutMetrics> {
|
||||
const metrics = await measureRow(page, stackName);
|
||||
expect(metrics.rowWithinSidebar, `${stackName} row clips past sidebar`).toBe(true);
|
||||
expect(metrics.nameOverflowHidden, `${stackName} name missing overflow hidden`).toBe(true);
|
||||
expect(metrics.rowHasMinWidthZero, `${stackName} row missing min-w-0`).toBe(true);
|
||||
expect(metrics.nameTruncated, `${stackName} truncation state`).toBe(opts.expectTruncated);
|
||||
if (opts.trailingKind) {
|
||||
expect(metrics.trailingKind, `${stackName} trailing indicator`).toBe(opts.trailingKind);
|
||||
expect(metrics.trailingWithinSidebar, `${stackName} trailing indicator clips`).toBe(true);
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
test.describe('Sidebar stack name truncation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page);
|
||||
await waitForStacksLoaded(page);
|
||||
await ensureTestStacks(page);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await deleteAllTestStacks(page);
|
||||
});
|
||||
|
||||
test('long stack names truncate and stay within the sidebar', async ({ page }) => {
|
||||
await assertRowLayout(page, LONG_STACK, { expectTruncated: true });
|
||||
});
|
||||
|
||||
test('short stack names do not truncate unnecessarily', async ({ page }) => {
|
||||
await assertRowLayout(page, SHORT_STACK, { expectTruncated: false });
|
||||
});
|
||||
|
||||
test('update dot stays visible on a long stack name', async ({ page }) => {
|
||||
await page.route('**/api/image-updates/detail', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
[UPDATE_STACK]: {
|
||||
hasUpdate: true,
|
||||
checkStatus: 'ok',
|
||||
lastError: null,
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const detailResponse = page.waitForResponse(
|
||||
(res) => res.url().includes('/api/image-updates/detail') && res.ok(),
|
||||
);
|
||||
await page.reload();
|
||||
await waitForStacksLoaded(page);
|
||||
await detailResponse;
|
||||
await expect(
|
||||
page.locator('[data-testid="stack-row"]').filter({ hasText: UPDATE_STACK }).locator('[data-testid="stack-trailing-update"]'),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await assertRowLayout(page, UPDATE_STACK, {
|
||||
expectTruncated: true,
|
||||
trailingKind: 'update',
|
||||
});
|
||||
});
|
||||
|
||||
test('check-failed icon stays visible on a long stack name', async ({ page }) => {
|
||||
await page.route('**/api/image-updates/detail', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
[FAILED_STACK]: {
|
||||
hasUpdate: false,
|
||||
checkStatus: 'failed',
|
||||
lastError: 'Registry unreachable',
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const detailResponse = page.waitForResponse(
|
||||
(res) => res.url().includes('/api/image-updates/detail') && res.ok(),
|
||||
);
|
||||
await page.reload();
|
||||
await waitForStacksLoaded(page);
|
||||
await detailResponse;
|
||||
await expect(
|
||||
page.locator('[data-testid="stack-row"]').filter({ hasText: FAILED_STACK }).locator('[data-testid="stack-trailing-check-failed"]'),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await assertRowLayout(page, FAILED_STACK, {
|
||||
expectTruncated: true,
|
||||
trailingKind: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
test('update dot wins over check-failed on a long stack name', async ({ page }) => {
|
||||
await page.route('**/api/image-updates/detail', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
[UPDATE_STACK]: {
|
||||
hasUpdate: true,
|
||||
checkStatus: 'failed',
|
||||
lastError: 'Registry unreachable',
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await waitForStacksLoaded(page);
|
||||
|
||||
await assertRowLayout(page, UPDATE_STACK, {
|
||||
expectTruncated: true,
|
||||
trailingKind: 'update',
|
||||
});
|
||||
});
|
||||
|
||||
test('git-pending icon stays visible on a long stack name', async ({ page }) => {
|
||||
await page.route('**/api/git-sources', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{ stack_name: GIT_STACK, pending_commit_sha: 'abc123def456' },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
const gitResponse = page.waitForResponse(
|
||||
(res) => res.url().includes('/api/git-sources') && res.ok(),
|
||||
);
|
||||
await page.reload();
|
||||
await waitForStacksLoaded(page);
|
||||
await gitResponse;
|
||||
await expect(
|
||||
page.locator('[data-testid="stack-row"]').filter({ hasText: GIT_STACK }).locator('[data-testid="stack-trailing-git-pending"]'),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await assertRowLayout(page, GIT_STACK, {
|
||||
expectTruncated: true,
|
||||
trailingKind: 'git',
|
||||
});
|
||||
});
|
||||
|
||||
test('active row with a long name still truncates', async ({ page }) => {
|
||||
const row = page.locator('[data-testid="stack-row"]').filter({ hasText: LONG_STACK });
|
||||
await row.click();
|
||||
await expect(row).toHaveClass(/bg-accent/);
|
||||
await assertRowLayout(page, LONG_STACK, { expectTruncated: true });
|
||||
});
|
||||
|
||||
test('bulk mode checkbox does not break truncation on long names', async ({ page }) => {
|
||||
const bulkToggle = page.locator('button[aria-pressed]').filter({ has: page.locator('.lucide-layout-list') });
|
||||
await bulkToggle.click();
|
||||
await expect(bulkToggle).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const row = page.locator('[data-testid="stack-row"][data-bulk="true"]').filter({ hasText: LONG_STACK });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.locator('[role="checkbox"]')).toBeVisible();
|
||||
|
||||
await assertRowLayout(page, LONG_STACK, { expectTruncated: true });
|
||||
});
|
||||
|
||||
test('all stack rows stay within the sidebar width', async ({ page }) => {
|
||||
const metrics = await page.evaluate(() => {
|
||||
const sidebar = document.querySelector('.bg-sidebar.border-r, .w-64.border-r');
|
||||
const sidebarRect = sidebar?.getBoundingClientRect();
|
||||
if (!sidebarRect) return { ok: false, offenders: ['sidebar-missing'] };
|
||||
|
||||
const rows = Array.from(document.querySelectorAll('[data-testid="stack-row"]'));
|
||||
const offenders: string[] = [];
|
||||
for (const row of rows) {
|
||||
const rect = row.getBoundingClientRect();
|
||||
if (rect.right > sidebarRect.right + 0.5) {
|
||||
offenders.push(row.textContent?.trim().slice(0, 40) ?? 'unknown-row');
|
||||
}
|
||||
}
|
||||
return { ok: offenders.length === 0, offenders };
|
||||
});
|
||||
|
||||
expect(metrics.ok, `rows overflow sidebar: ${metrics.offenders.join(', ')}`).toBe(true);
|
||||
});
|
||||
|
||||
test('ScrollArea block mode constrains list width', async ({ page }) => {
|
||||
const viewportUsesBlock = await page.evaluate(() => {
|
||||
const viewport = document.querySelector('[data-radix-scroll-area-viewport]');
|
||||
const inner = viewport?.firstElementChild as HTMLElement | null;
|
||||
if (!inner) return false;
|
||||
return getComputedStyle(inner).display === 'block';
|
||||
});
|
||||
expect(viewportUsesBlock).toBe(true);
|
||||
});
|
||||
|
||||
test('mobile viewport keeps long names truncated', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.reload();
|
||||
await waitForStacksLoaded(page);
|
||||
|
||||
const metrics = await measureRow(page, LONG_STACK);
|
||||
expect(metrics.nameTruncated).toBe(true);
|
||||
expect(metrics.rowWithinSidebar).toBe(true);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -205,7 +205,7 @@ export function StackList(props: StackListProps & StackListBulkProps) {
|
||||
<CommandItem
|
||||
value={file}
|
||||
onSelect={() => onSelectFile(file)}
|
||||
className="p-0 data-[selected=true]:bg-transparent"
|
||||
className="min-w-0 w-full p-0 data-[selected=true]:bg-transparent"
|
||||
>
|
||||
<StackRow
|
||||
file={file}
|
||||
|
||||
@@ -105,11 +105,11 @@ export function StackRow(props: StackRowProps) {
|
||||
<span className="flex-1 truncate font-mono text-sm min-w-0">{displayName}</span>
|
||||
|
||||
{/* Fixed trailing icon slot: update dot > check-failed > git pending */}
|
||||
<span className="w-3.5 h-3.5 flex items-center justify-center shrink-0">
|
||||
<span className="w-3.5 h-3.5 flex items-center justify-center shrink-0" data-testid="stack-row-trailing">
|
||||
{hasUpdate ? (
|
||||
<RowTooltip
|
||||
trigger={(
|
||||
<span className="relative inline-flex w-2 h-2">
|
||||
<span className="relative inline-flex w-2 h-2" data-testid="stack-trailing-update">
|
||||
<span className="absolute inset-0 rounded-full bg-update opacity-75 animate-ping" />
|
||||
<span className="relative w-2 h-2 rounded-full bg-update" />
|
||||
</span>
|
||||
@@ -118,12 +118,24 @@ export function StackRow(props: StackRowProps) {
|
||||
/>
|
||||
) : checkStatus === 'failed' ? (
|
||||
<RowTooltip
|
||||
trigger={<AlertCircle className="w-3 h-3 text-muted-foreground/70" strokeWidth={1.5} />}
|
||||
trigger={(
|
||||
<AlertCircle
|
||||
className="w-3 h-3 text-muted-foreground/70"
|
||||
strokeWidth={1.5}
|
||||
data-testid="stack-trailing-check-failed"
|
||||
/>
|
||||
)}
|
||||
label={lastError ? `Update check failed: ${lastError}` : 'Update check failed'}
|
||||
/>
|
||||
) : hasGitPending ? (
|
||||
<RowTooltip
|
||||
trigger={<GitBranch className="w-3 h-3 text-brand" strokeWidth={1.5} />}
|
||||
trigger={(
|
||||
<GitBranch
|
||||
className="w-3 h-3 text-brand"
|
||||
strokeWidth={1.5}
|
||||
data-testid="stack-trailing-git-pending"
|
||||
/>
|
||||
)}
|
||||
label="Git source update pending"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -95,7 +95,7 @@ export function StackSidebar(props: StackSidebarProps) {
|
||||
onClear={onClearSelection}
|
||||
/>
|
||||
)}
|
||||
<ScrollArea className="flex-1 px-2 pb-2">
|
||||
<ScrollArea block className="flex-1 px-2 pb-2">
|
||||
<div data-stacks-loaded={list.isLoading ? 'false' : 'true'}>
|
||||
<StackList {...list} bulkMode={bulkMode} selectedFiles={selectedFiles} onToggleSelect={onToggleSelect} />
|
||||
</div>
|
||||
|
||||
@@ -123,4 +123,11 @@ describe('StackRow', () => {
|
||||
expect(container.querySelector('[data-slot="cursor-container"]')).toBeNull();
|
||||
expect(container.querySelector('.bg-update')).toBeNull();
|
||||
});
|
||||
|
||||
it('constrains long stack names so trailing indicators stay in the row', () => {
|
||||
const longName = 'tick-grafana-docker-observability-stack';
|
||||
render(<StackRow {...base({ displayName: longName })} />);
|
||||
expect(screen.getByTestId('stack-row')).toHaveClass('min-w-0');
|
||||
expect(screen.getByText(longName)).toHaveClass('truncate');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const sidebarRowBase = cn(
|
||||
'relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md mb-0.5',
|
||||
'relative flex items-center gap-2 w-full min-w-0 px-2 py-1.5 rounded-md mb-0.5',
|
||||
// 44px tap target on touch viewports without changing desktop density.
|
||||
'max-md:min-h-11 max-md:py-2.5',
|
||||
'font-mono text-[13px] text-muted-foreground',
|
||||
|
||||
Reference in New Issue
Block a user