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:
parent
d5b8c0beeb
commit
c4d4f1dda7
@ -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}
|
||||
/>
|
||||
|
||||
50
apps/web/src/app/reminders/[id]/edit/name/page.tsx
Normal file
50
apps/web/src/app/reminders/[id]/edit/name/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
122
apps/web/src/components/reminder-edit/edit-name-form.tsx
Normal file
122
apps/web/src/components/reminder-edit/edit-name-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user