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()}
|
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
|
||||||
rrule={reminder.rrule}
|
rrule={reminder.rrule}
|
||||||
timezone={reminder.timezone}
|
timezone={reminder.timezone}
|
||||||
initialName={reminder.name}
|
name={reminder.name}
|
||||||
initialMessages={initialMessages}
|
initialMessages={initialMessages}
|
||||||
initialMediaInfo={mediaInfo}
|
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,
|
FileTextIcon,
|
||||||
RepeatIcon,
|
RepeatIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
|
TagIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { describeRecurrence, specFromRrule } from "@/lib/recurrence";
|
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
|
// Per-section edit pages — each opens a focused single-form editor for
|
||||||
// just that part of the reminder, no multi-step flow.
|
// 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 =>
|
const editHref = (section: Section): string =>
|
||||||
`/reminders/${reminder.id}/edit/${section}`;
|
`/reminders/${reminder.id}/edit/${section}`;
|
||||||
|
|
||||||
@ -120,6 +121,26 @@ export default async function ReminderDetailPage({ params }: Props) {
|
|||||||
|
|
||||||
<Separator />
|
<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 */}
|
{/* Account — click to edit step 1 */}
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<Link href={editHref("account") as any} className={linkWrapperClasses} aria-label="Edit account">
|
<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",
|
scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
|
||||||
rrule: "FREQ=DAILY",
|
rrule: "FREQ=DAILY",
|
||||||
timezone: "Asia/Kuala_Lumpur",
|
timezone: "Asia/Kuala_Lumpur",
|
||||||
initialName: "",
|
name: "Existing name",
|
||||||
initialMessages: [
|
initialMessages: [
|
||||||
{ kind: "text", textContent: "Hello", mediaId: null },
|
{ kind: "text", textContent: "Hello", mediaId: null },
|
||||||
] satisfies MessagePart[],
|
] satisfies MessagePart[],
|
||||||
@ -73,6 +73,7 @@ describe("EditMessageForm — submission delegates to updateReminderAction", ()
|
|||||||
reminderId: baseProps.reminderId,
|
reminderId: baseProps.reminderId,
|
||||||
accountId: baseProps.accountId,
|
accountId: baseProps.accountId,
|
||||||
groupIds: baseProps.groupIds,
|
groupIds: baseProps.groupIds,
|
||||||
|
name: baseProps.name,
|
||||||
messages: [{ kind: "text", textContent: "Hello", mediaId: null }],
|
messages: [{ kind: "text", textContent: "Hello", mediaId: null }],
|
||||||
scheduledAtIso: baseProps.scheduledAtIso,
|
scheduledAtIso: baseProps.scheduledAtIso,
|
||||||
rrule: baseProps.rrule,
|
rrule: baseProps.rrule,
|
||||||
|
|||||||
@ -2,14 +2,11 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { MessageStack } from "@/components/message-stack";
|
import { MessageStack } from "@/components/message-stack";
|
||||||
import { updateReminderAction } from "@/actions/reminders";
|
import { updateReminderAction } from "@/actions/reminders";
|
||||||
import type { MessagePart } from "@/lib/reminder-messages";
|
import type { MessagePart } from "@/lib/reminder-messages";
|
||||||
import { REMINDER_NAME_MAX } from "@/lib/reminder-name";
|
|
||||||
|
|
||||||
interface EditMessageFormProps {
|
interface EditMessageFormProps {
|
||||||
reminderId: string;
|
reminderId: string;
|
||||||
@ -18,7 +15,9 @@ interface EditMessageFormProps {
|
|||||||
scheduledAtIso: string;
|
scheduledAtIso: string;
|
||||||
rrule: string | null;
|
rrule: string | null;
|
||||||
timezone: string;
|
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[];
|
initialMessages: MessagePart[];
|
||||||
initialMediaInfo?: Record<string, { filename: string; mimeType: string }>;
|
initialMediaInfo?: Record<string, { filename: string; mimeType: string }>;
|
||||||
}
|
}
|
||||||
@ -36,22 +35,16 @@ export function EditMessageForm({
|
|||||||
scheduledAtIso,
|
scheduledAtIso,
|
||||||
rrule,
|
rrule,
|
||||||
timezone,
|
timezone,
|
||||||
initialName,
|
name,
|
||||||
initialMessages,
|
initialMessages,
|
||||||
initialMediaInfo,
|
initialMediaInfo,
|
||||||
}: EditMessageFormProps) {
|
}: EditMessageFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [name, setName] = useState(initialName);
|
|
||||||
const [messages, setMessages] = useState<MessagePart[]>(initialMessages);
|
const [messages, setMessages] = useState<MessagePart[]>(initialMessages);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
const trimmedName = name.trim();
|
|
||||||
if (!trimmedName) {
|
|
||||||
setError("Give the reminder a name.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
setError("Add at least one text or file part.");
|
setError("Add at least one text or file part.");
|
||||||
return;
|
return;
|
||||||
@ -63,7 +56,7 @@ export function EditMessageForm({
|
|||||||
reminderId,
|
reminderId,
|
||||||
accountId,
|
accountId,
|
||||||
groupIds,
|
groupIds,
|
||||||
name: trimmedName,
|
name,
|
||||||
messages,
|
messages,
|
||||||
scheduledAtIso,
|
scheduledAtIso,
|
||||||
rrule,
|
rrule,
|
||||||
@ -84,26 +77,6 @@ export function EditMessageForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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
|
<MessageStack
|
||||||
initial={initialMessages}
|
initial={initialMessages}
|
||||||
initialMediaInfo={initialMediaInfo}
|
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",
|
scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
|
||||||
rrule: "FREQ=DAILY",
|
rrule: "FREQ=DAILY",
|
||||||
timezone: "Asia/Kuala_Lumpur",
|
timezone: "Asia/Kuala_Lumpur",
|
||||||
initialName: "",
|
name: "Existing name",
|
||||||
initialMessages: [
|
initialMessages: [
|
||||||
{ kind: "text" as const, textContent: "Hello", mediaId: null },
|
{ 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"
|
scheduledAtIso="2026-05-13T09:00:00.000+08:00"
|
||||||
rrule={null}
|
rrule={null}
|
||||||
timezone="Asia/Kuala_Lumpur"
|
timezone="Asia/Kuala_Lumpur"
|
||||||
initialName=""
|
name="Existing name"
|
||||||
initialMessages={[{ kind: "text", textContent: "Hello", mediaId: null }]}
|
initialMessages={[{ kind: "text", textContent: "Hello", mediaId: null }]}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user