diff --git a/apps/web/src/app/activity/page.tsx b/apps/web/src/app/activity/page.tsx
new file mode 100644
index 0000000..0a43d93
--- /dev/null
+++ b/apps/web/src/app/activity/page.tsx
@@ -0,0 +1,284 @@
+import Link from "next/link";
+import {
+ ActivityIcon,
+ CheckCircle2Icon,
+ AlertTriangleIcon,
+ XCircleIcon,
+ MinusCircleIcon,
+ Trash2Icon,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { getSeededOperator } from "@/lib/operator";
+import { listActivityRuns } from "@/lib/queries";
+import { clearHistoryAction } from "@/actions/history";
+
+function relativeTime(date: Date | string): string {
+ const d = typeof date === "string" ? new Date(date) : date;
+ const diffSec = Math.floor((Date.now() - d.getTime()) / 1000);
+ const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
+ if (diffSec < 60) return rtf.format(-diffSec, "second");
+ if (diffSec < 3600) return rtf.format(-Math.floor(diffSec / 60), "minute");
+ if (diffSec < 86400) return rtf.format(-Math.floor(diffSec / 3600), "hour");
+ return rtf.format(-Math.floor(diffSec / 86400), "day");
+}
+
+const RUN_STATUS_CONFIG: Record<
+ string,
+ { label: string; className: string; icon: React.ElementType }
+> = {
+ success: {
+ label: "Success",
+ className:
+ "bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
+ icon: CheckCircle2Icon,
+ },
+ partial: {
+ label: "Partial",
+ className:
+ "bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
+ icon: AlertTriangleIcon,
+ },
+ failed: {
+ label: "Failed",
+ className:
+ "bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent",
+ icon: XCircleIcon,
+ },
+ skipped: {
+ label: "Skipped",
+ className:
+ "bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
+ icon: MinusCircleIcon,
+ },
+};
+
+function RunStatusBadge({ status }: { status: string }) {
+ const cfg = RUN_STATUS_CONFIG[status] ?? {
+ label: status,
+ className: "bg-secondary text-secondary-foreground border-transparent",
+ icon: ActivityIcon,
+ };
+ const Icon = cfg.icon;
+ return (
+
+
+ {cfg.label}
+
+ );
+}
+
+type FilterValue = "all" | "success" | "partial" | "failed" | "skipped";
+const FILTER_TABS: { value: FilterValue; label: string }[] = [
+ { value: "all", label: "All" },
+ { value: "success", label: "Success" },
+ { value: "partial", label: "Partial" },
+ { value: "failed", label: "Failed" },
+ { value: "skipped", label: "Skipped" },
+];
+
+interface PageProps {
+ searchParams: Promise<{ filter?: string }>;
+}
+
+export default async function ActivityPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const filter: FilterValue =
+ sp.filter === "success" ||
+ sp.filter === "partial" ||
+ sp.filter === "failed" ||
+ sp.filter === "skipped"
+ ? sp.filter
+ : "all";
+
+ const op = await getSeededOperator();
+ const runs = await listActivityRuns(op.id);
+ const filtered = filter === "all" ? runs : runs.filter((r) => r.status === filter);
+ const hasAny = runs.length > 0;
+
+ return (
+
+
+
Activity
+ {hasAny && (
+
+ )}
+
+
+
+
+ {FILTER_TABS.map(({ value, label }) => (
+
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+
+ {label}
+
+
+ ))}
+
+
+
+ {filtered.length > 0 ? (
+ <>
+ {/* Mobile: cards */}
+
+ {filtered.map((run) => {
+ const body = (
+
+
+
+
+ {run.reminderName}
+ {run.isDeleted && (
+
+ (deleted)
+
+ )}
+
+
+ {relativeTime(run.firedAt)}
+
+
+
+
+
+ );
+ return run.reminderId && !run.isDeleted ? (
+
+ {body}
+
+ ) : (
+
{body}
+ );
+ })}
+
+
+ {/* Desktop: table */}
+
+
+
+
+
+
+ Reminder
+ Status
+ Fired
+
+
+
+ {filtered.map((run) => {
+ const clickable = run.reminderId && !run.isDeleted;
+ return (
+
+
+ {clickable ? (
+
+ {run.reminderName}
+
+ ) : (
+
+ {run.reminderName}
+ {run.isDeleted && " (deleted)"}
+
+ )}
+
+
+
+
+
+ {relativeTime(run.firedAt)}
+
+
+ );
+ })}
+
+
+
+
+
+ >
+ ) : (
+
+
+
+
+
+ {filter === "all"
+ ? "No activity yet."
+ : `No ${filter} runs yet.`}
+
+
+ {hasAny
+ ? "Runs in other states aren't shown by this filter."
+ : "Reminder fire events will appear here."}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/reminders/[id]/edit/account/page.tsx b/apps/web/src/app/reminders/[id]/edit/account/page.tsx
new file mode 100644
index 0000000..e3013a6
--- /dev/null
+++ b/apps/web/src/app/reminders/[id]/edit/account/page.tsx
@@ -0,0 +1,45 @@
+import { notFound } from "next/navigation";
+import { getSeededOperator } from "@/lib/operator";
+import { getReminderWithRuns, listAccounts } from "@/lib/queries";
+import { EditShell } from "@/components/reminder-edit/edit-shell";
+import { EditAccountForm } from "@/components/reminder-edit/edit-account-form";
+
+interface Props {
+ params: Promise<{ id: string }>;
+}
+
+export default async function EditAccountPage({ params }: Props) {
+ const { id } = await params;
+ const op = await getSeededOperator();
+ const data = await getReminderWithRuns(op.id, id);
+ if (!data) notFound();
+
+ const { reminder, messages } = data;
+ const allAccounts = await listAccounts(op.id);
+ const first = messages[0];
+
+ return (
+
+ ({
+ id: a.id,
+ label: a.label,
+ status: a.status,
+ phoneNumber: a.phoneNumber,
+ }))}
+ initialAccountId={reminder.accountId}
+ />
+
+ );
+}
diff --git a/apps/web/src/app/reminders/[id]/edit/groups/page.tsx b/apps/web/src/app/reminders/[id]/edit/groups/page.tsx
new file mode 100644
index 0000000..807314f
--- /dev/null
+++ b/apps/web/src/app/reminders/[id]/edit/groups/page.tsx
@@ -0,0 +1,42 @@
+import { notFound } from "next/navigation";
+import { getSeededOperator } from "@/lib/operator";
+import { getReminderWithRuns, listGroupsForAccount } from "@/lib/queries";
+import { EditShell } from "@/components/reminder-edit/edit-shell";
+import { EditGroupsForm } from "@/components/reminder-edit/edit-groups-form";
+
+interface Props {
+ params: Promise<{ id: string }>;
+}
+
+export default async function EditGroupsPage({ params }: Props) {
+ const { id } = await params;
+ const op = await getSeededOperator();
+ const data = await getReminderWithRuns(op.id, id);
+ if (!data) notFound();
+
+ const { reminder, targets, messages } = data;
+ const groupsResult = await listGroupsForAccount(op.id, reminder.accountId);
+ const groups = groupsResult?.groups ?? [];
+ const first = messages[0];
+
+ return (
+
+ t.groupId)}
+ />
+
+ );
+}
diff --git a/apps/web/src/app/reminders/[id]/edit/message/page.tsx b/apps/web/src/app/reminders/[id]/edit/message/page.tsx
new file mode 100644
index 0000000..3540686
--- /dev/null
+++ b/apps/web/src/app/reminders/[id]/edit/message/page.tsx
@@ -0,0 +1,41 @@
+import { notFound } from "next/navigation";
+import { getSeededOperator } from "@/lib/operator";
+import { getReminderWithRuns } from "@/lib/queries";
+import { EditShell } from "@/components/reminder-edit/edit-shell";
+import { EditMessageForm } from "@/components/reminder-edit/edit-message-form";
+
+interface Props {
+ params: Promise<{ id: string }>;
+}
+
+export default async function EditMessagePage({ params }: Props) {
+ const { id } = await params;
+ const op = await getSeededOperator();
+ const data = await getReminderWithRuns(op.id, id);
+ if (!data) notFound();
+
+ const { reminder, targets, messages } = data;
+ const first = messages[0];
+ const text = first && !first.mediaId ? first.textContent ?? "" : "";
+ const caption = first && first.mediaId ? first.textContent ?? "" : "";
+
+ return (
+
+ t.groupId)}
+ scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
+ rrule={reminder.rrule}
+ timezone={reminder.timezone}
+ initialText={text}
+ initialMediaId={first?.mediaId ?? null}
+ initialCaption={caption}
+ />
+
+ );
+}
diff --git a/apps/web/src/app/reminders/[id]/edit/page.tsx b/apps/web/src/app/reminders/[id]/edit/page.tsx
deleted file mode 100644
index 02e4d20..0000000
--- a/apps/web/src/app/reminders/[id]/edit/page.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { notFound, redirect } from "next/navigation";
-import { getSeededOperator } from "@/lib/operator";
-import { getReminderWithRuns } from "@/lib/queries";
-
-interface Props {
- params: Promise<{ id: string }>;
-}
-
-/**
- * Edit shell — load the reminder, encode its current state into the wizard's
- * URL params (step 2 = Compose), and forward the user there. The wizard's
- * review-submit branch detects `editReminderId` and calls
- * updateReminderAction instead of createReminderAction.
- */
-export default async function EditReminderRedirectPage({ params }: Props) {
- const { id } = await params;
- const op = await getSeededOperator();
- const data = await getReminderWithRuns(op.id, id);
- if (!data) notFound();
-
- const { reminder, targets, messages } = data;
-
- const sp = new URLSearchParams({
- step: "2",
- accountId: reminder.accountId,
- editReminderId: reminder.id,
- });
-
- const groupIds = targets.map((t) => t.groupId).filter(Boolean).join(",");
- if (groupIds) sp.set("groupIds", groupIds);
-
- // Use the first message part for text/media — multi-part editing is out of scope.
- const first = messages[0];
- if (first?.textContent) {
- if (first.mediaId) {
- sp.set("caption", first.textContent);
- sp.set("mediaId", first.mediaId);
- } else {
- sp.set("text", first.textContent);
- }
- } else if (first?.mediaId) {
- sp.set("mediaId", first.mediaId);
- }
-
- if (reminder.scheduledAt) sp.set("scheduledAt", reminder.scheduledAt.toISOString());
- if (reminder.rrule) sp.set("rrule", reminder.rrule);
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- redirect(`/reminders/new?${sp.toString()}` as any);
-}
diff --git a/apps/web/src/app/reminders/[id]/edit/when/page.tsx b/apps/web/src/app/reminders/[id]/edit/when/page.tsx
new file mode 100644
index 0000000..e9b98a5
--- /dev/null
+++ b/apps/web/src/app/reminders/[id]/edit/when/page.tsx
@@ -0,0 +1,40 @@
+import { notFound } from "next/navigation";
+import { getSeededOperator } from "@/lib/operator";
+import { getReminderWithRuns } from "@/lib/queries";
+import { specFromRrule } from "@/lib/recurrence";
+import { EditShell } from "@/components/reminder-edit/edit-shell";
+import { EditWhenForm } from "@/components/reminder-edit/edit-when-form";
+
+interface Props {
+ params: Promise<{ id: string }>;
+}
+
+export default async function EditWhenPage({ params }: Props) {
+ const { id } = await params;
+ const op = await getSeededOperator();
+ const data = await getReminderWithRuns(op.id, id);
+ if (!data) notFound();
+
+ const { reminder, targets, messages } = data;
+ const first = messages[0];
+
+ return (
+
+ t.groupId)}
+ text={first && !first.mediaId ? first.textContent ?? null : null}
+ mediaId={first?.mediaId ?? null}
+ caption={first?.mediaId ? first.textContent ?? null : null}
+ initialIso={(reminder.scheduledAt ?? new Date()).toISOString()}
+ initialSpec={specFromRrule(reminder.rrule)}
+ timezone={reminder.timezone}
+ />
+
+ );
+}
diff --git a/apps/web/src/app/reminders/[id]/page.tsx b/apps/web/src/app/reminders/[id]/page.tsx
index 4f438b4..0ce6c67 100644
--- a/apps/web/src/app/reminders/[id]/page.tsx
+++ b/apps/web/src/app/reminders/[id]/page.tsx
@@ -85,32 +85,11 @@ export default async function ReminderDetailPage({ params }: Props) {
const { reminder, account, targets, messages, runs } = data;
const tz = op.defaultTimezone ?? "UTC";
- // Build a wizard URL pointing at `step` with the current reminder state
- // serialised — the wizard's review-submit detects editReminderId and
- // routes to updateReminderAction instead of createReminderAction.
- function editStepHref(step: number): string {
- const sp = new URLSearchParams({
- step: String(step),
- accountId: reminder.accountId,
- editReminderId: reminder.id,
- });
- const groupIds = targets.map((t) => t.groupId).filter(Boolean).join(",");
- if (groupIds) sp.set("groupIds", groupIds);
- const first = messages[0];
- if (first?.textContent) {
- if (first.mediaId) {
- sp.set("caption", first.textContent);
- sp.set("mediaId", first.mediaId);
- } else {
- sp.set("text", first.textContent);
- }
- } else if (first?.mediaId) {
- sp.set("mediaId", first.mediaId);
- }
- if (reminder.scheduledAt) sp.set("scheduledAt", reminder.scheduledAt.toISOString());
- if (reminder.rrule) sp.set("rrule", reminder.rrule);
- return `/reminders/new?${sp.toString()}`;
- }
+ // Per-section edit pages — each opens a focused single-form editor for
+ // just that part of the reminder, no multi-step flow.
+ type Section = "account" | "message" | "when" | "groups";
+ const editHref = (section: Section): string =>
+ `/reminders/${reminder.id}/edit/${section}`;
const cardClasses =
"transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer";
@@ -143,7 +122,7 @@ export default async function ReminderDetailPage({ params }: Props) {
{/* Account — click to edit step 1 */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
-
+
@@ -165,7 +144,7 @@ export default async function ReminderDetailPage({ params }: Props) {
{/* Message — click to edit step 2 */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
-
+
@@ -201,7 +180,7 @@ export default async function ReminderDetailPage({ params }: Props) {
{/* When / Recurrence — click to edit step 3 */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
-
+
@@ -231,7 +210,7 @@ export default async function ReminderDetailPage({ params }: Props) {
{/* Groups — click to edit step 4 */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
-
+
diff --git a/apps/web/src/components/nav-config.ts b/apps/web/src/components/nav-config.ts
index 00b97c2..05dcc03 100644
--- a/apps/web/src/components/nav-config.ts
+++ b/apps/web/src/components/nav-config.ts
@@ -1,4 +1,4 @@
-import { Home, Smartphone, Calendar, Settings } from "lucide-react";
+import { Home, Smartphone, Calendar, Activity, Settings } from "lucide-react";
import type { LucideIcon } from "lucide-react";
export interface NavItem {
@@ -12,5 +12,6 @@ export const NAV_ITEMS: NavItem[] = [
{ key: "dashboard", href: "/", label: "Dashboard", icon: Home },
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
+ { key: "activity", href: "/activity", label: "Activity", icon: Activity },
{ key: "settings", href: "/settings", label: "Settings", icon: Settings },
];
diff --git a/apps/web/src/components/pair-live.tsx b/apps/web/src/components/pair-live.tsx
index 31e177c..97ba945 100644
--- a/apps/web/src/components/pair-live.tsx
+++ b/apps/web/src/components/pair-live.tsx
@@ -7,6 +7,7 @@ import { CheckCircle2Icon, XCircleIcon, ScanLineIcon, DownloadIcon } from "lucid
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useEvents } from "@/hooks/use-events";
+import { countdownRender } from "@/lib/qr-dedupe";
type PairingState =
| { phase: "waiting" }
@@ -20,19 +21,22 @@ interface PairLiveProps {
}
function CountdownBar({ seconds, total }: { seconds: number; total: number }) {
- const pct = Math.max(0, Math.min(100, Math.round((seconds / total) * 100)));
- const danger = seconds <= 10;
+ const { pct, danger, expired } = countdownRender(seconds, total);
return (
- QR expires in
-
- {seconds}s
+
+ {expired ? "QR expired — waiting for refresh" : "QR expires in"}
+ {!expired && (
+
+ {seconds}s
+
+ )}
(null);
+
+ const accountChanged = selected !== initialAccountId;
+
+ async function handleSave() {
+ setSubmitting(true);
+ setError(null);
+ try {
+ const r = await updateReminderAction({
+ reminderId,
+ accountId: selected,
+ // Account scope changes invalidate group selection — drop targets
+ // when switching accounts so the action doesn't fail validating a
+ // mixed-account groupIds set. The user re-picks groups afterwards.
+ groupIds: accountChanged ? [] : [],
+ text,
+ mediaId,
+ caption,
+ scheduledAtIso,
+ rrule,
+ timezone,
+ });
+ if (r.ok) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ router.push(`/reminders/${reminderId}` as any);
+ } else {
+ setError(r.error);
+ setSubmitting(false);
+ }
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Unexpected error");
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+ {accounts.length === 0 ? (
+
+ No accounts paired yet. Pair an account before you can change this.
+
+ ) : (
+
+ {accounts.map((account) => {
+ const active = account.id === selected;
+ const connected = account.status === "connected";
+ return (
+
+ );
+ })}
+
+ )}
+
+ {accountChanged && (
+
+
+ Group targets will be cleared because groups are scoped per account.
+ Re-pick them on the Groups section after saving.
+
+ )}
+
+ {error && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/reminder-edit/edit-groups-form.tsx b/apps/web/src/components/reminder-edit/edit-groups-form.tsx
new file mode 100644
index 0000000..6ee3ab8
--- /dev/null
+++ b/apps/web/src/components/reminder-edit/edit-groups-form.tsx
@@ -0,0 +1,196 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { useRouter } from "next/navigation";
+import {
+ AlertCircleIcon,
+ Loader2Icon,
+ SaveIcon,
+ SearchIcon,
+ UsersIcon,
+} from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { cn } from "@/lib/utils";
+import { updateReminderAction } from "@/actions/reminders";
+
+interface Group {
+ id: string;
+ name: string;
+ participantCount: number;
+ isArchived: boolean;
+}
+
+interface EditGroupsFormProps {
+ reminderId: string;
+ accountId: string;
+ scheduledAtIso: string;
+ rrule: string | null;
+ text: string | null;
+ mediaId: string | null;
+ caption: string | null;
+ timezone: string;
+ groups: Group[];
+ initialSelected: string[];
+}
+
+export function EditGroupsForm({
+ reminderId,
+ accountId,
+ scheduledAtIso,
+ rrule,
+ text,
+ mediaId,
+ caption,
+ timezone,
+ groups,
+ initialSelected,
+}: EditGroupsFormProps) {
+ const router = useRouter();
+ const [selected, setSelected] = useState
>(() => new Set(initialSelected));
+ const [search, setSearch] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const filtered = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ if (!q) return groups;
+ return groups.filter((g) => g.name.toLowerCase().includes(q));
+ }, [groups, search]);
+
+ function toggle(id: string) {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ setError(null);
+ }
+
+ async function handleSave() {
+ setSubmitting(true);
+ setError(null);
+ try {
+ const r = await updateReminderAction({
+ reminderId,
+ accountId,
+ groupIds: Array.from(selected),
+ text,
+ mediaId,
+ caption,
+ scheduledAtIso,
+ rrule,
+ timezone,
+ });
+ if (r.ok) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ router.push(`/reminders/${reminderId}` as any);
+ } else {
+ setError(r.error);
+ setSubmitting(false);
+ }
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Unexpected error");
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
+ setSearch(e.target.value)}
+ className="pl-8"
+ aria-label="Search groups"
+ />
+
+
+ {selected.size > 0 && (
+
+ {selected.size} group{selected.size !== 1 ? "s" : ""} selected
+
+ )}
+
+ {filtered.length === 0 ? (
+
+
+
+ {groups.length === 0 ? "No groups yet for this account." : "No groups match your search."}
+
+
+ ) : (
+
+ {filtered.map((group) => {
+ const isChecked = selected.has(group.id);
+ return (
+
+ );
+ })}
+
+ )}
+
+ {error && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/reminder-edit/edit-message-form.test.tsx b/apps/web/src/components/reminder-edit/edit-message-form.test.tsx
new file mode 100644
index 0000000..4db5fa4
--- /dev/null
+++ b/apps/web/src/components/reminder-edit/edit-message-form.test.tsx
@@ -0,0 +1,103 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+// Mocks must come before the import that uses them.
+const updateMock = vi.fn();
+vi.mock("@/actions/reminders", () => ({
+ updateReminderAction: (...args: unknown[]) => updateMock(...args),
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn() }),
+}));
+
+import { EditMessageForm } from "./edit-message-form";
+
+const baseProps = {
+ reminderId: "r-1",
+ accountId: "acc-1",
+ groupIds: ["g-1", "g-2"],
+ scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
+ rrule: "FREQ=DAILY",
+ timezone: "Asia/Kuala_Lumpur",
+ initialText: "Hello",
+ initialMediaId: null as string | null,
+ initialCaption: "",
+};
+
+describe("EditMessageForm — SSR layout", () => {
+ it("pre-fills the textarea with the existing text", () => {
+ const html = renderToStaticMarkup();
+ expect(html).toContain('");
+ });
+
+ it("hides the caption field when no media is attached", () => {
+ const html = renderToStaticMarkup();
+ expect(html).not.toContain('id="msg-caption"');
+ });
+
+ it("shows the caption field when media is attached", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('id="msg-caption"');
+ expect(html).toMatch(/value="hi there"/);
+ });
+
+ it("renders a Save button (not 'Save changes', not 'Schedule reminder')", () => {
+ const html = renderToStaticMarkup();
+ // Must look like a single-section save, not the wizard's submit copy.
+ expect(html).toMatch(/