Skip to content

Commit 686956b

Browse files
os-zhuangclaude
andcommitted
feat(app-shell): the console mounts the notification surfaces (#3014 follow-up)
#3071 gave each spec `NotificationTypeSchema` member its own presentation, but no host mounted `NotificationProvider` — the capability existed and the console could not reach it. `ConsoleShell` now mounts the provider plus the surfaces with a single global home; `ConsoleLayout` mounts the one that belongs in the content area: toast -> sonner, via the new `presentNotificationToast` (ConsoleShell) snackbar -> <NotificationSnackbar /> (ConsoleShell) alert -> <NotificationAlerts /> (ConsoleShell) banner -> <NotificationBanners />, beside the draft / unpublished bars (ConsoleLayout) inline -> the raising surface's own <NotificationInline />; deliberately NOT mounted globally, since rendering in place at the raiser is the whole difference between it and a banner `presentNotificationToast` is the single place a notification becomes a sonner call: severity -> variant, `duration: 0` -> `Infinity` (the contract's "persistent", which passed through raw would make the toast vanish on the next tick), first action -> sonner's one action slot, an absent duration left to the ConsoleToaster default. Its severity table is `Record<NotificationSeverityLevel, …>`, so a new spec severity fails type-check instead of silently rendering neutral. The banners go through `ConsoleNotificationBanners`, gated on `useHasNotificationProvider()`: `ConsoleShell` is deliberately composable pieces a host assembles itself, so `ConsoleLayout` can render without the provider above it — and `useNotifications()` throws there, white-screening the app instead of simply showing no banners. Both pieces are exported for hand-assembled shells. Verified in the running console (login route): a toast, a snackbar with an Undo action, and a blocking alert dialog on screen at once, visibly distinct; raising an `inline` with no outlet logs the dev warning naming the component to mount, once per notify(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2a40b5e commit 686956b

12 files changed

Lines changed: 445 additions & 16 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@object-ui/app-shell": minor
3+
---
4+
5+
feat(app-shell): the console mounts the notification surfaces, so `displayType` works there (#3014 follow-up)
6+
7+
#3071 gave each spec `NotificationTypeSchema` member its own presentation, but no
8+
host mounted `NotificationProvider` — the capability existed and the console
9+
could not reach it. `ConsoleShell` now mounts the provider and the surfaces with
10+
a single global home; `ConsoleLayout` mounts the one that belongs in the content
11+
area:
12+
13+
| `displayType` | Surface | Mounted by |
14+
|---|---|---|
15+
| `toast` | sonner, via the new `presentNotificationToast` | `ConsoleShell` |
16+
| `snackbar` | `<NotificationSnackbar />` | `ConsoleShell` |
17+
| `alert` | `<NotificationAlerts />` | `ConsoleShell` |
18+
| `banner` | `<NotificationBanners />` | `ConsoleLayout`, beside the draft / unpublished bars |
19+
| `inline` | `<NotificationInline scope="…" />` | the raising surface — **not** mounted globally |
20+
21+
`inline` is left out deliberately: rendering in place at the raiser is the whole
22+
difference between it and a banner, so a global inline outlet would collapse the
23+
two again.
24+
25+
`presentNotificationToast` is the single place a notification becomes a sonner
26+
call — severity → variant, `duration: 0``Infinity` (the contract's
27+
"persistent", which passed through raw would have made the toast vanish on the
28+
next tick), first action → the one action slot sonner offers, an absent duration
29+
left to the `ConsoleToaster` default rather than reinvented. Its severity table
30+
is `Record<NotificationSeverityLevel, …>`, so a new spec severity fails
31+
type-check instead of silently rendering neutral.
32+
33+
The banners go through `ConsoleNotificationBanners`, which gates on
34+
`useHasNotificationProvider()`. `ConsoleShell` is deliberately a set of
35+
composable pieces a host assembles in its own `App.tsx`, so `ConsoleLayout` can
36+
legitimately render without the provider above it — and `useNotifications()`
37+
throws there, which would white-screen the whole app instead of simply showing
38+
no banners.
39+
40+
Both pieces are exported (`presentNotificationToast`, `ConsoleNotificationBanners`)
41+
for hand-assembled shells. The provider's `defaultDuration` matches
42+
`ConsoleToaster`'s 4s, so a snackbar and a toast raised together disappear
43+
together.

content/docs/guide/notifications.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,23 @@ are collected in `notifications` / `unreadCount` for a bell or list, and nothing
6767
is overlaid. Raising one of the other four types with its surface unmounted is a
6868
mistake, though — dev builds warn, naming the component to mount.
6969

70+
### In the console
71+
72+
`@object-ui/app-shell` already does this wiring — a console route can call
73+
`useNotifications()` and every display type presents correctly with no setup:
74+
75+
| Surface | Mounted by |
76+
|---|---|
77+
| `toast` | `ConsoleShell`, via `presentNotificationToast` → sonner (`ConsoleToaster`) |
78+
| `snackbar`, `alert` | `ConsoleShell` — both have a single global home |
79+
| `banner` | `ConsoleLayout`, at the top of the content area, beside the draft / unpublished bars |
80+
| `inline` | nothing, by contract — the raising surface mounts its own `<NotificationInline scope="…" />` |
81+
82+
Assembling a shell by hand? Both pieces are exported: `presentNotificationToast`
83+
for the `onToast` delegate, and `ConsoleNotificationBanners``NotificationBanners`
84+
guarded by `useHasNotificationProvider()`, so a layout rendered without the
85+
provider above it renders no banners instead of throwing.
86+
7087
## Raising notifications
7188

7289
```tsx

packages/app-shell/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,29 @@ function MyDashboard() {
6464
- **Full-Page AI Workspace**: The `/ai` surface provides a responsive chat
6565
workspace with a desktop conversation rail, mobile Chats drawer, and a
6666
constrained reading width for long conversations
67+
- **Notification Surfaces**: `ConsoleShell` mounts `NotificationProvider` and
68+
every spec `displayType` presents distinctly — no per-app wiring
69+
70+
## Notifications
71+
72+
`ConsoleShell` mounts `NotificationProvider`, so any console route can call
73+
`useNotifications()` and get the presentation it asked for:
74+
75+
| `displayType` | Surface | Mounted by |
76+
| --- | --- | --- |
77+
| `toast` | sonner, via `presentNotificationToast` | `ConsoleShell` |
78+
| `snackbar` | `<NotificationSnackbar />` | `ConsoleShell` |
79+
| `alert` | `<NotificationAlerts />` | `ConsoleShell` |
80+
| `banner` | `<NotificationBanners />` | `ConsoleLayout`, top of the content area |
81+
| `inline` | `<NotificationInline scope="…" />` | the surface that raises it |
82+
83+
`inline` is deliberately not mounted globally — rendering in place at the raiser
84+
is the whole difference between it and a banner. Both console-side pieces are
85+
exported for hand-assembled shells: `presentNotificationToast` (the `onToast`
86+
delegate) and `ConsoleNotificationBanners` (the banners, guarded by
87+
`useHasNotificationProvider()` so a layout without the provider renders no
88+
banners instead of throwing). See the
89+
[notifications guide](https://objectui.org/docs/guide/notifications).
6790

6891
## Components
6992

packages/app-shell/src/chrome/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export { ErrorBoundary } from './ErrorBoundary';
88
export { LoadingScreen } from './LoadingScreen';
99
export { ThemeProvider, useTheme } from './ThemeProvider';
1010
export { toastWithUndo, type ToastWithUndoOptions } from './toast-helpers';
11+
export { presentNotificationToast } from './notificationToast';
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* The console's `toast` surface — the one display type `NotificationProvider`
3+
* delegates instead of rendering itself (#3014). These pin the mapping onto
4+
* sonner; which notifications ever get here is covered by
5+
* `console/__tests__/ConsoleShell.notifications.test.tsx`.
6+
*/
7+
8+
import { describe, it, expect, vi, beforeEach } from 'vitest';
9+
import type { NotificationItem } from '@object-ui/react';
10+
11+
vi.mock('sonner', () => ({
12+
toast: Object.assign(vi.fn(), {
13+
info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn(),
14+
}),
15+
}));
16+
17+
import { toast } from 'sonner';
18+
import { presentNotificationToast } from './notificationToast';
19+
20+
function item(overrides: Partial<NotificationItem> = {}): NotificationItem {
21+
return {
22+
id: 'n1',
23+
title: 'Saved',
24+
severity: 'success',
25+
createdAt: new Date(0),
26+
...overrides,
27+
};
28+
}
29+
30+
describe('presentNotificationToast', () => {
31+
beforeEach(() => { vi.clearAllMocks(); });
32+
33+
it('maps each severity to its sonner variant', () => {
34+
presentNotificationToast(item({ severity: 'info' }));
35+
presentNotificationToast(item({ severity: 'success' }));
36+
presentNotificationToast(item({ severity: 'warning' }));
37+
presentNotificationToast(item({ severity: 'error' }));
38+
39+
expect(toast.info).toHaveBeenCalledTimes(1);
40+
expect(toast.success).toHaveBeenCalledTimes(1);
41+
expect(toast.warning).toHaveBeenCalledTimes(1);
42+
expect(toast.error).toHaveBeenCalledTimes(1);
43+
});
44+
45+
it('passes the message through as the toast description', () => {
46+
presentNotificationToast(item({ message: 'Record updated' }));
47+
expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({
48+
description: 'Record updated',
49+
}));
50+
});
51+
52+
it('turns duration 0 into a persistent toast', () => {
53+
// `0 = persistent` is the notification contract; sonner spells it Infinity.
54+
// Passing the 0 through would make the toast vanish on the next tick.
55+
presentNotificationToast(item({ duration: 0 }));
56+
expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({
57+
duration: Infinity,
58+
}));
59+
});
60+
61+
it('leaves an unset duration to the ConsoleToaster default', () => {
62+
presentNotificationToast(item());
63+
expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({
64+
duration: undefined,
65+
}));
66+
});
67+
68+
it('forwards an explicit duration unchanged', () => {
69+
presentNotificationToast(item({ duration: 12_000 }));
70+
expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({
71+
duration: 12_000,
72+
}));
73+
});
74+
75+
it('maps the first action to the toast action button', () => {
76+
const onClick = vi.fn();
77+
presentNotificationToast(item({
78+
actions: [
79+
{ label: 'Undo', onClick },
80+
{ label: 'Ignored — a toast has one action slot', onClick: vi.fn() },
81+
],
82+
}));
83+
84+
const options = vi.mocked(toast.success).mock.calls[0][1] as {
85+
action?: { label: string; onClick: () => void };
86+
};
87+
expect(options.action?.label).toBe('Undo');
88+
options.action?.onClick();
89+
expect(onClick).toHaveBeenCalledTimes(1);
90+
});
91+
92+
it('omits the action slot entirely when none is declared', () => {
93+
presentNotificationToast(item());
94+
const options = vi.mocked(toast.success).mock.calls[0][1] as Record<string, unknown>;
95+
expect(options).not.toHaveProperty('action');
96+
});
97+
98+
it('forwards dismissible', () => {
99+
presentNotificationToast(item({ dismissible: false }));
100+
expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({
101+
dismissible: false,
102+
}));
103+
});
104+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* The console's `toast` surface.
3+
*
4+
* `NotificationProvider` presents four of the five spec display types through
5+
* surface components (#3014); `toast` is the one it delegates, and in the
6+
* console that delegate is sonner. This module is that bridge — it is the
7+
* ONLY place a notification becomes a sonner call, so the mapping (severity →
8+
* variant, `duration: 0` → persistent, first action → the toast's action
9+
* button) lives in one testable function rather than inline in the shell.
10+
*
11+
* Non-toast display types never reach here: the provider routes them to
12+
* `NotificationBanners` / `NotificationSnackbar` / `NotificationAlerts` /
13+
* `NotificationInline` instead.
14+
*/
15+
16+
import { toast } from 'sonner';
17+
import type { NotificationItem, NotificationSeverityLevel } from '@object-ui/react';
18+
19+
/**
20+
* Typed as `Record<NotificationSeverityLevel, …>` so a new severity in the spec
21+
* `NotificationSeveritySchema` fails type-check here until it has a variant,
22+
* rather than silently rendering as a neutral toast.
23+
*/
24+
const TOAST_BY_SEVERITY: Record<NotificationSeverityLevel, (message: string, data?: unknown) => unknown> = {
25+
info: (message, data) => toast.info(message, data as never),
26+
success: (message, data) => toast.success(message, data as never),
27+
warning: (message, data) => toast.warning(message, data as never),
28+
error: (message, data) => toast.error(message, data as never),
29+
};
30+
31+
/**
32+
* Present one `displayType: 'toast'` notification through sonner.
33+
*
34+
* Duration follows the notification contract: `0` means persistent (sonner's
35+
* `Infinity`), and an absent duration defers to the `ConsoleToaster` default
36+
* rather than being invented here.
37+
*/
38+
export function presentNotificationToast(notification: NotificationItem): void {
39+
const show = TOAST_BY_SEVERITY[notification.severity] ?? TOAST_BY_SEVERITY.info;
40+
// A toast carries at most one action button — sonner has one `action` slot,
41+
// and a notification needing more than one belongs on a banner or an alert.
42+
const action = notification.actions?.[0];
43+
44+
show(notification.title, {
45+
description: notification.message,
46+
duration: notification.duration === 0 ? Infinity : notification.duration,
47+
dismissible: notification.dismissible,
48+
...(action ? { action: { label: action.label, onClick: action.onClick } } : {}),
49+
});
50+
}

packages/app-shell/src/console/ConsoleShell.tsx

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import { Suspense, useEffect, useRef, useState, type ReactNode } from 'react';
1414
import { Navigate, useLocation } from 'react-router-dom';
1515
import { AuthGuard, useAuth, createAuthenticatedFetch } from '@object-ui/auth';
1616
import { useObjectTranslation } from '@object-ui/i18n';
17-
import { SchemaRendererProvider, ActionProvider } from '@object-ui/react';
17+
import { SchemaRendererProvider, ActionProvider, NotificationProvider } from '@object-ui/react';
18+
import { NotificationAlerts, NotificationSnackbar } from '@object-ui/components';
19+
import { presentNotificationToast } from '../chrome/notificationToast';
1820
import { useActionModal } from '../hooks/useActionModal';
1921
import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime';
2022
import { createObjectStackUserStateAdapter } from '@object-ui/data-objectstack';
@@ -101,8 +103,9 @@ function GlobalActionRuntimeProvider({ dataSource, children }: { dataSource: unk
101103
/**
102104
* ConsoleShell — top-level provider stack shared by every console route.
103105
* Wraps children in ThemeProvider + NavigationProvider + FavoritesProvider +
104-
* Suspense so lazy route components get a default loading fallback and
105-
* dark/light/system theme switching works out of the box.
106+
* NotificationProvider + Suspense so lazy route components get a default
107+
* loading fallback, dark/light/system theme switching, and a notification
108+
* system that honors the spec `displayType`.
106109
*
107110
* Place this inside a <BrowserRouter> and around your <Routes>:
108111
*
@@ -111,23 +114,43 @@ function GlobalActionRuntimeProvider({ dataSource, children }: { dataSource: unk
111114
* <Routes>...</Routes>
112115
* </ConsoleShell>
113116
* </BrowserRouter>
117+
*
118+
* ── Notification surfaces (#3014) ──
119+
* Each spec display type has its own presentation, so the shell mounts the ones
120+
* with a single global home and leaves the rest to their owners:
121+
*
122+
* toast → `presentNotificationToast` (sonner, via `onToast`)
123+
* snackbar → <NotificationSnackbar /> here — it anchors itself bottom-center
124+
* alert → <NotificationAlerts /> here — a blocking dialog is global
125+
* banner → <NotificationBanners /> in `ConsoleLayout`, at the top of the
126+
* content area, next to the draft /
127+
* unpublished bars it sits with
128+
* inline → nothing here, by contract — an inline notification is rendered
129+
* by the surface that RAISED it (`<NotificationInline scope=…/>`),
130+
* which is the whole difference between it and a banner
114131
*/
115132
export function ConsoleShell({ children }: { children: ReactNode }) {
116133
return (
117134
<ThemeProvider defaultTheme="system" storageKey="object-ui-theme">
118-
<NavigationProvider>
119-
<UserStateAdaptersProvider>
120-
<FavoritesProvider>
121-
<RecentItemsProvider>
122-
<FlowPaletteRecentsProvider>
123-
<Suspense fallback={<LoadingFallback />}>{children}</Suspense>
124-
{/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */}
125-
<RemediationOverlay />
126-
</FlowPaletteRecentsProvider>
127-
</RecentItemsProvider>
128-
</FavoritesProvider>
129-
</UserStateAdaptersProvider>
130-
</NavigationProvider>
135+
{/* `defaultDuration` matches ConsoleToaster's 4s toast default, so a
136+
snackbar and a toast raised together disappear together. */}
137+
<NotificationProvider config={{ defaultDuration: 4000, maxVisible: 4 }} onToast={presentNotificationToast}>
138+
<NavigationProvider>
139+
<UserStateAdaptersProvider>
140+
<FavoritesProvider>
141+
<RecentItemsProvider>
142+
<FlowPaletteRecentsProvider>
143+
<Suspense fallback={<LoadingFallback />}>{children}</Suspense>
144+
{/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */}
145+
<RemediationOverlay />
146+
<NotificationSnackbar />
147+
<NotificationAlerts />
148+
</FlowPaletteRecentsProvider>
149+
</RecentItemsProvider>
150+
</FavoritesProvider>
151+
</UserStateAdaptersProvider>
152+
</NavigationProvider>
153+
</NotificationProvider>
131154
</ThemeProvider>
132155
);
133156
}

0 commit comments

Comments
 (0)