From 3c1c910dc936cac8e48b13512fcaace841297064 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 9 Nov 2025 19:08:24 +0100 Subject: [PATCH] cleanup --- frontend/src/app/AppLayout.jsx | 65 +- frontend/src/app/DocumentsAppLayout.jsx | 64 - frontend/src/app/useDocumentsWorkspace.js | 1 - frontend/src/desktop/useDeskWorkspaceProps.js | 107 +- frontend/src/detail/useDetailWorkspace.js | 300 ++++ .../documents/hooks/useBulkDocumentActions.js | 207 +++ .../documents/hooks/useDocumentsPanelProps.js | 176 ++ .../documents/hooks/useDocumentsSelection.js | 122 ++ .../hooks/documents/useDocumentMutations.js | 633 +++++++ .../hooks/documents/useDocumentsWorkspace.js | 1582 +++-------------- .../hooks/documents/useFolderTreeActions.js | 618 +++++++ frontend/src/sidebar/useSidebarProps.js | 117 ++ 12 files changed, 2561 insertions(+), 1431 deletions(-) delete mode 100644 frontend/src/app/DocumentsAppLayout.jsx delete mode 100644 frontend/src/app/useDocumentsWorkspace.js create mode 100644 frontend/src/detail/useDetailWorkspace.js create mode 100644 frontend/src/documents/hooks/useBulkDocumentActions.js create mode 100644 frontend/src/documents/hooks/useDocumentsPanelProps.js create mode 100644 frontend/src/documents/hooks/useDocumentsSelection.js create mode 100644 frontend/src/hooks/documents/useDocumentMutations.js create mode 100644 frontend/src/hooks/documents/useFolderTreeActions.js create mode 100644 frontend/src/sidebar/useSidebarProps.js diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index 3f6cd7a..ff1d38f 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -1 +1,64 @@ -export { default } from './DocumentsAppLayout'; +import React from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; +import { AppShellContext } from '../appShellContext'; +import DropOverlay from './DropOverlay'; +import useDocumentsWorkspace from '../hooks/documents/useDocumentsWorkspace'; +import { useDocumentsPreferences } from './useDocumentsPreferences'; + +const AppLayout = () => { + const documentsPreferences = useDocumentsPreferences(); + const { + appStatus, + location, + shellRef, + dropOverlayState, + managementModals, + contextValue, + } = useDocumentsWorkspace({ + documentsViewMode: documentsPreferences.documentsViewMode, + documentsSortField: documentsPreferences.documentsSortField, + documentsSortDirection: documentsPreferences.documentsSortDirection, + documentsSortFieldRef: documentsPreferences.documentsSortFieldRef, + documentsSortDirectionRef: documentsPreferences.documentsSortDirectionRef, + onDocumentsViewModeChange: documentsPreferences.handleDocumentsViewModeChange, + onDocumentsSortFieldChange: documentsPreferences.handleDocumentsSortFieldChange, + onDocumentsSortDirectionToggle: documentsPreferences.handleDocumentsSortDirectionToggle, + searchIncludeDescendants: documentsPreferences.searchIncludeDescendants, + onToggleSearchIncludeDescendants: documentsPreferences.toggleSearchIncludeDescendants, + onSetSearchIncludeDescendants: documentsPreferences.setSearchIncludeDescendants, + sortRefreshReadyRef: documentsPreferences.sortRefreshReadyRef, + deskHelpOpen: documentsPreferences.deskHelpOpen, + setDeskHelpOpen: documentsPreferences.setDeskHelpOpen, + handleDeskExit: documentsPreferences.handleDeskExit, + }); + + if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) { + const shouldRememberLastLocation = appStatus !== 'logged-out'; + return ( + + ); + } + + return ( + +
+ + + {managementModals} +
+
+ ); +}; + +export default AppLayout; diff --git a/frontend/src/app/DocumentsAppLayout.jsx b/frontend/src/app/DocumentsAppLayout.jsx deleted file mode 100644 index 05aece8..0000000 --- a/frontend/src/app/DocumentsAppLayout.jsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react'; -import { Navigate, Outlet } from 'react-router-dom'; -import { AppShellContext } from '../appShellContext'; -import DropOverlay from './DropOverlay'; -import useDocumentsWorkspace from './useDocumentsWorkspace'; -import { useDocumentsPreferences } from './useDocumentsPreferences'; - -const DocumentsAppLayout = () => { - const documentsPreferences = useDocumentsPreferences(); - const { - appStatus, - location, - shellRef, - dropOverlayState, - managementModals, - contextValue, - } = useDocumentsWorkspace({ - documentsViewMode: documentsPreferences.documentsViewMode, - documentsSortField: documentsPreferences.documentsSortField, - documentsSortDirection: documentsPreferences.documentsSortDirection, - documentsSortFieldRef: documentsPreferences.documentsSortFieldRef, - documentsSortDirectionRef: documentsPreferences.documentsSortDirectionRef, - onDocumentsViewModeChange: documentsPreferences.handleDocumentsViewModeChange, - onDocumentsSortFieldChange: documentsPreferences.handleDocumentsSortFieldChange, - onDocumentsSortDirectionToggle: documentsPreferences.handleDocumentsSortDirectionToggle, - searchIncludeDescendants: documentsPreferences.searchIncludeDescendants, - onToggleSearchIncludeDescendants: documentsPreferences.toggleSearchIncludeDescendants, - onSetSearchIncludeDescendants: documentsPreferences.setSearchIncludeDescendants, - sortRefreshReadyRef: documentsPreferences.sortRefreshReadyRef, - deskHelpOpen: documentsPreferences.deskHelpOpen, - setDeskHelpOpen: documentsPreferences.setDeskHelpOpen, - handleDeskExit: documentsPreferences.handleDeskExit, - }); - - if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) { - const shouldRememberLastLocation = appStatus !== 'logged-out'; - return ( - - ); - } - - return ( - -
- - - {managementModals} -
-
- ); -}; - -export default DocumentsAppLayout; diff --git a/frontend/src/app/useDocumentsWorkspace.js b/frontend/src/app/useDocumentsWorkspace.js deleted file mode 100644 index d3bf8a1..0000000 --- a/frontend/src/app/useDocumentsWorkspace.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '../hooks/documents/useDocumentsWorkspace'; diff --git a/frontend/src/desktop/useDeskWorkspaceProps.js b/frontend/src/desktop/useDeskWorkspaceProps.js index 6d22c1f..32782c5 100644 --- a/frontend/src/desktop/useDeskWorkspaceProps.js +++ b/frontend/src/desktop/useDeskWorkspaceProps.js @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; const useDeskWorkspaceProps = ({ documents, @@ -9,16 +9,10 @@ const useDeskWorkspaceProps = ({ handleDocumentsViewModeChange, handleDeskExit, refreshCurrentFolder, - handleDeskDocumentOpen, inspectDocument, handleEntryPointerCore, - handleDeskDocumentStackSelect, promoteSelectionOrder, - handleDeskHelpOpen, - deskHelpOpen, - handleDeskHelpClose, currentTenantId, - deskViewId, selectedDocumentIds, selectedFolderIds, clearDocumentSelection, @@ -44,8 +38,102 @@ const useDeskWorkspaceProps = ({ moveDocumentsToFolder, searchIncludeDescendants, toggleSearchIncludeDescendants, -}) => - useMemo( + selectedEntries, + selectionAnchorRef, + applySelection, + resolveDocumentRowKey, + showingSearchResults, + searchQuery, + activeCorrespondentFilters, + selectedFolder, + setDeskHelpOpen, + deskHelpOpen, + openDetailPanel, +}) => { + const handleDeskDocumentStackSelect = useCallback( + (docIds) => { + if (!Array.isArray(docIds) || docIds.length === 0) { + return; + } + + const rowKeys = docIds + .map((id) => resolveDocumentRowKey(id)) + .filter(Boolean); + + if (!rowKeys.length) { + return; + } + + const nextKeys = [...selectedEntries]; + rowKeys.forEach((key) => { + if (!nextKeys.includes(key)) { + nextKeys.push(key); + } + }); + + const anchor = rowKeys[0] + || selectionAnchorRef.current + || nextKeys[nextKeys.length - 1]; + + applySelection(nextKeys, { + anchor, + interactedKeys: rowKeys, + }); + }, + [applySelection, resolveDocumentRowKey, selectedEntries, selectionAnchorRef], + ); + + const handleDeskDocumentOpen = useCallback( + (docId, { useSelection = false } = {}) => { + const selectionDocIds = Array.isArray(selectedDocumentIds) + ? selectedDocumentIds + : []; + let targetIds = []; + + if ((useSelection || selectionDocIds.includes(docId)) && selectionDocIds.length) { + targetIds = selectionDocIds; + } else if (selectionDocIds.length) { + targetIds = selectionDocIds; + } else if (docId) { + targetIds = [docId]; + } + + if (!targetIds.length) { + return; + } + + openDetailPanel({ documentIds: targetIds }); + }, + [openDetailPanel, selectedDocumentIds], + ); + + const handleDeskHelpOpen = useCallback(() => { + setDeskHelpOpen(true); + }, [setDeskHelpOpen]); + + const handleDeskHelpClose = useCallback(() => { + setDeskHelpOpen(false); + }, [setDeskHelpOpen]); + + const deskViewId = useMemo(() => { + if (showingSearchResults) { + const trimmedQuery = searchQuery.trim(); + const tagsKey = [...activeTagFilters].sort().join(','); + const correspondentsKey = [...activeCorrespondentFilters].sort().join(','); + return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`; + } + + const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root'; + return `folder:${folderKey}`; + }, [ + showingSearchResults, + searchQuery, + activeTagFilters, + activeCorrespondentFilters, + selectedFolder, + ]); + + return useMemo( () => ({ documents, searchResults, @@ -137,5 +225,6 @@ const useDeskWorkspaceProps = ({ toggleSearchIncludeDescendants, ], ); +}; export default useDeskWorkspaceProps; diff --git a/frontend/src/detail/useDetailWorkspace.js b/frontend/src/detail/useDetailWorkspace.js new file mode 100644 index 0000000..4c74455 --- /dev/null +++ b/frontend/src/detail/useDetailWorkspace.js @@ -0,0 +1,300 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { resolveDocumentAssetUrl } from '../asset_manager'; +import { useDetailPanel } from '../app/useDetailPanel'; +import { + DEFAULT_FOLDER_NAME, + getRowId, + isDocumentRowKey, +} from '../app/appLayoutUtils'; + +const useDetailWorkspace = ({ + documents, + searchResults, + focusedDocumentId, + selectionOrder, + selectedDocumentIds, + documentLookup, + folderNodes, + ensureFolderData, + detailPanelControlRef, + detailFolderFetchRef, + previewEntries, + previewDocumentId, + activePreviewId, + openDocumentPreview, + promoteSelectionOrder, + handleDocumentTitleUpdate, + handleDocumentIssuedUpdate, + handleDocumentTagAdd, + handleTagRemove, + ensureAssetUrl, + getDocumentAsset, + ensurePreviewData, + correspondents, + handleCorrespondentAdd, + handleCorrespondentRemove, + resolveApiPath, + selectFolder, + tags, + tagLookupById, +}) => { + const selectedDocument = useMemo(() => { + if (!focusedDocumentId) { + return null; + } + const pool = searchResults ?? documents; + return pool.find((doc) => doc.id === focusedDocumentId) || null; + }, [focusedDocumentId, searchResults, documents]); + + const orderedSelectedDocuments = useMemo(() => { + const ordered = []; + const seen = new Set(); + + const pushDoc = (doc) => { + if (doc?.id && !seen.has(doc.id)) { + ordered.push(doc); + seen.add(doc.id); + } + }; + + selectionOrder.forEach((key) => { + if (!isDocumentRowKey(key)) { + return; + } + const docId = getRowId(key); + const doc = documentLookup.get(docId) || null; + pushDoc(doc); + }); + + selectedDocumentIds.forEach((docId) => { + if (seen.has(docId)) { + return; + } + const doc = documentLookup.get(docId) || null; + pushDoc(doc); + }); + + return ordered; + }, [selectionOrder, documentLookup, selectedDocumentIds]); + + const { + detailPanelOpen, + detailPanelDocument, + openDetailPanel, + closeDetailPanel, + } = useDetailPanel({ + documentLookup, + orderedSelectedDocuments, + }); + + useEffect(() => { + detailPanelControlRef.current = { + open: openDetailPanel, + close: closeDetailPanel, + }; + }, [detailPanelControlRef, openDetailPanel, closeDetailPanel]); + + useEffect(() => { + if (!orderedSelectedDocuments.length) { + return; + } + + const visited = new Set(); + + orderedSelectedDocuments.forEach((doc) => { + const folderId = doc?.folder_id; + if (!folderId) { + return; + } + + let currentId = folderId; + let guard = 0; + + while (currentId && currentId !== 'root' && guard < 32) { + guard += 1; + if (visited.has(currentId)) { + break; + } + visited.add(currentId); + + const node = folderNodes.get(currentId); + if (!node) { + if (!detailFolderFetchRef.current.has(currentId)) { + detailFolderFetchRef.current.add(currentId); + ensureFolderData(currentId, { force: false, includeDocuments: false }) + .catch((error) => { + console.warn('Failed to preload folder metadata for detail path', currentId, error); + }) + .finally(() => { + detailFolderFetchRef.current.delete(currentId); + }); + } + break; + } + + const parentId = node.parentId ?? 'root'; + if (!parentId || parentId === 'root') { + break; + } + currentId = parentId; + } + }); + }, [orderedSelectedDocuments, folderNodes, ensureFolderData, detailFolderFetchRef]); + + const resolveFolderPath = useCallback( + (folderId) => { + if (!folderId || folderId === 'root') { + return []; + } + + const segments = []; + const visited = new Set(); + let currentId = folderId; + let guard = 0; + + while (currentId && guard < 32 && !visited.has(currentId)) { + guard += 1; + visited.add(currentId); + + if (currentId === 'root') { + break; + } + + const node = folderNodes.get(currentId); + if (!node) { + segments.push({ id: currentId, name: '…' }); + break; + } + + segments.push({ id: node.id, name: node.name || 'Folder' }); + + const parentId = node.parentId ?? 'root'; + if (!parentId || parentId === 'root') { + segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); + break; + } + + currentId = parentId; + } + + if (!segments.some((segment) => segment.id === 'root')) { + segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); + } + + return segments.reverse(); + }, + [folderNodes], + ); + + const selectedPreviewEntry = useMemo(() => { + if (!selectedDocument) { + return null; + } + return previewEntries.get(selectedDocument.id) || null; + }, [selectedDocument, previewEntries]); + + const previewWorkspaceEntry = useMemo(() => { + if (!previewDocumentId) { + return null; + } + return previewEntries.get(previewDocumentId) || null; + }, [previewDocumentId, previewEntries]); + + const previewWorkspaceDocument = useMemo(() => { + if (!previewDocumentId) { + return null; + } + const pool = searchResults ?? documents; + return pool.find((doc) => doc.id === previewDocumentId) || null; + }, [previewDocumentId, searchResults, documents]); + + const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument); + + const resolveThumbnailUrlForDoc = useCallback( + (doc) => + resolveDocumentAssetUrl(doc, 'thumbnail', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }), + [ensureAssetUrl, getDocumentAsset], + ); + + const inspectDocument = useCallback( + (documentId) => { + if (!documentId) { + return; + } + openDetailPanel({ documentIds: [documentId] }); + }, + [openDetailPanel], + ); + + const handleDetailPanelClose = useCallback(() => { + closeDetailPanel(); + }, [closeDetailPanel]); + + const detailPanelProps = useMemo( + () => ({ + document: detailPanelDocument, + tags, + tagLookupById, + onTagAdd: handleDocumentTagAdd, + onTagRemove: handleTagRemove, + previewEntry: selectedPreviewEntry, + onOpenPreview: openDocumentPreview, + onPromoteSelection: promoteSelectionOrder, + activePreviewId, + onUpdateTitle: handleDocumentTitleUpdate, + onUpdateIssued: handleDocumentIssuedUpdate, + ensureAssetUrl, + getDocumentAsset, + ensurePreviewData, + correspondents, + onCorrespondentAdd: handleCorrespondentAdd, + onCorrespondentRemove: handleCorrespondentRemove, + resolveApiPath, + onFolderNavigate: selectFolder, + onClose: handleDetailPanelClose, + resolveFolderPath, + }), + [ + activePreviewId, + correspondents, + detailPanelDocument, + ensureAssetUrl, + ensurePreviewData, + getDocumentAsset, + handleCorrespondentAdd, + handleCorrespondentRemove, + handleDetailPanelClose, + handleDocumentTagAdd, + handleDocumentIssuedUpdate, + handleDocumentTitleUpdate, + handleTagRemove, + openDocumentPreview, + promoteSelectionOrder, + resolveApiPath, + resolveFolderPath, + selectFolder, + selectedPreviewEntry, + tags, + tagLookupById, + ], + ); + + return { + detailPanelProps, + detailPanelOpen, + openDetailPanel, + closeDetailPanel, + handleDetailPanelClose, + inspectDocument, + previewActive, + previewWorkspaceDocument, + previewWorkspaceEntry, + resolveThumbnailUrlForDoc, + resolveFolderPath, + }; +}; + +export default useDetailWorkspace; diff --git a/frontend/src/documents/hooks/useBulkDocumentActions.js b/frontend/src/documents/hooks/useBulkDocumentActions.js new file mode 100644 index 0000000..317ee2e --- /dev/null +++ b/frontend/src/documents/hooks/useBulkDocumentActions.js @@ -0,0 +1,207 @@ +import { useCallback } from 'react'; + +const useBulkDocumentActions = ({ + api, + resolveTargetDocumentIds, + correspondentLookupByName, + handleCorrespondentCreate, + refreshCurrentFolder, + setStatusMessage, + selectedDocumentIds, + selectedFolderIds, + handleDocumentsDelete, + handleFolderDelete, + clearDocumentSelection, + setLoading, +}) => { + const handleBulkCorrespondentAdd = useCallback( + async ({ name, input, documentIds }) => { + const trimmed = typeof name === 'string' ? name.trim() : ''; + if (!trimmed) { + setStatusMessage('Correspondent name is required.', 'error'); + return; + } + const targets = resolveTargetDocumentIds(documentIds); + if (!targets.length) { + setStatusMessage('Select documents before assigning correspondents.', 'error'); + return; + } + + let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null; + if (!target) { + try { + target = await handleCorrespondentCreate({ name: trimmed }); + } catch (error) { + return; + } + } + + if (!target?.id) { + setStatusMessage('Unable to resolve correspondent.', 'error'); + return; + } + + const response = await api.post('/documents/bulk/correspondents', { + document_ids: targets, + assignments: [ + { + correspondent_id: target.id, + }, + ], + action: 'add', + }); + + const { assigned = 0, removed = 0 } = response.data || {}; + + await refreshCurrentFolder(); + const assignedSuffix = assigned === 1 ? '' : 's'; + if (removed > 0) { + const removedSuffix = removed === 1 ? '' : 's'; + setStatusMessage( + `Correspondent assigned (${assigned}) and replaced ${removed} link${removedSuffix}.`, + 'success', + ); + } else { + setStatusMessage( + `Correspondent assigned to ${assigned} document${assignedSuffix}.`, + 'success', + ); + } + + if (input) { + input.value = ''; + } + }, + [ + api, + correspondentLookupByName, + handleCorrespondentCreate, + refreshCurrentFolder, + resolveTargetDocumentIds, + setStatusMessage, + ], + ); + + const handleBulkCorrespondentRemove = useCallback( + async ({ assignments = [], documentIds }) => { + if (!assignments.length) { + setStatusMessage('Select a correspondent to remove.', 'error'); + return; + } + + const targets = resolveTargetDocumentIds(documentIds); + + if (!targets.length) { + setStatusMessage('Select documents before removing correspondents.', 'error'); + return; + } + + const normalizedAssignments = assignments.map((entry) => ({ + correspondent_id: entry.correspondent_id, + })); + + const response = await api.post('/documents/bulk/correspondents', { + document_ids: targets, + assignments: normalizedAssignments, + action: 'remove', + }); + + const { assigned = 0, removed = 0 } = response.data || {}; + await refreshCurrentFolder(); + + if (removed > 0) { + const removedSuffix = removed === 1 ? '' : 's'; + setStatusMessage( + `Correspondent removed from ${removed} link${removedSuffix}.`, + 'success', + ); + } else if (assigned > 0) { + const assignedSuffix = assigned === 1 ? '' : 's'; + setStatusMessage(`Correspondent updated ${assigned} link${assignedSuffix}.`, 'info'); + } else { + setStatusMessage('No correspondents changed.', 'info'); + } + }, + [api, refreshCurrentFolder, resolveTargetDocumentIds, setStatusMessage], + ); + + const handleDeleteSelection = useCallback(async () => { + const docIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : []; + const folderIds = Array.isArray(selectedFolderIds) ? selectedFolderIds : []; + + if (docIds.length === 0 && folderIds.length === 0) { + return; + } + + const parts = []; + if (docIds.length) { + parts.push(`${docIds.length} document${docIds.length === 1 ? '' : 's'}`); + } + if (folderIds.length) { + parts.push(`${folderIds.length} folder${folderIds.length === 1 ? '' : 's'}`); + } + const descriptor = parts.join(' and '); + const confirmation = parts.length === 1 + ? `Delete ${descriptor}? Folders must be empty before deletion. You can restore documents later from trash.` + : `Delete ${descriptor}? Folders must be empty before deletion. You can restore documents later from trash.`; + + if (!window.confirm(confirmation)) { + return; + } + + setLoading(true); + let docsOk = true; + let foldersOk = true; + + try { + if (docIds.length) { + docsOk = await handleDocumentsDelete(docIds, { showMessage: false, manageLoading: false }); + } + + if (folderIds.length) { + for (const folderId of folderIds) { + // eslint-disable-next-line no-await-in-loop + const success = await handleFolderDelete(folderId, { showMessage: false, manageLoading: false }); + if (!success) { + foldersOk = false; + } + } + } + } finally { + setLoading(false); + } + + if (!docsOk || !foldersOk) { + setStatusMessage('Some items could not be deleted. Ensure folders are empty before deletion.', 'error'); + return; + } + + clearDocumentSelection(); + + const successParts = []; + if (docIds.length) { + successParts.push(docIds.length === 1 ? 'Document deleted.' : 'Documents deleted.'); + } + if (folderIds.length) { + successParts.push(folderIds.length === 1 ? 'Folder deleted.' : 'Folders deleted.'); + } + + setStatusMessage(successParts.join(' '), 'success'); + }, [ + clearDocumentSelection, + handleDocumentsDelete, + handleFolderDelete, + selectedDocumentIds, + selectedFolderIds, + setLoading, + setStatusMessage, + ]); + + return { + handleBulkCorrespondentAdd, + handleBulkCorrespondentRemove, + handleDeleteSelection, + }; +}; + +export default useBulkDocumentActions; diff --git a/frontend/src/documents/hooks/useDocumentsPanelProps.js b/frontend/src/documents/hooks/useDocumentsPanelProps.js new file mode 100644 index 0000000..80c7ccc --- /dev/null +++ b/frontend/src/documents/hooks/useDocumentsPanelProps.js @@ -0,0 +1,176 @@ +import { useMemo } from 'react'; + +const useDocumentsPanelProps = ({ + currentFolderName, + breadcrumbs, + refreshCurrentFolder, + currentSubfolders, + documents, + searchResults, + isFilterActive, + folderClickHandlers, + selectFolder, + handleFolderDragStart, + handleFolderDragEnd, + draggedFolderId, + handleFolderRename, + openDocumentPreview, + handleDocumentTitleUpdate, + selectedDocumentIds, + selectedFolderIds, + focusedRowKey, + draggedDocumentIds, + handleDocumentDragStart, + handleDocumentDragEnd, + searchLoading, + tagLookupById, + activeCorrespondentFilters, + selectedEntries, + setFocusedRowKey, + ensureAssetUrl, + getDocumentAsset, + toggleTagFilter, + toggleCorrespondentFilter, + handleDocumentTagDrop, + documentsViewMode, + documentsSortField, + documentsSortDirection, + handleDocumentsSortFieldChange, + handleDocumentsSortDirectionToggle, + searchIncludeDescendants, + toggleSearchIncludeDescendants, + handleDocumentsViewModeChange, + clearDocumentSelection, + handleDeleteSelection, + handleEntryPointerCore, + inspectDocument, + handleEntrySelection, + tags, + correspondents, + documentLookup, + handleBulkTagAddFromDetail, + handleBulkTagRemoveFromDetail, + handleBulkCorrespondentAdd, + handleBulkCorrespondentRemove, + handleBulkSelectionReanalyze, + folderOptions, + moveDocumentsToFolder, +}) => + useMemo( + () => ({ + currentFolderName, + breadcrumbs, + onRefresh: refreshCurrentFolder, + subfolders: currentSubfolders, + documents, + searchResults, + isFilterActive, + onFolderSelect: selectFolder, + onFolderDrop: folderClickHandlers.onDrop, + onFolderDragOver: folderClickHandlers.onDragOver, + onFolderDragLeave: folderClickHandlers.onDragLeave, + onFolderDragStart: handleFolderDragStart, + onFolderDragEnd: handleFolderDragEnd, + draggedFolderId, + onFolderRename: handleFolderRename, + onDocumentOpen: openDocumentPreview, + onDocumentRename: handleDocumentTitleUpdate, + selectedDocumentIds, + selectedFolderIds, + focusedRowKey, + draggingDocumentIds: draggedDocumentIds, + onDocumentDragStart: handleDocumentDragStart, + onDocumentDragEnd: handleDocumentDragEnd, + isSearchLoading: searchLoading, + tagLookupById, + activeCorrespondentIds: activeCorrespondentFilters, + selectedEntries, + onFocusedRowChange: setFocusedRowKey, + ensureAssetUrl, + getDocumentAsset, + onTagClick: toggleTagFilter, + onCorrespondentClick: toggleCorrespondentFilter, + onDocumentTagDrop: handleDocumentTagDrop, + viewMode: documentsViewMode, + sortField: documentsSortField, + sortDirection: documentsSortDirection, + onSortFieldChange: handleDocumentsSortFieldChange, + onSortDirectionToggle: handleDocumentsSortDirectionToggle, + searchIncludeDescendants, + onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants, + onViewModeChange: handleDocumentsViewModeChange, + onClearSelection: clearDocumentSelection, + onDeleteSelection: handleDeleteSelection, + onEntryPointer: handleEntryPointerCore, + onInspectDocument: inspectDocument, + onEntrySelection: handleEntrySelection, + tags, + correspondents, + documentLookup, + onBulkTagAdd: handleBulkTagAddFromDetail, + onBulkTagRemove: handleBulkTagRemoveFromDetail, + onBulkCorrespondentAdd: handleBulkCorrespondentAdd, + onBulkCorrespondentRemove: handleBulkCorrespondentRemove, + onBulkReanalyze: handleBulkSelectionReanalyze, + folderOptions, + onMoveDocumentsToFolder: moveDocumentsToFolder, + }), + [ + activeCorrespondentFilters, + breadcrumbs, + clearDocumentSelection, + correspondents, + currentFolderName, + currentSubfolders, + documents, + documentsSortDirection, + documentsSortField, + documentsViewMode, + documentLookup, + draggedDocumentIds, + draggedFolderId, + focusedRowKey, + folderClickHandlers, + handleBulkCorrespondentAdd, + handleBulkCorrespondentRemove, + handleBulkSelectionReanalyze, + handleBulkTagAddFromDetail, + handleBulkTagRemoveFromDetail, + handleDeleteSelection, + handleDocumentDragEnd, + handleDocumentDragStart, + handleDocumentTagDrop, + handleDocumentTitleUpdate, + handleDocumentsSortDirectionToggle, + handleDocumentsSortFieldChange, + handleDocumentsViewModeChange, + handleEntryPointerCore, + handleEntrySelection, + handleFolderDragEnd, + handleFolderDragStart, + handleFolderRename, + inspectDocument, + isFilterActive, + moveDocumentsToFolder, + openDocumentPreview, + refreshCurrentFolder, + searchIncludeDescendants, + searchLoading, + searchResults, + selectedDocumentIds, + selectedEntries, + selectedFolderIds, + selectFolder, + setFocusedRowKey, + tagLookupById, + tags, + toggleCorrespondentFilter, + toggleSearchIncludeDescendants, + toggleTagFilter, + ensureAssetUrl, + getDocumentAsset, + folderOptions, + ], + ); + +export default useDocumentsPanelProps; diff --git a/frontend/src/documents/hooks/useDocumentsSelection.js b/frontend/src/documents/hooks/useDocumentsSelection.js new file mode 100644 index 0000000..c0c0d86 --- /dev/null +++ b/frontend/src/documents/hooks/useDocumentsSelection.js @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +const useDocumentsSelection = ({ + showingSearchResults, + currentSubfolders, + visibleDocuments, + resolveFolderRowKey, + resolveDocumentRowKey, + configureSelectionEnvironment, + visibleRowKeySet, + selectedEntries, + selectionAnchorRef, + promoteSelectionOrderRaw, + setFocusedDocumentId, + setActivePreviewId, + clearSelection, + focusedDocumentId, + setFocusedRowKey, + focusedRowKey, + isFolderRowKey, +}) => { + const navigableRows = useMemo(() => { + const entries = []; + if (!showingSearchResults) { + currentSubfolders.forEach((folder) => { + const key = resolveFolderRowKey(folder.id); + if (key) { + entries.push({ key, type: 'folder', id: folder.id }); + } + }); + } + visibleDocuments.forEach((doc) => { + const key = resolveDocumentRowKey(doc.id); + if (key) { + entries.push({ key, type: 'document', id: doc.id }); + } + }); + return entries; + }, [showingSearchResults, currentSubfolders, visibleDocuments, resolveFolderRowKey, resolveDocumentRowKey]); + + const navigableRowKeys = useMemo( + () => navigableRows.map((entry) => entry.key), + [navigableRows], + ); + + useEffect(() => { + configureSelectionEnvironment({ + visibleRowKeySet, + navigableRowKeys, + }); + }, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]); + + const promoteSelectionOrder = useCallback( + (docId) => { + if (!docId) return; + promoteSelectionOrderRaw(docId); + const rowKey = resolveDocumentRowKey(docId); + if (rowKey) { + selectionAnchorRef.current = rowKey; + } + setFocusedDocumentId(docId); + setActivePreviewId(docId); + }, + [promoteSelectionOrderRaw, resolveDocumentRowKey, selectionAnchorRef, setFocusedDocumentId, setActivePreviewId], + ); + + const clearDocumentSelection = useCallback(() => { + clearSelection(); + }, [clearSelection]); + + const prevFocusedDocIdRef = useRef(focusedDocumentId); + useEffect(() => { + const previous = prevFocusedDocIdRef.current; + if (previous === focusedDocumentId) { + return; + } + prevFocusedDocIdRef.current = focusedDocumentId; + if (focusedDocumentId) { + setFocusedRowKey(resolveDocumentRowKey(focusedDocumentId)); + } else { + setFocusedRowKey((current) => (isFolderRowKey(current) ? current : null)); + } + }, [focusedDocumentId, resolveDocumentRowKey, setFocusedRowKey, isFolderRowKey]); + + useEffect(() => { + if (!navigableRowKeys.length) { + if (focusedRowKey) { + setFocusedRowKey(null); + } + return; + } + + if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) { + return; + } + + const docKey = focusedDocumentId ? resolveDocumentRowKey(focusedDocumentId) : null; + if (docKey && navigableRowKeys.includes(docKey)) { + setFocusedRowKey(docKey); + return; + } + + const selectedKey = selectedEntries.find((key) => navigableRowKeys.includes(key)); + if (selectedKey) { + setFocusedRowKey(selectedKey); + return; + } + + if (focusedRowKey) { + setFocusedRowKey(null); + } + }, [focusedRowKey, focusedDocumentId, navigableRowKeys, selectedEntries, setFocusedRowKey, resolveDocumentRowKey]); + + return { + navigableRows, + navigableRowKeys, + promoteSelectionOrder, + clearDocumentSelection, + }; +}; + +export default useDocumentsSelection; diff --git a/frontend/src/hooks/documents/useDocumentMutations.js b/frontend/src/hooks/documents/useDocumentMutations.js new file mode 100644 index 0000000..9725cd1 --- /dev/null +++ b/frontend/src/hooks/documents/useDocumentMutations.js @@ -0,0 +1,633 @@ +import { useCallback } from 'react'; +import { DEFAULT_FOLDER_NAME, getRowId, isDocumentRowKey } from '../../app/appLayoutUtils'; + +const normalizeDocumentId = (value) => { + if (!value) return null; + if (typeof value === 'object' && value.id) { + return value.id; + } + return value; +}; + +const useDocumentMutations = ({ + api, + token, + documentLookup, + folderLabelMap, + ensureFolderData, + selectedFolder, + setSelectedFolder, + setDocuments, + setFolderContents, + setSearchResults, + setSelectedEntries, + setSelectionOrder, + selectionOrderRef, + selectionAnchorRef, + setFocusedDocumentId, + focusedDocumentId, + setFocusedRowKey, + focusedRowKey, + notifyApiError, + setStatusMessage, + setLoading, + mapDocumentCaches, + applySelectedFolder, + folderNodes, + setFolderNodes, + removeDocumentsFromCaches, + closeDocumentPreview, + previewDocumentId, + refreshCurrentFolder, + documentsViewMode, + updateDocumentCaches, + tagLookupById, + tags, + refreshTags, + tagManager, + extractDocumentFromResponse, +}) => { + const moveDocumentsToFolder = useCallback( + async (documentIds, targetFolderId) => { + const uniqueIds = Array.from( + new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean)), + ); + if (!uniqueIds.length) return; + + const uniqueIdSet = new Set(uniqueIds); + const target = targetFolderId === 'root' ? null : targetFolderId; + const targetLabel = + target === null + ? DEFAULT_FOLDER_NAME + : folderLabelMap.get(targetFolderId) || 'target folder'; + + const movedDocs = uniqueIds + .map((id) => { + const doc = documentLookup.get(id); + if (!doc) { + return null; + } + return { + id, + sourceFolderId: doc.folder_id ?? null, + document: doc, + }; + }) + .filter(Boolean); + + 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, + 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) => + collection.filter((key) => { + if (!isDocumentRowKey(key)) { + return true; + } + const id = getRowId(key); + return id ? !uniqueIdSet.has(id) : true; + }); + + setLoading(true); + try { + if (uniqueIds.length === 1) { + await api.patch(`/documents/${uniqueIds[0]}/folder`, { folder_id: target }); + } else { + await api.post('/documents/bulk/move', { + document_ids: uniqueIds, + folder_id: target, + }); + } + + const count = uniqueIds.length; + const suffix = count === 1 ? '' : 's'; + setStatusMessage(`Moved ${count} document${suffix} to ${targetLabel}.`, 'success'); + + if (updatedDocsMap.size) { + mapDocumentCaches((doc) => { + if (!doc || !uniqueIdSet.has(doc.id)) { + return doc; + } + const updated = updatedDocsMap.get(doc.id); + if (updated) { + return updated; + } + return { ...doc, folder_id: target }; + }); + } else { + mapDocumentCaches((doc) => { + if (!doc || !uniqueIdSet.has(doc.id)) { + return doc; + } + return { ...doc, folder_id: target }; + }); + } + + if (uniqueIdSet.size) { + setSearchResults((prev) => { + if (!Array.isArray(prev) || !prev.length) { + return prev; + } + const filtered = prev.filter((doc) => doc && !uniqueIdSet.has(doc.id)); + return filtered.length === prev.length ? prev : filtered; + }); + setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id))); + setFolderContents((prev) => { + if (!prev.size) { + return prev; + } + let changed = false; + const next = new Map(prev); + movedDocs.forEach(({ id, sourceFolderId }) => { + const sourceKey = sourceFolderId || 'root'; + const entry = next.get(sourceKey); + if (!entry?.documents?.length) { + return; + } + const filteredDocs = entry.documents.filter((doc) => doc.id !== id); + if (filteredDocs.length !== entry.documents.length) { + changed = true; + next.set(sourceKey, { ...entry, documents: filteredDocs }); + } + }); + return changed ? next : prev; + }); + + setSelectedEntries((prev) => pruneRow(prev, uniqueIdSet)); + setSelectionOrder((prev) => pruneRow(prev, uniqueIdSet)); + selectionOrderRef.current = pruneRow(selectionOrderRef.current || [], uniqueIdSet); + if ( + selectionAnchorRef.current && + isDocumentRowKey(selectionAnchorRef.current) && + uniqueIdSet.has(getRowId(selectionAnchorRef.current)) + ) { + selectionAnchorRef.current = null; + } + if (focusedDocumentId && uniqueIdSet.has(focusedDocumentId)) { + setFocusedDocumentId(null); + } + if ( + focusedRowKey && + isDocumentRowKey(focusedRowKey) && + uniqueIdSet.has(getRowId(focusedRowKey)) + ) { + setFocusedRowKey(null); + } + } + + if (targetFolderId && targetFolderId !== selectedFolder) { + await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 }); + } + } catch (error) { + const message = error.response?.data?.error || 'Failed to move documents.'; + notifyApiError(error, message); + } finally { + setLoading(false); + } + }, + [ + api, + documentLookup, + folderLabelMap, + ensureFolderData, + selectedFolder, + setSearchResults, + setDocuments, + setFolderContents, + setSelectedEntries, + setSelectionOrder, + selectionOrderRef, + selectionAnchorRef, + setFocusedDocumentId, + focusedDocumentId, + setFocusedRowKey, + focusedRowKey, + notifyApiError, + setStatusMessage, + setLoading, + mapDocumentCaches, + ], + ); + + const handleThumbnailRegeneration = useCallback( + async (documentId) => { + if (!token) { + setStatusMessage('Log in to manage assets.', 'error'); + return; + } + setLoading(true); + try { + await api.post(`/documents/${documentId}/assets`, null, { + params: { force: true }, + }); + setStatusMessage('Document re-analysis queued.', 'info'); + await refreshCurrentFolder(); + } catch (error) { + const message = error.response?.data?.error || 'Failed to request thumbnail generation.'; + notifyApiError(error, message); + } finally { + setLoading(false); + } + }, + [api, token, refreshCurrentFolder, notifyApiError, setStatusMessage, setLoading], + ); + + const handleDocumentsDelete = useCallback( + async (documentIds, { showMessage = true, manageLoading = true } = {}) => { + if (!documentIds || documentIds.length === 0) { + return false; + } + + if (!token) { + setStatusMessage('Log in to manage documents.', 'error'); + return false; + } + + if (manageLoading) { + setLoading(true); + } + + try { + await Promise.all( + documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`)), + ); + + removeDocumentsFromCaches(documentIds); + + if (documentIds.includes(previewDocumentId)) { + closeDocumentPreview(); + } + + if (showMessage) { + const message = documentIds.length === 1 ? 'Document deleted.' : 'Documents deleted.'; + setStatusMessage(message, 'success'); + } + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to delete documents.'; + notifyApiError(error, message); + return false; + } finally { + if (manageLoading) { + setLoading(false); + } + } + }, + [ + api, + token, + removeDocumentsFromCaches, + previewDocumentId, + closeDocumentPreview, + notifyApiError, + setStatusMessage, + setLoading, + ], + ); + + const handleDocumentTitleUpdate = useCallback( + async (documentId, nextTitle) => { + const trimmed = typeof nextTitle === 'string' ? nextTitle.trim() : ''; + if (!trimmed) { + setStatusMessage('Document title cannot be empty.', 'error'); + return false; + } + + setLoading(true); + try { + const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed }); + const updatedDocument = extractDocumentFromResponse?.(data); + + updateDocumentCaches(documentId, (doc) => { + if (updatedDocument) { + return { ...doc, ...updatedDocument }; + } + return { ...doc, title: trimmed }; + }); + + setStatusMessage('Document title updated.', 'success'); + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to update document title.'; + notifyApiError(error, message); + return false; + } finally { + setLoading(false); + } + }, + [api, extractDocumentFromResponse, notifyApiError, setLoading, setStatusMessage, updateDocumentCaches], + ); + + const handleDocumentIssuedUpdate = useCallback( + async (documentId, nextIssuedDate) => { + setLoading(true); + const payload = { issued_at: nextIssuedDate || null }; + try { + const { data } = await api.patch(`/documents/${documentId}`, payload); + const updatedDocument = extractDocumentFromResponse?.(data); + + updateDocumentCaches(documentId, (doc) => { + if (updatedDocument) { + return { ...doc, ...updatedDocument }; + } + return { ...doc, issued_at: payload.issued_at }; + }); + + const message = payload.issued_at ? 'Issued date updated.' : 'Issued date cleared.'; + setStatusMessage(message, 'success'); + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to update issued date.'; + notifyApiError(error, message); + return false; + } finally { + setLoading(false); + } + }, + [api, extractDocumentFromResponse, notifyApiError, setLoading, setStatusMessage, updateDocumentCaches], + ); + + const handleDocumentTagAdd = useCallback( + async (document, label, extras = null) => { + const normalizedLabel = tagManager.normalizeLabel(label); + const optionCandidate = + extras && typeof extras === 'object' && 'option' in extras ? extras.option : null; + const input = + extras && typeof extras === 'object' && 'input' in extras ? extras.input : null; + + let tag = 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 }); + const { data } = await api.post('/tags', payload); + tag = data; + await refreshTags(); + } + await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] }); + setStatusMessage('Tag assigned.', 'success'); + if (input && typeof input === 'object') { + input.value = ''; + } + await refreshCurrentFolder(); + } catch (error) { + notifyApiError(error, 'Failed to assign tag.'); + } + }, + [api, tags, refreshTags, refreshCurrentFolder, notifyApiError, setStatusMessage, tagManager], + ); + + const handleDocumentTagAttach = useCallback( + async ({ documentId, tagId, tag: tagData = null }) => { + if (!documentId || !tagId) { + return false; + } + + const resolveTagForCache = () => { + const lookupTag = tagLookupById.get(tagId); + const source = lookupTag ?? tagData; + if (!source || source.id == null || typeof source.label !== 'string') { + return null; + } + return { + id: source.id, + label: source.label, + color: Object.prototype.hasOwnProperty.call(source, 'color') ? source.color : null, + }; + }; + + try { + await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] }); + updateDocumentCaches(documentId, (doc) => { + if (!doc) { + return doc; + } + const currentTags = Array.isArray(doc.tags) ? doc.tags : []; + if (currentTags.some((existing) => existing?.id === tagId)) { + return doc; + } + const resolvedTag = resolveTagForCache(); + if (!resolvedTag) { + return doc; + } + return { ...doc, tags: [...currentTags, resolvedTag] }; + }); + setStatusMessage('Tag assigned.', 'success'); + if (documentsViewMode !== 'desk') { + await refreshCurrentFolder(); + } + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to assign tag.'; + notifyApiError(error, message); + return false; + } + }, + [ + api, + refreshCurrentFolder, + documentsViewMode, + notifyApiError, + setStatusMessage, + updateDocumentCaches, + tagLookupById, + ], + ); + + const applyTagRemovalToCaches = useCallback( + (documentId, tagId) => { + if (!documentId || !tagId) { + return; + } + + updateDocumentCaches(documentId, (doc) => { + if (!Array.isArray(doc.tags)) { + return doc; + } + const nextTags = doc.tags.filter((tag) => tag.id !== tagId); + if (nextTags.length === doc.tags.length) { + return doc; + } + return { ...doc, tags: nextTags }; + }); + }, + [updateDocumentCaches], + ); + + const handleTagRemove = useCallback( + async (documentId, tagId, { refreshTagList = true, showMessage = true } = {}) => { + if (!documentId || !tagId) { + return false; + } + + try { + await api.delete(`/documents/${documentId}/tags/${tagId}`); + applyTagRemovalToCaches(documentId, tagId); + if (refreshTagList) { + await refreshTags(); + } + if (showMessage) { + setStatusMessage('Tag removed.', 'success'); + } + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to remove tag.'; + notifyApiError(error, message); + return false; + } + }, + [api, applyTagRemovalToCaches, notifyApiError, refreshTags, setStatusMessage], + ); + + const handleFolderDelete = useCallback( + async (folderId, { showMessage = true, manageLoading = true } = {}) => { + if (!token) { + if (showMessage) { + setStatusMessage('Log in to manage folders.', 'error'); + } + return false; + } + if (!folderId || folderId === 'root') { + if (showMessage) { + setStatusMessage('The root folder cannot be removed.', 'error'); + } + return false; + } + + if (manageLoading) { + setLoading(true); + } + + try { + const contents = await ensureFolderData(folderId, { + force: true, + prefetchDepth: 1, + }); + const hasChildren = (contents.subfolders || []).length > 0; + const hasDocs = (contents.documents || []).length > 0; + if (hasChildren || hasDocs) { + if (showMessage) { + setStatusMessage('Folder must be empty before it can be deleted.', 'error'); + } + return false; + } + + await api.delete(`/folders/${folderId}`); + + setFolderNodes((prev) => { + 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, + }); + } + } + return next; + }); + + setFolderContents((prev) => { + const next = new Map(prev); + next.delete(folderId); + return next; + }); + + if (selectedFolder === folderId) { + const node = folderNodes.get(folderId); + const parentId = node?.parentId || 'root'; + setSelectedFolder(parentId); + const parentContents = await ensureFolderData(parentId, { + force: true, + prefetchDepth: 1, + }); + applySelectedFolder(parentId, parentContents); + } else if (selectedFolder !== 'root') { + await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 }); + } + + if (showMessage) { + setStatusMessage('Folder deleted.', 'success'); + } + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to delete folder.'; + notifyApiError(error, message); + if (showMessage) { + setStatusMessage(message, 'error'); + } + return false; + } finally { + if (manageLoading) { + setLoading(false); + } + } + }, + [ + api, + token, + ensureFolderData, + selectedFolder, + folderNodes, + setSelectedFolder, + applySelectedFolder, + setFolderNodes, + setFolderContents, + notifyApiError, + setStatusMessage, + setLoading, + ], + ); + + return { + moveDocumentsToFolder, + handleThumbnailRegeneration, + handleDocumentsDelete, + handleDocumentTagAdd, + handleDocumentTagAttach, + handleDocumentTitleUpdate, + handleDocumentIssuedUpdate, + handleTagRemove, + handleFolderDelete, + }; +}; + +export default useDocumentMutations; diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.js b/frontend/src/hooks/documents/useDocumentsWorkspace.js index d8d1765..28eb0cb 100644 --- a/frontend/src/hooks/documents/useDocumentsWorkspace.js +++ b/frontend/src/hooks/documents/useDocumentsWorkspace.js @@ -5,30 +5,28 @@ import { useMatch, useNavigate, } from 'react-router-dom'; -import AssetManager, { - getAssetFromVersion, - resolveDocumentAssetUrl, -} from '../../asset_manager'; +import AssetManager, { getAssetFromVersion } from '../../asset_manager'; import useApiError from '../useApiError'; import TagManager from '../../tag_manager'; import usePasskeys from '../../settings/usePasskeys'; import { useManagementModals } from '../../app/useManagementModals'; import { api, useAppDispatch, useAppState } from '../../app/appState'; -import { useDetailPanel } from '../../app/useDetailPanel'; import useWorkspaceSelection from '../../app/useWorkspaceSelection'; import { useEntryPointerHandler as useEntryPointerCore } from '../../documents/useEntryPointer'; import { isTagTransferEvent } from '../../documents/tagTransfer'; +import useDocumentsSelection from '../../documents/hooks/useDocumentsSelection'; +import useBulkDocumentActions from '../../documents/hooks/useBulkDocumentActions'; +import useDocumentsPanelProps from '../../documents/hooks/useDocumentsPanelProps'; import useDocumentPreview from '../../app/useDocumentPreview'; import useDeskWorkspaceProps from '../../desktop/useDeskWorkspaceProps'; +import useSidebarProps from '../../sidebar/useSidebarProps'; import { ASSET_PRESIGN_TTL_MS, DEFAULT_FOLDER_NAME, DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD, - TAG_FILTER_UNTAGGED, createRootNode, getRowId, - hasFiles, isDocumentRowKey, isFolderRowKey, mergeAssetIntoDocument, @@ -44,11 +42,13 @@ import useCorrespondents from './useCorrespondents'; import useTenantManager from './useTenantManager'; import useDocuments from './useDocuments'; import useFolderTree from './useFolderTree'; +import useFolderTreeActions from './useFolderTreeActions'; import useDocumentTagging from './useDocumentTagging'; import useDocumentCorrespondentActions from './useDocumentCorrespondentActions'; import useDocumentUploads from './useDocumentUploads'; import useDocumentDragHandlers from './useDocumentDragHandlers'; import useDocumentMutations from './useDocumentMutations'; +import useDetailWorkspace from '../../detail/useDetailWorkspace'; const noop = () => {}; @@ -324,103 +324,6 @@ const useDocumentsWorkspace = ({ setSearchResultsRef.current = setSearchResults; }, [setSearchResults]); - const moveFolder = useCallback( - async (folderId, targetFolderId) => { - const node = folderNodes.get(folderId); - if (!node) { - setStatusMessage('Folder metadata unavailable. Try refreshing.', 'error'); - return; - } - - const previousParentKey = node.parentId ?? 'root'; - const targetKey = targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root'; - - if (previousParentKey === targetKey) { - return; - } - - const parent_id = targetKey === 'root' ? null : targetKey; - - try { - await api.patch(`/folders/${folderId}`, { parent_id }); - - setFolderNodes((prev) => { - const next = new Map(prev); - const currentNode = next.get(folderId); - if (!currentNode) { - return prev; - } - - next.set(folderId, { ...currentNode, parentId: parent_id ?? null }); - - const previousParent = next.get(previousParentKey); - if (previousParent) { - const remainingChildren = (previousParent.children || []).filter( - (childId) => childId !== folderId, - ); - next.set(previousParentKey, { - ...previousParent, - children: remainingChildren, - hasChildren: remainingChildren.length > 0, - }); - } - - if (!next.has(targetKey)) { - next.set(targetKey, { - id: targetKey, - name: targetKey === 'root' ? DEFAULT_FOLDER_NAME : 'Folder', - parentId: targetKey === 'root' ? null : null, - children: [], - expanded: targetKey === 'root', - loaded: false, - hasChildren: false, - }); - } - - const targetNode = next.get(targetKey); - if (targetNode && !targetNode.children.includes(folderId)) { - next.set(targetKey, { - ...targetNode, - children: [...targetNode.children, folderId], - hasChildren: true, - }); - } - - return next; - }); - - const refreshTargets = new Set([previousParentKey, targetKey]); - for (const key of refreshTargets) { - await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }); - } - - if (selectedFolder === folderId) { - await ensureFolderData(folderId, { force: true, prefetchDepth: 1 }); - setSelectedFolder(folderId); - } - - setStatusMessage('Folder moved.', 'success'); - } catch (error) { - const message = error.response?.data?.error || 'Failed to move folder.'; - notifyApiError(error, message); - - const refreshTargets = new Set([previousParentKey, targetKey]); - for (const key of refreshTargets) { - await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }); - } - } - }, - [ - folderNodes, - ensureFolderData, - selectedFolder, - setSelectedFolder, - setFolderNodes, - notifyApiError, - setStatusMessage, - ], - ); - const { previewEntries, ensurePreviewData, @@ -810,7 +713,9 @@ const useDocumentsWorkspace = ({ handleDocumentsDelete, handleDocumentTagAdd, handleDocumentTagAttach, - handleFolderDelete, + handleDocumentTitleUpdate, + handleDocumentIssuedUpdate, + handleTagRemove, } = useDocumentMutations({ api, token, @@ -847,177 +752,67 @@ const useDocumentsWorkspace = ({ tags, refreshTags, tagManager, + extractDocumentFromResponse, }); - const navigableRows = useMemo(() => { - const entries = []; - if (!showingSearchResults) { - currentSubfolders.forEach((folder) => { - const key = resolveFolderRowKey(folder.id); - if (key) { - entries.push({ key, type: 'folder', id: folder.id }); - } - }); - } - visibleDocuments.forEach((doc) => { - const key = resolveDocumentRowKey(doc.id); - if (key) { - entries.push({ key, type: 'document', id: doc.id }); - } - }); - return entries; - }, [showingSearchResults, currentSubfolders, visibleDocuments]); + const { + loadFolder, + selectFolder, + handleFolderRename, + handleFolderCreate, + handleFolderDelete, + folderClickHandlers, + } = useFolderTreeActions({ + api, + token, + folderNodes, + setFolderNodes, + selectedFolder, + setSelectedFolder, + ensureFolderData, + ensureFolderAncestorsLoaded, + expandFolderAncestors, + applySelectedFolder, + notifyApiError, + setStatusMessage, + setLoading, + setFolderContents, + setCurrentFolder, + setSearchResults, + isFilterActive, + navigate, + handleFileDrop, + moveDocumentsToFolder, + draggedDocumentIds, + draggedFolderId, + setDraggedDocumentIds, + setDraggedFolderId, + isInvalidFolderDrop, + setCreatingFolder, + }); - const navigableRowKeys = useMemo( - () => navigableRows.map((entry) => entry.key), - [navigableRows], - ); - - useEffect(() => { - configureSelectionEnvironment({ - visibleRowKeySet, - navigableRowKeys, - }); - }, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]); - - const promoteSelectionOrder = useCallback( - (docId) => { - if (!docId) return; - promoteSelectionOrderRaw(docId); - const rowKey = resolveDocumentRowKey(docId); - if (rowKey) { - selectionAnchorRef.current = rowKey; - } - setFocusedDocumentId(docId); - setActivePreviewId(docId); - }, - [ - promoteSelectionOrderRaw, - selectionAnchorRef, - setFocusedDocumentId, - setActivePreviewId, - ], - ); - - const clearDocumentSelection = useCallback(() => { - clearSelection(); - }, [clearSelection]); - - const prevFocusedDocIdRef = useRef(focusedDocumentId); - useEffect(() => { - const previous = prevFocusedDocIdRef.current; - if (previous === focusedDocumentId) { - return; - } - prevFocusedDocIdRef.current = focusedDocumentId; - if (focusedDocumentId) { - setFocusedRowKey(resolveDocumentRowKey(focusedDocumentId)); - } else { - setFocusedRowKey((current) => (isFolderRowKey(current) ? current : null)); - } - }, [focusedDocumentId, setFocusedRowKey]); - - useEffect(() => { - if (!navigableRowKeys.length) { - if (focusedRowKey) { - setFocusedRowKey(null); - } - return; - } - - if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) { - return; - } - - const docKey = focusedDocumentId ? resolveDocumentRowKey(focusedDocumentId) : null; - if (docKey && navigableRowKeys.includes(docKey)) { - setFocusedRowKey(docKey); - return; - } - - const selectedKey = selectedEntries.find((key) => navigableRowKeys.includes(key)); - if (selectedKey) { - setFocusedRowKey(selectedKey); - return; - } - - if (focusedRowKey) { - setFocusedRowKey(null); - } - }, [ - focusedRowKey, - focusedDocumentId, - navigableRowKeys, + const { + promoteSelectionOrder, + clearDocumentSelection, + } = useDocumentsSelection({ + showingSearchResults, + currentSubfolders, + visibleDocuments, + resolveFolderRowKey, + resolveDocumentRowKey, + configureSelectionEnvironment, + visibleRowKeySet, selectedEntries, + selectionAnchorRef, + promoteSelectionOrderRaw, + setFocusedDocumentId, + setActivePreviewId, + clearSelection, + focusedDocumentId, setFocusedRowKey, - ]); - const loadFolder = useCallback( - async (folderId, { showLoading = true, preserveSearch = false } = {}) => { - const targetId = folderId || 'root'; - setSelectedFolder(targetId); - await ensureFolderAncestorsLoaded(targetId); - expandFolderAncestors(targetId); - if (showLoading) setLoading(true); - try { - const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 }); - if (targetId !== 'root') { - try { - await ensureFolderData('root', { - force: false, - includeDocuments: false, - prefetchDepth: 1, - }); - } catch (error) { - console.warn('Failed to refresh root folder tree', error); - } - } - applySelectedFolder(targetId, contents); - if (!preserveSearch) { - setSearchResults(null); - } - } catch (error) { - notifyApiError(error, 'Failed to load folder contents.'); - } finally { - if (showLoading) setLoading(false); - } - }, - [ - ensureFolderData, - applySelectedFolder, - notifyApiError, - ensureFolderAncestorsLoaded, - expandFolderAncestors, - setSearchResults, - setSelectedFolder, - ], - ); - - const selectFolder = useCallback( - async (folderId, { replace = false, immediate = false } = {}) => { - const targetId = folderId && folderId !== 'root' ? folderId : 'root'; - - await ensureFolderAncestorsLoaded(targetId); - expandFolderAncestors(targetId); - - if (!navigate || immediate) { - await loadFolder(targetId, { preserveSearch: isFilterActive }); - setSelectedFolder(targetId); - return; - } - - const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`; - navigate(path, { replace }); - }, - [ - ensureFolderAncestorsLoaded, - expandFolderAncestors, - navigate, - loadFolder, - isFilterActive, - setSelectedFolder, - ], - ); - + focusedRowKey, + isFolderRowKey, + }); const initializeAfterLogin = useCallback(async () => { setLoading(true); try { @@ -1061,115 +856,7 @@ const useDocumentsWorkspace = ({ selectFolder, ]); - const handleBulkCorrespondentAdd = useCallback( - async ({ name, input, documentIds }) => { - const trimmed = typeof name === 'string' ? name.trim() : ''; - if (!trimmed) { - setStatusMessage('Correspondent name is required.', 'error'); - return; - } - const targets = resolveTargetDocumentIds(documentIds); - if (!targets.length) { - setStatusMessage('Select documents before assigning correspondents.', 'error'); - return; - } - let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null; - if (!target) { - try { - target = await handleCorrespondentCreate({ name: trimmed }); - } catch (error) { - return; - } - } - - if (!target?.id) { - setStatusMessage('Unable to resolve correspondent.', 'error'); - return; - } - - const response = await api.post('/documents/bulk/correspondents', { - document_ids: targets, - assignments: [ - { - correspondent_id: target.id, - }, - ], - action: 'add', - }); - - const { assigned = 0, removed = 0 } = response.data || {}; - - await refreshCurrentFolder(); - const assignedSuffix = assigned === 1 ? '' : 's'; - if (removed > 0) { - const removedSuffix = removed === 1 ? '' : 's'; - setStatusMessage( - `Correspondent assigned (${assigned}) and replaced ${removed} link${removedSuffix}.`, - 'success', - ); - } else { - setStatusMessage( - `Correspondent assigned to ${assigned} document${assignedSuffix}.`, - 'success', - ); - } - - if (input) { - input.value = ''; - } - }, - [ - correspondentLookupByName, - handleCorrespondentCreate, - refreshCurrentFolder, - resolveTargetDocumentIds, - setStatusMessage, - ], - ); - - const handleBulkCorrespondentRemove = useCallback( - async ({ assignments = [], documentIds }) => { - if (!assignments.length) { - setStatusMessage('Select a correspondent to remove.', 'error'); - return; - } - - const targets = resolveTargetDocumentIds(documentIds); - - if (!targets.length) { - setStatusMessage('Select documents before removing correspondents.', 'error'); - return; - } - - const normalizedAssignments = assignments.map((entry) => ({ - correspondent_id: entry.correspondent_id, - })); - - const response = await api.post('/documents/bulk/correspondents', { - document_ids: targets, - assignments: normalizedAssignments, - action: 'remove', - }); - - const { assigned = 0, removed = 0 } = response.data || {}; - await refreshCurrentFolder(); - - if (removed > 0) { - const removedSuffix = removed === 1 ? '' : 's'; - setStatusMessage( - `Correspondent removed from ${removed} link${removedSuffix}.`, - 'success', - ); - } else if (assigned > 0) { - const assignedSuffix = assigned === 1 ? '' : 's'; - setStatusMessage(`Correspondent updated ${assigned} link${assignedSuffix}.`, 'info'); - } else { - setStatusMessage('No correspondents changed.', 'info'); - } - }, - [refreshCurrentFolder, resolveTargetDocumentIds, setStatusMessage], - ); useEffect(() => { if (appStatus !== 'authenticated') { @@ -1206,6 +893,25 @@ const useDocumentsWorkspace = ({ }; }, [appStatus, appDispatch, initializeAfterLogin]); + const { + handleBulkCorrespondentAdd, + handleBulkCorrespondentRemove, + handleDeleteSelection, + } = useBulkDocumentActions({ + api, + resolveTargetDocumentIds, + correspondentLookupByName, + handleCorrespondentCreate, + refreshCurrentFolder, + setStatusMessage, + selectedDocumentIds, + selectedFolderIds, + handleDocumentsDelete, + handleFolderDelete, + clearDocumentSelection, + setLoading, + }); + const ensureAssetUrl = useCallback( @@ -1246,190 +952,9 @@ const useDocumentsWorkspace = ({ - const handleDocumentTitleUpdate = useCallback( - async (documentId, nextTitle) => { - const trimmed = nextTitle.trim(); - if (!trimmed) { - setStatusMessage('Document title cannot be empty.', 'error'); - return false; - } - - setLoading(true); - try { - const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed }); - const updatedDocument = extractDocumentFromResponse(data); - - updateDocumentCaches(documentId, (doc) => { - if (updatedDocument) { - return { ...doc, ...updatedDocument }; - } - return { ...doc, title: trimmed }; - }); - - setStatusMessage('Document title updated.', 'success'); - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to update document title.'; - notifyApiError(error, message); - return false; - } finally { - setLoading(false); - } - }, - [notifyApiError, setStatusMessage, updateDocumentCaches, extractDocumentFromResponse], - ); - - const handleDocumentIssuedUpdate = useCallback( - async (documentId, nextIssuedDate) => { - setLoading(true); - const payload = { issued_at: nextIssuedDate || null }; - try { - const { data } = await api.patch(`/documents/${documentId}`, payload); - const updatedDocument = extractDocumentFromResponse(data); - - updateDocumentCaches(documentId, (doc) => { - if (updatedDocument) { - return { ...doc, ...updatedDocument }; - } - return { ...doc, issued_at: payload.issued_at }; - }); - - const message = payload.issued_at ? 'Issued date updated.' : 'Issued date cleared.'; - setStatusMessage(message, 'success'); - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to update issued date.'; - notifyApiError(error, message); - return false; - } finally { - setLoading(false); - } - }, - [extractDocumentFromResponse, notifyApiError, setStatusMessage, updateDocumentCaches], - ); - - const applyTagRemovalToCaches = useCallback( - (documentId, tagId) => { - if (!documentId || !tagId) { - return; - } - - updateDocumentCaches(documentId, (doc) => { - if (!Array.isArray(doc.tags)) { - return doc; - } - const nextTags = doc.tags.filter((tag) => tag.id !== tagId); - if (nextTags.length === doc.tags.length) { - return doc; - } - return { ...doc, tags: nextTags }; - }); - }, - [updateDocumentCaches], - ); - - const handleTagRemove = useCallback( - async (documentId, tagId, { refreshTagList = true, showMessage = true } = {}) => { - if (!documentId || !tagId) { - return false; - } - - try { - await api.delete(`/documents/${documentId}/tags/${tagId}`); - applyTagRemovalToCaches(documentId, tagId); - if (refreshTagList) { - await refreshTags(); - } - if (showMessage) { - setStatusMessage('Tag removed.', 'success'); - } - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to remove tag.'; - notifyApiError(error, message); - return false; - } - }, - [refreshTags, notifyApiError, setStatusMessage, applyTagRemovalToCaches], - ); - - - - - - const handleDeleteSelection = useCallback(async () => { - const docIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : []; - const folderIds = Array.isArray(selectedFolderIds) ? selectedFolderIds : []; - - if (docIds.length === 0 && folderIds.length === 0) { - return; - } - - const parts = []; - if (docIds.length) { - parts.push(`${docIds.length} document${docIds.length === 1 ? '' : 's'}`); - } - if (folderIds.length) { - parts.push(`${folderIds.length} folder${folderIds.length === 1 ? '' : 's'}`); - } - const descriptor = parts.join(' and '); - const confirmation = parts.length === 1 - ? `Delete ${descriptor}? Folders must be empty before deletion. You can restore documents later from trash.` - : `Delete ${descriptor}? Folders must be empty before deletion. You can restore documents later from trash.`; - - if (!window.confirm(confirmation)) { - return; - } - - setLoading(true); - let docsOk = true; - let foldersOk = true; - - try { - if (docIds.length) { - docsOk = await handleDocumentsDelete(docIds, { showMessage: false, manageLoading: false }); - } - - if (folderIds.length) { - for (const folderId of folderIds) { - const success = await handleFolderDelete(folderId, { showMessage: false, manageLoading: false }); - if (!success) { - foldersOk = false; - } - } - } - } finally { - setLoading(false); - } - - if (!docsOk || !foldersOk) { - setStatusMessage('Some items could not be deleted. Ensure folders are empty before deletion.', 'error'); - return; - } - - clearDocumentSelection(); - - const successParts = []; - if (docIds.length) { - successParts.push(docIds.length === 1 ? 'Document deleted.' : 'Documents deleted.'); - } - if (folderIds.length) { - successParts.push(folderIds.length === 1 ? 'Folder deleted.' : 'Folders deleted.'); - } - - setStatusMessage(successParts.join(' '), 'success'); - }, [ - selectedDocumentIds, - selectedFolderIds, - handleDocumentsDelete, - handleFolderDelete, - clearDocumentSelection, - setLoading, - setStatusMessage, - ]); const handleDocumentTagDrop = useCallback( async (documentId, tag) => { @@ -1456,127 +981,7 @@ const useDocumentsWorkspace = ({ [handleDocumentTagAttach, handleTagRemove], ); - const handleFolderRename = useCallback( - async (folderId, nextName) => { - if (!token) { - setStatusMessage('Log in to rename folders.', 'error'); - return false; - } - if (!folderId || folderId === 'root') { - setStatusMessage('The root folder cannot be renamed.', 'error'); - return false; - } - const trimmed = typeof nextName === 'string' ? nextName.trim() : ''; - if (!trimmed) { - setStatusMessage('Folder name cannot be empty.', 'error'); - return false; - } - setLoading(true); - try { - await api.patch(`/folders/${folderId}`, { name: trimmed }); - - setFolderNodes((prev) => { - const next = new Map(prev); - const node = next.get(folderId); - if (node) { - next.set(folderId, { ...node, name: trimmed }); - } - return next; - }); - - setFolderContents((prev) => { - if (!prev.has(folderId)) { - return prev; - } - const next = new Map(prev); - const existing = next.get(folderId) || {}; - const folderInfo = existing.folder - ? { ...existing.folder, name: trimmed } - : { id: folderId, name: trimmed }; - next.set(folderId, { ...existing, folder: folderInfo }); - return next; - }); - - setCurrentFolder((prev) => (prev?.id === folderId ? { ...prev, name: trimmed } : prev)); - - setStatusMessage('Folder renamed.', 'success'); - return true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to rename folder.'; - notifyApiError(error, message); - return false; - } finally { - setLoading(false); - } - }, - [token, notifyApiError, setStatusMessage, setFolderNodes, setFolderContents, setCurrentFolder], - ); - - const handleFolderCreate = useCallback( - async (name) => { - if (!token) { - setStatusMessage('Log in to create folders.', 'error'); - return false; - } - if (!name.trim()) { - setStatusMessage('Folder name cannot be empty.', 'error'); - return false; - } - const payload = { - name: name.trim(), - parent_id: selectedFolder === 'root' ? null : selectedFolder, - }; - setLoading(true); - let succeeded = false; - try { - const { data } = await api.post('/folders', payload); - setStatusMessage('Folder created.', 'success'); - setFolderNodes((prev) => { - const next = new Map(prev); - const parentId = payload.parent_id || 'root'; - const parentNode = next.get(parentId); - if (parentNode) { - next.set(parentId, { - ...parentNode, - children: parentNode.children.concat([data.folder.id]), - loaded: true, - hasChildren: true, - }); - } - next.set(data.folder.id, { - id: data.folder.id, - name: data.folder.name, - parentId: parentId, - children: [], - expanded: false, - loaded: false, - hasChildren: false, - }); - return next; - }); - await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 }); - await selectFolder(data.folder.id, { immediate: true }); - succeeded = true; - } catch (error) { - const message = error.response?.data?.error || 'Failed to create folder.'; - notifyApiError(error, message); - succeeded = false; - } finally { - setLoading(false); - } - return succeeded; - }, - [ - token, - selectedFolder, - ensureFolderData, - notifyApiError, - setStatusMessage, - selectFolder, - setFolderNodes, - ], - ); const handlePromptCreateFolder = useCallback(async () => { if (creatingFolder) { @@ -1713,229 +1118,50 @@ const useDocumentsWorkspace = ({ }; }, [handleTagRemove, setTagRemovalCursor]); - const folderClickHandlers = useMemo( - () => ({ - onToggle: async (folderId) => { - const node = folderNodes.get(folderId); - const nextExpanded = !(node?.expanded ?? false); - if (nextExpanded) { - try { - await ensureFolderData(folderId, { - includeDocuments: false, - prefetchDepth: 1, - }); - } catch (error) { - notifyApiError(error, 'Failed to load folder.'); - } - } else if (node && !node.loaded) { - try { - await ensureFolderData(folderId, { - includeDocuments: false, - prefetchDepth: 1, - }); - } catch (error) { - notifyApiError(error, 'Failed to load folder.'); - } - } - setFolderNodes((prev) => { - const next = new Map(prev); - const current = next.get(folderId); - if (!current) return prev; - next.set(folderId, { ...current, expanded: nextExpanded }); - return next; - }); - }, - onSelect: selectFolder, - onDrop: async (event, folderId) => { - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.classList.remove('is-drop-target'); - - let folderIds = []; - try { - const rawFolderList = event.dataTransfer.getData('application/x-papercrate-folder-list'); - if (rawFolderList) { - const parsed = JSON.parse(rawFolderList); - if (Array.isArray(parsed)) { - folderIds = parsed.filter(Boolean); - } - } - } catch (error) { - console.warn('[folders] Failed to parse folder list drag payload', error); - } - - if (!folderIds.length) { - let folderSourceId = draggedFolderId; - if (!folderSourceId) { - try { - if (event.dataTransfer.types?.includes('application/x-papercrate-folder')) { - folderSourceId = event.dataTransfer.getData('application/x-papercrate-folder'); - } - } catch (error) { - console.warn('[folders] Failed to read folder id from drag payload', error); - } - } - - if (folderSourceId) { - folderIds = [folderSourceId]; - } - } - - folderIds = Array.from(new Set(folderIds.filter(Boolean))); - - if (folderIds.length) { - setDraggedFolderId(null); - const invalidMove = folderIds.some((sourceId) => isInvalidFolderDrop(sourceId, folderId)); - if (invalidMove) { - setStatusMessage( - 'Cannot move a folder into itself or one of its descendants.', - 'error', - ); - return; - } - - for (const sourceId of folderIds) { - // eslint-disable-next-line no-await-in-loop - await moveFolder(sourceId, folderId); - } - } - - if (hasFiles(event)) { - await handleFileDrop(event.dataTransfer, folderId); - return; - } - - let docIds = []; - try { - const raw = event.dataTransfer.getData('application/x-papercrate-doc-list'); - if (raw) { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - docIds = parsed.filter(Boolean); - } - } - } catch (error) { - console.warn('[documents] Failed to parse document list drag payload', error); - } - - if (!docIds.length) { - try { - const single = event.dataTransfer.getData('application/x-papercrate-doc'); - if (single) { - docIds = [single]; - } - } catch (error) { - console.warn('[documents] Failed to read single document drag payload', error); - } - } - - if (!docIds.length && draggedDocumentIds.length) { - docIds = draggedDocumentIds; - } - - docIds = Array.from(new Set(docIds)); - - if (!docIds.length || folderId === selectedFolder) { - return; - } - - setDraggedDocumentIds([]); - await moveDocumentsToFolder(docIds, folderId); - }, - onDragOver: (event, folderId) => { - const folderDragActive = Boolean(draggedFolderId); - if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) { - return; - } - - if (hasFiles(event)) { - event.preventDefault(); - event.dataTransfer.dropEffect = 'copy'; - event.currentTarget.classList.add('is-drop-target'); - return; - } - - if (draggedDocumentIds.length || folderDragActive) { - event.preventDefault(); - event.dataTransfer.dropEffect = 'move'; - event.currentTarget.classList.add('is-drop-target'); - } - }, - onDragLeave: (event) => { - event.currentTarget.classList.remove('is-drop-target'); - }, - }), - [ - draggedDocumentIds, - draggedFolderId, - ensureFolderData, - folderNodes, - handleFileDrop, - isInvalidFolderDrop, - moveDocumentsToFolder, - moveFolder, - notifyApiError, - selectFolder, - selectedFolder, - setDraggedDocumentIds, - setDraggedFolderId, - setFolderNodes, - setStatusMessage, - ], - ); - - const selectedDocument = useMemo(() => { - if (!focusedDocumentId) { - return null; - } - const list = searchResults ?? documents; - return list.find((doc) => doc.id === focusedDocumentId) || null; - }, [searchResults, documents, focusedDocumentId]); - - - const orderedSelectedDocuments = useMemo(() => { - const ordered = []; - const seen = new Set(); - const pushDoc = (doc) => { - if (doc?.id && !seen.has(doc.id)) { - ordered.push(doc); - seen.add(doc.id); - } - }; - - selectionOrder.forEach((key) => { - if (!isDocumentRowKey(key)) { - return; - } - const docId = getRowId(key); - const doc = documentLookup.get(docId) || null; - pushDoc(doc); - }); - - selectedDocumentIds.forEach((id) => { - if (seen.has(id)) return; - const doc = documentLookup.get(id) || null; - pushDoc(doc); - }); - - return ordered; - }, [selectionOrder, documentLookup, selectedDocumentIds]); const { + detailPanelProps, detailPanelOpen, - detailPanelDocument, openDetailPanel, - closeDetailPanel, - } = useDetailPanel({ + handleDetailPanelClose, + inspectDocument, + previewActive, + previewWorkspaceDocument, + previewWorkspaceEntry, + resolveThumbnailUrlForDoc, + resolveFolderPath, + } = useDetailWorkspace({ + documents, + searchResults, + focusedDocumentId, + selectionOrder, + selectedDocumentIds, documentLookup, - orderedSelectedDocuments, + folderNodes, + ensureFolderData, + detailPanelControlRef, + detailFolderFetchRef, + previewEntries, + previewDocumentId, + activePreviewId, + openDocumentPreview, + promoteSelectionOrder, + handleDocumentTitleUpdate, + handleDocumentIssuedUpdate, + handleDocumentTagAdd, + handleTagRemove, + ensureAssetUrl, + getDocumentAsset, + ensurePreviewData, + correspondents, + handleCorrespondentAdd, + handleCorrespondentRemove, + resolveApiPath, + selectFolder, + tags, + tagLookupById, }); - detailPanelControlRef.current = { - open: openDetailPanel, - close: closeDetailPanel, - }; - const handleEntryPointerCore = useEntryPointerCore({ resolveDocumentRowKey, resolveFolderRowKey, @@ -1956,16 +1182,6 @@ const useDocumentsWorkspace = ({ }, }); - const inspectDocument = useCallback( - (documentId) => { - if (!documentId) { - return; - } - openDetailPanel({ documentIds: [documentId] }); - }, - [openDetailPanel], - ); - const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => { const chain = []; const seen = new Set(); @@ -2046,126 +1262,6 @@ const useDocumentsWorkspace = ({ }); }, [missingBreadcrumbAncestors, ensureFolderData]); - useEffect(() => { - if (!orderedSelectedDocuments.length) { - return; - } - - const visited = new Set(); - - orderedSelectedDocuments.forEach((doc) => { - const folderId = doc?.folder_id; - if (!folderId) { - return; - } - let currentId = folderId; - let guard = 0; - while (currentId && currentId !== 'root' && guard < 32) { - guard += 1; - if (visited.has(currentId)) { - break; - } - visited.add(currentId); - const node = folderNodes.get(currentId); - if (!node) { - if (!detailFolderFetchRef.current.has(currentId)) { - detailFolderFetchRef.current.add(currentId); - ensureFolderData(currentId, { force: false, includeDocuments: false }) - .catch((error) => { - console.warn('Failed to preload folder metadata for detail path', currentId, error); - }) - .finally(() => { - detailFolderFetchRef.current.delete(currentId); - }); - } - break; - } - - const parentId = node.parentId ?? 'root'; - if (!parentId || parentId === 'root') { - break; - } - currentId = parentId; - } - }); - }, [orderedSelectedDocuments, folderNodes, ensureFolderData]); - - const resolveFolderPath = useCallback( - (folderId) => { - if (!folderId || folderId === 'root') { - return []; - } - - const segments = []; - const visited = new Set(); - let currentId = folderId; - let guard = 0; - - while (currentId && guard < 32 && !visited.has(currentId)) { - guard += 1; - visited.add(currentId); - - if (currentId === 'root') { - break; - } - - const node = folderNodes.get(currentId); - if (!node) { - segments.push({ id: currentId, name: '…' }); - break; - } - - segments.push({ id: node.id, name: node.name || 'Folder' }); - - const parentId = node.parentId ?? 'root'; - if (!parentId || parentId === 'root') { - segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); - break; - } - - currentId = parentId; - } - - if (!segments.some((segment) => segment.id === 'root')) { - segments.push({ id: 'root', name: DEFAULT_FOLDER_NAME }); - } - - return segments.reverse(); - }, - [folderNodes], - ); - - const selectedPreviewEntry = useMemo(() => { - if (!selectedDocument) { - return null; - } - return previewEntries.get(selectedDocument.id) || null; - }, [selectedDocument, previewEntries]); - - const previewWorkspaceEntry = useMemo(() => { - if (!previewDocumentId) { - return null; - } - return previewEntries.get(previewDocumentId) || null; - }, [previewDocumentId, previewEntries]); - - const previewWorkspaceDocument = useMemo(() => { - if (!previewDocumentId) return null; - const pool = searchResults ?? documents; - return pool.find((doc) => doc.id === previewDocumentId) || null; - }, [previewDocumentId, searchResults, documents]); - - const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument); - - const resolveThumbnailUrlForDoc = useCallback( - (doc) => - resolveDocumentAssetUrl(doc, 'thumbnail', { - ensureAssetUrl, - getAsset: getDocumentAsset, - }), - [ensureAssetUrl, getDocumentAsset], - ); - const { handleTenantSelect } = useTenantManager({ apiClient: api, appDispatch, @@ -2184,333 +1280,101 @@ const useDocumentsWorkspace = ({ }); - const documentsTableProps = useMemo( - () => ({ - currentFolderName, - breadcrumbs, - onRefresh: refreshCurrentFolder, - subfolders: currentSubfolders, - documents, - searchResults, - isFilterActive, - onFolderSelect: selectFolder, - onFolderDrop: folderClickHandlers.onDrop, - onFolderDragOver: folderClickHandlers.onDragOver, - onFolderDragLeave: folderClickHandlers.onDragLeave, - onFolderDragStart: handleFolderDragStart, - onFolderDragEnd: handleFolderDragEnd, - draggedFolderId, - onFolderRename: handleFolderRename, - onDocumentOpen: openDocumentPreview, - onDocumentRename: handleDocumentTitleUpdate, - selectedDocumentIds, - selectedFolderIds, - focusedRowKey, - draggingDocumentIds: draggedDocumentIds, - onDocumentDragStart: handleDocumentDragStart, - onDocumentDragEnd: handleDocumentDragEnd, - isSearchLoading: searchLoading, - tagLookupById, - activeCorrespondentIds: activeCorrespondentFilters, - selectedEntries, - onFocusedRowChange: setFocusedRowKey, - ensureAssetUrl, - getDocumentAsset, - onTagClick: toggleTagFilter, - onCorrespondentClick: toggleCorrespondentFilter, - onDocumentTagDrop: handleDocumentTagDrop, - viewMode: documentsViewMode, - sortField: documentsSortField, - sortDirection: documentsSortDirection, - onSortFieldChange: handleDocumentsSortFieldChange, - onSortDirectionToggle: handleDocumentsSortDirectionToggle, - searchIncludeDescendants, - onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants, - onViewModeChange: handleDocumentsViewModeChange, - onClearSelection: clearDocumentSelection, - onDeleteSelection: handleDeleteSelection, - onEntryPointer: handleEntryPointerCore, - onInspectDocument: inspectDocument, - onEntrySelection: handleEntrySelection, - tags, - correspondents, - documentLookup, - onBulkTagAdd: handleBulkTagAddFromDetail, - onBulkTagRemove: handleBulkTagRemoveFromDetail, - onBulkCorrespondentAdd: handleBulkCorrespondentAdd, - onBulkCorrespondentRemove: handleBulkCorrespondentRemove, - onBulkReanalyze: handleBulkSelectionReanalyze, - folderOptions, - onMoveDocumentsToFolder: moveDocumentsToFolder, - }), - [ - activeCorrespondentFilters, - breadcrumbs, - clearDocumentSelection, - currentFolderName, - currentSubfolders, - documents, - documentsViewMode, - documentsSortField, - documentsSortDirection, - draggedDocumentIds, - draggedFolderId, - focusedRowKey, - folderClickHandlers, - handleDocumentDragEnd, - handleDocumentDragStart, - handleDocumentTagDrop, - handleDocumentTitleUpdate, - handleDocumentsViewModeChange, - handleDocumentsSortFieldChange, - handleDocumentsSortDirectionToggle, - handleFolderDragEnd, - handleFolderDragStart, - handleFolderRename, - handleEntryPointerCore, - inspectDocument, - handleEntrySelection, - handleDeleteSelection, - isFilterActive, - openDocumentPreview, - refreshCurrentFolder, - searchLoading, - searchResults, - searchIncludeDescendants, - selectFolder, - selectedDocumentIds, - selectedEntries, - selectedFolderIds, - setFocusedRowKey, - tagLookupById, - toggleCorrespondentFilter, - toggleTagFilter, - ensureAssetUrl, - getDocumentAsset, - tags, - correspondents, - documentLookup, - handleBulkTagAddFromDetail, - handleBulkTagRemoveFromDetail, - handleBulkCorrespondentAdd, - handleBulkCorrespondentRemove, - handleBulkSelectionReanalyze, - folderOptions, - moveDocumentsToFolder, - toggleSearchIncludeDescendants, - ], - ); + const documentsTableProps = useDocumentsPanelProps({ + currentFolderName, + breadcrumbs, + refreshCurrentFolder, + currentSubfolders, + documents, + searchResults, + isFilterActive, + folderClickHandlers, + handleFolderDragStart, + handleFolderDragEnd, + draggedFolderId, + handleFolderRename, + openDocumentPreview, + handleDocumentTitleUpdate, + selectedDocumentIds, + selectedFolderIds, + focusedRowKey, + draggedDocumentIds, + handleDocumentDragStart, + handleDocumentDragEnd, + searchLoading, + tagLookupById, + activeCorrespondentFilters, + selectedEntries, + setFocusedRowKey, + ensureAssetUrl, + getDocumentAsset, + toggleTagFilter, + toggleCorrespondentFilter, + handleDocumentTagDrop, + documentsViewMode, + documentsSortField, + documentsSortDirection, + handleDocumentsSortFieldChange, + handleDocumentsSortDirectionToggle, + searchIncludeDescendants, + toggleSearchIncludeDescendants, + handleDocumentsViewModeChange, + clearDocumentSelection, + handleDeleteSelection, + handleEntryPointerCore, + inspectDocument, + handleEntrySelection, + tags, + correspondents, + documentLookup, + handleBulkTagAddFromDetail, + handleBulkTagRemoveFromDetail, + handleBulkCorrespondentAdd, + handleBulkCorrespondentRemove, + handleBulkSelectionReanalyze, + folderOptions, + moveDocumentsToFolder, + selectFolder, + }); - const sidebarProps = useMemo( - () => ({ - folderNodes, - onToggle: folderClickHandlers.onToggle, - onSelect: folderClickHandlers.onSelect, - onDrop: folderClickHandlers.onDrop, - onDragOver: folderClickHandlers.onDragOver, - onDragLeave: folderClickHandlers.onDragLeave, - onDeleteFolder: handleFolderDelete, - onRenameFolder: handleFolderRename, - selectedFolder, - onFolderDragStart: handleFolderDragStart, - onFolderDragEnd: handleFolderDragEnd, - draggedFolderId, - onCreateFolder: handlePromptCreateFolder, - creatingFolder, - tags, - untaggedFilterId: TAG_FILTER_UNTAGGED, - activeTagIds: activeTagFilters, - onToggleTagFilter: toggleTagFilter, - onCreateTag: (label) => handleTagCreate({ label }), - correspondents, - activeCorrespondentIds: activeCorrespondentFilters, - onToggleCorrespondentFilter: toggleCorrespondentFilter, - onCreateCorrespondent: (name) => handleCorrespondentCreate({ name }), - appStatus, - loading, - previewActive, - searchQuery, - onSearchChange: handleSearchChange, - onSearchSubmit: handleSearchSubmit, - onSearchClear: clearFilters, - isFilterActive, - onLogout: handleLogout, - status, - tenantName, - tenants: tenantOptions, - activeTenantId: currentTenantId, - onSelectTenant: handleTenantSelect, - onOpenSettings: openSettings, - }), - [ - activeCorrespondentFilters, - activeTagFilters, - appStatus, - clearFilters, - correspondents, - currentTenantId, - folderClickHandlers, - folderNodes, - handleFolderDragEnd, - handleFolderDragStart, - handleFolderDelete, - handleFolderRename, - handleLogout, - handleSearchChange, - handleSearchSubmit, - handleTenantSelect, - loading, - openSettings, - previewActive, - searchQuery, - draggedFolderId, - isFilterActive, - selectedFolder, - status, - tags, - tenantOptions, - tenantName, - toggleCorrespondentFilter, - toggleTagFilter, - handleTagCreate, - handleCorrespondentCreate, - handlePromptCreateFolder, - creatingFolder, - ], - ); - - const handleDetailPanelClose = useCallback(() => { - closeDetailPanel(); - }, [closeDetailPanel]); - - const detailPanelProps = useMemo( - () => ({ - document: detailPanelDocument, - tags, - tagLookupById, - onTagAdd: handleDocumentTagAdd, - onTagRemove: handleTagRemove, - previewEntry: selectedPreviewEntry, - onOpenPreview: openDocumentPreview, - onPromoteSelection: promoteSelectionOrder, - activePreviewId, - onUpdateTitle: handleDocumentTitleUpdate, - onUpdateIssued: handleDocumentIssuedUpdate, - ensureAssetUrl, - getDocumentAsset, - ensurePreviewData, - correspondents, - onCorrespondentAdd: handleCorrespondentAdd, - onCorrespondentRemove: handleCorrespondentRemove, - resolveApiPath, - onFolderNavigate: selectFolder, - onClose: handleDetailPanelClose, - resolveFolderPath, - }), - [ - activePreviewId, - correspondents, - detailPanelDocument, - ensureAssetUrl, - ensurePreviewData, - getDocumentAsset, - handleCorrespondentAdd, - handleCorrespondentRemove, - handleDetailPanelClose, - handleDocumentTitleUpdate, - handleDocumentIssuedUpdate, - handleDocumentTagAdd, - handleTagRemove, - openDocumentPreview, - promoteSelectionOrder, - resolveFolderPath, - selectFolder, - selectedPreviewEntry, - tags, - tagLookupById, - ], - ); + const sidebarProps = useSidebarProps({ + folderNodes, + folderClickHandlers, + handleFolderDelete, + handleFolderRename, + selectedFolder, + handleFolderDragStart, + handleFolderDragEnd, + draggedFolderId, + handlePromptCreateFolder, + creatingFolder, + tags, + activeTagFilters, + toggleTagFilter, + handleTagCreate, + correspondents, + activeCorrespondentFilters, + toggleCorrespondentFilter, + handleCorrespondentCreate, + appStatus, + loading, + previewActive, + searchQuery, + handleSearchChange, + handleSearchSubmit, + clearFilters, + isFilterActive, + handleLogout, + status, + tenantName, + tenantOptions, + currentTenantId, + handleTenantSelect, + openSettings, + }); - const handleDeskDocumentStackSelect = useCallback( - (docIds) => { - if (!Array.isArray(docIds) || docIds.length === 0) { - return; - } - - const rowKeys = docIds - .map((id) => resolveDocumentRowKey(id)) - .filter(Boolean); - - if (!rowKeys.length) { - return; - } - - const nextKeys = [...selectedEntries]; - rowKeys.forEach((key) => { - if (!nextKeys.includes(key)) { - nextKeys.push(key); - } - }); - - const anchor = rowKeys[0] || selectionAnchorRef.current || nextKeys[nextKeys.length - 1]; - - applySelection(nextKeys, { - anchor, - interactedKeys: rowKeys, - }); - }, - [applySelection, selectedEntries, selectionAnchorRef], - ); - - const handleDeskDocumentOpen = useCallback( - (docId, { useSelection = false } = {}) => { - const selectionDocIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : []; - let targetIds = []; - - if ((useSelection || selectionDocIds.includes(docId)) && selectionDocIds.length) { - targetIds = selectionDocIds; - } else if (selectionDocIds.length) { - targetIds = selectionDocIds; - } else if (docId) { - targetIds = [docId]; - } - - if (!targetIds.length) { - return; - } - - openDetailPanel({ documentIds: targetIds }); - }, - [openDetailPanel, selectedDocumentIds], - ); - - const handleDeskHelpOpen = useCallback(() => { - setDeskHelpOpenSafe(true); - }, [setDeskHelpOpenSafe]); - - const handleDeskHelpClose = useCallback(() => { - setDeskHelpOpenSafe(false); - }, [setDeskHelpOpenSafe]); - - const deskViewId = useMemo(() => { - if (showingSearchResults) { - const trimmedQuery = searchQuery.trim(); - const tagsKey = [...activeTagFilters].sort().join(','); - const correspondentsKey = [...activeCorrespondentFilters].sort().join(','); - return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`; - } - const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root'; - return `folder:${folderKey}`; - }, [ - showingSearchResults, - searchQuery, - activeTagFilters, - activeCorrespondentFilters, - selectedFolder, - ]); - const deskWorkspaceProps = useDeskWorkspaceProps({ documents, searchResults, @@ -2520,16 +1384,10 @@ const useDocumentsWorkspace = ({ handleDocumentsViewModeChange, handleDeskExit: handleDeskExitSafe, refreshCurrentFolder, - handleDeskDocumentOpen, inspectDocument, handleEntryPointerCore, - handleDeskDocumentStackSelect, promoteSelectionOrder, - handleDeskHelpOpen, - deskHelpOpen, - handleDeskHelpClose, currentTenantId, - deskViewId, selectedDocumentIds, selectedFolderIds, clearDocumentSelection, @@ -2555,6 +1413,17 @@ const useDocumentsWorkspace = ({ moveDocumentsToFolder, searchIncludeDescendants, toggleSearchIncludeDescendants, + selectedEntries, + selectionAnchorRef, + applySelection, + resolveDocumentRowKey, + showingSearchResults, + searchQuery, + activeCorrespondentFilters, + selectedFolder, + setDeskHelpOpen: setDeskHelpOpenSafe, + deskHelpOpen, + openDetailPanel, }); const contextValue = useMemo( @@ -2599,7 +1468,7 @@ const useDocumentsWorkspace = ({ deskWorkspaceProps, ensurePreviewData, ensureAssetUrl, - resolveApiPath, + resolveFolderPath, getDocumentAsset, notifyApiError, openTagsModal, @@ -2649,6 +1518,7 @@ const useDocumentsWorkspace = ({ deskWorkspaceProps, ensurePreviewData, ensureAssetUrl, + resolveFolderPath, getDocumentAsset, notifyApiError, openTagsModal, diff --git a/frontend/src/hooks/documents/useFolderTreeActions.js b/frontend/src/hooks/documents/useFolderTreeActions.js new file mode 100644 index 0000000..8103619 --- /dev/null +++ b/frontend/src/hooks/documents/useFolderTreeActions.js @@ -0,0 +1,618 @@ +import { useCallback, useMemo } from 'react'; +import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils'; + +const useFolderTreeActions = ({ + api, + token, + folderNodes, + setFolderNodes, + selectedFolder, + setSelectedFolder, + ensureFolderData, + ensureFolderAncestorsLoaded, + expandFolderAncestors, + applySelectedFolder, + notifyApiError, + setStatusMessage, + setLoading, + setFolderContents, + setCurrentFolder, + setSearchResults, + isFilterActive, + navigate, + handleFileDrop, + moveDocumentsToFolder, + draggedDocumentIds, + draggedFolderId, + setDraggedDocumentIds, + setDraggedFolderId, + isInvalidFolderDrop, + setCreatingFolder, +}) => { + const moveFolder = useCallback( + async (folderId, targetFolderId) => { + const node = folderNodes.get(folderId); + if (!node) { + setStatusMessage('Folder metadata unavailable. Try refreshing.', 'error'); + return; + } + + const previousParentKey = node.parentId ?? 'root'; + const targetKey = targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root'; + + if (previousParentKey === targetKey) { + return; + } + + const parent_id = targetKey === 'root' ? null : targetKey; + + try { + await api.patch(`/folders/${folderId}`, { parent_id }); + + setFolderNodes((prev) => { + const next = new Map(prev); + const currentNode = next.get(folderId); + if (!currentNode) { + return prev; + } + + next.set(folderId, { ...currentNode, parentId: parent_id ?? null }); + + const previousParent = next.get(previousParentKey); + if (previousParent) { + const remainingChildren = (previousParent.children || []).filter( + (childId) => childId !== folderId, + ); + next.set(previousParentKey, { + ...previousParent, + children: remainingChildren, + hasChildren: remainingChildren.length > 0, + }); + } + + if (!next.has(targetKey)) { + next.set(targetKey, { + id: targetKey, + name: targetKey === 'root' ? DEFAULT_FOLDER_NAME : 'Folder', + parentId: targetKey === 'root' ? null : null, + children: [], + expanded: targetKey === 'root', + loaded: false, + hasChildren: false, + }); + } + + const targetNode = next.get(targetKey); + if (targetNode && !targetNode.children.includes(folderId)) { + next.set(targetKey, { + ...targetNode, + children: [...targetNode.children, folderId], + hasChildren: true, + }); + } + + return next; + }); + + const refreshTargets = new Set([previousParentKey, targetKey]); + for (const key of refreshTargets) { + // eslint-disable-next-line no-await-in-loop + await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }); + } + + if (selectedFolder === folderId) { + await ensureFolderData(folderId, { force: true, prefetchDepth: 1 }); + setSelectedFolder(folderId); + } + + setStatusMessage('Folder moved.', 'success'); + } catch (error) { + const message = error.response?.data?.error || 'Failed to move folder.'; + notifyApiError(error, message); + + const refreshTargets = new Set([previousParentKey, targetKey]); + for (const key of refreshTargets) { + // eslint-disable-next-line no-await-in-loop + await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }); + } + } + }, + [ + api, + ensureFolderData, + folderNodes, + notifyApiError, + selectedFolder, + setFolderNodes, + setSelectedFolder, + setStatusMessage, + ], + ); + + const loadFolder = useCallback( + async (folderId, { showLoading = true, preserveSearch = false } = {}) => { + const targetId = folderId || 'root'; + setSelectedFolder(targetId); + await ensureFolderAncestorsLoaded(targetId); + expandFolderAncestors(targetId); + if (showLoading) setLoading(true); + try { + const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 }); + if (targetId !== 'root') { + try { + await ensureFolderData('root', { + force: false, + includeDocuments: false, + prefetchDepth: 1, + }); + } catch (error) { + console.warn('Failed to refresh root folder tree', error); + } + } + applySelectedFolder(targetId, contents); + if (!preserveSearch) { + setSearchResults(null); + } + } catch (error) { + notifyApiError(error, 'Failed to load folder contents.'); + } finally { + if (showLoading) setLoading(false); + } + }, + [ + applySelectedFolder, + ensureFolderAncestorsLoaded, + ensureFolderData, + expandFolderAncestors, + notifyApiError, + setLoading, + setSearchResults, + setSelectedFolder, + ], + ); + + const selectFolder = useCallback( + async (folderId, { replace = false, immediate = false } = {}) => { + const targetId = folderId && folderId !== 'root' ? folderId : 'root'; + + await ensureFolderAncestorsLoaded(targetId); + expandFolderAncestors(targetId); + + if (!navigate || immediate) { + await loadFolder(targetId, { preserveSearch: isFilterActive }); + setSelectedFolder(targetId); + return; + } + + const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`; + navigate(path, { replace }); + }, + [ + ensureFolderAncestorsLoaded, + expandFolderAncestors, + isFilterActive, + loadFolder, + navigate, + setSelectedFolder, + ], + ); + + const handleFolderRename = useCallback( + async (folderId, nextName) => { + if (!token) { + setStatusMessage('Log in to rename folders.', 'error'); + return false; + } + const trimmed = typeof nextName === 'string' ? nextName.trim() : ''; + if (!trimmed) { + setStatusMessage('Folder name cannot be empty.', 'error'); + return false; + } + + setLoading(true); + try { + await api.patch(`/folders/${folderId}`, { name: trimmed }); + + setFolderNodes((prev) => { + const next = new Map(prev); + const node = next.get(folderId); + if (node) { + next.set(folderId, { ...node, name: trimmed }); + } + return next; + }); + + setFolderContents((prev) => { + if (!prev.has(folderId)) { + return prev; + } + const next = new Map(prev); + const existing = next.get(folderId) || {}; + const folderInfo = existing.folder + ? { ...existing.folder, name: trimmed } + : { id: folderId, name: trimmed }; + next.set(folderId, { ...existing, folder: folderInfo }); + return next; + }); + + setCurrentFolder((prev) => (prev?.id === folderId ? { ...prev, name: trimmed } : prev)); + setStatusMessage('Folder renamed.', 'success'); + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to rename folder.'; + notifyApiError(error, message); + return false; + } finally { + setLoading(false); + } + }, + [ + api, + notifyApiError, + setCurrentFolder, + setFolderContents, + setFolderNodes, + setLoading, + setStatusMessage, + token, + ], + ); + + const handleFolderCreate = useCallback( + async (name) => { + if (!token) { + setStatusMessage('Log in to create folders.', 'error'); + return false; + } + if (!name.trim()) { + setStatusMessage('Folder name cannot be empty.', 'error'); + return false; + } + const payload = { + name: name.trim(), + parent_id: selectedFolder === 'root' ? null : selectedFolder, + }; + setCreatingFolder(true); + let succeeded = false; + try { + const { data } = await api.post('/folders', payload); + setStatusMessage('Folder created.', 'success'); + setFolderNodes((prev) => { + const next = new Map(prev); + const parentId = payload.parent_id || 'root'; + const parentNode = next.get(parentId); + if (parentNode) { + next.set(parentId, { + ...parentNode, + children: parentNode.children.concat([data.folder.id]), + loaded: true, + hasChildren: true, + }); + } + next.set(data.folder.id, { + id: data.folder.id, + name: data.folder.name, + parentId: parentId, + children: [], + expanded: false, + loaded: false, + hasChildren: false, + }); + return next; + }); + await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 }); + await selectFolder(data.folder.id, { immediate: true }); + succeeded = true; + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to create folder.'; + notifyApiError(error, message); + return false; + } finally { + setCreatingFolder(false); + if (!succeeded) { + setStatusMessage('Folder creation failed.', 'error'); + } + } + }, + [ + api, + ensureFolderData, + notifyApiError, + selectFolder, + selectedFolder, + setCreatingFolder, + setFolderNodes, + setStatusMessage, + token, + ], + ); + + const handleFolderDelete = useCallback( + async (folderId, { showMessage = true, manageLoading = true } = {}) => { + if (!token) { + if (showMessage) { + setStatusMessage('Log in to manage folders.', 'error'); + } + return false; + } + if (!folderId || folderId === 'root') { + if (showMessage) { + setStatusMessage('The root folder cannot be removed.', 'error'); + } + return false; + } + + if (manageLoading) { + setLoading(true); + } + + try { + const contents = await ensureFolderData(folderId, { + force: true, + prefetchDepth: 1, + }); + const hasChildren = (contents.subfolders || []).length > 0; + const hasDocs = (contents.documents || []).length > 0; + if (hasChildren || hasDocs) { + if (showMessage) { + setStatusMessage('Folder must be empty before it can be deleted.', 'error'); + } + return false; + } + + await api.delete(`/folders/${folderId}`); + + setFolderNodes((prev) => { + 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, + }); + } + } + return next; + }); + + setFolderContents((prev) => { + const next = new Map(prev); + next.delete(folderId); + return next; + }); + + if (selectedFolder === folderId) { + const node = folderNodes.get(folderId); + const parentId = node?.parentId || 'root'; + setSelectedFolder(parentId); + const parentContents = await ensureFolderData(parentId, { + force: true, + prefetchDepth: 1, + }); + applySelectedFolder(parentId, parentContents); + } else if (selectedFolder !== 'root') { + await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 }); + } + + if (showMessage) { + setStatusMessage('Folder deleted.', 'success'); + } + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to delete folder.'; + notifyApiError(error, message); + if (showMessage) { + setStatusMessage(message, 'error'); + } + return false; + } finally { + if (manageLoading) { + setLoading(false); + } + } + }, + [ + api, + token, + applySelectedFolder, + ensureFolderData, + folderNodes, + notifyApiError, + selectedFolder, + setFolderContents, + setFolderNodes, + setLoading, + setSelectedFolder, + setStatusMessage, + ], + ); + + const folderClickHandlers = useMemo( + () => ({ + onToggle: async (folderId) => { + const node = folderNodes.get(folderId); + const nextExpanded = !(node?.expanded ?? false); + if (nextExpanded) { + try { + await ensureFolderData(folderId, { + includeDocuments: false, + prefetchDepth: 1, + }); + } catch (error) { + notifyApiError(error, 'Failed to load folder.'); + } + } else if (node && !node.loaded) { + try { + await ensureFolderData(folderId, { + includeDocuments: false, + prefetchDepth: 1, + }); + } catch (error) { + notifyApiError(error, 'Failed to load folder.'); + } + } + setFolderNodes((prev) => { + const next = new Map(prev); + const current = next.get(folderId); + if (!current) return prev; + next.set(folderId, { ...current, expanded: nextExpanded }); + return next; + }); + }, + onSelect: selectFolder, + onDrop: async (event, folderId) => { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.classList.remove('is-drop-target'); + + let folderIds = []; + try { + const rawFolderList = event.dataTransfer.getData('application/x-papercrate-folder-list'); + if (rawFolderList) { + const parsed = JSON.parse(rawFolderList); + if (Array.isArray(parsed)) { + folderIds = parsed.filter(Boolean); + } + } + } catch (error) { + console.warn('[folders] Failed to parse folder list drag payload', error); + } + + if (!folderIds.length) { + let folderSourceId = draggedFolderId; + if (!folderSourceId) { + try { + if (event.dataTransfer.types?.includes('application/x-papercrate-folder')) { + folderSourceId = event.dataTransfer.getData('application/x-papercrate-folder'); + } + } catch (error) { + console.warn('[folders] Failed to read folder id from drag payload', error); + } + } + + if (folderSourceId) { + folderIds = [folderSourceId]; + } + } + + folderIds = Array.from(new Set(folderIds.filter(Boolean))); + + if (folderIds.length) { + setDraggedFolderId(null); + const invalidMove = folderIds.some((sourceId) => isInvalidFolderDrop(sourceId, folderId)); + if (invalidMove) { + setStatusMessage( + 'Cannot move a folder into itself or one of its descendants.', + 'error', + ); + return; + } + + for (const sourceId of folderIds) { + // eslint-disable-next-line no-await-in-loop + await moveFolder(sourceId, folderId); + } + } + + if (hasFiles(event)) { + await handleFileDrop(event.dataTransfer, folderId); + return; + } + + let docIds = []; + try { + const raw = event.dataTransfer.getData('application/x-papercrate-doc-list'); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + docIds = parsed.filter(Boolean); + } + } + } catch (error) { + console.warn('[documents] Failed to parse document list drag payload', error); + } + + if (!docIds.length) { + try { + const single = event.dataTransfer.getData('application/x-papercrate-doc'); + if (single) { + docIds = [single]; + } + } catch (error) { + console.warn('[documents] Failed to read single document drag payload', error); + } + } + + if (!docIds.length && draggedDocumentIds.length) { + docIds = draggedDocumentIds; + } + + docIds = Array.from(new Set(docIds)); + + if (!docIds.length || folderId === selectedFolder) { + return; + } + + setDraggedDocumentIds([]); + await moveDocumentsToFolder(docIds, folderId); + }, + onDragOver: (event, folderId) => { + const folderDragActive = Boolean(draggedFolderId); + if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) { + return; + } + + if (hasFiles(event)) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + event.currentTarget.classList.add('is-drop-target'); + return; + } + + if (draggedDocumentIds.length || folderDragActive) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + event.currentTarget.classList.add('is-drop-target'); + } + }, + onDragLeave: (event) => { + event.currentTarget.classList.remove('is-drop-target'); + }, + }), + [ + draggedDocumentIds, + draggedFolderId, + ensureFolderData, + folderNodes, + handleFileDrop, + isInvalidFolderDrop, + moveDocumentsToFolder, + moveFolder, + notifyApiError, + selectFolder, + selectedFolder, + setDraggedDocumentIds, + setDraggedFolderId, + setFolderNodes, + setStatusMessage, + ], + ); + + return { + loadFolder, + selectFolder, + handleFolderRename, + handleFolderCreate, + handleFolderDelete, + folderClickHandlers, + }; +}; + +export default useFolderTreeActions; diff --git a/frontend/src/sidebar/useSidebarProps.js b/frontend/src/sidebar/useSidebarProps.js new file mode 100644 index 0000000..dd38f75 --- /dev/null +++ b/frontend/src/sidebar/useSidebarProps.js @@ -0,0 +1,117 @@ +import { useMemo } from 'react'; +import { TAG_FILTER_UNTAGGED } from '../app/appLayoutUtils'; + +const useSidebarProps = ({ + folderNodes, + folderClickHandlers, + handleFolderDelete, + handleFolderRename, + selectedFolder, + handleFolderDragStart, + handleFolderDragEnd, + draggedFolderId, + handlePromptCreateFolder, + creatingFolder, + tags, + activeTagFilters, + toggleTagFilter, + handleTagCreate, + correspondents, + activeCorrespondentFilters, + toggleCorrespondentFilter, + handleCorrespondentCreate, + appStatus, + loading, + previewActive, + searchQuery, + handleSearchChange, + handleSearchSubmit, + clearFilters, + isFilterActive, + handleLogout, + status, + tenantName, + tenantOptions, + currentTenantId, + handleTenantSelect, + openSettings, +}) => + useMemo( + () => ({ + folderNodes, + onToggle: folderClickHandlers.onToggle, + onSelect: folderClickHandlers.onSelect, + onDrop: folderClickHandlers.onDrop, + onDragOver: folderClickHandlers.onDragOver, + onDragLeave: folderClickHandlers.onDragLeave, + onDeleteFolder: handleFolderDelete, + onRenameFolder: handleFolderRename, + selectedFolder, + onFolderDragStart: handleFolderDragStart, + onFolderDragEnd: handleFolderDragEnd, + draggedFolderId, + onCreateFolder: handlePromptCreateFolder, + creatingFolder, + tags, + untaggedFilterId: TAG_FILTER_UNTAGGED, + activeTagIds: activeTagFilters, + onToggleTagFilter: toggleTagFilter, + onCreateTag: (label) => handleTagCreate({ label }), + correspondents, + activeCorrespondentIds: activeCorrespondentFilters, + onToggleCorrespondentFilter: toggleCorrespondentFilter, + onCreateCorrespondent: (name) => handleCorrespondentCreate({ name }), + appStatus, + loading, + previewActive, + searchQuery, + onSearchChange: handleSearchChange, + onSearchSubmit: handleSearchSubmit, + onSearchClear: clearFilters, + isFilterActive, + onLogout: handleLogout, + status, + tenantName, + tenants: tenantOptions, + activeTenantId: currentTenantId, + onSelectTenant: handleTenantSelect, + onOpenSettings: openSettings, + }), + [ + activeCorrespondentFilters, + activeTagFilters, + appStatus, + clearFilters, + correspondents, + creatingFolder, + currentTenantId, + folderClickHandlers, + folderNodes, + handleCorrespondentCreate, + handleFolderDragEnd, + handleFolderDragStart, + handleFolderDelete, + handleFolderRename, + handleLogout, + handlePromptCreateFolder, + handleSearchChange, + handleSearchSubmit, + handleTagCreate, + handleTenantSelect, + isFilterActive, + loading, + openSettings, + previewActive, + searchQuery, + draggedFolderId, + selectedFolder, + status, + tags, + tenantName, + tenantOptions, + toggleCorrespondentFilter, + toggleTagFilter, + ], + ); + +export default useSidebarProps;