Merge branch 'fix/v1437-review' into release/v1.43.7

# Conflicts:
#	src/app/api/analytics/route.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 06:20:42 +02:00
commit 45c027aee2
217 files changed
+3636 -2127

No files matched your search

@@ -0,0 +1,104 @@
/**
* A failed read is reported by what the failure means, and both read-failure
* shapes decide it the same way from the query's error:
*
* - 403: the viewer may not read this. Nothing a retry could fix; the surface
* draws its refused state, or nothing.
* - 429: the read budget is spent. A note, never a retry that would only be
* refused again.
* - 401: the session ended. Never a silent blank: the reader is offered the
* way back to sign-in.
* - anything else: a real failure, with a retry.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
import { ApiError } from "@/lib/api/api-fetch";
import { readFailureKind } from "@/lib/api/read-failure";
import { I18nProvider } from "@/lib/i18n/context";
import { QueryErrorCard } from "../query-error-card";
import { QueryErrorRow } from "../query-error-row";
function ssr(node: ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const row = (error: unknown, refused?: ReactNode) =>
ssr(
<QueryErrorRow
error={error}
refused={refused}
message="Could not load the strip."
onRetry={() => {}}
slot="probe"
/>,
);
const card = (error: unknown, refused?: ReactNode) =>
ssr(
<QueryErrorCard
error={error}
refused={refused}
title="Could not load the list."
description="Something on our side."
onRetry={() => {}}
/>,
);
describe("readFailureKind", () => {
it("names an ended session, a refusal, a spent budget and the rest", () => {
expect(readFailureKind(new ApiError("x", 401))).toBe("unauthenticated");
expect(readFailureKind(new ApiError("x", 403))).toBe("refused");
expect(readFailureKind(new ApiError("x", 429))).toBe("throttled");
expect(readFailureKind(new ApiError("x", 500))).toBe("failed");
expect(readFailureKind(new ApiError("x", 404))).toBe("failed");
expect(readFailureKind(new TypeError("Failed to fetch"))).toBe("failed");
expect(readFailureKind(null)).toBe("failed");
});
});
describe.each([
["<QueryErrorRow>", row],
["<QueryErrorCard>", card],
])("%s", (_name, render) => {
it("draws nothing for a refused read, or the surface's refused state", () => {
expect(render(new ApiError("Forbidden", 403))).toBe("");
expect(
render(
new ApiError("Forbidden", 403),
<p data-slot="probe-refused">Not shared with you.</p>,
),
).toBe('<p data-slot="probe-refused">Not shared with you.</p>');
});
it("says a spent budget without offering a retry", () => {
const html = render(new ApiError("Too many", 429));
expect(html).toContain("Too many requests just now.");
expect(html).not.toContain("retry");
expect(html).not.toContain("Could not load");
expect(html).not.toContain("Something on our side.");
});
it("offers sign-in for an ended session, never a blank", () => {
const html = render(new ApiError("Unauthorized", 401));
expect(html).not.toBe("");
expect(html).toContain("Your session has ended.");
expect(html).toContain('data-slot="query-error-sign-in"');
expect(html).not.toContain("retry");
});
it("offers a retry for a real failure", () => {
for (const error of [
new ApiError("Boom", 500),
new TypeError("Failed to fetch"),
]) {
const html = render(error);
expect(html).toContain("Could not load the");
expect(html).toMatch(/data-slot="query-error(-row)?-retry"/);
expect(html).not.toContain("query-error-sign-in");
}
});
});
@@ -1,9 +1,13 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ApiError } from "@/lib/api/api-fetch";
import { I18nProvider } from "@/lib/i18n/context";
import { QueryErrorRow } from "../query-error-row";
/** A real read failure; the status-specific shapes live in query-error-primitives. */
const failed = new ApiError("Boom", 500);
function ssr(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
@@ -12,7 +16,9 @@ function ssr(node: React.ReactNode) {
describe("<QueryErrorRow>", () => {
it("renders the compact alert row with foreground message text", () => {
const html = ssr(<QueryErrorRow message="Could not load vitals" />);
const html = ssr(
<QueryErrorRow error={failed} message="Could not load vitals" />,
);
expect(html).toContain('role="alert"');
expect(html).toContain('data-slot="query-error-row"');
expect(html).toContain("Could not load vitals");
@@ -25,13 +31,17 @@ describe("<QueryErrorRow>", () => {
});
it("falls back to the generic load-failed copy", () => {
expect(ssr(<QueryErrorRow />)).toContain("Could not load");
expect(ssr(<QueryErrorRow error={failed} />)).toContain("Could not load");
});
it("only renders the retry affordance when a handler is wired", () => {
expect(ssr(<QueryErrorRow message="boom" />)).not.toContain("<button");
expect(ssr(<QueryErrorRow error={failed} message="boom" />)).not.toContain(
"<button",
);
const withRetry = ssr(<QueryErrorRow message="boom" onRetry={() => {}} />);
const withRetry = ssr(
<QueryErrorRow error={failed} message="boom" onRetry={() => {}} />,
);
expect(withRetry).toContain("<button");
expect(withRetry).toContain("Retry");
expect(withRetry).toContain('data-slot="query-error-row-retry"');
@@ -40,6 +50,7 @@ describe("<QueryErrorRow>", () => {
it("honours the slot overrides the converted surfaces depend on", () => {
const html = ssr(
<QueryErrorRow
error={failed}
message="boom"
onRetry={() => {}}
slot="healthkit-metric-error"
@@ -54,7 +65,12 @@ describe("<QueryErrorRow>", () => {
it("takes a custom retry label", () => {
expect(
ssr(
<QueryErrorRow message="boom" onRetry={() => {}} retryLabel="Again" />,
<QueryErrorRow
error={failed}
message="boom"
onRetry={() => {}}
retryLabel="Again"
/>,
),
).toContain("Again");
});
@@ -1,62 +0,0 @@
/**
* A supplementary read that fails is reported by what the failure means.
*
* Several strips and tiles answer a failed read with a retry row. Two
* failures are not worth one: a refusal (a read-only share reaching a
* manage-level read, a grant without the section) cannot be retried into
* success and must draw nothing, and a spent read budget would only be
* refused again by an immediate retry.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ApiError } from "@/lib/api/api-fetch";
import { I18nProvider } from "@/lib/i18n/context";
import { ReadFailureRow, readFailureKind } from "../read-failure-row";
function ssr(error: unknown) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ReadFailureRow
error={error}
message="Could not load the strip."
onRetry={() => {}}
slot="probe"
/>
</I18nProvider>,
);
}
describe("readFailureKind", () => {
it("names refusals, a spent budget and everything else", () => {
expect(readFailureKind(new ApiError("x", 401))).toBe("denied");
expect(readFailureKind(new ApiError("x", 403))).toBe("denied");
expect(readFailureKind(new ApiError("x", 429))).toBe("throttled");
expect(readFailureKind(new ApiError("x", 500))).toBe("failed");
expect(readFailureKind(new ApiError("x", 404))).toBe("failed");
expect(readFailureKind(new TypeError("Failed to fetch"))).toBe("failed");
});
});
describe("<ReadFailureRow>", () => {
it("draws nothing for a refused read", () => {
expect(ssr(new ApiError("Forbidden", 403))).toBe("");
expect(ssr(new ApiError("Unauthorized", 401))).toBe("");
});
it("says a spent budget without offering a retry", () => {
const html = ssr(new ApiError("Too many", 429));
expect(html).toContain('data-slot="probe"');
expect(html).toContain("Too many requests just now.");
expect(html).not.toContain("query-error-row-retry");
});
it("offers a retry for a real failure", () => {
const html = ssr(new ApiError("Boom", 500));
expect(html).toContain("Could not load the strip.");
expect(html).toContain('data-slot="query-error-row-retry"');
expect(ssr(new TypeError("Failed to fetch"))).toContain(
'data-slot="query-error-row-retry"',
);
});
});
+33 -8
View File
@@ -5,7 +5,7 @@ import { AlertTriangle, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { useReadFailure } from "@/hooks/use-read-failure";
import { cn } from "@/lib/utils";
/**
@@ -18,21 +18,37 @@ import { cn } from "@/lib/utils";
* the generic `common.*` strings so a caller can drop it in with just
* `onRetry`; a surface with its own "couldn't load X" string passes it through
* `title` / `description`.
*
* The card reads the failure itself (`error`), exactly as `QueryErrorRow`
* does: a refusal draws `refused` (nothing by default), a spent budget says
* so without a retry, an ended session offers sign-in, and only a real
* failure shows `title` / `description` with `onRetry`.
*/
export function QueryErrorCard({
error,
refused = null,
title,
description,
onRetry,
retryLabel,
className,
}: {
/** The query's error. It decides what the card offers. */
error: unknown;
/** What a refused (403) read draws instead of the card. */
refused?: ReactNode;
title?: ReactNode;
description?: ReactNode;
onRetry?: () => void;
retryLabel?: string;
className?: string;
}) {
const { t } = useTranslations();
const failure = useReadFailure(error, { message: title, onRetry });
if (!failure) return <>{refused}</>;
const { action } = failure;
// The description belongs to a real failure; a spent budget or an ended
// session replaces the title, and the surface's own detail would not fit.
const detail = failure.kind === "failed" ? description : null;
return (
<Card data-slot="query-error-card" className={cn(className)}>
<CardContent
@@ -43,11 +59,11 @@ export function QueryErrorCard({
className="text-muted-foreground h-8 w-8"
aria-hidden="true"
/>
<p className="text-sm font-medium">{title ?? t("common.loadFailed")}</p>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
<p className="text-sm font-medium">{failure.message}</p>
{detail ? (
<p className="text-muted-foreground text-sm">{detail}</p>
) : null}
{onRetry ? (
{action?.kind === "retry" ? (
// The slot is the accessibility suite's handle on the one control
// this card offers: the error state has to be recoverable by
// keyboard, and asserting that against viewport text would pin the
@@ -56,10 +72,19 @@ export function QueryErrorCard({
data-slot="query-error-retry"
variant="outline"
size="sm"
onClick={onRetry}
onClick={action.onClick}
>
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
{retryLabel ?? t("common.retry")}
{retryLabel ?? action.label}
</Button>
) : action ? (
<Button
data-slot="query-error-sign-in"
variant="outline"
size="sm"
onClick={action.onClick}
>
{action.label}
</Button>
) : null}
</CardContent>
+31 -10
View File
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
import { AlertTriangle, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { useReadFailure } from "@/hooks/use-read-failure";
import { cn } from "@/lib/utils";
/**
@@ -23,11 +23,16 @@ import { cn } from "@/lib/utils";
* `QueryErrorRow`
* - chart body → `ChartErrorState`
*
* One deliberate change from the copies it replaces: the message renders in
* `text-foreground`. An alert IS the content of a failed surface, and §3
* reserves muted for meta — both copies had it muted.
* The row reads the failure itself (`error`): a refusal draws `refused`
* (nothing by default), a spent budget says so without a retry, an ended
* session offers sign-in, and only a real failure offers `onRetry`.
*
* The message renders in `text-foreground`: an alert IS the content of a
* failed surface, and §3 reserves muted for meta.
*/
export function QueryErrorRow({
error,
refused = null,
message,
onRetry,
retryLabel,
@@ -35,7 +40,11 @@ export function QueryErrorRow({
retrySlot,
className,
}: {
/** The failure line. Defaults to the generic `common.loadFailed`. */
/** The query's error. It decides what the row offers. */
error: unknown;
/** What a refused (403) read draws instead of the row. */
refused?: ReactNode;
/** The line for a real failure. Defaults to `common.loadFailed`. */
message?: ReactNode;
onRetry?: () => void;
retryLabel?: string;
@@ -49,7 +58,9 @@ export function QueryErrorRow({
retrySlot?: string;
className?: string;
}) {
const { t } = useTranslations();
const failure = useReadFailure(error, { message, onRetry });
if (!failure) return <>{refused}</>;
const { action } = failure;
return (
<div
data-slot={slot ?? "query-error-row"}
@@ -64,19 +75,29 @@ export function QueryErrorRow({
className="text-muted-foreground mt-0.5 size-4 shrink-0 sm:mt-0"
aria-hidden="true"
/>
<span>{message ?? t("common.loadFailed")}</span>
<span>{failure.message}</span>
</span>
{onRetry ? (
{action?.kind === "retry" ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={onRetry}
onClick={action.onClick}
data-slot={retrySlot ?? "query-error-row-retry"}
className="gap-1.5"
>
<RotateCw className="size-3.5" aria-hidden="true" />
<span>{retryLabel ?? t("common.retry")}</span>
<span>{retryLabel ?? action.label}</span>
</Button>
) : action ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={action.onClick}
data-slot="query-error-sign-in"
>
{action.label}
</Button>
) : null}
</div>
-68
View File
@@ -1,68 +0,0 @@
"use client";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
/**
* What a failed read means for the surface that made it.
*
* - `denied`: 401 / 403. The viewer may not read this, typically a shared
* or delegated record whose grant leaves the section out. Nothing failed
* that a retry could fix, and the surface is simply not theirs to see.
* - `throttled`: 429. The read budget is spent for now; an immediate retry
* would only be refused again.
* - `failed`: everything else, a real read failure worth a retry.
*/
export type ReadFailureKind = "denied" | "throttled" | "failed";
export function readFailureKind(error: unknown): ReadFailureKind {
if (error instanceof ApiError) {
if (error.status === 401 || error.status === 403) return "denied";
if (error.status === 429) return "throttled";
}
return "failed";
}
/**
* The compact failure row for a supplementary read, by what the failure
* means: nothing for a refusal, a notice without a retry for a spent budget,
* and `QueryErrorRow` with a retry for a real failure.
*
* For a surface the page can stand without (a strip, a tile, a block under a
* card). A primary list keeps `QueryErrorCard`.
*/
export function ReadFailureRow({
error,
message,
onRetry,
slot,
className,
}: {
error: unknown;
message?: string;
onRetry: () => void;
slot?: string;
className?: string;
}) {
const { t } = useTranslations();
const kind = readFailureKind(error);
if (kind === "denied") return null;
if (kind === "throttled") {
return (
<QueryErrorRow
slot={slot}
message={t("common.readThrottled")}
className={className}
/>
);
}
return (
<QueryErrorRow
slot={slot}
message={message}
onRetry={onRetry}
className={className}
/>
);
}