Ctrl K
ComunidadGuías…

src/components/dives/species-multi-select.tsx

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

Archivo original ↗Preguntar a KiBot
"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<string, SpeciesSearchResult>;
  // 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<string, SpeciesSearchResult>();
  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<SpeciesSummary>(
      readSpecies,
      value
        .map((sighting) => sighting.species_uuid)
        .filter((uuid) => !knownSpecies?.some((entry) => entry.uuid === uuid)),
    );
  const [pending, setPending] = useState<PendingSpecies[]>([]);
  // 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<string[]>([]);
  // 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<Map<string, SpeciesSearchResult>>(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<ComboboxSearchResult> => {
      // 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 (
    <div className="space-y-4">
      {(value.length > 0 || pending.length > 0) && (
        // Text selection would otherwise sweep across the rows mid-drag.
        <ul
          className={cn("space-y-4", draggingIndex !== null && "select-none")}
        >
          {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 (
              <RepeatableRow
                key={uuid}
                as="li"
                ref={setItemRef(index)}
                title={
                  <>
                    {label}
                    {binomial && (
                      <span className="italic font-normal"> {binomial}</span>
                    )}
                  </>
                }
                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
                }
              >
                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
                  <SightingQuantityField
                    id={countId}
                    speciesLabel={label}
                    value={sighting}
                    onChange={(quantity) => setQuantity(uuid, quantity)}
                    countError={rowErrors?.count}
                    abundanceError={rowErrors?.abundance}
                    disabled={disabled}
                  />
                  <RepeatableRowField
                    id={notesId}
                    label="Notes"
                    labelSuffix={`on ${label}`}
                  >
                    {/* 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. */}
                    <Textarea
                      id={notesId}
                      rows={1}
                      aria-invalid={rowErrors?.notes ? true : undefined}
                      aria-describedby={
                        rowErrors?.notes ? notesErrorId : undefined
                      }
                      className="field-sizing-content max-h-40 min-h-10 resize-none leading-5 touch:min-h-11"
                      disabled={disabled}
                      value={sighting.notes ?? ""}
                      onChange={(event) => setNotes(uuid, event.target.value)}
                    />
                    {rowErrors?.notes && (
                      <p
                        id={notesErrorId}
                        className="text-sm font-medium text-destructive"
                      >
                        {rowErrors.notes}
                      </p>
                    )}
                  </RepeatableRowField>
                </div>
              </RepeatableRow>
            );
          })}

          {/* Rows the diver has picked but the catalog doesn't hold yet. No drag
              handle and no remove button: there is nothing in form state to
              reorder or take out, and both would be gone again within a second
              or two anyway. */}
          {pending.map((entry) => (
            <li
              key={pendingSpeciesId(entry.aphiaId)}
              className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground"
            >
              <ButtonSpinner className="shrink-0" />
              <span className="flex-1 truncate">{entry.label}</span>
              <span className="sr-only">Adding</span>
            </li>
          ))}
        </ul>
      )}

      <CreatableCombobox
        {...slotProps}
        onSearch={searchSpecies}
        // Slower than the app's own pickers because two live third parties sit
        // behind this one - see DECISIONS.md ("The picker types more slowly than
        // the rest of the app, on purpose").
        searchDebounceMs={450}
        // Gates only what an empty menu says: one letter is already searched,
        // and the diver's own species answer it.
        minSearchLength={MIN_SPECIES_QUERY_LENGTH}
        maxSearchLength={MAX_SPECIES_QUERY_LENGTH}
        queryTooLongLabel="That's too long to search for - try just the name."
        // Already-selected species are hidden so the same one can't be added
        // twice, and so are the ones being resolved: re-picking a pending row
        // would fire a second resolve for a species that is on its way in.
        excludeIds={[
          ...value.map((sighting) => sighting.species_uuid),
          ...pending.map((entry) => pendingSpeciesId(entry.aphiaId)),
        ]}
        value={undefined}
        onChange={addSpecies}
        disabled={disabled}
        placeholder={
          value.length ? "Add another species..." : "Select a species..."
        }
        // No `onAddNew`/`onCreate`: there is no such thing as creating a species,
        // so committed text that matches nothing does nothing.
        noItemsLabel="Type a species name — common or scientific."
        noMatchesLabel="No species found."
        keepOpenOnSelect
      />

      {/* A licence condition of the data, so it is rendered wherever the results
          are - through `Attribution`, in case a provider's credit arrives as a
          markdown link the way the geocoder's does.

          Not rendered until there is a credit to show: an empty line under the
          picker reads as a gap in the form, and a hidden one would still leave
          the picker its `space-y-4` margin. Sized and joined like the trip
          picker's, which is the same credit line in the same role. */}
      {attributions.length > 0 && (
        <p className="text-[10px] leading-4 text-muted-foreground">
          {attributions.map((attribution, index) => (
            <Fragment key={attribution}>
              {index > 0 && " · "}
              <Attribution value={attribution} />
            </Fragment>
          ))}
        </p>
      )}
    </div>
  );
}