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:
@@ -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);
|
||||
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,
|
||||
description: description || null,
|
||||
location: location || null,
|
||||
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: allDay,
|
||||
priority,
|
||||
subtype,
|
||||
calendar_id: calendarId,
|
||||
all_day: data.all_day,
|
||||
priority: data.priority,
|
||||
subtype: data.subtype,
|
||||
calendar_id: data.calendar_id,
|
||||
});
|
||||
onSaved(updated);
|
||||
} else {
|
||||
const payload: EntryCreatePayload = {
|
||||
calendar_id: calendarId,
|
||||
calendar_id: data.calendar_id,
|
||||
entry_type: 'appointment',
|
||||
title,
|
||||
description: description || null,
|
||||
location: location || null,
|
||||
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: allDay,
|
||||
priority,
|
||||
subtype,
|
||||
all_day: data.all_day,
|
||||
priority: data.priority,
|
||||
subtype: data.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 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user