selection

This commit is contained in:
2025-11-03 02:31:46 +01:00
parent 1c261bf0a2
commit 152f824e2e
15 changed files with 1331 additions and 164 deletions
+82
View File
@@ -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,