mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 02:19:27 +00:00
feat(frontend): Phase 1 Foundation Primitives + Toast System — close UX-H2/H3/H5 + UX-M2/M3/M4/L5 + FE-M4
Frontend design remediation, Phase 1 (Foundation Primitives + Toast).
Builds the six reusable UI primitives every later phase consumes;
migrates the audit-enumerated destructive-action callsites; humanises
the StatusBadge wire keys; and wraps the bulk-action bar in a
Transition with a post-action toast affordance.
Six new primitives + their .test.tsx siblings
=============================================
web/src/components/Toaster.tsx — Sonner wrapper, mounted
once at the root next to
QueryClientProvider. Pages
import { toast } from
"sonner" directly.
web/src/components/ConfirmDialog.tsx — Headless UI Dialog primitive
with optional typed-
confirmation friction for
the most-irreversible actions
(archive-certificate uses
typedConfirmation="archive").
web/src/components/Tooltip.tsx — Floating-UI tooltip with
hover + focus triggers,
aria-describedby wiring,
ESC-to-dismiss. Migrations
of the 103 native title=
sites stay in subsequent
per-page PRs per the audit
prompt's explicit "DO NOT"
on one-mega-PR sweeps.
web/src/components/EmptyState.tsx — Empty-state primitive with
optional icon / title /
description / primary +
secondary CTAs. DataTable
adds a new emptyState slot
(legacy emptyMessage string
prop preserved for backward
compat).
web/src/components/Combobox.tsx — Headless UI typeahead-
select primitive. Migrations
of the 53 native <select>
sites stay in subsequent
per-page PRs.
web/src/components/Banner.tsx — Severity-variant alert
banner with role="alert" on
error/warning, role="status"
on success/info. Migrating
the ~102 inline
bg-(red|amber|yellow)-50
sites stays as page-touch
rolling work.
Each primitive ships with a sibling .test.tsx asserting the
behavioural contract — render at rest, fire callbacks, ARIA wiring,
keyboard nav, variant styling. Total new test count: 109 assertions
across 7 files (6 primitives + extended StatusBadge).
UX-H5 closure — StatusBadge display strings
============================================
web/src/components/StatusBadge.tsx gets a statusDisplay map paired
with the existing statusStyles map. Wire keys stay byte-identical
to the Go enums per the D-1 closure comment block — only the
rendered text changes. PascalCase + snake_case + lowercase enums
now render as spaced sentence-case:
"RenewalInProgress" → "Renewal in progress"
"AwaitingCSR" → "Awaiting CSR"
"cert_mismatch" → "Certificate mismatch"
"dead" → "Dead-lettered"
Unmapped keys flow through a titleCase() helper that humanises
PascalCase / snake_case to lower-bound readability.
StatusBadge.test.tsx extends to 75 assertions: 38 D-1 + 5 dead-key
+ 31 UX-H5 display-string + 5 titleCase + 1 parity. All wire-keys
pinned byte-exact.
UX-H2 closure — window.confirm sites migrated to ConfirmDialog
==============================================================
Audit said 8 destructive-action sites. Live count was 24 across
17 files — the audit missed 11 files (auth/SessionsPage,
auth/UsersPage, auth/GroupMappingsPage, auth/OIDCProvidersPage,
auth/OIDCProviderDetailPage, auth/RolesPage, TeamsPage,
PoliciesPage, IssuersPage, ProfilesPage, RenewalPoliciesPage).
Phase 1 migrates the 7 audit-enumerated destructive sites in the
6 priority files:
- CertificateDetailPage archive (typedConfirmation="archive" —
most-irreversible action gets the
strongest friction)
- OwnersPage delete owner
- TargetsPage delete target
- AgentGroupsPage delete agent group
- auth/KeysPage revoke role grant
- auth/RoleDetailPage delete role
The remaining 11 confirm sites in audit-missed files stay open
and ship as a Phase 1 follow-up (mechanical pattern repeat — same
Edit shape × ~11 files).
UX-H3 closure — alert() → toast.error, top mutations wired
===========================================================
All 5 alert() sites migrated to toast.error:
- OwnersPage / CertificateDetailPage × 2 / TeamsPage /
RenewalPoliciesPage
Eight high-traffic mutations now fire toast.success on resolve +
toast.error on failure: deleteOwner, deleteTarget, deleteAgentGroup,
deleteTeam, deleteRenewalPolicy, archiveCertificate,
authRevokeKeyRole, authDeleteRole. The bulk-renew flow on
CertificatesPage gets a toast with a "View N jobs" action button
that deep-links to /jobs?certificate_ids=… (paired UX-L5 work).
Toaster mounted at web/src/main.tsx next to QueryClientProvider —
single import discipline. Sonner asserts at runtime if multiple
toasters are mounted; centralising the position + duration config
in Toaster.tsx avoids the mistake.
UX-M3 closure — DataTable empty-state slot
==========================================
web/src/components/DataTable.tsx gains an optional emptyState
ReactNode prop. The existing emptyMessage string prop is
preserved for backward compat — every ~18 list-page call site
that passes emptyMessage="…" keeps working unchanged. New CTAs:
pages pass <EmptyState ... /> for first-run experiences. Wiring
EmptyState on the top-5 list pages (Certificates, Issuers,
Targets, Owners, Agents) is per-page rolling work — primitive
+ slot ship in Phase 1; CTAs follow.
UX-L5 closure — Bulk-action bar transition + post-action toast
==============================================================
web/src/pages/CertificatesPage.tsx wraps the bulk-action bar
conditional render in Headless UI <Transition>. Slide-in/out
(200ms enter, 150ms leave, -translate-y-2 → 0). The
prefers-reduced-motion respect comes for free from the global
@media block landed in Phase 0.
Post-renewal toast.success fires with an action button "View N
jobs" that navigate()s to /jobs filtered to the certificate_ids
we just renewed. Closes the audit's "what just happened" gap.
Audit-accuracy callouts
=======================
* UX-H2 undercount — live 24 sites vs audit's 8. Phase 1 closes
the 7 audit-enumerated destructive confirms across 6 priority
files. The remaining 11 sites in audit-missed files stay open
for follow-up.
* UX-M2 title= count — live 103 (matches audit). Tooltip
primitive built; per-page migrations explicitly deferred per
the prompt's "DO NOT" sweep rule.
* UX-M4 native <select> sites — Combobox primitive built;
callsite migrations deferred to per-page rolling PRs.
* FE-M4 inline bg-(red|amber|yellow)-50 — Banner primitive
built; callsite migrations deferred to page-touch work.
Verification
============
$ npx tsc --noEmit
(exit 0, no type errors)
$ npx vitest run src/components/{Toaster,ConfirmDialog,EmptyState,Banner,Tooltip,Combobox}.test.tsx src/components/StatusBadge.test.tsx
Test Files 7 passed (7)
Tests 109 passed (109)
$ npx vitest run src/pages/{OwnersPage,AgentGroupsPage,TargetsPage,CertificatesPage,CertificateDetailPage,TeamsPage,RenewalPoliciesPage}.test.tsx src/pages/auth/{KeysPage,RoleDetailPage}.test.tsx
Test Files 9 passed (9)
Tests 52 passed (52)
(TargetsPage.test.tsx updated — the existing Delete confirm
test stubbed window.confirm; new test clicks the dialog's
destructive Delete button.)
$ npx vite build
✓ built in 2.89s
dist/assets/index-DZ1ZcRdP.js 1,110.61 kB (was 1,028.66 kB)
+82 KB / +26 KB gzipped from sonner + @headlessui + @floating-ui.
Bundle code-splitting is a separate phase (FE-M5).
Residual risks + follow-ups
============================
* 11 remaining window.confirm sites in audit-missed files. Phase 1
follow-up commit will sweep them with the same ConfirmDialog
pattern — mechanical work.
* The discard-unsaved-changes confirm in EditRoleModal (and 2
sibling modal sub-components) stays as window.confirm; treated
as a UX safety guardrail rather than a destructive-action
confirmation. Migrating to ConfirmDialog is fine but not
audit-priority.
* Tooltip + Combobox + Banner callsite migrations are explicit
per-page rolling work for subsequent phases — primitives
landed; per the audit prompt's "DO NOT" rule the migrations
don't sweep here.
* Optimistic-update wiring on the 5 priority mutations
(mark-notification-read, dismiss-discovery, archive-cert,
claim-discovered-cert, role-assignment) is staged for Phase 2
TQ-M3 per the prompt's explicit "DO NOT add new mutations to
the optimistic-update list beyond the 5 priority ones".
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import Banner from './Banner';
|
||||
|
||||
describe('Banner', () => {
|
||||
it('renders the children', () => {
|
||||
render(<Banner type="info">Operator note</Banner>);
|
||||
expect(screen.getByText('Operator note')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the optional title', () => {
|
||||
render(
|
||||
<Banner type="error" title="Save failed">
|
||||
Permission denied.
|
||||
</Banner>,
|
||||
);
|
||||
expect(screen.getByText('Save failed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Permission denied.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses role="alert" for error variant', () => {
|
||||
render(<Banner type="error">Permission denied.</Banner>);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses role="alert" for warning variant', () => {
|
||||
render(<Banner type="warning">Stale data.</Banner>);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses role="status" for success variant', () => {
|
||||
render(<Banner type="success">Saved.</Banner>);
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses role="status" for info variant', () => {
|
||||
render(<Banner type="info">Heads up.</Banner>);
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies variant-specific bg + border classes', () => {
|
||||
const { container } = render(<Banner type="error">err</Banner>);
|
||||
const root = container.firstChild as HTMLElement;
|
||||
expect(root.className).toContain('bg-red-50');
|
||||
expect(root.className).toContain('border-red-200');
|
||||
});
|
||||
|
||||
it('hides dismiss button when onDismiss not supplied', () => {
|
||||
render(<Banner type="info">No close affordance.</Banner>);
|
||||
expect(screen.queryByRole('button', { name: /dismiss/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('renders dismiss button + fires onDismiss when supplied', () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<Banner type="info" onDismiss={onDismiss}>
|
||||
Closable.
|
||||
</Banner>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
//
|
||||
// Banner — the certctl-themed alert / message banner primitive. Phase 1
|
||||
// closure for FE-M4 (no banner primitives; ~102 inline
|
||||
// bg-(red|amber|yellow)-50 copy-paste sites across the codebase).
|
||||
//
|
||||
// Four severity variants:
|
||||
// - error red surface, role="alert" — operator action required
|
||||
// - warning amber surface, role="alert" — risky-but-not-fatal
|
||||
// - success teal surface, role="status" — confirmation of last action
|
||||
// - info blue surface, role="status" — neutral context
|
||||
//
|
||||
// role="alert" on error + warning surfaces these to screen readers
|
||||
// immediately on render (aria-live=assertive equivalent). role="status"
|
||||
// on success + info surfaces them politely (aria-live=polite).
|
||||
//
|
||||
// Optional `onDismiss` adds a close button — useful for transient
|
||||
// banners. Persistent banners (e.g. "TLS bootstrap incomplete") omit
|
||||
// it so the operator can't paper over the underlying state.
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type BannerType = 'error' | 'warning' | 'success' | 'info';
|
||||
|
||||
export interface BannerProps {
|
||||
type: BannerType;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantStyles: Record<BannerType, string> = {
|
||||
error: 'bg-red-50 border-red-200 text-red-800',
|
||||
warning: 'bg-amber-50 border-amber-200 text-amber-800',
|
||||
success: 'bg-emerald-50 border-emerald-200 text-emerald-800',
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
};
|
||||
|
||||
const variantTitleStyles: Record<BannerType, string> = {
|
||||
error: 'text-red-900',
|
||||
warning: 'text-amber-900',
|
||||
success: 'text-emerald-900',
|
||||
info: 'text-blue-900',
|
||||
};
|
||||
|
||||
export default function Banner({
|
||||
type,
|
||||
title,
|
||||
children,
|
||||
onDismiss,
|
||||
className = '',
|
||||
}: BannerProps) {
|
||||
// role="alert" announces immediately; role="status" announces politely.
|
||||
// Use alert for actionable / dangerous; status for confirmation /
|
||||
// background context.
|
||||
const role = type === 'error' || type === 'warning' ? 'alert' : 'status';
|
||||
|
||||
return (
|
||||
<div
|
||||
role={role}
|
||||
className={`border-l-4 p-3 rounded ${variantStyles[type]} ${className}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 text-sm">
|
||||
{title && (
|
||||
<div className={`font-semibold mb-0.5 ${variantTitleStyles[type]}`}>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss"
|
||||
className={`text-xl leading-none opacity-60 hover:opacity-100 transition-opacity ${variantTitleStyles[type]}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import Combobox from './Combobox';
|
||||
|
||||
type Option = { id: string; name: string };
|
||||
|
||||
const OPTIONS: Option[] = [
|
||||
{ id: 'iss-vault', name: 'Vault PKI' },
|
||||
{ id: 'iss-acme', name: 'ACME (Let\'s Encrypt)' },
|
||||
{ id: 'iss-local', name: 'Local CA' },
|
||||
];
|
||||
|
||||
describe('Combobox', () => {
|
||||
it('renders the input', () => {
|
||||
render(
|
||||
<Combobox<Option>
|
||||
value={null}
|
||||
onChange={() => {}}
|
||||
options={OPTIONS}
|
||||
getKey={(o) => o.id}
|
||||
getLabel={(o) => o.name}
|
||||
placeholder="Pick issuer"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByPlaceholderText('Pick issuer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the selected value as the input display', () => {
|
||||
render(
|
||||
<Combobox<Option>
|
||||
value={OPTIONS[2]}
|
||||
onChange={() => {}}
|
||||
options={OPTIONS}
|
||||
getKey={(o) => o.id}
|
||||
getLabel={(o) => o.name}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByDisplayValue('Local CA')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters options as the operator types', () => {
|
||||
render(
|
||||
<Combobox<Option>
|
||||
value={null}
|
||||
onChange={() => {}}
|
||||
options={OPTIONS}
|
||||
getKey={(o) => o.id}
|
||||
getLabel={(o) => o.name}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'vault' } });
|
||||
expect(screen.getByText('Vault PKI')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Local CA')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("ACME (Let's Encrypt)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires onChange when the operator selects via keyboard', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Combobox<Option>
|
||||
value={null}
|
||||
onChange={onChange}
|
||||
options={OPTIONS}
|
||||
getKey={(o) => o.id}
|
||||
getLabel={(o) => o.name}
|
||||
/>,
|
||||
);
|
||||
// Open the listbox + filter to a single option, then press Enter.
|
||||
// Click-to-select on Headless UI requires the pointerdown sequence
|
||||
// which @testing-library/dom's fireEvent doesn't synthesize; the
|
||||
// keyboard path is the accessible-equivalent and is what screen
|
||||
// reader / keyboard-only operators use anyway.
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: 'Local' } });
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(onChange).toHaveBeenCalledWith(OPTIONS[2]);
|
||||
});
|
||||
|
||||
it('shows "No matches" when the filter excludes everything', () => {
|
||||
render(
|
||||
<Combobox<Option>
|
||||
value={null}
|
||||
onChange={() => {}}
|
||||
options={OPTIONS}
|
||||
getKey={(o) => o.id}
|
||||
getLabel={(o) => o.name}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: 'nonexistent' } });
|
||||
expect(screen.getByText('No matches.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
//
|
||||
// Combobox — Headless UI-backed typeahead select primitive. Phase 1
|
||||
// closure for UX-M4 (~53 native HTML <select> elements with no
|
||||
// typeahead surface). Migrating callsites is per-page rolling work
|
||||
// in subsequent PRs; Phase 1 builds the primitive.
|
||||
//
|
||||
// Compared with native <select>:
|
||||
// - typeahead filter narrows options as the operator types
|
||||
// - keyboard nav (Up/Down/Enter/Esc) handled by Headless UI
|
||||
// - aria-expanded / aria-activedescendant / aria-labelledby wired
|
||||
// for free
|
||||
// - styled to match the certctl .input + .card token palette
|
||||
//
|
||||
// Generic on the option value type T (string IDs are typical; arbitrary
|
||||
// objects work too — supply a `getKey` + `getLabel`).
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Combobox as HeadlessCombobox } from '@headlessui/react';
|
||||
|
||||
export interface ComboboxProps<T> {
|
||||
/** The currently-selected option, or null if none. */
|
||||
value: T | null;
|
||||
/** Fires when the operator picks an option. */
|
||||
onChange: (next: T | null) => void;
|
||||
/** Full options list — Combobox filters internally on typed query. */
|
||||
options: T[];
|
||||
/** Stable string key per option (used for React `key` + filter equality). */
|
||||
getKey: (option: T) => string;
|
||||
/** Human-readable label rendered in the input + dropdown row. */
|
||||
getLabel: (option: T) => string;
|
||||
/** Optional placeholder when no value is selected. */
|
||||
placeholder?: string;
|
||||
/** Optional `id` on the input element (label wiring). */
|
||||
inputId?: string;
|
||||
/** Disabled state. */
|
||||
disabled?: boolean;
|
||||
/** Extra className on the outer wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Combobox<T>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
getKey,
|
||||
getLabel,
|
||||
placeholder,
|
||||
inputId,
|
||||
disabled,
|
||||
className = '',
|
||||
}: ComboboxProps<T>) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
// Filter is local + case-insensitive substring against the label.
|
||||
// For >1000-option lists this should move to server-side; not Phase
|
||||
// 1's problem.
|
||||
const filtered = useMemo(() => {
|
||||
if (!query) return options;
|
||||
const needle = query.toLowerCase();
|
||||
return options.filter((o) => getLabel(o).toLowerCase().includes(needle));
|
||||
}, [options, query, getLabel]);
|
||||
|
||||
return (
|
||||
<HeadlessCombobox
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className={`relative ${className}`}>
|
||||
<HeadlessCombobox.Input
|
||||
id={inputId}
|
||||
className="input w-full"
|
||||
placeholder={placeholder}
|
||||
displayValue={(o: T | null) => (o ? getLabel(o) : '')}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<HeadlessCombobox.Options
|
||||
className="absolute z-30 mt-1 max-h-60 w-full overflow-auto rounded border border-surface-border bg-surface shadow-lg focus:outline-none"
|
||||
>
|
||||
{filtered.length === 0 && query !== '' && (
|
||||
<div className="px-3 py-2 text-sm text-ink-faint">
|
||||
No matches.
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((option) => (
|
||||
<HeadlessCombobox.Option
|
||||
key={getKey(option)}
|
||||
value={option}
|
||||
className={({ active, selected }) =>
|
||||
`cursor-pointer px-3 py-2 text-sm ${
|
||||
active ? 'bg-brand-50 text-brand-700' : 'text-ink'
|
||||
} ${selected ? 'font-semibold' : ''}`
|
||||
}
|
||||
>
|
||||
{getLabel(option)}
|
||||
</HeadlessCombobox.Option>
|
||||
))}
|
||||
</HeadlessCombobox.Options>
|
||||
</div>
|
||||
</HeadlessCombobox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
//
|
||||
// Smoke + behavior tests for ConfirmDialog. The primitive replaces
|
||||
// window.confirm(); the test suite asserts the contract:
|
||||
// - hidden when open=false
|
||||
// - title + message render
|
||||
// - ESC + backdrop click + cancel button → onCancel
|
||||
// - confirm button → onConfirm
|
||||
// - typedConfirmation gates the confirm button until the exact string
|
||||
// is typed
|
||||
// - destructive=true uses the btn-danger styling
|
||||
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
|
||||
describe('ConfirmDialog', () => {
|
||||
it('does not render when open=false', () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open={false}
|
||||
title="Archive cert"
|
||||
message="Cannot be undone."
|
||||
onConfirm={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Archive cert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders title + message when open=true', () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Archive cert"
|
||||
message="Cannot be undone."
|
||||
onConfirm={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Archive cert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cannot be undone.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires onConfirm when confirm button clicked', () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete owner"
|
||||
message="Bob will be removed."
|
||||
onConfirm={onConfirm}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /confirm/i }));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fires onCancel when cancel button clicked', () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete owner"
|
||||
message="Bob will be removed."
|
||||
onConfirm={() => {}}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('disables confirm button until typedConfirmation matches', () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Archive cert"
|
||||
message="Type DELETE to confirm."
|
||||
typedConfirmation="DELETE"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
const confirmBtn = screen.getByRole('button', { name: /confirm/i });
|
||||
expect(confirmBtn).toBeDisabled();
|
||||
|
||||
const input = screen.getByLabelText(/Type/i);
|
||||
fireEvent.change(input, { target: { value: 'wrong' } });
|
||||
expect(confirmBtn).toBeDisabled();
|
||||
|
||||
fireEvent.change(input, { target: { value: 'DELETE' } });
|
||||
expect(confirmBtn).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(confirmBtn);
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses btn-danger styling when destructive=true', () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Revoke cert"
|
||||
message="Cannot be undone."
|
||||
destructive
|
||||
onConfirm={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
const confirmBtn = screen.getByRole('button', { name: /confirm/i });
|
||||
expect(confirmBtn.className).toContain('btn-danger');
|
||||
});
|
||||
|
||||
it('honours custom confirmLabel + cancelLabel', () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Archive cert"
|
||||
message="Are you sure?"
|
||||
confirmLabel="Yes, archive"
|
||||
cancelLabel="No, go back"
|
||||
onConfirm={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Yes, archive' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'No, go back' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
//
|
||||
// ConfirmDialog — the certctl-themed replacement for window.confirm().
|
||||
// Phase 1 closure for UX-H2 (destructive actions use window.confirm).
|
||||
//
|
||||
// Built on Headless UI's <Dialog>, which gives us:
|
||||
// - automatic focus trap (Tab/Shift-Tab stays inside the modal)
|
||||
// - automatic ESC-to-close (we wire onCancel to it)
|
||||
// - automatic backdrop-click-to-close (we wire onCancel to it)
|
||||
// - role="dialog" + aria-modal="true" on the panel
|
||||
// - aria-labelledby on the title node, aria-describedby on the body
|
||||
// - <Transition> handles enter/exit; respects prefers-reduced-motion
|
||||
// transparently via the @media block in src/index.css.
|
||||
//
|
||||
// Optional `typedConfirmation` raises the friction for the most
|
||||
// irreversible actions. Passing `typedConfirmation: "delete"` requires
|
||||
// the operator to literally type the string "delete" into a field
|
||||
// before the confirm button enables. Reserve it for the worst-case
|
||||
// actions: archive-this-certificate, delete-root-CA, etc.
|
||||
//
|
||||
// Visual posture: destructive variant uses red surface tints + a red
|
||||
// confirm button matching .btn-danger. Non-destructive uses the
|
||||
// default brand-teal confirm button.
|
||||
|
||||
import { Fragment, useState, useEffect, useRef } from 'react';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
/** Controls visibility. Parent owns the boolean. */
|
||||
open: boolean;
|
||||
/** Title shown at the top of the dialog. Concise: "Archive certificate". */
|
||||
title: string;
|
||||
/** Body copy. Plain text recommended; spell out consequences. */
|
||||
message: string;
|
||||
/** Label for the confirm button. Defaults to "Confirm". */
|
||||
confirmLabel?: string;
|
||||
/** Label for the cancel button. Defaults to "Cancel". */
|
||||
cancelLabel?: string;
|
||||
/** When true, confirm button uses .btn-danger styling. */
|
||||
destructive?: boolean;
|
||||
/**
|
||||
* When set, the operator must type this exact string before the
|
||||
* confirm button enables. Use for the most irreversible actions
|
||||
* (archive certificate, delete CA, etc.).
|
||||
*/
|
||||
typedConfirmation?: string;
|
||||
/** Fires when the confirm button is clicked. Parent closes the dialog. */
|
||||
onConfirm: () => void;
|
||||
/** Fires on ESC, backdrop click, or cancel button. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
destructive = false,
|
||||
typedConfirmation,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
const [typedValue, setTypedValue] = useState('');
|
||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Reset typed-confirmation state every time the dialog closes/reopens.
|
||||
// Without this, a previous successful confirmation leaves the field
|
||||
// pre-filled on the next confirmation prompt — that's a footgun.
|
||||
useEffect(() => {
|
||||
if (open) setTypedValue('');
|
||||
}, [open]);
|
||||
|
||||
const typedOK = !typedConfirmation || typedValue === typedConfirmation;
|
||||
const confirmDisabled = !typedOK;
|
||||
|
||||
const confirmClass = destructive
|
||||
? 'btn btn-danger'
|
||||
: 'btn btn-primary';
|
||||
|
||||
return (
|
||||
<Transition show={open} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-50"
|
||||
onClose={onCancel}
|
||||
initialFocus={cancelButtonRef}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-150"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/40" aria-hidden="true" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-150"
|
||||
enterFrom="opacity-0 translate-y-2 scale-95"
|
||||
enterTo="opacity-100 translate-y-0 scale-100"
|
||||
leave="ease-in duration-100"
|
||||
leaveFrom="opacity-100 translate-y-0 scale-100"
|
||||
leaveTo="opacity-0 translate-y-2 scale-95"
|
||||
>
|
||||
<Dialog.Panel
|
||||
className={`w-full max-w-md transform overflow-hidden rounded-lg bg-surface shadow-xl border ${
|
||||
destructive ? 'border-red-200' : 'border-surface-border'
|
||||
} p-6`}
|
||||
>
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-semibold text-ink"
|
||||
>
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description
|
||||
as="p"
|
||||
className="mt-2 text-sm text-ink-muted"
|
||||
>
|
||||
{message}
|
||||
</Dialog.Description>
|
||||
|
||||
{typedConfirmation && (
|
||||
<div className="mt-4">
|
||||
<label
|
||||
htmlFor="confirm-typed-input"
|
||||
className="block text-xs font-medium text-ink-muted mb-1"
|
||||
>
|
||||
Type{' '}
|
||||
<code className="text-ink font-mono">
|
||||
{typedConfirmation}
|
||||
</code>{' '}
|
||||
to enable confirmation:
|
||||
</label>
|
||||
<input
|
||||
id="confirm-typed-input"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
value={typedValue}
|
||||
onChange={(e) => setTypedValue(e.target.value)}
|
||||
className="input w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
ref={cancelButtonRef}
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={confirmClass}
|
||||
onClick={onConfirm}
|
||||
disabled={confirmDisabled}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -28,6 +30,14 @@ interface DataTableProps<T> {
|
||||
data: T[];
|
||||
onRowClick?: (item: T) => void;
|
||||
emptyMessage?: string;
|
||||
/**
|
||||
* UX-M3 / Phase 1: rich empty-state slot. Pass an <EmptyState />
|
||||
* component (or any ReactNode) here when the page wants a CTA-driven
|
||||
* first-run experience instead of the bare emptyMessage string. The
|
||||
* existing `emptyMessage` prop is preserved for backward compat with
|
||||
* the ~18 list-page call sites that pass a simple string.
|
||||
*/
|
||||
emptyState?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
keyField?: string;
|
||||
selectable?: boolean;
|
||||
@@ -36,7 +46,7 @@ interface DataTableProps<T> {
|
||||
pagination?: PaginationProps;
|
||||
}
|
||||
|
||||
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps<T>) {
|
||||
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, emptyState, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps<T>) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-ink-muted">
|
||||
@@ -50,6 +60,12 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
|
||||
}
|
||||
|
||||
if (!data.length) {
|
||||
// UX-M3 / Phase 1: prefer the rich <EmptyState /> slot when supplied;
|
||||
// fall back to the legacy string render so existing call sites with
|
||||
// emptyMessage="…" stay unchanged.
|
||||
if (emptyState) {
|
||||
return <>{emptyState}</>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-ink-faint">
|
||||
{emptyMessage || 'No data found'}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import EmptyState from './EmptyState';
|
||||
|
||||
describe('EmptyState', () => {
|
||||
it('renders the title', () => {
|
||||
render(<EmptyState title="No certificates yet" />);
|
||||
expect(screen.getByText('No certificates yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description when provided', () => {
|
||||
render(
|
||||
<EmptyState
|
||||
title="No certificates yet"
|
||||
description="Issue your first certificate to get started."
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Issue your first certificate to get started.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icon slot when provided', () => {
|
||||
render(
|
||||
<EmptyState
|
||||
icon={<span data-testid="empty-icon">📜</span>}
|
||||
title="No certificates"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('empty-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders primaryAction button and fires its onClick', () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<EmptyState
|
||||
title="No certificates"
|
||||
primaryAction={{ label: 'Issue certificate', onClick }}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Issue certificate' }));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders secondaryAction button and fires its onClick', () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<EmptyState
|
||||
title="No certificates"
|
||||
secondaryAction={{ label: 'Read docs', onClick }}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Read docs' }));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders both actions side-by-side', () => {
|
||||
render(
|
||||
<EmptyState
|
||||
title="No certificates"
|
||||
primaryAction={{ label: 'Issue', onClick: () => {} }}
|
||||
secondaryAction={{ label: 'Connect issuer', onClick: () => {} }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Issue' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Connect issuer' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes role="status" for screen readers', () => {
|
||||
render(<EmptyState title="No data" />);
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
//
|
||||
// EmptyState — the certctl-themed empty-state primitive. Phase 1
|
||||
// closure for UX-M3 (no <EmptyState> primitive; DataTable shows a bare
|
||||
// 'No data found' string).
|
||||
//
|
||||
// Two render paths:
|
||||
// 1) `<EmptyState title="..." description="..." />` — minimum
|
||||
// acceptable empty state. Title is required (the user must
|
||||
// understand what's missing); description + actions are optional.
|
||||
// 2) `<EmptyState icon={<Icon />} title="..." description="..."
|
||||
// primaryAction={{ label, onClick }} secondaryAction={...} />` —
|
||||
// first-run CTA shape. Renders icon at the top, title in the
|
||||
// middle, two action buttons at the bottom. Use this on list pages
|
||||
// that an operator might hit on their first visit ("No certs yet —
|
||||
// [Issue first certificate] [Connect an issuer]").
|
||||
//
|
||||
// Composition with DataTable: DataTable accepts `emptyState?: ReactNode`
|
||||
// (added alongside the existing `emptyMessage?: string` for backward
|
||||
// compat) so list pages can pass either a string or a full <EmptyState />
|
||||
// component.
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface EmptyStateAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
/** Optional icon at the top. Pass any ReactNode (lucide / SVG / emoji). */
|
||||
icon?: ReactNode;
|
||||
/** Required headline. Keep short: "No certificates yet". */
|
||||
title: string;
|
||||
/** Optional sub-copy. One sentence explaining the empty condition. */
|
||||
description?: string;
|
||||
/** Optional primary CTA. Renders as .btn-primary. */
|
||||
primaryAction?: EmptyStateAction;
|
||||
/** Optional secondary CTA. Renders as .btn-outline alongside primary. */
|
||||
secondaryAction?: EmptyStateAction;
|
||||
/** Override default centering / padding when nested inside a card. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={
|
||||
className ||
|
||||
'flex flex-col items-center justify-center text-center py-16 px-6'
|
||||
}
|
||||
>
|
||||
{icon && (
|
||||
<div className="mb-4 text-ink-faint" aria-hidden="true">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-base font-semibold text-ink mb-1">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-ink-muted max-w-md mb-4">{description}</p>
|
||||
)}
|
||||
{(primaryAction || secondaryAction) && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{primaryAction && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={primaryAction.onClick}
|
||||
>
|
||||
{primaryAction.label}
|
||||
</button>
|
||||
)}
|
||||
{secondaryAction && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={secondaryAction.onClick}
|
||||
>
|
||||
{secondaryAction.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import StatusBadge from './StatusBadge';
|
||||
import StatusBadge, { statusDisplay, titleCase } from './StatusBadge';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// D-1 master — StatusBadge enum-coverage contract
|
||||
@@ -118,13 +118,111 @@ describe('StatusBadge — enum-coverage contract (D-1 master)', () => {
|
||||
expect(container.querySelector('span')!.className).toContain('badge-warning');
|
||||
});
|
||||
|
||||
// Unknown statuses fall through to neutral. The string is still
|
||||
// displayed verbatim so an operator can see "what is this?" rather
|
||||
// than nothing at all.
|
||||
it('unknown status string renders as neutral but preserves the label text', () => {
|
||||
// Unknown statuses fall through to neutral. The label is humanised
|
||||
// via the titleCase() helper (UX-H5) so the operator sees readable
|
||||
// text rather than the raw enum key — "Some future status" instead
|
||||
// of "SomeFutureStatus".
|
||||
it('unknown status string renders as neutral with titleCase fallback', () => {
|
||||
const { container } = render(<StatusBadge status="SomeFutureStatus" />);
|
||||
const span = container.querySelector('span');
|
||||
expect(span!.className).toBe('badge badge-neutral');
|
||||
expect(span!.textContent).toBe('SomeFutureStatus');
|
||||
expect(span!.textContent).toBe('Some future status');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// UX-H5 master — StatusBadge display-string contract (Phase 1, 2026-05-14)
|
||||
//
|
||||
// The audit finding: pre-Phase-1, StatusBadge rendered raw Go enum keys
|
||||
// — operators saw "RenewalInProgress" / "AwaitingCSR" / "cert_mismatch"
|
||||
// / "dead" verbatim. Phase 1 adds a statusDisplay map next to
|
||||
// statusStyles; this suite pins the byte-exact display string for every
|
||||
// wire key.
|
||||
// -----------------------------------------------------------------------------
|
||||
describe('StatusBadge — display-string contract (UX-H5)', () => {
|
||||
// Every wire key in the colour map MUST have a display-string entry
|
||||
// and the entry MUST be non-empty. Missing entries fall back to the
|
||||
// titleCase() helper, but having an explicit entry in statusDisplay
|
||||
// is the preferred path (lets us pick the cleanest sentence-case
|
||||
// phrasing, with terms like "Awaiting CSR" capitalised correctly
|
||||
// where titleCase would yield "Awaiting csr").
|
||||
const EXPECTED_DISPLAY: Array<[string, string]> = [
|
||||
// Certificate statuses
|
||||
['Active', 'Active'],
|
||||
['Expiring', 'Expiring soon'],
|
||||
['Expired', 'Expired'],
|
||||
['RenewalInProgress', 'Renewal in progress'],
|
||||
['Archived', 'Archived'],
|
||||
['Revoked', 'Revoked'],
|
||||
// Job statuses
|
||||
['Pending', 'Pending'],
|
||||
['AwaitingCSR', 'Awaiting CSR'],
|
||||
['AwaitingApproval', 'Awaiting approval'],
|
||||
['Running', 'Running'],
|
||||
['Completed', 'Completed'],
|
||||
['Failed', 'Failed'],
|
||||
['Cancelled', 'Cancelled'],
|
||||
// Agent statuses
|
||||
['Online', 'Online'],
|
||||
['Offline', 'Offline'],
|
||||
['Degraded', 'Degraded'],
|
||||
// Discovery statuses
|
||||
['Unmanaged', 'Unmanaged'],
|
||||
['Managed', 'Managed'],
|
||||
['Dismissed', 'Dismissed'],
|
||||
// Frontend-synthesized issuer statuses
|
||||
['Enabled', 'Enabled'],
|
||||
['Disabled', 'Disabled'],
|
||||
// Notification statuses (lowercase wire values)
|
||||
['sent', 'Sent'],
|
||||
['pending', 'Pending'],
|
||||
['failed', 'Failed'],
|
||||
['dead', 'Dead-lettered'],
|
||||
['read', 'Read'],
|
||||
// Health check statuses (lowercase + snake_case)
|
||||
['healthy', 'Healthy'],
|
||||
['degraded', 'Degraded'],
|
||||
['down', 'Down'],
|
||||
['cert_mismatch', 'Certificate mismatch'],
|
||||
['unknown', 'Unknown'],
|
||||
];
|
||||
|
||||
it.each(EXPECTED_DISPLAY)(
|
||||
"wire key '%s' renders display string '%s'",
|
||||
(wire, expected) => {
|
||||
// First — verify the statusDisplay map carries the entry verbatim.
|
||||
expect(statusDisplay[wire]).toBe(expected);
|
||||
// Then — verify the rendered <span>'s textContent matches.
|
||||
const { container } = render(<StatusBadge status={wire} />);
|
||||
expect(container.querySelector('span')!.textContent).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('every wire key in statusStyles has a matching statusDisplay entry', () => {
|
||||
// Parity check — re-deriving the styles key set isn't possible at
|
||||
// runtime without re-importing it, but we can probe a known sample
|
||||
// and pin: if a future PR adds a new style entry without a display
|
||||
// entry, the EXPECTED_DISPLAY list above will mismatch.
|
||||
expect(Object.keys(statusDisplay).length).toBeGreaterThanOrEqual(
|
||||
EXPECTED_DISPLAY.length,
|
||||
);
|
||||
});
|
||||
|
||||
describe('titleCase() helper — fallback for unmapped keys', () => {
|
||||
it('humanises PascalCase', () => {
|
||||
expect(titleCase('RenewalInProgress')).toBe('Renewal in progress');
|
||||
});
|
||||
it('humanises snake_case', () => {
|
||||
expect(titleCase('cert_mismatch')).toBe('Cert mismatch');
|
||||
});
|
||||
it('handles single-word lowercase', () => {
|
||||
expect(titleCase('pending')).toBe('Pending');
|
||||
});
|
||||
it('handles single-word PascalCase', () => {
|
||||
expect(titleCase('Active')).toBe('Active');
|
||||
});
|
||||
it('handles empty string defensively', () => {
|
||||
expect(titleCase('')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
// the Go side; StatusBadge.test.tsx walks every value and will go red
|
||||
// before users see a default-grey "what is happening?" badge.
|
||||
//
|
||||
// UX-H5 closure (Phase 1, 2026-05-14): we now render a human display
|
||||
// string rather than the raw enum key. The wire keys stay byte-
|
||||
// identical to the Go-side enums (per the D-1 closure comment above) —
|
||||
// only the rendered text changes. PascalCase + snake_case +
|
||||
// lowercase enums map to spaced sentence-case ("Renewal in progress",
|
||||
// "Awaiting CSR", "Dead-lettered", "Certificate mismatch"). Unmapped
|
||||
// keys fall through to a titleCase helper that lower-bounds the
|
||||
// readability even when a new Go-side enum lands before the frontend
|
||||
// catches up.
|
||||
//
|
||||
// D-1 master closure (cat-d-359e92c20cbf, cat-d-9f4c8e4a91f1,
|
||||
// cat-d-1447e04732e7, cat-f-cert_detail_page_key_render_fallback,
|
||||
// cat-f-ae0d06b6588f) fixed the pre-master drift:
|
||||
@@ -74,7 +84,73 @@ const statusStyles: Record<string, string> = {
|
||||
unknown: 'badge-neutral',
|
||||
};
|
||||
|
||||
// statusDisplay — human-facing text for each wire key. UX-H5 closure.
|
||||
// Keys MUST stay byte-identical to statusStyles above (which is byte-
|
||||
// identical to the Go enums). When a key here is missing, the
|
||||
// titleCase fallback below renders something readable rather than
|
||||
// the raw enum key.
|
||||
const statusDisplay: Record<string, string> = {
|
||||
// Certificate statuses
|
||||
Active: 'Active',
|
||||
Expiring: 'Expiring soon',
|
||||
Expired: 'Expired',
|
||||
RenewalInProgress: 'Renewal in progress',
|
||||
Archived: 'Archived',
|
||||
Revoked: 'Revoked',
|
||||
// Job statuses
|
||||
Pending: 'Pending',
|
||||
AwaitingCSR: 'Awaiting CSR',
|
||||
AwaitingApproval: 'Awaiting approval',
|
||||
Running: 'Running',
|
||||
Completed: 'Completed',
|
||||
Failed: 'Failed',
|
||||
Cancelled: 'Cancelled',
|
||||
// Agent statuses
|
||||
Online: 'Online',
|
||||
Offline: 'Offline',
|
||||
Degraded: 'Degraded',
|
||||
// Discovery statuses
|
||||
Unmanaged: 'Unmanaged',
|
||||
Managed: 'Managed',
|
||||
Dismissed: 'Dismissed',
|
||||
// Issuer statuses (frontend-synthesized)
|
||||
Enabled: 'Enabled',
|
||||
Disabled: 'Disabled',
|
||||
// Notification statuses
|
||||
sent: 'Sent',
|
||||
pending: 'Pending',
|
||||
failed: 'Failed',
|
||||
dead: 'Dead-lettered',
|
||||
read: 'Read',
|
||||
// Health check statuses
|
||||
healthy: 'Healthy',
|
||||
degraded: 'Degraded',
|
||||
down: 'Down',
|
||||
cert_mismatch: 'Certificate mismatch',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
|
||||
// titleCase — best-effort humanizer for wire keys not in statusDisplay.
|
||||
// Handles PascalCase ("RenewalInProgress" → "Renewal in progress") and
|
||||
// snake_case ("cert_mismatch" → "Cert mismatch"). The render-time fallback;
|
||||
// adding a proper entry to statusDisplay above is the preferred path.
|
||||
function titleCase(s: string): string {
|
||||
if (!s) return s;
|
||||
// snake_case → space-separated lower
|
||||
let out = s.replace(/_/g, ' ');
|
||||
// PascalCase / camelCase → space before capitals (but not the first)
|
||||
out = out.replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||
// Lowercase everything, then capitalize the first character.
|
||||
out = out.toLowerCase();
|
||||
return out.charAt(0).toUpperCase() + out.slice(1);
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status }: { status: string }) {
|
||||
const cls = statusStyles[status] || 'badge-neutral';
|
||||
return <span className={`badge ${cls}`}>{status}</span>;
|
||||
const display = statusDisplay[status] ?? titleCase(status);
|
||||
return <span className={`badge ${cls}`}>{display}</span>;
|
||||
}
|
||||
|
||||
// Exported for the StatusBadge.test.tsx suite — pinning the byte-exact
|
||||
// display strings for every wire key in one place.
|
||||
export { statusStyles, statusDisplay, titleCase };
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
//
|
||||
// Smoke-test the Toaster wrapper. Sonner has its own deep test suite;
|
||||
// we just pin (a) the wrapper renders without crashing, (b) the
|
||||
// Sonner <Toaster /> root lands in the DOM with our position prop, and
|
||||
// (c) toast.success / toast.error reach the renderer.
|
||||
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { toast } from 'sonner';
|
||||
import Toaster from './Toaster';
|
||||
|
||||
describe('Toaster', () => {
|
||||
it('renders the Sonner root without crashing', () => {
|
||||
render(<Toaster />);
|
||||
// Sonner mounts a section[aria-label="Notifications <kbd>"] container
|
||||
// — the label includes Sonner's expand-shortcut hint (e.g. "alt+T").
|
||||
// Match the prefix only.
|
||||
expect(screen.getByLabelText(/Notifications/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('forwards toast.success() to the visible queue', async () => {
|
||||
render(<Toaster />);
|
||||
act(() => {
|
||||
toast.success('Profile saved');
|
||||
});
|
||||
// Sonner debounces render slightly; flush via findByText.
|
||||
expect(await screen.findByText('Profile saved')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('forwards toast.error() to the visible queue', async () => {
|
||||
render(<Toaster />);
|
||||
act(() => {
|
||||
toast.error('Save failed: not authorized');
|
||||
});
|
||||
expect(
|
||||
await screen.findByText('Save failed: not authorized'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
//
|
||||
// Toaster — the certctl-themed Sonner wrapper. Phase 1 closure for
|
||||
// UX-H3 (no toast / snackbar system) per the frontend-design-audit.
|
||||
//
|
||||
// Mount once near the top of <main.tsx>'s React tree (next to
|
||||
// QueryClientProvider). Inside any component, import { toast } from
|
||||
// "sonner" and call toast.success(…) / toast.error(…) / toast.info(…) /
|
||||
// toast.warning(…). Sonner handles the singleton queue, focus + ARIA
|
||||
// (role="status" / role="alert"), enter/exit animation, swipe-to-
|
||||
// dismiss, and respects prefers-reduced-motion automatically.
|
||||
//
|
||||
// We surface a thin wrapper rather than the bare <Toaster /> so the
|
||||
// default position + visual config lives in one place. Pages must NOT
|
||||
// mount their own Toaster instances — Sonner asserts at runtime if
|
||||
// multiple are mounted, but the failure mode is "toasts duplicate or
|
||||
// disappear silently" which is hard to debug. Single import discipline.
|
||||
//
|
||||
// Visual position: top-right. Operators are paginated-table-heavy;
|
||||
// top-right keeps the toast away from row-action click targets at the
|
||||
// bottom of the list. richColors gives us the per-severity background
|
||||
// fills (success teal / error red / warning amber / info blue) that
|
||||
// match the existing .badge-* color tier.
|
||||
|
||||
import { Toaster as SonnerToaster } from 'sonner';
|
||||
|
||||
export default function Toaster() {
|
||||
return (
|
||||
<SonnerToaster
|
||||
position="top-right"
|
||||
richColors
|
||||
closeButton
|
||||
// 4s default for non-action toasts; persistent for error toasts
|
||||
// with action (set per-call via toast.error(msg, { duration: ... })).
|
||||
duration={4000}
|
||||
// visibleToasts: cap stack so a runaway error loop doesn't drown
|
||||
// the screen. 5 is the Sonner default; pinning it explicitly so
|
||||
// the choice is documented.
|
||||
visibleToasts={5}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
//
|
||||
// Tooltip smoke + interaction tests. Floating-UI's positioning math
|
||||
// requires a real browser layout engine; we just assert the wiring:
|
||||
// - children render at rest (no tooltip)
|
||||
// - focus reveals the tooltip body in the portal
|
||||
// - escape dismisses
|
||||
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
describe('Tooltip', () => {
|
||||
it('renders the trigger at rest with no tooltip visible', () => {
|
||||
render(
|
||||
<Tooltip content="Hint">
|
||||
<button>Hover me</button>
|
||||
</Tooltip>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Hover me' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Hint')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reveals tooltip body on focus', () => {
|
||||
render(
|
||||
<Tooltip content="Hint visible">
|
||||
<button>Focusable trigger</button>
|
||||
</Tooltip>,
|
||||
);
|
||||
const trigger = screen.getByRole('button', { name: 'Focusable trigger' });
|
||||
fireEvent.focus(trigger);
|
||||
// FloatingPortal renders into document.body; queryable.
|
||||
expect(screen.getByText('Hint visible')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dismisses on Escape after focus-open', () => {
|
||||
render(
|
||||
<Tooltip content="Press escape">
|
||||
<button>Focusable</button>
|
||||
</Tooltip>,
|
||||
);
|
||||
const trigger = screen.getByRole('button', { name: 'Focusable' });
|
||||
fireEvent.focus(trigger);
|
||||
expect(screen.getByText('Press escape')).toBeInTheDocument();
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByText('Press escape')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
//
|
||||
// Tooltip — Floating-UI-backed replacement for the ~103 native title=
|
||||
// attributes. Phase 1 builds the primitive; migrating the 103 callsites
|
||||
// is per-page rolling work that happens in subsequent PRs (per the
|
||||
// audit prompt's explicit "DO NOT" on one-mega-PR sweeps).
|
||||
//
|
||||
// Why Floating-UI: native title= renders poorly on mobile + has no
|
||||
// reliable show/hide timing, no visual styling, no positioning around
|
||||
// the edges of the viewport, and (most importantly) zero a11y story
|
||||
// beyond the browser's default tooltip — which screen readers
|
||||
// inconsistently surface. Floating-UI gives us:
|
||||
// - middleware-driven positioning (auto-flip, shift, offset)
|
||||
// - hover + focus triggers (with `useFocus` + `useHover`)
|
||||
// - aria-describedby wiring via `useRole`
|
||||
// - dismissable via ESC
|
||||
//
|
||||
// Usage:
|
||||
// <Tooltip content="Some hint">
|
||||
// <button>Hover me</button>
|
||||
// </Tooltip>
|
||||
//
|
||||
// Children must be a single element capable of accepting a ref. For
|
||||
// non-ref-forwardable children (e.g. plain text), wrap in a span.
|
||||
|
||||
import { useState, cloneElement, isValidElement } from 'react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import {
|
||||
useFloating,
|
||||
useHover,
|
||||
useFocus,
|
||||
useDismiss,
|
||||
useRole,
|
||||
useInteractions,
|
||||
flip,
|
||||
shift,
|
||||
offset,
|
||||
autoUpdate,
|
||||
FloatingPortal,
|
||||
} from '@floating-ui/react';
|
||||
|
||||
export interface TooltipProps {
|
||||
/** Tooltip body — usually a short string; ReactNode is allowed for icons. */
|
||||
content: ReactNode;
|
||||
/** Single child element that receives the ref + ARIA wiring. */
|
||||
children: ReactElement;
|
||||
/** Preferred placement; Floating-UI will auto-flip if viewport-clamped. */
|
||||
placement?: 'top' | 'right' | 'bottom' | 'left';
|
||||
/** Pixel offset between the trigger and the tooltip. Default 6. */
|
||||
offsetPx?: number;
|
||||
}
|
||||
|
||||
export default function Tooltip({
|
||||
content,
|
||||
children,
|
||||
placement = 'top',
|
||||
offsetPx = 6,
|
||||
}: TooltipProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
placement,
|
||||
middleware: [offset(offsetPx), flip(), shift({ padding: 8 })],
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
|
||||
const hover = useHover(context, { move: false, delay: { open: 200, close: 0 } });
|
||||
const focus = useFocus(context);
|
||||
const dismiss = useDismiss(context);
|
||||
const role = useRole(context, { role: 'tooltip' });
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
hover,
|
||||
focus,
|
||||
dismiss,
|
||||
role,
|
||||
]);
|
||||
|
||||
if (!isValidElement(children)) {
|
||||
// Defensive: render the child verbatim; Tooltip wiring is skipped.
|
||||
// Console-warn so the misuse is visible during dev.
|
||||
if (typeof console !== 'undefined') {
|
||||
console.warn(
|
||||
'<Tooltip> requires a single React element child; got:',
|
||||
children,
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Merge the ref + interaction props onto the child. cloneElement keeps
|
||||
// the original child's type + own props; we layer ours on top.
|
||||
const triggerProps = getReferenceProps();
|
||||
const child = cloneElement(
|
||||
children as ReactElement<Record<string, unknown>>,
|
||||
{
|
||||
ref: refs.setReference,
|
||||
...triggerProps,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{child}
|
||||
{open && content && (
|
||||
<FloatingPortal>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
{...getFloatingProps()}
|
||||
className="z-50 max-w-xs rounded bg-ink/95 text-white text-xs px-2 py-1 shadow-lg pointer-events-none"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user