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.
This commit is contained in:
Shankar Reddy
2026-04-19 14:49:04 +00:00
parent 49002c8cba
commit 6836286c37
6 changed files with 941 additions and 12 deletions
+14 -3
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
@@ -162,6 +162,7 @@ function DigestCard() {
export default function DashboardPage() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// Onboarding wizard state: once shown, stays shown until explicitly dismissed.
// Uses a ref to "latch" the first-run detection so query refetches don't yank the wizard away.
@@ -170,6 +171,10 @@ export default function DashboardPage() {
});
const [showWizard, setShowWizard] = useState(false);
// Re-entry signal: sidebar "Setup guide" button navigates to /?onboarding=1 to reopen the wizard
// even after dismissal. Takes precedence over localStorage dismissal; stripped on close.
const forceOnboarding = searchParams.get('onboarding') === '1';
// All hooks must be called unconditionally (React rules of hooks — no hooks after early returns)
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, refetchInterval: 30000 });
const { data: summary } = useQuery({ queryKey: ['dashboard-summary'], queryFn: getDashboardSummary, refetchInterval: 30000 });
@@ -190,17 +195,23 @@ export default function DashboardPage() {
summary.total_certificates === 0 &&
userConfiguredIssuers.length === 0;
if (isFirstRun && !showWizard) {
if ((isFirstRun || forceOnboarding) && !showWizard) {
// Can't call setState during render — use a microtask
setTimeout(() => setShowWizard(true), 0);
}
if (showWizard && !onboardingDismissed) {
if ((showWizard && !onboardingDismissed) || forceOnboarding) {
return (
<OnboardingWizard onDismiss={() => {
try { localStorage.setItem('certctl:onboarding-dismissed', 'true'); } catch { /* noop */ }
setOnboardingDismissed(true);
setShowWizard(false);
// Strip ?onboarding=1 so page refresh doesn't relaunch the wizard
if (searchParams.has('onboarding')) {
const next = new URLSearchParams(searchParams);
next.delete('onboarding');
setSearchParams(next, { replace: true });
}
}} />
);
}