This commit is contained in:
2025-11-15 04:11:22 +01:00
parent bd3eab8b40
commit d0379c57ce
47 changed files with 558 additions and 703 deletions
+7 -8
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import DocumentSummarySection, { DocumentSummarySectionProps } from './DocumentSummarySection';
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
import { describeDocumentSummary, extractDocumentMetadataPayload, type DocumentSummaryRow } from './documentSummary';
type PanelTab = { id: string; label: string; render: (context?: Record<string, unknown>) => ReactNode };
@@ -15,8 +15,8 @@ type ContentState =
export interface DocumentInfoPanelProps {
document: DocumentSummarySectionProps['document'];
summaryProps?: Omit<DocumentSummarySectionProps, 'document' | 'detailItems' | 'layout'>;
metadataItems?: Array<{ label: string; value?: string }>;
summaryProps?: Omit<DocumentSummarySectionProps, 'document' | 'layout'>;
metadataItems?: DocumentSummaryRow[];
metadataPayload?: Record<string, unknown>;
metadataTabLabel?: string;
detailsTabLabel?: string;
@@ -76,7 +76,7 @@ const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) {
return metadataItemsProp;
}
return buildDocumentMetadataItems(document);
return describeDocumentSummary(document);
}, [metadataItemsProp, document]);
const metadataPayload = useMemo(() => {
@@ -149,18 +149,17 @@ const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
const renderSummarySection = useCallback(() => (
<DocumentSummarySection
document={document}
detailItems={metadataItems}
layout={summaryLayout}
{...summaryProps}
/>
), [document, summaryLayout, summaryProps, metadataItems]);
), [document, summaryLayout, summaryProps]);
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}>
{metadataItems.map(({ key, label, value }) => (
<div className={`${base}__section-item`} key={key || label}>
<dt>{label}</dt>
<dd>{value || '—'}</dd>
</div>
+167 -268
View File
@@ -1,14 +1,16 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { EditIcon, IconX, PlusIcon } from '../ui/icons';
import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu';
import React, { useCallback, useEffect, useMemo, useState, type ReactNode, type FormEvent } from 'react';
import { EditIcon, IconX, CheckIcon, PlusIcon } from '../ui/icons';
import SelectionAssignmentMenu, {
SelectionAssignmentMenuItem,
type NormalizedSelectionAssignmentItem,
} from './SelectionAssignmentMenu';
import { getTagColorStyle } from '../utils/colors';
import {
formatDate,
toDateInputValue,
toIssuedTimestamp,
} from '../utils/date';
import { describeDocumentSummary } from './documentSummary';
import { describeDocumentSummary, type DocumentSummaryRow } from './documentSummary';
import { isPlainObject } from '../utils/typeGuards';
type Identifier = string | number;
@@ -70,7 +72,14 @@ export interface DocumentSummarySectionProps {
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 }>;
}
interface MetaItem {
key: string;
label: string;
valueContent?: React.ReactNode | null;
fallbackValue?: string | null;
error?: string | null;
}
export const sortCorrespondents = (entries = []) =>
@@ -122,7 +131,7 @@ const resolveOptionName = (source?: QuickAddOption | string | null): string => {
return `${source}`.trim();
};
const normalizeQuickAddOption = (option: QuickAddOption | string | null | undefined): QuickAddEntry | null => {
const normalizeQuickAddOption = (option?: QuickAddOption | string | null): QuickAddEntry | null => {
if (option == null) {
return null;
}
@@ -227,7 +236,7 @@ export const TagSection: React.FC<TagSectionProps> = ({
}, [normalizedOptions, tags]);
const handleAssignmentSelect = useCallback(
(item: SelectionAssignmentMenuItem | null) => {
(item: NormalizedSelectionAssignmentItem) => {
if (!item) {
return;
}
@@ -271,11 +280,7 @@ export const TagSection: React.FC<TagSectionProps> = ({
showCounts={false}
positionStrategy="fixed"
triggerClassName="quick-add__chip quick-add__trigger"
triggerContent={(
<span className="quick-add__chip-label">
<PlusIcon className="icon-inline" aria-hidden="true" /> Add tag
</span>
)}
triggerContent={<PlusIcon className="icon-inline" aria-hidden="true" />}
/>
) : null}
{!tags.length && !showQuickAdd ? <span className="tag-list__empty meta">{emptyMessage}</span> : null}
@@ -356,7 +361,7 @@ export const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
}, [normalizedOptions, entries]);
const handleAssignmentSelect = useCallback(
(item: SelectionAssignmentMenuItem | null) => {
(item: NormalizedSelectionAssignmentItem) => {
if (!onAdd || !item) {
return;
}
@@ -411,11 +416,7 @@ export const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
showCounts={false}
positionStrategy="fixed"
triggerClassName="quick-add__chip quick-add__trigger"
triggerContent={(
<span className="quick-add__chip-label">
<PlusIcon className="icon-inline" aria-hidden="true" /> Add correspondent
</span>
)}
triggerContent={<PlusIcon className="icon-inline" aria-hidden="true" />}
/>
) : null}
</div>
@@ -435,20 +436,9 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
onUpdateTitle,
onUpdateIssued,
layout = 'default',
detailItems = [],
}) => {
const isCompactLayout = layout === 'compact';
const summary = useMemo(() => {
if (!document) {
return {
title: '',
originalName: '',
sizeLabel: '—',
pageCount: null,
};
}
return describeDocumentSummary(document);
}, [document]);
const summaryRows = useMemo(() => describeDocumentSummary(document), [document]);
const issuedDateLabel = useMemo(
() => formatDate(document?.issued_at, { fallback: null }),
[document?.issued_at],
@@ -475,8 +465,8 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
return sortCorrespondents(document?.correspondents || []);
}, [correspondents, document?.correspondents]);
const metaRows = useMemo(() => {
const rows: { key: string; label: string; value: string | null }[] = [];
const extraSummaryRows = useMemo(() => {
const rows: DocumentSummaryRow[] = [];
const currentVersionNumber = document?.current_version?.version_number;
if (Number.isFinite(currentVersionNumber)) {
rows.push({
@@ -485,17 +475,8 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
value: `#${currentVersionNumber}`,
});
}
if (summary.sizeLabel && summary.sizeLabel !== '—') {
rows.push({ key: 'size', label: 'Size', value: summary.sizeLabel });
}
if (Number.isFinite(summary.pageCount)) {
rows.push({ key: 'pages', label: 'Pages', value: String(summary.pageCount) });
}
return rows;
}, [document?.current_version?.version_number, summary]);
}, [document?.current_version?.version_number]);
const [titleDraft, setTitleDraft] = useState('');
const [titleSaving, setTitleSaving] = useState(false);
@@ -595,57 +576,60 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
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 renderTitleEditForm = (extraClassName?: string) => (
<form className={`doc-title-edit${extraClassName ? ` ${extraClassName}` : ''}`} 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" className="icon-button icon-button--accent" disabled={titleSaving} aria-label="Save title">
<CheckIcon size={16} />
</button>
<button
type="button"
className="icon-button"
onClick={cancelTitleEdit}
disabled={titleSaving}
aria-label="Cancel"
>
<IconX size={16} />
</button>
</form>
);
const titleMetaDisplay = editableTitle && isTitleEditing
? renderTitleEditForm('doc-title-edit--inline')
: (
<>
<span className="detail-meta__value">{document?.title}</span>
{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
@@ -660,16 +644,17 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
aria-label="Issued on"
disabled={issuedSaving}
/>
<button type="submit" disabled={issuedSaving}>
Save
<button type="submit" className="icon-button icon-button--accent" disabled={issuedSaving} aria-label="Save issued date">
<CheckIcon size={16} />
</button>
<button
type="button"
className="secondary"
className="icon-button"
onClick={cancelIssuedEdit}
disabled={issuedSaving}
aria-label="Cancel"
>
Cancel
<IconX size={16} />
</button>
</form>
) : (
@@ -689,184 +674,98 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
</>
);
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 tagsValueContent = (
<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"
/>
);
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>
const correspondentsValueContent = (
<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"
/>
);
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>
);
}
const summaryRowOverrides = useMemo(
() => ({
title: { valueContent: titleMetaDisplay, error: titleError },
issued: { valueContent: issuedDisplay, error: issuedError },
tags: { valueContent: tagsValueContent },
correspondents: { valueContent: correspondentsValueContent },
}),
[titleMetaDisplay, titleError, issuedDisplay, issuedError, tagsValueContent, correspondentsValueContent],
);
const baseRows: MetaItem[] = useMemo(
() => [...summaryRows, ...extraSummaryRows].map((row) => {
const overrides = summaryRowOverrides[row.key] || {};
return {
key: row.key,
label: row.label,
valueContent: overrides.valueContent ?? null,
fallbackValue: overrides.valueContent ? row.value : row.value,
error: overrides.error ?? null,
};
}),
[summaryRows, extraSummaryRows, summaryRowOverrides],
);
const allRows = baseRows;
const summaryClass = `document-summary${isCompactLayout ? ' document-summary--compact' : ''}`;
const sectionClass = `document-summary__section document-summary__meta${isCompactLayout ? ' document-summary__meta--compact' : ''}`;
const listClass = `document-summary__details-list${isCompactLayout ? ' document-summary__details-list--meta' : ''}`;
return (
<div className="document-summary">
<div className="doc-title-row">
<div className="doc-title-row__primary">
{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}
</>
)}
</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>
{issuedDisplay}
</div>
{issuedError ? <div className="status-inline error">{issuedError}</div> : null}
{metaRows.map((row) => (
<div key={row.key} className="detail-meta__row">
<span className="detail-meta__label">{row.label}:</span>
<span className="detail-meta__value">{row.value}</span>
</div>
))}
</div>
{renderTags()}
{renderCorrespondents()}
<div className={summaryClass}>
<section className={sectionClass}>
<dl className={listClass}>
{allRows.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>
</div>
);
};
+21 -12
View File
@@ -77,10 +77,10 @@ interface DocumentsGridProps {
getDocumentAsset?: (...args: any[]) => unknown;
gridIconSize?: number;
tagLookupById?: Map<Identifier, DocumentTag> | null;
onTagClick?: (tagId?: Identifier | null) => void;
onTagClick?: (tagId: Identifier) => void;
scrollRef?: RefObject<HTMLElement | null>;
onCorrespondentClick?: (correspondentId?: Identifier | null) => void;
activeCorrespondentIdSet?: Set<Identifier | null> | null;
onCorrespondentClick?: (correspondentId: Identifier) => void;
activeCorrespondentIdSet?: Set<Identifier> | null;
onDocumentRename?: (docId: Identifier, title: string) => Promise<boolean> | boolean;
}
@@ -434,20 +434,26 @@ const DocumentsGrid: React.FC<DocumentsGridProps> = ({
</div>
{visibleTags.length > 0 && (
<div className="document-card__tags">
{visibleTags.map((tag) => {
{visibleTags.map((tag, index) => {
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
const style = getTagColorStyle(colorSource);
const tagId = tag?.id ?? null;
const clickable = tagId != null && typeof onTagClick === 'function';
const key = tagId ?? `${doc.id}-tag-${index}`;
return (
<span
key={tag.id}
key={key}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
role="button"
onClick={(event) => {
role={clickable ? 'button' : undefined}
onClick={clickable ? (event) => {
event.stopPropagation();
onTagClick?.(tag.id);
}}
if (tagId == null) {
return;
}
onTagClick?.(tagId);
} : undefined}
draggable
onDragStart={(event) => {
event.stopPropagation();
@@ -463,13 +469,16 @@ const DocumentsGrid: React.FC<DocumentsGridProps> = ({
onDragEnd={(event) => {
event.stopPropagation();
}}
onKeyDown={(event) => {
onKeyDown={clickable ? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
onTagClick?.(tag.id);
if (tagId == null) {
return;
}
onTagClick?.(tagId);
}
}}
} : undefined}
>
{tag.label}
</span>
+19 -12
View File
@@ -82,9 +82,9 @@ export interface DocumentsListProps {
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> | null;
onTagClick?: (tagId: Identifier) => void;
onCorrespondentClick?: (correspondentId: Identifier) => void;
activeCorrespondentIdSet?: Set<Identifier> | null;
scrollRef?: RefObject<HTMLElement | null>;
}
@@ -456,20 +456,24 @@ const DocumentsList: React.FC<DocumentsListProps> = ({
</div>
{(doc.tags || []).length > 0 && (
<div className="doc-name__tags">
{(doc.tags || []).map((tag) => {
{(doc.tags || []).map((tag, index) => {
const colorSource = tag?.color || tagLookupById?.get(tag.id)?.color;
const style = getTagColorStyle(colorSource);
const tagId = tag?.id ?? null;
const clickable = tagId != null && typeof onTagClick === 'function';
const key = tagId ?? `${doc.id}-tag-${index}`;
return (
<span
key={tag.id}
key={key}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
role="button"
onClick={(event) => {
role={clickable ? 'button' : undefined}
onClick={clickable ? (event) => {
event.stopPropagation();
onTagClick?.(tag.id);
}}
if (tagId == null) return;
onTagClick?.(tagId);
} : undefined}
draggable
onDragStart={(event) => {
event.stopPropagation();
@@ -485,13 +489,16 @@ const DocumentsList: React.FC<DocumentsListProps> = ({
onDragEnd={(event) => {
event.stopPropagation();
}}
onKeyDown={(event) => {
onKeyDown={clickable ? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
onTagClick?.(tag.id);
if (tagId == null) {
return;
}
onTagClick?.(tagId);
}
}}
} : undefined}
>
{tag.label}
</span>
@@ -29,7 +29,7 @@ export interface SelectionAssignmentMenuProps {
items?: SelectionAssignmentMenuItem[];
placeholder?: string;
emptyMessage?: string;
createLabel?: string | null;
createLabel?: string;
onToggle?: (item: NormalizedSelectionAssignmentItem) => Promise<void> | void;
onCreate?: (value: string) => Promise<void> | void;
disabled?: boolean;
@@ -82,7 +82,7 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
items = [],
placeholder = 'Search…',
emptyMessage = 'No entries',
createLabel = null,
createLabel = 'Add',
onToggle,
onCreate,
disabled = false,
@@ -91,8 +91,8 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
triggerClassName = 'quick-add__chip quick-add__trigger panel-floating-actions__trigger',
showStateIndicators = true,
showCounts = true,
onOpenMenu = null,
renderItemLabel = null,
onOpenMenu,
renderItemLabel,
positionStrategy = 'absolute',
}) => {
const anchorRef = useRef<HTMLButtonElement | null>(null);
@@ -162,7 +162,7 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
const handleToggle = useCallback(
async (item: NormalizedSelectionAssignmentItem) => {
if (!item || !onToggle) {
if (!onToggle) {
return;
}
setPending(true);
@@ -265,8 +265,8 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
type="submit"
className="icon-button selection-assignment__add"
disabled={!canSubmitCreate}
aria-label={createLabel || 'Add'}
title={createLabel || 'Add'}
aria-label={createLabel}
title={createLabel}
>
<PlusIcon aria-hidden="true" />
</button>
+3 -3
View File
@@ -12,18 +12,18 @@ export type DocumentLike = OcrDocumentLike;
const asyncFalse = async () => false;
const resolveDocumentDownloadHref = (document: DocumentLike | null | undefined, resolveApiPath?: ResolveApiPath | null): string | null => {
const resolveDocumentDownloadHref = (document?: DocumentLike | null, resolveApiPath?: ResolveApiPath | null): string | null => {
if (!document || !resolveApiPath) {
return null;
}
const downloadPath = (document.current_version as { download_path?: string | null } | null | undefined)?.download_path;
const downloadPath = (document.current_version as { download_path?: string | null } | null)?.download_path;
if (!downloadPath) {
return null;
}
return resolveApiPath(downloadPath);
};
const hasDocumentOcrAsset = (document: DocumentLike | null | undefined, getDocumentAsset?: GetDocumentAsset | null): boolean => {
const hasDocumentOcrAsset = (document?: DocumentLike | null, getDocumentAsset?: GetDocumentAsset | null): boolean => {
if (!document || !getDocumentAsset) {
return false;
}
@@ -1,66 +0,0 @@
import { formatDateTime } from '../utils/date';
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;
}
export const buildDocumentMetadataItems = (document?: DocumentLike | null): DocumentMetadataItem[] => {
if (!document) {
return [];
}
const metadata = document.current_version || {};
return [
{ label: 'Created at', value: formatDateTime(document.created_at) },
{ label: 'Updated at', value: formatDateTime(document.updated_at) },
{
label: 'Filename',
value: document.filename ?? null,
},
{
label: 'Original filename',
value: document.original_name ?? null,
},
{
label: 'SHA-256 checksum',
value: metadata.checksum ?? null,
},
{
label: 'Content type',
value: document.content_type ?? null,
},
];
};
export const extractDocumentMetadataPayload = (document?: DocumentLike | null): DocumentMetadata | null => {
if (!document?.metadata) {
return null;
}
const keys = Object.keys(document.metadata);
if (!keys.length) {
return null;
}
return document.metadata;
};
export default buildDocumentMetadataItems;
+49 -87
View File
@@ -1,13 +1,13 @@
import { formatFileSize } from '../utils/format';
import { formatDateTime as defaultFormatDateTime } from '../utils/date';
interface DocumentMetadata {
interface DocumentPageMetadata {
page_count?: number | string | null;
}
interface DocumentVersion {
size_bytes?: number | string | null;
metadata?: DocumentMetadata | null;
metadata?: DocumentPageMetadata | null;
}
interface TagEntry {
@@ -35,31 +35,18 @@ interface DescribeSummaryOptions {
formatDateTime?: typeof defaultFormatDateTime;
}
export type DocumentSummaryRowType = 'text' | 'editable-title' | 'editable-issued' | 'tags' | 'correspondents';
export interface DocumentSummaryRow {
key: string;
label: string;
value: string | null;
kind?: DocumentSummaryRowType;
}
export interface DocumentSummary {
title: string | undefined;
originalName: string | null;
mimeTypeLabel: string;
sizeLabel: string;
createdAtLabel: string;
issuedLabel: string;
updatedAtLabel: string;
pageCount: number | null;
pageCountLabel: string;
folderLabel: string | null;
tags: TagEntry[];
correspondents: CorrespondentEntry[];
tagsSummary: string;
correspondentsSummary: string;
summaryRows: DocumentSummaryRow[];
}
export type DocumentSummary = DocumentSummaryRow[];
const coercePageCount = (metadata?: DocumentMetadata | null): number | null => {
const coercePageCount = (metadata?: DocumentPageMetadata | null): number | null => {
const raw = metadata?.page_count;
if (raw == null || raw === '') {
return null;
@@ -71,86 +58,61 @@ const coercePageCount = (metadata?: DocumentMetadata | null): number | null => {
const sanitizeArray = <T>(entries?: Array<T | null> | null): T[] =>
Array.isArray(entries) ? entries.filter(Boolean) as T[] : [];
interface DocumentMetadataPayload {
[key: string]: unknown;
}
export interface MetadataDocumentLike {
created_at?: string | null;
updated_at?: string | null;
filename?: string | null;
original_name?: string | null;
content_type?: string | null;
metadata?: DocumentMetadataPayload | null;
current_version?: { checksum?: string | null } | null;
}
export const describeDocumentSummary = (document?: SummaryDocument | null, options: DescribeSummaryOptions = {}): DocumentSummary => {
const {
formatDateTime = defaultFormatDateTime,
} = options;
if (!document) {
return {
title: '',
originalName: null,
mimeTypeLabel: '—',
sizeLabel: '—',
createdAtLabel: '—',
issuedLabel: '—',
updatedAtLabel: '—',
pageCount: null,
pageCountLabel: '—',
folderLabel: null,
tags: [],
correspondents: [],
tagsSummary: '—',
correspondentsSummary: '—',
summaryRows: [],
};
}
const originalName = document.original_name;
const mimeTypeLabel = document.content_type || 'Unknown';
const sizeBytes = Number(document.current_version?.size_bytes);
const formatDateLabel = (value?: string | null) => formatDateTime(value) || '—';
const doc = document ?? {};
const sizeBytes = Number(doc.current_version?.size_bytes);
const sizeLabel = Number.isFinite(sizeBytes) && sizeBytes > 0 ? formatFileSize(sizeBytes) : '—';
const metadata = document.current_version?.metadata || null;
const metadata = doc.current_version?.metadata || null;
const pageCount = coercePageCount(metadata);
const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—';
const createdAtLabel = formatDateTime(document.created_at);
const issuedLabel = formatDateTime(document.issued_at);
const updatedAtLabel = formatDateTime(document.updated_at);
const folderLabel = document.folder_path ?? null;
const displayFolderLabel = folderLabel ?? 'Documents';
const tags = sanitizeArray<TagEntry>(document.tags);
const correspondents = sanitizeArray<CorrespondentEntry>(document.correspondents);
const tags = sanitizeArray<TagEntry>(doc.tags);
const correspondents = sanitizeArray<CorrespondentEntry>(doc.correspondents);
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: DocumentSummaryRow[] = [
{ key: 'created', label: 'Created', value: createdAtLabel },
const correspondentsSummary = correspondentLabels.length ? correspondentLabels.join(', ') : '—';
return [
{ key: 'title', label: 'Title', value: doc.title ?? null, kind: 'editable-title' },
{ key: 'tags', label: 'Tags', value: tagsSummary, kind: 'tags' },
{ key: 'correspondents', label: 'Correspondents', value: correspondentsSummary, kind: 'correspondents' },
{ key: 'issued', label: 'Issued', value: formatDateLabel(doc.issued_at), kind: 'editable-issued' },
{ key: 'created', label: 'Created at', value: formatDateLabel(doc.created_at) },
{ key: 'updated', label: 'Updated at', value: formatDateLabel(doc.updated_at) },
{ key: 'size', label: 'Size', value: sizeLabel },
{ key: 'type', label: 'Type', value: mimeTypeLabel },
{ key: 'issued', label: 'Issued', value: issuedLabel },
{ key: 'content-type', label: 'Content type', value: doc.content_type || 'Unknown' },
{ key: 'pages', label: 'Pages', value: pageCountLabel },
{ key: 'updated', label: 'Updated', value: updatedAtLabel },
{ key: 'folder', label: 'Folder', value: displayFolderLabel },
{ key: 'tags', label: 'Tags', value: tagsSummary },
{ key: 'correspondents', label: 'Correspondents', value: correspondentsSummary },
{ key: 'filename', label: 'Filename', value: doc.filename },
{ key: 'original-filename', label: 'Original filename', value: doc.original_name },
{ key: 'checksum', label: 'SHA-256 checksum', value: doc.current_version?.checksum },
];
};
return {
title: document.title,
originalName,
mimeTypeLabel,
sizeLabel,
createdAtLabel,
issuedLabel,
updatedAtLabel,
pageCount,
pageCountLabel,
folderLabel,
tags,
correspondents,
tagsSummary,
correspondentsSummary,
summaryRows,
};
export const extractDocumentMetadataPayload = (document?: MetadataDocumentLike | null): DocumentMetadataPayload | null => {
if (!document?.metadata) {
return null;
}
const keys = Object.keys(document.metadata);
if (!keys.length) {
return null;
}
return document.metadata;
};
@@ -20,8 +20,8 @@ interface UseDocumentsSelectionOptions {
showingSearchResults: boolean;
currentSubfolders: FolderEntry[];
visibleDocuments: DocumentEntry[];
resolveFolderRowKey: (id: string | number) => string | null | undefined;
resolveDocumentRowKey: (id: string | number) => string | null | undefined;
resolveFolderRowKey: (id: string | number) => string | null;
resolveDocumentRowKey: (id: string | number) => string | null;
configureSelectionEnvironment: (config: { visibleRowKeySet: Set<string>; navigableRowKeys: string[] }) => void;
visibleRowKeySet: Set<string>;
selectedEntries: string[];
+10 -12
View File
@@ -12,8 +12,8 @@ type FocusableInput = (HTMLInputElement | HTMLTextAreaElement) & {
};
type InlineRenameOptions<TEntity> = {
getCurrentValue?: (entity: TEntity) => string | null | undefined;
getEntityId?: (entity: TEntity) => string | number | null | undefined;
getCurrentValue?: (entity: TEntity) => string | null;
getEntityId?: (entity: TEntity) => string | number | null;
};
type InlineRenameHandler = (
@@ -25,9 +25,9 @@ type InlineRenameReturn<TEntity> = {
editingId: string | number | null;
draftValue: string;
setDraftValue: Dispatch<SetStateAction<string>>;
beginEditing: (entity: TEntity | null | undefined, event?: SyntheticEvent | Event) => void;
beginEditing: (entity?: TEntity | null, event?: SyntheticEvent | Event) => void;
cancelEditing: (event?: SyntheticEvent | Event) => void;
submitEditing: (entity: TEntity | null | undefined) => Promise<boolean>;
submitEditing: (entity?: TEntity | null) => Promise<boolean>;
savingId: string | number | null;
attachInputRef: (node: FocusableInput | null) => void;
};
@@ -50,16 +50,14 @@ const focusInput = (node: FocusableInput | null) => {
const identity = (value: unknown) => value as string;
const defaultGetEntityId = <T,>(entity: T) =>
(entity as { id?: string | number } | null | undefined)?.id ?? null;
const defaultGetEntityId = <T,>(entity?: T | null) =>
(entity as { id?: string | number } | null)?.id ?? null;
const useInlineRename = <TEntity,>(
onRename?: InlineRenameHandler,
{
getCurrentValue = identity as (entity: TEntity) => string | null | undefined,
getEntityId = defaultGetEntityId as (
entity: TEntity,
) => string | number | null | undefined,
getCurrentValue = identity as (entity: TEntity) => string | null,
getEntityId = defaultGetEntityId as (entity: TEntity) => string | number | null,
}: InlineRenameOptions<TEntity> = {},
): InlineRenameReturn<TEntity> => {
const [editingId, setEditingId] = useState<string | number | null>(null);
@@ -75,7 +73,7 @@ const useInlineRename = <TEntity,>(
}, []);
const beginEditing = useCallback(
(entity: TEntity | null | undefined, event?: SyntheticEvent | Event) => {
(entity?: TEntity | null, event?: SyntheticEvent | Event) => {
if (!entity) {
return;
}
@@ -107,7 +105,7 @@ const useInlineRename = <TEntity,>(
);
const submitEditing = useCallback(
async (entity: TEntity | null | undefined) => {
async (entity?: TEntity | null) => {
if (!entity) {
return false;
}