mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 01:01:30 +00:00
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:
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// UX-001 Phase 4 — Layout "Setup guide" re-entry button
|
||||
//
|
||||
// Phase 2 added a persistent "Setup guide" button to the sidebar so operators
|
||||
// who dismissed the onboarding wizard (or closed it mid-flow) can always walk
|
||||
// themselves back in. The button must:
|
||||
//
|
||||
// 1. Render with the accessible name "Setup guide".
|
||||
// 2. On click, clear the `certctl:onboarding-dismissed` localStorage key so
|
||||
// DashboardPage's first-run detection re-engages.
|
||||
// 3. On click, navigate to `/?onboarding=1` — the query-param re-entry
|
||||
// signal DashboardPage reads via useSearchParams. The query param is the
|
||||
// contract between Layout and DashboardPage; without it, a user who
|
||||
// already has certs + issuers would not see the wizard again.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Intercept useNavigate so we can assert the destination path without having
|
||||
// to configure every route segment the wizard might push to.
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Layout pulls auth state from AuthProvider to decide whether to render the
|
||||
// logout button. Tests don't care about auth — stub the hook with an anonymous
|
||||
// session so Layout renders without needing a real AuthProvider wrapper.
|
||||
vi.mock('./AuthProvider', () => ({
|
||||
useAuth: () => ({
|
||||
loading: false,
|
||||
authRequired: false,
|
||||
authenticated: true,
|
||||
authType: 'none',
|
||||
user: '',
|
||||
admin: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Imported after vi.mock so the mocks are in effect when Layout's module graph
|
||||
// resolves.
|
||||
import Layout from './Layout';
|
||||
|
||||
function renderLayout() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<div data-testid="outlet-root">root</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Layout — UX-001 Setup guide sidebar button', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('renders a "Setup guide" button in the sidebar', () => {
|
||||
renderLayout();
|
||||
|
||||
// Red-to-green guard: if the button is removed or renamed, this catches
|
||||
// it. We match by accessible name so the assertion survives className /
|
||||
// icon churn.
|
||||
expect(screen.getByRole('button', { name: /Setup guide/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears the onboarding-dismissed localStorage key on click', () => {
|
||||
localStorage.setItem('certctl:onboarding-dismissed', 'true');
|
||||
expect(localStorage.getItem('certctl:onboarding-dismissed')).toBe('true');
|
||||
|
||||
renderLayout();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Setup guide/i }));
|
||||
|
||||
// DashboardPage reads this key synchronously to decide whether the first-
|
||||
// run wizard can auto-open. Leaving it set would suppress the wizard even
|
||||
// after navigation, defeating the re-entry contract.
|
||||
expect(localStorage.getItem('certctl:onboarding-dismissed')).toBeNull();
|
||||
});
|
||||
|
||||
it('navigates to /?onboarding=1 on click', () => {
|
||||
renderLayout();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Setup guide/i }));
|
||||
|
||||
// The `?onboarding=1` query param is the explicit signal DashboardPage
|
||||
// checks via useSearchParams. Asserting the exact path pins the contract
|
||||
// both ends rely on.
|
||||
expect(mockNavigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/?onboarding=1');
|
||||
});
|
||||
|
||||
it('tolerates localStorage access failure without throwing', () => {
|
||||
// Some browsers / privacy modes throw on localStorage access. Layout
|
||||
// wraps the removal in try/catch so the navigation still fires. Simulate
|
||||
// the failure and verify the navigation path is unaffected.
|
||||
const original = Storage.prototype.removeItem;
|
||||
Storage.prototype.removeItem = vi.fn(() => {
|
||||
throw new Error('localStorage unavailable');
|
||||
});
|
||||
|
||||
try {
|
||||
renderLayout();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Setup guide/i }));
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/?onboarding=1');
|
||||
} finally {
|
||||
Storage.prototype.removeItem = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from './AuthProvider';
|
||||
import logo from '../assets/certctl-logo.png';
|
||||
|
||||
@@ -35,6 +35,12 @@ function Icon({ d }: { d: string }) {
|
||||
|
||||
export default function Layout() {
|
||||
const { authRequired, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const openSetupGuide = () => {
|
||||
try { localStorage.removeItem('certctl:onboarding-dismissed'); } catch { /* noop */ }
|
||||
navigate('/?onboarding=1');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
@@ -71,6 +77,18 @@ export default function Layout() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="px-3 pb-2 pt-2 border-t border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openSetupGuide}
|
||||
title="Reopen the onboarding wizard"
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-[13px] rounded text-sidebar-text hover:text-white hover:bg-white/10 transition-all duration-150"
|
||||
>
|
||||
<Icon d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
Setup guide
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
|
||||
<span className="text-[10px] text-brand-300/60 font-mono">certctl</span>
|
||||
{authRequired && (
|
||||
|
||||
Reference in New Issue
Block a user