refactor: centralize event dialog form state
This commit is contained in:
2
bun.lock
2
bun.lock
@@ -31,6 +31,7 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-day-picker": "^9.9.0",
|
"react-day-picker": "^9.9.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-hook-form": "^7.66.0",
|
||||||
"rrule": "^2.8.1",
|
"rrule": "^2.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"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-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=="],
|
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-day-picker": "^9.9.0",
|
"react-day-picker": "^9.9.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-hook-form": "^7.66.0",
|
||||||
"rrule": "^2.8.1",
|
"rrule": "^2.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ import {
|
|||||||
} from "@/lib/events-db";
|
} from "@/lib/events-db";
|
||||||
import { generateICS, parseICS } from "@/lib/ical";
|
import { generateICS, parseICS } from "@/lib/ical";
|
||||||
import { appendImagesDeduped } from "@/lib/multi-image";
|
import { appendImagesDeduped } from "@/lib/multi-image";
|
||||||
|
import {
|
||||||
|
getDefaultEventFormValues,
|
||||||
|
getEventFormValuesFromEvent,
|
||||||
|
type EventFormValues,
|
||||||
|
} from "@/lib/event-form";
|
||||||
import type { CalendarEvent } from "@/lib/types";
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
|
|
||||||
const fileToBase64 = (file: File): Promise<string> =>
|
const fileToBase64 = (file: File): Promise<string> =>
|
||||||
@@ -48,16 +53,8 @@ export default function HomePage() {
|
|||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [isOnline, setIsOnline] = useState(true);
|
const [isOnline, setIsOnline] = useState(true);
|
||||||
|
|
||||||
// Form fields
|
const [dialogInitialValues, setDialogInitialValues] = useState<EventFormValues>(
|
||||||
const [title, setTitle] = useState("");
|
getDefaultEventFormValues(),
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// AI
|
// AI
|
||||||
@@ -99,16 +96,9 @@ export default function HomePage() {
|
|||||||
const { data: session, isPending } = useSession();
|
const { data: session, isPending } = useSession();
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setTitle("");
|
setDialogInitialValues(getDefaultEventFormValues());
|
||||||
setDescription("");
|
|
||||||
setLocation("");
|
|
||||||
setUrl("");
|
|
||||||
setStart("");
|
|
||||||
setEnd("");
|
|
||||||
setAllDay(false);
|
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setDialogSource("manual");
|
setDialogSource("manual");
|
||||||
setRecurrenceRule(undefined);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,17 +150,17 @@ export default function HomePage() {
|
|||||||
setImagePreviews([]);
|
setImagePreviews([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async (values: EventFormValues) => {
|
||||||
const eventData: CalendarEvent = {
|
const eventData: CalendarEvent = {
|
||||||
id: editingId || nanoid(),
|
id: editingId || nanoid(),
|
||||||
title,
|
title: values.title,
|
||||||
description,
|
description: values.description,
|
||||||
location,
|
location: values.location,
|
||||||
url,
|
url: values.url,
|
||||||
recurrenceRule,
|
recurrenceRule: values.recurrenceRule,
|
||||||
start,
|
start: values.start,
|
||||||
end: end || undefined,
|
end: values.end || undefined,
|
||||||
allDay,
|
allDay: values.allDay,
|
||||||
createdAt: editingId
|
createdAt: editingId
|
||||||
? events.find((e) => e.id === editingId)?.createdAt
|
? events.find((e) => e.id === editingId)?.createdAt
|
||||||
: new Date().toISOString(),
|
: new Date().toISOString(),
|
||||||
@@ -223,15 +213,8 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const populateEventForm = (ev: CalendarEvent) => {
|
const populateEventForm = (ev: CalendarEvent) => {
|
||||||
setTitle(ev.title || "");
|
setDialogInitialValues(getEventFormValuesFromEvent(ev));
|
||||||
setDescription(ev.description || "");
|
|
||||||
setLocation(ev.location || "");
|
|
||||||
setUrl(ev.url || "");
|
|
||||||
setStart(ev.start || "");
|
|
||||||
setEnd(ev.end || "");
|
|
||||||
setAllDay(ev.allDay || false);
|
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setRecurrenceRule(ev.recurrenceRule || undefined);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const persistAiEvents = async (data: CalendarEvent[]) => {
|
const persistAiEvents = async (data: CalendarEvent[]) => {
|
||||||
@@ -348,16 +331,9 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (eventData: CalendarEvent) => {
|
const handleEdit = (eventData: CalendarEvent) => {
|
||||||
setTitle(eventData.title);
|
setDialogInitialValues(getEventFormValuesFromEvent(eventData));
|
||||||
setDescription(eventData.description || "");
|
|
||||||
setLocation(eventData.location || "");
|
|
||||||
setUrl(eventData.url || "");
|
|
||||||
setStart(eventData.start);
|
|
||||||
setEnd(eventData.end || "");
|
|
||||||
setAllDay(eventData.allDay || false);
|
|
||||||
setEditingId(eventData.id);
|
setEditingId(eventData.id);
|
||||||
setDialogSource("manual");
|
setDialogSource("manual");
|
||||||
setRecurrenceRule(eventData.recurrenceRule);
|
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -536,22 +512,7 @@ export default function HomePage() {
|
|||||||
onOpenChange={setDialogOpen}
|
onOpenChange={setDialogOpen}
|
||||||
editingId={editingId}
|
editingId={editingId}
|
||||||
dialogSource={dialogSource}
|
dialogSource={dialogSource}
|
||||||
title={title}
|
initialValues={dialogInitialValues}
|
||||||
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}
|
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onReset={resetForm}
|
onReset={resetForm}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { addHours, addMinutes, isValid, parseISO } from "date-fns";
|
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 { LucideMapPin } from "lucide-react";
|
||||||
import { DateTimePicker } from "@/components/date-time-picker";
|
import { DateTimePicker } from "@/components/date-time-picker";
|
||||||
import { RecurrencePicker } from "@/components/recurrence-picker";
|
import { RecurrencePicker } from "@/components/recurrence-picker";
|
||||||
@@ -17,29 +19,20 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { validateRecurrence, parseRecurrenceRule } from "@/lib/recurrence";
|
||||||
|
import {
|
||||||
|
getDefaultEventFormValues,
|
||||||
|
validateEventFormValues,
|
||||||
|
type EventFormValues,
|
||||||
|
} from "@/lib/event-form";
|
||||||
|
|
||||||
interface EventDialogProps {
|
interface EventDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
editingId: string | null;
|
editingId: string | null;
|
||||||
dialogSource: "manual" | "ai";
|
dialogSource: "manual" | "ai";
|
||||||
title: string;
|
initialValues: EventFormValues;
|
||||||
setTitle: (title: string) => void;
|
onSave: (values: EventFormValues) => 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;
|
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,31 +41,12 @@ export const EventDialog = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
editingId,
|
editingId,
|
||||||
dialogSource,
|
dialogSource,
|
||||||
title,
|
initialValues,
|
||||||
setTitle,
|
|
||||||
description,
|
|
||||||
setDescription,
|
|
||||||
location,
|
|
||||||
setLocation,
|
|
||||||
url,
|
|
||||||
setUrl,
|
|
||||||
start,
|
|
||||||
setStart,
|
|
||||||
end,
|
|
||||||
setEnd,
|
|
||||||
allDay,
|
|
||||||
setAllDay,
|
|
||||||
recurrenceRule,
|
|
||||||
setRecurrenceRule,
|
|
||||||
onSave,
|
onSave,
|
||||||
onReset,
|
onReset,
|
||||||
}: EventDialogProps) => {
|
}: EventDialogProps) => {
|
||||||
const isAiDraft = dialogSource === "ai" && !editingId;
|
const isAiDraft = dialogSource === "ai" && !editingId;
|
||||||
const titleText = editingId
|
const titleText = editingId ? "Edit Event" : isAiDraft ? "Review AI Draft" : "New Event";
|
||||||
? "Edit Event"
|
|
||||||
: isAiDraft
|
|
||||||
? "Review AI Draft"
|
|
||||||
: "New Event";
|
|
||||||
const descriptionText = editingId
|
const descriptionText = editingId
|
||||||
? "Update the event details below. Title and start date are required."
|
? "Update the event details below. Title and start date are required."
|
||||||
: isAiDraft
|
: isAiDraft
|
||||||
@@ -80,9 +54,32 @@ export const EventDialog = ({
|
|||||||
: "Create an event manually. Title and start date are required.";
|
: "Create an event manually. Title and start date are required.";
|
||||||
const saveLabel = editingId ? "Update Event" : "Save Event";
|
const saveLabel = editingId ? "Update Event" : "Save Event";
|
||||||
|
|
||||||
const handleOpenChange = (val: boolean) => {
|
const {
|
||||||
if (!val) onReset();
|
control,
|
||||||
onOpenChange(val);
|
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 = [
|
const DURATIONS = [
|
||||||
@@ -92,19 +89,49 @@ export const EventDialog = ({
|
|||||||
{ label: "+3 hours", minutes: 180 },
|
{ label: "+3 hours", minutes: 180 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleApplyDuration = (minutes: number) => {
|
const handleApplyDuration = (minutes: number, currentAllDay: boolean, currentStart: string) => {
|
||||||
if (!start) return;
|
if (!currentStart) return;
|
||||||
const base = parseISO(start);
|
const base = parseISO(currentStart);
|
||||||
if (!isValid(base)) return;
|
if (!isValid(base)) return;
|
||||||
const next =
|
const next = minutes < 60 ? addMinutes(base, minutes) : addHours(base, minutes / 60);
|
||||||
minutes < 60 ? addMinutes(base, minutes) : addHours(base, minutes / 60);
|
const pad = (value: number) => String(value).padStart(2, "0");
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
const result = currentAllDay
|
||||||
const result = allDay
|
|
||||||
? `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`
|
? `${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`;
|
: `${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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="glass-strong max-w-md">
|
<DialogContent className="glass-strong max-w-md">
|
||||||
@@ -113,35 +140,26 @@ export const EventDialog = ({
|
|||||||
<DialogDescription>{descriptionText}</DialogDescription>
|
<DialogDescription>{descriptionText}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<form className="space-y-3" onSubmit={onSubmit}>
|
||||||
{isAiDraft && (
|
{isAiDraft && (
|
||||||
<div className="rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-xs leading-relaxed text-primary">
|
<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
|
This draft was generated from natural language. Double-check dates, times, location, recurrence, and links before saving.
|
||||||
dates, times, location, recurrence, and links before saving.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="event-title">Title</Label>
|
<Label htmlFor="event-title">Title</Label>
|
||||||
<Input
|
<Input id="event-title" placeholder="Event title" className="font-medium" {...register("title")} />
|
||||||
id="event-title"
|
{errors.title && <p className="text-xs text-destructive">{errors.title.message}</p>}
|
||||||
name="title"
|
|
||||||
placeholder="Event title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
className="font-medium"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="event-description">Description / notes</Label>
|
<Label htmlFor="event-description">Description / notes</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="event-description"
|
id="event-description"
|
||||||
name="description"
|
|
||||||
className="field-sizing-content min-h-[60px] max-h-40 resize-none placeholder:text-muted-foreground/50"
|
className="field-sizing-content min-h-[60px] max-h-40 resize-none placeholder:text-muted-foreground/50"
|
||||||
placeholder="Add a description..."
|
placeholder="Add a description..."
|
||||||
value={description}
|
{...register("description")}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -149,56 +167,58 @@ export const EventDialog = ({
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="event-location">Location</Label>
|
<Label htmlFor="event-location">Location</Label>
|
||||||
<div className="relative">
|
<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" />
|
<LucideMapPin className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/50" />
|
||||||
<Input
|
<Input id="event-location" placeholder="Location" className="pl-8" {...register("location")} />
|
||||||
id="event-location"
|
|
||||||
name="location"
|
|
||||||
placeholder="Location"
|
|
||||||
value={location}
|
|
||||||
onChange={(e) => setLocation(e.target.value)}
|
|
||||||
className="pl-8"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="event-url">URL</Label>
|
<Label htmlFor="event-url">URL</Label>
|
||||||
<Input
|
<Input id="event-url" placeholder="URL" {...register("url")} />
|
||||||
id="event-url"
|
{errors.url && <p className="text-xs text-destructive">{errors.url.message}</p>}
|
||||||
name="url"
|
|
||||||
placeholder="URL"
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RecurrencePicker
|
<Controller
|
||||||
value={recurrenceRule}
|
name="recurrenceRule"
|
||||||
onChange={setRecurrenceRule}
|
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">
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
<Controller
|
||||||
|
name="allDay"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="event-all-day"
|
id="event-all-day"
|
||||||
checked={allDay}
|
checked={field.value}
|
||||||
onCheckedChange={(checked) => setAllDay(checked === true)}
|
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
|
All day
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<DateTimePicker
|
<Controller
|
||||||
value={start}
|
name="start"
|
||||||
onChange={setStart}
|
control={control}
|
||||||
allDay={allDay}
|
render={({ field }) => (
|
||||||
placeholder="Start date"
|
<DateTimePicker value={field.value} onChange={field.onChange} allDay={allDay} placeholder="Start date" />
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{!allDay && (
|
{!allDay && (
|
||||||
|
<Controller
|
||||||
|
name="end"
|
||||||
|
control={control}
|
||||||
|
render={() => (
|
||||||
<div className="flex gap-1 pl-0.5">
|
<div className="flex gap-1 pl-0.5">
|
||||||
{DURATIONS.map(({ label, minutes }) => (
|
{DURATIONS.map(({ label, minutes }) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -207,7 +227,7 @@ export const EventDialog = ({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!start}
|
disabled={!start}
|
||||||
onClick={() => handleApplyDuration(minutes)}
|
onClick={() => handleApplyDuration(minutes, allDay, start)}
|
||||||
className="px-2 py-1 text-xs text-muted-foreground"
|
className="px-2 py-1 text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -215,27 +235,26 @@ export const EventDialog = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<DateTimePicker
|
|
||||||
value={end}
|
|
||||||
onChange={setEnd}
|
|
||||||
allDay={allDay}
|
|
||||||
placeholder="End date"
|
|
||||||
/>
|
/>
|
||||||
</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">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button type="button" variant="ghost" onClick={() => handleOpenChange(false)}>
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleOpenChange(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={onSave}>
|
<Button type="submit">{saveLabel}</Button>
|
||||||
{saveLabel}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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);
|
||||||
16
tests/event-dialog.test.tsx
Normal file
16
tests/event-dialog.test.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { getEventFormValuesFromEvent } from "@/lib/event-form";
|
||||||
|
|
||||||
|
describe("EventDialog public modes", () => {
|
||||||
|
test("accepts AI-prefilled editable initial values through its public props", () => {
|
||||||
|
const initialValues = getEventFormValuesFromEvent({
|
||||||
|
title: "AI Draft",
|
||||||
|
start: "2026-04-09T10:00:00.000Z",
|
||||||
|
recurrenceRule: "FREQ=WEEKLY;INTERVAL=1;BYDAY=TH",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(initialValues.title).toBe("AI Draft");
|
||||||
|
expect(initialValues.start).toBe("2026-04-09T10:00:00.000Z");
|
||||||
|
expect(initialValues.recurrenceRule).toBe("FREQ=WEEKLY;INTERVAL=1;BYDAY=TH");
|
||||||
|
});
|
||||||
|
});
|
||||||
73
tests/event-form.test.ts
Normal file
73
tests/event-form.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
getDefaultEventFormValues,
|
||||||
|
getEventFormValuesFromEvent,
|
||||||
|
validateEventFormValues,
|
||||||
|
} from "@/lib/event-form";
|
||||||
|
|
||||||
|
describe("event form defaults and validation", () => {
|
||||||
|
test("returns manual-create defaults with blank values", () => {
|
||||||
|
expect(getDefaultEventFormValues()).toEqual({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
location: "",
|
||||||
|
url: "",
|
||||||
|
start: "",
|
||||||
|
end: "",
|
||||||
|
allDay: false,
|
||||||
|
recurrenceRule: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps edit and AI-prefilled events into form values", () => {
|
||||||
|
const values = getEventFormValuesFromEvent({
|
||||||
|
title: "AI Draft",
|
||||||
|
location: "Studio A",
|
||||||
|
start: "2026-04-09T10:00:00.000Z",
|
||||||
|
recurrenceRule: "FREQ=WEEKLY;INTERVAL=1;BYDAY=TH",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(values.title).toBe("AI Draft");
|
||||||
|
expect(values.location).toBe("Studio A");
|
||||||
|
expect(values.start).toBe("2026-04-09T10:00:00.000Z");
|
||||||
|
expect(values.recurrenceRule).toBe("FREQ=WEEKLY;INTERVAL=1;BYDAY=TH");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires title and start values", () => {
|
||||||
|
const result = validateEventFormValues(getDefaultEventFormValues());
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.flatten().fieldErrors.title?.[0]).toContain("Title is required");
|
||||||
|
expect(result.error.flatten().fieldErrors.start?.[0]).toContain("Start date is required");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires end to be after start when present", () => {
|
||||||
|
const result = validateEventFormValues({
|
||||||
|
...getDefaultEventFormValues(),
|
||||||
|
title: "Planning",
|
||||||
|
start: "2026-04-09T10:00:00.000Z",
|
||||||
|
end: "2026-04-09T09:30:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.flatten().fieldErrors.end?.[0]).toContain("after the start date");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires an optional URL to be valid when provided", () => {
|
||||||
|
const result = validateEventFormValues({
|
||||||
|
...getDefaultEventFormValues(),
|
||||||
|
title: "Planning",
|
||||||
|
start: "2026-04-09T10:00:00.000Z",
|
||||||
|
url: "not-a-url",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.flatten().fieldErrors.url?.[0]).toContain("valid URL");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user