cleanup
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
const useDeskWorkspaceProps = ({
|
const useDeskWorkspaceProps = ({
|
||||||
documents,
|
documents,
|
||||||
@@ -9,16 +9,10 @@ const useDeskWorkspaceProps = ({
|
|||||||
handleDocumentsViewModeChange,
|
handleDocumentsViewModeChange,
|
||||||
handleDeskExit,
|
handleDeskExit,
|
||||||
refreshCurrentFolder,
|
refreshCurrentFolder,
|
||||||
handleDeskDocumentOpen,
|
|
||||||
inspectDocument,
|
inspectDocument,
|
||||||
handleEntryPointerCore,
|
handleEntryPointerCore,
|
||||||
handleDeskDocumentStackSelect,
|
|
||||||
promoteSelectionOrder,
|
promoteSelectionOrder,
|
||||||
handleDeskHelpOpen,
|
|
||||||
deskHelpOpen,
|
|
||||||
handleDeskHelpClose,
|
|
||||||
currentTenantId,
|
currentTenantId,
|
||||||
deskViewId,
|
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
selectedFolderIds,
|
selectedFolderIds,
|
||||||
clearDocumentSelection,
|
clearDocumentSelection,
|
||||||
@@ -44,8 +38,102 @@ const useDeskWorkspaceProps = ({
|
|||||||
moveDocumentsToFolder,
|
moveDocumentsToFolder,
|
||||||
searchIncludeDescendants,
|
searchIncludeDescendants,
|
||||||
toggleSearchIncludeDescendants,
|
toggleSearchIncludeDescendants,
|
||||||
}) =>
|
selectedEntries,
|
||||||
useMemo(
|
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,
|
documents,
|
||||||
searchResults,
|
searchResults,
|
||||||
@@ -137,5 +225,6 @@ const useDeskWorkspaceProps = ({
|
|||||||
toggleSearchIncludeDescendants,
|
toggleSearchIncludeDescendants,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default useDeskWorkspaceProps;
|
export default useDeskWorkspaceProps;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user