mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 00:48:53 +00:00
6836286c37
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.
238 lines
8.3 KiB
TypeScript
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();
|
|
});
|
|
});
|