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.
|
* 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user