From 12f2fd95dc63053040f2e912cb0c9267c9d901ef Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Thu, 9 Apr 2026 17:41:26 -0400 Subject: [PATCH] refactor: centralize event dialog form state --- bun.lock | 2 + package.json | 1 + src/app/page.tsx | 79 +++------ src/components/event-dialog.tsx | 277 +++++++++++++++++--------------- src/lib/event-form.ts | 84 ++++++++++ tests/event-dialog.test.tsx | 16 ++ tests/event-form.test.ts | 73 +++++++++ 7 files changed, 344 insertions(+), 188 deletions(-) create mode 100644 src/lib/event-form.ts create mode 100644 tests/event-dialog.test.tsx create mode 100644 tests/event-form.test.ts diff --git a/bun.lock b/bun.lock index 86a3f2f..517d57c 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "react": "19.1.0", "react-day-picker": "^9.9.0", "react-dom": "19.1.0", + "react-hook-form": "^7.66.0", "rrule": "^2.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", @@ -1017,6 +1018,7 @@ "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], + "react-hook-form": ["react-hook-form@7.72.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], diff --git a/package.json b/package.json index 54d1a79..58c49e4 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "react": "19.1.0", "react-day-picker": "^9.9.0", "react-dom": "19.1.0", + "react-hook-form": "^7.66.0", "rrule": "^2.8.1", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", diff --git a/src/app/page.tsx b/src/app/page.tsx index 77defb3..6d25298 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -23,6 +23,11 @@ import { } from "@/lib/events-db"; import { generateICS, parseICS } from "@/lib/ical"; import { appendImagesDeduped } from "@/lib/multi-image"; +import { + getDefaultEventFormValues, + getEventFormValuesFromEvent, + type EventFormValues, +} from "@/lib/event-form"; import type { CalendarEvent } from "@/lib/types"; const fileToBase64 = (file: File): Promise => @@ -48,16 +53,8 @@ export default function HomePage() { const [isDragOver, setIsDragOver] = useState(false); const [isOnline, setIsOnline] = useState(true); - // Form fields - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); - const [location, setLocation] = useState(""); - const [url, setUrl] = useState(""); - const [start, setStart] = useState(""); - const [end, setEnd] = useState(""); - const [allDay, setAllDay] = useState(false); - const [recurrenceRule, setRecurrenceRule] = useState( - undefined, + const [dialogInitialValues, setDialogInitialValues] = useState( + getDefaultEventFormValues(), ); // AI @@ -99,16 +96,9 @@ export default function HomePage() { const { data: session, isPending } = useSession(); const resetForm = () => { - setTitle(""); - setDescription(""); - setLocation(""); - setUrl(""); - setStart(""); - setEnd(""); - setAllDay(false); + setDialogInitialValues(getDefaultEventFormValues()); setEditingId(null); setDialogSource("manual"); - setRecurrenceRule(undefined); }; /** @@ -160,17 +150,17 @@ export default function HomePage() { setImagePreviews([]); }; - const handleSave = async () => { + const handleSave = async (values: EventFormValues) => { const eventData: CalendarEvent = { id: editingId || nanoid(), - title, - description, - location, - url, - recurrenceRule, - start, - end: end || undefined, - allDay, + title: values.title, + description: values.description, + location: values.location, + url: values.url, + recurrenceRule: values.recurrenceRule, + start: values.start, + end: values.end || undefined, + allDay: values.allDay, createdAt: editingId ? events.find((e) => e.id === editingId)?.createdAt : new Date().toISOString(), @@ -223,15 +213,8 @@ export default function HomePage() { }; const populateEventForm = (ev: CalendarEvent) => { - setTitle(ev.title || ""); - setDescription(ev.description || ""); - setLocation(ev.location || ""); - setUrl(ev.url || ""); - setStart(ev.start || ""); - setEnd(ev.end || ""); - setAllDay(ev.allDay || false); + setDialogInitialValues(getEventFormValuesFromEvent(ev)); setEditingId(null); - setRecurrenceRule(ev.recurrenceRule || undefined); }; const persistAiEvents = async (data: CalendarEvent[]) => { @@ -348,16 +331,9 @@ export default function HomePage() { }; const handleEdit = (eventData: CalendarEvent) => { - setTitle(eventData.title); - setDescription(eventData.description || ""); - setLocation(eventData.location || ""); - setUrl(eventData.url || ""); - setStart(eventData.start); - setEnd(eventData.end || ""); - setAllDay(eventData.allDay || false); + setDialogInitialValues(getEventFormValuesFromEvent(eventData)); setEditingId(eventData.id); setDialogSource("manual"); - setRecurrenceRule(eventData.recurrenceRule); setDialogOpen(true); }; @@ -536,22 +512,7 @@ export default function HomePage() { onOpenChange={setDialogOpen} editingId={editingId} dialogSource={dialogSource} - title={title} - setTitle={setTitle} - description={description} - setDescription={setDescription} - location={location} - setLocation={setLocation} - url={url} - setUrl={setUrl} - start={start} - setStart={setStart} - end={end} - setEnd={setEnd} - allDay={allDay} - setAllDay={setAllDay} - recurrenceRule={recurrenceRule} - setRecurrenceRule={setRecurrenceRule} + initialValues={dialogInitialValues} onSave={handleSave} onReset={resetForm} /> diff --git a/src/components/event-dialog.tsx b/src/components/event-dialog.tsx index cc0f9ee..ea62126 100644 --- a/src/components/event-dialog.tsx +++ b/src/components/event-dialog.tsx @@ -1,6 +1,8 @@ "use client"; import { addHours, addMinutes, isValid, parseISO } from "date-fns"; +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; import { LucideMapPin } from "lucide-react"; import { DateTimePicker } from "@/components/date-time-picker"; import { RecurrencePicker } from "@/components/recurrence-picker"; @@ -17,29 +19,20 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { validateRecurrence, parseRecurrenceRule } from "@/lib/recurrence"; +import { + getDefaultEventFormValues, + validateEventFormValues, + type EventFormValues, +} from "@/lib/event-form"; interface EventDialogProps { open: boolean; onOpenChange: (open: boolean) => void; editingId: string | null; dialogSource: "manual" | "ai"; - title: string; - setTitle: (title: string) => void; - description: string; - setDescription: (description: string) => void; - location: string; - setLocation: (location: string) => void; - url: string; - setUrl: (url: string) => void; - start: string; - setStart: (start: string) => void; - end: string; - setEnd: (end: string) => void; - allDay: boolean; - setAllDay: (allDay: boolean) => void; - recurrenceRule: string | undefined; - setRecurrenceRule: (rule: string | undefined) => void; - onSave: () => void; + initialValues: EventFormValues; + onSave: (values: EventFormValues) => void; onReset: () => void; } @@ -48,31 +41,12 @@ export const EventDialog = ({ onOpenChange, editingId, dialogSource, - title, - setTitle, - description, - setDescription, - location, - setLocation, - url, - setUrl, - start, - setStart, - end, - setEnd, - allDay, - setAllDay, - recurrenceRule, - setRecurrenceRule, + initialValues, onSave, onReset, }: EventDialogProps) => { const isAiDraft = dialogSource === "ai" && !editingId; - const titleText = editingId - ? "Edit Event" - : isAiDraft - ? "Review AI Draft" - : "New Event"; + const titleText = editingId ? "Edit Event" : isAiDraft ? "Review AI Draft" : "New Event"; const descriptionText = editingId ? "Update the event details below. Title and start date are required." : isAiDraft @@ -80,9 +54,32 @@ export const EventDialog = ({ : "Create an event manually. Title and start date are required."; const saveLabel = editingId ? "Update Event" : "Save Event"; - const handleOpenChange = (val: boolean) => { - if (!val) onReset(); - onOpenChange(val); + const { + control, + handleSubmit, + register, + reset, + setError, + setValue, + watch, + formState: { errors }, + } = useForm({ + defaultValues: getDefaultEventFormValues(), + }); + + useEffect(() => { + reset(initialValues); + }, [initialValues, reset]); + + const allDay = watch("allDay"); + const start = watch("start"); + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + reset(getDefaultEventFormValues()); + onReset(); + } + onOpenChange(nextOpen); }; const DURATIONS = [ @@ -92,19 +89,49 @@ export const EventDialog = ({ { label: "+3 hours", minutes: 180 }, ]; - const handleApplyDuration = (minutes: number) => { - if (!start) return; - const base = parseISO(start); + const handleApplyDuration = (minutes: number, currentAllDay: boolean, currentStart: string) => { + if (!currentStart) return; + const base = parseISO(currentStart); if (!isValid(base)) return; - const next = - minutes < 60 ? addMinutes(base, minutes) : addHours(base, minutes / 60); - const pad = (n: number) => String(n).padStart(2, "0"); - const result = allDay + const next = minutes < 60 ? addMinutes(base, minutes) : addHours(base, minutes / 60); + const pad = (value: number) => String(value).padStart(2, "0"); + const result = currentAllDay ? `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}` : `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}T${pad(next.getHours())}:${pad(next.getMinutes())}:00`; - setEnd(result); + setValue("end", result, { shouldDirty: true }); }; + const onSubmit = handleSubmit((values) => { + const result = validateEventFormValues(values); + if (!result.success) { + const fieldErrors = result.error.flatten().fieldErrors; + for (const [fieldName, messages] of Object.entries(fieldErrors)) { + const firstMessage = messages?.[0]; + if (firstMessage) { + setError(fieldName as keyof EventFormValues, { message: firstMessage }); + } + } + return; + } + + if (values.recurrenceRule) { + const recurrenceValidation = validateRecurrence(parseRecurrenceRule(values.recurrenceRule)); + if (!recurrenceValidation.isValid) { + setError("recurrenceRule", { + message: + recurrenceValidation.errors.rule || + recurrenceValidation.errors.count || + recurrenceValidation.errors.until || + "Invalid recurrence.", + }); + return; + } + } + + onSave(result.data); + reset(getDefaultEventFormValues()); + }); + return ( @@ -113,35 +140,26 @@ export const EventDialog = ({ {descriptionText} -
+
{isAiDraft && (
- This draft was generated from natural language. Double-check - dates, times, location, recurrence, and links before saving. + This draft was generated from natural language. Double-check dates, times, location, recurrence, and links before saving.
)}
- setTitle(e.target.value)} - className="font-medium" - /> + + {errors.title &&

{errors.title.message}

}