"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 { form: UseFormReturn; 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; 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({ form, mixtureFieldArray, mode, visibility, onSubmit, isSubmitting, cancelHref, submittingLabel, submitLabel, fileList, recordings, diveUuid, knownDiveSites, diveFix, knownGearItems, knownSpecies, diveNumberNotice, fetched, draft, }: DiveFormProps) { // 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[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) => { 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 `
` 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(null); const submit = form.handleSubmit(onSubmit, handleInvalid); const handleSubmitEvent = (event: FormEvent) => { 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 (
{/* 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. */}
{visibility.anyAutofilled && } {isVisible("file_import") && ( visibility.revealNonEmpty(form.getValues()) } onWrite={visibility.noteAutofill} /> )}
); }