selection
This commit is contained in:
@@ -6,6 +6,8 @@ import {
|
||||
RefreshIcon,
|
||||
MinusVerticalIcon,
|
||||
InfoIcon,
|
||||
FileIcon,
|
||||
FolderOutlineIcon,
|
||||
} from '../ui/icons';
|
||||
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||
import createWorkspaceSurfaceConfig from './workspaceHeader';
|
||||
@@ -13,6 +15,7 @@ import DetailPanel from '../detail/DetailPanel';
|
||||
import DocumentsGrid from './DocumentsGrid';
|
||||
import DocumentsList from './DocumentsList';
|
||||
import { isTagTransferEvent } from './tagTransfer';
|
||||
import SelectionFloatingActions from './SelectionFloatingActions';
|
||||
|
||||
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||
|
||||
@@ -503,6 +506,7 @@ const DocumentsPanel = ({
|
||||
onCorrespondentClick={onCorrespondentClick}
|
||||
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||
scrollRef={scrollRef}
|
||||
onClearSelection={onClearSelection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -602,6 +606,18 @@ export const createDocumentsSurface = ({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onRefresh,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
onDeleteSelection,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
tagLookupById,
|
||||
onBulkTagAdd,
|
||||
onBulkTagRemove,
|
||||
onBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove,
|
||||
onBulkReanalyze,
|
||||
} = tableProps;
|
||||
|
||||
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
||||
@@ -609,12 +625,76 @@ export const createDocumentsSurface = ({
|
||||
? `${searchResults.length} matching document${searchResults.length === 1 ? '' : 's'}`
|
||||
: null;
|
||||
|
||||
|
||||
const documentSelectionCount = Array.isArray(selectedDocumentIds) ? selectedDocumentIds.length : 0;
|
||||
const folderSelectionCount = Array.isArray(selectedFolderIds) ? selectedFolderIds.length : 0;
|
||||
const selectionCount = documentSelectionCount + folderSelectionCount;
|
||||
let selectionLabel = null;
|
||||
if (selectionCount) {
|
||||
const tokens = [];
|
||||
|
||||
if (documentSelectionCount) {
|
||||
tokens.push({
|
||||
key: 'documents',
|
||||
count: documentSelectionCount,
|
||||
icon: <FileIcon className="selection-summary__icon" size={16} />,
|
||||
});
|
||||
}
|
||||
|
||||
if (folderSelectionCount) {
|
||||
tokens.push({
|
||||
key: 'folders',
|
||||
count: folderSelectionCount,
|
||||
icon: <FolderOutlineIcon className="selection-summary__icon" size={16} />,
|
||||
});
|
||||
}
|
||||
|
||||
if (tokens.length) {
|
||||
selectionLabel = (
|
||||
<span className="selection-summary">
|
||||
{tokens.map((token, index) => (
|
||||
<React.Fragment key={token.key}>
|
||||
{index > 0 ? <span className="selection-summary__separator">·</span> : null}
|
||||
<span className="selection-summary__token">
|
||||
<span className="selection-summary__count">{token.count}</span>
|
||||
<span className="selection-summary__multiply">×</span>
|
||||
{token.icon}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
selectionLabel = `${selectionCount} item${selectionCount === 1 ? '' : 's'}`;
|
||||
}
|
||||
}
|
||||
|
||||
const actions = createDocumentsTableHeaderActions({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onRefresh,
|
||||
});
|
||||
|
||||
const floatingActions = selectionLabel
|
||||
? (
|
||||
<SelectionFloatingActions
|
||||
selectionCount={selectionCount}
|
||||
selectedDocumentIds={selectedDocumentIds}
|
||||
documentLookup={documentLookup}
|
||||
tags={tags}
|
||||
tagLookupById={tagLookupById}
|
||||
correspondents={correspondents}
|
||||
onBulkTagAdd={onBulkTagAdd}
|
||||
onBulkTagRemove={onBulkTagRemove}
|
||||
onBulkCorrespondentAdd={onBulkCorrespondentAdd}
|
||||
onBulkCorrespondentRemove={onBulkCorrespondentRemove}
|
||||
onBulkReanalyze={onBulkReanalyze}
|
||||
onDeleteSelection={onDeleteSelection}
|
||||
/>
|
||||
)
|
||||
: null;
|
||||
|
||||
|
||||
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
|
||||
const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
|
||||
|
||||
@@ -628,6 +708,8 @@ export const createDocumentsSurface = ({
|
||||
onNavigateParent,
|
||||
actions,
|
||||
breadcrumbs,
|
||||
selectionLabel,
|
||||
floatingActions,
|
||||
content: <DocumentsPanel {...tableProps} showHeader={false} />,
|
||||
detail,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import useFloatingMenu from '../ui/useFloatingMenu';
|
||||
import {
|
||||
PlusIcon,
|
||||
CheckIcon,
|
||||
CircleDashedCheckIcon,
|
||||
} from '../ui/icons';
|
||||
|
||||
const STATE_ORDER = {
|
||||
all: 0,
|
||||
partial: 1,
|
||||
none: 2,
|
||||
};
|
||||
|
||||
const normalizeItems = (items) =>
|
||||
(Array.isArray(items) ? items : [])
|
||||
.filter((item) => item && typeof item.label === 'string' && item.label.trim().length > 0)
|
||||
.map((item) => ({
|
||||
id: item.id ?? item.label,
|
||||
label: item.label.trim(),
|
||||
state: item.state === 'all' ? 'all' : item.state === 'partial' ? 'partial' : 'none',
|
||||
count: typeof item.count === 'number' ? item.count : null,
|
||||
total: typeof item.total === 'number' ? item.total : null,
|
||||
payload: item.payload ?? item,
|
||||
}));
|
||||
|
||||
const SelectionAssignmentMenu = ({
|
||||
label,
|
||||
items = [],
|
||||
placeholder = 'Search…',
|
||||
emptyMessage = 'No entries',
|
||||
createLabel = null,
|
||||
onToggle,
|
||||
onCreate,
|
||||
disabled = false,
|
||||
className,
|
||||
}) => {
|
||||
const anchorRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
toggle,
|
||||
close,
|
||||
menuRef,
|
||||
menuStyle,
|
||||
updatePosition,
|
||||
} = useFloatingMenu({
|
||||
anchorRef,
|
||||
align: 'center',
|
||||
positionStrategy: 'absolute',
|
||||
minWidth: 220,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled && isOpen) {
|
||||
close();
|
||||
}
|
||||
}, [disabled, isOpen, close]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return undefined;
|
||||
}
|
||||
setQuery('');
|
||||
setPending(false);
|
||||
const frame = requestAnimationFrame(() => {
|
||||
updatePosition();
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select?.();
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [isOpen, updatePosition]);
|
||||
|
||||
const normalizedItems = useMemo(() => normalizeItems(items), [items]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const search = query.trim().toLowerCase();
|
||||
const sorted = normalizedItems.slice().sort((a, b) => {
|
||||
const stateDiff = STATE_ORDER[a.state] - STATE_ORDER[b.state];
|
||||
if (stateDiff !== 0) {
|
||||
return stateDiff;
|
||||
}
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
if (!search) {
|
||||
return sorted;
|
||||
}
|
||||
return sorted.filter((item) => item.label.toLowerCase().includes(search));
|
||||
}, [normalizedItems, query]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (item) => {
|
||||
if (!item || typeof onToggle !== 'function') {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
try {
|
||||
await onToggle(item);
|
||||
setPending(false);
|
||||
close();
|
||||
} catch (error) {
|
||||
setPending(false);
|
||||
console.error('[selection-assignment] toggle failed', error);
|
||||
}
|
||||
},
|
||||
[onToggle, close],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async () => {
|
||||
if (typeof onCreate !== 'function') {
|
||||
return;
|
||||
}
|
||||
const value = query.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
try {
|
||||
await onCreate(value);
|
||||
setPending(false);
|
||||
close();
|
||||
} catch (error) {
|
||||
setPending(false);
|
||||
console.error('[selection-assignment] creation failed', error);
|
||||
}
|
||||
},
|
||||
[onCreate, query, close],
|
||||
);
|
||||
|
||||
const existingLabels = useMemo(
|
||||
() => new Set(normalizedItems.map((item) => item.label.toLowerCase())),
|
||||
[normalizedItems],
|
||||
);
|
||||
|
||||
const canCreate = Boolean(onCreate);
|
||||
const showCreateOption = canCreate
|
||||
&& query.trim().length > 0
|
||||
&& !existingLabels.has(query.trim().toLowerCase());
|
||||
|
||||
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}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="quick-add__chip-label">
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div
|
||||
className="menu menu--floating selection-assignment__menu"
|
||||
ref={menuRef}
|
||||
style={menuStyle || undefined}
|
||||
role="menu"
|
||||
>
|
||||
<div className="selection-assignment__header">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<div className="selection-assignment__list" role="presentation">
|
||||
{filteredItems.length ? (
|
||||
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)
|
||||
? `${item.count ?? 0}/${item.total}`
|
||||
: null;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`menu__item selection-assignment__item selection-assignment__item--${item.state}`}
|
||||
onClick={() => handleToggle(item)}
|
||||
disabled={pending}
|
||||
role="menuitem"
|
||||
>
|
||||
{icon}
|
||||
<span className="selection-assignment__label">{item.label}</span>
|
||||
{countLabel ? (
|
||||
<span className="selection-assignment__count">{countLabel}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="menu__empty selection-assignment__empty">{emptyMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
{showCreateOption ? (
|
||||
<button
|
||||
type="button"
|
||||
className="menu__item selection-assignment__create"
|
||||
onClick={handleCreate}
|
||||
disabled={pending}
|
||||
>
|
||||
<PlusIcon className="selection-assignment__icon" aria-hidden="true" />
|
||||
<span className="selection-assignment__label">
|
||||
{createLabel ? `${createLabel} “${query.trim()}”` : `Create “${query.trim()}”`}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectionAssignmentMenu;
|
||||
@@ -0,0 +1,255 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { TrashIcon, AnalyzeIcon } from '../ui/icons';
|
||||
import SelectionAssignmentMenu from './SelectionAssignmentMenu';
|
||||
|
||||
const normalizeDocumentList = (selectedDocumentIds) =>
|
||||
Array.isArray(selectedDocumentIds) ? selectedDocumentIds.filter(Boolean) : [];
|
||||
|
||||
const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => {
|
||||
if (!total) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const map = new Map();
|
||||
|
||||
const ensureEntry = (id, label, color = null) => {
|
||||
const key = id ?? label;
|
||||
if (!key || !label) {
|
||||
return null;
|
||||
}
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
id,
|
||||
label,
|
||||
color,
|
||||
count: 0,
|
||||
total,
|
||||
});
|
||||
}
|
||||
return map.get(key);
|
||||
};
|
||||
|
||||
selectedDocuments.forEach((doc) => {
|
||||
(doc?.tags || []).forEach((tag) => {
|
||||
const lookupColor = tag?.id && tagLookupById instanceof Map ? tagLookupById.get(tag.id)?.color : null;
|
||||
const entry = ensureEntry(tag?.id, tag?.label, tag?.color ?? lookupColor ?? null);
|
||||
if (entry) {
|
||||
entry.count += 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(tags || []).forEach((tag) => {
|
||||
const lookupColor = tag?.id && tagLookupById instanceof Map ? tagLookupById.get(tag.id)?.color : null;
|
||||
ensureEntry(tag?.id, tag?.label, tag?.color ?? lookupColor ?? null);
|
||||
});
|
||||
|
||||
return Array.from(map.values()).map((entry) => {
|
||||
const count = entry.count || 0;
|
||||
const state = count === total ? 'all' : count > 0 ? 'partial' : 'none';
|
||||
return {
|
||||
id: entry.id ?? entry.label,
|
||||
label: entry.label,
|
||||
color: entry.color ?? null,
|
||||
count,
|
||||
total,
|
||||
state,
|
||||
payload: entry,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const buildCorrespondentAssignments = (selectedDocuments, correspondents, total) => {
|
||||
if (!total) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const map = new Map();
|
||||
|
||||
const ensureEntry = (id, name) => {
|
||||
const key = id ?? name;
|
||||
if (!key || !name) {
|
||||
return null;
|
||||
}
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
id,
|
||||
label: name,
|
||||
count: 0,
|
||||
total,
|
||||
});
|
||||
}
|
||||
return map.get(key);
|
||||
};
|
||||
|
||||
selectedDocuments.forEach((doc) => {
|
||||
(doc?.correspondents || []).forEach((entry) => {
|
||||
const target = ensureEntry(entry?.id, entry?.name);
|
||||
if (target) {
|
||||
target.count += 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(correspondents || []).forEach((entry) => {
|
||||
ensureEntry(entry?.id, entry?.name);
|
||||
});
|
||||
|
||||
return Array.from(map.values()).map((entry) => {
|
||||
const count = entry.count || 0;
|
||||
const state = count === total ? 'all' : count > 0 ? 'partial' : 'none';
|
||||
return {
|
||||
id: entry.id ?? entry.label,
|
||||
label: entry.label,
|
||||
count,
|
||||
total,
|
||||
state,
|
||||
payload: entry,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const SelectionFloatingActions = ({
|
||||
selectionCount = 0,
|
||||
selectedDocumentIds,
|
||||
documentLookup,
|
||||
tags,
|
||||
tagLookupById,
|
||||
correspondents,
|
||||
onBulkTagAdd,
|
||||
onBulkTagRemove,
|
||||
onBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove,
|
||||
onBulkReanalyze,
|
||||
onDeleteSelection,
|
||||
}) => {
|
||||
const documentIdList = useMemo(
|
||||
() => normalizeDocumentList(selectedDocumentIds),
|
||||
[selectedDocumentIds],
|
||||
);
|
||||
|
||||
const selectedDocuments = useMemo(() => {
|
||||
if (!documentIdList.length || !(documentLookup instanceof Map)) {
|
||||
return [];
|
||||
}
|
||||
return documentIdList
|
||||
.map((id) => documentLookup.get(id))
|
||||
.filter(Boolean);
|
||||
}, [documentIdList, documentLookup]);
|
||||
|
||||
const selectedDocCount = selectedDocuments.length;
|
||||
|
||||
const tagAssignments = useMemo(
|
||||
() => buildTagAssignments(selectedDocuments, tagLookupById, tags, selectedDocCount),
|
||||
[selectedDocuments, tagLookupById, tags, selectedDocCount],
|
||||
);
|
||||
|
||||
const correspondentAssignments = useMemo(
|
||||
() => buildCorrespondentAssignments(selectedDocuments, correspondents, selectedDocCount),
|
||||
[selectedDocuments, correspondents, selectedDocCount],
|
||||
);
|
||||
|
||||
const handleToggleTagAssignment = useCallback(
|
||||
async (item) => {
|
||||
if (!selectedDocCount || !item) {
|
||||
return;
|
||||
}
|
||||
if (item.state === 'all') {
|
||||
await onBulkTagRemove?.({ label: item.label, input: null, documentIds: documentIdList });
|
||||
} else {
|
||||
await onBulkTagAdd?.({ label: item.label, input: null, documentIds: documentIdList });
|
||||
}
|
||||
},
|
||||
[selectedDocCount, onBulkTagAdd, onBulkTagRemove, documentIdList],
|
||||
);
|
||||
|
||||
const handleCreateTagAssignment = useCallback(
|
||||
async (label) => {
|
||||
if (!selectedDocCount || !label) {
|
||||
return;
|
||||
}
|
||||
await onBulkTagAdd?.({ label, input: null, documentIds: documentIdList });
|
||||
},
|
||||
[selectedDocCount, onBulkTagAdd, documentIdList],
|
||||
);
|
||||
|
||||
const handleToggleCorrespondentAssignment = useCallback(
|
||||
async (item) => {
|
||||
if (!selectedDocCount || !item) {
|
||||
return;
|
||||
}
|
||||
if (item.state === 'all') {
|
||||
if (!item.id) {
|
||||
return;
|
||||
}
|
||||
await onBulkCorrespondentRemove?.({
|
||||
assignments: [{ correspondent_id: item.id }],
|
||||
documentIds: documentIdList,
|
||||
});
|
||||
} else {
|
||||
await onBulkCorrespondentAdd?.({ name: item.label, input: null, documentIds: documentIdList });
|
||||
}
|
||||
},
|
||||
[selectedDocCount, onBulkCorrespondentAdd, onBulkCorrespondentRemove, documentIdList],
|
||||
);
|
||||
|
||||
const handleCreateCorrespondentAssignment = useCallback(
|
||||
async (name) => {
|
||||
if (!selectedDocCount || !name) {
|
||||
return;
|
||||
}
|
||||
await onBulkCorrespondentAdd?.({ name, input: null, documentIds: documentIdList });
|
||||
},
|
||||
[selectedDocCount, onBulkCorrespondentAdd, documentIdList],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="panel-floating-actions">
|
||||
<SelectionAssignmentMenu
|
||||
label="Tags"
|
||||
items={tagAssignments}
|
||||
placeholder="Search tags…"
|
||||
emptyMessage="No tags"
|
||||
createLabel="Create"
|
||||
onToggle={handleToggleTagAssignment}
|
||||
onCreate={handleCreateTagAssignment}
|
||||
disabled={!selectedDocCount}
|
||||
/>
|
||||
<SelectionAssignmentMenu
|
||||
label="Correspondents"
|
||||
items={correspondentAssignments}
|
||||
placeholder="Search correspondents…"
|
||||
emptyMessage="No correspondents"
|
||||
createLabel="Create"
|
||||
onToggle={handleToggleCorrespondentAssignment}
|
||||
onCreate={handleCreateCorrespondentAssignment}
|
||||
disabled={!selectedDocCount}
|
||||
/>
|
||||
{typeof onBulkReanalyze === 'function' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button panel-floating-actions__button"
|
||||
onClick={() => onBulkReanalyze(documentIdList)}
|
||||
aria-label="Re-run analysis for selection"
|
||||
title="Re-run analysis for selection"
|
||||
disabled={!documentIdList.length}
|
||||
>
|
||||
<AnalyzeIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
{typeof onDeleteSelection === 'function' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button danger panel-floating-actions__button"
|
||||
onClick={onDeleteSelection}
|
||||
aria-label="Delete selected items"
|
||||
disabled={selectionCount === 0}
|
||||
>
|
||||
<TrashIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectionFloatingActions;
|
||||
@@ -6,6 +6,8 @@ export const createWorkspaceSurfaceConfig = ({
|
||||
sidebarToggle = null,
|
||||
actions = null,
|
||||
breadcrumbs = null,
|
||||
selectionLabel = null,
|
||||
floatingActions = null,
|
||||
content = null,
|
||||
detail = null,
|
||||
variant = 'documents',
|
||||
@@ -28,6 +30,8 @@ export const createWorkspaceSurfaceConfig = ({
|
||||
leading,
|
||||
actions,
|
||||
breadcrumbs,
|
||||
selectionLabel,
|
||||
floatingActions,
|
||||
},
|
||||
content,
|
||||
detail,
|
||||
|
||||
Reference in New Issue
Block a user