From c4d4f1dda71ab445db0eae23f2d054171b584865 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 14:23:23 +0800 Subject: [PATCH] feat(web): extract reminder name to its own edit section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../app/reminders/[id]/edit/message/page.tsx | 2 +- .../src/app/reminders/[id]/edit/name/page.tsx | 50 +++++++ apps/web/src/app/reminders/[id]/page.tsx | 23 +++- .../reminder-edit/edit-message-form.test.tsx | 3 +- .../reminder-edit/edit-message-form.tsx | 39 +----- .../reminder-edit/edit-name-form.tsx | 122 ++++++++++++++++++ apps/web/src/test/no-render-warnings.test.tsx | 4 +- 7 files changed, 205 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/app/reminders/[id]/edit/name/page.tsx create mode 100644 apps/web/src/components/reminder-edit/edit-name-form.tsx diff --git a/apps/web/src/app/reminders/[id]/edit/message/page.tsx b/apps/web/src/app/reminders/[id]/edit/message/page.tsx index ca38359..6e8a8b0 100644 --- a/apps/web/src/app/reminders/[id]/edit/message/page.tsx +++ b/apps/web/src/app/reminders/[id]/edit/message/page.tsx @@ -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} /> diff --git a/apps/web/src/app/reminders/[id]/edit/name/page.tsx b/apps/web/src/app/reminders/[id]/edit/name/page.tsx new file mode 100644 index 0000000..a022bea --- /dev/null +++ b/apps/web/src/app/reminders/[id]/edit/name/page.tsx @@ -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 ( + + t.groupId)} + scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()} + rrule={reminder.rrule} + timezone={reminder.timezone} + initialName={reminder.name} + messages={initialMessages} + /> + + ); +} diff --git a/apps/web/src/app/reminders/[id]/page.tsx b/apps/web/src/app/reminders/[id]/page.tsx index 1af4c26..c6828b5 100644 --- a/apps/web/src/app/reminders/[id]/page.tsx +++ b/apps/web/src/app/reminders/[id]/page.tsx @@ -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) { + {/* 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 */} + + + +
+ +
+
+

+ Name +

+

{reminder.name}

+
+ +
+
+ + {/* Account — click to edit step 1 */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} 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 index ffbaf48..1311b83 100644 --- a/apps/web/src/components/reminder-edit/edit-message-form.test.tsx +++ b/apps/web/src/components/reminder-edit/edit-message-form.test.tsx @@ -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, diff --git a/apps/web/src/components/reminder-edit/edit-message-form.tsx b/apps/web/src/components/reminder-edit/edit-message-form.tsx index 4a31e1d..6cfc29a 100644 --- a/apps/web/src/components/reminder-edit/edit-message-form.tsx +++ b/apps/web/src/components/reminder-edit/edit-message-form.tsx @@ -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; } @@ -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(initialMessages); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(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 (
-
- - { - setName(e.target.value); - setError(null); - }} - placeholder="e.g. Sunday morning standup" - maxLength={REMINDER_NAME_MAX} - required - aria-required="true" - /> -
- (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 ( +
+
+ + { + setName(e.target.value); + setError(null); + }} + placeholder="e.g. Sunday morning standup" + maxLength={REMINDER_NAME_MAX} + required + aria-required="true" + autoFocus + /> +

+ Up to {REMINDER_NAME_MAX} characters. Shown in the reminder list and + activity log. +

+
+ + {error && ( +
+ + {error} +
+ )} + +
+ +
+
+ ); +} diff --git a/apps/web/src/test/no-render-warnings.test.tsx b/apps/web/src/test/no-render-warnings.test.tsx index 62835a6..a16d002 100644 --- a/apps/web/src/test/no-render-warnings.test.tsx +++ b/apps/web/src/test/no-render-warnings.test.tsx @@ -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
inside