refactor: centralize event dialog form state
This commit is contained in:
@@ -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<string> =>
|
||||
@@ -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<string | undefined>(
|
||||
undefined,
|
||||
const [dialogInitialValues, setDialogInitialValues] = useState<EventFormValues>(
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
84
src/lib/event-form.ts
Normal file
84
src/lib/event-form.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { isAfter, parseISO } from "date-fns";
|
||||
import { z } from "zod";
|
||||
import type { CalendarEvent } from "@/lib/types";
|
||||
|
||||
export interface EventFormValues {
|
||||
title: string;
|
||||
description: string;
|
||||
location: string;
|
||||
url: string;
|
||||
start: string;
|
||||
end: string;
|
||||
allDay: boolean;
|
||||
recurrenceRule?: string;
|
||||
}
|
||||
|
||||
const eventFormSchema = z
|
||||
.object({
|
||||
title: z.string().trim().min(1, "Title is required."),
|
||||
description: z.string(),
|
||||
location: z.string(),
|
||||
url: z.preprocess((value) => value ?? "", z.string().trim()),
|
||||
start: z.string().trim().min(1, "Start date is required."),
|
||||
end: z.string(),
|
||||
allDay: z.boolean(),
|
||||
recurrenceRule: z.string().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.url) {
|
||||
const urlValidation = z.string().url().safeParse(value.url);
|
||||
if (!urlValidation.success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["url"],
|
||||
message: "Enter a valid URL.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (value.end) {
|
||||
const startDate = parseISO(value.start);
|
||||
const endDate = parseISO(value.end);
|
||||
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["end"],
|
||||
message: "End date must be valid.",
|
||||
});
|
||||
} else if (!isAfter(endDate, startDate)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["end"],
|
||||
message: "End date must be after the start date.",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const getDefaultEventFormValues = (): EventFormValues => ({
|
||||
title: "",
|
||||
description: "",
|
||||
location: "",
|
||||
url: "",
|
||||
start: "",
|
||||
end: "",
|
||||
allDay: false,
|
||||
recurrenceRule: undefined,
|
||||
});
|
||||
|
||||
export const getEventFormValuesFromEvent = (
|
||||
event?: Partial<CalendarEvent>,
|
||||
): EventFormValues => ({
|
||||
...getDefaultEventFormValues(),
|
||||
title: event?.title || "",
|
||||
description: event?.description || "",
|
||||
location: event?.location || "",
|
||||
url: event?.url || "",
|
||||
start: event?.start || "",
|
||||
end: event?.end || "",
|
||||
allDay: event?.allDay || false,
|
||||
recurrenceRule: event?.recurrenceRule || undefined,
|
||||
});
|
||||
|
||||
export const validateEventFormValues = (values: EventFormValues) =>
|
||||
eventFormSchema.safeParse(values);
|
||||
Reference in New Issue
Block a user