mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
fix(ui): draw nothing for a refused read and no retry for a spent budget
The failure rows added for supplementary reads offered a retry for any error. The usual-range strip reads at manage level, so a viewer on a read-only share saw a permanent failure with a retry that could never succeed; the visit preparation reads measurements under visits that need only the profile section, which a share can grant alone. A refused read (401/403) now draws nothing, a spent read budget (429) says so without a retry, and only a real failure keeps the retry row. The cycle strip holds no cell for a refused calendar read.
This commit is contained in:
1 parent
bf84c0c11e
commit
9095b1037e
20 files changed
+224
-42
No files matched your search
+2
-1
@@ -54,7 +54,8 @@
|
||||
"durationClockHours": "{time} Std.",
|
||||
"errorIdLabel": "Fehler-ID",
|
||||
"errorIdCopy": "Fehler-ID kopieren",
|
||||
"errorIdCopied": "Fehler-ID kopiert"
|
||||
"errorIdCopied": "Fehler-ID kopiert",
|
||||
"readThrottled": "Gerade zu viele Anfragen. Versuch es später noch einmal."
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
|
||||
+2
-1
@@ -54,7 +54,8 @@
|
||||
"durationClockHours": "{time} h",
|
||||
"errorIdLabel": "Error ID",
|
||||
"errorIdCopy": "Copy error ID",
|
||||
"errorIdCopied": "Error ID copied"
|
||||
"errorIdCopied": "Error ID copied",
|
||||
"readThrottled": "Too many requests just now. Try again later."
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
|
||||
+2
-1
@@ -54,7 +54,8 @@
|
||||
"durationClockHours": "{time} h",
|
||||
"errorIdLabel": "ID de error",
|
||||
"errorIdCopy": "Copiar ID de error",
|
||||
"errorIdCopied": "ID de error copiado"
|
||||
"errorIdCopied": "ID de error copiado",
|
||||
"readThrottled": "Demasiadas solicitudes ahora mismo. Vuelve a intentarlo más tarde."
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
|
||||
+2
-1
@@ -54,7 +54,8 @@
|
||||
"durationClockHours": "{time} h",
|
||||
"errorIdLabel": "ID d'erreur",
|
||||
"errorIdCopy": "Copier l'ID d'erreur",
|
||||
"errorIdCopied": "ID d'erreur copié"
|
||||
"errorIdCopied": "ID d'erreur copié",
|
||||
"readThrottled": "Trop de requêtes pour le moment. Réessaie plus tard."
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
|
||||
+2
-1
@@ -54,7 +54,8 @@
|
||||
"durationClockHours": "{time} h",
|
||||
"errorIdLabel": "ID errore",
|
||||
"errorIdCopy": "Copia ID errore",
|
||||
"errorIdCopied": "ID errore copiato"
|
||||
"errorIdCopied": "ID errore copiato",
|
||||
"readThrottled": "Troppe richieste in questo momento. Riprova più tardi."
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
|
||||
+2
-1
@@ -54,7 +54,8 @@
|
||||
"durationClockHours": "{time}시간",
|
||||
"errorIdLabel": "오류 ID",
|
||||
"errorIdCopy": "오류 ID 복사",
|
||||
"errorIdCopied": "오류 ID 복사됨"
|
||||
"errorIdCopied": "오류 ID 복사됨",
|
||||
"readThrottled": "지금은 요청이 너무 많아요. 나중에 다시 시도해 주세요."
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
|
||||
+2
-1
@@ -54,7 +54,8 @@
|
||||
"durationClockHours": "{time} godz.",
|
||||
"errorIdLabel": "ID błędu",
|
||||
"errorIdCopy": "Kopiuj ID błędu",
|
||||
"errorIdCopied": "Skopiowano ID błędu"
|
||||
"errorIdCopied": "Skopiowano ID błędu",
|
||||
"readThrottled": "Zbyt wiele zapytań w tej chwili. Spróbuj ponownie później."
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { localYmd, useCycleRingDial } from "../use-cycle";
|
||||
@@ -32,9 +33,11 @@ function Probe({ enabled }: { enabled: boolean }) {
|
||||
|
||||
/** Marks the calendar read as failed, with no data behind it. */
|
||||
const FAILED = Symbol("failed");
|
||||
/** Marks the calendar read as refused (a share without the section). */
|
||||
const REFUSED = Symbol("refused");
|
||||
|
||||
function probe(
|
||||
verdict: unknown | undefined | typeof FAILED,
|
||||
verdict: unknown | undefined | typeof FAILED | typeof REFUSED,
|
||||
enabled = true,
|
||||
): string {
|
||||
const client = new QueryClient({
|
||||
@@ -45,13 +48,16 @@ function probe(
|
||||
},
|
||||
});
|
||||
const [from, to] = window();
|
||||
if (verdict === FAILED) {
|
||||
if (verdict === FAILED || verdict === REFUSED) {
|
||||
client
|
||||
.getQueryCache()
|
||||
.build(client, { queryKey: queryKeys.cycleCalendar(from, to) })
|
||||
.setState({
|
||||
status: "error",
|
||||
error: new Error("calendar read failed"),
|
||||
error:
|
||||
verdict === REFUSED
|
||||
? new ApiError("Forbidden", 403)
|
||||
: new Error("calendar read failed"),
|
||||
fetchStatus: "idle",
|
||||
});
|
||||
} else if (verdict !== undefined) {
|
||||
@@ -92,6 +98,11 @@ describe("useCycleRingDial", () => {
|
||||
expect(probe(FAILED)).toBe("false:false:failed");
|
||||
expect(probe(FAILED, false)).toBe("false:false");
|
||||
});
|
||||
|
||||
// A refused read draws nothing in the tile, so it must not hold a cell.
|
||||
it("holds no cell for a refused read", () => {
|
||||
expect(probe(REFUSED)).toBe("false:false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the scores strip keeps the ring's cell on a failed read", () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Droplets } from "lucide-react";
|
||||
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CycleRing } from "./cycle-ring";
|
||||
@@ -71,7 +71,8 @@ export function CycleRingTile({ className }: { className?: string }) {
|
||||
if (calendar.isLoading && !calendar.data) return null;
|
||||
if (calendar.isError && !calendar.data) {
|
||||
return (
|
||||
<QueryErrorRow
|
||||
<ReadFailureRow
|
||||
error={calendar.error}
|
||||
slot="wellness-cycle-tile-error"
|
||||
message={t("cycle.wellnessRing.loadError")}
|
||||
onRetry={() => void calendar.refetch()}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { useCalendarDate } from "@/hooks/use-calendar-date";
|
||||
import { readFailureKind } from "@/components/ui/read-failure-row";
|
||||
|
||||
import {
|
||||
cycleDependentKeys,
|
||||
@@ -151,7 +152,13 @@ export function useCycleRingDial(enabled: boolean): {
|
||||
return {
|
||||
dial: enabled && verdict?.phase != null && verdict.dayOfCycle != null,
|
||||
pending: enabled && calendar.isPending,
|
||||
failed: enabled && calendar.isError && !calendar.data,
|
||||
// A refused read is not a failure to report: the tile would draw nothing
|
||||
// and leave its cell empty.
|
||||
failed:
|
||||
enabled &&
|
||||
calendar.isError &&
|
||||
!calendar.data &&
|
||||
readFailureKind(calendar.error) !== "denied",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import type { EncounterDTO } from "@/lib/encounters/dto";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import type {
|
||||
@@ -61,7 +61,9 @@ import { useTodayKey } from "./use-today-key";
|
||||
* most often turns out empty, then collapse it again. Arriving late costs
|
||||
* only the visit list below a shift, once per window; the read is cached for
|
||||
* five minutes. A failed read is not a quiet window, so it says so in a
|
||||
* compact row with a retry.
|
||||
* compact row with a retry. A refused one draws nothing: visits need the
|
||||
* profile section and this window reads measurements, and a share can carry
|
||||
* the first without the second.
|
||||
*/
|
||||
|
||||
const EVENT_ICON: Partial<Record<DayEventKind, LucideIcon>> = {
|
||||
@@ -193,7 +195,8 @@ export function SinceLastVisit({
|
||||
|
||||
if (window.isError && rows.length === 0) {
|
||||
return (
|
||||
<QueryErrorRow
|
||||
<ReadFailureRow
|
||||
error={window.error}
|
||||
slot="since-last-visit-error"
|
||||
message={t("day.sinceLastVisit.loadError")}
|
||||
onRetry={() => void window.refetch()}
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock("@/lib/api/api-fetch", async () => {
|
||||
});
|
||||
|
||||
import { dateKeyOfInstant, todayKeyInZone } from "@/components/day/day-url";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import type { DayNotableWindowResponse } from "@/lib/day/contract";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -234,8 +235,8 @@ describe("<VisitsSection> since the last visit", () => {
|
||||
expect(upcomingCards(html)).toBe(upcomingCards(baseline));
|
||||
});
|
||||
|
||||
it("says a failed read failed, rather than passing it off as a quiet window", () => {
|
||||
const html = render({ upcoming: [next], past: [last] }, (queryClient) => {
|
||||
function seedFailure(error: Error) {
|
||||
return (queryClient: QueryClient) => {
|
||||
const tz = DEFAULT_TIMEZONE;
|
||||
const queryKey = queryKeys.dayNotable(
|
||||
dateKeyOfInstant(last.occurredAt, tz),
|
||||
@@ -247,12 +248,15 @@ describe("<VisitsSection> since the last visit", () => {
|
||||
queryClient
|
||||
.getQueryCache()
|
||||
.build(queryClient, { queryKey })
|
||||
.setState({
|
||||
status: "error",
|
||||
error: new Error("window read failed"),
|
||||
fetchStatus: "idle",
|
||||
});
|
||||
});
|
||||
.setState({ status: "error", error, fetchStatus: "idle" });
|
||||
};
|
||||
}
|
||||
|
||||
it("says a failed read failed, rather than passing it off as a quiet window", () => {
|
||||
const html = render(
|
||||
{ upcoming: [next], past: [last] },
|
||||
seedFailure(new ApiError("window read failed", 500)),
|
||||
);
|
||||
const baseline = render({ upcoming: [next], past: [] });
|
||||
expect(html).toContain('data-slot="since-last-visit-error"');
|
||||
expect(html).toContain('role="alert"');
|
||||
@@ -260,6 +264,17 @@ describe("<VisitsSection> since the last visit", () => {
|
||||
expect(upcomingCards(html)).toBe(upcomingCards(baseline));
|
||||
});
|
||||
|
||||
// Visits need the profile section, the window reads measurements, and a
|
||||
// share can carry the first without the second. A refusal is not a failure.
|
||||
it("draws nothing when the window read is refused", () => {
|
||||
const html = render(
|
||||
{ upcoming: [next], past: [last] },
|
||||
seedFailure(new ApiError("Forbidden", 403)),
|
||||
);
|
||||
expect(html).not.toContain("since-last-visit-error");
|
||||
expect(html).not.toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("draws one card with the rows once there is something to list", () => {
|
||||
const html = render(
|
||||
{ upcoming: [next], past: [last] },
|
||||
|
||||
@@ -11,7 +11,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { metricFractionDigits } from "@/lib/measurements/value-domain";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape";
|
||||
@@ -80,7 +80,7 @@ export function CoachReadStrip({
|
||||
const transformed = unitDisplay.isTransformed(metricType);
|
||||
const resolvedUnit = transformed ? unitDisplay.unitFor(metricType) : unit;
|
||||
|
||||
const { data, isError, refetch } = useQuery({
|
||||
const { data, error, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsCoachRead(metricType, locale),
|
||||
queryFn: () =>
|
||||
apiGet<CoachReadStripData>(
|
||||
@@ -97,12 +97,15 @@ export function CoachReadStrip({
|
||||
// baseline line degrades to the "learning" copy), so the placeholder only
|
||||
// ever gives way to a card of its own height. A failed read gives way to a
|
||||
// notice with a retry: dropping the strip read as "nothing to say about
|
||||
// today", which is not what happened.
|
||||
// today", which is not what happened. The route is a manage-level read, so
|
||||
// a viewer on a read-only share is refused it; that draws nothing, and a
|
||||
// spent analytics budget says so without offering a retry.
|
||||
if (!mounted || !data) {
|
||||
if (!isAuthenticated) return null;
|
||||
if (isError) {
|
||||
return (
|
||||
<QueryErrorRow
|
||||
<ReadFailureRow
|
||||
error={error}
|
||||
slot="coach-read-strip-error"
|
||||
message={t("insights.coach.readStrip.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
|
||||
@@ -32,7 +32,7 @@ import { Compass } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { formatDate } from "@/lib/date-format";
|
||||
@@ -62,7 +62,7 @@ export function MoodPrognosisCard() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isError, refetch } = useQuery({
|
||||
const { data, error, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.moodPrognosis(),
|
||||
queryFn: async () => apiGet<PrognosisReading>("/api/mood/prognosis"),
|
||||
enabled: isAuthenticated,
|
||||
@@ -71,7 +71,8 @@ export function MoodPrognosisCard() {
|
||||
|
||||
if (isError && !data) {
|
||||
return (
|
||||
<QueryErrorRow
|
||||
<ReadFailureRow
|
||||
error={error}
|
||||
slot="mood-prognosis-error"
|
||||
message={t("insights.mood.prognosis.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Coffee } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { NutrientDailyBarChartDynamic } from "@/components/charts/nutrient-daily-bar-chart-dynamic";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -30,7 +30,7 @@ export function CaffeineCard() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, isError, refetch } = useQuery({
|
||||
const { data, error, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.nutrientDaily("caffeine", WINDOW_DAYS),
|
||||
queryFn: () =>
|
||||
apiGet<NutrientDailySeries>(
|
||||
@@ -43,7 +43,8 @@ export function CaffeineCard() {
|
||||
|
||||
if (isError && !data) {
|
||||
return (
|
||||
<QueryErrorRow
|
||||
<ReadFailureRow
|
||||
error={error}
|
||||
slot="nutrients-caffeine-error"
|
||||
message={t("nutrients.caffeine.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ListRow } from "@/components/ui/list-row";
|
||||
import {
|
||||
@@ -1924,7 +1924,8 @@ function DayDrillDown({
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<QueryErrorRow
|
||||
<ReadFailureRow
|
||||
error={error}
|
||||
slot="measurement-drilldown-error"
|
||||
message={t("measurements.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -63,7 +63,7 @@ export function MoodQuickTags({
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, error, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.moodTagCatalog(),
|
||||
queryFn: async () => apiGet<CatalogResponse>("/api/mood/tags"),
|
||||
enabled: isAuthenticated,
|
||||
@@ -75,7 +75,8 @@ export function MoodQuickTags({
|
||||
}
|
||||
if (isError && !data) {
|
||||
return (
|
||||
<QueryErrorRow
|
||||
<ReadFailureRow
|
||||
error={error}
|
||||
slot="mood-quick-tags-error"
|
||||
message={t("mood.tagCatalogLoadError")}
|
||||
onRetry={() => void refetch()}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Plus, Tag } from "lucide-react";
|
||||
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -105,7 +105,7 @@ export function MoodTagPicker({
|
||||
// tile belongs to, or null when closed.
|
||||
const [createGroupKey, setCreateGroupKey] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, error, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.moodTagCatalog(),
|
||||
queryFn: async () => {
|
||||
return apiGet<CatalogResponse>("/api/mood/tags");
|
||||
@@ -124,7 +124,8 @@ export function MoodTagPicker({
|
||||
|
||||
if (isError && !data) {
|
||||
return (
|
||||
<QueryErrorRow
|
||||
<ReadFailureRow
|
||||
error={error}
|
||||
slot="mood-tag-picker-error"
|
||||
message={t("mood.tagCatalogLoadError")}
|
||||
onRetry={() => void refetch()}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
"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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user