document viewer
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user