refactor: Decouple frontend API calls from a shared client instance

This commit is contained in:
2025-12-04 21:59:39 +01:00
parent d06fc133e7
commit 4308ff220b
13 changed files with 248 additions and 136 deletions
@@ -250,8 +250,34 @@ const SelectionFloatingActions: React.FC<SelectionFloatingActionsProps> = ({
const fetchPromise = (async () => { const fetchPromise = (async () => {
try { try {
const data = await getFolderTree(); const data = await getFolderTree();
setRemoteFolderTree(data);
return data; // Convert flat list to tree
const nodeMap = new Map<string, FolderTreeNode>();
data.forEach((item) => {
nodeMap.set(item.id, {
...item,
children: [],
} as FolderTreeNode);
});
const roots: FolderTreeNode[] = [];
data.forEach((item) => {
const node = nodeMap.get(item.id);
if (!node) return;
if (item.children && item.children.length > 0) {
node.children = item.children
.map((id) => nodeMap.get(id))
.filter((n): n is FolderTreeNode => Boolean(n));
}
if (!item.parent_id) {
roots.push(node);
}
});
setRemoteFolderTree(roots);
return roots;
} catch (error) { } catch (error) {
console.warn('[selection] Failed to load folder tree', error); console.warn('[selection] Failed to load folder tree', error);
setRemoteFolderTree([]); setRemoteFolderTree([]);
@@ -1,15 +1,9 @@
import { MutableRefObject, useCallback, useState } from 'react'; import { MutableRefObject, useCallback, useState } from 'react';
import type { Correspondent } from '../../types/documents'; import type { Correspondent } from '../../types/documents';
type ApiClient = { import { listCorrespondents, createCorrespondent, updateCorrespondent, deleteCorrespondent } from '../../lib/apiClient';
get: (path: string) => Promise<{ data: unknown }>;
post: (path: string, body: unknown) => Promise<{ data: unknown }>;
patch: (path: string, body: unknown) => Promise<{ data: unknown }>;
delete: (path: string) => Promise<{ data: unknown }>;
};
interface UseCorrespondentsOptions { interface UseCorrespondentsOptions {
apiClient: ApiClient;
notifyApiError: (error: unknown, fallback: string) => void; notifyApiError: (error: unknown, fallback: string) => void;
setStatusMessage: (message: string, variant?: string) => void; setStatusMessage: (message: string, variant?: string) => void;
tenantIdRef: MutableRefObject<string | null>; tenantIdRef: MutableRefObject<string | null>;
@@ -17,7 +11,6 @@ interface UseCorrespondentsOptions {
} }
const useCorrespondents = ({ const useCorrespondents = ({
apiClient,
notifyApiError, notifyApiError,
setStatusMessage, setStatusMessage,
tenantIdRef, tenantIdRef,
@@ -28,7 +21,7 @@ const useCorrespondents = ({
const refreshCorrespondents = useCallback(async () => { const refreshCorrespondents = useCallback(async () => {
const requestTenantId = tenantIdRef.current; const requestTenantId = tenantIdRef.current;
try { try {
const { data } = await apiClient.get('/correspondents'); const data = await listCorrespondents();
if (tenantIdRef.current !== requestTenantId) { if (tenantIdRef.current !== requestTenantId) {
return; return;
} }
@@ -39,7 +32,7 @@ const useCorrespondents = ({
} }
notifyApiError(error, 'Unable to load correspondents.'); notifyApiError(error, 'Unable to load correspondents.');
} }
}, [apiClient, notifyApiError, tenantIdRef]); }, [notifyApiError, tenantIdRef]);
const handleCorrespondentUpdate = useCallback( const handleCorrespondentUpdate = useCallback(
async (correspondentId: string, changes: { name?: string }) => { async (correspondentId: string, changes: { name?: string }) => {
@@ -61,7 +54,7 @@ const useCorrespondents = ({
} }
try { try {
await apiClient.patch(`/correspondents/${correspondentId}`, payload); await updateCorrespondent(correspondentId, payload);
await refreshCorrespondents(); await refreshCorrespondents();
setStatusMessage('Correspondent updated.', 'success'); setStatusMessage('Correspondent updated.', 'success');
return true; return true;
@@ -71,7 +64,7 @@ const useCorrespondents = ({
throw new Error(message); throw new Error(message);
} }
}, },
[apiClient, notifyApiError, refreshCorrespondents, setStatusMessage], [notifyApiError, refreshCorrespondents, setStatusMessage],
); );
const handleCorrespondentCreate = useCallback( const handleCorrespondentCreate = useCallback(
@@ -81,7 +74,7 @@ const useCorrespondents = ({
throw new Error('Correspondent name is required.'); throw new Error('Correspondent name is required.');
} }
try { try {
const { data } = await apiClient.post('/correspondents', { name: trimmed }); const data = await createCorrespondent({ name: trimmed });
await refreshCorrespondents(); await refreshCorrespondents();
setStatusMessage('Correspondent created.', 'success'); setStatusMessage('Correspondent created.', 'success');
return data; return data;
@@ -91,7 +84,7 @@ const useCorrespondents = ({
throw new Error(message); throw new Error(message);
} }
}, },
[apiClient, notifyApiError, refreshCorrespondents, setStatusMessage], [notifyApiError, refreshCorrespondents, setStatusMessage],
); );
const handleCorrespondentDelete = useCallback( const handleCorrespondentDelete = useCallback(
@@ -112,7 +105,7 @@ const useCorrespondents = ({
}; };
try { try {
await apiClient.delete(`/correspondents/${correspondentId}`); await deleteCorrespondent(correspondentId);
await refreshCorrespondents(); await refreshCorrespondents();
mapDocumentCaches?.(stripFromDoc); mapDocumentCaches?.(stripFromDoc);
@@ -125,7 +118,7 @@ const useCorrespondents = ({
throw new Error(message); throw new Error(message);
} }
}, },
[apiClient, mapDocumentCaches, notifyApiError, refreshCorrespondents, setStatusMessage], [mapDocumentCaches, notifyApiError, refreshCorrespondents, setStatusMessage],
); );
return { return {
@@ -2,10 +2,7 @@ import { useCallback, useMemo } from 'react';
import type { Identifier } from '../../types/identifiers'; import type { Identifier } from '../../types/identifiers';
type ApiClient = { import { addDocumentCorrespondent, removeDocumentCorrespondent } from '../../lib/apiClient';
post: (path: string, body?: unknown) => Promise<{ data: unknown }>;
delete: (path: string) => Promise<{ data: unknown }>;
};
interface CorrespondentOption { interface CorrespondentOption {
id?: string; id?: string;
@@ -14,7 +11,6 @@ interface CorrespondentOption {
} }
interface UseDocumentCorrespondentActionsArgs { interface UseDocumentCorrespondentActionsArgs {
apiClient: ApiClient;
correspondents: CorrespondentOption[]; correspondents: CorrespondentOption[];
handleCorrespondentCreate: (payload: { name: string }) => Promise<CorrespondentOption | null>; handleCorrespondentCreate: (payload: { name: string }) => Promise<CorrespondentOption | null>;
notifyApiError: (error: unknown, fallback: string) => void; notifyApiError: (error: unknown, fallback: string) => void;
@@ -26,7 +22,6 @@ interface UseDocumentCorrespondentActionsArgs {
} }
const useDocumentCorrespondentActions = ({ const useDocumentCorrespondentActions = ({
apiClient,
correspondents, correspondents,
handleCorrespondentCreate, handleCorrespondentCreate,
notifyApiError, notifyApiError,
@@ -56,10 +51,7 @@ const useDocumentCorrespondentActions = ({
throw new Error('Missing document or correspondent.'); throw new Error('Missing document or correspondent.');
} }
try { try {
await apiClient.post(`/documents/${documentId}/correspondents`, { await addDocumentCorrespondent(documentId, correspondentId);
assignments: [{ correspondent_id: correspondentId }],
replace: false,
});
if (updateDocumentCaches) { if (updateDocumentCaches) {
const resolved = correspondent const resolved = correspondent
|| correspondents.find((entry) => entry?.id === correspondentId) || correspondents.find((entry) => entry?.id === correspondentId)
@@ -88,7 +80,7 @@ const useDocumentCorrespondentActions = ({
throw new Error(message); throw new Error(message);
} }
}, },
[apiClient, correspondents, notifyApiError, setStatusMessage, updateDocumentCaches], [correspondents, notifyApiError, setStatusMessage, updateDocumentCaches],
); );
const handleCorrespondentRemove = useCallback( const handleCorrespondentRemove = useCallback(
@@ -100,7 +92,7 @@ const useDocumentCorrespondentActions = ({
throw new Error('Missing document or correspondent.'); throw new Error('Missing document or correspondent.');
} }
try { try {
await apiClient.delete(`/documents/${documentId}/correspondents/${correspondentId}`); await removeDocumentCorrespondent(documentId, correspondentId);
if (updateDocumentCaches) { if (updateDocumentCaches) {
updateDocumentCaches(documentId, (doc) => { updateDocumentCaches(documentId, (doc) => {
if (!doc || !Array.isArray(doc.correspondents)) { if (!doc || !Array.isArray(doc.correspondents)) {
@@ -120,7 +112,7 @@ const useDocumentCorrespondentActions = ({
throw new Error(message); throw new Error(message);
} }
}, },
[apiClient, notifyApiError, setStatusMessage, updateDocumentCaches], [notifyApiError, setStatusMessage, updateDocumentCaches],
); );
const normalizeOption = ( const normalizeOption = (
@@ -8,16 +8,13 @@ interface TagRecord {
[key: string]: unknown; [key: string]: unknown;
} }
interface ApiClient { import { createTag, bulkTagDocuments, bulkReanalyzeDocuments } from '../../lib/apiClient';
post: <T = { data: unknown }>(path: string, payload: unknown) => Promise<{ data: T } | T>;
}
interface TagManager { interface TagManager {
buildPayload: (input: { label: string }) => Record<string, unknown>; buildPayload: (input: { label: string }) => Record<string, unknown>;
} }
interface UseDocumentTaggingArgs { interface UseDocumentTaggingArgs {
apiClient: ApiClient;
tags: TagRecord[]; tags: TagRecord[];
tagManager: TagManager; tagManager: TagManager;
refreshTags: () => Promise<void> | void; refreshTags: () => Promise<void> | void;
@@ -42,7 +39,6 @@ interface BulkTagOperationResult {
} }
const useDocumentTagging = ({ const useDocumentTagging = ({
apiClient,
tags, tags,
tagManager, tagManager,
refreshTags, refreshTags,
@@ -85,9 +81,9 @@ const useDocumentTagging = ({
for (const label of normalized) { for (const label of normalized) {
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null; let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
if (!tag) { if (!tag) {
const payload = tagManager.buildPayload({ label }); const payload = tagManager.buildPayload({ label }) as { label: string; color?: string | null };
const response = await apiClient.post<{ id?: Identifier; label: string }>('/tags', payload); const response = await createTag(payload);
tag = 'data' in response ? response.data : response; tag = response as TagRecord;
await refreshTags(); await refreshTags();
} }
createdIds.push(tag.id); createdIds.push(tag.id);
@@ -137,7 +133,7 @@ const useDocumentTagging = ({
return { ok: false, reason: 'no-tags' }; return { ok: false, reason: 'no-tags' };
} }
await apiClient.post('/documents/bulk/tags', { await bulkTagDocuments({
document_ids: targetDocumentIds, document_ids: targetDocumentIds,
tag_ids: tagIds, tag_ids: tagIds,
action, action,
@@ -180,7 +176,6 @@ const useDocumentTagging = ({
refreshTags, refreshTags,
notifyApiError, notifyApiError,
tagManager, tagManager,
apiClient,
updateDocumentCaches, updateDocumentCaches,
], ],
); );
@@ -259,14 +254,11 @@ const useDocumentTagging = ({
} }
try { try {
const response = await apiClient.post<{ queued?: number }>( const response = await bulkReanalyzeDocuments({
'/documents/bulk/reanalyze', document_ids: targetIds,
{ force: true,
document_ids: targetIds, });
force: true, const payload = response;
},
);
const payload = 'data' in response ? response.data : response;
const queued = payload?.queued != null const queued = payload?.queued != null
? Number(payload.queued) ? Number(payload.queued)
: targetIds.length; : targetIds.length;
@@ -280,7 +272,7 @@ const useDocumentTagging = ({
notifyApiError(error, message); notifyApiError(error, message);
} }
}, },
[resolveTargetDocumentIds, notifyApiError, setStatusMessage, apiClient], [resolveTargetDocumentIds, notifyApiError, setStatusMessage],
); );
return { return {
@@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import useFileDrop from './useFileDrop'; import useFileDrop from './useFileDrop';
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/workspaceUtils'; import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/workspaceUtils';
import { fetchDocument } from '../../lib/apiClient'; import { fetchDocument, uploadDocument, resolveFolderPath } from '../../lib/apiClient';
import type { Identifier } from '../../types/identifiers'; import type { Identifier } from '../../types/identifiers';
type FolderId = Identifier | 'root' | null; type FolderId = Identifier | 'root' | null;
@@ -26,17 +26,6 @@ type UploadQueueItem = {
conflictDocumentId: Identifier | null; conflictDocumentId: Identifier | null;
}; };
interface UploadResponse {
reused?: boolean;
document?: unknown;
folder?: { id?: FolderId };
}
interface ApiClient {
post<T = UploadResponse>(url: string, payload: unknown): Promise<{ data: T; status?: number }>;
get<T = { document?: unknown }>(url: string): Promise<{ data: T }>;
}
type StatusLevel = 'success' | 'info' | 'warning' | 'error' | string; type StatusLevel = 'success' | 'info' | 'warning' | 'error' | string;
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void; type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
type SetStatusMessage = (message: string, level?: StatusLevel) => void; type SetStatusMessage = (message: string, level?: StatusLevel) => void;
@@ -59,7 +48,7 @@ interface FileSystemDirectoryReaderLike {
) => void; ) => void;
} }
interface FileSystemFileEntryLike { interface FileSystemFileEntryLike extends FileSystemEntry {
isFile: true; isFile: true;
isDirectory: false; isDirectory: false;
name: string; name: string;
@@ -69,12 +58,19 @@ interface FileSystemFileEntryLike {
) => void; ) => void;
} }
interface FileSystemDirectoryEntryLike { interface FileSystemDirectoryEntryLike extends FileSystemEntry {
isFile: false; isFile: false;
isDirectory: true; isDirectory: true;
name: string; name: string;
createReader: () => FileSystemDirectoryReaderLike; createReader: () => FileSystemDirectoryReaderLike;
} }
const isFileEntry = (entry: FileSystemEntryLike): entry is FileSystemFileEntryLike => {
return entry.isFile && !entry.isDirectory;
};
const isDirectoryEntry = (entry: FileSystemEntryLike): entry is FileSystemDirectoryEntryLike => {
return entry.isDirectory && !entry.isFile && 'createReader' in entry;
};
const mapFilesToEntries = (filesInput?: FileList | File[] | null): FileEntry[] => { const mapFilesToEntries = (filesInput?: FileList | File[] | null): FileEntry[] => {
if (!filesInput) { if (!filesInput) {
@@ -96,7 +92,6 @@ const mapFilesToEntries = (filesInput?: FileList | File[] | null): FileEntry[] =
}; };
interface UseDocumentUploadsArgs { interface UseDocumentUploadsArgs {
apiClient: ApiClient;
token?: string | null; token?: string | null;
selectedFolder?: FolderId; selectedFolder?: FolderId;
currentFolderName?: string | null; currentFolderName?: string | null;
@@ -126,7 +121,6 @@ interface UseDocumentUploadsResult {
} }
const useDocumentUploads = ({ const useDocumentUploads = ({
apiClient,
token, token,
selectedFolder, selectedFolder,
currentFolderName, currentFolderName,
@@ -158,11 +152,10 @@ const useDocumentUploads = ({
} }
try { try {
const { data, status } = await apiClient.post('/documents', formData); const { reused, document, status } = await uploadDocument(formData);
const duplicate = data?.reused || status === 200; const duplicate = reused || status === 200;
const document = data?.document ?? data ?? null;
return { return {
document, document: document ?? null,
duplicate, duplicate,
statusCode: status ?? (duplicate ? 200 : 201), statusCode: status ?? (duplicate ? 200 : 201),
conflictDocumentId: null, conflictDocumentId: null,
@@ -192,7 +185,7 @@ const useDocumentUploads = ({
throw wrapped; throw wrapped;
} }
}, },
[apiClient, notifyApiError, setStatusMessage], [notifyApiError, setStatusMessage],
); );
const appendQueueItems = useCallback((entries: FileEntry[], targetFolderId?: FolderId) => { const appendQueueItems = useCallback((entries: FileEntry[], targetFolderId?: FolderId) => {
@@ -244,15 +237,12 @@ const useDocumentUploads = ({
segments: trimmedSegments, segments: trimmedSegments,
}; };
const { data } = await apiClient.post<{ folder?: { id?: FolderId | null } }>( const { folder } = await resolveFolderPath(payload);
'/folders/path', const resolvedId = (folder?.id ?? null) as FolderId;
payload,
);
const resolvedId = (data?.folder?.id ?? null) as FolderId;
cache.set(cacheKey, resolvedId); cache.set(cacheKey, resolvedId);
return resolvedId; return resolvedId;
}, },
[apiClient], [],
); );
const extractFilesFromDataTransfer = useCallback(async (dataTransfer: DataTransfer) => { const extractFilesFromDataTransfer = useCallback(async (dataTransfer: DataTransfer) => {
@@ -294,10 +284,10 @@ const useDocumentUploads = ({
const walkEntry = async (entry: FileSystemEntryLike | null, ancestors: string[] = []) => { const walkEntry = async (entry: FileSystemEntryLike | null, ancestors: string[] = []) => {
if (!entry) return; if (!entry) return;
if (entry.isFile) { if (isFileEntry(entry)) {
const file = await new Promise<File>((resolve, reject) => { const file = await new Promise<File>((resolve, reject) => {
try { try {
(entry as unknown as FileSystemFileEntryLike).file(resolve, reject); entry.file(resolve, reject);
} catch (error) { } catch (error) {
console.warn('[Uploads] entry.file failed', error); console.warn('[Uploads] entry.file failed', error);
reject(error as Error); reject(error as Error);
@@ -306,9 +296,9 @@ const useDocumentUploads = ({
pushFile(file, ancestors); pushFile(file, ancestors);
return; return;
} }
if (entry.isDirectory) { if (isDirectoryEntry(entry)) {
const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors]; const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors];
const reader = (entry as unknown as FileSystemDirectoryEntryLike).createReader(); const reader = entry.createReader();
const entries = await readAllEntries(reader); const entries = await readAllEntries(reader);
for (const child of entries) { for (const child of entries) {
await walkEntry(child, nextAncestors); await walkEntry(child, nextAncestors);
@@ -338,7 +338,6 @@ const useDocumentsWorkspace = ({
isInvalidFolderDrop, isInvalidFolderDrop,
} = useFolderTree({ } = useFolderTree({
initialSelectedFolder: routeFolderId || 'root', initialSelectedFolder: routeFolderId || 'root',
apiClient,
tenantIdRef, tenantIdRef,
documentsSortFieldRef: activeSortFieldRef, documentsSortFieldRef: activeSortFieldRef,
documentsSortDirectionRef: activeSortDirectionRef, documentsSortDirectionRef: activeSortDirectionRef,
@@ -506,7 +505,6 @@ const useDocumentsWorkspace = ({
registerPasskey, registerPasskey,
revokePasskey, revokePasskey,
} = useWorkspaceTaxonomies({ } = useWorkspaceTaxonomies({
apiClient,
notifyApiError, notifyApiError,
setStatusMessage, setStatusMessage,
tagManager, tagManager,
@@ -544,7 +542,6 @@ const useDocumentsWorkspace = ({
handleBulkTagRemoveFromDetail, handleBulkTagRemoveFromDetail,
handleBulkSelectionReanalyze, handleBulkSelectionReanalyze,
} = useDocumentTagging({ } = useDocumentTagging({
apiClient,
tags, tags,
tagManager, tagManager,
refreshTags, refreshTags,
@@ -562,7 +559,6 @@ const useDocumentsWorkspace = ({
clearUploadQueue, clearUploadQueue,
resetUploadsState, resetUploadsState,
} = useDocumentUploads({ } = useDocumentUploads({
apiClient,
token, token,
selectedFolder, selectedFolder,
currentFolderName, currentFolderName,
@@ -1124,7 +1120,6 @@ const useDocumentsWorkspace = ({
}); });
const { handleTenantSelect } = useTenantManager({ const { handleTenantSelect } = useTenantManager({
apiClient,
appDispatch, appDispatch,
currentTenantId, currentTenantId,
resetWorkspaceState, resetWorkspaceState,
+48 -9
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getFolderTree, listFolderContents } from '../../lib/apiClient';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { createRootNode, DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils'; import { createRootNode, DEFAULT_FOLDER_NAME } from '../../app/workspaceUtils';
import { import {
@@ -43,10 +44,6 @@ interface FolderTreeNode extends FolderSummary {
hasChildren?: boolean; hasChildren?: boolean;
} }
interface ApiClient {
get<T = FolderContentsEntry>(path: string, config?: { params?: Record<string, unknown> }): Promise<{ data: T }>;
}
interface SelectionHelpers { interface SelectionHelpers {
focusedDocumentId: Identifier | null; focusedDocumentId: Identifier | null;
setFocusedDocumentId: Dispatch<SetStateAction<Identifier | null>>; setFocusedDocumentId: Dispatch<SetStateAction<Identifier | null>>;
@@ -58,7 +55,6 @@ interface SelectionHelpers {
interface UseFolderTreeOptions { interface UseFolderTreeOptions {
initialSelectedFolder?: FolderId; initialSelectedFolder?: FolderId;
apiClient: ApiClient;
tenantIdRef: MutableRefObject<Identifier | null>; tenantIdRef: MutableRefObject<Identifier | null>;
documentsSortFieldRef: MutableRefObject<string>; documentsSortFieldRef: MutableRefObject<string>;
documentsSortDirectionRef: MutableRefObject<string>; documentsSortDirectionRef: MutableRefObject<string>;
@@ -75,7 +71,6 @@ interface FolderOption {
const useFolderTree = ({ const useFolderTree = ({
initialSelectedFolder = 'root', initialSelectedFolder = 'root',
apiClient,
tenantIdRef, tenantIdRef,
documentsSortFieldRef, documentsSortFieldRef,
documentsSortDirectionRef, documentsSortDirectionRef,
@@ -89,6 +84,52 @@ const useFolderTree = ({
return new Map([[rootNode.id, rootNode]]); return new Map([[rootNode.id, rootNode]]);
}); });
useEffect(() => {
const fetchTree = async () => {
try {
const data = await getFolderTree();
setFolderNodes((prev) => {
const next = new Map(prev);
const rootChildren: FolderId[] = [];
data.forEach((item) => {
const id = item.id as FolderId;
const parentId = (item.parent_id || 'root') as FolderId;
const children = (item.children || []).map((c) => c as FolderId);
next.set(id, {
id,
name: item.name,
parentId,
children,
expanded: false,
loaded: true,
hasChildren: children.length > 0,
});
if (parentId === 'root') {
rootChildren.push(id);
}
});
const root = next.get('root');
if (root) {
next.set('root', {
...(root as FolderTreeNode),
children: rootChildren,
hasChildren: rootChildren.length > 0,
loaded: true,
});
}
return next;
});
} catch (error) {
console.error('Failed to fetch folder tree', error);
}
};
fetchTree();
}, []);
const [selectedFolder, setSelectedFolder] = useState<FolderId>(initialSelectedFolder || 'root'); const [selectedFolder, setSelectedFolder] = useState<FolderId>(initialSelectedFolder || 'root');
const [currentFolder, setCurrentFolder] = useState<FolderSummary | null>(null); const [currentFolder, setCurrentFolder] = useState<FolderSummary | null>(null);
const [currentSubfolders, setCurrentSubfolders] = useState<FolderSummary[]>([]); const [currentSubfolders, setCurrentSubfolders] = useState<FolderSummary[]>([]);
@@ -245,8 +286,7 @@ const useFolderTree = ({
params.sort = sortField; params.sort = sortField;
params.dir = sortDirection; params.dir = sortDirection;
} }
const requestConfig = Object.keys(params).length ? { params } : {}; const data = await listFolderContents<FolderContentsEntry>(path, params);
const { data } = await apiClient.get<FolderContentsEntry>(`/folders/${path}/contents`, requestConfig);
const childFolders = Array.isArray(data.subfolders) ? data.subfolders : []; const childFolders = Array.isArray(data.subfolders) ? data.subfolders : [];
const childIds = childFolders const childIds = childFolders
.map((child) => (child?.id ?? null) as FolderId | null) .map((child) => (child?.id ?? null) as FolderId | null)
@@ -359,7 +399,6 @@ const useFolderTree = ({
return enriched; return enriched;
}, },
[ [
apiClient,
documentsSortDirectionRef, documentsSortDirectionRef,
documentsSortFieldRef, documentsSortFieldRef,
tenantIdRef, tenantIdRef,
+11 -16
View File
@@ -2,19 +2,14 @@ import { MutableRefObject, useCallback, useState } from 'react';
import type { TagId, TenantId } from '../../types/identifiers'; import type { TagId, TenantId } from '../../types/identifiers';
import type { Tag } from '../../types/documents'; import type { Tag } from '../../types/documents';
type ApiClient = { import { listTags, updateTag, createTag, deleteTag } from '../../lib/apiClient';
get: (path: string) => Promise<{ data: unknown }>
post: (path: string, body: unknown) => Promise<{ data: unknown }>
patch: (path: string, body: unknown) => Promise<{ data: unknown }>
delete: (path: string) => Promise<{ data: unknown }>
};
interface TagManagerInterface { interface TagManagerInterface {
buildPayload: (input: { label?: string; color?: string | null }) => { label: string; color: string | null }; buildPayload: (input: { label?: string; color?: string | null }) => { label: string; color: string | null };
} }
interface UseTagsOptions { interface UseTagsOptions {
apiClient: ApiClient; // apiClient removed
notifyApiError: (error: unknown, fallback: string) => void; notifyApiError: (error: unknown, fallback: string) => void;
setStatusMessage: (message: string, variant?: string) => void; setStatusMessage: (message: string, variant?: string) => void;
tagManager: TagManagerInterface; tagManager: TagManagerInterface;
@@ -24,7 +19,7 @@ interface UseTagsOptions {
} }
const useTags = ({ const useTags = ({
apiClient, // apiClient removed
notifyApiError, notifyApiError,
setStatusMessage, setStatusMessage,
tagManager, tagManager,
@@ -37,7 +32,7 @@ const useTags = ({
const refreshTags = useCallback(async () => { const refreshTags = useCallback(async () => {
const requestTenantId = tenantIdRef.current; const requestTenantId = tenantIdRef.current;
try { try {
const { data } = await apiClient.get('/tags'); const data = await listTags();
if (tenantIdRef.current !== requestTenantId) { if (tenantIdRef.current !== requestTenantId) {
return; return;
} }
@@ -48,7 +43,7 @@ const useTags = ({
} }
notifyApiError(error, 'Unable to load tags.'); notifyApiError(error, 'Unable to load tags.');
} }
}, [apiClient, notifyApiError, tenantIdRef]); }, [notifyApiError, tenantIdRef]);
const handleTagUpdate = useCallback( const handleTagUpdate = useCallback(
async (tagId: TagId, changes: { label?: string; color?: string | null }) => { async (tagId: TagId, changes: { label?: string; color?: string | null }) => {
@@ -69,7 +64,7 @@ const useTags = ({
} }
try { try {
await apiClient.patch(`/tags/${tagId}`, payload); await updateTag(tagId, payload);
await refreshTags(); await refreshTags();
setStatusMessage('Tag updated.', 'success'); setStatusMessage('Tag updated.', 'success');
return true; return true;
@@ -79,14 +74,14 @@ const useTags = ({
throw new Error(message); throw new Error(message);
} }
}, },
[apiClient, notifyApiError, refreshTags, setStatusMessage], [notifyApiError, refreshTags, setStatusMessage],
); );
const handleTagCreate = useCallback( const handleTagCreate = useCallback(
async ({ label, color }: { label?: string; color?: string | null } = {}) => { async ({ label, color }: { label?: string; color?: string | null } = {}) => {
const payload = tagManager.buildPayload({ label, color }); const payload = tagManager.buildPayload({ label, color });
try { try {
await apiClient.post('/tags', payload); await createTag(payload);
await refreshTags(); await refreshTags();
setStatusMessage('Tag created.', 'success'); setStatusMessage('Tag created.', 'success');
} catch (error) { } catch (error) {
@@ -95,7 +90,7 @@ const useTags = ({
throw new Error(message); throw new Error(message);
} }
}, },
[apiClient, notifyApiError, refreshTags, setStatusMessage, tagManager], [notifyApiError, refreshTags, setStatusMessage, tagManager],
); );
const handleTagDelete = useCallback( const handleTagDelete = useCallback(
@@ -105,7 +100,7 @@ const useTags = ({
} }
try { try {
await apiClient.delete(`/tags/${tagId}`); await deleteTag(tagId);
setActiveTagFilters((prev) => prev.filter((id) => id !== tagId)); setActiveTagFilters((prev) => prev.filter((id) => id !== tagId));
const stripTagFromDoc = (doc: any) => { const stripTagFromDoc = (doc: any) => {
@@ -130,7 +125,7 @@ const useTags = ({
throw new Error(message); throw new Error(message);
} }
}, },
[apiClient, mapDocumentCaches, notifyApiError, refreshTags, setActiveTagFilters, setStatusMessage], [mapDocumentCaches, notifyApiError, refreshTags, setActiveTagFilters, setStatusMessage],
); );
return { return {
@@ -2,11 +2,7 @@ import { MutableRefObject, useCallback } from 'react';
import type { NavigateFunction } from 'react-router-dom'; import type { NavigateFunction } from 'react-router-dom';
import type { FolderId, TenantId } from '../../types/identifiers'; import type { FolderId, TenantId } from '../../types/identifiers';
interface ApiClient { import { api, listTenants, switchTenant } from '../../lib/apiClient';
get: (path: string) => Promise<{ data: unknown }>;
post: (path: string, body?: unknown) => Promise<{ data: any }>;
defaults: { headers: { common: Record<string, unknown> } };
}
interface TenantOption { interface TenantOption {
id?: TenantId; id?: TenantId;
@@ -14,7 +10,6 @@ interface TenantOption {
} }
interface UseTenantManagerOptions { interface UseTenantManagerOptions {
apiClient: ApiClient;
appDispatch: (action: any) => void; appDispatch: (action: any) => void;
currentTenantId: TenantId | null; currentTenantId: TenantId | null;
resetWorkspaceState: () => void; resetWorkspaceState: () => void;
@@ -30,7 +25,6 @@ interface UseTenantManagerOptions {
} }
const useTenantManager = ({ const useTenantManager = ({
apiClient,
appDispatch, appDispatch,
currentTenantId, currentTenantId,
resetWorkspaceState, resetWorkspaceState,
@@ -53,17 +47,15 @@ const useTenantManager = ({
try { try {
if (refreshOnly) { if (refreshOnly) {
const { data } = await apiClient.get('/tenants'); const data = await listTenants();
appDispatch({ appDispatch({
type: 'SET_TENANTS', type: 'SET_TENANTS',
tenants: Array.isArray(data) ? data : [], tenants: data,
}); });
return; return;
} }
const { data } = await apiClient.post('/auth/select-tenant', { const data = await switchTenant(requestedTenantId);
tenant_id: requestedTenantId,
});
if (!data?.access_token) { if (!data?.access_token) {
throw new Error('Missing access token in tenant switch response.'); throw new Error('Missing access token in tenant switch response.');
} }
@@ -77,7 +69,7 @@ const useTenantManager = ({
tenant: data.tenant || null, tenant: data.tenant || null,
}); });
apiClient.defaults.headers.common.Authorization = `Bearer ${data.access_token}`; api.defaults.headers.common.Authorization = `Bearer ${data.access_token}`;
if (tokenRef) { if (tokenRef) {
tokenRef.current = data.access_token; tokenRef.current = data.access_token;
} }
@@ -102,7 +94,6 @@ const useTenantManager = ({
} }
}, },
[ [
apiClient,
appDispatch, appDispatch,
currentTenantId, currentTenantId,
handleDocumentsViewModeChange, handleDocumentsViewModeChange,
@@ -8,7 +8,6 @@ import useTags from './useTags';
import type { Identifier } from '../../types/identifiers'; import type { Identifier } from '../../types/identifiers';
interface UseWorkspaceTaxonomiesArgs { interface UseWorkspaceTaxonomiesArgs {
apiClient: any;
notifyApiError: (error: unknown, fallbackMessage?: string, variant?: string) => void; notifyApiError: (error: unknown, fallbackMessage?: string, variant?: string) => void;
setStatusMessage: (message: string, variant?: string) => void; setStatusMessage: (message: string, variant?: string) => void;
tagManager: TagManager; tagManager: TagManager;
@@ -21,7 +20,6 @@ interface UseWorkspaceTaxonomiesArgs {
} }
const useWorkspaceTaxonomies = ({ const useWorkspaceTaxonomies = ({
apiClient,
notifyApiError, notifyApiError,
setStatusMessage, setStatusMessage,
tagManager, tagManager,
@@ -40,7 +38,6 @@ const useWorkspaceTaxonomies = ({
handleTagDelete, handleTagDelete,
setTags, setTags,
} = useTags({ } = useTags({
apiClient,
notifyApiError, notifyApiError,
setStatusMessage, setStatusMessage,
tagManager, tagManager,
@@ -71,7 +68,6 @@ const useWorkspaceTaxonomies = ({
handleCorrespondentDelete, handleCorrespondentDelete,
setCorrespondents, setCorrespondents,
} = useCorrespondents({ } = useCorrespondents({
apiClient,
notifyApiError, notifyApiError,
setStatusMessage, setStatusMessage,
tenantIdRef, tenantIdRef,
@@ -84,7 +80,6 @@ const useWorkspaceTaxonomies = ({
handleCorrespondentRemove, handleCorrespondentRemove,
handleCorrespondentAdd, handleCorrespondentAdd,
} = useDocumentCorrespondentActions({ } = useDocumentCorrespondentActions({
apiClient,
correspondents, correspondents,
handleCorrespondentCreate, handleCorrespondentCreate,
notifyApiError, notifyApiError,
+104 -4
View File
@@ -1,4 +1,5 @@
import api from './api'; import api from './api';
export { api };
import type { import type {
ApiTokenRecord, ApiTokenRecord,
AssetResponse, AssetResponse,
@@ -6,11 +7,12 @@ import type {
CapabilitySetResponse, CapabilitySetResponse,
DownloadLink, DownloadLink,
DocumentResponse, DocumentResponse,
FolderTreeNode,
Identifier, Identifier,
PasskeySummary, PasskeySummary,
TenantSnippet, TenantSnippet,
TagResponse, TagResponse,
CorrespondentResponse,
FolderTreeResponseItem,
} from './apiTypes'; } from './apiTypes';
import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig, AxiosRequestConfig } from 'axios'; import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig, AxiosRequestConfig } from 'axios';
@@ -56,7 +58,7 @@ const normalizeDownload = (input?: DownloadLink | null): DownloadLink | null =>
export const fetchDocument = async (id: Identifier): Promise<DocumentResponse> => { export const fetchDocument = async (id: Identifier): Promise<DocumentResponse> => {
const { data } = await api.get<{ document?: DocumentResponse }>(`/documents/${id}`); const { data } = await api.get<{ document?: DocumentResponse }>(`/documents/${id}`);
const doc = data?.document || (data as unknown as DocumentResponse); const doc = data?.document || (data as DocumentResponse);
if (doc?.current_version?.download) { if (doc?.current_version?.download) {
doc.current_version.download = normalizeDownload(doc.current_version.download); doc.current_version.download = normalizeDownload(doc.current_version.download);
} }
@@ -77,8 +79,8 @@ export const listDocuments = async (params: Record<string, unknown> = {}): Promi
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
}; };
export const getFolderTree = async (): Promise<FolderTreeNode[]> => { export const getFolderTree = async (): Promise<FolderTreeResponseItem[]> => {
const { data } = await api.get<FolderTreeNode[]>('/folders/tree'); const { data } = await api.get<FolderTreeResponseItem[]>('/folders/tree');
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
}; };
@@ -342,4 +344,102 @@ api.interceptors.response.use(
}, },
); );
export const uploadDocument = async (
formData: FormData,
): Promise<{ reused?: boolean; document?: unknown; status?: number }> => {
const { data, status } = await api.post<{ reused?: boolean; document?: unknown }>('/documents', formData);
return { ...data, status };
};
export const resolveFolderPath = async (
payload: { parent_id?: Identifier | null; segments: string[] },
): Promise<{ folder?: { id?: Identifier | null } }> => {
const { data } = await api.post<{ folder?: { id?: Identifier | null } }>('/folders/path', payload);
return data;
};
export const bulkTagDocuments = async (
payload: { document_ids: Identifier[]; tag_ids: Identifier[]; action: 'add' | 'remove' },
): Promise<void> => {
await api.post('/documents/bulk/tags', payload);
};
export const bulkReanalyzeDocuments = async (
payload: { document_ids: Identifier[]; force?: boolean },
): Promise<{ queued?: number }> => {
const { data } = await api.post<{ queued?: number }>('/documents/bulk/reanalyze', payload);
return data;
};
export const listTags = async (): Promise<TagResponse[]> => {
const { data } = await api.get<TagResponse[]>('/tags');
return Array.isArray(data) ? data : [];
};
export const updateTag = async (
tagId: Identifier,
payload: { label?: string; color?: string | null },
): Promise<void> => {
await api.patch(`/tags/${tagId}`, payload);
};
export const deleteTag = async (tagId: Identifier): Promise<void> => {
await api.delete(`/tags/${tagId}`);
};
export const listCorrespondents = async (): Promise<CorrespondentResponse[]> => {
const { data } = await api.get<CorrespondentResponse[]>('/correspondents');
return Array.isArray(data) ? data : [];
};
export const createCorrespondent = async (payload: { name: string }): Promise<CorrespondentResponse> => {
const { data } = await api.post<CorrespondentResponse>('/correspondents', payload);
return data;
};
export const updateCorrespondent = async (
correspondentId: Identifier,
payload: { name?: string },
): Promise<void> => {
await api.patch(`/correspondents/${correspondentId}`, payload);
};
export const deleteCorrespondent = async (correspondentId: Identifier): Promise<void> => {
await api.delete(`/correspondents/${correspondentId}`);
};
export const addDocumentCorrespondent = async (
documentId: Identifier,
correspondentId: Identifier,
): Promise<void> => {
await api.post(`/documents/${documentId}/correspondents`, {
assignments: [{ correspondent_id: correspondentId }],
replace: false,
});
};
export const removeDocumentCorrespondent = async (
documentId: Identifier,
correspondentId: Identifier,
): Promise<void> => {
await api.delete(`/documents/${documentId}/correspondents/${correspondentId}`);
};
export const switchTenant = async (tenantId: Identifier): Promise<{ access_token: string; tenant: any; tenants?: any[] }> => {
const { data } = await api.post<{ access_token: string; tenant: any; tenants?: any[] }>('/auth/select-tenant', {
tenant_id: tenantId,
});
return data;
};
export const listFolderContents = async <T = any>(
path: string,
params?: Record<string, unknown>,
): Promise<T> => {
const { data } = await api.get<T>(`/folders/${path}/contents`, { params });
return data;
};
export type { ApiTokenRecord } from './apiTypes'; export type { ApiTokenRecord } from './apiTypes';
+5 -1
View File
@@ -14,7 +14,7 @@ export interface TagResponse {
color?: string | null; color?: string | null;
} }
interface CorrespondentResponse { export interface CorrespondentResponse {
id: string; id: string;
name: string; name: string;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
@@ -69,6 +69,10 @@ export interface FolderTreeNode extends FolderInfo {
children?: FolderTreeNode[]; children?: FolderTreeNode[];
} }
export interface FolderTreeResponseItem extends FolderInfo {
children?: string[];
}
export interface CapabilitySetResponse { export interface CapabilitySetResponse {
id: string; id: string;
slug: string; slug: string;
+1 -1
View File
@@ -1,6 +1,6 @@
.status-toast-container { .status-toast-container {
position: fixed; position: fixed;
top: 2rem; top: 3rem;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
z-index: 5000000; z-index: 5000000;