"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 `
`; * 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(null); // `null` until a switch is flipped, so an untouched dialog follows the live set. const [draft, setDraft] = useState(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(() => { 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 ( { 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); }} > close(true)} onCancel={() => close(false)} /> ); } 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 ( <> Configure Fields Choose what the dive form asks for. Applied changes are saved to your account and used for every dive you log. Fields Presets { const saved = existing ? (await presets.updateHiddenFields(existing, fields.hidden)) && existing : await presets.createPreset(name, fields.hidden); if (!saved) return; onApply(); visibility.selectPreset(saved.uuid); }} /> {tab === "fields" && ( )} ); }