refactor: Extract folder assignment logic into a new SelectionFolderMenu component, adding new icons and updating related styles.
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user