Ctrl K
ComunidadGuías…

src/components/courses/course-combobox.tsx

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

Archivo original ↗Preguntar a KiBot
"use client";

import { useCallback, useRef, useState } from "react";
import {
  ComboboxSearchResult,
  CreatableCombobox,
} from "@/components/ui/creatable-combobox";
import type { FormControlA11yProps } from "@/components/ui/form";
import { coursesAPI, Course } from "@/lib/api/courses";
import { CourseDialog } from "@/components/courses/course-dialog";
import { useLookupLabels } from "@/hooks/useLookupLabels";

// How many courses the dropdown asks for at a time. Enough to scroll through
// before typing, far short of the API's 100 cap.
const COURSES_PER_SEARCH = 25;

const lookupCourses = (uuids: string[]) =>
  coursesAPI.lookupCoursesByUuid(uuids);

export interface CourseComboboxProps extends FormControlA11yProps {
  value?: string | null;
  // `null`, not `undefined`, for "no course" - and the distinction is
  // load-bearing on the dive edit form, which builds its PATCH body by skipping
  // fields that are `undefined`. Clearing the picker has to be a value the diver
  // *chose*, not an absent one, or the course is dropped from the request and
  // silently survives the save. `CreatableCombobox` speaks `undefined`, so it is
  // normalized here rather than changing that shared component for its other
  // consumers - the same shape as `TripCombobox`.
  onChange: (courseId: string | null) => void;
  // Fired with the whole course whenever the diver picks one - from the menu,
  // from a committed exact-match, or straight out of the inline "Add course..."
  // dialog. A menu row is only a name, so a pick reads the course first and
  // fires once that read lands - unless another pick or a clear came first, or
  // picking A then B would hand B's host A's contact.
  //
  // Not fired for a cleared selection: clearing says "no logged course", not
  // "those facts are wrong". Nor for the lookup of an incoming `value`, which is
  // the form seeding itself rather than the diver choosing.
  onCourseSelected?: (course: Course) => void;
  // The date of the record being edited - see `TripCombobox.until`.
  until?: string;
  disabled?: boolean;
}

// Picks (or creates) the training course a dive was part of, or the one a
// certification came out of. The dropdown searches the course lookup
// server-side rather than fetching the user's whole course list - see
// DECISIONS.md.
export function CourseCombobox({
  value,
  onChange,
  onCourseSelected,
  until,
  disabled,
  ...slotProps
}: CourseComboboxProps) {
  const [showNewDialog, setShowNewDialog] = useState(false);
  const { records: courses, remember } = useLookupLabels(lookupCourses, [
    value,
  ]);
  // Counts picks and clears, so a read landing after a later one fires nothing -
  // a counter rather than the uuid, since re-picking the same course must not
  // let the superseded read fire as well.
  const pickRef = useRef(0);

  const searchCourses = useCallback(
    async (query: string): Promise<ComboboxSearchResult> => {
      const response = await coursesAPI.lookupCourses(1, COURSES_PER_SEARCH, {
        search: query,
        until,
      });
      remember(...response.data);
      return {
        items: response.data.map((course) => ({
          id: course.uuid,
          name: course.name,
        })),
        hasMore: response.has_more,
      };
    },
    [remember, until],
  );

  // The picker's one way in: `CreatableCombobox` reports a pick, a committed
  // exact match and a clear through the same callback, so the full-record
  // notification is derived here rather than at four call sites.
  const handleChange = (courseId: string | undefined) => {
    onChange(courseId ?? null);
    const pick = ++pickRef.current;
    if (!courseId || !onCourseSelected) return;
    coursesAPI
      .getCourse(courseId)
      .then((course) => {
        if (pickRef.current === pick) onCourseSelected(course);
      })
      .catch((error) => console.error("Failed to fetch course:", error));
  };

  const handleCreated = (newCourse: Course) => {
    pickRef.current += 1;
    remember(newCourse);
    onChange(newCourse.uuid);
    onCourseSelected?.(newCourse);
  };

  return (
    <>
      <CreatableCombobox
        {...slotProps}
        onSearch={searchCourses}
        value={value ?? undefined}
        selectedItem={
          value && courses[value]
            ? { id: value, name: courses[value].name }
            : undefined
        }
        onChange={handleChange}
        disabled={disabled}
        placeholder="Select a course..."
        noItemsLabel="No courses yet."
        noMatchesLabel="No courses match."
        addNewLabel="Add course..."
        onAddNew={() => setShowNewDialog(true)}
      />

      {/* Its own mount rather than the app-wide quick-create one, for the same
          reason the other pickers keep theirs: this needs the created course
          handed back so it can select it, which "create something and go look at
          it" cannot do. In the certification dialog that puts a dialog inside a
          dialog - see DECISIONS.md on why that holds. */}
      <CourseDialog
        open={showNewDialog}
        onOpenChange={setShowNewDialog}
        onSaved={handleCreated}
      />
    </>
  );
}