Reminders pick up a real, user-controlled name instead of being auto-named from the first message body. Auto-derive stays as the fallback so empty inputs still produce something useful. Resolution policy (single source of truth in lib/reminder-name.ts) ------------------------------------------------------------------ 1. User-supplied name, trimmed, clamped to 60 chars. 2. First text-bearing message part — text body or media caption, trimmed, clamped to 60. 3. Literal "Reminder" (only if every part is media-without-caption and no name was given). Wizard ------ - New "Name" input above the message stack on step 2 (Compose). Optional (label says so), maxLength 60, placeholder gives an example. Blank flows through the URL as an absent param. - The name parameter passes through every subsequent step (when, groups, review) via the existing URL-state pattern. - Review step gains a "Name" row at the very top showing what the resolver will produce. If the user left it blank, the row shows the auto-derived value plus a muted "(auto from message)" tag so they know what's happening. Edit forms ---------- - `EditMessageForm` gains the same Name input at the top — consistent with the wizard's compose step. - `EditAccountForm` / `EditWhenForm` / `EditGroupsForm` accept the current `name` and forward it unchanged on save. Otherwise saving any of those sections would re-auto-derive the name from the message body, silently overriding what the operator typed. Server action ------------- - Both `createReminderAction` and `updateReminderAction` accept an optional `name` field on the schema. The body collapses through the new `resolveReminderName` helper, replacing the inline `firstLabel ?? "Reminder"` slice. Tests (+17 new in lib/reminder-name.test.ts) -------------------------------------------- - User priority: user name wins over message body even when both are present; trimming. - Auto-derive: first text part, first non-empty after skipping empties, media caption when present, trims around the value. - Fallback: null/undefined/empty stack, every-part-empty, every part media-without-caption. - Clamping: user-supplied long names truncate at 60; auto-derived long names truncate at 60; short names pass through. - The 60-char ceiling matches what the wizard's <Input maxLength> enforces and what the DB column allows. Existing tests updated to pass the new required prop (`initialName` on EditMessageForm, `name` on EditAccountForm/EditGroupsForm SSR fixtures, plus a couple in no-render-warnings.test.tsx). Total: 298 web + 31 shared + 26 bot = 355 passing (was 338). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
95 lines
3.3 KiB
TypeScript
95 lines
3.3 KiB
TypeScript
import Link from "next/link";
|
|
import { redirect } from "next/navigation";
|
|
import { ArrowLeftIcon } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { getSeededOperator } from "@/lib/operator";
|
|
import { WhenFormClient } from "./when-form-client";
|
|
import { specFromRrule } from "@/lib/recurrence";
|
|
import { defaultFirstFireIso } from "@/lib/date-picker";
|
|
import { decodeMessages, legacyMessageToParts } from "@/lib/reminder-messages";
|
|
|
|
interface StepWhenParams {
|
|
step?: string;
|
|
accountId?: string;
|
|
groupIds?: string;
|
|
/** User-supplied reminder name (passes through unchanged). */
|
|
name?: string;
|
|
/** New shape — encoded MessagePart[]. */
|
|
messages?: string;
|
|
/** Legacy single-message fields, accepted for back-compat. */
|
|
text?: string;
|
|
mediaId?: string;
|
|
caption?: string;
|
|
scheduledAt?: string;
|
|
rrule?: string;
|
|
editReminderId?: string;
|
|
}
|
|
|
|
interface StepWhenProps {
|
|
params: StepWhenParams;
|
|
}
|
|
|
|
export async function StepWhen({ params }: StepWhenProps) {
|
|
const { accountId, groupIds, scheduledAt, rrule, editReminderId } = params;
|
|
|
|
// Resolve the messages param once: prefer the new shape, fall back to
|
|
// the legacy fields. Either way the wizard needs at least one part to
|
|
// continue past Compose.
|
|
const messagesParam =
|
|
params.messages && decodeMessages(params.messages)
|
|
? params.messages
|
|
: (() => {
|
|
const legacy = legacyMessageToParts(params.text, params.mediaId, params.caption);
|
|
if (!legacy) return undefined;
|
|
// Re-encode the legacy fallback so subsequent steps see one
|
|
// canonical wire-format and don't have to know about both.
|
|
return new URLSearchParams({
|
|
messages: encodeURIComponent(JSON.stringify(legacy)),
|
|
}).get("messages") ?? undefined;
|
|
})();
|
|
|
|
if (!accountId || !messagesParam) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
redirect("/reminders/new" as any);
|
|
}
|
|
|
|
const op = await getSeededOperator();
|
|
const timezone = op.defaultTimezone ?? "UTC";
|
|
|
|
const backParams = new URLSearchParams({ step: "2", accountId });
|
|
if (groupIds) backParams.set("groupIds", groupIds);
|
|
if (params.name) backParams.set("name", params.name);
|
|
if (messagesParam) backParams.set("messages", messagesParam);
|
|
if (rrule) backParams.set("rrule", rrule);
|
|
if (editReminderId) backParams.set("editReminderId", editReminderId);
|
|
const backHref = `/reminders/new?${backParams.toString()}`;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
<Link href={backHref as any}>
|
|
<ArrowLeftIcon />
|
|
Back
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
Choose when to send this reminder. Times are in{" "}
|
|
<span className="font-medium text-foreground">{timezone}</span>.
|
|
</p>
|
|
|
|
<WhenFormClient
|
|
accountId={accountId}
|
|
groupIds={groupIds ?? ""}
|
|
timezone={timezone}
|
|
initialDefaultIso={scheduledAt ?? defaultFirstFireIso(timezone)}
|
|
initialSpec={specFromRrule(rrule)}
|
|
passThroughParams={{ name: params.name, messages: messagesParam, editReminderId }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|