import { useCallback, useEffect, useMemo, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; import { TAG_FILTER_UNTAGGED } from './appLayoutUtils'; type Identifier = string | number; type DocumentLike = { id?: Identifier } & Record; type ApiClient = { get: (url: string, config?: { params?: Record }) => Promise<{ data: T }>; }; interface UseDocumentsSearchArgs { api: ApiClient; token?: string | null; selectedFolder?: Identifier | 'root' | null; navigate?: (path: string, options?: { replace?: boolean }) => void; locationPathname?: string; isDocumentsRoute?: boolean; searchIncludeDescendants?: boolean; documentsSortField?: string; documentsSortDirection?: string; notifyApiError: (error: unknown, message: string) => void; setLoading: (state: boolean) => void; setSearchIncludeDescendants: (value: boolean) => void; documentsManager: { ingest: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean }; }; } interface UseDocumentsSearchResult { searchQuery: string; setSearchQuery: Dispatch>; searchResultIds: Identifier[] | null; setSearchResultIds: Dispatch>; searchLoading: boolean; setSearchLoading: Dispatch>; activeTagFilters: Identifier[]; setActiveTagFilters: Dispatch>; activeCorrespondentFilters: Identifier[]; setActiveCorrespondentFilters: Dispatch>; toggleTagFilter: (tagId: Identifier) => void; toggleCorrespondentFilter: (correspondentId?: Identifier | null) => void; isFilterActive: boolean; clearFilters: () => void; handleSearchChange: (value: string) => void; handleSearchSubmit: () => void; refetchSearchResults: () => void; documentsFilterValue: { query: string; searchResultIds: Identifier[] | null; searchLoading: boolean; includeDescendants: boolean; activeTagIds: Identifier[]; activeCorrespondentIds: Identifier[]; isActive: boolean; setQuery: (value: string) => void; submit: () => void; clear: () => void; toggleTag: (tagId: Identifier) => void; toggleCorrespondent: (correspondentId?: Identifier | null) => void; toggleIncludeDescendants: () => void; }; } const useDocumentsSearch = ({ api, token, selectedFolder, navigate, locationPathname, isDocumentsRoute, searchIncludeDescendants, documentsSortField, documentsSortDirection, notifyApiError, setLoading, setSearchIncludeDescendants, documentsManager, }: UseDocumentsSearchArgs): UseDocumentsSearchResult => { const [searchQuery, setSearchQuery] = useState(''); const [activeTagFilters, setActiveTagFilters] = useState([]); const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]); const [searchResultIds, setSearchResultIds] = useState(null); const [searchLoading, setSearchLoading] = useState(false); const [searchTrigger, setSearchTrigger] = useState(0); const toggleTagFilter = useCallback((tagId: Identifier) => { if (!tagId) return; setActiveTagFilters((previous) => { if (tagId === TAG_FILTER_UNTAGGED) { return previous.includes(TAG_FILTER_UNTAGGED) ? [] : [TAG_FILTER_UNTAGGED]; } const sanitized = previous.filter((id) => id !== TAG_FILTER_UNTAGGED); if (sanitized.includes(tagId)) { return sanitized.filter((id) => id !== tagId); } return sanitized.concat([tagId]); }); }, []); const toggleCorrespondentFilter = useCallback((correspondentId?: Identifier | null) => { setActiveCorrespondentFilters((previous) => { if (!correspondentId) { return []; } return previous.includes(correspondentId) ? [] : [correspondentId]; }); }, []); const isFilterActive = useMemo( () => searchQuery.trim().length > 0 || activeTagFilters.length > 0 || activeCorrespondentFilters.length > 0, [searchQuery, activeTagFilters, activeCorrespondentFilters], ); const clearFilters = useCallback(() => { setSearchQuery(''); setActiveTagFilters([]); setActiveCorrespondentFilters([]); setSearchLoading(false); setSearchIncludeDescendants(true); setSearchResultIds(null); }, [ setSearchIncludeDescendants, ]); const handleSearchChange = useCallback((value: string) => { setSearchQuery(value); }, []); const handleSearchSubmit = useCallback(() => { if (!navigate) return; const targetFolder = selectedFolder && selectedFolder !== 'root' ? selectedFolder : 'root'; const targetPath = targetFolder === 'root' ? '/documents' : `/documents/folder/${targetFolder}`; if (!isDocumentsRoute || locationPathname !== targetPath) { navigate(targetPath, { replace: false }); } }, [navigate, selectedFolder, isDocumentsRoute, locationPathname]); const documentsFilterValue = useMemo( () => ({ query: searchQuery, searchResultIds, searchLoading, includeDescendants: Boolean(searchIncludeDescendants), activeTagIds: activeTagFilters, activeCorrespondentIds: activeCorrespondentFilters, isActive: isFilterActive, setQuery: handleSearchChange, submit: handleSearchSubmit, clear: clearFilters, toggleTag: toggleTagFilter, toggleCorrespondent: toggleCorrespondentFilter, toggleIncludeDescendants: () => setSearchIncludeDescendants(!searchIncludeDescendants), }), [ searchQuery, searchResultIds, searchLoading, searchIncludeDescendants, activeTagFilters, activeCorrespondentFilters, isFilterActive, handleSearchChange, handleSearchSubmit, clearFilters, toggleTagFilter, toggleCorrespondentFilter, setSearchIncludeDescendants, ], ); const refetchSearchResults = useCallback(() => { setSearchTrigger(Date.now()); }, []); useEffect(() => { if (!token) return undefined; if (!isFilterActive) { setSearchResultIds(null); setSearchLoading(false); return undefined; } let cancelled = false; let started = false; setSearchLoading(true); const debounce = setTimeout(async () => { started = true; setLoading(true); try { const params: Record = {}; const trimmedQuery = searchQuery.trim(); if (trimmedQuery.length) { params.query = trimmedQuery; } if (activeTagFilters.length) { const onlyUntagged = activeTagFilters.length === 1 && activeTagFilters[0] === TAG_FILTER_UNTAGGED; if (onlyUntagged) { params.tags = 'none'; } else { const tagIds = activeTagFilters.filter((id) => id !== TAG_FILTER_UNTAGGED); if (tagIds.length) { params.tags = tagIds.join(','); } } } if (activeCorrespondentFilters.length) { params.correspondents = activeCorrespondentFilters.join(','); } const folderIdentifier = selectedFolder === 'root' ? null : selectedFolder; if (folderIdentifier) { params.folder_id = folderIdentifier; } if (!searchIncludeDescendants) { params.include_descendants = false; } if (documentsSortField) { params.sort = documentsSortField; } if (documentsSortDirection) { params.dir = documentsSortDirection; } const { data } = await api.get('/documents', { params }); if (cancelled) return; const results = Array.isArray(data) ? data : []; const { canonical } = documentsManager.ingest(results); const ids = canonical .map((doc) => (doc?.id ?? null) as Identifier | null) .filter((id): id is Identifier => id != null); setSearchResultIds(ids); if (!ids.length) { setSearchLoading(false); return; } } catch (error) { if (cancelled) return; notifyApiError(error, 'Search failed. Please try again.'); setSearchResultIds(null); } finally { if (!cancelled && started) { setLoading(false); setSearchLoading(false); } } }, 300); return () => { cancelled = true; clearTimeout(debounce); if (started) { setLoading(false); setSearchLoading(false); } }; }, [ api, token, isFilterActive, searchQuery, activeTagFilters, activeCorrespondentFilters, searchIncludeDescendants, documentsSortField, documentsSortDirection, selectedFolder, notifyApiError, setLoading, documentsManager, searchTrigger, ]); return { searchQuery, setSearchQuery, searchResultIds, setSearchResultIds, searchLoading, setSearchLoading, activeTagFilters, setActiveTagFilters, activeCorrespondentFilters, setActiveCorrespondentFilters, toggleTagFilter, toggleCorrespondentFilter, isFilterActive, clearFilters, handleSearchChange, handleSearchSubmit, refetchSearchResults, documentsFilterValue, }; }; export default useDocumentsSearch;