Переглянути джерело

RHL-024 feat(search): add useSearchQuery hook for managing search UI data lifecycle

Code_Uwe 3 тижнів тому
батько
коміт
55f4c32302
1 змінених файлів з 231 додано та 0 видалено
  1. 231 0
      lib/frontend/search/useSearchQuery.js

+ 231 - 0
lib/frontend/search/useSearchQuery.js

@@ -0,0 +1,231 @@
+"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,
+	};
+}