Ctrl K
ComunidadGuías…

src/components/dives/dive-form-fields-dialog.tsx

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

Archivo original ↗Preguntar a KiBot
"use client";

import { useId, useMemo, useRef, useState } from "react";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { FormApiError } from "@/components/ui/form-api-error";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
  DiveFormFieldSwitches,
  type DiveFormFieldsView,
} from "@/components/dives/dive-form-field-switches";
import { DiveFormPresetList } from "@/components/dives/dive-form-preset-list";
import { DiveFormPresetSaveAs } from "@/components/dives/dive-form-preset-save-as";
import {
  canonicalHiddenFields,
  hiddenFieldsEqual,
  type DiveFormFieldKey,
} from "@/lib/dive-form-fields";
import {
  currentDiveFormPreset,
  type DiveFormPreset,
} from "@/lib/api/dive-form-presets";
import type { DiveFormPresets } from "@/hooks/useDiveFormPresets";
import type { DiveFormVisibility } from "@/hooks/useDiveFormVisibility";

interface Draft {
  hidden: readonly DiveFormFieldKey[];
  /** The draft's keys whose switch the diver turned off, in the order of `hidden`. */
  turnedOff: readonly DiveFormFieldKey[];
}

interface DiveFormFieldsDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  visibility: DiveFormVisibility;
  /** Held by the Fields menu and handed down, so opening this refetches nothing. */
  presets: DiveFormPresets;
}

/**
 * The saved preset the fields on screen are on, or `""` for none - the built-in "All"
 * included, which nothing can be saved over. The same answer the Fields trigger gives,
 * so the two can never disagree about what is current.
 *
 * Recomputed on every render, deliberately. Freezing it at the moment the
 * dialog opened is the behaviour "Save as" needs, and it gets that from being
 * *mounted* then rather than from anything here holding a value still - see the
 * `key` at the call site.
 */
function matchingPresetName(
  presets: readonly DiveFormPreset[] | null,
  hidden: readonly DiveFormFieldKey[],
  pickedUuid: string | null,
): string {
  const current = currentDiveFormPreset(presets, hidden, pickedUuid);
  return current.kind === "saved" ? current.preset.name : "";
}

/**
 * Configure: the fields the dive form asks for, and the presets they can be saved as.
 *
 * **Two tabs, Fields first and default**, because that is what a diver opens this for.
 * Presets is the housekeeping tab - renaming, deleting, restoring the three seeded
 * defaults - and nothing on it changes what the form shows.
 *
 * **Saving lives with the fields, not with the presets.** "Save as" sits at the foot of
 * the Fields tab, where the thing being saved is on screen above it, and its Save
 * button shows in the footer only while that tab is; a preset row is the wrong place
 * to write the current form into, which is what the per-row "Update with current
 * fields" button was.
 *
 * **The switches edit a draft.** Nothing reaches the form until Apply or Save preset;
 * Cancel, the X and Escape drop it, as they do in every other dialog. So there is
 * nothing to undo, and no hide has emptied a value it would have to bring back. The switches
 * preview what applying the draft will do, revealed fields included: the draft keeps
 * which switches were turned off, and those are the only fields `setHidden` takes off
 * screen when they hold a value. Preset writes on the Presets tab go to the server at
 * once and are not part of the draft.
 *
 * **A dialog rather than a panel on the card.** Nothing in it is a form control of the
 * dive. Base UI portals the content to `document.body`, so the DOM has no nested `<form>`;
 * the two forms inside it still go through `dialogFormSubmit`, because React bubbles
 * submit events through its own tree and not the DOM's (DECISIONS.md, "A dialog's
 * submit event bubbles into the form that opened it").
 */
