mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: report admin results in the app's toasts instead of a banner under the page header
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
|
||||
|
||||
// The admin reports what an action did in the app's toasts, like Settings,
|
||||
// not in a banner under the page header.
|
||||
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
|
||||
vi.mock('@/stores/toast-store', () => ({ toast, useToastStore: () => [] }));
|
||||
let fail = false;
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'POST' && fail) {
|
||||
return new Response(JSON.stringify({ error: 'not a URL' }), { status: 400, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
consent: 'off', consentSource: 'file', endpoint: 'https://t.example', defaultEndpoint: 'https://t.example',
|
||||
consentedAt: null, lastSentAt: null, nextScheduledAt: null, payloadPreview: {}, accountCounts: { total: 0, active7d: 0 },
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}),
|
||||
withBasePath: (p: string) => p,
|
||||
}));
|
||||
vi.mock('next/link', () => ({ default: ({ children }: { children: React.ReactNode }) => children }));
|
||||
|
||||
import { TelemetryPage } from '../_pages/telemetry';
|
||||
|
||||
afterEach(() => {
|
||||
fail = false;
|
||||
toast.success.mockClear();
|
||||
toast.error.mockClear();
|
||||
useAdminUnsavedStore.setState({ dirty: {}, pendingLeave: null, pendingScope: null });
|
||||
});
|
||||
|
||||
async function saveEndpoint(value: string) {
|
||||
render(<TelemetryPage />);
|
||||
fireEvent.change(await screen.findByLabelText('Collector URL'), { target: { value } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
}
|
||||
|
||||
describe('admin feedback', () => {
|
||||
it('says a save worked in a success toast, with no banner', async () => {
|
||||
await saveEndpoint('https://collector.example');
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Telemetry settings saved.'));
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
it('says a save failed in an error toast', async () => {
|
||||
fail = true;
|
||||
await saveEndpoint('nope');
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("Couldn't save: Endpoint: not a URL"));
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,15 @@ import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
|
||||
import { AdminShell } from '@/components/admin/admin-shell';
|
||||
import { UnsavedChangesGuard } from './_unsaved-changes-guard';
|
||||
import { ToastContainer } from '@/components/ui/toast';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
|
||||
/** The app's toasts, which the admin reports its results in (R9). */
|
||||
function AdminToasts() {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
const removeToast = useToastStore((s) => s.removeToast);
|
||||
return <ToastContainer toasts={toasts} onClose={removeToast} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gates the admin panel behind the admin session and draws its shell. The
|
||||
@@ -107,12 +116,13 @@ export function AdminLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Don't gate the login page
|
||||
if (pathname === '/admin/login') {
|
||||
return <>{children}</>;
|
||||
return <>{children}<AdminToasts /></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<UnsavedChangesGuard />
|
||||
<AdminToasts />
|
||||
<AdminShell
|
||||
authenticated={authenticated === true}
|
||||
isStalwartAutoLogin={isStalwartAutoLogin}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SettingsSection, SettingItem, SectionNav } from '@/components/settings/
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
import { Flash, useFlash } from '@/components/admin/flash-message';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { ConfigTextItem, ConfigToggleItem, ConfigSelectItem, inputClass } from '@/components/admin/config-items';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -30,7 +30,7 @@ export function AuthPage() {
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useFlash();
|
||||
const setMessage = useAdminFeedback();
|
||||
const [configReadOnly, setConfigReadOnly] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -163,7 +163,6 @@ export function AuthPage() {
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
{setupOpen && (
|
||||
<div
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
import { Badge } from '@/components/admin/badge';
|
||||
import { Flash, useFlash } from '@/components/admin/flash-message';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { inputClass, RevertButton } from '@/components/admin/config-items';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
@@ -79,7 +79,7 @@ export function BrandingPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [message, setMessage] = useFlash();
|
||||
const setMessage = useAdminFeedback();
|
||||
const [selectedHost, setSelectedHost] = useState<string | null>(null);
|
||||
const [addingHost, setAddingHost] = useState(false);
|
||||
const [newHostInput, setNewHostInput] = useState('');
|
||||
@@ -431,7 +431,6 @@ export function BrandingPage() {
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
<ConfirmDialog {...confirmProps} />
|
||||
|
||||
<div className="space-y-10">
|
||||
|
||||
@@ -15,7 +15,7 @@ import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { AdminLink } from '@/components/admin/admin-link';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
import { Badge } from '@/components/admin/badge';
|
||||
import { Flash, useFlash } from '@/components/admin/flash-message';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { inputClass } from '@/components/admin/config-items';
|
||||
import { ExtensionIcon } from '@/components/admin/extension-icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -84,7 +84,7 @@ export function ExtensionDetailPage({ kind, id }: Props) {
|
||||
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [message, setMessage] = useFlash();
|
||||
const setMessage = useAdminFeedback();
|
||||
const iconFor = useExtensionIcons();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -354,7 +354,6 @@ export function ExtensionDetailPage({ kind, id }: Props) {
|
||||
|
||||
{entry.description && <p className="-mt-2 mb-6 text-sm text-foreground">{entry.description}</p>}
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
<ConfirmDialog {...confirmProps} />
|
||||
|
||||
<div className="space-y-10">
|
||||
|
||||
@@ -35,7 +35,7 @@ import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { AdminLink } from '@/components/admin/admin-link';
|
||||
import { AdminButtonLink } from '@/components/admin/button-link';
|
||||
import { Badge } from '@/components/admin/badge';
|
||||
import { Flash, useFlash } from '@/components/admin/flash-message';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { installVersion } from './marketplace';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
|
||||
@@ -138,7 +138,7 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [uninstalling, setUninstalling] = useState(false);
|
||||
const [message, setMessage] = useFlash();
|
||||
const setMessage = useAdminFeedback();
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const [showManifest, setShowManifest] = useState(false);
|
||||
// The version being previewed, from the install link (?version=) or a row
|
||||
@@ -429,7 +429,6 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
<ConfirmDialog {...confirmProps} />
|
||||
|
||||
<div className="mb-6 space-y-3 empty:hidden">
|
||||
|
||||
@@ -18,7 +18,7 @@ import { AdminButtonLink } from '@/components/admin/button-link';
|
||||
import { AdminList, AdminListRow } from '@/components/admin/list';
|
||||
import { Badge } from '@/components/admin/badge';
|
||||
import { EmptyState } from '@/components/admin/empty-state';
|
||||
import { Flash, useFlash } from '@/components/admin/flash-message';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { ConfigNote, inputClass } from '@/components/admin/config-items';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
|
||||
@@ -139,7 +139,7 @@ export function MarketplacePage() {
|
||||
const [directoryUrl, setDirectoryUrl] = useState<string | null>(null);
|
||||
const [channel, setChannel] = useState<'stable' | 'beta'>('stable');
|
||||
const [installing, setInstalling] = useState<Set<string>>(new Set());
|
||||
const [message, setMessage] = useFlash();
|
||||
const setMessage = useAdminFeedback();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [updates, setUpdates] = useState<UpdatesResult | null>(null);
|
||||
const [updatesLoading, setUpdatesLoading] = useState(true);
|
||||
@@ -270,7 +270,6 @@ export function MarketplacePage() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
<ConfirmDialog {...confirmProps} />
|
||||
|
||||
<div className="space-y-10">
|
||||
|
||||
@@ -16,7 +16,7 @@ import { AdminList, AdminListRow } from '@/components/admin/list';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
import { Badge } from '@/components/admin/badge';
|
||||
import { EmptyState } from '@/components/admin/empty-state';
|
||||
import { Flash, useFlash } from '@/components/admin/flash-message';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { ExtensionIcon } from '@/components/admin/extension-icon';
|
||||
import { PluginApprovalsPanel } from './plugin-approvals-panel';
|
||||
import { useExtensionIcons } from './use-extension-icons';
|
||||
@@ -26,7 +26,7 @@ export function PluginsPage() {
|
||||
const [plugins, setPlugins] = useState<ExtensionEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [message, setMessage] = useFlash();
|
||||
const setMessage = useAdminFeedback();
|
||||
// On/off per plugin waits for Save, like the policy switches below.
|
||||
const [enabledDraft, setEnabledDraft] = useState<Record<string, boolean>>({});
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -184,7 +184,6 @@ export function PluginsPage() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
<div className="space-y-10">
|
||||
<PluginApprovalsPanel />
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
import { Badge } from '@/components/admin/badge';
|
||||
import { Flash, useFlash } from '@/components/admin/flash-message';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { inputClass } from '@/components/admin/config-items';
|
||||
|
||||
// `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized
|
||||
@@ -98,7 +98,7 @@ export function PolicyPage() {
|
||||
const [savedPolicy, setSavedPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useFlash();
|
||||
const setMessage = useAdminFeedback();
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => { fetchPolicy(); }, []);
|
||||
@@ -291,7 +291,6 @@ export function PolicyPage() {
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
|
||||
import { SettingsSection, SectionNav } from '@/components/settings/settings-section';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
import { Flash, useFlash } from '@/components/admin/flash-message';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { ConfigTextItem, ConfigToggleItem, ConfigSelectItem, ConfigNote } from '@/components/admin/config-items';
|
||||
import { JmapServersSection } from './_jmap-servers-section';
|
||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
@@ -26,7 +26,7 @@ export function SettingsPage() {
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useFlash();
|
||||
const setMessage = useAdminFeedback();
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
@@ -111,7 +111,6 @@ export function SettingsPage() {
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
<div className="space-y-10">
|
||||
<SectionNav />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/setting
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
import { Flash, useFlash } from '@/components/admin/flash-message';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { ConfigNote, inputClass } from '@/components/admin/config-items';
|
||||
import { adminPageDef } from '@/lib/admin/admin-nav';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
@@ -45,7 +45,7 @@ export function TelemetryPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [endpointDraft, setEndpointDraft] = useState('');
|
||||
const [message, setMessage] = useFlash();
|
||||
const setMessage = useAdminFeedback();
|
||||
// The heartbeat switch waits for Save like the endpoint (one page, one
|
||||
// save bar).
|
||||
const [consentDraft, setConsentDraft] = useState<'on' | 'off' | null>(null);
|
||||
@@ -149,7 +149,6 @@ export function TelemetryPage() {
|
||||
<div>
|
||||
{header}
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
<div className="space-y-10">
|
||||
<ConfigNote>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { AdminList, AdminListRow } from '@/components/admin/list';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
import { Badge } from '@/components/admin/badge';
|
||||
import { EmptyState } from '@/components/admin/empty-state';
|
||||
import { Flash, useFlash } from '@/components/admin/flash-message';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { inputClass } from '@/components/admin/config-items';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ExtensionIcon } from '@/components/admin/extension-icon';
|
||||
@@ -42,7 +42,7 @@ export function ThemesPage() {
|
||||
const iconFor = useExtensionIcons();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [message, setMessage] = useFlash();
|
||||
const setMessage = useAdminFeedback();
|
||||
// On/off per theme waits for Save, like the policy switches below.
|
||||
const [enabledDraft, setEnabledDraft] = useState<Record<string, boolean>>({});
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -262,7 +262,6 @@ export function ThemesPage() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
<div className="space-y-10">
|
||||
<SettingsSection
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SettingsSection, SettingItem } from '@/components/settings/settings-sec
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { Badge, type BadgeTone } from '@/components/admin/badge';
|
||||
import { Flash, useFlash } from '@/components/admin/flash-message';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { adminPageDef } from '@/lib/admin/admin-nav';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
|
||||
@@ -64,7 +64,7 @@ export function VersionPage() {
|
||||
const [data, setData] = useState<VersionAdminStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [message, setMessage] = useFlash();
|
||||
const setMessage = useAdminFeedback();
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
setLoading(true);
|
||||
@@ -133,7 +133,6 @@ export function VersionPage() {
|
||||
<div>
|
||||
{header}
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
<div className="space-y-10">
|
||||
<SettingsSection id="status" title="Status">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Loader2 } from '@/components/icons';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { Flash, useFlash } from '@/components/admin/flash-message';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { inputClass } from '@/components/admin/config-items';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function ChangePasswordPage() {
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [message, setMessage] = useFlash();
|
||||
const setMessage = useAdminFeedback();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
@@ -61,7 +61,6 @@ export default function ChangePasswordPage() {
|
||||
<div className="max-w-md">
|
||||
<AdminPageHeader title="Change password" description="The password for this admin panel. At least 8 characters." back={{ href: '/admin', label: 'Overview' }} />
|
||||
|
||||
<Flash message={message} onDismiss={() => setMessage(null)} />
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{fields.map((field) => (
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Flash } from '../flash-message';
|
||||
|
||||
describe('Flash', () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('renders nothing without a message', () => {
|
||||
const { container } = render(<Flash message={null} onDismiss={() => {}} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('shows an error as an alert until dismissed', () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<Flash message={{ type: 'error', text: 'Save failed' }} onDismiss={onDismiss} />);
|
||||
expect(screen.getByRole('alert').textContent).toContain('Save failed');
|
||||
act(() => { vi.advanceTimersByTime(10_000); });
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lets a success go away by itself', () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<Flash message={{ type: 'success', text: 'Saved' }} onDismiss={onDismiss} />);
|
||||
expect(screen.getByRole('status').textContent).toContain('Saved');
|
||||
act(() => { vi.advanceTimersByTime(6_000); });
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
|
||||
export type AdminFeedback = { type: 'success' | 'error'; text: string } | null;
|
||||
|
||||
/**
|
||||
* Reports the outcome of an admin action in a toast, the same feedback the
|
||||
* rest of the app uses (R9). A success goes away by itself, an error stays
|
||||
* longer. `null` says nothing: it used to clear the page banner this
|
||||
* replaces, and pages still pass it before an action starts.
|
||||
*/
|
||||
export function useAdminFeedback(): (feedback: AdminFeedback) => void {
|
||||
return useCallback((feedback: AdminFeedback) => {
|
||||
if (!feedback) return;
|
||||
if (feedback.type === 'success') toast.success(feedback.text);
|
||||
else toast.error(feedback.text);
|
||||
}, []);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { CheckCircle2, AlertTriangle, X } from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type FlashMessage = { type: 'success' | 'error'; text: string } | null;
|
||||
|
||||
/** Page-level feedback state; the message is shown with `<Flash>`. */
|
||||
export function useFlash(): [FlashMessage, Dispatch<SetStateAction<FlashMessage>>] {
|
||||
return useState<FlashMessage>(null);
|
||||
}
|
||||
|
||||
interface FlashProps {
|
||||
message: FlashMessage;
|
||||
onDismiss: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The outcome of the last action, under the page header. A success goes
|
||||
* away by itself; an error stays until dismissed or the next action.
|
||||
*/
|
||||
export function Flash({ message, onDismiss, className }: FlashProps) {
|
||||
useEffect(() => {
|
||||
if (message?.type !== 'success') return;
|
||||
const timer = setTimeout(onDismiss, 6000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [message, onDismiss]);
|
||||
|
||||
if (!message) return null;
|
||||
const success = message.type === 'success';
|
||||
const Icon = success ? CheckCircle2 : AlertTriangle;
|
||||
return (
|
||||
<div
|
||||
role={success ? 'status' : 'alert'}
|
||||
className={cn(
|
||||
'mb-6 flex items-start gap-2.5 rounded-md border px-3 py-2.5 text-sm',
|
||||
success
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-300'
|
||||
: 'border-destructive/30 bg-destructive/10 text-destructive',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span className="flex-1 min-w-0 break-words">{message.text}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss"
|
||||
className="shrink-0 -me-1 rounded p-0.5 opacity-70 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user