mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(vorsorge): add a loading state and align the cards on the shared primitives
The reminder list read isLoading but rendered nothing, so the page showed a header over blank space while fetching. - Loading now paints a card-shaped Skeleton stack via the shared primitive. - The no-data case routes through the shared EmptyState with an icon and an add action, matching the lab empty. - Reminder cards title themselves with CardHeader/CardTitle + CardAction and the form card drops its pt-6 override, so the card token owns the vertical rhythm and the double-pad goes away. The card stays neutral — due state still reads only through the discreet badge.
This commit is contained in:
1 parent
bc237d61e2
commit
dba107de51
2 files changed
+129
-26
No files matched your search
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
|
||||
|
||||
/**
|
||||
* v1.17.1 — the Vorsorge section's loading + empty affordances.
|
||||
*
|
||||
* Audit 02-H1/H2 + 11-H1: the list read `isLoading` but rendered nothing while
|
||||
* fetching (a header over blank space). The fix paints a tile-shaped `Skeleton`
|
||||
* stack while loading and routes the no-data case through the shared
|
||||
* `<EmptyState>` with an add action. These tests pin both.
|
||||
*/
|
||||
|
||||
const remindersMock = vi.fn();
|
||||
vi.mock("@/hooks/use-measurement-reminders", () => ({
|
||||
useMeasurementReminders: () => remindersMock(),
|
||||
useMeasurementReminderMutations: () => ({
|
||||
create: { mutate: vi.fn(), isPending: false },
|
||||
remove: { mutate: vi.fn(), isPending: false },
|
||||
satisfy: { mutate: vi.fn(), isPending: false },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { VorsorgeSection } from "../vorsorge-section";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
remindersMock.mockReset();
|
||||
});
|
||||
|
||||
describe("<VorsorgeSection> loading + empty", () => {
|
||||
it("paints the shared Skeleton stack while loading", () => {
|
||||
remindersMock.mockReturnValue({ data: undefined, isLoading: true });
|
||||
const html = render(<VorsorgeSection />);
|
||||
expect(html).toContain('data-slot="vorsorge-loading"');
|
||||
expect(html).toContain('data-slot="skeleton"');
|
||||
// No empty-state while still loading.
|
||||
expect(html).not.toContain('data-slot="empty-state"');
|
||||
});
|
||||
|
||||
it("routes the no-data case through the shared EmptyState with an action", () => {
|
||||
remindersMock.mockReturnValue({ data: [], isLoading: false });
|
||||
const html = render(<VorsorgeSection />);
|
||||
expect(html).toContain('data-slot="empty-state"');
|
||||
expect(html).toContain("border-dashed");
|
||||
expect(html).not.toContain('data-slot="vorsorge-loading"');
|
||||
});
|
||||
|
||||
it("renders reminder cards once data lands", () => {
|
||||
const reminder: MeasurementReminder = {
|
||||
id: "r1",
|
||||
label: "Annual blood panel",
|
||||
measurementType: null,
|
||||
intervalDays: 365,
|
||||
rrule: null,
|
||||
nextDueAt: null,
|
||||
notifyHour: 9,
|
||||
location: null,
|
||||
enabled: true,
|
||||
} as MeasurementReminder;
|
||||
remindersMock.mockReturnValue({ data: [reminder], isLoading: false });
|
||||
const html = render(<VorsorgeSection />);
|
||||
expect(html).toContain("Annual blood panel");
|
||||
expect(html).not.toContain('data-slot="empty-state"');
|
||||
expect(html).not.toContain('data-slot="vorsorge-loading"');
|
||||
});
|
||||
});
|
||||
@@ -18,11 +18,18 @@ import { CheckCircle2, Plus, Trash2 } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
useMeasurementReminders,
|
||||
useMeasurementReminderMutations,
|
||||
@@ -137,7 +144,7 @@ export function VorsorgeSection({ enabled = true }: { enabled?: boolean }) {
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorsorge-label">
|
||||
{t("measurementReminders.form.label")}
|
||||
@@ -263,10 +270,32 @@ export function VorsorgeSection({ enabled = true }: { enabled?: boolean }) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading && !showForm && (
|
||||
<div className="space-y-3" data-slot="vorsorge-loading">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Card key={i} aria-hidden="true">
|
||||
<CardContent className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-24 shrink-0" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (reminders?.length ?? 0) === 0 && !showForm && (
|
||||
<EmptyState
|
||||
icon={<Plus className="size-6" />}
|
||||
title={t("measurementReminders.empty.title")}
|
||||
description={t("measurementReminders.empty.description")}
|
||||
action={
|
||||
<Button type="button" onClick={() => setShowForm(true)}>
|
||||
{t("measurementReminders.addButton")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -315,31 +344,16 @@ function VorsorgeCard({
|
||||
{/* NEUTRAL card — no status-driven colour. The due state reads only
|
||||
through the discreet badge below. */}
|
||||
<Card>
|
||||
<CardContent className="flex items-start justify-between gap-3 pt-6">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate font-medium">{reminder.label}</p>
|
||||
{!reminder.enabled && (
|
||||
<Badge variant="outline">
|
||||
{t("measurementReminders.disabledBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Badge variant="secondary" className="mr-2">
|
||||
{t(`measurementReminders.${due.key}`, { days: due.days })}
|
||||
<CardHeader>
|
||||
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<span className="truncate font-medium">{reminder.label}</span>
|
||||
{!reminder.enabled && (
|
||||
<Badge variant="outline">
|
||||
{t("measurementReminders.disabledBadge")}
|
||||
</Badge>
|
||||
{cadence}
|
||||
</p>
|
||||
{reminder.location && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("measurementReminders.location.prefix", {
|
||||
location: reminder.location,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
</CardTitle>
|
||||
<CardAction className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -361,7 +375,22 @@ function VorsorgeCard({
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Badge variant="secondary" className="mr-2">
|
||||
{t(`measurementReminders.${due.key}`, { days: due.days })}
|
||||
</Badge>
|
||||
{cadence}
|
||||
</p>
|
||||
{reminder.location && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("measurementReminders.location.prefix", {
|
||||
location: reminder.location,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
|
||||
Reference in new issue
Block a user