useSearchQuery.js 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. "use client";
  2. import React from "react";
  3. import { search as apiSearch } from "@/lib/frontend/apiClient";
  4. import { buildSearchApiInput } from "@/lib/frontend/search/searchApiInput";
  5. /**
  6. * useSearchQuery
  7. *
  8. * Purpose:
  9. * - Encapsulate the Search UI data lifecycle in one place:
  10. * - "first page" load (URL-driven, cursor reset)
  11. * - "load more" pagination (cursor-based, append)
  12. * - race protection (ignore stale responses)
  13. *
  14. * Design rules:
  15. * - The URL (searchKey) is the identity of the first page.
  16. * - Cursor is intentionally NOT part of searchKey (not shareable).
  17. * - When searchKey changes, we reset and load the first page again.
  18. *
  19. * Returned state is UI-friendly and stable:
  20. * - status: "idle" | "loading" | "success" | "error"
  21. * - items / nextCursor / total / error
  22. * - loadMore() / isLoadingMore / loadMoreError
  23. * - retry() triggers a re-fetch of the first page for the current searchKey
  24. *
  25. * NOTE:
  26. * - This hook does not do any redirects or UI mapping.
  27. * - Mapping to German user-facing messages stays in SearchPage/SearchResults via mapSearchError().
  28. *
  29. * @param {{
  30. * searchKey: string,
  31. * urlState: {
  32. * q: string|null,
  33. * scope: "single"|"multi"|"all",
  34. * branch: string|null,
  35. * branches: string[],
  36. * limit: number,
  37. * from: string|null,
  38. * to: string|null
  39. * },
  40. * routeBranch: string,
  41. * user: { role: string, branchId: string|null }|null,
  42. * limit?: number
  43. * }} args
  44. */
  45. export function useSearchQuery({
  46. searchKey,
  47. urlState,
  48. routeBranch,
  49. user,
  50. limit = 100,
  51. }) {
  52. const [status, setStatus] = React.useState("idle"); // idle|loading|success|error
  53. const [items, setItems] = React.useState([]);
  54. const [nextCursor, setNextCursor] = React.useState(null);
  55. const [total, setTotal] = React.useState(null); // number|null
  56. const [error, setError] = React.useState(null);
  57. const [isLoadingMore, setIsLoadingMore] = React.useState(false);
  58. const [loadMoreError, setLoadMoreError] = React.useState(null);
  59. // Retry tick triggers a first-page reload without changing searchKey.
  60. const [retryTick, setRetryTick] = React.useState(0);
  61. // Track the latest searchKey so "load more" cannot append into a newer search.
  62. const searchKeyRef = React.useRef(searchKey);
  63. React.useEffect(() => {
  64. searchKeyRef.current = searchKey;
  65. }, [searchKey]);
  66. // Race protection:
  67. // - firstPageRequestId increments for each first-page fetch
  68. // - loadMoreRequestId increments for each loadMore fetch
  69. const firstPageRequestIdRef = React.useRef(0);
  70. const loadMoreRequestIdRef = React.useRef(0);
  71. const retry = React.useCallback(() => {
  72. setRetryTick((n) => n + 1);
  73. }, []);
  74. const runFirstPage = React.useCallback(async () => {
  75. // Always reset "load more" state when starting a new first-page request.
  76. setIsLoadingMore(false);
  77. setLoadMoreError(null);
  78. const { input, error: localError } = buildSearchApiInput({
  79. urlState,
  80. routeBranch,
  81. user,
  82. cursor: null,
  83. limit,
  84. });
  85. // No query => we are in "idle" mode (no results, no error).
  86. if (!input && !localError) {
  87. setStatus("idle");
  88. setItems([]);
  89. setNextCursor(null);
  90. setTotal(null);
  91. setError(null);
  92. return;
  93. }
  94. // Local validation failed (e.g. multi without branches) => show error without calling API.
  95. if (localError) {
  96. setStatus("error");
  97. setItems([]);
  98. setNextCursor(null);
  99. setTotal(null);
  100. setError(localError);
  101. return;
  102. }
  103. const requestId = ++firstPageRequestIdRef.current;
  104. setStatus("loading");
  105. setItems([]);
  106. setNextCursor(null);
  107. setTotal(null);
  108. setError(null);
  109. try {
  110. const res = await apiSearch(input);
  111. // Ignore stale responses.
  112. if (requestId !== firstPageRequestIdRef.current) return;
  113. const nextItems = Array.isArray(res?.items) ? res.items : [];
  114. const next =
  115. typeof res?.nextCursor === "string" && res.nextCursor.trim()
  116. ? res.nextCursor
  117. : null;
  118. const t =
  119. typeof res?.total === "number" && Number.isFinite(res.total)
  120. ? res.total
  121. : null;
  122. setItems(nextItems);
  123. setNextCursor(next);
  124. setTotal(t);
  125. setStatus("success");
  126. } catch (err) {
  127. if (requestId !== firstPageRequestIdRef.current) return;
  128. setItems([]);
  129. setNextCursor(null);
  130. setTotal(null);
  131. setError(err);
  132. setStatus("error");
  133. }
  134. }, [urlState, routeBranch, user, limit]);
  135. React.useEffect(() => {
  136. runFirstPage();
  137. }, [runFirstPage, searchKey, retryTick]);
  138. const loadMore = React.useCallback(async () => {
  139. if (!nextCursor) return;
  140. if (isLoadingMore) return;
  141. const baseKey = searchKeyRef.current;
  142. const { input, error: localError } = buildSearchApiInput({
  143. urlState,
  144. routeBranch,
  145. user,
  146. cursor: nextCursor,
  147. limit,
  148. });
  149. if (localError) {
  150. setLoadMoreError(localError);
  151. return;
  152. }
  153. if (!input) return;
  154. const requestId = ++loadMoreRequestIdRef.current;
  155. setIsLoadingMore(true);
  156. setLoadMoreError(null);
  157. try {
  158. const res = await apiSearch(input);
  159. // Ignore stale responses.
  160. if (requestId !== loadMoreRequestIdRef.current) return;
  161. // If the base search changed since the click, do not append.
  162. if (searchKeyRef.current !== baseKey) return;
  163. const moreItems = Array.isArray(res?.items) ? res.items : [];
  164. const next =
  165. typeof res?.nextCursor === "string" && res.nextCursor.trim()
  166. ? res.nextCursor
  167. : null;
  168. const t =
  169. typeof res?.total === "number" && Number.isFinite(res.total)
  170. ? res.total
  171. : null;
  172. setItems((prev) => [...prev, ...moreItems]);
  173. setNextCursor(next);
  174. // Keep total stable but refresh when the backend provides it.
  175. if (t !== null) setTotal(t);
  176. } catch (err) {
  177. if (requestId !== loadMoreRequestIdRef.current) return;
  178. setLoadMoreError(err);
  179. } finally {
  180. // Only the latest loadMore call can clear the loading flag.
  181. if (requestId === loadMoreRequestIdRef.current) {
  182. setIsLoadingMore(false);
  183. }
  184. }
  185. }, [nextCursor, isLoadingMore, urlState, routeBranch, user, limit]);
  186. return {
  187. status,
  188. items,
  189. nextCursor,
  190. total,
  191. error,
  192. retry,
  193. loadMore,
  194. isLoadingMore,
  195. loadMoreError,
  196. };
  197. }