From ce821c1817d516d340cd32b86e89104b484b2829 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 9 Dec 2025 13:49:07 +0100 Subject: [PATCH] feat: update frontend document management components. --- frontend/src/documents/FoldersManager.ts | 53 +++++---- .../documents/data/useBulkDocumentActions.ts | 71 ++++++------ .../src/documents/data/useCorrespondents.ts | 8 +- .../data/useDocumentMoveMutations.ts | 4 +- .../documents/data/useDocumentMutations.ts | 17 ++- .../documents/data/useDocumentTagMutations.ts | 13 ++- frontend/src/documents/data/useDocuments.ts | 105 ++++++------------ .../documents/data/useDocumentsWorkspace.ts | 91 +++++++-------- frontend/src/documents/data/useTags.ts | 8 +- .../useDocumentCorrespondentActions.ts | 67 ++++++----- .../features/folders/useFolderTreeActions.ts | 6 +- .../features/tagging/useDocumentTagActions.ts | 91 ++++++++------- .../src/documents/types/workspaceTypes.ts | 22 +--- 13 files changed, 264 insertions(+), 292 deletions(-) diff --git a/frontend/src/documents/FoldersManager.ts b/frontend/src/documents/FoldersManager.ts index dd7a076..2331561 100644 --- a/frontend/src/documents/FoldersManager.ts +++ b/frontend/src/documents/FoldersManager.ts @@ -202,32 +202,39 @@ class FoldersManager { return this.treePromise; } - this.treePromise = (async () => { - try { - const raw = await getFolderTree(); - const flattened = flattenFolderTree(raw); - this.ingest(flattened); - const rootsPromises = raw as FolderTreeNode[]; - const rootNode = createRootNode() as FolderTreeNode; - - rootNode.children = rootsPromises; - rootNode.hasChildren = rootsPromises.length > 0; - rootNode.loaded = true; - - this.treeSnapshot = [rootNode]; - this.emit(); - return [rootNode]; - } catch (error) { - console.warn('Failed to fetch folder tree', error); - return []; - } finally { - this.treePromise = null; - } - })(); - + this.treePromise = this.fetchTreeInternal(); return this.treePromise; } + async refreshTree(): Promise { + this.treePromise = this.fetchTreeInternal(); + return this.treePromise; + } + + private async fetchTreeInternal(): Promise { + try { + const raw = await getFolderTree(); + const flattened = flattenFolderTree(raw); + this.ingest(flattened); + const rootsPromises = raw as FolderTreeNode[]; + const rootNode = createRootNode() as FolderTreeNode; + + rootNode.children = rootsPromises; + rootNode.hasChildren = rootsPromises.length > 0; + rootNode.loaded = true; + + this.treeSnapshot = [rootNode]; + this.emit(); + return [rootNode]; + } catch (error) { + console.warn('Failed to fetch folder tree', error); + // On error, do not clear existing snapshot if this was a refresh + return this.treeSnapshot.length > 0 ? this.treeSnapshot : []; + } finally { + this.treePromise = null; + } + } + invalidateTree() { this.treeSnapshot = []; this.treePromise = null; diff --git a/frontend/src/documents/data/useBulkDocumentActions.ts b/frontend/src/documents/data/useBulkDocumentActions.ts index b66cc84..b5b4806 100644 --- a/frontend/src/documents/data/useBulkDocumentActions.ts +++ b/frontend/src/documents/data/useBulkDocumentActions.ts @@ -13,6 +13,8 @@ type CorrespondentAssignment = { correspondent_id?: Identifier; }; +import type { DocumentsManagerInterface } from '../types/workspaceTypes'; + interface UseBulkDocumentActionsArgs { resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[]; correspondentLookupByName: Map; @@ -22,7 +24,7 @@ interface UseBulkDocumentActionsArgs { handleDocumentsDelete: (ids: Identifier[], options?: MessageOptions) => Promise; handleFolderDelete: (id: Identifier, options?: MessageOptions) => Promise; clearDocumentSelection: () => void; - updateDocumentCaches?: (id: Identifier, updater: (doc: any) => any) => void; + documentsManager: DocumentsManagerInterface; } const useBulkDocumentActions = ({ @@ -34,7 +36,7 @@ const useBulkDocumentActions = ({ handleDocumentsDelete, handleFolderDelete, clearDocumentSelection, - updateDocumentCaches, + documentsManager, }: UseBulkDocumentActionsArgs) => { const { showToast } = useStatusToast(); @@ -77,19 +79,22 @@ const useBulkDocumentActions = ({ const { assigned = 0, removed = 0 } = response; - if (updateDocumentCaches && target.id) { - targets.forEach((docId) => { - updateDocumentCaches(docId, (doc) => { - if (!doc) return doc; - const current = Array.isArray((doc as any).correspondents) ? (doc as any).correspondents : []; - if (current.some((entry: any) => entry?.id === target.id)) { - return doc; - } - return { - ...(doc as any), - correspondents: [...current, { id: target.id, name: (target as any).name }], - }; - }); + if (target.id) { + const targetSet = new Set(targets); + let targetId = target.id; + let targetName = (target as any).name; + + documentsManager.map((doc) => { + if (!targetSet.has(doc.id as Identifier)) return undefined; + + const current = Array.isArray((doc as any).correspondents) ? (doc as any).correspondents : []; + if (current.some((entry: any) => entry?.id === targetId)) { + return doc; + } + return { + ...(doc as any), + correspondents: [...current, { id: targetId, name: targetName }], + }; }); } const assignedSuffix = assigned === 1 ? '' : 's'; @@ -115,7 +120,7 @@ const useBulkDocumentActions = ({ handleCorrespondentCreate, resolveTargetDocumentIds, showToast, - updateDocumentCaches, + documentsManager, ], ); @@ -144,22 +149,22 @@ const useBulkDocumentActions = ({ }); const { assigned = 0, removed = 0 } = response; - if (updateDocumentCaches) { - targets.forEach((docId) => { - updateDocumentCaches(docId, (doc) => { - if (!doc || !Array.isArray((doc as any).correspondents)) { - return doc; - } - const filtered = (doc as any).correspondents.filter( - (entry: any) => - entry && !normalizedAssignments.some((assignment) => assignment.correspondent_id === entry.id), - ); - return filtered.length === (doc as any).correspondents.length - ? doc - : { ...(doc as any), correspondents: filtered }; - }); - }); - } + + const targetSet = new Set(targets); + documentsManager.map((doc) => { + if (!targetSet.has(doc.id as Identifier)) return undefined; + + if (!doc || !Array.isArray((doc as any).correspondents)) { + return doc; + } + const filtered = (doc as any).correspondents.filter( + (entry: any) => + entry && !normalizedAssignments.some((assignment) => assignment.correspondent_id === entry.id), + ); + return filtered.length === (doc as any).correspondents.length + ? doc + : { ...(doc as any), correspondents: filtered }; + }); if (removed > 0) { const removedSuffix = removed === 1 ? '' : 's'; @@ -174,7 +179,7 @@ const useBulkDocumentActions = ({ showToast('No correspondents changed.', 'info'); } }, - [resolveTargetDocumentIds, showToast, updateDocumentCaches], + [resolveTargetDocumentIds, showToast, documentsManager], ); const handleDeleteSelection = useCallback(async () => { diff --git a/frontend/src/documents/data/useCorrespondents.ts b/frontend/src/documents/data/useCorrespondents.ts index e5f2c10..60fdb5e 100644 --- a/frontend/src/documents/data/useCorrespondents.ts +++ b/frontend/src/documents/data/useCorrespondents.ts @@ -8,12 +8,12 @@ import useNotifyApiError from '../../hooks/useNotifyApiError'; interface UseCorrespondentsOptions { tenantIdRef: MutableRefObject; - mapDocumentCaches?: (mapper: (doc: any) => any) => void; + documentsManager?: { map: (mapper: (doc: any) => any) => void }; } const useCorrespondents = ({ tenantIdRef, - mapDocumentCaches, + documentsManager, }: UseCorrespondentsOptions) => { const [correspondents, setCorrespondents] = useState([]); const { showToast } = useStatusToast(); @@ -109,7 +109,7 @@ const useCorrespondents = ({ await deleteCorrespondent(correspondentId); await refreshCorrespondents(); - mapDocumentCaches?.(stripFromDoc); + documentsManager?.map(stripFromDoc); showToast('Correspondent deleted.', 'success'); return true; @@ -119,7 +119,7 @@ const useCorrespondents = ({ throw new Error(message); } }, - [mapDocumentCaches, notifyApiError, refreshCorrespondents, showToast], + [documentsManager, notifyApiError, refreshCorrespondents, showToast], ); return { diff --git a/frontend/src/documents/data/useDocumentMoveMutations.ts b/frontend/src/documents/data/useDocumentMoveMutations.ts index f959ffc..27f9b39 100644 --- a/frontend/src/documents/data/useDocumentMoveMutations.ts +++ b/frontend/src/documents/data/useDocumentMoveMutations.ts @@ -110,9 +110,9 @@ export const useDocumentMoveMutations = ({ showToast(`Moved ${count} document${suffix} to ${targetLabel}.`, 'success'); if (updatedDocsMap.size) { - documentsState.mapDocumentCaches((doc) => { + documentsState.documentsManager.map((doc) => { if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) { - return doc; + return undefined; } const updated = updatedDocsMap.get(doc.id as DocumentId); if (updated) { diff --git a/frontend/src/documents/data/useDocumentMutations.ts b/frontend/src/documents/data/useDocumentMutations.ts index 3918e97..5e5598b 100644 --- a/frontend/src/documents/data/useDocumentMutations.ts +++ b/frontend/src/documents/data/useDocumentMutations.ts @@ -89,7 +89,7 @@ const useDocumentMutations = ({ handleDocumentTagDetach, } = useDocumentTagMutations({ tagsState, - documentsState: { updateDocumentCaches: documentsState.updateDocumentCaches }, + documentsState: { documentsManager: documentsState.documentsManager }, }); const handleThumbnailRegeneration = useCallback( @@ -115,13 +115,10 @@ const useDocumentMutations = ({ // Optimistic update could happen here but usually we wait for standardized confirmation // However workspace expects mutation here. try { - // We use trashDocument for soft delete - // If we want hard delete we need deleteDocument - // Assuming trash for now as it makes sense for "Delete" action in UI unless specified await Promise.all(documentIds.map((id) => trashDocument(id))); - // Remove from local state - documentsState.removeDocumentsFromCaches(documentIds); + // Remove from local state and manager + documentsState.documentsManager.remove(documentIds); if (showMessage) { const count = documentIds.length; @@ -156,7 +153,8 @@ const useDocumentMutations = ({ if (updatedDocument && documentsState.ingestDocuments) { documentsState.ingestDocuments([updatedDocument]); } else { - documentsState.updateDocumentCaches(documentId, (doc) => { + documentsState.documentsManager.map((doc) => { + if (doc.id !== documentId) return undefined; if (updatedDocument) { return { ...doc, ...updatedDocument }; } @@ -189,7 +187,8 @@ const useDocumentMutations = ({ if (updatedDocument && documentsState.ingestDocuments) { documentsState.ingestDocuments([updatedDocument]); } else { - documentsState.updateDocumentCaches(documentId, (doc) => { + documentsState.documentsManager.map((doc) => { + if (doc.id !== documentId) return undefined; if (updatedDocument) { return { ...doc, ...updatedDocument }; } @@ -213,8 +212,6 @@ const useDocumentMutations = ({ ], ); - // handleFolderDelete is removed from here - return { moveDocumentsToFolder, handleThumbnailRegeneration, diff --git a/frontend/src/documents/data/useDocumentTagMutations.ts b/frontend/src/documents/data/useDocumentTagMutations.ts index 2941553..b3495ad 100644 --- a/frontend/src/documents/data/useDocumentTagMutations.ts +++ b/frontend/src/documents/data/useDocumentTagMutations.ts @@ -21,7 +21,7 @@ interface DocumentTagExtras { interface UseDocumentTagMutationsArgs { tagsState: TagsState; - documentsState: Pick; + documentsState: Pick; } export const useDocumentTagMutations = ({ @@ -51,9 +51,9 @@ export const useDocumentTagMutations = ({ try { await addDocumentTags(documentId, [cachedTag.id]); - documentsState.updateDocumentCaches(documentId, (doc) => { - if (!doc) { - return doc; + documentsState.documentsManager.map((doc) => { + if (doc.id !== documentId) { + return undefined; } const currentTags = Array.isArray(doc.tags) ? doc.tags : []; if (currentTags.some((entry) => entry?.id === cachedTag.id)) { @@ -149,7 +149,10 @@ export const useDocumentTagMutations = ({ try { await deleteDocumentTag(documentId, tagId); // Inlined applyTagRemovalToCaches logic - documentsState.updateDocumentCaches(documentId, (doc) => { + documentsState.documentsManager.map((doc) => { + if (doc.id !== documentId) { + return undefined; + } if (!doc || !Array.isArray(doc.tags)) { return doc; } diff --git a/frontend/src/documents/data/useDocuments.ts b/frontend/src/documents/data/useDocuments.ts index 72657db..a9504a7 100644 --- a/frontend/src/documents/data/useDocuments.ts +++ b/frontend/src/documents/data/useDocuments.ts @@ -1,8 +1,10 @@ import { useCallback, useEffect, + useMemo, useRef, useState, + useSyncExternalStore, } from 'react'; import DocumentsManager from '../DocumentsManager'; import type { DocumentId } from '../../types/identifiers'; @@ -18,91 +20,58 @@ const useDocuments = ({ const managerRef = useRef( new DocumentsManager(fetchDocumentById), ); - const [documents, setDocumentsState] = useState([]); + // Store only IDs in local state + const [documentIds, setDocumentIds] = useState([]); useEffect(() => { managerRef.current.setFetcher(fetchDocumentById); }, [fetchDocumentById]); + // Subscribe to the manager for reactive updates + const managerSnapshot = useSyncExternalStore( + useCallback((cb) => managerRef.current.subscribe(cb), []), + () => managerRef.current.getSnapshot(), + () => managerRef.current.getSnapshot(), + ); + + // Derive the full document objects from IDs + Snapshot + const documents = useMemo(() => { + if (!documentIds.length) return []; + + // Efficiently map IDs to current document objects from the snapshot + // If an ID is missing in the snapshot (unlikely if ingested correctly), return null/undefined and filter + return documentIds + .map(id => managerSnapshot.get(id)) + .filter((doc): doc is Document => Boolean(doc)); + }, [documentIds, managerSnapshot]); + + // Keep a ref to the latest documents to avoid setDocuments dependency + const documentsRef = useRef(documents); + useEffect(() => { + documentsRef.current = documents; + }, [documents]); + const setDocuments = useCallback( (value: Document[] | ((prev: Document[]) => Document[])) => { - setDocumentsState((prev) => { - const resolved = typeof value === 'function' ? value(prev) : value; - if (!Array.isArray(resolved)) { - return resolved; - } - const { canonical } = managerRef.current.ingest(resolved); - return canonical; - }); - }, - [], - ); + // Support functional updates using the current derived documents as the previous state. + // Use ref to avoid re-creating this callback when documents change. + const prevDocs = documentsRef.current; + const newDocs = typeof value === 'function' ? value(prevDocs) : value; - const mapDocumentCaches = useCallback( - (mapper: (doc: Document) => Document | undefined) => { - managerRef.current.map(mapper); - const lookupSnapshot = managerRef.current.getSnapshot(); - - setDocumentsState((prev) => { - if (!Array.isArray(prev) || prev.length === 0) { - return 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 Document; - 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; - }); - }, - [], - ); - - const updateDocumentCaches = useCallback( - (documentId, updater) => { - if (!documentId) { + if (!Array.isArray(newDocs)) { return; } - mapDocumentCaches((doc) => { - if (!doc || doc.id !== documentId) { - return doc; - } - const updated = updater(doc); - return updated === undefined ? doc : updated; - }); + const { canonical } = managerRef.current.ingest(newDocs); + const newIds = canonical.map(d => d.id as DocumentId).filter(Boolean); + setDocumentIds(newIds); }, - [mapDocumentCaches], - ); - - const removeDocumentsFromLookup = useCallback( - (documentIds: Array) => { - if (!Array.isArray(documentIds) || !documentIds.length) { - return; - } - managerRef.current.remove(documentIds); - }, - [], + [] // Stable callback ); return { documents, setDocuments, - removeDocumentsFromLookup, - mapDocumentCaches, - updateDocumentCaches, documentsManager: managerRef.current, }; }; diff --git a/frontend/src/documents/data/useDocumentsWorkspace.ts b/frontend/src/documents/data/useDocumentsWorkspace.ts index ce2253f..203630d 100644 --- a/frontend/src/documents/data/useDocumentsWorkspace.ts +++ b/frontend/src/documents/data/useDocumentsWorkspace.ts @@ -223,9 +223,6 @@ const useDocumentsWorkspace = ({ const { documents, setDocuments, - removeDocumentsFromLookup, - mapDocumentCaches, - updateDocumentCaches, documentsManager, } = useDocuments({ fetchDocumentById, @@ -264,6 +261,23 @@ const useDocumentsWorkspace = ({ const [currentSubfolders, setCurrentSubfolders] = useState>([]); + const foldersSnapshot = useSyncExternalStore( + useCallback((cb) => foldersManager.subscribe(cb), [foldersManager]), + () => foldersManager.getSnapshot(), + () => foldersManager.getSnapshot(), + ); + + const visibleSubfolders = useMemo(() => { + return currentSubfolders.map((folder) => { + if (!folder?.id) return folder; + const live = foldersSnapshot.get(folder.id); + if (live) { + return { ...folder, ...live }; + } + return folder; + }); + }, [currentSubfolders, foldersSnapshot]); + const reconcileSelectionWithFolderData = useCallback( (currentSelection: string[], docs: Document[], subfolders: any[]) => { const availableDocKeys = docs @@ -383,15 +397,30 @@ const useDocumentsWorkspace = ({ const documentsFilter = documentsFilterValue; const showingSearchResults = searchResultIds !== null; + // Live Filter: Ensure we only show documents that actually belong to the current folder. + // Since 'documents' is reactive, if a document is moved, its folder_id updates immediately. + // We must filter out any documents that no longer match the selectedFolder. + const liveFilteredDocuments = useMemo(() => { + if (showingSearchResults) { + return documents; + } + const targetFolder = selectedFolder || 'root'; + return documents.filter((doc) => { + if (!doc) return false; + const docFolder = doc.folder_id || 'root'; + return docFolder === targetFolder; + }); + }, [documents, showingSearchResults, selectedFolder]); + const { viewDocuments, visibleEntryKeySet, } = useWorkspaceViewData({ - documents, + documents: liveFilteredDocuments, documentLookup, searchResultIds, showingSearchResults, - currentSubfolders, + currentSubfolders: visibleSubfolders, }); const { @@ -445,7 +474,7 @@ const useDocumentsWorkspace = ({ tenantIdRef, tagManager, setActiveTagFilters, - mapDocumentCaches, + documentsManager, }); const { tags, @@ -476,7 +505,7 @@ const useDocumentsWorkspace = ({ const correspondentsStateRaw = useCorrespondents({ tenantIdRef, - mapDocumentCaches, + documentsManager, }); const { correspondents, @@ -495,7 +524,7 @@ const useDocumentsWorkspace = ({ } = useDocumentCorrespondentActions({ correspondents, handleCorrespondentCreate, - updateDocumentCaches, + documentsManager, }); const { @@ -548,7 +577,7 @@ const useDocumentsWorkspace = ({ tagManager, refreshTags, resolveTargetDocumentIds, - updateDocumentCaches, + documentsManager, }); const { @@ -642,40 +671,11 @@ const useDocumentsWorkspace = ({ } }, [appStatus, resetWorkspaceState, foldersManager]); - - const removeDocumentsFromCaches = useCallback( - (documentIds: DocumentId[]) => { - if (!documentIds.length) { - return; - } - - const idSet = new Set(documentIds); - - setDocuments((prev) => prev.filter((doc) => !idSet.has(doc.id))); - setSearchResultIds((prev) => { - if (!Array.isArray(prev)) { - return prev; - } - const filtered = prev.filter((id) => !idSet.has(id as DocumentId)); - return filtered.length === prev.length ? prev : filtered; - }); - - removeDocumentsFromLookup(Array.from(idSet)); - }, - [ - setDocuments, - setSearchResultIds, - removeDocumentsFromLookup, - ], - ); - const documentsState = { documentLookup, setDocuments, setSearchResultIds, - removeDocumentsFromCaches, - updateDocumentCaches, - mapDocumentCaches, + documentsManager, extractDocumentFromResponse, ingestDocuments: (docs: unknown[]) => documentsManager.ingest(docs), }; @@ -732,7 +732,7 @@ const useDocumentsWorkspace = ({ clearDocumentSelection, } = useDocumentsSelection({ showingSearchResults, - currentSubfolders, + currentSubfolders: visibleSubfolders, visibleDocuments: viewDocuments, configureSelectionEnvironment, visibleEntryKeySet, @@ -834,7 +834,7 @@ const useDocumentsWorkspace = ({ handleDocumentsDelete, handleFolderDelete, clearDocumentSelection, - updateDocumentCaches, + documentsManager, }); const ensureAssetUrl = useCallback( @@ -852,7 +852,10 @@ const useDocumentsWorkspace = ({ return null; } - updateDocumentCaches(documentId, (doc) => mergeAssetIntoDocument(doc, entry)); + documentsManager.map((doc) => { + if (doc.id !== documentId) return undefined; + return mergeAssetIntoDocument(doc, entry); + }); return entry; } catch (error) { @@ -860,7 +863,7 @@ const useDocumentsWorkspace = ({ throw error; } }, - [assetManager, updateDocumentCaches, notifyApiError], + [assetManager, documentsManager, notifyApiError], ); const handlePromptCreateFolder = useCallback(async (parentId?: Identifier | null) => { @@ -1097,7 +1100,7 @@ const useDocumentsWorkspace = ({ draggedFolderId, handlePromptCreateFolder, creatingFolder, - currentSubfolders, + currentSubfolders: visibleSubfolders, breadcrumbs, }; diff --git a/frontend/src/documents/data/useTags.ts b/frontend/src/documents/data/useTags.ts index 8b01ce3..7b48f15 100644 --- a/frontend/src/documents/data/useTags.ts +++ b/frontend/src/documents/data/useTags.ts @@ -16,7 +16,7 @@ interface UseTagsOptions { tagManager: TagManagerInterface; tenantIdRef: MutableRefObject; setActiveTagFilters: (updater: (prev: Array) => Array) => void; - mapDocumentCaches?: (mapper: (doc: any) => any) => void; + documentsManager?: { map: (mapper: (doc: any) => any) => void }; } const useTags = ({ @@ -24,7 +24,7 @@ const useTags = ({ tagManager, tenantIdRef, setActiveTagFilters, - mapDocumentCaches, + documentsManager, }: UseTagsOptions) => { const [tags, setTags] = useState([]); const { showToast } = useStatusToast(); @@ -115,7 +115,7 @@ const useTags = ({ return { ...doc, tags: nextTags }; }; - mapDocumentCaches?.(stripTagFromDoc); + documentsManager?.map(stripTagFromDoc); await refreshTags(); showToast('Tag deleted.', 'success'); @@ -126,7 +126,7 @@ const useTags = ({ throw new Error(message); } }, - [mapDocumentCaches, notifyApiError, refreshTags, setActiveTagFilters, showToast], + [documentsManager, notifyApiError, refreshTags, setActiveTagFilters, showToast], ); return { diff --git a/frontend/src/documents/features/correspondents/useDocumentCorrespondentActions.ts b/frontend/src/documents/features/correspondents/useDocumentCorrespondentActions.ts index b738059..9c9e275 100644 --- a/frontend/src/documents/features/correspondents/useDocumentCorrespondentActions.ts +++ b/frontend/src/documents/features/correspondents/useDocumentCorrespondentActions.ts @@ -12,20 +12,18 @@ interface CorrespondentOption { } import useNotifyApiError from '../../../hooks/useNotifyApiError'; +import type { DocumentsManagerInterface } from '../../types/workspaceTypes'; interface UseDocumentCorrespondentActionsArgs { correspondents: CorrespondentOption[]; handleCorrespondentCreate: (payload: { name: string }) => Promise; - updateDocumentCaches?: ( - id: Identifier, - updater: (doc: { correspondents?: CorrespondentOption[] } | null) => { correspondents?: CorrespondentOption[] } | null, - ) => void; + documentsManager: DocumentsManagerInterface; } const useDocumentCorrespondentActions = ({ correspondents, handleCorrespondentCreate, - updateDocumentCaches, + documentsManager, }: UseDocumentCorrespondentActionsArgs) => { const { showToast } = useStatusToast(); const notifyApiError = useNotifyApiError(); @@ -54,24 +52,24 @@ const useDocumentCorrespondentActions = ({ } try { await addDocumentCorrespondent(documentId, correspondentId); - if (updateDocumentCaches) { - const resolved = correspondent - || correspondents.find((entry) => entry?.id === correspondentId) - || null; - updateDocumentCaches(documentId, (doc) => { - if (!doc) { - return doc; - } - const current = Array.isArray(doc.correspondents) ? doc.correspondents : []; - if (current.some((entry) => entry?.id === correspondentId)) { - return doc; - } - const nextEntry = resolved?.name - ? { id: resolved.id ?? correspondentId, name: resolved.name } - : { id: correspondentId }; - return { ...doc, correspondents: [...current, nextEntry] }; - }); - } + + const resolved = correspondent + || correspondents.find((entry) => entry?.id === correspondentId) + || null; + + documentsManager.map((doc) => { + if (doc.id !== documentId) return undefined; + + const current = Array.isArray(doc.correspondents) ? doc.correspondents : []; + if (current.some((entry) => entry?.id === correspondentId)) { + return doc; + } + const nextEntry = resolved?.name + ? { id: resolved.id ?? correspondentId, name: resolved.name } + : { id: correspondentId }; + return { ...doc, correspondents: [...current, nextEntry] }; + }); + if (notify) { showToast('Correspondent assigned.', 'success'); } @@ -82,7 +80,7 @@ const useDocumentCorrespondentActions = ({ throw new Error(message); } }, - [correspondents, notifyApiError, showToast, updateDocumentCaches], + [correspondents, notifyApiError, showToast, documentsManager], ); const handleCorrespondentRemove = useCallback( @@ -95,15 +93,16 @@ const useDocumentCorrespondentActions = ({ } try { await removeDocumentCorrespondent(documentId, correspondentId); - if (updateDocumentCaches) { - updateDocumentCaches(documentId, (doc) => { - if (!doc || !Array.isArray(doc.correspondents)) { - return doc; - } - const filtered = doc.correspondents.filter((entry) => entry?.id !== correspondentId); - return filtered.length === doc.correspondents.length ? doc : { ...doc, correspondents: filtered }; - }); - } + + documentsManager.map((doc) => { + if (doc.id !== documentId) return undefined; + if (!doc || !Array.isArray(doc.correspondents)) { + return doc; + } + const filtered = doc.correspondents.filter((entry) => entry?.id !== correspondentId); + return filtered.length === doc.correspondents.length ? doc : { ...doc, correspondents: filtered }; + }); + if (notify) { showToast('Correspondent removed.', 'success'); } @@ -114,7 +113,7 @@ const useDocumentCorrespondentActions = ({ throw new Error(message); } }, - [notifyApiError, showToast, updateDocumentCaches], + [notifyApiError, showToast, documentsManager], ); const normalizeOption = ( diff --git a/frontend/src/documents/features/folders/useFolderTreeActions.ts b/frontend/src/documents/features/folders/useFolderTreeActions.ts index dd51891..7c65f8f 100644 --- a/frontend/src/documents/features/folders/useFolderTreeActions.ts +++ b/frontend/src/documents/features/folders/useFolderTreeActions.ts @@ -103,7 +103,7 @@ const useFolderTreeActions = ({ await moveFolderRequest(folderId, parent_id); if (foldersManager) { - foldersManager.invalidateTree(); + foldersManager.refreshTree(); } if (selectedFolder === folderId) { @@ -210,7 +210,7 @@ const useFolderTreeActions = ({ // Ingest the new folder data immediately so it's available foldersManager.ingest([folderData]); // Force tree refresh to update structure - foldersManager.invalidateTree(); + foldersManager.refreshTree(); } await selectFolder(folderData.id, { immediate: true }); @@ -251,7 +251,7 @@ const useFolderTreeActions = ({ if (foldersManager) { foldersManager.remove([folderId]); - foldersManager.invalidateTree(); + foldersManager.refreshTree(); } if (selectedFolder === folderId) { diff --git a/frontend/src/documents/features/tagging/useDocumentTagActions.ts b/frontend/src/documents/features/tagging/useDocumentTagActions.ts index 211cb1d..8950f7b 100644 --- a/frontend/src/documents/features/tagging/useDocumentTagActions.ts +++ b/frontend/src/documents/features/tagging/useDocumentTagActions.ts @@ -16,13 +16,14 @@ interface TagManager { } import useNotifyApiError from '../../../hooks/useNotifyApiError'; +import type { DocumentsManagerInterface } from '../../types/workspaceTypes'; interface UseDocumentTaggingArgs { tags: TagRecord[]; tagManager: TagManager; refreshTags: () => Promise | void; resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[]; - updateDocumentCaches?: (id: Identifier, updater: (doc: TagRecord | null) => TagRecord | null) => void; + documentsManager: DocumentsManagerInterface; } interface BulkTagOperationArgs { @@ -44,7 +45,7 @@ const useDocumentTagActions = ({ tagManager, refreshTags, resolveTargetDocumentIds, - updateDocumentCaches, + documentsManager, }: UseDocumentTaggingArgs) => { const { showToast } = useStatusToast(); const notifyApiError = useNotifyApiError(); @@ -93,38 +94,39 @@ const useDocumentTagActions = ({ } tagIds = Array.from(new Set(createdIds)); - if (updateDocumentCaches) { - const tagById = new Map(); - tags.forEach((tag) => { - if (tag?.id != null) { - tagById.set(tag.id, tag); - } - }); - createdTags.forEach((tag) => { - if (tag?.id != null) { - tagById.set(tag.id, tag); - } - }); - targetDocumentIds.forEach((docId) => { + const tagById = new Map(); + tags.forEach((tag) => { + if (tag?.id != null) { + tagById.set(tag.id, tag); + } + }); + createdTags.forEach((tag) => { + if (tag?.id != null) { + tagById.set(tag.id, tag); + } + }); + + if (tagIds.length > 0) { + const targetSet = new Set(targetDocumentIds); + documentsManager.map((doc) => { + if (!targetSet.has(doc.id as Identifier)) return undefined; + + const currentTags = Array.isArray((doc as any).tags) ? (doc as any).tags : []; + let nextTags = [...currentTags]; + let changed = false; + tagIds.forEach((tagId) => { - const cachedTag = tagById.get(tagId); - if (!cachedTag) { + if (nextTags.some((entry: any) => entry?.id === tagId)) { return; } - updateDocumentCaches(docId, (doc) => { - if (!doc) { - return doc; - } - const currentTags = Array.isArray((doc as any).tags) ? (doc as any).tags : []; - if (currentTags.some((entry: any) => entry?.id === tagId)) { - return doc; - } - return { - ...(doc as any), - tags: [...currentTags, { ...cachedTag }], - }; - }); + const cachedTag = tagById.get(tagId); + if (cachedTag) { + nextTags.push({ ...cachedTag }); + changed = true; + } }); + + return changed ? { ...(doc as any), tags: nextTags } : doc; }); } } @@ -141,21 +143,18 @@ const useDocumentTagActions = ({ action, }); - if (updateDocumentCaches) { - targetDocumentIds.forEach((docId) => { - tagIds.forEach((tagId) => { - updateDocumentCaches(docId, (doc) => { - if (!doc || !Array.isArray((doc as any).tags)) { - return doc; - } - const currentTags = (doc as any).tags; - if (action === 'remove') { - const filtered = currentTags.filter((entry: any) => entry?.id !== tagId); - return filtered.length === currentTags.length ? doc : { ...(doc as any), tags: filtered }; - } - return doc; - }); - }); + if (action === 'remove') { + const targetSet = new Set(targetDocumentIds); + const removeSet = new Set(tagIds); + + documentsManager.map((doc) => { + if (!targetSet.has(doc.id as Identifier)) return undefined; + if (!doc || !Array.isArray((doc as any).tags)) { + return doc; + } + const currentTags = (doc as any).tags; + const filtered = currentTags.filter((entry: any) => !removeSet.has(entry?.id)); + return filtered.length === currentTags.length ? doc : { ...(doc as any), tags: filtered }; }); } @@ -178,7 +177,7 @@ const useDocumentTagActions = ({ refreshTags, notifyApiError, tagManager, - updateDocumentCaches, + documentsManager, ], ); diff --git a/frontend/src/documents/types/workspaceTypes.ts b/frontend/src/documents/types/workspaceTypes.ts index 666e196..66d089e 100644 --- a/frontend/src/documents/types/workspaceTypes.ts +++ b/frontend/src/documents/types/workspaceTypes.ts @@ -15,20 +15,12 @@ interface TagManager { buildPayload: (args: { label: string }) => Record; } -type DocumentCacheMapper = ( - doc: Document | null, -) => Document | null; -type MapDocumentCaches = (mapper: DocumentCacheMapper) => void; - -type UpdateDocumentCaches = ( - documentId: DocumentId, - updater: DocumentCacheMapper, -) => void; - - - -type RemoveDocumentsFromCaches = (documentIds: DocumentId[]) => void; +export interface DocumentsManagerInterface { + map(mapper: (doc: Document) => Document | undefined): boolean; + ingest(rawDocs: unknown[]): { canonical: Document[]; changed: boolean }; + remove(ids: Array): boolean; +} type CloseDocumentPreview = () => void; @@ -36,9 +28,7 @@ export interface DocumentsState { documentLookup: Map; setDocuments: Dispatch>; setSearchResultIds: Dispatch>; - removeDocumentsFromCaches: RemoveDocumentsFromCaches; - updateDocumentCaches: UpdateDocumentCaches; - mapDocumentCaches: MapDocumentCaches; + documentsManager: DocumentsManagerInterface; extractDocumentFromResponse?: (payload: unknown) => Document | null; ingestDocuments?: (docs: unknown[]) => { canonical: Document[]; changed: boolean }; }