export function DiveFormFieldsDialog({
  open,
  onOpenChange,
  visibility,
  presets,
}: DiveFormFieldsDialogProps) {
  const contentRef = useRef<HTMLDivElement>(null);
  // `null` until a switch is flipped, so an untouched dialog follows the live set.
  const [draft, setDraft] = useState<Draft | null>(null);
  // A draft equal to the stored set still changes the form when it turns off a field
  // that is on screen only because it holds a value - `setHidden` takes that back off.
  const changed =
    draft !== null &&
    (!hiddenFieldsEqual(draft.hidden, visibility.hidden) ||
      draft.turnedOff.some((key) => visibility.isRevealed(key)));

  const fields = useMemo<DiveFormFieldsView>(() => {
    const setHidden = (
      next: readonly DiveFormFieldKey[],
      turnedOff: readonly DiveFormFieldKey[] = [],
    ) =>
      setDraft((previous) => {
        // Canonical, as `setHidden` stores it - a preset saved from the draft gets it
        // as is. A key switched back on is no longer one the diver turned off.
        const hidden = canonicalHiddenFields(next);
        const off = new Set([...(previous?.turnedOff ?? []), ...turnedOff]);
        return { hidden, turnedOff: hidden.filter((key) => off.has(key)) };
      });
    if (!changed) return { ...visibility, setHidden };
    const hidden = new Set(draft.hidden);
    const isRevealed = (key: DiveFormFieldKey) =>
      visibility.isRevealed(key) && !draft.turnedOff.includes(key);
    return {
      hidden: draft.hidden,
      isHidden: (key) => hidden.has(key),
      isRevealed,
      isVisible: (key) => !hidden.has(key) || isRevealed(key),
      setHidden,
    };
  }, [changed, draft, visibility]);

  const close = (apply: boolean) => {
    if (apply && changed) visibility.setHidden(draft.hidden, draft.turnedOff);
    setDraft(null);
    onOpenChange(false);
  };

  return (
    <Dialog
      open={open}
      onOpenChange={(next, details) => {
        const target = details.event.target;
        if (
          details.reason === "escape-key" &&
          target instanceof HTMLElement &&
          target.getAttribute("aria-expanded") === "true"
        ) {
          details.cancel();
          return;
        }
        if (next) onOpenChange(true);
        else close(false);
      }}
    >
      <DialogContent
        // Base UI focuses the first tabbable descendant on open. Focus goes to the
        // container instead so that Enter on a freshly opened dialog does nothing
        // at all - `ConfirmDialog` documents the same move for the same reason -
        // and `focus:outline-none` because this is a focus *holder*, not a control,
        // and a ring around the whole dialog reads as an error state.
        className="sm:max-w-2xl focus:outline-none"
        initialFocus={contentRef}
        // Escape on an open dropdown - the Save as list - closes the list only,
        // rather than the dialog and the draft with it.
        ref={contentRef}
      >
        <DiveFormFieldsDialogBody
          visibility={visibility}
          fields={fields}
          presets={presets}
          onApply={() => close(true)}
          onCancel={() => close(false)}
        />
      </DialogContent>
    </Dialog>
  );
}

interface DiveFormFieldsDialogBodyProps {
  visibility: DiveFormVisibility;
  /** The draft the switches edit - `visibility` itself is only written on close. */
  fields: DiveFormFieldsView;
  presets: DiveFormPresets;
  /** Applies the draft and closes. */
  onApply: () => void;
  /** Drops the draft and closes. */
  onCancel: () => void;
}

// Mounted with `DialogContent`, so the tab and the Save state start over on each open.
function DiveFormFieldsDialogBody({
  visibility,
  fields,
  presets,
  onApply,
  onCancel,
}: DiveFormFieldsDialogBodyProps) {
  const [tab, setTab] = useState("fields");
  const [canSave, setCanSave] = useState(false);
  const saveFormId = useId();

  return (
    <>
      <DialogHeader>
        <DialogTitle>Configure Fields</DialogTitle>
        <DialogDescription>
          Choose what the dive form asks for. Applied changes are saved to your
          account and used for every dive you log.
        </DialogDescription>
      </DialogHeader>

      <Tabs value={tab} onValueChange={setTab}>
        <TabsList>
          <TabsTrigger value="fields">Fields</TabsTrigger>
          <TabsTrigger value="presets">Presets</TabsTrigger>
        </TabsList>

        <TabsContent value="fields" className="space-y-6 pt-4">
          <DiveFormFieldSwitches visibility={fields} />
          <DiveFormPresetSaveAs
            // `initialName` is read once, when this mounts - which is when the
            // dialog opens, `DialogPortal` rendering `null` while closed. That
            // is what makes the seed a snapshot of the fields as they were
            // *opened* on, and it has to be: the value below is recomputed as
            // the diver flips switches, so a control that kept adopting it
            // would blank its own name on the first edit - precisely the edit
            // they mean to save back under that name.
            //
            // The one moment the mount is too early is when the account's
            // presets are still on the wire, when there is nothing to match
            // against yet. Keyed on that, so their arrival remounts the
            // control on the answer instead of leaving it seeded with "".
            key={presets.presets === null ? "awaiting-presets" : "presets"}
            presets={presets.presets ?? []}
            initialName={matchingPresetName(
              presets.presets,
              fields.hidden,
              visibility.selectedPresetUuid,
            )}
            disabled={presets.isWorking}
            formId={saveFormId}
            onSaveableChange={setCanSave}
            // The draft is now that preset's, so it reaches the form and the
            // preset becomes the pick.
            onSave={async (name, existing) => {
              const saved = existing
                ? (await presets.updateHiddenFields(existing, fields.hidden)) &&
                  existing
                : await presets.createPreset(name, fields.hidden);
              if (!saved) return;
              onApply();
              visibility.selectPreset(saved.uuid);
            }}
          />
        </TabsContent>

        <TabsContent value="presets" className="pt-4">
          <DiveFormPresetList presets={presets} />
        </TabsContent>
      </Tabs>

      <FormApiError error={presets.error} />

      <DialogFooter>
        <Button type="button" variant="outline" onClick={onCancel}>
          Cancel
        </Button>
        <Button type="button" variant="outline" onClick={onApply}>
          Apply
        </Button>
        {tab === "fields" && (
          <Button type="submit" form={saveFormId} disabled={!canSave}>
            <Save className="h-4 w-4 mr-2" />
            Save preset
          </Button>
        )}
      </DialogFooter>
    </>
  );
}