folders-move

This commit is contained in:
2025-11-06 00:58:17 +01:00
parent e9c5f27b17
commit a16f1501d3
12 changed files with 1525 additions and 89 deletions
@@ -844,6 +844,8 @@ export const createDocumentsSurface = ({
onBulkCorrespondentAdd,
onBulkCorrespondentRemove,
onBulkReanalyze,
folderOptions,
onMoveDocumentsToFolder,
} = tableProps;
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
@@ -879,6 +881,8 @@ export const createDocumentsSurface = ({
onBulkReanalyze={onBulkReanalyze}
onDeleteSelection={onDeleteSelection}
onClearSelection={onClearSelection}
folderOptions={folderOptions}
onMoveDocumentsToFolder={onMoveDocumentsToFolder}
/>
)
: null;
@@ -34,6 +34,11 @@ const SelectionAssignmentMenu = ({
onCreate,
disabled = false,
className,
triggerContent = null,
showStateIndicators = true,
showCounts = true,
onOpenMenu = null,
renderItemLabel = null,
}) => {
const anchorRef = useRef(null);
const inputRef = useRef(null);
@@ -143,20 +148,32 @@ const SelectionAssignmentMenu = ({
&& query.trim().length > 0
&& !existingLabels.has(query.trim().toLowerCase());
const handleTriggerClick = useCallback(() => {
if (disabled) {
return;
}
if (!isOpen) {
onOpenMenu?.();
}
toggle();
}, [disabled, isOpen, onOpenMenu, toggle]);
return (
<div className={className ? `selection-assignment ${className}` : 'selection-assignment'}>
<button
type="button"
ref={anchorRef}
className="quick-add__chip quick-add__trigger panel-floating-actions__trigger"
onClick={toggle}
onClick={handleTriggerClick}
aria-haspopup="menu"
aria-expanded={isOpen}
disabled={disabled}
>
<span className="quick-add__chip-label">
{label}
</span>
{triggerContent ? triggerContent : (
<span className="quick-add__chip-label">
{label}
</span>
)}
</button>
{isOpen ? (
<div
@@ -181,16 +198,21 @@ const SelectionAssignmentMenu = ({
filteredItems.map((item) => {
const isAll = item.state === 'all';
const isPartial = item.state === 'partial';
const icon = isAll ? (
<CheckIcon className="selection-assignment__icon" aria-hidden="true" />
) : isPartial ? (
<CircleDashedCheckIcon className="selection-assignment__icon" aria-hidden="true" />
) : (
<span className="selection-assignment__icon selection-assignment__icon--empty" aria-hidden="true" />
);
const countLabel = item.total && (isPartial || isAll)
const icon = showStateIndicators
? isAll
? <CheckIcon className="selection-assignment__icon" aria-hidden="true" />
: isPartial
? <CircleDashedCheckIcon className="selection-assignment__icon" aria-hidden="true" />
: <span className="selection-assignment__icon selection-assignment__icon--empty" aria-hidden="true" />
: null;
const countLabel = showCounts && item.total && (isPartial || isAll)
? `${item.count ?? 0}/${item.total}`
: null;
const labelContent = renderItemLabel ? renderItemLabel(item) : item.label;
const labelClassName = [
'selection-assignment__label',
(!showStateIndicators || !icon) ? 'selection-assignment__label--nowrap' : null,
].filter(Boolean).join(' ');
return (
<button
key={item.id}
@@ -201,7 +223,9 @@ const SelectionAssignmentMenu = ({
role="menuitem"
>
{icon}
<span className="selection-assignment__label">{item.label}</span>
<span className={labelClassName}>
{labelContent}
</span>
{countLabel ? (
<span className="selection-assignment__count">{countLabel}</span>
) : null}
@@ -1,11 +1,58 @@
import React, { useCallback, useMemo } from 'react';
import { TrashIcon, AnalyzeIcon, IconX } from '../ui/icons';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
TrashIcon,
AnalyzeIcon,
IconX,
FolderOutlineIcon,
TagIcon,
CorrespondentIcon,
LoaderIcon,
} from '../ui/icons';
import SelectionAssignmentMenu from './SelectionAssignmentMenu';
import SelectionSummary from './SelectionSummary';
import { api, useAppState } from '../app/appState';
const normalizeDocumentList = (selectedDocumentIds) =>
Array.isArray(selectedDocumentIds) ? selectedDocumentIds.filter(Boolean) : [];
const ROOT_FOLDER_LABEL = 'Documents';
const buildFolderTreeOptions = (tree) => {
const entries = [];
const traverse = (nodes, parentSegments) => {
if (!Array.isArray(nodes) || nodes.length === 0) {
return;
}
nodes.forEach((node) => {
if (!node || !node.id) {
return;
}
const name = typeof node.name === 'string' && node.name.trim().length
? node.name.trim()
: 'Folder';
const nextSegments = parentSegments.concat([name]);
const label = nextSegments.join('/');
entries.push({ id: node.id, label });
if (Array.isArray(node.children) && node.children.length) {
traverse(node.children, nextSegments);
}
});
};
traverse(Array.isArray(tree) ? tree : [], [ROOT_FOLDER_LABEL]);
entries.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
return [{ id: 'root', label: ROOT_FOLDER_LABEL }, ...entries];
};
const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => {
if (!total) {
return [];
@@ -118,6 +165,7 @@ const SelectionFloatingActions = ({
tags,
tagLookupById,
correspondents,
folderOptions = [],
onBulkTagAdd,
onBulkTagRemove,
onBulkCorrespondentAdd,
@@ -125,7 +173,67 @@ const SelectionFloatingActions = ({
onBulkReanalyze,
onDeleteSelection,
onClearSelection = null,
onMoveDocumentsToFolder,
}) => {
const { token, tenant } = useAppState();
const tenantId = tenant?.id ?? null;
const [remoteFolderOptions, setRemoteFolderOptions] = useState(null);
const [loadingFolders, setLoadingFolders] = useState(false);
const folderTreeFetchRef = useRef(null);
useEffect(() => {
setRemoteFolderOptions(null);
folderTreeFetchRef.current = null;
setLoadingFolders(false);
}, [tenantId, token]);
const requestFolderTree = useCallback(async () => {
if (!token) {
setRemoteFolderOptions([]);
return [];
}
if (Array.isArray(remoteFolderOptions)) {
return remoteFolderOptions;
}
if (folderTreeFetchRef.current) {
return folderTreeFetchRef.current;
}
const fetchPromise = (async () => {
setLoadingFolders(true);
try {
const { data } = await api.get('/folders/tree');
const options = buildFolderTreeOptions(data);
setRemoteFolderOptions(options);
return options;
} catch (error) {
console.warn('[selection] Failed to load folder tree', error);
setRemoteFolderOptions([]);
return [];
} finally {
setLoadingFolders(false);
folderTreeFetchRef.current = null;
}
})();
folderTreeFetchRef.current = fetchPromise;
return fetchPromise;
}, [remoteFolderOptions, token]);
const handleMoveMenuOpen = useCallback(() => {
requestFolderTree();
}, [requestFolderTree]);
const effectiveFolderOptions = useMemo(() => {
if (remoteFolderOptions !== null) {
return remoteFolderOptions;
}
return Array.isArray(folderOptions) ? folderOptions : [];
}, [remoteFolderOptions, folderOptions]);
const documentIdList = useMemo(
() => normalizeDocumentList(selectedDocumentIds),
[selectedDocumentIds],
@@ -153,6 +261,36 @@ const SelectionFloatingActions = ({
const selectedDocCount = selectedDocuments.length;
const moveAssignments = useMemo(() => {
if (!Array.isArray(effectiveFolderOptions)) {
return [];
}
return effectiveFolderOptions
.map((option) => {
const id = option?.id ?? option?.value ?? option;
if (!id) {
return null;
}
const label = option?.label || option?.name || String(id);
const segments = label.split('/');
const depth = Math.max(segments.length - 1, 0);
return {
id,
label,
state: 'none',
count: null,
total: null,
payload: {
id,
label,
segments,
depth,
},
};
})
.filter(Boolean);
}, [effectiveFolderOptions]);
const tagAssignments = useMemo(
() => buildTagAssignments(selectedDocuments, tagLookupById, tags, selectedDocCount),
[selectedDocuments, tagLookupById, tags, selectedDocCount],
@@ -163,6 +301,33 @@ const SelectionFloatingActions = ({
[selectedDocuments, correspondents, selectedDocCount],
);
const renderFolderLabel = useCallback((item) => {
const segments = item?.payload?.segments || (item?.label ? item.label.split('/') : []);
const depth = item?.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) => {
if (!selectedDocCount || !item) {
@@ -217,6 +382,20 @@ const SelectionFloatingActions = ({
[selectedDocCount, onBulkCorrespondentAdd, documentIdList],
);
const handleMoveSelectionToFolder = useCallback(
async (option) => {
if (!documentIdList.length || typeof onMoveDocumentsToFolder !== 'function') {
return;
}
const value = option?.id ?? option?.value ?? option;
if (!value) {
return;
}
await onMoveDocumentsToFolder(documentIdList, value);
},
[documentIdList, onMoveDocumentsToFolder],
);
const summaryNode = totalCount > 0 ? (
<SelectionSummary
documentCount={documentCount}
@@ -231,8 +410,38 @@ const SelectionFloatingActions = ({
<span className="panel-floating__label">{summaryNode}</span>
) : null}
<div className="panel-floating-actions">
{typeof onMoveDocumentsToFolder === 'function' ? (
<SelectionAssignmentMenu
label="Move"
triggerContent={(
<span className="quick-add__chip-label">
<FolderOutlineIcon className="icon-inline" aria-hidden="true" />
{' '}
Move
{loadingFolders ? (
<LoaderIcon className="icon-inline icon--spin selection-assignment__spinner" aria-hidden="true" />
) : null}
</span>
)}
items={moveAssignments}
placeholder="Search folders…"
emptyMessage={loadingFolders ? 'Loading folders…' : 'No folders'}
onToggle={(item) => handleMoveSelectionToFolder(item?.payload || item)}
disabled={!documentCount || (loadingFolders && !moveAssignments.length)}
createLabel={null}
showStateIndicators={false}
showCounts={false}
onOpenMenu={handleMoveMenuOpen}
renderItemLabel={renderFolderLabel}
/>
) : null}
<SelectionAssignmentMenu
label="Tags"
triggerContent={(
<span className="quick-add__chip-label">
<TagIcon className="icon-inline" aria-hidden="true" /> Tags
</span>
)}
items={tagAssignments}
placeholder="Search tags…"
emptyMessage="No tags"
@@ -243,6 +452,11 @@ const SelectionFloatingActions = ({
/>
<SelectionAssignmentMenu
label="Correspondents"
triggerContent={(
<span className="quick-add__chip-label">
<CorrespondentIcon className="icon-inline" aria-hidden="true" /> Correspondents
</span>
)}
items={correspondentAssignments}
placeholder="Search correspondents…"
emptyMessage="No correspondents"