Files
papercrate/frontend/src/viewer/components/DocumentInfoPanel.tsx
T

501 lines
15 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import DocumentSummarySection, { DocumentSummarySectionProps } from './DocumentSummarySection';
import { describeDocumentSummary, extractDocumentMetadataPayload, type DocumentSummaryRow } from '../logic/documentSummary';
import type { Tag, Correspondent } from '../../types/documents';
import type { TagId, Identifier } from '../../types/identifiers';
type PanelTab = { id: string; label: string; render: (context?: Record<string, unknown>) => 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 };
export interface DocumentInfoPanelProps {
tagLookupById?: Map<TagId, Tag>;
correspondentLookupById?: Map<Identifier, Correspondent>;
summaryProps?: Omit<DocumentSummarySectionProps, 'document' | 'layout'>;
metadataItems?: DocumentSummaryRow[];
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 | 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,
tagLookupById,
correspondentLookupById,
summaryProps = {},
metadataItems: metadataItemsProp,
metadataPayload: metadataPayloadProp,
metadataTabLabel = 'Metadata',
detailsTabLabel = 'Details',
contentConfig: contentConfigProp = null,
activeTab: controlledActiveTab,
onTabChange,
defaultTabId = 'details',
resetKey = null,
classNamePrefix = 'document-info',
hideTabNavWhenSingle = true,
summaryPlacement = 'inline',
summaryTabLabel = 'Summary',
summaryTabId = 'summary',
leadingTabs = [],
trailingTabs = [],
tabsPlacement = 'top',
summaryLayout = 'default',
}) => {
const base = classNamePrefix;
const metadataItems = useMemo(() => {
if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) {
return metadataItemsProp;
}
return describeDocumentSummary(document, { tagLookupById, correspondentLookupById });
}, [metadataItemsProp, document, tagLookupById, correspondentLookupById]);
const metadataPayload = useMemo(() => {
if (metadataPayloadProp !== undefined) {
return metadataPayloadProp;
}
return extractDocumentMetadataPayload(document);
}, [metadataPayloadProp, document]);
const contentConfig = contentConfigProp || null;
const contentEnabled = Boolean(contentConfig && (contentConfig.enabled ?? true));
const loadContent = contentConfig?.loadContent ?? null;
const showContentTab = Boolean(contentConfig && (contentConfig.forceDisplay ?? contentEnabled));
const [contentState, setContentState] = useState<ContentState | null>(() => {
if (!contentConfig) {
return null;
}
if (!contentEnabled || !loadContent) {
return { status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null };
}
return { status: 'idle', data: null, error: null };
});
const renderSummarySection = useCallback(() => (
<DocumentSummarySection
document={document}
layout={summaryLayout}
correspondentLookupById={correspondentLookupById}
{...summaryProps}
/>
), [document, summaryLayout, summaryProps, correspondentLookupById]);
const renderDetailsSection = useCallback(() => (
<section className={`${base}__section`}>
{metadataItems.length ? (
<dl className={`${base}__section-list`}>
{metadataItems.map(({ key, label, value }) => (
<div className={`${base}__section-item`} key={key || label}>
<dt>{label}</dt>
<dd>{value || '—'}</dd>
</div>
))}
</dl>
) : (
<p className={`${base}__section-placeholder`}>No details available.</p>
)}
</section>
), [base, metadataItems]);
const summaryInline = summaryPlacement !== 'tabs';
const summaryTab = useMemo(() => {
if (summaryPlacement !== 'tabs') {
return null;
}
return {
id: summaryTabId,
label: summaryTabLabel,
render: () => (
<div className={`${base}__summary-tab-content`}>
{renderSummarySection()}
</div>
),
};
}, [summaryPlacement, summaryTabId, summaryTabLabel, base, renderSummarySection]);
const normalizedLeadingTabs = useMemo(
() => (Array.isArray(leadingTabs)
? leadingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
: []),
[leadingTabs],
);
const normalizedTrailingTabs = useMemo(
() => (Array.isArray(trailingTabs)
? trailingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
: []),
[trailingTabs],
);
const summaryNode = summaryInline
? (
<>
{renderSummarySection()}
{renderDetailsSection()}
</>
)
: null;
const visibleTabs = useMemo(() => {
const tabsList: PanelTab[] = [];
if (normalizedLeadingTabs.length) {
tabsList.push(...normalizedLeadingTabs);
}
if (summaryTab) {
tabsList.push(summaryTab);
}
if (summaryPlacement !== 'tabs') {
tabsList.push({
id: 'details',
label: detailsTabLabel,
render: () => renderDetailsSection(),
});
}
if (showContentTab && contentConfig) {
tabsList.push({
id: contentConfig.id || 'content',
label: contentConfig.label || 'Content',
render: () => {
const messageClass = `${base}__message`;
const errorClass = `${base}__message ${base}__message--error`;
const objectClass = `${base}__object ${base}__object--text-content`;
if (!contentEnabled || !contentConfig.loadContent) {
return (
<div className={messageClass}>
{contentConfig.unavailableMessage || 'Content not available.'}
</div>
);
}
if (!contentState) {
return (
<div className={messageClass}>
{contentConfig.emptyMessage || 'No content available.'}
</div>
);
}
switch (contentState.status) {
case 'loading':
return (
<div className={messageClass}>
{contentConfig.loadingMessage || 'Loading content…'}
</div>
);
case 'error': {
const errorMessage =
contentConfig.errorMessage
|| (contentState.error instanceof Error ? contentState.error.message : null)
|| 'Failed to load content.';
return <div className={errorClass}>{errorMessage}</div>;
}
case 'empty':
return (
<div className={messageClass}>
{contentConfig.emptyMessage || 'No content available.'}
</div>
);
case 'loaded':
return (
<pre className={objectClass}>{contentState.data}</pre>
);
case 'unavailable':
return (
<div className={messageClass}>
{contentConfig.unavailableMessage || 'Content not available.'}
</div>
);
default:
return (
<div className={messageClass}>
{contentConfig.emptyMessage || 'No content available.'}
</div>
);
}
},
});
}
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>
),
});
}
if (normalizedTrailingTabs.length) {
tabsList.push(...normalizedTrailingTabs);
}
return tabsList;
}, [
base,
detailsTabLabel,
contentConfig,
contentEnabled,
contentState,
metadataPayload,
metadataTabLabel,
showContentTab,
summaryTab,
normalizedLeadingTabs,
normalizedTrailingTabs,
summaryPlacement,
renderDetailsSection,
]);
const fallbackTabId = useMemo(() => {
if (!visibleTabs.length) {
return null;
}
if (defaultTabId && visibleTabs.some((tab) => tab.id === defaultTabId)) {
return defaultTabId;
}
return visibleTabs[0].id;
}, [visibleTabs, defaultTabId]);
const renderTabContent = (tab?: PanelTab | null, context: Record<string, unknown> = {}) => {
if (!tab) {
return null;
}
if (!tab.render) {
return null;
}
return tab.render(context);
};
const isControlled = controlledActiveTab !== undefined && controlledActiveTab !== null;
const [uncontrolledTab, setUncontrolledTab] = useState(
isControlled ? controlledActiveTab : fallbackTabId,
);
useEffect(() => {
if (!isControlled) {
setUncontrolledTab(fallbackTabId);
}
}, [fallbackTabId, isControlled]);
useEffect(() => {
if (isControlled && controlledActiveTab && !visibleTabs.some((tab) => tab.id === controlledActiveTab)) {
const nextTab = fallbackTabId;
if (nextTab && nextTab !== controlledActiveTab) {
onTabChange?.(nextTab);
}
}
}, [isControlled, controlledActiveTab, visibleTabs, fallbackTabId, onTabChange]);
const activeTabId = isControlled ? controlledActiveTab : uncontrolledTab;
// Track previous ID/key to avoid unnecessary resets on prop reference changes
const prevDocIdRef = React.useRef(document?.id);
const prevResetKeyRef = React.useRef(resetKey);
const activeControllerRef = React.useRef<AbortController | null>(null);
// Reset content state when document changes or contentConfig becomes available
useEffect(() => {
const docIdChanged = prevDocIdRef.current !== document?.id;
const resetKeyChanged = prevResetKeyRef.current !== resetKey;
// Check if we need to initialize state (e.g. contentConfig was loaded asynchronously)
const needsInit = contentConfig && !contentState;
if (docIdChanged || resetKeyChanged || needsInit) {
prevDocIdRef.current = document?.id;
prevResetKeyRef.current = resetKey;
if (!contentConfig || !showContentTab) {
setContentState(null);
return;
}
if (!contentEnabled || !loadContent) {
setContentState({ status: contentEnabled ? 'idle' : 'unavailable', data: null, error: null });
return;
}
// Reset to idle so the loading effect can trigger if needed
setContentState({ status: 'idle', data: null, error: null });
}
}, [
contentConfig,
showContentTab,
contentEnabled,
loadContent,
document?.id,
resetKey,
contentState,
]);
// Cleanup effect: aborts when inputs change or component unmounts
useEffect(() => {
return () => {
activeControllerRef.current?.abort();
contentConfig?.onCancel?.();
};
}, [activeTabId, contentConfig, loadContent, contentEnabled, document?.id]);
// Loading effect: triggers load when status is idle
useEffect(() => {
const contentTabId = contentConfig?.id || 'content';
const isActive = activeTabId === contentTabId;
if (!isActive || !contentConfig || !loadContent || !contentEnabled) {
return;
}
if (contentState?.status !== 'idle') {
return;
}
const controller = new AbortController();
activeControllerRef.current = controller;
setContentState({ status: 'loading', data: null, error: null });
Promise.resolve(loadContent({ signal: controller.signal }))
.then((result) => {
if (controller.signal.aborted) {
return;
}
if (result && result.length) {
setContentState({ status: 'loaded', data: result, error: null });
} else {
setContentState({ status: 'empty', data: '', error: null });
}
})
.catch((error) => {
if (controller.signal.aborted || error?.name === 'AbortError') {
return;
}
setContentState({
status: 'error',
data: null,
error,
});
});
}, [
activeTabId,
contentConfig,
loadContent,
contentEnabled,
contentState?.status,
document?.id,
]);
const handleTabSelect = (tabId) => {
if (!visibleTabs.some((tab) => tab.id === tabId)) {
return;
}
if (!isControlled) {
setUncontrolledTab(tabId);
}
if (tabId !== activeTabId) {
onTabChange?.(tabId);
}
};
const singleTab = visibleTabs.length === 1 ? visibleTabs[0] : null;
const shouldHideNav = hideTabNavWhenSingle && singleTab;
const tabNav = (
<div className={`${base}__tabs`} role="tablist" aria-label="Document details">
{visibleTabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={tab.id === activeTabId}
className={`${base}__tab${tab.id === activeTabId ? ' is-active' : ''}`}
onClick={() => handleTabSelect(tab.id)}
>
{tab.label}
</button>
))}
</div>
);
const tabPanels = (
<div className={`${base}__tabpanes`}>
{visibleTabs.map((tab) => (
tab.id === activeTabId ? (
<div key={tab.id} role="tabpanel" className={`${base}__tabpanel`}>
{renderTabContent(tab, { document })}
</div>
) : null
))}
</div>
);
const tabsWrapperClass = `${base}__tabs-wrapper${tabsPlacement === 'bottom' ? ` ${base}__tabs-wrapper--bottom` : ''}`;
return (
<>
{summaryNode}
{shouldHideNav ? (
<div className={`${base}__tabpanes ${base}__tabpanes--single`}>
<div className={`${base}__tabpanel`}>
{renderTabContent(singleTab, { document })}
</div>
</div>
) : (
<div className={tabsWrapperClass}>
{tabsPlacement !== 'bottom' ? tabNav : null}
{tabsPlacement === 'bottom' ? tabPanels : null}
{tabsPlacement === 'bottom' ? tabNav : null}
{tabsPlacement !== 'bottom' ? tabPanels : null}
</div>
)}
</>
);
};
export default DocumentInfoPanel;