diff --git a/frontend/src/documents/data/useDocumentMoveMutations.ts b/frontend/src/documents/data/useDocumentMoveMutations.ts new file mode 100644 index 0000000..f959ffc --- /dev/null +++ b/frontend/src/documents/data/useDocumentMoveMutations.ts @@ -0,0 +1,177 @@ +import { useCallback } from 'react'; +import { useStatusToast } from '../../lib/context/StatusToastContext'; +import useNotifyApiError from '../../hooks/useNotifyApiError'; +import { DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils'; +import { getEntryId, isDocumentEntry } from '../../app/entryKey'; +import { + moveDocumentsBulk, + moveDocumentToFolder, + listFolderContents, +} from '../../lib/api/apiClient'; +import type { DocumentId, FolderId as FolderIdentifier } from '../../types/identifiers'; +import type { Document } from '../../types/documents'; +import type { + DocumentsState, + FolderState, + SelectionState, +} from '../types/workspaceTypes'; + +type FolderId = FolderIdentifier | 'root'; +type NullableFolderId = FolderId | null; + +interface UseDocumentMoveMutationsArgs { + documentsState: DocumentsState; + folderState: FolderState; + selectionState: SelectionState; +} + +export const useDocumentMoveMutations = ({ + documentsState, + folderState, + selectionState, +}: UseDocumentMoveMutationsArgs) => { + const { showToast } = useStatusToast(); + const notifyApiError = useNotifyApiError(); + + const normalizeDocumentId = (value: unknown): DocumentId | null => { + if (!value) return null; + if (value && typeof value === 'object' && 'id' in value && value.id != null) { + return value.id as DocumentId; + } + return value as DocumentId; + }; + + const moveDocumentsToFolder = useCallback( + async (documentIds: Array, targetFolderId?: NullableFolderId) => { + const uniqueIds = Array.from( + new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean) as DocumentId[]), + ); + if (!uniqueIds.length) return; + + const uniqueIdSet = new Set(uniqueIds); + const target = targetFolderId === 'root' ? null : targetFolderId ?? null; + const targetLabel = + target === null ? DEFAULT_FOLDER_NAME : folderState.folderLabelMap.get(targetFolderId as FolderId) || 'target folder'; + + const movedDocs = uniqueIds + .map((id) => { + const doc = documentsState.documentLookup.get(id) || null; + if (!doc) { + return null; + } + return { + id, + sourceFolderId: (doc.folder_id ?? null) as NullableFolderId, + document: doc, + }; + }) + .filter(Boolean) as Array<{ id: DocumentId; sourceFolderId: NullableFolderId; document: Document }>; + + const updatedDocsMap = new Map(); + const resolveTargetName = () => { + if (!targetLabel) { + return null; + } + const segments = String(targetLabel).split('/'); + return segments[segments.length - 1] || targetLabel; + }; + const targetName = resolveTargetName(); + + movedDocs.forEach(({ id, document }) => { + if (!document) { + return; + } + const updated: Document = { + ...document, + folder_id: target, + }; + if (targetLabel) { + updated.folder_path = targetLabel; + if (targetName) { + updated.folder_name = targetName; + } + } else if (target === null) { + updated.folder_path = DEFAULT_FOLDER_NAME; + updated.folder_name = DEFAULT_FOLDER_NAME; + } + updatedDocsMap.set(id, updated); + }); + + + try { + if (uniqueIds.length === 1) { + await moveDocumentToFolder(uniqueIds[0], target); + } else { + await moveDocumentsBulk(uniqueIds, target); + } + + const count = uniqueIds.length; + const suffix = count === 1 ? '' : 's'; + showToast(`Moved ${count} document${suffix} to ${targetLabel}.`, 'success'); + + if (updatedDocsMap.size) { + documentsState.mapDocumentCaches((doc) => { + if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) { + return doc; + } + const updated = updatedDocsMap.get(doc.id as DocumentId); + if (updated) { + return updated; + } + return doc; + }); + } + + if (uniqueIdSet.size) { + const pruneRow = (rows: string[]) => rows.filter(id => !uniqueIdSet.has(getEntryId(id) as DocumentId)); + const { selectionOrderRef, selectionAnchorRef, setSelectionOrder, setFocusedDocumentId, setFocusedEntryKey, setSelectedEntries } = selectionState; + + setSelectedEntries((prev) => pruneRow(prev)); + setSelectionOrder((prev) => pruneRow(prev)); + + const nextSelectionOrder = pruneRow(selectionOrderRef.current || []); + selectionOrderRef.current = nextSelectionOrder; + + if ( + selectionAnchorRef.current && + isDocumentEntry(selectionAnchorRef.current) && + uniqueIdSet.has(getEntryId(selectionAnchorRef.current) as DocumentId) + ) { + selectionAnchorRef.current = null; + } + if ( + selectionState.focusedDocumentId && + uniqueIdSet.has(selectionState.focusedDocumentId) + ) { + setFocusedDocumentId(null); + } + if ( + selectionState.focusedEntryKey && + isDocumentEntry(selectionState.focusedEntryKey) && + uniqueIdSet.has(getEntryId(selectionState.focusedEntryKey) as DocumentId) + ) { + setFocusedEntryKey(null); + } + } + + if (targetFolderId && targetFolderId !== folderState.selectedFolder) { + await listFolderContents(targetFolderId as FolderId); + } + + } catch (error) { + const message = (error as Record)?.response?.data?.error || 'Failed to move documents.'; + notifyApiError(error, message); + } + }, + [ + documentsState, + folderState.folderLabelMap, + folderState.selectedFolder, + selectionState, + notifyApiError, + showToast, + ], + ); + + return { moveDocumentsToFolder }; +}; diff --git a/frontend/src/documents/data/useDocumentMutations.ts b/frontend/src/documents/data/useDocumentMutations.ts index 034e64d..3918e97 100644 --- a/frontend/src/documents/data/useDocumentMutations.ts +++ b/frontend/src/documents/data/useDocumentMutations.ts @@ -1,71 +1,27 @@ import { useCallback } from 'react'; -import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { useStatusToast } from '../../lib/context/StatusToastContext'; -import { DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils'; -import { getEntryId, isDocumentEntry } from '../../app/entryKey'; import { - addDocumentTags, - createTag, - deleteDocumentTag, - deleteFolder, - moveDocumentsBulk, - moveDocumentToFolder, queueDocumentReanalysis, trashDocument, updateDocument, } from '../../lib/api/apiClient'; import type { DocumentId, FolderId as FolderIdentifier } from '../../types/identifiers'; import type { Document, MessageOptions } from '../../types/documents'; +import { useDocumentTagMutations } from './useDocumentTagMutations'; +import { useDocumentMoveMutations } from './useDocumentMoveMutations'; +import type { + DocumentsState, + FolderState, + SelectionState, + TagsState, + ActionsState, + Tag, +} from '../types/workspaceTypes'; type FolderId = FolderIdentifier | 'root'; type NullableFolderId = FolderId | null; -type DocumentCacheMapper = ( - doc: Document | null, -) => Document | null; - -type MapDocumentCaches = (mapper: DocumentCacheMapper) => void; - -type UpdateDocumentCaches = ( - documentId: DocumentId, - updater: DocumentCacheMapper, -) => void; - -type EnsureFolderData = ( - folderId: FolderId, - options?: { includeDocuments?: boolean }, -) => Promise; - -type RemoveDocumentsFromCaches = (documentIds: DocumentId[]) => void; - -type CloseDocumentPreview = () => void; - -interface Tag { - id: DocumentId; - label: string; - color?: string | null; - [key: string]: unknown; -} - -interface FolderContents { - documents?: Document[]; - subfolders?: Array<{ id?: FolderId;[key: string]: unknown }>; - [key: string]: unknown; -} - -interface FolderNode { - id: FolderId; - parentId?: FolderId; - children: FolderId[]; - hasChildren?: boolean; - [key: string]: unknown; -} - -interface TagManager { - normalizeLabel: (label: string) => string; - buildPayload: (args: { label: string }) => Record; -} interface DocumentTagExtras { option?: Tag | null; @@ -75,35 +31,12 @@ interface DocumentTagExtras { import useNotifyApiError from '../../hooks/useNotifyApiError'; interface UseDocumentMutationsArgs { - documentLookup: Map; - folderLabelMap: Map; - ensureFolderData: EnsureFolderData; - selectedFolder: FolderId; - setSelectedFolder: Dispatch>; - setDocuments: Dispatch>; - setSearchResultIds: Dispatch>; - setSelectedEntries: Dispatch>; - setSelectionOrder: Dispatch>; - selectionOrderRef: MutableRefObject; - selectionAnchorRef: MutableRefObject; - setFocusedDocumentId: Dispatch>; - focusedDocumentId: DocumentId | null; - setFocusedEntryKey: Dispatch>; - focusedEntryKey: string | null; - mapDocumentCaches: MapDocumentCaches; - folderNodes: Map; - setFolderNodes: Dispatch>>; - removeDocumentsFromCaches: RemoveDocumentsFromCaches; - closeDocumentPreview: CloseDocumentPreview; + documentsState: DocumentsState; + folderState: FolderState; + selectionState: SelectionState; + tagsState: TagsState; + actions: ActionsState; previewDocumentId?: DocumentId | null; - refreshCurrentFolder: () => Promise; - updateDocumentCaches: UpdateDocumentCaches; - tagLookupById: Map; - tags: Tag[]; - refreshTags: () => Promise; - tagManager: TagManager; - extractDocumentFromResponse?: (payload: unknown) => Document | null; - ingestDocuments?: (docs: unknown[]) => { canonical: Document[]; changed: boolean }; } interface UseDocumentMutationsResult { @@ -131,242 +64,69 @@ interface UseDocumentMutationsResult { documentId?: DocumentId, tagId?: DocumentId, ) => Promise; - handleFolderDelete: (folderId?: FolderId, options?: MessageOptions) => Promise; } -const normalizeDocumentId = (value: unknown): DocumentId | null => { - if (!value) return null; - if (value && typeof value === 'object' && 'id' in value && value.id != null) { - return value.id as DocumentId; - } - return value as DocumentId; -}; - const useDocumentMutations = ({ - documentLookup, - folderLabelMap, - ensureFolderData, - selectedFolder, - setSelectedFolder, - setDocuments, - setSearchResultIds, - setSelectedEntries, - setSelectionOrder, - selectionOrderRef, - selectionAnchorRef, - setFocusedDocumentId, - focusedDocumentId, - setFocusedEntryKey, - focusedEntryKey, - mapDocumentCaches, - folderNodes, - setFolderNodes, - removeDocumentsFromCaches, - closeDocumentPreview, + documentsState, + folderState, + selectionState, + tagsState, + actions, previewDocumentId, - refreshCurrentFolder, - updateDocumentCaches, - tagLookupById, - tags, - refreshTags, - tagManager, - extractDocumentFromResponse, - ingestDocuments, }: UseDocumentMutationsArgs): UseDocumentMutationsResult => { const { showToast } = useStatusToast(); const notifyApiError = useNotifyApiError(); - const moveDocumentsToFolder = useCallback( - async (documentIds: Array, targetFolderId?: NullableFolderId) => { - const uniqueIds = Array.from( - new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean) as DocumentId[]), - ); - if (!uniqueIds.length) return; + const { moveDocumentsToFolder } = useDocumentMoveMutations({ + documentsState, + folderState, + selectionState, + }); - const uniqueIdSet = new Set(uniqueIds); - const target = targetFolderId === 'root' ? null : targetFolderId ?? null; - const targetLabel = - target === null ? DEFAULT_FOLDER_NAME : folderLabelMap.get(targetFolderId as FolderId) || 'target folder'; - - const movedDocs = uniqueIds - .map((id) => { - const doc = documentLookup.get(id) || null; - if (!doc) { - return null; - } - return { - id, - sourceFolderId: (doc.folder_id ?? null) as NullableFolderId, - document: doc, - }; - }) - .filter(Boolean) as Array<{ id: DocumentId; sourceFolderId: NullableFolderId; document: Document }>; - - const updatedDocsMap = new Map(); - const resolveTargetName = () => { - if (!targetLabel) { - return null; - } - const segments = String(targetLabel).split('/'); - return segments[segments.length - 1] || targetLabel; - }; - const targetName = resolveTargetName(); - - movedDocs.forEach(({ id, document }) => { - if (!document) { - return; - } - const updated: Document = { - ...document, - folder_id: target, - }; - if (targetLabel) { - updated.folder_path = targetLabel; - if (targetName) { - updated.folder_name = targetName; - } - } else if (target === null) { - updated.folder_path = DEFAULT_FOLDER_NAME; - updated.folder_name = DEFAULT_FOLDER_NAME; - } - updatedDocsMap.set(id, updated); - }); - - const pruneRow = (collection: string[]): string[] => - collection.filter((key) => { - if (!isDocumentEntry(key)) { - return true; - } - const id = getEntryId(key); - return id ? !uniqueIdSet.has(id as DocumentId) : true; - }); - try { - if (uniqueIds.length === 1) { - await moveDocumentToFolder(uniqueIds[0], target); - } else { - await moveDocumentsBulk(uniqueIds, target); - } - - const count = uniqueIds.length; - const suffix = count === 1 ? '' : 's'; - showToast(`Moved ${count} document${suffix} to ${targetLabel}.`, 'success'); - - if (updatedDocsMap.size) { - mapDocumentCaches((doc) => { - if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) { - return doc; - } - const updated = updatedDocsMap.get(doc.id as DocumentId); - if (updated) { - return updated; - } - return { ...doc, folder_id: target }; - }); - } else { - mapDocumentCaches((doc) => { - if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) { - return doc; - } - return { ...doc, folder_id: target }; - }); - } - - if (uniqueIdSet.size) { - setSearchResultIds((prev) => { - if (!Array.isArray(prev) || !prev.length) { - return prev; - } - const filtered = prev.filter((id) => !uniqueIdSet.has(id as DocumentId)); - return filtered.length === prev.length ? prev : filtered; - }); - setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId))); - setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId))); - // setFolderContents removed as we don't hold full cache anymore - - setSelectedEntries((prev) => pruneRow(prev)); - setSelectionOrder((prev) => pruneRow(prev)); - const nextSelectionOrder = pruneRow(selectionOrderRef.current || []); - selectionOrderRef.current = nextSelectionOrder; - if ( - selectionAnchorRef.current && - isDocumentEntry(selectionAnchorRef.current) && - uniqueIdSet.has(getEntryId(selectionAnchorRef.current) as DocumentId) - ) { - selectionAnchorRef.current = null; - } - if (focusedDocumentId && uniqueIdSet.has(focusedDocumentId)) { - setFocusedDocumentId(null); - } - if ( - focusedEntryKey && - isDocumentEntry(focusedEntryKey) && - uniqueIdSet.has(getEntryId(focusedEntryKey) as DocumentId) - ) { - setFocusedEntryKey(null); - } - } - - if (targetFolderId && targetFolderId !== selectedFolder) { - await ensureFolderData(targetFolderId as FolderId); - } - } catch (error) { - const message = (error as Record)?.response?.data?.error || 'Failed to move documents.'; - notifyApiError(error, message); - } - }, - [ - documentLookup, - folderLabelMap, - ensureFolderData, - selectedFolder, - setSearchResultIds, - setDocuments, - setSelectedEntries, - setSelectionOrder, - selectionOrderRef, - selectionAnchorRef, - setFocusedDocumentId, - focusedDocumentId, - setFocusedEntryKey, - focusedEntryKey, - notifyApiError, - showToast, - mapDocumentCaches, - ], - ); + const { + handleDocumentTagAdd, + handleDocumentTagAttach, + handleDocumentTagDetach, + } = useDocumentTagMutations({ + tagsState, + documentsState: { updateDocumentCaches: documentsState.updateDocumentCaches }, + }); const handleThumbnailRegeneration = useCallback( async (documentId: DocumentId) => { try { - await queueDocumentReanalysis(documentId, { force: true }); - showToast('Document re-analysis queued.', 'info'); - await refreshCurrentFolder(); + await queueDocumentReanalysis(documentId); + showToast('Analysis queued.', 'info'); + // Close preview if it's the current one to allow refresh? + if (previewDocumentId === documentId) { + actions.closeDocumentPreview(); + } } catch (error) { - const message = (error as Record)?.response?.data?.error || 'Failed to request thumbnail generation.'; - notifyApiError(error, message); + notifyApiError(error, 'Failed to queue analysis.'); } }, - [refreshCurrentFolder, notifyApiError, showToast], + [actions, notifyApiError, previewDocumentId, showToast], ); const handleDocumentsDelete = useCallback( async (documentIds: DocumentId[], { showMessage = true }: MessageOptions = {}) => { - if (!documentIds || documentIds.length === 0) { - return false; - } + if (!documentIds?.length) return false; + // Optimistic update could happen here but usually we wait for standardized confirmation + // However workspace expects mutation here. try { - await Promise.all(documentIds.map((documentId) => trashDocument(documentId))); + // 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))); - removeDocumentsFromCaches(documentIds); - - if (previewDocumentId && documentIds.includes(previewDocumentId)) { - closeDocumentPreview(); - } + // Remove from local state + documentsState.removeDocumentsFromCaches(documentIds); if (showMessage) { - const message = documentIds.length === 1 ? 'Document deleted.' : 'Documents deleted.'; - showToast(message, 'success'); + const count = documentIds.length; + const suffix = count === 1 ? '' : 's'; + showToast(`${count} document${suffix} deleted.`, 'success'); } return true; } catch (error) { @@ -376,9 +136,7 @@ const useDocumentMutations = ({ } }, [ - removeDocumentsFromCaches, - previewDocumentId, - closeDocumentPreview, + documentsState, notifyApiError, showToast, ], @@ -393,12 +151,12 @@ const useDocumentMutations = ({ } try { const data = await updateDocument(documentId, { title: trimmed }); - const updatedDocument = extractDocumentFromResponse?.(data); + const updatedDocument = documentsState.extractDocumentFromResponse?.(data); - if (updatedDocument && ingestDocuments) { - ingestDocuments([updatedDocument]); + if (updatedDocument && documentsState.ingestDocuments) { + documentsState.ingestDocuments([updatedDocument]); } else { - updateDocumentCaches(documentId, (doc) => { + documentsState.updateDocumentCaches(documentId, (doc) => { if (updatedDocument) { return { ...doc, ...updatedDocument }; } @@ -415,11 +173,9 @@ const useDocumentMutations = ({ } }, [ - extractDocumentFromResponse, - ingestDocuments, + documentsState, notifyApiError, showToast, - updateDocumentCaches, ], ); @@ -428,12 +184,12 @@ const useDocumentMutations = ({ const payload = { issued_at: nextIssuedDate || null }; try { const data = await updateDocument(documentId, payload); - const updatedDocument = extractDocumentFromResponse?.(data); + const updatedDocument = documentsState.extractDocumentFromResponse?.(data); - if (updatedDocument && ingestDocuments) { - ingestDocuments([updatedDocument]); + if (updatedDocument && documentsState.ingestDocuments) { + documentsState.ingestDocuments([updatedDocument]); } else { - updateDocumentCaches(documentId, (doc) => { + documentsState.updateDocumentCaches(documentId, (doc) => { if (updatedDocument) { return { ...doc, ...updatedDocument }; } @@ -451,242 +207,14 @@ const useDocumentMutations = ({ } }, [ - extractDocumentFromResponse, - ingestDocuments, - notifyApiError, - showToast, - 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 addDocumentTags(documentId, [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] }; - }); - showToast('Tag assigned.', 'success'); - return true; - } catch (error) { - const message = (error as Record)?.response?.data?.error || 'Failed to assign tag.'; - notifyApiError(error, message); - return false; - } - }, - [notifyApiError, showToast, updateDocumentCaches], - ); - - const handleDocumentTagAdd = useCallback( - async (document: Document, label: string, extras: DocumentTagExtras | null = null) => { - const normalizedLabel = tagManager.normalizeLabel(label); - const optionCandidate = extras?.option ?? null; - const input = extras?.input ?? null; - - let tag: Tag | null = null; - if (optionCandidate && optionCandidate.id) { - tag = tags.find((item) => item.id === optionCandidate.id) || optionCandidate; - } - if (!tag) { - tag = tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null; - } - try { - if (!tag) { - const payload = tagManager.buildPayload({ label: normalizedLabel }) as { label: string; color?: string | null }; - const data = await createTag(payload); - tag = data as Tag; - await refreshTags(); - } - await attachTagToDocument({ - documentId: document.id as DocumentId, - tag, - }); - if (input && Object(input) === input && 'value' in (input as Record)) { - (input as { value?: string }).value = ''; - } - } catch (error) { - notifyApiError(error, 'Failed to assign tag.'); - } - }, - [tags, refreshTags, attachTagToDocument, notifyApiError, tagManager], - ); - - const handleDocumentTagAttach = useCallback( - async (documentId: DocumentId, tagId: DocumentId) => { - if (!documentId || !tagId) { - return false; - } - - const resolveTagForCache = (): Tag | null => { - const lookupTag = tagLookupById.get(tagId); - if (!lookupTag || lookupTag.id == null) { - return null; - } - const labelText = `${lookupTag.label ?? ''} `.trim(); - if (!labelText) { - return null; - } - return { - id: lookupTag.id, - label: labelText, - color: Object.prototype.hasOwnProperty.call(lookupTag, 'color') ? (lookupTag as Tag).color ?? null : null, - }; - }; - - const resolvedTag = resolveTagForCache(); - return attachTagToDocument({ - documentId, - tag: resolvedTag, - }); - }, - [ - attachTagToDocument, - tagLookupById, - ], - ); - - const applyTagRemovalToCaches = useCallback( - (documentId?: DocumentId, tagId?: DocumentId) => { - if (!documentId || !tagId) { - return; - } - - updateDocumentCaches(documentId, (doc) => { - if (!doc || !Array.isArray(doc.tags)) { - return doc; - } - const nextTags = doc.tags.filter((tagEntry) => tagEntry.id !== tagId); - if (nextTags.length === doc.tags.length) { - return doc; - } - return { ...doc, tags: nextTags }; - }); - }, - [updateDocumentCaches], - ); - - const handleDocumentTagDetach = useCallback( - async ( - documentId?: DocumentId, - tagId?: DocumentId, - ) => { - if (!documentId || !tagId) { - return false; - } - - try { - await deleteDocumentTag(documentId, tagId); - applyTagRemovalToCaches(documentId, tagId); - showToast('Tag removed.', 'success'); - return true; - } catch (error) { - const message = (error as Record)?.response?.data?.error || 'Failed to remove tag.'; - notifyApiError(error, message); - return false; - } - }, - [applyTagRemovalToCaches, notifyApiError, showToast], - ); - - const handleFolderDelete = useCallback( - async (folderId?: FolderId, { showMessage = true }: MessageOptions = {}) => { - if (!folderId || folderId === 'root') { - if (showMessage) { - showToast('The root folder cannot be removed.', 'error'); - } - return false; - } - - try { - const contents = await ensureFolderData(folderId); - const hasChildren = (contents.subfolders || []).length > 0; - const hasDocs = (contents.documents || []).length > 0; - if (hasChildren || hasDocs) { - if (showMessage) { - showToast('Folder must be empty before it can be deleted.', 'error'); - } - return false; - } - - await deleteFolder(folderId); - - setFolderNodes((prev: Map) => { - const next = new Map(prev); - const node = next.get(folderId); - next.delete(folderId); - if (node) { - const parentId = node.parentId || 'root'; - const parentNode = next.get(parentId); - if (parentNode) { - const remaining = parentNode.children.filter((id) => id !== folderId); - next.set(parentId, { - ...parentNode, - children: remaining, - hasChildren: remaining.length > 0, - }); - } - } - }); - - // setFolderContents removed - - if (selectedFolder === folderId) { - const node = folderNodes.get(folderId); - const parentId = node?.parentId || 'root'; - setSelectedFolder(parentId); - // ensureFolderData(parentId) will be called by useDocumentsWorkspace effect when selectedFolder changes - } else if (selectedFolder !== 'root') { - // If deleted folder was not selected, just check if we need to refresh (maybe redundant) - await ensureFolderData(selectedFolder); - } - - if (showMessage) { - showToast('Folder deleted.', 'success'); - } - return true; - } catch (error) { - const message = (error as Record)?.response?.data?.error || 'Failed to delete folder.'; - notifyApiError(error, message); - if (showMessage) { - showToast(message, 'error'); - } - return false; - } - }, - [ - ensureFolderData, - selectedFolder, - folderNodes, - setSelectedFolder, - setFolderNodes, + documentsState, notifyApiError, showToast, ], ); + // handleFolderDelete is removed from here + return { moveDocumentsToFolder, handleThumbnailRegeneration, @@ -696,7 +224,6 @@ const useDocumentMutations = ({ handleDocumentTitleUpdate, handleDocumentIssuedUpdate, handleDocumentTagDetach, - handleFolderDelete, }; }; diff --git a/frontend/src/documents/data/useDocumentTagMutations.ts b/frontend/src/documents/data/useDocumentTagMutations.ts new file mode 100644 index 0000000..2941553 --- /dev/null +++ b/frontend/src/documents/data/useDocumentTagMutations.ts @@ -0,0 +1,178 @@ +import { useCallback } from 'react'; +import type { DocumentId } from '../../types/identifiers'; +import type { Document } from '../../types/documents'; +import { + addDocumentTags, + createTag, + deleteDocumentTag, +} from '../../lib/api/apiClient'; +import useNotifyApiError from '../../hooks/useNotifyApiError'; +import { useStatusToast } from '../../lib/context/StatusToastContext'; +import type { + TagsState, + DocumentsState, + Tag, +} from '../types/workspaceTypes'; + +interface DocumentTagExtras { + option?: Tag | null; + input?: { value?: string } | null; +} + +interface UseDocumentTagMutationsArgs { + tagsState: TagsState; + documentsState: Pick; +} + +export const useDocumentTagMutations = ({ + tagsState, + documentsState, +}: UseDocumentTagMutationsArgs) => { + const { showToast } = useStatusToast(); + const notifyApiError = useNotifyApiError(); + + 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 addDocumentTags(documentId, [cachedTag.id]); + documentsState.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] }; + }); + showToast('Tag assigned.', 'success'); + return true; + } catch (error) { + const message = (error as Record)?.response?.data?.error || 'Failed to assign tag.'; + notifyApiError(error, message); + return false; + } + }, + [notifyApiError, showToast, documentsState], + ); + + const handleDocumentTagAdd = useCallback( + async (document: Document, label: string, extras?: DocumentTagExtras | null) => { + const normalizedLabel = tagsState.tagManager.normalizeLabel(label); + const optionCandidate = extras?.option ?? null; + const input = extras?.input ?? null; + + let tag: Tag | null = null; + if (optionCandidate && optionCandidate.id) { + tag = tagsState.tags.find((item) => item.id === optionCandidate.id) || optionCandidate; + } + if (!tag) { + tag = tagsState.tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null; + } + try { + if (!tag) { + const payload = tagsState.tagManager.buildPayload({ label: normalizedLabel }) as { label: string; color?: string | null }; + const data = await createTag(payload); + tag = data as Tag; + await tagsState.refreshTags(); + } + await attachTagToDocument({ + documentId: document.id as DocumentId, + tag, + }); + if (input && typeof input === 'object' && 'value' in input) { + (input as { value?: string }).value = ''; + } + } catch (error) { + notifyApiError(error, 'Failed to assign tag.'); + } + }, + [tagsState, attachTagToDocument, notifyApiError], + ); + + const handleDocumentTagAttach = useCallback( + async (documentId: DocumentId, tagId: DocumentId) => { + if (!documentId || !tagId) { + return false; + } + + const resolveTagForCache = (): Tag | null => { + const lookupTag = tagsState.tagLookupById.get(tagId); + if (!lookupTag || lookupTag.id == null) { + return null; + } + const labelText = `${lookupTag.label ?? ''} `.trim(); + if (!labelText) { + return null; + } + return { + id: lookupTag.id, + label: labelText, + color: Object.prototype.hasOwnProperty.call(lookupTag, 'color') ? (lookupTag as Tag).color ?? null : null, + }; + }; + + const resolvedTag = resolveTagForCache(); + return attachTagToDocument({ + documentId, + tag: resolvedTag, + }); + }, + [ + attachTagToDocument, + tagsState, + ], + ); + + const handleDocumentTagDetach = useCallback( + async (documentId?: DocumentId, tagId?: DocumentId) => { + if (!documentId || !tagId) { + return false; + } + + try { + await deleteDocumentTag(documentId, tagId); + // Inlined applyTagRemovalToCaches logic + documentsState.updateDocumentCaches(documentId, (doc) => { + if (!doc || !Array.isArray(doc.tags)) { + return doc; + } + const nextTags = doc.tags.filter((tagEntry) => tagEntry.id !== tagId); + if (nextTags.length === doc.tags.length) { + return doc; + } + return { ...doc, tags: nextTags }; + }); + showToast('Tag removed.', 'success'); + return true; + } catch (error) { + const message = (error as Record)?.response?.data?.error || 'Failed to remove tag.'; + notifyApiError(error, message); + return false; + } + }, + [documentsState, notifyApiError, showToast], + ); + + return { + handleDocumentTagAdd, + handleDocumentTagAttach, + handleDocumentTagDetach, + }; +}; diff --git a/frontend/src/documents/data/useDocumentsWorkspace.ts b/frontend/src/documents/data/useDocumentsWorkspace.ts index ca5bdab..cf064e0 100644 --- a/frontend/src/documents/data/useDocumentsWorkspace.ts +++ b/frontend/src/documents/data/useDocumentsWorkspace.ts @@ -214,7 +214,7 @@ const useDocumentsWorkspace = ({ } const tagManager = tagManagerRef.current; - const selection = useWorkspaceSelection(); + const selectionState = useWorkspaceSelection(); const { selectedEntries, @@ -234,7 +234,7 @@ const useDocumentsWorkspace = ({ clearSelection, promoteSelectionOrder: promoteSelectionOrderRaw, configureSelectionEnvironment, - } = selection; + } = selectionState; const { documents, @@ -247,18 +247,22 @@ const useDocumentsWorkspace = ({ fetchDocumentById, }); - const foldersManagerRef = useRef(null); - if (!foldersManagerRef.current) { - foldersManagerRef.current = new FoldersManager(); - } - const foldersManager = foldersManagerRef.current; - const documentLookup = useSyncExternalStore( (onStoreChange) => documentsManager.subscribe(onStoreChange), () => documentsManager.getSnapshot(), () => documentsManager.getSnapshot(), ); + const foldersManagerRef = useRef(null); + if (!foldersManagerRef.current) { + foldersManagerRef.current = new FoldersManager(); + } + const foldersManager = foldersManagerRef.current; + + const folderStateRaw = useFolderTree({ + initialSelectedFolder: routeFolderId || 'root', + foldersManager, + }); const { folderNodes, setFolderNodes, @@ -266,21 +270,75 @@ const useDocumentsWorkspace = ({ setSelectedFolder, currentFolderName, folderOptions, - folderLabelMap, isInvalidFolderDrop, - } = useFolderTree({ - initialSelectedFolder: routeFolderId || 'root', - foldersManager, - }); + } = folderStateRaw; + + const folderState = { + ...folderStateRaw, + setCreatingFolder, + }; const [currentSubfolders, setCurrentSubfolders] = useState>([]); - const ensureFolderData = useCallback( + const reconcileSelectionWithFolderData = useCallback( + (currentSelection: string[], docs: Document[], subfolders: any[]) => { + const availableDocKeys = docs + .map((doc) => createDocumentEntryKey(doc?.id as Identifier)) + .filter(Boolean); + const availableDocKeySet = new Set(availableDocKeys); + const availableFolderKeys = new Set( + subfolders + .map((folder) => createFolderEntryKey(folder?.id as Identifier)) + .filter(Boolean), + ); + + const previousFolderKeys = currentSelection + .filter(isFolderEntry) + .filter((key) => availableFolderKeys.has(key)); + const previousDocKeys = currentSelection.filter(isDocumentEntry); + const nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key)); + return [...previousFolderKeys, ...nextDocKeys]; + }, + [], + ); + + const selectedFolderRef = useRef(selectedFolder); + useEffect(() => { + selectedFolderRef.current = selectedFolder; + }, [selectedFolder]); + + const updateViewState = useCallback( + (folderId: FolderNodeId, data: any, includeDocuments: boolean) => { + // Guard against race conditions: only update if the folder is still selected + if (folderId === selectedFolderRef.current) { + if (includeDocuments) { + setDocuments((data.documents || []) as Document[]); + } + setCurrentSubfolders((data.subfolders || []) as any[]); + + if (includeDocuments) { + setSelectedEntries((prev) => reconcileSelectionWithFolderData( + prev, + (data.documents || []) as Document[], + (data.subfolders || []) as any[] + )); + } + } + }, + [ + setDocuments, + setSelectedEntries, + setCurrentSubfolders, + reconcileSelectionWithFolderData, + selectedFolderRef, + ] + ); + + const fetchFolderData = useCallback( async ( folderId: FolderNodeId, options: { includeDocuments?: boolean } = {} ) => { - const path = folderId === 'root' ? 'root' : folderId; const includeDocuments = options.includeDocuments ?? true; const params: Record = { @@ -290,61 +348,25 @@ const useDocumentsWorkspace = ({ }; const data = await listFolderContents(path, params); - - // Only update UI state if we are fetching for the currently selected folder - if (folderId === selectedFolder) { - // Update documents state if included - if (includeDocuments) { - setDocuments((data.documents || []) as Document[]); - } - setCurrentSubfolders((data.subfolders || []) as any[]); - - // Update selection state based on new documents - if (includeDocuments) { - const docs = (data.documents || []) as Document[]; - const subfolders = (data.subfolders || []) as any[]; - - const availableDocKeys = docs - .map((doc) => createDocumentEntryKey(doc?.id as Identifier)) - .filter(Boolean); - const availableDocKeySet = new Set(availableDocKeys); - const availableFolderKeys = new Set( - subfolders - .map((folder) => createFolderEntryKey(folder?.id as Identifier)) - .filter(Boolean), - ); - - setSelectedEntries((previous) => { - const previousFolderKeys = previous - .filter(isFolderEntry) - .filter((key) => availableFolderKeys.has(key)); - const previousDocKeys = previous.filter(isDocumentEntry); - const nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key)); - const mergedSelection = [...previousFolderKeys, ...nextDocKeys]; - return mergedSelection; - }); - } - } - return data; // Return data for consumers (e.g. useDocumentMutations) - + return { data, includeDocuments }; }, [ activeSortFieldRef, activeSortDirectionRef, - setDocuments, - setSelectedEntries, - setCurrentSubfolders, - selectedFolder, ] ); useEffect(() => { if (selectedFolder) { - ensureFolderData(selectedFolder).catch((error) => { - notifyApiError(error, 'Failed to fetch folder contents'); - }); + fetchFolderData(selectedFolder) + .then(({ data, includeDocuments }) => { + updateViewState(selectedFolder, data, includeDocuments); + }) + .catch((error) => { + notifyApiError(error, 'Failed to fetch folder contents'); + }); } - }, [selectedFolder, documentsSortField, documentsSortDirection, ensureFolderData, notifyApiError]); + }, [selectedFolder, documentsSortField, documentsSortDirection, fetchFolderData, updateViewState, notifyApiError]); const { searchQuery, @@ -472,6 +494,12 @@ const useDocumentsWorkspace = ({ selectionInitializedRef, }); + const tagsStateRaw = useTags({ + tenantIdRef, + tagManager, + setActiveTagFilters, + mapDocumentCaches, + }); const { tags, refreshTags, @@ -479,13 +507,9 @@ const useDocumentsWorkspace = ({ handleTagUpdate, handleTagDelete, setTags, - } = useTags({ - tenantIdRef, - tagManager, - setActiveTagFilters, - mapDocumentCaches, - }); + } = tagsStateRaw; + // tagLookupById is derived locally useEffect(() => { tenantIdRef.current = currentTenantId; }, [currentTenantId, tenantIdRef]); @@ -500,6 +524,16 @@ const useDocumentsWorkspace = ({ return map; }, [tags]); + const tagsState = { + ...tagsStateRaw, + tagLookupById, // Add derived lookup + tagManager, + }; + + const correspondentsStateRaw = useCorrespondents({ + tenantIdRef, + mapDocumentCaches, + }); const { correspondents, refreshCorrespondents, @@ -507,10 +541,7 @@ const useDocumentsWorkspace = ({ handleCorrespondentUpdate, handleCorrespondentDelete, setCorrespondents, - } = useCorrespondents({ - tenantIdRef, - mapDocumentCaches, - }); + } = correspondentsStateRaw; const { correspondentLookupByName, @@ -549,9 +580,10 @@ const useDocumentsWorkspace = ({ const refreshCurrentFolder = useCallback(async () => { if (selectedFolder) { - await ensureFolderData(selectedFolder); + const { data, includeDocuments } = await fetchFolderData(selectedFolder); + updateViewState(selectedFolder, data, includeDocuments); } - }, [selectedFolder, ensureFolderData]); + }, [selectedFolder, fetchFolderData, updateViewState]); const { handleBulkTagAddFromDetail, @@ -575,7 +607,6 @@ const useDocumentsWorkspace = ({ } = useDocumentUploads({ selectedFolder, currentFolderName, - ensureFolderData, refreshCurrentFolder, shellRef, }); @@ -686,6 +717,22 @@ const useDocumentsWorkspace = ({ ], ); + const documentsState = { + documentLookup, + setDocuments, + setSearchResultIds, + removeDocumentsFromCaches, + updateDocumentCaches, + mapDocumentCaches, + extractDocumentFromResponse, + ingestDocuments: (docs: unknown[]) => documentsManager.ingest(docs), + }; + + const actionsState = { + refreshCurrentFolder, + closeDocumentPreview, + }; + const { moveDocumentsToFolder, handleThumbnailRegeneration, @@ -696,37 +743,21 @@ const useDocumentsWorkspace = ({ handleDocumentIssuedUpdate, handleDocumentTagDetach, } = useDocumentMutations({ - documentLookup, - folderLabelMap, - ensureFolderData, - selectedFolder, - setSelectedFolder, - setDocuments, - setSearchResultIds, - setSelectedEntries, - setSelectionOrder, - selectionOrderRef, - selectionAnchorRef, - setFocusedDocumentId, - focusedDocumentId, - setFocusedEntryKey, - focusedEntryKey, - mapDocumentCaches, - folderNodes, - setFolderNodes, - removeDocumentsFromCaches, - closeDocumentPreview, + documentsState, + folderState, + selectionState, + tagsState, + actions: actionsState, previewDocumentId, - refreshCurrentFolder, - updateDocumentCaches, - tagLookupById, - tags, - refreshTags, - tagManager, - extractDocumentFromResponse, - ingestDocuments: (docs) => documentsManager.ingest(docs), }); + const dragState = { + draggedDocumentIds, + draggedFolderId, + setDraggedDocumentIds, + setDraggedFolderId, + }; + const { loadFolder, selectFolder, @@ -735,18 +766,15 @@ const useDocumentsWorkspace = ({ handleFolderDelete, folderClickHandlers, } = useFolderTreeActions({ - folderNodes, - setFolderNodes, - selectedFolder, - setSelectedFolder, - handleFileDrop, - moveDocumentsToFolder, - draggedDocumentIds, - draggedFolderId, - setDraggedDocumentIds, - setDraggedFolderId, - isInvalidFolderDrop, - setCreatingFolder, + folderState, + dragState, + actions: { + handleFileDrop, + moveDocumentsToFolder, + }, + utils: { + isInvalidFolderDrop, + }, }); const { @@ -1017,7 +1045,6 @@ const useDocumentsWorkspace = ({ documents: viewDocuments, documentLookup, folderNodes, - ensureFolderData, detailPanelControlRef, detailFolderFetchRef, previewDocumentId, @@ -1188,7 +1215,7 @@ const useDocumentsWorkspace = ({ handleDeleteSelection, handleEntryPointerCore, handleBulkSelectionReanalyze, - selectionValue: selection, + selectionValue: selectionState, }; const documentMutations = { diff --git a/frontend/src/documents/features/folders/useFolderTreeActions.ts b/frontend/src/documents/features/folders/useFolderTreeActions.ts index 7ee912e..4eaf1e2 100644 --- a/frontend/src/documents/features/folders/useFolderTreeActions.ts +++ b/frontend/src/documents/features/folders/useFolderTreeActions.ts @@ -10,7 +10,7 @@ import { renameFolder as renameFolderRequest, } from '../../../lib/api/apiClient'; import type { FolderId } from '../../../types/identifiers'; -import type { MessageOptions, FolderNode } from '../../../types/documents'; +import type { MessageOptions } from '../../../types/documents'; type FolderKey = FolderId | 'root'; @@ -32,35 +32,51 @@ interface FolderClickHandlers { import useNotifyApiError from '../../../hooks/useNotifyApiError'; +import type { + FolderState, + DragState, + FolderNode, +} from '../../types/workspaceTypes'; + interface UseFolderTreeActionsOptions { - folderNodes: Map; - setFolderNodes: (updater: (prev: Map) => Map) => void; - selectedFolder: FolderKey; - setSelectedFolder: (folderId: FolderKey) => void; - handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderKey) => Promise | void; - moveDocumentsToFolder: (docIds: FolderId[], folderId: FolderKey) => Promise; - draggedDocumentIds: FolderId[]; - draggedFolderId: FolderKey | null; - setDraggedDocumentIds: (ids: FolderId[]) => void; - setDraggedFolderId: (id: FolderKey | null) => void; - isInvalidFolderDrop: (sourceFolderId: FolderKey, targetFolderId: FolderKey) => boolean; - setCreatingFolder: (value: boolean) => void; + folderState: Pick; + dragState: DragState; + actions: { + handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderKey) => Promise | void; + moveDocumentsToFolder: (docIds: FolderId[], folderId: FolderKey) => Promise; + }; + utils: { + isInvalidFolderDrop: (sourceFolderId: FolderKey, targetFolderId: FolderKey) => boolean; + }; } const useFolderTreeActions = ({ - folderNodes, - setFolderNodes, - selectedFolder, - setSelectedFolder, - handleFileDrop, - moveDocumentsToFolder, - draggedDocumentIds, - draggedFolderId, - setDraggedDocumentIds, - setDraggedFolderId, - isInvalidFolderDrop, - setCreatingFolder, + folderState, + dragState, + actions, + utils, }: UseFolderTreeActionsOptions) => { + const { + folderNodes, + setFolderNodes, + selectedFolder, + setSelectedFolder, + setCreatingFolder, + } = folderState; + + const { + draggedDocumentIds, + draggedFolderId, + setDraggedDocumentIds, + setDraggedFolderId, + } = dragState; + + const { + handleFileDrop, + moveDocumentsToFolder, + } = actions; + + const { isInvalidFolderDrop } = utils; const { showToast } = useStatusToast(); const notifyApiError = useNotifyApiError(); const navigate = useNavigate(); @@ -85,7 +101,7 @@ const useFolderTreeActions = ({ try { await moveFolderRequest(folderId, parent_id); - setFolderNodes((prev) => { + setFolderNodes((prev: Map) => { const next = new Map(prev); const currentNode = next.get(folderId); if (!currentNode) { @@ -154,7 +170,6 @@ const useFolderTreeActions = ({ async (folderId: FolderKey | null, { preserveSearch: _preserveSearch = false }: LoadFolderOptions = {}) => { const targetId = folderId || 'root'; setSelectedFolder(targetId); - // Data fetching is now reactive in the parent component based on selectedFolder }, [setSelectedFolder], ); @@ -187,7 +202,7 @@ const useFolderTreeActions = ({ try { await renameFolderRequest(folderId, trimmed); - setFolderNodes((prev) => { + setFolderNodes((prev: Map) => { const next = new Map(prev); const node = next.get(folderId); if (node) { @@ -235,7 +250,7 @@ const useFolderTreeActions = ({ throw new Error('Folder creation failed.'); } showToast('Folder created.', 'success'); - setFolderNodes((prev) => { + setFolderNodes((prev: Map) => { const next = new Map(prev); const parentId = folderData.parent_id ?? payload.parent_id ?? 'root'; const parentNode = next.get(parentId); @@ -295,7 +310,7 @@ const useFolderTreeActions = ({ try { await deleteFolder(folderId); - setFolderNodes((prev) => { + setFolderNodes((prev: Map) => { const next = new Map(prev); const node = next.get(folderId); next.delete(folderId); diff --git a/frontend/src/documents/features/upload/useDocumentUploads.ts b/frontend/src/documents/features/upload/useDocumentUploads.ts index 7c5c32e..38377f9 100644 --- a/frontend/src/documents/features/upload/useDocumentUploads.ts +++ b/frontend/src/documents/features/upload/useDocumentUploads.ts @@ -3,7 +3,7 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import useFileDrop from './useFileDrop'; import { useStatusToast } from '../../../lib/context/StatusToastContext'; import { DEFAULT_FOLDER_NAME, hasFiles } from '../../../app/workspaceUtils'; -import { fetchDocument, uploadDocument, resolveFolderPath } from '../../../lib/api/apiClient'; +import { fetchDocument, uploadDocument, resolveFolderPath, listFolderContents } from '../../../lib/api/apiClient'; import type { Identifier } from '../../../types/identifiers'; type FolderId = Identifier | 'root' | null; @@ -93,7 +93,6 @@ const mapFilesToEntries = (filesInput?: FileList | File[] | null): FileEntry[] = interface UseDocumentUploadsArgs { selectedFolder?: FolderId; currentFolderName?: string | null; - ensureFolderData: (folderId: FolderId, options?: { [key: string]: unknown }) => Promise; refreshCurrentFolder: () => Promise; shellRef: MutableRefObject; notifyApiError?: NotifyApiError; @@ -122,7 +121,6 @@ import useNotifyApiError from '../../../hooks/useNotifyApiError'; const useDocumentUploads = ({ selectedFolder, currentFolderName, - ensureFolderData, refreshCurrentFolder, shellRef, }: UseDocumentUploadsArgs): UseDocumentUploadsResult => { @@ -426,7 +424,7 @@ const useDocumentUploads = ({ targetFolderId !== 'root' && targetFolderId !== selectedFolder ) { - await ensureFolderData(targetFolderId); + await listFolderContents(targetFolderId); } } catch (error: any) { const message = error.message || 'Failed to upload files.'; @@ -450,7 +448,6 @@ const useDocumentUploads = ({ uploadFile, refreshCurrentFolder, selectedFolder, - ensureFolderData, appendQueueItems, updateQueueItem, ], diff --git a/frontend/src/documents/types/workspaceTypes.ts b/frontend/src/documents/types/workspaceTypes.ts new file mode 100644 index 0000000..678c1ed --- /dev/null +++ b/frontend/src/documents/types/workspaceTypes.ts @@ -0,0 +1,95 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import type { DocumentId, FolderId as FolderIdentifier } from '../../types/identifiers'; +import type { Document } from '../../types/documents'; + +type FolderId = FolderIdentifier | 'root'; + +export interface FolderNode { + id: FolderId; + parentId?: FolderId; + children: FolderId[]; + hasChildren?: boolean; + name?: string; + expanded?: boolean; + loaded?: boolean; +} + +export interface Tag { + id: DocumentId; + label: string; + color?: string | null; +} + +interface TagManager { + normalizeLabel: (label: string) => string; + buildPayload: (args: { label: string }) => Record; +} + +type DocumentCacheMapper = ( + doc: Document | null, +) => Document | null; + +type MapDocumentCaches = (mapper: DocumentCacheMapper) => void; + +type UpdateDocumentCaches = ( + documentId: DocumentId, + updater: DocumentCacheMapper, +) => void; + + + +type RemoveDocumentsFromCaches = (documentIds: DocumentId[]) => void; + +type CloseDocumentPreview = () => void; + +export interface DocumentsState { + documentLookup: Map; + setDocuments: Dispatch>; + setSearchResultIds: Dispatch>; + removeDocumentsFromCaches: RemoveDocumentsFromCaches; + updateDocumentCaches: UpdateDocumentCaches; + mapDocumentCaches: MapDocumentCaches; + extractDocumentFromResponse?: (payload: unknown) => Document | null; + ingestDocuments?: (docs: unknown[]) => { canonical: Document[]; changed: boolean }; +} + +export interface FolderState { + folderNodes: Map; + setFolderNodes: Dispatch>>; + selectedFolder: FolderId; + setSelectedFolder: Dispatch>; + folderLabelMap: Map; + setCreatingFolder?: (value: boolean) => void; +} + +export interface SelectionState { + setSelectedEntries: Dispatch>; + setSelectionOrder: Dispatch>; + selectionOrderRef: MutableRefObject; + selectionAnchorRef: MutableRefObject; + setFocusedDocumentId: Dispatch>; + focusedDocumentId: DocumentId | null; + setFocusedEntryKey: Dispatch>; + focusedEntryKey: string | null; +} + +export interface TagsState { + tags: Tag[]; + tagLookupById: Map; + refreshTags: () => Promise; + tagManager: TagManager; +} + +export interface ActionsState { + refreshCurrentFolder: () => Promise; + closeDocumentPreview: CloseDocumentPreview; + handleFileDrop?: (dataTransfer: DataTransfer, folderId: FolderId) => Promise | void; + moveDocumentsToFolder?: (docIds: FolderId[], folderId: FolderId) => Promise; +} + +export interface DragState { + draggedDocumentIds: FolderId[]; + draggedFolderId: FolderId | null; + setDraggedDocumentIds: (ids: FolderId[]) => void; + setDraggedFolderId: (id: FolderId | null) => void; +} diff --git a/frontend/src/viewer/logic/useDetailWorkspace.ts b/frontend/src/viewer/logic/useDetailWorkspace.ts index de6bea5..b7544b8 100644 --- a/frontend/src/viewer/logic/useDetailWorkspace.ts +++ b/frontend/src/viewer/logic/useDetailWorkspace.ts @@ -18,7 +18,6 @@ interface UseDetailWorkspaceArgs { documents: Document[]; documentLookup: Map; folderNodes: Map; - ensureFolderData: (folderId: Identifier | 'root', options?: { force?: boolean; includeDocuments?: boolean }) => Promise; detailPanelControlRef: MutableRefObject<{ open?: (documentId: Identifier) => void; close?: () => void } | null>; detailFolderFetchRef: MutableRefObject>; previewDocumentId?: Identifier | null; @@ -51,11 +50,12 @@ interface UseDetailWorkspaceResult { resolveFolderPath: (folderId?: Identifier | 'root') => Array<{ id: Identifier | 'root'; name: string }>; } +import { listFolderContents } from '../../lib/api/apiClient'; + const useDetailWorkspace = ({ documents, documentLookup, folderNodes, - ensureFolderData, detailPanelControlRef, detailFolderFetchRef, previewDocumentId, @@ -118,7 +118,7 @@ const useDetailWorkspace = ({ if (!node) { if (!detailFolderFetchRef.current.has(currentId)) { detailFolderFetchRef.current.add(currentId); - ensureFolderData(currentId, { force: false, includeDocuments: false }) + listFolderContents(currentId, { include_documents: false }) .catch((error) => { console.warn('Failed to preload folder metadata for detail path', currentId, error); }) @@ -135,7 +135,7 @@ const useDetailWorkspace = ({ } currentId = parentId; } - }, [detailPanelDocument, folderNodes, ensureFolderData, detailFolderFetchRef]); + }, [detailPanelDocument, folderNodes, detailFolderFetchRef]); const resolveFolderPath = useCallback( (folderId) => { @@ -198,7 +198,6 @@ const useDetailWorkspace = ({ onClose: handleDetailPanelClose, resolveFolderPath, folderNodes, - ensureFolderData, }), [ activePreviewId, @@ -214,7 +213,6 @@ const useDetailWorkspace = ({ handleDocumentTitleUpdate, handleDocumentTagDetach, folderNodes, - ensureFolderData, openDocumentPreview, resolveFolderPath, selectFolder,