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