fix(settings): make settings read failures foreground and recoverable

A family of settings surfaces rendered a failed read as a bare
text-destructive line with no way to recover: the security sessions,
trusted-devices and activity cards, the coach memory and reminders
sections, the about-me section, and the document link/share pickers. Swap
each to the shared QueryErrorRow primitive with a retry control. The suite
covering the statically renderable members was watched fail first against
the bare lines; the two portaled pickers take the same swap.
This commit is contained in:
Marc-André Bombeck committed 2026-08-09 04:26:46 +02:00
1 parent f63fdb373e
commit 44582008e9
9 files changed
+108 -39

No files matched your search

@@ -20,6 +20,7 @@ import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
@@ -132,9 +133,10 @@ export function DocumentLinkPicker({
))}
</div>
) : list.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.list.loadError")}
</p>
<QueryErrorRow
message={t("documents.list.loadError")}
onRetry={() => list.refetch()}
/>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("documents.linkPicker.empty")}
@@ -0,0 +1,65 @@
/**
* The bare-destructive read-failure family: several settings cards rendered a
* failed read as a bare `text-destructive` line with no way to recover. Each is
* forced into its query error state and must now render the shared
* `query-error-row` primitive with a retry control — an alert is foreground
* content, and a read failure has to be retryable.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: undefined,
isLoading: false,
isError: true,
refetch: vi.fn(),
}),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
reset: vi.fn(),
}),
useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings",
useSearchParams: () => new URLSearchParams(),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { SecuritySessionsCard } from "../security-sessions-card";
import { SecurityActivityCard } from "../security-activity-card";
import { TrustedDevicesCard } from "../trusted-devices-card";
import { CoachMemorySection } from "../coach-memory-section";
import { CoachRemindersSection } from "../coach-reminders-section";
import { AboutMeSection } from "../about-me-section";
function renderCard(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const cases: Array<[string, React.ReactNode]> = [
["SecuritySessionsCard", <SecuritySessionsCard isAuthenticated />],
["SecurityActivityCard", <SecurityActivityCard isAuthenticated />],
["TrustedDevicesCard", <TrustedDevicesCard isAuthenticated />],
["CoachMemorySection", <CoachMemorySection isAuthenticated />],
["CoachRemindersSection", <CoachRemindersSection isAuthenticated />],
["AboutMeSection", <AboutMeSection isAuthenticated />],
];
describe("settings read-error family — a read failure is foreground and recoverable", () => {
it.each(cases)("%s renders a query-error row with retry", (_name, node) => {
const html = renderCard(node);
expect(html).toContain('data-slot="query-error-row"');
expect(html).toContain('data-slot="query-error-row-retry"');
// No bare red line: the alert is the primitive now.
expect(html).not.toContain('<p role="alert" class="text-destructive');
});
});
+5 -7
View File
@@ -49,6 +49,7 @@ import {
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { Label } from "@/components/ui/label";
import { ConfirmButton } from "@/components/ui/confirm-button";
@@ -325,13 +326,10 @@ export function AboutMeSection({
)}
{query.isError && (
<p
role="status"
aria-live="polite"
className="text-destructive text-sm"
>
{t("settings.ai.aboutMe.loadError")}
</p>
<QueryErrorRow
message={t("settings.ai.aboutMe.loadError")}
onRetry={() => query.refetch()}
/>
)}
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
@@ -34,6 +34,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
@@ -152,13 +153,10 @@ export function CoachMemorySection({
<p className="text-sm">{t("settings.ai.coachMemory.detail")}</p>
{query.isError && (
<p
role="status"
aria-live="polite"
className="text-destructive text-sm"
>
{t("settings.ai.coachMemory.loadError")}
</p>
<QueryErrorRow
message={t("settings.ai.coachMemory.loadError")}
onRetry={() => query.refetch()}
/>
)}
{!query.isError && facts.length === 0 ? (
@@ -22,6 +22,7 @@ import { BellRing, Check, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { formatDateOrRelative } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -165,13 +166,10 @@ export function CoachRemindersSection({
<p className="text-sm">{t("settings.ai.coachReminders.detail")}</p>
{query.isError && (
<p
role="status"
aria-live="polite"
className="text-destructive text-sm"
>
{t("settings.ai.coachReminders.loadError")}
</p>
<QueryErrorRow
message={t("settings.ai.coachReminders.loadError")}
onRetry={() => query.refetch()}
/>
)}
{!query.isError && reminders.length === 0 ? (
@@ -15,6 +15,7 @@ import { ChevronDown, Loader2, ShieldCheck } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
@@ -62,7 +63,7 @@ export function SecurityActivityCard({
const [open, setOpen] = useState(false);
const regionId = useId();
const { data, isLoading, isError } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.securityActivity(),
queryFn: () =>
apiGet<{ events: ActivityRow[] }>("/api/auth/me/security-activity"),
@@ -102,9 +103,10 @@ export function SecurityActivityCard({
)}
{isError && (
<p role="alert" className="text-destructive text-sm">
{t("settings.security.activityLoadError")}
</p>
<QueryErrorRow
message={t("settings.security.activityLoadError")}
onRetry={() => refetch()}
/>
)}
{!isLoading && !isError && events.length === 0 && (
@@ -18,6 +18,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
@@ -48,7 +49,7 @@ export function SecuritySessionsCard({
const [open, setOpen] = useState(false);
const regionId = useId();
const { data, isLoading, isError } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.sessions(),
queryFn: () => apiGet<{ sessions: SessionRow[] }>("/api/auth/me/sessions"),
enabled: isAuthenticated,
@@ -117,9 +118,10 @@ export function SecuritySessionsCard({
)}
{isError && (
<p role="alert" className="text-destructive text-sm">
{t("settings.security.sessionsLoadError")}
</p>
<QueryErrorRow
message={t("settings.security.sessionsLoadError")}
onRetry={() => refetch()}
/>
)}
{!isLoading && !isError && sessions.length === 0 && (
@@ -19,6 +19,7 @@ import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
@@ -152,9 +153,10 @@ export function ShareDocumentPicker({
))}
</div>
) : list.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.list.loadError")}
</p>
<QueryErrorRow
message={t("documents.list.loadError")}
onRetry={() => list.refetch()}
/>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("settings.sharing.pickerEmpty")}
@@ -15,6 +15,7 @@ import { Loader2, ShieldCheck } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -39,7 +40,7 @@ export function TrustedDevicesCard({
const queryClient = useQueryClient();
const [status, setStatus] = useState<string | null>(null);
const { data, isLoading, isError } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.trustedDevices(),
queryFn: () =>
apiGet<{ devices: TrustedDeviceRow[] }>("/api/auth/me/trusted-devices"),
@@ -102,9 +103,10 @@ export function TrustedDevicesCard({
)}
{isError && (
<p role="alert" className="text-destructive text-sm">
{t("settings.security.trustedDevices.loadError")}
</p>
<QueryErrorRow
message={t("settings.security.trustedDevices.loadError")}
onRetry={() => refetch()}
/>
)}
{devices.length > 0 && (