Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/console-mounts-notification-surfaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
"@object-ui/app-shell": minor
---

feat(app-shell): the console mounts the notification surfaces, so `displayType` works there (#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 and the surfaces with
a single global home; `ConsoleLayout` mounts the one that belongs in the content
area:

| `displayType` | Surface | Mounted by |
|---|---|---|
| `toast` | sonner, via the new `presentNotificationToast` | `ConsoleShell` |
| `snackbar` | `<NotificationSnackbar />` | `ConsoleShell` |
| `alert` | `<NotificationAlerts />` | `ConsoleShell` |
| `banner` | `<NotificationBanners />` | `ConsoleLayout`, beside the draft / unpublished bars |
| `inline` | `<NotificationInline scope="…" />` | the raising surface — **not** mounted globally |

`inline` is left out deliberately: rendering in place at the raiser is the whole
difference between it and a banner, so a global inline outlet would collapse the
two again.

`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 have made the toast vanish on the
next tick), first action → the one action slot sonner offers, an absent duration
left to the `ConsoleToaster` default rather than reinvented. Its severity table
is `Record<NotificationSeverityLevel, …>`, so a new spec severity fails
type-check instead of silently rendering neutral.

The banners go through `ConsoleNotificationBanners`, which gates on
`useHasNotificationProvider()`. `ConsoleShell` is deliberately a set of
composable pieces a host assembles in its own `App.tsx`, so `ConsoleLayout` can
legitimately render without the provider above it — and `useNotifications()`
throws there, which would white-screen the whole app instead of simply showing
no banners.

Both pieces are exported (`presentNotificationToast`, `ConsoleNotificationBanners`)
for hand-assembled shells. The provider's `defaultDuration` matches
`ConsoleToaster`'s 4s, so a snackbar and a toast raised together disappear
together.
17 changes: 17 additions & 0 deletions content/docs/guide/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,23 @@ are collected in `notifications` / `unreadCount` for a bell or list, and nothing
is overlaid. Raising one of the other four types with its surface unmounted is a
mistake, though — dev builds warn, naming the component to mount.

### In the console

`@object-ui/app-shell` already does this wiring — a console route can call
`useNotifications()` and every display type presents correctly with no setup:

| Surface | Mounted by |
|---|---|
| `toast` | `ConsoleShell`, via `presentNotificationToast` → sonner (`ConsoleToaster`) |
| `snackbar`, `alert` | `ConsoleShell` — both have a single global home |
| `banner` | `ConsoleLayout`, at the top of the content area, beside the draft / unpublished bars |
| `inline` | nothing, by contract — the raising surface mounts its own `<NotificationInline scope="…" />` |

Assembling a shell by hand? Both pieces are exported: `presentNotificationToast`
for the `onToast` delegate, and `ConsoleNotificationBanners` — `NotificationBanners`
guarded by `useHasNotificationProvider()`, so a layout rendered without the
provider above it renders no banners instead of throwing.

## Raising notifications

```tsx
Expand Down
23 changes: 23 additions & 0 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,29 @@ function MyDashboard() {
- **Full-Page AI Workspace**: The `/ai` surface provides a responsive chat
workspace with a desktop conversation rail, mobile Chats drawer, and a
constrained reading width for long conversations
- **Notification Surfaces**: `ConsoleShell` mounts `NotificationProvider` and
every spec `displayType` presents distinctly — no per-app wiring

## Notifications

`ConsoleShell` mounts `NotificationProvider`, so any console route can call
`useNotifications()` and get the presentation it asked for:

| `displayType` | Surface | Mounted by |
| --- | --- | --- |
| `toast` | sonner, via `presentNotificationToast` | `ConsoleShell` |
| `snackbar` | `<NotificationSnackbar />` | `ConsoleShell` |
| `alert` | `<NotificationAlerts />` | `ConsoleShell` |
| `banner` | `<NotificationBanners />` | `ConsoleLayout`, top of the content area |
| `inline` | `<NotificationInline scope="…" />` | the surface that raises it |

`inline` is deliberately not mounted globally — rendering in place at the raiser
is the whole difference between it and a banner. Both console-side pieces are
exported for hand-assembled shells: `presentNotificationToast` (the `onToast`
delegate) and `ConsoleNotificationBanners` (the banners, guarded by
`useHasNotificationProvider()` so a layout without the provider renders no
banners instead of throwing). See the
[notifications guide](https://objectui.org/docs/guide/notifications).

## Components

Expand Down
1 change: 1 addition & 0 deletions packages/app-shell/src/chrome/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export { ErrorBoundary } from './ErrorBoundary';
export { LoadingScreen } from './LoadingScreen';
export { ThemeProvider, useTheme } from './ThemeProvider';
export { toastWithUndo, type ToastWithUndoOptions } from './toast-helpers';
export { presentNotificationToast } from './notificationToast';
104 changes: 104 additions & 0 deletions packages/app-shell/src/chrome/notificationToast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* The console's `toast` surface — the one display type `NotificationProvider`
* delegates instead of rendering itself (#3014). These pin the mapping onto
* sonner; which notifications ever get here is covered by
* `console/__tests__/ConsoleShell.notifications.test.tsx`.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { NotificationItem } from '@object-ui/react';

vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), {
info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn(),
}),
}));

