mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
This commit is contained in:
1 parent
6e54f33901
commit
3fbd16a0a6
9 files changed
+91
-36
No files matched your search
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Every read-failure primitive is handed the error it reports.
|
||||
*
|
||||
* `QueryErrorRow` and `QueryErrorCard` decide what a failed read offers from
|
||||
* `QueryErrorRow`, `QueryErrorCard` and `ChartErrorState` decide what a failed read offers from
|
||||
* the error itself: nothing (or the surface's refused state) for a 403, a
|
||||
* note without a retry for a 429, sign-in for a 401, a retry otherwise. A
|
||||
* call site that renders one without the error falls back to "failed" for
|
||||
@@ -49,7 +49,7 @@ function walk(
|
||||
/** The opening tag's attribute text, from the name to its closing `>`. */
|
||||
function openingTags(source: string): string[] {
|
||||
const tags: string[] = [];
|
||||
const open = /<(QueryErrorRow|QueryErrorCard)(?=[\s/>])/g;
|
||||
const open = /<(QueryErrorRow|QueryErrorCard|ChartErrorState)(?=[\s/>])/g;
|
||||
for (let m = open.exec(source); m; m = open.exec(source)) {
|
||||
let depth = 0;
|
||||
let i = m.index + m[0].length;
|
||||
|
||||
@@ -130,16 +130,32 @@ describe("<ChartErrorState> — announcement, sizing, accessible name", () => {
|
||||
props: Partial<import("../chart-error-state").ChartErrorStateProps> = {},
|
||||
) {
|
||||
const { ChartErrorState } = await import("../chart-error-state");
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
return renderToStaticMarkup(
|
||||
<ChartErrorState
|
||||
title="Data could not be loaded"
|
||||
actionLabel="Retry"
|
||||
onAction={() => undefined}
|
||||
{...props}
|
||||
/>,
|
||||
<I18nProvider initialLocale="en">
|
||||
<ChartErrorState
|
||||
error={new Error("boom")}
|
||||
title="Data could not be loaded"
|
||||
actionLabel="Retry"
|
||||
onAction={() => undefined}
|
||||
{...props}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("decides from the error, like the other read-failure primitives", async () => {
|
||||
const { ApiError } = await import("@/lib/api/api-fetch");
|
||||
// A refused read draws nothing, not a retry that cannot help.
|
||||
expect(await renderState({ error: new ApiError("x", 403) })).toBe("");
|
||||
const ended = await renderState({ error: new ApiError("x", 401) });
|
||||
expect(ended).toContain('data-slot="query-error-sign-in"');
|
||||
expect(ended).not.toContain("Retry");
|
||||
const throttled = await renderState({ error: new ApiError("x", 429) });
|
||||
expect(throttled).not.toContain("<button");
|
||||
expect(await renderState()).toContain("Retry");
|
||||
});
|
||||
|
||||
it("announces the failure as a status by default", async () => {
|
||||
const html = await renderState();
|
||||
expect(html).toContain('role="status"');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Component, type ReactElement, type ReactNode } from "react";
|
||||
import { AlertTriangle, RotateCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useReadFailure } from "@/hooks/use-read-failure";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -22,9 +23,18 @@ import { cn } from "@/lib/utils";
|
||||
* reflows — with a compact action button. `<ChartErrorBoundary>` wraps
|
||||
* each lazy chart mount so a failed chunk degrades to one card with a
|
||||
* reload affordance instead of taking the page down.
|
||||
*
|
||||
* Like `QueryErrorCard` and `QueryErrorRow`, the card reads the failure
|
||||
* itself (`error`): a refusal draws `refused` (nothing by default), a spent
|
||||
* budget says so without an action, an ended session offers sign-in, and only
|
||||
* a real failure offers `onAction`.
|
||||
*/
|
||||
|
||||
export interface ChartErrorStateProps {
|
||||
/** The failure. It decides what the card offers. */
|
||||
error: unknown;
|
||||
/** What a refused (403) read draws instead of the card. */
|
||||
refused?: ReactNode;
|
||||
/** Translated headline, e.g. "Data could not be loaded". */
|
||||
title: string;
|
||||
/** Translated action-button label, e.g. "Retry". */
|
||||
@@ -54,6 +64,8 @@ export interface ChartErrorStateProps {
|
||||
}
|
||||
|
||||
export function ChartErrorState({
|
||||
error,
|
||||
refused = null,
|
||||
title,
|
||||
actionLabel,
|
||||
onAction,
|
||||
@@ -61,6 +73,10 @@ export function ChartErrorState({
|
||||
actionContext,
|
||||
role = "status",
|
||||
}: ChartErrorStateProps): ReactElement {
|
||||
const failure = useReadFailure(error, { message: title, onRetry: onAction });
|
||||
if (!failure) return <>{refused}</>;
|
||||
const { action } = failure;
|
||||
const label = action?.kind === "retry" ? actionLabel : action?.label;
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-error-state"
|
||||
@@ -73,42 +89,48 @@ export function ChartErrorState({
|
||||
style={height === undefined ? undefined : { height }}
|
||||
>
|
||||
<AlertTriangle
|
||||
className="text-muted-foreground h-8 w-8"
|
||||
className="text-muted-foreground size-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAction}
|
||||
aria-label={
|
||||
actionContext ? `${actionLabel}: ${actionContext}` : undefined
|
||||
}
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{actionLabel}
|
||||
</Button>
|
||||
<p className="text-sm font-medium">{failure.message}</p>
|
||||
{action ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={action.onClick}
|
||||
data-slot={
|
||||
action.kind === "signIn" ? "query-error-sign-in" : undefined
|
||||
}
|
||||
aria-label={actionContext ? `${label}: ${actionContext}` : undefined}
|
||||
>
|
||||
{action.kind === "retry" ? (
|
||||
<RotateCw className="size-3.5" aria-hidden="true" />
|
||||
) : null}
|
||||
{label}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BoundaryProps {
|
||||
fallback: ReactNode;
|
||||
/** Drawn once caught; a function receives the caught error. */
|
||||
fallback: ReactNode | ((error: Error) => ReactNode);
|
||||
children: ReactNode;
|
||||
/** Called once the boundary has caught, after the fallback commits. */
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface BoundaryState {
|
||||
failed: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/** Exported for its unit test; mount `<ChartErrorBoundary>` instead. */
|
||||
export class ChartBoundary extends Component<BoundaryProps, BoundaryState> {
|
||||
state: BoundaryState = { failed: false };
|
||||
state: BoundaryState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(): BoundaryState {
|
||||
return { failed: true };
|
||||
static getDerivedStateFromError(error: Error): BoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
@@ -125,7 +147,10 @@ export class ChartBoundary extends Component<BoundaryProps, BoundaryState> {
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
return this.state.failed ? this.props.fallback : this.props.children;
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
const { fallback } = this.props;
|
||||
return typeof fallback === "function" ? fallback(error) : fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +177,7 @@ export function ChartErrorBoundary({
|
||||
return (
|
||||
<ChartBoundary
|
||||
onError={onError}
|
||||
fallback={
|
||||
fallback={(error) => (
|
||||
// Wrapped in the chart-card shell so a chunk failure renders
|
||||
// as one solid card among the other cards, not a bare dashed
|
||||
// box; `role="alert"` because the whole chart is gone, not
|
||||
@@ -162,13 +187,14 @@ export function ChartErrorBoundary({
|
||||
className="bg-card border-border rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<ChartErrorState
|
||||
error={error}
|
||||
title={t("charts.loadErrorTitle")}
|
||||
actionLabel={t("charts.loadErrorReload")}
|
||||
onAction={() => window.location.reload()}
|
||||
role="alert"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ChartBoundary>
|
||||
|
||||
@@ -820,7 +820,7 @@ export function HealthChart({
|
||||
);
|
||||
}, [preloadedSeries, preloadedCoverageDays, fetchWindow.windowDays, types]);
|
||||
|
||||
const { data, isLoading, isError, isPlaceholderData, refetch } = useQuery({
|
||||
const chartQuery = useQuery({
|
||||
// A range-tab change re-keys the query; without placeholder data every
|
||||
// switch dropped the painted chart to a skeleton for the fetch round-trip.
|
||||
// Keeping the previous range's series visible is only honest because the
|
||||
@@ -1096,6 +1096,8 @@ export function HealthChart({
|
||||
),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
const { data, isLoading, isError, error, isPlaceholderData, refetch } =
|
||||
chartQuery;
|
||||
|
||||
// v1.16.0 — report the settled data query to the dashboard's shared
|
||||
// reveal gate. `isLoading` only covers the INITIAL fetch (a later
|
||||
@@ -1921,6 +1923,7 @@ export function HealthChart({
|
||||
// Pre-fix `isError` fell through to the empty branch and an
|
||||
// outage read as "you have no measurements".
|
||||
<ChartErrorState
|
||||
error={error}
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
actionContext={title}
|
||||
|
||||
@@ -270,7 +270,7 @@ export function MedicationComplianceChart({
|
||||
// `["dashboard-medication-compliance"]` lands in
|
||||
// `medicationDependentKeys` and an intake POST refreshes the chart
|
||||
// immediately rather than waiting for `staleTime` (audit L4).
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.dashboardMedicationCompliance(days),
|
||||
queryFn: async (): Promise<DailyCompliancePoint[]> => {
|
||||
return apiGet<DailyCompliancePoint[]>(
|
||||
@@ -548,6 +548,7 @@ export function MedicationComplianceChart({
|
||||
// through to the empty branch (data undefined → hasData false)
|
||||
// and an outage read as "no doses tracked".
|
||||
<ChartErrorState
|
||||
error={error}
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
actionContext={displayTitle}
|
||||
|
||||
@@ -406,7 +406,7 @@ export function MoodChart({
|
||||
// cache fresh; the chart's own subscription becomes the cache writer
|
||||
// for any insights / mood sub-page that mounts the chart without the
|
||||
// dashboard parent having fetched first.
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.moodAnalytics(),
|
||||
queryFn: async (): Promise<MoodAnalyticsData> => {
|
||||
return apiGet<MoodAnalyticsData>("/api/mood/analytics");
|
||||
@@ -756,6 +756,7 @@ export function MoodChart({
|
||||
// v1.16.8 — a failed query paints as an ERROR with a retry
|
||||
// affordance instead of unmounting the card (see the gate above).
|
||||
<ChartErrorState
|
||||
error={error}
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
actionContext={displayTitle}
|
||||
|
||||
@@ -203,6 +203,7 @@ export function ScoreTrendChart({
|
||||
<Skeleton className={`w-full ${heightClass}`} />
|
||||
) : query.isError ? (
|
||||
<ChartErrorState
|
||||
error={query.error}
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
actionContext={label}
|
||||
|
||||
@@ -53,10 +53,10 @@ export function QueryErrorCard({
|
||||
<Card data-slot="query-error-card" className={cn(className)}>
|
||||
<CardContent
|
||||
role="alert"
|
||||
className="flex flex-col items-center justify-center gap-3 px-6 py-10 text-center"
|
||||
className="flex flex-col items-center justify-center gap-3 py-10 text-center"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="text-muted-foreground h-8 w-8"
|
||||
className="text-muted-foreground size-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm font-medium">{failure.message}</p>
|
||||
@@ -74,7 +74,7 @@ export function QueryErrorCard({
|
||||
size="sm"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<RotateCw className="size-3.5" aria-hidden="true" />
|
||||
{retryLabel ?? action.label}
|
||||
</Button>
|
||||
) : action ? (
|
||||
|
||||
@@ -29,6 +29,9 @@ import { cn } from "@/lib/utils";
|
||||
*
|
||||
* 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,
|
||||
@@ -38,6 +41,7 @@ export function QueryErrorRow({
|
||||
retryLabel,
|
||||
slot,
|
||||
retrySlot,
|
||||
inset = false,
|
||||
className,
|
||||
}: {
|
||||
/** The query's error. It decides what the row offers. */
|
||||
@@ -56,6 +60,8 @@ export function QueryErrorRow({
|
||||
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 });
|
||||
@@ -66,7 +72,8 @@ export function QueryErrorRow({
|
||||
data-slot={slot ?? "query-error-row"}
|
||||
role="alert"
|
||||
className={cn(
|
||||
"bg-card border-border text-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between",
|
||||
"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,
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user