| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231 |
- "use client";
- import React from "react";
- import { search as apiSearch } from "@/lib/frontend/apiClient";
- import { buildSearchApiInput } from "@/lib/frontend/search/searchApiInput";
- /**
- * useSearchQuery
- *
- * Purpose:
- * - Encapsulate the Search UI data lifecycle in one place:
- * - "first page" load (URL-driven, cursor reset)
- * - "load more" pagination (cursor-based, append)
- * - race protection (ignore stale responses)
- *
- * Design rules:
- * - The URL (searchKey) is the identity of the first page.
- * - Cursor is intentionally NOT part of searchKey (not shareable).
- * - When searchKey changes, we reset and load the first page again.
- *
- * Returned state is UI-friendly and stable:
- * - status: "idle" | "loading" | "success" | "error"
- * - items / nextCursor / total / error
- * - loadMore() / isLoadingMore / loadMoreError
- * - retry() triggers a re-fetch of the first page for the current searchKey
- *
- * NOTE:
- * - This hook does not do any redirects or UI mapping.
- * - Mapping to German user-facing messages stays in SearchPage/SearchResults via mapSearchError().
- *
- * @param {{
- * searchKey: string,
- * urlState: {
- * q: string|null,
- * scope: "single"|"multi"|"all",
- * branch: string|null,
- * branches: string[],
- * limit: number,
- * from: string|null,
- * to: string|null
- * },
- * routeBranch: string,
- * user: { role: string, branchId: string|null }|null,
- * limit?: number
- * }} args
- */
- export function useSearchQuery({
- searchKey,
- urlState,
- routeBranch,
- user,
- limit = 100,
- }) {
- const [status, setStatus] = React.useState("idle"); // idle|loading|success|error
- const [items, setItems] = React.useState([]);
- const [nextCursor, setNextCursor] = React.useState(null);
- const [total, setTotal] = React.useState(null); // number|null
- const [error, setError] = React.useState(null);
- const [isLoadingMore, setIsLoadingMore] = React.useState(false);
- const [loadMoreError, setLoadMoreError] = React.useState(null);
- // Retry tick triggers a first-page reload without changing searchKey.
- const [retryTick, setRetryTick] = React.useState(0);
- // Track the latest searchKey so "load more" cannot append into a newer search.
- const searchKeyRef = React.useRef(searchKey);
- React.useEffect(() => {
- searchKeyRef.current = searchKey;
- }, [searchKey]);
- // Race protection:
- // - firstPageRequestId increments for each first-page fetch
- // - loadMoreRequestId increments for each loadMore fetch
- const firstPageRequestIdRef = React.useRef(0);
- const loadMoreRequestIdRef = React.useRef(0);
- const retry = React.useCallback(() => {
- setRetryTick((n) => n + 1);
- }, []);
- const runFirstPage = React.useCallback(async () => {
- // Always reset "load more" state when starting a new first-page request.
- setIsLoadingMore(false);
- setLoadMoreError(null);
- const { input, error: localError } = buildSearchApiInput({
- urlState,
- routeBranch,
- user,
- cursor: null,
- limit,
- });
- // No query => we are in "idle" mode (no results, no error).
- if (!input && !localError) {
- setStatus("idle");
- setItems([]);
- setNextCursor(null);
- setTotal(null);
- setError(null);
- return;
- }
- // Local validation failed (e.g. multi without branches) => show error without calling API.
- if (localError) {
- setStatus("error");
- setItems([]);
- setNextCursor(null);
- setTotal(null);
- setError(localError);
- return;
- }
- const requestId = ++firstPageRequestIdRef.current;
- setStatus("loading");
- setItems([]);
- setNextCursor(null);
- setTotal(null);
- setError(null);
- try {
- const res = await apiSearch(input);
- // Ignore stale responses.
- if (requestId !== firstPageRequestIdRef.current) return;
- const nextItems = Array.isArray(res?.items) ? res.items : [];
- const next =
- typeof res?.nextCursor === "string" && res.nextCursor.trim()
- ? res.nextCursor
- : null;
- const t =
- typeof res?.total === "number" && Number.isFinite(res.total)
- ? res.total
- : null;
- setItems(nextItems);
- setNextCursor(next);
- setTotal(t);
- setStatus("success");
- } catch (err) {
- if (requestId !== firstPageRequestIdRef.current) return;
- setItems([]);
- setNextCursor(null);
- setTotal(null);
- setError(err);
- setStatus("error");
- }
- }, [urlState, routeBranch, user, limit]);
- React.useEffect(() => {
- runFirstPage();
- }, [runFirstPage, searchKey, retryTick]);
- const loadMore = React.useCallback(async () => {
- if (!nextCursor) return;
- if (isLoadingMore) return;
- const baseKey = searchKeyRef.current;
- const { input, error: localError } = buildSearchApiInput({
- urlState,
- routeBranch,
- user,
- cursor: nextCursor,
- limit,
- });
- if (localError) {
- setLoadMoreError(localError);
- return;
- }
- if (!input) return;
- const requestId = ++loadMoreRequestIdRef.current;
- setIsLoadingMore(true);
- setLoadMoreError(null);
- try {
- const res = await apiSearch(input);
- // Ignore stale responses.
- if (requestId !== loadMoreRequestIdRef.current) return;
- // If the base search changed since the click, do not append.
- if (searchKeyRef.current !== baseKey) return;
- const moreItems = Array.isArray(res?.items) ? res.items : [];
- const next =
- typeof res?.nextCursor === "string" && res.nextCursor.trim()
- ? res.nextCursor
- : null;
- const t =
- typeof res?.total === "number" && Number.isFinite(res.total)
- ? res.total
- : null;
- setItems((prev) => [...prev, ...moreItems]);
- setNextCursor(next);
- // Keep total stable but refresh when the backend provides it.
- if (t !== null) setTotal(t);
- } catch (err) {
- if (requestId !== loadMoreRequestIdRef.current) return;
- setLoadMoreError(err);
- } finally {
- // Only the latest loadMore call can clear the loading flag.
- if (requestId === loadMoreRequestIdRef.current) {
- setIsLoadingMore(false);
- }
- }
- }, [nextCursor, isLoadingMore, urlState, routeBranch, user, limit]);
- return {
- status,
- items,
- nextCursor,
- total,
- error,
- retry,
- loadMore,
- isLoadingMore,
- loadMoreError,
- };
- }
|