documentsmanager

This commit is contained in:
2025-11-19 23:36:04 +01:00
parent e1b47ed153
commit b76f1df27f
17 changed files with 564 additions and 300 deletions
@@ -114,7 +114,7 @@ interface UseDocumentMutationsArgs {
setSelectedFolder: Dispatch<SetStateAction<FolderId>>;
setDocuments: Dispatch<SetStateAction<DocumentLike[]>>;
setFolderContents: Dispatch<SetStateAction<Map<FolderId, FolderContents>>>;
setSearchResults: Dispatch<SetStateAction<DocumentLike[] | null>>;
setSearchResultIds: Dispatch<SetStateAction<DocumentId[] | null>>;
setSelectedEntries: Dispatch<SetStateAction<string[]>>;
setSelectionOrder: Dispatch<SetStateAction<string[]>>;
selectionOrderRef: MutableRefObject<string[] | null>;
@@ -190,7 +190,7 @@ const useDocumentMutations = ({
setSelectedFolder,
setDocuments,
setFolderContents,
setSearchResults,
setSearchResultIds,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
@@ -319,11 +319,11 @@ const useDocumentMutations = ({
}
if (uniqueIdSet.size) {
setSearchResults((prev) => {
setSearchResultIds((prev) => {
if (!Array.isArray(prev) || !prev.length) {
return prev;
}
const filtered = prev.filter((doc) => doc && !uniqueIdSet.has(doc.id as DocumentId));
const filtered = prev.filter((id) => !uniqueIdSet.has(id as DocumentId));
return filtered.length === prev.length ? prev : filtered;
});
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId)));
@@ -387,7 +387,7 @@ const useDocumentMutations = ({
folderLabelMap,
ensureFolderData,
selectedFolder,
setSearchResults,
setSearchResultIds,
setDocuments,
setFolderContents,
setSelectedEntries,
+97 -29
View File
@@ -1,7 +1,17 @@
import { Dispatch, SetStateAction, useCallback, useState } from 'react';
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import DocumentsManager from '../../documents/DocumentsManager';
type DocumentId = string | number;
interface DocumentLike {
id?: string | number;
id?: DocumentId;
[key: string]: unknown;
}
@@ -11,36 +21,74 @@ interface FolderContentsEntry {
}
interface UseDocumentsOptions {
setSearchResults: Dispatch<SetStateAction<DocumentLike[] | null>>;
setFolderContents: Dispatch<SetStateAction<Map<string, FolderContentsEntry>>>;
fetchDocumentById?: (id: DocumentId) => Promise<DocumentLike | null>;
hydrateDocument?: (payload: unknown) => DocumentLike | null;
hydrateDocuments?: (payload: unknown[]) => DocumentLike[];
extractDocument?: (payload: unknown) => DocumentLike | null;
}
const useDocuments = ({ setSearchResults, setFolderContents }: UseDocumentsOptions) => {
const [documents, setDocuments] = useState<DocumentLike[]>([]);
const useDocuments = ({
setFolderContents,
fetchDocumentById,
hydrateDocument,
hydrateDocuments,
extractDocument,
}: UseDocumentsOptions) => {
const managerRef = useRef(
new DocumentsManager<DocumentLike>(fetchDocumentById, {
hydrateDocument,
hydrateDocuments,
extractDocument,
}),
);
const [documents, setDocumentsState] = useState<DocumentLike[]>([]);
useEffect(() => {
managerRef.current.setFetcher(fetchDocumentById);
}, [fetchDocumentById]);
const setDocuments = useCallback(
(value: DocumentLike[] | ((prev: DocumentLike[]) => DocumentLike[])) => {
setDocumentsState((prev) => {
const resolved = typeof value === 'function' ? value(prev) : value;
if (!Array.isArray(resolved)) {
return resolved;
}
const { canonical } = managerRef.current.ingest(resolved);
return canonical;
});
},
[],
);
const mapDocumentCaches = useCallback(
(mapper: (doc: DocumentLike) => DocumentLike | undefined) => {
managerRef.current.map(mapper);
const lookupSnapshot = managerRef.current.getSnapshot();
const applyToList = (list?: DocumentLike[] | null) => {
let changed = false;
const safeList = Array.isArray(list) ? list : [];
const next = safeList.map((doc) => {
const updated = mapper(doc);
if (updated === undefined || updated === doc) {
return doc;
}
changed = true;
return updated;
});
return changed ? next : safeList;
};
setDocuments((prev) => applyToList(prev));
setSearchResults((prev) => {
if (!Array.isArray(prev)) {
setDocumentsState((prev) => {
if (!Array.isArray(prev) || prev.length === 0) {
return prev;
}
return applyToList(prev);
let changed = false;
const next = prev.map((doc) => {
const id = doc?.id;
if (id != null && lookupSnapshot.has(id as DocumentId)) {
const canonical = lookupSnapshot.get(id as DocumentId) as DocumentLike;
if (canonical !== doc) {
changed = true;
}
return canonical;
}
const updated = mapper(doc);
const nextDoc = updated === undefined ? doc : updated;
if (nextDoc !== doc) {
changed = true;
}
return nextDoc;
});
return changed ? next : prev;
});
setFolderContents((prev) => {
if (!prev.size) {
@@ -56,12 +104,20 @@ const useDocuments = ({ setSearchResults, setFolderContents }: UseDocumentsOptio
}
let docsChanged = false;
const updatedDocs = docs.map((doc) => {
const updated = mapper(doc);
if (updated === undefined || updated === doc) {
return doc;
const id = doc?.id;
if (id != null && lookupSnapshot.has(id as DocumentId)) {
const canonical = lookupSnapshot.get(id as DocumentId) as DocumentLike;
if (canonical !== doc) {
docsChanged = true;
}
return canonical;
}
docsChanged = true;
return updated;
const updated = mapper(doc);
const nextDoc = updated === undefined ? doc : updated;
if (nextDoc !== doc) {
docsChanged = true;
}
return nextDoc;
});
if (docsChanged) {
changed = true;
@@ -73,7 +129,7 @@ const useDocuments = ({ setSearchResults, setFolderContents }: UseDocumentsOptio
return changed ? next : prev;
});
},
[setFolderContents, setSearchResults],
[setFolderContents],
);
const updateDocumentCaches = useCallback(
@@ -93,11 +149,23 @@ const useDocuments = ({ setSearchResults, setFolderContents }: UseDocumentsOptio
[mapDocumentCaches],
);
const removeDocumentsFromLookup = useCallback(
(documentIds: Array<DocumentId>) => {
if (!Array.isArray(documentIds) || !documentIds.length) {
return;
}
managerRef.current.remove(documentIds);
},
[],
);
return {
documents,
setDocuments,
removeDocumentsFromLookup,
mapDocumentCaches,
updateDocumentCaches,
documentsManager: managerRef.current,
};
};
@@ -1,4 +1,12 @@
import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import {
matchPath,
useLocation,
@@ -220,10 +228,20 @@ const useDocumentsWorkspace = ({
if (!payload) {
return null;
}
const hydratedDetail = assetManager.hydrateDetail(payload);
return hydratedDetail?.document || payload.document || payload;
return payload.document || payload;
},
[assetManager],
[],
);
const fetchDocumentById = useCallback(
async (documentId: DocumentId) => {
if (!documentId) {
return null;
}
const { data } = await api.get(`/documents/${documentId}`);
return extractDocumentFromResponse(data);
},
[extractDocumentFromResponse],
);
const tagManagerRef = useRef(null);
@@ -290,21 +308,27 @@ const useDocumentsWorkspace = ({
folderContentsRef.current = folderContents;
}, [folderContents]);
const setSearchResultsRef = useRef<(value: unknown) => void>(() => {});
const setSearchResultsProxy = useCallback((value) => {
setSearchResultsRef.current(value);
}, []);
const {
documents,
setDocuments,
removeDocumentsFromLookup,
mapDocumentCaches,
updateDocumentCaches,
documentsManager,
} = useDocuments({
setSearchResults: setSearchResultsProxy,
setFolderContents,
fetchDocumentById,
hydrateDocument: (payload) => assetManager.hydrateDocument(payload),
hydrateDocuments: (payload) => assetManager.hydrateDocuments(payload),
extractDocument: extractDocumentFromResponse,
});
const documentLookup = useSyncExternalStore(
(onStoreChange) => documentsManager.subscribe(onStoreChange),
() => documentsManager.getSnapshot(),
() => documentsManager.getSnapshot(),
);
const {
folderNodes,
setFolderNodes,
@@ -338,8 +362,8 @@ const useDocumentsWorkspace = ({
const {
searchQuery,
setSearchQuery,
searchResults,
setSearchResults,
searchResultIds,
setSearchResultIds,
searchLoading,
activeTagFilters,
setActiveTagFilters,
@@ -349,7 +373,6 @@ const useDocumentsWorkspace = ({
documentsFilterValue,
} = useDocumentsSearch({
api,
assetManager,
token,
selectedFolder,
navigate,
@@ -361,17 +384,66 @@ const useDocumentsWorkspace = ({
notifyApiError,
setLoading,
setSearchIncludeDescendants,
documentsManager,
});
const documentsFilter = documentsFilterValue;
const [visibleDocumentIds, setVisibleDocumentIds] = useState<DocumentId[]>([]);
const showingSearchResults = searchResultIds !== null;
useEffect(() => {
setSearchResultsRef.current = setSearchResults;
}, [setSearchResults]);
const arraysEqual = (a: DocumentId[], b: DocumentId[]) =>
a.length === b.length && a.every((value, index) => value === b[index]);
if (showingSearchResults && Array.isArray(searchResultIds)) {
const ids = searchResultIds.filter((id): id is DocumentId => id != null);
setVisibleDocumentIds((prev) => (arraysEqual(prev, ids) ? prev : ids));
return;
}
const folderIds = documents
.map((doc) => (doc?.id ?? null) as DocumentId | null)
.filter((id): id is DocumentId => id != null);
setVisibleDocumentIds((prev) => (arraysEqual(prev, folderIds) ? prev : folderIds));
}, [showingSearchResults, searchResultIds, documents]);
const viewDocuments = useMemo(
() =>
visibleDocumentIds
.map((id) => documentLookup.get(id) || null)
.filter((doc): doc is DocumentLike => Boolean(doc)),
[visibleDocumentIds, documentLookup],
);
const visibleDocumentKeys = useMemo(
() => visibleDocumentIds.map((id) => resolveDocumentRowKey(id)).filter(Boolean),
[visibleDocumentIds],
);
const visibleFolderKeys = useMemo(
() =>
showingSearchResults
? []
: currentSubfolders
.map((folder) => resolveFolderRowKey(folder.id))
.filter(Boolean),
[showingSearchResults, currentSubfolders],
);
const visibleRowKeys = useMemo(
() => [...visibleFolderKeys, ...visibleDocumentKeys],
[visibleFolderKeys, visibleDocumentKeys],
);
const visibleRowKeySet = useMemo(
() => new Set(visibleRowKeys),
[visibleRowKeys],
);
const {
documentLinks,
previewDocuments,
ensurePreviewData,
ensureDownloadUrl,
openDocumentPreview,
@@ -380,8 +452,7 @@ const useDocumentsWorkspace = ({
removeDocumentLinks,
} = useDocumentPreview({
routeDocumentId: previewDocumentId,
documents,
searchResults,
documentsManager,
selectedFolder,
assetManager,
api,
@@ -414,65 +485,6 @@ const useDocumentsWorkspace = ({
const detailFolderFetchRef = useRef(new Set());
const showingSearchResults = searchResults !== null;
const visibleDocuments = useMemo(
() => (showingSearchResults ? searchResults : documents),
[showingSearchResults, searchResults, documents],
);
const visibleDocumentIds = useMemo(
() => visibleDocuments.map((doc) => doc.id),
[visibleDocuments],
);
const visibleDocumentKeys = useMemo(
() => visibleDocumentIds.map((id) => resolveDocumentRowKey(id)).filter(Boolean),
[visibleDocumentIds],
);
const visibleFolderKeys = useMemo(
() =>
showingSearchResults
? []
: currentSubfolders
.map((folder) => resolveFolderRowKey(folder.id))
.filter(Boolean),
[showingSearchResults, currentSubfolders],
);
const visibleRowKeys = useMemo(
() => [...visibleFolderKeys, ...visibleDocumentKeys],
[visibleFolderKeys, visibleDocumentKeys],
);
const visibleRowKeySet = useMemo(
() => new Set(visibleRowKeys),
[visibleRowKeys],
);
const documentLookup = useMemo(() => {
const map = new Map();
const push = (items) => {
(items || []).forEach((doc) => {
if (doc?.id) {
map.set(doc.id, doc);
}
});
};
push(documents);
if (Array.isArray(searchResults)) {
push(searchResults);
}
previewDocuments.forEach((doc, id) => {
if (doc && id && !map.has(id)) {
map.set(id, doc);
}
});
return map;
}, [documents, searchResults, previewDocuments]);
useEffect(() => {
if (!showingSearchResults) {
return;
@@ -696,7 +708,7 @@ const useDocumentsWorkspace = ({
selectionAnchorRef.current = null;
setDraggedDocumentIds([]);
setDraggedFolderId(null);
setSearchResults(null);
setSearchResultIds(null);
setTags([]);
setCorrespondents([]);
setSearchQuery('');
@@ -729,7 +741,7 @@ const useDocumentsWorkspace = ({
setDocuments,
setDraggedDocumentIds,
setDraggedFolderId,
setSearchResults,
setSearchResultIds,
setTags,
setCorrespondents,
setSearchQuery,
@@ -757,11 +769,11 @@ const useDocumentsWorkspace = ({
const idSet = new Set<DocumentId>(documentIds);
setDocuments((prev) => prev.filter((doc) => !idSet.has(doc.id)));
setSearchResults((prev) => {
setSearchResultIds((prev) => {
if (!Array.isArray(prev)) {
return prev;
}
const filtered = prev.filter((doc) => !idSet.has(doc.id));
const filtered = prev.filter((id) => !idSet.has(id as DocumentId));
return filtered.length === prev.length ? prev : filtered;
});
@@ -788,9 +800,16 @@ const useDocumentsWorkspace = ({
return changed ? next : prev;
});
removeDocumentsFromLookup(Array.from(idSet));
removeDocumentLinks(Array.from(idSet));
},
[setDocuments, setSearchResults, setFolderContents, removeDocumentLinks],
[
setDocuments,
setSearchResultIds,
setFolderContents,
removeDocumentsFromLookup,
removeDocumentLinks,
],
);
const {
@@ -812,7 +831,7 @@ const useDocumentsWorkspace = ({
setSelectedFolder,
setDocuments,
setFolderContents,
setSearchResults,
setSearchResultIds,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
@@ -864,7 +883,7 @@ const useDocumentsWorkspace = ({
setLoading,
setFolderContents,
setCurrentFolder,
setSearchResults,
setSearchResultIds,
isFilterActive,
navigate,
handleFileDrop,
@@ -883,7 +902,7 @@ const useDocumentsWorkspace = ({
} = useDocumentsSelection({
showingSearchResults,
currentSubfolders,
visibleDocuments,
visibleDocuments: viewDocuments,
resolveFolderRowKey,
resolveDocumentRowKey,
configureSelectionEnvironment,
@@ -1021,19 +1040,13 @@ const useDocumentsWorkspace = ({
prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc)),
);
setSearchResults((prev) =>
Array.isArray(prev)
? prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc))
: prev,
);
return entry;
} catch (error) {
notifyApiError(error, 'Unable to refresh document asset.');
throw error;
}
},
[assetManager, setDocuments, setSearchResults, notifyApiError],
[assetManager, setDocuments, notifyApiError],
);
@@ -1233,9 +1246,7 @@ const useDocumentsWorkspace = ({
documentLink,
resolveFolderPath,
} = useDetailWorkspace({
documents,
searchResults,
previewDocuments,
documents: viewDocuments,
selectionOrder,
selectedDocumentIds,
documentLookup,
@@ -1446,8 +1457,7 @@ const useDocumentsWorkspace = ({
const deskWorkspaceProps = useMemo(
() => ({
documents,
searchResults,
documents: viewDocuments,
onInspectDocument: inspectDocumentForDesk,
onEntryPointer: handleEntryPointerCore,
onDocumentStackSelect: handleDeskDocumentStackSelect,
@@ -1464,8 +1474,7 @@ const useDocumentsWorkspace = ({
ensureDownloadUrl,
}),
[
documents,
searchResults,
viewDocuments,
inspectDocumentForDesk,
handleEntryPointerCore,
handleDeskDocumentStackSelect,
@@ -1488,8 +1497,8 @@ const useDocumentsWorkspace = ({
breadcrumbs,
refreshCurrentFolder,
currentSubfolders,
documents,
searchResults,
documents: viewDocuments,
searchResultIds,
folderClickHandlers,
handleFolderDragStart,
handleFolderDragEnd,
@@ -70,7 +70,7 @@ interface UseFolderTreeActionsOptions {
updater: (prev: Map<FolderKey, FolderContentsState>) => Map<FolderKey, FolderContentsState>,
) => void;
setCurrentFolder: (updater: (prev: any) => any) => void;
setSearchResults: (value: any) => void;
setSearchResultIds: (value: any) => void;
isFilterActive: boolean;
navigate?: (path: string, options?: { replace?: boolean }) => void;
handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderKey) => Promise<void> | void;
@@ -99,7 +99,7 @@ const useFolderTreeActions = ({
setLoading,
setFolderContents,
setCurrentFolder,
setSearchResults,
setSearchResultIds,
isFilterActive,
navigate,
handleFileDrop,
@@ -235,7 +235,7 @@ const useFolderTreeActions = ({
}
applySelectedFolder(targetId, contents);
if (!preserveSearch) {
setSearchResults(null);
setSearchResultIds(null);
}
} catch (error) {
notifyApiError(error, 'Failed to load folder contents.');
@@ -250,7 +250,7 @@ const useFolderTreeActions = ({
expandFolderAncestors,
notifyApiError,
setLoading,
setSearchResults,
setSearchResultIds,
setSelectedFolder,
],
);