import * as React from "react"; import { useFetcher } from "react-router"; import { useDebounce } from "~/hooks/useDebounce"; /** Sentinel items rendered in place of real results while loading or when empty. */ export type EntitySearchPlaceholder = | { id: "PLACEHOLDER" } | { id: "NO_RESULTS" }; export type EntitySearchItem = TItem | EntitySearchPlaceholder; interface UseEntitySearchArgs { /** Builds the loader URL queried (debounced) as the user types. */ buildUrl: (query: string) => string; /** * Turns raw loader data into result items. Return `null` when the data does * not (yet) correspond to the current query so a placeholder is shown. */ parseResults: (data: unknown, query: string) => TItem[] | null; /** Already resolved item to pin to the top of the list (e.g. when editing). */ initialItem?: TItem; /** Id to preselect on mount even before its item is resolved. */ initialSelectedId?: number; onChange?: (item: TItem | null) => void; } export interface EntitySearch { filterText: string; setFilterText: (text: string) => void; items: EntitySearchItem[]; selectedKey: number | null; onSelectionChange: (key: number) => void; } /** * Shared state + data fetching for the autocomplete search selects * (e.g. `UserSearch`, `TeamSearch`, `TournamentSearch`). Pair with the * presentational `SearchSelect` component, passing the returned value as its * `search` prop. */ export function useEntitySearch({ buildUrl, parseResults, initialItem, initialSelectedId, onChange, }: UseEntitySearchArgs): EntitySearch { const [filterText, setFilterText] = React.useState(""); const [selectedKey, setSelectedKey] = React.useState( initialSelectedId ?? null, ); const queryFetcher = useFetcher(); useDebounce( () => { if (!filterText) return; queryFetcher.load(buildUrl(filterText)); setSelectedKey(null); }, 500, [filterText], ); React.useEffect(() => { if (typeof initialSelectedId === "number") { setSelectedKey(initialSelectedId); } }, [initialSelectedId]); const items = withInitialItem( toEntitySearchItems(parseResults(queryFetcher.data, filterText)), initialItem, ); const realItems = items.filter( (item): item is TItem => typeof item.id === "number", ); // clear the selection when its item is no longer among the results const realItemIdsKey = realItems.map((item) => item.id).join(","); React.useEffect(() => { if (!realItemIdsKey) return; const ids = realItemIdsKey.split(",").map(Number); if ( selectedKey && selectedKey !== initialSelectedId && !ids.includes(selectedKey) ) { setSelectedKey(null); onChange?.(null); } }, [realItemIdsKey, selectedKey, onChange, initialSelectedId]); const onSelectionChange = (key: number) => { setSelectedKey(key); const item = realItems.find((item) => item.id === key); if (item) { onChange?.(item); } }; return { filterText, setFilterText, items, selectedKey, onSelectionChange }; } function toEntitySearchItems( parsed: TItem[] | null, ): EntitySearchItem[] { if (parsed === null) return [{ id: "PLACEHOLDER" }]; if (parsed.length === 0) return [{ id: "NO_RESULTS" }]; return parsed; } function withInitialItem( items: EntitySearchItem[], initialItem?: TItem, ): EntitySearchItem[] { if (!initialItem) return items; return [ initialItem, ...items.filter( (item) => typeof item.id !== "number" || item.id !== initialItem.id, ), ]; }