refactor: Decompose document and folder views into modular components and hooks, replacing monolithic list/grid implementations.

This commit is contained in:
2025-11-26 01:50:53 +01:00
parent 342714528a
commit 0a4a3ae35c
20 changed files with 1074 additions and 917 deletions
@@ -0,0 +1,37 @@
import React from 'react';
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
import type { DocumentViewLogic } from '../hooks/useDocumentViewLogic';
import { useDocumentItemLogic } from '../hooks/useDocumentItemLogic';
import EntryShell from './EntryShell';
interface DocumentEntryProps extends DocumentsViewProps {
doc: any;
viewLogic: DocumentViewLogic;
component: React.ElementType;
className?: string;
role?: string;
children: (logic: ReturnType<typeof useDocumentItemLogic>) => React.ReactNode;
}
const DocumentEntry: React.FC<DocumentEntryProps> = (props) => {
const { doc, component, className, role, children, viewLogic } = props;
const logic = useDocumentItemLogic({ doc, viewLogic, ...props });
return (
<EntryShell
component={component}
id={`document-${doc.id}`}
docId={doc.id}
handlers={logic.handlers}
isSelected={logic.isSelected}
isDragging={logic.isDraggingDoc}
canDrag={true}
className={className}
role={role}
>
{children(logic)}
</EntryShell>
);
};
export default DocumentEntry;
@@ -0,0 +1,85 @@
import React from 'react';
import { getTagColorStyle } from '../../utils/colors';
import { writeTagTransferData } from '../tagTransfer';
import type { DocumentTag } from '../../types/documents';
import type { Identifier } from '../../types/identifiers';
interface DocumentTagsProps {
tags: DocumentTag[];
tagLookupById?: Map<Identifier, DocumentTag> | null;
onTagClick?: (tagId: Identifier) => void;
docId: Identifier;
maxTags?: number;
}
const DocumentTags: React.FC<DocumentTagsProps> = ({
tags,
tagLookupById,
onTagClick,
docId,
maxTags,
}) => {
const visibleTags = maxTags ? tags.slice(0, maxTags) : tags;
const remainingTagCount = maxTags && tags.length > maxTags ? tags.length - maxTags : 0;
if (tags.length === 0) {
return null;
}
return (
<>
{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 ?? `${docId}-tag-${index}`;
return (
<span
key={key}
className="badge tag-chip"
style={style || undefined}
title={tag.label}
role={clickable ? 'button' : undefined}
onClick={clickable ? (event) => {
event.stopPropagation();
if (tagId == null) return;
onTagClick?.(tagId);
} : undefined}
draggable
onDragStart={(event) => {
event.stopPropagation();
try {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copyMove';
}
} catch (error) {
console.warn('[documents] Failed to configure drag effect', error);
}
writeTagTransferData(event.dataTransfer, tag, docId);
}}
onDragEnd={(event) => {
event.stopPropagation();
}}
onKeyDown={clickable ? (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
if (tagId == null) return;
onTagClick?.(tagId);
}
} : undefined}
>
{tag.label}
</span>
);
})}
{remainingTagCount > 0 && (
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
)}
</>
);
};
export default DocumentTags;
@@ -0,0 +1,129 @@
import React from 'react';
import { FolderIcon } from '../../ui/icons';
import DocumentThumbnailImage from '../DocumentThumbnailImage';
import { resolveCorrespondents } from '../correspondents';
import type { DocumentsListEntry } from '../../types/documents';
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
import type { DocumentViewLogic } from '../hooks/useDocumentViewLogic';
import EditableEntryTitle from './EditableEntryTitle';
import EntryCorrespondents from './EntryCorrespondents';
import EntryTags from './EntryTags';
import FolderEntry from './FolderEntry';
import DocumentEntry from './DocumentEntry';
interface DocumentsGridCardProps extends DocumentsViewProps {
entry: DocumentsListEntry;
viewLogic: DocumentViewLogic;
gridIconSize?: number;
}
const DocumentsGridCard: React.FC<DocumentsGridCardProps> = (props) => {
const { entry, gridIconSize } = props;
if (entry.type === 'folder') {
const folder = entry.folder;
if (!folder) return null;
return (
<FolderEntry
{...props}
folder={folder}
component="div"
className="document-card folder-card"
role="listitem"
>
{(logic) => (
<>
<div className="folder-card__icon">
<FolderIcon className="folder-card__icon-svg" size={gridIconSize} />
</div>
<div className="folder-card__meta">
<div className="folder-card__label-row">
<EditableEntryTitle
isEditing={logic.isFolderEditing}
draftValue={logic.folderDraftValue}
onChange={logic.handlers.onRenameChange}
onSubmit={logic.handlers.onRenameSubmit}
onCancel={logic.handlers.onRenameCancel}
isSaving={logic.isFolderSaving}
canSubmit={logic.canSubmitFolder}
inputRef={logic.attachFolderInputRef}
allowInlineEdit={logic.allowInlineFolderEdit}
onBeginEditing={logic.handlers.onRenameBegin}
className="folder-card__name"
>
{folder.name}
</EditableEntryTitle>
</div>
</div>
</>
)}
</FolderEntry>
);
}
const doc = entry.document;
if (!doc) return null;
const correspondents = resolveCorrespondents(doc);
return (
<DocumentEntry
{...props}
doc={doc}
component="div"
className="document-card document"
role="listitem"
>
{(logic) => (
<>
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={props.ensureAssetUrl}
getDocumentAsset={props.getDocumentAsset}
alt={`Thumbnail for ${doc.title}`}
maxSize={gridIconSize}
scrollRootRef={props.scrollRef}
/>
<div className="document-card__meta">
<div className="document-card__title" title={doc.title}>
<EntryCorrespondents
correspondents={correspondents}
activeCorrespondentIdSet={props.activeCorrespondentIdSet}
onCorrespondentClick={props.onCorrespondentClick}
/>
<div className="document-card__title-row">
<EditableEntryTitle
isEditing={logic.isEditingDoc}
draftValue={logic.documentDraftValue}
onChange={logic.handlers.onRenameChange}
onSubmit={logic.handlers.onRenameSubmit}
onCancel={logic.handlers.onRenameCancel}
isSaving={logic.isDocumentSaving}
canSubmit={logic.canSubmitDocument}
inputRef={logic.attachDocumentInputRef}
allowInlineEdit={logic.allowInlineDocumentEdit}
onBeginEditing={logic.handlers.onRenameBegin}
className="document-card__title-badge"
>
{doc.title}
</EditableEntryTitle>
</div>
</div>
<div className="document-card__tags">
<EntryTags
tags={doc.tags || []}
tagLookupById={props.tagLookupById}
onTagClick={props.onTagClick}
docId={doc.id}
maxTags={3}
/>
</div>
</div>
</>
)}
</DocumentEntry>
);
};
export default DocumentsGridCard;
@@ -0,0 +1,34 @@
import React from 'react';
interface DocumentsGridContainerProps {
children: React.ReactNode;
clearSelection: () => void;
gridIconSize?: number;
}
const DocumentsGridContainer: React.FC<DocumentsGridContainerProps> = ({
children,
clearSelection,
gridIconSize,
}) => {
return (
<div
className="documents-grid"
role="list"
style={
gridIconSize
? ({ '--documents-grid-icon-size': `${gridIconSize}px` } as React.CSSProperties)
: undefined
}
onClick={(event) => {
if (event.target === event.currentTarget) {
clearSelection();
}
}}
>
{children}
</div>
);
};
export default DocumentsGridContainer;
@@ -0,0 +1,31 @@
import React from 'react';
interface DocumentsListContainerProps {
children: React.ReactNode;
clearSelection: () => void;
}
const DocumentsListContainer: React.FC<DocumentsListContainerProps> = ({
children,
clearSelection,
}) => {
return (
<table aria-multiselectable="true">
<thead
onClick={() => {
clearSelection();
}}
>
<tr>
<th>&nbsp;</th>
<th>Name</th>
<th>Issued</th>
<th>Added</th>
</tr>
</thead>
<tbody>{children}</tbody>
</table>
);
};
export default DocumentsListContainer;
@@ -0,0 +1,143 @@
import React from 'react';
import { FolderIcon } from '../../ui/icons';
import { formatDate } from '../../utils/date';
import DocumentThumbnailImage from '../DocumentThumbnailImage';
import { resolveCorrespondents } from '../correspondents';
import type { DocumentsListEntry } from '../../types/documents';
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
import type { DocumentViewLogic } from '../hooks/useDocumentViewLogic';
import EditableEntryTitle from './EditableEntryTitle';
import EntryCorrespondents from './EntryCorrespondents';
import EntryTags from './EntryTags';
import FolderEntry from './FolderEntry';
import DocumentEntry from './DocumentEntry';
interface DocumentsListRowProps extends DocumentsViewProps {
entry: DocumentsListEntry;
viewLogic: DocumentViewLogic;
}
const DocumentsListRow: React.FC<DocumentsListRowProps> = (props) => {
const { entry } = props;
if (entry.type === 'folder') {
const folder = entry.folder;
if (!folder) return null;
return (
<FolderEntry
{...props}
folder={folder}
component="tr"
className="folder"
>
{(logic) => (
<>
<td className="thumb-cell">
<div className="thumb-icon">
<FolderIcon className="thumb-icon__image" size={32} />
</div>
</td>
<td className="doc-list__name">
<div className="doc-list__name-content">
<span className="doc-name__title">
<span className="doc-name__primary">
<EditableEntryTitle
isEditing={logic.isFolderEditing}
draftValue={logic.folderDraftValue}
onChange={logic.handlers.onRenameChange}
onSubmit={logic.handlers.onRenameSubmit}
onCancel={logic.handlers.onRenameCancel}
isSaving={logic.isFolderSaving}
canSubmit={logic.canSubmitFolder}
inputRef={logic.attachFolderInputRef}
allowInlineEdit={logic.allowInlineFolderEdit}
onBeginEditing={logic.handlers.onRenameBegin}
className="doc-name__primary-text"
>
{folder.name}
</EditableEntryTitle>
</span>
</span>
</div>
</td>
<td></td>
<td></td>
</>
)}
</FolderEntry>
);
}
const doc = entry.document;
if (!doc) return null;
const correspondents = resolveCorrespondents(doc);
const issuedLabel = formatDate(doc.issued_at);
const addedLabel = formatDate(doc.created_at || doc.uploaded_at);
return (
<DocumentEntry
{...props}
doc={doc}
component="tr"
className="document"
>
{(logic) => (
<>
<td className="thumb-cell">
<DocumentThumbnailImage
document={doc}
ensureAssetUrl={props.ensureAssetUrl}
getDocumentAsset={props.getDocumentAsset}
alt={`Thumbnail for ${doc.title}`}
scrollRootRef={props.scrollRef}
/>
</td>
<td className="doc-list__name">
<div className="doc-name">
<div className="doc-list__name-content">
<span className="doc-name__title">
<EntryCorrespondents
correspondents={correspondents}
activeCorrespondentIdSet={props.activeCorrespondentIdSet}
onCorrespondentClick={props.onCorrespondentClick}
/>
<span className="doc-name__primary">
<EditableEntryTitle
isEditing={logic.isEditingDoc}
draftValue={logic.documentDraftValue}
onChange={logic.handlers.onRenameChange}
onSubmit={logic.handlers.onRenameSubmit}
onCancel={logic.handlers.onRenameCancel}
isSaving={logic.isDocumentSaving}
canSubmit={logic.canSubmitDocument}
inputRef={logic.attachDocumentInputRef}
allowInlineEdit={logic.allowInlineDocumentEdit}
onBeginEditing={logic.handlers.onRenameBegin}
className="doc-name__primary-text"
>
{doc.title}
</EditableEntryTitle>
</span>
</span>
</div>
<div className="doc-name__tags">
<EntryTags
tags={doc.tags || []}
tagLookupById={props.tagLookupById}
onTagClick={props.onTagClick}
docId={doc.id}
/>
</div>
</div>
</td>
<td>{issuedLabel}</td>
<td>{addedLabel}</td>
</>
)}
</DocumentEntry>
);
};
export default DocumentsListRow;
@@ -0,0 +1,67 @@
import React from 'react';
import InlineRenameInput from './InlineRenameInput';
interface EditableEntryTitleProps {
isEditing: boolean;
draftValue: string;
onChange: (value: string) => void;
onSubmit: () => void;
onCancel: (event?: React.SyntheticEvent) => void;
isSaving: boolean;
canSubmit: boolean;
inputRef: (ref: HTMLInputElement | null) => void;
allowInlineEdit: boolean | undefined;
onBeginEditing: (event: React.SyntheticEvent) => void;
children: React.ReactNode;
className?: string;
}
const EditableEntryTitle: React.FC<EditableEntryTitleProps> = ({
isEditing,
draftValue,
onChange,
onSubmit,
onCancel,
isSaving,
canSubmit,
inputRef,
allowInlineEdit,
onBeginEditing,
children,
className,
}) => {
if (isEditing) {
return (
<div className={`doc-title-edit ${className || ''}`}>
<InlineRenameInput
value={draftValue}
onChange={onChange}
onSubmit={onSubmit}
onCancel={onCancel}
isSaving={isSaving}
canSubmit={canSubmit}
inputRef={inputRef}
/>
</div>
);
}
return (
<span
className={className}
role={allowInlineEdit ? 'button' : undefined}
tabIndex={allowInlineEdit ? 0 : undefined}
onClick={onBeginEditing}
onKeyDown={(event) => {
if (!allowInlineEdit) return;
if (event.key === 'Enter') {
onBeginEditing(event);
}
}}
>
{children}
</span>
);
};
export default EditableEntryTitle;
@@ -0,0 +1,27 @@
import React from 'react';
import CorrespondentLinks from '../CorrespondentLinks';
import type { Identifier } from '../../types/identifiers';
interface EntryCorrespondentsProps {
correspondents: any[];
activeCorrespondentIdSet?: Set<Identifier> | null;
onCorrespondentClick?: (correspondentId: Identifier) => void;
}
const EntryCorrespondents: React.FC<EntryCorrespondentsProps> = (props) => {
if (!props.correspondents || props.correspondents.length === 0) {
return null;
}
return (
<span className="doc-correspondents">
<CorrespondentLinks
correspondents={props.correspondents}
activeCorrespondentIdSet={props.activeCorrespondentIdSet}
onCorrespondentClick={props.onCorrespondentClick}
/>
</span>
);
};
export default EntryCorrespondents;
@@ -0,0 +1,66 @@
import React, { type DragEvent } from 'react';
export interface EntryShellHandlers {
onClick: (event: React.MouseEvent) => void;
onDoubleClick: (event: React.MouseEvent) => void;
onDragStart: (event: DragEvent<HTMLElement>) => void;
onDragEnd: (event: DragEvent<HTMLElement>) => void;
onDragOver: (event: DragEvent<HTMLElement>) => void;
onDragLeave: (event: DragEvent<HTMLElement>) => void;
onDrop: (event: DragEvent<HTMLElement>) => void;
onDragOverCapture?: (event: DragEvent<HTMLElement>) => void;
onDragLeaveCapture?: (event: DragEvent<HTMLElement>) => void;
}
interface EntryShellProps {
component: React.ElementType;
handlers: EntryShellHandlers;
isSelected?: boolean;
isDragging?: boolean;
canDrag?: boolean;
id: string;
className?: string;
children: React.ReactNode;
docId?: number;
role?: string;
}
const EntryShell: React.FC<EntryShellProps> = ({
component: Component,
handlers,
isSelected,
isDragging,
canDrag,
id,
className = '',
children,
docId,
role,
}) => {
const classes = [className];
if (isSelected) classes.push('selected');
if (isDragging) classes.push('is-dragging');
const commonProps = {
id,
className: classes.join(' '),
onClick: handlers.onClick,
onDoubleClick: handlers.onDoubleClick,
draggable: canDrag,
onDragStart: handlers.onDragStart,
onDragEnd: handlers.onDragEnd,
onDragOver: handlers.onDragOver,
onDragLeave: handlers.onDragLeave,
onDrop: handlers.onDrop,
...(docId ? { 'data-doc-id': docId } : {}),
...(role ? { role } : {}),
};
return (
<Component {...commonProps}>
{children}
</Component>
);
};
export default EntryShell;
@@ -0,0 +1,30 @@
import React from 'react';
import DocumentTags from './DocumentTags';
import type { Identifier } from '../../types/identifiers';
import type { DocumentTag } from '../../types/documents';
interface EntryTagsProps {
tags: Identifier[];
tagLookupById?: Map<Identifier, DocumentTag> | null;
onTagClick?: (tagId: Identifier) => void;
docId: Identifier;
maxTags?: number;
}
const EntryTags: React.FC<EntryTagsProps> = (props) => {
if (!props.tags || props.tags.length === 0) {
return null;
}
return (
<DocumentTags
tags={props.tags}
tagLookupById={props.tagLookupById}
onTagClick={props.onTagClick}
docId={props.docId}
maxTags={props.maxTags}
/>
);
};
export default EntryTags;
@@ -0,0 +1,36 @@
import React from 'react';
import type { DocumentsViewProps } from '../panel/DocumentsPanel';
import type { DocumentViewLogic } from '../hooks/useDocumentViewLogic';
import { useFolderItemLogic } from '../hooks/useFolderItemLogic';
import EntryShell from './EntryShell';
interface FolderEntryProps extends DocumentsViewProps {
folder: any;
viewLogic: DocumentViewLogic;
component: React.ElementType;
className?: string;
role?: string;
children: (logic: ReturnType<typeof useFolderItemLogic>) => React.ReactNode;
}
const FolderEntry: React.FC<FolderEntryProps> = (props) => {
const { folder, component, className, role, children, viewLogic } = props;
const logic = useFolderItemLogic({ folder, viewLogic, ...props });
return (
<EntryShell
component={component}
id={`folder-${folder.id}`}
handlers={logic.handlers}
isSelected={logic.isSelectedFolder}
isDragging={logic.isDraggingFolder}
canDrag={logic.canDragFolder}
className={className}
role={role}
>
{children(logic)}
</EntryShell>
);
};
export default FolderEntry;
@@ -0,0 +1,77 @@
import React from 'react';
import { CheckIcon, CloseIcon } from '../../ui/icons';
interface InlineRenameInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onCancel: (event?: React.SyntheticEvent) => void;
isSaving?: boolean;
canSubmit?: boolean;
inputRef?: React.Ref<HTMLInputElement>;
className?: string;
}
const InlineRenameInput: React.FC<InlineRenameInputProps> = ({
value,
onChange,
onSubmit,
onCancel,
isSaving = false,
canSubmit = true,
inputRef,
className = 'doc-title-edit',
}) => {
return (
<span className={className}>
<input
type="text"
ref={inputRef}
value={value}
onChange={(event) => onChange(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
onSubmit();
} else if (event.key === 'Escape') {
event.preventDefault();
onCancel(event);
}
}}
onBlur={(event) => {
const nextFocus = event.relatedTarget;
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
onCancel();
}
}}
/>
<button
type="button"
className="icon-button"
aria-label="Save"
title="Save"
disabled={!canSubmit || isSaving}
onClick={(event) => {
event.stopPropagation();
onSubmit();
}}
>
<CheckIcon />
</button>
<button
type="button"
className="icon-button"
aria-label="Cancel"
title="Cancel"
onClick={(event) => {
onCancel(event);
}}
>
<CloseIcon />
</button>
</span>
);
};
export default InlineRenameInput;