From eea63b0c71f3fc5add1fd8406e6edd1f893c1818 Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Mon, 25 May 2026 09:33:25 -0400 Subject: [PATCH] fix: validate mobile event drawer steps with schema --- src/components/event-dialog.tsx | 24 +++++- src/lib/event-form.ts | 24 ++++++ tests/event-dialog.test.tsx | 8 ++ tests/event-form.test.ts | 146 +++++++++++++++++++------------- 4 files changed, 139 insertions(+), 63 deletions(-) diff --git a/src/components/event-dialog.tsx b/src/components/event-dialog.tsx index f7aa174..7ff0bae 100644 --- a/src/components/event-dialog.tsx +++ b/src/components/event-dialog.tsx @@ -31,6 +31,7 @@ import { useIsMobile } from "@/hooks/use-mobile"; import { type EventFormValues, getDefaultEventFormValues, + validateEventFormStep, validateEventFormValues, } from "@/lib/event-form"; import { parseRecurrenceRule, validateRecurrence } from "@/lib/recurrence"; @@ -76,13 +77,14 @@ export const EventDialog = ({ const saveLabel = editingId ? "Update Event" : "Save Event"; const { + clearErrors, control, + getValues, handleSubmit, register, reset, setError, setValue, - trigger, watch, formState: { errors }, } = useForm({ @@ -106,9 +108,23 @@ export const EventDialog = ({ onOpenChange(nextOpen); }; - const handleNext = async () => { - const valid = await trigger(STEP_FIELDS[step]); - if (valid) advanceStep(); + const handleNext = () => { + const stepFields = STEP_FIELDS[step]; + clearErrors(stepFields); + const stepValidation = validateEventFormStep(getValues(), stepFields); + if (stepValidation.success) { + advanceStep(); + return; + } + + for (const [fieldName, messages] of Object.entries( + stepValidation.fieldErrors, + )) { + const firstMessage = messages?.[0]; + if (firstMessage) { + setError(fieldName as keyof EventFormValues, { message: firstMessage }); + } + } }; const handleSave = handleSubmit((values) => { diff --git a/src/lib/event-form.ts b/src/lib/event-form.ts index 1a3ff4c..e3f5ab3 100644 --- a/src/lib/event-form.ts +++ b/src/lib/event-form.ts @@ -110,3 +110,27 @@ export const getEventValidationIssues = ( export const validateEventFormValues = (values: EventFormValues) => eventFormSchema.safeParse(values); + +export const validateEventFormStep = ( + values: EventFormValues, + fields: Array, +) => { + const result = validateEventFormValues(values); + if (result.success) { + return { success: true as const, fieldErrors: {} }; + } + + const allFieldErrors = result.error.flatten().fieldErrors; + const fieldErrors: Partial> = {}; + for (const field of fields) { + const messages = allFieldErrors[field]; + if (messages?.length) { + fieldErrors[field] = messages; + } + } + + return { + success: Object.keys(fieldErrors).length === 0, + fieldErrors, + }; +}; diff --git a/tests/event-dialog.test.tsx b/tests/event-dialog.test.tsx index 9102556..0e51764 100644 --- a/tests/event-dialog.test.tsx +++ b/tests/event-dialog.test.tsx @@ -77,6 +77,14 @@ describe("EventDialog public modes", () => { expect(source).toContain("setStep(1)"); }); + test("event-dialog validates mobile drawer steps with schema-backed step validation", () => { + const source = readFileSync("src/components/event-dialog.tsx", "utf8"); + + expect(source).toContain("validateEventFormStep"); + expect(source).toContain("getValues()"); + expect(source).toContain("fieldErrors"); + }); + test("dialog.tsx no longer forks on isMobile in DialogContent", () => { const source = readFileSync("src/components/ui/dialog.tsx", "utf8"); diff --git a/tests/event-form.test.ts b/tests/event-form.test.ts index a9e6e29..d439c14 100644 --- a/tests/event-form.test.ts +++ b/tests/event-form.test.ts @@ -1,73 +1,101 @@ import { describe, expect, test } from "bun:test"; import { - getDefaultEventFormValues, - getEventFormValuesFromEvent, - validateEventFormValues, + getDefaultEventFormValues, + getEventFormValuesFromEvent, + validateEventFormStep, + 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("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", - }); + 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"); - }); + 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()); + 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"); - } - }); + 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", - }); + 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"); - } - }); + 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", - }); + 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"); - } - }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.flatten().fieldErrors.url?.[0]).toContain("valid URL"); + } + }); + + test("validates only the requested mobile drawer step", () => { + const values = { + ...getDefaultEventFormValues(), + url: "not-a-url", + end: "2026-04-09T09:30:00.000Z", + }; + + const detailsResult = validateEventFormStep(values, ["title", "url"]); + const scheduleResult = validateEventFormStep(values, ["start", "end"]); + + expect(detailsResult.success).toBe(false); + if (!detailsResult.success) { + expect(detailsResult.fieldErrors.title?.[0]).toContain("Title is required"); + expect(detailsResult.fieldErrors.url?.[0]).toContain("valid URL"); + expect(detailsResult.fieldErrors.start).toBeUndefined(); + expect(detailsResult.fieldErrors.end).toBeUndefined(); + } + + expect(scheduleResult.success).toBe(false); + if (!scheduleResult.success) { + expect(scheduleResult.fieldErrors.start?.[0]).toContain("Start date is required"); + expect(scheduleResult.fieldErrors.end?.[0]).toContain("valid"); + expect(scheduleResult.fieldErrors.title).toBeUndefined(); + expect(scheduleResult.fieldErrors.url).toBeUndefined(); + } + }); });