folders-move
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user