feat: update frontend document management components.

This commit is contained in:
2025-12-09 13:49:07 +01:00
parent 8a16293f0e
commit ce821c1817
13 changed files with 264 additions and 292 deletions
+30 -23
View File
@@ -202,32 +202,39 @@ class FoldersManager<T extends ManagedFolder = ManagedFolder> {
return this.treePromise; return this.treePromise;
} }
this.treePromise = (async () => { this.treePromise = this.fetchTreeInternal();
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;
}
})();
return this.treePromise; return this.treePromise;
} }
async refreshTree(): Promise<FolderTreeNode[]> {
this.treePromise = this.fetchTreeInternal();
return this.treePromise;
}
private async fetchTreeInternal(): Promise<FolderTreeNode[]> {
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() { invalidateTree() {
this.treeSnapshot = []; this.treeSnapshot = [];
this.treePromise = null; this.treePromise = null;
@@ -13,6 +13,8 @@ type CorrespondentAssignment = {
correspondent_id?: Identifier; correspondent_id?: Identifier;
}; };
import type { DocumentsManagerInterface } from '../types/workspaceTypes';
interface UseBulkDocumentActionsArgs { interface UseBulkDocumentActionsArgs {
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[]; resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
correspondentLookupByName: Map<string, { id?: Identifier }>; correspondentLookupByName: Map<string, { id?: Identifier }>;
@@ -22,7 +24,7 @@ interface UseBulkDocumentActionsArgs {
handleDocumentsDelete: (ids: Identifier[], options?: MessageOptions) => Promise<boolean>; handleDocumentsDelete: (ids: Identifier[], options?: MessageOptions) => Promise<boolean>;
handleFolderDelete: (id: Identifier, options?: MessageOptions) => Promise<boolean>; handleFolderDelete: (id: Identifier, options?: MessageOptions) => Promise<boolean>;
clearDocumentSelection: () => void; clearDocumentSelection: () => void;
updateDocumentCaches?: (id: Identifier, updater: (doc: any) => any) => void; documentsManager: DocumentsManagerInterface;
} }
const useBulkDocumentActions = ({ const useBulkDocumentActions = ({
@@ -34,7 +36,7 @@ const useBulkDocumentActions = ({
handleDocumentsDelete, handleDocumentsDelete,
handleFolderDelete, handleFolderDelete,
clearDocumentSelection, clearDocumentSelection,
updateDocumentCaches, documentsManager,
}: UseBulkDocumentActionsArgs) => { }: UseBulkDocumentActionsArgs) => {
const { showToast } = useStatusToast(); const { showToast } = useStatusToast();
@@ -77,19 +79,22 @@ const useBulkDocumentActions = ({
const { assigned = 0, removed = 0 } = response; const { assigned = 0, removed = 0 } = response;
if (updateDocumentCaches && target.id) { if (target.id) {
targets.forEach((docId) => { const targetSet = new Set(targets);
updateDocumentCaches(docId, (doc) => { let targetId = target.id;
if (!doc) return doc; let targetName = (target as any).name;
const current = Array.isArray((doc as any).correspondents) ? (doc as any).correspondents : [];
if (current.some((entry: any) => entry?.id === target.id)) { documentsManager.map((doc) => {
return doc; if (!targetSet.has(doc.id as Identifier)) return undefined;
}
return { const current = Array.isArray((doc as any).correspondents) ? (doc as any).correspondents : [];
...(doc as any), if (current.some((entry: any) => entry?.id === targetId)) {
correspondents: [...current, { id: target.id, name: (target as any).name }], return doc;
}; }
}); return {
...(doc as any),
correspondents: [...current, { id: targetId, name: targetName }],
};
}); });
} }
const assignedSuffix = assigned === 1 ? '' : 's'; const assignedSuffix = assigned === 1 ? '' : 's';
@@ -115,7 +120,7 @@ const useBulkDocumentActions = ({
handleCorrespondentCreate, handleCorrespondentCreate,
resolveTargetDocumentIds, resolveTargetDocumentIds,
showToast, showToast,
updateDocumentCaches, documentsManager,
], ],
); );
@@ -144,22 +149,22 @@ const useBulkDocumentActions = ({
}); });
const { assigned = 0, removed = 0 } = response; const { assigned = 0, removed = 0 } = response;
if (updateDocumentCaches) {
targets.forEach((docId) => { const targetSet = new Set(targets);
updateDocumentCaches(docId, (doc) => { documentsManager.map((doc) => {
if (!doc || !Array.isArray((doc as any).correspondents)) { if (!targetSet.has(doc.id as Identifier)) return undefined;
return doc;
} if (!doc || !Array.isArray((doc as any).correspondents)) {
const filtered = (doc as any).correspondents.filter( return doc;
(entry: any) => }
entry && !normalizedAssignments.some((assignment) => assignment.correspondent_id === entry.id), const filtered = (doc as any).correspondents.filter(
); (entry: any) =>
return filtered.length === (doc as any).correspondents.length entry && !normalizedAssignments.some((assignment) => assignment.correspondent_id === entry.id),
? doc );
: { ...(doc as any), correspondents: filtered }; return filtered.length === (doc as any).correspondents.length
}); ? doc
}); : { ...(doc as any), correspondents: filtered };
} });
if (removed > 0) { if (removed > 0) {
const removedSuffix = removed === 1 ? '' : 's'; const removedSuffix = removed === 1 ? '' : 's';
@@ -174,7 +179,7 @@ const useBulkDocumentActions = ({
showToast('No correspondents changed.', 'info'); showToast('No correspondents changed.', 'info');
} }
}, },
[resolveTargetDocumentIds, showToast, updateDocumentCaches], [resolveTargetDocumentIds, showToast, documentsManager],
); );
const handleDeleteSelection = useCallback(async () => { const handleDeleteSelection = useCallback(async () => {
@@ -8,12 +8,12 @@ import useNotifyApiError from '../../hooks/useNotifyApiError';
interface UseCorrespondentsOptions { interface UseCorrespondentsOptions {
tenantIdRef: MutableRefObject<string | null>; tenantIdRef: MutableRefObject<string | null>;
mapDocumentCaches?: (mapper: (doc: any) => any) => void; documentsManager?: { map: (mapper: (doc: any) => any) => void };
} }
const useCorrespondents = ({ const useCorrespondents = ({
tenantIdRef, tenantIdRef,
mapDocumentCaches, documentsManager,
}: UseCorrespondentsOptions) => { }: UseCorrespondentsOptions) => {
const [correspondents, setCorrespondents] = useState<Correspondent[]>([]); const [correspondents, setCorrespondents] = useState<Correspondent[]>([]);
const { showToast } = useStatusToast(); const { showToast } = useStatusToast();
@@ -109,7 +109,7 @@ const useCorrespondents = ({
await deleteCorrespondent(correspondentId); await deleteCorrespondent(correspondentId);
await refreshCorrespondents(); await refreshCorrespondents();
mapDocumentCaches?.(stripFromDoc); documentsManager?.map(stripFromDoc);
showToast('Correspondent deleted.', 'success'); showToast('Correspondent deleted.', 'success');
return true; return true;
@@ -119,7 +119,7 @@ const useCorrespondents = ({
throw new Error(message); throw new Error(message);
} }
}, },
[mapDocumentCaches, notifyApiError, refreshCorrespondents, showToast], [documentsManager, notifyApiError, refreshCorrespondents, showToast],
); );
return { return {
@@ -110,9 +110,9 @@ export const useDocumentMoveMutations = ({
showToast(`Moved ${count} document${suffix} to ${targetLabel}.`, 'success'); showToast(`Moved ${count} document${suffix} to ${targetLabel}.`, 'success');
if (updatedDocsMap.size) { if (updatedDocsMap.size) {
documentsState.mapDocumentCaches((doc) => { documentsState.documentsManager.map((doc) => {
if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) { if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
return doc; return undefined;
} }
const updated = updatedDocsMap.get(doc.id as DocumentId); const updated = updatedDocsMap.get(doc.id as DocumentId);
if (updated) { if (updated) {
@@ -89,7 +89,7 @@ const useDocumentMutations = ({
handleDocumentTagDetach, handleDocumentTagDetach,
} = useDocumentTagMutations({ } = useDocumentTagMutations({
tagsState, tagsState,
documentsState: { updateDocumentCaches: documentsState.updateDocumentCaches }, documentsState: { documentsManager: documentsState.documentsManager },
}); });
const handleThumbnailRegeneration = useCallback( const handleThumbnailRegeneration = useCallback(
@@ -115,13 +115,10 @@ const useDocumentMutations = ({
// Optimistic update could happen here but usually we wait for standardized confirmation // Optimistic update could happen here but usually we wait for standardized confirmation
// However workspace expects mutation here. // However workspace expects mutation here.
try { 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))); await Promise.all(documentIds.map((id) => trashDocument(id)));
// Remove from local state // Remove from local state and manager
documentsState.removeDocumentsFromCaches(documentIds); documentsState.documentsManager.remove(documentIds);
if (showMessage) { if (showMessage) {
const count = documentIds.length; const count = documentIds.length;
@@ -156,7 +153,8 @@ const useDocumentMutations = ({
if (updatedDocument && documentsState.ingestDocuments) { if (updatedDocument && documentsState.ingestDocuments) {
documentsState.ingestDocuments([updatedDocument]); documentsState.ingestDocuments([updatedDocument]);
} else { } else {
documentsState.updateDocumentCaches(documentId, (doc) => { documentsState.documentsManager.map((doc) => {
if (doc.id !== documentId) return undefined;
if (updatedDocument) { if (updatedDocument) {
return { ...doc, ...updatedDocument }; return { ...doc, ...updatedDocument };
} }
@@ -189,7 +187,8 @@ const useDocumentMutations = ({
if (updatedDocument && documentsState.ingestDocuments) { if (updatedDocument && documentsState.ingestDocuments) {
documentsState.ingestDocuments([updatedDocument]); documentsState.ingestDocuments([updatedDocument]);
} else { } else {
documentsState.updateDocumentCaches(documentId, (doc) => { documentsState.documentsManager.map((doc) => {
if (doc.id !== documentId) return undefined;
if (updatedDocument) { if (updatedDocument) {
return { ...doc, ...updatedDocument }; return { ...doc, ...updatedDocument };
} }
@@ -213,8 +212,6 @@ const useDocumentMutations = ({
], ],
); );
// handleFolderDelete is removed from here
return { return {
moveDocumentsToFolder, moveDocumentsToFolder,
handleThumbnailRegeneration, handleThumbnailRegeneration,
@@ -21,7 +21,7 @@ interface DocumentTagExtras {
interface UseDocumentTagMutationsArgs { interface UseDocumentTagMutationsArgs {
tagsState: TagsState; tagsState: TagsState;
documentsState: Pick<DocumentsState, 'updateDocumentCaches'>; documentsState: Pick<DocumentsState, 'documentsManager'>;
} }
export const useDocumentTagMutations = ({ export const useDocumentTagMutations = ({
@@ -51,9 +51,9 @@ export const useDocumentTagMutations = ({
try { try {
await addDocumentTags(documentId, [cachedTag.id]); await addDocumentTags(documentId, [cachedTag.id]);
documentsState.updateDocumentCaches(documentId, (doc) => { documentsState.documentsManager.map((doc) => {
if (!doc) { if (doc.id !== documentId) {
return doc; return undefined;
} }
const currentTags = Array.isArray(doc.tags) ? doc.tags : []; const currentTags = Array.isArray(doc.tags) ? doc.tags : [];
if (currentTags.some((entry) => entry?.id === cachedTag.id)) { if (currentTags.some((entry) => entry?.id === cachedTag.id)) {
@@ -149,7 +149,10 @@ export const useDocumentTagMutations = ({
try { try {
await deleteDocumentTag(documentId, tagId); await deleteDocumentTag(documentId, tagId);
// Inlined applyTagRemovalToCaches logic // Inlined applyTagRemovalToCaches logic
documentsState.updateDocumentCaches(documentId, (doc) => { documentsState.documentsManager.map((doc) => {
if (doc.id !== documentId) {
return undefined;
}
if (!doc || !Array.isArray(doc.tags)) { if (!doc || !Array.isArray(doc.tags)) {
return doc; return doc;
} }
+37 -68
View File
@@ -1,8 +1,10 @@
import { import {
useCallback, useCallback,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
useSyncExternalStore,
} from 'react'; } from 'react';
import DocumentsManager from '../DocumentsManager'; import DocumentsManager from '../DocumentsManager';
import type { DocumentId } from '../../types/identifiers'; import type { DocumentId } from '../../types/identifiers';
@@ -18,91 +20,58 @@ const useDocuments = ({
const managerRef = useRef( const managerRef = useRef(
new DocumentsManager<Document>(fetchDocumentById), new DocumentsManager<Document>(fetchDocumentById),
); );
const [documents, setDocumentsState] = useState<Document[]>([]); // Store only IDs in local state
const [documentIds, setDocumentIds] = useState<DocumentId[]>([]);
useEffect(() => { useEffect(() => {
managerRef.current.setFetcher(fetchDocumentById); managerRef.current.setFetcher(fetchDocumentById);
}, [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( const setDocuments = useCallback(
(value: Document[] | ((prev: Document[]) => Document[])) => { (value: Document[] | ((prev: Document[]) => Document[])) => {
setDocumentsState((prev) => { // Support functional updates using the current derived documents as the previous state.
const resolved = typeof value === 'function' ? value(prev) : value; // Use ref to avoid re-creating this callback when documents change.
if (!Array.isArray(resolved)) { const prevDocs = documentsRef.current;
return resolved; const newDocs = typeof value === 'function' ? value(prevDocs) : value;
}
const { canonical } = managerRef.current.ingest(resolved);
return canonical;
});
},
[],
);
const mapDocumentCaches = useCallback( if (!Array.isArray(newDocs)) {
(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) {
return; return;
} }
mapDocumentCaches((doc) => { const { canonical } = managerRef.current.ingest(newDocs);
if (!doc || doc.id !== documentId) { const newIds = canonical.map(d => d.id as DocumentId).filter(Boolean);
return doc; setDocumentIds(newIds);
}
const updated = updater(doc);
return updated === undefined ? doc : updated;
});
}, },
[mapDocumentCaches], [] // Stable callback
);
const removeDocumentsFromLookup = useCallback(
(documentIds: Array<DocumentId>) => {
if (!Array.isArray(documentIds) || !documentIds.length) {
return;
}
managerRef.current.remove(documentIds);
},
[],
); );
return { return {
documents, documents,
setDocuments, setDocuments,
removeDocumentsFromLookup,
mapDocumentCaches,
updateDocumentCaches,
documentsManager: managerRef.current, documentsManager: managerRef.current,
}; };
}; };
@@ -223,9 +223,6 @@ const useDocumentsWorkspace = ({
const { const {
documents, documents,
setDocuments, setDocuments,
removeDocumentsFromLookup,
mapDocumentCaches,
updateDocumentCaches,
documentsManager, documentsManager,
} = useDocuments({ } = useDocuments({
fetchDocumentById, fetchDocumentById,
@@ -264,6 +261,23 @@ const useDocumentsWorkspace = ({
const [currentSubfolders, setCurrentSubfolders] = useState<Array<{ id?: FolderNodeId; name?: string | null;[key: string]: unknown }>>([]); const [currentSubfolders, setCurrentSubfolders] = useState<Array<{ id?: FolderNodeId; name?: string | null;[key: string]: unknown }>>([]);
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( const reconcileSelectionWithFolderData = useCallback(
(currentSelection: string[], docs: Document[], subfolders: any[]) => { (currentSelection: string[], docs: Document[], subfolders: any[]) => {
const availableDocKeys = docs const availableDocKeys = docs
@@ -383,15 +397,30 @@ const useDocumentsWorkspace = ({
const documentsFilter = documentsFilterValue; const documentsFilter = documentsFilterValue;
const showingSearchResults = searchResultIds !== null; 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 { const {
viewDocuments, viewDocuments,
visibleEntryKeySet, visibleEntryKeySet,
} = useWorkspaceViewData({ } = useWorkspaceViewData({
documents, documents: liveFilteredDocuments,
documentLookup, documentLookup,
searchResultIds, searchResultIds,
showingSearchResults, showingSearchResults,
currentSubfolders, currentSubfolders: visibleSubfolders,
}); });
const { const {
@@ -445,7 +474,7 @@ const useDocumentsWorkspace = ({
tenantIdRef, tenantIdRef,
tagManager, tagManager,
setActiveTagFilters, setActiveTagFilters,
mapDocumentCaches, documentsManager,
}); });
const { const {
tags, tags,
@@ -476,7 +505,7 @@ const useDocumentsWorkspace = ({
const correspondentsStateRaw = useCorrespondents({ const correspondentsStateRaw = useCorrespondents({
tenantIdRef, tenantIdRef,
mapDocumentCaches, documentsManager,
}); });
const { const {
correspondents, correspondents,
@@ -495,7 +524,7 @@ const useDocumentsWorkspace = ({
} = useDocumentCorrespondentActions({ } = useDocumentCorrespondentActions({
correspondents, correspondents,
handleCorrespondentCreate, handleCorrespondentCreate,
updateDocumentCaches, documentsManager,
}); });
const { const {
@@ -548,7 +577,7 @@ const useDocumentsWorkspace = ({
tagManager, tagManager,
refreshTags, refreshTags,
resolveTargetDocumentIds, resolveTargetDocumentIds,
updateDocumentCaches, documentsManager,
}); });
const { const {
@@ -642,40 +671,11 @@ const useDocumentsWorkspace = ({
} }
}, [appStatus, resetWorkspaceState, foldersManager]); }, [appStatus, resetWorkspaceState, foldersManager]);
const removeDocumentsFromCaches = useCallback(
(documentIds: DocumentId[]) => {
if (!documentIds.length) {
return;
}
const idSet = new Set<DocumentId>(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 = { const documentsState = {
documentLookup, documentLookup,
setDocuments, setDocuments,
setSearchResultIds, setSearchResultIds,
removeDocumentsFromCaches, documentsManager,
updateDocumentCaches,
mapDocumentCaches,
extractDocumentFromResponse, extractDocumentFromResponse,
ingestDocuments: (docs: unknown[]) => documentsManager.ingest(docs), ingestDocuments: (docs: unknown[]) => documentsManager.ingest(docs),
}; };
@@ -732,7 +732,7 @@ const useDocumentsWorkspace = ({
clearDocumentSelection, clearDocumentSelection,
} = useDocumentsSelection({ } = useDocumentsSelection({
showingSearchResults, showingSearchResults,
currentSubfolders, currentSubfolders: visibleSubfolders,
visibleDocuments: viewDocuments, visibleDocuments: viewDocuments,
configureSelectionEnvironment, configureSelectionEnvironment,
visibleEntryKeySet, visibleEntryKeySet,
@@ -834,7 +834,7 @@ const useDocumentsWorkspace = ({
handleDocumentsDelete, handleDocumentsDelete,
handleFolderDelete, handleFolderDelete,
clearDocumentSelection, clearDocumentSelection,
updateDocumentCaches, documentsManager,
}); });
const ensureAssetUrl = useCallback( const ensureAssetUrl = useCallback(
@@ -852,7 +852,10 @@ const useDocumentsWorkspace = ({
return null; return null;
} }
updateDocumentCaches(documentId, (doc) => mergeAssetIntoDocument(doc, entry)); documentsManager.map((doc) => {
if (doc.id !== documentId) return undefined;
return mergeAssetIntoDocument(doc, entry);
});
return entry; return entry;
} catch (error) { } catch (error) {
@@ -860,7 +863,7 @@ const useDocumentsWorkspace = ({
throw error; throw error;
} }
}, },
[assetManager, updateDocumentCaches, notifyApiError], [assetManager, documentsManager, notifyApiError],
); );
const handlePromptCreateFolder = useCallback(async (parentId?: Identifier | null) => { const handlePromptCreateFolder = useCallback(async (parentId?: Identifier | null) => {
@@ -1097,7 +1100,7 @@ const useDocumentsWorkspace = ({
draggedFolderId, draggedFolderId,
handlePromptCreateFolder, handlePromptCreateFolder,
creatingFolder, creatingFolder,
currentSubfolders, currentSubfolders: visibleSubfolders,
breadcrumbs, breadcrumbs,
}; };
+4 -4
View File
@@ -16,7 +16,7 @@ interface UseTagsOptions {
tagManager: TagManagerInterface; tagManager: TagManagerInterface;
tenantIdRef: MutableRefObject<TenantId | null>; tenantIdRef: MutableRefObject<TenantId | null>;
setActiveTagFilters: (updater: (prev: Array<TagId>) => Array<TagId>) => void; setActiveTagFilters: (updater: (prev: Array<TagId>) => Array<TagId>) => void;
mapDocumentCaches?: (mapper: (doc: any) => any) => void; documentsManager?: { map: (mapper: (doc: any) => any) => void };
} }
const useTags = ({ const useTags = ({
@@ -24,7 +24,7 @@ const useTags = ({
tagManager, tagManager,
tenantIdRef, tenantIdRef,
setActiveTagFilters, setActiveTagFilters,
mapDocumentCaches, documentsManager,
}: UseTagsOptions) => { }: UseTagsOptions) => {
const [tags, setTags] = useState<Tag[]>([]); const [tags, setTags] = useState<Tag[]>([]);
const { showToast } = useStatusToast(); const { showToast } = useStatusToast();
@@ -115,7 +115,7 @@ const useTags = ({
return { ...doc, tags: nextTags }; return { ...doc, tags: nextTags };
}; };
mapDocumentCaches?.(stripTagFromDoc); documentsManager?.map(stripTagFromDoc);
await refreshTags(); await refreshTags();
showToast('Tag deleted.', 'success'); showToast('Tag deleted.', 'success');
@@ -126,7 +126,7 @@ const useTags = ({
throw new Error(message); throw new Error(message);
} }
}, },
[mapDocumentCaches, notifyApiError, refreshTags, setActiveTagFilters, showToast], [documentsManager, notifyApiError, refreshTags, setActiveTagFilters, showToast],
); );
return { return {
@@ -12,20 +12,18 @@ interface CorrespondentOption {
} }
import useNotifyApiError from '../../../hooks/useNotifyApiError'; import useNotifyApiError from '../../../hooks/useNotifyApiError';
import type { DocumentsManagerInterface } from '../../types/workspaceTypes';
interface UseDocumentCorrespondentActionsArgs { interface UseDocumentCorrespondentActionsArgs {
correspondents: CorrespondentOption[]; correspondents: CorrespondentOption[];
handleCorrespondentCreate: (payload: { name: string }) => Promise<CorrespondentOption | null>; handleCorrespondentCreate: (payload: { name: string }) => Promise<CorrespondentOption | null>;
updateDocumentCaches?: ( documentsManager: DocumentsManagerInterface;
id: Identifier,
updater: (doc: { correspondents?: CorrespondentOption[] } | null) => { correspondents?: CorrespondentOption[] } | null,
) => void;
} }
const useDocumentCorrespondentActions = ({ const useDocumentCorrespondentActions = ({
correspondents, correspondents,
handleCorrespondentCreate, handleCorrespondentCreate,
updateDocumentCaches, documentsManager,
}: UseDocumentCorrespondentActionsArgs) => { }: UseDocumentCorrespondentActionsArgs) => {
const { showToast } = useStatusToast(); const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError(); const notifyApiError = useNotifyApiError();
@@ -54,24 +52,24 @@ const useDocumentCorrespondentActions = ({
} }
try { try {
await addDocumentCorrespondent(documentId, correspondentId); await addDocumentCorrespondent(documentId, correspondentId);
if (updateDocumentCaches) {
const resolved = correspondent const resolved = correspondent
|| correspondents.find((entry) => entry?.id === correspondentId) || correspondents.find((entry) => entry?.id === correspondentId)
|| null; || null;
updateDocumentCaches(documentId, (doc) => {
if (!doc) { documentsManager.map((doc) => {
return doc; if (doc.id !== documentId) return undefined;
}
const current = Array.isArray(doc.correspondents) ? doc.correspondents : []; const current = Array.isArray(doc.correspondents) ? doc.correspondents : [];
if (current.some((entry) => entry?.id === correspondentId)) { if (current.some((entry) => entry?.id === correspondentId)) {
return doc; return doc;
} }
const nextEntry = resolved?.name const nextEntry = resolved?.name
? { id: resolved.id ?? correspondentId, name: resolved.name } ? { id: resolved.id ?? correspondentId, name: resolved.name }
: { id: correspondentId }; : { id: correspondentId };
return { ...doc, correspondents: [...current, nextEntry] }; return { ...doc, correspondents: [...current, nextEntry] };
}); });
}
if (notify) { if (notify) {
showToast('Correspondent assigned.', 'success'); showToast('Correspondent assigned.', 'success');
} }
@@ -82,7 +80,7 @@ const useDocumentCorrespondentActions = ({
throw new Error(message); throw new Error(message);
} }
}, },
[correspondents, notifyApiError, showToast, updateDocumentCaches], [correspondents, notifyApiError, showToast, documentsManager],
); );
const handleCorrespondentRemove = useCallback( const handleCorrespondentRemove = useCallback(
@@ -95,15 +93,16 @@ const useDocumentCorrespondentActions = ({
} }
try { try {
await removeDocumentCorrespondent(documentId, correspondentId); await removeDocumentCorrespondent(documentId, correspondentId);
if (updateDocumentCaches) {
updateDocumentCaches(documentId, (doc) => { documentsManager.map((doc) => {
if (!doc || !Array.isArray(doc.correspondents)) { if (doc.id !== documentId) return undefined;
return 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 }; const filtered = doc.correspondents.filter((entry) => entry?.id !== correspondentId);
}); return filtered.length === doc.correspondents.length ? doc : { ...doc, correspondents: filtered };
} });
if (notify) { if (notify) {
showToast('Correspondent removed.', 'success'); showToast('Correspondent removed.', 'success');
} }
@@ -114,7 +113,7 @@ const useDocumentCorrespondentActions = ({
throw new Error(message); throw new Error(message);
} }
}, },
[notifyApiError, showToast, updateDocumentCaches], [notifyApiError, showToast, documentsManager],
); );
const normalizeOption = ( const normalizeOption = (
@@ -103,7 +103,7 @@ const useFolderTreeActions = ({
await moveFolderRequest(folderId, parent_id); await moveFolderRequest(folderId, parent_id);
if (foldersManager) { if (foldersManager) {
foldersManager.invalidateTree(); foldersManager.refreshTree();
} }
if (selectedFolder === folderId) { if (selectedFolder === folderId) {
@@ -210,7 +210,7 @@ const useFolderTreeActions = ({
// Ingest the new folder data immediately so it's available // Ingest the new folder data immediately so it's available
foldersManager.ingest([folderData]); foldersManager.ingest([folderData]);
// Force tree refresh to update structure // Force tree refresh to update structure
foldersManager.invalidateTree(); foldersManager.refreshTree();
} }
await selectFolder(folderData.id, { immediate: true }); await selectFolder(folderData.id, { immediate: true });
@@ -251,7 +251,7 @@ const useFolderTreeActions = ({
if (foldersManager) { if (foldersManager) {
foldersManager.remove([folderId]); foldersManager.remove([folderId]);
foldersManager.invalidateTree(); foldersManager.refreshTree();
} }
if (selectedFolder === folderId) { if (selectedFolder === folderId) {
@@ -16,13 +16,14 @@ interface TagManager {
} }
import useNotifyApiError from '../../../hooks/useNotifyApiError'; import useNotifyApiError from '../../../hooks/useNotifyApiError';
import type { DocumentsManagerInterface } from '../../types/workspaceTypes';
interface UseDocumentTaggingArgs { interface UseDocumentTaggingArgs {
tags: TagRecord[]; tags: TagRecord[];
tagManager: TagManager; tagManager: TagManager;
refreshTags: () => Promise<void> | void; refreshTags: () => Promise<void> | void;
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[]; resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
updateDocumentCaches?: (id: Identifier, updater: (doc: TagRecord | null) => TagRecord | null) => void; documentsManager: DocumentsManagerInterface;
} }
interface BulkTagOperationArgs { interface BulkTagOperationArgs {
@@ -44,7 +45,7 @@ const useDocumentTagActions = ({
tagManager, tagManager,
refreshTags, refreshTags,
resolveTargetDocumentIds, resolveTargetDocumentIds,
updateDocumentCaches, documentsManager,
}: UseDocumentTaggingArgs) => { }: UseDocumentTaggingArgs) => {
const { showToast } = useStatusToast(); const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError(); const notifyApiError = useNotifyApiError();
@@ -93,38 +94,39 @@ const useDocumentTagActions = ({
} }
tagIds = Array.from(new Set(createdIds)); tagIds = Array.from(new Set(createdIds));
if (updateDocumentCaches) { const tagById = new Map<Identifier, TagRecord>();
const tagById = new Map<Identifier, TagRecord>(); tags.forEach((tag) => {
tags.forEach((tag) => { if (tag?.id != null) {
if (tag?.id != null) { tagById.set(tag.id, tag);
tagById.set(tag.id, tag); }
} });
}); createdTags.forEach((tag) => {
createdTags.forEach((tag) => { if (tag?.id != null) {
if (tag?.id != null) { tagById.set(tag.id, tag);
tagById.set(tag.id, tag); }
} });
});
targetDocumentIds.forEach((docId) => { 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) => { tagIds.forEach((tagId) => {
const cachedTag = tagById.get(tagId); if (nextTags.some((entry: any) => entry?.id === tagId)) {
if (!cachedTag) {
return; return;
} }
updateDocumentCaches(docId, (doc) => { const cachedTag = tagById.get(tagId);
if (!doc) { if (cachedTag) {
return doc; nextTags.push({ ...cachedTag });
} changed = true;
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 }],
};
});
}); });
return changed ? { ...(doc as any), tags: nextTags } : doc;
}); });
} }
} }
@@ -141,21 +143,18 @@ const useDocumentTagActions = ({
action, action,
}); });
if (updateDocumentCaches) { if (action === 'remove') {
targetDocumentIds.forEach((docId) => { const targetSet = new Set(targetDocumentIds);
tagIds.forEach((tagId) => { const removeSet = new Set(tagIds);
updateDocumentCaches(docId, (doc) => {
if (!doc || !Array.isArray((doc as any).tags)) { documentsManager.map((doc) => {
return doc; if (!targetSet.has(doc.id as Identifier)) return undefined;
} if (!doc || !Array.isArray((doc as any).tags)) {
const currentTags = (doc as any).tags; return doc;
if (action === 'remove') { }
const filtered = currentTags.filter((entry: any) => entry?.id !== tagId); const currentTags = (doc as any).tags;
return filtered.length === currentTags.length ? doc : { ...(doc as any), tags: filtered }; const filtered = currentTags.filter((entry: any) => !removeSet.has(entry?.id));
} return filtered.length === currentTags.length ? doc : { ...(doc as any), tags: filtered };
return doc;
});
});
}); });
} }
@@ -178,7 +177,7 @@ const useDocumentTagActions = ({
refreshTags, refreshTags,
notifyApiError, notifyApiError,
tagManager, tagManager,
updateDocumentCaches, documentsManager,
], ],
); );
+6 -16
View File
@@ -15,20 +15,12 @@ interface TagManager {
buildPayload: (args: { label: string }) => Record<string, unknown>; buildPayload: (args: { label: string }) => Record<string, unknown>;
} }
type DocumentCacheMapper = (
doc: Document | null,
) => Document | null;
type MapDocumentCaches = (mapper: DocumentCacheMapper) => void; export interface DocumentsManagerInterface {
map(mapper: (doc: Document) => Document | undefined): boolean;
type UpdateDocumentCaches = ( ingest(rawDocs: unknown[]): { canonical: Document[]; changed: boolean };
documentId: DocumentId, remove(ids: Array<DocumentId>): boolean;
updater: DocumentCacheMapper, }
) => void;
type RemoveDocumentsFromCaches = (documentIds: DocumentId[]) => void;
type CloseDocumentPreview = () => void; type CloseDocumentPreview = () => void;
@@ -36,9 +28,7 @@ export interface DocumentsState {
documentLookup: Map<DocumentId, Document>; documentLookup: Map<DocumentId, Document>;
setDocuments: Dispatch<SetStateAction<Document[]>>; setDocuments: Dispatch<SetStateAction<Document[]>>;
setSearchResultIds: Dispatch<SetStateAction<DocumentId[] | null>>; setSearchResultIds: Dispatch<SetStateAction<DocumentId[] | null>>;
removeDocumentsFromCaches: RemoveDocumentsFromCaches; documentsManager: DocumentsManagerInterface;
updateDocumentCaches: UpdateDocumentCaches;
mapDocumentCaches: MapDocumentCaches;
extractDocumentFromResponse?: (payload: unknown) => Document | null; extractDocumentFromResponse?: (payload: unknown) => Document | null;
ingestDocuments?: (docs: unknown[]) => { canonical: Document[]; changed: boolean }; ingestDocuments?: (docs: unknown[]) => { canonical: Document[]; changed: boolean };
} }