refactor: Extract folder assignment logic into a new SelectionFolderMenu component, adding new icons and updating related styles.

This commit is contained in:
2025-11-28 19:28:12 +01:00
parent 970490d9c8
commit 34604c5842
4 changed files with 407 additions and 340 deletions
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DEFAULT_FOLDER_NAME } from '../constants/workspace';
import { getFolderTree } from '../lib/apiClient';
import {
TrashIcon,
@@ -9,24 +10,17 @@ import {
CorrespondentIcon,
} from '../ui/icons';
import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu';
import SelectionFolderMenu from './SelectionFolderMenu';
import SelectionSummary from './SelectionSummary';
import { useAppState } from '../app/appState';
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
import { DEFAULT_FOLDER_NAME } from '../constants/workspace';
import type { DocumentId } from '../types/identifiers';
import type { FolderTreeNode } from '../lib/apiTypes';
type NullableDocumentId = DocumentId | null;
type SelectedIdList = NullableDocumentId[] | null;
type FolderTreeNode = {
id?: DocumentId;
name?: string;
label?: string;
value?: DocumentId;
children?: FolderTreeNode[];
};
interface TagOption {
id?: DocumentId;
label?: string;
@@ -67,7 +61,6 @@ export interface SelectionFloatingActionsProps {
tags?: TagOption[] | null;
tagLookupById?: Map<DocumentId, TagOption> | null;
correspondents?: CorrespondentOption[] | null;
folderOptions?: SelectionAssignmentMenuItem[] | null;
onBulkTagAdd?: (args: BulkTagMutationArgs) => Promise<void> | void;
onBulkTagRemove?: (args: BulkTagMutationArgs) => Promise<void> | void;
onBulkCorrespondentAdd?: (args: BulkCorrespondentAddArgs) => Promise<void> | void;
@@ -75,7 +68,7 @@ export interface SelectionFloatingActionsProps {
onBulkReanalyze?: (documentIds: DocumentId[]) => Promise<void> | void;
onDeleteSelection?: () => void;
onClearSelection?: () => void;
onMoveDocumentsToFolder?: (documentIds: DocumentId[], folderId: DocumentId) => Promise<void> | void;
onMoveDocumentsToFolder?: (documentIds: DocumentId[], folderId: DocumentId | null) => Promise<void> | void;
}
const normalizeDocumentList = (selectedIds?: SelectedIdList): DocumentId[] =>
@@ -83,52 +76,6 @@ const normalizeDocumentList = (selectedIds?: SelectedIdList): DocumentId[] =>
? selectedIds.filter((value): value is DocumentId => value !== null && value !== undefined)
: [];
const isRecord = (value: unknown): value is Record<string, unknown> => value != null && Object(value) === value;
const splitLabelSegments = (input: unknown): string[] => {
const text = `${input ?? ''}`.trim();
return text ? text.split('/') : [];
};
const buildFolderTreeOptions = (tree?: FolderTreeNode[] | null): SelectionAssignmentMenuItem[] => {
const entries: SelectionAssignmentMenuItem[] = [];
const traverse = (nodes: FolderTreeNode[] | null, parentSegments: string[]) => {
if (!Array.isArray(nodes) || nodes.length === 0) {
return;
}
nodes.forEach((node) => {
if (!node || !node.id) {
return;
}
const trimmedName = node.name?.trim?.();
const name = trimmedName?.length ? trimmedName : 'Folder';
const nextSegments = parentSegments.concat([name]);
const label = nextSegments.join('/');
entries.push({
id: node.id,
label,
state: 'none',
payload: {
id: node.id,
label,
segments: nextSegments,
depth: Math.max(nextSegments.length - 1, 0),
},
});
if (Array.isArray(node.children) && node.children.length) {
traverse(node.children, nextSegments);
}
});
};
traverse(Array.isArray(tree) ? tree : [], [DEFAULT_FOLDER_NAME]);
entries.sort((a, b) => (a.label || '').localeCompare(b.label || '', undefined, { sensitivity: 'base' }));
return [{ id: 'root', label: DEFAULT_FOLDER_NAME, state: 'none', payload: { id: 'root' } }, ...entries];
};
const buildTagAssignments = (
selectedDocuments: Document[],
tagLookupById: Map<DocumentId, TagOption> | null,
@@ -261,7 +208,6 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
tags = [],
tagLookupById,
correspondents = [],
folderOptions = [],
onBulkTagAdd,
onBulkTagRemove,
onBulkCorrespondentAdd,
@@ -271,7 +217,7 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
onClearSelection = null,
onMoveDocumentsToFolder,
}) => {
const { token, tenant } = useAppState() as { token?: string; tenant?: { id?: DocumentId } | null };
const { token, tenant } = useAppState() as { token?: string; tenant?: { id?: DocumentId; name?: string } | null };
const tenantId = tenant?.id ?? null;
const documentLookupMap = useMemo(() => (
@@ -279,22 +225,22 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
), [documentLookup]);
const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null;
const [remoteFolderOptions, setRemoteFolderOptions] = useState<SelectionAssignmentMenuItem[] | null>(null);
const folderTreeFetchRef = useRef<Promise<SelectionAssignmentMenuItem[]> | null>(null);
const [remoteFolderTree, setRemoteFolderTree] = useState<FolderTreeNode[] | null>(null);
const folderTreeFetchRef = useRef<Promise<FolderTreeNode[]> | null>(null);
useEffect(() => {
setRemoteFolderOptions(null);
setRemoteFolderTree(null);
folderTreeFetchRef.current = null;
}, [tenantId, token]);
const requestFolderTree = useCallback(async (): Promise<SelectionAssignmentMenuItem[]> => {
const requestFolderTree = useCallback(async (): Promise<FolderTreeNode[]> => {
if (!token) {
setRemoteFolderOptions([]);
setRemoteFolderTree([]);
return [];
}
if (Array.isArray(remoteFolderOptions)) {
return remoteFolderOptions;
if (Array.isArray(remoteFolderTree)) {
return remoteFolderTree;
}
if (folderTreeFetchRef.current) {
@@ -304,12 +250,11 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
const fetchPromise = (async () => {
try {
const data = await getFolderTree();
const options = buildFolderTreeOptions(data);
setRemoteFolderOptions(options);
return options;
setRemoteFolderTree(data);
return data;
} catch (error) {
console.warn('[selection] Failed to load folder tree', error);
setRemoteFolderOptions([]);
setRemoteFolderTree([]);
return [];
} finally {
folderTreeFetchRef.current = null;
@@ -318,19 +263,12 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
folderTreeFetchRef.current = fetchPromise;
return fetchPromise;
}, [remoteFolderOptions, token]);
}, [remoteFolderTree, token]);
const handleMoveMenuOpen = useCallback(() => {
requestFolderTree();
}, [requestFolderTree]);
const effectiveFolderOptions = useMemo<SelectionAssignmentMenuItem[]>(() => {
if (remoteFolderOptions !== null) {
return remoteFolderOptions;
}
return Array.isArray(folderOptions) ? folderOptions : [];
}, [remoteFolderOptions, folderOptions]);
const documentIdList = useMemo<DocumentId[]>(
() => normalizeDocumentList(selectedDocumentIds),
[selectedDocumentIds],
@@ -356,34 +294,6 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
const selectedDocCount = selectedDocuments.length;
const moveAssignments = useMemo<SelectionAssignmentMenuItem[]>(() => {
if (!Array.isArray(effectiveFolderOptions)) {
return [];
}
return effectiveFolderOptions
.map<SelectionAssignmentMenuItem | null>((option) => {
const id = (option?.id ?? option?.payload?.id ?? option?.value) as DocumentId | undefined;
if (!id) {
return null;
}
const label = option?.label || option?.payload?.label || option?.name || String(id);
const segments = splitLabelSegments(label);
const depth = Math.max(segments.length - 1, 0);
return {
id,
label,
state: 'none',
payload: {
id,
label,
segments,
depth,
},
};
})
.filter((entry): entry is SelectionAssignmentMenuItem => Boolean(entry));
}, [effectiveFolderOptions]);
const tagAssignments = useMemo(
() => buildTagAssignments(selectedDocuments, tagLookupMap, tags, selectedDocCount),
[selectedDocuments, tagLookupMap, tags, selectedDocCount],
@@ -394,34 +304,6 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
[selectedDocuments, correspondents, selectedDocCount],
);
const renderFolderLabel = useCallback((item: SelectionAssignmentMenuItem) => {
const payload = (item?.payload as { segments?: string[]; depth?: number }) || {};
const segments = payload.segments || splitLabelSegments(item.label);
const depth = payload.depth ?? Math.max(segments.length - 1, 0);
const clampedDepth = Math.min(depth, 6);
const indentWidth = clampedDepth > 0 ? clampedDepth * 0.9 : 0;
const name = segments.length ? segments[segments.length - 1] : item?.label || 'Folder';
const parentPath = segments.length > 1 ? segments.slice(0, -1).join(' / ') : '';
return (
<>
{indentWidth ? (
<span
className="selection-assignment__indent"
style={{ width: `${indentWidth}rem` }}
aria-hidden="true"
/>
) : null}
<span className="selection-assignment__folder-label">
<span className="selection-assignment__folder-name">{name}</span>
{parentPath ? (
<span className="selection-assignment__folder-path">{parentPath}</span>
) : null}
</span>
</>
);
}, []);
const handleToggleTagAssignment = useCallback(
async (item: SelectionAssignmentMenuItem) => {
if (!selectedDocCount || !item) {
@@ -477,18 +359,11 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
);
const handleMoveSelectionToFolder = useCallback(
async (option: unknown) => {
async (folderId: DocumentId | null) => {
if (!documentIdList.length || !onMoveDocumentsToFolder) {
return;
}
const candidate = option as { id?: DocumentId; value?: DocumentId } | DocumentId | null;
const value = isRecord(candidate)
? (candidate?.id ?? candidate?.value ?? null)
: candidate;
if (!value) {
return;
}
await onMoveDocumentsToFolder(documentIdList, value as DocumentId);
await onMoveDocumentsToFolder(documentIdList, folderId);
},
[documentIdList, onMoveDocumentsToFolder],
);
@@ -503,8 +378,12 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
const showPrimaryButtons = Boolean(onBulkReanalyze || onDeleteSelection || onClearSelection);
const rootTitle = (remoteFolderTree && remoteFolderTree.length === 1)
? remoteFolderTree[0].name
: DEFAULT_FOLDER_NAME;
const moveMenu = onMoveDocumentsToFolder ? (
<SelectionAssignmentMenu
<SelectionFolderMenu
label="Move"
triggerContent={(
<span className="quick-add__chip-label" title="Move">
@@ -512,16 +391,13 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
<span className="quick-add__chip-text" aria-hidden="true">Move</span>
</span>
)}
items={moveAssignments}
folderTree={remoteFolderTree || []}
placeholder="Search folders…"
emptyMessage="No folders"
onToggle={(item) => handleMoveSelectionToFolder(item?.payload || item)}
onSelectFolder={handleMoveSelectionToFolder}
disabled={!documentCount}
createLabel={null}
showStateIndicators={false}
showCounts={false}
onOpenMenu={handleMoveMenuOpen}
renderItemLabel={renderFolderLabel}
rootTitle={rootTitle}
/>
) : null;