import { toast } from 'sonner';
import { presentNotificationToast } from './notificationToast';

function item(overrides: Partial<NotificationItem> = {}): NotificationItem {
return {
id: 'n1',
title: 'Saved',
severity: 'success',
createdAt: new Date(0),
...overrides,
};
}

describe('presentNotificationToast', () => {
beforeEach(() => { vi.clearAllMocks(); });

it('maps each severity to its sonner variant', () => {
presentNotificationToast(item({ severity: 'info' }));
presentNotificationToast(item({ severity: 'success' }));
presentNotificationToast(item({ severity: 'warning' }));
presentNotificationToast(item({ severity: 'error' }));

expect(toast.info).toHaveBeenCalledTimes(1);
expect(toast.success).toHaveBeenCalledTimes(1);
expect(toast.warning).toHaveBeenCalledTimes(1);
expect(toast.error).toHaveBeenCalledTimes(1);
});

it('passes the message through as the toast description', () => {
presentNotificationToast(item({ message: 'Record updated' }));
expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({
description: 'Record updated',
}));
});

it('turns duration 0 into a persistent toast', () => {
// `0 = persistent` is the notification contract; sonner spells it Infinity.
// Passing the 0 through would make the toast vanish on the next tick.
presentNotificationToast(item({ duration: 0 }));
expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({
duration: Infinity,
}));
});

it('leaves an unset duration to the ConsoleToaster default', () => {
presentNotificationToast(item());
expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({
duration: undefined,
}));
});

it('forwards an explicit duration unchanged', () => {
presentNotificationToast(item({ duration: 12_000 }));
expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({
duration: 12_000,
}));
});

it('maps the first action to the toast action button', () => {
const onClick = vi.fn();
presentNotificationToast(item({
actions: [
{ label: 'Undo', onClick },
{ label: 'Ignored — a toast has one action slot', onClick: vi.fn() },
],
}));

const options = vi.mocked(toast.success).mock.calls[0][1] as {
action?: { label: string; onClick: () => void };
};
expect(options.action?.label).toBe('Undo');
options.action?.onClick();
expect(onClick).toHaveBeenCalledTimes(1);
});

it('omits the action slot entirely when none is declared', () => {
presentNotificationToast(item());
const options = vi.mocked(toast.success).mock.calls[0][1] as Record<string, unknown>;
expect(options).not.toHaveProperty('action');
});

