feat(web): drop Delete card from accounts overview
Account-level destructive actions (Delete, Unpair, Re-pair) live on the detail page only. The overview is now a calm grid of one card per account, each linking to its detail page. - Removed the dedicated Delete card and its dialog from accounts-list-view.tsx. - The whole account card is once again the link target — no inline trigger surfaces, no Dialog component, no destructive click area. - AccountsListView no longer needs the deleteFormAction prop; the /accounts page passes only `accounts`. Tests updated: - accounts-list-view.test.tsx: 6 tests now (was 8). The two cases that asserted on the delete card are replaced with one positive test that asserts no Delete affordance is rendered on the overview, plus a test that the only `<a>` per cell wraps the card with no inline buttons inside it. - no-render-warnings.test.tsx: drops the obsolete deleteFormAction prop in its renderQuiet calls. Hydration: live curl on /, /accounts, /reminders, /activity, /settings and a detail page returns 200 with no Hydration / script-tag warning in the web logs after this commit. 98 tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c8199f0bbf
commit
8ca7ebdd5b
@ -1,11 +1,10 @@
|
|||||||
import { AccountsListView } from "@/components/accounts-list-view";
|
import { AccountsListView } from "@/components/accounts-list-view";
|
||||||
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";
|
|
||||||
|
|
||||||
export default async function AccountsPage() {
|
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 <AccountsListView accounts={accounts} deleteFormAction={deleteAccountAction} />;
|
return <AccountsListView accounts={accounts} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,21 +16,6 @@ vi.mock("next/link", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 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 => ({
|
const mkAccount = (over: Partial<AccountsListAccount> = {}): AccountsListAccount => ({
|
||||||
id: "a-1",
|
id: "a-1",
|
||||||
label: "Personal",
|
label: "Personal",
|
||||||
@ -60,11 +45,9 @@ function render(html: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const noopAction = () => {};
|
|
||||||
|
|
||||||
describe("AccountsListView", () => {
|
describe("AccountsListView", () => {
|
||||||
describe("layout — accounts present", () => {
|
describe("layout — accounts present", () => {
|
||||||
it("renders one cell per account, each with a main card and a delete card", () => {
|
it("renders exactly one card per account (no inline destructive triggers)", () => {
|
||||||
const accounts = [
|
const accounts = [
|
||||||
mkAccount({ id: "a-1", label: "Personal" }),
|
mkAccount({ id: "a-1", label: "Personal" }),
|
||||||
mkAccount({ id: "a-2", label: "Work" }),
|
mkAccount({ id: "a-2", label: "Work" }),
|
||||||
@ -72,100 +55,56 @@ describe("AccountsListView", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const { count } = render(
|
const { count } = render(
|
||||||
renderToStaticMarkup(
|
renderToStaticMarkup(<AccountsListView accounts={accounts} />),
|
||||||
<AccountsListView accounts={accounts} deleteFormAction={noopAction} />,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(count('data-testid="account-cell"')).toBe(3);
|
expect(count('data-testid="account-cell"')).toBe(3);
|
||||||
expect(count('data-testid="account-card"')).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]", () => {
|
it("does NOT render any Delete affordance on the overview", () => {
|
||||||
const { has } = render(
|
// Account-level destructive actions live on the detail page only.
|
||||||
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(
|
const html = renderToStaticMarkup(
|
||||||
<AccountsListView
|
<AccountsListView accounts={[mkAccount({ label: "Sales" })]} />,
|
||||||
accounts={[mkAccount({ label: "MyBiz" })]}
|
|
||||||
deleteFormAction={noopAction}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
// Main card title
|
expect(html).not.toContain("Delete account");
|
||||||
expect(html).toContain(">MyBiz<");
|
expect(html).not.toContain("Remove Sales");
|
||||||
// Delete card description references the account by name
|
expect(html).not.toMatch(/data-testid="account-delete-card"/);
|
||||||
expect(html).toContain("Remove MyBiz and its reminders");
|
expect(html).not.toMatch(/aria-label="Delete /);
|
||||||
// Dialog confirmation also uses the label
|
|
||||||
expect(html).toMatch(/<strong>MyBiz<\/strong>/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("delete card uses a real <button> overlay (not a div with role=button)", () => {
|
it("the whole card is the link target — no inline buttons", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AccountsListView
|
<AccountsListView accounts={[mkAccount({ id: "abc-123", label: "Personal" })]} />,
|
||||||
accounts={[mkAccount({ label: "Sales" })]}
|
|
||||||
deleteFormAction={noopAction}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
// The visible Card stays a <div>; a real <button type="button">
|
// Wrapping anchor goes straight to the detail page.
|
||||||
// overlays it. Putting a <div> inside a <button> would be invalid
|
expect(html).toMatch(/<a [^>]*href="\/accounts\/abc-123"/);
|
||||||
// HTML, and Radix's `asChild` injecting button props onto a <div>
|
// Header CTA (Add Account) is the only <button>-like control —
|
||||||
// would mismatch SSR vs client. The overlay sidesteps both.
|
// and even that is rendered as an <a> by our mocked Link wrapper.
|
||||||
expect(html).toMatch(
|
// No inline form/button trigger inside any account-cell.
|
||||||
/<button[^>]*type="button"[^>]*data-testid="account-delete-card"|<button[^>]*data-testid="account-delete-card"[^>]*type="button"/,
|
const cells = html.match(
|
||||||
);
|
/<a [^>]*data-testid="account-cell"[^>]*>[\s\S]*?<\/a>/g,
|
||||||
expect(html).toMatch(/aria-label="Delete Sales"/);
|
) ?? [];
|
||||||
// `Delete account` heading copy lives inside the card
|
expect(cells).toHaveLength(1);
|
||||||
expect(html).toContain("Delete account");
|
expect(cells[0]).not.toContain("<button");
|
||||||
});
|
|
||||||
|
|
||||||
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", () => {
|
it("displays the phone number when paired, italic 'Not paired yet' otherwise", () => {
|
||||||
const paired = renderToStaticMarkup(
|
const paired = renderToStaticMarkup(
|
||||||
<AccountsListView
|
<AccountsListView accounts={[mkAccount({ phoneNumber: "+60123456789" })]} />,
|
||||||
accounts={[mkAccount({ phoneNumber: "+60123456789" })]}
|
|
||||||
deleteFormAction={noopAction}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
expect(paired).toContain("+60123456789");
|
expect(paired).toContain("+60123456789");
|
||||||
expect(paired).not.toContain("Not paired yet");
|
expect(paired).not.toContain("Not paired yet");
|
||||||
|
|
||||||
const unpaired = renderToStaticMarkup(
|
const unpaired = renderToStaticMarkup(
|
||||||
<AccountsListView
|
<AccountsListView accounts={[mkAccount({ phoneNumber: null })]} />,
|
||||||
accounts={[mkAccount({ phoneNumber: null })]}
|
|
||||||
deleteFormAction={noopAction}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
expect(unpaired).toContain("Not paired yet");
|
expect(unpaired).toContain("Not paired yet");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the Add Account header link", () => {
|
it("renders the Add Account header link", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AccountsListView
|
<AccountsListView accounts={[mkAccount()]} />,
|
||||||
accounts={[mkAccount()]}
|
|
||||||
deleteFormAction={noopAction}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
expect(html).toContain("Add Account");
|
expect(html).toContain("Add Account");
|
||||||
expect(html).toMatch(/href="\/accounts\/new"/);
|
expect(html).toMatch(/href="\/accounts\/new"/);
|
||||||
@ -174,9 +113,7 @@ describe("AccountsListView", () => {
|
|||||||
|
|
||||||
describe("layout — empty state", () => {
|
describe("layout — empty state", () => {
|
||||||
it("shows the empty-state card and hides the grid when no accounts", () => {
|
it("shows the empty-state card and hides the grid when no accounts", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(<AccountsListView accounts={[]} />);
|
||||||
<AccountsListView accounts={[]} deleteFormAction={noopAction} />,
|
|
||||||
);
|
|
||||||
expect(html).toContain('data-testid="accounts-empty"');
|
expect(html).toContain('data-testid="accounts-empty"');
|
||||||
expect(html).not.toContain('data-testid="accounts-grid"');
|
expect(html).not.toContain('data-testid="accounts-grid"');
|
||||||
expect(html).not.toContain('data-testid="account-cell"');
|
expect(html).not.toContain('data-testid="account-cell"');
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PlusIcon, SmartphoneIcon, CalendarIcon, Trash2Icon } from "lucide-react";
|
import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@ -7,15 +7,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { AccountStatusBadge } from "@/components/account-status-badge";
|
import { AccountStatusBadge } from "@/components/account-status-badge";
|
||||||
|
|
||||||
export interface AccountsListAccount {
|
export interface AccountsListAccount {
|
||||||
@ -28,17 +19,15 @@ export interface AccountsListAccount {
|
|||||||
|
|
||||||
interface AccountsListViewProps {
|
interface AccountsListViewProps {
|
||||||
accounts: AccountsListAccount[];
|
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
|
* Pure presentational view for /accounts. Renders one card per account
|
||||||
* account and a separate destructive Delete card stacked below it. The
|
* that links to its detail page. Account-level destructive actions
|
||||||
* page passes accounts + the server action; this component has no data
|
* (Delete, Unpair, Re-pair) live on the detail page so this overview
|
||||||
* dependencies, which makes it unit-testable via SSR markup.
|
* stays calm — no inline trigger surfaces here.
|
||||||
*/
|
*/
|
||||||
export function AccountsListView({ accounts, deleteFormAction }: AccountsListViewProps) {
|
export function AccountsListView({ accounts }: AccountsListViewProps) {
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6">
|
<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">
|
<div className="flex items-center justify-between gap-4">
|
||||||
@ -58,103 +47,50 @@ export function AccountsListView({ accounts, deleteFormAction }: AccountsListVie
|
|||||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||||
>
|
>
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<div
|
<Link
|
||||||
key={account.id}
|
key={account.id}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={`/accounts/${account.id}` as any}
|
||||||
data-testid="account-cell"
|
data-testid="account-cell"
|
||||||
data-account-id={account.id}
|
data-account-id={account.id}
|
||||||
className="flex flex-col gap-2"
|
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
|
||||||
>
|
>
|
||||||
<Link
|
<Card
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
data-testid="account-card"
|
||||||
href={`/accounts/${account.id}` as any}
|
className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer"
|
||||||
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
|
|
||||||
>
|
>
|
||||||
<Card
|
<CardHeader>
|
||||||
data-testid="account-card"
|
<div className="flex items-start justify-between gap-2">
|
||||||
className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer"
|
<CardTitle className="text-base leading-snug">{account.label}</CardTitle>
|
||||||
>
|
<AccountStatusBadge status={account.status} />
|
||||||
<CardHeader>
|
</div>
|
||||||
<div className="flex items-start justify-between gap-2">
|
</CardHeader>
|
||||||
<CardTitle className="text-base leading-snug">{account.label}</CardTitle>
|
<CardContent className="space-y-2">
|
||||||
<AccountStatusBadge status={account.status} />
|
{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>
|
</div>
|
||||||
</CardHeader>
|
) : (
|
||||||
<CardContent className="space-y-2">
|
<p className="text-sm text-muted-foreground/60 italic">Not paired yet</p>
|
||||||
{account.phoneNumber ? (
|
)}
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
{account.lastConnectedAt ? (
|
||||||
<SmartphoneIcon className="size-3.5 shrink-0" />
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<span>{account.phoneNumber}</span>
|
<CalendarIcon className="size-3 shrink-0" />
|
||||||
</div>
|
<span>
|
||||||
) : (
|
Last connected{" "}
|
||||||
<p className="text-sm text-muted-foreground/60 italic">Not paired yet</p>
|
{account.lastConnectedAt.toLocaleDateString("en-MY", {
|
||||||
)}
|
timeZone: "Asia/Kuala_Lumpur",
|
||||||
{account.lastConnectedAt ? (
|
year: "numeric",
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
month: "short",
|
||||||
<CalendarIcon className="size-3 shrink-0" />
|
day: "numeric",
|
||||||
<span>
|
})}
|
||||||
Last connected{" "}
|
</span>
|
||||||
{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.
|
|
||||||
We use a transparent <button> overlay instead of passing
|
|
||||||
the Card as `asChild` to DialogTrigger: Radix injects
|
|
||||||
`type="button"` and other button-specific props onto its
|
|
||||||
child, which on a <div> mismatches between SSR and the
|
|
||||||
client tree. Putting a real <button> over the card keeps
|
|
||||||
the visible Card as a <div> with valid attributes. */}
|
|
||||||
<Dialog>
|
|
||||||
<Card className="relative 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>
|
||||||
<div className="min-w-0 flex-1">
|
) : null}
|
||||||
<p className="text-sm font-medium text-destructive">Delete account</p>
|
</CardContent>
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
</Card>
|
||||||
Remove {account.label} and its reminders & groups
|
</Link>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="account-delete-card"
|
|
||||||
aria-label={`Delete ${account.label}`}
|
|
||||||
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
|
|
||||||
/>
|
|
||||||
</DialogTrigger>
|
|
||||||
</Card>
|
|
||||||
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -99,7 +99,7 @@ const account: AccountsListAccount = {
|
|||||||
describe("SSR render — no React errors or warnings", () => {
|
describe("SSR render — no React errors or warnings", () => {
|
||||||
it("AccountsListView (populated) renders cleanly", () => {
|
it("AccountsListView (populated) renders cleanly", () => {
|
||||||
const { errors, warns } = renderQuiet(
|
const { errors, warns } = renderQuiet(
|
||||||
<AccountsListView accounts={[account]} deleteFormAction={() => {}} />,
|
<AccountsListView accounts={[account]} />,
|
||||||
);
|
);
|
||||||
expect(errors).toEqual([]);
|
expect(errors).toEqual([]);
|
||||||
expect(warns).toEqual([]);
|
expect(warns).toEqual([]);
|
||||||
@ -107,7 +107,7 @@ describe("SSR render — no React errors or warnings", () => {
|
|||||||
|
|
||||||
it("AccountsListView (empty) renders cleanly", () => {
|
it("AccountsListView (empty) renders cleanly", () => {
|
||||||
const { errors, warns } = renderQuiet(
|
const { errors, warns } = renderQuiet(
|
||||||
<AccountsListView accounts={[]} deleteFormAction={() => {}} />,
|
<AccountsListView accounts={[]} />,
|
||||||
);
|
);
|
||||||
expect(errors).toEqual([]);
|
expect(errors).toEqual([]);
|
||||||
expect(warns).toEqual([]);
|
expect(warns).toEqual([]);
|
||||||
@ -153,7 +153,7 @@ describe("SSR render — no React errors or warnings", () => {
|
|||||||
describe("SSR markup — no <div> inside <button> (the bug we just fixed)", () => {
|
describe("SSR markup — no <div> inside <button> (the bug we just fixed)", () => {
|
||||||
it("AccountsListView never nests a div inside a button", () => {
|
it("AccountsListView never nests a div inside a button", () => {
|
||||||
const { html } = renderQuiet(
|
const { html } = renderQuiet(
|
||||||
<AccountsListView accounts={[account]} deleteFormAction={() => {}} />,
|
<AccountsListView accounts={[account]} />,
|
||||||
);
|
);
|
||||||
// Naive but effective: scan each <button>...</button> region for any
|
// Naive but effective: scan each <button>...</button> region for any
|
||||||
// <div, <p, or <h-tag inside. Those are flow content and not allowed
|
// <div, <p, or <h-tag inside. Those are flow content and not allowed
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user