feat: Introduce FoldersManager and useFolderData to manage and display folder contents, including documents and subfolders.

This commit is contained in:
2025-12-06 22:06:53 +01:00
parent 9b79115170
commit c88c3c13d6
8 changed files with 363 additions and 51 deletions
+19 -4
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import type { ReactNode, ComponentProps } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import {
DocumentsFilterProvider,
} from '../documents/context/DocumentsFilterContext';
@@ -12,12 +12,14 @@ import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
import { PanelManagerProvider, usePanelManager } from './PanelManagerContext';
import Sidebar from '../sidebar/Sidebar';
import useDocumentsShell from './useDocumentsShell';
import useFolderData from '../documents/data/useFolderData';
const DocumentsInner: React.FC<{
sidebarProps: ComponentProps<typeof Sidebar> | null;
surfaceConfig: any;
folderData: any;
onNavigate: (documentId: string) => void;
}> = ({ sidebarProps, surfaceConfig, onNavigate }) => {
}> = ({ sidebarProps, surfaceConfig, folderData, onNavigate }) => {
const { openPreview } = usePreviewContext();
const { collapsed: sidebarCollapsed } = useSidebarContext();
const {
@@ -46,9 +48,16 @@ const DocumentsInner: React.FC<{
const documentsTablePropsWithNav = useMemo(() => (
surfaceConfig.documentsTableProps
? { ...surfaceConfig.documentsTableProps, onBreadcrumbNavigate: handleBreadcrumbNavigate }
? {
...surfaceConfig.documentsTableProps,
onBreadcrumbNavigate: handleBreadcrumbNavigate,
documents: folderData.documents,
subfolders: folderData.subfolders,
currentFolderName: folderData.folder?.name,
isSearchLoading: folderData.loading,
}
: null
), [surfaceConfig.documentsTableProps, handleBreadcrumbNavigate]);
), [surfaceConfig.documentsTableProps, handleBreadcrumbNavigate, folderData]);
const { surface } = useWorkspaceSurface({
sidebarHidden,
@@ -98,8 +107,13 @@ const DocumentsRouteContent: React.FC = () => {
sidebarProps,
surfaceConfig,
documentsFilter,
documentsManager,
foldersManager,
} = useDocumentsShell();
const navigate = useNavigate();
const params = useParams();
const folderId = params.folderId || 'root';
const folderData = useFolderData(folderId, documentsManager, foldersManager);
const handleDocumentNavigate = useCallback((documentId: string) => {
navigate(`/documents/${documentId}`);
@@ -113,6 +127,7 @@ const DocumentsRouteContent: React.FC = () => {
<DocumentsInner
sidebarProps={sidebarProps}
surfaceConfig={surfaceConfig}
folderData={folderData}
onNavigate={handleDocumentNavigate}
/>
</PreviewProvider>
+4
View File
@@ -15,6 +15,8 @@ interface DocumentsShellView {
sidebarProps: ComponentProps<typeof Sidebar> | null;
surfaceConfig: WorkspaceSurfaceConfig;
documentsFilter: DocumentsFilterValue;
documentsManager: any;
foldersManager: any; // Using any to avoid circular dependency
}
const useDocumentsShell = (): DocumentsShellView => {
@@ -49,6 +51,8 @@ const useDocumentsShell = (): DocumentsShellView => {
sidebarProps: sidebarWithActions,
surfaceConfig,
documentsFilter: shell.documentsFilter as DocumentsFilterValue,
documentsManager: shell.documentsManager,
foldersManager: shell.foldersManager,
};
}, [shell]);
};
+177
View File
@@ -0,0 +1,177 @@
import { shallowEqual } from 'react-redux';
import type { FolderNodeId } from '../types/identifiers';
import type { Folder } from '../types/documents';
type ManagedFolder = Folder;
type FetchFolder = (id: FolderNodeId) => Promise<unknown>;
class FoldersManager<T extends ManagedFolder = ManagedFolder> {
private byId: Map<FolderNodeId, T>;
private fetcher?: FetchFolder;
private inflight: Map<FolderNodeId, Promise<T | null>>;
private listeners: Set<() => void>;
private emitScheduled: boolean;
constructor(
fetchFolder?: FetchFolder,
) {
this.byId = new Map();
this.fetcher = fetchFolder;
this.inflight = new Map();
this.listeners = new Set();
this.emitScheduled = false;
}
private emit() {
if (this.emitScheduled) {
return;
}
this.emitScheduled = true;
setTimeout(() => {
this.emitScheduled = false;
this.listeners.forEach((fn) => fn());
}, 0);
}
subscribe(listener: () => void) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
setFetcher(fetchFolder?: FetchFolder) {
this.fetcher = fetchFolder;
}
ingest(rawFolders: unknown[] = []): { canonical: T[]; changed: boolean } {
const folders = rawFolders.map((f) => f as T).filter(Boolean);
let changed = false;
let nextById = this.byId;
const canonical: T[] = [];
folders.forEach((folder) => {
const id = folder?.id;
if (id == null) {
canonical.push(folder);
return;
}
const existing = nextById.get(id as FolderNodeId);
const merged = existing ? ({ ...existing, ...folder } as T) : ({ ...(folder as T) } as T);
const useExisting = existing && shallowEqual(existing, merged);
const nextFolder = useExisting ? (existing as T) : merged;
if (!useExisting) {
if (!changed) {
nextById = new Map(this.byId);
}
nextById.set(id as FolderNodeId, nextFolder);
changed = true;
}
canonical.push(nextFolder);
});
if (changed) {
this.byId = nextById;
this.emit();
}
return { canonical, changed };
}
async ensure(id: FolderNodeId, fetcherOverride?: FetchFolder): Promise<T | null> {
if (id == null) {
return null;
}
const cached = this.byId.get(id);
if (cached) {
return cached;
}
const fetcher = fetcherOverride || this.fetcher;
if (!fetcher) {
return null;
}
const inflight = this.inflight.get(id);
if (inflight) {
return inflight;
}
const request = (async () => {
try {
const fetched = await fetcher(id);
const { canonical } = this.ingest([fetched as unknown]);
return canonical[0] ?? null;
} finally {
this.inflight.delete(id);
}
})();
this.inflight.set(id, request);
return request;
}
map(mapper: (folder: T) => T | undefined): boolean {
if (!this.byId.size) {
return false;
}
let changed = false;
const next = new Map<FolderNodeId, T>();
this.byId.forEach((folder, key) => {
const updated = mapper(folder);
const nextFolder = updated === undefined ? folder : updated;
if (nextFolder !== folder) {
changed = true;
}
next.set(key, nextFolder ?? folder);
});
if (changed) {
this.byId = next;
this.emit();
}
return changed;
}
remove(ids: Array<FolderNodeId>): boolean {
if (!Array.isArray(ids) || ids.length === 0) {
return false;
}
let changed = false;
let next = this.byId;
ids.forEach((id) => {
if (next.has(id)) {
if (!changed) {
next = new Map(this.byId);
}
next.delete(id);
changed = true;
}
});
if (changed) {
this.byId = next;
this.emit();
}
return changed;
}
getById(id: FolderNodeId): T | null {
return this.byId.get(id) ?? null;
}
getMany(ids: Array<FolderNodeId> = []): T[] {
return ids
.map((id) => this.byId.get(id) || null)
.filter((folder): folder is T => Boolean(folder));
}
getSnapshot(): Map<FolderNodeId, T> {
return this.byId;
}
}
export default FoldersManager;
@@ -40,6 +40,7 @@ import { useStatusToast, type ToastVariant } from '../../lib/context/StatusToast
import useAuthManager from './useAuthManager';
import useTenantManager from './useTenantManager';
import useDocuments from './useDocuments';
import FoldersManager from '../FoldersManager';
import { fetchDocument } from '../../lib/api/apiClient';
import useFolderTree from '../features/folders/useFolderTree';
import useFolderTreeActions from '../features/folders/useFolderTreeActions';
@@ -315,6 +316,12 @@ const useDocumentsWorkspace = ({
fetchDocumentById,
});
const foldersManagerRef = useRef<FoldersManager | null>(null);
if (!foldersManagerRef.current) {
foldersManagerRef.current = new FoldersManager();
}
const foldersManager = foldersManagerRef.current;
const documentLookup = useSyncExternalStore(
(onStoreChange) => documentsManager.subscribe(onStoreChange),
() => documentsManager.getSnapshot(),
@@ -1301,6 +1308,8 @@ const useDocumentsWorkspace = ({
uploadQueue,
clearUploadQueue,
documentsFilter,
documentsManager,
foldersManager,
};
// hook callers handle rendering / routing
@@ -0,0 +1,127 @@
import { useState, useEffect, useCallback, useMemo, useSyncExternalStore } from 'react';
import { listFolderContents } from '../../lib/api/apiClient';
import type { Document, Folder, FolderContents } from '../../types/documents';
import type { Identifier, DocumentId, FolderNodeId } from '../../types/identifiers';
import type DocumentsManager from '../DocumentsManager';
import type FoldersManager from '../FoldersManager';
interface UseFolderDataResult {
folder: Folder | null;
documents: Document[];
subfolders: Folder[];
loading: boolean;
error: Error | null;
refresh: () => Promise<void>;
}
export const useFolderData = (
folderId: Identifier | 'root' = 'root',
documentsManager?: DocumentsManager<Document>,
foldersManager?: FoldersManager<Folder>
): UseFolderDataResult => {
const [data, setData] = useState<FolderContents>({
folder: null,
documents: [],
subfolders: [],
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const path = folderId === 'root' ? 'root' : folderId;
const result = await listFolderContents<FolderContents>(path, {
include_documents: true,
});
if (documentsManager && result.documents) {
documentsManager.ingest(result.documents);
}
if (foldersManager) {
const foldersToIngest: Folder[] = [];
if (result.folder) foldersToIngest.push(result.folder);
if (result.subfolders) foldersToIngest.push(...result.subfolders);
if (foldersToIngest.length > 0) {
foldersManager.ingest(foldersToIngest);
}
}
setData({
folder: result.folder || null,
documents: result.documents || [],
subfolders: result.subfolders || [],
});
} catch (err) {
console.error('Failed to fetch folder data:', err);
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setLoading(false);
}
}, [folderId, documentsManager, foldersManager]);
useEffect(() => {
fetchData();
}, [fetchData]);
const subscribeToDocs = useCallback((onStoreChange: () => void) => {
if (!documentsManager) return () => { };
return documentsManager.subscribe(onStoreChange);
}, [documentsManager]);
const getDocSnapshot = useCallback(() => {
if (!documentsManager) return null;
return documentsManager.getSnapshot();
}, [documentsManager]);
const docSnapshot = useSyncExternalStore(subscribeToDocs, getDocSnapshot, getDocSnapshot);
const reactiveDocuments = useMemo(() => {
if (!documentsManager || !docSnapshot) {
return data.documents;
}
const ids = data.documents.map(d => d.id as DocumentId).filter(Boolean);
return documentsManager.getMany(ids);
}, [data.documents, documentsManager, docSnapshot]);
const subscribeToFolders = useCallback((onStoreChange: () => void) => {
if (!foldersManager) return () => { };
return foldersManager.subscribe(onStoreChange);
}, [foldersManager]);
const getFolderSnapshot = useCallback(() => {
if (!foldersManager) return null;
return foldersManager.getSnapshot();
}, [foldersManager]);
const folderSnapshot = useSyncExternalStore(subscribeToFolders, getFolderSnapshot, getFolderSnapshot);
const reactiveFolder = useMemo(() => {
if (!foldersManager || !folderSnapshot || !data.folder) {
return data.folder;
}
return foldersManager.getById(data.folder.id as FolderNodeId) || data.folder;
}, [data.folder, foldersManager, folderSnapshot]);
const reactiveSubfolders = useMemo(() => {
if (!foldersManager || !folderSnapshot) {
return data.subfolders;
}
const ids = data.subfolders.map(f => f.id as FolderNodeId).filter(Boolean);
return foldersManager.getMany(ids);
}, [data.subfolders, foldersManager, folderSnapshot]);
return {
...data,
folder: reactiveFolder,
documents: reactiveDocuments,
subfolders: reactiveSubfolders,
loading,
error,
refresh: fetchData,
};
};
export default useFolderData;
@@ -9,37 +9,23 @@ import {
createDocumentEntryKey,
createFolderEntryKey,
} from '../../../app/entryKey';
import type { FolderId, FolderNodeId, Identifier } from '../../../types/identifiers';
import type { Document } from '../../../types/documents';
import type { FolderNodeId, Identifier, FolderId } from '../../../types/identifiers';
import type { Document, Folder, FolderContents } from '../../../types/documents';
interface FolderSummary {
id?: FolderNodeId;
name?: string;
parent_id?: FolderNodeId | null;
parentId?: FolderNodeId | null;
children?: FolderNodeId[];
subfolders?: FolderSummary[];
has_children?: boolean;
hasChildren?: boolean;
[key: string]: unknown;
}
interface FolderContentsEntry {
folder?: FolderSummary | null;
documents?: Document[];
subfolders?: FolderSummary[];
interface FolderContentsEntry extends FolderContents {
__includesDocuments?: boolean;
__sortField?: string | null;
__sortDirection?: string | null;
[key: string]: unknown;
}
interface FolderTreeNode extends FolderSummary {
interface FolderTreeNode extends Folder {
id: FolderNodeId;
children: FolderNodeId[];
expanded?: boolean;
loaded?: boolean;
hasChildren?: boolean;
parentId?: FolderNodeId;
}
interface SelectionHelpers {
@@ -129,8 +115,8 @@ const useFolderTree = ({
}, []);
const [selectedFolder, setSelectedFolder] = useState<FolderNodeId>(initialSelectedFolder || 'root');
const [currentFolder, setCurrentFolder] = useState<FolderSummary | null>(null);
const [currentSubfolders, setCurrentSubfolders] = useState<FolderSummary[]>([]);
const [currentFolder, setCurrentFolder] = useState<Folder | null>(null);
const [currentSubfolders, setCurrentSubfolders] = useState<Folder[]>([]);
const {
focusedDocumentId,
@@ -15,7 +15,7 @@ import SelectionSummary from './SelectionSummary';
import { useAppState } from '../../../lib/store/appState';
import { useWorkspaceSelectionContext } from '../../../app/WorkspaceSelectionContext';
import type { DocumentId } from '../../../types/identifiers';
import type { FolderTreeNode } from '../../../lib/api/apiTypes';
import type { FolderTreeNode, FolderTreeResponseItem } from '../../../lib/api/apiTypes';
type NullableDocumentId = DocumentId | null;
@@ -251,31 +251,14 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
try {
const data = await getFolderTree();
// Convert flat list to tree
const nodeMap = new Map<string, FolderTreeNode>();
data.forEach((item) => {
nodeMap.set(item.id, {
const traverse = (items: FolderTreeResponseItem[]): FolderTreeNode[] => {
return items.map(item => ({
...item,
children: [],
} as FolderTreeNode);
});
const roots: FolderTreeNode[] = [];
data.forEach((item) => {
const node = nodeMap.get(item.id);
if (!node) return;
if (item.children && item.children.length > 0) {
node.children = item.children
.map((id) => nodeMap.get(id))
.filter((n): n is FolderTreeNode => Boolean(n));
}
if (!item.parent_id) {
roots.push(node);
}
});
children: item.children ? traverse(item.children) : [],
}));
};
const roots = traverse(data);
setRemoteFolderTree(roots);
return roots;
} catch (error) {
+13 -2
View File
@@ -14,6 +14,13 @@ export interface DocumentCorrespondent {
count?: number;
}
export interface FolderContents {
folder: Folder | null;
documents: Document[];
subfolders: Folder[];
[key: string]: unknown;
}
/**
* A tag entity as returned by the API (includes usage_count).
* Use DocumentTag for the embedded version on documents.
@@ -76,8 +83,12 @@ export interface Document {
}
export interface Folder {
id?: Identifier | 'root';
name?: string;
id: Identifier | 'root';
name: string;
parent_id?: Identifier | 'root' | null;
created_at?: string;
updated_at?: string;
[key: string]: unknown;
}
type FolderEntry = {