Files
certctl/web/src/pages/DashboardPage.test.tsx
T
Shankar Reddy 6836286c37 UX-001: sidebar re-entry + inline team/owner creation in wizard
Closes UX-001 (OnboardingWizard CertificateStep dead-end): users no
longer have to navigate away from the wizard and lose their in-flight
state when the required Owner/Team dropdowns are empty.

Layout.tsx
  - Adds persistent 'Setup guide' button in the left sidebar.
  - Clears localStorage 'certctl:onboarding-dismissed' then navigates
    to /?onboarding=1 as a re-entry signal that overrides dismissal.
  - localStorage.removeItem wrapped in try/catch to tolerate storage
    access errors (private browsing, quota, etc.).

DashboardPage.tsx
  - Reads ?onboarding=1 via useSearchParams as a forceOnboarding flag.
  - forceOnboarding bypasses the latched first-run gate so the wizard
    reopens even after dismissal or with certs/issuers already present.
  - onDismiss now also strips ?onboarding=1 via setSearchParams(next,
    { replace: true }) so a page refresh does not relaunch the wizard.

OnboardingWizard.tsx
  - Adds CreateTeamModalInline and CreateOwnerModalInline inside
    CertificateStep. Both wire through React Query: createTeam /
    createOwner mutation on success invalidates ['teams'] / ['owners']
    and calls onCreated(id) so the parent select auto-selects the new
    row as soon as the refetch lands.
  - '+ New team' and '+ New owner' buttons placed next to the select
    labels; empty-state copy replaced with inline 'create one now'
    buttons (no more Link back to /owners /teams).
  - CreateOwner coerces empty teamId to undefined before mutation so
    the server contract matches OwnersPage.

Tests (12 new, all green; total suite 252 passed / 0 failed):
  - Layout.test.tsx (4): Setup guide button renders, clicking it clears
    the dismissal key and navigates to /?onboarding=1, tolerates
    localStorage.removeItem throwing.
  - DashboardPage.test.tsx (4): first-run auto-open, ?onboarding=1
    re-entry after dismissal, onDismiss writes localStorage + strips
    the query param, dismissed-with-no-param stays closed.
  - OnboardingWizard.test.tsx (4): Skip-Skip reaches CertificateStep
    with '+ New team' / '+ New owner' buttons visible; '+ New team'
    happy path with React Query invalidation + parent-select
    auto-select via option-parent traversal (label is a sibling, not
    htmlFor-linked); '+ New owner' happy path pins team_id: undefined
    coercion; Cancel abort never mutates.

Test infrastructure notes:
  - Closure-driven vi.fn().mockImplementation pattern drives the
    post-invalidation refetch: the mutation mock mutates a closure
    variable that the getTeams/getOwners mock reads, so the parent
    select's new <option> exists by the time the refetch lands.
  - Anchored regex (/^Create Team$/, /^Create Owner$/) disambiguates
    the modal submit from the '+ New team' / '+ New owner' triggers.

Verification gates (all green):
  - vitest run: 252 passed / 0 failed (8 files, 13.98s)
  - tsc --noEmit: 0 errors
  - vite build: clean production bundle (851.77 kB js / 226.81 kB gzip)

No new runtime dependencies. Frontend-only change.
2026-04-19 14:49:04 +00:00

