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";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PageShell } from "@/components/page-shell";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
import { listActivityRuns } from "@/lib/queries";
|
||||
import {
|
||||
@ -397,25 +398,21 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
|
||||
<ActivityIcon className="size-10 text-muted-foreground/40" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{filter === "all"
|
||||
? "No activity yet."
|
||||
: showingArchived
|
||||
? "No archived runs."
|
||||
: `No ${filter} runs yet.`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{hasAny
|
||||
? "Runs in other states aren't shown by this filter."
|
||||
: "Reminder fire events will appear here."}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EmptyState
|
||||
icon={ActivityIcon}
|
||||
title={
|
||||
filter === "all"
|
||||
? "No activity yet."
|
||||
: showingArchived
|
||||
? "No archived runs."
|
||||
: `No ${filter} runs yet.`
|
||||
}
|
||||
description={
|
||||
hasAny
|
||||
? "Runs in other states aren't shown by this filter."
|
||||
: "Reminder fire events will appear here."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@ -39,6 +39,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
import { getDashboardStats } from "@/lib/queries";
|
||||
import { PageShell } from "@/components/page-shell";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Time helpers (no external dep, server-safe)
|
||||
@ -333,21 +334,17 @@ export default async function DashboardPage() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
|
||||
<ActivityIcon className="size-10 text-muted-foreground/40" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">No reminders have fired yet.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Schedule one to start sending WhatsApp messages.
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={ActivityIcon}
|
||||
title="No reminders have fired yet."
|
||||
description="Schedule one to start sending WhatsApp messages."
|
||||
action={
|
||||
<Button asChild size="sm">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={"/reminders/new" as any}>Schedule a reminder</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</PageShell>
|
||||
|
||||
@ -15,6 +15,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PageShell } from "@/components/page-shell";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
import { listAccounts, listReminders } from "@/lib/queries";
|
||||
import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
|
||||
@ -326,26 +327,24 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
|
||||
<BellIcon className="size-10 text-muted-foreground/40" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{allReminders.length === 0
|
||||
? "No reminders yet."
|
||||
: hasAnyFilter
|
||||
? "No reminders match your filters."
|
||||
: `No ${status} reminders yet.`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{allReminders.length === 0
|
||||
? "Create a reminder to start sending scheduled WhatsApp messages."
|
||||
: hasAnyFilter
|
||||
? "Try clearing the filters or widening your search."
|
||||
: "Reminders in other states aren't shown by this filter."}
|
||||
</p>
|
||||
</div>
|
||||
{allReminders.length === 0 && (
|
||||
<EmptyState
|
||||
icon={BellIcon}
|
||||
title={
|
||||
allReminders.length === 0
|
||||
? "No reminders yet."
|
||||
: hasAnyFilter
|
||||
? "No reminders match your filters."
|
||||
: `No ${status} reminders yet.`
|
||||
}
|
||||
description={
|
||||
allReminders.length === 0
|
||||
? "Create a reminder to start sending scheduled WhatsApp messages."
|
||||
: hasAnyFilter
|
||||
? "Try clearing the filters or widening your search."
|
||||
: "Reminders in other states aren't shown by this filter."
|
||||
}
|
||||
action={
|
||||
allReminders.length === 0 ? (
|
||||
<Button asChild size="sm">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={"/reminders/new" as any}>
|
||||
@ -353,9 +352,9 @@ export default async function RemindersPage({ searchParams }: PageProps) {
|
||||
New Reminder
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageShell } from "@/components/page-shell";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@ -103,24 +104,22 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card data-testid="accounts-empty">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
|
||||
<SmartphoneIcon className="size-10 text-muted-foreground/40" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">No accounts paired yet.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pair a WhatsApp account to start scheduling reminders.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild size="sm">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={"/accounts/new" as any}>
|
||||
<PlusIcon />
|
||||
Add Account
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div data-testid="accounts-empty">
|
||||
<EmptyState
|
||||
icon={SmartphoneIcon}
|
||||
title="No accounts paired yet."
|
||||
description="Pair a WhatsApp account to start scheduling reminders."
|
||||
action={
|
||||
<Button asChild size="sm">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={"/accounts/new" as any}>
|
||||
<PlusIcon />
|
||||
Add Account
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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