This commit is contained in:
2025-11-09 19:08:24 +01:00
parent 14b4dd434c
commit 3c1c910dc9
12 changed files with 2561 additions and 1431 deletions
+64 -1
View File
@@ -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 (
<Navigate
to="/account/login"
replace
state={
shouldRememberLastLocation
? { from: location.pathname + location.search }
: undefined
}
/>
);
}
return (
<AppShellContext.Provider value={contextValue}>
<div className="app-shell" ref={shellRef}>
<DropOverlay
active={dropOverlayState.active}
folderName={dropOverlayState.folderName}
/>
<Outlet />
{managementModals}
</div>
</AppShellContext.Provider>
);
};
export default AppLayout;
-64
View File
@@ -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 (
<Navigate
to="/account/login"
replace
state={
shouldRememberLastLocation
? { from: location.pathname + location.search }
: undefined
}
/>
);
}
return (
<AppShellContext.Provider value={contextValue}>
<div className="app-shell" ref={shellRef}>
<DropOverlay
active={dropOverlayState.active}
folderName={dropOverlayState.folderName}
/>
<Outlet />
{managementModals}
</div>
</AppShellContext.Provider>
);
};
export default DocumentsAppLayout;
@@ -1 +0,0 @@
export { default } from '../hooks/documents/useDocumentsWorkspace';
+98 -9
View File
@@ -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;
+300
View File
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
File diff suppressed because it is too large Load Diff
@@ -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;
+117
View File
@@ -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;