// The dive form's hideable fields: the vocabulary, the panel's registry, and the // value rules that go with them. // // The API owns the vocabulary as a `StrEnum` and validates against it, so what is // below is a hand-kept mirror in the pattern this repo already uses for `TANK_USAGE` // and the water types (DECISIONS.md, "`TANK_USAGE` is a fourth hand-kept vocabulary // mirror"). What keeps the mirror honest without a cross-repo test is a two-sided // guard: the API asserts every one of its values names a non-required field of // `DiveCreateRequest` (or, prefixed, of `DiveMixtureCreate`), and // `dive-form-fields.test.ts` here asserts this list equals the optional keys of // `diveCreateSchema` and `diveMixtureSchema` exactly, minus the named exclusions // below. API-optional is wider than form-optional, which is why that side is a // subset check and this one is an equality check. /** * Every field of the dive form a diver may hide, in form order. * * **Order is load-bearing, and it is this client's.** Every hidden set is put in this * order on the way in and on the way out (`canonicalHiddenFields`), so two equal sets * are two equal lists and "which preset matches the current state?" is one * element-by-element comparison; and it is the order the Fields dialog takes its rows * from. The API canonicalizes too, into its own stable order, which need not match. * * **These are stored data, not labels.** A preset row and a diver's own hidden set * name them, so renaming one is a data migration on both sides rather than a rename. */ export const DIVE_FORM_FIELDS = [ "file_import", "course_uuid", "trip_uuid", "contact_uuid", "dive_site_uuids", "entry_type", "boat_name", "people", "type", "max_depth", "avg_depth", "bottom_temperature", "visibility", "water_type", "altitude", "waves", "current", "weather", "air_temperature", "mixtures", "gear_item_uuids", "weight", "sightings", "rating", "tags", "notes", "mixture.gear_item_uuid", "mixture.po2_limit", "mixture.helium", "mixture.start_pressure", "mixture.end_pressure", "mixture.usage", "mixture.role", ] as const; export type DiveFormFieldKey = (typeof DIVE_FORM_FIELDS)[number]; /** * Keys that hide a part of the form rather than a field of the dive, with no value * stored under them - so the value rules (prefill, reveal, empty values) pass them by. * The registry guard reads this list rather than restating it, so a key cannot skip * the schema check silently. */ export const DIVE_FORM_SECTION_FIELDS = [ "file_import", ] as const satisfies readonly DiveFormFieldKey[]; /** A key naming a field of the dive, which the form holds a value for. */ export type DiveFormValueFieldKey = Exclude< DiveFormFieldKey, (typeof DIVE_FORM_SECTION_FIELDS)[number] >; /** True for a key the form holds a value under. */ export function holdsValue( key: DiveFormFieldKey, ): key is DiveFormValueFieldKey { return !(DIVE_FORM_SECTION_FIELDS as readonly DiveFormFieldKey[]).includes( key, ); } /** The keys naming a field of the dive, in form order. */ export const DIVE_FORM_VALUE_FIELDS = DIVE_FORM_FIELDS.filter(holdsValue); /** * Marks a key as naming a field of *every* cylinder rather than of the dive. * * Deliberately not react-hook-form's own path (`mixtures.0.po2_limit`): a key names * the column, not one row of it, and hiding it hides that input on every tank card. */ export const MIXTURE_FIELD_PREFIX = "mixture."; /** True for a key that governs a column of the cylinder list. */ export function isMixtureField(key: DiveFormFieldKey): boolean { return key.startsWith(MIXTURE_FIELD_PREFIX); } /** The field name a `mixture.` key governs on one cylinder row. */ export function mixtureFieldName(key: DiveFormFieldKey): string { return key.slice(MIXTURE_FIELD_PREFIX.length); } /** The per-cylinder keys, in form order - the columns a tank card can lose. */ export const MIXTURE_FORM_FIELDS = DIVE_FORM_VALUE_FIELDS.filter(isMixtureField); /** * Keys of `diveMixtureSchema` that accept `undefined` and are still not hideable, * with the reason each is exempt. The registry guard reads this list rather than * restating it, so an exemption cannot be made silently. * * `id` and `gas_number` have no input at all - the first is stripped on submit, the * second round-trips untouched from an imported file. `gear_item` has none either: it * is the linked vessel as the read embeds it, carried to show the link, while the link * itself is `gear_item_uuid`, which is hideable. * * `volume` and `oxygen` became blank-able when a cylinder was allowed to record a mix * with no vessel, and they are exempt on the substance rather than on the type: they * are what a cylinder *is*. A tank card that can lose both records a row with nothing * in it, and "Add tank" would propose `DEFAULT_MIXTURE` where the diver could * neither see nor change it - which is the half of that change the split was chosen to * protect. * * `helium` was exempt with them and is not any more. It is the one of the three a diver * can be certain about without measuring: air and nitrox have none, so a logbook that * never records a trimix fill is asking a question whose answer is always the same. A * cylinder recording no helium is a cylinder of air; one recording no volume or oxygen * says nothing at all. The API grew a `mixture.helium` member to match, since a hidden * set is validated there. */ export const NON_HIDEABLE_MIXTURE_SCHEMA_KEYS = [ "id", "gas_number", "gear_item", "volume", "oxygen", ] as const; /** * The form's sections, in the order the form renders them, which the Fields dialog * groups its rows by. * * **The dialog lists fields in the order the form renders them**, and that order is the * invariant to preserve when either side moves: it is what makes a diver looking for a * field in the dialog find it where they would look for it on the form. Each group is * one section of the form and one run of `DIVE_FORM_FIELDS`, except that the * per-cylinder `mixture.` keys trail the list rather than following `mixtures`. * * Coarser than the form's rows, deliberately: a heading per row would offer more * choices than there are decisions to make. Groups carried only by always-on rows are * listed anyway - a gap where Start time should be reads as a field that went missing. */ export const DIVE_FORM_FIELD_GROUPS = [ "Import", "Training", "Location", "People", "Dive info", "Environment", "Tanks", "Gear", "Marine life", "Notes", ] as const; export type DiveFormFieldGroup = (typeof DIVE_FORM_FIELD_GROUPS)[number]; export interface DiveFormFieldEntry { key: DiveFormFieldKey; /** The field's own visible label on the form, so the panel names what the diver sees. */ label: string; group: DiveFormFieldGroup; } /** * Every hideable field with the label and group the panel lists it under, in form * order. Its completeness against the form schemas is a test, not a convention - a * new optional input fails the suite until it is registered here. */ export const DIVE_FORM_FIELD_REGISTRY: readonly DiveFormFieldEntry[] = [ { key: "file_import", label: "Upload dive files", group: "Import" }, { key: "course_uuid", label: "Course", group: "Training" }, { key: "trip_uuid", label: "Trip", group: "Location" }, { key: "contact_uuid", label: "Dive center", group: "Location" }, { key: "dive_site_uuids", label: "Dive site(s)", group: "Location", }, { key: "entry_type", label: "Entry type", group: "Location" }, { key: "boat_name", label: "Boat name", group: "Location" }, { key: "people", label: "People", group: "People" }, { key: "type", label: "Dive type", group: "Dive info" }, { key: "max_depth", label: "Maximum depth", group: "Dive info" }, { key: "avg_depth", label: "Average depth", group: "Dive info" }, { key: "bottom_temperature", label: "Bottom temperature", group: "Environment", }, { key: "visibility", label: "Visibility", group: "Environment" }, { key: "water_type", label: "Water type", group: "Environment" }, { key: "altitude", label: "Altitude", group: "Environment" }, { key: "waves", label: "Waves", group: "Environment" }, { key: "current", label: "Current", group: "Environment" }, { key: "weather", label: "Weather", group: "Environment" }, { key: "air_temperature", label: "Air temperature", group: "Environment" }, { key: "mixtures", label: "Tanks", group: "Tanks" }, { key: "gear_item_uuids", label: "Gear", group: "Gear" }, { key: "weight", label: "Weight", group: "Gear" }, { key: "sightings", label: "Species spotted", group: "Marine life" }, { key: "rating", label: "Rating", group: "Notes" }, { key: "tags", label: "Tags", group: "Notes" }, { key: "notes", label: "Notes", group: "Notes" }, { key: "mixture.gear_item_uuid", label: "Cylinder", group: "Tanks" }, { key: "mixture.po2_limit", label: "ppO₂ limit", group: "Tanks" }, { key: "mixture.helium", label: "He", group: "Tanks" }, { key: "mixture.start_pressure", label: "Start pressure", group: "Tanks", }, { key: "mixture.end_pressure", label: "End pressure", group: "Tanks" }, { key: "mixture.usage", label: "Usage", group: "Tanks" }, { key: "mixture.role", label: "Role", group: "Tanks" }, ]; /** * The inputs the Fields dialog lists as always shown, so a diver looking for "Dive time" * finds it rather than a gap where it should be. * * Three of them are required by the create schema; the two cylinder fields are exempt * rather than required - see `NON_HIDEABLE_MIXTURE_SCHEMA_KEYS`. Both reasons end in * the same row: a switch that is on and will not move, which is the only claim the * dialog makes about either. */ export const DIVE_FORM_ALWAYS_ON_FIELDS: readonly { label: string; group: DiveFormFieldGroup; }[] = [ // Form order, which is the invariant above: Start time is its own row under Dive // type, and Dive number and Dive time are the pair below it, left to right. { label: "Start time", group: "Dive info" }, { label: "Dive number", group: "Dive info" }, { label: "Dive time", group: "Dive info" }, { label: "Volume", group: "Tanks" }, { label: "O₂", group: "Tanks" }, ]; // The always-on inputs by form name, which `DIVE_FORM_ALWAYS_ON_FIELDS` lists by label. const ALWAYS_ON_FIELD_GROUPS: Readonly> = { start_time: "Dive info", dive_number: "Dive info", submerged_time: "Dive info", }; /** * The section a top-level form field renders in, hideable or not - what a failed submit * reads to open the collapsed sections its errors are in. */ export function diveFormFieldGroup( name: string, ): DiveFormFieldGroup | undefined { return ( ALWAYS_ON_FIELD_GROUPS[name] ?? DIVE_FORM_FIELD_REGISTRY.find((entry) => entry.key === name)?.group ); } /** * Collapses duplicates and imposes `DIVE_FORM_FIELDS` order. * * Applied before every `PATCH` and to everything read back, so a hidden set is a * *set* spelled as a list and comparing two of them is one loop. */ export function canonicalHiddenFields( values: Iterable, ): DiveFormFieldKey[] { const present = new Set(values); return DIVE_FORM_FIELDS.filter((field) => present.has(field)); } /** Whether two hidden sets are the same set, which is what marks the matching preset. */ export function hiddenFieldsEqual( first: Iterable, second: Iterable, ): boolean { const left = canonicalHiddenFields(first); const right = canonicalHiddenFields(second); return ( left.length === right.length && left.every((key, i) => key === right[i]) ); } /** * What each key holds when it is empty. * * Not one sentinel: every dive scalar clears to `null` and the mixture columns clear * to `""`, which is the split `UnitNumberInput.emptyValue` exists to serve. The rule * behind both is anti-`undefined` - react-hook-form re-displays a field's default the * moment its value resolves to `undefined`, so an "emptied" field would fill itself * back in from whatever the prefill left as the default. */ export const EMPTY_DIVE_FORM_VALUES: Readonly< Record > = { course_uuid: null, trip_uuid: null, contact_uuid: null, dive_site_uuids: [], entry_type: "", boat_name: "", people: [], type: "", max_depth: null, avg_depth: null, bottom_temperature: null, air_temperature: null, visibility: null, water_type: "", altitude: null, current: "", waves: "", weather: "", mixtures: [], gear_item_uuids: [], weight: null, sightings: [], rating: null, tags: [], notes: "", "mixture.gear_item_uuid": "", "mixture.po2_limit": "", // The one column that clears to a *number*, and the only one whose absence means // something. Hiding He is the diver saying they dive air and nitrox - which is what // made it hideable at all - so the value it clears to has to be the zero that claim // implies, matching `DEFAULT_MIXTURE.helium`. `""` here would spell "not recorded", // and `gasName`/`isNameableMix` refuse to name a mix whose helium is unknown: every // cylinder under Basic or Recreational would lose its "Air"/"EAN32" and its MOD, and // a hand-added tank (which takes `DEFAULT_MIXTURE` whole, hidden column or not) would // disagree with a carried one on the same dive, in a column neither is showing. // // Hiding cannot overwrite a recorded helium: a cylinder that holds one is non-empty, // so the reveal rule puts the column back on screen and marks it the diver's. "mixture.helium": 0, "mixture.start_pressure": "", "mixture.end_pressure": "", "mixture.role": "", "mixture.usage": "", }; /** * The keys whose empty value is a real value rather than a blank, and what each is. * * Only `mixture.helium`, whose `0` is the claim hiding the column makes - see "A hidden * helium is `0`, not blank" in DECISIONS.md. Every other key clears to `null`, `""` or * `[]`, and that is the rule: an empty value the reveal rule would call non-empty is * normally a mistake, since the key would put itself back on screen the moment a stored * dive held one. The registry guard reads this list rather than restating it, so a second * exception cannot be made silently, and it pins the value as well as the name. */ export const NON_BLANK_EMPTY_FIELD_VALUES: Partial< Record > = { "mixture.helium": 0, }; /** * "Non-empty" as the reveal rule means it: not `undefined`, not `null`, not `""` and * not `[]`. `0` is a value - an end pressure of 0 is a drained cylinder, not a * missing reading. */ export function isNonEmptyFieldValue(value: unknown): boolean { if (value === undefined || value === null || value === "") return false; if (Array.isArray(value)) return value.length > 0; return true; } /** The shape the reveal rule reads: a form's values, as loosely as it needs them. */ export type DiveFormFieldValues = { readonly [key: string]: unknown; } & { readonly mixtures?: readonly Record[] }; /** * Whether a value in `key` is one the reveal rule should put back on screen. * * Non-empty, *and* not simply what hiding the key would have written there. The second * half exists for `mixture.helium` and reads as a no-op everywhere else: every other * key clears to `null`, `""` or `[]`, which the first half already rejects. Helium * clears to `0`, and `0` is the value nearly every stored cylinder holds - so without * this, loading any air or nitrox dive would reveal the column the diver hid, on every * dive they own, and hiding He would work until the first time they opened a dive. * * A key does not reveal itself by holding what hiding it writes. `mixture.end_pressure` * still reveals on a stored `0`, because its empty value is `""`: a drained cylinder is * a reading, and that distinction is the whole reason the empty values are per-key. */ function revealsField(key: DiveFormValueFieldKey, value: unknown): boolean { return isNonEmptyFieldValue(value) && value !== EMPTY_DIVE_FORM_VALUES[key]; } /** * Every key these values hold something in - what the "a value arrived from outside * the diver's typing" moments reveal. * * A per-cylinder key counts as non-empty when *any* cylinder holds a value for it, so * an edit load or an import that brings the gas section back brings every column some * tank recorded with it: an imported start pressure lands on screen rather than * behind an unchecked box. */ export function nonEmptyDiveFormFields( values: DiveFormFieldValues, ): DiveFormFieldKey[] { const mixtures = Array.isArray(values.mixtures) ? values.mixtures : []; return DIVE_FORM_VALUE_FIELDS.filter((key) => { if (!isMixtureField(key)) return revealsField(key, values[key]); const name = mixtureFieldName(key); return mixtures.some((row) => revealsField(key, row?.[name])); }); } /** * The keys a failed submit has to put back on screen. * * The resolver validates hidden fields too (react-hook-form's default * `shouldUnregister: false` keeps their values in form state), so a hidden * field carrying an error would otherwise block the save with no message * anywhere on the page - the exact shape of "The API sends `null` and the form * schema wants `""`, so `toDiveMixtureInput` converts at the boundary". A * per-cylinder error also reveals `mixtures`, since its message has nowhere to * render while the section is off screen. */ export function diveFormFieldsWithErrors( errors: Record, ): DiveFormFieldKey[] { const revealed = new Set(); for (const key of DIVE_FORM_VALUE_FIELDS) { if (!isMixtureField(key) && errors[key] !== undefined) revealed.add(key); } const mixtureErrors = errors.mixtures; if (mixtureErrors !== undefined) revealed.add("mixtures"); if (Array.isArray(mixtureErrors)) { for (const row of mixtureErrors) { if (!row) continue; for (const key of MIXTURE_FORM_FIELDS) { if ((row as Record)[mixtureFieldName(key)]) { revealed.add(key); } } } } return canonicalHiddenFields(revealed); }