feat: Introduce FoldersManager and useFolderData to manage and display folder contents, including documents and subfolders.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user