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;
}
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<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() {
this.treeSnapshot = [];
this.treePromise = null;
@@ -13,6 +13,8 @@ type CorrespondentAssignment = {
correspondent_id?: Identifier;
};
import type { DocumentsManagerInterface } from '../types/workspaceTypes';
interface UseBulkDocumentActionsArgs {
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
correspondentLookupByName: Map<string, { id?: Identifier }>;
@@ -22,7 +24,7 @@ interface UseBulkDocumentActionsArgs {
handleDocumentsDelete: (ids: Identifier[], options?: MessageOptions) => Promise<boolean>;
handleFolderDelete: (id: Identifier, options?: MessageOptions) => Promise<boolean>;
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 () => {
@@ -8,12 +8,12 @@ import useNotifyApiError from '../../hooks/useNotifyApiError';
interface UseCorrespondentsOptions {
tenantIdRef: MutableRefObject<string | null>;
mapDocumentCaches?: (mapper: (doc: any) => any) => void;
documentsManager?: { map: (mapper: (doc: any) => any) => void };
}
const useCorrespondents = ({
tenantIdRef,
mapDocumentCaches,
documentsManager,
}: UseCorrespondentsOptions) => {
const [correspondents, setCorrespondents] = useState<Correspondent[]>([]);
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 {
@@ -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) {
@@ -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,
@@ -21,7 +21,7 @@ interface DocumentTagExtras {
interface UseDocumentTagMutationsArgs {
tagsState: TagsState;
documentsState: Pick<DocumentsState, 'updateDocumentCaches'>;
documentsState: Pick<DocumentsState, 'documentsManager'>;
}
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;
}
+37 -68
View File
@@ -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<Document>(fetchDocumentById),
);
const [documents, setDocumentsState] = useState<Document[]>([]);
// Store only IDs in local state
const [documentIds, setDocumentIds] = useState<DocumentId[]>([]);
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<DocumentId>) => {
if (!Array.isArray(documentIds) || !documentIds.length) {
return;
}
managerRef.current.remove(documentIds);
},
[],
[] // Stable callback
);
return {
documents,
setDocuments,
removeDocumentsFromLookup,
mapDocumentCaches,
updateDocumentCaches,
documentsManager: managerRef.current,
};
};
@@ -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<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(
(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<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 = {
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,
};
+4 -4
View File
@@ -16,7 +16,7 @@ interface UseTagsOptions {
tagManager: TagManagerInterface;
tenantIdRef: MutableRefObject<TenantId | null>;
setActiveTagFilters: (updater: (prev: Array<TagId>) => Array<TagId>) => 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<Tag[]>([]);
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 {
@@ -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<CorrespondentOption | null>;
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 = (
@@ -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) {
@@ -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> | 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<Identifier, TagRecord>();
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<Identifier, TagRecord>();
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,
],
);
+6 -16
View File
@@ -15,20 +15,12 @@ interface TagManager {
buildPayload: (args: { label: string }) => Record<string, unknown>;
}
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<DocumentId>): boolean;
}
type CloseDocumentPreview = () => void;
@@ -36,9 +28,7 @@ export interface DocumentsState {
documentLookup: Map<DocumentId, Document>;
setDocuments: Dispatch<SetStateAction<Document[]>>;
setSearchResultIds: Dispatch<SetStateAction<DocumentId[] | null>>;
removeDocumentsFromCaches: RemoveDocumentsFromCaches;
updateDocumentCaches: UpdateDocumentCaches;
mapDocumentCaches: MapDocumentCaches;
documentsManager: DocumentsManagerInterface;
extractDocumentFromResponse?: (payload: unknown) => Document | null;
ingestDocuments?: (docs: unknown[]) => { canonical: Document[]; changed: boolean };
}