mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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');
|
||||
});
|
||||
});
|
||||
@@ -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 && (
|
||||
|
||||
Reference in new issue
Block a user