Files
traveling-around-the-world/client/src/features/visits/components/VisitForm.tsx

212 lines
6.7 KiB
TypeScript

import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import type { Visit, VisitCreateInput } from '../types';
import { useCreateVisitMutation, useUpdateVisitMutation } from '../hooks/useVisitQueries';
import { useVisitDrawerStore } from '../../../state/useVisitDrawerStore';
import { useTranslation } from 'react-i18next';
const visitFormSchema = z
.object({
country: z.string().min(1, 'validation.countryRequired'),
city: z.string().optional(),
start: z.string().min(1, 'validation.startDateRequired'),
end: z.string().optional(),
notes: z.string().optional(),
lat: z.number(),
lng: z.number()
})
.refine(
(data) => {
if (!data.end) return true;
return new Date(data.end) >= new Date(data.start);
},
{ message: 'validation.endAfterStart', path: ['end'] }
);
type VisitFormSchema = z.infer<typeof visitFormSchema>;
interface VisitFormProps {
editingVisit?: Visit | null;
onCompleted?: () => void;
}
export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
const closeDrawer = useVisitDrawerStore((state) => state.close);
const initialLocation = useVisitDrawerStore((state) => state.initialLocation);
const { t } = useTranslation();
const createMutation = useCreateVisitMutation();
const updateMutation = useUpdateVisitMutation();
const {
register,
handleSubmit,
setValue,
formState: { errors, isSubmitting, isSubmitSuccessful }
} = useForm<VisitFormSchema>({
resolver: zodResolver(visitFormSchema),
defaultValues: {
country: editingVisit?.location.country ?? '',
city: editingVisit?.location.city ?? '',
start: editingVisit?.date.start ?? '',
end: editingVisit?.date.end ?? '',
notes: editingVisit?.notes ?? '',
lat: editingVisit?.location.lat ?? initialLocation?.lat ?? 0,
lng: editingVisit?.location.lng ?? initialLocation?.lng ?? 0
}
});
useEffect(() => {
if (initialLocation && !editingVisit) {
setValue('lat', initialLocation.lat);
setValue('lng', initialLocation.lng);
}
}, [initialLocation, editingVisit, setValue]);
useEffect(() => {
if (isSubmitSuccessful) {
closeDrawer();
onCompleted?.();
}
}, [isSubmitSuccessful, closeDrawer, onCompleted]);
async function onSubmit(values: VisitFormSchema) {
const payload: VisitCreateInput = {
location: {
country: values.country,
city: values.city,
lat: values.lat,
lng: values.lng
},
date: {
start: values.start,
end: values.end || undefined
},
notes: values.notes || undefined
};
if (editingVisit) {
await updateMutation.mutateAsync({
id: editingVisit.id,
data: {
...payload,
location: payload.location,
date: payload.date
}
});
return;
}
await createMutation.mutateAsync(payload);
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="country">
{t('form.country')}
</label>
<input
id="country"
className="rounded border border-slate-300 px-3 py-2"
placeholder={t('form.countryPlaceholder')}
{...register('country')}
/>
{errors.country ? (
<p className="text-sm text-rose-500">{t(errors.country.message ?? '')}</p>
) : null}
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="city">
{t('form.city')}
</label>
<input
id="city"
className="rounded border border-slate-300 px-3 py-2"
placeholder={t('form.cityPlaceholder')}
{...register('city')}
/>
{errors.city ? <p className="text-sm text-rose-500">{t(errors.city.message ?? '')}</p> : null}
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="start">
{t('form.startDate')}
</label>
<input id="start" type="date" className="rounded border border-slate-300 px-3 py-2" {...register('start')} />
{errors.start ? <p className="text-sm text-rose-500">{t(errors.start.message ?? '')}</p> : null}
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="end">
{t('form.endDate')}
</label>
<input id="end" type="date" className="rounded border border-slate-300 px-3 py-2" {...register('end')} />
{errors.end ? <p className="text-sm text-rose-500">{t(errors.end.message ?? '')}</p> : null}
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="notes">
{t('form.notes')}
</label>
<textarea
id="notes"
className="min-h-[96px] rounded border border-slate-300 px-3 py-2"
placeholder={t('form.notesPlaceholder')}
{...register('notes')}
/>
{errors.notes ? <p className="text-sm text-rose-500">{t(errors.notes.message ?? '')}</p> : null}
</div>
<div className="grid gap-2 md:grid-cols-2">
<div>
<label className="text-sm font-medium" htmlFor="lat">
{t('form.latitude')}
</label>
<input
id="lat"
type="number"
step="any"
className="w-full rounded border border-slate-300 px-3 py-2"
{...register('lat', { valueAsNumber: true })}
/>
{errors.lat ? <p className="text-sm text-rose-500">{t(errors.lat.message ?? '')}</p> : null}
</div>
<div>
<label className="text-sm font-medium" htmlFor="lng">
{t('form.longitude')}
</label>
<input
id="lng"
type="number"
step="any"
className="w-full rounded border border-slate-300 px-3 py-2"
{...register('lng', { valueAsNumber: true })}
/>
{errors.lng ? <p className="text-sm text-rose-500">{t(errors.lng.message ?? '')}</p> : null}
</div>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
className="rounded border border-slate-300 px-4 py-2"
onClick={() => closeDrawer()}
>
{t('form.cancel')}
</button>
<button
type="submit"
disabled={isSubmitting}
className="rounded bg-primary px-4 py-2 font-semibold text-white hover:bg-primary/90 disabled:opacity-60"
>
{editingVisit ? t('form.update') : t('form.create')}
</button>
</div>
</form>
);
}