501 lines
16 KiB
TypeScript
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;
|