mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
fix: give Calendar, Contacts, Files and Settings their own error boundary and show one error fallback with Try again everywhere
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { CalendarApp } from "@/components/calendar/calendar-app";
|
||||
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
|
||||
import { AppErrorBoundary } from "@/components/error/app-error-boundary";
|
||||
|
||||
// Calendar permalinks (#733):
|
||||
// /calendar the stored view, today
|
||||
@@ -17,5 +18,9 @@ export default async function CalendarRoute({
|
||||
params: Promise<{ segments?: string[] }>;
|
||||
}) {
|
||||
const { segments } = await params;
|
||||
return <CalendarApp linkSegments={segments ?? []} />;
|
||||
return (
|
||||
<AppErrorBoundary>
|
||||
<CalendarApp linkSegments={segments ?? []} />
|
||||
</AppErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ContactsApp } from "@/components/contacts/contacts-app";
|
||||
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
|
||||
import { AppErrorBoundary } from "@/components/error/app-error-boundary";
|
||||
|
||||
// Contact permalinks (#733): /contacts/<contactId>[/edit], plus /contacts/new
|
||||
// for the "add this sender" flow. The older query form (?contactId=, ?addEmail=)
|
||||
@@ -15,5 +16,9 @@ export default async function ContactsRoute({
|
||||
params: Promise<{ segments?: string[] }>;
|
||||
}) {
|
||||
const { segments } = await params;
|
||||
return <ContactsApp linkSegments={segments ?? []} />;
|
||||
return (
|
||||
<AppErrorBoundary>
|
||||
<ContactsApp linkSegments={segments ?? []} />
|
||||
</AppErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertCircle, RefreshCw, Home } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { ErrorState } from "@/components/error/error-fallbacks";
|
||||
|
||||
/**
|
||||
* Route-level error boundary for locale pages.
|
||||
@@ -18,35 +16,19 @@ export default function LocaleError({
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations("errors");
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
console.error("Route error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center max-w-md px-4">
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
|
||||
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-2">
|
||||
{t("page_error_title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{t("page_error_description")}
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button variant="outline" onClick={() => router.push('/')}>
|
||||
<Home className="w-4 h-4 me-2" />
|
||||
{t("go_home")}
|
||||
</Button>
|
||||
<Button onClick={reset}>
|
||||
<RefreshCw className="w-4 h-4 me-2" />
|
||||
{t("try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorState
|
||||
className="min-h-screen"
|
||||
title={t("page_error_title")}
|
||||
description={t("page_error_description")}
|
||||
onRetry={reset}
|
||||
retryLabel={t("try_again")}
|
||||
homeLabel={t("go_home")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FilesApp } from "@/components/files/files-app";
|
||||
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
|
||||
import { AppErrorBoundary } from "@/components/error/app-error-boundary";
|
||||
|
||||
// Files permalinks (#733): /files/<folder>/<subfolder>, one path segment per
|
||||
// level, with ?preview=<name> to open a file straight into the preview modal.
|
||||
@@ -14,5 +15,9 @@ export default async function FilesRoute({
|
||||
params: Promise<{ segments?: string[] }>;
|
||||
}) {
|
||||
const { segments } = await params;
|
||||
return <FilesApp linkSegments={segments ?? []} />;
|
||||
return (
|
||||
<AppErrorBoundary>
|
||||
<FilesApp linkSegments={segments ?? []} />
|
||||
</AppErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SettingsApp } from "@/components/settings/settings-app";
|
||||
import { generateLiteSegmentParams } from "@/lib/lite-static-params";
|
||||
import { AppErrorBoundary } from "@/components/error/app-error-boundary";
|
||||
|
||||
// Settings permalinks (#733): /settings/<tabId>. Without a tab the page falls
|
||||
// back to the last tab the user had open, as before.
|
||||
@@ -14,5 +15,9 @@ export default async function SettingsRoute({
|
||||
params: Promise<{ segments?: string[] }>;
|
||||
}) {
|
||||
const { segments } = await params;
|
||||
return <SettingsApp linkSegments={segments ?? []} />;
|
||||
return (
|
||||
<AppErrorBoundary kind="settings">
|
||||
<SettingsApp linkSegments={segments ?? []} />
|
||||
</AppErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// One error fallback everywhere: the same "Try again", a way back to the
|
||||
// inbox outside mail, and each app has a boundary of its own that keeps the
|
||||
// navigation on screen.
|
||||
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({
|
||||
NavigationRail: ({ orientation }: { orientation?: string }) => <nav aria-label={`rail-${orientation ?? 'vertical'}`} />,
|
||||
}));
|
||||
|
||||
const { AppErrorBoundary } = await import('../app-error-boundary');
|
||||
const { ErrorBoundary } = await import('../error-boundary');
|
||||
const { EmailListErrorFallback, SidebarErrorFallback, SettingsErrorFallback } = await import('../error-fallbacks');
|
||||
|
||||
let broken = true;
|
||||
function Flaky() {
|
||||
if (broken) throw new Error('boom');
|
||||
return <p>calendar works</p>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
broken = true;
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('app error boundary', () => {
|
||||
it('shows the fallback next to the navigation and recovers on Try again', () => {
|
||||
render(<AppErrorBoundary><Flaky /></AppErrorBoundary>);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('page_error_title');
|
||||
expect(screen.getByRole('navigation', { name: 'rail-vertical' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'go_home' })).toBeInTheDocument();
|
||||
|
||||
broken = false;
|
||||
fireEvent.click(screen.getByRole('button', { name: 'try_again' }));
|
||||
expect(screen.getByText('calendar works')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('section fallbacks', () => {
|
||||
it.each([
|
||||
['message list', EmailListErrorFallback],
|
||||
['folder sidebar', SidebarErrorFallback],
|
||||
['settings', SettingsErrorFallback],
|
||||
])('the %s offers the same "Try again"', (_name, fallback) => {
|
||||
render(<ErrorBoundary fallback={fallback}><Flaky /></ErrorBoundary>);
|
||||
expect(screen.getByRole('button', { name: 'try_again' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('inside mail, does not offer to go to the inbox it is already in', () => {
|
||||
render(<ErrorBoundary fallback={EmailListErrorFallback}><Flaky /></ErrorBoundary>);
|
||||
expect(screen.queryByRole('link', { name: 'go_home' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { ErrorBoundary, type FallbackProps } from "./error-boundary";
|
||||
import { PageErrorFallback, SettingsErrorFallback } from "./error-fallbacks";
|
||||
|
||||
function AppErrorLayout({ kind, ...props }: FallbackProps & { kind: "page" | "settings" }) {
|
||||
const isMobile = useIsMobile();
|
||||
const Fallback = kind === "settings" ? SettingsErrorFallback : PageErrorFallback;
|
||||
// The navigation stays, so one app crashing doesn't strand the user in it.
|
||||
return (
|
||||
<div className="flex h-dvh bg-background pt-[env(safe-area-inset-top)]">
|
||||
{!isMobile && (
|
||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0 border-e border-border">
|
||||
<NavigationRail collapsed />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Fallback {...props} />
|
||||
{isMobile && <NavigationRail orientation="horizontal" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps one app (Calendar, Contacts, Files, Settings): a crash in it shows
|
||||
* the shared error fallback next to the navigation, instead of the route
|
||||
* error page in place of everything.
|
||||
*/
|
||||
export function AppErrorBoundary({ children, kind = "page" }: { children: ReactNode; kind?: "page" | "settings" }) {
|
||||
return (
|
||||
<ErrorBoundary fallback={(props) => <AppErrorLayout kind={kind} {...props} />}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, RefreshCw, Inbox, Mail, Settings, FolderOpen } from "@/components/icons";
|
||||
import { AlertCircle, RefreshCw } from "@/components/icons";
|
||||
import type { AppIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FallbackProps } from "./error-boundary";
|
||||
|
||||
export interface ErrorStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Puts the part back together: re-renders it, or reloads the route. */
|
||||
onRetry: () => void;
|
||||
retryLabel: string;
|
||||
/** Offers a way out to the inbox; left out inside mail itself. */
|
||||
homeLabel?: string;
|
||||
icon?: AppIcon;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-page error fallback for route-level errors.
|
||||
* The one error fallback: icon, title, a line of text, "Try again", and "Go
|
||||
* to inbox" where the inbox is somewhere else. Every error boundary draws
|
||||
* this, at whatever size its pane has.
|
||||
*/
|
||||
export function PageErrorFallback({ error: _error, resetError, t }: FallbackProps) {
|
||||
export function ErrorState({ title, description, onRetry, retryLabel, homeLabel, icon: Icon = AlertCircle, className }: ErrorStateProps) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-center max-w-md px-4">
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
|
||||
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-2">
|
||||
{t("page_error_title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{t("page_error_description")}
|
||||
</p>
|
||||
<Button onClick={resetError}>
|
||||
<div role="alert" className={cn("flex flex-1 h-full flex-col items-center justify-center p-8 text-center bg-background", className)}>
|
||||
<div className="w-12 h-12 mb-4 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
|
||||
<Icon className="w-6 h-6 text-red-600 dark:text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-2">{title}</h2>
|
||||
{description && <p className="text-sm text-muted-foreground mb-6 max-w-md">{description}</p>}
|
||||
<div className={cn("flex flex-wrap gap-3 justify-center", !description && "mt-4")}>
|
||||
{homeLabel && (
|
||||
// A full load: whatever broke starts over.
|
||||
<a
|
||||
href={`${getPathPrefix()}/`}
|
||||
className="inline-flex items-center justify-center h-9 px-3 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
{homeLabel}
|
||||
</a>
|
||||
)}
|
||||
<Button size="sm" onClick={onRetry}>
|
||||
<RefreshCw className="w-4 h-4 me-2" />
|
||||
{t("try_again")}
|
||||
{retryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar error fallback - matches sidebar width (256px).
|
||||
*/
|
||||
/** Full-page error fallback, also used around each app (AppErrorBoundary). */
|
||||
export function PageErrorFallback({ resetError, t }: FallbackProps) {
|
||||
return (
|
||||
<ErrorState
|
||||
title={t("page_error_title")}
|
||||
description={t("page_error_description")}
|
||||
onRetry={resetError}
|
||||
retryLabel={t("try_again")}
|
||||
homeLabel={t("go_home")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mail's folder sidebar (256px wide). */
|
||||
export function SidebarErrorFallback({ resetError, t }: FallbackProps) {
|
||||
return (
|
||||
<div className="w-64 h-full border-e border-border bg-secondary flex flex-col items-center justify-center p-4">
|
||||
<FolderOpen className="w-10 h-10 text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground text-center mb-4">
|
||||
{t("sidebar_error")}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={resetError}>
|
||||
<RefreshCw className="w-3 h-3 me-1" />
|
||||
{t("reload")}
|
||||
</Button>
|
||||
</div>
|
||||
<ErrorState className="w-64 flex-none border-e border-border bg-secondary p-4" title={t("sidebar_error")} onRetry={resetError} retryLabel={t("try_again")} />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Email list error fallback - matches email list panel width (384px).
|
||||
*/
|
||||
/** Mail's message list. */
|
||||
export function EmailListErrorFallback({ resetError, t }: FallbackProps) {
|
||||
return (
|
||||
<div className="w-full h-full bg-background flex flex-col items-center justify-center p-4">
|
||||
<Inbox className="w-12 h-12 text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground text-center mb-4">
|
||||
{t("email_list_error")}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={resetError}>
|
||||
<RefreshCw className="w-4 h-4 me-2" />
|
||||
{t("reload_emails")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
return <ErrorState className="w-full p-4" title={t("email_list_error")} onRetry={resetError} retryLabel={t("try_again")} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email viewer error fallback - fills remaining space (flex-1).
|
||||
*/
|
||||
/** Mail's reading pane. */
|
||||
export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center bg-muted/30 p-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-50 dark:bg-red-900/20 flex items-center justify-center">
|
||||
<Mail className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{t("viewer_error_title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground text-center mb-6 max-w-md">
|
||||
{t("viewer_error_description")}
|
||||
</p>
|
||||
<Button onClick={resetError}>
|
||||
<RefreshCw className="w-4 h-4 me-2" />
|
||||
{t("try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
<ErrorState
|
||||
title={t("viewer_error_title")}
|
||||
description={t("viewer_error_description")}
|
||||
onRetry={resetError}
|
||||
retryLabel={t("try_again")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Email composer modal error fallback.
|
||||
*/
|
||||
/** The composer. */
|
||||
export function ComposerErrorFallback({ resetError, t }: FallbackProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background border rounded-lg items-center justify-center p-8">
|
||||
<AlertCircle className="w-10 h-10 text-warning mb-3" />
|
||||
<p className="text-sm text-muted-foreground text-center mb-4">
|
||||
{t("composer_error")}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={resetError}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
return <ErrorState className="border rounded-lg" title={t("composer_error")} onRetry={resetError} retryLabel={t("try_again")} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings page error fallback.
|
||||
*/
|
||||
/** Settings. */
|
||||
export function SettingsErrorFallback({ resetError, t }: FallbackProps) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8">
|
||||
<Settings className="w-12 h-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{t("settings_error_title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground text-center mb-6">
|
||||
{t("settings_error_description")}
|
||||
</p>
|
||||
<Button onClick={resetError}>
|
||||
<RefreshCw className="w-4 h-4 me-2" />
|
||||
{t("reload_settings")}
|
||||
</Button>
|
||||
</div>
|
||||
<ErrorState
|
||||
title={t("settings_error_title")}
|
||||
description={t("settings_error_description")}
|
||||
onRetry={resetError}
|
||||
retryLabel={t("try_again")}
|
||||
homeLabel={t("go_home")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export { ErrorBoundary } from "./error-boundary";
|
||||
export { AppErrorBoundary } from "./app-error-boundary";
|
||||
export type { FallbackProps } from "./error-boundary";
|
||||
export {
|
||||
ErrorState,
|
||||
PageErrorFallback,
|
||||
SidebarErrorFallback,
|
||||
EmailListErrorFallback,
|
||||
|
||||
Reference in New Issue
Block a user