refactor(documents): encapsulate panel logic and state into dedicated React contexts

This commit is contained in:
2025-12-08 12:31:30 +01:00
parent 44cf4603fe
commit 73468bbb02
13 changed files with 524 additions and 338 deletions
+78 -270
View File
@@ -6,21 +6,16 @@ import React, {
useEffect,
} from 'react';
import { DocumentsList, DocumentsGrid } from '../DocumentsView';
import type { DragEvent, ReactNode, RefObject } from 'react';
import type { ReactNode } from 'react';
import type {
DocumentsListEntry,
FolderEventHandler,
Document,
DocumentTag,
} from '../../types/documents';
import DesktopWorkspace from '../../desktop/components/DesktopWorkspace';
import { isTagTransferEvent } from '../features/tagging/tagTransfer';
import { isPointerModifierEvent, isPrimaryPointerEvent } from '../features/selection/useEntryPointer';
import {
WorkspaceSelectionProvider,
useWorkspaceSelectionContext,
type WorkspaceSelectionValue,
} from '../../app/WorkspaceSelectionContext';
import type { WorkspaceSelectionValue } from '../../app/WorkspaceSelectionContext';
import DocumentsPanelHeader, {
DocumentsPanelHeaderConfig,
DocumentsHeaderBreadcrumb,
@@ -33,14 +28,17 @@ import {
DEFAULT_LIST_ICON_SIZE,
DEFAULT_DESKTOP_CARD_SIZE,
} from '../../constants/documents';
import type { Identifier } from '../../types/identifiers';
import { DocumentsAssetContext } from '../context/DocumentsAssetContext';
import { DocumentsViewStateContext } from '../context/DocumentsViewStateContext';
import { DocumentsCommandContext } from '../context/DocumentsCommandContext';
import { useDocumentsContextValues } from './useDocumentsContextValues';
const EntryType = {
folder: 'folder',
document: 'document',
folder: 'folder' as const,
document: 'document' as const,
};
interface DocumentsPanelInnerProps {
export interface DocumentsPanelInnerProps {
headerLeading?: ReactNode;
onBreadcrumbNavigate?: (crumb: DocumentsHeaderBreadcrumb) => void;
[key: string]: any;
@@ -52,105 +50,60 @@ interface DocumentsPanelProps extends DocumentsPanelInnerProps {
export interface DocumentsViewProps {
entries: DocumentsListEntry[];
draggingDocumentIdsSet?: Set<Identifier> | null;
draggedFolderId?: Identifier | 'root' | null;
ensureAssetUrl?: (...args: any[]) => unknown;
getDocumentAsset?: (...args: any[]) => unknown;
onFolderClick?: FolderEventHandler;
onFolderSelect?: (folderId: Identifier | 'root') => void;
onFolderDragOver?: (event: DragEvent<HTMLElement>, folderId: Identifier | 'root') => void;
onFolderDragLeave?: (event: DragEvent<HTMLElement>) => void;
onFolderDrop?: (event: DragEvent<HTMLElement>, folderId: Identifier | 'root') => void;
onFolderDragStart?: (event: DragEvent<HTMLElement>, folderId: Identifier | 'root') => void;
onFolderDragEnd?: (event: DragEvent<HTMLElement>) => void;
onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise<boolean> | boolean;
onDocumentDragStart?: (event: DragEvent<HTMLElement>, document: Document) => void;
onDocumentDragEnd?: (event: DragEvent<HTMLElement>) => void;
onDocumentTagDragStart?: (event: DragEvent<HTMLElement>, docId: Identifier, tagId: Identifier) => void;
onDocumentTagDragEnd?: (event: DragEvent<HTMLElement>) => void;
onDocumentTagDragOver?: (event: DragEvent<HTMLElement>, docId: Identifier) => void;
onDocumentTagDragLeave?: (event: DragEvent<HTMLElement>) => void;
onDocumentTagAttach?: (documentId: Identifier, tagId: Identifier) => void;
onDocumentTagDetach?: (documentId: Identifier, tagId: Identifier) => void;
onDocumentRename?: (documentId: Identifier, nextTitle: string) => Promise<boolean> | boolean;
tagLookupById?: Map<Identifier, DocumentTag> | null;
onTagClick?: (tagId: Identifier) => void;
onCorrespondentClick?: (correspondentId: Identifier) => void;
activeCorrespondentIdSet?: Set<Identifier> | null;
scrollRef?: RefObject<HTMLElement | null>;
// Desk specific (optional for now or handled via intersection)
viewId?: string | null;
activeTagFilters?: Array<Identifier | null>;
}
const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
headerLeading = null,
onBreadcrumbNavigate,
currentFolderName,
breadcrumbs,
subfolders,
documents,
searchResultIds,
onFolderSelect,
onFolderDrop,
onFolderDragOver,
onFolderDragLeave,
onFolderDragStart,
onFolderDragEnd,
draggedFolderId,
onFolderRename,
draggingDocumentIds = [],
onDocumentDragStart,
onDocumentDragEnd,
onDocumentRename,
onEntryPointer = null,
tagLookupById,
activeCorrespondentIds = [],
ensureAssetUrl = null,
getDocumentAsset,
isSearchLoading = false,
viewMode = 'list',
onViewModeChange,
onRefresh = () => { },
sortField,
sortDirection,
onSortFieldChange,
onSortDirectionToggle,
onDeleteSelection,
documentLookup,
tags,
correspondents,
onBulkTagAdd,
onBulkTagRemove,
onBulkCorrespondentAdd,
onBulkCorrespondentRemove,
onBulkReanalyze,
folderOptions,
onMoveDocumentsToFolder,
searchQuery = '',
activeTagFilters = [],
activeCorrespondentFilters = [],
selectedFolder = null,
onDocumentTagAttach,
onDocumentTagDetach,
}): ReactNode => {
const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = (props) => {
const {
headerLeading = null,
onBreadcrumbNavigate,
currentFolderName,
breadcrumbs,
subfolders,
documents,
searchResultIds,
onRefresh = () => { },
sortField,
sortDirection,
onSortFieldChange,
onSortDirectionToggle,
onDeleteSelection,
documentLookup,
tags,
correspondents,
onBulkTagAdd,
onBulkTagRemove,
onBulkCorrespondentAdd,
onBulkCorrespondentRemove,
onBulkReanalyze,
folderOptions,
onMoveDocumentsToFolder,
viewMode = 'list',
onViewModeChange,
} = props;
const {
assetContextValue,
viewStateContextValue,
commandContextValue,
scrollRef,
hasDocumentEntries,
} = useDocumentsContextValues(props);
const {
setFocusedEntryKey,
clearSelection,
} = useWorkspaceSelectionContext();
const {
isActive: isFilterActive,
includeDescendants,
toggleIncludeDescendants,
toggleTag: toggleTagFilter,
toggleCorrespondent: toggleCorrespondentFilter,
} = useDocumentsFilter();
const searchDocuments = useMemo(
() =>
Array.isArray(searchResultIds)
? searchResultIds
.map((id) => documentLookup?.get?.(id) || null)
.filter((doc): doc is Record<string, unknown> => Boolean(doc))
.map((id: any) => documentLookup?.get?.(id) || null)
.filter((doc: any): doc is Record<string, unknown> => Boolean(doc))
: null,
[searchResultIds, documentLookup],
);
@@ -159,28 +112,10 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
const rows = showingSearchResults && searchDocuments ? searchDocuments : documents;
const viewId = useMemo(() => {
if (showingSearchResults) {
const trimmedQuery = searchQuery.trim();
const tagsKey = [...activeTagFilters].sort().join(',');
const correspondentsKey = [...activeCorrespondentFilters].sort().join(',');
return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`;
}
const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root';
return `folder:${folderKey}`;
}, [
showingSearchResults,
searchQuery,
activeTagFilters,
activeCorrespondentFilters,
selectedFolder,
]);
const headerTitle = showingSearchResults
? 'Search results'
: currentFolderName || 'Documents';
const headerSubtitle = null;
const headerActions = useMemo(
() => createDocumentsTableHeaderActions({
viewMode,
@@ -211,7 +146,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
<SelectionFloatingPanel
documentLookup={documentLookup}
tags={tags}
tagLookupById={tagLookupById}
tagLookupById={props.tagLookupById}
correspondents={correspondents}
onBulkTagAdd={onBulkTagAdd}
onBulkTagRemove={onBulkTagRemove}
@@ -226,7 +161,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
), [
documentLookup,
tags,
tagLookupById,
props.tagLookupById,
correspondents,
onBulkTagAdd,
onBulkTagRemove,
@@ -240,14 +175,13 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
]);
const headerConfig: DocumentsPanelHeaderConfig = useMemo(() => ({
title: headerTitle,
subtitle: headerSubtitle,
subtitle: null,
leading: headerLeading,
actions: headerActions,
breadcrumbs,
floatingActions,
}), [
headerTitle,
headerSubtitle,
headerLeading,
headerActions,
breadcrumbs,
@@ -265,7 +199,8 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
return trail[trail.length - 1]?.id || 'root';
}, [breadcrumbs, showingSearchResults]);
const selectionContextRef = useRef(null);
// Context marker logic for clearing selection on nav
const selectionContextRef = useRef<any>(null);
useEffect(() => {
const nextContext = showingSearchResults
? { type: 'search', marker: searchResultIds }
@@ -283,16 +218,16 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
}, [showingSearchResults, currentFolderId, searchResultIds, clearSelection]);
const entries = useMemo(() => {
const list = [];
const list: DocumentsListEntry[] = []; // Explicit type
if (!showingSearchResults) {
subfolders.forEach((folder) => {
subfolders.forEach((folder: any) => {
if (!folder || !folder.id) {
return;
}
list.push({ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder });
});
}
rows.forEach((doc) => {
rows.forEach((doc: any) => {
if (!doc || !doc.id) {
return;
}
@@ -301,143 +236,11 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
return list;
}, [showingSearchResults, subfolders, rows]);
const draggingSet = useMemo(
() => new Set(draggingDocumentIds || []),
[draggingDocumentIds],
);
const activeCorrespondentIdSet = useMemo(
() => new Set(activeCorrespondentIds || []),
[activeCorrespondentIds],
);
const scrollRef = useRef<HTMLElement | null>(null);
const suppressDocumentClickRef = useRef(false);
const isGridView = viewMode === 'grid';
const isDeskView = viewMode === 'desk';
const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []);
const draggingTagRef = useRef<{ docId: Identifier; tagId: Identifier } | null>(null);
const handleDocumentTagDragStart = useCallback(
(_event, docId, tagId) => {
draggingTagRef.current = { docId, tagId };
},
[],
);
const handleDocumentTagDragEnd = useCallback(
(_event) => {
draggingTagRef.current = null;
},
[],
);
const handleDocumentTagDragOver = useCallback(
(event, docId) => {
if (!isTagDragEvent(event)) {
return;
}
event.preventDefault();
const isSource = draggingTagRef.current?.docId === docId;
event.dataTransfer.dropEffect = isSource ? 'copy' : 'move';
event.currentTarget.classList.add('tag-drop-target');
},
[isTagDragEvent],
);
const handleDocumentTagDragLeave = useCallback(
(event) => {
if (!isTagDragEvent(event)) {
return;
}
if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) {
return;
}
event.currentTarget.classList.remove('tag-drop-target');
},
[isTagDragEvent],
);
const handleFolderClick = useCallback(
(folder, event) => {
if (!folder) {
return;
}
if (onEntryPointer) {
onEntryPointer(
{ type: EntryType.folder, id: folder.id, key: `folder:${folder.id}`, folder },
event,
);
}
if (
!isPointerModifierEvent(event)
&& isPrimaryPointerEvent(event)
&& scrollRef.current
) {
scrollRef.current.focus({ preventScroll: true });
setFocusedEntryKey(`folder:${folder.id}`);
}
},
[onEntryPointer, setFocusedEntryKey],
);
const handleDocumentDragStartLocal = useCallback(
(event, doc) => {
suppressDocumentClickRef.current = true;
onDocumentDragStart?.(event, doc);
},
[onDocumentDragStart],
);
const handleDocumentDragEndLocal = useCallback(
(event) => {
onDocumentDragEnd?.(event);
requestAnimationFrame(() => {
suppressDocumentClickRef.current = false;
});
},
[onDocumentDragEnd],
);
const hasDocumentEntries = useMemo(
() => entries.some((entry) => entry.type === EntryType.document),
[entries],
);
const viewProps = {
entries,
draggingDocumentIdsSet: draggingSet,
draggedFolderId,
onFolderClick: handleFolderClick,
onFolderSelect,
onFolderDragOver,
onFolderDragLeave,
onFolderDrop,
onFolderDragStart,
onFolderDragEnd,
onFolderRename,
onDocumentDragStart: handleDocumentDragStartLocal,
onDocumentDragEnd: handleDocumentDragEndLocal,
onDocumentTagDragStart: handleDocumentTagDragStart,
onDocumentTagDragEnd: handleDocumentTagDragEnd,
onDocumentTagDragOver: handleDocumentTagDragOver,
onDocumentTagDragLeave: handleDocumentTagDragLeave,
onDocumentTagAttach,
onDocumentTagDetach,
onDocumentRename,
ensureAssetUrl,
getDocumentAsset,
tagLookupById,
onTagClick: toggleTagFilter,
scrollRef,
activeCorrespondentIdSet: activeCorrespondentIdSet,
onCorrespondentClick: toggleCorrespondentFilter,
viewId,
onEntryPointer,
};
const [iconSizes] = useState({
@@ -446,6 +249,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
desk: DEFAULT_DESKTOP_CARD_SIZE,
});
const isSearchLoading = props.isSearchLoading || false;
const renderBody = () => {
const hasEntries = entries.length > 0;
@@ -489,21 +293,25 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
}, [shouldHandlePanelInteractions, clearSelection]);
return (
<>
<DocumentsPanelHeader
header={headerConfig}
onBreadcrumbClick={onBreadcrumbNavigate}
/>
<div className="documents-panel-wrapper">
<section
ref={scrollRef}
className={`documents-panel documents-panel--view-${panelVariant}`}
onClick={handleSectionClick}
>
{renderBody()}
</section>
</div>
</>
<DocumentsAssetContext.Provider value={assetContextValue}>
<DocumentsViewStateContext.Provider value={viewStateContextValue}>
<DocumentsCommandContext.Provider value={commandContextValue}>
<DocumentsPanelHeader
header={headerConfig}
onBreadcrumbClick={onBreadcrumbNavigate}
/>
<div className="documents-panel-wrapper">
<section
ref={scrollRef}
className={`documents-panel documents-panel--view-${panelVariant}`}
onClick={handleSectionClick}
>
{renderBody()}
</section>
</div>
</DocumentsCommandContext.Provider>
</DocumentsViewStateContext.Provider>
</DocumentsAssetContext.Provider>
);
};