Files
papercrate/frontend/src/app/useDocumentsSearch.ts
T

304 lines
9.2 KiB
TypeScript

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<string, unknown>;
type ApiClient = {
get: <T = unknown>(url: string, config?: { params?: Record<string, unknown> }) => 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<SetStateAction<string>>;
searchResultIds: Identifier[] | null;
setSearchResultIds: Dispatch<SetStateAction<Identifier[] | null>>;
searchLoading: boolean;
setSearchLoading: Dispatch<SetStateAction<boolean>>;
activeTagFilters: Identifier[];
setActiveTagFilters: Dispatch<SetStateAction<Identifier[]>>;
activeCorrespondentFilters: Identifier[];
setActiveCorrespondentFilters: Dispatch<SetStateAction<Identifier[]>>;
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<string>('');
const [activeTagFilters, setActiveTagFilters] = useState<Identifier[]>([]);
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState<Identifier[]>([]);
const [searchResultIds, setSearchResultIds] = useState<Identifier[] | null>(null);
const [searchLoading, setSearchLoading] = useState<boolean>(false);
const [searchTrigger, setSearchTrigger] = useState<number>(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<string, unknown> = {};
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<unknown[]>('/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;