typescript

This commit is contained in:
2025-11-13 01:07:55 +01:00
parent b812d748ea
commit ada089c05b
147 changed files with 7427 additions and 2534 deletions
@@ -2,7 +2,19 @@ import React from 'react';
const NBSP = String.fromCharCode(160);
const CorrespondentLinks = ({
export interface CorrespondentLinkEntry {
id?: string | number | null;
name?: string | null;
key?: string;
}
interface CorrespondentLinksProps {
correspondents?: CorrespondentLinkEntry[];
activeCorrespondentIdSet?: Set<string | number>;
onCorrespondentClick?: (id: string | number) => void;
}
const CorrespondentLinks: React.FC<CorrespondentLinksProps> = ({
correspondents,
activeCorrespondentIdSet,
onCorrespondentClick,
@@ -11,8 +23,8 @@ const CorrespondentLinks = ({
return null;
}
const activeSet = activeCorrespondentIdSet || new Set();
const handleClick = (event, correspondent) => {
const activeSet = activeCorrespondentIdSet || new Set<string | number>();
const handleClick = (event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>, correspondent: CorrespondentLinkEntry) => {
if (!onCorrespondentClick || correspondent.id == null) {
return;
}
@@ -27,11 +39,12 @@ const CorrespondentLinks = ({
if (isActive) classNames.push('is-active');
if (!hasHandler) classNames.push('is-static');
const isLast = index === correspondents.length - 1;
const label = isLast ? `${correspondent.name}:${NBSP}` : correspondent.name;
const fallbackLabel = correspondent.name ?? '—';
const label = isLast ? `${fallbackLabel}:${NBSP}` : fallbackLabel;
return (
<React.Fragment
key={correspondent.key ?? correspondent.id ?? `${correspondent.name}-${index}`}
key={correspondent.key ?? correspondent.id ?? `${fallbackLabel}-${index}`}
>
<button
type="button"
@@ -1,8 +1,54 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import DocumentSummarySection from './DocumentSummarySection';
import type { ReactNode } from 'react';
import DocumentSummarySection, { DocumentSummarySectionProps } from './DocumentSummarySection';
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
const DocumentInfoPanel = ({
type PanelTab = { id: string; label: string; render: () => ReactNode };
type ContentState =
| { status: 'idle'; data: null; error: null }
| { status: 'loading'; data: null; error: null }
| { status: 'loaded'; data: string; error: null }
| { status: 'empty'; data: string; error: null }
| { status: 'unavailable'; data: null; error: null }
| { status: 'error'; data: null; error: unknown };
interface DocumentInfoPanelProps {
document: DocumentSummarySectionProps['document'];
summaryProps?: Omit<DocumentSummarySectionProps, 'document' | 'detailItems' | 'layout'>;
metadataItems?: Array<{ label: string; value?: string }>;
metadataPayload?: Record<string, unknown>;
metadataTabLabel?: string;
detailsTabLabel?: string;
contentConfig?: {
id?: string;
label?: string;
enabled?: boolean;
forceDisplay?: boolean;
loadContent?: (args: { signal: AbortSignal }) => Promise<string>;
onCancel?: () => void;
loadingMessage?: string;
emptyMessage?: string;
unavailableMessage?: string;
errorMessage?: string;
renderContent?: (data: string) => ReactNode;
} | null;
activeTab?: string;
onTabChange?: (tabId: string) => void;
defaultTabId?: string;
resetKey?: string | number | null;
classNamePrefix?: string;
hideTabNavWhenSingle?: boolean;
summaryPlacement?: 'inline' | 'tabs';
summaryTabLabel?: string;
summaryTabId?: string;
leadingTabs?: PanelTab[];
trailingTabs?: PanelTab[];
tabsPlacement?: 'top' | 'bottom';
summaryLayout?: 'default' | 'compact';
}
const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
document,
summaryProps = {},
metadataItems: metadataItemsProp,
@@ -42,9 +88,9 @@ const DocumentInfoPanel = ({
const contentConfig = contentConfigProp || null;
const contentEnabled = Boolean(contentConfig && (contentConfig.enabled ?? true));
const showContentTab = Boolean(contentConfig && ((contentConfig.forceDisplay ?? contentEnabled)));
const showContentTab = Boolean(contentConfig && (contentConfig.forceDisplay ?? contentEnabled));
const [contentState, setContentState] = useState(() => {
const [contentState, setContentState] = useState<ContentState | null>(() => {
if (!contentConfig) {
return null;
}
@@ -144,14 +190,14 @@ const DocumentInfoPanel = ({
const normalizedLeadingTabs = useMemo(
() => (Array.isArray(leadingTabs)
? leadingTabs.filter((tab) => tab && tab.id && tab.label)
? leadingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
: []),
[leadingTabs],
);
const normalizedTrailingTabs = useMemo(
() => (Array.isArray(trailingTabs)
? trailingTabs.filter((tab) => tab && tab.id && tab.label)
? trailingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
: []),
[trailingTabs],
);
@@ -166,7 +212,7 @@ const DocumentInfoPanel = ({
: null;
const visibleTabs = useMemo(() => {
const tabsList = [];
const tabsList: PanelTab[] = [];
if (normalizedLeadingTabs.length) {
tabsList.push(...normalizedLeadingTabs);
@@ -251,17 +297,17 @@ const DocumentInfoPanel = ({
}
if (metadataPayload) {
tabsList.push({
id: 'metadata',
label: metadataTabLabel,
render: () => (
<section className={`${base}__section ${base}__section--metadata-json`}>
<pre className={`${base}__metadata-json`}>
{JSON.stringify(metadataPayload, null, 2)}
</pre>
</section>
),
});
tabsList.push({
id: 'metadata',
label: metadataTabLabel,
render: () => (
<section className={`${base}__section ${base}__section--metadata-json`}>
<pre className={`${base}__metadata-json`}>
{JSON.stringify(metadataPayload, null, 2)}
</pre>
</section>
),
});
}
if (normalizedTrailingTabs.length) {
@@ -295,18 +341,14 @@ const DocumentInfoPanel = ({
return visibleTabs[0].id;
}, [visibleTabs, defaultTabId]);
const renderTabContent = (tab, context = {}) => {
const renderTabContent = (tab?: PanelTab | null, context: Record<string, unknown> = {}) => {
if (!tab) {
return null;
}
if (typeof tab.render === 'function') {
return tab.render(context);
}
if (tab.component) {
const TabComponent = tab.component;
return <TabComponent {...context} />;
}
return React.isValidElement(tab.render) ? tab.render : null;
return null;
};
const isControlled = controlledActiveTab !== undefined && controlledActiveTab !== null;
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { EditIcon, IconX, PlusIcon } from '../ui/icons';
import SelectionAssignmentMenu from './SelectionAssignmentMenu';
import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu';
import { getTagColorStyle } from '../utils/colors';
import {
formatDate,
@@ -9,6 +10,68 @@ import {
} from '../utils/date';
import { describeDocumentSummary } from './documentSummary';
type Identifier = string | number;
interface TagEntry {
id?: Identifier;
label?: string;
color?: string | null;
}
interface CorrespondentEntry {
id?: Identifier;
name?: string;
count?: number;
}
interface DocumentLike {
id?: Identifier;
title?: string;
issued_at?: string | null;
current_version?: { version_number?: number } | null;
tags?: TagEntry[];
correspondents?: CorrespondentEntry[];
[key: string]: unknown;
}
interface TagSectionProps {
tags?: TagEntry[];
onRemove?: (tag: TagEntry) => void;
onAdd?: (payload: { value: string; option?: unknown; input?: unknown }) => void;
emptyMessage?: string;
addPlaceholder?: string;
addButtonLabel?: string;
datalistOptions?: Array<SelectionAssignmentMenuItem | string>;
className?: string;
}
interface CorrespondentSectionProps {
entries?: CorrespondentEntry[];
onRemove?: (entry: CorrespondentEntry) => void;
onAdd?: (payload: { name: string; option?: unknown; input?: unknown }) => void;
showCount?: boolean;
addPlaceholder?: string;
addButtonLabel?: string;
datalistOptions?: Array<SelectionAssignmentMenuItem | string>;
className?: string;
}
export interface DocumentSummarySectionProps {
document?: DocumentLike | null;
tagLookupById?: Map<Identifier, TagEntry>;
tagOptions?: SelectionAssignmentMenuItem[];
onTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void;
onTagRemove?: (docId: Identifier | undefined, tagId: Identifier | undefined) => void;
correspondents?: CorrespondentEntry[];
correspondentOptions?: SelectionAssignmentMenuItem[];
onCorrespondentAdd?: (payload: { document: DocumentLike; name: string; option?: unknown }) => void;
onCorrespondentRemove?: (payload: { documentId: Identifier | undefined; correspondentId: Identifier | undefined }) => void;
onUpdateTitle?: (docId: Identifier | undefined, title: string) => Promise<boolean> | boolean;
onUpdateIssued?: (docId: Identifier | undefined, timestamp: number | null) => Promise<boolean> | boolean;
layout?: 'default' | 'compact';
detailItems?: Array<{ label?: string; value?: string }>;
}
export const sortCorrespondents = (entries = []) =>
entries
.filter((entry) => entry && entry.name)
@@ -32,15 +95,28 @@ export const buildCorrespondentOptions = (entries = []) => {
}, []);
};
const normalizeOptions = (options) => (Array.isArray(options) ? options : []);
const normalizeOptions = <T,>(options?: T[] | null): T[] => (Array.isArray(options) ? options : []);
const normalizeQuickAddOption = (option) => {
interface QuickAddOption {
id?: Identifier;
label?: string;
name?: string;
[key: string]: unknown;
}
interface QuickAddEntry {
id: Identifier | string;
label: string;
original: QuickAddOption | string;
}
const normalizeQuickAddOption = (option: QuickAddOption | string | null | undefined): QuickAddEntry | null => {
if (option == null) {
return null;
}
const isObject = typeof option === 'object';
const labelSource = isObject ? option.label ?? option.name ?? '' : option;
const label = labelSource?.trim?.() || '';
const label = typeof labelSource === 'string' ? labelSource.trim() : '';
if (!label) {
return null;
}
@@ -51,7 +127,7 @@ const normalizeQuickAddOption = (option) => {
};
};
export const TagSection = ({
export const TagSection: React.FC<TagSectionProps> = ({
tags = [],
onRemove,
onAdd,
@@ -62,17 +138,17 @@ export const TagSection = ({
className,
}) => {
const handleCreate = useCallback(
(label) => onAdd?.({ value: label, input: null }),
(label: string) => onAdd?.({ value: label, input: null }),
[onAdd],
);
const handleSelect = useCallback(
(option) => {
(option: { label?: string; name?: string } | string | null) => {
if (!onAdd) return;
const labelSource = option && typeof option === 'object'
? option.label ?? option.name ?? ''
: option;
const label = labelSource?.trim?.() || '';
const label = typeof labelSource === 'string' ? labelSource.trim() : '';
if (!label) {
return;
}
@@ -85,14 +161,14 @@ export const TagSection = ({
() =>
normalizeOptions(datalistOptions)
.map((option) => normalizeQuickAddOption(option))
.filter(Boolean),
.filter((option): option is QuickAddEntry => Boolean(option)),
[datalistOptions],
);
const containerClass = className ? `tag-list ${className}` : 'tag-list';
const showQuickAdd = Boolean(onAdd);
const assignmentItems = useMemo(() => {
const map = new Map();
const assignmentItems = useMemo<SelectionAssignmentMenuItem[]>(() => {
const map = new Map<string, SelectionAssignmentMenuItem>();
normalizedOptions.forEach((option) => {
const label = option?.label?.trim();
@@ -120,8 +196,10 @@ export const TagSection = ({
const payload = { id: tag.id, label, color: tag.color ?? null };
if (map.has(key)) {
const entry = map.get(key);
entry.state = 'all';
entry.payload = payload;
if (entry) {
entry.state = 'all';
entry.payload = payload;
}
return;
}
map.set(key, {
@@ -136,7 +214,7 @@ export const TagSection = ({
}, [normalizedOptions, tags]);
const handleAssignmentSelect = useCallback(
(item) => {
(item: SelectionAssignmentMenuItem | null) => {
if (!item) {
return;
}
@@ -192,7 +270,7 @@ export const TagSection = ({
);
};
export const CorrespondentSection = ({
export const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
entries = [],
onRemove,
onAdd,
@@ -203,7 +281,7 @@ export const CorrespondentSection = ({
className,
}) => {
const handleCreate = useCallback(
(name) => onAdd?.({ name, input: null }),
(name: string) => onAdd?.({ name, input: null }),
[onAdd],
);
@@ -211,15 +289,15 @@ export const CorrespondentSection = ({
() =>
normalizeOptions(datalistOptions)
.map((option) => normalizeQuickAddOption(option))
.filter(Boolean),
.filter((option): option is QuickAddEntry => Boolean(option)),
[datalistOptions],
);
const hasEntries = entries && entries.length > 0;
const showQuickAdd = Boolean(onAdd);
const containerClass = className ? `correspondent-list ${className}` : 'correspondent-list';
const assignmentItems = useMemo(() => {
const map = new Map();
const assignmentItems = useMemo<SelectionAssignmentMenuItem[]>(() => {
const map = new Map<string, SelectionAssignmentMenuItem>();
normalizedOptions.forEach((option) => {
const label = option?.label?.trim();
@@ -247,8 +325,10 @@ export const CorrespondentSection = ({
const payload = { id: entry.id, name: label };
if (map.has(key)) {
const item = map.get(key);
item.state = 'all';
item.payload = payload;
if (item) {
item.state = 'all';
item.payload = payload;
}
return;
}
map.set(key, {
@@ -263,7 +343,7 @@ export const CorrespondentSection = ({
}, [normalizedOptions, entries]);
const handleAssignmentSelect = useCallback(
(item) => {
(item: SelectionAssignmentMenuItem | null) => {
if (!onAdd || !item) {
return;
}
@@ -333,7 +413,7 @@ export const CorrespondentSection = ({
);
};
const DocumentSummarySection = ({
const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
document,
tagLookupById = new Map(),
tagOptions = [],
@@ -387,7 +467,7 @@ const DocumentSummarySection = ({
}, [correspondents, document?.correspondents]);
const metaRows = useMemo(() => {
const rows = [];
const rows: { key: string; label: string; value: string | null | undefined }[] = [];
const currentVersionNumber = document?.current_version?.version_number;
if (Number.isFinite(currentVersionNumber)) {
rows.push({
@@ -433,7 +513,7 @@ const DocumentSummarySection = ({
const startTitleEdit = useCallback(() => {
if (!editableTitle || !document) return;
setIsTitleEditing(true);
setTitleDraft(document.title);
setTitleDraft(document.title || '');
setTitleError(null);
}, [document, editableTitle]);
@@ -445,9 +525,9 @@ const DocumentSummarySection = ({
}, []);
const submitTitleEdit = useCallback(
async (event) => {
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editableTitle || !document) return;
if (!editableTitle || !document || !onUpdateTitle) return;
const trimmed = titleDraft.trim();
if (!trimmed) {
setTitleError('Title cannot be empty.');
@@ -483,9 +563,9 @@ const DocumentSummarySection = ({
}, []);
const submitIssuedEdit = useCallback(
async (event) => {
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editableIssued || !document) return;
if (!editableIssued || !document || !onUpdateIssued) return;
const normalizedValue = issuedDraft ? toIssuedTimestamp(issuedDraft, document.issued_at) : null;
setIssuedSaving(true);
try {
@@ -749,15 +829,16 @@ const DocumentSummarySection = ({
className="icon-button"
onClick={startTitleEdit}
aria-label="Edit title"
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
)}
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
)}
</div>
</div>
</div>
{titleError ? <div className="status-inline error">{titleError}</div> : null}
<div className="detail-meta">
@@ -776,7 +857,6 @@ const DocumentSummarySection = ({
</div>
{renderTags()}
{renderCorrespondents()}
</div>
);
@@ -1,11 +1,15 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, JSX, MutableRefObject } from 'react';
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
const DEFAULT_THUMBNAIL_SIZE = 48;
// Detect when an element becomes visible within a scroll container so we can delay loading.
const useLazyVisibility = (rootRef, resetKey) => {
const targetRef = useRef(null);
const useLazyVisibility = (
rootRef: MutableRefObject<Element | null> | null,
resetKey: string | number | null | undefined,
) => {
const targetRef = useRef<HTMLDivElement | null>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
@@ -24,7 +28,7 @@ const useLazyVisibility = (rootRef, resetKey) => {
return undefined;
}
if (!('IntersectionObserver' in window)) {
if (typeof window === 'undefined' || !('IntersectionObserver' in window)) {
setIsVisible(true);
return undefined;
}
@@ -52,11 +56,54 @@ const useLazyVisibility = (rootRef, resetKey) => {
return { ref: targetRef, isVisible };
};
const getPageCount = (doc) =>
const getPageCount = (doc: DocumentLike | null | undefined) =>
Number.isFinite(doc?.current_version?.metadata?.page_count)
? doc.current_version.metadata.page_count
? (doc?.current_version?.metadata?.page_count as number)
: null;
type Identifier = string | number;
interface DocumentVersionLike {
metadata?: {
page_count?: number;
width?: number;
height?: number;
[key: string]: unknown;
};
assets?: unknown;
[key: string]: unknown;
}
interface DocumentLike {
id?: Identifier;
current_version?: DocumentVersionLike;
[key: string]: unknown;
}
interface AssetLike {
id?: Identifier;
url?: string | null;
metadata?: Record<string, unknown> | null;
[key: string]: unknown;
}
type EnsureAssetUrl = (
documentId: Identifier,
asset: AssetLike,
options?: { start?: number; limit?: number; [key: string]: unknown },
) => Promise<unknown> | void;
type GetDocumentAsset = (document: DocumentLike | null | undefined, assetType: string) => AssetLike | null | undefined;
interface DocumentThumbnailImageProps {
document?: DocumentLike | null;
ensureAssetUrl?: EnsureAssetUrl;
getDocumentAsset?: GetDocumentAsset;
alt?: string;
maxSize?: number;
scrollRootRef?: MutableRefObject<Element | null> | null;
}
const DocumentThumbnailImage = ({
document,
ensureAssetUrl,
@@ -64,12 +111,12 @@ const DocumentThumbnailImage = ({
alt = '',
maxSize = DEFAULT_THUMBNAIL_SIZE,
scrollRootRef = null,
}) => {
}: DocumentThumbnailImageProps): JSX.Element => {
const documentId = document?.id;
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, documentId);
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
const thumbnailAsset = useMemo(
const thumbnailAsset = useMemo<AssetLike | null>(
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
[document?.current_version],
);
@@ -89,7 +136,7 @@ const DocumentThumbnailImage = ({
};
}, [assetWidth, assetHeight, resolvedMaxSize]);
const innerStyle = useMemo(
const innerStyle = useMemo<CSSProperties>(
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
[dimensions.height, dimensions.width],
);
@@ -98,10 +145,17 @@ const DocumentThumbnailImage = ({
if (!isVisible) {
return null;
}
return resolveDocumentAssetUrl(document, 'thumbnail', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
const options: {
ensureAssetUrl?: EnsureAssetUrl;
getAsset?: GetDocumentAsset;
} = {};
if (ensureAssetUrl) {
options.ensureAssetUrl = ensureAssetUrl;
}
if (getDocumentAsset) {
options.getAsset = getDocumentAsset;
}
return resolveDocumentAssetUrl(document, 'thumbnail', options);
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
const pageCount = getPageCount(document);
@@ -1,4 +1,5 @@
import React from 'react';
import type { DragEvent, MouseEvent, RefObject } from 'react';
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
import DocumentThumbnailImage from './DocumentThumbnailImage';
import CorrespondentLinks from './CorrespondentLinks';
@@ -7,7 +8,85 @@ import { resolveCorrespondents } from './correspondents';
import { writeTagTransferData } from './tagTransfer';
import useInlineRename from './useInlineRename';
const DocumentsGrid = ({
export type Identifier = string | number;
export interface FolderLike {
id?: Identifier | 'root';
name?: string;
}
export interface DocumentTag {
id?: Identifier;
label?: string;
color?: string | null;
}
export interface DocumentCorrespondent {
id?: Identifier;
name?: string;
count?: number;
}
export interface DocumentLike {
id?: Identifier;
title?: string;
tags?: DocumentTag[] | null;
correspondents?: DocumentCorrespondent[] | null;
}
export type FolderEntry = {
type: 'folder';
id: Identifier | 'root';
key: string;
folder: FolderLike;
};
export type DocumentEntry = {
type: 'document';
id: Identifier;
key: string;
document: DocumentLike;
};
export type DocumentsGridEntry = FolderEntry | DocumentEntry;
type FolderEventHandler = (folder: FolderLike, event: MouseEvent<HTMLDivElement>) => void;
type DocumentEventHandler = (document: DocumentLike, event: MouseEvent<HTMLDivElement>) => void;
interface DocumentsGridProps {
entries: DocumentsGridEntry[];
selectedDocumentIdsSet?: Set<Identifier> | null;
selectedFolderIdsSet?: Set<Identifier | 'root'> | null;
draggingDocumentIdsSet?: Set<Identifier> | null;
draggedFolderId?: Identifier | 'root' | null;
onFolderClick?: FolderEventHandler;
onFolderSelect?: (folderId: Identifier | 'root') => void;
onFolderDragOver?: (event: DragEvent<HTMLDivElement>, folderId: Identifier | 'root') => void;
onFolderDragLeave?: (event: DragEvent<HTMLDivElement>) => void;
onFolderDrop?: (event: DragEvent<HTMLDivElement>, folderId: Identifier | 'root') => void;
onFolderDragStart?: (event: DragEvent<HTMLDivElement>, folderId: Identifier | 'root') => void;
onFolderDragEnd?: (event: DragEvent<HTMLDivElement>) => void;
onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise<boolean> | boolean;
onDocumentClick?: DocumentEventHandler;
onDocumentActivate?: DocumentEventHandler;
onDocumentDragStart?: (event: DragEvent<HTMLDivElement>, document: DocumentLike) => void;
onDocumentDragEnd?: (event: DragEvent<HTMLDivElement>) => void;
onDocumentTagDragOver?: (event: DragEvent<HTMLDivElement>) => void;
onDocumentTagDragLeave?: (event: DragEvent<HTMLDivElement>) => void;
onDocumentTagDrop?: (event: DragEvent<HTMLDivElement>, documentId: Identifier) => void;
ensureAssetUrl?: (...args: any[]) => unknown;
getDocumentAsset?: (...args: any[]) => unknown;
gridIconSize?: number;
tagLookupById?: Map<Identifier, DocumentTag> | null;
onTagClick?: (tagId?: Identifier | null) => void;
scrollRef?: RefObject<HTMLElement | null>;
onCorrespondentClick?: (correspondentId?: Identifier | null) => void;
activeCorrespondentIdSet?: Set<Identifier | null | undefined> | null;
onClearSelection?: () => void;
onDocumentRename?: (docId: Identifier, title: string) => Promise<boolean> | boolean;
}
const DocumentsGrid: React.FC<DocumentsGridProps> = ({
entries,
selectedDocumentIdsSet,
selectedFolderIdsSet,
@@ -1,4 +1,5 @@
import React from 'react';
import type { DragEvent, MouseEvent, RefObject } from 'react';
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
import { getTagColorStyle } from '../utils/colors';
import { formatDate } from '../utils/date';
@@ -8,7 +9,89 @@ import { resolveCorrespondents } from './correspondents';
import { writeTagTransferData } from './tagTransfer';
import useInlineRename from './useInlineRename';
const DocumentsList = ({
export type Identifier = string | number;
export interface FolderLike {
id?: Identifier | 'root';
name?: string;
}
export interface DocumentTag {
id?: Identifier;
label?: string;
color?: string | null;
}
export interface DocumentCorrespondent {
id?: Identifier;
name?: string;
count?: number;
}
export interface DocumentLike {
id?: Identifier;
title?: string;
issued_at?: string | null;
created_at?: string | null;
uploaded_at?: string | null;
tags?: DocumentTag[] | null;
correspondents?: DocumentCorrespondent[] | null;
}
export type FolderEntry = {
type: 'folder';
id: Identifier | 'root';
key: string;
folder: FolderLike;
};
export type DocumentEntry = {
type: 'document';
id: Identifier;
key: string;
document: DocumentLike;
};
export type DocumentsListEntry = FolderEntry | DocumentEntry;
export type FolderEventHandler = (folder: FolderLike, event: MouseEvent<HTMLTableRowElement>) => void;
export type DocumentEventHandler = (document: DocumentLike, event: MouseEvent<HTMLTableRowElement>) => void;
export interface DocumentsListProps {
entries: DocumentsListEntry[];
focusedRowKey?: string | null;
selectedDocumentIdsSet?: Set<Identifier> | null;
selectedFolderIdsSet?: Set<Identifier | 'root'> | null;
draggingDocumentIdsSet?: Set<Identifier> | null;
draggedFolderId?: Identifier | 'root' | null;
ensureAssetUrl?: (...args: any[]) => unknown;
getDocumentAsset?: (...args: any[]) => unknown;
onFolderClick?: FolderEventHandler;
onFolderSelect?: (folderId: Identifier | 'root') => void;
onFolderDragOver?: (event: DragEvent<HTMLTableRowElement>, folderId: Identifier | 'root') => void;
onFolderDragLeave?: (event: DragEvent<HTMLTableRowElement>) => void;
onFolderDrop?: (event: DragEvent<HTMLTableRowElement>, folderId: Identifier | 'root') => void;
onFolderDragStart?: (event: DragEvent<HTMLTableRowElement>, folderId: Identifier | 'root') => void;
onFolderDragEnd?: (event: DragEvent<HTMLTableRowElement>) => void;
onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise<boolean> | boolean;
onDocumentClick?: DocumentEventHandler;
onDocumentActivate?: DocumentEventHandler;
onDocumentDragStart?: (event: DragEvent<HTMLTableRowElement>, document: DocumentLike) => void;
onDocumentDragEnd?: (event: DragEvent<HTMLTableRowElement>) => void;
onDocumentTagDragOver?: (event: DragEvent<HTMLTableRowElement>) => void;
onDocumentTagDragLeave?: (event: DragEvent<HTMLTableRowElement>) => void;
onDocumentTagDrop?: (event: DragEvent<HTMLTableRowElement>, documentId: Identifier) => void;
onDocumentRename?: (documentId: Identifier, nextTitle: string) => Promise<boolean> | boolean;
tagLookupById?: Map<Identifier, DocumentTag> | null;
onTagClick?: (tagId?: Identifier | null) => void;
onCorrespondentClick?: (correspondentId?: Identifier | null) => void;
activeCorrespondentIdSet?: Set<Identifier | null | undefined> | null;
scrollRef?: RefObject<HTMLElement | null>;
onClearSelection?: () => void;
}
const DocumentsList: React.FC<DocumentsListProps> = ({
entries,
focusedRowKey,
selectedDocumentIdsSet,
@@ -1,16 +1,57 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import useFloatingMenu from '../ui/useFloatingMenu';
import { CheckIcon, CircleDashedCheckIcon, PlusIcon } from '../ui/icons';
const STATE_ORDER = {
export type AssignmentState = 'all' | 'partial' | 'none';
export interface SelectionAssignmentMenuItem {
id?: string | number;
label?: string;
state?: AssignmentState;
count?: number | null;
total?: number | null;
color?: string | null;
value?: string | number;
payload?: unknown;
}
export interface NormalizedSelectionAssignmentItem {
id: string | number;
label: string;
state: AssignmentState;
count: number | null;
total: number | null;
payload: unknown;
}
export interface SelectionAssignmentMenuProps {
label: React.ReactNode;
items?: SelectionAssignmentMenuItem[];
placeholder?: string;
emptyMessage?: string;
createLabel?: string | null;
onToggle?: (item: NormalizedSelectionAssignmentItem) => Promise<void> | void;
onCreate?: (value: string) => Promise<void> | void;
disabled?: boolean;
className?: string;
triggerContent?: React.ReactNode;
triggerClassName?: string;
showStateIndicators?: boolean;
showCounts?: boolean;
onOpenMenu?: () => void;
renderItemLabel?: (item: NormalizedSelectionAssignmentItem) => React.ReactNode;
positionStrategy?: 'absolute' | 'fixed';
}
const STATE_ORDER: Record<AssignmentState, number> = {
all: 0,
partial: 1,
none: 2,
};
const normalizeItems = (items) =>
const normalizeItems = (items?: SelectionAssignmentMenuItem[]): NormalizedSelectionAssignmentItem[] =>
(Array.isArray(items) ? items : [])
.map((item) => {
.map<NormalizedSelectionAssignmentItem | null>((item) => {
if (!item) {
return null;
}
@@ -18,18 +59,23 @@ const normalizeItems = (items) =>
if (!trimmedLabel) {
return null;
}
const state: AssignmentState = item.state === 'all'
? 'all'
: item.state === 'partial'
? 'partial'
: 'none';
return {
id: item.id ?? trimmedLabel,
label: trimmedLabel,
state: item.state === 'all' ? 'all' : item.state === 'partial' ? 'partial' : 'none',
state,
count: typeof item.count === 'number' ? item.count : null,
total: typeof item.total === 'number' ? item.total : null,
payload: item.payload ?? item,
};
})
.filter(Boolean);
.filter((item): item is NormalizedSelectionAssignmentItem => Boolean(item));
const SelectionAssignmentMenu = ({
const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
label,
items = [],
placeholder = 'Search…',
@@ -47,8 +93,8 @@ const SelectionAssignmentMenu = ({
renderItemLabel = null,
positionStrategy = 'absolute',
}) => {
const anchorRef = useRef(null);
const inputRef = useRef(null);
const anchorRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState('');
const [pending, setPending] = useState(false);
@@ -64,7 +110,14 @@ const SelectionAssignmentMenu = ({
align: 'center',
positionStrategy,
minWidth: 220,
});
}) as {
isOpen: boolean;
toggle: () => void;
close: () => void;
menuRef: React.MutableRefObject<HTMLDivElement | null>;
menuStyle: CSSProperties | null;
updatePosition: () => void;
};
useEffect(() => {
if (disabled && isOpen) {
@@ -106,7 +159,7 @@ const SelectionAssignmentMenu = ({
}, [normalizedItems, query]);
const handleToggle = useCallback(
async (item) => {
async (item: NormalizedSelectionAssignmentItem) => {
if (!item || typeof onToggle !== 'function') {
return;
}
@@ -124,7 +177,7 @@ const SelectionAssignmentMenu = ({
);
const handleCreate = useCallback(
async (event) => {
async (event: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault?.();
if (typeof onCreate !== 'function') {
return;
@@ -1,10 +1,4 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
TrashIcon,
AnalyzeIcon,
@@ -14,19 +8,90 @@ import {
CorrespondentIcon,
LoaderIcon,
} from '../ui/icons';
import SelectionAssignmentMenu from './SelectionAssignmentMenu';
import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } 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 = [];
type DocumentId = string | number;
type NullableDocumentId = DocumentId | null | undefined;
const traverse = (nodes, parentSegments) => {
type SelectedIdList = Array<NullableDocumentId> | null | undefined;
type FolderTreeNode = {
id?: DocumentId;
name?: string;
label?: string;
value?: DocumentId;
children?: FolderTreeNode[];
};
interface TagOption {
id?: DocumentId;
label?: string;
name?: string;
color?: string | null;
}
interface CorrespondentOption {
id?: DocumentId;
name?: string;
label?: string;
}
interface DocumentLike {
id?: DocumentId;
tags?: TagOption[];
correspondents?: CorrespondentOption[];
[key: string]: unknown;
}
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, DocumentLike> | null;
tags?: TagOption[] | null;
tagLookupById?: Map<DocumentId, TagOption> | null;
correspondents?: CorrespondentOption[] | null;
folderOptions?: SelectionAssignmentMenuItem[] | 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) => Promise<void> | void;
}
const normalizeDocumentList = (selectedIds?: SelectedIdList): DocumentId[] =>
Array.isArray(selectedIds)
? selectedIds.filter((value): value is DocumentId => value !== null && value !== undefined)
: [];
const buildFolderTreeOptions = (tree?: FolderTreeNode[] | null): SelectionAssignmentMenuItem[] => {
const entries: SelectionAssignmentMenuItem[] = [];
const traverse = (nodes: FolderTreeNode[] | undefined | null, parentSegments: string[]) => {
if (!Array.isArray(nodes) || nodes.length === 0) {
return;
}
@@ -34,11 +99,21 @@ const buildFolderTreeOptions = (tree) => {
if (!node || !node.id) {
return;
}
const trimmedName = node?.name?.trim?.();
const trimmedName = node.name?.trim?.();
const name = trimmedName?.length ? trimmedName : 'Folder';
const nextSegments = parentSegments.concat([name]);
const label = nextSegments.join('/');
entries.push({ id: node.id, label });
entries.push({
id: node.id,
label,
state: 'none',
payload: {
id: node.id,
label,
segments: nextSegments,
depth: Math.max(nextSegments.length - 1, 0),
},
});
if (Array.isArray(node.children) && node.children.length) {
traverse(node.children, nextSegments);
}
@@ -47,19 +122,30 @@ const buildFolderTreeOptions = (tree) => {
traverse(Array.isArray(tree) ? tree : [], [ROOT_FOLDER_LABEL]);
entries.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
entries.sort((a, b) => (a.label || '').localeCompare(b.label || '', undefined, { sensitivity: 'base' }));
return [{ id: 'root', label: ROOT_FOLDER_LABEL }, ...entries];
return [{ id: 'root', label: ROOT_FOLDER_LABEL, state: 'none', payload: { id: 'root' } }, ...entries];
};
const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => {
const buildTagAssignments = (
selectedDocuments: DocumentLike[],
tagLookupById: Map<DocumentId, TagOption> | null | undefined,
tags: TagOption[] | null | undefined,
total: number,
): SelectionAssignmentMenuItem[] => {
if (!total) {
return [];
}
const map = new Map();
const map = new Map<string | number, {
id?: DocumentId;
label: string;
color: string | null;
count: number;
total: number;
}>();
const ensureEntry = (id, label, color = null) => {
const ensureEntry = (id?: DocumentId, label?: string, color: string | null = null) => {
const key = id ?? label;
if (!key || !label) {
return null;
@@ -73,7 +159,7 @@ const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => {
total,
});
}
return map.get(key);
return map.get(key) ?? null;
};
selectedDocuments.forEach((doc) => {
@@ -106,14 +192,23 @@ const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => {
});
};
const buildCorrespondentAssignments = (selectedDocuments, correspondents, total) => {
const buildCorrespondentAssignments = (
selectedDocuments: DocumentLike[],
correspondents: CorrespondentOption[] | null | undefined,
total: number,
): SelectionAssignmentMenuItem[] => {
if (!total) {
return [];
}
const map = new Map();
const map = new Map<string | number, {
id?: DocumentId;
label: string;
count: number;
total: number;
}>();
const ensureEntry = (id, name) => {
const ensureEntry = (id?: DocumentId, name?: string) => {
const key = id ?? name;
if (!key || !name) {
return null;
@@ -126,7 +221,7 @@ const buildCorrespondentAssignments = (selectedDocuments, correspondents, total)
total,
});
}
return map.get(key);
return map.get(key) ?? null;
};
selectedDocuments.forEach((doc) => {
@@ -139,7 +234,7 @@ const buildCorrespondentAssignments = (selectedDocuments, correspondents, total)
});
(correspondents || []).forEach((entry) => {
ensureEntry(entry?.id, entry?.name);
ensureEntry(entry?.id, entry?.name || entry?.label);
});
return Array.from(map.values()).map((entry) => {
@@ -156,14 +251,14 @@ const buildCorrespondentAssignments = (selectedDocuments, correspondents, total)
});
};
const SelectionFloatingActions = ({
const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
selectionCount = 0,
selectedDocumentIds,
selectedDocumentIds = [],
selectedFolderIds = [],
documentLookup,
tags,
tags = [],
tagLookupById,
correspondents,
correspondents = [],
folderOptions = [],
onBulkTagAdd,
onBulkTagRemove,
@@ -174,12 +269,17 @@ const SelectionFloatingActions = ({
onClearSelection = null,
onMoveDocumentsToFolder,
}) => {
const { token, tenant } = useAppState();
const { token, tenant } = useAppState() as { token?: string; tenant?: { id?: DocumentId } | null };
const tenantId = tenant?.id ?? null;
const [remoteFolderOptions, setRemoteFolderOptions] = useState(null);
const documentLookupMap = useMemo(() => (
documentLookup instanceof Map ? documentLookup : new Map<DocumentId, DocumentLike>()
), [documentLookup]);
const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null;
const [remoteFolderOptions, setRemoteFolderOptions] = useState<SelectionAssignmentMenuItem[] | null>(null);
const [loadingFolders, setLoadingFolders] = useState(false);
const folderTreeFetchRef = useRef(null);
const folderTreeFetchRef = useRef<Promise<SelectionAssignmentMenuItem[]> | null>(null);
useEffect(() => {
setRemoteFolderOptions(null);
@@ -187,7 +287,7 @@ const SelectionFloatingActions = ({
setLoadingFolders(false);
}, [tenantId, token]);
const requestFolderTree = useCallback(async () => {
const requestFolderTree = useCallback(async (): Promise<SelectionAssignmentMenuItem[]> => {
if (!token) {
setRemoteFolderOptions([]);
return [];
@@ -226,59 +326,55 @@ const SelectionFloatingActions = ({
requestFolderTree();
}, [requestFolderTree]);
const effectiveFolderOptions = useMemo(() => {
const effectiveFolderOptions = useMemo<SelectionAssignmentMenuItem[]>(() => {
if (remoteFolderOptions !== null) {
return remoteFolderOptions;
}
return Array.isArray(folderOptions) ? folderOptions : [];
}, [remoteFolderOptions, folderOptions]);
const documentIdList = useMemo(
const documentIdList = useMemo<DocumentId[]>(
() => normalizeDocumentList(selectedDocumentIds),
[selectedDocumentIds],
);
const folderIdList = useMemo(
const folderIdList = useMemo<DocumentId[]>(
() => normalizeDocumentList(selectedFolderIds),
[selectedFolderIds],
);
const documentCount = documentIdList.length;
const folderCount = folderIdList.length;
const totalCount = typeof selectionCount === 'number'
? selectionCount
: documentCount + folderCount;
const totalCount = typeof selectionCount === 'number' ? selectionCount : documentCount + folderCount;
const selectedDocuments = useMemo(() => {
if (!documentIdList.length || !(documentLookup instanceof Map)) {
const selectedDocuments = useMemo<DocumentLike[]>(() => {
if (!documentIdList.length || !(documentLookupMap instanceof Map)) {
return [];
}
return documentIdList
.map((id) => documentLookup.get(id))
.filter(Boolean);
}, [documentIdList, documentLookup]);
.map((id) => documentLookupMap.get(id))
.filter((doc): doc is DocumentLike => Boolean(doc));
}, [documentIdList, documentLookupMap]);
const selectedDocCount = selectedDocuments.length;
const moveAssignments = useMemo(() => {
const moveAssignments = useMemo<SelectionAssignmentMenuItem[]>(() => {
if (!Array.isArray(effectiveFolderOptions)) {
return [];
}
return effectiveFolderOptions
.map((option) => {
const id = option?.id ?? option?.value ?? option;
.map<SelectionAssignmentMenuItem | null>((option) => {
const id = (option?.id ?? option?.payload?.id ?? option?.value) as DocumentId | undefined;
if (!id) {
return null;
}
const label = option?.label || option?.name || String(id);
const segments = label.split('/');
const label = option?.label || option?.payload?.label || option?.name || String(id);
const segments = typeof label === 'string' ? label.split('/') : [label];
const depth = Math.max(segments.length - 1, 0);
return {
id,
label,
state: 'none',
count: null,
total: null,
payload: {
id,
label,
@@ -287,12 +383,12 @@ const SelectionFloatingActions = ({
},
};
})
.filter(Boolean);
.filter((entry): entry is SelectionAssignmentMenuItem => Boolean(entry));
}, [effectiveFolderOptions]);
const tagAssignments = useMemo(
() => buildTagAssignments(selectedDocuments, tagLookupById, tags, selectedDocCount),
[selectedDocuments, tagLookupById, tags, selectedDocCount],
() => buildTagAssignments(selectedDocuments, tagLookupMap, tags, selectedDocCount),
[selectedDocuments, tagLookupMap, tags, selectedDocCount],
);
const correspondentAssignments = useMemo(
@@ -300,9 +396,10 @@ 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 renderFolderLabel = useCallback((item: SelectionAssignmentMenuItem) => {
const payload = (item?.payload as { segments?: string[]; depth?: number }) || {};
const segments = payload.segments || (typeof item.label === 'string' ? item.label.split('/') : []);
const depth = 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';
@@ -328,21 +425,21 @@ const SelectionFloatingActions = ({
}, []);
const handleToggleTagAssignment = useCallback(
async (item) => {
async (item: SelectionAssignmentMenuItem) => {
if (!selectedDocCount || !item) {
return;
}
if (item.state === 'all') {
await onBulkTagRemove?.({ label: item.label, input: null, documentIds: documentIdList });
await onBulkTagRemove?.({ label: item.label || '', input: null, documentIds: documentIdList });
} else {
await onBulkTagAdd?.({ label: item.label, input: null, documentIds: documentIdList });
await onBulkTagAdd?.({ label: item.label || '', input: null, documentIds: documentIdList });
}
},
[selectedDocCount, onBulkTagAdd, onBulkTagRemove, documentIdList],
);
const handleCreateTagAssignment = useCallback(
async (label) => {
async (label: string) => {
if (!selectedDocCount || !label) {
return;
}
@@ -352,7 +449,7 @@ const SelectionFloatingActions = ({
);
const handleToggleCorrespondentAssignment = useCallback(
async (item) => {
async (item: SelectionAssignmentMenuItem) => {
if (!selectedDocCount || !item) {
return;
}
@@ -365,14 +462,14 @@ const SelectionFloatingActions = ({
documentIds: documentIdList,
});
} else {
await onBulkCorrespondentAdd?.({ name: item.label, input: null, documentIds: documentIdList });
await onBulkCorrespondentAdd?.({ name: item.label || '', input: null, documentIds: documentIdList });
}
},
[selectedDocCount, onBulkCorrespondentAdd, onBulkCorrespondentRemove, documentIdList],
);
const handleCreateCorrespondentAssignment = useCallback(
async (name) => {
async (name: string) => {
if (!selectedDocCount || !name) {
return;
}
@@ -382,15 +479,18 @@ const SelectionFloatingActions = ({
);
const handleMoveSelectionToFolder = useCallback(
async (option) => {
async (option: unknown) => {
if (!documentIdList.length || typeof onMoveDocumentsToFolder !== 'function') {
return;
}
const value = option?.id ?? option?.value ?? option;
if (!value) {
const candidate = option as { id?: DocumentId; value?: DocumentId } | DocumentId | null | undefined;
const value = typeof candidate === 'object'
? candidate?.id ?? candidate?.value ?? null
: candidate;
if (!value && value !== 0) {
return;
}
await onMoveDocumentsToFolder(documentIdList, value);
await onMoveDocumentsToFolder(documentIdList, value as DocumentId);
},
[documentIdList, onMoveDocumentsToFolder],
);
@@ -1,7 +1,13 @@
import React from 'react';
import { FileIcon, FolderOutlineIcon } from '../ui/icons';
const SelectionSummary = ({ documentCount = 0, folderCount = 0, totalCount = 0 }) => {
interface SelectionSummaryProps {
documentCount?: number;
folderCount?: number;
totalCount?: number;
}
const SelectionSummary: React.FC<SelectionSummaryProps> = ({ documentCount = 0, folderCount = 0, totalCount = 0 }) => {
const docCount = Number(documentCount) || 0;
const folderCountNumber = Number(folderCount) || 0;
const aggregateCount = docCount + folderCountNumber;
-34
View File
@@ -1,34 +0,0 @@
export const resolveCorrespondents = (doc) => {
if (!doc || !Array.isArray(doc.correspondents)) {
return [];
}
const seen = new Set();
const results = [];
doc.correspondents.forEach((entry = {}, index) => {
const { id, name } = entry;
const trimmedName = name?.trim?.();
if (!trimmedName) {
return;
}
if (id && seen.has(id)) {
return;
}
if (id) {
seen.add(id);
}
results.push({
id,
name: trimmedName,
key: id ?? `${trimmedName}-${index}`,
});
});
return results;
};
export default resolveCorrespondents;
+50
View File
@@ -0,0 +1,50 @@
export interface CorrespondentReference {
id?: string | number | null;
name?: string | null;
key?: string;
}
export interface DocumentLike {
correspondents?: CorrespondentReference[];
}
export interface ResolvedCorrespondent {
id?: string | number | null;
name: string;
key: string | number;
}
export const resolveCorrespondents = (doc?: DocumentLike | null): ResolvedCorrespondent[] => {
if (!doc || !Array.isArray(doc.correspondents)) {
return [];
}
const seen = new Set<string | number>();
const results: ResolvedCorrespondent[] = [];
doc.correspondents.forEach((entry = {}, index) => {
const { id, name } = entry;
const trimmedName = name?.trim?.();
if (!trimmedName) {
return;
}
if (id != null && seen.has(id)) {
return;
}
if (id != null) {
seen.add(id);
}
results.push({
id,
name: trimmedName,
key: id ?? `${trimmedName}-${index}`,
});
});
return results;
};
export default resolveCorrespondents;
@@ -1,9 +1,23 @@
import { openOcrTextInNewTab } from '../utils/ocr';
type ResolveApiPath = (path: string) => string;
type EnsurePreviewData = (id: string | number) => Promise<void>;
type EnsureAssetUrl = (id: string | number, asset: unknown, options?: unknown) => Promise<unknown>;
type GetDocumentAsset = (document: DocumentLike, type: string) => unknown;
interface DocumentVersion {
download_path?: string | null;
}
export interface DocumentLike {
id?: string | number;
current_version?: DocumentVersion | null;
}
const asyncFalse = async () => false;
const resolveDocumentDownloadHref = (document, resolveApiPath) => {
if (!document || typeof resolveApiPath !== 'function') {
const resolveDocumentDownloadHref = (document: DocumentLike | null | undefined, resolveApiPath?: ResolveApiPath | null): string | null => {
if (!document || !resolveApiPath) {
return null;
}
const downloadPath = document.current_version?.download_path;
@@ -13,13 +27,23 @@ const resolveDocumentDownloadHref = (document, resolveApiPath) => {
return resolveApiPath(downloadPath);
};
const hasDocumentOcrAsset = (document, getDocumentAsset) => {
if (!document || typeof getDocumentAsset !== 'function') {
const hasDocumentOcrAsset = (document: DocumentLike | null | undefined, getDocumentAsset?: GetDocumentAsset | null): boolean => {
if (!document || !getDocumentAsset) {
return false;
}
return Boolean(getDocumentAsset(document, 'ocr-text'));
};
interface CreateDocumentActionStateArgs {
document: DocumentLike | null;
resolveApiPath?: ResolveApiPath | null;
ensurePreviewData: EnsurePreviewData;
ensureAssetUrl: EnsureAssetUrl;
getDocumentAsset?: GetDocumentAsset | null;
notifyApiError?: (error: unknown, message: string) => void;
ocrErrorMessage?: string;
}
export const createDocumentActionState = ({
document,
resolveApiPath,
@@ -28,7 +52,7 @@ export const createDocumentActionState = ({
getDocumentAsset,
notifyApiError,
ocrErrorMessage = 'Unable to open OCR text.',
}) => {
}: CreateDocumentActionStateArgs) => {
if (!document) {
return {
downloadHref: null,
@@ -49,14 +73,12 @@ export const createDocumentActionState = ({
getDocumentAsset,
ensureAssetUrl,
});
if (!success && typeof notifyApiError === 'function') {
notifyApiError(new Error('OCR text URL unavailable.'), ocrErrorMessage);
if (!success) {
notifyApiError?.(new Error('OCR text URL unavailable.'), ocrErrorMessage);
}
return success;
} catch (error) {
if (typeof notifyApiError === 'function') {
notifyApiError(error, ocrErrorMessage);
}
notifyApiError?.(error, ocrErrorMessage);
throw error;
}
}
@@ -1,6 +1,29 @@
import { formatDateTime } from '../utils/date';
export const buildDocumentMetadataItems = (document) => {
interface DocumentVersionMetadata {
checksum?: string | null;
}
interface DocumentMetadata {
[key: string]: unknown;
}
export interface DocumentLike {
created_at?: string | null;
updated_at?: string | null;
filename?: string | null;
original_name?: string | null;
content_type?: string | null;
metadata?: DocumentMetadata | null;
current_version?: DocumentVersionMetadata | null;
}
export interface DocumentMetadataItem {
label: string;
value: string | null | undefined;
}
export const buildDocumentMetadataItems = (document?: DocumentLike | null): DocumentMetadataItem[] => {
if (!document) {
return [];
}
@@ -29,8 +52,8 @@ export const buildDocumentMetadataItems = (document) => {
];
};
export const extractDocumentMetadataPayload = (document) => {
if (!document || !document.metadata) {
export const extractDocumentMetadataPayload = (document?: DocumentLike | null): DocumentMetadata | null => {
if (!document?.metadata) {
return null;
}
const keys = Object.keys(document.metadata);
@@ -1,13 +1,71 @@
import { formatFileSize } from '../utils/format';
import { formatDateTime as defaultFormatDateTime } from '../utils/date';
const coercePageCount = (metadata) => {
interface DocumentMetadata {
page_count?: number | string | null;
}
interface DocumentVersion {
size_bytes?: number | string | null;
metadata?: DocumentMetadata | null;
}
interface TagEntry {
label?: string | null;
}
interface CorrespondentEntry {
name?: string | null;
}
export interface SummaryDocument {
title?: string | null;
original_name?: string | null;
content_type?: string | null;
current_version?: DocumentVersion | null;
created_at?: string | null;
updated_at?: string | null;
issued_at?: string | null;
folder_path?: string | null;
tags?: TagEntry[] | null;
correspondents?: CorrespondentEntry[] | null;
}
interface DescribeSummaryOptions {
formatDateTime?: typeof defaultFormatDateTime;
}
export interface DocumentSummaryRow {
key: string;
label: string;
value: string | null | undefined;
}
export interface DocumentSummary {
title: string | undefined;
originalName: string | null | undefined;
mimeTypeLabel: string;
sizeLabel: string;
createdAtLabel: string;
issuedLabel: string;
updatedAtLabel: string;
pageCount: number | null;
pageCountLabel: string;
folderLabel: string | null | undefined;
tags: TagEntry[];
correspondents: CorrespondentEntry[];
tagsSummary: string;
correspondentsSummary: string;
summaryRows: DocumentSummaryRow[];
}
const coercePageCount = (metadata: DocumentMetadata | null | undefined): number | null => {
const raw = metadata?.page_count;
if (typeof raw === 'number') {
return Number.isFinite(raw) && raw >= 0 ? raw : null;
}
if (raw != null && raw !== '') {
const parsed = Number.parseInt(raw, 10);
const parsed = Number.parseInt(String(raw), 10);
if (Number.isFinite(parsed) && parsed >= 0) {
return parsed;
}
@@ -15,21 +73,14 @@ const coercePageCount = (metadata) => {
return null;
};
const sanitizeTags = (tags) => {
if (!Array.isArray(tags)) {
return [];
}
return tags.filter(Boolean);
};
const sanitizeArray = <T>(entries: (T | null | undefined)[] | null | undefined): T[] =>
Array.isArray(entries) ? entries.filter(Boolean) as T[] : [];
const sanitizeCorrespondents = (entries) => {
if (!Array.isArray(entries)) {
return [];
}
return entries.filter(Boolean);
};
export const describeDocumentSummary = (document?: SummaryDocument | null, options: DescribeSummaryOptions = {}): DocumentSummary => {
const {
formatDateTime = defaultFormatDateTime,
} = options;
export const describeDocumentSummary = (document, options = {}) => {
if (!document) {
return {
title: '',
@@ -50,10 +101,6 @@ export const describeDocumentSummary = (document, options = {}) => {
};
}
const {
formatDateTime = defaultFormatDateTime,
} = options;
const originalName = document.original_name;
const mimeTypeLabel = document.content_type || 'Unknown';
@@ -70,18 +117,18 @@ export const describeDocumentSummary = (document, options = {}) => {
const folderLabel = document.folder_path;
const tags = sanitizeTags(document.tags);
const correspondents = sanitizeCorrespondents(document.correspondents);
const tags = sanitizeArray<TagEntry>(document.tags);
const correspondents = sanitizeArray<CorrespondentEntry>(document.correspondents);
const tagLabels = tags.map((tag) => tag.label).filter(Boolean);
const correspondentLabels = correspondents.map((entry) => entry.name).filter(Boolean);
const tagLabels = tags.map((tag) => tag.label).filter(Boolean) as string[];
const correspondentLabels = correspondents.map((entry) => entry.name).filter(Boolean) as string[];
const tagsSummary = tagLabels.length ? tagLabels.join(', ') : '—';
const correspondentsSummary = correspondentLabels.length
? correspondentLabels.join(', ')
: '—';
const summaryRows = [
const summaryRows: DocumentSummaryRow[] = [
{ key: 'created', label: 'Created', value: createdAtLabel },
{ key: 'size', label: 'Size', value: sizeLabel },
{ key: 'type', label: 'Type', value: mimeTypeLabel },
@@ -1,5 +1,36 @@
import { useCallback } from 'react';
export type Identifier = string | number;
type ApiClient = {
post: <T = { data: unknown }>(url: string, payload: unknown) => Promise<{ data: T } | T>;
delete: (url: string) => Promise<unknown>;
};
type BulkAssignmentResponse = {
assigned?: number;
removed?: number;
};
type CorrespondentAssignment = {
correspondent_id?: Identifier;
};
interface UseBulkDocumentActionsArgs {
api: ApiClient;
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
correspondentLookupByName: Map<string, { id?: Identifier }>;
handleCorrespondentCreate: (payload: { name: string }) => Promise<{ id?: Identifier } | null>;
refreshCurrentFolder: () => Promise<void> | void;
setStatusMessage: (message: string, variant?: string) => void;
selectedDocumentIds?: Identifier[];
selectedFolderIds?: Identifier[];
handleDocumentsDelete: (ids: Identifier[], options?: { showMessage?: boolean; manageLoading?: boolean }) => Promise<boolean>;
handleFolderDelete: (id: Identifier, options?: { showMessage?: boolean; manageLoading?: boolean }) => Promise<boolean>;
clearDocumentSelection: () => void;
setLoading: (value: boolean) => void;
}
const useBulkDocumentActions = ({
api,
resolveTargetDocumentIds,
@@ -13,9 +44,9 @@ const useBulkDocumentActions = ({
handleFolderDelete,
clearDocumentSelection,
setLoading,
}) => {
}: UseBulkDocumentActionsArgs) => {
const handleBulkCorrespondentAdd = useCallback(
async ({ name, input, documentIds }) => {
async ({ name, input, documentIds }: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
const trimmed = name?.trim?.() || '';
if (!trimmed) {
setStatusMessage('Correspondent name is required.', 'error');
@@ -41,7 +72,7 @@ const useBulkDocumentActions = ({
return;
}
const response = await api.post('/documents/bulk/correspondents', {
const response = await api.post<BulkAssignmentResponse>('/documents/bulk/correspondents', {
document_ids: targets,
assignments: [
{
@@ -51,7 +82,7 @@ const useBulkDocumentActions = ({
action: 'add',
});
const { assigned = 0, removed = 0 } = response.data || {};
const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response;
await refreshCurrentFolder();
const assignedSuffix = assigned === 1 ? '' : 's';
@@ -83,7 +114,7 @@ const useBulkDocumentActions = ({
);
const handleBulkCorrespondentRemove = useCallback(
async ({ assignments = [], documentIds }) => {
async ({ assignments = [], documentIds }: { assignments?: CorrespondentAssignment[]; documentIds?: Identifier[] }) => {
if (!assignments.length) {
setStatusMessage('Select a correspondent to remove.', 'error');
return;
@@ -100,13 +131,13 @@ const useBulkDocumentActions = ({
correspondent_id: entry.correspondent_id,
}));
const response = await api.post('/documents/bulk/correspondents', {
const response = await api.post<BulkAssignmentResponse>('/documents/bulk/correspondents', {
document_ids: targets,
assignments: normalizedAssignments,
action: 'remove',
});
const { assigned = 0, removed = 0 } = response.data || {};
const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response;
await refreshCurrentFolder();
if (removed > 0) {
@@ -1,176 +0,0 @@
import { useMemo } from 'react';
const useDocumentsPanelProps = ({
currentFolderName,
breadcrumbs,
refreshCurrentFolder,
currentSubfolders,
documents,
searchResults,
isFilterActive,
folderClickHandlers,
selectFolder,
handleFolderDragStart,
handleFolderDragEnd,
draggedFolderId,
handleFolderRename,
openDocumentPreview,
handleDocumentTitleUpdate,
selectedDocumentIds,
selectedFolderIds,
focusedRowKey,
draggedDocumentIds,
handleDocumentDragStart,
handleDocumentDragEnd,
searchLoading,
tagLookupById,
activeCorrespondentFilters,
selectedEntries,
setFocusedRowKey,
ensureAssetUrl,
getDocumentAsset,
toggleTagFilter,
toggleCorrespondentFilter,
handleDocumentTagDrop,
documentsViewMode,
documentsSortField,
documentsSortDirection,
handleDocumentsSortFieldChange,
handleDocumentsSortDirectionToggle,
searchIncludeDescendants,
toggleSearchIncludeDescendants,
handleDocumentsViewModeChange,
clearDocumentSelection,
handleDeleteSelection,
handleEntryPointerCore,
inspectDocument,
handleEntrySelection,
tags,
correspondents,
documentLookup,
handleBulkTagAddFromDetail,
handleBulkTagRemoveFromDetail,
handleBulkCorrespondentAdd,
handleBulkCorrespondentRemove,
handleBulkSelectionReanalyze,
folderOptions,
moveDocumentsToFolder,
}) =>
useMemo(
() => ({
currentFolderName,
breadcrumbs,
onRefresh: refreshCurrentFolder,
subfolders: currentSubfolders,
documents,
searchResults,
isFilterActive,
onFolderSelect: selectFolder,
onFolderDrop: folderClickHandlers.onDrop,
onFolderDragOver: folderClickHandlers.onDragOver,
onFolderDragLeave: folderClickHandlers.onDragLeave,
onFolderDragStart: handleFolderDragStart,
onFolderDragEnd: handleFolderDragEnd,
draggedFolderId,
onFolderRename: handleFolderRename,
onDocumentOpen: openDocumentPreview,
onDocumentRename: handleDocumentTitleUpdate,
selectedDocumentIds,
selectedFolderIds,
focusedRowKey,
draggingDocumentIds: draggedDocumentIds,
onDocumentDragStart: handleDocumentDragStart,
onDocumentDragEnd: handleDocumentDragEnd,
isSearchLoading: searchLoading,
tagLookupById,
activeCorrespondentIds: activeCorrespondentFilters,
selectedEntries,
onFocusedRowChange: setFocusedRowKey,
ensureAssetUrl,
getDocumentAsset,
onTagClick: toggleTagFilter,
onCorrespondentClick: toggleCorrespondentFilter,
onDocumentTagDrop: handleDocumentTagDrop,
viewMode: documentsViewMode,
sortField: documentsSortField,
sortDirection: documentsSortDirection,
onSortFieldChange: handleDocumentsSortFieldChange,
onSortDirectionToggle: handleDocumentsSortDirectionToggle,
searchIncludeDescendants,
onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants,
onViewModeChange: handleDocumentsViewModeChange,
onClearSelection: clearDocumentSelection,
onDeleteSelection: handleDeleteSelection,
onEntryPointer: handleEntryPointerCore,
onInspectDocument: inspectDocument,
onEntrySelection: handleEntrySelection,
tags,
correspondents,
documentLookup,
onBulkTagAdd: handleBulkTagAddFromDetail,
onBulkTagRemove: handleBulkTagRemoveFromDetail,
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
onBulkReanalyze: handleBulkSelectionReanalyze,
folderOptions,
onMoveDocumentsToFolder: moveDocumentsToFolder,
}),
[
activeCorrespondentFilters,
breadcrumbs,
clearDocumentSelection,
correspondents,
currentFolderName,
currentSubfolders,
documents,
documentsSortDirection,
documentsSortField,
documentsViewMode,
documentLookup,
draggedDocumentIds,
draggedFolderId,
focusedRowKey,
folderClickHandlers,
handleBulkCorrespondentAdd,
handleBulkCorrespondentRemove,
handleBulkSelectionReanalyze,
handleBulkTagAddFromDetail,
handleBulkTagRemoveFromDetail,
handleDeleteSelection,
handleDocumentDragEnd,
handleDocumentDragStart,
handleDocumentTagDrop,
handleDocumentTitleUpdate,
handleDocumentsSortDirectionToggle,
handleDocumentsSortFieldChange,
handleDocumentsViewModeChange,
handleEntryPointerCore,
handleEntrySelection,
handleFolderDragEnd,
handleFolderDragStart,
handleFolderRename,
inspectDocument,
isFilterActive,
moveDocumentsToFolder,
openDocumentPreview,
refreshCurrentFolder,
searchIncludeDescendants,
searchLoading,
searchResults,
selectedDocumentIds,
selectedEntries,
selectedFolderIds,
selectFolder,
setFocusedRowKey,
tagLookupById,
tags,
toggleCorrespondentFilter,
toggleSearchIncludeDescendants,
toggleTagFilter,
ensureAssetUrl,
getDocumentAsset,
folderOptions,
],
);
export default useDocumentsPanelProps;
@@ -0,0 +1,253 @@
import { useMemo } from 'react';
type Identifier = string | number;
export interface Breadcrumb {
id?: Identifier;
name?: string;
label?: string;
title?: string;
}
export interface FolderClickHandlers {
onDrop?: (...args: unknown[]) => void;
onDragOver?: (...args: unknown[]) => void;
onDragLeave?: (...args: unknown[]) => void;
}
export interface UseDocumentsPanelPropsArgs {
currentFolderName?: string | null;
breadcrumbs?: Breadcrumb[];
refreshCurrentFolder?: () => void | Promise<void>;
currentSubfolders?: unknown[];
documents?: unknown[];
searchResults?: unknown[] | null;
isFilterActive?: boolean;
folderClickHandlers: FolderClickHandlers;
selectFolder?: (...args: unknown[]) => void;
handleFolderDragStart?: (...args: unknown[]) => void;
handleFolderDragEnd?: (...args: unknown[]) => void;
draggedFolderId?: Identifier | null;
handleFolderRename?: (...args: unknown[]) => void;
openDocumentPreview?: (...args: unknown[]) => void;
handleDocumentTitleUpdate?: (...args: unknown[]) => void;
selectedDocumentIds?: Identifier[];
selectedFolderIds?: Identifier[];
focusedRowKey?: Identifier | string | null;
draggedDocumentIds?: Identifier[];
handleDocumentDragStart?: (...args: unknown[]) => void;
handleDocumentDragEnd?: (...args: unknown[]) => void;
searchLoading?: boolean;
tagLookupById?: unknown;
activeCorrespondentFilters?: Identifier[];
selectedEntries?: unknown[];
setFocusedRowKey?: (key: Identifier | string | null) => void;
ensureAssetUrl?: (...args: unknown[]) => void;
getDocumentAsset?: (...args: unknown[]) => unknown;
toggleTagFilter?: (...args: unknown[]) => void;
toggleCorrespondentFilter?: (...args: unknown[]) => void;
handleDocumentTagDrop?: (...args: unknown[]) => void;
documentsViewMode?: string;
documentsSortField?: string;
documentsSortDirection?: string;
handleDocumentsSortFieldChange?: (field: string) => void;
handleDocumentsSortDirectionToggle?: () => void;
searchIncludeDescendants?: boolean;
toggleSearchIncludeDescendants?: () => void;
handleDocumentsViewModeChange?: (mode: string) => void;
clearDocumentSelection?: () => void;
handleDeleteSelection?: () => void;
handleEntryPointerCore?: (...args: unknown[]) => void;
inspectDocument?: (...args: unknown[]) => void;
handleEntrySelection?: (...args: unknown[]) => void;
tags?: unknown[];
correspondents?: unknown[];
documentLookup?: unknown;
handleBulkTagAddFromDetail?: (...args: unknown[]) => void;
handleBulkTagRemoveFromDetail?: (...args: unknown[]) => void;
handleBulkCorrespondentAdd?: (...args: unknown[]) => void;
handleBulkCorrespondentRemove?: (...args: unknown[]) => void;
handleBulkSelectionReanalyze?: (...args: unknown[]) => void;
folderOptions?: unknown[];
moveDocumentsToFolder?: (...args: unknown[]) => void;
}
export type DocumentsPanelProps = ReturnType<typeof useDocumentsPanelProps>;
const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
const {
currentFolderName,
breadcrumbs,
refreshCurrentFolder,
currentSubfolders,
documents,
searchResults,
isFilterActive,
folderClickHandlers,
selectFolder,
handleFolderDragStart,
handleFolderDragEnd,
draggedFolderId,
handleFolderRename,
openDocumentPreview,
handleDocumentTitleUpdate,
selectedDocumentIds,
selectedFolderIds,
focusedRowKey,
draggedDocumentIds,
handleDocumentDragStart,
handleDocumentDragEnd,
searchLoading,
tagLookupById,
activeCorrespondentFilters,
selectedEntries,
setFocusedRowKey,
ensureAssetUrl,
getDocumentAsset,
toggleTagFilter,
toggleCorrespondentFilter,
handleDocumentTagDrop,
documentsViewMode,
documentsSortField,
documentsSortDirection,
handleDocumentsSortFieldChange,
handleDocumentsSortDirectionToggle,
searchIncludeDescendants,
toggleSearchIncludeDescendants,
handleDocumentsViewModeChange,
clearDocumentSelection,
handleDeleteSelection,
handleEntryPointerCore,
inspectDocument,
handleEntrySelection,
tags,
correspondents,
documentLookup,
handleBulkTagAddFromDetail,
handleBulkTagRemoveFromDetail,
handleBulkCorrespondentAdd,
handleBulkCorrespondentRemove,
handleBulkSelectionReanalyze,
folderOptions,
moveDocumentsToFolder,
} = props;
return useMemo(
() => ({
currentFolderName,
breadcrumbs,
onRefresh: refreshCurrentFolder,
subfolders: currentSubfolders,
documents,
searchResults,
isFilterActive,
onFolderSelect: selectFolder,
onFolderDrop: folderClickHandlers.onDrop,
onFolderDragOver: folderClickHandlers.onDragOver,
onFolderDragLeave: folderClickHandlers.onDragLeave,
onFolderDragStart: handleFolderDragStart,
onFolderDragEnd: handleFolderDragEnd,
draggedFolderId,
onFolderRename: handleFolderRename,
onDocumentOpen: openDocumentPreview,
onDocumentRename: handleDocumentTitleUpdate,
selectedDocumentIds,
selectedFolderIds,
focusedRowKey,
draggingDocumentIds: draggedDocumentIds,
onDocumentDragStart: handleDocumentDragStart,
onDocumentDragEnd: handleDocumentDragEnd,
isSearchLoading: searchLoading,
tagLookupById,
activeCorrespondentIds: activeCorrespondentFilters,
selectedEntries,
onFocusedRowChange: setFocusedRowKey,
ensureAssetUrl,
getDocumentAsset,
onTagClick: toggleTagFilter,
onCorrespondentClick: toggleCorrespondentFilter,
onDocumentTagDrop: handleDocumentTagDrop,
viewMode: documentsViewMode,
sortField: documentsSortField,
sortDirection: documentsSortDirection,
onSortFieldChange: handleDocumentsSortFieldChange,
onSortDirectionToggle: handleDocumentsSortDirectionToggle,
searchIncludeDescendants,
onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants,
onViewModeChange: handleDocumentsViewModeChange,
onClearSelection: clearDocumentSelection,
onDeleteSelection: handleDeleteSelection,
onEntryPointer: handleEntryPointerCore,
onInspectDocument: inspectDocument,
onEntrySelection: handleEntrySelection,
tags,
correspondents,
documentLookup,
onBulkTagAdd: handleBulkTagAddFromDetail,
onBulkTagRemove: handleBulkTagRemoveFromDetail,
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
onBulkReanalyze: handleBulkSelectionReanalyze,
folderOptions,
onMoveDocumentsToFolder: moveDocumentsToFolder,
}),
[
activeCorrespondentFilters,
breadcrumbs,
clearDocumentSelection,
correspondents,
currentFolderName,
currentSubfolders,
documents,
documentsSortDirection,
documentsSortField,
documentsViewMode,
documentLookup,
draggedDocumentIds,
draggedFolderId,
focusedRowKey,
folderClickHandlers,
handleBulkCorrespondentAdd,
handleBulkCorrespondentRemove,
handleBulkSelectionReanalyze,
handleBulkTagAddFromDetail,
handleBulkTagRemoveFromDetail,
handleDeleteSelection,
handleDocumentDragEnd,
handleDocumentDragStart,
handleDocumentTagDrop,
handleDocumentTitleUpdate,
handleDocumentsSortDirectionToggle,
handleDocumentsSortFieldChange,
handleDocumentsViewModeChange,
handleEntryPointerCore,
handleEntrySelection,
handleFolderDragEnd,
handleFolderDragStart,
handleFolderRename,
inspectDocument,
isFilterActive,
moveDocumentsToFolder,
openDocumentPreview,
refreshCurrentFolder,
searchIncludeDescendants,
searchLoading,
searchResults,
selectedDocumentIds,
selectedEntries,
selectedFolderIds,
selectFolder,
setFocusedRowKey,
tagLookupById,
tags,
toggleCorrespondentFilter,
toggleSearchIncludeDescendants,
toggleTagFilter,
ensureAssetUrl,
getDocumentAsset,
folderOptions,
],
);
};
export default useDocumentsPanelProps;
@@ -1,5 +1,41 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
interface FolderEntry {
id: string | number;
[key: string]: unknown;
}
interface DocumentEntry {
id: string | number;
[key: string]: unknown;
}
interface NavigableRow {
key: string;
type: 'folder' | 'document';
id: string | number;
}
interface UseDocumentsSelectionOptions {
showingSearchResults: boolean;
currentSubfolders: FolderEntry[];
visibleDocuments: DocumentEntry[];
resolveFolderRowKey: (id: string | number) => string | null | undefined;
resolveDocumentRowKey: (id: string | number) => string | null | undefined;
configureSelectionEnvironment: (config: { visibleRowKeySet: Set<string>; navigableRowKeys: string[] }) => void;
visibleRowKeySet: Set<string>;
selectedEntries: string[];
selectionAnchorRef: { current: string | null };
promoteSelectionOrderRaw: (id: string | number) => void;
setFocusedDocumentId: (id: string | number | null) => void;
setActivePreviewId: (id: string | number | null) => void;
clearSelection: () => void;
focusedDocumentId: string | number | null;
setFocusedRowKey: (value: string | null | ((current: string | null) => string | null)) => void;
focusedRowKey: string | null;
isFolderRowKey: (key: string | null) => boolean;
}
const useDocumentsSelection = ({
showingSearchResults,
currentSubfolders,
@@ -18,9 +54,9 @@ const useDocumentsSelection = ({
setFocusedRowKey,
focusedRowKey,
isFolderRowKey,
}) => {
const navigableRows = useMemo(() => {
const entries = [];
}: UseDocumentsSelectionOptions) => {
const navigableRows = useMemo<NavigableRow[]>(() => {
const entries: NavigableRow[] = [];
if (!showingSearchResults) {
currentSubfolders.forEach((folder) => {
const key = resolveFolderRowKey(folder.id);
@@ -51,7 +87,7 @@ const useDocumentsSelection = ({
}, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]);
const promoteSelectionOrder = useCallback(
(docId) => {
(docId: string | number | null) => {
if (!docId) return;
promoteSelectionOrderRaw(docId);
const rowKey = resolveDocumentRowKey(docId);
@@ -68,7 +104,7 @@ const useDocumentsSelection = ({
clearSelection();
}, [clearSelection]);
const prevFocusedDocIdRef = useRef(focusedDocumentId);
const prevFocusedDocIdRef = useRef<string | number | null>(focusedDocumentId);
useEffect(() => {
const previous = prevFocusedDocIdRef.current;
if (previous === focusedDocumentId) {
@@ -15,7 +15,11 @@ const EntryType = {
document: 'document',
};
const DocumentsPanel = ({
interface DocumentsPanelProps {
[key: string]: any;
}
const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
currentFolderName,
breadcrumbs,
onRefresh,
@@ -1,4 +1,4 @@
import React from 'react';
import type { JSX } from 'react';
import {
ViewListIcon,
ViewGridIcon,
@@ -12,18 +12,34 @@ import {
} from '../../ui/icons';
import SortFieldQuickMenu from './SortFieldQuickMenu';
type ViewMode = 'list' | 'grid' | 'desk' | (string & {});
type SortDirection = 'asc' | 'desc' | (string & {});
interface DocumentsTableHeaderActionOptions {
viewMode?: ViewMode;
onViewModeChange?: (mode: ViewMode) => void;
onRefresh: () => void;
sortField?: string;
onSortFieldChange?: (field: string) => void;
sortDirection?: SortDirection;
onSortDirectionToggle?: () => void;
isFilterActive?: boolean;
includeDescendants?: boolean;
onToggleIncludeDescendants?: () => void;
}
export const createDocumentsTableHeaderActions = ({
viewMode,
viewMode = 'list',
onViewModeChange,
onRefresh,
sortField = 'title',
onSortFieldChange = null,
onSortFieldChange,
sortDirection = 'asc',
onSortDirectionToggle = null,
onSortDirectionToggle,
isFilterActive = false,
includeDescendants = true,
onToggleIncludeDescendants = null,
}) => {
onToggleIncludeDescendants,
}: DocumentsTableHeaderActionOptions): JSX.Element => {
const isListView = viewMode === 'list';
const isGridView = viewMode === 'grid';
const isDeskView = viewMode === 'desk';
@@ -14,7 +14,12 @@ const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((acc, option) => {
return next;
}, {});
const SortFieldQuickMenu = ({ sortField, onChange }) => {
interface SortFieldQuickMenuProps {
sortField: string;
onChange?: (value: string) => void;
}
const SortFieldQuickMenu: React.FC<SortFieldQuickMenuProps> = ({ sortField, onChange }) => {
const currentOption = useMemo(
() => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0],
[sortField],
@@ -26,8 +31,8 @@ const SortFieldQuickMenu = ({ sortField, onChange }) => {
);
const handleSelect = useCallback(
(value, option) => {
if (typeof onChange !== 'function') {
(value: string, option?: { id?: string; original?: { id?: string } }) => {
if (!onChange) {
return;
}
const nextValue = option?.id || option?.original?.id || value;
@@ -1,10 +1,26 @@
import React from 'react';
import React, { ReactNode } from 'react';
import DocumentViewerPanel from '../../preview/DocumentViewerPanel';
import SelectionFloatingActions from '../SelectionFloatingActions';
import createWorkspaceSurfaceConfig from '../workspaceHeader';
import DocumentsPanel from './DocumentsPanel';
import { createDocumentsTableHeaderActions } from './DocumentsToolbar';
interface Breadcrumb {
id?: string | number;
name?: string;
label?: string;
title?: string;
}
interface CreateDocumentsSurfaceArgs {
tableProps: Record<string, any>;
parentBreadcrumb?: Breadcrumb | null;
onNavigateParent?: () => void;
renderSidebarToggle?: () => ReactNode;
detailProps?: Record<string, any> | null;
detailOpen?: boolean;
}
const createDocumentsSurface = ({
tableProps,
parentBreadcrumb,
@@ -12,7 +28,7 @@ const createDocumentsSurface = ({
renderSidebarToggle,
detailProps,
detailOpen = false,
}) => {
}: CreateDocumentsSurfaceArgs) => {
const {
currentFolderName,
breadcrumbs,
@@ -1,7 +1,18 @@
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const TAG_TEXT_MIME_TYPE = 'text/plain';
const serializePayload = (payload) => {
interface TagPayload {
id: string | number;
label: string;
sourceDocId: string | number | null;
}
interface TagLike {
id?: string | number;
label?: string | null;
}
const serializePayload = (payload: TagPayload): string | null => {
try {
return JSON.stringify(payload);
} catch (error) {
@@ -10,8 +21,8 @@ const serializePayload = (payload) => {
}
};
export const createTagTransferPayload = (tag, sourceDocId = null) => {
if (!tag || !tag.id) {
export const createTagTransferPayload = (tag: TagLike | null | undefined, sourceDocId: string | number | null = null): TagPayload | null => {
if (!tag || tag.id == null) {
return null;
}
@@ -22,7 +33,7 @@ export const createTagTransferPayload = (tag, sourceDocId = null) => {
};
};
export const writeTagTransferData = (dataTransfer, tag, sourceDocId = null) => {
export const writeTagTransferData = (dataTransfer: DataTransfer | null, tag: TagLike, sourceDocId: string | number | null = null): void => {
if (!dataTransfer) {
return;
}
@@ -38,8 +49,7 @@ export const writeTagTransferData = (dataTransfer, tag, sourceDocId = null) => {
}
try {
dataTransfer.setData(TAG_MIME_TYPES[0], serialized);
dataTransfer.setData(TAG_MIME_TYPES[1], serialized);
TAG_MIME_TYPES.forEach((type) => dataTransfer.setData(type, serialized));
if (payload.label) {
dataTransfer.setData(TAG_TEXT_MIME_TYPE, payload.label);
}
@@ -48,7 +58,7 @@ export const writeTagTransferData = (dataTransfer, tag, sourceDocId = null) => {
}
};
export const readTagTransferData = (dataTransfer) => {
export const readTagTransferData = (dataTransfer: DataTransfer | null | undefined): string | null => {
if (!dataTransfer) {
return null;
}
@@ -67,15 +77,17 @@ export const readTagTransferData = (dataTransfer) => {
return null;
};
export const parseTagTransferPayload = (input) => {
type DragEventLike = DragEvent | { dataTransfer?: DataTransfer | null };
export const parseTagTransferPayload = (input: DataTransfer | DragEventLike | null): TagPayload | null => {
const dataTransfer = input && 'dataTransfer' in input ? input.dataTransfer : input;
const raw = readTagTransferData(dataTransfer);
const raw = readTagTransferData(dataTransfer || null);
if (!raw) {
return null;
}
try {
return JSON.parse(raw);
return JSON.parse(raw) as TagPayload;
} catch (error) {
console.warn('[tagTransfer] Failed to parse drag payload', error);
}
@@ -83,7 +95,7 @@ export const parseTagTransferPayload = (input) => {
return null;
};
export const isTagTransferEvent = (event) => {
export const isTagTransferEvent = (event?: DragEventLike | null): boolean => {
const types = event?.dataTransfer?.types;
if (!types) {
return false;
-65
View File
@@ -1,65 +0,0 @@
import { useCallback } from 'react';
export const isPointerModifierEvent = (event) =>
Boolean(event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey));
export const isPrimaryPointerEvent = (event) => {
if (!event) {
return true;
}
if (typeof event.button === 'number' && event.button !== 0) {
return false;
}
const type = event?.type?.toLowerCase?.() ?? '';
return type === 'click' || type === 'pointerdown' || type === 'pointerup';
};
const EntryType = Object.freeze({
document: 'document',
folder: 'folder',
});
export const useEntryPointer = ({
resolveDocumentRowKey,
resolveFolderRowKey,
onSelectEntry,
onInspectDocument,
}) =>
useCallback(
(entry, event) => {
if (!entry || !entry.id) {
return;
}
const { type, id } = entry;
if (type !== EntryType.document && type !== EntryType.folder) {
return;
}
const rowKey = entry.key
|| (type === EntryType.document ? resolveDocumentRowKey?.(id) : resolveFolderRowKey?.(id));
if (!rowKey) {
return;
}
const modifierClick = isPointerModifierEvent(event);
const primaryClick = isPrimaryPointerEvent(event);
const metadata = { modifierClick, primaryClick, rowKey, type, id };
if (typeof onSelectEntry === 'function') {
onSelectEntry(entry, event, metadata);
}
if (
type === EntryType.document
&& !modifierClick
&& primaryClick
&& typeof onInspectDocument === 'function'
) {
onInspectDocument(id, metadata);
}
},
[resolveDocumentRowKey, resolveFolderRowKey, onSelectEntry, onInspectDocument],
);
export default useEntryPointer;
+79
View File
@@ -0,0 +1,79 @@
import { useCallback } from 'react';
export type PointerEventLike = MouseEvent | PointerEvent;
export const isPointerModifierEvent = (event?: PointerEventLike | null): boolean =>
Boolean(event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey));
export const isPrimaryPointerEvent = (event?: PointerEventLike | null): boolean => {
if (!event) {
return true;
}
if (typeof event.button === 'number' && event.button !== 0) {
return false;
}
const type = event?.type?.toLowerCase?.() ?? '';
return type === 'click' || type === 'pointerdown' || type === 'pointerup';
};
export type EntryType = 'document' | 'folder';
export interface WorkspaceEntry {
id: string | number;
key?: string;
type: EntryType;
[key: string]: unknown;
}
interface UseEntryPointerOptions {
resolveDocumentRowKey?: (id: string | number) => string | null | undefined;
resolveFolderRowKey?: (id: string | number) => string | null | undefined;
onSelectEntry?: (entry: WorkspaceEntry, event?: PointerEventLike | null, metadata?: EntryPointerMetadata) => void;
onInspectDocument?: (id: string | number, metadata?: EntryPointerMetadata) => void;
}
export interface EntryPointerMetadata {
modifierClick: boolean;
primaryClick: boolean;
rowKey: string;
type: EntryType;
id: string | number;
}
export const useEntryPointer = ({
resolveDocumentRowKey,
resolveFolderRowKey,
onSelectEntry,
onInspectDocument,
}: UseEntryPointerOptions) =>
useCallback(
(entry?: WorkspaceEntry | null, event?: PointerEventLike | null) => {
if (!entry || !entry.id) {
return;
}
const { type, id } = entry;
if (type !== 'document' && type !== 'folder') {
return;
}
const rowKey = entry.key
|| (type === 'document' ? resolveDocumentRowKey?.(id) : resolveFolderRowKey?.(id));
if (!rowKey) {
return;
}
const modifierClick = isPointerModifierEvent(event);
const primaryClick = isPrimaryPointerEvent(event);
const metadata: EntryPointerMetadata = { modifierClick, primaryClick, rowKey, type, id };
onSelectEntry?.(entry, event, metadata);
if (type === 'document' && !modifierClick && primaryClick) {
onInspectDocument?.(id, metadata);
}
},
[resolveDocumentRowKey, resolveFolderRowKey, onSelectEntry, onInspectDocument],
);
export default useEntryPointer;
-132
View File
@@ -1,132 +0,0 @@
import { useCallback, useRef, useState } from 'react';
const focusInput = (node) => {
if (!node) {
return;
}
const applyFocus = () => {
node.focus();
node.select?.();
};
const raf = window.requestAnimationFrame;
if (raf) {
raf(applyFocus);
return;
}
applyFocus();
};
const identity = (value) => value;
const useInlineRename = (
onRename,
{
getCurrentValue = identity,
getEntityId = (entity) => entity?.id ?? null,
} = {},
) => {
const [editingId, setEditingId] = useState(null);
const [draftValue, setDraftValue] = useState('');
const [savingId, setSavingId] = useState(null);
const inputRef = useRef(null);
const resetState = useCallback(() => {
setEditingId(null);
setDraftValue('');
setSavingId(null);
inputRef.current = null;
}, []);
const beginEditing = useCallback(
(entity, event) => {
if (!entity) {
return;
}
if (event) {
event.preventDefault();
event.stopPropagation();
}
const entityId = getEntityId(entity);
if (!entityId) {
return;
}
const currentValue = getCurrentValue(entity) ?? '';
setEditingId(entityId);
setDraftValue(currentValue);
setSavingId(null);
},
[getCurrentValue, getEntityId],
);
const cancelEditing = useCallback(
(event) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
resetState();
},
[resetState],
);
const submitEditing = useCallback(
async (entity) => {
if (!entity) {
return false;
}
const entityId = getEntityId(entity);
if (!entityId || editingId !== entityId) {
return false;
}
const trimmed = draftValue.trim();
const currentValue = getCurrentValue(entity) ?? '';
if (!trimmed || trimmed === currentValue) {
resetState();
return true;
}
if (typeof onRename !== 'function') {
resetState();
return true;
}
setSavingId(entityId);
try {
const result = await onRename(entityId, trimmed);
if (result === false) {
return false;
}
resetState();
return true;
} catch {
return false;
} finally {
setSavingId((current) => (current === entityId ? null : current));
}
},
[draftValue, editingId, getCurrentValue, getEntityId, onRename, resetState],
);
const attachInputRef = useCallback(
(node) => {
if (node) {
inputRef.current = node;
focusInput(node);
} else if (inputRef.current) {
inputRef.current = null;
}
},
[],
);
return {
editingId,
draftValue,
setDraftValue,
beginEditing,
cancelEditing,
submitEditing,
savingId,
attachInputRef,
};
};
export default useInlineRename;
+166
View File
@@ -0,0 +1,166 @@
import {
Dispatch,
SetStateAction,
SyntheticEvent,
useCallback,
useRef,
useState,
} from 'react';
type FocusableInput = (HTMLInputElement | HTMLTextAreaElement) & {
select?: () => void;
};
type InlineRenameOptions<TEntity> = {
getCurrentValue?: (entity: TEntity) => string | null | undefined;
getEntityId?: (entity: TEntity) => string | number | null | undefined;
};
type InlineRenameHandler = (
id: string | number,
value: string,
) => boolean | void | Promise<boolean | void>;
type InlineRenameReturn<TEntity> = {
editingId: string | number | null;
draftValue: string;
setDraftValue: Dispatch<SetStateAction<string>>;
beginEditing: (entity: TEntity | null | undefined, event?: SyntheticEvent | Event) => void;
cancelEditing: (event?: SyntheticEvent | Event) => void;
submitEditing: (entity: TEntity | null | undefined) => Promise<boolean>;
savingId: string | number | null;
attachInputRef: (node: FocusableInput | null) => void;
};
const focusInput = (node: FocusableInput | null) => {
if (!node) {
return;
}
const applyFocus = () => {
node.focus();
node.select?.();
};
const raf = window.requestAnimationFrame;
if (raf) {
raf(applyFocus);
return;
}
applyFocus();
};
const identity = (value: unknown) => value as string;
const defaultGetEntityId = <T,>(entity: T) =>
(entity as { id?: string | number } | null | undefined)?.id ?? null;
const useInlineRename = <TEntity,>(
onRename?: InlineRenameHandler,
{
getCurrentValue = identity as (entity: TEntity) => string | null | undefined,
getEntityId = defaultGetEntityId as (
entity: TEntity,
) => string | number | null | undefined,
}: InlineRenameOptions<TEntity> = {},
): InlineRenameReturn<TEntity> => {
const [editingId, setEditingId] = useState<string | number | null>(null);
const [draftValue, setDraftValue] = useState('');
const [savingId, setSavingId] = useState<string | number | null>(null);
const inputRef = useRef<FocusableInput | null>(null);
const resetState = useCallback(() => {
setEditingId(null);
setDraftValue('');
setSavingId(null);
inputRef.current = null;
}, []);
const beginEditing = useCallback(
(entity: TEntity | null | undefined, event?: SyntheticEvent | Event) => {
if (!entity) {
return;
}
if (event) {
event.preventDefault();
event.stopPropagation();
}
const entityId = getEntityId(entity);
if (!entityId) {
return;
}
const currentValue = getCurrentValue(entity) ?? '';
setEditingId(entityId);
setDraftValue(currentValue);
setSavingId(null);
},
[getCurrentValue, getEntityId],
);
const cancelEditing = useCallback(
(event?: SyntheticEvent | Event) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
resetState();
},
[resetState],
);
const submitEditing = useCallback(
async (entity: TEntity | null | undefined) => {
if (!entity) {
return false;
}
const entityId = getEntityId(entity);
if (!entityId || editingId !== entityId) {
return false;
}
const trimmed = draftValue.trim();
const currentValue = getCurrentValue(entity) ?? '';
if (!trimmed || trimmed === currentValue) {
resetState();
return true;
}
if (!onRename) {
resetState();
return true;
}
setSavingId(entityId);
try {
const result = await onRename(entityId, trimmed);
if (result === false) {
return false;
}
resetState();
return true;
} catch {
return false;
} finally {
setSavingId((current) => (current === entityId ? null : current));
}
},
[draftValue, editingId, getCurrentValue, getEntityId, onRename, resetState],
);
const attachInputRef = useCallback((node: FocusableInput | null) => {
if (node) {
inputRef.current = node;
focusInput(node);
} else if (inputRef.current) {
inputRef.current = null;
}
}, []);
return {
editingId,
draftValue,
setDraftValue,
beginEditing,
cancelEditing,
submitEditing,
savingId,
attachInputRef,
};
};
export default useInlineRename;
-41
View File
@@ -1,41 +0,0 @@
import React from 'react';
export const createWorkspaceSurfaceConfig = ({
title,
subtitle = null,
sidebarToggle = null,
actions = null,
breadcrumbs = null,
selectionLabel = null,
floatingActions = null,
content = null,
detail = null,
variant = 'documents',
key = 'documents',
}) => {
const leading = sidebarToggle
? (
<>
{sidebarToggle}
</>
)
: null;
return {
key,
variant,
header: {
title,
subtitle,
leading,
actions,
breadcrumbs,
selectionLabel,
floatingActions,
},
content,
detail,
};
};
export default createWorkspaceSurfaceConfig;
@@ -0,0 +1,67 @@
import { ReactNode } from 'react';
interface WorkspaceSurfaceHeaderConfig {
title: ReactNode;
subtitle?: ReactNode;
leading?: ReactNode;
actions?: ReactNode;
breadcrumbs?: ReactNode;
selectionLabel?: ReactNode;
floatingActions?: ReactNode;
}
interface WorkspaceSurfaceConfig {
key: string;
variant: string;
header: WorkspaceSurfaceHeaderConfig;
content?: ReactNode;
detail?: ReactNode;
}
interface CreateWorkspaceSurfaceConfigArgs {
title: ReactNode;
subtitle?: ReactNode;
sidebarToggle?: ReactNode;
actions?: ReactNode;
breadcrumbs?: ReactNode;
selectionLabel?: ReactNode;
floatingActions?: ReactNode;
content?: ReactNode;
detail?: ReactNode;
variant?: string;
key?: string;
}
export const createWorkspaceSurfaceConfig = ({
title,
subtitle = null,
sidebarToggle = null,
actions = null,
breadcrumbs = null,
selectionLabel = null,
floatingActions = null,
content = null,
detail = null,
variant = 'documents',
key = 'documents',
}: CreateWorkspaceSurfaceConfigArgs): WorkspaceSurfaceConfig => {
const leading = sidebarToggle ? <>{sidebarToggle}</> : null;
return {
key,
variant,
header: {
title,
subtitle,
leading,
actions,
breadcrumbs,
selectionLabel,
floatingActions,
},
content,
detail,
};
};
export default createWorkspaceSurfaceConfig;