cleanup
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user