"use client"; import { Fragment, useCallback, useEffect, useId, useRef, useState, } from "react"; import { Textarea } from "@/components/ui/textarea"; import { RepeatableRow, RepeatableRowField, } from "@/components/ui/repeatable-row"; import { ComboboxItem, ComboboxSearchResult, CreatableCombobox, } from "@/components/ui/creatable-combobox"; import type { FormControlA11yProps } from "@/components/ui/form"; import { Attribution } from "@/components/attribution"; import { useToast } from "@/components/ui/use-toast"; import { MAX_SPECIES_QUERY_LENGTH, MIN_SPECIES_QUERY_LENGTH, speciesAPI, SpeciesSearchResult, SpeciesSuggestion, SpeciesSuggestResponse, SpeciesSummary, } from "@/lib/api/species"; import { getApiErrorMessage } from "@/lib/api/error"; import type { SightingWrite } from "@/lib/api/dives"; import { speciesDisplayName, speciesRankLabel, speciesSecondaryName, speciesSeenOn, } from "@/lib/species"; import { moveItem, useDragSort } from "@/hooks/useDragSort"; import { readEach, useLookupLabels } from "@/hooks/useLookupLabels"; import { SightingQuantityField, type SightingQuantity, withQuantity, } from "@/components/dives/sighting-quantity-field"; import { cn } from "@/lib/utils"; import { countNoun } from "@/lib/format"; import { ButtonSpinner } from "@/components/ui/button-spinner"; // Menu-row id for a species that isn't in the catalog yet, and so has no uuid to // be identified by. Picking one of these is what triggers a resolve; the prefix // is what tells the two kinds of row apart in `onChange`, which only ever gets // handed an id back. // // Deliberately not a bare number: form state holds uuids, and an id that could // be mistaken for one is how a synthetic value ends up in a request body. const PENDING_ID_PREFIX = "aphia:"; const readSpecies = readEach((uuid: string) => speciesAPI.getSpecies(uuid)); /** The menu-row id for an upstream species, keyed by its AphiaID. */ export function pendingSpeciesId(aphiaId: number): string { return `${PENDING_ID_PREFIX}${aphiaId}`; } /** * The AphiaID behind a menu-row id, or `null` when the id is a catalog uuid. * * The `null` is the branch that matters: it means "this species already exists * locally, append it as-is", which is the whole difference between a pick that * is instant and one that goes to WoRMS first. */ export function parsePendingSpeciesId(id: string): number | null { if (!id.startsWith(PENDING_ID_PREFIX)) return null; const aphiaId = Number(id.slice(PENDING_ID_PREFIX.length)); return Number.isInteger(aphiaId) && aphiaId > 0 ? aphiaId : null; } export interface MappedSpecies { // Menu rows, in the order the API tiered and ranked them. items: ComboboxItem[]; // What each of those rows came from, by row id. The pending row a remote pick // creates is labelled from this, since the resolve it is waiting on is the // only other thing that knows the species' name. results: Map; // The results that are already catalog rows, ready to label a selection with. // Upstream-only results have no uuid and so nothing a label map could key on // until they are resolved. summaries: SpeciesSummary[]; // The licence notices carried by these results, deduplicated. attributions: string[]; hasMore: boolean; } /** * Turns a page of suggestions into what the menu and the picker need. * * Pure, and separate from the component, because this is the whole of the * mapping - everything else here is list plumbing and the resolve dance. * * The hint carries whichever fact the name doesn't. A row shown by its common * name is disambiguated by its binomial; a row shown by its binomial already is * one, so the rank is the useful thing to add ("Genus" tells the diver they are * about to log a whole genus). Then why the row sits where it does: how many of * the diver's dives at the form's sites logged it, else when they last logged it * anywhere - the API orders the page by exactly those, and a list of twenty * reads as rows, not as an order. `matched_name` is appended on top whenever it * was some *other* name that matched, because otherwise a search for "Manta * birostris" returns a row reading *Mobula birostris* and nothing on screen * explains why - the accepted-taxon rule is invisible from the outside. * * Results sharing a row id are collapsed: two menu rows with the same React key * are both a warning and a row that can't be excluded once picked. */ export function mapSpeciesResults( response: SpeciesSuggestResponse, ): MappedSpecies { const items: ComboboxItem[] = []; const results = new Map(); const summaries: SpeciesSummary[] = []; const attributions: string[] = []; for (const result of response.results) { const id = result.uuid ?? pendingSpeciesId(result.aphia_id); if (!results.has(id)) { results.set(id, result); items.push({ id, name: speciesDisplayName(result), hint: hintFor(result), }); if (result.uuid) { summaries.push({ uuid: result.uuid, scientific_name: result.scientific_name, common_name: result.common_name, rank: result.rank, }); } } if (result.attribution && !attributions.includes(result.attribution)) { attributions.push(result.attribution); } } return { items, results, summaries, attributions, hasMore: response.has_more, }; } function hintFor(result: SpeciesSuggestion): string | undefined { const parts: string[] = []; const context = result.common_name ? result.scientific_name : speciesRankLabel(result.rank); if (context) parts.push(context); const dives = result.dive_count_at_sites; if (dives > 0) { parts.push(`${countNoun(dives, "dive")} here`); } else if (result.last_seen) { parts.push(`last seen ${speciesSeenOn(result.last_seen)}`); } const matched = result.matched_name?.trim(); // Only when it says something the row doesn't already. The API nulls a hint // wherever a visible name already accounts for the *query*, which covers this // for as long as every `matched_name` is a name that matched - true today, and // a string comparison is cheaper than depending on it. const isRedundant = !matched || equalsIgnoringCase(matched, result.scientific_name) || equalsIgnoringCase(matched, result.common_name); if (!isRedundant) parts.push(`matched "${matched}"`); return parts.length > 0 ? parts.join(" · ") : undefined; } function equalsIgnoringCase(a: string, b: string | null): boolean { return b !== null && a.toLowerCase() === b.toLowerCase(); } /** What the form refused on one sighting's inputs. */ export interface SightingErrors { count?: string; abundance?: string; notes?: string; } // A species picked from the menu that isn't a catalog row yet, while its resolve // is in flight. Local state rather than form state on purpose - see the // component's doc comment. interface PendingSpecies { aphiaId: number; label: string; } export interface SpeciesMultiSelectProps extends FormControlA11yProps { // The sightings, in the order the diver listed them, each naming a catalog // species at most once. Order is presentation only here - unlike the dive site // picker, where the first entry is the dive's primary site - but it is preserved // end to end, so a diver who puts the manta first sees the manta first. value: SightingWrite[]; // Names for the species already in `value`, when the caller has them (the edit // form does: `Dive.sightings` carries them). Purely an optimization - any uuid // not covered here is fetched individually - but it saves a request per row on // the form that always has selections. knownSpecies?: SpeciesSummary[]; // The dive's sites, whose species the menu lists first. diveSiteUuids?: readonly string[]; onChange: (sightings: SightingWrite[]) => void; // What the form refused on each row, by position in `value`. errors?: readonly (SightingErrors | undefined)[]; // Reports whether a pick is still being resolved into a catalog row, so the // form can stop a save from racing it. Without this the window is small but // the loss is silent: the diver picks a species, hits Save inside the second // or two the resolve takes, and the dive is written without the sighting - // the resolve then lands on a form that has already navigated away. onPendingChange?: (isPending: boolean) => void; disabled?: boolean; } /** * Lets the diver record what they saw: the species they logged at the dive's * sites first, then the rest they have logged, then a global catalog that is * backed live by WoRMS and Wikidata. * * Wraps the generic `CreatableCombobox` the way `GearItemMultiSelect` does (see * DECISIONS.md) - the combobox is the "add a species" input, with a * drag-sortable list of what's already added above it, each row carrying how * many were seen and a note. * * The one thing this picker does that no other does: **a pick can require a * round trip before it has a value at all.** A species the catalog has never * seen exists only as an AphiaID upstream, and `POST /species/resolve` is what * turns it into a row. That resolve happens here, at pick time, and its result - * a real uuid - is what reaches form state. Nothing synthetic is ever put in * `value` for the submit path to trip over, and by the time a dive is saved * every uuid on it already exists, so saving never waits on a third party. * * What that costs is a moment where the diver has picked something the form does * not yet hold, which is what the pending rows are: local state, rendered with a * spinner, excluded from the menu so the same species can't be picked twice, and * dropped on either outcome. * * There is deliberately no "add a species we've never heard of" hatch, unlike * the trip location picker's free text. A global catalog has no owner to * attribute a made-up row to, and the dive's own notes field is where "weird * translucent blob, 10 cm" goes until it has a name. */ export function SpeciesMultiSelect({ value, knownSpecies, diveSiteUuids, onChange, errors, onPendingChange, disabled, // Forwarded to the "add a species" combobox - the one control the field's // label names. The rows above it name their own, after their species. ...slotProps }: SpeciesMultiSelectProps) { const { toast } = useToast(); const idPrefix = useId(); // Read through the `knownSpecies` prop rather than copied into `labels`: the // copy wouldn't have landed yet on the render that first sees a selection. The // rest are read so a selection never renders nameless - a dive has a handful of // species at most, so one at a time. const { records: labels, remember: rememberLabels } = useLookupLabels( readSpecies, value .map((sighting) => sighting.species_uuid) .filter((uuid) => !knownSpecies?.some((entry) => entry.uuid === uuid)), ); const [pending, setPending] = useState([]); // Kept for the session rather than cleared with each query: the credit is a // licence condition of data that has already been shown and, once picked, is // sitting in the list. const [attributions, setAttributions] = useState([]); // What each menu row would add, by row id - the combobox hands back an id, and // this is what turns it back into the result it came from, so a pending row // can be labelled without waiting for the resolve it is waiting on. const resultsRef = useRef>(new Map()); // The current selection, readable from an async callback. Two resolves can be // in flight at once (the menu stays open, so the diver picks the second while // the first is still going), and `value` captured in a closure would be the // list as it stood when that pick was made - so the second arrival would drop // the first. Claimed eagerly on append rather than only mirrored, because both // can land before React has re-rendered either. const valueRef = useRef(value); useEffect(() => { valueRef.current = value; }, [value]); const isPending = pending.length > 0; useEffect(() => { onPendingChange?.(isPending); }, [isPending, onPendingChange]); // Read from a ref so the unmount report below can be a mount-only effect: with // `onPendingChange` in its dependencies, a caller passing an inline function would // make the cleanup run on every render and report `false` over a live resolve. const onPendingChangeRef = useRef(onPendingChange); useEffect(() => { onPendingChangeRef.current = onPendingChange; }, [onPendingChange]); // A picker that unmounts reports itself no longer pending, and there is no other // way for it to: the effect above only fires while this component is alive, so a // resolve in flight when the picker leaves the page would leave the card's // `isResolvingSpecies` stuck at true and the Save button reading "Adding // species..." until a reload. Reachable from the Fields dialog - hide Species, or // apply a preset that does. useEffect(() => { return () => onPendingChangeRef.current?.(false); }, []); const rememberAttributions = useCallback((credits: string[]) => { if (credits.length === 0) return; setAttributions((previous) => { const missing = credits.filter((credit) => !previous.includes(credit)); return missing.length > 0 ? [...previous, ...missing] : previous; }); }, []); const labelFor = (uuid: string): SpeciesSummary | undefined => labels[uuid] ?? knownSpecies?.find((entry) => entry.uuid === uuid); const searchSpecies = useCallback( async (query: string): Promise => { // The held species go up so the server leaves them out before it cuts the // page, which is what lets the menu refill after a pick rather than lose a // row. Through `valueRef`, so the search the combobox re-runs right after a // pick already carries it. A pick still resolving has no uuid to send; // `excludeIds` hides that one. const response = await speciesAPI.suggestSpecies(query, { diveSiteUuids, excludeSpeciesUuids: valueRef.current.map( (sighting) => sighting.species_uuid, ), }); const { items, results, summaries, attributions: credits, hasMore, } = mapSpeciesResults(response); // Accumulated across queries rather than replaced: the menu can hand back // a row from a list the next keystroke has already superseded. results.forEach((result, id) => resultsRef.current.set(id, result)); rememberLabels(...summaries); rememberAttributions(credits); return { items, hasMore }; }, [diveSiteUuids, rememberLabels, rememberAttributions], ); // Appends a sighting of the species alone, claiming it in `valueRef` first so a // sibling resolve landing in the same tick appends to this list rather than the // one before it. const appendSighting = (uuid: string) => { const current = valueRef.current; // The same species can be reached from two queries, and resolve is // idempotent server-side - so a stale menu row's second pick would otherwise // add the row it already added. if (current.some((sighting) => sighting.species_uuid === uuid)) return; const next = [...current, { species_uuid: uuid }]; valueRef.current = next; onChange(next); }; const addSpecies = (id: string | undefined) => { if (id === undefined) return; const aphiaId = parsePendingSpeciesId(id); if (aphiaId === null) { appendSighting(id); return; } resolveAndAdd(aphiaId, id); }; const resolveAndAdd = async (aphiaId: number, rowId: string) => { const result = resultsRef.current.get(rowId); setPending((prev) => prev.some((entry) => entry.aphiaId === aphiaId) ? prev : [ ...prev, { aphiaId, // The fallback is only reachable if the menu handed back a row it // never returned, which it doesn't - but a nameless spinner would // be a worse way to find that out. label: result ? speciesDisplayName(result) : "Species...", }, ], ); try { const species = await speciesAPI.resolveSpecies(aphiaId); rememberLabels(species); appendSighting(species.uuid); } catch (error) { toast({ title: "Error", description: getApiErrorMessage(error, "Couldn't add that species."), variant: "destructive", }); } finally { setPending((prev) => prev.filter((entry) => entry.aphiaId !== aphiaId)); } }; const removeSpecies = (uuid: string) => onChange(value.filter((sighting) => sighting.species_uuid !== uuid)); // Through `valueRef` and claiming it, as `appendSighting` does: a resolve that // lands between a keystroke and the render after it would otherwise append to // the list from before the keystroke, dropping what was typed. const updateSighting = ( uuid: string, change: (sighting: SightingWrite) => SightingWrite, ) => { const next = valueRef.current.map((sighting) => sighting.species_uuid === uuid ? change(sighting) : sighting, ); valueRef.current = next; onChange(next); }; const setQuantity = (uuid: string, quantity: SightingQuantity) => updateSighting(uuid, (sighting) => withQuantity(sighting, quantity)); const setNotes = (uuid: string, notes: string) => updateSighting(uuid, (sighting) => ({ ...sighting, notes })); const reorder = useCallback( (from: number, to: number) => onChange(moveItem(value, from, to)), [value, onChange], ); const { draggingIndex, dragOffset, setItemRef, handleProps } = useDragSort({ itemCount: value.length, onReorder: reorder, disabled, }); return (
{(value.length > 0 || pending.length > 0) && ( // Text selection would otherwise sweep across the rows mid-drag.
    {value.map((sighting, index) => { const uuid = sighting.species_uuid; const species = labelFor(uuid); // The fallback is only ever visible for the moment between a species // being selected and its name being resolved. const label = species ? speciesDisplayName(species) : "Species..."; const binomial = species && speciesSecondaryName(species); const isDragging = draggingIndex === index; const rowErrors = errors?.[index]; const countId = `${idPrefix}-${uuid}-count`; const notesId = `${idPrefix}-${uuid}-notes`; const notesErrorId = `${notesId}-error`; return ( {label} {binomial && ( {binomial} )} } removeLabel={`Remove ${label}`} onRemove={() => removeSpecies(uuid)} dragHandle={ value.length > 1 ? { label: `Reorder ${label}, position ${index + 1} of ${value.length}. Use arrow up and arrow down to move it.`, props: handleProps(index), } : undefined } disabled={disabled} className={cn( isDragging && "relative z-10 bg-card shadow-lg ring-2 ring-ring", )} // The dragged row is translated to follow the pointer; the rest // stay put and are simply re-ordered around it by React. style={ isDragging ? { transform: `translateY(${dragOffset}px)` } : undefined } >
    setQuantity(uuid, quantity)} countError={rowErrors?.count} abundanceError={rowErrors?.abundance} disabled={disabled} /> {/* A textarea for the line breaks a merge or an import puts in a note, as the dive's own notes field is one - but one line tall until the note needs more. `field-sizing` does the growing; a browser without it keeps the one line and scrolls. */}