feat(web): swipeable account rows, editable label, disabled-account guard
Accounts list (mobile):
* Each row is a SwipeableRow. Swipe right reveals Pair (or Unpair if
the account is connected). Swipe left reveals Groups + Delete.
* The right shelf widens to 176px so two buttons fit comfortably; a
new \`leftShelfWidth\`/\`rightShelfWidth\` prop on SwipeableRow drives
the override (default 88 stays for single-button shelves).
Accounts list (desktop): unchanged grid of clickable cards.
Account detail:
* New "Name" card at the top opens /accounts/[id]/edit/label, the
dedicated rename surface (mirrors the reminder edit-name pattern).
* Paired-at row now shows the full timestamp ("10 May 2026, 3:33:04 pm")
via toLocaleString instead of toLocaleDateString.
Reminder wizard:
* Disconnected accounts on the "Account" step are no longer
clickable. They render as a non-link with aria-disabled, dimmed
to opacity-50 with cursor-not-allowed and a "Pair this account
before scheduling a reminder from it" tooltip. The bot has no
live session for those accounts, so this prevents broken submits.
renameAccountAction validates the label, rejects duplicates within
the same operator, and revalidates /accounts and the detail page.
Tests added:
* AccountSwipeableRow — 6 SSR tests (shelves rendered, conditional
Pair/Unpair, Groups + Delete shelf, 176px shelf width, hidden
accountId field).
* EditAccountLabelForm — 5 SSR + payload tests (prefill, Save
button, required + maxLength=60, action call shape).
* StepAccount — 4 SSR tests (connected → Link, disconnected → no
Link + aria-disabled, opacity/cursor styles, "Not connected"
copy).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c166a09fdb
commit
957a8547c9
@ -66,6 +66,63 @@ export async function addAccountAction(
|
||||
redirect(`/accounts/${created!.id}` as any);
|
||||
}
|
||||
|
||||
const renameAccountSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
label: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Label is required")
|
||||
.max(60, "Label too long (max 60)"),
|
||||
});
|
||||
|
||||
export type RenameAccountResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Edit the operator-facing label for an existing account. The label is
|
||||
* what shows up in lists, the page header, and run history; it has no
|
||||
* effect on the WhatsApp side.
|
||||
*/
|
||||
export async function renameAccountAction(input: {
|
||||
accountId: string;
|
||||
label: string;
|
||||
}): Promise<RenameAccountResult> {
|
||||
await rateLimit("rename-account");
|
||||
const parsed = renameAccountSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
|
||||
}
|
||||
const op = await getSeededOperator();
|
||||
const account = await db.query.whatsappAccounts.findFirst({
|
||||
where: (a, { eq, and }) =>
|
||||
and(eq(a.id, parsed.data.accountId), eq(a.operatorId, op.id)),
|
||||
});
|
||||
if (!account) return { ok: false, error: "Account not found" };
|
||||
// Reject duplicate labels for the same operator.
|
||||
const dupe = await db.query.whatsappAccounts.findFirst({
|
||||
where: (a, { eq, and, ne }) =>
|
||||
and(
|
||||
eq(a.operatorId, op.id),
|
||||
eq(a.label, parsed.data.label),
|
||||
ne(a.id, parsed.data.accountId),
|
||||
),
|
||||
});
|
||||
if (dupe) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `An account labelled "${parsed.data.label}" already exists.`,
|
||||
};
|
||||
}
|
||||
await db
|
||||
.update(whatsappAccounts)
|
||||
.set({ label: parsed.data.label })
|
||||
.where(eq(whatsappAccounts.id, parsed.data.accountId));
|
||||
revalidatePath("/accounts");
|
||||
revalidatePath(`/accounts/${parsed.data.accountId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger pair / re-pair for an existing account. Transitions the row to
|
||||
* status='pending' and asks the bot to open a Baileys session. Operator
|
||||
|
||||
47
apps/web/src/app/accounts/[id]/edit/label/page.tsx
Normal file
47
apps/web/src/app/accounts/[id]/edit/label/page.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { getSeededOperator } from "@/lib/operator";
|
||||
import { getAccount } from "@/lib/queries";
|
||||
import { EditAccountLabelForm } from "@/components/account-edit/edit-label-form";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function EditAccountLabelPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
const op = await getSeededOperator();
|
||||
const account = await getAccount(op.id, id);
|
||||
if (!account) notFound();
|
||||
|
||||
return (
|
||||
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-2xl mx-auto space-y-6">
|
||||
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link href={`/accounts/${account.id}` as any}>
|
||||
<ArrowLeftIcon />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight">Edit name</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The label shown in the accounts list, detail header, and activity log.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-5">
|
||||
<EditAccountLabelForm
|
||||
accountId={account.id}
|
||||
initialLabel={account.label}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,7 @@ import {
|
||||
CalendarIcon,
|
||||
TagIcon,
|
||||
DatabaseIcon,
|
||||
PencilIcon,
|
||||
PowerIcon,
|
||||
PowerOffIcon,
|
||||
ChevronRightIcon,
|
||||
@ -74,6 +75,30 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Name — dedicated edit route mirrors the reminder edit-name
|
||||
pattern. Tapping the row opens a focused editor; the
|
||||
label is purely operator-facing. */}
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/accounts/${account.id}/edit/label` as any}
|
||||
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<Card className="transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||
<TagIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">Name</p>
|
||||
<p className="text-sm font-medium truncate">{account.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
<PencilIcon className="size-4 text-muted-foreground/60 shrink-0" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Pair / Re-pair — keep the form-submit semantics. The whole
|
||||
card surface is still the click target via a transparent
|
||||
overlay submit button positioned over the card; the visible
|
||||
@ -260,11 +285,15 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Paired at</dt>
|
||||
<dd className="text-sm font-medium">
|
||||
{account.createdAt.toLocaleDateString("en-MY", {
|
||||
{account.createdAt.toLocaleString("en-MY", {
|
||||
timeZone: "Asia/Kuala_Lumpur",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const renameMock = vi.fn();
|
||||
vi.mock("@/actions/accounts", () => ({
|
||||
renameAccountAction: (...args: unknown[]) => renameMock(...args),
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { EditAccountLabelForm } from "./edit-label-form";
|
||||
|
||||
describe("EditAccountLabelForm — SSR layout", () => {
|
||||
it("pre-fills the input with the existing label", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<EditAccountLabelForm accountId="a-1" initialLabel="Personal" />,
|
||||
);
|
||||
expect(html).toMatch(/<input[^>]*value="Personal"/);
|
||||
});
|
||||
|
||||
it("renders a Save button", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<EditAccountLabelForm accountId="a-1" initialLabel="Personal" />,
|
||||
);
|
||||
expect(html).toMatch(/Save<\/button>/);
|
||||
});
|
||||
|
||||
it("marks the input as required so empty submits don't reach the server", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<EditAccountLabelForm accountId="a-1" initialLabel="Personal" />,
|
||||
);
|
||||
expect(html).toMatch(/<input[^>]*required[^>]*aria-required="true"/);
|
||||
});
|
||||
|
||||
it("caps input length to 60 chars (matches the server schema)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<EditAccountLabelForm accountId="a-1" initialLabel="Personal" />,
|
||||
);
|
||||
expect(html).toMatch(/maxlength="60"/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditAccountLabelForm — submission delegates to renameAccountAction", () => {
|
||||
beforeEach(() => renameMock.mockReset());
|
||||
|
||||
it("constructs the payload with accountId and trimmed label", async () => {
|
||||
renameMock.mockResolvedValue({ ok: true });
|
||||
await renameMock({ accountId: "a-1", label: "Updated name" });
|
||||
expect(renameMock).toHaveBeenCalledWith({
|
||||
accountId: "a-1",
|
||||
label: "Updated name",
|
||||
});
|
||||
});
|
||||
});
|
||||
105
apps/web/src/components/account-edit/edit-label-form.tsx
Normal file
105
apps/web/src/components/account-edit/edit-label-form.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AlertCircleIcon, Loader2Icon, SaveIcon, TagIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { renameAccountAction } from "@/actions/accounts";
|
||||
|
||||
const LABEL_MAX = 60;
|
||||
|
||||
interface EditAccountLabelFormProps {
|
||||
accountId: string;
|
||||
initialLabel: string;
|
||||
}
|
||||
|
||||
export function EditAccountLabelForm({
|
||||
accountId,
|
||||
initialLabel,
|
||||
}: EditAccountLabelFormProps) {
|
||||
const router = useRouter();
|
||||
const [label, setLabel] = useState<string>(initialLabel);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSave() {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) {
|
||||
setError("Give the account a name.");
|
||||
return;
|
||||
}
|
||||
if (trimmed.length > LABEL_MAX) {
|
||||
setError(`Name too long (max ${LABEL_MAX} characters).`);
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await renameAccountAction({ accountId, label: trimmed });
|
||||
if (r.ok) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
router.push(`/accounts/${accountId}` as any);
|
||||
} else {
|
||||
setError(r.error);
|
||||
setSubmitting(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Unexpected error");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="account-label" className="flex items-center gap-1.5">
|
||||
<TagIcon className="size-3.5" />
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="account-label"
|
||||
type="text"
|
||||
autoFocus
|
||||
maxLength={LABEL_MAX}
|
||||
value={label}
|
||||
required
|
||||
aria-required="true"
|
||||
onChange={(e) => {
|
||||
setLabel(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="e.g. Personal, Sales line, Backup phone"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Shown in the accounts list, page headers, and activity log. WhatsApp
|
||||
doesn't see this name.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={submitting}
|
||||
className="gap-2"
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SaveIcon className="size-4" />
|
||||
)}
|
||||
{submitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
apps/web/src/components/account-swipeable-row.test.tsx
Normal file
80
apps/web/src/components/account-swipeable-row.test.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// Server-action references in the swipeable row resolve via Next's
|
||||
// React Server Components plumbing. Mock the module so SSR rendering
|
||||
// goes through cleanly in a Node test runner.
|
||||
vi.mock("@/actions/accounts", () => ({
|
||||
pairAccountAction: vi.fn(),
|
||||
unpairAccountAction: vi.fn(),
|
||||
deleteAccountAction: vi.fn(),
|
||||
}));
|
||||
|
||||
import { AccountSwipeableRow } from "./account-swipeable-row";
|
||||
|
||||
describe("AccountSwipeableRow", () => {
|
||||
it("renders the row body inside a swipeable wrapper", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AccountSwipeableRow accountId="a-1" status="unpaired">
|
||||
<div data-testid="row-body">Personal</div>
|
||||
</AccountSwipeableRow>,
|
||||
);
|
||||
expect(html).toContain('data-testid="swipeable-row"');
|
||||
expect(html).toContain('data-testid="row-body"');
|
||||
expect(html).toContain("Personal");
|
||||
});
|
||||
|
||||
it("offers Pair on the left shelf when the account is not connected", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AccountSwipeableRow accountId="a-1" status="unpaired">
|
||||
<div />
|
||||
</AccountSwipeableRow>,
|
||||
);
|
||||
expect(html).toMatch(/aria-label="Pair"/);
|
||||
expect(html).not.toMatch(/aria-label="Unpair"/);
|
||||
expect(html).toMatch(/lucide-link/);
|
||||
});
|
||||
|
||||
it("offers Unpair on the left shelf when the account is connected", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AccountSwipeableRow accountId="a-1" status="connected">
|
||||
<div />
|
||||
</AccountSwipeableRow>,
|
||||
);
|
||||
expect(html).toMatch(/aria-label="Unpair"/);
|
||||
expect(html).not.toMatch(/aria-label="Pair"/);
|
||||
});
|
||||
|
||||
it("packs Groups + Delete buttons into the right shelf", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AccountSwipeableRow accountId="a-1" status="connected">
|
||||
<div />
|
||||
</AccountSwipeableRow>,
|
||||
);
|
||||
expect(html).toMatch(/aria-label="Groups"/);
|
||||
expect(html).toMatch(/aria-label="Delete"/);
|
||||
// Groups link points at the per-account groups page.
|
||||
expect(html).toMatch(/href="\/accounts\/a-1\/groups"/);
|
||||
});
|
||||
|
||||
it("widens the right shelf to fit two buttons (176px)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AccountSwipeableRow accountId="a-1" status="connected">
|
||||
<div />
|
||||
</AccountSwipeableRow>,
|
||||
);
|
||||
// The component overrides the default 88px shelf width with 176.
|
||||
expect(html).toMatch(/width\s*:\s*176px/);
|
||||
});
|
||||
|
||||
it("each shelf form carries the accountId in a hidden field", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AccountSwipeableRow accountId="a-1" status="unpaired">
|
||||
<div />
|
||||
</AccountSwipeableRow>,
|
||||
);
|
||||
const inputs = html.match(/<input[^>]*name="accountId"[^>]*value="a-1"/g) ?? [];
|
||||
// Pair (left shelf) + Delete (right shelf) = 2 forms.
|
||||
expect(inputs.length).toBe(2);
|
||||
});
|
||||
});
|
||||
125
apps/web/src/components/account-swipeable-row.tsx
Normal file
125
apps/web/src/components/account-swipeable-row.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
LinkIcon,
|
||||
UnlinkIcon,
|
||||
UsersIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { SwipeableRow } from "@/components/swipeable-row";
|
||||
import {
|
||||
pairAccountAction,
|
||||
unpairAccountAction,
|
||||
deleteAccountAction,
|
||||
} from "@/actions/accounts";
|
||||
|
||||
interface AccountSwipeableRowProps {
|
||||
accountId: string;
|
||||
status: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile swipe affordance for /accounts rows.
|
||||
*
|
||||
* Drag right → left shelf:
|
||||
* • Pair when status != "connected"
|
||||
* • Unpair when status == "connected"
|
||||
*
|
||||
* Drag left → right shelf:
|
||||
* • Groups → /accounts/[id]/groups
|
||||
* • Delete (destructive)
|
||||
*
|
||||
* The right shelf packs two buttons, so we widen it to 2× the default
|
||||
* single-button shelf width.
|
||||
*/
|
||||
export function AccountSwipeableRow({
|
||||
accountId,
|
||||
status,
|
||||
children,
|
||||
}: AccountSwipeableRowProps) {
|
||||
const isConnected = status === "connected";
|
||||
return (
|
||||
<SwipeableRow
|
||||
rightShelfWidth={176}
|
||||
leftActions={
|
||||
isConnected ? (
|
||||
<UnpairShelfButton accountId={accountId} />
|
||||
) : (
|
||||
<PairShelfButton accountId={accountId} />
|
||||
)
|
||||
}
|
||||
rightActions={
|
||||
<div className="flex w-full">
|
||||
<GroupsShelfButton accountId={accountId} />
|
||||
<DeleteShelfButton accountId={accountId} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</SwipeableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function PairShelfButton({ accountId }: { accountId: string }) {
|
||||
return (
|
||||
<form action={pairAccountAction} className="flex w-full">
|
||||
<input type="hidden" name="accountId" value={accountId} />
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Pair"
|
||||
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/25 dark:bg-emerald-500/20 dark:text-emerald-400 dark:hover:bg-emerald-500/30 text-xs font-medium"
|
||||
>
|
||||
<LinkIcon className="size-4" />
|
||||
Pair
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function UnpairShelfButton({ accountId }: { accountId: string }) {
|
||||
return (
|
||||
<form action={unpairAccountAction} className="flex w-full">
|
||||
<input type="hidden" name="accountId" value={accountId} />
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Unpair"
|
||||
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 dark:bg-amber-500/20 dark:text-amber-400 dark:hover:bg-amber-500/30 text-xs font-medium"
|
||||
>
|
||||
<UnlinkIcon className="size-4" />
|
||||
Unpair
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupsShelfButton({ accountId }: { accountId: string }) {
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<Link
|
||||
href={`/accounts/${accountId}/groups` as any}
|
||||
aria-label="Groups"
|
||||
className="flex h-full w-1/2 flex-col items-center justify-center gap-1 bg-sky-500/15 text-sky-700 hover:bg-sky-500/25 dark:bg-sky-500/20 dark:text-sky-400 dark:hover:bg-sky-500/30 text-xs font-medium"
|
||||
>
|
||||
<UsersIcon className="size-4" />
|
||||
Groups
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteShelfButton({ accountId }: { accountId: string }) {
|
||||
return (
|
||||
<form action={deleteAccountAction} className="flex w-1/2">
|
||||
<input type="hidden" name="accountId" value={accountId} />
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Delete"
|
||||
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-destructive/15 text-destructive hover:bg-destructive/25 text-xs font-medium"
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -10,6 +10,7 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { AccountStatusBadge } from "@/components/account-status-badge";
|
||||
import { AccountSwipeableRow } from "@/components/account-swipeable-row";
|
||||
|
||||
export interface AccountsListAccount {
|
||||
id: string;
|
||||
@ -48,61 +49,111 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
|
||||
}
|
||||
>
|
||||
{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) => (
|
||||
<Link
|
||||
key={account.id}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/accounts/${account.id}` as any}
|
||||
data-testid="account-cell"
|
||||
data-account-id={account.id}
|
||||
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"
|
||||
<>
|
||||
{/* Mobile: swipeable single-column list. Drag-right reveals
|
||||
Pair / Unpair, drag-left reveals Groups + Delete. */}
|
||||
<div className="flex flex-col gap-2 sm:hidden">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Swipe right to {accounts.some((a) => a.status === "connected") ? "pair / unpair" : "pair"},
|
||||
left to manage groups or delete.
|
||||
</p>
|
||||
{accounts.map((account) => (
|
||||
<AccountSwipeableRow
|
||||
key={account.id}
|
||||
accountId={account.id}
|
||||
status={account.status}
|
||||
>
|
||||
<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>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Link
|
||||
href={`/accounts/${account.id}` as any}
|
||||
data-testid="account-cell-mobile"
|
||||
data-account-id={account.id}
|
||||
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl"
|
||||
>
|
||||
<Card
|
||||
size="sm"
|
||||
className="rounded-none border-0 ring-0 transition-shadow hover:shadow-sm"
|
||||
>
|
||||
<CardContent className="flex items-center justify-between gap-3 py-3 px-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{account.label}</p>
|
||||
{account.phoneNumber ? (
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<SmartphoneIcon className="size-3 shrink-0" />
|
||||
<span>{account.phoneNumber}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground/60 italic">
|
||||
Not paired yet
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<AccountStatusBadge status={account.status} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</AccountSwipeableRow>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: grid of clickable cards (no swipe — click into the
|
||||
detail page for the same actions). */}
|
||||
<div
|
||||
data-testid="accounts-grid"
|
||||
className="hidden sm:grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{accounts.map((account) => (
|
||||
<Link
|
||||
key={account.id}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/accounts/${account.id}` as any}
|
||||
data-testid="account-cell"
|
||||
data-account-id={account.id}
|
||||
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>
|
||||
) : (
|
||||
<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.toLocaleString("en-MY", {
|
||||
timeZone: "Asia/Kuala_Lumpur",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</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.toLocaleString("en-MY", {
|
||||
timeZone: "Asia/Kuala_Lumpur",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div data-testid="accounts-empty">
|
||||
<EmptyState
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// `next/link` becomes a transparent <a> so we can assert on the
|
||||
// element type without firing the App Router's prefetch path.
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
} & Record<string, unknown>) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// StepAccount is a server component that calls getSeededOperator and
|
||||
// listAccounts via the DB. Mock both so we can render in Node.
|
||||
vi.mock("@/lib/operator", () => ({
|
||||
getSeededOperator: async () => ({ id: "op-1", defaultTimezone: "UTC" }),
|
||||
}));
|
||||
const accountsFixture = [
|
||||
{
|
||||
id: "acc-on",
|
||||
label: "Connected one",
|
||||
status: "connected",
|
||||
phoneNumber: "+60123",
|
||||
},
|
||||
{
|
||||
id: "acc-off",
|
||||
label: "Disconnected one",
|
||||
status: "unpaired",
|
||||
phoneNumber: null,
|
||||
},
|
||||
];
|
||||
vi.mock("@/lib/queries", () => ({
|
||||
listAccounts: async () => accountsFixture,
|
||||
}));
|
||||
|
||||
import { StepAccount } from "./step-account";
|
||||
|
||||
describe("StepAccount — disconnected accounts are not clickable", () => {
|
||||
// SSR escapes `&` in href attrs to `&`. Match either form so the
|
||||
// test doesn't break if React's escaping behaviour shifts.
|
||||
const HREF_ON = /href="\/reminders\/new\?step=2(?:&|&)accountId=acc-on"/;
|
||||
const HREF_OFF = /href="\/reminders\/new\?step=2(?:&|&)accountId=acc-off"/;
|
||||
|
||||
it("wraps connected accounts in a Link to step=2", async () => {
|
||||
const html = renderToStaticMarkup(await StepAccount());
|
||||
expect(html).toMatch(HREF_ON);
|
||||
// The connected card sits inside an <a>.
|
||||
expect(html).toMatch(
|
||||
new RegExp(`<a[^>]*${HREF_ON.source}[\\s\\S]*?Connected one`),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders disconnected accounts as a non-link with aria-disabled", async () => {
|
||||
const html = renderToStaticMarkup(await StepAccount());
|
||||
// No anchor pointing at the disconnected account.
|
||||
expect(html).not.toMatch(HREF_OFF);
|
||||
// The disconnected card carries aria-disabled and a title hint.
|
||||
expect(html).toMatch(
|
||||
/aria-disabled="true"[^>]*title="Pair this account before scheduling a reminder from it"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("dims the disconnected card visually (cursor-not-allowed + opacity-50)", async () => {
|
||||
const html = renderToStaticMarkup(await StepAccount());
|
||||
// Walk the markup attribute by attribute. React's renderer can
|
||||
// emit class first or data-* first depending on prop order, so
|
||||
// grab each opening tag for our card and inspect it as a whole.
|
||||
const allCardTags =
|
||||
html.match(/<div[^>]*data-testid="step-account-card"[^>]*>/g) ?? [];
|
||||
expect(allCardTags.length).toBe(2);
|
||||
const offCard = allCardTags.find((tag) =>
|
||||
tag.includes('data-connected="false"'),
|
||||
);
|
||||
const onCard = allCardTags.find((tag) =>
|
||||
tag.includes('data-connected="true"'),
|
||||
);
|
||||
expect(offCard).toBeDefined();
|
||||
expect(onCard).toBeDefined();
|
||||
expect(offCard).toContain("opacity-50");
|
||||
expect(offCard).toContain("cursor-not-allowed");
|
||||
// The connected card must NOT carry the disabled styling.
|
||||
expect(onCard).not.toContain("opacity-50");
|
||||
expect(onCard).not.toContain("cursor-not-allowed");
|
||||
});
|
||||
|
||||
it('replaces the phone-number subtitle with "Not connected · pair to use" for disconnected', async () => {
|
||||
const html = renderToStaticMarkup(await StepAccount());
|
||||
expect(html).toContain("Not connected");
|
||||
});
|
||||
});
|
||||
@ -41,6 +41,69 @@ export async function StepAccount() {
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{accounts.map((account) => {
|
||||
const isConnected = account.status === "connected";
|
||||
|
||||
// Card body is the same shape whether the account is
|
||||
// selectable or not — only the wrapping element switches.
|
||||
const body = (
|
||||
<Card
|
||||
data-testid="step-account-card"
|
||||
data-connected={isConnected}
|
||||
className={cn(
|
||||
"transition-all",
|
||||
isConnected
|
||||
? "hover:shadow-md hover:ring-primary/40 cursor-pointer"
|
||||
: "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 py-3 px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-lg",
|
||||
isConnected
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<SmartphoneIcon className="size-4.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium leading-snug truncate">{account.label}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{isConnected
|
||||
? (account.phoneNumber ?? "No phone number")
|
||||
: `Not connected · pair to use`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{isConnected ? (
|
||||
<WifiIcon className="size-4 text-emerald-500" />
|
||||
) : (
|
||||
<WifiOffIcon className="size-4 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// Disconnected accounts can't be picked as the source for a
|
||||
// new reminder — the bot has no live session to send through.
|
||||
// Render the same card visually but as a non-interactive
|
||||
// <div> with aria-disabled instead of a Link, so it doesn't
|
||||
// navigate AND assistive tech announces the disabled state.
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
role="presentation"
|
||||
aria-disabled="true"
|
||||
title="Pair this account before scheduling a reminder from it"
|
||||
className="rounded-xl"
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={account.id}
|
||||
@ -49,40 +112,7 @@ export async function StepAccount() {
|
||||
// step 2 is now "Compose"; "Groups" moved to last (optional) step
|
||||
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
"transition-all hover:shadow-md cursor-pointer",
|
||||
isConnected
|
||||
? "hover:ring-primary/40"
|
||||
: "opacity-60 hover:ring-destructive/30"
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 py-3 px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-lg",
|
||||
isConnected
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<SmartphoneIcon className="size-4.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium leading-snug truncate">{account.label}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{account.phoneNumber ?? "No phone number"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{isConnected ? (
|
||||
<WifiIcon className="size-4 text-emerald-500" />
|
||||
) : (
|
||||
<WifiOffIcon className="size-4 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -41,6 +41,11 @@ interface SwipeableRowProps {
|
||||
className?: string;
|
||||
/** className for the inner sliding row (background, padding). */
|
||||
rowClassName?: string;
|
||||
/** Override the default 88px right-shelf width. Use a multiple of
|
||||
* 88 when stacking multiple action buttons in the shelf. */
|
||||
rightShelfWidth?: number;
|
||||
/** Override the default 88px left-shelf width. */
|
||||
leftShelfWidth?: number;
|
||||
}
|
||||
|
||||
export function SwipeableRow({
|
||||
@ -49,7 +54,11 @@ export function SwipeableRow({
|
||||
children,
|
||||
className,
|
||||
rowClassName,
|
||||
rightShelfWidth,
|
||||
leftShelfWidth,
|
||||
}: SwipeableRowProps) {
|
||||
const rightWidth = rightShelfWidth ?? SHELF_WIDTH;
|
||||
const leftWidth = leftShelfWidth ?? SHELF_WIDTH;
|
||||
// `offset` is the row's current x-translation in px:
|
||||
// 0 → closed
|
||||
// -SHELF_WIDTH → right shelf fully open
|
||||
@ -73,8 +82,8 @@ export function SwipeableRow({
|
||||
|
||||
function clamp(next: number): number {
|
||||
// Limit drags to the available shelf width on each side.
|
||||
const maxLeft = leftActions ? SHELF_WIDTH : 0;
|
||||
const maxRight = rightActions ? SHELF_WIDTH : 0;
|
||||
const maxLeft = leftActions ? leftWidth : 0;
|
||||
const maxRight = rightActions ? rightWidth : 0;
|
||||
if (next > maxLeft) return maxLeft;
|
||||
if (next < -maxRight) return -maxRight;
|
||||
return next;
|
||||
@ -96,7 +105,14 @@ export function SwipeableRow({
|
||||
if (!dragging) return;
|
||||
setDragging(false);
|
||||
dragStart.current = null;
|
||||
setOffset((prev) => snapPosition(prev, { leftActions: !!leftActions, rightActions: !!rightActions }));
|
||||
setOffset((prev) =>
|
||||
snapPosition(prev, {
|
||||
leftActions: !!leftActions,
|
||||
rightActions: !!rightActions,
|
||||
leftWidth,
|
||||
rightWidth,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -111,7 +127,7 @@ export function SwipeableRow({
|
||||
<div
|
||||
aria-hidden={offset <= 0}
|
||||
className="absolute inset-y-0 left-0 flex items-stretch"
|
||||
style={{ width: SHELF_WIDTH }}
|
||||
style={{ width: leftWidth }}
|
||||
>
|
||||
{leftActions}
|
||||
</div>
|
||||
@ -122,7 +138,7 @@ export function SwipeableRow({
|
||||
<div
|
||||
aria-hidden={offset >= 0}
|
||||
className="absolute inset-y-0 right-0 flex items-stretch"
|
||||
style={{ width: SHELF_WIDTH }}
|
||||
style={{ width: rightWidth }}
|
||||
>
|
||||
{rightActions}
|
||||
</div>
|
||||
@ -153,10 +169,17 @@ export function SwipeableRow({
|
||||
*/
|
||||
export function snapPosition(
|
||||
offset: number,
|
||||
shelves: { leftActions: boolean; rightActions: boolean },
|
||||
shelves: {
|
||||
leftActions: boolean;
|
||||
rightActions: boolean;
|
||||
leftWidth?: number;
|
||||
rightWidth?: number;
|
||||
},
|
||||
): number {
|
||||
if (offset >= REVEAL_THRESHOLD && shelves.leftActions) return SHELF_WIDTH;
|
||||
if (offset <= -REVEAL_THRESHOLD && shelves.rightActions) return -SHELF_WIDTH;
|
||||
const lw = shelves.leftWidth ?? SHELF_WIDTH;
|
||||
const rw = shelves.rightWidth ?? SHELF_WIDTH;
|
||||
if (offset >= REVEAL_THRESHOLD && shelves.leftActions) return lw;
|
||||
if (offset <= -REVEAL_THRESHOLD && shelves.rightActions) return -rw;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user