refactor(web): extract EmptyState and reuse on every empty surface
One component now owns the icon / heading / helper / action stack that the dashboard, accounts list, reminders list, and activity tab were each rendering inline. The four duplicated 'flex-col items-center py-12 text-center' Card blocks collapse to one shared surface so the empty experience reads the same wherever the user lands. Covered by 4 SSR tests (icon + title + description, omitted helper, action slot pass-through, centring). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a5cb8cea46
commit
3c3a3f57d3
@ -31,6 +31,7 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { PageShell } from "@/components/page-shell";
|
import { PageShell } from "@/components/page-shell";
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { listActivityRuns } from "@/lib/queries";
|
import { listActivityRuns } from "@/lib/queries";
|
||||||
import {
|
import {
|
||||||
@ -397,25 +398,21 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<EmptyState
|
||||||
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
|
icon={ActivityIcon}
|
||||||
<ActivityIcon className="size-10 text-muted-foreground/40" />
|
title={
|
||||||
<div className="space-y-1">
|
filter === "all"
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{filter === "all"
|
|
||||||
? "No activity yet."
|
? "No activity yet."
|
||||||
: showingArchived
|
: showingArchived
|
||||||
? "No archived runs."
|
? "No archived runs."
|
||||||
: `No ${filter} runs yet.`}
|
: `No ${filter} runs yet.`
|
||||||
</p>
|
}
|
||||||
<p className="text-xs text-muted-foreground">
|
description={
|
||||||
{hasAny
|
hasAny
|
||||||
? "Runs in other states aren't shown by this filter."
|
? "Runs in other states aren't shown by this filter."
|
||||||
: "Reminder fire events will appear here."}
|
: "Reminder fire events will appear here."
|
||||||
</p>
|
}
|
||||||
</div>
|
/>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -39,6 +39,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { getDashboardStats } from "@/lib/queries";
|
import { getDashboardStats } from "@/lib/queries";
|
||||||
import { PageShell } from "@/components/page-shell";
|
import { PageShell } from "@/components/page-shell";
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Time helpers (no external dep, server-safe)
|
// Time helpers (no external dep, server-safe)
|
||||||
@ -333,21 +334,17 @@ export default async function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<EmptyState
|
||||||
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
|
icon={ActivityIcon}
|
||||||
<ActivityIcon className="size-10 text-muted-foreground/40" />
|
title="No reminders have fired yet."
|
||||||
<div className="space-y-1">
|
description="Schedule one to start sending WhatsApp messages."
|
||||||
<p className="text-sm font-medium">No reminders have fired yet.</p>
|
action={
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Schedule one to start sending WhatsApp messages.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button asChild size="sm">
|
<Button asChild size="sm">
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<Link href={"/reminders/new" as any}>Schedule a reminder</Link>
|
<Link href={"/reminders/new" as any}>Schedule a reminder</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
}
|
||||||
</Card>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { PageShell } from "@/components/page-shell";
|
import { PageShell } from "@/components/page-shell";
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { listAccounts, listReminders } from "@/lib/queries";
|
import { listAccounts, listReminders } from "@/lib/queries";
|
||||||
import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
|
import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
|
||||||
@ -326,26 +327,24 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<EmptyState
|
||||||
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
|
icon={BellIcon}
|
||||||
<BellIcon className="size-10 text-muted-foreground/40" />
|
title={
|
||||||
<div className="space-y-1">
|
allReminders.length === 0
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{allReminders.length === 0
|
|
||||||
? "No reminders yet."
|
? "No reminders yet."
|
||||||
: hasAnyFilter
|
: hasAnyFilter
|
||||||
? "No reminders match your filters."
|
? "No reminders match your filters."
|
||||||
: `No ${status} reminders yet.`}
|
: `No ${status} reminders yet.`
|
||||||
</p>
|
}
|
||||||
<p className="text-xs text-muted-foreground">
|
description={
|
||||||
{allReminders.length === 0
|
allReminders.length === 0
|
||||||
? "Create a reminder to start sending scheduled WhatsApp messages."
|
? "Create a reminder to start sending scheduled WhatsApp messages."
|
||||||
: hasAnyFilter
|
: hasAnyFilter
|
||||||
? "Try clearing the filters or widening your search."
|
? "Try clearing the filters or widening your search."
|
||||||
: "Reminders in other states aren't shown by this filter."}
|
: "Reminders in other states aren't shown by this filter."
|
||||||
</p>
|
}
|
||||||
</div>
|
action={
|
||||||
{allReminders.length === 0 && (
|
allReminders.length === 0 ? (
|
||||||
<Button asChild size="sm">
|
<Button asChild size="sm">
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<Link href={"/reminders/new" as any}>
|
<Link href={"/reminders/new" as any}>
|
||||||
@ -353,9 +352,9 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
|||||||
New Reminder
|
New Reminder
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : undefined
|
||||||
</CardContent>
|
}
|
||||||
</Card>
|
/>
|
||||||
)}
|
)}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react";
|
import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PageShell } from "@/components/page-shell";
|
import { PageShell } from "@/components/page-shell";
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@ -103,15 +104,12 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card data-testid="accounts-empty">
|
<div data-testid="accounts-empty">
|
||||||
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
|
<EmptyState
|
||||||
<SmartphoneIcon className="size-10 text-muted-foreground/40" />
|
icon={SmartphoneIcon}
|
||||||
<div className="space-y-1">
|
title="No accounts paired yet."
|
||||||
<p className="text-sm font-medium">No accounts paired yet.</p>
|
description="Pair a WhatsApp account to start scheduling reminders."
|
||||||
<p className="text-xs text-muted-foreground">
|
action={
|
||||||
Pair a WhatsApp account to start scheduling reminders.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button asChild size="sm">
|
<Button asChild size="sm">
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<Link href={"/accounts/new" as any}>
|
<Link href={"/accounts/new" as any}>
|
||||||
@ -119,8 +117,9 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
|
|||||||
Add Account
|
Add Account
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
}
|
||||||
</Card>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
52
apps/web/src/components/empty-state.test.tsx
Normal file
52
apps/web/src/components/empty-state.test.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { ActivityIcon } from "lucide-react";
|
||||||
|
import { EmptyState } from "./empty-state";
|
||||||
|
|
||||||
|
describe("EmptyState", () => {
|
||||||
|
it("renders the icon, title, and description", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<EmptyState
|
||||||
|
icon={ActivityIcon}
|
||||||
|
title="No activity yet."
|
||||||
|
description="Reminder fire events will appear here."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(html).toContain("No activity yet.");
|
||||||
|
expect(html).toContain("Reminder fire events will appear here.");
|
||||||
|
// The lucide icon component renders an <svg> with the lucide-activity class.
|
||||||
|
expect(html).toMatch(/<svg[^>]*lucide-activity/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the description when it isn't passed", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<EmptyState icon={ActivityIcon} title="No archived runs." />,
|
||||||
|
);
|
||||||
|
expect(html).toContain("No archived runs.");
|
||||||
|
// No second <p> element for the helper text — the only <p> is the title.
|
||||||
|
expect((html.match(/<p\b/g) ?? []).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the action slot when provided", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<EmptyState
|
||||||
|
icon={ActivityIcon}
|
||||||
|
title="No reminders yet."
|
||||||
|
action={<button data-testid="cta">Schedule one</button>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(html).toContain('data-testid="cta"');
|
||||||
|
expect(html).toContain("Schedule one");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("centres the layout (icon → text → action stack)", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<EmptyState icon={ActivityIcon} title="x" action={<span>cta</span>} />,
|
||||||
|
);
|
||||||
|
// The CardContent uses flex-col items-center text-center for the
|
||||||
|
// canonical empty state layout. Lock that in so future tweaks
|
||||||
|
// can't accidentally drop the centring.
|
||||||
|
expect(html).toMatch(/flex-col items-center/);
|
||||||
|
expect(html).toMatch(/text-center/);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
apps/web/src/components/empty-state.tsx
Normal file
39
apps/web/src/components/empty-state.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
/** Visual anchor — usually a lucide icon component, rendered at
|
||||||
|
* size-10 in muted/40 so it reads as decorative rather than active. */
|
||||||
|
icon: LucideIcon;
|
||||||
|
/** One-line headline. Required — empty states without one read as
|
||||||
|
* "is the page broken?" */
|
||||||
|
title: string;
|
||||||
|
/** Optional explainer below the headline. */
|
||||||
|
description?: string;
|
||||||
|
/** Optional CTA button or action link slot. */
|
||||||
|
action?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable empty-state card. Tabs render this when their list is
|
||||||
|
* empty (no accounts paired, no reminders scheduled, no activity
|
||||||
|
* yet). Centralises the icon / heading / helper / CTA layout so
|
||||||
|
* every empty surface in the app reads the same.
|
||||||
|
*/
|
||||||
|
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
|
||||||
|
<Icon className="size-10 text-muted-foreground/40" aria-hidden="true" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">{title}</p>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user