refactor: introduce dedicated identifier types for improved clarity and type safety
This commit is contained in:
@@ -3,15 +3,15 @@ import React from 'react';
|
||||
const NBSP = String.fromCharCode(160);
|
||||
|
||||
export interface CorrespondentLinkEntry {
|
||||
id?: string | number | null;
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
interface CorrespondentLinksProps {
|
||||
correspondents?: CorrespondentLinkEntry[];
|
||||
activeCorrespondentIdSet?: Set<string | number>;
|
||||
onCorrespondentClick?: (id: string | number) => void;
|
||||
activeCorrespondentIdSet?: Set<string>;
|
||||
onCorrespondentClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
const CorrespondentLinks: React.FC<CorrespondentLinksProps> = ({
|
||||
@@ -23,7 +23,7 @@ const CorrespondentLinks: React.FC<CorrespondentLinksProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeSet = activeCorrespondentIdSet || new Set<string | number>();
|
||||
const activeSet = activeCorrespondentIdSet || new Set<string>();
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>, correspondent: CorrespondentLinkEntry) => {
|
||||
if (!onCorrespondentClick || correspondent.id == null) {
|
||||
return;
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface DocumentInfoPanelProps {
|
||||
activeTab?: string;
|
||||
onTabChange?: (tabId: string) => void;
|
||||
defaultTabId?: string;
|
||||
resetKey?: string | number | null;
|
||||
resetKey?: string | null;
|
||||
classNamePrefix?: string;
|
||||
hideTabNavWhenSingle?: boolean;
|
||||
summaryPlacement?: 'inline' | 'tabs';
|
||||
@@ -204,11 +204,11 @@ const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
|
||||
|
||||
const summaryNode = summaryInline
|
||||
? (
|
||||
<>
|
||||
{renderSummarySection()}
|
||||
{renderDetailsSection()}
|
||||
</>
|
||||
)
|
||||
<>
|
||||
{renderSummarySection()}
|
||||
{renderDetailsSection()}
|
||||
</>
|
||||
)
|
||||
: null;
|
||||
|
||||
const visibleTabs = useMemo(() => {
|
||||
|
||||
@@ -14,11 +14,10 @@ import {
|
||||
import { describeDocumentSummary, type DocumentSummaryRow } from './documentSummary';
|
||||
|
||||
import { useFolderManager } from '../folders/FolderManagerContext';
|
||||
|
||||
type Identifier = string | number;
|
||||
import type { FolderId, Identifier, TagId } from '../types/identifiers';
|
||||
|
||||
interface TagEntry {
|
||||
id?: Identifier;
|
||||
id?: TagId;
|
||||
label?: string;
|
||||
color?: string | null;
|
||||
}
|
||||
@@ -33,7 +32,7 @@ interface DocumentLike {
|
||||
id?: Identifier;
|
||||
title?: string;
|
||||
issued_at?: string | null;
|
||||
folder_id?: string | null;
|
||||
folder_id?: FolderId | null;
|
||||
current_version?: { version_number?: number } | null;
|
||||
tags?: TagEntry[];
|
||||
correspondents?: CorrespondentEntry[];
|
||||
@@ -64,17 +63,17 @@ interface CorrespondentSectionProps {
|
||||
|
||||
export interface DocumentSummarySectionProps {
|
||||
document?: DocumentLike | null;
|
||||
tagLookupById?: Map<Identifier, TagEntry>;
|
||||
tagLookupById?: Map<TagId, TagEntry>;
|
||||
tagOptions?: SelectionAssignmentMenuItem[];
|
||||
onTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void;
|
||||
onTagRemove?: (docId: Identifier | undefined, tagId: Identifier | undefined) => void;
|
||||
onTagRemove?: (docId: Identifier | undefined, tagId: TagId | undefined) => void;
|
||||
correspondents?: CorrespondentEntry[];
|
||||
correspondentOptions?: SelectionAssignmentMenuItem[];
|
||||
onCorrespondentAdd?: (payload: { document: DocumentLike; name: string; option?: unknown }) => void;
|
||||
onCorrespondentRemove?: (payload: { documentId: Identifier | undefined; correspondentId: Identifier | undefined }) => void;
|
||||
onUpdateTitle?: (docId: Identifier | undefined, title: string) => Promise<boolean> | boolean;
|
||||
onUpdateIssued?: (docId: Identifier | undefined, timestamp: number | null) => Promise<boolean> | boolean;
|
||||
onFolderNavigate?: (folderId: string | null) => void;
|
||||
onFolderNavigate?: (folderId: FolderId | null) => void;
|
||||
layout?: 'default' | 'compact';
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const DEFAULT_THUMBNAIL_SIZE = 48;
|
||||
// Detect when an element becomes visible within a scroll container so we can delay loading.
|
||||
const useLazyVisibility = (
|
||||
rootRef: MutableRefObject<Element | null> | null,
|
||||
resetKey?: string | number | null,
|
||||
resetKey?: string | null,
|
||||
) => {
|
||||
const targetRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
@@ -8,8 +8,7 @@ import { resolveCorrespondents } from './correspondents';
|
||||
import { writeTagTransferData } from './tagTransfer';
|
||||
import useInlineRename from './useInlineRename';
|
||||
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
|
||||
|
||||
export type Identifier = string | number;
|
||||
import type { Identifier } from '../types/identifiers';
|
||||
|
||||
export interface FolderLike {
|
||||
id?: Identifier | 'root';
|
||||
@@ -153,347 +152,347 @@ const DocumentsGrid: React.FC<DocumentsGridProps> = ({
|
||||
const totalSelectionCount = documentSelectionCount + folderSelectionCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="documents-grid"
|
||||
role="list"
|
||||
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
<div
|
||||
className="documents-grid"
|
||||
role="list"
|
||||
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
clearSelection();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{entries.map((entry) => {
|
||||
if (entry.type === 'folder') {
|
||||
const folder = entry.folder;
|
||||
if (!folder) {
|
||||
return null;
|
||||
}
|
||||
const canDragFolder = folder.id !== 'root';
|
||||
const isDraggingFolder = draggedFolderId === folder.id;
|
||||
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
|
||||
const classes = ['document-card', 'folder-card'];
|
||||
if (isDraggingFolder) classes.push('is-dragging');
|
||||
if (isSelectedFolder) classes.push('selected');
|
||||
const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root';
|
||||
const isFolderEditing = editingFolderId === folder.id;
|
||||
const folderDraftValue = isFolderEditing ? folderDraft : folder.name;
|
||||
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
|
||||
const isFolderSaving = savingFolderId === folder.id;
|
||||
const canSubmitFolder =
|
||||
isFolderEditing && trimmedFolderDraft.length > 0 && trimmedFolderDraft !== folder.name;
|
||||
const allowInlineFolderEdit = canRenameFolder && isSelectedFolder && totalSelectionCount === 1;
|
||||
}}
|
||||
>
|
||||
{entries.map((entry) => {
|
||||
if (entry.type === 'folder') {
|
||||
const folder = entry.folder;
|
||||
if (!folder) {
|
||||
return null;
|
||||
}
|
||||
const canDragFolder = folder.id !== 'root';
|
||||
const isDraggingFolder = draggedFolderId === folder.id;
|
||||
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
|
||||
const classes = ['document-card', 'folder-card'];
|
||||
if (isDraggingFolder) classes.push('is-dragging');
|
||||
if (isSelectedFolder) classes.push('selected');
|
||||
const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root';
|
||||
const isFolderEditing = editingFolderId === folder.id;
|
||||
const folderDraftValue = isFolderEditing ? folderDraft : folder.name;
|
||||
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
|
||||
const isFolderSaving = savingFolderId === folder.id;
|
||||
const canSubmitFolder =
|
||||
isFolderEditing && trimmedFolderDraft.length > 0 && trimmedFolderDraft !== folder.name;
|
||||
const allowInlineFolderEdit = canRenameFolder && isSelectedFolder && totalSelectionCount === 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.key}
|
||||
className={classes.join(' ')}
|
||||
role="listitem"
|
||||
id={`folder-card-${folder.id}`}
|
||||
draggable={canDragFolder}
|
||||
onClick={(event) => onFolderClick?.(folder, event)}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
onFolderSelect?.(folder.id);
|
||||
}}
|
||||
onDragOver={(event) => onFolderDragOver?.(event, folder.id)}
|
||||
onDragLeave={onFolderDragLeave}
|
||||
onDrop={(event) => onFolderDrop?.(event, folder.id)}
|
||||
onDragStart={(event) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragStart?.(event, folder.id);
|
||||
}
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragEnd?.(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="folder-card__icon">
|
||||
<FolderIcon className="folder-card__icon-svg" size={gridIconSize} />
|
||||
</div>
|
||||
<div className="folder-card__meta">
|
||||
{isFolderEditing ? (
|
||||
<div className="folder-card__edit doc-title-edit">
|
||||
<input
|
||||
type="text"
|
||||
ref={attachFolderInputRef}
|
||||
value={folderDraftValue}
|
||||
onChange={(event) => setFolderDraft(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
return (
|
||||
<div
|
||||
key={entry.key}
|
||||
className={classes.join(' ')}
|
||||
role="listitem"
|
||||
id={`folder-card-${folder.id}`}
|
||||
draggable={canDragFolder}
|
||||
onClick={(event) => onFolderClick?.(folder, event)}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
onFolderSelect?.(folder.id);
|
||||
}}
|
||||
onDragOver={(event) => onFolderDragOver?.(event, folder.id)}
|
||||
onDragLeave={onFolderDragLeave}
|
||||
onDrop={(event) => onFolderDrop?.(event, folder.id)}
|
||||
onDragStart={(event) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragStart?.(event, folder.id);
|
||||
}
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragEnd?.(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="folder-card__icon">
|
||||
<FolderIcon className="folder-card__icon-svg" size={gridIconSize} />
|
||||
</div>
|
||||
<div className="folder-card__meta">
|
||||
{isFolderEditing ? (
|
||||
<div className="folder-card__edit doc-title-edit">
|
||||
<input
|
||||
type="text"
|
||||
ref={attachFolderInputRef}
|
||||
value={folderDraftValue}
|
||||
onChange={(event) => setFolderDraft(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
submitFolderEditing(folder);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelFolderEditing(event);
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
const nextFocus = event.relatedTarget;
|
||||
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
|
||||
cancelFolderEditing();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Save name"
|
||||
title="Save name"
|
||||
disabled={!canSubmitFolder || isFolderSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
submitFolderEditing(folder);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelFolderEditing(event);
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
const nextFocus = event.relatedTarget;
|
||||
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
|
||||
cancelFolderEditing();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Save name"
|
||||
title="Save name"
|
||||
disabled={!canSubmitFolder || isFolderSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
submitFolderEditing(folder);
|
||||
}}
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Cancel"
|
||||
title="Cancel"
|
||||
onClick={(event) => cancelFolderEditing(event)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="folder-card__label-row">
|
||||
<span
|
||||
className="folder-card__name"
|
||||
title={folder.name}
|
||||
role={allowInlineFolderEdit ? 'button' : undefined}
|
||||
tabIndex={allowInlineFolderEdit ? 0 : undefined}
|
||||
onClick={(event) => {
|
||||
if (!allowInlineFolderEdit) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
beginFolderEditing(folder);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!allowInlineFolderEdit) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
}}
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Cancel"
|
||||
title="Cancel"
|
||||
onClick={(event) => cancelFolderEditing(event)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="folder-card__label-row">
|
||||
<span
|
||||
className="folder-card__name"
|
||||
title={folder.name}
|
||||
role={allowInlineFolderEdit ? 'button' : undefined}
|
||||
tabIndex={allowInlineFolderEdit ? 0 : undefined}
|
||||
onClick={(event) => {
|
||||
if (!allowInlineFolderEdit) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
beginFolderEditing(folder);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{folder.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!allowInlineFolderEdit) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
beginFolderEditing(folder);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{folder.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const doc = entry.document;
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
const doc = entry.document;
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSelected = selectedDocumentIdsSet?.has(doc.id);
|
||||
const isDraggingDoc = draggingDocumentIdsSet?.has(doc.id);
|
||||
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
|
||||
const visibleTags = tagList.slice(0, 3);
|
||||
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
|
||||
const correspondents = resolveCorrespondents(doc);
|
||||
const cardClasses = ['document-card', 'document'];
|
||||
if (isSelected) cardClasses.push('selected');
|
||||
if (isDraggingDoc) cardClasses.push('is-dragging');
|
||||
const isEditingDoc = editingDocumentId === doc.id;
|
||||
const documentDraftValue = isEditingDoc ? documentDraft : doc.title;
|
||||
const trimmedDocumentDraft = isEditingDoc ? documentDraft.trim() : '';
|
||||
const isDocumentSaving = savingDocumentId === doc.id;
|
||||
const canSubmitDocument =
|
||||
isEditingDoc && trimmedDocumentDraft.length > 0 && trimmedDocumentDraft !== doc.title;
|
||||
const allowInlineDocumentEdit = onDocumentRename && isSelected && totalSelectionCount === 1;
|
||||
return (
|
||||
<div
|
||||
key={entry.key}
|
||||
className={cardClasses.join(' ')}
|
||||
role="listitem"
|
||||
id={`document-card-${doc.id}`}
|
||||
data-doc-id={doc.id}
|
||||
onClick={(event) => onDocumentClick?.(doc, event)}
|
||||
onDoubleClick={(event) => onDocumentActivate?.(doc, event)}
|
||||
draggable
|
||||
onDragStart={(event) => onDocumentDragStart?.(event, doc)}
|
||||
onDragEnd={(event) => onDocumentDragEnd?.(event)}
|
||||
onDragOver={(event) => onDocumentTagDragOver?.(event)}
|
||||
onDragOverCapture={(event) => onDocumentTagDragOver?.(event)}
|
||||
onDragLeave={onDocumentTagDragLeave}
|
||||
onDragLeaveCapture={onDocumentTagDragLeave}
|
||||
onDrop={(event) => onDocumentTagDrop?.(event, doc.id)}
|
||||
onDropCapture={(event) => onDocumentTagDrop?.(event, doc.id)}
|
||||
>
|
||||
<DocumentThumbnailImage
|
||||
document={doc}
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
alt={`Thumbnail for ${doc.title}`}
|
||||
maxSize={gridIconSize}
|
||||
scrollRootRef={scrollRef}
|
||||
/>
|
||||
<div className="document-card__meta">
|
||||
<div className="document-card__title" title={doc.title}>
|
||||
{correspondents.length > 0 ? (
|
||||
<span className="doc-correspondents">
|
||||
<CorrespondentLinks
|
||||
correspondents={correspondents}
|
||||
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||
onCorrespondentClick={onCorrespondentClick}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
{isEditingDoc ? (
|
||||
<div className="document-card__title-edit doc-title-edit">
|
||||
<input
|
||||
type="text"
|
||||
ref={attachDocumentInputRef}
|
||||
value={documentDraftValue}
|
||||
onChange={(event) => setDocumentDraft(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const isSelected = selectedDocumentIdsSet?.has(doc.id);
|
||||
const isDraggingDoc = draggingDocumentIdsSet?.has(doc.id);
|
||||
const tagList = Array.isArray(doc.tags) ? doc.tags : [];
|
||||
const visibleTags = tagList.slice(0, 3);
|
||||
const remainingTagCount = tagList.length > 3 ? tagList.length - 3 : 0;
|
||||
const correspondents = resolveCorrespondents(doc);
|
||||
const cardClasses = ['document-card', 'document'];
|
||||
if (isSelected) cardClasses.push('selected');
|
||||
if (isDraggingDoc) cardClasses.push('is-dragging');
|
||||
const isEditingDoc = editingDocumentId === doc.id;
|
||||
const documentDraftValue = isEditingDoc ? documentDraft : doc.title;
|
||||
const trimmedDocumentDraft = isEditingDoc ? documentDraft.trim() : '';
|
||||
const isDocumentSaving = savingDocumentId === doc.id;
|
||||
const canSubmitDocument =
|
||||
isEditingDoc && trimmedDocumentDraft.length > 0 && trimmedDocumentDraft !== doc.title;
|
||||
const allowInlineDocumentEdit = onDocumentRename && isSelected && totalSelectionCount === 1;
|
||||
return (
|
||||
<div
|
||||
key={entry.key}
|
||||
className={cardClasses.join(' ')}
|
||||
role="listitem"
|
||||
id={`document-card-${doc.id}`}
|
||||
data-doc-id={doc.id}
|
||||
onClick={(event) => onDocumentClick?.(doc, event)}
|
||||
onDoubleClick={(event) => onDocumentActivate?.(doc, event)}
|
||||
draggable
|
||||
onDragStart={(event) => onDocumentDragStart?.(event, doc)}
|
||||
onDragEnd={(event) => onDocumentDragEnd?.(event)}
|
||||
onDragOver={(event) => onDocumentTagDragOver?.(event)}
|
||||
onDragOverCapture={(event) => onDocumentTagDragOver?.(event)}
|
||||
onDragLeave={onDocumentTagDragLeave}
|
||||
onDragLeaveCapture={onDocumentTagDragLeave}
|
||||
onDrop={(event) => onDocumentTagDrop?.(event, doc.id)}
|
||||
onDropCapture={(event) => onDocumentTagDrop?.(event, doc.id)}
|
||||
>
|
||||
<DocumentThumbnailImage
|
||||
document={doc}
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
alt={`Thumbnail for ${doc.title}`}
|
||||
maxSize={gridIconSize}
|
||||
scrollRootRef={scrollRef}
|
||||
/>
|
||||
<div className="document-card__meta">
|
||||
<div className="document-card__title" title={doc.title}>
|
||||
{correspondents.length > 0 ? (
|
||||
<span className="doc-correspondents">
|
||||
<CorrespondentLinks
|
||||
correspondents={correspondents}
|
||||
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||
onCorrespondentClick={onCorrespondentClick}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
{isEditingDoc ? (
|
||||
<div className="document-card__title-edit doc-title-edit">
|
||||
<input
|
||||
type="text"
|
||||
ref={attachDocumentInputRef}
|
||||
value={documentDraftValue}
|
||||
onChange={(event) => setDocumentDraft(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
submitDocumentEditing(doc);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelDocumentEditing(event);
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
const nextFocus = event.relatedTarget;
|
||||
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
|
||||
cancelDocumentEditing();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Save title"
|
||||
title="Save title"
|
||||
disabled={!canSubmitDocument || isDocumentSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
submitDocumentEditing(doc);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelDocumentEditing(event);
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
const nextFocus = event.relatedTarget;
|
||||
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
|
||||
cancelDocumentEditing();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Save title"
|
||||
title="Save title"
|
||||
disabled={!canSubmitDocument || isDocumentSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
submitDocumentEditing(doc);
|
||||
}}
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Cancel"
|
||||
title="Cancel"
|
||||
onClick={(event) => cancelDocumentEditing(event)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="document-card__title-row">
|
||||
<span
|
||||
className="document-card__title-badge"
|
||||
role={allowInlineDocumentEdit ? 'button' : undefined}
|
||||
tabIndex={allowInlineDocumentEdit ? 0 : undefined}
|
||||
onClick={(event) => {
|
||||
if (!allowInlineDocumentEdit) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
beginDocumentEditing(doc);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!allowInlineDocumentEdit) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
}}
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Cancel"
|
||||
title="Cancel"
|
||||
onClick={(event) => cancelDocumentEditing(event)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="document-card__title-row">
|
||||
<span
|
||||
className="document-card__title-badge"
|
||||
role={allowInlineDocumentEdit ? 'button' : undefined}
|
||||
tabIndex={allowInlineDocumentEdit ? 0 : undefined}
|
||||
onClick={(event) => {
|
||||
if (!allowInlineDocumentEdit) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
beginDocumentEditing(doc);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{doc.title}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{visibleTags.length > 0 && (
|
||||
<div className="document-card__tags">
|
||||
{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={key}
|
||||
className="badge tag-chip"
|
||||
style={style || undefined}
|
||||
title={tag.label}
|
||||
role={clickable ? 'button' : undefined}
|
||||
onClick={clickable ? (event) => {
|
||||
event.stopPropagation();
|
||||
if (tagId == null) {
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!allowInlineDocumentEdit) {
|
||||
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, doc.id);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onKeyDown={clickable ? (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
beginDocumentEditing(doc);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{doc.title}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{visibleTags.length > 0 && (
|
||||
<div className="document-card__tags">
|
||||
{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={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}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{remainingTagCount > 0 && (
|
||||
<span className="badge tag-chip tag-chip--more">+{remainingTagCount}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
} : 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, doc.id);
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ import { resolveCorrespondents } from './correspondents';
|
||||
import { writeTagTransferData } from './tagTransfer';
|
||||
import useInlineRename from './useInlineRename';
|
||||
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
|
||||
|
||||
export type Identifier = string | number;
|
||||
import type { Identifier } from '../types/identifiers';
|
||||
|
||||
export interface FolderLike {
|
||||
id?: Identifier | 'root';
|
||||
@@ -160,356 +159,356 @@ const DocumentsList: React.FC<DocumentsListProps> = ({
|
||||
const totalSelectionCount = documentSelectionCount + folderSelectionCount;
|
||||
|
||||
return (
|
||||
<table aria-multiselectable="true">
|
||||
<thead
|
||||
onClick={() => {
|
||||
clearSelection();
|
||||
}}
|
||||
>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>Name</th>
|
||||
<th>Issued</th>
|
||||
<th>Added</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => {
|
||||
if (entry.type === 'folder') {
|
||||
const folder = entry.folder;
|
||||
if (!folder) {
|
||||
return null;
|
||||
}
|
||||
const canDragFolder = folder.id !== 'root';
|
||||
const isDraggingFolder = draggedFolderId === folder.id;
|
||||
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
|
||||
const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root';
|
||||
const isFolderEditing = editingFolderId === folder.id;
|
||||
const folderDraftValue = isFolderEditing ? folderDraft : folder.name;
|
||||
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
|
||||
const isFolderSaving = savingFolderId === folder.id;
|
||||
const canSubmitFolder =
|
||||
isFolderEditing && trimmedFolderDraft.length > 0 && trimmedFolderDraft !== folder.name;
|
||||
const allowInlineFolderEdit = canRenameFolder && isSelectedFolder && totalSelectionCount === 1;
|
||||
<table aria-multiselectable="true">
|
||||
<thead
|
||||
onClick={() => {
|
||||
clearSelection();
|
||||
}}
|
||||
>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>Name</th>
|
||||
<th>Issued</th>
|
||||
<th>Added</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => {
|
||||
if (entry.type === 'folder') {
|
||||
const folder = entry.folder;
|
||||
if (!folder) {
|
||||
return null;
|
||||
}
|
||||
const canDragFolder = folder.id !== 'root';
|
||||
const isDraggingFolder = draggedFolderId === folder.id;
|
||||
const isSelectedFolder = selectedFolderIdsSet?.has(folder.id);
|
||||
const canRenameFolder = Boolean(onFolderRename) && folder.id !== 'root';
|
||||
const isFolderEditing = editingFolderId === folder.id;
|
||||
const folderDraftValue = isFolderEditing ? folderDraft : folder.name;
|
||||
const trimmedFolderDraft = isFolderEditing ? folderDraft.trim() : '';
|
||||
const isFolderSaving = savingFolderId === folder.id;
|
||||
const canSubmitFolder =
|
||||
isFolderEditing && trimmedFolderDraft.length > 0 && trimmedFolderDraft !== folder.name;
|
||||
const allowInlineFolderEdit = canRenameFolder && isSelectedFolder && totalSelectionCount === 1;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={entry.key}
|
||||
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${isSelectedFolder ? ' selected' : ''}`}
|
||||
id={`folder-row-${folder.id}`}
|
||||
onClick={(event) => onFolderClick?.(folder, event)}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
onFolderSelect?.(folder.id);
|
||||
}}
|
||||
onDragOver={(event) => onFolderDragOver?.(event, folder.id)}
|
||||
onDragLeave={onFolderDragLeave}
|
||||
onDrop={(event) => onFolderDrop?.(event, folder.id)}
|
||||
draggable={canDragFolder}
|
||||
onDragStart={(event) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragStart?.(event, folder.id);
|
||||
}
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragEnd?.(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
{isFolderEditing ? (
|
||||
<span className="doc-title-edit">
|
||||
<input
|
||||
type="text"
|
||||
ref={attachFolderInputRef}
|
||||
value={folderDraftValue}
|
||||
onChange={(event) => setFolderDraft(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
return (
|
||||
<tr
|
||||
key={entry.key}
|
||||
className={`folder${isDraggingFolder ? ' is-dragging' : ''}${isSelectedFolder ? ' selected' : ''}`}
|
||||
id={`folder-row-${folder.id}`}
|
||||
onClick={(event) => onFolderClick?.(folder, event)}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
onFolderSelect?.(folder.id);
|
||||
}}
|
||||
onDragOver={(event) => onFolderDragOver?.(event, folder.id)}
|
||||
onDragLeave={onFolderDragLeave}
|
||||
onDrop={(event) => onFolderDrop?.(event, folder.id)}
|
||||
draggable={canDragFolder}
|
||||
onDragStart={(event) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragStart?.(event, folder.id);
|
||||
}
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
if (canDragFolder) {
|
||||
onFolderDragEnd?.(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
{isFolderEditing ? (
|
||||
<span className="doc-title-edit">
|
||||
<input
|
||||
type="text"
|
||||
ref={attachFolderInputRef}
|
||||
value={folderDraftValue}
|
||||
onChange={(event) => setFolderDraft(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
submitFolderEditing(folder);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelFolderEditing(event);
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
const nextFocus = event.relatedTarget;
|
||||
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
|
||||
cancelFolderEditing();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Save name"
|
||||
title="Save name"
|
||||
disabled={!canSubmitFolder || isFolderSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
submitFolderEditing(folder);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Cancel"
|
||||
title="Cancel"
|
||||
onClick={(event) => {
|
||||
cancelFolderEditing(event);
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
const nextFocus = event.relatedTarget;
|
||||
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
|
||||
cancelFolderEditing();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Save name"
|
||||
title="Save name"
|
||||
disabled={!canSubmitFolder || isFolderSaving}
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="doc-name__primary-text"
|
||||
role={allowInlineFolderEdit ? 'button' : undefined}
|
||||
tabIndex={allowInlineFolderEdit ? 0 : undefined}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
submitFolderEditing(folder);
|
||||
}}
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Cancel"
|
||||
title="Cancel"
|
||||
onClick={(event) => {
|
||||
cancelFolderEditing(event);
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="doc-name__primary-text"
|
||||
role={allowInlineFolderEdit ? 'button' : undefined}
|
||||
tabIndex={allowInlineFolderEdit ? 0 : undefined}
|
||||
onClick={(event) => {
|
||||
if (!allowInlineFolderEdit) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
beginFolderEditing(folder);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!allowInlineFolderEdit) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
if (!allowInlineFolderEdit) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
beginFolderEditing(folder);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{folder.name}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const doc = entry.document;
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
const isSelected = selectedDocumentIdsSet?.has(doc.id);
|
||||
const isDraggingDoc = draggingDocumentIdsSet?.has(doc.id);
|
||||
const rowClasses = ['document'];
|
||||
if (isSelected) rowClasses.push('selected');
|
||||
if (isDraggingDoc) rowClasses.push('is-dragging');
|
||||
const correspondents = resolveCorrespondents(doc);
|
||||
const isEditingDoc = editingDocumentId === doc.id;
|
||||
const documentDraftValue = isEditingDoc ? documentDraft : doc.title;
|
||||
const trimmedDocumentDraft = isEditingDoc ? documentDraft.trim() : '';
|
||||
const isDocumentSaving = savingDocumentId === doc.id;
|
||||
const canSubmitDocument =
|
||||
isEditingDoc && trimmedDocumentDraft.length > 0 && trimmedDocumentDraft !== doc.title;
|
||||
const allowInlineDocumentEdit =
|
||||
onDocumentRename && isSelected && totalSelectionCount === 1;
|
||||
const issuedLabel = formatDate(doc.issued_at);
|
||||
const addedLabel = formatDate(doc.created_at || doc.uploaded_at);
|
||||
return (
|
||||
<tr
|
||||
key={entry.key}
|
||||
className={rowClasses.join(' ')}
|
||||
id={`document-row-${doc.id}`}
|
||||
data-doc-id={doc.id}
|
||||
onClick={(event) => onDocumentClick?.(doc, event)}
|
||||
onDoubleClick={(event) => onDocumentActivate?.(doc, event)}
|
||||
draggable
|
||||
onDragStart={(event) => onDocumentDragStart?.(event, doc)}
|
||||
onDragEnd={(event) => onDocumentDragEnd?.(event)}
|
||||
onDragOver={onDocumentTagDragOver}
|
||||
onDragLeave={onDocumentTagDragLeave}
|
||||
onDrop={(event) => onDocumentTagDrop?.(event, doc.id)}
|
||||
>
|
||||
<td className="thumb-cell">
|
||||
<DocumentThumbnailImage
|
||||
document={doc}
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
alt={`Thumbnail for ${doc.title}`}
|
||||
scrollRootRef={scrollRef}
|
||||
/>
|
||||
</td>
|
||||
<td className="doc-list__name">
|
||||
<div className="doc-name">
|
||||
<div className="doc-list__name-content">
|
||||
<span className="doc-name__title">
|
||||
{correspondents.length > 0 ? (
|
||||
<span className="doc-correspondents">
|
||||
<CorrespondentLinks
|
||||
correspondents={correspondents}
|
||||
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||
onCorrespondentClick={onCorrespondentClick}
|
||||
/>
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!allowInlineFolderEdit) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
beginFolderEditing(folder);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{folder.name}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="doc-name__primary">
|
||||
{isEditingDoc ? (
|
||||
<span className="doc-title-edit">
|
||||
<input
|
||||
type="text"
|
||||
ref={attachDocumentInputRef}
|
||||
value={documentDraftValue}
|
||||
onChange={(event) => setDocumentDraft(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
submitDocumentEditing(doc);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelDocumentEditing(event);
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
const nextFocus = event.relatedTarget;
|
||||
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
|
||||
cancelDocumentEditing();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Save title"
|
||||
title="Save title"
|
||||
disabled={!canSubmitDocument || isDocumentSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
submitDocumentEditing(doc);
|
||||
}}
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Cancel"
|
||||
title="Cancel"
|
||||
onClick={(event) => {
|
||||
cancelDocumentEditing(event);
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const doc = entry.document;
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
const isSelected = selectedDocumentIdsSet?.has(doc.id);
|
||||
const isDraggingDoc = draggingDocumentIdsSet?.has(doc.id);
|
||||
const rowClasses = ['document'];
|
||||
if (isSelected) rowClasses.push('selected');
|
||||
if (isDraggingDoc) rowClasses.push('is-dragging');
|
||||
const correspondents = resolveCorrespondents(doc);
|
||||
const isEditingDoc = editingDocumentId === doc.id;
|
||||
const documentDraftValue = isEditingDoc ? documentDraft : doc.title;
|
||||
const trimmedDocumentDraft = isEditingDoc ? documentDraft.trim() : '';
|
||||
const isDocumentSaving = savingDocumentId === doc.id;
|
||||
const canSubmitDocument =
|
||||
isEditingDoc && trimmedDocumentDraft.length > 0 && trimmedDocumentDraft !== doc.title;
|
||||
const allowInlineDocumentEdit =
|
||||
onDocumentRename && isSelected && totalSelectionCount === 1;
|
||||
const issuedLabel = formatDate(doc.issued_at);
|
||||
const addedLabel = formatDate(doc.created_at || doc.uploaded_at);
|
||||
return (
|
||||
<tr
|
||||
key={entry.key}
|
||||
className={rowClasses.join(' ')}
|
||||
id={`document-row-${doc.id}`}
|
||||
data-doc-id={doc.id}
|
||||
onClick={(event) => onDocumentClick?.(doc, event)}
|
||||
onDoubleClick={(event) => onDocumentActivate?.(doc, event)}
|
||||
draggable
|
||||
onDragStart={(event) => onDocumentDragStart?.(event, doc)}
|
||||
onDragEnd={(event) => onDocumentDragEnd?.(event)}
|
||||
onDragOver={onDocumentTagDragOver}
|
||||
onDragLeave={onDocumentTagDragLeave}
|
||||
onDrop={(event) => onDocumentTagDrop?.(event, doc.id)}
|
||||
>
|
||||
<td className="thumb-cell">
|
||||
<DocumentThumbnailImage
|
||||
document={doc}
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
alt={`Thumbnail for ${doc.title}`}
|
||||
scrollRootRef={scrollRef}
|
||||
/>
|
||||
</td>
|
||||
<td className="doc-list__name">
|
||||
<div className="doc-name">
|
||||
<div className="doc-list__name-content">
|
||||
<span className="doc-name__title">
|
||||
{correspondents.length > 0 ? (
|
||||
<span className="doc-correspondents">
|
||||
<CorrespondentLinks
|
||||
correspondents={correspondents}
|
||||
activeCorrespondentIdSet={activeCorrespondentIdSet}
|
||||
onCorrespondentClick={onCorrespondentClick}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="doc-name__primary-text"
|
||||
role={allowInlineDocumentEdit ? 'button' : undefined}
|
||||
tabIndex={allowInlineDocumentEdit ? 0 : undefined}
|
||||
onClick={(event) => {
|
||||
if (!allowInlineDocumentEdit) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
beginDocumentEditing(doc);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!allowInlineDocumentEdit) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
) : null}
|
||||
<span className="doc-name__primary">
|
||||
{isEditingDoc ? (
|
||||
<span className="doc-title-edit">
|
||||
<input
|
||||
type="text"
|
||||
ref={attachDocumentInputRef}
|
||||
value={documentDraftValue}
|
||||
onChange={(event) => setDocumentDraft(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
submitDocumentEditing(doc);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelDocumentEditing(event);
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
const nextFocus = event.relatedTarget;
|
||||
if (!nextFocus || !event.currentTarget.parentElement?.contains(nextFocus)) {
|
||||
cancelDocumentEditing();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Save title"
|
||||
title="Save title"
|
||||
disabled={!canSubmitDocument || isDocumentSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
submitDocumentEditing(doc);
|
||||
}}
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label="Cancel"
|
||||
title="Cancel"
|
||||
onClick={(event) => {
|
||||
cancelDocumentEditing(event);
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="doc-name__primary-text"
|
||||
role={allowInlineDocumentEdit ? 'button' : undefined}
|
||||
tabIndex={allowInlineDocumentEdit ? 0 : undefined}
|
||||
onClick={(event) => {
|
||||
if (!allowInlineDocumentEdit) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
beginDocumentEditing(doc);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{doc.title}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{(doc.tags || []).length > 0 && (
|
||||
<div className="doc-name__tags">
|
||||
{(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={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, doc.id);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onKeyDown={clickable ? (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (tagId == null) {
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!allowInlineDocumentEdit) {
|
||||
return;
|
||||
}
|
||||
onTagClick?.(tagId);
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
beginDocumentEditing(doc);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{doc.title}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>{issuedLabel}</td>
|
||||
<td>{addedLabel}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{(doc.tags || []).length > 0 && (
|
||||
<div className="doc-name__tags">
|
||||
{(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={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, doc.id);
|
||||
}}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>{issuedLabel}</td>
|
||||
<td>{addedLabel}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { shallowEqual } from 'react-redux';
|
||||
|
||||
type DocumentId = string | number;
|
||||
import type { DocumentId } from '../types/identifiers';
|
||||
|
||||
export type ManagedDocument = { id?: DocumentId | null } & Record<string, unknown>;
|
||||
|
||||
|
||||
@@ -5,18 +5,18 @@ import { CheckIcon, CircleDashedCheckIcon, PlusIcon } from '../ui/icons';
|
||||
export type AssignmentState = 'all' | 'partial' | 'none';
|
||||
|
||||
export interface SelectionAssignmentMenuItem {
|
||||
id?: string | number;
|
||||
id?: string;
|
||||
label?: string;
|
||||
state?: AssignmentState;
|
||||
count?: number | null;
|
||||
total?: number | null;
|
||||
color?: string | null;
|
||||
value?: string | number;
|
||||
value?: string;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
export interface NormalizedSelectionAssignmentItem {
|
||||
id: string | number;
|
||||
id: string;
|
||||
label: string;
|
||||
state: AssignmentState;
|
||||
count: number | null;
|
||||
@@ -105,7 +105,7 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [pending, setPending] = useState(false);
|
||||
const [sortSnapshot, setSortSnapshot] = useState<Array<string | number> | null>(null);
|
||||
const [sortSnapshot, setSortSnapshot] = useState<Array<string> | null>(null);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
@@ -175,10 +175,10 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
|
||||
|
||||
const orderedItems = useMemo(() => {
|
||||
if (freezeSortOnOpen && sortSnapshot && sortByState) {
|
||||
const itemMap = new Map<string | number, NormalizedSelectionAssignmentItem>(
|
||||
const itemMap = new Map<string, NormalizedSelectionAssignmentItem>(
|
||||
sortedByStateItems.map((item) => [item.id, item]),
|
||||
);
|
||||
const seen = new Set<string | number>();
|
||||
const seen = new Set<string>();
|
||||
const fromSnapshot = sortSnapshot
|
||||
.map((id) => {
|
||||
const entry = itemMap.get(id);
|
||||
|
||||
@@ -12,10 +12,10 @@ import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './Selectio
|
||||
import SelectionSummary from './SelectionSummary';
|
||||
import { useAppState } from '../app/appState';
|
||||
import { useWorkspaceSelectionContext } from '../app/WorkspaceSelectionContext';
|
||||
import type { DocumentId } from '../types/identifiers';
|
||||
|
||||
const ROOT_FOLDER_LABEL = 'Documents';
|
||||
|
||||
type DocumentId = string | number;
|
||||
type NullableDocumentId = DocumentId | null;
|
||||
|
||||
type SelectedIdList = NullableDocumentId[] | null;
|
||||
@@ -145,7 +145,7 @@ const buildTagAssignments = (
|
||||
return [];
|
||||
}
|
||||
|
||||
const map = new Map<string | number, {
|
||||
const map = new Map<string, {
|
||||
id?: DocumentId;
|
||||
label: string;
|
||||
color: string | null;
|
||||
@@ -209,7 +209,7 @@ const buildCorrespondentAssignments = (
|
||||
return [];
|
||||
}
|
||||
|
||||
const map = new Map<string | number, {
|
||||
const map = new Map<string, {
|
||||
id?: DocumentId;
|
||||
label: string;
|
||||
count: number;
|
||||
@@ -491,7 +491,7 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
||||
const value = isRecord(candidate)
|
||||
? (candidate?.id ?? candidate?.value ?? null)
|
||||
: candidate;
|
||||
if (!value && value !== 0) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
await onMoveDocumentsToFolder(documentIdList, value as DocumentId);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
|
||||
type Identifier = string | number;
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
|
||||
export interface DocumentsFilterValue {
|
||||
query: string;
|
||||
searchResultIds: Array<string | number> | null;
|
||||
searchResultIds: Array<string> | null;
|
||||
searchLoading: boolean;
|
||||
includeDescendants: boolean;
|
||||
activeTagIds: Identifier[];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface CorrespondentReference {
|
||||
id?: string | number | null;
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
key?: string;
|
||||
}
|
||||
@@ -9,9 +9,9 @@ export interface DocumentLike {
|
||||
}
|
||||
|
||||
export interface ResolvedCorrespondent {
|
||||
id?: string | number | null;
|
||||
id?: string | null;
|
||||
name: string;
|
||||
key: string | number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export const resolveCorrespondents = (doc?: DocumentLike | null): ResolvedCorrespondent[] => {
|
||||
@@ -19,7 +19,7 @@ export const resolveCorrespondents = (doc?: DocumentLike | null): ResolvedCorres
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string | number>();
|
||||
const seen = new Set<string>();
|
||||
const results: ResolvedCorrespondent[] = [];
|
||||
|
||||
doc.correspondents.forEach((entry = {}, index) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { assignCorrespondentsBulk } from '../../lib/apiClient';
|
||||
|
||||
export type Identifier = string | number;
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
|
||||
type BulkAssignmentResponse = {
|
||||
assigned?: number;
|
||||
@@ -62,35 +61,35 @@ const useBulkDocumentActions = ({
|
||||
if (!target?.id) {
|
||||
setStatusMessage('Unable to resolve correspondent.', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const response: BulkAssignmentResponse = await assignCorrespondentsBulk<BulkAssignmentResponse>({
|
||||
document_ids: targets,
|
||||
assignments: [
|
||||
{
|
||||
correspondent_id: target.id,
|
||||
},
|
||||
],
|
||||
action: 'add',
|
||||
});
|
||||
|
||||
const { assigned = 0, removed = 0 } = response;
|
||||
|
||||
if (updateDocumentCaches && target.id) {
|
||||
targets.forEach((docId) => {
|
||||
updateDocumentCaches(docId, (doc) => {
|
||||
if (!doc) return doc;
|
||||
const current = Array.isArray((doc as any).correspondents) ? (doc as any).correspondents : [];
|
||||
if (current.some((entry: any) => entry?.id === target.id)) {
|
||||
return doc;
|
||||
}
|
||||
return {
|
||||
...(doc as any),
|
||||
correspondents: [...current, { id: target.id, name: (target as any).name }],
|
||||
};
|
||||
});
|
||||
const response: BulkAssignmentResponse = await assignCorrespondentsBulk<BulkAssignmentResponse>({
|
||||
document_ids: targets,
|
||||
assignments: [
|
||||
{
|
||||
correspondent_id: target.id,
|
||||
},
|
||||
],
|
||||
action: 'add',
|
||||
});
|
||||
}
|
||||
|
||||
const { assigned = 0, removed = 0 } = response;
|
||||
|
||||
if (updateDocumentCaches && target.id) {
|
||||
targets.forEach((docId) => {
|
||||
updateDocumentCaches(docId, (doc) => {
|
||||
if (!doc) return doc;
|
||||
const current = Array.isArray((doc as any).correspondents) ? (doc as any).correspondents : [];
|
||||
if (current.some((entry: any) => entry?.id === target.id)) {
|
||||
return doc;
|
||||
}
|
||||
return {
|
||||
...(doc as any),
|
||||
correspondents: [...current, { id: target.id, name: (target as any).name }],
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
const assignedSuffix = assigned === 1 ? '' : 's';
|
||||
if (removed > 0) {
|
||||
const removedSuffix = removed === 1 ? '' : 's';
|
||||
@@ -105,18 +104,18 @@ const useBulkDocumentActions = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
},
|
||||
[
|
||||
correspondentLookupByName,
|
||||
handleCorrespondentCreate,
|
||||
resolveTargetDocumentIds,
|
||||
setStatusMessage,
|
||||
updateDocumentCaches,
|
||||
],
|
||||
);
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
},
|
||||
[
|
||||
correspondentLookupByName,
|
||||
handleCorrespondentCreate,
|
||||
resolveTargetDocumentIds,
|
||||
setStatusMessage,
|
||||
updateDocumentCaches,
|
||||
],
|
||||
);
|
||||
|
||||
const handleBulkCorrespondentRemove = useCallback(
|
||||
async ({ assignments = [], documentIds }: { assignments?: CorrespondentAssignment[]; documentIds?: Identifier[] }) => {
|
||||
@@ -132,33 +131,33 @@ const useBulkDocumentActions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedAssignments = assignments.map((entry) => ({
|
||||
correspondent_id: entry.correspondent_id,
|
||||
}));
|
||||
const normalizedAssignments = assignments.map((entry) => ({
|
||||
correspondent_id: entry.correspondent_id,
|
||||
}));
|
||||
|
||||
const response: BulkAssignmentResponse = await assignCorrespondentsBulk<BulkAssignmentResponse>({
|
||||
document_ids: targets,
|
||||
assignments: normalizedAssignments,
|
||||
action: 'remove',
|
||||
});
|
||||
|
||||
const { assigned = 0, removed = 0 } = response;
|
||||
if (updateDocumentCaches) {
|
||||
targets.forEach((docId) => {
|
||||
updateDocumentCaches(docId, (doc) => {
|
||||
if (!doc || !Array.isArray((doc as any).correspondents)) {
|
||||
return doc;
|
||||
}
|
||||
const filtered = (doc as any).correspondents.filter(
|
||||
(entry: any) =>
|
||||
entry && !normalizedAssignments.some((assignment) => assignment.correspondent_id === entry.id),
|
||||
);
|
||||
return filtered.length === (doc as any).correspondents.length
|
||||
? doc
|
||||
: { ...(doc as any), correspondents: filtered };
|
||||
});
|
||||
const response: BulkAssignmentResponse = await assignCorrespondentsBulk<BulkAssignmentResponse>({
|
||||
document_ids: targets,
|
||||
assignments: normalizedAssignments,
|
||||
action: 'remove',
|
||||
});
|
||||
}
|
||||
|
||||
const { assigned = 0, removed = 0 } = response;
|
||||
if (updateDocumentCaches) {
|
||||
targets.forEach((docId) => {
|
||||
updateDocumentCaches(docId, (doc) => {
|
||||
if (!doc || !Array.isArray((doc as any).correspondents)) {
|
||||
return doc;
|
||||
}
|
||||
const filtered = (doc as any).correspondents.filter(
|
||||
(entry: any) =>
|
||||
entry && !normalizedAssignments.some((assignment) => assignment.correspondent_id === entry.id),
|
||||
);
|
||||
return filtered.length === (doc as any).correspondents.length
|
||||
? doc
|
||||
: { ...(doc as any), correspondents: filtered };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
const removedSuffix = removed === 1 ? '' : 's';
|
||||
@@ -169,12 +168,12 @@ const useBulkDocumentActions = ({
|
||||
} else if (assigned > 0) {
|
||||
const assignedSuffix = assigned === 1 ? '' : 's';
|
||||
setStatusMessage(`Correspondent updated ${assigned} link${assignedSuffix}.`, 'info');
|
||||
} else {
|
||||
setStatusMessage('No correspondents changed.', 'info');
|
||||
}
|
||||
},
|
||||
[resolveTargetDocumentIds, setStatusMessage, updateDocumentCaches],
|
||||
);
|
||||
} else {
|
||||
setStatusMessage('No correspondents changed.', 'info');
|
||||
}
|
||||
},
|
||||
[resolveTargetDocumentIds, setStatusMessage, updateDocumentCaches],
|
||||
);
|
||||
|
||||
const handleDeleteSelection = useCallback(async () => {
|
||||
const docIds = Array.isArray(selectedDocumentIds) ? selectedDocumentIds : [];
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { WorkspaceSelectionValue } from '../../app/WorkspaceSelectionContext';
|
||||
|
||||
type Identifier = string | number;
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
|
||||
interface DocumentLinkLike {
|
||||
url?: string | null;
|
||||
@@ -96,8 +95,8 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
||||
searchLoading,
|
||||
tagLookupById,
|
||||
activeCorrespondentFilters,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
handleDocumentTagDrop,
|
||||
documentsViewMode,
|
||||
documentsSortField,
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { createDocumentEntryKey, createFolderEntryKey, isFolderEntry } from '../../app/entryKey';
|
||||
import type { DocumentId, FolderId } from '../../types/identifiers';
|
||||
|
||||
interface FolderEntry {
|
||||
id: string | number;
|
||||
id: FolderId;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DocumentEntry {
|
||||
id: string | number;
|
||||
id: DocumentId;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface NavigableRow {
|
||||
key: string;
|
||||
type: 'folder' | 'document';
|
||||
id: string | number;
|
||||
id: FolderId | DocumentId;
|
||||
}
|
||||
|
||||
interface UseDocumentsSelectionOptions {
|
||||
@@ -25,19 +26,19 @@ interface UseDocumentsSelectionOptions {
|
||||
visibleRowKeySet: Set<string>;
|
||||
selectedEntries: string[];
|
||||
selectionAnchorRef: { current: string | null };
|
||||
promoteSelectionOrderRaw: (id: string | number) => void;
|
||||
setFocusedDocumentId: (id: string | number | null) => void;
|
||||
setActivePreviewId: (id: string | number | null) => void;
|
||||
promoteSelectionOrderRaw: (id: DocumentId) => void;
|
||||
setFocusedDocumentId: (id: DocumentId | null) => void;
|
||||
setActivePreviewId: (id: DocumentId | null) => void;
|
||||
clearSelection: () => void;
|
||||
focusedDocumentId: string | number | null;
|
||||
focusedDocumentId: DocumentId | null;
|
||||
setFocusedRowKey: (value: string | null | ((current: string | null) => string | null)) => void;
|
||||
focusedRowKey: string | null;
|
||||
}
|
||||
|
||||
const useDocumentsSelection = ({
|
||||
showingSearchResults,
|
||||
currentSubfolders,
|
||||
visibleDocuments,
|
||||
currentSubfolders = [],
|
||||
visibleDocuments = [],
|
||||
configureSelectionEnvironment,
|
||||
visibleRowKeySet,
|
||||
selectedEntries,
|
||||
@@ -82,7 +83,7 @@ const useDocumentsSelection = ({
|
||||
}, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]);
|
||||
|
||||
const promoteSelectionOrder = useCallback(
|
||||
(docId: string | number | null) => {
|
||||
(docId: DocumentId | null) => {
|
||||
if (!docId) return;
|
||||
promoteSelectionOrderRaw(docId);
|
||||
const rowKey = createDocumentEntryKey(docId);
|
||||
@@ -99,7 +100,7 @@ const useDocumentsSelection = ({
|
||||
clearSelection();
|
||||
}, [clearSelection]);
|
||||
|
||||
const prevFocusedDocIdRef = useRef<string | number | null>(focusedDocumentId);
|
||||
const prevFocusedDocIdRef = useRef<DocumentId | null>(focusedDocumentId);
|
||||
useEffect(() => {
|
||||
const previous = prevFocusedDocIdRef.current;
|
||||
if (previous === focusedDocumentId) {
|
||||
@@ -109,7 +110,7 @@ const useDocumentsSelection = ({
|
||||
if (focusedDocumentId) {
|
||||
setFocusedRowKey(createDocumentEntryKey(focusedDocumentId));
|
||||
} else {
|
||||
setFocusedRowKey((current) => (isFolderEntry(current) ? current : null));
|
||||
setFocusedRowKey((current) => (current && isFolderEntry(current) ? current : null));
|
||||
}
|
||||
}, [focusedDocumentId, setFocusedRowKey]);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import DocumentsPanelHeader, {
|
||||
import { SelectionFloatingPanel } from '../SelectionFloatingActions';
|
||||
import { createDocumentsTableHeaderActions } from './DocumentsToolbar';
|
||||
import { useDocumentsFilter } from '../context/DocumentsFilterContext';
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
|
||||
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||
|
||||
@@ -73,7 +74,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
||||
documentLinks,
|
||||
ensureDownloadUrl,
|
||||
deskWorkspaceProps = null,
|
||||
onRefresh = () => {},
|
||||
onRefresh = () => { },
|
||||
sortField,
|
||||
sortDirection,
|
||||
onSortFieldChange,
|
||||
@@ -108,8 +109,8 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
||||
() =>
|
||||
Array.isArray(searchResultIds)
|
||||
? searchResultIds
|
||||
.map((id) => documentLookup?.get?.(id) || null)
|
||||
.filter((doc): doc is Record<string, unknown> => Boolean(doc))
|
||||
.map((id) => documentLookup?.get?.(id) || null)
|
||||
.filter((doc): doc is Record<string, unknown> => Boolean(doc))
|
||||
: null,
|
||||
[searchResultIds, documentLookup],
|
||||
);
|
||||
@@ -256,8 +257,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
|
||||
const isGridView = viewMode === 'grid';
|
||||
const isDeskView = viewMode === 'desk';
|
||||
|
||||
type Identifier = string | number;
|
||||
type ZoomSource = { url: string; alt?: string | null; mimeType?: string | null };
|
||||
type ZoomSource = { url: string; alt?: string | null; mimeType?: string | null };
|
||||
|
||||
const [previewDocId, setPreviewDocId] = useState<Identifier | null>(null);
|
||||
|
||||
@@ -499,7 +499,7 @@ type ZoomSource = { url: string; alt?: string | null; mimeType?: string | null }
|
||||
setFocusedRowKey(targetRow.key);
|
||||
handleEntrySelection(targetRow.key, {
|
||||
shiftKey,
|
||||
preventDefault: () => {},
|
||||
preventDefault: () => { },
|
||||
});
|
||||
},
|
||||
[
|
||||
|
||||
@@ -2,8 +2,7 @@ import React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import PanelHeader from '../../ui/PanelHeader';
|
||||
import BreadcrumbTrail from '../../ui/BreadcrumbTrail';
|
||||
|
||||
type Identifier = string | number;
|
||||
import type { Identifier } from '../../types/identifiers';
|
||||
|
||||
export interface DocumentsHeaderBreadcrumb {
|
||||
id?: Identifier;
|
||||
@@ -40,12 +39,12 @@ const DocumentsPanelHeader: React.FC<DocumentsPanelHeaderProps> = ({
|
||||
const lastIndex = breadcrumbEntries.length - 1;
|
||||
const trailEntries = breadcrumbEntries.length
|
||||
? breadcrumbEntries.map((crumb, index) => ({
|
||||
id: crumb.id ?? index,
|
||||
label: crumb.name ?? crumb.label ?? crumb.title ?? '',
|
||||
onClick: index < lastIndex && onBreadcrumbClick
|
||||
? () => onBreadcrumbClick(crumb)
|
||||
: null,
|
||||
}))
|
||||
id: crumb.id ?? index,
|
||||
label: crumb.name ?? crumb.label ?? crumb.title ?? '',
|
||||
onClick: index < lastIndex && onBreadcrumbClick
|
||||
? () => onBreadcrumbClick(crumb)
|
||||
: null,
|
||||
}))
|
||||
: [{ id: 'current-location', label: header.title }];
|
||||
|
||||
const headerTitle = (
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { DocumentId, TagId } from '../types/identifiers';
|
||||
|
||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||
const TAG_TEXT_MIME_TYPE = 'text/plain';
|
||||
|
||||
interface TagPayload {
|
||||
id: string | number;
|
||||
id: TagId;
|
||||
label: string;
|
||||
sourceDocId: string | number | null;
|
||||
sourceDocId: DocumentId | null;
|
||||
}
|
||||
|
||||
interface TagLike {
|
||||
id?: string | number;
|
||||
id?: TagId;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
@@ -21,7 +23,10 @@ const serializePayload = (payload: TagPayload): string | null => {
|
||||
}
|
||||
};
|
||||
|
||||
export const createTagTransferPayload = (tag?: TagLike | null, sourceDocId: string | number | null = null): TagPayload | null => {
|
||||
export const createTagTransferPayload = (
|
||||
tag?: TagLike | null,
|
||||
sourceDocId: DocumentId | null = null,
|
||||
): TagPayload | null => {
|
||||
if (!tag || tag.id == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -33,7 +38,11 @@ export const createTagTransferPayload = (tag?: TagLike | null, sourceDocId: stri
|
||||
};
|
||||
};
|
||||
|
||||
export const writeTagTransferData = (dataTransfer: DataTransfer | null, tag: TagLike, sourceDocId: string | number | null = null): void => {
|
||||
export const writeTagTransferData = (
|
||||
dataTransfer: DataTransfer | null,
|
||||
tag: TagLike,
|
||||
sourceDocId: DocumentId | null = null,
|
||||
): void => {
|
||||
if (!dataTransfer) {
|
||||
return;
|
||||
}
|
||||
@@ -122,7 +131,7 @@ export const isTagTransferEvent = (event?: DragEventLike | null): boolean => {
|
||||
if (!types) {
|
||||
return false;
|
||||
}
|
||||
const typeList = Array.isArray(types) ? [...types] : Array.from(types);
|
||||
const typeList = Array.isArray(types) ? [...types] : Array.from(types);
|
||||
return TAG_MIME_TYPES.some((type) => typeList.includes(type));
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export const isPrimaryPointerEvent = (event?: PointerEventLike | null): boolean
|
||||
export type EntryType = 'document' | 'folder';
|
||||
|
||||
export interface WorkspaceEntry {
|
||||
id: string | number;
|
||||
id: string;
|
||||
key?: string;
|
||||
type: EntryType;
|
||||
[key: string]: unknown;
|
||||
@@ -28,7 +28,7 @@ export interface WorkspaceEntry {
|
||||
|
||||
interface UseEntryPointerOptions {
|
||||
onSelectEntry?: (entry: WorkspaceEntry, event?: PointerEventLike | null, metadata?: EntryPointerMetadata) => void;
|
||||
onDocumentActivate?: (id: string | number, metadata?: EntryPointerMetadata) => void;
|
||||
onDocumentActivate?: (id: string, metadata?: EntryPointerMetadata) => void;
|
||||
}
|
||||
|
||||
export interface EntryPointerMetadata {
|
||||
@@ -36,7 +36,7 @@ export interface EntryPointerMetadata {
|
||||
primaryClick: boolean;
|
||||
rowKey: string;
|
||||
type: EntryType;
|
||||
id: string | number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const useEntryPointer = ({
|
||||
|
||||
@@ -13,22 +13,22 @@ type FocusableInput = (HTMLInputElement | HTMLTextAreaElement) & {
|
||||
|
||||
type InlineRenameOptions<TEntity> = {
|
||||
getCurrentValue?: (entity: TEntity) => string | null;
|
||||
getEntityId?: (entity: TEntity) => string | number | null;
|
||||
getEntityId?: (entity: TEntity) => string | null;
|
||||
};
|
||||
|
||||
type InlineRenameHandler = (
|
||||
id: string | number,
|
||||
id: string,
|
||||
value: string,
|
||||
) => boolean | void | Promise<boolean | void>;
|
||||
|
||||
type InlineRenameReturn<TEntity> = {
|
||||
editingId: string | number | null;
|
||||
editingId: string | null;
|
||||
draftValue: string;
|
||||
setDraftValue: Dispatch<SetStateAction<string>>;
|
||||
beginEditing: (entity?: TEntity | null, event?: SyntheticEvent | Event) => void;
|
||||
cancelEditing: (event?: SyntheticEvent | Event) => void;
|
||||
submitEditing: (entity?: TEntity | null) => Promise<boolean>;
|
||||
savingId: string | number | null;
|
||||
savingId: string | null;
|
||||
attachInputRef: (node: FocusableInput | null) => void;
|
||||
};
|
||||
|
||||
@@ -51,18 +51,18 @@ const focusInput = (node: FocusableInput | null) => {
|
||||
const identity = (value: unknown) => value as string;
|
||||
|
||||
const defaultGetEntityId = <T,>(entity?: T | null) =>
|
||||
(entity as { id?: string | number } | null)?.id ?? null;
|
||||
(entity as { id?: string } | null)?.id ?? null;
|
||||
|
||||
const useInlineRename = <TEntity,>(
|
||||
onRename?: InlineRenameHandler,
|
||||
{
|
||||
getCurrentValue = identity as (entity: TEntity) => string | null,
|
||||
getEntityId = defaultGetEntityId as (entity: TEntity) => string | number | null,
|
||||
getEntityId = defaultGetEntityId as (entity: TEntity) => string | null,
|
||||
}: InlineRenameOptions<TEntity> = {},
|
||||
): InlineRenameReturn<TEntity> => {
|
||||
const [editingId, setEditingId] = useState<string | number | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [draftValue, setDraftValue] = useState('');
|
||||
const [savingId, setSavingId] = useState<string | number | null>(null);
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const inputRef = useRef<FocusableInput | null>(null);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user