fix: validate mobile event drawer steps with schema

This commit is contained in:
2026-05-25 09:33:25 -04:00
parent 4ddcc44f84
commit eea63b0c71
4 changed files with 139 additions and 63 deletions

View File

@@ -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<EventFormValues>({
@@ -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) => {

View File

@@ -110,3 +110,27 @@ export const getEventValidationIssues = (
export const validateEventFormValues = (values: EventFormValues) =>
eventFormSchema.safeParse(values);
export const validateEventFormStep = (
values: EventFormValues,
fields: Array<keyof EventFormValues>,
) => {
const result = validateEventFormValues(values);
if (result.success) {
return { success: true as const, fieldErrors: {} };
}
const allFieldErrors = result.error.flatten().fieldErrors;
const fieldErrors: Partial<Record<keyof EventFormValues, string[]>> = {};
for (const field of fields) {
const messages = allFieldErrors[field];
if (messages?.length) {
fieldErrors[field] = messages;
}
}
return {
success: Object.keys(fieldErrors).length === 0,
fieldErrors,
};
};

View File

@@ -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");

View File

@@ -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();
}
});
});