typescript
This commit is contained in:
+18
-5
@@ -2,7 +2,19 @@ import React from 'react';
|
||||
|
||||
const NBSP = String.fromCharCode(160);
|
||||
|
||||
const CorrespondentLinks = ({
|
||||
export interface CorrespondentLinkEntry {
|
||||
id?: string | number | null;
|
||||
name?: string | null;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
interface CorrespondentLinksProps {
|
||||
correspondents?: CorrespondentLinkEntry[];
|
||||
activeCorrespondentIdSet?: Set<string | number>;
|
||||
onCorrespondentClick?: (id: string | number) => void;
|
||||
}
|
||||
|
||||
const CorrespondentLinks: React.FC<CorrespondentLinksProps> = ({
|
||||
correspondents,
|
||||
activeCorrespondentIdSet,
|
||||
onCorrespondentClick,
|
||||
@@ -11,8 +23,8 @@ const CorrespondentLinks = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeSet = activeCorrespondentIdSet || new Set();
|
||||
const handleClick = (event, correspondent) => {
|
||||
const activeSet = activeCorrespondentIdSet || new Set<string | number>();
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>, correspondent: CorrespondentLinkEntry) => {
|
||||
if (!onCorrespondentClick || correspondent.id == null) {
|
||||
return;
|
||||
}
|
||||
@@ -27,11 +39,12 @@ const CorrespondentLinks = ({
|
||||
if (isActive) classNames.push('is-active');
|
||||
if (!hasHandler) classNames.push('is-static');
|
||||
const isLast = index === correspondents.length - 1;
|
||||
const label = isLast ? `${correspondent.name}:${NBSP}` : correspondent.name;
|
||||
const fallbackLabel = correspondent.name ?? '—';
|
||||
const label = isLast ? `${fallbackLabel}:${NBSP}` : fallbackLabel;
|
||||
|
||||
return (
|
||||
<React.Fragment
|
||||
key={correspondent.key ?? correspondent.id ?? `${correspondent.name}-${index}`}
|
||||
key={correspondent.key ?? correspondent.id ?? `${fallbackLabel}-${index}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
+66
-24
@@ -1,8 +1,54 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import DocumentSummarySection from './DocumentSummarySection';
|
||||
import type { ReactNode } from 'react';
|
||||
import DocumentSummarySection, { DocumentSummarySectionProps } from './DocumentSummarySection';
|
||||
import { buildDocumentMetadataItems, extractDocumentMetadataPayload } from './documentMetadata';
|
||||
|
||||
const DocumentInfoPanel = ({
|
||||
type PanelTab = { id: string; label: string; render: () => ReactNode };
|
||||
|
||||
type ContentState =
|
||||
| { status: 'idle'; data: null; error: null }
|
||||
| { status: 'loading'; data: null; error: null }
|
||||
| { status: 'loaded'; data: string; error: null }
|
||||
| { status: 'empty'; data: string; error: null }
|
||||
| { status: 'unavailable'; data: null; error: null }
|
||||
| { status: 'error'; data: null; error: unknown };
|
||||
|
||||
interface DocumentInfoPanelProps {
|
||||
document: DocumentSummarySectionProps['document'];
|
||||
summaryProps?: Omit<DocumentSummarySectionProps, 'document' | 'detailItems' | 'layout'>;
|
||||
metadataItems?: Array<{ label: string; value?: string }>;
|
||||
metadataPayload?: Record<string, unknown>;
|
||||
metadataTabLabel?: string;
|
||||
detailsTabLabel?: string;
|
||||
contentConfig?: {
|
||||
id?: string;
|
||||
label?: string;
|
||||
enabled?: boolean;
|
||||
forceDisplay?: boolean;
|
||||
loadContent?: (args: { signal: AbortSignal }) => Promise<string>;
|
||||
onCancel?: () => void;
|
||||
loadingMessage?: string;
|
||||
emptyMessage?: string;
|
||||
unavailableMessage?: string;
|
||||
errorMessage?: string;
|
||||
renderContent?: (data: string) => ReactNode;
|
||||
} | null;
|
||||
activeTab?: string;
|
||||
onTabChange?: (tabId: string) => void;
|
||||
defaultTabId?: string;
|
||||
resetKey?: string | number | null;
|
||||
classNamePrefix?: string;
|
||||
hideTabNavWhenSingle?: boolean;
|
||||
summaryPlacement?: 'inline' | 'tabs';
|
||||
summaryTabLabel?: string;
|
||||
summaryTabId?: string;
|
||||
leadingTabs?: PanelTab[];
|
||||
trailingTabs?: PanelTab[];
|
||||
tabsPlacement?: 'top' | 'bottom';
|
||||
summaryLayout?: 'default' | 'compact';
|
||||
}
|
||||
|
||||
const DocumentInfoPanel: React.FC<DocumentInfoPanelProps> = ({
|
||||
document,
|
||||
summaryProps = {},
|
||||
metadataItems: metadataItemsProp,
|
||||
@@ -42,9 +88,9 @@ const DocumentInfoPanel = ({
|
||||
|
||||
const contentConfig = contentConfigProp || null;
|
||||
const contentEnabled = Boolean(contentConfig && (contentConfig.enabled ?? true));
|
||||
const showContentTab = Boolean(contentConfig && ((contentConfig.forceDisplay ?? contentEnabled)));
|
||||
const showContentTab = Boolean(contentConfig && (contentConfig.forceDisplay ?? contentEnabled));
|
||||
|
||||
const [contentState, setContentState] = useState(() => {
|
||||
const [contentState, setContentState] = useState<ContentState | null>(() => {
|
||||
if (!contentConfig) {
|
||||
return null;
|
||||
}
|
||||
@@ -144,14 +190,14 @@ const DocumentInfoPanel = ({
|
||||
|
||||
const normalizedLeadingTabs = useMemo(
|
||||
() => (Array.isArray(leadingTabs)
|
||||
? leadingTabs.filter((tab) => tab && tab.id && tab.label)
|
||||
? leadingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
|
||||
: []),
|
||||
[leadingTabs],
|
||||
);
|
||||
|
||||
const normalizedTrailingTabs = useMemo(
|
||||
() => (Array.isArray(trailingTabs)
|
||||
? trailingTabs.filter((tab) => tab && tab.id && tab.label)
|
||||
? trailingTabs.filter((tab): tab is PanelTab => Boolean(tab && tab.id && tab.label))
|
||||
: []),
|
||||
[trailingTabs],
|
||||
);
|
||||
@@ -166,7 +212,7 @@ const DocumentInfoPanel = ({
|
||||
: null;
|
||||
|
||||
const visibleTabs = useMemo(() => {
|
||||
const tabsList = [];
|
||||
const tabsList: PanelTab[] = [];
|
||||
|
||||
if (normalizedLeadingTabs.length) {
|
||||
tabsList.push(...normalizedLeadingTabs);
|
||||
@@ -251,17 +297,17 @@ const DocumentInfoPanel = ({
|
||||
}
|
||||
|
||||
if (metadataPayload) {
|
||||
tabsList.push({
|
||||
id: 'metadata',
|
||||
label: metadataTabLabel,
|
||||
render: () => (
|
||||
<section className={`${base}__section ${base}__section--metadata-json`}>
|
||||
<pre className={`${base}__metadata-json`}>
|
||||
{JSON.stringify(metadataPayload, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
),
|
||||
});
|
||||
tabsList.push({
|
||||
id: 'metadata',
|
||||
label: metadataTabLabel,
|
||||
render: () => (
|
||||
<section className={`${base}__section ${base}__section--metadata-json`}>
|
||||
<pre className={`${base}__metadata-json`}>
|
||||
{JSON.stringify(metadataPayload, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedTrailingTabs.length) {
|
||||
@@ -295,18 +341,14 @@ const DocumentInfoPanel = ({
|
||||
return visibleTabs[0].id;
|
||||
}, [visibleTabs, defaultTabId]);
|
||||
|
||||
const renderTabContent = (tab, context = {}) => {
|
||||
const renderTabContent = (tab?: PanelTab | null, context: Record<string, unknown> = {}) => {
|
||||
if (!tab) {
|
||||
return null;
|
||||
}
|
||||
if (typeof tab.render === 'function') {
|
||||
return tab.render(context);
|
||||
}
|
||||
if (tab.component) {
|
||||
const TabComponent = tab.component;
|
||||
return <TabComponent {...context} />;
|
||||
}
|
||||
return React.isValidElement(tab.render) ? tab.render : null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const isControlled = controlledActiveTab !== undefined && controlledActiveTab !== null;
|
||||
+118
-38
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { EditIcon, IconX, PlusIcon } from '../ui/icons';
|
||||
import SelectionAssignmentMenu from './SelectionAssignmentMenu';
|
||||
import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import {
|
||||
formatDate,
|
||||
@@ -9,6 +10,68 @@ import {
|
||||
} from '../utils/date';
|
||||
import { describeDocumentSummary } from './documentSummary';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface TagEntry {
|
||||
id?: Identifier;
|
||||
label?: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
interface CorrespondentEntry {
|
||||
id?: Identifier;
|
||||
name?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier;
|
||||
title?: string;
|
||||
issued_at?: string | null;
|
||||
current_version?: { version_number?: number } | null;
|
||||
tags?: TagEntry[];
|
||||
correspondents?: CorrespondentEntry[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TagSectionProps {
|
||||
tags?: TagEntry[];
|
||||
onRemove?: (tag: TagEntry) => void;
|
||||
onAdd?: (payload: { value: string; option?: unknown; input?: unknown }) => void;
|
||||
emptyMessage?: string;
|
||||
addPlaceholder?: string;
|
||||
addButtonLabel?: string;
|
||||
datalistOptions?: Array<SelectionAssignmentMenuItem | string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CorrespondentSectionProps {
|
||||
entries?: CorrespondentEntry[];
|
||||
onRemove?: (entry: CorrespondentEntry) => void;
|
||||
onAdd?: (payload: { name: string; option?: unknown; input?: unknown }) => void;
|
||||
showCount?: boolean;
|
||||
addPlaceholder?: string;
|
||||
addButtonLabel?: string;
|
||||
datalistOptions?: Array<SelectionAssignmentMenuItem | string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface DocumentSummarySectionProps {
|
||||
document?: DocumentLike | null;
|
||||
tagLookupById?: Map<Identifier, TagEntry>;
|
||||
tagOptions?: SelectionAssignmentMenuItem[];
|
||||
onTagAdd?: (doc: DocumentLike, value: string, context?: { option?: unknown }) => void;
|
||||
onTagRemove?: (docId: Identifier | undefined, tagId: Identifier | 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;
|
||||
layout?: 'default' | 'compact';
|
||||
detailItems?: Array<{ label?: string; value?: string }>;
|
||||
}
|
||||
|
||||
export const sortCorrespondents = (entries = []) =>
|
||||
entries
|
||||
.filter((entry) => entry && entry.name)
|
||||
@@ -32,15 +95,28 @@ export const buildCorrespondentOptions = (entries = []) => {
|
||||
}, []);
|
||||
};
|
||||
|
||||
const normalizeOptions = (options) => (Array.isArray(options) ? options : []);
|
||||
const normalizeOptions = <T,>(options?: T[] | null): T[] => (Array.isArray(options) ? options : []);
|
||||
|
||||
const normalizeQuickAddOption = (option) => {
|
||||
interface QuickAddOption {
|
||||
id?: Identifier;
|
||||
label?: string;
|
||||
name?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface QuickAddEntry {
|
||||
id: Identifier | string;
|
||||
label: string;
|
||||
original: QuickAddOption | string;
|
||||
}
|
||||
|
||||
const normalizeQuickAddOption = (option: QuickAddOption | string | null | undefined): QuickAddEntry | null => {
|
||||
if (option == null) {
|
||||
return null;
|
||||
}
|
||||
const isObject = typeof option === 'object';
|
||||
const labelSource = isObject ? option.label ?? option.name ?? '' : option;
|
||||
const label = labelSource?.trim?.() || '';
|
||||
const label = typeof labelSource === 'string' ? labelSource.trim() : '';
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
@@ -51,7 +127,7 @@ const normalizeQuickAddOption = (option) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const TagSection = ({
|
||||
export const TagSection: React.FC<TagSectionProps> = ({
|
||||
tags = [],
|
||||
onRemove,
|
||||
onAdd,
|
||||
@@ -62,17 +138,17 @@ export const TagSection = ({
|
||||
className,
|
||||
}) => {
|
||||
const handleCreate = useCallback(
|
||||
(label) => onAdd?.({ value: label, input: null }),
|
||||
(label: string) => onAdd?.({ value: label, input: null }),
|
||||
[onAdd],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(option) => {
|
||||
(option: { label?: string; name?: string } | string | null) => {
|
||||
if (!onAdd) return;
|
||||
const labelSource = option && typeof option === 'object'
|
||||
? option.label ?? option.name ?? ''
|
||||
: option;
|
||||
const label = labelSource?.trim?.() || '';
|
||||
const label = typeof labelSource === 'string' ? labelSource.trim() : '';
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
@@ -85,14 +161,14 @@ export const TagSection = ({
|
||||
() =>
|
||||
normalizeOptions(datalistOptions)
|
||||
.map((option) => normalizeQuickAddOption(option))
|
||||
.filter(Boolean),
|
||||
.filter((option): option is QuickAddEntry => Boolean(option)),
|
||||
[datalistOptions],
|
||||
);
|
||||
const containerClass = className ? `tag-list ${className}` : 'tag-list';
|
||||
const showQuickAdd = Boolean(onAdd);
|
||||
|
||||
const assignmentItems = useMemo(() => {
|
||||
const map = new Map();
|
||||
const assignmentItems = useMemo<SelectionAssignmentMenuItem[]>(() => {
|
||||
const map = new Map<string, SelectionAssignmentMenuItem>();
|
||||
|
||||
normalizedOptions.forEach((option) => {
|
||||
const label = option?.label?.trim();
|
||||
@@ -120,8 +196,10 @@ export const TagSection = ({
|
||||
const payload = { id: tag.id, label, color: tag.color ?? null };
|
||||
if (map.has(key)) {
|
||||
const entry = map.get(key);
|
||||
entry.state = 'all';
|
||||
entry.payload = payload;
|
||||
if (entry) {
|
||||
entry.state = 'all';
|
||||
entry.payload = payload;
|
||||
}
|
||||
return;
|
||||
}
|
||||
map.set(key, {
|
||||
@@ -136,7 +214,7 @@ export const TagSection = ({
|
||||
}, [normalizedOptions, tags]);
|
||||
|
||||
const handleAssignmentSelect = useCallback(
|
||||
(item) => {
|
||||
(item: SelectionAssignmentMenuItem | null) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
@@ -192,7 +270,7 @@ export const TagSection = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const CorrespondentSection = ({
|
||||
export const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
|
||||
entries = [],
|
||||
onRemove,
|
||||
onAdd,
|
||||
@@ -203,7 +281,7 @@ export const CorrespondentSection = ({
|
||||
className,
|
||||
}) => {
|
||||
const handleCreate = useCallback(
|
||||
(name) => onAdd?.({ name, input: null }),
|
||||
(name: string) => onAdd?.({ name, input: null }),
|
||||
[onAdd],
|
||||
);
|
||||
|
||||
@@ -211,15 +289,15 @@ export const CorrespondentSection = ({
|
||||
() =>
|
||||
normalizeOptions(datalistOptions)
|
||||
.map((option) => normalizeQuickAddOption(option))
|
||||
.filter(Boolean),
|
||||
.filter((option): option is QuickAddEntry => Boolean(option)),
|
||||
[datalistOptions],
|
||||
);
|
||||
const hasEntries = entries && entries.length > 0;
|
||||
const showQuickAdd = Boolean(onAdd);
|
||||
const containerClass = className ? `correspondent-list ${className}` : 'correspondent-list';
|
||||
|
||||
const assignmentItems = useMemo(() => {
|
||||
const map = new Map();
|
||||
const assignmentItems = useMemo<SelectionAssignmentMenuItem[]>(() => {
|
||||
const map = new Map<string, SelectionAssignmentMenuItem>();
|
||||
|
||||
normalizedOptions.forEach((option) => {
|
||||
const label = option?.label?.trim();
|
||||
@@ -247,8 +325,10 @@ export const CorrespondentSection = ({
|
||||
const payload = { id: entry.id, name: label };
|
||||
if (map.has(key)) {
|
||||
const item = map.get(key);
|
||||
item.state = 'all';
|
||||
item.payload = payload;
|
||||
if (item) {
|
||||
item.state = 'all';
|
||||
item.payload = payload;
|
||||
}
|
||||
return;
|
||||
}
|
||||
map.set(key, {
|
||||
@@ -263,7 +343,7 @@ export const CorrespondentSection = ({
|
||||
}, [normalizedOptions, entries]);
|
||||
|
||||
const handleAssignmentSelect = useCallback(
|
||||
(item) => {
|
||||
(item: SelectionAssignmentMenuItem | null) => {
|
||||
if (!onAdd || !item) {
|
||||
return;
|
||||
}
|
||||
@@ -333,7 +413,7 @@ export const CorrespondentSection = ({
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentSummarySection = ({
|
||||
const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
|
||||
document,
|
||||
tagLookupById = new Map(),
|
||||
tagOptions = [],
|
||||
@@ -387,7 +467,7 @@ const DocumentSummarySection = ({
|
||||
}, [correspondents, document?.correspondents]);
|
||||
|
||||
const metaRows = useMemo(() => {
|
||||
const rows = [];
|
||||
const rows: { key: string; label: string; value: string | null | undefined }[] = [];
|
||||
const currentVersionNumber = document?.current_version?.version_number;
|
||||
if (Number.isFinite(currentVersionNumber)) {
|
||||
rows.push({
|
||||
@@ -433,7 +513,7 @@ const DocumentSummarySection = ({
|
||||
const startTitleEdit = useCallback(() => {
|
||||
if (!editableTitle || !document) return;
|
||||
setIsTitleEditing(true);
|
||||
setTitleDraft(document.title);
|
||||
setTitleDraft(document.title || '');
|
||||
setTitleError(null);
|
||||
}, [document, editableTitle]);
|
||||
|
||||
@@ -445,9 +525,9 @@ const DocumentSummarySection = ({
|
||||
}, []);
|
||||
|
||||
const submitTitleEdit = useCallback(
|
||||
async (event) => {
|
||||
async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!editableTitle || !document) return;
|
||||
if (!editableTitle || !document || !onUpdateTitle) return;
|
||||
const trimmed = titleDraft.trim();
|
||||
if (!trimmed) {
|
||||
setTitleError('Title cannot be empty.');
|
||||
@@ -483,9 +563,9 @@ const DocumentSummarySection = ({
|
||||
}, []);
|
||||
|
||||
const submitIssuedEdit = useCallback(
|
||||
async (event) => {
|
||||
async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!editableIssued || !document) return;
|
||||
if (!editableIssued || !document || !onUpdateIssued) return;
|
||||
const normalizedValue = issuedDraft ? toIssuedTimestamp(issuedDraft, document.issued_at) : null;
|
||||
setIssuedSaving(true);
|
||||
try {
|
||||
@@ -749,15 +829,16 @@ const DocumentSummarySection = ({
|
||||
className="icon-button"
|
||||
onClick={startTitleEdit}
|
||||
aria-label="Edit title"
|
||||
title="Edit title"
|
||||
>
|
||||
<EditIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
title="Edit title"
|
||||
>
|
||||
<EditIcon className="icon-inline" />
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{titleError ? <div className="status-inline error">{titleError}</div> : null}
|
||||
|
||||
<div className="detail-meta">
|
||||
@@ -776,7 +857,6 @@ const DocumentSummarySection = ({
|
||||
</div>
|
||||
|
||||
{renderTags()}
|
||||
|
||||
{renderCorrespondents()}
|
||||
</div>
|
||||
);
|
||||
+67
-13
@@ -1,11 +1,15 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties, JSX, MutableRefObject } from 'react';
|
||||
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||
|
||||
const DEFAULT_THUMBNAIL_SIZE = 48;
|
||||
|
||||
// Detect when an element becomes visible within a scroll container so we can delay loading.
|
||||
const useLazyVisibility = (rootRef, resetKey) => {
|
||||
const targetRef = useRef(null);
|
||||
const useLazyVisibility = (
|
||||
rootRef: MutableRefObject<Element | null> | null,
|
||||
resetKey: string | number | null | undefined,
|
||||
) => {
|
||||
const targetRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,7 +28,7 @@ const useLazyVisibility = (rootRef, resetKey) => {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
if (typeof window === 'undefined' || !('IntersectionObserver' in window)) {
|
||||
setIsVisible(true);
|
||||
return undefined;
|
||||
}
|
||||
@@ -52,11 +56,54 @@ const useLazyVisibility = (rootRef, resetKey) => {
|
||||
return { ref: targetRef, isVisible };
|
||||
};
|
||||
|
||||
const getPageCount = (doc) =>
|
||||
const getPageCount = (doc: DocumentLike | null | undefined) =>
|
||||
Number.isFinite(doc?.current_version?.metadata?.page_count)
|
||||
? doc.current_version.metadata.page_count
|
||||
? (doc?.current_version?.metadata?.page_count as number)
|
||||
: null;
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface DocumentVersionLike {
|
||||
metadata?: {
|
||||
page_count?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
assets?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier;
|
||||
current_version?: DocumentVersionLike;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AssetLike {
|
||||
id?: Identifier;
|
||||
url?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type EnsureAssetUrl = (
|
||||
documentId: Identifier,
|
||||
asset: AssetLike,
|
||||
options?: { start?: number; limit?: number; [key: string]: unknown },
|
||||
) => Promise<unknown> | void;
|
||||
|
||||
type GetDocumentAsset = (document: DocumentLike | null | undefined, assetType: string) => AssetLike | null | undefined;
|
||||
|
||||
interface DocumentThumbnailImageProps {
|
||||
document?: DocumentLike | null;
|
||||
ensureAssetUrl?: EnsureAssetUrl;
|
||||
getDocumentAsset?: GetDocumentAsset;
|
||||
alt?: string;
|
||||
maxSize?: number;
|
||||
scrollRootRef?: MutableRefObject<Element | null> | null;
|
||||
}
|
||||
|
||||
const DocumentThumbnailImage = ({
|
||||
document,
|
||||
ensureAssetUrl,
|
||||
@@ -64,12 +111,12 @@ const DocumentThumbnailImage = ({
|
||||
alt = '',
|
||||
maxSize = DEFAULT_THUMBNAIL_SIZE,
|
||||
scrollRootRef = null,
|
||||
}) => {
|
||||
}: DocumentThumbnailImageProps): JSX.Element => {
|
||||
const documentId = document?.id;
|
||||
const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, documentId);
|
||||
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
||||
|
||||
const thumbnailAsset = useMemo(
|
||||
const thumbnailAsset = useMemo<AssetLike | null>(
|
||||
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
|
||||
[document?.current_version],
|
||||
);
|
||||
@@ -89,7 +136,7 @@ const DocumentThumbnailImage = ({
|
||||
};
|
||||
}, [assetWidth, assetHeight, resolvedMaxSize]);
|
||||
|
||||
const innerStyle = useMemo(
|
||||
const innerStyle = useMemo<CSSProperties>(
|
||||
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
|
||||
[dimensions.height, dimensions.width],
|
||||
);
|
||||
@@ -98,10 +145,17 @@ const DocumentThumbnailImage = ({
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
return resolveDocumentAssetUrl(document, 'thumbnail', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
const options: {
|
||||
ensureAssetUrl?: EnsureAssetUrl;
|
||||
getAsset?: GetDocumentAsset;
|
||||
} = {};
|
||||
if (ensureAssetUrl) {
|
||||
options.ensureAssetUrl = ensureAssetUrl;
|
||||
}
|
||||
if (getDocumentAsset) {
|
||||
options.getAsset = getDocumentAsset;
|
||||
}
|
||||
return resolveDocumentAssetUrl(document, 'thumbnail', options);
|
||||
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
|
||||
|
||||
const pageCount = getPageCount(document);
|
||||
+80
-1
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { DragEvent, MouseEvent, RefObject } from 'react';
|
||||
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
|
||||
import DocumentThumbnailImage from './DocumentThumbnailImage';
|
||||
import CorrespondentLinks from './CorrespondentLinks';
|
||||
@@ -7,7 +8,85 @@ import { resolveCorrespondents } from './correspondents';
|
||||
import { writeTagTransferData } from './tagTransfer';
|
||||
import useInlineRename from './useInlineRename';
|
||||
|
||||
const DocumentsGrid = ({
|
||||
export type Identifier = string | number;
|
||||
|
||||
export interface FolderLike {
|
||||
id?: Identifier | 'root';
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface DocumentTag {
|
||||
id?: Identifier;
|
||||
label?: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentCorrespondent {
|
||||
id?: Identifier;
|
||||
name?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface DocumentLike {
|
||||
id?: Identifier;
|
||||
title?: string;
|
||||
tags?: DocumentTag[] | null;
|
||||
correspondents?: DocumentCorrespondent[] | null;
|
||||
}
|
||||
|
||||
export type FolderEntry = {
|
||||
type: 'folder';
|
||||
id: Identifier | 'root';
|
||||
key: string;
|
||||
folder: FolderLike;
|
||||
};
|
||||
|
||||
export type DocumentEntry = {
|
||||
type: 'document';
|
||||
id: Identifier;
|
||||
key: string;
|
||||
document: DocumentLike;
|
||||
};
|
||||
|
||||
export type DocumentsGridEntry = FolderEntry | DocumentEntry;
|
||||
|
||||
type FolderEventHandler = (folder: FolderLike, event: MouseEvent<HTMLDivElement>) => void;
|
||||
type DocumentEventHandler = (document: DocumentLike, event: MouseEvent<HTMLDivElement>) => void;
|
||||
|
||||
interface DocumentsGridProps {
|
||||
entries: DocumentsGridEntry[];
|
||||
selectedDocumentIdsSet?: Set<Identifier> | null;
|
||||
selectedFolderIdsSet?: Set<Identifier | 'root'> | null;
|
||||
draggingDocumentIdsSet?: Set<Identifier> | null;
|
||||
draggedFolderId?: Identifier | 'root' | null;
|
||||
onFolderClick?: FolderEventHandler;
|
||||
onFolderSelect?: (folderId: Identifier | 'root') => void;
|
||||
onFolderDragOver?: (event: DragEvent<HTMLDivElement>, folderId: Identifier | 'root') => void;
|
||||
onFolderDragLeave?: (event: DragEvent<HTMLDivElement>) => void;
|
||||
onFolderDrop?: (event: DragEvent<HTMLDivElement>, folderId: Identifier | 'root') => void;
|
||||
onFolderDragStart?: (event: DragEvent<HTMLDivElement>, folderId: Identifier | 'root') => void;
|
||||
onFolderDragEnd?: (event: DragEvent<HTMLDivElement>) => void;
|
||||
onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise<boolean> | boolean;
|
||||
onDocumentClick?: DocumentEventHandler;
|
||||
onDocumentActivate?: DocumentEventHandler;
|
||||
onDocumentDragStart?: (event: DragEvent<HTMLDivElement>, document: DocumentLike) => void;
|
||||
onDocumentDragEnd?: (event: DragEvent<HTMLDivElement>) => void;
|
||||
onDocumentTagDragOver?: (event: DragEvent<HTMLDivElement>) => void;
|
||||
onDocumentTagDragLeave?: (event: DragEvent<HTMLDivElement>) => void;
|
||||
onDocumentTagDrop?: (event: DragEvent<HTMLDivElement>, documentId: Identifier) => void;
|
||||
ensureAssetUrl?: (...args: any[]) => unknown;
|
||||
getDocumentAsset?: (...args: any[]) => unknown;
|
||||
gridIconSize?: number;
|
||||
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
||||
onTagClick?: (tagId?: Identifier | null) => void;
|
||||
scrollRef?: RefObject<HTMLElement | null>;
|
||||
onCorrespondentClick?: (correspondentId?: Identifier | null) => void;
|
||||
activeCorrespondentIdSet?: Set<Identifier | null | undefined> | null;
|
||||
onClearSelection?: () => void;
|
||||
onDocumentRename?: (docId: Identifier, title: string) => Promise<boolean> | boolean;
|
||||
}
|
||||
|
||||
const DocumentsGrid: React.FC<DocumentsGridProps> = ({
|
||||
entries,
|
||||
selectedDocumentIdsSet,
|
||||
selectedFolderIdsSet,
|
||||
+84
-1
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { DragEvent, MouseEvent, RefObject } from 'react';
|
||||
import { FolderIcon, CheckIcon, CloseIcon } from '../ui/icons';
|
||||
import { getTagColorStyle } from '../utils/colors';
|
||||
import { formatDate } from '../utils/date';
|
||||
@@ -8,7 +9,89 @@ import { resolveCorrespondents } from './correspondents';
|
||||
import { writeTagTransferData } from './tagTransfer';
|
||||
import useInlineRename from './useInlineRename';
|
||||
|
||||
const DocumentsList = ({
|
||||
export type Identifier = string | number;
|
||||
|
||||
export interface FolderLike {
|
||||
id?: Identifier | 'root';
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface DocumentTag {
|
||||
id?: Identifier;
|
||||
label?: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentCorrespondent {
|
||||
id?: Identifier;
|
||||
name?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface DocumentLike {
|
||||
id?: Identifier;
|
||||
title?: string;
|
||||
issued_at?: string | null;
|
||||
created_at?: string | null;
|
||||
uploaded_at?: string | null;
|
||||
tags?: DocumentTag[] | null;
|
||||
correspondents?: DocumentCorrespondent[] | null;
|
||||
}
|
||||
|
||||
export type FolderEntry = {
|
||||
type: 'folder';
|
||||
id: Identifier | 'root';
|
||||
key: string;
|
||||
folder: FolderLike;
|
||||
};
|
||||
|
||||
export type DocumentEntry = {
|
||||
type: 'document';
|
||||
id: Identifier;
|
||||
key: string;
|
||||
document: DocumentLike;
|
||||
};
|
||||
|
||||
export type DocumentsListEntry = FolderEntry | DocumentEntry;
|
||||
|
||||
export type FolderEventHandler = (folder: FolderLike, event: MouseEvent<HTMLTableRowElement>) => void;
|
||||
export type DocumentEventHandler = (document: DocumentLike, event: MouseEvent<HTMLTableRowElement>) => void;
|
||||
|
||||
export interface DocumentsListProps {
|
||||
entries: DocumentsListEntry[];
|
||||
focusedRowKey?: string | null;
|
||||
selectedDocumentIdsSet?: Set<Identifier> | null;
|
||||
selectedFolderIdsSet?: Set<Identifier | 'root'> | null;
|
||||
draggingDocumentIdsSet?: Set<Identifier> | null;
|
||||
draggedFolderId?: Identifier | 'root' | null;
|
||||
ensureAssetUrl?: (...args: any[]) => unknown;
|
||||
getDocumentAsset?: (...args: any[]) => unknown;
|
||||
onFolderClick?: FolderEventHandler;
|
||||
onFolderSelect?: (folderId: Identifier | 'root') => void;
|
||||
onFolderDragOver?: (event: DragEvent<HTMLTableRowElement>, folderId: Identifier | 'root') => void;
|
||||
onFolderDragLeave?: (event: DragEvent<HTMLTableRowElement>) => void;
|
||||
onFolderDrop?: (event: DragEvent<HTMLTableRowElement>, folderId: Identifier | 'root') => void;
|
||||
onFolderDragStart?: (event: DragEvent<HTMLTableRowElement>, folderId: Identifier | 'root') => void;
|
||||
onFolderDragEnd?: (event: DragEvent<HTMLTableRowElement>) => void;
|
||||
onFolderRename?: (folderId: Identifier | 'root', nextName: string) => Promise<boolean> | boolean;
|
||||
onDocumentClick?: DocumentEventHandler;
|
||||
onDocumentActivate?: DocumentEventHandler;
|
||||
onDocumentDragStart?: (event: DragEvent<HTMLTableRowElement>, document: DocumentLike) => void;
|
||||
onDocumentDragEnd?: (event: DragEvent<HTMLTableRowElement>) => void;
|
||||
onDocumentTagDragOver?: (event: DragEvent<HTMLTableRowElement>) => void;
|
||||
onDocumentTagDragLeave?: (event: DragEvent<HTMLTableRowElement>) => void;
|
||||
onDocumentTagDrop?: (event: DragEvent<HTMLTableRowElement>, documentId: Identifier) => void;
|
||||
onDocumentRename?: (documentId: Identifier, nextTitle: string) => Promise<boolean> | boolean;
|
||||
tagLookupById?: Map<Identifier, DocumentTag> | null;
|
||||
onTagClick?: (tagId?: Identifier | null) => void;
|
||||
onCorrespondentClick?: (correspondentId?: Identifier | null) => void;
|
||||
activeCorrespondentIdSet?: Set<Identifier | null | undefined> | null;
|
||||
scrollRef?: RefObject<HTMLElement | null>;
|
||||
onClearSelection?: () => void;
|
||||
}
|
||||
|
||||
|
||||
const DocumentsList: React.FC<DocumentsListProps> = ({
|
||||
entries,
|
||||
focusedRowKey,
|
||||
selectedDocumentIdsSet,
|
||||
+65
-12
@@ -1,16 +1,57 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import useFloatingMenu from '../ui/useFloatingMenu';
|
||||
import { CheckIcon, CircleDashedCheckIcon, PlusIcon } from '../ui/icons';
|
||||
|
||||
const STATE_ORDER = {
|
||||
export type AssignmentState = 'all' | 'partial' | 'none';
|
||||
|
||||
export interface SelectionAssignmentMenuItem {
|
||||
id?: string | number;
|
||||
label?: string;
|
||||
state?: AssignmentState;
|
||||
count?: number | null;
|
||||
total?: number | null;
|
||||
color?: string | null;
|
||||
value?: string | number;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
export interface NormalizedSelectionAssignmentItem {
|
||||
id: string | number;
|
||||
label: string;
|
||||
state: AssignmentState;
|
||||
count: number | null;
|
||||
total: number | null;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export interface SelectionAssignmentMenuProps {
|
||||
label: React.ReactNode;
|
||||
items?: SelectionAssignmentMenuItem[];
|
||||
placeholder?: string;
|
||||
emptyMessage?: string;
|
||||
createLabel?: string | null;
|
||||
onToggle?: (item: NormalizedSelectionAssignmentItem) => Promise<void> | void;
|
||||
onCreate?: (value: string) => Promise<void> | void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
triggerContent?: React.ReactNode;
|
||||
triggerClassName?: string;
|
||||
showStateIndicators?: boolean;
|
||||
showCounts?: boolean;
|
||||
onOpenMenu?: () => void;
|
||||
renderItemLabel?: (item: NormalizedSelectionAssignmentItem) => React.ReactNode;
|
||||
positionStrategy?: 'absolute' | 'fixed';
|
||||
}
|
||||
|
||||
const STATE_ORDER: Record<AssignmentState, number> = {
|
||||
all: 0,
|
||||
partial: 1,
|
||||
none: 2,
|
||||
};
|
||||
|
||||
const normalizeItems = (items) =>
|
||||
const normalizeItems = (items?: SelectionAssignmentMenuItem[]): NormalizedSelectionAssignmentItem[] =>
|
||||
(Array.isArray(items) ? items : [])
|
||||
.map((item) => {
|
||||
.map<NormalizedSelectionAssignmentItem | null>((item) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
@@ -18,18 +59,23 @@ const normalizeItems = (items) =>
|
||||
if (!trimmedLabel) {
|
||||
return null;
|
||||
}
|
||||
const state: AssignmentState = item.state === 'all'
|
||||
? 'all'
|
||||
: item.state === 'partial'
|
||||
? 'partial'
|
||||
: 'none';
|
||||
return {
|
||||
id: item.id ?? trimmedLabel,
|
||||
label: trimmedLabel,
|
||||
state: item.state === 'all' ? 'all' : item.state === 'partial' ? 'partial' : 'none',
|
||||
state,
|
||||
count: typeof item.count === 'number' ? item.count : null,
|
||||
total: typeof item.total === 'number' ? item.total : null,
|
||||
payload: item.payload ?? item,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
.filter((item): item is NormalizedSelectionAssignmentItem => Boolean(item));
|
||||
|
||||
const SelectionAssignmentMenu = ({
|
||||
const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
|
||||
label,
|
||||
items = [],
|
||||
placeholder = 'Search…',
|
||||
@@ -47,8 +93,8 @@ const SelectionAssignmentMenu = ({
|
||||
renderItemLabel = null,
|
||||
positionStrategy = 'absolute',
|
||||
}) => {
|
||||
const anchorRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
@@ -64,7 +110,14 @@ const SelectionAssignmentMenu = ({
|
||||
align: 'center',
|
||||
positionStrategy,
|
||||
minWidth: 220,
|
||||
});
|
||||
}) as {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
close: () => void;
|
||||
menuRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
menuStyle: CSSProperties | null;
|
||||
updatePosition: () => void;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled && isOpen) {
|
||||
@@ -106,7 +159,7 @@ const SelectionAssignmentMenu = ({
|
||||
}, [normalizedItems, query]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (item) => {
|
||||
async (item: NormalizedSelectionAssignmentItem) => {
|
||||
if (!item || typeof onToggle !== 'function') {
|
||||
return;
|
||||
}
|
||||
@@ -124,7 +177,7 @@ const SelectionAssignmentMenu = ({
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (event) => {
|
||||
async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault?.();
|
||||
if (typeof onCreate !== 'function') {
|
||||
return;
|
||||
+170
-70
@@ -1,10 +1,4 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
TrashIcon,
|
||||
AnalyzeIcon,
|
||||
@@ -14,19 +8,90 @@ import {
|
||||
CorrespondentIcon,
|
||||
LoaderIcon,
|
||||
} from '../ui/icons';
|
||||
import SelectionAssignmentMenu from './SelectionAssignmentMenu';
|
||||
import SelectionAssignmentMenu, { SelectionAssignmentMenuItem } from './SelectionAssignmentMenu';
|
||||
import SelectionSummary from './SelectionSummary';
|
||||
import { api, useAppState } from '../app/appState';
|
||||
|
||||
const normalizeDocumentList = (selectedDocumentIds) =>
|
||||
Array.isArray(selectedDocumentIds) ? selectedDocumentIds.filter(Boolean) : [];
|
||||
|
||||
const ROOT_FOLDER_LABEL = 'Documents';
|
||||
|
||||
const buildFolderTreeOptions = (tree) => {
|
||||
const entries = [];
|
||||
type DocumentId = string | number;
|
||||
type NullableDocumentId = DocumentId | null | undefined;
|
||||
|
||||
const traverse = (nodes, parentSegments) => {
|
||||
type SelectedIdList = Array<NullableDocumentId> | null | undefined;
|
||||
|
||||
type FolderTreeNode = {
|
||||
id?: DocumentId;
|
||||
name?: string;
|
||||
label?: string;
|
||||
value?: DocumentId;
|
||||
children?: FolderTreeNode[];
|
||||
};
|
||||
|
||||
interface TagOption {
|
||||
id?: DocumentId;
|
||||
label?: string;
|
||||
name?: string;
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
interface CorrespondentOption {
|
||||
id?: DocumentId;
|
||||
name?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface DocumentLike {
|
||||
id?: DocumentId;
|
||||
tags?: TagOption[];
|
||||
correspondents?: CorrespondentOption[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface BulkTagMutationArgs {
|
||||
label: string;
|
||||
input: unknown;
|
||||
documentIds: DocumentId[];
|
||||
}
|
||||
|
||||
interface BulkCorrespondentAddArgs {
|
||||
name: string;
|
||||
input: unknown;
|
||||
documentIds: DocumentId[];
|
||||
}
|
||||
|
||||
interface BulkCorrespondentRemoveArgs {
|
||||
assignments: Array<{ correspondent_id: DocumentId }>;
|
||||
documentIds: DocumentId[];
|
||||
}
|
||||
|
||||
interface SelectionFloatingActionsProps {
|
||||
selectionCount?: number;
|
||||
selectedDocumentIds?: SelectedIdList;
|
||||
selectedFolderIds?: SelectedIdList;
|
||||
documentLookup?: Map<DocumentId, DocumentLike> | null;
|
||||
tags?: TagOption[] | null;
|
||||
tagLookupById?: Map<DocumentId, TagOption> | null;
|
||||
correspondents?: CorrespondentOption[] | null;
|
||||
folderOptions?: SelectionAssignmentMenuItem[] | null;
|
||||
onBulkTagAdd?: (args: BulkTagMutationArgs) => Promise<void> | void;
|
||||
onBulkTagRemove?: (args: BulkTagMutationArgs) => Promise<void> | void;
|
||||
onBulkCorrespondentAdd?: (args: BulkCorrespondentAddArgs) => Promise<void> | void;
|
||||
onBulkCorrespondentRemove?: (args: BulkCorrespondentRemoveArgs) => Promise<void> | void;
|
||||
onBulkReanalyze?: (documentIds: DocumentId[]) => Promise<void> | void;
|
||||
onDeleteSelection?: () => void;
|
||||
onClearSelection?: () => void;
|
||||
onMoveDocumentsToFolder?: (documentIds: DocumentId[], folderId: DocumentId) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const normalizeDocumentList = (selectedIds?: SelectedIdList): DocumentId[] =>
|
||||
Array.isArray(selectedIds)
|
||||
? selectedIds.filter((value): value is DocumentId => value !== null && value !== undefined)
|
||||
: [];
|
||||
|
||||
const buildFolderTreeOptions = (tree?: FolderTreeNode[] | null): SelectionAssignmentMenuItem[] => {
|
||||
const entries: SelectionAssignmentMenuItem[] = [];
|
||||
|
||||
const traverse = (nodes: FolderTreeNode[] | undefined | null, parentSegments: string[]) => {
|
||||
if (!Array.isArray(nodes) || nodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -34,11 +99,21 @@ const buildFolderTreeOptions = (tree) => {
|
||||
if (!node || !node.id) {
|
||||
return;
|
||||
}
|
||||
const trimmedName = node?.name?.trim?.();
|
||||
const trimmedName = node.name?.trim?.();
|
||||
const name = trimmedName?.length ? trimmedName : 'Folder';
|
||||
const nextSegments = parentSegments.concat([name]);
|
||||
const label = nextSegments.join('/');
|
||||
entries.push({ id: node.id, label });
|
||||
entries.push({
|
||||
id: node.id,
|
||||
label,
|
||||
state: 'none',
|
||||
payload: {
|
||||
id: node.id,
|
||||
label,
|
||||
segments: nextSegments,
|
||||
depth: Math.max(nextSegments.length - 1, 0),
|
||||
},
|
||||
});
|
||||
if (Array.isArray(node.children) && node.children.length) {
|
||||
traverse(node.children, nextSegments);
|
||||
}
|
||||
@@ -47,19 +122,30 @@ const buildFolderTreeOptions = (tree) => {
|
||||
|
||||
traverse(Array.isArray(tree) ? tree : [], [ROOT_FOLDER_LABEL]);
|
||||
|
||||
entries.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }));
|
||||
entries.sort((a, b) => (a.label || '').localeCompare(b.label || '', undefined, { sensitivity: 'base' }));
|
||||
|
||||
return [{ id: 'root', label: ROOT_FOLDER_LABEL }, ...entries];
|
||||
return [{ id: 'root', label: ROOT_FOLDER_LABEL, state: 'none', payload: { id: 'root' } }, ...entries];
|
||||
};
|
||||
|
||||
const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => {
|
||||
const buildTagAssignments = (
|
||||
selectedDocuments: DocumentLike[],
|
||||
tagLookupById: Map<DocumentId, TagOption> | null | undefined,
|
||||
tags: TagOption[] | null | undefined,
|
||||
total: number,
|
||||
): SelectionAssignmentMenuItem[] => {
|
||||
if (!total) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const map = new Map();
|
||||
const map = new Map<string | number, {
|
||||
id?: DocumentId;
|
||||
label: string;
|
||||
color: string | null;
|
||||
count: number;
|
||||
total: number;
|
||||
}>();
|
||||
|
||||
const ensureEntry = (id, label, color = null) => {
|
||||
const ensureEntry = (id?: DocumentId, label?: string, color: string | null = null) => {
|
||||
const key = id ?? label;
|
||||
if (!key || !label) {
|
||||
return null;
|
||||
@@ -73,7 +159,7 @@ const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => {
|
||||
total,
|
||||
});
|
||||
}
|
||||
return map.get(key);
|
||||
return map.get(key) ?? null;
|
||||
};
|
||||
|
||||
selectedDocuments.forEach((doc) => {
|
||||
@@ -106,14 +192,23 @@ const buildTagAssignments = (selectedDocuments, tagLookupById, tags, total) => {
|
||||
});
|
||||
};
|
||||
|
||||
const buildCorrespondentAssignments = (selectedDocuments, correspondents, total) => {
|
||||
const buildCorrespondentAssignments = (
|
||||
selectedDocuments: DocumentLike[],
|
||||
correspondents: CorrespondentOption[] | null | undefined,
|
||||
total: number,
|
||||
): SelectionAssignmentMenuItem[] => {
|
||||
if (!total) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const map = new Map();
|
||||
const map = new Map<string | number, {
|
||||
id?: DocumentId;
|
||||
label: string;
|
||||
count: number;
|
||||
total: number;
|
||||
}>();
|
||||
|
||||
const ensureEntry = (id, name) => {
|
||||
const ensureEntry = (id?: DocumentId, name?: string) => {
|
||||
const key = id ?? name;
|
||||
if (!key || !name) {
|
||||
return null;
|
||||
@@ -126,7 +221,7 @@ const buildCorrespondentAssignments = (selectedDocuments, correspondents, total)
|
||||
total,
|
||||
});
|
||||
}
|
||||
return map.get(key);
|
||||
return map.get(key) ?? null;
|
||||
};
|
||||
|
||||
selectedDocuments.forEach((doc) => {
|
||||
@@ -139,7 +234,7 @@ const buildCorrespondentAssignments = (selectedDocuments, correspondents, total)
|
||||
});
|
||||
|
||||
(correspondents || []).forEach((entry) => {
|
||||
ensureEntry(entry?.id, entry?.name);
|
||||
ensureEntry(entry?.id, entry?.name || entry?.label);
|
||||
});
|
||||
|
||||
return Array.from(map.values()).map((entry) => {
|
||||
@@ -156,14 +251,14 @@ const buildCorrespondentAssignments = (selectedDocuments, correspondents, total)
|
||||
});
|
||||
};
|
||||
|
||||
const SelectionFloatingActions = ({
|
||||
const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
|
||||
selectionCount = 0,
|
||||
selectedDocumentIds,
|
||||
selectedDocumentIds = [],
|
||||
selectedFolderIds = [],
|
||||
documentLookup,
|
||||
tags,
|
||||
tags = [],
|
||||
tagLookupById,
|
||||
correspondents,
|
||||
correspondents = [],
|
||||
folderOptions = [],
|
||||
onBulkTagAdd,
|
||||
onBulkTagRemove,
|
||||
@@ -174,12 +269,17 @@ const SelectionFloatingActions = ({
|
||||
onClearSelection = null,
|
||||
onMoveDocumentsToFolder,
|
||||
}) => {
|
||||
const { token, tenant } = useAppState();
|
||||
const { token, tenant } = useAppState() as { token?: string; tenant?: { id?: DocumentId } | null };
|
||||
const tenantId = tenant?.id ?? null;
|
||||
|
||||
const [remoteFolderOptions, setRemoteFolderOptions] = useState(null);
|
||||
const documentLookupMap = useMemo(() => (
|
||||
documentLookup instanceof Map ? documentLookup : new Map<DocumentId, DocumentLike>()
|
||||
), [documentLookup]);
|
||||
const tagLookupMap = tagLookupById instanceof Map ? tagLookupById : null;
|
||||
|
||||
const [remoteFolderOptions, setRemoteFolderOptions] = useState<SelectionAssignmentMenuItem[] | null>(null);
|
||||
const [loadingFolders, setLoadingFolders] = useState(false);
|
||||
const folderTreeFetchRef = useRef(null);
|
||||
const folderTreeFetchRef = useRef<Promise<SelectionAssignmentMenuItem[]> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRemoteFolderOptions(null);
|
||||
@@ -187,7 +287,7 @@ const SelectionFloatingActions = ({
|
||||
setLoadingFolders(false);
|
||||
}, [tenantId, token]);
|
||||
|
||||
const requestFolderTree = useCallback(async () => {
|
||||
const requestFolderTree = useCallback(async (): Promise<SelectionAssignmentMenuItem[]> => {
|
||||
if (!token) {
|
||||
setRemoteFolderOptions([]);
|
||||
return [];
|
||||
@@ -226,59 +326,55 @@ const SelectionFloatingActions = ({
|
||||
requestFolderTree();
|
||||
}, [requestFolderTree]);
|
||||
|
||||
const effectiveFolderOptions = useMemo(() => {
|
||||
const effectiveFolderOptions = useMemo<SelectionAssignmentMenuItem[]>(() => {
|
||||
if (remoteFolderOptions !== null) {
|
||||
return remoteFolderOptions;
|
||||
}
|
||||
return Array.isArray(folderOptions) ? folderOptions : [];
|
||||
}, [remoteFolderOptions, folderOptions]);
|
||||
|
||||
const documentIdList = useMemo(
|
||||
const documentIdList = useMemo<DocumentId[]>(
|
||||
() => normalizeDocumentList(selectedDocumentIds),
|
||||
[selectedDocumentIds],
|
||||
);
|
||||
|
||||
const folderIdList = useMemo(
|
||||
const folderIdList = useMemo<DocumentId[]>(
|
||||
() => normalizeDocumentList(selectedFolderIds),
|
||||
[selectedFolderIds],
|
||||
);
|
||||
|
||||
const documentCount = documentIdList.length;
|
||||
const folderCount = folderIdList.length;
|
||||
const totalCount = typeof selectionCount === 'number'
|
||||
? selectionCount
|
||||
: documentCount + folderCount;
|
||||
const totalCount = typeof selectionCount === 'number' ? selectionCount : documentCount + folderCount;
|
||||
|
||||
const selectedDocuments = useMemo(() => {
|
||||
if (!documentIdList.length || !(documentLookup instanceof Map)) {
|
||||
const selectedDocuments = useMemo<DocumentLike[]>(() => {
|
||||
if (!documentIdList.length || !(documentLookupMap instanceof Map)) {
|
||||
return [];
|
||||
}
|
||||
return documentIdList
|
||||
.map((id) => documentLookup.get(id))
|
||||
.filter(Boolean);
|
||||
}, [documentIdList, documentLookup]);
|
||||
.map((id) => documentLookupMap.get(id))
|
||||
.filter((doc): doc is DocumentLike => Boolean(doc));
|
||||
}, [documentIdList, documentLookupMap]);
|
||||
|
||||
const selectedDocCount = selectedDocuments.length;
|
||||
|
||||
const moveAssignments = useMemo(() => {
|
||||
const moveAssignments = useMemo<SelectionAssignmentMenuItem[]>(() => {
|
||||
if (!Array.isArray(effectiveFolderOptions)) {
|
||||
return [];
|
||||
}
|
||||
return effectiveFolderOptions
|
||||
.map((option) => {
|
||||
const id = option?.id ?? option?.value ?? option;
|
||||
.map<SelectionAssignmentMenuItem | null>((option) => {
|
||||
const id = (option?.id ?? option?.payload?.id ?? option?.value) as DocumentId | undefined;
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const label = option?.label || option?.name || String(id);
|
||||
const segments = label.split('/');
|
||||
const label = option?.label || option?.payload?.label || option?.name || String(id);
|
||||
const segments = typeof label === 'string' ? label.split('/') : [label];
|
||||
const depth = Math.max(segments.length - 1, 0);
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
state: 'none',
|
||||
count: null,
|
||||
total: null,
|
||||
payload: {
|
||||
id,
|
||||
label,
|
||||
@@ -287,12 +383,12 @@ const SelectionFloatingActions = ({
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
.filter((entry): entry is SelectionAssignmentMenuItem => Boolean(entry));
|
||||
}, [effectiveFolderOptions]);
|
||||
|
||||
const tagAssignments = useMemo(
|
||||
() => buildTagAssignments(selectedDocuments, tagLookupById, tags, selectedDocCount),
|
||||
[selectedDocuments, tagLookupById, tags, selectedDocCount],
|
||||
() => buildTagAssignments(selectedDocuments, tagLookupMap, tags, selectedDocCount),
|
||||
[selectedDocuments, tagLookupMap, tags, selectedDocCount],
|
||||
);
|
||||
|
||||
const correspondentAssignments = useMemo(
|
||||
@@ -300,9 +396,10 @@ const SelectionFloatingActions = ({
|
||||
[selectedDocuments, correspondents, selectedDocCount],
|
||||
);
|
||||
|
||||
const renderFolderLabel = useCallback((item) => {
|
||||
const segments = item?.payload?.segments || (item?.label ? item.label.split('/') : []);
|
||||
const depth = item?.payload?.depth ?? Math.max(segments.length - 1, 0);
|
||||
const renderFolderLabel = useCallback((item: SelectionAssignmentMenuItem) => {
|
||||
const payload = (item?.payload as { segments?: string[]; depth?: number }) || {};
|
||||
const segments = payload.segments || (typeof item.label === 'string' ? item.label.split('/') : []);
|
||||
const depth = payload.depth ?? Math.max(segments.length - 1, 0);
|
||||
const clampedDepth = Math.min(depth, 6);
|
||||
const indentWidth = clampedDepth > 0 ? clampedDepth * 0.9 : 0;
|
||||
const name = segments.length ? segments[segments.length - 1] : item?.label || 'Folder';
|
||||
@@ -328,21 +425,21 @@ const SelectionFloatingActions = ({
|
||||
}, []);
|
||||
|
||||
const handleToggleTagAssignment = useCallback(
|
||||
async (item) => {
|
||||
async (item: SelectionAssignmentMenuItem) => {
|
||||
if (!selectedDocCount || !item) {
|
||||
return;
|
||||
}
|
||||
if (item.state === 'all') {
|
||||
await onBulkTagRemove?.({ label: item.label, input: null, documentIds: documentIdList });
|
||||
await onBulkTagRemove?.({ label: item.label || '', input: null, documentIds: documentIdList });
|
||||
} else {
|
||||
await onBulkTagAdd?.({ label: item.label, input: null, documentIds: documentIdList });
|
||||
await onBulkTagAdd?.({ label: item.label || '', input: null, documentIds: documentIdList });
|
||||
}
|
||||
},
|
||||
[selectedDocCount, onBulkTagAdd, onBulkTagRemove, documentIdList],
|
||||
);
|
||||
|
||||
const handleCreateTagAssignment = useCallback(
|
||||
async (label) => {
|
||||
async (label: string) => {
|
||||
if (!selectedDocCount || !label) {
|
||||
return;
|
||||
}
|
||||
@@ -352,7 +449,7 @@ const SelectionFloatingActions = ({
|
||||
);
|
||||
|
||||
const handleToggleCorrespondentAssignment = useCallback(
|
||||
async (item) => {
|
||||
async (item: SelectionAssignmentMenuItem) => {
|
||||
if (!selectedDocCount || !item) {
|
||||
return;
|
||||
}
|
||||
@@ -365,14 +462,14 @@ const SelectionFloatingActions = ({
|
||||
documentIds: documentIdList,
|
||||
});
|
||||
} else {
|
||||
await onBulkCorrespondentAdd?.({ name: item.label, input: null, documentIds: documentIdList });
|
||||
await onBulkCorrespondentAdd?.({ name: item.label || '', input: null, documentIds: documentIdList });
|
||||
}
|
||||
},
|
||||
[selectedDocCount, onBulkCorrespondentAdd, onBulkCorrespondentRemove, documentIdList],
|
||||
);
|
||||
|
||||
const handleCreateCorrespondentAssignment = useCallback(
|
||||
async (name) => {
|
||||
async (name: string) => {
|
||||
if (!selectedDocCount || !name) {
|
||||
return;
|
||||
}
|
||||
@@ -382,15 +479,18 @@ const SelectionFloatingActions = ({
|
||||
);
|
||||
|
||||
const handleMoveSelectionToFolder = useCallback(
|
||||
async (option) => {
|
||||
async (option: unknown) => {
|
||||
if (!documentIdList.length || typeof onMoveDocumentsToFolder !== 'function') {
|
||||
return;
|
||||
}
|
||||
const value = option?.id ?? option?.value ?? option;
|
||||
if (!value) {
|
||||
const candidate = option as { id?: DocumentId; value?: DocumentId } | DocumentId | null | undefined;
|
||||
const value = typeof candidate === 'object'
|
||||
? candidate?.id ?? candidate?.value ?? null
|
||||
: candidate;
|
||||
if (!value && value !== 0) {
|
||||
return;
|
||||
}
|
||||
await onMoveDocumentsToFolder(documentIdList, value);
|
||||
await onMoveDocumentsToFolder(documentIdList, value as DocumentId);
|
||||
},
|
||||
[documentIdList, onMoveDocumentsToFolder],
|
||||
);
|
||||
+7
-1
@@ -1,7 +1,13 @@
|
||||
import React from 'react';
|
||||
import { FileIcon, FolderOutlineIcon } from '../ui/icons';
|
||||
|
||||
const SelectionSummary = ({ documentCount = 0, folderCount = 0, totalCount = 0 }) => {
|
||||
interface SelectionSummaryProps {
|
||||
documentCount?: number;
|
||||
folderCount?: number;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
const SelectionSummary: React.FC<SelectionSummaryProps> = ({ documentCount = 0, folderCount = 0, totalCount = 0 }) => {
|
||||
const docCount = Number(documentCount) || 0;
|
||||
const folderCountNumber = Number(folderCount) || 0;
|
||||
const aggregateCount = docCount + folderCountNumber;
|
||||
@@ -1,34 +0,0 @@
|
||||
export const resolveCorrespondents = (doc) => {
|
||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const results = [];
|
||||
|
||||
doc.correspondents.forEach((entry = {}, index) => {
|
||||
const { id, name } = entry;
|
||||
const trimmedName = name?.trim?.();
|
||||
if (!trimmedName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id && seen.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
seen.add(id);
|
||||
}
|
||||
|
||||
results.push({
|
||||
id,
|
||||
name: trimmedName,
|
||||
key: id ?? `${trimmedName}-${index}`,
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export default resolveCorrespondents;
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface CorrespondentReference {
|
||||
id?: string | number | null;
|
||||
name?: string | null;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export interface DocumentLike {
|
||||
correspondents?: CorrespondentReference[];
|
||||
}
|
||||
|
||||
export interface ResolvedCorrespondent {
|
||||
id?: string | number | null;
|
||||
name: string;
|
||||
key: string | number;
|
||||
}
|
||||
|
||||
export const resolveCorrespondents = (doc?: DocumentLike | null): ResolvedCorrespondent[] => {
|
||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string | number>();
|
||||
const results: ResolvedCorrespondent[] = [];
|
||||
|
||||
doc.correspondents.forEach((entry = {}, index) => {
|
||||
const { id, name } = entry;
|
||||
const trimmedName = name?.trim?.();
|
||||
if (!trimmedName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id != null && seen.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id != null) {
|
||||
seen.add(id);
|
||||
}
|
||||
|
||||
results.push({
|
||||
id,
|
||||
name: trimmedName,
|
||||
key: id ?? `${trimmedName}-${index}`,
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export default resolveCorrespondents;
|
||||
+32
-10
@@ -1,9 +1,23 @@
|
||||
import { openOcrTextInNewTab } from '../utils/ocr';
|
||||
|
||||
type ResolveApiPath = (path: string) => string;
|
||||
type EnsurePreviewData = (id: string | number) => Promise<void>;
|
||||
type EnsureAssetUrl = (id: string | number, asset: unknown, options?: unknown) => Promise<unknown>;
|
||||
type GetDocumentAsset = (document: DocumentLike, type: string) => unknown;
|
||||
|
||||
interface DocumentVersion {
|
||||
download_path?: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentLike {
|
||||
id?: string | number;
|
||||
current_version?: DocumentVersion | null;
|
||||
}
|
||||
|
||||
const asyncFalse = async () => false;
|
||||
|
||||
const resolveDocumentDownloadHref = (document, resolveApiPath) => {
|
||||
if (!document || typeof resolveApiPath !== 'function') {
|
||||
const resolveDocumentDownloadHref = (document: DocumentLike | null | undefined, resolveApiPath?: ResolveApiPath | null): string | null => {
|
||||
if (!document || !resolveApiPath) {
|
||||
return null;
|
||||
}
|
||||
const downloadPath = document.current_version?.download_path;
|
||||
@@ -13,13 +27,23 @@ const resolveDocumentDownloadHref = (document, resolveApiPath) => {
|
||||
return resolveApiPath(downloadPath);
|
||||
};
|
||||
|
||||
const hasDocumentOcrAsset = (document, getDocumentAsset) => {
|
||||
if (!document || typeof getDocumentAsset !== 'function') {
|
||||
const hasDocumentOcrAsset = (document: DocumentLike | null | undefined, getDocumentAsset?: GetDocumentAsset | null): boolean => {
|
||||
if (!document || !getDocumentAsset) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(getDocumentAsset(document, 'ocr-text'));
|
||||
};
|
||||
|
||||
interface CreateDocumentActionStateArgs {
|
||||
document: DocumentLike | null;
|
||||
resolveApiPath?: ResolveApiPath | null;
|
||||
ensurePreviewData: EnsurePreviewData;
|
||||
ensureAssetUrl: EnsureAssetUrl;
|
||||
getDocumentAsset?: GetDocumentAsset | null;
|
||||
notifyApiError?: (error: unknown, message: string) => void;
|
||||
ocrErrorMessage?: string;
|
||||
}
|
||||
|
||||
export const createDocumentActionState = ({
|
||||
document,
|
||||
resolveApiPath,
|
||||
@@ -28,7 +52,7 @@ export const createDocumentActionState = ({
|
||||
getDocumentAsset,
|
||||
notifyApiError,
|
||||
ocrErrorMessage = 'Unable to open OCR text.',
|
||||
}) => {
|
||||
}: CreateDocumentActionStateArgs) => {
|
||||
if (!document) {
|
||||
return {
|
||||
downloadHref: null,
|
||||
@@ -49,14 +73,12 @@ export const createDocumentActionState = ({
|
||||
getDocumentAsset,
|
||||
ensureAssetUrl,
|
||||
});
|
||||
if (!success && typeof notifyApiError === 'function') {
|
||||
notifyApiError(new Error('OCR text URL unavailable.'), ocrErrorMessage);
|
||||
if (!success) {
|
||||
notifyApiError?.(new Error('OCR text URL unavailable.'), ocrErrorMessage);
|
||||
}
|
||||
return success;
|
||||
} catch (error) {
|
||||
if (typeof notifyApiError === 'function') {
|
||||
notifyApiError(error, ocrErrorMessage);
|
||||
}
|
||||
notifyApiError?.(error, ocrErrorMessage);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+26
-3
@@ -1,6 +1,29 @@
|
||||
import { formatDateTime } from '../utils/date';
|
||||
|
||||
export const buildDocumentMetadataItems = (document) => {
|
||||
interface DocumentVersionMetadata {
|
||||
checksum?: string | null;
|
||||
}
|
||||
|
||||
interface DocumentMetadata {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DocumentLike {
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
filename?: string | null;
|
||||
original_name?: string | null;
|
||||
content_type?: string | null;
|
||||
metadata?: DocumentMetadata | null;
|
||||
current_version?: DocumentVersionMetadata | null;
|
||||
}
|
||||
|
||||
export interface DocumentMetadataItem {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
}
|
||||
|
||||
export const buildDocumentMetadataItems = (document?: DocumentLike | null): DocumentMetadataItem[] => {
|
||||
if (!document) {
|
||||
return [];
|
||||
}
|
||||
@@ -29,8 +52,8 @@ export const buildDocumentMetadataItems = (document) => {
|
||||
];
|
||||
};
|
||||
|
||||
export const extractDocumentMetadataPayload = (document) => {
|
||||
if (!document || !document.metadata) {
|
||||
export const extractDocumentMetadataPayload = (document?: DocumentLike | null): DocumentMetadata | null => {
|
||||
if (!document?.metadata) {
|
||||
return null;
|
||||
}
|
||||
const keys = Object.keys(document.metadata);
|
||||
+71
-24
@@ -1,13 +1,71 @@
|
||||
import { formatFileSize } from '../utils/format';
|
||||
import { formatDateTime as defaultFormatDateTime } from '../utils/date';
|
||||
|
||||
const coercePageCount = (metadata) => {
|
||||
interface DocumentMetadata {
|
||||
page_count?: number | string | null;
|
||||
}
|
||||
|
||||
interface DocumentVersion {
|
||||
size_bytes?: number | string | null;
|
||||
metadata?: DocumentMetadata | null;
|
||||
}
|
||||
|
||||
interface TagEntry {
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
interface CorrespondentEntry {
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
export interface SummaryDocument {
|
||||
title?: string | null;
|
||||
original_name?: string | null;
|
||||
content_type?: string | null;
|
||||
current_version?: DocumentVersion | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
issued_at?: string | null;
|
||||
folder_path?: string | null;
|
||||
tags?: TagEntry[] | null;
|
||||
correspondents?: CorrespondentEntry[] | null;
|
||||
}
|
||||
|
||||
interface DescribeSummaryOptions {
|
||||
formatDateTime?: typeof defaultFormatDateTime;
|
||||
}
|
||||
|
||||
export interface DocumentSummaryRow {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface DocumentSummary {
|
||||
title: string | undefined;
|
||||
originalName: string | null | undefined;
|
||||
mimeTypeLabel: string;
|
||||
sizeLabel: string;
|
||||
createdAtLabel: string;
|
||||
issuedLabel: string;
|
||||
updatedAtLabel: string;
|
||||
pageCount: number | null;
|
||||
pageCountLabel: string;
|
||||
folderLabel: string | null | undefined;
|
||||
tags: TagEntry[];
|
||||
correspondents: CorrespondentEntry[];
|
||||
tagsSummary: string;
|
||||
correspondentsSummary: string;
|
||||
summaryRows: DocumentSummaryRow[];
|
||||
}
|
||||
|
||||
const coercePageCount = (metadata: DocumentMetadata | null | undefined): number | null => {
|
||||
const raw = metadata?.page_count;
|
||||
if (typeof raw === 'number') {
|
||||
return Number.isFinite(raw) && raw >= 0 ? raw : null;
|
||||
}
|
||||
if (raw != null && raw !== '') {
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
const parsed = Number.parseInt(String(raw), 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return parsed;
|
||||
}
|
||||
@@ -15,21 +73,14 @@ const coercePageCount = (metadata) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const sanitizeTags = (tags) => {
|
||||
if (!Array.isArray(tags)) {
|
||||
return [];
|
||||
}
|
||||
return tags.filter(Boolean);
|
||||
};
|
||||
const sanitizeArray = <T>(entries: (T | null | undefined)[] | null | undefined): T[] =>
|
||||
Array.isArray(entries) ? entries.filter(Boolean) as T[] : [];
|
||||
|
||||
const sanitizeCorrespondents = (entries) => {
|
||||
if (!Array.isArray(entries)) {
|
||||
return [];
|
||||
}
|
||||
return entries.filter(Boolean);
|
||||
};
|
||||
export const describeDocumentSummary = (document?: SummaryDocument | null, options: DescribeSummaryOptions = {}): DocumentSummary => {
|
||||
const {
|
||||
formatDateTime = defaultFormatDateTime,
|
||||
} = options;
|
||||
|
||||
export const describeDocumentSummary = (document, options = {}) => {
|
||||
if (!document) {
|
||||
return {
|
||||
title: '',
|
||||
@@ -50,10 +101,6 @@ export const describeDocumentSummary = (document, options = {}) => {
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
formatDateTime = defaultFormatDateTime,
|
||||
} = options;
|
||||
|
||||
const originalName = document.original_name;
|
||||
const mimeTypeLabel = document.content_type || 'Unknown';
|
||||
|
||||
@@ -70,18 +117,18 @@ export const describeDocumentSummary = (document, options = {}) => {
|
||||
|
||||
const folderLabel = document.folder_path;
|
||||
|
||||
const tags = sanitizeTags(document.tags);
|
||||
const correspondents = sanitizeCorrespondents(document.correspondents);
|
||||
const tags = sanitizeArray<TagEntry>(document.tags);
|
||||
const correspondents = sanitizeArray<CorrespondentEntry>(document.correspondents);
|
||||
|
||||
const tagLabels = tags.map((tag) => tag.label).filter(Boolean);
|
||||
const correspondentLabels = correspondents.map((entry) => entry.name).filter(Boolean);
|
||||
const tagLabels = tags.map((tag) => tag.label).filter(Boolean) as string[];
|
||||
const correspondentLabels = correspondents.map((entry) => entry.name).filter(Boolean) as string[];
|
||||
|
||||
const tagsSummary = tagLabels.length ? tagLabels.join(', ') : '—';
|
||||
const correspondentsSummary = correspondentLabels.length
|
||||
? correspondentLabels.join(', ')
|
||||
: '—';
|
||||
|
||||
const summaryRows = [
|
||||
const summaryRows: DocumentSummaryRow[] = [
|
||||
{ key: 'created', label: 'Created', value: createdAtLabel },
|
||||
{ key: 'size', label: 'Size', value: sizeLabel },
|
||||
{ key: 'type', label: 'Type', value: mimeTypeLabel },
|
||||
+38
-7
@@ -1,5 +1,36 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export type Identifier = string | number;
|
||||
|
||||
type ApiClient = {
|
||||
post: <T = { data: unknown }>(url: string, payload: unknown) => Promise<{ data: T } | T>;
|
||||
delete: (url: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type BulkAssignmentResponse = {
|
||||
assigned?: number;
|
||||
removed?: number;
|
||||
};
|
||||
|
||||
type CorrespondentAssignment = {
|
||||
correspondent_id?: Identifier;
|
||||
};
|
||||
|
||||
interface UseBulkDocumentActionsArgs {
|
||||
api: ApiClient;
|
||||
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
|
||||
correspondentLookupByName: Map<string, { id?: Identifier }>;
|
||||
handleCorrespondentCreate: (payload: { name: string }) => Promise<{ id?: Identifier } | null>;
|
||||
refreshCurrentFolder: () => Promise<void> | void;
|
||||
setStatusMessage: (message: string, variant?: string) => void;
|
||||
selectedDocumentIds?: Identifier[];
|
||||
selectedFolderIds?: Identifier[];
|
||||
handleDocumentsDelete: (ids: Identifier[], options?: { showMessage?: boolean; manageLoading?: boolean }) => Promise<boolean>;
|
||||
handleFolderDelete: (id: Identifier, options?: { showMessage?: boolean; manageLoading?: boolean }) => Promise<boolean>;
|
||||
clearDocumentSelection: () => void;
|
||||
setLoading: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const useBulkDocumentActions = ({
|
||||
api,
|
||||
resolveTargetDocumentIds,
|
||||
@@ -13,9 +44,9 @@ const useBulkDocumentActions = ({
|
||||
handleFolderDelete,
|
||||
clearDocumentSelection,
|
||||
setLoading,
|
||||
}) => {
|
||||
}: UseBulkDocumentActionsArgs) => {
|
||||
const handleBulkCorrespondentAdd = useCallback(
|
||||
async ({ name, input, documentIds }) => {
|
||||
async ({ name, input, documentIds }: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||
const trimmed = name?.trim?.() || '';
|
||||
if (!trimmed) {
|
||||
setStatusMessage('Correspondent name is required.', 'error');
|
||||
@@ -41,7 +72,7 @@ const useBulkDocumentActions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await api.post('/documents/bulk/correspondents', {
|
||||
const response = await api.post<BulkAssignmentResponse>('/documents/bulk/correspondents', {
|
||||
document_ids: targets,
|
||||
assignments: [
|
||||
{
|
||||
@@ -51,7 +82,7 @@ const useBulkDocumentActions = ({
|
||||
action: 'add',
|
||||
});
|
||||
|
||||
const { assigned = 0, removed = 0 } = response.data || {};
|
||||
const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response;
|
||||
|
||||
await refreshCurrentFolder();
|
||||
const assignedSuffix = assigned === 1 ? '' : 's';
|
||||
@@ -83,7 +114,7 @@ const useBulkDocumentActions = ({
|
||||
);
|
||||
|
||||
const handleBulkCorrespondentRemove = useCallback(
|
||||
async ({ assignments = [], documentIds }) => {
|
||||
async ({ assignments = [], documentIds }: { assignments?: CorrespondentAssignment[]; documentIds?: Identifier[] }) => {
|
||||
if (!assignments.length) {
|
||||
setStatusMessage('Select a correspondent to remove.', 'error');
|
||||
return;
|
||||
@@ -100,13 +131,13 @@ const useBulkDocumentActions = ({
|
||||
correspondent_id: entry.correspondent_id,
|
||||
}));
|
||||
|
||||
const response = await api.post('/documents/bulk/correspondents', {
|
||||
const response = await api.post<BulkAssignmentResponse>('/documents/bulk/correspondents', {
|
||||
document_ids: targets,
|
||||
assignments: normalizedAssignments,
|
||||
action: 'remove',
|
||||
});
|
||||
|
||||
const { assigned = 0, removed = 0 } = response.data || {};
|
||||
const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response;
|
||||
await refreshCurrentFolder();
|
||||
|
||||
if (removed > 0) {
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const useDocumentsPanelProps = ({
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
refreshCurrentFolder,
|
||||
currentSubfolders,
|
||||
documents,
|
||||
searchResults,
|
||||
isFilterActive,
|
||||
folderClickHandlers,
|
||||
selectFolder,
|
||||
handleFolderDragStart,
|
||||
handleFolderDragEnd,
|
||||
draggedFolderId,
|
||||
handleFolderRename,
|
||||
openDocumentPreview,
|
||||
handleDocumentTitleUpdate,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
focusedRowKey,
|
||||
draggedDocumentIds,
|
||||
handleDocumentDragStart,
|
||||
handleDocumentDragEnd,
|
||||
searchLoading,
|
||||
tagLookupById,
|
||||
activeCorrespondentFilters,
|
||||
selectedEntries,
|
||||
setFocusedRowKey,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
toggleTagFilter,
|
||||
toggleCorrespondentFilter,
|
||||
handleDocumentTagDrop,
|
||||
documentsViewMode,
|
||||
documentsSortField,
|
||||
documentsSortDirection,
|
||||
handleDocumentsSortFieldChange,
|
||||
handleDocumentsSortDirectionToggle,
|
||||
searchIncludeDescendants,
|
||||
toggleSearchIncludeDescendants,
|
||||
handleDocumentsViewModeChange,
|
||||
clearDocumentSelection,
|
||||
handleDeleteSelection,
|
||||
handleEntryPointerCore,
|
||||
inspectDocument,
|
||||
handleEntrySelection,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleBulkSelectionReanalyze,
|
||||
folderOptions,
|
||||
moveDocumentsToFolder,
|
||||
}) =>
|
||||
useMemo(
|
||||
() => ({
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
onRefresh: refreshCurrentFolder,
|
||||
subfolders: currentSubfolders,
|
||||
documents,
|
||||
searchResults,
|
||||
isFilterActive,
|
||||
onFolderSelect: selectFolder,
|
||||
onFolderDrop: folderClickHandlers.onDrop,
|
||||
onFolderDragOver: folderClickHandlers.onDragOver,
|
||||
onFolderDragLeave: folderClickHandlers.onDragLeave,
|
||||
onFolderDragStart: handleFolderDragStart,
|
||||
onFolderDragEnd: handleFolderDragEnd,
|
||||
draggedFolderId,
|
||||
onFolderRename: handleFolderRename,
|
||||
onDocumentOpen: openDocumentPreview,
|
||||
onDocumentRename: handleDocumentTitleUpdate,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
focusedRowKey,
|
||||
draggingDocumentIds: draggedDocumentIds,
|
||||
onDocumentDragStart: handleDocumentDragStart,
|
||||
onDocumentDragEnd: handleDocumentDragEnd,
|
||||
isSearchLoading: searchLoading,
|
||||
tagLookupById,
|
||||
activeCorrespondentIds: activeCorrespondentFilters,
|
||||
selectedEntries,
|
||||
onFocusedRowChange: setFocusedRowKey,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
onTagClick: toggleTagFilter,
|
||||
onCorrespondentClick: toggleCorrespondentFilter,
|
||||
onDocumentTagDrop: handleDocumentTagDrop,
|
||||
viewMode: documentsViewMode,
|
||||
sortField: documentsSortField,
|
||||
sortDirection: documentsSortDirection,
|
||||
onSortFieldChange: handleDocumentsSortFieldChange,
|
||||
onSortDirectionToggle: handleDocumentsSortDirectionToggle,
|
||||
searchIncludeDescendants,
|
||||
onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants,
|
||||
onViewModeChange: handleDocumentsViewModeChange,
|
||||
onClearSelection: clearDocumentSelection,
|
||||
onDeleteSelection: handleDeleteSelection,
|
||||
onEntryPointer: handleEntryPointerCore,
|
||||
onInspectDocument: inspectDocument,
|
||||
onEntrySelection: handleEntrySelection,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
onBulkTagAdd: handleBulkTagAddFromDetail,
|
||||
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
||||
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
||||
onBulkReanalyze: handleBulkSelectionReanalyze,
|
||||
folderOptions,
|
||||
onMoveDocumentsToFolder: moveDocumentsToFolder,
|
||||
}),
|
||||
[
|
||||
activeCorrespondentFilters,
|
||||
breadcrumbs,
|
||||
clearDocumentSelection,
|
||||
correspondents,
|
||||
currentFolderName,
|
||||
currentSubfolders,
|
||||
documents,
|
||||
documentsSortDirection,
|
||||
documentsSortField,
|
||||
documentsViewMode,
|
||||
documentLookup,
|
||||
draggedDocumentIds,
|
||||
draggedFolderId,
|
||||
focusedRowKey,
|
||||
folderClickHandlers,
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleBulkSelectionReanalyze,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleDeleteSelection,
|
||||
handleDocumentDragEnd,
|
||||
handleDocumentDragStart,
|
||||
handleDocumentTagDrop,
|
||||
handleDocumentTitleUpdate,
|
||||
handleDocumentsSortDirectionToggle,
|
||||
handleDocumentsSortFieldChange,
|
||||
handleDocumentsViewModeChange,
|
||||
handleEntryPointerCore,
|
||||
handleEntrySelection,
|
||||
handleFolderDragEnd,
|
||||
handleFolderDragStart,
|
||||
handleFolderRename,
|
||||
inspectDocument,
|
||||
isFilterActive,
|
||||
moveDocumentsToFolder,
|
||||
openDocumentPreview,
|
||||
refreshCurrentFolder,
|
||||
searchIncludeDescendants,
|
||||
searchLoading,
|
||||
searchResults,
|
||||
selectedDocumentIds,
|
||||
selectedEntries,
|
||||
selectedFolderIds,
|
||||
selectFolder,
|
||||
setFocusedRowKey,
|
||||
tagLookupById,
|
||||
tags,
|
||||
toggleCorrespondentFilter,
|
||||
toggleSearchIncludeDescendants,
|
||||
toggleTagFilter,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
folderOptions,
|
||||
],
|
||||
);
|
||||
|
||||
export default useDocumentsPanelProps;
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
export interface Breadcrumb {
|
||||
id?: Identifier;
|
||||
name?: string;
|
||||
label?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface FolderClickHandlers {
|
||||
onDrop?: (...args: unknown[]) => void;
|
||||
onDragOver?: (...args: unknown[]) => void;
|
||||
onDragLeave?: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export interface UseDocumentsPanelPropsArgs {
|
||||
currentFolderName?: string | null;
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
refreshCurrentFolder?: () => void | Promise<void>;
|
||||
currentSubfolders?: unknown[];
|
||||
documents?: unknown[];
|
||||
searchResults?: unknown[] | null;
|
||||
isFilterActive?: boolean;
|
||||
folderClickHandlers: FolderClickHandlers;
|
||||
selectFolder?: (...args: unknown[]) => void;
|
||||
handleFolderDragStart?: (...args: unknown[]) => void;
|
||||
handleFolderDragEnd?: (...args: unknown[]) => void;
|
||||
draggedFolderId?: Identifier | null;
|
||||
handleFolderRename?: (...args: unknown[]) => void;
|
||||
openDocumentPreview?: (...args: unknown[]) => void;
|
||||
handleDocumentTitleUpdate?: (...args: unknown[]) => void;
|
||||
selectedDocumentIds?: Identifier[];
|
||||
selectedFolderIds?: Identifier[];
|
||||
focusedRowKey?: Identifier | string | null;
|
||||
draggedDocumentIds?: Identifier[];
|
||||
handleDocumentDragStart?: (...args: unknown[]) => void;
|
||||
handleDocumentDragEnd?: (...args: unknown[]) => void;
|
||||
searchLoading?: boolean;
|
||||
tagLookupById?: unknown;
|
||||
activeCorrespondentFilters?: Identifier[];
|
||||
selectedEntries?: unknown[];
|
||||
setFocusedRowKey?: (key: Identifier | string | null) => void;
|
||||
ensureAssetUrl?: (...args: unknown[]) => void;
|
||||
getDocumentAsset?: (...args: unknown[]) => unknown;
|
||||
toggleTagFilter?: (...args: unknown[]) => void;
|
||||
toggleCorrespondentFilter?: (...args: unknown[]) => void;
|
||||
handleDocumentTagDrop?: (...args: unknown[]) => void;
|
||||
documentsViewMode?: string;
|
||||
documentsSortField?: string;
|
||||
documentsSortDirection?: string;
|
||||
handleDocumentsSortFieldChange?: (field: string) => void;
|
||||
handleDocumentsSortDirectionToggle?: () => void;
|
||||
searchIncludeDescendants?: boolean;
|
||||
toggleSearchIncludeDescendants?: () => void;
|
||||
handleDocumentsViewModeChange?: (mode: string) => void;
|
||||
clearDocumentSelection?: () => void;
|
||||
handleDeleteSelection?: () => void;
|
||||
handleEntryPointerCore?: (...args: unknown[]) => void;
|
||||
inspectDocument?: (...args: unknown[]) => void;
|
||||
handleEntrySelection?: (...args: unknown[]) => void;
|
||||
tags?: unknown[];
|
||||
correspondents?: unknown[];
|
||||
documentLookup?: unknown;
|
||||
handleBulkTagAddFromDetail?: (...args: unknown[]) => void;
|
||||
handleBulkTagRemoveFromDetail?: (...args: unknown[]) => void;
|
||||
handleBulkCorrespondentAdd?: (...args: unknown[]) => void;
|
||||
handleBulkCorrespondentRemove?: (...args: unknown[]) => void;
|
||||
handleBulkSelectionReanalyze?: (...args: unknown[]) => void;
|
||||
folderOptions?: unknown[];
|
||||
moveDocumentsToFolder?: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export type DocumentsPanelProps = ReturnType<typeof useDocumentsPanelProps>;
|
||||
|
||||
const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
|
||||
const {
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
refreshCurrentFolder,
|
||||
currentSubfolders,
|
||||
documents,
|
||||
searchResults,
|
||||
isFilterActive,
|
||||
folderClickHandlers,
|
||||
selectFolder,
|
||||
handleFolderDragStart,
|
||||
handleFolderDragEnd,
|
||||
draggedFolderId,
|
||||
handleFolderRename,
|
||||
openDocumentPreview,
|
||||
handleDocumentTitleUpdate,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
focusedRowKey,
|
||||
draggedDocumentIds,
|
||||
handleDocumentDragStart,
|
||||
handleDocumentDragEnd,
|
||||
searchLoading,
|
||||
tagLookupById,
|
||||
activeCorrespondentFilters,
|
||||
selectedEntries,
|
||||
setFocusedRowKey,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
toggleTagFilter,
|
||||
toggleCorrespondentFilter,
|
||||
handleDocumentTagDrop,
|
||||
documentsViewMode,
|
||||
documentsSortField,
|
||||
documentsSortDirection,
|
||||
handleDocumentsSortFieldChange,
|
||||
handleDocumentsSortDirectionToggle,
|
||||
searchIncludeDescendants,
|
||||
toggleSearchIncludeDescendants,
|
||||
handleDocumentsViewModeChange,
|
||||
clearDocumentSelection,
|
||||
handleDeleteSelection,
|
||||
handleEntryPointerCore,
|
||||
inspectDocument,
|
||||
handleEntrySelection,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleBulkSelectionReanalyze,
|
||||
folderOptions,
|
||||
moveDocumentsToFolder,
|
||||
} = props;
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
onRefresh: refreshCurrentFolder,
|
||||
subfolders: currentSubfolders,
|
||||
documents,
|
||||
searchResults,
|
||||
isFilterActive,
|
||||
onFolderSelect: selectFolder,
|
||||
onFolderDrop: folderClickHandlers.onDrop,
|
||||
onFolderDragOver: folderClickHandlers.onDragOver,
|
||||
onFolderDragLeave: folderClickHandlers.onDragLeave,
|
||||
onFolderDragStart: handleFolderDragStart,
|
||||
onFolderDragEnd: handleFolderDragEnd,
|
||||
draggedFolderId,
|
||||
onFolderRename: handleFolderRename,
|
||||
onDocumentOpen: openDocumentPreview,
|
||||
onDocumentRename: handleDocumentTitleUpdate,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
focusedRowKey,
|
||||
draggingDocumentIds: draggedDocumentIds,
|
||||
onDocumentDragStart: handleDocumentDragStart,
|
||||
onDocumentDragEnd: handleDocumentDragEnd,
|
||||
isSearchLoading: searchLoading,
|
||||
tagLookupById,
|
||||
activeCorrespondentIds: activeCorrespondentFilters,
|
||||
selectedEntries,
|
||||
onFocusedRowChange: setFocusedRowKey,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
onTagClick: toggleTagFilter,
|
||||
onCorrespondentClick: toggleCorrespondentFilter,
|
||||
onDocumentTagDrop: handleDocumentTagDrop,
|
||||
viewMode: documentsViewMode,
|
||||
sortField: documentsSortField,
|
||||
sortDirection: documentsSortDirection,
|
||||
onSortFieldChange: handleDocumentsSortFieldChange,
|
||||
onSortDirectionToggle: handleDocumentsSortDirectionToggle,
|
||||
searchIncludeDescendants,
|
||||
onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants,
|
||||
onViewModeChange: handleDocumentsViewModeChange,
|
||||
onClearSelection: clearDocumentSelection,
|
||||
onDeleteSelection: handleDeleteSelection,
|
||||
onEntryPointer: handleEntryPointerCore,
|
||||
onInspectDocument: inspectDocument,
|
||||
onEntrySelection: handleEntrySelection,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
onBulkTagAdd: handleBulkTagAddFromDetail,
|
||||
onBulkTagRemove: handleBulkTagRemoveFromDetail,
|
||||
onBulkCorrespondentAdd: handleBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove: handleBulkCorrespondentRemove,
|
||||
onBulkReanalyze: handleBulkSelectionReanalyze,
|
||||
folderOptions,
|
||||
onMoveDocumentsToFolder: moveDocumentsToFolder,
|
||||
}),
|
||||
[
|
||||
activeCorrespondentFilters,
|
||||
breadcrumbs,
|
||||
clearDocumentSelection,
|
||||
correspondents,
|
||||
currentFolderName,
|
||||
currentSubfolders,
|
||||
documents,
|
||||
documentsSortDirection,
|
||||
documentsSortField,
|
||||
documentsViewMode,
|
||||
documentLookup,
|
||||
draggedDocumentIds,
|
||||
draggedFolderId,
|
||||
focusedRowKey,
|
||||
folderClickHandlers,
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleBulkSelectionReanalyze,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleDeleteSelection,
|
||||
handleDocumentDragEnd,
|
||||
handleDocumentDragStart,
|
||||
handleDocumentTagDrop,
|
||||
handleDocumentTitleUpdate,
|
||||
handleDocumentsSortDirectionToggle,
|
||||
handleDocumentsSortFieldChange,
|
||||
handleDocumentsViewModeChange,
|
||||
handleEntryPointerCore,
|
||||
handleEntrySelection,
|
||||
handleFolderDragEnd,
|
||||
handleFolderDragStart,
|
||||
handleFolderRename,
|
||||
inspectDocument,
|
||||
isFilterActive,
|
||||
moveDocumentsToFolder,
|
||||
openDocumentPreview,
|
||||
refreshCurrentFolder,
|
||||
searchIncludeDescendants,
|
||||
searchLoading,
|
||||
searchResults,
|
||||
selectedDocumentIds,
|
||||
selectedEntries,
|
||||
selectedFolderIds,
|
||||
selectFolder,
|
||||
setFocusedRowKey,
|
||||
tagLookupById,
|
||||
tags,
|
||||
toggleCorrespondentFilter,
|
||||
toggleSearchIncludeDescendants,
|
||||
toggleTagFilter,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
folderOptions,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
export default useDocumentsPanelProps;
|
||||
+41
-5
@@ -1,5 +1,41 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
interface FolderEntry {
|
||||
id: string | number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DocumentEntry {
|
||||
id: string | number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface NavigableRow {
|
||||
key: string;
|
||||
type: 'folder' | 'document';
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
interface UseDocumentsSelectionOptions {
|
||||
showingSearchResults: boolean;
|
||||
currentSubfolders: FolderEntry[];
|
||||
visibleDocuments: DocumentEntry[];
|
||||
resolveFolderRowKey: (id: string | number) => string | null | undefined;
|
||||
resolveDocumentRowKey: (id: string | number) => string | null | undefined;
|
||||
configureSelectionEnvironment: (config: { visibleRowKeySet: Set<string>; navigableRowKeys: string[] }) => void;
|
||||
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;
|
||||
clearSelection: () => void;
|
||||
focusedDocumentId: string | number | null;
|
||||
setFocusedRowKey: (value: string | null | ((current: string | null) => string | null)) => void;
|
||||
focusedRowKey: string | null;
|
||||
isFolderRowKey: (key: string | null) => boolean;
|
||||
}
|
||||
|
||||
const useDocumentsSelection = ({
|
||||
showingSearchResults,
|
||||
currentSubfolders,
|
||||
@@ -18,9 +54,9 @@ const useDocumentsSelection = ({
|
||||
setFocusedRowKey,
|
||||
focusedRowKey,
|
||||
isFolderRowKey,
|
||||
}) => {
|
||||
const navigableRows = useMemo(() => {
|
||||
const entries = [];
|
||||
}: UseDocumentsSelectionOptions) => {
|
||||
const navigableRows = useMemo<NavigableRow[]>(() => {
|
||||
const entries: NavigableRow[] = [];
|
||||
if (!showingSearchResults) {
|
||||
currentSubfolders.forEach((folder) => {
|
||||
const key = resolveFolderRowKey(folder.id);
|
||||
@@ -51,7 +87,7 @@ const useDocumentsSelection = ({
|
||||
}, [configureSelectionEnvironment, visibleRowKeySet, navigableRowKeys]);
|
||||
|
||||
const promoteSelectionOrder = useCallback(
|
||||
(docId) => {
|
||||
(docId: string | number | null) => {
|
||||
if (!docId) return;
|
||||
promoteSelectionOrderRaw(docId);
|
||||
const rowKey = resolveDocumentRowKey(docId);
|
||||
@@ -68,7 +104,7 @@ const useDocumentsSelection = ({
|
||||
clearSelection();
|
||||
}, [clearSelection]);
|
||||
|
||||
const prevFocusedDocIdRef = useRef(focusedDocumentId);
|
||||
const prevFocusedDocIdRef = useRef<string | number | null>(focusedDocumentId);
|
||||
useEffect(() => {
|
||||
const previous = prevFocusedDocIdRef.current;
|
||||
if (previous === focusedDocumentId) {
|
||||
+5
-1
@@ -15,7 +15,11 @@ const EntryType = {
|
||||
document: 'document',
|
||||
};
|
||||
|
||||
const DocumentsPanel = ({
|
||||
interface DocumentsPanelProps {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const DocumentsPanel: React.FC<DocumentsPanelProps> = ({
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
onRefresh,
|
||||
+22
-6
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import type { JSX } from 'react';
|
||||
import {
|
||||
ViewListIcon,
|
||||
ViewGridIcon,
|
||||
@@ -12,18 +12,34 @@ import {
|
||||
} from '../../ui/icons';
|
||||
import SortFieldQuickMenu from './SortFieldQuickMenu';
|
||||
|
||||
type ViewMode = 'list' | 'grid' | 'desk' | (string & {});
|
||||
type SortDirection = 'asc' | 'desc' | (string & {});
|
||||
|
||||
interface DocumentsTableHeaderActionOptions {
|
||||
viewMode?: ViewMode;
|
||||
onViewModeChange?: (mode: ViewMode) => void;
|
||||
onRefresh: () => void;
|
||||
sortField?: string;
|
||||
onSortFieldChange?: (field: string) => void;
|
||||
sortDirection?: SortDirection;
|
||||
onSortDirectionToggle?: () => void;
|
||||
isFilterActive?: boolean;
|
||||
includeDescendants?: boolean;
|
||||
onToggleIncludeDescendants?: () => void;
|
||||
}
|
||||
|
||||
export const createDocumentsTableHeaderActions = ({
|
||||
viewMode,
|
||||
viewMode = 'list',
|
||||
onViewModeChange,
|
||||
onRefresh,
|
||||
sortField = 'title',
|
||||
onSortFieldChange = null,
|
||||
onSortFieldChange,
|
||||
sortDirection = 'asc',
|
||||
onSortDirectionToggle = null,
|
||||
onSortDirectionToggle,
|
||||
isFilterActive = false,
|
||||
includeDescendants = true,
|
||||
onToggleIncludeDescendants = null,
|
||||
}) => {
|
||||
onToggleIncludeDescendants,
|
||||
}: DocumentsTableHeaderActionOptions): JSX.Element => {
|
||||
const isListView = viewMode === 'list';
|
||||
const isGridView = viewMode === 'grid';
|
||||
const isDeskView = viewMode === 'desk';
|
||||
+8
-3
@@ -14,7 +14,12 @@ const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((acc, option) => {
|
||||
return next;
|
||||
}, {});
|
||||
|
||||
const SortFieldQuickMenu = ({ sortField, onChange }) => {
|
||||
interface SortFieldQuickMenuProps {
|
||||
sortField: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
const SortFieldQuickMenu: React.FC<SortFieldQuickMenuProps> = ({ sortField, onChange }) => {
|
||||
const currentOption = useMemo(
|
||||
() => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0],
|
||||
[sortField],
|
||||
@@ -26,8 +31,8 @@ const SortFieldQuickMenu = ({ sortField, onChange }) => {
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value, option) => {
|
||||
if (typeof onChange !== 'function') {
|
||||
(value: string, option?: { id?: string; original?: { id?: string } }) => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
const nextValue = option?.id || option?.original?.id || value;
|
||||
+18
-2
@@ -1,10 +1,26 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import DocumentViewerPanel from '../../preview/DocumentViewerPanel';
|
||||
import SelectionFloatingActions from '../SelectionFloatingActions';
|
||||
import createWorkspaceSurfaceConfig from '../workspaceHeader';
|
||||
import DocumentsPanel from './DocumentsPanel';
|
||||
import { createDocumentsTableHeaderActions } from './DocumentsToolbar';
|
||||
|
||||
interface Breadcrumb {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
label?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface CreateDocumentsSurfaceArgs {
|
||||
tableProps: Record<string, any>;
|
||||
parentBreadcrumb?: Breadcrumb | null;
|
||||
onNavigateParent?: () => void;
|
||||
renderSidebarToggle?: () => ReactNode;
|
||||
detailProps?: Record<string, any> | null;
|
||||
detailOpen?: boolean;
|
||||
}
|
||||
|
||||
const createDocumentsSurface = ({
|
||||
tableProps,
|
||||
parentBreadcrumb,
|
||||
@@ -12,7 +28,7 @@ const createDocumentsSurface = ({
|
||||
renderSidebarToggle,
|
||||
detailProps,
|
||||
detailOpen = false,
|
||||
}) => {
|
||||
}: CreateDocumentsSurfaceArgs) => {
|
||||
const {
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
@@ -1,7 +1,18 @@
|
||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||
const TAG_TEXT_MIME_TYPE = 'text/plain';
|
||||
|
||||
const serializePayload = (payload) => {
|
||||
interface TagPayload {
|
||||
id: string | number;
|
||||
label: string;
|
||||
sourceDocId: string | number | null;
|
||||
}
|
||||
|
||||
interface TagLike {
|
||||
id?: string | number;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
const serializePayload = (payload: TagPayload): string | null => {
|
||||
try {
|
||||
return JSON.stringify(payload);
|
||||
} catch (error) {
|
||||
@@ -10,8 +21,8 @@ const serializePayload = (payload) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const createTagTransferPayload = (tag, sourceDocId = null) => {
|
||||
if (!tag || !tag.id) {
|
||||
export const createTagTransferPayload = (tag: TagLike | null | undefined, sourceDocId: string | number | null = null): TagPayload | null => {
|
||||
if (!tag || tag.id == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -22,7 +33,7 @@ export const createTagTransferPayload = (tag, sourceDocId = null) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const writeTagTransferData = (dataTransfer, tag, sourceDocId = null) => {
|
||||
export const writeTagTransferData = (dataTransfer: DataTransfer | null, tag: TagLike, sourceDocId: string | number | null = null): void => {
|
||||
if (!dataTransfer) {
|
||||
return;
|
||||
}
|
||||
@@ -38,8 +49,7 @@ export const writeTagTransferData = (dataTransfer, tag, sourceDocId = null) => {
|
||||
}
|
||||
|
||||
try {
|
||||
dataTransfer.setData(TAG_MIME_TYPES[0], serialized);
|
||||
dataTransfer.setData(TAG_MIME_TYPES[1], serialized);
|
||||
TAG_MIME_TYPES.forEach((type) => dataTransfer.setData(type, serialized));
|
||||
if (payload.label) {
|
||||
dataTransfer.setData(TAG_TEXT_MIME_TYPE, payload.label);
|
||||
}
|
||||
@@ -48,7 +58,7 @@ export const writeTagTransferData = (dataTransfer, tag, sourceDocId = null) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const readTagTransferData = (dataTransfer) => {
|
||||
export const readTagTransferData = (dataTransfer: DataTransfer | null | undefined): string | null => {
|
||||
if (!dataTransfer) {
|
||||
return null;
|
||||
}
|
||||
@@ -67,15 +77,17 @@ export const readTagTransferData = (dataTransfer) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const parseTagTransferPayload = (input) => {
|
||||
type DragEventLike = DragEvent | { dataTransfer?: DataTransfer | null };
|
||||
|
||||
export const parseTagTransferPayload = (input: DataTransfer | DragEventLike | null): TagPayload | null => {
|
||||
const dataTransfer = input && 'dataTransfer' in input ? input.dataTransfer : input;
|
||||
const raw = readTagTransferData(dataTransfer);
|
||||
const raw = readTagTransferData(dataTransfer || null);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
return JSON.parse(raw) as TagPayload;
|
||||
} catch (error) {
|
||||
console.warn('[tagTransfer] Failed to parse drag payload', error);
|
||||
}
|
||||
@@ -83,7 +95,7 @@ export const parseTagTransferPayload = (input) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isTagTransferEvent = (event) => {
|
||||
export const isTagTransferEvent = (event?: DragEventLike | null): boolean => {
|
||||
const types = event?.dataTransfer?.types;
|
||||
if (!types) {
|
||||
return false;
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export const isPointerModifierEvent = (event) =>
|
||||
Boolean(event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey));
|
||||
|
||||
export const isPrimaryPointerEvent = (event) => {
|
||||
if (!event) {
|
||||
return true;
|
||||
}
|
||||
if (typeof event.button === 'number' && event.button !== 0) {
|
||||
return false;
|
||||
}
|
||||
const type = event?.type?.toLowerCase?.() ?? '';
|
||||
return type === 'click' || type === 'pointerdown' || type === 'pointerup';
|
||||
};
|
||||
|
||||
const EntryType = Object.freeze({
|
||||
document: 'document',
|
||||
folder: 'folder',
|
||||
});
|
||||
|
||||
export const useEntryPointer = ({
|
||||
resolveDocumentRowKey,
|
||||
resolveFolderRowKey,
|
||||
onSelectEntry,
|
||||
onInspectDocument,
|
||||
}) =>
|
||||
useCallback(
|
||||
(entry, event) => {
|
||||
if (!entry || !entry.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, id } = entry;
|
||||
if (type !== EntryType.document && type !== EntryType.folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rowKey = entry.key
|
||||
|| (type === EntryType.document ? resolveDocumentRowKey?.(id) : resolveFolderRowKey?.(id));
|
||||
if (!rowKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modifierClick = isPointerModifierEvent(event);
|
||||
const primaryClick = isPrimaryPointerEvent(event);
|
||||
const metadata = { modifierClick, primaryClick, rowKey, type, id };
|
||||
|
||||
if (typeof onSelectEntry === 'function') {
|
||||
onSelectEntry(entry, event, metadata);
|
||||
}
|
||||
|
||||
if (
|
||||
type === EntryType.document
|
||||
&& !modifierClick
|
||||
&& primaryClick
|
||||
&& typeof onInspectDocument === 'function'
|
||||
) {
|
||||
onInspectDocument(id, metadata);
|
||||
}
|
||||
},
|
||||
[resolveDocumentRowKey, resolveFolderRowKey, onSelectEntry, onInspectDocument],
|
||||
);
|
||||
|
||||
export default useEntryPointer;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export type PointerEventLike = MouseEvent | PointerEvent;
|
||||
|
||||
export const isPointerModifierEvent = (event?: PointerEventLike | null): boolean =>
|
||||
Boolean(event && (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey));
|
||||
|
||||
export const isPrimaryPointerEvent = (event?: PointerEventLike | null): boolean => {
|
||||
if (!event) {
|
||||
return true;
|
||||
}
|
||||
if (typeof event.button === 'number' && event.button !== 0) {
|
||||
return false;
|
||||
}
|
||||
const type = event?.type?.toLowerCase?.() ?? '';
|
||||
return type === 'click' || type === 'pointerdown' || type === 'pointerup';
|
||||
};
|
||||
|
||||
export type EntryType = 'document' | 'folder';
|
||||
|
||||
export interface WorkspaceEntry {
|
||||
id: string | number;
|
||||
key?: string;
|
||||
type: EntryType;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseEntryPointerOptions {
|
||||
resolveDocumentRowKey?: (id: string | number) => string | null | undefined;
|
||||
resolveFolderRowKey?: (id: string | number) => string | null | undefined;
|
||||
onSelectEntry?: (entry: WorkspaceEntry, event?: PointerEventLike | null, metadata?: EntryPointerMetadata) => void;
|
||||
onInspectDocument?: (id: string | number, metadata?: EntryPointerMetadata) => void;
|
||||
}
|
||||
|
||||
export interface EntryPointerMetadata {
|
||||
modifierClick: boolean;
|
||||
primaryClick: boolean;
|
||||
rowKey: string;
|
||||
type: EntryType;
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
export const useEntryPointer = ({
|
||||
resolveDocumentRowKey,
|
||||
resolveFolderRowKey,
|
||||
onSelectEntry,
|
||||
onInspectDocument,
|
||||
}: UseEntryPointerOptions) =>
|
||||
useCallback(
|
||||
(entry?: WorkspaceEntry | null, event?: PointerEventLike | null) => {
|
||||
if (!entry || !entry.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, id } = entry;
|
||||
if (type !== 'document' && type !== 'folder') {
|
||||
return;
|
||||
}
|
||||
|
||||
const rowKey = entry.key
|
||||
|| (type === 'document' ? resolveDocumentRowKey?.(id) : resolveFolderRowKey?.(id));
|
||||
if (!rowKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modifierClick = isPointerModifierEvent(event);
|
||||
const primaryClick = isPrimaryPointerEvent(event);
|
||||
const metadata: EntryPointerMetadata = { modifierClick, primaryClick, rowKey, type, id };
|
||||
|
||||
onSelectEntry?.(entry, event, metadata);
|
||||
|
||||
if (type === 'document' && !modifierClick && primaryClick) {
|
||||
onInspectDocument?.(id, metadata);
|
||||
}
|
||||
},
|
||||
[resolveDocumentRowKey, resolveFolderRowKey, onSelectEntry, onInspectDocument],
|
||||
);
|
||||
|
||||
export default useEntryPointer;
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
const focusInput = (node) => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const applyFocus = () => {
|
||||
node.focus();
|
||||
node.select?.();
|
||||
};
|
||||
const raf = window.requestAnimationFrame;
|
||||
if (raf) {
|
||||
raf(applyFocus);
|
||||
return;
|
||||
}
|
||||
applyFocus();
|
||||
};
|
||||
|
||||
const identity = (value) => value;
|
||||
|
||||
const useInlineRename = (
|
||||
onRename,
|
||||
{
|
||||
getCurrentValue = identity,
|
||||
getEntityId = (entity) => entity?.id ?? null,
|
||||
} = {},
|
||||
) => {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [draftValue, setDraftValue] = useState('');
|
||||
const [savingId, setSavingId] = useState(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setDraftValue('');
|
||||
setSavingId(null);
|
||||
inputRef.current = null;
|
||||
}, []);
|
||||
|
||||
const beginEditing = useCallback(
|
||||
(entity, event) => {
|
||||
if (!entity) {
|
||||
return;
|
||||
}
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
const entityId = getEntityId(entity);
|
||||
if (!entityId) {
|
||||
return;
|
||||
}
|
||||
const currentValue = getCurrentValue(entity) ?? '';
|
||||
setEditingId(entityId);
|
||||
setDraftValue(currentValue);
|
||||
setSavingId(null);
|
||||
},
|
||||
[getCurrentValue, getEntityId],
|
||||
);
|
||||
|
||||
const cancelEditing = useCallback(
|
||||
(event) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
resetState();
|
||||
},
|
||||
[resetState],
|
||||
);
|
||||
|
||||
const submitEditing = useCallback(
|
||||
async (entity) => {
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
const entityId = getEntityId(entity);
|
||||
if (!entityId || editingId !== entityId) {
|
||||
return false;
|
||||
}
|
||||
const trimmed = draftValue.trim();
|
||||
const currentValue = getCurrentValue(entity) ?? '';
|
||||
if (!trimmed || trimmed === currentValue) {
|
||||
resetState();
|
||||
return true;
|
||||
}
|
||||
if (typeof onRename !== 'function') {
|
||||
resetState();
|
||||
return true;
|
||||
}
|
||||
setSavingId(entityId);
|
||||
try {
|
||||
const result = await onRename(entityId, trimmed);
|
||||
if (result === false) {
|
||||
return false;
|
||||
}
|
||||
resetState();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setSavingId((current) => (current === entityId ? null : current));
|
||||
}
|
||||
},
|
||||
[draftValue, editingId, getCurrentValue, getEntityId, onRename, resetState],
|
||||
);
|
||||
|
||||
const attachInputRef = useCallback(
|
||||
(node) => {
|
||||
if (node) {
|
||||
inputRef.current = node;
|
||||
focusInput(node);
|
||||
} else if (inputRef.current) {
|
||||
inputRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
editingId,
|
||||
draftValue,
|
||||
setDraftValue,
|
||||
beginEditing,
|
||||
cancelEditing,
|
||||
submitEditing,
|
||||
savingId,
|
||||
attachInputRef,
|
||||
};
|
||||
};
|
||||
|
||||
export default useInlineRename;
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
type FocusableInput = (HTMLInputElement | HTMLTextAreaElement) & {
|
||||
select?: () => void;
|
||||
};
|
||||
|
||||
type InlineRenameOptions<TEntity> = {
|
||||
getCurrentValue?: (entity: TEntity) => string | null | undefined;
|
||||
getEntityId?: (entity: TEntity) => string | number | null | undefined;
|
||||
};
|
||||
|
||||
type InlineRenameHandler = (
|
||||
id: string | number,
|
||||
value: string,
|
||||
) => boolean | void | Promise<boolean | void>;
|
||||
|
||||
type InlineRenameReturn<TEntity> = {
|
||||
editingId: string | number | null;
|
||||
draftValue: string;
|
||||
setDraftValue: Dispatch<SetStateAction<string>>;
|
||||
beginEditing: (entity: TEntity | null | undefined, event?: SyntheticEvent | Event) => void;
|
||||
cancelEditing: (event?: SyntheticEvent | Event) => void;
|
||||
submitEditing: (entity: TEntity | null | undefined) => Promise<boolean>;
|
||||
savingId: string | number | null;
|
||||
attachInputRef: (node: FocusableInput | null) => void;
|
||||
};
|
||||
|
||||
const focusInput = (node: FocusableInput | null) => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const applyFocus = () => {
|
||||
node.focus();
|
||||
node.select?.();
|
||||
};
|
||||
const raf = window.requestAnimationFrame;
|
||||
if (raf) {
|
||||
raf(applyFocus);
|
||||
return;
|
||||
}
|
||||
applyFocus();
|
||||
};
|
||||
|
||||
const identity = (value: unknown) => value as string;
|
||||
|
||||
const defaultGetEntityId = <T,>(entity: T) =>
|
||||
(entity as { id?: string | number } | null | undefined)?.id ?? null;
|
||||
|
||||
const useInlineRename = <TEntity,>(
|
||||
onRename?: InlineRenameHandler,
|
||||
{
|
||||
getCurrentValue = identity as (entity: TEntity) => string | null | undefined,
|
||||
getEntityId = defaultGetEntityId as (
|
||||
entity: TEntity,
|
||||
) => string | number | null | undefined,
|
||||
}: InlineRenameOptions<TEntity> = {},
|
||||
): InlineRenameReturn<TEntity> => {
|
||||
const [editingId, setEditingId] = useState<string | number | null>(null);
|
||||
const [draftValue, setDraftValue] = useState('');
|
||||
const [savingId, setSavingId] = useState<string | number | null>(null);
|
||||
const inputRef = useRef<FocusableInput | null>(null);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setEditingId(null);
|
||||
setDraftValue('');
|
||||
setSavingId(null);
|
||||
inputRef.current = null;
|
||||
}, []);
|
||||
|
||||
const beginEditing = useCallback(
|
||||
(entity: TEntity | null | undefined, event?: SyntheticEvent | Event) => {
|
||||
if (!entity) {
|
||||
return;
|
||||
}
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
const entityId = getEntityId(entity);
|
||||
if (!entityId) {
|
||||
return;
|
||||
}
|
||||
const currentValue = getCurrentValue(entity) ?? '';
|
||||
setEditingId(entityId);
|
||||
setDraftValue(currentValue);
|
||||
setSavingId(null);
|
||||
},
|
||||
[getCurrentValue, getEntityId],
|
||||
);
|
||||
|
||||
const cancelEditing = useCallback(
|
||||
(event?: SyntheticEvent | Event) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
resetState();
|
||||
},
|
||||
[resetState],
|
||||
);
|
||||
|
||||
const submitEditing = useCallback(
|
||||
async (entity: TEntity | null | undefined) => {
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
const entityId = getEntityId(entity);
|
||||
if (!entityId || editingId !== entityId) {
|
||||
return false;
|
||||
}
|
||||
const trimmed = draftValue.trim();
|
||||
const currentValue = getCurrentValue(entity) ?? '';
|
||||
if (!trimmed || trimmed === currentValue) {
|
||||
resetState();
|
||||
return true;
|
||||
}
|
||||
if (!onRename) {
|
||||
resetState();
|
||||
return true;
|
||||
}
|
||||
setSavingId(entityId);
|
||||
try {
|
||||
const result = await onRename(entityId, trimmed);
|
||||
if (result === false) {
|
||||
return false;
|
||||
}
|
||||
resetState();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setSavingId((current) => (current === entityId ? null : current));
|
||||
}
|
||||
},
|
||||
[draftValue, editingId, getCurrentValue, getEntityId, onRename, resetState],
|
||||
);
|
||||
|
||||
const attachInputRef = useCallback((node: FocusableInput | null) => {
|
||||
if (node) {
|
||||
inputRef.current = node;
|
||||
focusInput(node);
|
||||
} else if (inputRef.current) {
|
||||
inputRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
editingId,
|
||||
draftValue,
|
||||
setDraftValue,
|
||||
beginEditing,
|
||||
cancelEditing,
|
||||
submitEditing,
|
||||
savingId,
|
||||
attachInputRef,
|
||||
};
|
||||
};
|
||||
|
||||
export default useInlineRename;
|
||||
@@ -1,41 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export const createWorkspaceSurfaceConfig = ({
|
||||
title,
|
||||
subtitle = null,
|
||||
sidebarToggle = null,
|
||||
actions = null,
|
||||
breadcrumbs = null,
|
||||
selectionLabel = null,
|
||||
floatingActions = null,
|
||||
content = null,
|
||||
detail = null,
|
||||
variant = 'documents',
|
||||
key = 'documents',
|
||||
}) => {
|
||||
const leading = sidebarToggle
|
||||
? (
|
||||
<>
|
||||
{sidebarToggle}
|
||||
</>
|
||||
)
|
||||
: null;
|
||||
|
||||
return {
|
||||
key,
|
||||
variant,
|
||||
header: {
|
||||
title,
|
||||
subtitle,
|
||||
leading,
|
||||
actions,
|
||||
breadcrumbs,
|
||||
selectionLabel,
|
||||
floatingActions,
|
||||
},
|
||||
content,
|
||||
detail,
|
||||
};
|
||||
};
|
||||
|
||||
export default createWorkspaceSurfaceConfig;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface WorkspaceSurfaceHeaderConfig {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
leading?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
breadcrumbs?: ReactNode;
|
||||
selectionLabel?: ReactNode;
|
||||
floatingActions?: ReactNode;
|
||||
}
|
||||
|
||||
interface WorkspaceSurfaceConfig {
|
||||
key: string;
|
||||
variant: string;
|
||||
header: WorkspaceSurfaceHeaderConfig;
|
||||
content?: ReactNode;
|
||||
detail?: ReactNode;
|
||||
}
|
||||
|
||||
interface CreateWorkspaceSurfaceConfigArgs {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
sidebarToggle?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
breadcrumbs?: ReactNode;
|
||||
selectionLabel?: ReactNode;
|
||||
floatingActions?: ReactNode;
|
||||
content?: ReactNode;
|
||||
detail?: ReactNode;
|
||||
variant?: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export const createWorkspaceSurfaceConfig = ({
|
||||
title,
|
||||
subtitle = null,
|
||||
sidebarToggle = null,
|
||||
actions = null,
|
||||
breadcrumbs = null,
|
||||
selectionLabel = null,
|
||||
floatingActions = null,
|
||||
content = null,
|
||||
detail = null,
|
||||
variant = 'documents',
|
||||
key = 'documents',
|
||||
}: CreateWorkspaceSurfaceConfigArgs): WorkspaceSurfaceConfig => {
|
||||
const leading = sidebarToggle ? <>{sidebarToggle}</> : null;
|
||||
|
||||
return {
|
||||
key,
|
||||
variant,
|
||||
header: {
|
||||
title,
|
||||
subtitle,
|
||||
leading,
|
||||
actions,
|
||||
breadcrumbs,
|
||||
selectionLabel,
|
||||
floatingActions,
|
||||
},
|
||||
content,
|
||||
detail,
|
||||
};
|
||||
};
|
||||
|
||||
export default createWorkspaceSurfaceConfig;
|
||||
Reference in New Issue
Block a user