238 lines
8.3 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
import type { ReactNode } from 'react';
// -----------------------------------------------------------------------------
// UX-001 Phase 4 — DashboardPage onboarding wizard entry/exit contract
//
// DashboardPage is the arbiter of when the OnboardingWizard is visible. Two
// triggers must both work:
//
// 1. First-run detection: no certificates + no user-configured issuers +
// no dismissal flag → auto-open the wizard.
//
// 2. Re-entry via URL: `?onboarding=1` query param forces the wizard open
// even for users who already have certs or have previously dismissed it.
// This is the other end of the Layout "Setup guide" contract tested in
// Layout.test.tsx.
//
// And the close path must clean up after itself:
//
// 3. On dismiss, DashboardPage must (a) set
// `certctl:onboarding-dismissed=true` in localStorage so it doesn't
// auto-reopen on refresh, and (b) strip the `?onboarding=1` query param
// via setSearchParams(..., { replace: true }) so a subsequent refresh
// doesn't relaunch the wizard either.
// -----------------------------------------------------------------------------
// Mock the entire API client. vi.mock factories are hoisted above the imports
// that follow, so these stubs are in effect when DashboardPage's module graph
// resolves.
vi.mock('../api/client', () => ({
getCertificates: vi.fn(),
getAgents: vi.fn(),
getJobs: vi.fn(),
getNotifications: vi.fn(),
getHealth: vi.fn(),
getDashboardSummary: vi.fn(),
getCertificatesByStatus: vi.fn(),
getExpirationTimeline: vi.fn(),
getJobTrends: vi.fn(),
getIssuanceRate: vi.fn(),
previewDigest: vi.fn(),
sendDigest: vi.fn(),
getIssuers: vi.fn(),
}));
// Replace OnboardingWizard with a paper-thin stub so these tests exercise the
// entry/exit contract without pulling OnboardingWizard's (heavy) query tree
// and step machinery into the fixture. OnboardingWizard's own behaviour is
// covered in OnboardingWizard.test.tsx.
vi.mock('./OnboardingWizard', () => ({
default: ({ onDismiss }: { onDismiss: () => void }) => (
<div data-testid="onboarding-wizard">
<button type="button" data-testid="wizard-dismiss-btn" onClick={onDismiss}>
dismiss
</button>
</div>
),
}));
import DashboardPage from './DashboardPage';
import * as client from '../api/client';
// Location probe: renders current pathname+search into the DOM so we can
// assert that setSearchParams(next, { replace: true }) actually stripped the
// `?onboarding=1` query param without relying on router internals.
function LocationProbe() {
const loc = useLocation();
return (
<div data-testid="location-probe">
{loc.pathname}
{loc.search}
</div>
);
}
function renderDashboard(initialEntry = '/') {
const qc = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: 0 },
},
});
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[initialEntry]}>
<LocationProbe />
<Routes>
<Route path="/" element={<DashboardPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
// Canonical "empty but well-formed" stubs for every query DashboardPage calls
// unconditionally. Returning data (rather than leaving queries pending) lets
// the component pass its `summary !== undefined && issuersData !== undefined`
// gate and compute isFirstRun in a single render pass.
function stubAllQueriesEmpty() {
vi.mocked(client.getHealth).mockResolvedValue({ status: 'healthy' } as never);
vi.mocked(client.getDashboardSummary).mockResolvedValue({
total_certificates: 0,
expiring_certificates: 0,
expired_certificates: 0,
revoked_certificates: 0,
active_agents: 0,
pending_jobs: 0,
completed_jobs: 0,
failed_jobs: 0,
notifications_dead: 0,
} as never);
vi.mocked(client.getIssuers).mockResolvedValue({
data: [],
total: 0,
page: 1,
per_page: 100,
} as never);
vi.mocked(client.getCertificatesByStatus).mockResolvedValue([] as never);
vi.mocked(client.getExpirationTimeline).mockResolvedValue([] as never);
vi.mocked(client.getJobTrends).mockResolvedValue([] as never);
vi.mocked(client.getIssuanceRate).mockResolvedValue([] as never);
vi.mocked(client.getCertificates).mockResolvedValue({
data: [],
total: 0,
page: 1,
per_page: 100,
} as never);
vi.mocked(client.getJobs).mockResolvedValue({
data: [],
total: 0,
page: 1,
per_page: 100,
} as never);
}
describe('DashboardPage — UX-001 onboarding wizard entry/exit', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
localStorage.clear();
stubAllQueriesEmpty();
});
afterEach(() => {
localStorage.clear();
});
it('auto-opens the wizard on first run (no certs, no user-configured issuers, no dismissal)', async () => {
renderDashboard('/');
// First-run detection runs after the summary + issuers queries resolve.
// The wizard is gated behind a setTimeout(..., 0) in DashboardPage to
// avoid setState-during-render, so we waitFor the stub to appear.
await waitFor(() => {
expect(screen.getByTestId('onboarding-wizard')).toBeInTheDocument();
});
});
it('opens the wizard when URL has ?onboarding=1, even with dismissal flag set', async () => {
// Simulate a user who dismissed the wizard previously and is now clicking
// the sidebar "Setup guide" button, which navigates to /?onboarding=1.
localStorage.setItem('certctl:onboarding-dismissed', 'true');
// Give the fixture non-zero counts so first-run detection would *not*
// fire on its own. Only the query-param override should open the wizard.
vi.mocked(client.getDashboardSummary).mockResolvedValue({
total_certificates: 42,
expiring_certificates: 0,
expired_certificates: 0,
revoked_certificates: 0,
active_agents: 3,
pending_jobs: 0,
completed_jobs: 0,
failed_jobs: 0,
notifications_dead: 0,
} as never);
vi.mocked(client.getIssuers).mockResolvedValue({
data: [{ id: 'iss-prod', name: 'Prod ACME', type: 'ACME', source: 'database' }],
total: 1,
page: 1,
per_page: 100,
} as never);
renderDashboard('/?onboarding=1');
await waitFor(() => {
expect(screen.getByTestId('onboarding-wizard')).toBeInTheDocument();
});
});
it('onDismiss sets localStorage flag and strips ?onboarding=1 from the URL', async () => {
renderDashboard('/?onboarding=1');
// Wait for the wizard to open.
await waitFor(() => {
expect(screen.getByTestId('onboarding-wizard')).toBeInTheDocument();
});
// Sanity: URL still carries the re-entry signal before dismiss.
expect(screen.getByTestId('location-probe').textContent).toContain('onboarding=1');
fireEvent.click(screen.getByTestId('wizard-dismiss-btn'));
// After dismiss: localStorage is set, so a future refresh won't re-open.
await waitFor(() => {
expect(localStorage.getItem('certctl:onboarding-dismissed')).toBe('true');
});
// The wizard is torn down.
await waitFor(() => {
expect(screen.queryByTestId('onboarding-wizard')).not.toBeInTheDocument();
});
// And the `?onboarding=1` query param is stripped via replace: true so
// refreshing the page won't reopen the wizard via the URL path either.
await waitFor(() => {
const probe = screen.getByTestId('location-probe').textContent ?? '';
expect(probe).not.toContain('onboarding=1');
});
});
it('does not open the wizard when dismissed and URL has no onboarding param', async () => {
localStorage.setItem('certctl:onboarding-dismissed', 'true');
renderDashboard('/');
// Give queries a tick to settle.
await waitFor(() => {
expect(screen.getByText(/System healthy|Checking system status/i)).toBeInTheDocument();
});
// The wizard should NOT have auto-opened — dismissal flag is respected
// when the URL doesn't carry the override signal.
expect(screen.queryByTestId('onboarding-wizard')).not.toBeInTheDocument();
});
});