From 0eb4c0a294a3f0e6a953c1517f8cdf0b604c0fce Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Thu, 20 Nov 2025 02:18:33 +0100 Subject: [PATCH] Remove asset/document hydration pipeline and introduce client-side cache updates for correspondents, tags, and search results --- frontend/src/app/useDocumentPreview.ts | 7 - frontend/src/app/useDocumentsSearch.ts | 8 + frontend/src/asset_manager.ts | 123 --------------- frontend/src/detail/useDetailWorkspace.ts | 4 - frontend/src/documents/DocumentsManager.ts | 33 +--- .../documents/hooks/useBulkDocumentActions.ts | 101 +++++++----- .../src/documents/panel/DocumentsPanel.tsx | 26 +++- .../useDocumentCorrespondentActions.ts | 42 +++-- .../hooks/documents/useDocumentMutations.ts | 144 ++++++++++++------ .../src/hooks/documents/useDocumentTagging.ts | 87 ++++++++--- frontend/src/hooks/documents/useDocuments.ts | 12 +- .../hooks/documents/useDocumentsWorkspace.ts | 32 ++-- frontend/src/hooks/documents/useFolderTree.ts | 18 +-- frontend/src/preview/DocumentViewerPanel.tsx | 8 - 14 files changed, 316 insertions(+), 329 deletions(-) diff --git a/frontend/src/app/useDocumentPreview.ts b/frontend/src/app/useDocumentPreview.ts index 8477a3e..aa09d21 100644 --- a/frontend/src/app/useDocumentPreview.ts +++ b/frontend/src/app/useDocumentPreview.ts @@ -23,11 +23,6 @@ type DocumentLink = { expiresAt?: number; }; -interface AssetManagerLike { - hydrateDetail: (payload: unknown) => { document?: DocumentLike } | null; - hydrateDocument: (payload: unknown) => DocumentLike | null; -} - interface ApiClient { get: (path: string) => Promise<{ data: T }>; } @@ -44,7 +39,6 @@ interface UseDocumentPreviewArgs { ingest: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean }; }; selectedFolder?: FolderId | null; - assetManager: AssetManagerLike; api: ApiClient; resolveApiPath?: (path: string) => string; notifyApiError: (error: unknown, message: string) => void; @@ -72,7 +66,6 @@ const useDocumentPreview = ({ routeDocumentId, documentsManager, selectedFolder, - assetManager: _assetManager, api, resolveApiPath, notifyApiError, diff --git a/frontend/src/app/useDocumentsSearch.ts b/frontend/src/app/useDocumentsSearch.ts index 5dbbff4..9329b99 100644 --- a/frontend/src/app/useDocumentsSearch.ts +++ b/frontend/src/app/useDocumentsSearch.ts @@ -45,6 +45,7 @@ interface UseDocumentsSearchResult { clearFilters: () => void; handleSearchChange: (value: string) => void; handleSearchSubmit: () => void; + refetchSearchResults: () => void; documentsFilterValue: { query: string; searchResultIds: Identifier[] | null; @@ -82,6 +83,7 @@ const useDocumentsSearch = ({ 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; @@ -171,6 +173,10 @@ const useDocumentsSearch = ({ ], ); + const refetchSearchResults = useCallback(() => { + setSearchTrigger(Date.now()); + }, []); + useEffect(() => { if (!token) return undefined; @@ -269,6 +275,7 @@ const useDocumentsSearch = ({ notifyApiError, setLoading, documentsManager, + searchTrigger, ]); return { @@ -288,6 +295,7 @@ const useDocumentsSearch = ({ clearFilters, handleSearchChange, handleSearchSubmit, + refetchSearchResults, documentsFilterValue, }; }; diff --git a/frontend/src/asset_manager.ts b/frontend/src/asset_manager.ts index 7692cc7..6b5a5e6 100644 --- a/frontend/src/asset_manager.ts +++ b/frontend/src/asset_manager.ts @@ -259,129 +259,6 @@ class AssetManager { } } - hydrateAsset(asset?: Nullable): Nullable { - if (!asset || !asset.id) { - return asset; - } - const cached = this.assetCache.get(asset.id); - if (!cached) { - const normalized = mergeAssetObjects(null, asset.objects); - if (normalized.length) { - return { ...asset, objects: normalized }; - } - return asset; - } - const merged = { ...cached, ...asset }; - if (cached.url && !asset.url) { - merged.url = cached.url; - } - if (cached.expiresAt) { - const cachedExpires = Number(cached.expiresAt) || null; - const assetExpires = Number(asset.expiresAt) || null; - if (!assetExpires || (cachedExpires && cachedExpires > assetExpires)) { - merged.expiresAt = cachedExpires; - } - } - const mergedObjects = mergeAssetObjects(cached.objects, asset.objects); - if (mergedObjects.length) { - merged.objects = mergedObjects; - } - return merged; - } - - hydrateDocument(document?: Nullable): Nullable { - if (!document) { - return document; - } - - const currentVersion = document.current_version || null; - if (!currentVersion) { - return document; - } - - let changed = false; - let nextAssets = currentVersion.assets; - - if (nextAssets && !Array.isArray(nextAssets)) { - const hydrated = {}; - Object.keys(nextAssets).forEach((key) => { - hydrated[key] = this.hydrateAsset(nextAssets[key]); - if (hydrated[key] !== nextAssets[key]) { - changed = true; - } - }); - if (changed) { - nextAssets = { ...nextAssets, ...hydrated }; - } - } else if (Array.isArray(nextAssets)) { - const hydratedList = nextAssets.map((item) => this.hydrateAsset(item)); - if ( - hydratedList.length !== nextAssets.length || - hydratedList.some((item, index) => item !== nextAssets[index]) - ) { - changed = true; - nextAssets = hydratedList; - } - } - - if (!changed) { - return document; - } - - const nextCurrentVersion: DocumentVersionLike = { ...currentVersion, assets: nextAssets }; - return { ...document, current_version: nextCurrentVersion }; - } - - hydrateDocuments(documents?: DocumentLike[] | null) { - if (!Array.isArray(documents)) { - return documents ?? []; - } - return documents.map((doc) => this.hydrateDocument(doc)); - } - - hydrateDetail(detail?: { document?: DocumentLike; assets?: AssetLike[] } | null) { - if (!detail) { - return detail; - } - let changed = false; - const next = { ...detail }; - - if (detail.document) { - const hydratedDocument = this.hydrateDocument(detail.document); - if (hydratedDocument !== detail.document) { - next.document = hydratedDocument; - changed = true; - } - } - - if (Array.isArray(detail.assets)) { - const hydratedAssets = detail.assets.map((item) => this.hydrateAsset(item)); - if ( - hydratedAssets.length !== detail.assets.length || - hydratedAssets.some((item, index) => item !== detail.assets[index]) - ) { - next.assets = hydratedAssets; - changed = true; - } - } - - return changed ? next : detail; - } - - hydrateFolderContents(contents?: { documents?: DocumentLike[]; document?: DocumentLike } | null) { - if (!contents) { - return contents; - } - const next = { ...contents }; - if (Array.isArray(contents.documents)) { - next.documents = this.hydrateDocuments(contents.documents); - } - if (contents.document) { - next.document = this.hydrateDocument(contents.document); - } - return next; - } - ensureAsset( documentId?: Identifier | null, asset?: Nullable, diff --git a/frontend/src/detail/useDetailWorkspace.ts b/frontend/src/detail/useDetailWorkspace.ts index a2be9d3..115684c 100644 --- a/frontend/src/detail/useDetailWorkspace.ts +++ b/frontend/src/detail/useDetailWorkspace.ts @@ -49,7 +49,6 @@ interface UseDetailWorkspaceArgs { handleTagRemove?: (...args: unknown[]) => void; ensureAssetUrl?: EnsureAssetUrl; getDocumentAsset?: GetDocumentAsset; - ensurePreviewData?: (docId: Identifier, options?: Record) => Promise; correspondents?: unknown[]; handleCorrespondentAdd?: (...args: unknown[]) => void; handleCorrespondentRemove?: (...args: unknown[]) => void; @@ -92,7 +91,6 @@ const useDetailWorkspace = ({ handleTagRemove, ensureAssetUrl, getDocumentAsset, - ensurePreviewData, correspondents, handleCorrespondentAdd, handleCorrespondentRemove, @@ -294,7 +292,6 @@ const useDetailWorkspace = ({ onUpdateIssued: handleDocumentIssuedUpdate, ensureAssetUrl, getDocumentAsset, - hydrateDocument: ensurePreviewData, correspondents, onCorrespondentAdd: handleCorrespondentAdd, onCorrespondentRemove: handleCorrespondentRemove, @@ -308,7 +305,6 @@ const useDetailWorkspace = ({ correspondents, detailPanelDocument, ensureAssetUrl, - ensurePreviewData, getDocumentAsset, handleCorrespondentAdd, handleCorrespondentRemove, diff --git a/frontend/src/documents/DocumentsManager.ts b/frontend/src/documents/DocumentsManager.ts index c1c8e8a..dba5090 100644 --- a/frontend/src/documents/DocumentsManager.ts +++ b/frontend/src/documents/DocumentsManager.ts @@ -5,37 +5,21 @@ type DocumentId = string | number; export type ManagedDocument = { id?: DocumentId | null } & Record; type FetchDocument = (id: DocumentId) => Promise; -type HydrateDocument = (payload: unknown) => T | null; -type HydrateDocuments = (payload: unknown[]) => T[]; class DocumentsManager { private byId: Map; private fetcher?: FetchDocument; - private hydrateDocument?: HydrateDocument; - - private hydrateDocuments?: HydrateDocuments; - - private extractDocument?: HydrateDocument; - private inflight: Map>; private listeners: Set<() => void>; constructor( fetchDocument?: FetchDocument, - options?: { - hydrateDocument?: HydrateDocument; - hydrateDocuments?: HydrateDocuments; - extractDocument?: HydrateDocument; - }, ) { this.byId = new Map(); this.fetcher = fetchDocument; - this.hydrateDocument = options?.hydrateDocument; - this.hydrateDocuments = options?.hydrateDocuments; - this.extractDocument = options?.extractDocument; this.inflight = new Map(); this.listeners = new Set(); } @@ -54,19 +38,7 @@ class DocumentsManager { } ingest(rawDocs: unknown[] = []): { canonical: T[]; changed: boolean } { - const normalize = (docs: unknown[]) => { - if (this.hydrateDocuments) { - return this.hydrateDocuments(docs).filter(Boolean) as T[]; - } - if (this.hydrateDocument) { - return docs - .map((doc) => this.hydrateDocument ? this.hydrateDocument(doc) : (doc as T | null)) - .filter(Boolean) as T[]; - } - return docs.filter(Boolean) as T[]; - }; - - const docs = normalize(rawDocs); + const docs = rawDocs.map((doc) => doc as T).filter(Boolean); let changed = false; let nextById = this.byId; const canonical: T[] = []; @@ -124,8 +96,7 @@ class DocumentsManager { const request = (async () => { try { const fetched = await fetcher(id); - const extracted = this.extractDocument ? this.extractDocument(fetched) : fetched; - const { canonical } = this.ingest([extracted as unknown]); + const { canonical } = this.ingest([fetched as unknown]); return canonical[0] ?? null; } finally { this.inflight.delete(id); diff --git a/frontend/src/documents/hooks/useBulkDocumentActions.ts b/frontend/src/documents/hooks/useBulkDocumentActions.ts index bff4247..47d436a 100644 --- a/frontend/src/documents/hooks/useBulkDocumentActions.ts +++ b/frontend/src/documents/hooks/useBulkDocumentActions.ts @@ -21,7 +21,6 @@ interface UseBulkDocumentActionsArgs { resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[]; correspondentLookupByName: Map; handleCorrespondentCreate: (payload: { name: string }) => Promise<{ id?: Identifier } | null>; - refreshCurrentFolder: () => Promise | void; setStatusMessage: (message: string, variant?: string) => void; selectedDocumentIds?: Identifier[]; selectedFolderIds?: Identifier[]; @@ -29,6 +28,7 @@ interface UseBulkDocumentActionsArgs { handleFolderDelete: (id: Identifier, options?: { showMessage?: boolean; manageLoading?: boolean }) => Promise; clearDocumentSelection: () => void; setLoading: (value: boolean) => void; + updateDocumentCaches?: (id: Identifier, updater: (doc: any) => any) => void; } const useBulkDocumentActions = ({ @@ -36,7 +36,6 @@ const useBulkDocumentActions = ({ resolveTargetDocumentIds, correspondentLookupByName, handleCorrespondentCreate, - refreshCurrentFolder, setStatusMessage, selectedDocumentIds, selectedFolderIds, @@ -44,6 +43,7 @@ const useBulkDocumentActions = ({ handleFolderDelete, clearDocumentSelection, setLoading, + updateDocumentCaches, }: UseBulkDocumentActionsArgs) => { const handleBulkCorrespondentAdd = useCallback( async ({ name, input, documentIds }: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => { @@ -70,21 +70,35 @@ const useBulkDocumentActions = ({ if (!target?.id) { setStatusMessage('Unable to resolve correspondent.', 'error'); return; - } + } - const response = await api.post('/documents/bulk/correspondents', { - document_ids: targets, - assignments: [ - { - correspondent_id: target.id, - }, - ], - action: 'add', + const response = await api.post('/documents/bulk/correspondents', { + document_ids: targets, + assignments: [ + { + correspondent_id: target.id, + }, + ], + action: 'add', + }); + + const { assigned = 0, removed = 0 } = 'data' in response ? response.data : 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 }], + }; + }); }); - - const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response; - - await refreshCurrentFolder(); + } const assignedSuffix = assigned === 1 ? '' : 's'; if (removed > 0) { const removedSuffix = removed === 1 ? '' : 's'; @@ -99,19 +113,19 @@ const useBulkDocumentActions = ({ ); } - if (input) { - input.value = ''; - } - }, - [ - api, - correspondentLookupByName, - handleCorrespondentCreate, - refreshCurrentFolder, - resolveTargetDocumentIds, - setStatusMessage, - ], - ); + if (input) { + input.value = ''; + } + }, + [ + api, + correspondentLookupByName, + handleCorrespondentCreate, + resolveTargetDocumentIds, + setStatusMessage, + updateDocumentCaches, + ], +); const handleBulkCorrespondentRemove = useCallback( async ({ assignments = [], documentIds }: { assignments?: CorrespondentAssignment[]; documentIds?: Identifier[] }) => { @@ -131,14 +145,29 @@ const useBulkDocumentActions = ({ correspondent_id: entry.correspondent_id, })); - const response = await api.post('/documents/bulk/correspondents', { - document_ids: targets, - assignments: normalizedAssignments, - action: 'remove', - }); + const response = await api.post('/documents/bulk/correspondents', { + document_ids: targets, + assignments: normalizedAssignments, + action: 'remove', + }); - const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response; - await refreshCurrentFolder(); + const { assigned = 0, removed = 0 } = 'data' in response ? response.data : 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 }; + }); + }); + } if (removed > 0) { const removedSuffix = removed === 1 ? '' : 's'; @@ -153,7 +182,7 @@ const useBulkDocumentActions = ({ setStatusMessage('No correspondents changed.', 'info'); } }, - [api, refreshCurrentFolder, resolveTargetDocumentIds, setStatusMessage], + [api, resolveTargetDocumentIds, setStatusMessage, updateDocumentCaches], ); const handleDeleteSelection = useCallback(async () => { diff --git a/frontend/src/documents/panel/DocumentsPanel.tsx b/frontend/src/documents/panel/DocumentsPanel.tsx index 7a172a4..ff57976 100644 --- a/frontend/src/documents/panel/DocumentsPanel.tsx +++ b/frontend/src/documents/panel/DocumentsPanel.tsx @@ -518,11 +518,33 @@ const DocumentsPanelInner: React.FC = ({ ); const gridIconSize = DEFAULT_GRID_ICON_SIZE; - useEffect(() => { + + const scrollToTop = useCallback(() => { if (scrollRef.current) { scrollRef.current.scrollTop = 0; } - }, [viewMode]); + }, []); + + const searchKey = useMemo( + () => (Array.isArray(searchResultIds) ? searchResultIds.join(':') : 'none'), + [searchResultIds], + ); + + const breadcrumbKey = useMemo( + () => (Array.isArray(breadcrumbs) ? breadcrumbs.map((crumb) => crumb?.id ?? '').join(':') : 'none'), + [breadcrumbs], + ); + + useEffect(() => { + scrollToTop(); + }, [ + scrollToTop, + viewMode, + showingSearchResults, + searchKey, + breadcrumbKey, + ]); + const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []); const ensureFocusedRowVisible = useCallback(() => { if (!focusedRowKey) return; diff --git a/frontend/src/hooks/documents/useDocumentCorrespondentActions.ts b/frontend/src/hooks/documents/useDocumentCorrespondentActions.ts index 59873ee..28ba59c 100644 --- a/frontend/src/hooks/documents/useDocumentCorrespondentActions.ts +++ b/frontend/src/hooks/documents/useDocumentCorrespondentActions.ts @@ -18,18 +18,21 @@ interface UseDocumentCorrespondentActionsArgs { apiClient: ApiClient; correspondents: CorrespondentOption[]; handleCorrespondentCreate: (payload: { name: string }) => Promise; - refreshCurrentFolder: () => Promise; notifyApiError: (error: unknown, fallback: string) => void; setStatusMessage: (message: string, variant?: string) => void; + updateDocumentCaches?: ( + id: Identifier, + updater: (doc: { correspondents?: CorrespondentOption[] } | null) => { correspondents?: CorrespondentOption[] } | null, + ) => void; } const useDocumentCorrespondentActions = ({ apiClient, correspondents, handleCorrespondentCreate, - refreshCurrentFolder, notifyApiError, setStatusMessage, + updateDocumentCaches, }: UseDocumentCorrespondentActionsArgs) => { const correspondentLookupByName = useMemo(() => { const map = new Map(); @@ -44,7 +47,7 @@ const useDocumentCorrespondentActions = ({ const handleDocumentCorrespondentAttach = useCallback( async ( { documentId, correspondentId }: { documentId: Identifier; correspondentId: Identifier }, - { notify = true, refresh = true }: { notify?: boolean; refresh?: boolean } = {}, + { notify = true }: { notify?: boolean } = {}, ) => { if (documentId == null || correspondentId == null) { throw new Error('Missing document or correspondent.'); @@ -54,8 +57,21 @@ const useDocumentCorrespondentActions = ({ assignments: [{ correspondent_id: correspondentId }], replace: false, }); - if (refresh) { - await refreshCurrentFolder(); + if (updateDocumentCaches) { + const 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 = correspondent + ? { id: correspondent.id, name: correspondent.name } + : { id: correspondentId }; + return { ...doc, correspondents: [...current, nextEntry] }; + }); } if (notify) { setStatusMessage('Correspondent assigned.', 'success'); @@ -67,21 +83,27 @@ const useDocumentCorrespondentActions = ({ throw new Error(message); } }, - [apiClient, refreshCurrentFolder, notifyApiError, setStatusMessage], + [apiClient, correspondents, notifyApiError, setStatusMessage, updateDocumentCaches], ); const handleCorrespondentRemove = useCallback( async ( { documentId, correspondentId }: { documentId: Identifier; correspondentId: Identifier }, - { notify = true, refresh = true }: { notify?: boolean; refresh?: boolean } = {}, + { notify = true }: { notify?: boolean } = {}, ) => { if (documentId == null || correspondentId == null) { throw new Error('Missing document or correspondent.'); } try { await apiClient.delete(`/documents/${documentId}/correspondents/${correspondentId}`); - if (refresh) { - await refreshCurrentFolder(); + 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 }; + }); } if (notify) { setStatusMessage('Correspondent removed.', 'success'); @@ -93,7 +115,7 @@ const useDocumentCorrespondentActions = ({ throw new Error(message); } }, - [apiClient, refreshCurrentFolder, notifyApiError, setStatusMessage], + [apiClient, notifyApiError, setStatusMessage, updateDocumentCaches], ); const normalizeOption = ( diff --git a/frontend/src/hooks/documents/useDocumentMutations.ts b/frontend/src/hooks/documents/useDocumentMutations.ts index a82bd4d..112894c 100644 --- a/frontend/src/hooks/documents/useDocumentMutations.ts +++ b/frontend/src/hooks/documents/useDocumentMutations.ts @@ -134,6 +134,7 @@ interface UseDocumentMutationsArgs { closeDocumentPreview: CloseDocumentPreview; previewDocumentId?: DocumentId | null; refreshCurrentFolder: () => Promise; + refreshVisibleDocuments: () => Promise; documentsViewMode?: string; updateDocumentCaches: UpdateDocumentCaches; tagLookupById: Map; @@ -141,6 +142,7 @@ interface UseDocumentMutationsArgs { refreshTags: () => Promise; tagManager: TagManager; extractDocumentFromResponse?: (payload: unknown) => DocumentLike | null; + ingestDocuments?: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean }; } interface UseDocumentMutationsResult { @@ -210,6 +212,7 @@ const useDocumentMutations = ({ closeDocumentPreview, previewDocumentId, refreshCurrentFolder, + refreshVisibleDocuments, documentsViewMode, updateDocumentCaches, tagLookupById, @@ -217,6 +220,7 @@ const useDocumentMutations = ({ refreshTags, tagManager, extractDocumentFromResponse, + ingestDocuments, }: UseDocumentMutationsArgs): UseDocumentMutationsResult => { const moveDocumentsToFolder = useCallback( async (documentIds: Array, targetFolderId?: NullableFolderId) => { @@ -492,12 +496,16 @@ const useDocumentMutations = ({ const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed }); const updatedDocument = extractDocumentFromResponse?.(data); - updateDocumentCaches(documentId, (doc) => { - if (updatedDocument) { - return { ...doc, ...updatedDocument }; - } - return { ...doc, title: trimmed }; - }); + if (updatedDocument && ingestDocuments) { + ingestDocuments([updatedDocument]); + } else { + updateDocumentCaches(documentId, (doc) => { + if (updatedDocument) { + return { ...doc, ...updatedDocument }; + } + return { ...doc, title: trimmed }; + }); + } setStatusMessage('Document title updated.', 'success'); return true; @@ -509,7 +517,15 @@ const useDocumentMutations = ({ setLoading(false); } }, - [api, extractDocumentFromResponse, notifyApiError, setLoading, setStatusMessage, updateDocumentCaches], + [ + api, + extractDocumentFromResponse, + ingestDocuments, + notifyApiError, + setLoading, + setStatusMessage, + updateDocumentCaches, + ], ); const handleDocumentIssuedUpdate = useCallback( @@ -520,12 +536,16 @@ const useDocumentMutations = ({ const { data } = await api.patch(`/documents/${documentId}`, payload); const updatedDocument = extractDocumentFromResponse?.(data); - updateDocumentCaches(documentId, (doc) => { - if (updatedDocument) { - return { ...doc, ...updatedDocument }; - } - return { ...doc, issued_at: payload.issued_at }; - }); + if (updatedDocument && ingestDocuments) { + ingestDocuments([updatedDocument]); + } else { + updateDocumentCaches(documentId, (doc) => { + if (updatedDocument) { + return { ...doc, ...updatedDocument }; + } + return { ...doc, issued_at: payload.issued_at }; + }); + } const message = payload.issued_at ? 'Issued date updated.' : 'Issued date cleared.'; setStatusMessage(message, 'success'); @@ -538,7 +558,56 @@ const useDocumentMutations = ({ setLoading(false); } }, - [api, extractDocumentFromResponse, notifyApiError, setLoading, setStatusMessage, updateDocumentCaches], + [ + api, + extractDocumentFromResponse, + ingestDocuments, + notifyApiError, + setLoading, + setStatusMessage, + updateDocumentCaches, + ], + ); + + const attachTagToDocument = useCallback( + async ({ + documentId, + tag, + }: { + documentId?: DocumentId; + tag?: Tag | null; + }) => { + if (!documentId || !tag?.id) { + return false; + } + + const cachedTag: Tag = { + id: tag.id, + label: tag.label, + color: Object.prototype.hasOwnProperty.call(tag, 'color') ? tag.color ?? null : null, + }; + + try { + await api.post(`/documents/${documentId}/tags`, { tag_ids: [cachedTag.id] }); + updateDocumentCaches(documentId, (doc) => { + if (!doc) { + return doc; + } + const currentTags = Array.isArray(doc.tags) ? doc.tags : []; + if (currentTags.some((entry) => entry?.id === cachedTag.id)) { + return doc; + } + return { ...doc, tags: [...currentTags, cachedTag] }; + }); + setStatusMessage('Tag assigned.', 'success'); + return true; + } catch (error) { + const message = (error as Record)?.response?.data?.error || 'Failed to assign tag.'; + notifyApiError(error, message); + return false; + } + }, + [api, notifyApiError, setStatusMessage, updateDocumentCaches], ); const handleDocumentTagAdd = useCallback( @@ -561,17 +630,18 @@ const useDocumentMutations = ({ tag = data as Tag; await refreshTags(); } - await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] }); - setStatusMessage('Tag assigned.', 'success'); + await attachTagToDocument({ + documentId: document.id as DocumentId, + tag, + }); if (input && Object(input) === input && 'value' in (input as Record)) { (input as { value?: string }).value = ''; } - await refreshCurrentFolder(); } catch (error) { notifyApiError(error, 'Failed to assign tag.'); } }, - [api, tags, refreshTags, refreshCurrentFolder, notifyApiError, setStatusMessage, tagManager], + [api, tags, refreshTags, attachTagToDocument, notifyApiError, setStatusMessage, tagManager], ); const handleDocumentTagAttach = useCallback( @@ -597,40 +667,14 @@ const useDocumentMutations = ({ }; }; - try { - await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] }); - updateDocumentCaches(documentId, (doc) => { - if (!doc) { - return doc; - } - const currentTags = Array.isArray(doc.tags) ? doc.tags : []; - if (currentTags.some((existing) => existing?.id === tagId)) { - return doc; - } - const resolvedTag = resolveTagForCache(); - if (!resolvedTag) { - return doc; - } - return { ...doc, tags: [...currentTags, resolvedTag] }; - }); - setStatusMessage('Tag assigned.', 'success'); - if (documentsViewMode !== 'desk') { - await refreshCurrentFolder(); - } - return true; - } catch (error) { - const message = (error as Record)?.response?.data?.error || 'Failed to assign tag.'; - notifyApiError(error, message); - return false; - } + const resolvedTag = resolveTagForCache(); + return attachTagToDocument({ + documentId, + tag: resolvedTag, + }); }, [ - api, - refreshCurrentFolder, - documentsViewMode, - notifyApiError, - setStatusMessage, - updateDocumentCaches, + attachTagToDocument, tagLookupById, ], ); diff --git a/frontend/src/hooks/documents/useDocumentTagging.ts b/frontend/src/hooks/documents/useDocumentTagging.ts index 929ec1a..2f2a4f6 100644 --- a/frontend/src/hooks/documents/useDocumentTagging.ts +++ b/frontend/src/hooks/documents/useDocumentTagging.ts @@ -21,11 +21,11 @@ interface UseDocumentTaggingArgs { tags: TagRecord[]; tagManager: TagManager; refreshTags: () => Promise | void; - refreshCurrentFolder: () => Promise | void; resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[]; notifyApiError: (error: unknown, message: string) => void; setStatusMessage: (message: string, variant?: string) => void; setLoading: (state: boolean) => void; + updateDocumentCaches?: (id: Identifier, updater: (doc: TagRecord | null) => TagRecord | null) => void; } interface BulkTagOperationArgs { @@ -47,11 +47,11 @@ const useDocumentTagging = ({ tags, tagManager, refreshTags, - refreshCurrentFolder, resolveTargetDocumentIds, notifyApiError, setStatusMessage, setLoading, + updateDocumentCaches, }: UseDocumentTaggingArgs) => { const bulkTagOperation = useCallback( async ({ labels, action, documentIds }: BulkTagOperationArgs): Promise => { @@ -82,22 +82,59 @@ const useDocumentTagging = ({ setLoading(true); try { - if (action === 'add') { - const createdIds: Identifier[] = []; - for (const label of normalized) { - let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null; - if (!tag) { - const payload = tagManager.buildPayload({ label }); - const response = await apiClient.post<{ id?: Identifier; label: string }>('/tags', payload); - tag = 'data' in response ? response.data : response; - await refreshTags(); - } - createdIds.push(tag.id); + if (action === 'add') { + const createdIds: Identifier[] = []; + const createdTags: TagRecord[] = []; + for (const label of normalized) { + let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null; + if (!tag) { + const payload = tagManager.buildPayload({ label }); + const response = await apiClient.post<{ id?: Identifier; label: string }>('/tags', payload); + tag = 'data' in response ? response.data : response; + await refreshTags(); } - tagIds = Array.from(new Set(createdIds)); + createdIds.push(tag.id); + createdTags.push(tag); } + tagIds = Array.from(new Set(createdIds)); - tagIds = Array.from(new Set(tagIds)); + 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) => { + tagIds.forEach((tagId) => { + const cachedTag = tagById.get(tagId); + if (!cachedTag) { + 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 }], + }; + }); + }); + }); + } + } + + tagIds = Array.from(new Set(tagIds)); if (!tagIds.length) { return { ok: false, reason: 'no-tags' }; @@ -109,7 +146,23 @@ const useDocumentTagging = ({ action, }); - await refreshCurrentFolder(); + 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; + }); + }); + }); + } return { ok: true, @@ -130,11 +183,11 @@ const useDocumentTagging = ({ resolveTargetDocumentIds, tags, refreshTags, - refreshCurrentFolder, notifyApiError, setLoading, tagManager, apiClient, + updateDocumentCaches, ], ); diff --git a/frontend/src/hooks/documents/useDocuments.ts b/frontend/src/hooks/documents/useDocuments.ts index 412243d..8b82955 100644 --- a/frontend/src/hooks/documents/useDocuments.ts +++ b/frontend/src/hooks/documents/useDocuments.ts @@ -23,24 +23,14 @@ interface FolderContentsEntry { interface UseDocumentsOptions { setFolderContents: Dispatch>>; fetchDocumentById?: (id: DocumentId) => Promise; - hydrateDocument?: (payload: unknown) => DocumentLike | null; - hydrateDocuments?: (payload: unknown[]) => DocumentLike[]; - extractDocument?: (payload: unknown) => DocumentLike | null; } const useDocuments = ({ setFolderContents, fetchDocumentById, - hydrateDocument, - hydrateDocuments, - extractDocument, }: UseDocumentsOptions) => { const managerRef = useRef( - new DocumentsManager(fetchDocumentById, { - hydrateDocument, - hydrateDocuments, - extractDocument, - }), + new DocumentsManager(fetchDocumentById), ); const [documents, setDocumentsState] = useState([]); diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.ts b/frontend/src/hooks/documents/useDocumentsWorkspace.ts index d05aa12..8a0637f 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.ts +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.ts @@ -318,9 +318,6 @@ const useDocumentsWorkspace = ({ } = useDocuments({ setFolderContents, fetchDocumentById, - hydrateDocument: (payload) => assetManager.hydrateDocument(payload), - hydrateDocuments: (payload) => assetManager.hydrateDocuments(payload), - extractDocument: extractDocumentFromResponse, }); const documentLookup = useSyncExternalStore( @@ -348,7 +345,6 @@ const useDocumentsWorkspace = ({ isInvalidFolderDrop, } = useFolderTree({ initialSelectedFolder: routeFolderId || 'root', - assetManager, apiClient: api, tenantIdRef, documentsSortFieldRef: activeSortFieldRef, @@ -370,6 +366,7 @@ const useDocumentsWorkspace = ({ activeCorrespondentFilters, setActiveCorrespondentFilters, isFilterActive, + refetchSearchResults, documentsFilterValue, } = useDocumentsSearch({ api, @@ -444,7 +441,6 @@ const useDocumentsWorkspace = ({ const { documentLinks, - ensurePreviewData, ensureDownloadUrl, openDocumentPreview, closeDocumentPreview, @@ -454,7 +450,6 @@ const useDocumentsWorkspace = ({ routeDocumentId: previewDocumentId, documentsManager, selectedFolder, - assetManager, api, resolveApiPath, notifyApiError, @@ -607,6 +602,14 @@ const useDocumentsWorkspace = ({ } }, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]); + const refreshVisibleDocuments = useCallback(async () => { + if (showingSearchResults) { + await refetchSearchResults(); + return; + } + await refreshCurrentFolder(); + }, [showingSearchResults, refetchSearchResults, refreshCurrentFolder]); + const { handleBulkTagAddFromDetail, handleBulkTagRemoveFromDetail, @@ -616,11 +619,11 @@ const useDocumentsWorkspace = ({ tags, tagManager, refreshTags, - refreshCurrentFolder, resolveTargetDocumentIds, notifyApiError, setStatusMessage, setLoading, + updateDocumentCaches, }); const { @@ -671,9 +674,9 @@ const useDocumentsWorkspace = ({ apiClient: api, correspondents, handleCorrespondentCreate, - refreshCurrentFolder, notifyApiError, setStatusMessage, + updateDocumentCaches, }); useEffect(() => { @@ -851,6 +854,7 @@ const useDocumentsWorkspace = ({ closeDocumentPreview, previewDocumentId, refreshCurrentFolder, + refreshVisibleDocuments, documentsViewMode, updateDocumentCaches, tagLookupById, @@ -858,6 +862,7 @@ const useDocumentsWorkspace = ({ refreshTags, tagManager, extractDocumentFromResponse, + ingestDocuments: (docs) => documentsManager.ingest(docs), }); const { @@ -1007,7 +1012,6 @@ const useDocumentsWorkspace = ({ resolveTargetDocumentIds, correspondentLookupByName, handleCorrespondentCreate, - refreshCurrentFolder, setStatusMessage, selectedDocumentIds, selectedFolderIds, @@ -1015,6 +1019,7 @@ const useDocumentsWorkspace = ({ handleFolderDelete, clearDocumentSelection, setLoading, + updateDocumentCaches, }); @@ -1036,9 +1041,7 @@ const useDocumentsWorkspace = ({ return null; } - setDocuments((prev) => - prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc)), - ); + updateDocumentCaches(documentId, (doc) => mergeAssetIntoDocument(doc, entry)); return entry; } catch (error) { @@ -1046,7 +1049,7 @@ const useDocumentsWorkspace = ({ throw error; } }, - [assetManager, setDocuments, notifyApiError], + [assetManager, updateDocumentCaches, notifyApiError], ); @@ -1264,7 +1267,6 @@ const useDocumentsWorkspace = ({ handleTagRemove, ensureAssetUrl, getDocumentAsset, - ensurePreviewData, correspondents, handleCorrespondentAdd, handleCorrespondentRemove, @@ -1617,7 +1619,6 @@ const useDocumentsWorkspace = ({ documentsTableProps, detailPanelProps, documentsViewMode, - ensurePreviewData, ensureAssetUrl, resolveFolderPath, getDocumentAsset, @@ -1669,7 +1670,6 @@ const useDocumentsWorkspace = ({ documentsTableProps, detailPanelProps, documentsViewMode, - ensurePreviewData, ensureAssetUrl, resolveFolderPath, getDocumentAsset, diff --git a/frontend/src/hooks/documents/useFolderTree.ts b/frontend/src/hooks/documents/useFolderTree.ts index e05a9df..5bf9980 100644 --- a/frontend/src/hooks/documents/useFolderTree.ts +++ b/frontend/src/hooks/documents/useFolderTree.ts @@ -48,11 +48,6 @@ interface FolderTreeNode extends FolderSummary { hasChildren?: boolean; } -interface AssetManagerLike { - hydrateDocuments: (docs: DocumentLike[]) => DocumentLike[]; - hydrateFolderContents: (payload: FolderContentsEntry) => FolderContentsEntry; -} - interface ApiClient { get(path: string, config?: { params?: Record }): Promise<{ data: T }>; } @@ -68,7 +63,6 @@ interface SelectionHelpers { interface UseFolderTreeOptions { initialSelectedFolder?: FolderId; - assetManager: AssetManagerLike; apiClient: ApiClient; tenantIdRef: MutableRefObject; documentsSortFieldRef: MutableRefObject; @@ -86,7 +80,6 @@ interface FolderOption { const useFolderTree = ({ initialSelectedFolder = 'root', - assetManager, apiClient, tenantIdRef, documentsSortFieldRef, @@ -117,7 +110,7 @@ const useFolderTree = ({ const applySelectedFolder = useCallback( (folderId: FolderId, contents?: FolderContentsEntry | null) => { const subfolders = Array.isArray(contents?.subfolders) ? contents.subfolders : []; - const docs = assetManager.hydrateDocuments(contents?.documents ?? []); + const docs = Array.isArray(contents?.documents) ? contents.documents : []; const folderInfo = contents?.folder ?? null; setCurrentSubfolders(subfolders); @@ -166,7 +159,6 @@ const useFolderTree = ({ setSelectionOrder(mergedSelection); }, [ - assetManager, focusedDocumentId, selectionAnchorRef, selectionOrderRef, @@ -258,14 +250,13 @@ const useFolderTree = ({ } const requestConfig = Object.keys(params).length ? { params } : {}; const { data } = await apiClient.get(`/folders/${path}/contents`, requestConfig); - const hydrated = assetManager.hydrateFolderContents(data); const childFolders = Array.isArray(data.subfolders) ? data.subfolders : []; const childIds = childFolders .map((child) => (child?.id ?? null) as FolderId | null) .filter((id): id is FolderId => Boolean(id)); const enriched = { - ...hydrated, + ...data, __includesDocuments: includeDocuments, __sortField: includeDocuments ? sortField : cachedSortField, __sortDirection: includeDocuments ? sortDirection : cachedSortDirection, @@ -353,10 +344,10 @@ const useFolderTree = ({ if (existingEntry) { next.set(folderId, { ...existingEntry, - ...hydrated, + ...data, documents: existingEntry.__includesDocuments ? existingEntry.documents - : hydrated.documents, + : data.documents, __includesDocuments: existingEntry.__includesDocuments || false, __sortField: existingEntry.__sortField ?? enriched.__sortField, __sortDirection: existingEntry.__sortDirection ?? enriched.__sortDirection, @@ -372,7 +363,6 @@ const useFolderTree = ({ }, [ apiClient, - assetManager, documentsSortDirectionRef, documentsSortFieldRef, tenantIdRef, diff --git a/frontend/src/preview/DocumentViewerPanel.tsx b/frontend/src/preview/DocumentViewerPanel.tsx index 61fe4e3..c14cba8 100644 --- a/frontend/src/preview/DocumentViewerPanel.tsx +++ b/frontend/src/preview/DocumentViewerPanel.tsx @@ -61,7 +61,6 @@ interface DocumentViewerPanelProps extends DocumentSummarySectionProps { contentType?: string | null; filename?: string | null; } | null; - hydrateDocument?: (doc: DocumentLike | null) => DocumentLike | null; ensureAssetUrl?: (docId: string | number, asset: AssetLike, options?: { start?: number; limit?: number }) => Promise; getDocumentAsset?: (doc: DocumentLike | null, type: string) => AssetLike | null; ensurePreviewData?: (docId: string | number, options?: { signal?: AbortSignal }) => Promise; @@ -124,7 +123,6 @@ export const createDocumentViewerHeaderActions = ({ const DocumentViewerPanel: React.FC = ({ document, documentLink, - hydrateDocument, tagLookupById, tagOptions, onTagAdd, @@ -291,12 +289,6 @@ const DocumentViewerPanel: React.FC = ({ setZoomOverlayOpen(false); }, [effectiveDocumentLink?.url, document?.id]); - useEffect(() => { - if (hydrateDocument && document?.id) { - hydrateDocument(document.id); - } - }, [hydrateDocument, document?.id]); - const panelRef = useRef(null); const isStackedLayout = useViewerLayoutMode(panelRef, document?.id);