feat(dashboard): Enhance dashboard with PageHeader, StatCard, and auth fixes
- Create PageHeader component with title, description, and action slots - Create StatCard component with trend indicators and icons - Update dashboard with KPI cards, Quick Actions, and Allocation Preview - Polish login page with branding and centered layout - Fix auth redirect: authenticated users accessing /login go to dashboard - Fix page refresh: auth state persists, no blank page - Fix sidebar: visible after login, toggle works, state persists - Fix CSS import: add app.css to layout, fix DaisyUI import path - Fix breadcrumbs: home icon links to /dashboard - Add comprehensive E2E and unit tests Refs: openspec/changes/p03-dashboard-enhancement Closes: p03-dashboard-enhancement
This commit is contained in:
@@ -232,6 +232,26 @@ test.describe('Authentication E2E', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Already Authenticated Redirects', () => {
|
||||
test('authenticated user accessing login redirects to dashboard @auth', async ({ page }) => {
|
||||
// Login first
|
||||
await page.goto('/login');
|
||||
await page.fill('input[type="email"]', 'superuser@headroom.test');
|
||||
await page.fill('input[type="password"]', 'password');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Now try to access login page while authenticated
|
||||
await page.goto('/login');
|
||||
|
||||
// Should redirect to dashboard, not show login page
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Verify we're on dashboard
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Token Auto-refresh', () => {
|
||||
test('token auto-refresh on 401 response @auth', async ({ page }) => {
|
||||
// Login first
|
||||
|
||||
49
frontend/tests/e2e/dashboard.spec.ts
Normal file
49
frontend/tests/e2e/dashboard.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Dashboard Page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Login first
|
||||
await page.goto('/login');
|
||||
await page.fill('input[type="email"]', 'admin@example.com');
|
||||
await page.fill('input[type="password"]', 'password');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for navigation to dashboard
|
||||
await page.waitForURL('/dashboard');
|
||||
});
|
||||
|
||||
test('dashboard renders correctly', async ({ page }) => {
|
||||
// Check page title
|
||||
await expect(page).toHaveTitle(/Dashboard/);
|
||||
|
||||
// Check PageHeader renders
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByText('Overview of your resource allocation')).toBeVisible();
|
||||
|
||||
// Check New Allocation button
|
||||
await expect(page.getByRole('button', { name: /New Allocation/i })).toBeVisible();
|
||||
|
||||
// Check all 4 StatCards render
|
||||
await expect(page.getByText('Active Projects')).toBeVisible();
|
||||
await expect(page.getByText('Team Members')).toBeVisible();
|
||||
await expect(page.getByText('Allocations (hrs)')).toBeVisible();
|
||||
await expect(page.getByText('Avg Utilization')).toBeVisible();
|
||||
|
||||
// Check stat values
|
||||
await expect(page.getByText('14')).toBeVisible(); // Active Projects
|
||||
await expect(page.getByText('8')).toBeVisible(); // Team Members
|
||||
await expect(page.getByText('186')).toBeVisible(); // Allocations
|
||||
await expect(page.getByText('87%')).toBeVisible(); // Avg Utilization
|
||||
|
||||
// Check Quick Actions section
|
||||
await expect(page.getByRole('heading', { name: 'Quick Actions' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /Team/i })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /Projects/i })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /Allocate/i })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /Forecast/i })).toBeVisible();
|
||||
|
||||
// Check Allocation Preview section
|
||||
await expect(page.getByRole('heading', { name: 'Allocation Preview' })).toBeVisible();
|
||||
await expect(page.getByText(/Allocation matrix for/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -164,4 +164,119 @@ test.describe('Layout E2E', () => {
|
||||
.poll(async () => page.evaluate(() => document.documentElement.getAttribute('data-sidebar')))
|
||||
.not.toBe(before);
|
||||
});
|
||||
|
||||
test('sidebar is visible after login', async ({ page }) => {
|
||||
await loginThroughForm(page);
|
||||
|
||||
// Sidebar should be visible
|
||||
const sidebar = page.locator('[data-testid="sidebar"]');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
// Verify sidebar has content
|
||||
await expect(sidebar.locator('text=Dashboard')).toBeVisible();
|
||||
});
|
||||
|
||||
test('sidebar toggle button works when collapsed', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await openDashboard(page);
|
||||
|
||||
// Get initial state
|
||||
const sidebar = page.locator('[data-testid="sidebar"]');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
// Find and click collapse button (if exists)
|
||||
const collapseButton = page.locator('[data-testid="sidebar-toggle"], [aria-label*="toggle"], button').filter({ has: page.locator('svg') }).first();
|
||||
|
||||
if (await collapseButton.isVisible().catch(() => false)) {
|
||||
// Collapse the sidebar
|
||||
await collapseButton.click();
|
||||
|
||||
// Wait for collapsed state
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Sidebar should still exist but be in collapsed state
|
||||
await expect(sidebar).toBeAttached();
|
||||
|
||||
// Click toggle again to expand
|
||||
await collapseButton.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Sidebar should be visible again
|
||||
await expect(sidebar).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('page refresh after login does not show blank page', async ({ page }) => {
|
||||
await loginThroughForm(page);
|
||||
|
||||
// Verify we're on dashboard
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
|
||||
// Get the token before refresh
|
||||
const tokenBefore = await page.evaluate(() => localStorage.getItem('headroom_access_token'));
|
||||
expect(tokenBefore).toBeTruthy();
|
||||
|
||||
// Refresh the page
|
||||
await page.reload();
|
||||
|
||||
// Wait for page to fully load after refresh
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check what URL we're on (for debugging)
|
||||
const currentUrl = page.url();
|
||||
|
||||
// We should either be on dashboard (success) or redirected to login (bug)
|
||||
// If we're on login, that's the bug being reported
|
||||
if (currentUrl.includes('/login')) {
|
||||
// Bug: We were redirected to login after refresh
|
||||
// Check if token is still there
|
||||
const tokenAfter = await page.evaluate(() => localStorage.getItem('headroom_access_token'));
|
||||
expect(tokenAfter, 'Token should persist after refresh').toBeTruthy();
|
||||
// Fail the test to document the bug
|
||||
throw new Error('Bug: User was redirected to login after page refresh despite having valid token');
|
||||
}
|
||||
|
||||
// If we're still on dashboard, wait for client-side hydration to complete
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
|
||||
// Wait for SvelteKit to hydrate the page
|
||||
// The page might be SSR'd but needs client-side hydration to show content
|
||||
await page.waitForFunction(() => {
|
||||
// Check if the page has hydrated by looking for SvelteKit's hydration marker
|
||||
return document.querySelector('[data-sveltekit-hydrated]') !== null ||
|
||||
document.body.innerHTML.includes('Dashboard');
|
||||
}, { timeout: 10000 });
|
||||
|
||||
// Now verify content is visible
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify token is still there
|
||||
const tokenAfter = await page.evaluate(() => localStorage.getItem('headroom_access_token'));
|
||||
expect(tokenAfter).toBe(tokenBefore);
|
||||
});
|
||||
|
||||
test('sidebar state persists after page refresh', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await openDashboard(page);
|
||||
|
||||
// Get initial sidebar state
|
||||
const initialState = await page.evaluate(() =>
|
||||
document.documentElement.getAttribute('data-sidebar')
|
||||
);
|
||||
|
||||
// Refresh page
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Verify sidebar is still visible
|
||||
const sidebar = page.locator('[data-testid="sidebar"]');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
// Verify state persisted
|
||||
const afterRefresh = await page.evaluate(() =>
|
||||
document.documentElement.getAttribute('data-sidebar')
|
||||
);
|
||||
expect(afterRefresh).toBe(initialState);
|
||||
});
|
||||
});
|
||||
|
||||
62
frontend/tests/e2e/login.spec.ts
Normal file
62
frontend/tests/e2e/login.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Login Page', () => {
|
||||
test('login page is centered and displays branding', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Check page title
|
||||
await expect(page).toHaveTitle(/Login/);
|
||||
|
||||
// Check branding/logo is visible
|
||||
await expect(page.getByRole('heading', { name: 'Headroom' })).toBeVisible();
|
||||
await expect(page.getByText('Resource Planning & Capacity Management')).toBeVisible();
|
||||
|
||||
// Check logo icon is present (using the LayoutDashboard icon container)
|
||||
const logoContainer = page.locator('.bg-primary.rounded-2xl');
|
||||
await expect(logoContainer).toBeVisible();
|
||||
|
||||
// Check login form is centered (card is visible and centered via flexbox)
|
||||
const card = page.locator('.card');
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
// Check welcome message
|
||||
await expect(page.getByRole('heading', { name: 'Welcome Back' })).toBeVisible();
|
||||
await expect(page.getByText('Sign in to access your dashboard')).toBeVisible();
|
||||
|
||||
// Check form elements are present
|
||||
await expect(page.getByLabel('Email')).toBeVisible();
|
||||
await expect(page.getByLabel('Password')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Login/i })).toBeVisible();
|
||||
|
||||
// Check demo credentials section
|
||||
await expect(page.getByText('Demo Access')).toBeVisible();
|
||||
await expect(page.getByText('Use these credentials to explore:')).toBeVisible();
|
||||
await expect(page.locator('code').filter({ hasText: 'admin@example.com' })).toBeVisible();
|
||||
await expect(page.locator('code').filter({ hasText: 'password' })).toBeVisible();
|
||||
|
||||
// Check footer
|
||||
await expect(page.getByText('Headroom - Engineering Resource Planning')).toBeVisible();
|
||||
});
|
||||
|
||||
test('login form is vertically centered in viewport', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Get the container and check it uses flexbox centering
|
||||
const container = page.locator('.min-h-screen');
|
||||
await expect(container).toHaveClass(/flex-col/);
|
||||
await expect(container).toHaveClass(/items-center/);
|
||||
await expect(container).toHaveClass(/justify-center/);
|
||||
|
||||
// The card should be centered both horizontally and vertically
|
||||
const card = page.locator('.card');
|
||||
const box = await card.boundingBox();
|
||||
const viewport = page.viewportSize();
|
||||
|
||||
if (box && viewport) {
|
||||
const cardCenterY = box.y + box.height / 2;
|
||||
const viewportCenterY = viewport.height / 2;
|
||||
// Allow for some margin of error (within 100px of center)
|
||||
expect(Math.abs(cardCenterY - viewportCenterY)).toBeLessThan(100);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user