it('forwards dismissible', () => {
presentNotificationToast(item({ dismissible: false }));
expect(toast.success).toHaveBeenCalledWith('Saved', expect.objectContaining({
dismissible: false,
}));
});
});
50 changes: 50 additions & 0 deletions packages/app-shell/src/chrome/notificationToast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* The console's `toast` surface.
*
* `NotificationProvider` presents four of the five spec display types through
* surface components (#3014); `toast` is the one it delegates, and in the
* console that delegate is sonner. This module is that bridge — it is the
* ONLY place a notification becomes a sonner call, so the mapping (severity →
* variant, `duration: 0` → persistent, first action → the toast's action
* button) lives in one testable function rather than inline in the shell.
*
* Non-toast display types never reach here: the provider routes them to
* `NotificationBanners` / `NotificationSnackbar` / `NotificationAlerts` /
* `NotificationInline` instead.
*/

import { toast } from 'sonner';
import type { NotificationItem, NotificationSeverityLevel } from '@object-ui/react';

/**
* Typed as `Record<NotificationSeverityLevel, …>` so a new severity in the spec
* `NotificationSeveritySchema` fails type-check here until it has a variant,
* rather than silently rendering as a neutral toast.
*/
const TOAST_BY_SEVERITY: Record<NotificationSeverityLevel, (message: string, data?: unknown) => unknown> = {
info: (message, data) => toast.info(message, data as never),
success: (message, data) => toast.success(message, data as never),
warning: (message, data) => toast.warning(message, data as never),
error: (message, data) => toast.error(message, data as never),
};

/**
* Present one `displayType: 'toast'` notification through sonner.
*
* Duration follows the notification contract: `0` means persistent (sonner's
* `Infinity`), and an absent duration defers to the `ConsoleToaster` default
* rather than being invented here.
*/
export function presentNotificationToast(notification: NotificationItem): void {
const show = TOAST_BY_SEVERITY[notification.severity] ?? TOAST_BY_SEVERITY.info;
// A toast carries at most one action button — sonner has one `action` slot,
// and a notification needing more than one belongs on a banner or an alert.
const action = notification.actions?.[0];

show(notification.title, {
description: notification.message,
duration: notification.duration === 0 ? Infinity : notification.duration,
dismissible: notification.dismissible,
...(action ? { action: { label: action.label, onClick: action.onClick } } : {}),
});
}
55 changes: 39 additions & 16 deletions packages/app-shell/src/console/ConsoleShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import { Suspense, useEffect, useRef, useState, type ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { AuthGuard, useAuth, createAuthenticatedFetch } from '@object-ui/auth';
import { useObjectTranslation } from '@object-ui/i18n';
import { SchemaRendererProvider, ActionProvider } from '@object-ui/react';
import { SchemaRendererProvider, ActionProvider, NotificationProvider } from '@object-ui/react';
import { NotificationAlerts, NotificationSnackbar } from '@object-ui/components';
import { presentNotificationToast } from '../chrome/notificationToast';
import { useActionModal } from '../hooks/useActionModal';
import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime';
import { createObjectStackUserStateAdapter } from '@object-ui/data-objectstack';
Expand Down Expand Up @@ -101,8 +103,9 @@ function GlobalActionRuntimeProvider({ dataSource, children }: { dataSource: unk
/**
* ConsoleShell — top-level provider stack shared by every console route.
* Wraps children in ThemeProvider + NavigationProvider + FavoritesProvider +
* Suspense so lazy route components get a default loading fallback and
* dark/light/system theme switching works out of the box.
* NotificationProvider + Suspense so lazy route components get a default
* loading fallback, dark/light/system theme switching, and a notification
* system that honors the spec `displayType`.
*
* Place this inside a <BrowserRouter> and around your <Routes>:
*
Expand All @@ -111,23 +114,43 @@ function GlobalActionRuntimeProvider({ dataSource, children }: { dataSource: unk
* <Routes>...</Routes>
* </ConsoleShell>
* </BrowserRouter>
*
* ── Notification surfaces (#3014) ──
* Each spec display type has its own presentation, so the shell mounts the ones
* with a single global home and leaves the rest to their owners:
*
* toast → `presentNotificationToast` (sonner, via `onToast`)
* snackbar → <NotificationSnackbar /> here — it anchors itself bottom-center
* alert → <NotificationAlerts /> here — a blocking dialog is global
* banner → <NotificationBanners /> in `ConsoleLayout`, at the top of the
* content area, next to the draft /
* unpublished bars it sits with
* inline → nothing here, by contract — an inline notification is rendered
* by the surface that RAISED it (`<NotificationInline scope=…/>`),
* which is the whole difference between it and a banner
*/
export function ConsoleShell({ children }: { children: ReactNode }) {
return (
<ThemeProvider defaultTheme="system" storageKey="object-ui-theme">
<NavigationProvider>
<UserStateAdaptersProvider>
<FavoritesProvider>
<RecentItemsProvider>
<FlowPaletteRecentsProvider>
<Suspense fallback={<LoadingFallback />}>{children}</Suspense>
{/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */}
<RemediationOverlay />
</FlowPaletteRecentsProvider>
</RecentItemsProvider>
</FavoritesProvider>
</UserStateAdaptersProvider>
</NavigationProvider>
{/* `defaultDuration` matches ConsoleToaster's 4s toast default, so a
snackbar and a toast raised together disappear together. */}
<NotificationProvider config={{ defaultDuration: 4000, maxVisible: 4 }} onToast={presentNotificationToast}>
<NavigationProvider>
<UserStateAdaptersProvider>
<FavoritesProvider>
<RecentItemsProvider>
<FlowPaletteRecentsProvider>
<Suspense fallback={<LoadingFallback />}>{children}</Suspense>
{/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */}
<RemediationOverlay />
<NotificationSnackbar />
<NotificationAlerts />
</FlowPaletteRecentsProvider>
</RecentItemsProvider>
</FavoritesProvider>
</UserStateAdaptersProvider>
</NavigationProvider>
</NotificationProvider>
</ThemeProvider>
);
}
Expand Down
Loading
Loading