Files
papercrate/frontend/src/hooks/documents/useFolderTree.ts
T

501 lines
16 KiB
TypeScript

import { useCallback, useMemo, useState } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { createRootNode, DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils';
import {
getEntryId,
isDocumentEntry,
isFolderEntry,
createDocumentEntryKey,
createFolderEntryKey,
} from '../../app/entryKey';
type Identifier = string | number;
type FolderId = Identifier | 'root';
interface DocumentLike {
id?: Identifier | null;
[key: string]: unknown;
}
interface FolderSummary {
id?: FolderId;
name?: string;
parent_id?: FolderId | null;
parentId?: FolderId | null;
children?: FolderId[];
subfolders?: FolderSummary[];
has_children?: boolean;
hasChildren?: boolean;
[key: string]: unknown;
}
interface FolderContentsEntry {
folder?: FolderSummary | null;
documents?: DocumentLike[];
subfolders?: FolderSummary[];
__includesDocuments?: boolean;
__sortField?: string | null;
__sortDirection?: string | null;
[key: string]: unknown;
}
interface FolderTreeNode extends FolderSummary {
id: FolderId;
children: FolderId[];
expanded?: boolean;
loaded?: boolean;
hasChildren?: boolean;
}
interface ApiClient {
get<T = FolderContentsEntry>(path: string, config?: { params?: Record<string, unknown> }): Promise<{ data: T }>;
}
interface SelectionHelpers {
focusedDocumentId: Identifier | null;
setFocusedDocumentId: Dispatch<SetStateAction<Identifier | null>>;
setSelectedEntries: Dispatch<SetStateAction<string[]>>;
setSelectionOrder: Dispatch<SetStateAction<string[]>>;
selectionOrderRef: MutableRefObject<string[] | null>;
selectionAnchorRef: MutableRefObject<string | null>;
}
interface UseFolderTreeOptions {
initialSelectedFolder?: FolderId;
apiClient: ApiClient;
tenantIdRef: MutableRefObject<Identifier | null>;
documentsSortFieldRef: MutableRefObject<string>;
documentsSortDirectionRef: MutableRefObject<string>;
selectionHelpers: SelectionHelpers;
setDocuments: Dispatch<SetStateAction<DocumentLike[]>>;
setFolderContents: Dispatch<SetStateAction<Map<FolderId, FolderContentsEntry>>>;
folderContentsRef: MutableRefObject<Map<FolderId, FolderContentsEntry>>;
}
interface FolderOption {
id: FolderId;
label: string;
}
const useFolderTree = ({
initialSelectedFolder = 'root',
apiClient,
tenantIdRef,
documentsSortFieldRef,
documentsSortDirectionRef,
selectionHelpers,
setDocuments,
setFolderContents,
folderContentsRef,
}: UseFolderTreeOptions) => {
const [folderNodes, setFolderNodes] = useState<Map<FolderId, FolderTreeNode>>(() => {
const rootNode = createRootNode() as FolderTreeNode;
return new Map([[rootNode.id, rootNode]]);
});
const [selectedFolder, setSelectedFolder] = useState<FolderId>(initialSelectedFolder || 'root');
const [currentFolder, setCurrentFolder] = useState<FolderSummary | null>(null);
const [currentSubfolders, setCurrentSubfolders] = useState<FolderSummary[]>([]);
const {
focusedDocumentId,
setFocusedDocumentId,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
selectionAnchorRef,
} = selectionHelpers;
const applySelectedFolder = useCallback(
(folderId: FolderId, contents?: FolderContentsEntry | null) => {
const subfolders = Array.isArray(contents?.subfolders) ? contents.subfolders : [];
const docs = Array.isArray(contents?.documents) ? contents.documents : [];
const folderInfo = contents?.folder ?? null;
setCurrentSubfolders(subfolders);
setDocuments(docs);
setCurrentFolder(folderInfo);
const availableDocKeys = docs
.map((doc) => createDocumentEntryKey(doc?.id as Identifier))
.filter(Boolean);
const availableDocKeySet = new Set(availableDocKeys);
const availableFolderKeys = new Set(
subfolders
.map((folder) => createFolderEntryKey(folder?.id as Identifier))
.filter(Boolean),
);
let nextDocKeys = [];
let mergedSelection = [];
setSelectedEntries((previous) => {
const previousFolderKeys = previous
.filter(isFolderEntry)
.filter((key) => availableFolderKeys.has(key));
const previousDocKeys = previous.filter(isDocumentEntry);
nextDocKeys = previousDocKeys.filter((key) => availableDocKeySet.has(key));
mergedSelection = [...previousFolderKeys, ...nextDocKeys];
return mergedSelection;
});
const nextFocus = (() => {
const currentFocusedKey = createDocumentEntryKey(focusedDocumentId);
if (currentFocusedKey && availableDocKeySet.has(currentFocusedKey)) {
return focusedDocumentId;
}
if (nextDocKeys.length) {
const lastDocKey = nextDocKeys[nextDocKeys.length - 1];
return getEntryId(lastDocKey) || null;
}
return null;
})();
setFocusedDocumentId(nextFocus);
const nextAnchor = mergedSelection.length ? mergedSelection[mergedSelection.length - 1] : null;
selectionAnchorRef.current = nextAnchor;
selectionOrderRef.current = mergedSelection;
setSelectionOrder(mergedSelection);
},
[
focusedDocumentId,
selectionAnchorRef,
selectionOrderRef,
setDocuments,
setFocusedDocumentId,
setSelectedEntries,
setSelectionOrder,
],
);
const expandFolderAncestors = useCallback((targetId: FolderId | null) => {
if (!targetId || targetId === 'root') {
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
const root = prev.get('root');
if (root?.expanded) return prev;
const next = new Map<FolderId, FolderTreeNode>(prev);
next.set('root', { ...root, expanded: true });
return next;
});
return;
}
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
const next = new Map<FolderId, FolderTreeNode>(prev);
let currentId = targetId;
let guard = 0;
while (currentId && guard < 32) {
guard += 1;
const node = next.get(currentId);
if (!node) break;
if (!node.expanded) {
next.set(currentId, { ...node, expanded: true });
}
currentId = node.parentId ?? 'root';
}
return next;
});
}, []);
const ensureFolderData = useCallback(
async (
folderId: FolderId,
{
includeDocuments = true,
prefetchDepth = 0,
force = false,
sortField = documentsSortFieldRef.current,
sortDirection = documentsSortDirectionRef.current,
}: {
includeDocuments?: boolean;
prefetchDepth?: number;
force?: boolean;
sortField?: string;
sortDirection?: string;
} = {},
): Promise<FolderContentsEntry> => {
const requestTenantId = tenantIdRef.current;
const cached = folderContentsRef.current.get(folderId);
const cachedSortField = cached?.__sortField || documentsSortFieldRef.current;
const cachedSortDirection = cached?.__sortDirection || documentsSortDirectionRef.current;
const cachedSortMatches = cachedSortField === sortField && cachedSortDirection === sortDirection;
if (!force && cached) {
const includesDocuments = Boolean(cached.__includesDocuments);
if (!includeDocuments || (includesDocuments && cachedSortMatches)) {
if (prefetchDepth > 0) {
const subfolders = Array.isArray(cached.subfolders) ? cached.subfolders : [];
await Promise.allSettled(
subfolders.map((entry) =>
ensureFolderData(entry.id, {
includeDocuments: false,
prefetchDepth: prefetchDepth - 1,
force: false,
}),
),
);
}
return cached;
}
}
const path = folderId === 'root' ? 'root' : folderId;
const params: Record<string, unknown> = {};
if (!includeDocuments) {
params.include_documents = false;
} else {
params.sort = sortField;
params.dir = sortDirection;
}
const requestConfig = Object.keys(params).length ? { params } : {};
const { data } = await apiClient.get<FolderContentsEntry>(`/folders/${path}/contents`, requestConfig);
const childFolders = Array.isArray(data.subfolders) ? data.subfolders : [];
const childIds = childFolders
.map((child) => (child?.id ?? null) as FolderId | null)
.filter((id): id is FolderId => Boolean(id));
const enriched = {
...data,
__includesDocuments: includeDocuments,
__sortField: includeDocuments ? sortField : cachedSortField,
__sortDirection: includeDocuments ? sortDirection : cachedSortDirection,
};
if (tenantIdRef.current !== requestTenantId) {
return enriched;
}
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
const next = new Map<FolderId, FolderTreeNode>(prev);
const existingNode = next.get(folderId) || {
id: folderId,
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || 'Folder',
parentId: data.folder?.parent_id || 'root',
children: [],
expanded: folderId === 'root',
loaded: false,
hasChildren: false,
};
next.set(folderId, {
...existingNode,
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || existingNode.name,
parentId: data.folder?.parent_id ?? existingNode.parentId ?? 'root',
children: childIds,
expanded: folderId === 'root' ? true : existingNode.expanded,
loaded: true,
hasChildren: childIds.length > 0,
});
childFolders.forEach((child) => {
const childId = (child?.id ?? null) as FolderId | null;
if (!childId) {
return;
}
const childNode = next.get(childId);
const previousChildren = Array.isArray(childNode?.children) ? childNode.children : [];
const childHasChildren = (() => {
if (childNode?.loaded) {
return previousChildren.length > 0;
}
if (Array.isArray(child?.subfolders)) {
return child.subfolders.length > 0;
}
const flag = [child?.has_children, child?.hasChildren, childNode?.hasChildren]
.find((value) => value != null);
return Boolean(flag);
})();
next.set(childId, {
id: childId,
name: child.name,
parentId: (child.parent_id ?? 'root') as FolderId,
children: previousChildren,
expanded: childNode?.expanded ?? false,
loaded: childNode?.loaded ?? false,
hasChildren: childHasChildren,
});
});
return next;
});
if (prefetchDepth > 0 && childIds.length > 0 && tenantIdRef.current === requestTenantId) {
await Promise.allSettled(
childIds.map((childId) =>
ensureFolderData(childId, {
includeDocuments: false,
force: false,
prefetchDepth: prefetchDepth - 1,
}),
),
);
}
setFolderContents((prev: Map<FolderId, FolderContentsEntry>) => {
if (tenantIdRef.current !== requestTenantId) {
return prev;
}
const next = new Map<FolderId, FolderContentsEntry>(prev);
if (includeDocuments) {
next.set(folderId, enriched);
} else {
const existingEntry = next.get(folderId);
if (existingEntry) {
next.set(folderId, {
...existingEntry,
...data,
documents: existingEntry.__includesDocuments
? existingEntry.documents
: data.documents,
__includesDocuments: existingEntry.__includesDocuments || false,
__sortField: existingEntry.__sortField ?? enriched.__sortField,
__sortDirection: existingEntry.__sortDirection ?? enriched.__sortDirection,
});
} else {
next.set(folderId, enriched);
}
}
return next;
});
return enriched;
},
[
apiClient,
documentsSortDirectionRef,
documentsSortFieldRef,
tenantIdRef,
setFolderContents,
folderContentsRef,
],
);
const ensureFolderAncestorsLoaded = useCallback(
async (targetId: FolderId | null) => {
if (!targetId || targetId === 'root') {
return;
}
let current = targetId;
let guard = 0;
while (current && current !== 'root' && guard < 32) {
guard += 1;
const node = folderNodes.get(current);
if (node?.loaded) {
current = node.parentId ?? 'root';
continue;
}
await ensureFolderData(current, { includeDocuments: false, prefetchDepth: 0 });
current = folderNodes.get(current)?.parentId ?? 'root';
}
},
[folderNodes, ensureFolderData],
);
const isInvalidFolderDrop = useCallback(
(sourceId: FolderId | null, targetId: FolderId | null) => {
if (!sourceId) return false;
if (!targetId || targetId === 'root') {
return false;
}
if (sourceId === targetId) {
return true;
}
let current = targetId;
const visited = new Set();
while (current && current !== 'root' && !visited.has(current)) {
visited.add(current);
if (current === sourceId) {
return true;
}
const node = folderNodes.get(current);
if (!node) break;
current = node.parentId ?? 'root';
}
return false;
},
[folderNodes],
);
const resetFolderTreeState = useCallback(() => {
const rootNode = createRootNode() as FolderTreeNode;
setFolderNodes(new Map<FolderId, FolderTreeNode>([[rootNode.id, rootNode]]));
setFolderContents(new Map<FolderId, FolderContentsEntry>());
setSelectedFolder('root');
setCurrentFolder(null);
setCurrentSubfolders([]);
}, [setFolderContents]);
const currentFolderName = useMemo(() => {
if (selectedFolder === 'root' || !currentFolder) return DEFAULT_FOLDER_NAME;
return currentFolder.name;
}, [selectedFolder, currentFolder]);
const folderOptions: FolderOption[] = useMemo(() => {
const cache = new Map<FolderId, string>();
const computePath = (id: FolderId | null): string => {
if (cache.has(id as FolderId)) {
return cache.get(id as FolderId) as string;
}
if (!id || id === 'root') {
cache.set('root', DEFAULT_FOLDER_NAME);
return DEFAULT_FOLDER_NAME;
}
const node = folderNodes.get(id);
if (!node) {
return 'Folder';
}
const parentId = (node.parentId || 'root') as FolderId;
const parentPath = computePath(parentId);
const name = node.name || 'Folder';
const fullPath = parentId === 'root' ? name : `${parentPath}/${name}`;
cache.set(id, fullPath);
return fullPath;
};
const entries: FolderOption[] = [];
folderNodes.forEach((node, id) => {
if (!node) return;
entries.push({ id, label: computePath(id) });
});
entries.sort((a, b) => {
if (a.id === 'root') return -1;
if (b.id === 'root') return 1;
return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
});
return entries;
}, [folderNodes]);
const folderLabelMap = useMemo(() => {
const map = new Map<FolderId, string>();
folderOptions.forEach((option) => {
map.set(option.id, option.label);
});
return map;
}, [folderOptions]);
return {
folderNodes,
setFolderNodes,
selectedFolder,
setSelectedFolder,
currentFolder,
setCurrentFolder,
currentSubfolders,
setCurrentSubfolders,
currentFolderName,
folderOptions,
folderLabelMap,
applySelectedFolder,
ensureFolderData,
ensureFolderAncestorsLoaded,
expandFolderAncestors,
isInvalidFolderDrop,
resetFolderTreeState,
};
};
export default useFolderTree;