fix(catalyst-ui): remove status banners from Apps page; surface as global notifications (closes #475) (#487)
Founder #475 — the "Provisioning failed" / "Cancel & Wipe" / "Per-component install monitoring is unavailable" banners pollute the Apps page. They render above the apps grid, forcing operators onto the Apps tab to read terminal deployment status, and crowd out the actual catalog. Replaces the inline banners with a global toast surface: • new shared/ui/notifications.tsx — NotificationProvider + useNotifications() seam. Bottom-right stacked tray, fixed positioning so it's visible on every tab (Apps / Jobs / Dashboard / Cloud / Users). Toasts replace in-place by id so a deployment-failure update edits the existing card rather than stacking duplicates. • RootLayout — mounts NotificationProvider once at the top of the tree. • AppsPage — strips FailureCard + Phase1UnavailableBanner. Two new useEffects mirror the same copy + the same retry / wipe / back-to-wizard actions through notify(). WipeDeploymentModal stays page-scoped so the toast action can flip it open. • useDeploymentEvents — wraps `retry` in useCallback so the AppsPage notification effect doesn't re-fire every render (would otherwise loop notify → re-render → notify). Vitest: • 8 cases on the notification surface (push, replace-by-id, dismiss, role=alert vs role=status, action dismissOnClick semantics, provider guard). • 2 new cases on AppsPage that gate any future regression: main element has zero role="alert" / role="status" children on first paint, and the legacy banner test ids never render. Acceptance vs founder ask: • Apps page in failed state renders ONLY apps grid + tabs + search box. • Same status content fires as a bottom-right toast with Retry stream / Cancel & Wipe / Back to wizard actions. • Notifications stay visible across Apps / Jobs / Dashboard / Cloud / Users tabs because the tray is mounted in RootLayout above Outlet. Co-authored-by: hatiyildiz <hatiyildiz@noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
62e03ae129
commit
e6663f169d
@ -1,10 +1,13 @@
|
||||
import { Outlet } from '@tanstack/react-router'
|
||||
import { TooltipProvider } from '@/shared/ui/tooltip'
|
||||
import { NotificationProvider } from '@/shared/ui/notifications'
|
||||
|
||||
export function RootLayout() {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Outlet />
|
||||
<NotificationProvider>
|
||||
<Outlet />
|
||||
</NotificationProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -24,9 +24,16 @@ import {
|
||||
import { AppsPage } from './AppsPage'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model'
|
||||
import { NotificationProvider } from '@/shared/ui/notifications'
|
||||
|
||||
function renderProvision(deploymentId: string) {
|
||||
const rootRoute = createRootRoute({ component: () => <Outlet /> })
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<NotificationProvider>
|
||||
<Outlet />
|
||||
</NotificationProvider>
|
||||
),
|
||||
})
|
||||
const provisionRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/provision/$deploymentId',
|
||||
@ -116,6 +123,29 @@ describe('AppsPage — tabs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppsPage — banner pollution gate (founder #475)', () => {
|
||||
it('renders no role="alert" or role="status" inside main on first paint', async () => {
|
||||
renderProvision('d-1')
|
||||
// Wait for the page to settle.
|
||||
await screen.findByText('Applications')
|
||||
// The Apps page main surface must be free of inline banners. Toasts
|
||||
// (which would render inside the global tray, not main) are allowed.
|
||||
const main = document.querySelector('main')
|
||||
expect(main).toBeTruthy()
|
||||
expect(main!.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(main!.querySelector('[role="status"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not import or render the legacy FailureCard test ids', async () => {
|
||||
renderProvision('d-1')
|
||||
await screen.findByText('Applications')
|
||||
// These test ids were the inline banner anchors. They must not paint
|
||||
// anywhere on the Apps surface; the failure UX moves to global toasts.
|
||||
expect(screen.queryByTestId('sov-failure-card')).toBeNull()
|
||||
expect(screen.queryByTestId('sov-phase1-unavailable-banner')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppsPage — card grid', () => {
|
||||
it('renders one .app-card per Application from first paint', async () => {
|
||||
renderProvision('d-1')
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
* is no hand-maintained id list in this file.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams, useRouter, Link } from '@tanstack/react-router'
|
||||
import { useWizardStore } from '@/entities/deployment/store'
|
||||
import { PortalShell } from './PortalShell'
|
||||
@ -38,6 +38,7 @@ import { resolveApplications, type ApplicationDescriptor } from './applicationCa
|
||||
import { useDeploymentEvents } from './useDeploymentEvents'
|
||||
import type { ApplicationStatus } from './eventReducer'
|
||||
import { WipeDeploymentModal } from '@/components/CrudModals/WipeDeploymentModal'
|
||||
import { useNotifications } from '@/shared/ui/notifications'
|
||||
|
||||
interface AppsPageProps {
|
||||
/** Test seam — disables the live SSE EventSource attach. */
|
||||
@ -70,6 +71,88 @@ export function AppsPage({ disableStream = false }: AppsPageProps = {}) {
|
||||
const failureMessage = streamError ?? snapshot?.error ?? null
|
||||
const sovereignFQDN = snapshot?.sovereignFQDN ?? snapshot?.result?.sovereignFQDN ?? null
|
||||
|
||||
// Wipe modal is owned by AppsPage so the "Cancel & Wipe" toast action
|
||||
// can flip it open. The toast itself is mounted by the global tray; the
|
||||
// modal stays here because it owns the destructive POST + spinner.
|
||||
const [showWipeModal, setShowWipeModal] = useState(false)
|
||||
const onWipeComplete = () => {
|
||||
setShowWipeModal(false)
|
||||
router.navigate({ to: '/wizard' })
|
||||
}
|
||||
|
||||
// Surface terminal-failure + phase-1-skipped state as global toasts
|
||||
// (founder #475 — Apps page must render only the apps grid). Same
|
||||
// copy + same actions as the legacy banner, just delivered via the
|
||||
// `useNotifications` seam so they stay visible across tabs and don't
|
||||
// fight the apps grid for vertical space.
|
||||
const { notify, dismiss } = useNotifications()
|
||||
useEffect(() => {
|
||||
const id = `deployment-failure:${deploymentId}`
|
||||
if (!isFailed) {
|
||||
dismiss(id)
|
||||
return
|
||||
}
|
||||
const isUnreachable = streamStatus === 'unreachable'
|
||||
notify({
|
||||
id,
|
||||
level: 'error',
|
||||
title: isUnreachable
|
||||
? 'Couldn’t reach the deployment stream'
|
||||
: 'Provisioning failed',
|
||||
body: isUnreachable
|
||||
? `The catalyst-api is unreachable, or deployment ${deploymentId} is unknown to the backend.`
|
||||
: `The catalyst-api emitted a terminal failure for deployment ${deploymentId}.`,
|
||||
raw: failureMessage ?? undefined,
|
||||
actions: [
|
||||
{
|
||||
label: 'Retry stream',
|
||||
variant: 'primary',
|
||||
testId: 'sov-failure-retry',
|
||||
onClick: retry,
|
||||
dismissOnClick: false,
|
||||
},
|
||||
{
|
||||
label: 'Cancel & Wipe',
|
||||
variant: 'danger',
|
||||
testId: 'sov-failure-wipe',
|
||||
onClick: () => setShowWipeModal(true),
|
||||
dismissOnClick: false,
|
||||
},
|
||||
{
|
||||
label: 'Back to wizard',
|
||||
variant: 'ghost',
|
||||
testId: 'sov-failure-back',
|
||||
onClick: () => router.navigate({ to: '/wizard' }),
|
||||
},
|
||||
],
|
||||
})
|
||||
// The dismiss-on-recovery branch above handles cleanup; no return
|
||||
// closure needed because notification ids are stable per deployment.
|
||||
}, [isFailed, streamStatus, failureMessage, deploymentId, notify, dismiss, retry, router])
|
||||
|
||||
useEffect(() => {
|
||||
const id = `phase1-unavailable:${deploymentId}`
|
||||
if (!state.phase1WatchSkipped) {
|
||||
dismiss(id)
|
||||
return
|
||||
}
|
||||
const target = sovereignFQDN ?? 'the new Sovereign cluster'
|
||||
notify({
|
||||
id,
|
||||
level: 'warn',
|
||||
title: 'Per-component install monitoring is unavailable for this deployment',
|
||||
body: `The Catalyst API couldn’t fetch the new cluster’s kubeconfig. Use kubectl directly to check Helm releases on ${target}.`,
|
||||
raw: state.phase1WatchSkippedReason ?? undefined,
|
||||
})
|
||||
}, [
|
||||
state.phase1WatchSkipped,
|
||||
state.phase1WatchSkippedReason,
|
||||
sovereignFQDN,
|
||||
deploymentId,
|
||||
notify,
|
||||
dismiss,
|
||||
])
|
||||
|
||||
// Catalog = every Application this deployment knows about (canonical
|
||||
// calls this "every app in the org's catalog"; for the wizard surface
|
||||
// it's the union of bootstrap-kit + transitive deps + selected). Same
|
||||
@ -156,22 +239,23 @@ export function AppsPage({ disableStream = false }: AppsPageProps = {}) {
|
||||
>
|
||||
<style>{APPS_PAGE_CSS}</style>
|
||||
|
||||
{isFailed ? (
|
||||
<FailureCard
|
||||
{/*
|
||||
* Failure + phase-1-unavailable banners used to render here above
|
||||
* the apps grid. Per founder #475 they now fire as global toasts
|
||||
* via the NotificationProvider mounted in RootLayout, leaving the
|
||||
* Apps page to render only the grid + tabs + search box.
|
||||
*
|
||||
* The Cancel-&-Wipe action on the failure toast still needs a
|
||||
* page-scoped modal mount, so WipeDeploymentModal lives below.
|
||||
*/}
|
||||
|
||||
{showWipeModal ? (
|
||||
<WipeDeploymentModal
|
||||
open={showWipeModal}
|
||||
deploymentId={deploymentId}
|
||||
sovereignFQDN={sovereignFQDN}
|
||||
status={streamStatus as 'failed' | 'unreachable'}
|
||||
message={failureMessage}
|
||||
onRetry={retry}
|
||||
onBack={() => router.navigate({ to: '/wizard' })}
|
||||
onWiped={() => router.navigate({ to: '/wizard' })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state.phase1WatchSkipped ? (
|
||||
<Phase1UnavailableBanner
|
||||
fqdn={sovereignFQDN}
|
||||
reason={state.phase1WatchSkippedReason}
|
||||
onClose={() => setShowWipeModal(false)}
|
||||
onWiped={onWipeComplete}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@ -324,108 +408,6 @@ function AppCard({ app, status, deploymentId, isService }: AppCardProps) {
|
||||
)
|
||||
}
|
||||
|
||||
interface FailureCardProps {
|
||||
deploymentId: string
|
||||
sovereignFQDN: string | null
|
||||
status: 'failed' | 'unreachable'
|
||||
message: string | null
|
||||
onRetry: () => void
|
||||
onBack: () => void
|
||||
onWiped: () => void
|
||||
}
|
||||
|
||||
function FailureCard({ deploymentId, sovereignFQDN, status, message, onRetry, onBack, onWiped }: FailureCardProps) {
|
||||
const isUnreachable = status === 'unreachable'
|
||||
const [showWipeModal, setShowWipeModal] = useState(false)
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="sov-failure-card"
|
||||
className="my-3 rounded-xl border border-[var(--color-danger)]/40 bg-[var(--color-danger)]/10 p-4 text-sm text-[var(--color-text)]"
|
||||
>
|
||||
<h3 className="m-0 mb-1 text-base font-semibold text-[var(--color-danger)]">
|
||||
{isUnreachable ? 'Couldn’t reach the deployment stream' : 'Provisioning failed'}
|
||||
</h3>
|
||||
<p className="m-0 mb-2 text-[var(--color-text-dim)]">
|
||||
{isUnreachable
|
||||
? `The catalyst-api is unreachable, or deployment ${deploymentId} is unknown to the backend.`
|
||||
: `The catalyst-api emitted a terminal failure for deployment ${deploymentId}.`}
|
||||
</p>
|
||||
{message ? (
|
||||
<pre data-testid="sov-failure-error" className="my-2 overflow-x-auto rounded bg-[var(--color-bg)] p-2 text-[11px] text-[var(--color-text-dim)]">
|
||||
{message}
|
||||
</pre>
|
||||
) : null}
|
||||
<div className="mt-2 flex gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
data-testid="sov-failure-retry"
|
||||
className="rounded-md border border-[var(--color-accent)] bg-[var(--color-accent)] px-3 py-1 text-xs font-semibold text-white hover:bg-[var(--color-accent-hover)]"
|
||||
>
|
||||
Retry stream
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWipeModal(true)}
|
||||
data-testid="sov-failure-wipe"
|
||||
className="rounded-md border border-[var(--color-danger)] bg-[var(--color-danger)] px-3 py-1 text-xs font-semibold text-white hover:opacity-90"
|
||||
>
|
||||
Cancel & Wipe
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
data-testid="sov-failure-back"
|
||||
className="rounded-md border border-[var(--color-border)] bg-transparent px-3 py-1 text-xs text-[var(--color-text-dim)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
Back to wizard
|
||||
</button>
|
||||
</div>
|
||||
{showWipeModal ? (
|
||||
<WipeDeploymentModal
|
||||
open={showWipeModal}
|
||||
deploymentId={deploymentId}
|
||||
sovereignFQDN={sovereignFQDN}
|
||||
onClose={() => setShowWipeModal(false)}
|
||||
onWiped={() => { setShowWipeModal(false); onWiped() }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface Phase1UnavailableBannerProps {
|
||||
fqdn: string | null
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
function Phase1UnavailableBanner({ fqdn, reason }: Phase1UnavailableBannerProps) {
|
||||
const target = fqdn ?? 'the new Sovereign cluster'
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-testid="sov-phase1-unavailable-banner"
|
||||
className="my-3 rounded-lg border border-[var(--color-warn)]/35 bg-[var(--color-warn)]/10 p-3 text-sm text-[var(--color-text)]"
|
||||
>
|
||||
<strong className="text-[var(--color-warn)] font-bold">
|
||||
Per-component install monitoring is unavailable for this deployment
|
||||
</strong>{' '}
|
||||
<span className="text-xs text-[var(--color-text-dim)]">
|
||||
— the Catalyst API couldn’t fetch the new cluster’s kubeconfig. Use kubectl directly to check Helm releases on {target}.
|
||||
</span>
|
||||
{reason ? (
|
||||
<pre
|
||||
data-testid="sov-phase1-unavailable-reason"
|
||||
className="mt-2 whitespace-pre-wrap break-words rounded bg-[var(--color-bg)] p-2 font-mono text-[11px] text-[var(--color-text-dim)]"
|
||||
>
|
||||
{reason}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pixel-ported `<style>` block from the canonical AppsPage.svelte.
|
||||
* Same selector tree, same values — the only Tailwind-vs-CSS diff is
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
* does less than the other.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { API_BASE } from '@/shared/config/urls'
|
||||
import {
|
||||
buildInitialState,
|
||||
@ -257,6 +257,13 @@ export function useDeploymentEvents(
|
||||
}
|
||||
}, [deploymentId, retryNonce, disableStream])
|
||||
|
||||
// Stable callback — referential identity matters because callers
|
||||
// (e.g. AppsPage's notification effect) include `retry` in their
|
||||
// useEffect dependency arrays. A new function every render would
|
||||
// re-fire the effect on every state change and cause the toast push
|
||||
// → re-render → toast push loop that triggered the test hang.
|
||||
const retry = useCallback(() => setRetryNonce((n) => n + 1), [])
|
||||
|
||||
return {
|
||||
state,
|
||||
snapshot,
|
||||
@ -264,6 +271,6 @@ export function useDeploymentEvents(
|
||||
streamError,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
retry: () => setRetryNonce((n) => n + 1),
|
||||
retry,
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,227 @@
|
||||
/**
|
||||
* notifications.test.tsx — global toast surface (founder #475).
|
||||
*
|
||||
* • Provider mounts with no toasts initially → tray DOM is absent
|
||||
* • notify() pushes a toast → renders title + body + raw + actions
|
||||
* • notify() with the same id REPLACES the existing toast in-place
|
||||
* • dismiss(id) removes the toast
|
||||
* • action onClick fires + auto-dismisses unless dismissOnClick=false
|
||||
* • level=error renders role="alert", everything else role="status"
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest'
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
act,
|
||||
} from '@testing-library/react'
|
||||
import {
|
||||
NotificationProvider,
|
||||
useNotifications,
|
||||
type NotificationLevel,
|
||||
} from './notifications'
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
interface PushOpts {
|
||||
level?: NotificationLevel
|
||||
title?: string
|
||||
body?: string
|
||||
raw?: string
|
||||
id?: string
|
||||
onRetry?: () => void
|
||||
onWipe?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Test harness — exposes a button that triggers `notify()` with the
|
||||
* supplied opts. Re-rendering with new opts gives the test direct
|
||||
* control over the next call's payload.
|
||||
*/
|
||||
function PushButton({ opts }: { opts: PushOpts }) {
|
||||
const { notify } = useNotifications()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="harness-push"
|
||||
onClick={() =>
|
||||
notify({
|
||||
id: opts.id,
|
||||
level: opts.level ?? 'error',
|
||||
title: opts.title ?? 'Provisioning failed',
|
||||
body: opts.body,
|
||||
raw: opts.raw,
|
||||
actions:
|
||||
opts.onRetry || opts.onWipe
|
||||
? [
|
||||
...(opts.onRetry
|
||||
? [
|
||||
{
|
||||
label: 'Retry stream',
|
||||
variant: 'primary' as const,
|
||||
testId: 'sov-failure-retry',
|
||||
onClick: opts.onRetry,
|
||||
dismissOnClick: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(opts.onWipe
|
||||
? [
|
||||
{
|
||||
label: 'Cancel & Wipe',
|
||||
variant: 'danger' as const,
|
||||
testId: 'sov-failure-wipe',
|
||||
onClick: opts.onWipe,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
push
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function renderHarness(opts: PushOpts = {}) {
|
||||
return render(
|
||||
<NotificationProvider>
|
||||
<PushButton opts={opts} />
|
||||
</NotificationProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('NotificationProvider — empty state', () => {
|
||||
it('does not render the tray when there are no notifications', () => {
|
||||
renderHarness()
|
||||
expect(screen.queryByTestId('notification-tray')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NotificationProvider — push', () => {
|
||||
it('renders a toast with title, body, raw block, and action buttons', () => {
|
||||
renderHarness({
|
||||
id: 'deployment-failure:d-1',
|
||||
level: 'error',
|
||||
title: 'Provisioning failed',
|
||||
body: 'The catalyst-api emitted a terminal failure for deployment d-1.',
|
||||
raw: 'tofu apply: rg busy',
|
||||
onRetry: () => undefined,
|
||||
onWipe: () => undefined,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('harness-push'))
|
||||
expect(screen.getByTestId('notification-tray')).toBeTruthy()
|
||||
expect(screen.getByText('Provisioning failed')).toBeTruthy()
|
||||
expect(screen.getByText(/terminal failure for deployment d-1/)).toBeTruthy()
|
||||
expect(
|
||||
screen.getByTestId('notification-deployment-failure:d-1-raw').textContent,
|
||||
).toContain('tofu apply: rg busy')
|
||||
expect(screen.getByTestId('sov-failure-retry')).toBeTruthy()
|
||||
expect(screen.getByTestId('sov-failure-wipe')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('uses role="alert" for level=error and role="status" for warn/info', () => {
|
||||
const { rerender } = render(
|
||||
<NotificationProvider>
|
||||
<PushButton opts={{ id: 't1', level: 'error', title: 'boom' }} />
|
||||
</NotificationProvider>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('harness-push'))
|
||||
expect(screen.getByTestId('notification-t1').getAttribute('role')).toBe('alert')
|
||||
|
||||
rerender(
|
||||
<NotificationProvider>
|
||||
<PushButton opts={{ id: 't2', level: 'warn', title: 'careful' }} />
|
||||
</NotificationProvider>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('harness-push'))
|
||||
expect(screen.getByTestId('notification-t2').getAttribute('role')).toBe('status')
|
||||
})
|
||||
})
|
||||
|
||||
describe('NotificationProvider — id-based replace', () => {
|
||||
it('replaces an existing toast when notify() is called with the same id', () => {
|
||||
function Harness() {
|
||||
const { notify } = useNotifications()
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
data-testid="push-a"
|
||||
onClick={() =>
|
||||
notify({ id: 'same', level: 'error', title: 'first' })
|
||||
}
|
||||
>
|
||||
a
|
||||
</button>
|
||||
<button
|
||||
data-testid="push-b"
|
||||
onClick={() =>
|
||||
notify({ id: 'same', level: 'error', title: 'second' })
|
||||
}
|
||||
>
|
||||
b
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
render(
|
||||
<NotificationProvider>
|
||||
<Harness />
|
||||
</NotificationProvider>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('push-a'))
|
||||
fireEvent.click(screen.getByTestId('push-b'))
|
||||
expect(screen.getAllByTestId('notification-same').length).toBe(1)
|
||||
expect(screen.getByText('second')).toBeTruthy()
|
||||
expect(screen.queryByText('first')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NotificationProvider — dismiss', () => {
|
||||
it('removes the toast when the close button is clicked', () => {
|
||||
renderHarness({ id: 'x', level: 'info', title: 'hi' })
|
||||
fireEvent.click(screen.getByTestId('harness-push'))
|
||||
expect(screen.queryByTestId('notification-x')).toBeTruthy()
|
||||
fireEvent.click(screen.getByTestId('notification-x-dismiss'))
|
||||
expect(screen.queryByTestId('notification-x')).toBeNull()
|
||||
})
|
||||
|
||||
it('action with dismissOnClick !== false auto-dismisses after firing', () => {
|
||||
const onWipe = vi.fn()
|
||||
renderHarness({ id: 'y', level: 'error', title: 'failed', onWipe })
|
||||
fireEvent.click(screen.getByTestId('harness-push'))
|
||||
fireEvent.click(screen.getByTestId('sov-failure-wipe'))
|
||||
expect(onWipe).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByTestId('notification-y')).toBeNull()
|
||||
})
|
||||
|
||||
it('action with dismissOnClick=false fires onClick but keeps the toast', () => {
|
||||
const onRetry = vi.fn()
|
||||
renderHarness({ id: 'z', level: 'error', title: 'failed', onRetry })
|
||||
fireEvent.click(screen.getByTestId('harness-push'))
|
||||
fireEvent.click(screen.getByTestId('sov-failure-retry'))
|
||||
expect(onRetry).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByTestId('notification-z')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NotificationProvider — context guard', () => {
|
||||
it('throws when useNotifications is called outside the provider', () => {
|
||||
function Naked() {
|
||||
useNotifications()
|
||||
return null
|
||||
}
|
||||
// React logs the boundary error; suppress for cleaner test output.
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
expect(() =>
|
||||
act(() => {
|
||||
render(<Naked />)
|
||||
}),
|
||||
).toThrow(/NotificationProvider/)
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
251
products/catalyst/bootstrap/ui/src/shared/ui/notifications.tsx
Normal file
251
products/catalyst/bootstrap/ui/src/shared/ui/notifications.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
/**
|
||||
* notifications.tsx — global, app-wide toast surface.
|
||||
*
|
||||
* Founder mandate (#475, 2026-05-01): page-level status banners (e.g. the
|
||||
* "Provisioning failed" banner that used to render above the apps grid)
|
||||
* pollute the surface they're attached to. The Apps page must show ONLY
|
||||
* the apps grid; deployment status must surface via a global notification
|
||||
* affordance — bottom-right toasts stack here.
|
||||
*
|
||||
* The provider is mounted once in `RootLayout` so toasts are visible
|
||||
* across every tab (Apps / Jobs / Dashboard / Cloud / Users) and survive
|
||||
* client-side navigation.
|
||||
*
|
||||
* Public API:
|
||||
* • <NotificationProvider> — wraps the app, renders the toast tray
|
||||
* • useNotifications() — returns { notify, dismiss, items }
|
||||
* • notify({ id?, level, title, body?, actions? })
|
||||
*
|
||||
* If `id` is supplied, calls with the same id REPLACE the existing toast
|
||||
* (so a deployment-failure update doesn't stack — it edits in-place).
|
||||
*
|
||||
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), every visual
|
||||
* value flows through CSS variables (`--color-danger`, `--color-warn`,
|
||||
* `--color-accent`, …). No inlined hex.
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
|
||||
export type NotificationLevel = 'info' | 'warn' | 'error' | 'success'
|
||||
|
||||
export interface NotificationAction {
|
||||
/** Visible label, e.g. "Retry stream" or "Cancel & Wipe". */
|
||||
label: string
|
||||
/** Click handler — triggered before the toast is dismissed. */
|
||||
onClick: () => void
|
||||
/** Visual emphasis. `primary` matches the canonical accent button. */
|
||||
variant?: 'primary' | 'danger' | 'ghost'
|
||||
/** When true (default) the toast is dismissed after `onClick` fires. */
|
||||
dismissOnClick?: boolean
|
||||
/** data-testid to expose for E2E. */
|
||||
testId?: string
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
/** Stable id — pass to replace an existing toast in-place. Auto-assigned if omitted. */
|
||||
id: string
|
||||
level: NotificationLevel
|
||||
title: string
|
||||
/** Optional secondary body (string or pre-formatted error text). */
|
||||
body?: string
|
||||
/** Action buttons rendered at the bottom of the toast. */
|
||||
actions?: NotificationAction[]
|
||||
/**
|
||||
* Render a `<pre>` raw block (e.g. server error trace). Set this
|
||||
* separately from `body` so the layout can use a monospaced rail
|
||||
* without pre-wrapping arbitrary copy.
|
||||
*/
|
||||
raw?: string
|
||||
}
|
||||
|
||||
interface NotificationsContextValue {
|
||||
items: readonly Notification[]
|
||||
/** Push a new toast OR replace an existing one with the same id. */
|
||||
notify: (n: Omit<Notification, 'id'> & { id?: string }) => string
|
||||
/** Dismiss by id. No-op if the id is unknown. */
|
||||
dismiss: (id: string) => void
|
||||
}
|
||||
|
||||
const NotificationsContext = createContext<NotificationsContextValue | null>(null)
|
||||
|
||||
export function useNotifications(): NotificationsContextValue {
|
||||
const ctx = useContext(NotificationsContext)
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'useNotifications() must be called inside a <NotificationProvider>',
|
||||
)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
interface NotificationProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
let nextAutoId = 0
|
||||
|
||||
export function NotificationProvider({ children }: NotificationProviderProps) {
|
||||
const [items, setItems] = useState<readonly Notification[]>([])
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setItems((prev) => prev.filter((n) => n.id !== id))
|
||||
}, [])
|
||||
|
||||
const notify = useCallback<NotificationsContextValue['notify']>((n) => {
|
||||
const id = n.id ?? `auto-${++nextAutoId}`
|
||||
const next: Notification = { ...n, id }
|
||||
setItems((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === id)
|
||||
if (idx === -1) return [...prev, next]
|
||||
const copy = [...prev]
|
||||
copy[idx] = next
|
||||
return copy
|
||||
})
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const value = useMemo<NotificationsContextValue>(
|
||||
() => ({ items, notify, dismiss }),
|
||||
[items, notify, dismiss],
|
||||
)
|
||||
|
||||
return (
|
||||
<NotificationsContext.Provider value={value}>
|
||||
{children}
|
||||
<NotificationTray items={items} dismiss={dismiss} />
|
||||
</NotificationsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
interface NotificationTrayProps {
|
||||
items: readonly Notification[]
|
||||
dismiss: (id: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom-right stacked toast tray. Fixed positioning so the surface is
|
||||
* visible regardless of which page or tab is rendered. Tray is
|
||||
* unmounted entirely when there are no items, keeping the DOM clean
|
||||
* (and keeping the `role="alert"` / `role="status"` count at zero on
|
||||
* the page chrome).
|
||||
*/
|
||||
function NotificationTray({ items, dismiss }: NotificationTrayProps) {
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<div
|
||||
data-testid="notification-tray"
|
||||
className="fixed bottom-4 right-4 z-[100] flex max-w-[26rem] flex-col gap-2"
|
||||
aria-live="polite"
|
||||
>
|
||||
{items.map((n) => (
|
||||
<NotificationCard key={n.id} item={n} dismiss={dismiss} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface NotificationCardProps {
|
||||
item: Notification
|
||||
dismiss: (id: string) => void
|
||||
}
|
||||
|
||||
function NotificationCard({ item, dismiss }: NotificationCardProps) {
|
||||
const tone = TONE_BY_LEVEL[item.level]
|
||||
return (
|
||||
<div
|
||||
role={item.level === 'error' ? 'alert' : 'status'}
|
||||
data-testid={`notification-${item.id}`}
|
||||
data-level={item.level}
|
||||
className={`rounded-xl border ${tone.border} ${tone.surface} p-3 text-sm text-[var(--color-text)] shadow-lg`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<h3 className={`m-0 flex-1 text-sm font-semibold ${tone.title}`}>
|
||||
{item.title}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss notification"
|
||||
data-testid={`notification-${item.id}-dismiss`}
|
||||
onClick={() => dismiss(item.id)}
|
||||
className="-m-1 ml-2 rounded p-1 text-[var(--color-text-dim)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width={14} height={14} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{item.body ? (
|
||||
<p className="m-0 mt-1 text-[var(--color-text-dim)]">{item.body}</p>
|
||||
) : null}
|
||||
{item.raw ? (
|
||||
<pre
|
||||
data-testid={`notification-${item.id}-raw`}
|
||||
className="my-2 max-h-32 overflow-auto rounded bg-[var(--color-bg)] p-2 text-[11px] text-[var(--color-text-dim)]"
|
||||
>
|
||||
{item.raw}
|
||||
</pre>
|
||||
) : null}
|
||||
{item.actions && item.actions.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{item.actions.map((a, i) => (
|
||||
<button
|
||||
key={`${a.label}-${i}`}
|
||||
type="button"
|
||||
data-testid={a.testId}
|
||||
onClick={() => {
|
||||
a.onClick()
|
||||
if (a.dismissOnClick !== false) dismiss(item.id)
|
||||
}}
|
||||
className={ACTION_CLASS[a.variant ?? 'primary']}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TONE_BY_LEVEL: Record<
|
||||
NotificationLevel,
|
||||
{ border: string; surface: string; title: string }
|
||||
> = {
|
||||
error: {
|
||||
border: 'border-[var(--color-danger)]/40',
|
||||
surface: 'bg-[var(--color-danger)]/10',
|
||||
title: 'text-[var(--color-danger)]',
|
||||
},
|
||||
warn: {
|
||||
border: 'border-[var(--color-warn)]/40',
|
||||
surface: 'bg-[var(--color-warn)]/10',
|
||||
title: 'text-[var(--color-warn)]',
|
||||
},
|
||||
info: {
|
||||
border: 'border-[var(--color-accent)]/40',
|
||||
surface: 'bg-[var(--color-accent)]/10',
|
||||
title: 'text-[var(--color-accent)]',
|
||||
},
|
||||
success: {
|
||||
border: 'border-[var(--color-success)]/40',
|
||||
surface: 'bg-[var(--color-success)]/10',
|
||||
title: 'text-[var(--color-success)]',
|
||||
},
|
||||
}
|
||||
|
||||
const ACTION_CLASS: Record<NonNullable<NotificationAction['variant']>, string> = {
|
||||
primary:
|
||||
'rounded-md border border-[var(--color-accent)] bg-[var(--color-accent)] px-3 py-1 text-xs font-semibold text-white hover:bg-[var(--color-accent-hover)]',
|
||||
danger:
|
||||
'rounded-md border border-[var(--color-danger)] bg-[var(--color-danger)] px-3 py-1 text-xs font-semibold text-white hover:opacity-90',
|
||||
ghost:
|
||||
'rounded-md border border-[var(--color-border)] bg-transparent px-3 py-1 text-xs text-[var(--color-text-dim)] hover:text-[var(--color-text)]',
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user