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>
100 lines
2.7 KiB
TypeScript
100 lines
2.7 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { CalendarCheckIcon, AlertCircleIcon, Loader2Icon } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { createReminderAction, updateReminderAction } from "@/actions/reminders";
|
|
import { cn } from "@/lib/utils";
|
|
import type { MessagePart } from "@/lib/reminder-messages";
|
|
|
|
interface ReviewSubmitClientProps {
|
|
accountId: string;
|
|
groupIds?: string;
|
|
name?: string;
|
|
messages: MessagePart[];
|
|
scheduledAt: string;
|
|
rrule?: string;
|
|
editReminderId?: string;
|
|
timezone: string;
|
|
}
|
|
|
|
export function ReviewSubmitClient({
|
|
accountId,
|
|
groupIds,
|
|
name,
|
|
messages,
|
|
scheduledAt,
|
|
rrule,
|
|
editReminderId,
|
|
timezone,
|
|
}: ReviewSubmitClientProps) {
|
|
const router = useRouter();
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function handleSchedule() {
|
|
setSubmitting(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const payload = {
|
|
accountId,
|
|
groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [],
|
|
name: name?.trim() || null,
|
|
messages,
|
|
scheduledAtIso: scheduledAt,
|
|
rrule: rrule ?? null,
|
|
timezone,
|
|
};
|
|
const result = editReminderId
|
|
? await updateReminderAction({ ...payload, reminderId: editReminderId })
|
|
: await createReminderAction(payload);
|
|
|
|
if (result.ok) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
router.push(`/reminders/${result.reminderId}` as any);
|
|
} else {
|
|
setError(result.error);
|
|
setSubmitting(false);
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "An unexpected error occurred.");
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3 pt-2">
|
|
{error && (
|
|
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
|
|
<AlertCircleIcon className="size-4 shrink-0 mt-0.5" />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end">
|
|
<Button
|
|
type="button"
|
|
size="lg"
|
|
onClick={handleSchedule}
|
|
disabled={submitting}
|
|
className={cn("gap-2", submitting && "cursor-wait")}
|
|
>
|
|
{submitting ? (
|
|
<>
|
|
<Loader2Icon className="size-4 animate-spin" />
|
|
{editReminderId ? "Saving…" : "Scheduling…"}
|
|
</>
|
|
) : (
|
|
<>
|
|
<CalendarCheckIcon className="size-4" />
|
|
{editReminderId ? "Save changes" : "Schedule Reminder"}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|