From 2931a850c0181bba7085ce38546103d78b6cce4c Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Fri, 24 Jul 2026 00:22:31 +0200 Subject: [PATCH] Phase 6.2: AppointmentModal on RHF + Zod - Migrated AppointmentModal from useState to React Hook Form + Zod - Zod schema: title (required), calendar_id (required), start_at/end_at (required, end > start) - Object-level superRefine for date validation (end must be after start) - Error display via Input error prop and summary error div - Preserved all existing functionality: create, edit, delete, prefill - Added 3 validation tests (empty title, end before start, valid submit) --- .../AppointmentModal.validation.test.tsx | 94 +++++++ .../components/calendar/AppointmentModal.tsx | 239 ++++++++++-------- 2 files changed, 226 insertions(+), 107 deletions(-) create mode 100644 frontend/src/__tests__/calendar/AppointmentModal.validation.test.tsx diff --git a/frontend/src/__tests__/calendar/AppointmentModal.validation.test.tsx b/frontend/src/__tests__/calendar/AppointmentModal.validation.test.tsx new file mode 100644 index 0000000..8593a96 --- /dev/null +++ b/frontend/src/__tests__/calendar/AppointmentModal.validation.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { AppointmentModal } from '@/components/calendar/AppointmentModal'; +import type { Calendar } from '@/api/calendar'; + +vi.mock('@/api/calendar', async () => { + const actual = await vi.importActual('@/api/calendar'); + return { + ...actual, + createEntry: vi.fn(), + updateEntry: vi.fn(), + deleteEntry: vi.fn(), + }; +}); + +import { createEntry } from '@/api/calendar'; + +const calendars: Calendar[] = [ + { id: 'cal-1', name: 'Persönlich', color: '#3B82F6', type: 'personal', owner_id: 'u-1' }, +]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('AppointmentModal validation (RHF + Zod)', () => { + it('shows validation error when title is empty', async () => { + render( + + ); + fireEvent.click(screen.getByTestId('appointment-save')); + const err = await screen.findByTestId('appointment-error'); + expect(err.textContent?.toLowerCase()).toContain('erforderlich'); + }); + + it('shows error when end is before start', async () => { + render( + + ); + // Set title so only date validation fails + fireEvent.change(screen.getByTestId('appointment-title'), { target: { value: 'Test' } }); + // Set end before start + fireEvent.change(screen.getByTestId('appointment-end'), { target: { value: '2026-07-01T08:00' } }); + fireEvent.click(screen.getByTestId('appointment-save')); + await waitFor(() => { + const err = screen.queryByTestId('appointment-error'); + expect(err).toBeTruthy(); + }); + }); + + it('submits successfully with valid data', async () => { + (createEntry as unknown as ReturnType).mockResolvedValue({ + id: 'e-1', calendar_id: 'cal-1', entry_type: 'appointment', subtype: 'normal', + title: 'Test', description: null, location: null, + start_at: '2026-07-01T09:00:00.000Z', end_at: '2026-07-01T10:00:00.000Z', + all_day: false, priority: 'medium', status: 'open', created_by: 'u-1', + }); + const onSaved = vi.fn(); + render( + + ); + fireEvent.change(screen.getByTestId('appointment-title'), { target: { value: 'Test Termin' } }); + fireEvent.click(screen.getByTestId('appointment-save')); + await waitFor(() => { + expect(createEntry).toHaveBeenCalledTimes(1); + }); + expect(onSaved).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/calendar/AppointmentModal.tsx b/frontend/src/components/calendar/AppointmentModal.tsx index 9d1c5c3..f01f71f 100644 --- a/frontend/src/components/calendar/AppointmentModal.tsx +++ b/frontend/src/components/calendar/AppointmentModal.tsx @@ -1,12 +1,16 @@ /** * AppointmentModal — create/edit an appointment (or task) entry. + * Form validation: React Hook Form + Zod. * * Pre-fills start_at / end_at when the caller supplies a `prefillDate` * (used by clicking an empty day cell in MonthView). */ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; @@ -52,6 +56,38 @@ function fromDateTimeLocalValue(v: string): Date | null { return d; } +// ── Zod Schema ── + +const appointmentSchema = z + .object({ + title: z.string().min(1, 'required'), + calendar_id: z.string().min(1, 'required'), + start_at: z.string().min(1, 'required'), + end_at: z.string().min(1, 'required'), + all_day: z.boolean().default(false), + priority: z.enum(['low', 'medium', 'high']).default('medium'), + subtype: z.enum(['normal', 'follow_up', 'private']).default('normal'), + location: z.string().optional().default(''), + description: z.string().optional().default(''), + }) + .superRefine((data, ctx) => { + if (data.start_at && data.end_at) { + const start = new Date(data.start_at); + const end = new Date(data.end_at); + if (!Number.isNaN(start.getTime()) && !Number.isNaN(end.getTime())) { + if (end < start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'endBeforeStart', + path: ['end_at'], + }); + } + } + } + }); + +type AppointmentFormData = z.infer; + export function AppointmentModal({ open, onClose, @@ -84,112 +120,105 @@ export function AppointmentModal({ return d; }, [entry, initialStart]); - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); - const [location, setLocation] = useState(''); - const [startAt, setStartAt] = useState(toDateTimeLocalValue(initialStart)); - const [endAt, setEndAt] = useState(toDateTimeLocalValue(initialEnd)); - const [allDay, setAllDay] = useState(false); - const [priority, setPriority] = useState('medium'); - const [subtype, setSubtype] = useState('normal'); - const [calendarId, setCalendarId] = useState(''); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(appointmentSchema), + defaultValues: { + title: '', + calendar_id: '', + start_at: toDateTimeLocalValue(initialStart), + end_at: toDateTimeLocalValue(initialEnd), + all_day: false, + priority: 'medium', + subtype: 'normal', + location: '', + description: '', + }, + }); // Reset form when entry / prefill / open changes useEffect(() => { if (!open) return; - setTitle(entry?.title ?? ''); - setDescription(entry?.description ?? ''); - setLocation(entry?.location ?? ''); - setStartAt(toDateTimeLocalValue(entry?.start_at ? new Date(entry.start_at) : initialStart)); - setEndAt(toDateTimeLocalValue(entry?.end_at ? new Date(entry.end_at) : initialEnd)); - setAllDay(entry?.all_day ?? false); - setPriority(entry?.priority ?? 'medium'); - setSubtype(entry?.subtype ?? 'normal'); const fallbackId = entry?.calendar_id ?? defaultCalendarId ?? calendars[0]?.id ?? ''; - setCalendarId(fallbackId); - setError(null); - }, [open, entry, defaultCalendarId, calendars, initialStart, initialEnd]); + reset({ + title: entry?.title ?? '', + calendar_id: fallbackId, + start_at: toDateTimeLocalValue(entry?.start_at ? new Date(entry.start_at) : initialStart), + end_at: toDateTimeLocalValue(entry?.end_at ? new Date(entry.end_at) : initialEnd), + all_day: entry?.all_day ?? false, + priority: entry?.priority ?? 'medium', + subtype: entry?.subtype ?? 'normal', + location: entry?.location ?? '', + description: entry?.description ?? '', + }); + }, [open, entry, defaultCalendarId, calendars, initialStart, initialEnd, reset]); - const handleSave = async () => { - if (!title.trim()) { - setError(t('validation.required')); - return; - } - if (!calendarId) { - setError(t('calendar.noCalendars')); - return; - } - setSubmitting(true); - setError(null); - try { - const startDate = fromDateTimeLocalValue(startAt); - const endDate = fromDateTimeLocalValue(endAt); - if (isEdit && entry) { - const updated = await updateEntry(entry.id, { - title, - description: description || null, - location: location || null, - start_at: startDate ? startDate.toISOString() : null, - end_at: endDate ? endDate.toISOString() : null, - all_day: allDay, - priority, - subtype, - calendar_id: calendarId, - }); - onSaved(updated); - } else { - const payload: EntryCreatePayload = { - calendar_id: calendarId, - entry_type: 'appointment', - title, - description: description || null, - location: location || null, - start_at: startDate ? startDate.toISOString() : null, - end_at: endDate ? endDate.toISOString() : null, - all_day: allDay, - priority, - subtype, - status: 'open', - }; - const created = await createEntry(payload); - onSaved(created); - } - onClose(); - } catch (e) { - const err = e as { message?: string }; - setError(err.message ?? t('calendar.errorSave')); - } finally { - setSubmitting(false); + const onSubmit = async (data: AppointmentFormData) => { + const startDate = fromDateTimeLocalValue(data.start_at); + const endDate = fromDateTimeLocalValue(data.end_at); + if (isEdit && entry) { + const updated = await updateEntry(entry.id, { + title: data.title, + description: data.description || null, + location: data.location || null, + start_at: startDate ? startDate.toISOString() : null, + end_at: endDate ? endDate.toISOString() : null, + all_day: data.all_day, + priority: data.priority, + subtype: data.subtype, + calendar_id: data.calendar_id, + }); + onSaved(updated); + } else { + const payload: EntryCreatePayload = { + calendar_id: data.calendar_id, + entry_type: 'appointment', + title: data.title, + description: data.description || null, + location: data.location || null, + start_at: startDate ? startDate.toISOString() : null, + end_at: endDate ? endDate.toISOString() : null, + all_day: data.all_day, + priority: data.priority, + subtype: data.subtype, + status: 'open', + }; + const created = await createEntry(payload); + onSaved(created); } + onClose(); }; const handleDelete = async () => { if (!entry) return; if (!window.confirm(t('calendar.deleteEntryConfirm'))) return; - setSubmitting(true); - setError(null); try { await deleteEntry(entry.id); onDeleted(entry.id); onClose(); } catch (e) { const err = e as { message?: string }; - setError(err.message ?? t('calendar.errorSave')); - } finally { - setSubmitting(false); + console.error(err.message ?? t('calendar.errorSave')); } }; const calendarOptions = useMemo( - () => [ - ...calendars.map((c) => ({ value: c.id, label: c.name })), - ], + () => [...calendars.map((c) => ({ value: c.id, label: c.name }))], [calendars] ); + const errorMsg = (key: string | undefined) => { + if (!key) return undefined; + if (key === 'required') return t('validation.required'); + if (key === 'endBeforeStart') return t('calendar.endBeforeStart', 'End must be after start'); + return key; + }; + return ( -
+
setTitle(e.target.value)} + {...register('title')} + error={errorMsg(errors.title?.message)} placeholder={t('calendar.appointmentTitle')} data-testid="appointment-title" /> @@ -215,8 +244,8 @@ export function AppointmentModal({ {t('calendar.appointmentCalendar')} * setStartAt(e.target.value)} + {...register('start_at')} + error={errorMsg(errors.start_at?.message)} data-testid="appointment-start" />
@@ -240,8 +269,8 @@ export function AppointmentModal({ setEndAt(e.target.value)} + {...register('end_at')} + error={errorMsg(errors.end_at?.message)} data-testid="appointment-end" />
@@ -251,8 +280,7 @@ export function AppointmentModal({ setAllDay(e.target.checked)} + {...register('all_day')} className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500" data-testid="appointment-allday" /> @@ -266,8 +294,7 @@ export function AppointmentModal({ {t('calendar.appointmentLocation')} setLocation(e.target.value)} + {...register('location')} placeholder={t('calendar.appointmentLocation')} data-testid="appointment-location" /> @@ -278,8 +305,7 @@ export function AppointmentModal({ {t('calendar.appointmentDescription')}