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.
* 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<typeof appointmentSchema>;
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<string>(toDateTimeLocalValue(initialStart));
const [endAt, setEndAt] = useState<string>(toDateTimeLocalValue(initialEnd));
const [allDay, setAllDay] = useState(false);
const [priority, setPriority] = useState<EntryPriority>('medium');
const [subtype, setSubtype] = useState<EntrySubtype>('normal');
const [calendarId, setCalendarId] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<AppointmentFormData>({
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 (
<Modal
open={open}
@@ -197,14 +226,14 @@ export function AppointmentModal({
title={isEdit ? t('calendar.editAppointment') : t('calendar.newAppointment')}
size="lg"
>
<div className="space-y-4" data-testid="appointment-modal">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" data-testid="appointment-modal">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('calendar.appointmentTitle')} *
</label>
<Input
value={title}
onChange={(e) => 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')} *
</label>
<Select
value={calendarId}
onChange={(e) => setCalendarId(e.target.value)}
{...register('calendar_id')}
error={errorMsg(errors.calendar_id?.message)}
options={calendarOptions}
data-testid="appointment-calendar"
/>
@@ -229,8 +258,8 @@ export function AppointmentModal({
</label>
<Input
type="datetime-local"
value={startAt}
onChange={(e) => setStartAt(e.target.value)}
{...register('start_at')}
error={errorMsg(errors.start_at?.message)}
data-testid="appointment-start"
/>
</div>
@@ -240,8 +269,8 @@ export function AppointmentModal({
</label>
<Input
type="datetime-local"
value={endAt}
onChange={(e) => setEndAt(e.target.value)}
{...register('end_at')}
error={errorMsg(errors.end_at?.message)}
data-testid="appointment-end"
/>
</div>
@@ -251,8 +280,7 @@ export function AppointmentModal({
<input
id="appointment-allday"
type="checkbox"
checked={allDay}
onChange={(e) => 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')}
</label>
<Input
value={location}
onChange={(e) => setLocation(e.target.value)}
{...register('location')}
placeholder={t('calendar.appointmentLocation')}
data-testid="appointment-location"
/>
@@ -278,8 +305,7 @@ export function AppointmentModal({
{t('calendar.appointmentDescription')}
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
{...register('description')}
rows={3}
className="w-full rounded-md border-secondary-300 focus:border-primary-500 focus:ring-primary-500"
data-testid="appointment-description"
@@ -292,8 +318,7 @@ export function AppointmentModal({
{t('calendar.appointmentPriority')}
</label>
<Select
value={priority}
onChange={(e) => setPriority(e.target.value as EntryPriority)}
{...register('priority')}
options={[
{ value: 'low', label: t('calendar.priority.low') },
{ value: 'medium', label: t('calendar.priority.medium') },
@@ -307,8 +332,7 @@ export function AppointmentModal({
{t('calendar.appointmentSubtype')}
</label>
<Select
value={subtype}
onChange={(e) => setSubtype(e.target.value as EntrySubtype)}
{...register('subtype')}
options={[
{ value: 'normal', label: t('calendar.subtype.normal') },
{ value: 'follow_up', label: t('calendar.subtype.follow_up') },
@@ -319,9 +343,9 @@ export function AppointmentModal({
</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">
{error}
{errorMsg(errors.title?.message) || errorMsg(errors.calendar_id?.message) || errorMsg(errors.start_at?.message) || errorMsg(errors.end_at?.message)}
</div>
)}
@@ -331,28 +355,29 @@ export function AppointmentModal({
<Button
variant="danger"
onClick={handleDelete}
disabled={submitting}
disabled={isSubmitting}
data-testid="appointment-delete"
type="button"
>
{t('calendar.delete')}
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose} disabled={submitting}>
<Button variant="ghost" onClick={onClose} disabled={isSubmitting} type="button">
{t('calendar.cancel')}
</Button>
<Button
variant="primary"
onClick={handleSave}
isLoading={submitting}
type="submit"
isLoading={isSubmitting}
data-testid="appointment-save"
>
{t('calendar.save')}
</Button>
</div>
</div>
</div>
</form>
</Modal>
);
}