src/components/dives/dive-form.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
import { type FormEvent, useEffect, useState } from "react";
import { UseFormReturn } from "react-hook-form";
import { Form } from "@/components/ui/form";
import {
AutofilledLegend,
AutofilledMarks,
} from "@/components/dives/autofilled-marks";
import { DiveFileImport } from "@/components/dives/dive-file-import";
import {
DiveFormFields,
DiveFormValues,
} from "@/components/dives/dive-form-fields";
import {
DiveFormActions,
type DiveFormDraftActions,
} from "@/components/dives/dive-form-actions";
import { useDiveFormSections } from "@/components/dives/dive-form-sections";
import {
SectionIndexNav,
SectionIndexProvider,
} from "@/components/ui/section-index";
import { MixtureFieldArray } from "@/components/dives/mixture-fields";
import { DiveSiteSummary, Recording } from "@/lib/api/dives";
import type { EntryFix } from "@/lib/dive-recordings";
import type { DiveFileListProps } from "@/components/dives/use-pending-dive-files";
import { GearItemSummary } from "@/lib/api/gear";
import { SpeciesSummary } from "@/lib/api/species";
import {
diveFormFieldGroup,
diveFormFieldsWithErrors,
} from "@/lib/dive-form-fields";
import { describeBlockedSubmit } from "@/lib/form-validity";
import type { DiveFormVisibility } from "@/hooks/useDiveFormVisibility";
import type { FetchedMarks } from "@/lib/dive-conditions";
export interface DiveFormProps<TFieldValues extends DiveFormValues> {
form: UseFormReturn<TFieldValues>;
mixtureFieldArray: MixtureFieldArray;
mode: "create" | "edit";
// Created by the page alongside `form`, because the page also owns the moments a
// value arrives from outside the diver's typing (the edit load, the URL parameters)
// and the prefill the show/hide rules are defined against.
visibility: DiveFormVisibility;
onSubmit: (data: TFieldValues) => void | Promise<void>;
isSubmitting: boolean;
cancelHref: string;
submittingLabel: string;
submitLabel: string;
// Passed straight through to `DiveFileImport`. The page, not this card, owns
// the file list - see `usePendingDiveFiles`.
fileList?: DiveFileListProps;
recordings?: Recording[];
// The dive being edited, so the import can tell a match against it from a
// match against some other dive. Absent when creating.
diveUuid?: string;
// The dive's existing sites, when editing - see `DiveFormFields`.
knownDiveSites?: DiveSiteSummary[];
// The entry fix the save would leave, which the site finder opens on.
diveFix?: EntryFix | null;
knownGearItems?: GearItemSummary[];
knownSpecies?: SpeciesSummary[];
// Note shown under the dive number - see `DiveFormFields`.
diveNumberNotice?: { forValue: number; message: string } | null;
// The fields a weather service filled or the form previews - `useDiveConditionsFill`.
fetched?: FetchedMarks;
// The create form's draft buttons, beside the submit.
draft?: DiveFormDraftActions;
}
// The dive form shared by the create and edit dive pages: the file import and the
// field sections, a card each, and the cancel/submit action row - identical between
// the two pages apart from `mode`/`onSubmit`/the action labels, which are the only
// pieces that actually differ between logging a new dive and editing an existing one.
export function DiveForm<TFieldValues extends DiveFormValues>({
form,
mixtureFieldArray,
mode,
visibility,
onSubmit,
isSubmitting,
cancelHref,
submittingLabel,
submitLabel,
fileList,
recordings,
diveUuid,
knownDiveSites,
diveFix,
knownGearItems,
knownSpecies,
diveNumberNotice,
fetched,
draft,
}: DiveFormProps<TFieldValues>) {
// Owned here rather than in `DiveFormFields` because the button that has to
// wait for it is this component's, not that one's. A species picked but not
// yet resolved is not in form state, so a save that beat the resolve would
// write the dive without the sighting and say nothing about it.
const [isResolvingSpecies, setIsResolvingSpecies] = useState(false);
// The field a failed submit revealed, focused once it is actually on screen.
// react-hook-form focuses the first errored field itself, but only one that is
// mounted - and the whole point of this path is that it wasn't.
//
// A fresh object per request, so two failed submits on the same field both fire and
// an unrelated later reveal - an import, a gear set - does not. The `setState` and
// the `reveal` beside it are batched into one render, which is the render after
// which the input exists.
const [focusRequest, setFocusRequest] = useState<{
key: string;
} | null>(null);
const sections = useDiveFormSections();
const { collapsedGroups, setGroupOpen, openGroups } = sections;
const { isVisible, reveal } = visibility;
useEffect(() => {
if (!focusRequest) return;
// A per-cylinder key names a column rather than an input, so there is no one
// field to focus; the section being on screen is the whole of the fix there.
if (focusRequest.key.includes(".")) return;
form.setFocus(
focusRequest.key as unknown as Parameters<typeof form.setFocus>[0],
);
}, [focusRequest, form]);
// The resolver validates hidden fields too - react-hook-form's default
// `shouldUnregister: false` keeps their values in form state - so without
// this a hidden field carrying an error would block the save with no message
// anywhere on the page, which is the failure "The API sends `null` and the
// form schema wants `""`, so `toDiveMixtureInput` converts at the boundary"
// in DECISIONS.md.
//
// A collapsed section is the same failure by another route - its fields are not
// mounted either - so the sections holding an error open with it, always-on fields
// included.
const handleInvalid = (errors: Record<string, unknown>) => {
const inCollapsed = Object.keys(errors).filter((name) => {
const group = diveFormFieldGroup(name);
return group !== undefined && collapsedGroups.has(group);
});
if (inCollapsed.length > 0) {
openGroups(inCollapsed.map((name) => diveFormFieldGroup(name)!));
}
const keys = diveFormFieldsWithErrors(errors);
const stillHidden = keys.filter((key) => !isVisible(key));
const unmounted = stillHidden[0] ?? inCollapsed[0];
if (unmounted !== undefined) setFocusRequest({ key: unmounted });
if (keys.length > 0) reveal(keys);
};
// The other half of the same defect, and the half nothing here could see.
//
// `handleInvalid` above covers a *resolver* rejection. The browser's own
// constraint validation runs earlier than that and cancels the submit outright:
// `handleSubmit` is never called, so neither callback fires and the dive form
// has nothing to render. `noValidate` on the `<form>` below moves that decision
// here, where the refusal can be both reported and shown.
//
// A value the diver never typed is what makes this reachable rather than
// theoretical - an imported `avg_depth` of 2.70000029 is a `stepMismatch` the
// moment the box declares a step, and the form would simply stop saving. The
// steps themselves are fixed (see `UnitNumberInput`); this is here so the next
// constraint that disagrees with the data says so instead of going quiet.
const [blockedSubmit, setBlockedSubmit] = useState<string | null>(null);
const submit = form.handleSubmit(onSubmit, handleInvalid);
const handleSubmitEvent = (event: FormEvent<HTMLFormElement>) => {
const element = event.currentTarget;
const blocked = describeBlockedSubmit(element);
setBlockedSubmit(blocked);
if (blocked === null) return submit(event);
event.preventDefault();
// Unchanged where it already worked: this is what focuses the first refused
// field and draws the browser's bubble over it. The message above is what
// survives when it doesn't.
element.reportValidity();
};
return (
<AutofilledMarks
control={form.control}
isAutofilled={visibility.isAutofilled}
fetched={fetched}
>
<SectionIndexProvider index={sections.index}>
<Form {...form}>
<div className="relative">
{/* The section index, in the room beside the form's column from `lg`
up: a rail the form's full height, so the list can stick inside it
level with the first card and follow the reader down. Ahead of the
form in the order, where a keyboard reaches a table of contents
before the pages it lists. Below `lg` a stuck heading carries it. */}
<div className="absolute inset-y-0 left-full ml-6 hidden w-36 lg:block">
<SectionIndexNav className="sticky top-[calc(var(--header-height)+--spacing(6))]" />
</div>
<form
// The browser doesn't cancel this submit on its own - see
// `handleSubmitEvent`, which asks it the same question and reports
// the answer rather than leaving the diver with a dead button.
noValidate
onSubmit={handleSubmitEvent}
className="space-y-6 max-sm:space-y-2.5"
>
{visibility.anyAutofilled && <AutofilledLegend />}
{isVisible("file_import") && (
<DiveFileImport
form={form}
replaceMixtures={mixtureFieldArray.replace}
{...fileList}
recordings={recordings}
diveUuid={diveUuid}
returnTo={cancelHref}
// One of the moments a value arrives from outside the diver's
// typing: whatever the file filled in is on screen, whether or not
// the stored set hides it, and it counts as the diver's from here on.
onValuesApplied={() =>
visibility.revealNonEmpty(form.getValues())
}
onWrite={visibility.noteAutofill}
/>
)}
<DiveFormFields
control={form.control}
mode={mode}
visibility={visibility}
mixtureFieldArray={mixtureFieldArray}
knownDiveSites={knownDiveSites}
diveFix={diveFix}
knownGearItems={knownGearItems}
knownSpecies={knownSpecies}
onSpeciesPendingChange={setIsResolvingSpecies}
diveNumberNotice={diveNumberNotice}
collapsedGroups={collapsedGroups}
onGroupOpenChange={setGroupOpen}
/>
<DiveFormActions
cancelHref={cancelHref}
mode={mode}
isSubmitting={isSubmitting}
submittingLabel={submittingLabel}
submitLabel={submitLabel}
isBusy={isResolvingSpecies}
busyLabel="Adding species..."
error={blockedSubmit}
draft={draft}
/>
</form>
</div>
</Form>
</SectionIndexProvider>
</AutofilledMarks>
);
}