Ctrl K
ComunidadGuías…

src/components/dives/dive-form.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"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>
  );
}