diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index e9f5222..cd96f24 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -117,6 +117,13 @@ export function Dashboard({ onLogout }: Props) { const tab: Tab = { device: d, key: ++tabCounter }; setTabs((prev) => [...prev, tab]); setActiveTab(tab.key); + // Focus the cell where the tab will land: first empty cell, or the + // currently focused cell if all are occupied. + let targetCell = grid.focusedCell; + for (const [idx, v] of grid.assignments) { + if (v === null) { targetCell = idx; break; } + } + grid.setFocusedCell(targetCell); grid.autoPlace(tab.key); }; @@ -168,9 +175,17 @@ export function Dashboard({ onLogout }: Props) { {/* Tab strip — row 2 on mobile (full width), inline on sm+ */}
- {tabs.map((tab) => ( + {tabs.map((tab) => { + // A tab is "focused" when it lives in the currently focused grid cell. + const isFocused = grid.assignments.get(grid.focusedCell) === tab.key; + // A tab is "visible" when it is assigned to any grid cell (but not the focused one). + const isVisible = !isFocused && Array.from(grid.assignments.values()).includes(tab.key); + return (
{ setActiveTab(tab.key); // Highlight the cell that holds this tab; if not in any cell, auto-place it @@ -182,8 +197,10 @@ export function Dashboard({ onLogout }: Props) { }} className={`flex items-center gap-1.5 px-3 py-1 rounded-md text-xs cursor-pointer select-none whitespace-nowrap transition-colors flex-shrink-0 - ${activeTab === tab.key + ${isFocused ? "bg-blue-600/30 text-blue-300 border border-blue-600/50" + : isVisible + ? "bg-slate-700/50 text-slate-300 border border-slate-600/50" : "text-slate-400 hover:bg-slate-800 border border-transparent" }`} > @@ -202,7 +219,8 @@ export function Dashboard({ onLogout }: Props) { x
- ))} + ); + })}
{/* Right actions — row 1, right (ml-auto pushes it to the right edge) */} diff --git a/frontend/src/test/DashboardTabHighlight.test.tsx b/frontend/src/test/DashboardTabHighlight.test.tsx new file mode 100644 index 0000000..454cc4b --- /dev/null +++ b/frontend/src/test/DashboardTabHighlight.test.tsx @@ -0,0 +1,323 @@ +/** + * Tests for tab highlighting in the Dashboard top bar. + * + * Three distinct visual states must be applied to each tab chip: + * + * 1. Focused — the tab is assigned to the currently focused grid cell. + * CSS classes: bg-blue-600/30 text-blue-300 border-blue-600/50 + * data attribute: data-tab-focused="true" + * + * 2. Visible — the tab is assigned to a cell that is visible but NOT focused. + * CSS classes: bg-slate-700/50 text-slate-300 border-slate-600/50 + * data attribute: data-tab-visible="true" + * + * 3. Inactive — the tab is open but not assigned to any visible cell. + * CSS classes: text-slate-400 hover:bg-slate-800 + * + * In a single-pane layout, at most one tab is focused; all others are inactive. + * In a split layout, the focused cell tab is "focused" and all other occupied + * cells get the "visible" style. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Dashboard } from '../pages/Dashboard'; +import { ToastProvider } from '../components/Toast'; +import type { Device } from '../api/client'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock('../api/client', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listDevices: vi.fn().mockResolvedValue([]), + logout: vi.fn().mockResolvedValue(undefined), + getTokenExpiry: vi.fn().mockReturnValue(new Date(Date.now() + 60 * 60 * 1000)), + }; +}); + +vi.mock('../components/Terminal', () => ({ + Terminal: ({ device }: { device: Device }) => ( +
+ ), +})); + +vi.mock('../components/FileManager', () => ({ + FileManager: ({ device }: { device: Device }) => ( +
+ ), +})); + +vi.mock('../components/FtpFileManager', () => ({ + FtpFileManager: ({ device }: { device: Device }) => ( +
+ ), +})); + +vi.mock('../components/DeviceList', () => ({ + DeviceList: (props: { onConnect: (d: Device) => void }) => ( +
+ + + +
+ ), +})); + +vi.mock('../components/DeviceForm', () => ({ DeviceForm: () => null })); +vi.mock('../components/ChangePasswordModal', () => ({ ChangePasswordModal: () => null })); +vi.mock('../components/AuditLogModal', () => ({ AuditLogModal: () => null })); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function setup() { + const onLogout = vi.fn(); + render( + + + , + ); + return { onLogout }; +} + +async function setupAsync() { + const result = setup(); + await waitFor(() => expect(document.querySelector('header')).toBeInTheDocument()); + return result; +} + +/** Return the tab chip element for a given device name. */ +function getTabChip(name: string) { + return screen.getByText(name).closest('[data-tab-key]') as HTMLElement; +} + +beforeEach(() => vi.clearAllMocks()); + +// ── Single-pane: first connection ───────────────────────────────────────────── + +describe('Tab highlight — single pane, one open tab', () => { + it('the connected tab has data-tab-focused="true"', async () => { + await setupAsync(); + await userEvent.click(screen.getByTestId('connect-device-1')); + await waitFor(() => screen.getByText('Server Alpha')); + + const chip = getTabChip('Server Alpha'); + expect(chip).toHaveAttribute('data-tab-focused', 'true'); + }); + + it('the connected tab does NOT have data-tab-visible', async () => { + await setupAsync(); + await userEvent.click(screen.getByTestId('connect-device-1')); + await waitFor(() => screen.getByText('Server Alpha')); + + const chip = getTabChip('Server Alpha'); + expect(chip).not.toHaveAttribute('data-tab-visible'); + }); + + it('the focused tab has the blue highlight classes', async () => { + await setupAsync(); + await userEvent.click(screen.getByTestId('connect-device-1')); + await waitFor(() => screen.getByText('Server Alpha')); + + const chip = getTabChip('Server Alpha'); + expect(chip.className).toContain('bg-blue-600/30'); + expect(chip.className).toContain('text-blue-300'); + expect(chip.className).toContain('border-blue-600/50'); + }); +}); + +// ── Single-pane: two open tabs ──────────────────────────────────────────────── + +describe('Tab highlight — single pane, two open tabs', () => { + it('only the most recently connected tab is focused', async () => { + await setupAsync(); + await userEvent.click(screen.getByTestId('connect-device-1')); + await userEvent.click(screen.getByTestId('connect-device-2')); + await waitFor(() => screen.getByText('Server Beta')); + + expect(getTabChip('Server Beta')).toHaveAttribute('data-tab-focused', 'true'); + expect(getTabChip('Server Alpha')).not.toHaveAttribute('data-tab-focused'); + }); + + it('the other tab is inactive (no visible or focused attribute)', async () => { + await setupAsync(); + await userEvent.click(screen.getByTestId('connect-device-1')); + await userEvent.click(screen.getByTestId('connect-device-2')); + await waitFor(() => screen.getByText('Server Beta')); + + const alphaChip = getTabChip('Server Alpha'); + expect(alphaChip).not.toHaveAttribute('data-tab-focused'); + expect(alphaChip).not.toHaveAttribute('data-tab-visible'); + }); + + it('the inactive tab has the default (non-highlighted) classes', async () => { + await setupAsync(); + await userEvent.click(screen.getByTestId('connect-device-1')); + await userEvent.click(screen.getByTestId('connect-device-2')); + await waitFor(() => screen.getByText('Server Beta')); + + const alphaChip = getTabChip('Server Alpha'); + expect(alphaChip.className).toContain('text-slate-400'); + expect(alphaChip.className).not.toContain('bg-blue-600/30'); + expect(alphaChip.className).not.toContain('bg-slate-700/50'); + }); + + it('clicking the inactive tab makes it focused', async () => { + await setupAsync(); + await userEvent.click(screen.getByTestId('connect-device-1')); + await userEvent.click(screen.getByTestId('connect-device-2')); + await waitFor(() => screen.getByText('Server Beta')); + + await userEvent.click(getTabChip('Server Alpha')); + await waitFor(() => + expect(getTabChip('Server Alpha')).toHaveAttribute('data-tab-focused', 'true'), + ); + expect(getTabChip('Server Beta')).not.toHaveAttribute('data-tab-focused'); + }); +}); + +// ── Grid layout: split pane (1|1) ──────────────────────────────────────────── + +describe('Tab highlight — split layout, two visible tabs', () => { + /** + * Switch to the vertical split layout via the LayoutPicker. + * The LayoutPicker renders buttons whose accessible names contain the + * layout description text (e.g. "Vertical split"). + */ + async function switchToVerticalSplit() { + const splitBtn = screen.getByTitle('Vertical split'); + await userEvent.click(splitBtn); + } + + it('in split mode the focused tab has data-tab-focused="true"', async () => { + await setupAsync(); + await switchToVerticalSplit(); + await userEvent.click(screen.getByTestId('connect-device-1')); + await userEvent.click(screen.getByTestId('connect-device-2')); + await waitFor(() => screen.getByText('Server Beta')); + + // After two connections in split mode the last one is in the focused cell + expect(getTabChip('Server Beta')).toHaveAttribute('data-tab-focused', 'true'); + }); + + it('in split mode the other visible tab has data-tab-visible="true"', async () => { + await setupAsync(); + await switchToVerticalSplit(); + await userEvent.click(screen.getByTestId('connect-device-1')); + await userEvent.click(screen.getByTestId('connect-device-2')); + await waitFor(() => screen.getByText('Server Beta')); + + expect(getTabChip('Server Alpha')).toHaveAttribute('data-tab-visible', 'true'); + }); + + it('in split mode the visible-but-not-focused tab has the slate highlight classes', async () => { + await setupAsync(); + await switchToVerticalSplit(); + await userEvent.click(screen.getByTestId('connect-device-1')); + await userEvent.click(screen.getByTestId('connect-device-2')); + await waitFor(() => screen.getByText('Server Beta')); + + const alphaChip = getTabChip('Server Alpha'); + expect(alphaChip.className).toContain('bg-slate-700/50'); + expect(alphaChip.className).toContain('text-slate-300'); + expect(alphaChip.className).not.toContain('bg-blue-600/30'); + }); + + it('in split mode a third tab replaces the focused cell and becomes focused', async () => { + await setupAsync(); + await switchToVerticalSplit(); + // Fill both cells + await userEvent.click(screen.getByTestId('connect-device-1')); + await userEvent.click(screen.getByTestId('connect-device-2')); + await waitFor(() => screen.getByText('Server Beta')); + // Open a third connection — no empty cell, so it replaces the focused cell + await userEvent.click(screen.getByTestId('connect-device-3')); + await waitFor(() => screen.getByText('Server Gamma')); + + // Gamma is placed in the focused cell, so it becomes the focused tab + const gammaChip = getTabChip('Server Gamma'); + expect(gammaChip).toHaveAttribute('data-tab-focused', 'true'); + expect(gammaChip.className).toContain('bg-blue-600/30'); + + // The evicted tab (the one that was in the focused cell) is now inactive + // (it is still open but no longer in any cell) + const evictedChip = document.querySelector( + '[data-tab-key]:not([data-tab-focused]):not([data-tab-visible])', + ) as HTMLElement | null; + expect(evictedChip).not.toBeNull(); + expect(evictedChip!.className).toContain('text-slate-400'); + }); +}); + +// ── data-tab-key attribute ──────────────────────────────────────────────────── + +describe('Tab highlight — data-tab-key attribute', () => { + it('every tab chip carries a numeric data-tab-key', async () => { + await setupAsync(); + await userEvent.click(screen.getByTestId('connect-device-1')); + await userEvent.click(screen.getByTestId('connect-device-2')); + await waitFor(() => screen.getByText('Server Beta')); + + const chips = document.querySelectorAll('[data-tab-key]'); + expect(chips.length).toBe(2); + chips.forEach((chip) => { + expect(chip.getAttribute('data-tab-key')).toMatch(/^\d+$/); + }); + }); +});