src/components/contacts/contact-dialog.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useDialogApiError } from "@/hooks/useDialogApiError";
import { useEffectOnChange } from "@/hooks/useEffectOnChange";
import { FormApiError } from "@/components/ui/form-api-error";
import {
contactSchema,
contactAddressFromForm,
contactAddressToForm,
EMPTY_CONTACT_ADDRESS,
normalizeWebsite,
type ContactInput,
} from "@/lib/validations/contact";
import {
formatCoordinateForForm,
parseFormCoordinate,
} from "@/lib/validations/coordinates";
import {
contactsAPI,
CONTACT_ROLES,
CONTACT_ROLE_LABELS,
type Contact,
type ContactRole,
} from "@/lib/api/contacts";
import { formatContactAddress } from "@/lib/contact";
import { getApiDuplicateError, getApiErrorMessage } from "@/lib/api/error";
import { dialogFormSubmit } from "@/lib/dialog-form";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { DialogSubmitButton } from "@/components/ui/dialog-submit-button";
import { ContactPositionField } from "@/components/contacts/contact-position-field";
interface ContactDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
// Pass an existing contact to edit it; omit to create a new one.
contact?: Contact | null;
// Called with the created/updated contact so the caller can refresh whatever
// list it's showing - and, in the pickers, select it straight away.
onSaved: (contact: Contact) => void;
// Prefills the name with whatever was typed into the picker before "Add...".
initialName?: string;
// The roles a new contact starts with, from the host it is created on - a
// course's is a school, a trip part's a place to stay. A suggestion only: the
// row means what the diver leaves ticked. Ignored when editing.
initialRoles?: readonly ContactRole[];
}
// The address's parts in the order a postal address is written, with the label
// each one shows. `street` is the whole first line - number, building, unit - so it
// is labelled for that rather than for the street alone. `country` is last and the
// one an address cannot go without.
const ADDRESS_PARTS = [
{ name: "street", label: "Address" },
{ name: "city", label: "City" },
{ name: "postcode", label: "Postcode" },
{ name: "region", label: "Region" },
{ name: "country", label: "Country" },
] as const;
// The one create/edit form for a contact, used by the contacts page and by every
// picker that names one - the dive, course, certification and service-record
// forms and a trip part's accommodation. A dialog for the reason `CourseDialog`
// is one: the flow that matters is adding a dive center from inside a half-filled
// form, and navigating away would lose it.
export function ContactDialog({
open,
onOpenChange,
contact,
onSaved,
initialName,
initialRoles,
}: ContactDialogProps) {
const [apiError, setApiError, handleOpenChange] = useDialogApiError(
open,
onOpenChange,
);
const isEdit = !!contact;
const form = useForm<ContactInput>({
resolver: zodResolver(contactSchema),
defaultValues: {
name: "",
roles: [],
phone: "",
email: "",
website: "",
address: EMPTY_CONTACT_ADDRESS,
latitude: "",
longitude: "",
notes: "",
},
});
const address = useWatch({ control: form.control, name: "address" });
const addressSummary = formatContactAddress(address);
// Compared by value: a host passing `["school"]` inline hands over a new array
// every render, and an identity in the deps below would reset the form under
// the diver's typing on each one.
const initialRolesKey = (initialRoles ?? []).join(",");
// Reload the form whenever the dialog opens, so it shows the contact being
// edited rather than whatever the previous invocation left behind.
const { reset, setError, setFocus } = form;
useEffectOnChange(() => {
if (!open) return;
reset({
name: contact?.name ?? initialName ?? "",
roles: contact
? contact.roles
: initialRolesKey
? initialRolesKey.split(",")
: [],
phone: contact?.phone ?? "",
email: contact?.email ?? "",
website: contact?.website ?? "",
address: contactAddressToForm(contact?.address),
latitude: formatCoordinateForForm(contact?.latitude),
longitude: formatCoordinateForForm(contact?.longitude),
notes: contact?.notes ?? "",
});
// Same deliberate reset-on-open pattern as `course-dialog.tsx`.
}, [open, contact, initialName, initialRolesKey, reset]);
const onSubmit = async (data: ContactInput) => {
setApiError(null);
try {
// "" is the form's "not set" for every optional field. It goes as an
// explicit null, which on an update is what clears a stored value; `notes`
// is the exception, being a string the API never takes as null.
const shared = {
name: data.name.trim(),
roles: data.roles,
phone: data.phone.trim() || null,
email: data.email.trim() || null,
website: normalizeWebsite(data.website),
address: contactAddressFromForm(data.address),
latitude: parseFormCoordinate(data.latitude),
longitude: parseFormCoordinate(data.longitude),
notes: data.notes,
};
if (contact) {
// The API answers a PATCH with a status message only, so the updated
// contact is assembled here for the caller - with its roles in the
// vocabulary's order, which is how the API stores them.
await contactsAPI.updateContact(contact.uuid, shared);
onSaved({
...contact,
...shared,
roles: [
...CONTACT_ROLES.filter((role) => shared.roles.includes(role)),
...shared.roles.filter(
(role) => !(CONTACT_ROLES as readonly string[]).includes(role),
),
],
});
} else {
onSaved(await contactsAPI.createContact(shared));
}
onOpenChange(false);
} catch (error) {
// A name already taken is shown on the name, beside what the diver typed.
const duplicate = getApiDuplicateError(error);
if (duplicate) {
setError("name", { message: duplicate });
setFocus("name");
return;
}
setApiError(
getApiErrorMessage(
error,
`Failed to ${isEdit ? "update" : "create"} contact. Please try again.`,
),
);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isEdit ? "Edit Contact" : "New Contact"}</DialogTitle>
</DialogHeader>
<Form {...form}>
{/* `dialogFormSubmit` keeps this submit from bubbling into the form
this dialog is opened from - the dive form, or a dialog of its own
such as the certification dialog's course dialog. See
`lib/dialog-form.ts`. */}
<form
onSubmit={dialogFormSubmit(form.handleSubmit(onSubmit))}
className="space-y-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name *</FormLabel>
<FormControl
render={
<Input
placeholder="e.g. Blue Ocean Dive Center"
autoFocus
{...field}
/>
}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="roles"
render={({ field }) => (
<FormItem>
{/* A group of boxes rather than one select: a resort is a dive
center and a place to stay at once, and the set is what the
diver means. Each row is the label, so the whole 44px line
is the target rather than the 16px box. */}
<fieldset>
<legend className="text-sm font-medium leading-none">
Roles
</legend>
<div className="mt-2 grid grid-cols-2 gap-x-4 sm:grid-cols-3">
{CONTACT_ROLES.map((role) => (
<label
key={role}
className="flex min-h-11 cursor-pointer items-center gap-2 text-sm"
>
<Checkbox
checked={field.value.includes(role)}
onChange={(event) =>
field.onChange(
event.target.checked
? [...field.value, role]
: field.value.filter(
(value) => value !== role,
),
)
}
/>
{CONTACT_ROLE_LABELS[role]}
</label>
))}
</div>
</fieldset>
</FormItem>
)}
/>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone</FormLabel>
<FormControl
render={
<Input type="tel" autoComplete="off" {...field} />
}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl
render={
<Input type="email" autoComplete="off" {...field} />
}
/>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem>
<FormLabel>Website</FormLabel>
{/* Not `type="url"`: the browser would refuse the bare host
a diver copies off a sign, which is the case the scheme
is added on save for. */}
<FormControl
render={
<Input
inputMode="url"
autoComplete="off"
placeholder="e.g. blueocean.com"
{...field}
/>
}
/>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-4 sm:grid-cols-2">
{ADDRESS_PARTS.map((part) => (
<FormField
key={part.name}
control={form.control}
name={`address.${part.name}`}
render={({ field }) => (
<FormItem
className={cn(part.name === "street" && "sm:col-span-2")}
>
<FormLabel>
{part.label}
{/* Required only once the address holds anything - no
part filled in is no address at all. */}
{part.name === "country" && addressSummary && " *"}
</FormLabel>
<FormControl
render={
<Input
autoComplete="off"
placeholder={
part.name === "street"
? "Street, number, building, unit"
: undefined
}
{...field}
/>
}
/>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
<ContactPositionField form={form} />
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl
render={
<Textarea
placeholder="Opening hours, who to ask for, what to bring..."
className="min-h-[80px]"
{...field}
/>
}
/>
<FormMessage />
</FormItem>
)}
/>
<FormApiError error={apiError} />
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
<DialogSubmitButton
isEdit={isEdit}
createLabel="Create contact"
/>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}