src/components/dives/dive-section-dialog.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
import { type FormEvent, useLayoutEffect, useRef, useState } from "react";
import { useForm, type Resolver } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Save } from "lucide-react";
import { divesAPI, type Dive } from "@/lib/api/dives";
import { getApiErrorMessage } from "@/lib/api/error";
import {
DIVE_FORM_FIELD_REGISTRY,
diveFormFieldsWithErrors,
holdsValue,
isMixtureField,
type DiveFormFieldGroup,
type DiveFormValueFieldKey,
} from "@/lib/dive-form-fields";
import { describeBlockedSubmit } from "@/lib/form-validity";
import {
buildDiveUpdate,
diveToFormValues,
diveUpdateSchema,
type DiveUpdateInput,
} from "@/lib/validations/dive";
import { useDiveFormVisibility } from "@/hooks/useDiveFormVisibility";
import { DiveFormFields } from "@/components/dives/dive-form-fields";
import { AutofilledMarks } from "@/components/dives/autofilled-marks";
import { fetchedMarks } from "@/lib/dive-conditions";
import { diveTitleText } from "@/components/dives/dive-title";
import { useMixtureFieldArray } from "@/components/dives/mixture-fields";
import { Button } from "@/components/ui/button";
import { ButtonSpinner } from "@/components/ui/button-spinner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Form } from "@/components/ui/form";
import { FormApiError } from "@/components/ui/form-api-error";
import { useToast } from "@/components/ui/use-toast";
/**
* The dive form's sections a dive page card is edited through. Dive info has no
* card - the hero carries it, and the Edit page edits it - and Import is the
* Recordings card's, which `DiveImportDialog` edits.
*/
export type DiveCardSection = Exclude<
DiveFormFieldGroup,
"Import" | "Dive info"
>;
const NO_GROUPS: ReadonlySet<DiveFormFieldGroup> = new Set();
const NEVER_AUTOFILLED = () => false;
// The section's fields of the dive, cylinder columns aside: those are parts of
// `mixtures`, which is the field.
function sectionFields(section: DiveCardSection): DiveFormValueFieldKey[] {
return DIVE_FORM_FIELD_REGISTRY.flatMap(({ key, group }) =>
group === section && holdsValue(key) && !isMixtureField(key) ? [key] : [],
);
}
export interface DiveSectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
dive: Dive;
section: DiveCardSection;
/** Called once the dive has saved, so the page can re-read it. */
onSaved: () => void;
}
/**
* One section of the dive form in a dialog, over the card it edits: the same
* fields the Edit page shows under that heading, saved as a `PATCH` of that
* section's fields alone.
*
* No state of its own - `DialogPortal` unmounts the content when the dialog
* closes, so each open builds a fresh form from the dive on the page.
*/
export function DiveSectionDialog({
open,
onOpenChange,
dive,
section,
onSaved,
}: DiveSectionDialogProps) {
const panel = useRef<HTMLDivElement>(null);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
ref={panel}
className="sm:max-w-2xl"
// The panel rather than the first field: on a phone a focused box raises
// the keyboard over a dialog the diver has not read yet, and a combobox
// would open its list. Base UI's own fallback for a dialog with nothing
// tabbable, so it is still announced and still traps focus.
initialFocus={panel}
>
<DialogHeader>
<DialogTitle>Edit {section}</DialogTitle>
<DialogDescription>
{diveTitleText(dive.dive_number, dive.dive_sites)}
</DialogDescription>
</DialogHeader>
<DiveSectionForm
dive={dive}
section={section}
onCancel={() => onOpenChange(false)}
onSaved={() => {
onSaved();
onOpenChange(false);
}}
/>
</DialogContent>
</Dialog>
);
}
function DiveSectionForm({
dive,
section,
onCancel,
onSaved,
}: {
dive: Dive;
section: DiveCardSection;
onCancel: () => void;
onSaved: () => void;
}) {
const { toast } = useToast();
const [fields] = useState(() => sectionFields(section));
// Seeded with the whole dive, which the pickers read - the start time bounds
// what they rank, the sites lead the species - and validated and sent as the
// section alone. The picked schema strips the rest from what it hands the
// submit, so a stored value this dialog does not show can neither block the
// save nor ride along with it.
const form = useForm<DiveUpdateInput>({
resolver: zodResolver(
diveUpdateSchema.pick(
Object.fromEntries(fields.map((key) => [key, true])) as Partial<
Record<keyof typeof diveUpdateSchema.shape, true>
>,
),
) as unknown as Resolver<DiveUpdateInput>,
defaultValues: diveToFormValues(dive),
});
const mixtureFieldArray = useMixtureFieldArray(form.control);
const visibility = useDiveFormVisibility({
form,
replaceMixtures: mixtureFieldArray.replace,
fillsDefaults: false,
});
const { reveal, revealNonEmpty } = visibility;
const [fetched] = useState(() => fetchedMarks(dive));
// Every field of the section, hidden on the form or not: the diver opened this
// card to edit it, and a field of it missing here would be missing from the one
// place they looked. Cylinder columns follow the stored set, as they do on the
// Edit page, unless the tanks hold a value in one. Before paint, so the dialog
// does not open a field short.
useLayoutEffect(() => {
revealNonEmpty(form.getValues());
reveal(fields);
}, [form, fields, reveal, revealNonEmpty]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isResolvingSpecies, setIsResolvingSpecies] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const onSubmit = async (data: DiveUpdateInput) => {
setIsSubmitting(true);
setApiError(null);
try {
await divesAPI.updateDive(dive.uuid, buildDiveUpdate(data));
toast({ title: "Success", description: "Dive updated successfully!" });
onSaved();
} catch (error) {
console.error("Failed to update dive:", error);
setApiError(
getApiErrorMessage(error, "Failed to update dive. Please try again."),
);
} finally {
setIsSubmitting(false);
}
};
// A refused cylinder column hidden by the stored set comes on screen with its
// error, as on the Edit page.
const submit = form.handleSubmit(onSubmit, (errors) =>
reveal(diveFormFieldsWithErrors(errors)),
);
// The browser's own constraint validation would cancel the submit before
// `handleSubmit` runs and say nothing - see `DiveForm`.
const handleSubmitEvent = (event: FormEvent<HTMLFormElement>) => {
const blocked = describeBlockedSubmit(event.currentTarget);
if (blocked === null) return submit(event);
event.preventDefault();
setApiError(blocked);
event.currentTarget.reportValidity();
};
return (
<AutofilledMarks
control={form.control}
// The fetched marks alone: the form's own carry a legend on the Edit page,
// which this dialog has no room for.
isAutofilled={NEVER_AUTOFILLED}
fetched={fetched}
>
<Form {...form}>
<form noValidate onSubmit={handleSubmitEvent} className="space-y-6">
<DiveFormFields
control={form.control}
mode="edit"
visibility={visibility}
mixtureFieldArray={mixtureFieldArray}
knownDiveSites={dive.dive_sites}
diveFix={
dive.entry_latitude != null && dive.entry_longitude != null
? {
latitude: dive.entry_latitude,
longitude: dive.entry_longitude,
}
: null
}
knownGearItems={dive.gear_items}
knownSpecies={dive.sightings}
onSpeciesPendingChange={setIsResolvingSpecies}
collapsedGroups={NO_GROUPS}
onGroupOpenChange={() => {}}
only={section}
/>
<FormApiError error={apiError} />
<DialogFooter>
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || isResolvingSpecies}>
{isSubmitting || isResolvingSpecies ? (
<>
<ButtonSpinner className="mr-2" />
{isSubmitting ? "Saving..." : "Adding species..."}
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save changes
</>
)}
</Button>
</DialogFooter>
</form>
</Form>
</AutofilledMarks>
);
}