diff --git a/apps/web/src/app/accounts/page.tsx b/apps/web/src/app/accounts/page.tsx index 53df973..d847734 100644 --- a/apps/web/src/app/accounts/page.tsx +++ b/apps/web/src/app/accounts/page.tsx @@ -1,22 +1,4 @@ -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"; +import { AccountsListView } from "@/components/accounts-list-view"; import { getSeededOperator } from "@/lib/operator"; import { listAccounts } from "@/lib/queries"; import { deleteAccountAction } from "@/actions/accounts"; @@ -25,130 +7,5 @@ export default async function AccountsPage() { const op = await getSeededOperator(); const accounts = await listAccounts(op.id); - return ( -
-
-

Accounts

- -
- - {accounts.length > 0 ? ( -
- {accounts.map((account) => ( -
- - - -
- - {account.label} - - -
-
- - {account.phoneNumber ? ( -
- - {account.phoneNumber} -
- ) : ( -

Not paired yet

- )} - {account.lastConnectedAt ? ( -
- - - Last connected{" "} - {account.lastConnectedAt.toLocaleDateString("en-MY", { - timeZone: "Asia/Kuala_Lumpur", - year: "numeric", - month: "short", - day: "numeric", - })} - -
- ) : null} -
-
- - - {/* Dedicated Delete card — entire card is the dialog trigger. */} - - - - - - - Delete this account? - - {account.label} and all its reminders, groups, - and history will be permanently removed. This cannot be undone. - - - -
- - -
-
-
-
-
- ))} -
- ) : ( - - - -
-

No accounts paired yet.

-

- Pair a WhatsApp account to start scheduling reminders. -

-
- -
-
- )} -
- ); + return ; } diff --git a/apps/web/src/components/accounts-list-view.test.tsx b/apps/web/src/components/accounts-list-view.test.tsx new file mode 100644 index 0000000..c1aac06 --- /dev/null +++ b/apps/web/src/components/accounts-list-view.test.tsx @@ -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) => ( + + {children} + + ), +})); + +// 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 }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, + DialogDescription: ({ children }: { children: ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +const mkAccount = (over: Partial = {}): 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( + , + ), + ); + + 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( + , + ), + ); + 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( + , + ); + // 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(/MyBiz<\/strong>/); + }); + + it("delete card is a button with the destructive aria-label", () => { + const html = renderToStaticMarkup( + , + ); + // Order of attributes in the rendered + + + {accounts.length > 0 ? ( +
+ {accounts.map((account) => ( +
+ + + +
+ {account.label} + +
+
+ + {account.phoneNumber ? ( +
+ + {account.phoneNumber} +
+ ) : ( +

Not paired yet

+ )} + {account.lastConnectedAt ? ( +
+ + + Last connected{" "} + {account.lastConnectedAt.toLocaleDateString("en-MY", { + timeZone: "Asia/Kuala_Lumpur", + year: "numeric", + month: "short", + day: "numeric", + })} + +
+ ) : null} +
+
+ + + {/* Dedicated Delete card — entire card is the dialog trigger. */} + + + + + + + Delete this account? + + {account.label} and all its reminders, groups, and + history will be permanently removed. This cannot be undone. + + + +
+ + +
+
+
+
+
+ ))} +
+ ) : ( + + + +
+

No accounts paired yet.

+

+ Pair a WhatsApp account to start scheduling reminders. +

+
+ +
+
+ )} + + ); +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 76c330f..6175dd7 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -4,7 +4,10 @@ import path from "node:path"; export default defineConfig({ test: { environment: "node", - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.{ts,tsx}"], + }, + esbuild: { + jsx: "automatic", }, resolve: { alias: {