feat: Refactor tag and correspondent management to use canonical types and dedicated managers.
This commit is contained in:
@@ -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[] = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user