refactor: Reorganize frontend by moving UI components, hooks, and utilities to new components, logic, features, and lib directories
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { DEFAULT_FOLDER_NAME } from '../../../constants/workspace';
|
||||
import { getFolderTree } from '../../../lib/api/apiClient';
|
||||
import {
|
||||
TrashIcon,
|
||||
AnalyzeIcon,
|
||||
IconX,
|
||||
FolderOutlineIcon,
|
||||
TagIcon,
|
||||
CorrespondentIcon,
|
||||
} from '../../../components/icons';
|
||||
import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu';
|
||||
import SelectionFolderMenu from './SelectionFolderMenu';
|
||||
import SelectionSummary from './SelectionSummary';
|
||||
import { useAppState } from '../../../lib/store/appState';
|
||||
import { useWorkspaceSelectionContext } from '../../../app/WorkspaceSelectionContext';
|
||||
import type { DocumentId } from '../../../types/identifiers';
|
||||
import type { FolderTreeNode } from '../../../lib/api/apiTypes';
|
||||
|
||||
type NullableDocumentId = DocumentId | null;
|
||||
|
||||
type SelectedIdList = NullableDocumentId[] | null;
|
||||
|
||||
interface TagOption {
|
||||
id?: DocumentId;
|
||||
label?: string;
|
||||
name?: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
interface CorrespondentOption {
|
||||
id?: DocumentId;
|
||||
name?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
import type { Document } from '../../../types/documents';
|
||||
|
||||
interface BulkTagMutationArgs {
|
||||
label: string;
|
||||
input: unknown;
|
||||
documentIds: DocumentId[];
|
||||
}
|
||||
|
||||
interface BulkCorrespondentAddArgs {
|
||||
name: string;
|
||||
input: unknown;
|
||||
documentIds: DocumentId[];
|
||||
}
|
||||
|
||||
interface BulkCorrespondentRemoveArgs {
|
||||
assignments: Array<{ correspondent_id: DocumentId }>;
|
||||
documentIds: DocumentId[];
|
||||
}
|
||||
|
||||
interface SelectionFloatingActionsProps {
|
||||
selectionCount?: number;
|
||||
selectedDocumentIds?: SelectedIdList;
|
||||
selectedFolderIds?: SelectedIdList;
|
||||
documentLookup?: Map<DocumentId, Document> | null;
|
||||
tags?: TagOption[] | null;
|
||||
tagLookupById?: Map<DocumentId, TagOption> | null;
|
||||
correspondents?: CorrespondentOption[] | null;
|
||||
onBulkTagAdd?: (args: BulkTagMutationArgs) => Promise<void> | void;
|
||||
onBulkTagRemove?: (args: BulkTagMutationArgs) => Promise<void> | void;
|
||||
onBulkCorrespondentAdd?: (args: BulkCorrespondentAddArgs) => Promise<void> | void;
|
||||
onBulkCorrespondentRemove?: (args: BulkCorrespondentRemoveArgs) => Promise<void> | void;
|
||||
onBulkReanalyze?: (documentIds: DocumentId[]) => Promise<void> | void;
|
||||
onDeleteSelection?: () => void;
|
||||
onClearSelection?: () => void;
|
||||
onMoveDocumentsToFolder?: (documentIds: DocumentId[], folderId: DocumentId | null) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const normalizeDocumentList = (selectedIds?: SelectedIdList): DocumentId[] =>
|
||||
Array.isArray(selectedIds)
|
||||
? selectedIds.filter((value): value is DocumentId => value !== null && value !== undefined)
|
||||
: [];
|
||||
|
||||
const buildTagAssignments = (
|
||||
selectedDocuments: Document[],
|
||||
tagLookupById: Map<DocumentId, TagOption> | null,
|
||||
tags: TagOption[] | null,
|
||||
total: number,
|
||||
): SelectionAssignmentMenuItem[] => {
|
||||
if (!total) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const map = new Map<string, {
|
||||
id?: DocumentId;
|
||||
label: string;
|
||||
color: string | null;
|
||||
count: number;
|
||||
total: number;
|
||||
}>();
|
||||
|
||||
const ensureEntry = (id?: DocumentId, label?: string, color: string | null = 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) ?? null;
|
||||
};
|
||||
|
||||
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: Document[],
|
||||
correspondents: CorrespondentOption[] | null,
|
||||
total: number,
|
||||
): SelectionAssignmentMenuItem[] => {
|
||||
if (!total) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const map = new Map<string, {
|
||||
id?: DocumentId;
|
||||
label: string;
|
||||
count: number;
|
||||
total: number;
|
||||
}>();
|
||||
|
||||
const ensureEntry = (id?: DocumentId, name?: string) => {
|
||||
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) ?? null;
|
||||
};
|
||||
|
||||
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 || entry?.label);
|
||||
});
|
||||
|
||||
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: React.FC<SelectionFloatingActionsProps> = ({
|
||||
selectionCount = 0,
|
||||
selectedDocumentIds = [],
|
||||
selectedFolderIds = [],
|
||||
documentLookup,
|
||||
tags = [],
|
||||
tagLookupById,
|
||||
correspondents = [],
|
||||
onBulkTagAdd,
|
||||
onBulkTagRemove,
|
||||
onBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove,
|
||||
onBulkReanalyze,
|
||||
onDeleteSelection,
|
||||
onClearSelection = null,
|
||||
onMoveDocumentsToFolder,
|
||||
}) => {
|
||||
const { token, tenant } = useAppState() as { token?: string; tenant?: { id?: DocumentId; name?: string } | null };
|
||||
const tenantId = tenant?.id ?? null;
|
||||
|
||||
const documentLookupMap = useMemo(() => (
|
||||
documentLookup instanceof Map ? documentLookup : new Map<DocumentId, Document>()
|
||||
), [documentLookup]);
|
||||
const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null;
|
||||
|
||||
const [remoteFolderTree, setRemoteFolderTree] = useState<FolderTreeNode[] | null>(null);
|
||||
const folderTreeFetchRef = useRef<Promise<FolderTreeNode[]> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRemoteFolderTree(null);
|
||||
folderTreeFetchRef.current = null;
|
||||
}, [tenantId, token]);
|
||||
|
||||
const requestFolderTree = useCallback(async (): Promise<FolderTreeNode[]> => {
|
||||
if (!token) {
|
||||
setRemoteFolderTree([]);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(remoteFolderTree)) {
|
||||
return remoteFolderTree;
|
||||
}
|
||||
|
||||
if (folderTreeFetchRef.current) {
|
||||
return folderTreeFetchRef.current;
|
||||
}
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
try {
|
||||
const data = await getFolderTree();
|
||||
|
||||
// Convert flat list to tree
|
||||
const nodeMap = new Map<string, FolderTreeNode>();
|
||||
data.forEach((item) => {
|
||||
nodeMap.set(item.id, {
|
||||
...item,
|
||||
children: [],
|
||||
} as FolderTreeNode);
|
||||
});
|
||||
|
||||
const roots: FolderTreeNode[] = [];
|
||||
data.forEach((item) => {
|
||||
const node = nodeMap.get(item.id);
|
||||
if (!node) return;
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
node.children = item.children
|
||||
.map((id) => nodeMap.get(id))
|
||||
.filter((n): n is FolderTreeNode => Boolean(n));
|
||||
}
|
||||
|
||||
if (!item.parent_id) {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
setRemoteFolderTree(roots);
|
||||
return roots;
|
||||
} catch (error) {
|
||||
console.warn('[selection] Failed to load folder tree', error);
|
||||
setRemoteFolderTree([]);
|
||||
return [];
|
||||
} finally {
|
||||
folderTreeFetchRef.current = null;
|
||||
}
|
||||
})();
|
||||
|
||||
folderTreeFetchRef.current = fetchPromise;
|
||||
return fetchPromise;
|
||||
}, [remoteFolderTree, token]);
|
||||
|
||||
const handleMoveMenuOpen = useCallback(() => {
|
||||
requestFolderTree();
|
||||
}, [requestFolderTree]);
|
||||
|
||||
const documentIdList = useMemo<DocumentId[]>(
|
||||
() => normalizeDocumentList(selectedDocumentIds),
|
||||
[selectedDocumentIds],
|
||||
);
|
||||
|
||||
const folderIdList = useMemo<DocumentId[]>(
|
||||
() => normalizeDocumentList(selectedFolderIds),
|
||||
[selectedFolderIds],
|
||||
);
|
||||
|
||||
const documentCount = documentIdList.length;
|
||||
const folderCount = folderIdList.length;
|
||||
const totalCount = selectionCount ?? documentCount + folderCount;
|
||||
|
||||
const selectedDocuments = useMemo<Document[]>(() => {
|
||||
if (!documentIdList.length || !(documentLookupMap instanceof Map)) {
|
||||
return [];
|
||||
}
|
||||
return documentIdList
|
||||
.map((id) => documentLookupMap.get(id))
|
||||
.filter((doc): doc is Document => Boolean(doc));
|
||||
}, [documentIdList, documentLookupMap]);
|
||||
|
||||
const selectedDocCount = selectedDocuments.length;
|
||||
|
||||
const tagAssignments = useMemo(
|
||||
() => buildTagAssignments(selectedDocuments, tagLookupMap, tags, selectedDocCount),
|
||||
[selectedDocuments, tagLookupMap, tags, selectedDocCount],
|
||||
);
|
||||
|
||||
const correspondentAssignments = useMemo(
|
||||
() => buildCorrespondentAssignments(selectedDocuments, correspondents, selectedDocCount),
|
||||
[selectedDocuments, correspondents, selectedDocCount],
|
||||
);
|
||||
|
||||
const handleToggleTagAssignment = useCallback(
|
||||
async (item: SelectionAssignmentMenuItem) => {
|
||||
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: string) => {
|
||||
if (!selectedDocCount || !label) {
|
||||
return;
|
||||
}
|
||||
await onBulkTagAdd?.({ label, input: null, documentIds: documentIdList });
|
||||
},
|
||||
[selectedDocCount, onBulkTagAdd, documentIdList],
|
||||
);
|
||||
|
||||
const handleToggleCorrespondentAssignment = useCallback(
|
||||
async (item: SelectionAssignmentMenuItem) => {
|
||||
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: string) => {
|
||||
if (!selectedDocCount || !name) {
|
||||
return;
|
||||
}
|
||||
await onBulkCorrespondentAdd?.({ name, input: null, documentIds: documentIdList });
|
||||
},
|
||||
[selectedDocCount, onBulkCorrespondentAdd, documentIdList],
|
||||
);
|
||||
|
||||
const handleMoveSelectionToFolder = useCallback(
|
||||
async (folderId: DocumentId | null) => {
|
||||
const itemsToMove = [...documentIdList, ...folderIdList];
|
||||
if (!itemsToMove.length || !onMoveDocumentsToFolder) {
|
||||
return;
|
||||
}
|
||||
await onMoveDocumentsToFolder(itemsToMove, folderId);
|
||||
},
|
||||
[documentIdList, folderIdList, onMoveDocumentsToFolder],
|
||||
);
|
||||
|
||||
const summaryNode = totalCount > 0 ? (
|
||||
<SelectionSummary
|
||||
documentCount={documentCount}
|
||||
folderCount={folderCount}
|
||||
totalCount={totalCount}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const showPrimaryButtons = Boolean(onBulkReanalyze || onDeleteSelection || onClearSelection);
|
||||
|
||||
const rootTitle = (remoteFolderTree && remoteFolderTree.length === 1)
|
||||
? remoteFolderTree[0].name
|
||||
: DEFAULT_FOLDER_NAME;
|
||||
|
||||
const moveMenu = onMoveDocumentsToFolder ? (
|
||||
<SelectionFolderMenu
|
||||
label="Move"
|
||||
triggerContent={(
|
||||
<span className="quick-add__chip-label" title="Move">
|
||||
<FolderOutlineIcon className="icon-inline" aria-hidden="true" />
|
||||
<span className="quick-add__chip-text" aria-hidden="true">Move</span>
|
||||
</span>
|
||||
)}
|
||||
folderTree={remoteFolderTree || []}
|
||||
placeholder="Search folders…"
|
||||
emptyMessage="No folders"
|
||||
onSelectFolder={handleMoveSelectionToFolder}
|
||||
disabled={!documentCount && !folderCount}
|
||||
onOpenMenu={handleMoveMenuOpen}
|
||||
rootTitle={rootTitle}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const primaryButtons = showPrimaryButtons ? (
|
||||
<div className="panel-floating__buttons">
|
||||
{onBulkReanalyze ? (
|
||||
<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 === 0}
|
||||
>
|
||||
<AnalyzeIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
{onDeleteSelection ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button danger panel-floating-actions__button"
|
||||
onClick={onDeleteSelection}
|
||||
aria-label="Delete selected items"
|
||||
disabled={totalCount === 0}
|
||||
>
|
||||
<TrashIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
{onClearSelection ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button panel-floating-actions__button"
|
||||
onClick={onClearSelection}
|
||||
aria-label="Clear selection"
|
||||
title="Clear selection"
|
||||
disabled={totalCount === 0}
|
||||
>
|
||||
<IconX className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{summaryNode ? (
|
||||
<span className="panel-floating__label">{summaryNode}</span>
|
||||
) : null}
|
||||
<div className="panel-floating-actions panel-floating-actions--assignments">
|
||||
{moveMenu}
|
||||
<SelectionAssignmentMenu
|
||||
label="Tags"
|
||||
triggerContent={(
|
||||
<span className="quick-add__chip-label" title="Tags">
|
||||
<TagIcon className="icon-inline" aria-hidden="true" />
|
||||
<span className="quick-add__chip-text" aria-hidden="true">Tags</span>
|
||||
</span>
|
||||
)}
|
||||
items={tagAssignments}
|
||||
placeholder="Search tags…"
|
||||
emptyMessage="No tags"
|
||||
createLabel="Create"
|
||||
onToggle={handleToggleTagAssignment}
|
||||
onCreate={handleCreateTagAssignment}
|
||||
disabled={!documentCount}
|
||||
/>
|
||||
<SelectionAssignmentMenu
|
||||
label="Correspondents"
|
||||
triggerContent={(
|
||||
<span className="quick-add__chip-label" title="Correspondents">
|
||||
<CorrespondentIcon className="icon-inline" aria-hidden="true" />
|
||||
<span className="quick-add__chip-text" aria-hidden="true">Correspondents</span>
|
||||
</span>
|
||||
)}
|
||||
items={correspondentAssignments}
|
||||
placeholder="Search correspondents…"
|
||||
emptyMessage="No correspondents"
|
||||
createLabel="Create"
|
||||
onToggle={handleToggleCorrespondentAssignment}
|
||||
onCreate={handleCreateCorrespondentAssignment}
|
||||
disabled={!documentCount}
|
||||
/>
|
||||
</div>
|
||||
{primaryButtons}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type SelectionFloatingPanelProps = Omit<SelectionFloatingActionsProps, 'selectedDocumentIds' | 'selectedFolderIds' | 'selectionCount'>;
|
||||
|
||||
export const SelectionFloatingPanel: React.FC<SelectionFloatingPanelProps> = ({ onClearSelection, ...rest }) => {
|
||||
const { selectedDocumentIds, selectedFolderIds, clearSelection } = useWorkspaceSelectionContext();
|
||||
const documentIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : [];
|
||||
const folderIds = Array.isArray(selectedFolderIds) ? selectedFolderIds : [];
|
||||
const selectionCount = documentIds.length + folderIds.length;
|
||||
if (selectionCount === 0) {
|
||||
return null;
|
||||
}
|
||||
const handleClear = onClearSelection || clearSelection;
|
||||
return (
|
||||
<div className="panel-floating-region" aria-live="polite" aria-atomic="true">
|
||||
<div className="panel-floating">
|
||||
<SelectionFloatingActions
|
||||
selectionCount={selectionCount}
|
||||
selectedDocumentIds={documentIds}
|
||||
selectedFolderIds={folderIds}
|
||||
onClearSelection={handleClear}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user