feat(web): extract reminder name to its own edit section

The name input previously lived inside the message edit page. Now that
it's a required field — and one users may want to revise without
touching the message stack — it gets a dedicated card on the reminder
detail page and its own edit route at /reminders/[id]/edit/name.

EditMessageForm receives the name as a pass-through prop so saving
messages doesn't drop the existing name from the action payload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 14:23:23 +08:00
parent d5b8c0beeb
commit c4d4f1dda7
7 changed files with 205 additions and 38 deletions

View File

@ -59,7 +59,7 @@ export default async function EditMessagePage({ params }: Props) {
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
rrule={reminder.rrule}
timezone={reminder.timezone}
initialName={reminder.name}
name={reminder.name}
initialMessages={initialMessages}
initialMediaInfo={mediaInfo}
/>

View File

@ -0,0 +1,50 @@
import { notFound } from "next/navigation";
import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns } from "@/lib/queries";
import { EditShell } from "@/components/reminder-edit/edit-shell";
import { EditNameForm } from "@/components/reminder-edit/edit-name-form";
import type { MessagePart } from "@/lib/reminder-messages";
interface Props {
params: Promise<{ id: string }>;
}
export default async function EditNamePage({ 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;
// Forward the existing message stack so saving the name doesn't
// wipe parts 2..N from reminder_messages (the action replaces the
// stack wholesale on every update).
const initialMessages: MessagePart[] = messages
.slice()
.sort((a, b) => a.position - b.position)
.map((m) => ({
kind: m.kind === "media" ? "media" : "text",
textContent: m.textContent ?? null,
mediaId: m.mediaId ?? null,
}));
return (
<EditShell
reminderId={reminder.id}
title="Edit name"
description="The label shown in the reminder list, detail header, and activity log."
>
<EditNameForm
reminderId={reminder.id}
accountId={reminder.accountId}
groupIds={targets.map((t) => t.groupId)}
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
rrule={reminder.rrule}
timezone={reminder.timezone}
initialName={reminder.name}
messages={initialMessages}
/>
</EditShell>
);
}

View File

@ -9,6 +9,7 @@ import {
FileTextIcon,
RepeatIcon,
PencilIcon,
TagIcon,
} from "lucide-react";
import { DateTime } from "luxon";
import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
@ -87,7 +88,7 @@ export default async function ReminderDetailPage({ params }: Props) {
// 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";
type Section = "name" | "account" | "message" | "when" | "groups";
const editHref = (section: Section): string =>
`/reminders/${reminder.id}/edit/${section}`;
@ -120,6 +121,26 @@ export default async function ReminderDetailPage({ params }: Props) {
<Separator />
{/* Name click to edit. Required field, the operator's
identifier for the reminder in lists / activity / runs. */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={editHref("name") as any} className={linkWrapperClasses} aria-label="Edit name">
<Card className={cardClasses}>
<CardContent className="flex items-start gap-3 py-4 px-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<TagIcon className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Name
</p>
<p className="text-sm font-medium truncate">{reminder.name}</p>
</div>
<PencilIcon className="size-3.5 text-muted-foreground/60 mt-1 shrink-0" />
</CardContent>
</Card>
</Link>
{/* Account — click to edit step 1 */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={editHref("account") as any} className={linkWrapperClasses} aria-label="Edit account">

View File

@ -20,7 +20,7 @@ const baseProps = {
scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
rrule: "FREQ=DAILY",
timezone: "Asia/Kuala_Lumpur",
initialName: "",
name: "Existing name",
initialMessages: [
{ kind: "text", textContent: "Hello", mediaId: null },
] satisfies MessagePart[],
@ -73,6 +73,7 @@ describe("EditMessageForm — submission delegates to updateReminderAction", ()
reminderId: baseProps.reminderId,
accountId: baseProps.accountId,
groupIds: baseProps.groupIds,
name: baseProps.name,
messages: [{ kind: "text", textContent: "Hello", mediaId: null }],
scheduledAtIso: baseProps.scheduledAtIso,
rrule: baseProps.rrule,

View File

@ -2,14 +2,11 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AlertCircleIcon, Loader2Icon, SaveIcon, TagIcon } from "lucide-react";
import { AlertCircleIcon, Loader2Icon, SaveIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { MessageStack } from "@/components/message-stack";
import { updateReminderAction } from "@/actions/reminders";
import type { MessagePart } from "@/lib/reminder-messages";
import { REMINDER_NAME_MAX } from "@/lib/reminder-name";
interface EditMessageFormProps {
reminderId: string;
@ -18,7 +15,9 @@ interface EditMessageFormProps {
scheduledAtIso: string;
rrule: string | null;
timezone: string;
initialName: string;
/** Existing name passed through unchanged on save. The name has
* its own dedicated edit page now (/reminders/[id]/edit/name). */
name: string;
initialMessages: MessagePart[];
initialMediaInfo?: Record<string, { filename: string; mimeType: string }>;
}
@ -36,22 +35,16 @@ export function EditMessageForm({
scheduledAtIso,
rrule,
timezone,
initialName,
name,
initialMessages,
initialMediaInfo,
}: EditMessageFormProps) {
const router = useRouter();
const [name, setName] = useState(initialName);
const [messages, setMessages] = useState<MessagePart[]>(initialMessages);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSave() {
const trimmedName = name.trim();
if (!trimmedName) {
setError("Give the reminder a name.");
return;
}
if (messages.length === 0) {
setError("Add at least one text or file part.");
return;
@ -63,7 +56,7 @@ export function EditMessageForm({
reminderId,
accountId,
groupIds,
name: trimmedName,
name,
messages,
scheduledAtIso,
rrule,
@ -84,26 +77,6 @@ export function EditMessageForm({
return (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="reminder-name" className="flex items-center gap-1.5">
<TagIcon className="size-3.5" />
Name
</Label>
<Input
id="reminder-name"
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
placeholder="e.g. Sunday morning standup"
maxLength={REMINDER_NAME_MAX}
required
aria-required="true"
/>
</div>
<MessageStack
initial={initialMessages}
initialMediaInfo={initialMediaInfo}

View File

@ -0,0 +1,122 @@
"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 { updateReminderAction } from "@/actions/reminders";
import type { MessagePart } from "@/lib/reminder-messages";
import { REMINDER_NAME_MAX } from "@/lib/reminder-name";
interface EditNameFormProps {
reminderId: string;
accountId: string;
groupIds: string[];
scheduledAtIso: string;
rrule: string | null;
timezone: string;
initialName: string;
/** Existing message stack passed through unchanged on save so
* this form doesn't drop parts 2..N. */
messages: MessagePart[];
}
/**
* Single-purpose form for the reminder's display name. Lives at
* /reminders/[id]/edit/name. Mirrors the pattern set by EditAccountForm
* (one piece of state, validate, save, route back). Required field
* empty submits are blocked client-side and rejected by the action's
* Zod schema.
*/
export function EditNameForm({
reminderId,
accountId,
groupIds,
scheduledAtIso,
rrule,
timezone,
initialName,
messages,
}: EditNameFormProps) {
const router = useRouter();
const [name, setName] = useState(initialName);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSave() {
const trimmed = name.trim();
if (!trimmed) {
setError("Give the reminder a name.");
return;
}
setSubmitting(true);
setError(null);
try {
const r = await updateReminderAction({
reminderId,
accountId,
groupIds,
name: trimmed,
messages,
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 (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="reminder-name" className="flex items-center gap-1.5">
<TagIcon className="size-3.5" />
Name
</Label>
<Input
id="reminder-name"
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
placeholder="e.g. Sunday morning standup"
maxLength={REMINDER_NAME_MAX}
required
aria-required="true"
autoFocus
/>
<p className="text-xs text-muted-foreground">
Up to {REMINDER_NAME_MAX} characters. Shown in the reminder list and
activity log.
</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>
);
}

View File

@ -130,7 +130,7 @@ describe("SSR render — no React errors or warnings", () => {
scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
rrule: "FREQ=DAILY",
timezone: "Asia/Kuala_Lumpur",
initialName: "",
name: "Existing name",
initialMessages: [
{ kind: "text" as const, textContent: "Hello", mediaId: null },
],
@ -181,7 +181,7 @@ describe("SSR markup — no <div> inside <button> (the bug we just fixed)", () =
scheduledAtIso="2026-05-13T09:00:00.000+08:00"
rrule={null}
timezone="Asia/Kuala_Lumpur"
initialName=""
name="Existing name"
initialMessages={[{ kind: "text", textContent: "Hello", mediaId: null }]}
/>,
);