Files
HealthLog/src/components/ui/query-error-row.tsx
T
Marc-André Bombeck 3fbd16a0a6 fix(ui): let the chart error card decide from the error too
ChartErrorState took no error, so a chart whose read was refused or
whose session had ended offered a retry that could not help. It now
takes the error and branches the way QueryErrorCard and QueryErrorRow
do, the chunk boundary hands it the error it caught, and the error-prop
guard covers it.

QueryErrorRow gains an inset form for a failure inside a card body, and
its frame takes the md:p-6 step. QueryErrorCard drops its fixed px-6 for
the card's own inset and both icons move onto the size scale.
2026-10-20 10:10:31 +02:00

113 lines
3.7 KiB
TypeScript

"use client";
import type { ReactNode } from "react";
import { AlertTriangle, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useReadFailure } from "@/hooks/use-read-failure";
import { cn } from "@/lib/utils";
/**
* Read-failure row — the compact sibling of `<QueryErrorCard>`.
*
* Two dense metric surfaces (the HealthKit metric sub-pages and the vitals
* dashboard) had each hand-rolled the identical row: a one-line message with
* a Retry button on the same baseline. The tall centred card is the right
* shape for a primary list surface, but on a strip of tiles it dominates the
* geometry and pushes everything the user came for below the fold — so this
* shape stays, promoted to a primitive instead of being copied a third time.
*
* Pick by surface (UI-STANDARDS §6):
* - primary / list surface → `QueryErrorCard`
* - dense metric strip or tile where the tall card would dominate →
* `QueryErrorRow`
* - chart body → `ChartErrorState`
*
* 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.
*
* `inset` drops the row's own frame for a failure inside a card or tile body
* that already draws one; a framed row there would be a card inside a card.
*/
export function QueryErrorRow({
error,
refused = null,
message,
onRetry,
retryLabel,
slot,
retrySlot,
inset = false,
className,
}: {
/** 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;
/**
* Overrides the row's `data-slot`. The two converted surfaces keep their
* shipped hooks (`healthkit-metric-error`, `vitals-dashboard-error`) so
* their tests and any e2e selector stay stable.
*/
slot?: string;
/** Same, for the Retry button's own `data-slot`. */
retrySlot?: string;
/** Inside a card or tile body: no frame of its own. */
inset?: boolean;
className?: string;
}) {
const failure = useReadFailure(error, { message, onRetry });
if (!failure) return <>{refused}</>;
const { action } = failure;
return (
<div
data-slot={slot ?? "query-error-row"}
role="alert"
className={cn(
"text-foreground flex flex-col items-start gap-3 text-sm sm:flex-row sm:items-center sm:justify-between",
!inset && "bg-card border-border rounded-xl border p-4 md:p-6",
className,
)}
>
<span className="flex items-start gap-2 sm:items-center">
<AlertTriangle
className="text-muted-foreground mt-0.5 size-4 shrink-0 sm:mt-0"
aria-hidden="true"
/>
<span>{failure.message}</span>
</span>
{action?.kind === "retry" ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={action.onClick}
data-slot={retrySlot ?? "query-error-row-retry"}
className="gap-1.5"
>
<RotateCw className="size-3.5" aria-hidden="true" />
<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>
);
}