refactor: centralize event dialog form state

This commit is contained in:
2026-04-09 17:41:26 -04:00
parent 911e5735a4
commit 12f2fd95dc
7 changed files with 344 additions and 188 deletions

View File

@@ -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<EventFormValues>({
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="glass-strong max-w-md">
@@ -113,35 +140,26 @@ export const EventDialog = ({
<DialogDescription>{descriptionText}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<form className="space-y-3" onSubmit={onSubmit}>
{isAiDraft && (
<div className="rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-xs leading-relaxed text-primary">
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.
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="event-title">Title</Label>
<Input
id="event-title"
name="title"
placeholder="Event title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="font-medium"
/>
<Input id="event-title" placeholder="Event title" className="font-medium" {...register("title")} />
{errors.title && <p className="text-xs text-destructive">{errors.title.message}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="event-description">Description / notes</Label>
<Textarea
id="event-description"
name="description"
className="field-sizing-content min-h-[60px] max-h-40 resize-none placeholder:text-muted-foreground/50"
placeholder="Add a description..."
value={description}
onChange={(e) => setDescription(e.target.value)}
{...register("description")}
/>
</div>
@@ -149,93 +167,94 @@ export const EventDialog = ({
<div className="space-y-1.5">
<Label htmlFor="event-location">Location</Label>
<div className="relative">
<LucideMapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50" />
<Input
id="event-location"
name="location"
placeholder="Location"
value={location}
onChange={(e) => setLocation(e.target.value)}
className="pl-8"
/>
<LucideMapPin className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/50" />
<Input id="event-location" placeholder="Location" className="pl-8" {...register("location")} />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="event-url">URL</Label>
<Input
id="event-url"
name="url"
placeholder="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<Input id="event-url" placeholder="URL" {...register("url")} />
{errors.url && <p className="text-xs text-destructive">{errors.url.message}</p>}
</div>
</div>
<RecurrencePicker
value={recurrenceRule}
onChange={setRecurrenceRule}
<Controller
name="recurrenceRule"
control={control}
render={({ field }) => (
<RecurrencePicker value={field.value} onChange={field.onChange} start={start} />
)}
/>
{errors.recurrenceRule && (
<p className="text-xs text-destructive">{errors.recurrenceRule.message}</p>
)}
<div className="flex items-center gap-2 py-1">
<Checkbox
id="event-all-day"
checked={allDay}
onCheckedChange={(checked) => setAllDay(checked === true)}
<Controller
name="allDay"
control={control}
render={({ field }) => (
<Checkbox
id="event-all-day"
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
)}
/>
<Label
htmlFor="event-all-day"
className="text-sm font-normal cursor-pointer"
>
<Label htmlFor="event-all-day" className="cursor-pointer text-sm font-normal">
All day
</Label>
</div>
<div className="space-y-2">
<DateTimePicker
value={start}
onChange={setStart}
allDay={allDay}
placeholder="Start date"
<Controller
name="start"
control={control}
render={({ field }) => (
<DateTimePicker value={field.value} onChange={field.onChange} allDay={allDay} placeholder="Start date" />
)}
/>
{!allDay && (
<div className="flex gap-1 pl-0.5">
{DURATIONS.map(({ label, minutes }) => (
<Button
key={label}
type="button"
variant="ghost"
size="sm"
disabled={!start}
onClick={() => handleApplyDuration(minutes)}
className="px-2 py-1 text-xs text-muted-foreground"
>
{label}
</Button>
))}
</div>
)}
<DateTimePicker
value={end}
onChange={setEnd}
allDay={allDay}
placeholder="End date"
<Controller
name="end"
control={control}
render={() => (
<div className="flex gap-1 pl-0.5">
{DURATIONS.map(({ label, minutes }) => (
<Button
key={label}
type="button"
variant="ghost"
size="sm"
disabled={!start}
onClick={() => handleApplyDuration(minutes, allDay, start)}
className="px-2 py-1 text-xs text-muted-foreground"
>
{label}
</Button>
))}
</div>
)}
/>
)}
<Controller
name="end"
control={control}
render={({ field }) => (
<DateTimePicker value={field.value} onChange={field.onChange} allDay={allDay} placeholder="End date" />
)}
/>
{errors.start && <p className="text-xs text-destructive">{errors.start.message}</p>}
{errors.end && <p className="text-xs text-destructive">{errors.end.message}</p>}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="ghost"
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
<Button type="button" onClick={onSave}>
{saveLabel}
</Button>
</DialogFooter>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="ghost" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button type="submit">{saveLabel}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);