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)
This commit is contained in:
Agent Zero
2026-07-24 00:22:31 +02:00
parent d4aa661164
commit 2931a850c0
2 changed files with 226 additions and 107 deletions
@@ -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<typeof import('@/api/calendar')>('@/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(
<AppointmentModal
open
onClose={vi.fn()}
prefillDate={new Date(2026, 6, 1)}
calendars={calendars}
defaultCalendarId="cal-1"
onSaved={vi.fn()}
onDeleted={vi.fn()}
/>
);
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(
<AppointmentModal
open
onClose={vi.fn()}
prefillDate={new Date(2026, 6, 1)}
calendars={calendars}
defaultCalendarId="cal-1"
onSaved={vi.fn()}
onDeleted={vi.fn()}
/>
);
// 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<typeof vi.fn>).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(
<AppointmentModal
open
onClose={vi.fn()}
prefillDate={new Date(2026, 6, 1, 9, 0)}
calendars={calendars}
defaultCalendarId="cal-1"
onSaved={onSaved}
onDeleted={vi.fn()}
/>
);
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();
});
});
@@ -1,12 +1,16 @@
/** /**
* AppointmentModal — create/edit an appointment (or task) entry. * 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` * Pre-fills start_at / end_at when the caller supplies a `prefillDate`
* (used by clicking an empty day cell in MonthView). * (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 { 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 { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
@@ -52,6 +56,38 @@ function fromDateTimeLocalValue(v: string): Date | null {
return d; 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<typeof appointmentSchema>;
export function AppointmentModal({ export function AppointmentModal({
open, open,
onClose, onClose,
@@ -84,112 +120,105 @@ export function AppointmentModal({
return d; return d;
}, [entry, initialStart]); }, [entry, initialStart]);
const [title, setTitle] = useState(''); const {
const [description, setDescription] = useState(''); register,
const [location, setLocation] = useState(''); handleSubmit,
const [startAt, setStartAt] = useState<string>(toDateTimeLocalValue(initialStart)); reset,
const [endAt, setEndAt] = useState<string>(toDateTimeLocalValue(initialEnd)); formState: { errors, isSubmitting },
const [allDay, setAllDay] = useState(false); } = useForm<AppointmentFormData>({
const [priority, setPriority] = useState<EntryPriority>('medium'); resolver: zodResolver(appointmentSchema),
const [subtype, setSubtype] = useState<EntrySubtype>('normal'); defaultValues: {
const [calendarId, setCalendarId] = useState<string>(''); title: '',
const [submitting, setSubmitting] = useState(false); calendar_id: '',
const [error, setError] = useState<string | null>(null); start_at: toDateTimeLocalValue(initialStart),
end_at: toDateTimeLocalValue(initialEnd),
all_day: false,
priority: 'medium',
subtype: 'normal',
location: '',
description: '',
},
});
// Reset form when entry / prefill / open changes // Reset form when entry / prefill / open changes
useEffect(() => { useEffect(() => {
if (!open) return; 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 = const fallbackId =
entry?.calendar_id ?? defaultCalendarId ?? calendars[0]?.id ?? ''; entry?.calendar_id ?? defaultCalendarId ?? calendars[0]?.id ?? '';
setCalendarId(fallbackId); reset({
setError(null); title: entry?.title ?? '',
}, [open, entry, defaultCalendarId, calendars, initialStart, initialEnd]); 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 () => { const onSubmit = async (data: AppointmentFormData) => {
if (!title.trim()) { const startDate = fromDateTimeLocalValue(data.start_at);
setError(t('validation.required')); const endDate = fromDateTimeLocalValue(data.end_at);
return; if (isEdit && entry) {
} const updated = await updateEntry(entry.id, {
if (!calendarId) { title: data.title,
setError(t('calendar.noCalendars')); description: data.description || null,
return; location: data.location || null,
} start_at: startDate ? startDate.toISOString() : null,
setSubmitting(true); end_at: endDate ? endDate.toISOString() : null,
setError(null); all_day: data.all_day,
try { priority: data.priority,
const startDate = fromDateTimeLocalValue(startAt); subtype: data.subtype,
const endDate = fromDateTimeLocalValue(endAt); calendar_id: data.calendar_id,
if (isEdit && entry) { });
const updated = await updateEntry(entry.id, { onSaved(updated);
title, } else {
description: description || null, const payload: EntryCreatePayload = {
location: location || null, calendar_id: data.calendar_id,
start_at: startDate ? startDate.toISOString() : null, entry_type: 'appointment',
end_at: endDate ? endDate.toISOString() : null, title: data.title,
all_day: allDay, description: data.description || null,
priority, location: data.location || null,
subtype, start_at: startDate ? startDate.toISOString() : null,
calendar_id: calendarId, end_at: endDate ? endDate.toISOString() : null,
}); all_day: data.all_day,
onSaved(updated); priority: data.priority,
} else { subtype: data.subtype,
const payload: EntryCreatePayload = { status: 'open',
calendar_id: calendarId, };
entry_type: 'appointment', const created = await createEntry(payload);
title, onSaved(created);
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);
} }
onClose();
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!entry) return; if (!entry) return;
if (!window.confirm(t('calendar.deleteEntryConfirm'))) return; if (!window.confirm(t('calendar.deleteEntryConfirm'))) return;
setSubmitting(true);
setError(null);
try { try {
await deleteEntry(entry.id); await deleteEntry(entry.id);
onDeleted(entry.id); onDeleted(entry.id);
onClose(); onClose();
} catch (e) { } catch (e) {
const err = e as { message?: string }; const err = e as { message?: string };
setError(err.message ?? t('calendar.errorSave')); console.error(err.message ?? t('calendar.errorSave'));
} finally {
setSubmitting(false);
} }
}; };
const calendarOptions = useMemo( const calendarOptions = useMemo(
() => [ () => [...calendars.map((c) => ({ value: c.id, label: c.name }))],
...calendars.map((c) => ({ value: c.id, label: c.name })),
],
[calendars] [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 ( return (
<Modal <Modal
open={open} open={open}
@@ -197,14 +226,14 @@ export function AppointmentModal({
title={isEdit ? t('calendar.editAppointment') : t('calendar.newAppointment')} title={isEdit ? t('calendar.editAppointment') : t('calendar.newAppointment')}
size="lg" size="lg"
> >
<div className="space-y-4" data-testid="appointment-modal"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4" data-testid="appointment-modal">
<div> <div>
<label className="block text-sm font-medium text-secondary-700 mb-1"> <label className="block text-sm font-medium text-secondary-700 mb-1">
{t('calendar.appointmentTitle')} * {t('calendar.appointmentTitle')} *
</label> </label>
<Input <Input
value={title} {...register('title')}
onChange={(e) => setTitle(e.target.value)} error={errorMsg(errors.title?.message)}
placeholder={t('calendar.appointmentTitle')} placeholder={t('calendar.appointmentTitle')}
data-testid="appointment-title" data-testid="appointment-title"
/> />
@@ -215,8 +244,8 @@ export function AppointmentModal({
{t('calendar.appointmentCalendar')} * {t('calendar.appointmentCalendar')} *
</label> </label>
<Select <Select
value={calendarId} {...register('calendar_id')}
onChange={(e) => setCalendarId(e.target.value)} error={errorMsg(errors.calendar_id?.message)}
options={calendarOptions} options={calendarOptions}
data-testid="appointment-calendar" data-testid="appointment-calendar"
/> />
@@ -229,8 +258,8 @@ export function AppointmentModal({
</label> </label>
<Input <Input
type="datetime-local" type="datetime-local"
value={startAt} {...register('start_at')}
onChange={(e) => setStartAt(e.target.value)} error={errorMsg(errors.start_at?.message)}
data-testid="appointment-start" data-testid="appointment-start"
/> />
</div> </div>
@@ -240,8 +269,8 @@ export function AppointmentModal({
</label> </label>
<Input <Input
type="datetime-local" type="datetime-local"
value={endAt} {...register('end_at')}
onChange={(e) => setEndAt(e.target.value)} error={errorMsg(errors.end_at?.message)}
data-testid="appointment-end" data-testid="appointment-end"
/> />
</div> </div>
@@ -251,8 +280,7 @@ export function AppointmentModal({
<input <input
id="appointment-allday" id="appointment-allday"
type="checkbox" type="checkbox"
checked={allDay} {...register('all_day')}
onChange={(e) => setAllDay(e.target.checked)}
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500" className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
data-testid="appointment-allday" data-testid="appointment-allday"
/> />
@@ -266,8 +294,7 @@ export function AppointmentModal({
{t('calendar.appointmentLocation')} {t('calendar.appointmentLocation')}
</label> </label>
<Input <Input
value={location} {...register('location')}
onChange={(e) => setLocation(e.target.value)}
placeholder={t('calendar.appointmentLocation')} placeholder={t('calendar.appointmentLocation')}
data-testid="appointment-location" data-testid="appointment-location"
/> />
@@ -278,8 +305,7 @@ export function AppointmentModal({
{t('calendar.appointmentDescription')} {t('calendar.appointmentDescription')}
</label> </label>
<textarea <textarea
value={description} {...register('description')}
onChange={(e) => setDescription(e.target.value)}
rows={3} rows={3}
className="w-full rounded-md border-secondary-300 focus:border-primary-500 focus:ring-primary-500" className="w-full rounded-md border-secondary-300 focus:border-primary-500 focus:ring-primary-500"
data-testid="appointment-description" data-testid="appointment-description"
@@ -292,8 +318,7 @@ export function AppointmentModal({
{t('calendar.appointmentPriority')} {t('calendar.appointmentPriority')}
</label> </label>
<Select <Select
value={priority} {...register('priority')}
onChange={(e) => setPriority(e.target.value as EntryPriority)}
options={[ options={[
{ value: 'low', label: t('calendar.priority.low') }, { value: 'low', label: t('calendar.priority.low') },
{ value: 'medium', label: t('calendar.priority.medium') }, { value: 'medium', label: t('calendar.priority.medium') },
@@ -307,8 +332,7 @@ export function AppointmentModal({
{t('calendar.appointmentSubtype')} {t('calendar.appointmentSubtype')}
</label> </label>
<Select <Select
value={subtype} {...register('subtype')}
onChange={(e) => setSubtype(e.target.value as EntrySubtype)}
options={[ options={[
{ value: 'normal', label: t('calendar.subtype.normal') }, { value: 'normal', label: t('calendar.subtype.normal') },
{ value: 'follow_up', label: t('calendar.subtype.follow_up') }, { value: 'follow_up', label: t('calendar.subtype.follow_up') },
@@ -319,9 +343,9 @@ export function AppointmentModal({
</div> </div>
</div> </div>
{error && ( {(errors.title || errors.calendar_id || errors.start_at || errors.end_at) && (
<div className="text-sm text-danger-700 bg-danger-50 border border-danger-200 rounded-md p-2" data-testid="appointment-error"> <div className="text-sm text-danger-700 bg-danger-50 border border-danger-200 rounded-md p-2" data-testid="appointment-error">
{error} {errorMsg(errors.title?.message) || errorMsg(errors.calendar_id?.message) || errorMsg(errors.start_at?.message) || errorMsg(errors.end_at?.message)}
</div> </div>
)} )}
@@ -331,28 +355,29 @@ export function AppointmentModal({
<Button <Button
variant="danger" variant="danger"
onClick={handleDelete} onClick={handleDelete}
disabled={submitting} disabled={isSubmitting}
data-testid="appointment-delete" data-testid="appointment-delete"
type="button"
> >
{t('calendar.delete')} {t('calendar.delete')}
</Button> </Button>
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="ghost" onClick={onClose} disabled={submitting}> <Button variant="ghost" onClick={onClose} disabled={isSubmitting} type="button">
{t('calendar.cancel')} {t('calendar.cancel')}
</Button> </Button>
<Button <Button
variant="primary" variant="primary"
onClick={handleSave} type="submit"
isLoading={submitting} isLoading={isSubmitting}
data-testid="appointment-save" data-testid="appointment-save"
> >
{t('calendar.save')} {t('calendar.save')}
</Button> </Button>
</div> </div>
</div> </div>
</div> </form>
</Modal> </Modal>
); );
} }