document viewer

This commit is contained in:
2025-11-12 02:24:46 +01:00
parent 762e4ee315
commit 44c982f015
7 changed files with 546 additions and 192 deletions
+147 -62
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import DocumentSummarySection from './DocumentSummarySection';
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
@@ -16,6 +16,13 @@ const DocumentInfoPanel = ({
resetKey = null,
classNamePrefix = 'document-info',
hideTabNavWhenSingle = true,
summaryPlacement = 'inline',
summaryTabLabel = 'Summary',
summaryTabId = 'summary',
leadingTabs = [],
trailingTabs = [],
tabsPlacement = 'top',
summaryLayout = 'default',
}) => {
const base = classNamePrefix;
@@ -92,29 +99,90 @@ const DocumentInfoPanel = ({
};
}, [contentConfig, contentEnabled, showContentTab, document?.id, resetKey]);
const renderSummarySection = useCallback(() => (
<DocumentSummarySection
document={document}
detailItems={metadataItems}
layout={summaryLayout}
{...summaryProps}
/>
), [document, summaryLayout, summaryProps, metadataItems]);
const renderDetailsSection = useCallback(() => (
<section className={`${base}__section`}>
{metadataItems.length ? (
<dl className={`${base}__section-list`}>
{metadataItems.map(({ label, value }) => (
<div className={`${base}__section-item`} 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 && tab.id && tab.label)
: []),
[leadingTabs],
);
const normalizedTrailingTabs = useMemo(
() => (Array.isArray(trailingTabs)
? trailingTabs.filter((tab) => tab && tab.id && tab.label)
: []),
[trailingTabs],
);
const summaryNode = summaryInline
? (
<>
{renderSummarySection()}
{renderDetailsSection()}
</>
)
: null;
const visibleTabs = useMemo(() => {
const tabsList = [];
tabsList.push({
id: 'details',
label: detailsTabLabel,
render: () => (
<section className={`${base}__section`}>
{metadataItems.length ? (
<dl className={`${base}__section-list`}>
{metadataItems.map(({ label, value }) => (
<div className={`${base}__section-item`} key={label}>
<dt>{label}</dt>
<dd>{value || '—'}</dd>
</div>
))}
</dl>
) : (
<p className={`${base}__section-placeholder`}>No details available.</p>
)}
</section>
),
});
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({
@@ -183,30 +251,38 @@ 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) {
tabsList.push(...normalizedTrailingTabs);
}
return tabsList;
}, [
base,
detailsTabLabel,
metadataItems,
contentConfig,
contentEnabled,
contentState,
metadataPayload,
metadataTabLabel,
showContentTab,
summaryTab,
normalizedLeadingTabs,
normalizedTrailingTabs,
summaryPlacement,
renderDetailsSection,
]);
const fallbackTabId = useMemo(() => {
@@ -242,7 +318,7 @@ const DocumentInfoPanel = ({
if (!isControlled) {
setUncontrolledTab(fallbackTabId);
}
}, [fallbackTabId, resetKey, isControlled]);
}, [fallbackTabId, isControlled]);
useEffect(() => {
if (isControlled && controlledActiveTab && !visibleTabs.some((tab) => tab.id === controlledActiveTab)) {
@@ -270,12 +346,40 @@ const DocumentInfoPanel = ({
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 (
<>
<DocumentSummarySection
document={document}
{...summaryProps}
/>
{summaryNode}
{shouldHideNav ? (
<div className={`${base}__tabpanes ${base}__tabpanes--single`}>
<div className={`${base}__tabpanel`}>
@@ -283,30 +387,11 @@ const DocumentInfoPanel = ({
</div>
</div>
) : (
<div className={`${base}__tabs-wrapper`}>
<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>
<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>
<div className={tabsWrapperClass}>
{tabsPlacement !== 'bottom' ? tabNav : null}
{tabsPlacement === 'bottom' ? tabPanels : null}
{tabsPlacement === 'bottom' ? tabNav : null}
{tabsPlacement !== 'bottom' ? tabPanels : null}
</div>
)}
</>
+213 -89
View File
@@ -348,8 +348,11 @@ const DocumentSummarySection = ({
onCorrespondentAdd,
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued
onUpdateIssued,
layout = 'default',
detailItems = [],
}) => {
const isCompactLayout = layout === 'compact';
const summary = useMemo(() => {
if (!document) {
return {
@@ -507,6 +510,204 @@ const DocumentSummarySection = ({
return null;
}
const TitleSection = () => (
editableTitle && isTitleEditing ? (
<form className="doc-title-edit" onSubmit={submitTitleEdit}>
<input
value={titleDraft}
onChange={(event) => {
setTitleDraft(event.target.value);
if (titleError) {
setTitleError(null);
}
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancelTitleEdit();
}
}}
aria-label="Document title"
autoFocus
disabled={titleSaving}
/>
<button type="submit" disabled={titleSaving}>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelTitleEdit}
disabled={titleSaving}
>
Cancel
</button>
</form>
) : (
<>
<h3 className="doc-title-row__title">{summary.title}</h3>
{editableTitle ? (
<button
type="button"
className="icon-button"
onClick={startTitleEdit}
aria-label="Edit title"
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
)
);
const issuedDisplay = editableIssued && isIssuedEditing ? (
<form className="doc-issued-edit" onSubmit={submitIssuedEdit}>
<input
type="date"
value={issuedDraft}
onChange={(event) => {
setIssuedDraft(event.target.value);
if (issuedError) {
setIssuedError(null);
}
}}
aria-label="Issued on"
disabled={issuedSaving}
/>
<button type="submit" disabled={issuedSaving}>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelIssuedEdit}
disabled={issuedSaving}
>
Cancel
</button>
</form>
) : (
<>
<span className="detail-meta__value">{issuedDateLabel || 'Not set'}</span>
{editableIssued ? (
<button
type="button"
className="icon-button"
onClick={startIssuedEdit}
aria-label={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
title={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
);
const metaItems = [
{
key: 'issued',
label: 'Issued',
valueContent: issuedDisplay,
error: issuedError,
},
...metaRows.map((row) => ({
key: row.key,
label: row.label,
fallbackValue: row.value,
})),
];
const detailRows = Array.isArray(detailItems)
? detailItems.map((item, index) => ({
key: `detail-${item?.label || index}`,
label: item?.label || '—',
fallbackValue: item?.value,
}))
: [];
const compactRows = [...metaItems, ...detailRows];
const renderTags = () => (
<section className="document-summary__section document-summary__section--tags">
<TagSection
tags={resolvedTags}
onRemove={
onTagRemove
? (tag) => onTagRemove(document.id, tag.id)
: undefined
}
onAdd={
onTagAdd
? ({ value, option }) => onTagAdd(document, value, { option })
: undefined
}
datalistOptions={tagOptions}
className="document-summary__tags"
/>
</section>
);
const renderCorrespondents = () => (
<section className="document-summary__section document-summary__section--correspondents">
<CorrespondentSection
entries={resolvedCorrespondents}
onRemove={
onCorrespondentRemove
? (entry) =>
onCorrespondentRemove({
documentId: document.id,
correspondentId: entry.id,
})
: undefined
}
onAdd={
onCorrespondentAdd
? ({ name, option }) =>
onCorrespondentAdd({
document,
name,
option,
})
: undefined
}
showCount
datalistOptions={correspondentOptions}
className="document-summary__correspondents"
/>
</section>
);
if (isCompactLayout) {
return (
<div className="document-summary document-summary--compact">
<section className="document-summary__section document-summary__title-row">
<TitleSection />
</section>
{titleError ? <div className="status-inline error">{titleError}</div> : null}
{renderTags()}
{renderCorrespondents()}
{compactRows.length ? (
<section className="document-summary__section document-summary__meta document-summary__meta--compact">
<dl className="document-summary__details-list document-summary__details-list--meta">
{compactRows.map((item) => (
<div key={item.key} className="document-summary__details-row">
<dt>{item.label}</dt>
<dd>
{item.valueContent != null && item.valueContent !== ''
? item.valueContent
: item.fallbackValue || '—'}
</dd>
{item.error ? <div className="status-inline error">{item.error}</div> : null}
</div>
))}
</dl>
</section>
) : null}
</div>
);
}
return (
<div className="document-summary">
<div className="doc-title-row">
@@ -552,62 +753,21 @@ const DocumentSummarySection = ({
className="icon-button"
onClick={startTitleEdit}
aria-label="Edit title"
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
)}
</div>
title="Edit title"
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
)}
</div>
</div>
{titleError ? <div className="status-inline error">{titleError}</div> : null}
<div className="detail-meta">
<div className="detail-meta__row">
<span className="detail-meta__label">Issued:</span>
{editableIssued && isIssuedEditing ? (
<form className="doc-issued-edit" onSubmit={submitIssuedEdit}>
<input
type="date"
value={issuedDraft}
onChange={(event) => {
setIssuedDraft(event.target.value);
if (issuedError) {
setIssuedError(null);
}
}}
aria-label="Issued on"
disabled={issuedSaving}
/>
<button type="submit" disabled={issuedSaving}>
Save
</button>
<button
type="button"
className="secondary"
onClick={cancelIssuedEdit}
disabled={issuedSaving}
>
Cancel
</button>
</form>
) : (
<>
<span className="detail-meta__value">{issuedDateLabel || 'Not set'}</span>
{editableIssued ? (
<button
type="button"
className="icon-button"
onClick={startIssuedEdit}
aria-label={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
title={issuedDateLabel ? 'Edit issued date' : 'Set issued date'}
>
<EditIcon className="icon-inline" />
</button>
) : null}
</>
)}
{issuedDisplay}
</div>
{issuedError ? <div className="status-inline error">{issuedError}</div> : null}
@@ -619,45 +779,9 @@ const DocumentSummarySection = ({
))}
</div>
<TagSection
tags={resolvedTags}
onRemove={
onTagRemove
? (tag) => onTagRemove(document.id, tag.id)
: undefined
}
onAdd={
onTagAdd
? ({ value, option }) => onTagAdd(document, value, { option })
: undefined
}
datalistOptions={tagOptions}
/>
{renderTags()}
<CorrespondentSection
entries={resolvedCorrespondents}
onRemove={
onCorrespondentRemove
? (entry) =>
onCorrespondentRemove({
documentId: document.id,
correspondentId: entry.id,
})
: undefined
}
onAdd={
onCorrespondentAdd
? ({ name, option }) =>
onCorrespondentAdd({
document,
name,
option,
})
: undefined
}
showCount
datalistOptions={correspondentOptions}
/>
{renderCorrespondents()}
</div>
);
};