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

@@ -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=="],

View File

@@ -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",

View File

@@ -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}
/> />

View File

@@ -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,93 +167,94 @@ 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">
<Checkbox <Controller
id="event-all-day" name="allDay"
checked={allDay} control={control}
onCheckedChange={(checked) => setAllDay(checked === true)} render={({ field }) => (
<Checkbox
id="event-all-day"
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
)}
/> />
<Label <Label htmlFor="event-all-day" className="cursor-pointer text-sm font-normal">
htmlFor="event-all-day"
className="text-sm font-normal cursor-pointer"
>
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 && (
<div className="flex gap-1 pl-0.5"> <Controller
{DURATIONS.map(({ label, minutes }) => ( name="end"
<Button control={control}
key={label} render={() => (
type="button" <div className="flex gap-1 pl-0.5">
variant="ghost" {DURATIONS.map(({ label, minutes }) => (
size="sm" <Button
disabled={!start} key={label}
onClick={() => handleApplyDuration(minutes)} type="button"
className="px-2 py-1 text-xs text-muted-foreground" variant="ghost"
> size="sm"
{label} disabled={!start}
</Button> onClick={() => handleApplyDuration(minutes, allDay, start)}
))} className="px-2 py-1 text-xs text-muted-foreground"
</div> >
)} {label}
<DateTimePicker </Button>
value={end} ))}
onChange={setEnd} </div>
allDay={allDay} )}
placeholder="End date"
/> />
)}
<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>
</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" Cancel
variant="ghost" </Button>
onClick={() => handleOpenChange(false)} <Button type="submit">{saveLabel}</Button>
> </DialogFooter>
Cancel </form>
</Button>
<Button type="button" onClick={onSave}>
{saveLabel}
</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

84
src/lib/event-form.ts Normal file
View 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);

View 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
View 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");
}
});
});