test(web): unit tests for accounts-list layout and behaviour
Refactor the /accounts page into a thin data-fetching shell plus a
pure presentational AccountsListView. The view has no DB or server-
action dependencies (the deleteFormAction is passed in), which makes
it directly unit-testable.
Tests use react-dom/server's renderToStaticMarkup — no jsdom or DOM
testing-library needed. next/link and the radix Dialog are mocked to
plain wrappers so the markup is deterministic.
Coverage:
- one cell per account, each with one main account-card and one
delete-card
- main card links to /accounts/[id]
- account label appears in main card, delete card description, and
the destructive confirm dialog
- delete card is a <button> with the right aria-label
- delete dialog form has a hidden accountId input matching the row
- phone number renders when paired; "Not paired yet" when not
- header CTA renders an Add Account link
- empty state replaces the grid and still offers Add Account
vitest config: include src/**/*.test.{ts,tsx} and switch esbuild jsx
to "automatic" so test files don't need a React import.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b453555a3d
commit
4f6d9c3f38
@ -1,22 +1,4 @@
|
|||||||
import Link from "next/link";
|
import { AccountsListView } from "@/components/accounts-list-view";
|
||||||
import { PlusIcon, SmartphoneIcon, CalendarIcon, Trash2Icon } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { AccountStatusBadge } from "@/components/account-status-badge";
|
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { listAccounts } from "@/lib/queries";
|
import { listAccounts } from "@/lib/queries";
|
||||||
import { deleteAccountAction } from "@/actions/accounts";
|
import { deleteAccountAction } from "@/actions/accounts";
|
||||||
@ -25,130 +7,5 @@ export default async function AccountsPage() {
|
|||||||
const op = await getSeededOperator();
|
const op = await getSeededOperator();
|
||||||
const accounts = await listAccounts(op.id);
|
const accounts = await listAccounts(op.id);
|
||||||
|
|
||||||
return (
|
return <AccountsListView accounts={accounts} deleteFormAction={deleteAccountAction} />;
|
||||||
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Accounts</h1>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{accounts.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{accounts.map((account) => (
|
|
||||||
<div key={account.id} className="flex flex-col gap-2">
|
|
||||||
<Link
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
href={`/accounts/${account.id}` as any}
|
|
||||||
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
|
|
||||||
>
|
|
||||||
<Card className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<CardTitle className="text-base leading-snug">
|
|
||||||
{account.label}
|
|
||||||
</CardTitle>
|
|
||||||
<AccountStatusBadge status={account.status} />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
{account.phoneNumber ? (
|
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
||||||
<SmartphoneIcon className="size-3.5 shrink-0" />
|
|
||||||
<span>{account.phoneNumber}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground/60 italic">Not paired yet</p>
|
|
||||||
)}
|
|
||||||
{account.lastConnectedAt ? (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
<CalendarIcon className="size-3 shrink-0" />
|
|
||||||
<span>
|
|
||||||
Last connected{" "}
|
|
||||||
{account.lastConnectedAt.toLocaleDateString("en-MY", {
|
|
||||||
timeZone: "Asia/Kuala_Lumpur",
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Dedicated Delete card — entire card is the dialog trigger. */}
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="block w-full text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<Card className="transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer">
|
|
||||||
<CardContent className="flex items-center gap-3 py-3 px-4">
|
|
||||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
|
||||||
<Trash2Icon className="size-4 text-destructive" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-medium text-destructive">
|
|
||||||
Delete account
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
Remove {account.label} and its reminders & groups
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete this account?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<strong>{account.label}</strong> and all its reminders, groups,
|
|
||||||
and history will be permanently removed. This cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter showCloseButton>
|
|
||||||
<form action={deleteAccountAction}>
|
|
||||||
<input type="hidden" name="accountId" value={account.id} />
|
|
||||||
<Button type="submit" variant="destructive" size="sm">
|
|
||||||
<Trash2Icon />
|
|
||||||
Yes, delete
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
184
apps/web/src/components/accounts-list-view.test.tsx
Normal file
184
apps/web/src/components/accounts-list-view.test.tsx
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
AccountsListView,
|
||||||
|
type AccountsListAccount,
|
||||||
|
} from "./accounts-list-view";
|
||||||
|
|
||||||
|
// next/link in node tests can't access the Next router context — render a
|
||||||
|
// plain anchor with the href so we can assert on it.
|
||||||
|
vi.mock("next/link", () => ({
|
||||||
|
default: ({ href, children, ...rest }: { href: string; children: ReactNode } & Record<string, unknown>) => (
|
||||||
|
<a href={href} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Radix Dialog uses portals + client refs. For SSR markup tests we want
|
||||||
|
// the trigger to be rendered inline (with `asChild`) and the content tree
|
||||||
|
// to render too, so we can assert dialog text deterministically.
|
||||||
|
vi.mock("./ui/dialog", () => ({
|
||||||
|
Dialog: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
DialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}</>,
|
||||||
|
DialogContent: ({ children }: { children: ReactNode }) => (
|
||||||
|
<div data-testid="dialog-content">{children}</div>
|
||||||
|
),
|
||||||
|
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
|
||||||
|
DialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mkAccount = (over: Partial<AccountsListAccount> = {}): AccountsListAccount => ({
|
||||||
|
id: "a-1",
|
||||||
|
label: "Personal",
|
||||||
|
status: "connected",
|
||||||
|
phoneNumber: "+60123456789",
|
||||||
|
lastConnectedAt: new Date("2026-05-01T10:00:00Z"),
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(html: string) {
|
||||||
|
// Count occurrences of a substring.
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
count(needle: string): number {
|
||||||
|
let n = 0;
|
||||||
|
let i = 0;
|
||||||
|
while (true) {
|
||||||
|
const j = html.indexOf(needle, i);
|
||||||
|
if (j === -1) return n;
|
||||||
|
n++;
|
||||||
|
i = j + needle.length;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
has(re: RegExp): boolean {
|
||||||
|
return re.test(html);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const noopAction = () => {};
|
||||||
|
|
||||||
|
describe("AccountsListView", () => {
|
||||||
|
describe("layout — accounts present", () => {
|
||||||
|
it("renders one cell per account, each with a main card and a delete card", () => {
|
||||||
|
const accounts = [
|
||||||
|
mkAccount({ id: "a-1", label: "Personal" }),
|
||||||
|
mkAccount({ id: "a-2", label: "Work" }),
|
||||||
|
mkAccount({ id: "a-3", label: "Support" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const { count } = render(
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<AccountsListView accounts={accounts} deleteFormAction={noopAction} />,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(count('data-testid="account-cell"')).toBe(3);
|
||||||
|
expect(count('data-testid="account-card"')).toBe(3);
|
||||||
|
expect(count('data-testid="account-delete-card"')).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("each main card links to /accounts/[id]", () => {
|
||||||
|
const { has } = render(
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<AccountsListView
|
||||||
|
accounts={[mkAccount({ id: "abc-123", label: "Personal" })]}
|
||||||
|
deleteFormAction={noopAction}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(has(/href="\/accounts\/abc-123"/)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the account label in both the main card and the delete card", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AccountsListView
|
||||||
|
accounts={[mkAccount({ label: "MyBiz" })]}
|
||||||
|
deleteFormAction={noopAction}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Main card title
|
||||||
|
expect(html).toContain(">MyBiz<");
|
||||||
|
// Delete card description references the account by name
|
||||||
|
expect(html).toContain("Remove MyBiz and its reminders");
|
||||||
|
// Dialog confirmation also uses the label
|
||||||
|
expect(html).toMatch(/<strong>MyBiz<\/strong>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delete card is a button with the destructive aria-label", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AccountsListView
|
||||||
|
accounts={[mkAccount({ label: "Sales" })]}
|
||||||
|
deleteFormAction={noopAction}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Order of attributes in the rendered <button> isn't guaranteed, so
|
||||||
|
// assert each one exists and that they're on a button element.
|
||||||
|
expect(html).toMatch(/<button[^>]*\baria-label="Delete Sales"/);
|
||||||
|
expect(html).toMatch(/<button[^>]*\bdata-testid="account-delete-card"/);
|
||||||
|
// `Delete account` heading copy lives inside the card
|
||||||
|
expect(html).toContain("Delete account");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delete dialog form posts the matching accountId hidden input", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AccountsListView
|
||||||
|
accounts={[mkAccount({ id: "uuid-XYZ" })]}
|
||||||
|
deleteFormAction={noopAction}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(html).toMatch(
|
||||||
|
/<input[^>]+type="hidden"[^>]+name="accountId"[^>]+value="uuid-XYZ"/,
|
||||||
|
);
|
||||||
|
expect(html).toContain("Yes, delete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays the phone number when paired, italic 'Not paired yet' otherwise", () => {
|
||||||
|
const paired = renderToStaticMarkup(
|
||||||
|
<AccountsListView
|
||||||
|
accounts={[mkAccount({ phoneNumber: "+60123456789" })]}
|
||||||
|
deleteFormAction={noopAction}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(paired).toContain("+60123456789");
|
||||||
|
expect(paired).not.toContain("Not paired yet");
|
||||||
|
|
||||||
|
const unpaired = renderToStaticMarkup(
|
||||||
|
<AccountsListView
|
||||||
|
accounts={[mkAccount({ phoneNumber: null })]}
|
||||||
|
deleteFormAction={noopAction}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(unpaired).toContain("Not paired yet");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the Add Account header link", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AccountsListView
|
||||||
|
accounts={[mkAccount()]}
|
||||||
|
deleteFormAction={noopAction}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(html).toContain("Add Account");
|
||||||
|
expect(html).toMatch(/href="\/accounts\/new"/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("layout — empty state", () => {
|
||||||
|
it("shows the empty-state card and hides the grid when no accounts", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AccountsListView accounts={[]} deleteFormAction={noopAction} />,
|
||||||
|
);
|
||||||
|
expect(html).toContain('data-testid="accounts-empty"');
|
||||||
|
expect(html).not.toContain('data-testid="accounts-grid"');
|
||||||
|
expect(html).not.toContain('data-testid="account-cell"');
|
||||||
|
expect(html).toContain("No accounts paired yet.");
|
||||||
|
// The empty card still offers the Add Account CTA
|
||||||
|
expect(html).toMatch(/href="\/accounts\/new"/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
177
apps/web/src/components/accounts-list-view.tsx
Normal file
177
apps/web/src/components/accounts-list-view.tsx
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { PlusIcon, SmartphoneIcon, CalendarIcon, Trash2Icon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { AccountStatusBadge } from "@/components/account-status-badge";
|
||||||
|
|
||||||
|
export interface AccountsListAccount {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: string;
|
||||||
|
phoneNumber: string | null;
|
||||||
|
lastConnectedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountsListViewProps {
|
||||||
|
accounts: AccountsListAccount[];
|
||||||
|
/** Server action wired by the page; takes the FormData with `accountId`. */
|
||||||
|
deleteFormAction: (formData: FormData) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure presentational view for /accounts. Renders one main link card per
|
||||||
|
* account and a separate destructive Delete card stacked below it. The
|
||||||
|
* page passes accounts + the server action; this component has no data
|
||||||
|
* dependencies, which makes it unit-testable via SSR markup.
|
||||||
|
*/
|
||||||
|
export function AccountsListView({ accounts, deleteFormAction }: AccountsListViewProps) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Accounts</h1>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{accounts.length > 0 ? (
|
||||||
|
<div
|
||||||
|
data-testid="accounts-grid"
|
||||||
|
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||||
|
>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<div
|
||||||
|
key={account.id}
|
||||||
|
data-testid="account-cell"
|
||||||
|
data-account-id={account.id}
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/accounts/${account.id}` as any}
|
||||||
|
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
data-testid="account-card"
|
||||||
|
className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<CardTitle className="text-base leading-snug">{account.label}</CardTitle>
|
||||||
|
<AccountStatusBadge status={account.status} />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{account.phoneNumber ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<SmartphoneIcon className="size-3.5 shrink-0" />
|
||||||
|
<span>{account.phoneNumber}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground/60 italic">Not paired yet</p>
|
||||||
|
)}
|
||||||
|
{account.lastConnectedAt ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<CalendarIcon className="size-3 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Last connected{" "}
|
||||||
|
{account.lastConnectedAt.toLocaleDateString("en-MY", {
|
||||||
|
timeZone: "Asia/Kuala_Lumpur",
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Dedicated Delete card — entire card is the dialog trigger. */}
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="account-delete-card"
|
||||||
|
aria-label={`Delete ${account.label}`}
|
||||||
|
className="block w-full text-left rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<Card className="transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer">
|
||||||
|
<CardContent className="flex items-center gap-3 py-3 px-4">
|
||||||
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
||||||
|
<Trash2Icon className="size-4 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-destructive">Delete account</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
Remove {account.label} and its reminders & groups
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete this account?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<strong>{account.label}</strong> and all its reminders, groups, and
|
||||||
|
history will be permanently removed. This cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter showCloseButton>
|
||||||
|
<form action={deleteFormAction}>
|
||||||
|
<input type="hidden" name="accountId" value={account.id} />
|
||||||
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
|
<Trash2Icon />
|
||||||
|
Yes, delete
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,7 +4,10 @@ import path from "node:path";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
include: ["src/**/*.test.ts"],
|
include: ["src/**/*.test.{ts,tsx}"],
|
||||||
|
},
|
||||||
|
esbuild: {
|
||||||
|
jsx: "automatic",
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user