"use client"; import { useId, type ReactNode } from "react"; import { Control, FieldValues, Path, useWatch } from "react-hook-form"; import { ArrowDownToLine, ChevronsDownUp, Clock, CloudSun, Eye, Hash, Mountain, Shapes, Ship, Thermometer, ThermometerSun, Waves, WavesArrowDown, WavesArrowUp, Weight, Wind, } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { DiveStartTimeField } from "@/components/dives/dive-start-time-field"; import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { MixtureFieldArray, MixtureFields, } from "@/components/dives/mixture-fields"; import { TripCombobox } from "@/components/trips/trip-combobox"; import { CourseCombobox } from "@/components/courses/course-combobox"; import { ContactCombobox } from "@/components/contacts/contact-combobox"; import type { ContactRole } from "@/lib/api/contacts"; import { PeopleMultiSelect } from "@/components/people/people-multi-select"; import type { PersonReference } from "@/lib/api/people"; import { DiveSitesField } from "@/components/sites/dive-sites-field"; import { useDiveRoster } from "@/hooks/useDiveRoster"; import { DiveGearField } from "@/components/gear/dive-gear-field"; import { SpeciesMultiSelect, type SightingErrors, } from "@/components/dives/species-multi-select"; import { DiveUnitNumberField, DiveUnitNumberItem, } from "@/components/dives/dive-unit-number-field"; import { DiveVocabularyField } from "@/components/dives/dive-vocabulary-field"; import { RatingInput } from "@/components/dives/rating-input"; import { TagsMultiSelect } from "@/components/dives/tags-multi-select"; import { DiveMixtureInput, type SightingInput } from "@/lib/validations/dive"; import { CURRENT_LABELS, CURRENTS, DIVE_TYPE_LABELS, DIVE_TYPES, DiveSiteSummary, ENTRY_TYPE_LABELS, ENTRY_TYPES, WATER_TYPES, WATER_TYPE_LABELS, WAVES, WAVES_LABELS, WEATHER, WEATHER_LABELS, type DiveCurrent, type DiveType, type DiveWaves, type DiveWeather, type EntryType, type WaterType, } from "@/lib/api/dives"; import { GearItemSummary } from "@/lib/api/gear"; import type { EntryFix } from "@/lib/dive-recordings"; import { SpeciesSummary } from "@/lib/api/species"; import type { DiveFormFieldGroup, DiveFormFieldKey, } from "@/lib/dive-form-fields"; import type { DiveFormVisibility } from "@/hooks/useDiveFormVisibility"; import { DiveFormSection } from "@/components/dives/dive-form-section"; import { FetchedSource } from "@/components/dives/autofilled-marks"; // Freediving and snorkeling stay in the vocabulary, which a stored dive can hold, // but the form does not offer them yet. const OFFERED_DIVE_TYPES = DIVE_TYPES.filter( (type) => type !== "freedive" && type !== "snorkel", ); // What a contact created from the dive form starts as. const DIVE_CENTER: readonly ContactRole[] = ["dive_center"]; // What each sighting's inputs were refused for, by row, out of the field's error - // which for a list is an array of per-row errors with no message of its own. function sightingErrors( error: unknown, ): (SightingErrors | undefined)[] | undefined { if (!Array.isArray(error)) return undefined; return error.map( (row) => row && { count: row.count?.message, abundance: row.abundance?.message, notes: row.notes?.message, }, ); } // The field shape shared by both `DiveCreateInput` and `DiveUpdateInput` // (see `lib/validations/dive.ts`): the create schema's fields, all optional // (the update schema optionalizes every field, since a PATCH only needs to // send what changed). Both concrete form input types are structurally // assignable to this, so `DiveFormFields`/`MixtureFields` can stay generic // over `TFieldValues` instead of falling back to `Control`, // which erased type safety between the create/update form shapes entirely. export interface DiveFormValues extends FieldValues { dive_number?: number; // ISO 8601, e.g. "2021-04-04T10:04:47+02:00" - the dive's own original // timezone, not the viewer's browser, and on an imported dive possibly no // timezone at all ("2026-04-17T11:49:23"). Edited via `DiveStartTimeField`, // which is the only place that splits/recombines it into the wall-clock + // offset pair its two underlying inputs actually edit - see // `lib/date-time.ts`. start_time?: string; submerged_time?: string; max_depth?: number | null; avg_depth?: number | null; bottom_temperature?: number | null; visibility?: number | null; // `""` is the "Not recorded" option, and the live cleared state - never // `undefined`, which react-hook-form re-displays the field's default for. // `null` is what the submit path converts it to; both are in the union // because `diveToFormValues` seeds one and `buildDiveUpdate` reads the other. water_type?: WaterType | "" | null; altitude?: number | null; // The same three states for every other select, and `""` for a boat name // nobody typed. type?: DiveType | "" | null; current?: DiveCurrent | "" | null; waves?: DiveWaves | "" | null; weather?: DiveWeather | "" | null; entry_type?: EntryType | "" | null; boat_name?: string; air_temperature?: number | null; // `null` is unrated, which the control's clear writes. rating?: number | null; weight?: number | null; // `null` means "no trip", and is distinct from `undefined` ("field not // touched") on the edit form - see `DiveUpdate.trip_uuid`. trip_uuid?: string | null; // Same three states, same reason, for the training course this dive was on. course_uuid?: string | null; // And for the dive center that ran it. contact_uuid?: string | null; // Who the diver was with, each with their role on this dive. people?: PersonReference[]; dive_site_uuids?: string[]; gear_item_uuids?: string[]; sightings?: SightingInput[]; // By name, in the diver's order. tags?: string[]; notes?: string; mixtures?: DiveMixtureInput[]; } export interface DiveFormFieldsProps { control: Control; // In "create" mode, dive number/start time/dive time are required by the // schema and marked with a "*" in the UI. In "edit" mode these fields are // optional at the schema level (a PATCH only needs to send what changed), // so no asterisks are shown and a cleared value resolves to `undefined` // rather than falling back to a default. mode: "create" | "edit"; // Which of the optional fields this form renders at all. A hidden field's // `FormField` is not rendered, so its input is out of the DOM, the tab order and // the accessibility tree - and its *value* is untouched by that, which is // react-hook-form's `shouldUnregister: false` default doing the work. visibility: DiveFormVisibility; // Created once by the page (alongside `form`/`control`) and also passed to // `DiveFileImport` - see `MixtureFieldArray`'s own doc comment for why this // can't just be created internally by `MixtureFields`. mixtureFieldArray: MixtureFieldArray; // The dive's existing sites, when editing. The site picker doesn't load the // user's whole catalogue, so it can't look a selected uuid's name up // locally - passing the ones already on the record saves it a request each. knownDiveSites?: DiveSiteSummary[]; // The dive's GPS fix as the save would leave it - the site finder opens on it // when no chosen site has a pin. diveFix?: EntryFix | null; // Same idea for gear: `Dive.gear_items` already carries what a picked row // renders, so the picker needn't fetch each item back by uuid. knownGearItems?: GearItemSummary[]; // And for species: `Dive.sightings` carries the names the picker's rows need, // so an edit form starts out labelled without a lookup per row. knownSpecies?: SpeciesSummary[]; // Raised by the species picker while a pick is still being resolved into a // catalog row - see `SpeciesMultiSelect.onPendingChange`. Owned by // `DiveForm`, which is where the submit button that must wait for it is. onSpeciesPendingChange?: (isPending: boolean) => void; // A note shown under the dive number, but only while the field still holds // `forValue`. Carried as a value rather than a ready-made string so the // "still showing it?" check can happen inside the field's own render, where // the current value is already reactive - the alternative, a `form.watch` in // the page, opts that whole component out of compiler memoization. // // Currently only the create form sets it, to say the suggested number is // already in use (see `useSuggestedDiveNumber`). A note rather than a // validation error on purpose: duplicates are a normal state while // back-filling a log, reconciled later with Renumber, so this must not block // a save. diveNumberNotice?: { forValue: number; message: string } | null; // Owned by `DiveForm`, whose failed-submit path opens the sections an error // landed in. collapsedGroups: ReadonlySet; onGroupOpenChange: (group: DiveFormFieldGroup, open: boolean) => void; // That one section's fields alone, with no card around them: what a dive page // card's edit dialog holds. only?: DiveFormFieldGroup; } export function DiveFormFields({ control, mode, visibility, mixtureFieldArray, knownDiveSites, diveFix, knownGearItems, knownSpecies, onSpeciesPendingChange, diveNumberNotice, collapsedGroups, onGroupOpenChange, only, }: DiveFormFieldsProps) { const required = mode === "create"; const requiredMark = required ? " *" : ""; const isVisible = visibility.isVisible; // The rating is a group, which a label names by reference rather than by `for`. const ratingLabelId = useId(); // Offered for a boat entry, and kept on screen whenever it holds a name - typed // before the entry changed, or stored - so a name is never both kept and out of // reach. Submitted like any other field. const [entryType, boatName] = useWatch({ control, name: ["entry_type", "boat_name"] as Path[], }); const showBoatName = isVisible("boat_name") && (entryType === "boat" || Boolean(boatName)); // The lookup pickers below rank their rows by last use at or before the dive's // own start time, sent as the field holds it; an empty field sends no bound. // The species picker takes no bound - a site's species belong to the place, not // to the date - and lists the species logged at the dive's sites first. const [startTime, tripUuid, courseUuid, diveSiteUuids] = useWatch({ control, name: [ "start_time", "trip_uuid", "course_uuid", "dive_site_uuids", ] as Path[], }); const until: string | undefined = startTime || undefined; const roster = useDiveRoster(tripUuid, courseUuid); const section = (group: DiveFormFieldGroup, children: ReactNode) => { if (only) { return group === only &&
{children}
; } return ( onGroupOpenChange(group, open)} > {children} ); }; return ( // A fragment, so each section is a card in the form's own stack. <> {isVisible("course_uuid") && section( "Training", } render={({ field }) => ( Course } /> )} />, )} {(isVisible("trip_uuid") || isVisible("contact_uuid") || isVisible("dive_site_uuids") || isVisible("entry_type") || showBoatName) && section( "Location", <> {isVisible("trip_uuid") && ( } render={({ field }) => ( Trip } /> )} /> )} {isVisible("contact_uuid") && ( } render={({ field }) => ( Dive center } /> )} /> )} {/* Dive Site(s) */} {isVisible("dive_site_uuids") && ( } render={({ field }) => ( Dive site(s) } /> )} /> )} {/* How the diver got in, under where: a property of the site as much as of the dive. The boat name beside it - see `showBoatName`. */} {(isVisible("entry_type") || showBoatName) && (
{isVisible("entry_type") && ( } label="Entry type" icon={WavesArrowDown} values={ENTRY_TYPES} labels={ENTRY_TYPE_LABELS} /> )} {showBoatName && ( } render={({ field }) => ( Boat name
{/* `""` is not recorded, and the submit paths trim it into the API's `null` - see `boatNameOrNull`. */} } />
)} /> )}
)} , )} {/* A person picked here is a buddy until the diver says otherwise. */} {isVisible("people") && section( "People", } render={({ field }) => ( People } /> )} />, )} {section( "Dive info", <> {/* What kind of dive it was, first: it frames every fact below it. Half width in a grid of its own, for the reason Weight is: a select as wide as the form is wider than anything it offers. */} {isVisible("type") && (
} label="Dive type" icon={Shapes} values={OFFERED_DIVE_TYPES} labels={DIVE_TYPE_LABELS} />
)} {/* Date and Time */} } render={({ field }) => ( Start time{requiredMark} } /> )} /> {/* Dive number & Dive time. Paired because both are always rendered - neither is in the Fields dialog - so this row is the one pair no visibility choice can break, and neither field has to sit half-width beside a hole. Dive number was alone in a grid of its own until then, for the column width: full width would make the form's one always-present field its widest, and a number box is the last thing that should be. The pair keeps that width without the empty column. Unequal heights are expected here: `diveNumberNotice` adds a line under the number when the suggestion is already taken, and Dive time has nothing to match it with. */}
} render={({ field }) => ( Dive number{requiredMark}
{ // `parseInt(...) || 1` looked equivalent and wasn't: `||` // treats an emptied box (NaN) and a typed 0 alike, so // clearing the field instantly rewrote it to 1. That write // also marked the field dirty, and `useSuggestedDiveNumber` // reads `isDirty` as its permanent "the diver chose a // number" latch - so one accidental clear stopped the // number following the date for the rest of the form's // life, including after a file import changed the date. // An emptied box must stay empty and let the schema speak. const parsed = parseInt(e.target.value, 10); field.onChange( Number.isNaN(parsed) ? undefined : parsed, ); }} /> } />
{diveNumberNotice && field.value === diveNumberNotice.forValue ? ( {diveNumberNotice.message} ) : null}
)} /> } render={({ field }) => ( Dive time{requiredMark}
} />
)} />
{/* The depths, the pair that closes the dive's own facts. A lone survivor sits half-width rather than spanning: see the readings grid below. */} {(isVisible("max_depth") || isVisible("avg_depth")) && (
{isVisible("max_depth") && ( } label="Maximum depth" dimension="depth" icon={ArrowDownToLine} min={0} placeholderValue={30.52} /> )} {isVisible("avg_depth") && ( } label="Average depth" dimension="depth" icon={ChevronsDownUp} min={0} placeholderValue={18.24} /> )}
)} , )} {(isVisible("bottom_temperature") || isVisible("air_temperature") || isVisible("visibility") || isVisible("water_type") || isVisible("altitude") || isVisible("current") || isVisible("waves") || isVisible("weather")) && section( "Environment", <> {/* The readings. One grid rather than fixed pairs, because every one of them hides on its own. Paired up, hiding half of a pair left the other half in its column with an empty one beside it, which reads as a field that failed to load; a single grid lets auto-flow pack whatever survives from the left, so a hidden field costs a slot and not a hole. The fields follow the Fields dialog's order, two to a row. An odd number visible leaves one field half-width on the last row. That is accepted and deliberately not spanned: a ragged bottom edge reads as the end of a list, a gap in the middle reads as breakage. `gap-y-6` rather than `gap-4`'s 1rem, because the row gap is the section's own `space-y-6` rhythm, not a pair's: a full row sits exactly where a pair of its own would. The column gap is still 1rem. On a phone the fields stack at 1.5rem - one grid has one row gap. */}
{isVisible("bottom_temperature") && ( } label="Bottom temperature" dimension="temperature" icon={Thermometer} min={-50} max={50} placeholderValue={22.5} /> )} {isVisible("visibility") && ( // An `Integer` column, so feet commit whole metres: 50 ft is stored // as 15 m and reads back as 49 ft. Accepted - see DECISIONS.md - // because visibility is an estimate and whole-metre resolution is // finer than anyone judges it to. } label="Visibility" dimension="visibility" icon={Eye} min={0} placeholderValue={15} /> )} {/* Water and altitude - what the water was and where it was, which the computer treats as calibration settings and the log treats as facts about the dive. */} {isVisible("water_type") && ( } label="Water type" icon={Waves} values={WATER_TYPES} labels={WATER_TYPE_LABELS} /> )} {isVisible("altitude") && ( // Bounds in metres, which the Zod schema and the DB `CHECK` behind it // are written in; the box converts them for the spinner. Not // Visibility's `min={0}`: the Dead Sea is below sea level, which is // the whole reason the API's bound is -450 rather than 0. } label="Altitude" dimension="altitude" icon={Mountain} min={-450} max={6500} placeholderValue={372} /> )} {/* Then up from the water: the surface, and the air above it. */} {isVisible("waves") && ( } label="Waves" icon={WavesArrowUp} values={WAVES} labels={WAVES_LABELS} /> )} {isVisible("current") && ( } label="Current" icon={Wind} values={CURRENTS} labels={CURRENT_LABELS} /> )} {isVisible("weather") && ( } label="Weather" icon={CloudSun} values={WEATHER} labels={WEATHER_LABELS} /> )} {isVisible("air_temperature") && ( } label="Air temperature" dimension="temperature" icon={ThermometerSun} min={-60} max={60} placeholderValue={28} /> )}
, )} {/* Tanks. Hidden, the whole section goes - the heading, every tank card, "Add tank" and the empty-state line - while the cylinders themselves stay in form state and are submitted, exactly as a hidden scalar is. */} {isVisible("mixtures") && section( "Tanks", control={control} fieldArray={mixtureFieldArray} isVisible={isVisible} until={until} />, )} {/* Gear, weight included - grouped as "how the diver was configured for this dive", as opposed to the environment readings above. Weight is a plain per-dive number rather than one of the gear items (see DECISIONS.md), but it belongs next to them here. The two are nested rather than rendered side by side because loading a gear set fills in *both*: `DiveGearField` needs the weight field's value and setter, and nesting is what puts them in scope without registering `weight` twice. They still hide independently, and the nesting is what makes that cheap: the outer `FormField` is a render prop rather than an input, so each half is gated on its own key inside it and `weight` is registered exactly once however many of the two are on screen. The pair is skipped altogether when neither is - `weight`'s value survives that, which is `shouldUnregister: false`. */} {(isVisible("gear_item_uuids") || isVisible("weight")) && section( "Gear", } render={({ field: weightField }) => (
{isVisible("gear_item_uuids") && ( } render={({ field }) => ( Gear { const revealed: DiveFormFieldKey[] = []; if (set.gear_items.length > 0) { revealed.push("gear_item_uuids"); } if (set.weight != null) revealed.push("weight"); visibility.reveal(revealed); }} /> } /> )} /> )} {isVisible("weight") && (
{/* Weight is the one dimension with a second toggle elsewhere: the gear-set dialog opens from inside this form and enters a weight of its own. Both read the same store, so the two never disagree, and the modal keeps only one of them exposed at a time. The outer `FormField` holds `weight`, so this is the field's body alone. */}
)}
)} />, )} {/* Species spotted - after the kit and before the notes, which is where the dive page's own card sits: what was seen is an observation about the dive, and the notes underneath are where anything this picker can't name ends up. */} {isVisible("sightings") && section( "Marine life", } render={({ field, fieldState }) => ( // No `FormMessage`: it would print that array's missing message as // "undefined". Each row shows its own. Species spotted } /> )} />, )} {/* The diver's own word on the dive: how it rated, what it is filed under, and the notes - the order the dive page reads them in. */} {(isVisible("rating") || isVisible("tags") || isVisible("notes")) && section( "Notes", <> {isVisible("rating") && ( } render={({ field }) => ( Rating } /> )} /> )} {isVisible("tags") && ( } render={({ field }) => ( Tags } /> )} /> )} {isVisible("notes") && ( } render={({ field }) => ( Notes } /> )} /> )} , )} ); }