feat: Refactor tag and correspondent management to use canonical types and dedicated managers.

This commit is contained in:
2025-12-09 18:56:11 +01:00
parent ce821c1817
commit 3ccc1f6698
34 changed files with 790 additions and 427 deletions
@@ -67,6 +67,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
onTagAdd,
onTagRemove,
correspondents,
correspondentLookupById,
onCorrespondentAdd,
onCorrespondentRemove,
onUpdateTitle,
@@ -134,6 +135,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
onTagAdd,
onTagRemove,
correspondents: sortedCorrespondents,
correspondentLookupById,
correspondentOptions,
onCorrespondentAdd,
onCorrespondentRemove,
@@ -147,6 +149,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
onTagAdd,
onTagRemove,
sortedCorrespondents,
correspondentLookupById,
correspondentOptions,
onCorrespondentAdd,
onCorrespondentRemove,
@@ -156,6 +159,11 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
],
);
const infoPanelProps = useMemo(() => ({
tagLookupById,
correspondentLookupById,
}), [tagLookupById, correspondentLookupById]);
const loadOcrContent = useCallback(async ({ signal }: { signal?: AbortSignal } = {}) => {
if (!document || !hasOcr || !getDocumentAsset) {
return '';
@@ -358,6 +366,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
<DocumentViewerLayout
document={document}
summaryProps={summaryProps}
infoPanelProps={infoPanelProps}
metadataPayload={metadataPayload}
contentTabConfig={contentTabConfig}
previewLoadingMessage="Loading preview…"
@@ -2,6 +2,8 @@ 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 };
@@ -14,7 +16,8 @@ type ContentState =
| { status: 'error'; data: null; error: unknown };
export interface DocumentInfoPanelProps {
document: DocumentSummarySectionProps['document'];
tagLookupById?: Map<TagId, Tag>;
correspondentLookupById?: Map<Identifier, Correspondent>;
summaryProps?: Omit<DocumentSummarySectionProps, 'document' | 'layout'>;
metadataItems?: DocumentSummaryRow[];
metadataPayload?: Record<string, unknown>;
@@ -50,6 +53,8 @@ export interface DocumentInfoPanelProps {
const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
document,
tagLookupById,
correspondentLookupById,
summaryProps = {},
metadataItems: metadataItemsProp,
metadataPayload: metadataPayloadProp,
@@ -76,8 +81,9 @@ const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
if (Array.isArray(metadataItemsProp) && metadataItemsProp.length) {
return metadataItemsProp;
}
return describeDocumentSummary(document);
}, [metadataItemsProp, document]);
return describeDocumentSummary(document, { tagLookupById, correspondentLookupById });
}, [metadataItemsProp, document, tagLookupById, correspondentLookupById]);
const metadataPayload = useMemo(() => {
if (metadataPayloadProp !== undefined) {
@@ -107,9 +113,10 @@ const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
<DocumentSummarySection
document={document}
layout={summaryLayout}
correspondentLookupById={correspondentLookupById}
{...summaryProps}
/>
), [document, summaryLayout, summaryProps]);
), [document, summaryLayout, summaryProps, correspondentLookupById]);
const renderDetailsSection = useCallback(() => (
<section className={`${base}__section`}>
@@ -15,25 +15,12 @@ import {
import { describeDocumentSummary, type DocumentSummaryRow } from '../logic/documentSummary';
import { useFolderManager } from '../../folders/FolderManagerContext';
import type { Document, Tag, Correspondent } from '../../types/documents';
import type { FolderId, Identifier, TagId } from '../../types/identifiers';
interface TagEntry {
id?: TagId;
label?: string;
color?: string | null;
}
interface CorrespondentEntry {
id?: Identifier;
name?: string;
count?: number;
}
import type { Document } from '../../types/documents';
interface TagSectionProps {
tags?: TagEntry[];
onRemove?: (tag: TagEntry) => void;
tags?: Tag[];
onRemove?: (tag: Tag) => void;
onAdd?: (payload: { value: string; option?: unknown; input?: unknown }) => void;
emptyMessage?: string;
addPlaceholder?: string;
@@ -43,8 +30,8 @@ interface TagSectionProps {
}
interface CorrespondentSectionProps {
entries?: CorrespondentEntry[];
onRemove?: (entry: CorrespondentEntry) => void;
entries?: Correspondent[];
onRemove?: (entry: Correspondent) => void;
onAdd?: (payload: { name: string; option?: unknown; input?: unknown }) => void;
showCount?: boolean;
addPlaceholder?: string;
@@ -55,11 +42,12 @@ interface CorrespondentSectionProps {
export interface DocumentSummarySectionProps {
document?: Document | null;
tagLookupById?: Map<TagId, TagEntry>;
tagLookupById?: Map<TagId, Tag>;
tagOptions?: SelectionAssignmentMenuItem[];
onTagAdd?: (doc: Document, value: string, context?: { option?: unknown }) => void;
onTagRemove?: (docId: Identifier | undefined, tagId: TagId | undefined) => void;
correspondents?: CorrespondentEntry[];
correspondents?: Correspondent[];
correspondentLookupById?: Map<Identifier, Correspondent>;
correspondentOptions?: SelectionAssignmentMenuItem[];
onCorrespondentAdd?: (payload: { document: Document; name: string; option?: unknown }) => void;
onCorrespondentRemove?: (payload: { documentId: Identifier | undefined; correspondentId: Identifier | undefined }) => void;
@@ -77,10 +65,9 @@ interface MetaItem {
error?: string | null;
}
export const sortCorrespondents = (entries = []) =>
export const sortCorrespondents = (entries: Correspondent[] = []) =>
entries
.filter((entry) => entry && entry.name)
.map(({ id, name, count }) => ({ id, name, count }))
.sort((a, b) => a.name.localeCompare(b.name));
export const buildCorrespondentOptions = (entries = []) => {
@@ -237,8 +224,8 @@ const TagSection: React.FC<TagSectionProps> = ({
}
if (item.state === 'all' && onRemove) {
const payload = item.payload && typeof item.payload === 'object' && 'id' in item.payload
? (item.payload as TagEntry)
: tags.find((tag) => (tag.id ?? tag.label) === item.id) ?? { id: item.id, label: item.label };
? (item.payload as Tag)
: tags.find((tag) => (tag.id ?? tag.label) === item.id) ?? { id: item.id, label: item.label } as unknown as Tag;
onRemove(payload);
return;
}
@@ -344,7 +331,7 @@ const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
return;
}
const key = label.toLowerCase();
const payload = { id: entry.id, name: label };
const payload = entry;
if (map.has(key)) {
const item = map.get(key);
if (item) {
@@ -370,9 +357,7 @@ const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
return;
}
if (item.state === 'all' && onRemove) {
const payload = item.payload && typeof item.payload === 'object' && 'id' in item.payload
? (item.payload as CorrespondentEntry)
: entries.find((entry) => (entry.id ?? entry.name) === item.id) ?? { id: item.id, name: item.label };
const payload = (item.payload || { id: item.id, name: item.label }) as Correspondent;
onRemove(payload);
return;
}
@@ -389,7 +374,7 @@ const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
: { id: null, name: resolvedName };
onAdd({ name: resolvedName, option: payload, input: null });
},
[entries, onAdd, onRemove],
[onAdd, onRemove],
);
return (
@@ -401,7 +386,7 @@ const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
<span key={key} className="correspondent-pill">
<span className="correspondent-pill__label">
{entry.name}
{showCount && entry.count ? ` (${entry.count})` : ''}
{showCount && entry.usage_count ? ` (${entry.usage_count})` : ''}
</span>
{onRemove ? (
<button
@@ -446,6 +431,7 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
onTagAdd,
onTagRemove,
correspondents,
correspondentLookupById,
correspondentOptions = [],
onCorrespondentAdd,
onCorrespondentRemove,
@@ -456,7 +442,7 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
}) => {
const folderManager = useFolderManager();
const isCompactLayout = layout === 'compact';
const summaryRows = useMemo(() => describeDocumentSummary(document), [document]);
const summaryRows = useMemo(() => describeDocumentSummary(document, { tagLookupById }), [document, tagLookupById]);
const issuedDateLabel = useMemo(
() => formatDate(document?.issued_at, { fallback: null }),
[document?.issued_at],
@@ -469,23 +455,27 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
if (!Array.isArray(document?.tags)) {
return [];
}
return document.tags.map((tag) => ({
id: tag.id,
label: tag.label,
color: tag.color ?? tagLookupById.get(tag.id)?.color ?? null,
})).sort((a, b) => {
const labelA = (a.label || '').toLowerCase();
const labelB = (b.label || '').toLowerCase();
return labelA.localeCompare(labelB);
});
return document.tags.map((tagId) => tagLookupById.get(tagId))
.filter((t): t is Tag => Boolean(t))
.sort((a, b) => {
const labelA = (a.label || '').toLowerCase();
const labelB = (b.label || '').toLowerCase();
return labelA.localeCompare(labelB);
});
}, [document?.tags, tagLookupById]);
const resolvedCorrespondents = useMemo(() => {
if (Array.isArray(correspondents) && correspondents.length) {
return correspondents;
}
return sortCorrespondents(document?.correspondents || []);
}, [correspondents, document?.correspondents]);
if (!document?.correspondents) return [];
return document.correspondents
.map((id) => correspondentLookupById?.get(id))
.sort((a, b) => a.name.localeCompare(b.name));
}, [correspondents, document?.correspondents, correspondentLookupById]);
const extraSummaryRows = useMemo(() => {
const rows: DocumentSummaryRow[] = [];
+18 -7
View File
@@ -1,11 +1,13 @@
import { formatFileSize } from '../../utils/format';
import { formatDateTime as defaultFormatDateTime } from '../../utils/date';
import { DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils';
import type { DocumentTag, DocumentCorrespondent, Document } from '../../types/documents';
import type { Identifier } from '../../types/identifiers';
import type { Correspondent, Document, Tag } from '../../types/documents';
interface DescribeSummaryOptions {
formatDateTime?: typeof defaultFormatDateTime;
tagLookupById?: Map<Identifier, Tag> | null;
correspondentLookupById?: Map<Identifier, Correspondent> | null;
}
type DocumentSummaryRowType = 'text' | 'editable-title' | 'editable-issued' | 'tags' | 'correspondents' | 'folder';
@@ -38,6 +40,8 @@ interface DocumentMetadataPayload {
export const describeDocumentSummary = (document?: Document | null, options: DescribeSummaryOptions = {}): DocumentSummary => {
const {
formatDateTime = defaultFormatDateTime,
tagLookupById,
correspondentLookupById,
} = options;
const formatDateLabel = (value?: string | number | null) => {
@@ -52,11 +56,18 @@ export const describeDocumentSummary = (document?: Document | null, options: Des
const metadata = doc.current_version?.metadata || null;
const pageCount = coercePageCount(metadata);
const pageCountLabel = pageCount !== null ? String(pageCount) : '—';
const folderLabel = doc.folder_id == null ? DEFAULT_FOLDER_NAME : `Folder ${doc.folder_id}`;
const tags = sanitizeArray<DocumentTag>(doc.tags);
const correspondents = sanitizeArray<DocumentCorrespondent>(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 folderLabel = doc.folder_id == null ? DEFAULT_FOLDER_NAME : `Folder ${doc.folder_id} `;
const tags = sanitizeArray<Identifier>(doc.tags);
const correspondents = sanitizeArray<Identifier>(doc.correspondents);
const tagLabels = tags
.map((tagId) => tagLookupById?.get(tagId)?.label)
.filter(Boolean) as string[];
const correspondentLabels = correspondents
.map((id) => correspondentLookupById?.get(id)?.name)
.filter(Boolean) as string[];
const tagsSummary = tagLabels.length ? tagLabels.join(', ') : '—';
const correspondentsSummary = correspondentLabels.length ? correspondentLabels.join(', ') : '—';
return [
@@ -7,6 +7,7 @@ import type { EnsureAssetUrl, GetAsset } from '../../lib/assets/AssetManager';
import type { Identifier } from '../../types/identifiers';
import type { Document } from '../../types/documents';
import { resolveBreadcrumbs } from '../../documents/logic/breadcrumbs';
import type { Tag, Correspondent } from '../../types/documents';
interface FolderNode {
id: Identifier | 'root';
@@ -34,7 +35,8 @@ interface UseDetailWorkspaceArgs {
handleCorrespondentRemove?: (...args: unknown[]) => void;
selectFolder?: (folderId?: Identifier | 'root') => void;
tags?: unknown[];
tagLookupById?: Map<Identifier, unknown> | null;
tagLookupById?: Map<Identifier, Tag> | null;
correspondentLookupById?: Map<Identifier, Correspondent> | null;
}
interface UseDetailWorkspaceResult {
@@ -73,6 +75,7 @@ const useDetailWorkspace = ({
selectFolder,
tags,
tagLookupById,
correspondentLookupById,
}: UseDetailWorkspaceArgs): UseDetailWorkspaceResult => {
const {
detailPanelOpen,
@@ -182,6 +185,7 @@ const useDetailWorkspace = ({
document: detailPanelDocument,
tags,
tagLookupById,
correspondentLookupById,
onTagAdd: handleDocumentTagAdd,
onTagRemove: handleDocumentTagDetach,
onOpenPreview: openDocumentPreview,