typescript
This commit is contained in:
@@ -93,6 +93,24 @@ const useDocumentCorrespondentActions = ({
|
||||
[apiClient, refreshCurrentFolder, notifyApiError, setStatusMessage],
|
||||
);
|
||||
|
||||
const normalizeOption = (
|
||||
option: CorrespondentOption | string | null,
|
||||
): CorrespondentOption | null => {
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
if (typeof option === 'object' && 'id' in option) {
|
||||
return option as CorrespondentOption;
|
||||
}
|
||||
if (typeof option === 'string') {
|
||||
const trimmed = option.trim();
|
||||
if (trimmed) {
|
||||
return { id: null, name: trimmed };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleCorrespondentAdd = useCallback(
|
||||
async ({ document, name, input = null, option = null }: { document?: { id?: string | number }; name?: string; input?: HTMLInputElement | null; option?: CorrespondentOption | string | null }) => {
|
||||
if (!document?.id) {
|
||||
@@ -104,12 +122,7 @@ const useDocumentCorrespondentActions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
let target = null;
|
||||
if (option && option.id) {
|
||||
target = correspondentLookupByName.get(trimmed.toLowerCase()) || option;
|
||||
} else {
|
||||
target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
|
||||
}
|
||||
let target = correspondentLookupByName.get(trimmed.toLowerCase()) || normalizeOption(option);
|
||||
if (!target) {
|
||||
try {
|
||||
target = await handleCorrespondentCreate({ name: trimmed });
|
||||
|
||||
@@ -102,12 +102,18 @@ const useDocumentDragHandlers = ({
|
||||
if (item.type === 'document') {
|
||||
const doc = item.payload;
|
||||
const rowEl = doc?.id
|
||||
? document.getElementById(`document-row-${doc.id}`)
|
||||
|| document.getElementById(`document-card-${doc.id}`)
|
||||
? (document.getElementById(`document-row-${doc.id}`)
|
||||
|| document.getElementById(`document-card-${doc.id}`))
|
||||
: null;
|
||||
const wrapperEl = rowEl instanceof HTMLElement
|
||||
? rowEl.querySelector<HTMLElement>('.document-thumbnail-wrapper')
|
||||
: null;
|
||||
const thumbnailEl = rowEl instanceof HTMLElement
|
||||
? rowEl.querySelector<HTMLImageElement>('.document-thumbnail')
|
||||
: null;
|
||||
const placeholderEl = rowEl instanceof HTMLElement
|
||||
? rowEl.querySelector<HTMLElement>('.thumb-placeholder')
|
||||
: null;
|
||||
const wrapperEl = rowEl?.querySelector('.document-thumbnail-wrapper');
|
||||
const thumbnailEl = rowEl?.querySelector('.document-thumbnail');
|
||||
const placeholderEl = rowEl?.querySelector('.thumb-placeholder');
|
||||
const aspectAttr = wrapperEl?.dataset?.thumbnailAspect;
|
||||
const aspectRatio = aspectAttr ? parseFloat(aspectAttr) : null;
|
||||
|
||||
@@ -130,7 +136,7 @@ const useDocumentDragHandlers = ({
|
||||
layer.classList.add('document-drag-preview__item--image');
|
||||
layer.style.backgroundImage = `url("${thumbSrc}")`;
|
||||
} else if (placeholderEl instanceof HTMLElement) {
|
||||
const clone = placeholderEl.cloneNode(true);
|
||||
const clone = placeholderEl.cloneNode(true) as HTMLElement;
|
||||
clone.style.pointerEvents = 'none';
|
||||
layer.appendChild(clone);
|
||||
} else {
|
||||
@@ -138,27 +144,36 @@ const useDocumentDragHandlers = ({
|
||||
}
|
||||
} else {
|
||||
const payload = item.payload;
|
||||
const folderId = payload?.id ?? (typeof payload?.trim === 'function' ? payload : null);
|
||||
const folderId = (payload && typeof payload === 'object' && 'id' in payload)
|
||||
? (payload as { id?: FolderIdentifier }).id
|
||||
: (typeof (payload as { trim?: () => string })?.trim === 'function'
|
||||
? (payload as { trim: () => string }).trim()
|
||||
: null);
|
||||
const rowEl = folderId
|
||||
? document.getElementById(`folder-row-${folderId}`)
|
||||
|| document.getElementById(`folder-card-${folderId}`)
|
||||
? (document.getElementById(`folder-row-${folderId}`)
|
||||
|| document.getElementById(`folder-card-${folderId}`))
|
||||
: null;
|
||||
const iconEl = rowEl instanceof HTMLElement
|
||||
? rowEl.querySelector('.thumb-icon, .folder-card__icon')
|
||||
: null;
|
||||
const iconEl = rowEl?.querySelector('.thumb-icon, .folder-card__icon');
|
||||
layer.style.width = `${size}px`;
|
||||
layer.style.height = `${size}px`;
|
||||
layer.classList.add('document-drag-preview__item--folder');
|
||||
|
||||
let content = null;
|
||||
let content: HTMLElement | null = null;
|
||||
if (iconEl instanceof HTMLElement) {
|
||||
const cloneSource = iconEl.classList.contains('folder-card__icon')
|
||||
? iconEl.querySelector('svg') || iconEl
|
||||
: iconEl;
|
||||
content = cloneSource.cloneNode(true);
|
||||
content.classList.add('document-drag-preview__folder-thumb');
|
||||
const svg = content.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.setAttribute('width', '48');
|
||||
svg.setAttribute('height', '48');
|
||||
const clone = cloneSource.cloneNode(true);
|
||||
if (clone instanceof HTMLElement) {
|
||||
content = clone;
|
||||
content.classList.add('document-drag-preview__folder-thumb');
|
||||
const svg = content.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.setAttribute('width', '48');
|
||||
svg.setAttribute('height', '48');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -326,12 +326,12 @@ const useDocumentMutations = ({
|
||||
return filtered.length === prev.length ? prev : filtered;
|
||||
});
|
||||
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId)));
|
||||
setFolderContents((prev) => {
|
||||
setFolderContents((prev: Map<FolderId, FolderContents>) => {
|
||||
if (!prev.size) {
|
||||
return prev;
|
||||
}
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
const next = new Map<FolderId, FolderContents>(prev);
|
||||
movedDocs.forEach(({ id, sourceFolderId }) => {
|
||||
const sourceKey = (sourceFolderId || 'root') as FolderId;
|
||||
const entry = next.get(sourceKey);
|
||||
@@ -714,8 +714,8 @@ const useDocumentMutations = ({
|
||||
|
||||
await api.delete(`/folders/${folderId}`);
|
||||
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
setFolderNodes((prev: Map<FolderId, FolderNode>) => {
|
||||
const next = new Map<FolderId, FolderNode>(prev);
|
||||
const node = next.get(folderId);
|
||||
next.delete(folderId);
|
||||
if (node) {
|
||||
@@ -733,8 +733,8 @@ const useDocumentMutations = ({
|
||||
return next;
|
||||
});
|
||||
|
||||
setFolderContents((prev) => {
|
||||
const next = new Map(prev);
|
||||
setFolderContents((prev: Map<FolderId, FolderContents>) => {
|
||||
const next = new Map<FolderId, FolderContents>(prev);
|
||||
next.delete(folderId);
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -214,11 +214,15 @@ const useDocumentTagging = ({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await apiClient.post('/documents/bulk/reanalyze', {
|
||||
document_ids: targetIds,
|
||||
force: true,
|
||||
});
|
||||
const queued = data?.queued ?? targetIds.length;
|
||||
const response = await apiClient.post<{ queued?: number }>(
|
||||
'/documents/bulk/reanalyze',
|
||||
{
|
||||
document_ids: targetIds,
|
||||
force: true,
|
||||
},
|
||||
);
|
||||
const payload = 'data' in response ? response.data : response;
|
||||
const queued = typeof payload?.queued === 'number' ? payload.queued : targetIds.length;
|
||||
setStatusMessage(
|
||||
`Queued re-analysis for ${queued} document${queued === 1 ? '' : 's'}.`,
|
||||
'success',
|
||||
|
||||
@@ -28,6 +28,7 @@ type UploadQueueItem = {
|
||||
interface UploadResponse {
|
||||
reused?: boolean;
|
||||
document?: unknown;
|
||||
folder?: { id?: FolderId };
|
||||
}
|
||||
|
||||
interface ApiClient {
|
||||
@@ -35,15 +36,19 @@ interface ApiClient {
|
||||
get<T = { document?: unknown }>(url: string): Promise<{ data: T }>;
|
||||
}
|
||||
|
||||
type StatusLevel = 'success' | 'info' | 'warning' | 'error' | string;
|
||||
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
|
||||
type SetStatusMessage = (message: string, level?: StatusLevel) => void;
|
||||
|
||||
type DropOverlayState = {
|
||||
active: boolean;
|
||||
folderName: string;
|
||||
};
|
||||
|
||||
type FileSystemEntryLike = FileSystemFileEntryLike | FileSystemDirectoryEntryLike;
|
||||
type FileSystemEntryLike = FileSystemEntry;
|
||||
|
||||
type ExtendedDataTransferItem = DataTransferItem & {
|
||||
webkitGetAsEntry?: () => FileSystemEntryLike | null;
|
||||
webkitGetAsEntry?: () => FileSystemEntry | null;
|
||||
};
|
||||
|
||||
interface FileSystemDirectoryReaderLike {
|
||||
@@ -98,6 +103,8 @@ interface UseDocumentUploadsArgs {
|
||||
refreshCurrentFolder: () => Promise<void>;
|
||||
setLoading: (state: boolean) => void;
|
||||
shellRef: MutableRefObject<HTMLElement | null>;
|
||||
notifyApiError?: NotifyApiError;
|
||||
setStatusMessage?: SetStatusMessage;
|
||||
}
|
||||
|
||||
interface UseDocumentUploadsResult {
|
||||
@@ -127,6 +134,8 @@ const useDocumentUploads = ({
|
||||
refreshCurrentFolder,
|
||||
setLoading,
|
||||
shellRef,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
}: UseDocumentUploadsArgs): UseDocumentUploadsResult => {
|
||||
const [dropOverlayState, setDropOverlayState] = useState<DropOverlayState>({
|
||||
active: false,
|
||||
@@ -145,8 +154,8 @@ const useDocumentUploads = ({
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
if (targetFolderId && targetFolderId !== 'root') {
|
||||
formData.append('folder_id', targetFolderId);
|
||||
if (targetFolderId != null && targetFolderId !== 'root') {
|
||||
formData.append('folder_id', String(targetFolderId));
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -179,11 +188,13 @@ const useDocumentUploads = ({
|
||||
};
|
||||
}
|
||||
const message = error.response?.data?.error || `Failed to upload ${file.name}.`;
|
||||
notifyApiError?.(error, message);
|
||||
setStatusMessage?.(message, 'error');
|
||||
const wrapped = Object.assign(new Error(message), { response: error.response });
|
||||
throw wrapped;
|
||||
}
|
||||
},
|
||||
[apiClient],
|
||||
[apiClient, notifyApiError, setStatusMessage],
|
||||
);
|
||||
|
||||
const appendQueueItems = useCallback((entries: FileEntry[], targetFolderId?: FolderId) => {
|
||||
@@ -235,9 +246,13 @@ const useDocumentUploads = ({
|
||||
segments: trimmedSegments,
|
||||
};
|
||||
|
||||
const { data } = await apiClient.post('/folders/path', payload);
|
||||
cache.set(cacheKey, data.folder.id);
|
||||
return data.folder.id;
|
||||
const { data } = await apiClient.post<{ folder?: { id?: FolderId | null } }>(
|
||||
'/folders/path',
|
||||
payload,
|
||||
);
|
||||
const resolvedId = (data?.folder?.id ?? null) as FolderId;
|
||||
cache.set(cacheKey, resolvedId);
|
||||
return resolvedId;
|
||||
},
|
||||
[apiClient],
|
||||
);
|
||||
@@ -284,7 +299,7 @@ const useDocumentUploads = ({
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
try {
|
||||
(entry as FileSystemFileEntryLike).file(resolve, reject);
|
||||
(entry as unknown as FileSystemFileEntryLike).file(resolve, reject);
|
||||
} catch (error) {
|
||||
console.warn('[Uploads] entry.file failed', error);
|
||||
reject(error as Error);
|
||||
@@ -295,7 +310,7 @@ const useDocumentUploads = ({
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors];
|
||||
const reader = (entry as FileSystemDirectoryEntryLike).createReader();
|
||||
const reader = (entry as unknown as FileSystemDirectoryEntryLike).createReader();
|
||||
const entries = await readAllEntries(reader);
|
||||
for (const child of entries) {
|
||||
await walkEntry(child, nextAncestors);
|
||||
@@ -506,7 +521,6 @@ const useDocumentUploads = ({
|
||||
hasFiles,
|
||||
defaultFolderName: DEFAULT_FOLDER_NAME,
|
||||
dragCounterRef,
|
||||
dropOverlayState,
|
||||
setDropOverlayState,
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,44 @@ const EntryType = Object.freeze({
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
type Identifier = string | number;
|
||||
type DocumentId = Identifier;
|
||||
type FolderId = Identifier | 'root';
|
||||
|
||||
interface DocumentLike {
|
||||
id?: DocumentId | null;
|
||||
title?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderNode {
|
||||
id: FolderId;
|
||||
name?: string | null;
|
||||
parentId?: FolderId | null;
|
||||
children: FolderId[];
|
||||
hasChildren?: boolean;
|
||||
expanded?: boolean;
|
||||
loaded?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderContentsEntry {
|
||||
folder?: { id?: FolderId; name?: string | null } | null;
|
||||
documents?: DocumentLike[];
|
||||
subfolders?: Array<{ id?: FolderId; name?: string | null; [key: string]: unknown }>;
|
||||
__includesDocuments?: boolean;
|
||||
__sortField?: string | null;
|
||||
__sortDirection?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TenantOption {
|
||||
id?: Identifier | null;
|
||||
name?: string | null;
|
||||
slug?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseDocumentsWorkspaceOptions {
|
||||
documentsViewMode?: string;
|
||||
documentsSortField?: string;
|
||||
@@ -118,9 +156,31 @@ const useDocumentsWorkspace = ({
|
||||
const routeFolderId = folderMatch?.params?.folderId || null;
|
||||
const routeDocumentId = docMatch?.params?.documentId || null;
|
||||
const previewDocumentId = routeDocumentId;
|
||||
const { status: appStatus, token, tenant, tenants: tenantOptions = [] } = appState;
|
||||
const tenantName = tenant?.name || tenant?.slug || null;
|
||||
const currentTenantId = tenant?.id || null;
|
||||
const {
|
||||
status: appStatus,
|
||||
token,
|
||||
tenant,
|
||||
tenants: tenantOptionsRaw = [],
|
||||
} = appState;
|
||||
|
||||
const tenantRecord = (tenant ?? null) as TenantOption | null;
|
||||
const tenantName: string | null = typeof tenantRecord?.name === 'string'
|
||||
? tenantRecord.name
|
||||
: typeof tenantRecord?.slug === 'string'
|
||||
? tenantRecord.slug
|
||||
: null;
|
||||
|
||||
const currentTenantId: Identifier | null = (() => {
|
||||
const value = tenantRecord?.id;
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return value as Identifier;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const tenantOptions: TenantOption[] = Array.isArray(tenantOptionsRaw)
|
||||
? (tenantOptionsRaw as TenantOption[])
|
||||
: [];
|
||||
const { status, setStatusMessage } = useDocumentsStore();
|
||||
const handleApiReport = useCallback(
|
||||
({ message, variant }) => setStatusMessage(message, variant),
|
||||
@@ -172,9 +232,9 @@ const useDocumentsWorkspace = ({
|
||||
documentsRouteMatch || documentsFolderRouteMatch || documentsDetailRouteMatch,
|
||||
);
|
||||
|
||||
const [draggedDocumentIds, setDraggedDocumentIds] = useState([]);
|
||||
const [draggedFolderId, setDraggedFolderId] = useState(null);
|
||||
const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null);
|
||||
const [draggedDocumentIds, setDraggedDocumentIds] = useState<DocumentId[]>([]);
|
||||
const [draggedFolderId, setDraggedFolderId] = useState<FolderId | null>(null);
|
||||
const [activePreviewId, setActivePreviewId] = useState<DocumentId | null>(routeDocumentId || null);
|
||||
const shellRef = useRef(null);
|
||||
const assetManagerRef = useRef(null);
|
||||
if (!assetManagerRef.current) {
|
||||
@@ -249,7 +309,9 @@ const useDocumentsWorkspace = ({
|
||||
],
|
||||
);
|
||||
|
||||
const [folderContents, setFolderContents] = useState(() => new Map());
|
||||
const [folderContents, setFolderContents] = useState<Map<FolderId, FolderContentsEntry>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const folderContentsRef = useRef(folderContents);
|
||||
useEffect(() => {
|
||||
folderContentsRef.current = folderContents;
|
||||
@@ -363,6 +425,17 @@ const useDocumentsWorkspace = ({
|
||||
setActivePreviewId,
|
||||
});
|
||||
|
||||
const openDocumentPreviewForDetail = useCallback(
|
||||
({ documentIds }: { documentIds?: Identifier[] } = {}) => {
|
||||
const targetId = documentIds?.find((value): value is Identifier => value != null);
|
||||
if (targetId == null) {
|
||||
return;
|
||||
}
|
||||
openDocumentPreview(targetId, { replace: true });
|
||||
},
|
||||
[openDocumentPreview],
|
||||
);
|
||||
|
||||
const getDocumentAsset = useCallback((doc, type) => {
|
||||
if (!doc || !type) return null;
|
||||
return getAssetFromVersion(doc.current_version || null, type);
|
||||
@@ -688,12 +761,13 @@ const useDocumentsWorkspace = ({
|
||||
|
||||
|
||||
const removeDocumentsFromCaches = useCallback(
|
||||
(documentIds) => {
|
||||
if (!documentIds || documentIds.length === 0) {
|
||||
(documentIds: DocumentId[] | null | undefined) => {
|
||||
const safeIds = Array.isArray(documentIds) ? documentIds : [];
|
||||
if (!safeIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idSet = new Set(documentIds);
|
||||
const idSet = new Set<DocumentId>(safeIds);
|
||||
|
||||
setDocuments((prev) => prev.filter((doc) => !idSet.has(doc.id)));
|
||||
setSearchResults((prev) => {
|
||||
@@ -704,12 +778,12 @@ const useDocumentsWorkspace = ({
|
||||
return filtered.length === prev.length ? prev : filtered;
|
||||
});
|
||||
|
||||
setFolderContents((prev) => {
|
||||
setFolderContents((prev: Map<FolderId, FolderContentsEntry>) => {
|
||||
if (!prev.size) {
|
||||
return prev;
|
||||
}
|
||||
let changed = false;
|
||||
const next = new Map();
|
||||
const next = new Map<FolderId, FolderContentsEntry>();
|
||||
prev.forEach((contents, key) => {
|
||||
const docs = Array.isArray(contents?.documents) ? contents.documents : null;
|
||||
if (!docs || docs.length === 0) {
|
||||
@@ -1177,7 +1251,6 @@ const useDocumentsWorkspace = ({
|
||||
documents,
|
||||
searchResults,
|
||||
previewDocuments,
|
||||
focusedDocumentId,
|
||||
selectionOrder,
|
||||
selectedDocumentIds,
|
||||
documentLookup,
|
||||
@@ -1188,8 +1261,7 @@ const useDocumentsWorkspace = ({
|
||||
previewEntries,
|
||||
previewDocumentId,
|
||||
activePreviewId,
|
||||
openDocumentPreview,
|
||||
promoteSelectionOrder,
|
||||
openDocumentPreview: openDocumentPreviewForDetail,
|
||||
handleDocumentTitleUpdate,
|
||||
handleDocumentIssuedUpdate,
|
||||
handleDocumentTagAdd,
|
||||
@@ -1206,6 +1278,17 @@ const useDocumentsWorkspace = ({
|
||||
tagLookupById,
|
||||
});
|
||||
|
||||
const inspectDocumentForDesk = useCallback(
|
||||
(doc: DocumentLike | null) => {
|
||||
const docId = doc?.id;
|
||||
if (docId == null) {
|
||||
return;
|
||||
}
|
||||
inspectDocument(docId);
|
||||
},
|
||||
[inspectDocument],
|
||||
);
|
||||
|
||||
const handleEntryPointerCore = useEntryPointerCore({
|
||||
resolveDocumentRowKey,
|
||||
resolveFolderRowKey,
|
||||
@@ -1426,7 +1509,7 @@ const useDocumentsWorkspace = ({
|
||||
handleDocumentsViewModeChange,
|
||||
handleDeskExit: handleDeskExitSafe,
|
||||
refreshCurrentFolder,
|
||||
inspectDocument,
|
||||
inspectDocument: inspectDocumentForDesk,
|
||||
handleEntryPointerCore,
|
||||
promoteSelectionOrder,
|
||||
currentTenantId,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { MutableRefObject, useEffect } from 'react';
|
||||
|
||||
type FolderId = string | number | 'root' | null;
|
||||
|
||||
interface DropOverlayState {
|
||||
active: boolean;
|
||||
folderName: string | null;
|
||||
@@ -9,8 +11,8 @@ interface UseFileDropOptions {
|
||||
shellRef: MutableRefObject<HTMLElement | null>;
|
||||
token?: string | null;
|
||||
currentFolderName: string | null;
|
||||
selectedFolder: string | null;
|
||||
handleFileDrop: (dataTransfer: DataTransfer, folderId: string | null) => Promise<void>;
|
||||
selectedFolder: FolderId;
|
||||
handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderId) => Promise<void>;
|
||||
hasFiles: (event: DragEvent) => boolean;
|
||||
defaultFolderName: string;
|
||||
dragCounterRef: MutableRefObject<number>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
import {
|
||||
DEFAULT_FOLDER_NAME,
|
||||
createRootNode,
|
||||
@@ -9,6 +10,80 @@ import {
|
||||
resolveFolderRowKey,
|
||||
} from '../../app/appLayoutUtils';
|
||||
|
||||
type Identifier = string | number;
|
||||
type FolderId = Identifier | 'root';
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderSummary {
|
||||
id?: FolderId;
|
||||
name?: string;
|
||||
parent_id?: FolderId | null;
|
||||
parentId?: FolderId | null;
|
||||
children?: FolderId[];
|
||||
subfolders?: FolderSummary[];
|
||||
has_children?: boolean;
|
||||
hasChildren?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderContentsEntry {
|
||||
folder?: FolderSummary | null;
|
||||
documents?: DocumentLike[];
|
||||
subfolders?: FolderSummary[];
|
||||
__includesDocuments?: boolean;
|
||||
__sortField?: string | null;
|
||||
__sortDirection?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderTreeNode extends FolderSummary {
|
||||
id: FolderId;
|
||||
children: FolderId[];
|
||||
expanded?: boolean;
|
||||
loaded?: boolean;
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
|
||||
interface AssetManagerLike {
|
||||
hydrateDocuments: (docs: DocumentLike[]) => DocumentLike[];
|
||||
hydrateFolderContents: (payload: FolderContentsEntry) => FolderContentsEntry;
|
||||
}
|
||||
|
||||
interface ApiClient {
|
||||
get<T = FolderContentsEntry>(path: string, config?: { params?: Record<string, unknown> }): Promise<{ data: T }>;
|
||||
}
|
||||
|
||||
interface SelectionHelpers {
|
||||
focusedDocumentId: Identifier | null;
|
||||
setFocusedDocumentId: Dispatch<SetStateAction<Identifier | null>>;
|
||||
setSelectedEntries: Dispatch<SetStateAction<string[]>>;
|
||||
setSelectionOrder: Dispatch<SetStateAction<string[]>>;
|
||||
selectionOrderRef: MutableRefObject<string[] | null>;
|
||||
selectionAnchorRef: MutableRefObject<string | null>;
|
||||
}
|
||||
|
||||
interface UseFolderTreeOptions {
|
||||
initialSelectedFolder?: FolderId;
|
||||
assetManager: AssetManagerLike;
|
||||
apiClient: ApiClient;
|
||||
tenantIdRef: MutableRefObject<Identifier | null>;
|
||||
documentsSortFieldRef: MutableRefObject<string>;
|
||||
documentsSortDirectionRef: MutableRefObject<string>;
|
||||
selectionHelpers: SelectionHelpers;
|
||||
setDocuments: Dispatch<SetStateAction<DocumentLike[]>>;
|
||||
setFolderContents: Dispatch<SetStateAction<Map<FolderId, FolderContentsEntry>>>;
|
||||
folderContentsRef: MutableRefObject<Map<FolderId, FolderContentsEntry>>;
|
||||
}
|
||||
|
||||
interface FolderOption {
|
||||
id: FolderId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const useFolderTree = ({
|
||||
initialSelectedFolder = 'root',
|
||||
assetManager,
|
||||
@@ -20,15 +95,15 @@ const useFolderTree = ({
|
||||
setDocuments,
|
||||
setFolderContents,
|
||||
folderContentsRef,
|
||||
}) => {
|
||||
const [folderNodes, setFolderNodes] = useState(() => {
|
||||
const rootNode = createRootNode();
|
||||
}: UseFolderTreeOptions) => {
|
||||
const [folderNodes, setFolderNodes] = useState<Map<FolderId, FolderTreeNode>>(() => {
|
||||
const rootNode = createRootNode() as FolderTreeNode;
|
||||
return new Map([[rootNode.id, rootNode]]);
|
||||
});
|
||||
|
||||
const [selectedFolder, setSelectedFolder] = useState(initialSelectedFolder || 'root');
|
||||
const [currentFolder, setCurrentFolder] = useState(null);
|
||||
const [currentSubfolders, setCurrentSubfolders] = useState([]);
|
||||
const [selectedFolder, setSelectedFolder] = useState<FolderId>(initialSelectedFolder || 'root');
|
||||
const [currentFolder, setCurrentFolder] = useState<FolderSummary | null>(null);
|
||||
const [currentSubfolders, setCurrentSubfolders] = useState<FolderSummary[]>([]);
|
||||
|
||||
const {
|
||||
focusedDocumentId,
|
||||
@@ -40,8 +115,8 @@ const useFolderTree = ({
|
||||
} = selectionHelpers;
|
||||
|
||||
const applySelectedFolder = useCallback(
|
||||
(folderId, contents) => {
|
||||
const subfolders = contents?.subfolders ?? [];
|
||||
(folderId: FolderId, contents?: FolderContentsEntry | null) => {
|
||||
const subfolders = Array.isArray(contents?.subfolders) ? contents.subfolders : [];
|
||||
const docs = assetManager.hydrateDocuments(contents?.documents ?? []);
|
||||
const folderInfo = contents?.folder ?? null;
|
||||
|
||||
@@ -50,12 +125,12 @@ const useFolderTree = ({
|
||||
setCurrentFolder(folderInfo);
|
||||
|
||||
const availableDocKeys = docs
|
||||
.map((doc) => resolveDocumentRowKey(doc.id))
|
||||
.map((doc) => resolveDocumentRowKey(doc?.id as Identifier))
|
||||
.filter(Boolean);
|
||||
const availableDocKeySet = new Set(availableDocKeys);
|
||||
const availableFolderKeys = new Set(
|
||||
subfolders
|
||||
.map((folder) => resolveFolderRowKey(folder.id))
|
||||
.map((folder) => resolveFolderRowKey(folder?.id as Identifier))
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
@@ -102,20 +177,20 @@ const useFolderTree = ({
|
||||
],
|
||||
);
|
||||
|
||||
const expandFolderAncestors = useCallback((targetId) => {
|
||||
const expandFolderAncestors = useCallback((targetId: FolderId | null) => {
|
||||
if (!targetId || targetId === 'root') {
|
||||
setFolderNodes((prev) => {
|
||||
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
|
||||
const root = prev.get('root');
|
||||
if (root?.expanded) return prev;
|
||||
const next = new Map(prev);
|
||||
const next = new Map<FolderId, FolderTreeNode>(prev);
|
||||
next.set('root', { ...root, expanded: true });
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
|
||||
const next = new Map<FolderId, FolderTreeNode>(prev);
|
||||
let currentId = targetId;
|
||||
let guard = 0;
|
||||
while (currentId && guard < 32) {
|
||||
@@ -133,15 +208,21 @@ const useFolderTree = ({
|
||||
|
||||
const ensureFolderData = useCallback(
|
||||
async (
|
||||
folderId,
|
||||
folderId: FolderId,
|
||||
{
|
||||
includeDocuments = true,
|
||||
prefetchDepth = 0,
|
||||
force = false,
|
||||
sortField = documentsSortFieldRef.current,
|
||||
sortDirection = documentsSortDirectionRef.current,
|
||||
}: {
|
||||
includeDocuments?: boolean;
|
||||
prefetchDepth?: number;
|
||||
force?: boolean;
|
||||
sortField?: string;
|
||||
sortDirection?: string;
|
||||
} = {},
|
||||
) => {
|
||||
): Promise<FolderContentsEntry> => {
|
||||
const requestTenantId = tenantIdRef.current;
|
||||
const cached = folderContentsRef.current.get(folderId);
|
||||
const cachedSortField = cached?.__sortField || documentsSortFieldRef.current;
|
||||
@@ -168,7 +249,7 @@ const useFolderTree = ({
|
||||
}
|
||||
|
||||
const path = folderId === 'root' ? 'root' : folderId;
|
||||
const params = {};
|
||||
const params: Record<string, unknown> = {};
|
||||
if (!includeDocuments) {
|
||||
params.include_documents = false;
|
||||
} else {
|
||||
@@ -176,10 +257,12 @@ const useFolderTree = ({
|
||||
params.dir = sortDirection;
|
||||
}
|
||||
const requestConfig = Object.keys(params).length ? { params } : {};
|
||||
const { data } = await apiClient.get(`/folders/${path}/contents`, requestConfig);
|
||||
const { data } = await apiClient.get<FolderContentsEntry>(`/folders/${path}/contents`, requestConfig);
|
||||
const hydrated = assetManager.hydrateFolderContents(data);
|
||||
const childFolders = Array.isArray(data.subfolders) ? data.subfolders : [];
|
||||
const childIds = childFolders.map((child) => child.id);
|
||||
const childIds = childFolders
|
||||
.map((child) => (child?.id ?? null) as FolderId | null)
|
||||
.filter((id): id is FolderId => Boolean(id));
|
||||
|
||||
const enriched = {
|
||||
...hydrated,
|
||||
@@ -192,8 +275,8 @@ const useFolderTree = ({
|
||||
return enriched;
|
||||
}
|
||||
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
|
||||
const next = new Map<FolderId, FolderTreeNode>(prev);
|
||||
const existingNode = next.get(folderId) || {
|
||||
id: folderId,
|
||||
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || 'Folder',
|
||||
@@ -215,7 +298,11 @@ const useFolderTree = ({
|
||||
});
|
||||
|
||||
childFolders.forEach((child) => {
|
||||
const childNode = next.get(child.id);
|
||||
const childId = (child?.id ?? null) as FolderId | null;
|
||||
if (!childId) {
|
||||
return;
|
||||
}
|
||||
const childNode = next.get(childId);
|
||||
const previousChildren = Array.isArray(childNode?.children) ? childNode.children : [];
|
||||
const childHasChildren = (() => {
|
||||
if (childNode?.loaded) {
|
||||
@@ -235,10 +322,10 @@ const useFolderTree = ({
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
next.set(child.id, {
|
||||
id: child.id,
|
||||
next.set(childId, {
|
||||
id: childId,
|
||||
name: child.name,
|
||||
parentId: child.parent_id ?? 'root',
|
||||
parentId: (child.parent_id ?? 'root') as FolderId,
|
||||
children: previousChildren,
|
||||
expanded: childNode?.expanded ?? false,
|
||||
loaded: childNode?.loaded ?? false,
|
||||
@@ -261,11 +348,11 @@ const useFolderTree = ({
|
||||
);
|
||||
}
|
||||
|
||||
setFolderContents((prev) => {
|
||||
setFolderContents((prev: Map<FolderId, FolderContentsEntry>) => {
|
||||
if (tenantIdRef.current !== requestTenantId) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
const next = new Map<FolderId, FolderContentsEntry>(prev);
|
||||
if (includeDocuments) {
|
||||
next.set(folderId, enriched);
|
||||
} else {
|
||||
@@ -302,7 +389,7 @@ const useFolderTree = ({
|
||||
);
|
||||
|
||||
const ensureFolderAncestorsLoaded = useCallback(
|
||||
async (targetId) => {
|
||||
async (targetId: FolderId | null) => {
|
||||
if (!targetId || targetId === 'root') {
|
||||
return;
|
||||
}
|
||||
@@ -323,7 +410,7 @@ const useFolderTree = ({
|
||||
);
|
||||
|
||||
const isInvalidFolderDrop = useCallback(
|
||||
(sourceId, targetId) => {
|
||||
(sourceId: FolderId | null, targetId: FolderId | null) => {
|
||||
if (!sourceId) return false;
|
||||
if (!targetId || targetId === 'root') {
|
||||
return false;
|
||||
@@ -349,9 +436,9 @@ const useFolderTree = ({
|
||||
);
|
||||
|
||||
const resetFolderTreeState = useCallback(() => {
|
||||
const rootNode = createRootNode();
|
||||
setFolderNodes(new Map([[rootNode.id, rootNode]]));
|
||||
setFolderContents(new Map());
|
||||
const rootNode = createRootNode() as FolderTreeNode;
|
||||
setFolderNodes(new Map<FolderId, FolderTreeNode>([[rootNode.id, rootNode]]));
|
||||
setFolderContents(new Map<FolderId, FolderContentsEntry>());
|
||||
setSelectedFolder('root');
|
||||
setCurrentFolder(null);
|
||||
setCurrentSubfolders([]);
|
||||
@@ -362,11 +449,11 @@ const useFolderTree = ({
|
||||
return currentFolder.name;
|
||||
}, [selectedFolder, currentFolder]);
|
||||
|
||||
const folderOptions = useMemo(() => {
|
||||
const cache = new Map();
|
||||
const computePath = (id) => {
|
||||
if (cache.has(id)) {
|
||||
return cache.get(id);
|
||||
const folderOptions: FolderOption[] = useMemo(() => {
|
||||
const cache = new Map<FolderId, string>();
|
||||
const computePath = (id: FolderId | null): string => {
|
||||
if (cache.has(id as FolderId)) {
|
||||
return cache.get(id as FolderId) as string;
|
||||
}
|
||||
if (!id || id === 'root') {
|
||||
cache.set('root', DEFAULT_FOLDER_NAME);
|
||||
@@ -376,7 +463,7 @@ const useFolderTree = ({
|
||||
if (!node) {
|
||||
return 'Folder';
|
||||
}
|
||||
const parentId = node.parentId || 'root';
|
||||
const parentId = (node.parentId || 'root') as FolderId;
|
||||
const parentPath = computePath(parentId);
|
||||
const name = node.name || 'Folder';
|
||||
const fullPath = parentId === 'root' ? name : `${parentPath}/${name}`;
|
||||
@@ -384,7 +471,7 @@ const useFolderTree = ({
|
||||
return fullPath;
|
||||
};
|
||||
|
||||
const entries = [];
|
||||
const entries: FolderOption[] = [];
|
||||
folderNodes.forEach((node, id) => {
|
||||
if (!node) return;
|
||||
entries.push({ id, label: computePath(id) });
|
||||
@@ -400,7 +487,7 @@ const useFolderTree = ({
|
||||
}, [folderNodes]);
|
||||
|
||||
const folderLabelMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
const map = new Map<FolderId, string>();
|
||||
folderOptions.forEach((option) => {
|
||||
map.set(option.id, option.label);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NavigateFunction } from 'react-router-dom';
|
||||
interface ApiClient {
|
||||
get: (path: string) => Promise<{ data: unknown }>;
|
||||
post: (path: string, body?: unknown) => Promise<{ data: any }>;
|
||||
defaults: { headers: { common: Record<string, string> } };
|
||||
defaults: { headers: { common: Record<string, unknown> } };
|
||||
}
|
||||
|
||||
interface TenantOption {
|
||||
|
||||
Reference in New Issue
Block a user