304 lines
9.2 KiB
TypeScript
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;
|