Merge remote-tracking branch 'ui/ui' into dev
This commit is contained in:
@@ -1,30 +1,18 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { AxiosHeaders } from 'axios';
|
||||
import { clearAuthToken, logoutSession, refreshSession, setAuthToken } from '../../lib/apiClient';
|
||||
|
||||
type AppStatus = string;
|
||||
|
||||
type AppDispatch = (action: { type: string; [key: string]: unknown }) => void;
|
||||
|
||||
type NotifyApiError = (error: unknown, fallbackMessage: string, variant?: string) => void;
|
||||
|
||||
type SetStatusMessage = (message: string, variant?: string) => void;
|
||||
|
||||
type SetLoading = (state: boolean) => void;
|
||||
|
||||
interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig {
|
||||
_retry?: boolean;
|
||||
}
|
||||
|
||||
interface UseAuthManagerArgs {
|
||||
apiClient: AxiosInstance;
|
||||
token?: string | null;
|
||||
appStatus: AppStatus;
|
||||
appDispatch: AppDispatch;
|
||||
notifyApiError: NotifyApiError;
|
||||
setStatusMessage: SetStatusMessage;
|
||||
setLoading: SetLoading;
|
||||
}
|
||||
|
||||
interface UseAuthManagerResult {
|
||||
@@ -33,40 +21,22 @@ interface UseAuthManagerResult {
|
||||
handleLogout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ensureAxiosHeaders = (
|
||||
headers?: InternalAxiosRequestConfig['headers'],
|
||||
): AxiosHeaders => {
|
||||
if (headers instanceof AxiosHeaders) {
|
||||
return headers;
|
||||
}
|
||||
return AxiosHeaders.from(headers || {});
|
||||
};
|
||||
|
||||
const setHeaderAuthorization = (config: InternalAxiosRequestConfig, token: string): void => {
|
||||
const headers = ensureAxiosHeaders(config.headers);
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
config.headers = headers;
|
||||
};
|
||||
|
||||
const useAuthManager = ({
|
||||
apiClient,
|
||||
token,
|
||||
appStatus,
|
||||
appDispatch,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
}: UseAuthManagerArgs): UseAuthManagerResult => {
|
||||
const tokenRef = useRef<string | null>(token);
|
||||
const refreshPromiseRef = useRef<Promise<string> | null>(null);
|
||||
const initialRefreshAttemptedRef = useRef(Boolean(token));
|
||||
|
||||
const refreshAccessToken = useCallback(async (): Promise<string> => {
|
||||
console.log('[Auth] Attempting to refresh access token…');
|
||||
appDispatch({ type: 'TOKEN_REFRESH_START' });
|
||||
try {
|
||||
const { data } = await apiClient.post<{ access_token?: string; tenant?: unknown }>('/auth/refresh');
|
||||
const data = await refreshSession();
|
||||
if (data?.access_token) {
|
||||
setAuthToken(data.access_token);
|
||||
appDispatch({
|
||||
type: 'TOKEN_REFRESH_SUCCESS',
|
||||
token: data.access_token,
|
||||
@@ -81,7 +51,7 @@ const useAuthManager = ({
|
||||
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null });
|
||||
throw error;
|
||||
}
|
||||
}, [apiClient, appDispatch]);
|
||||
}, [appDispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
tokenRef.current = token;
|
||||
@@ -95,90 +65,17 @@ const useAuthManager = ({
|
||||
}
|
||||
}, [token, appStatus, refreshAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const requestInterceptor = apiClient.interceptors.request.use((config) => {
|
||||
const currentToken = tokenRef.current;
|
||||
if (currentToken) {
|
||||
const headers = ensureAxiosHeaders(config.headers);
|
||||
if (!headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${currentToken}`);
|
||||
}
|
||||
config.headers = headers;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
const responseInterceptor = apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const axiosError = error as AxiosError & { config?: RetryableAxiosRequestConfig };
|
||||
const { response, config } = axiosError;
|
||||
if (!response || !config) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
const url = String(config?.url ?? '');
|
||||
const isAuthRoute = url.includes('/auth/login') || url.includes('/auth/refresh');
|
||||
|
||||
if (status === 401 && !config._retry && !isAuthRoute) {
|
||||
console.warn('[Auth] 401 received for', url, '- attempting token refresh');
|
||||
|
||||
if (!refreshPromiseRef.current) {
|
||||
refreshPromiseRef.current = (async () => {
|
||||
try {
|
||||
return await refreshAccessToken();
|
||||
} finally {
|
||||
refreshPromiseRef.current = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
try {
|
||||
const newToken = await refreshPromiseRef.current;
|
||||
if (!newToken) {
|
||||
throw new Error('No token returned from refresh');
|
||||
}
|
||||
config._retry = true;
|
||||
setHeaderAuthorization(config, newToken);
|
||||
console.log('[Auth] Retrying original request', url);
|
||||
try {
|
||||
return await apiClient(config);
|
||||
} catch (retryError) {
|
||||
if ((retryError as AxiosError)?.response?.status === 401) {
|
||||
notifyApiError(retryError, 'Session expired. Please log in again.');
|
||||
}
|
||||
throw retryError;
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.warn('[Auth] Refresh failed, clearing session');
|
||||
notifyApiError(refreshError, 'Session expired. Please log in again.');
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
apiClient.interceptors.request.eject(requestInterceptor);
|
||||
apiClient.interceptors.response.eject(responseInterceptor);
|
||||
};
|
||||
}, [apiClient, notifyApiError, refreshAccessToken]);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await apiClient.post('/auth/logout');
|
||||
await logoutSession();
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Failed to revoke refresh token during logout', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
clearAuthToken();
|
||||
appDispatch({ type: 'LOGOUT' });
|
||||
setStatusMessage('Logged out.', 'info');
|
||||
}
|
||||
}, [apiClient, appDispatch, setLoading, setStatusMessage]);
|
||||
}, [appDispatch, setStatusMessage]);
|
||||
|
||||
return { tokenRef, refreshAccessToken, handleLogout };
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import type { DragEvent } from 'react';
|
||||
import { isPlainObject, isFunctionValue } from '../../utils/typeGuards';
|
||||
|
||||
type Identifier = string | number;
|
||||
type FolderIdentifier = Identifier | 'root';
|
||||
type FolderIdentifier = string | 'root';
|
||||
type FolderInput = FolderIdentifier | number;
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier | null;
|
||||
@@ -24,7 +24,7 @@ type HandleEntrySelectionFn = (
|
||||
interface UseDocumentDragHandlersOptions {
|
||||
selectedEntries: string[];
|
||||
selectedDocumentIds: Identifier[];
|
||||
selectedFolderIds: FolderIdentifier[];
|
||||
selectedFolderIds: FolderInput[];
|
||||
applySelection: ApplySelectionFn;
|
||||
handleEntrySelection: HandleEntrySelectionFn;
|
||||
documentLookup: Map<Identifier, DocumentLike>;
|
||||
@@ -49,6 +49,10 @@ const useDocumentDragHandlers = ({
|
||||
documentsViewMode,
|
||||
}: UseDocumentDragHandlersOptions) => {
|
||||
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
|
||||
const normalizedFolderIds = useMemo(
|
||||
() => selectedFolderIds.map((id) => (id === 'root' ? 'root' : String(id))) as FolderIdentifier[],
|
||||
[selectedFolderIds],
|
||||
);
|
||||
|
||||
const destroyDragPreview = useCallback(() => {
|
||||
const node = dragPreviewRef.current;
|
||||
@@ -61,7 +65,7 @@ const useDocumentDragHandlers = ({
|
||||
useEffect(() => destroyDragPreview, [destroyDragPreview]);
|
||||
|
||||
const createDragPreview = useCallback(
|
||||
({ documents = [], folders = [] }: { documents?: DocumentLike[]; folders?: Array<FolderIdentifier | Identifier> } = {}) => {
|
||||
({ documents = [], folders = [] }: { documents?: DocumentLike[]; folders?: FolderIdentifier[] } = {}) => {
|
||||
destroyDragPreview();
|
||||
|
||||
const docEntries = (documents || []).filter(Boolean);
|
||||
@@ -145,17 +149,7 @@ const useDocumentDragHandlers = ({
|
||||
}
|
||||
} else {
|
||||
const payload = item.payload;
|
||||
const folderId = (() => {
|
||||
if (isPlainObject(payload) && 'id' in payload) {
|
||||
return (payload as { id?: FolderIdentifier }).id ?? null;
|
||||
}
|
||||
const maybeTrim = (payload as { trim?: () => string })?.trim;
|
||||
if (isFunctionValue(maybeTrim)) {
|
||||
const nextValue = maybeTrim.call(payload);
|
||||
return nextValue || null;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const folderId = payload as FolderIdentifier;
|
||||
const rowEl = folderId
|
||||
? (document.getElementById(`folder-row-${folderId}`)
|
||||
|| document.getElementById(`folder-card-${folderId}`))
|
||||
@@ -300,28 +294,29 @@ const useDocumentDragHandlers = ({
|
||||
);
|
||||
|
||||
const handleFolderDragStart = useCallback(
|
||||
(event: DragEvent<HTMLElement>, folderId: FolderIdentifier) => {
|
||||
if (folderId === 'root') {
|
||||
(event: DragEvent<HTMLElement>, folderId: FolderInput) => {
|
||||
const normalizedFolderId: FolderIdentifier = folderId === 'root' ? 'root' : String(folderId);
|
||||
if (normalizedFolderId === 'root') {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
const folderKey = resolveFolderRowKey(folderId);
|
||||
const folderKey = resolveFolderRowKey(normalizedFolderId);
|
||||
const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false;
|
||||
|
||||
let effectiveFolderSelection: FolderIdentifier[] = selectedFolderIds;
|
||||
let effectiveFolderSelection: FolderIdentifier[] = normalizedFolderIds;
|
||||
let effectiveDocumentSelection: Identifier[] = selectedDocumentIds;
|
||||
|
||||
if (!isAlreadySelected && folderKey) {
|
||||
effectiveFolderSelection = [folderId];
|
||||
effectiveFolderSelection = [normalizedFolderId];
|
||||
effectiveDocumentSelection = [];
|
||||
handleEntrySelection(folderKey, { preventDefault: () => {} });
|
||||
}
|
||||
|
||||
const uniqueFolders = effectiveFolderSelection.length
|
||||
? Array.from(new Set(effectiveFolderSelection.filter(Boolean)))
|
||||
: [folderId];
|
||||
: [normalizedFolderId];
|
||||
|
||||
setDraggedFolderId(folderId);
|
||||
setDraggedFolderId(normalizedFolderId);
|
||||
if (effectiveDocumentSelection.length) {
|
||||
setDraggedDocumentIds(effectiveDocumentSelection);
|
||||
}
|
||||
@@ -360,7 +355,7 @@ const useDocumentDragHandlers = ({
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedFolderIds,
|
||||
normalizedFolderIds,
|
||||
selectedEntries,
|
||||
selectedDocumentIds,
|
||||
handleEntrySelection,
|
||||
|
||||
@@ -2,6 +2,17 @@ import { useCallback } from 'react';
|
||||
import { isPlainObject } from '../../utils/typeGuards';
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
import { DEFAULT_FOLDER_NAME, getRowId, isDocumentRowKey } from '../../app/appLayoutUtils';
|
||||
import {
|
||||
addDocumentTags,
|
||||
createTag,
|
||||
deleteDocumentTag,
|
||||
deleteFolder,
|
||||
moveDocumentsBulk,
|
||||
moveDocumentToFolder,
|
||||
queueDocumentReanalysis,
|
||||
trashDocument,
|
||||
updateDocument,
|
||||
} from '../../lib/apiClient';
|
||||
|
||||
type DocumentId = string | number;
|
||||
type FolderId = DocumentId | 'root';
|
||||
@@ -35,12 +46,6 @@ type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
|
||||
|
||||
type SetStatusMessage = (message: string, level?: StatusLevel) => void;
|
||||
|
||||
interface ApiClient {
|
||||
post<T = unknown>(url: string, data?: unknown, config?: Record<string, unknown>): Promise<{ data: T }>;
|
||||
patch<T = unknown>(url: string, data?: unknown, config?: Record<string, unknown>): Promise<{ data: T }>;
|
||||
delete<T = unknown>(url: string, config?: Record<string, unknown>): Promise<{ data: T }>;
|
||||
}
|
||||
|
||||
interface Tag {
|
||||
id: DocumentId;
|
||||
label: string;
|
||||
@@ -85,7 +90,6 @@ interface DocumentTagExtras {
|
||||
|
||||
interface DeleteOptions {
|
||||
showMessage?: boolean;
|
||||
manageLoading?: boolean;
|
||||
}
|
||||
|
||||
interface TagAttachArgs {
|
||||
@@ -101,11 +105,9 @@ interface TagRemoveOptions {
|
||||
|
||||
interface FolderDeleteOptions {
|
||||
showMessage?: boolean;
|
||||
manageLoading?: boolean;
|
||||
}
|
||||
|
||||
interface UseDocumentMutationsArgs {
|
||||
api: ApiClient;
|
||||
token?: string | null;
|
||||
documentLookup: Map<DocumentId, DocumentLike>;
|
||||
folderLabelMap: Map<FolderId, string>;
|
||||
@@ -125,7 +127,6 @@ interface UseDocumentMutationsArgs {
|
||||
focusedRowKey: string | null;
|
||||
notifyApiError: NotifyApiError;
|
||||
setStatusMessage: SetStatusMessage;
|
||||
setLoading: (next: boolean) => void;
|
||||
mapDocumentCaches: MapDocumentCaches;
|
||||
applySelectedFolder: ApplySelectedFolder;
|
||||
folderNodes: Map<FolderId, FolderNode>;
|
||||
@@ -181,7 +182,6 @@ const normalizeDocumentId = (value: unknown): DocumentId | null => {
|
||||
};
|
||||
|
||||
const useDocumentMutations = ({
|
||||
api,
|
||||
token,
|
||||
documentLookup,
|
||||
folderLabelMap,
|
||||
@@ -201,7 +201,6 @@ const useDocumentMutations = ({
|
||||
focusedRowKey,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
mapDocumentCaches,
|
||||
applySelectedFolder,
|
||||
folderNodes,
|
||||
@@ -282,16 +281,11 @@ const useDocumentMutations = ({
|
||||
const id = getRowId(key);
|
||||
return id ? !uniqueIdSet.has(id as DocumentId) : true;
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (uniqueIds.length === 1) {
|
||||
await api.patch(`/documents/${uniqueIds[0]}/folder`, { folder_id: target });
|
||||
await moveDocumentToFolder(uniqueIds[0], target);
|
||||
} else {
|
||||
await api.post('/documents/bulk/move', {
|
||||
document_ids: uniqueIds,
|
||||
folder_id: target,
|
||||
});
|
||||
await moveDocumentsBulk(uniqueIds, target);
|
||||
}
|
||||
|
||||
const count = uniqueIds.length;
|
||||
@@ -377,12 +371,9 @@ const useDocumentMutations = ({
|
||||
} catch (error) {
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to move documents.';
|
||||
notifyApiError(error, message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
documentLookup,
|
||||
folderLabelMap,
|
||||
ensureFolderData,
|
||||
@@ -400,7 +391,6 @@ const useDocumentMutations = ({
|
||||
focusedRowKey,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
mapDocumentCaches,
|
||||
],
|
||||
);
|
||||
@@ -411,25 +401,20 @@ const useDocumentMutations = ({
|
||||
setStatusMessage('Log in to manage assets.', 'error');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post(`/documents/${documentId}/assets`, null, {
|
||||
params: { force: true },
|
||||
});
|
||||
await queueDocumentReanalysis(documentId, { force: true });
|
||||
setStatusMessage('Document re-analysis queued.', 'info');
|
||||
await refreshCurrentFolder();
|
||||
} catch (error) {
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to request thumbnail generation.';
|
||||
notifyApiError(error, message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[api, token, refreshCurrentFolder, notifyApiError, setStatusMessage, setLoading],
|
||||
[token, refreshCurrentFolder, notifyApiError, setStatusMessage],
|
||||
);
|
||||
|
||||
const handleDocumentsDelete = useCallback(
|
||||
async (documentIds: DocumentId[], { showMessage = true, manageLoading = true }: DeleteOptions = {}) => {
|
||||
async (documentIds: DocumentId[], { showMessage = true }: DeleteOptions = {}) => {
|
||||
if (!documentIds || documentIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -439,12 +424,8 @@ const useDocumentMutations = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (manageLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`)));
|
||||
await Promise.all(documentIds.map((documentId) => trashDocument(documentId)));
|
||||
|
||||
removeDocumentsFromCaches(documentIds);
|
||||
|
||||
@@ -461,14 +442,9 @@ const useDocumentMutations = ({
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete documents.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
} finally {
|
||||
if (manageLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
token,
|
||||
documentLookup,
|
||||
removeDocumentsFromCaches,
|
||||
@@ -476,7 +452,6 @@ const useDocumentMutations = ({
|
||||
closeDocumentPreview,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -487,10 +462,8 @@ const useDocumentMutations = ({
|
||||
setStatusMessage('Document title cannot be empty.', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed });
|
||||
const data = await updateDocument(documentId, { title: trimmed });
|
||||
const updatedDocument = extractDocumentFromResponse?.(data);
|
||||
|
||||
if (updatedDocument && ingestDocuments) {
|
||||
@@ -510,27 +483,21 @@ const useDocumentMutations = ({
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update document title.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
extractDocumentFromResponse,
|
||||
ingestDocuments,
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
setStatusMessage,
|
||||
updateDocumentCaches,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDocumentIssuedUpdate = useCallback(
|
||||
async (documentId: DocumentId, nextIssuedDate: number | null) => {
|
||||
setLoading(true);
|
||||
const payload = { issued_at: nextIssuedDate || null };
|
||||
async (documentId: DocumentId, nextIssuedDate: number | null) => {const payload = { issued_at: nextIssuedDate || null };
|
||||
try {
|
||||
const { data } = await api.patch(`/documents/${documentId}`, payload);
|
||||
const data = await updateDocument(documentId, payload);
|
||||
const updatedDocument = extractDocumentFromResponse?.(data);
|
||||
|
||||
if (updatedDocument && ingestDocuments) {
|
||||
@@ -551,16 +518,12 @@ const useDocumentMutations = ({
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to update issued date.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
extractDocumentFromResponse,
|
||||
ingestDocuments,
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
setStatusMessage,
|
||||
updateDocumentCaches,
|
||||
],
|
||||
@@ -585,7 +548,7 @@ const useDocumentMutations = ({
|
||||
};
|
||||
|
||||
try {
|
||||
await api.post(`/documents/${documentId}/tags`, { tag_ids: [cachedTag.id] });
|
||||
await addDocumentTags(documentId, [cachedTag.id]);
|
||||
updateDocumentCaches(documentId, (doc) => {
|
||||
if (!doc) {
|
||||
return doc;
|
||||
@@ -604,7 +567,7 @@ const useDocumentMutations = ({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[api, notifyApiError, setStatusMessage, updateDocumentCaches],
|
||||
[notifyApiError, setStatusMessage, updateDocumentCaches],
|
||||
);
|
||||
|
||||
const handleDocumentTagAdd = useCallback(
|
||||
@@ -622,8 +585,8 @@ const useDocumentMutations = ({
|
||||
}
|
||||
try {
|
||||
if (!tag) {
|
||||
const payload = tagManager.buildPayload({ label: normalizedLabel });
|
||||
const { data } = await api.post('/tags', payload);
|
||||
const payload = tagManager.buildPayload({ label: normalizedLabel }) as { label: string; color?: string | null };
|
||||
const data = await createTag(payload);
|
||||
tag = data as Tag;
|
||||
await refreshTags();
|
||||
}
|
||||
@@ -638,7 +601,7 @@ const useDocumentMutations = ({
|
||||
notifyApiError(error, 'Failed to assign tag.');
|
||||
}
|
||||
},
|
||||
[api, tags, refreshTags, attachTagToDocument, notifyApiError, tagManager],
|
||||
[tags, refreshTags, attachTagToDocument, notifyApiError, tagManager],
|
||||
);
|
||||
|
||||
const handleDocumentTagAttach = useCallback(
|
||||
@@ -707,7 +670,7 @@ const useDocumentMutations = ({
|
||||
}
|
||||
|
||||
try {
|
||||
await api.delete(`/documents/${documentId}/tags/${tagId}`);
|
||||
await deleteDocumentTag(documentId, tagId);
|
||||
applyTagRemovalToCaches(documentId, tagId);
|
||||
if (refreshTagList) {
|
||||
await refreshTags();
|
||||
@@ -722,11 +685,11 @@ const useDocumentMutations = ({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[api, applyTagRemovalToCaches, notifyApiError, refreshTags, setStatusMessage],
|
||||
[applyTagRemovalToCaches, notifyApiError, refreshTags, setStatusMessage],
|
||||
);
|
||||
|
||||
const handleFolderDelete = useCallback(
|
||||
async (folderId?: FolderId, { showMessage = true, manageLoading = true }: FolderDeleteOptions = {}) => {
|
||||
async (folderId?: FolderId, { showMessage = true }: FolderDeleteOptions = {}) => {
|
||||
if (!token) {
|
||||
if (showMessage) {
|
||||
setStatusMessage('Log in to manage folders.', 'error');
|
||||
@@ -740,10 +703,6 @@ const useDocumentMutations = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (manageLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const contents = await ensureFolderData(folderId, {
|
||||
force: true,
|
||||
@@ -758,7 +717,7 @@ const useDocumentMutations = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
await api.delete(`/folders/${folderId}`);
|
||||
await deleteFolder(folderId);
|
||||
|
||||
setFolderNodes((prev: Map<FolderId, FolderNode>) => {
|
||||
const next = new Map<FolderId, FolderNode>(prev);
|
||||
@@ -809,14 +768,9 @@ const useDocumentMutations = ({
|
||||
setStatusMessage(message, 'error');
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (manageLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
token,
|
||||
ensureFolderData,
|
||||
selectedFolder,
|
||||
@@ -827,7 +781,6 @@ const useDocumentMutations = ({
|
||||
setFolderContents,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ interface UseDocumentTaggingArgs {
|
||||
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
|
||||
notifyApiError: (error: unknown, message: string) => void;
|
||||
setStatusMessage: (message: string, variant?: string) => void;
|
||||
setLoading: (state: boolean) => void;
|
||||
updateDocumentCaches?: (id: Identifier, updater: (doc: TagRecord | null) => TagRecord | null) => void;
|
||||
}
|
||||
|
||||
@@ -50,7 +49,6 @@ const useDocumentTagging = ({
|
||||
resolveTargetDocumentIds,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
updateDocumentCaches,
|
||||
}: UseDocumentTaggingArgs) => {
|
||||
const bulkTagOperation = useCallback(
|
||||
@@ -80,7 +78,6 @@ const useDocumentTagging = ({
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (action === 'add') {
|
||||
const createdIds: Identifier[] = [];
|
||||
@@ -175,8 +172,6 @@ const useDocumentTagging = ({
|
||||
(action === 'add' ? 'Failed to assign tags.' : 'Failed to remove tags.');
|
||||
notifyApiError(error, message);
|
||||
return { ok: false, reason: 'request-failed' };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -184,7 +179,6 @@ const useDocumentTagging = ({
|
||||
tags,
|
||||
refreshTags,
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
tagManager,
|
||||
apiClient,
|
||||
updateDocumentCaches,
|
||||
@@ -265,7 +259,6 @@ const useDocumentTagging = ({
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.post<{ queued?: number }>(
|
||||
'/documents/bulk/reanalyze',
|
||||
@@ -286,11 +279,9 @@ const useDocumentTagging = ({
|
||||
const message =
|
||||
error.response?.data?.error || 'Failed to queue document re-analysis.';
|
||||
notifyApiError(error, message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[resolveTargetDocumentIds, notifyApiError, setStatusMessage, setLoading, apiClient],
|
||||
[resolveTargetDocumentIds, notifyApiError, setStatusMessage, apiClient],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from 'react';
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
import useFileDrop from './useFileDrop';
|
||||
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils';
|
||||
import { fetchDocument } from '../../lib/apiClient';
|
||||
|
||||
type Identifier = string | number;
|
||||
type FolderId = Identifier | 'root' | null;
|
||||
@@ -101,7 +102,6 @@ interface UseDocumentUploadsArgs {
|
||||
currentFolderName?: string | null;
|
||||
ensureFolderData: (folderId: FolderId, options?: { force?: boolean; prefetchDepth?: number }) => Promise<void>;
|
||||
refreshCurrentFolder: () => Promise<void>;
|
||||
setLoading: (state: boolean) => void;
|
||||
shellRef: MutableRefObject<HTMLElement | null>;
|
||||
notifyApiError?: NotifyApiError;
|
||||
setStatusMessage?: SetStatusMessage;
|
||||
@@ -132,7 +132,6 @@ const useDocumentUploads = ({
|
||||
currentFolderName,
|
||||
ensureFolderData,
|
||||
refreshCurrentFolder,
|
||||
setLoading,
|
||||
shellRef,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
@@ -174,8 +173,7 @@ const useDocumentUploads = ({
|
||||
let conflictDocument = null;
|
||||
if (conflictId) {
|
||||
try {
|
||||
const { data } = await apiClient.get(`/documents/${conflictId}`);
|
||||
conflictDocument = (data as any)?.document ?? data ?? null;
|
||||
conflictDocument = await fetchDocument(conflictId);
|
||||
} catch (fetchError) {
|
||||
console.warn('[Uploads] failed to fetch conflict document', fetchError);
|
||||
}
|
||||
@@ -395,8 +393,6 @@ const useDocumentUploads = ({
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
folderPathCacheRef.current.clear();
|
||||
|
||||
@@ -472,8 +468,6 @@ const useDocumentUploads = ({
|
||||
Object.assign(item, patch);
|
||||
});
|
||||
console.error('[Uploads] batch failed', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -483,7 +477,6 @@ const useDocumentUploads = ({
|
||||
refreshCurrentFolder,
|
||||
selectedFolder,
|
||||
ensureFolderData,
|
||||
setLoading,
|
||||
appendQueueItems,
|
||||
updateQueueItem,
|
||||
],
|
||||
|
||||
@@ -16,9 +16,10 @@ import {
|
||||
import AssetManager, { getAssetFromVersion } from '../../asset_manager';
|
||||
import useApiError from '../useApiError';
|
||||
import TagManager from '../../tag_manager';
|
||||
import usePasskeys from '../../settings/usePasskeys';
|
||||
import { useManagementModals } from '../../app/useManagementModals';
|
||||
import { api, useAppDispatch, useAppState } from '../../app/appState';
|
||||
import { useAppDispatch, useAppState } from '../../app/appState';
|
||||
import { fetchAsset } from '../../lib/apiClient';
|
||||
import { useApi } from '../../app/ApiContext';
|
||||
import useWorkspaceSelection from '../../app/useWorkspaceSelection';
|
||||
import { useEntryPointer as useEntryPointerCore } from '../../documents/useEntryPointer';
|
||||
import { isTagTransferEvent } from '../../documents/tagTransfer';
|
||||
@@ -29,7 +30,6 @@ import useDocumentPreview from '../../app/useDocumentPreview';
|
||||
import useSidebarProps from '../../sidebar/useSidebarProps';
|
||||
import {
|
||||
ASSET_PRESIGN_TTL_MS,
|
||||
DEFAULT_FOLDER_NAME,
|
||||
DEFAULT_SORT_DIRECTION,
|
||||
DEFAULT_SORT_FIELD,
|
||||
createRootNode,
|
||||
@@ -44,18 +44,20 @@ import {
|
||||
import useDocumentsSearch from '../../app/useDocumentsSearch';
|
||||
import useDocumentsStore from './store/useDocumentsStore';
|
||||
import useAuthManager from './useAuthManager';
|
||||
import useTags from './useTags';
|
||||
import useCorrespondents from './useCorrespondents';
|
||||
import useTenantManager from './useTenantManager';
|
||||
import useDocuments from './useDocuments';
|
||||
import { fetchDocument } from '../../lib/apiClient';
|
||||
import useFolderTree from './useFolderTree';
|
||||
import useFolderTreeActions from './useFolderTreeActions';
|
||||
import useDocumentTagging from './useDocumentTagging';
|
||||
import useDocumentCorrespondentActions from './useDocumentCorrespondentActions';
|
||||
import useDocumentUploads from './useDocumentUploads';
|
||||
import useDocumentDragHandlers from './useDocumentDragHandlers';
|
||||
import useDocumentMutations from './useDocumentMutations';
|
||||
import useDetailWorkspace from '../../detail/useDetailWorkspace';
|
||||
import useWorkspaceTaxonomies from './useWorkspaceTaxonomies';
|
||||
import useWorkspaceBreadcrumbs from './useWorkspaceBreadcrumbs';
|
||||
import useWorkspaceDeskProps from './useWorkspaceDeskProps';
|
||||
import useWorkspaceSelectionSync from './useWorkspaceSelectionSync';
|
||||
|
||||
const EntryType = Object.freeze({
|
||||
document: 'document',
|
||||
@@ -152,6 +154,7 @@ const useDocumentsWorkspace = ({
|
||||
tenant,
|
||||
tenants: tenantOptionsRaw = [],
|
||||
} = appState;
|
||||
const { client: apiClient } = useApi();
|
||||
|
||||
const tenantRecord = (tenant ?? null) as TenantOption | null;
|
||||
const tenantNameCandidate = tenantRecord?.name ?? tenantRecord?.slug ?? null;
|
||||
@@ -175,16 +178,12 @@ const useDocumentsWorkspace = ({
|
||||
reportApiError(error, { message: fallbackMessage, variant }),
|
||||
[reportApiError],
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const { tokenRef, handleLogout } = useAuthManager({
|
||||
apiClient: api,
|
||||
token,
|
||||
appStatus,
|
||||
appDispatch,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
});
|
||||
|
||||
const breadcrumbFetchRef = useRef(new Set());
|
||||
@@ -219,7 +218,11 @@ const useDocumentsWorkspace = ({
|
||||
const shellRef = useRef(null);
|
||||
const assetManagerRef = useRef(null);
|
||||
if (!assetManagerRef.current) {
|
||||
assetManagerRef.current = new AssetManager({ api, assetPresignTtlMs: ASSET_PRESIGN_TTL_MS });
|
||||
const fetcher = async (id: Identifier) => {
|
||||
const asset = await fetchAsset(id);
|
||||
return (asset as unknown) as any;
|
||||
};
|
||||
assetManagerRef.current = new AssetManager({ fetchAsset: fetcher, assetPresignTtlMs: ASSET_PRESIGN_TTL_MS });
|
||||
}
|
||||
const assetManager = assetManagerRef.current;
|
||||
|
||||
@@ -238,7 +241,7 @@ const useDocumentsWorkspace = ({
|
||||
if (!documentId) {
|
||||
return null;
|
||||
}
|
||||
const { data } = await api.get(`/documents/${documentId}`);
|
||||
const data = await fetchDocument(documentId);
|
||||
return extractDocumentFromResponse(data);
|
||||
},
|
||||
[extractDocumentFromResponse],
|
||||
@@ -345,7 +348,7 @@ const useDocumentsWorkspace = ({
|
||||
isInvalidFolderDrop,
|
||||
} = useFolderTree({
|
||||
initialSelectedFolder: routeFolderId || 'root',
|
||||
apiClient: api,
|
||||
apiClient,
|
||||
tenantIdRef,
|
||||
documentsSortFieldRef: activeSortFieldRef,
|
||||
documentsSortDirectionRef: activeSortDirectionRef,
|
||||
@@ -368,7 +371,7 @@ const useDocumentsWorkspace = ({
|
||||
isFilterActive,
|
||||
documentsFilterValue,
|
||||
} = useDocumentsSearch({
|
||||
api,
|
||||
api: apiClient,
|
||||
token,
|
||||
selectedFolder,
|
||||
navigate,
|
||||
@@ -378,7 +381,6 @@ const useDocumentsWorkspace = ({
|
||||
documentsSortField,
|
||||
documentsSortDirection,
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
setSearchIncludeDescendants,
|
||||
documentsManager,
|
||||
});
|
||||
@@ -449,8 +451,6 @@ const useDocumentsWorkspace = ({
|
||||
routeDocumentId: previewDocumentId,
|
||||
documentsManager,
|
||||
selectedFolder,
|
||||
api,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
navigate,
|
||||
locationPathname: location.pathname,
|
||||
@@ -478,17 +478,7 @@ const useDocumentsWorkspace = ({
|
||||
const bootstrapInitializedRef = useRef(false);
|
||||
const detailFolderFetchRef = useRef(new Set());
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!showingSearchResults) {
|
||||
return;
|
||||
}
|
||||
setSelectedEntries([]);
|
||||
setSelectionOrder([]);
|
||||
selectionOrderRef.current = [];
|
||||
selectionAnchorRef.current = null;
|
||||
setFocusedDocumentId(null);
|
||||
}, [
|
||||
useWorkspaceSelectionSync({
|
||||
showingSearchResults,
|
||||
searchQuery,
|
||||
setSelectedEntries,
|
||||
@@ -496,7 +486,11 @@ const useDocumentsWorkspace = ({
|
||||
selectionOrderRef,
|
||||
selectionAnchorRef,
|
||||
setFocusedDocumentId,
|
||||
]);
|
||||
selectedDocumentIds,
|
||||
activePreviewId,
|
||||
setActivePreviewId,
|
||||
selectionInitializedRef,
|
||||
});
|
||||
|
||||
const {
|
||||
tags,
|
||||
@@ -505,59 +499,17 @@ const useDocumentsWorkspace = ({
|
||||
handleTagUpdate,
|
||||
handleTagDelete,
|
||||
setTags,
|
||||
} = useTags({
|
||||
apiClient: api,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
tagManager,
|
||||
tenantIdRef,
|
||||
setActiveTagFilters,
|
||||
mapDocumentCaches,
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
tenantIdRef.current = currentTenantId;
|
||||
}, [currentTenantId]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDocumentIds.length) {
|
||||
return;
|
||||
}
|
||||
if (!selectedDocumentIds.includes(activePreviewId)) {
|
||||
setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]);
|
||||
}
|
||||
selectionInitializedRef.current = true;
|
||||
}, [selectedDocumentIds, activePreviewId, selectionInitializedRef]);
|
||||
|
||||
|
||||
const tagLookupById = useMemo(() => {
|
||||
const map = new Map();
|
||||
tags.forEach((tag) => {
|
||||
if (tag?.id) {
|
||||
map.set(tag.id, tag);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [tags]);
|
||||
|
||||
const {
|
||||
tagLookupById,
|
||||
correspondents,
|
||||
refreshCorrespondents,
|
||||
handleCorrespondentCreate,
|
||||
handleCorrespondentUpdate,
|
||||
handleCorrespondentDelete,
|
||||
setCorrespondents,
|
||||
} = useCorrespondents({
|
||||
apiClient: api,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
tenantIdRef,
|
||||
mapDocumentCaches,
|
||||
});
|
||||
|
||||
const {
|
||||
correspondentLookupByName,
|
||||
handleDocumentCorrespondentAttach,
|
||||
handleCorrespondentRemove,
|
||||
handleCorrespondentAdd,
|
||||
passkeys,
|
||||
passkeysSupported,
|
||||
passkeysLoading,
|
||||
@@ -566,10 +518,16 @@ const useDocumentsWorkspace = ({
|
||||
refreshPasskeys,
|
||||
registerPasskey,
|
||||
revokePasskey,
|
||||
} = usePasskeys({
|
||||
api,
|
||||
} = useWorkspaceTaxonomies({
|
||||
apiClient,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
tagManager,
|
||||
tenantIdRef,
|
||||
currentTenantId,
|
||||
setActiveTagFilters,
|
||||
mapDocumentCaches,
|
||||
updateDocumentCaches,
|
||||
token,
|
||||
});
|
||||
|
||||
@@ -587,33 +545,25 @@ const useDocumentsWorkspace = ({
|
||||
);
|
||||
|
||||
const refreshCurrentFolder = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const contents = await ensureFolderData(selectedFolder, {
|
||||
force: true,
|
||||
prefetchDepth: 1,
|
||||
});
|
||||
applySelectedFolder(selectedFolder, contents);
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Failed to refresh folder.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]);
|
||||
const contents = await ensureFolderData(selectedFolder, {
|
||||
force: true,
|
||||
prefetchDepth: 1,
|
||||
});
|
||||
applySelectedFolder(selectedFolder, contents);
|
||||
}, [selectedFolder, ensureFolderData, applySelectedFolder]);
|
||||
|
||||
const {
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleBulkSelectionReanalyze,
|
||||
} = useDocumentTagging({
|
||||
apiClient: api,
|
||||
apiClient,
|
||||
tags,
|
||||
tagManager,
|
||||
refreshTags,
|
||||
resolveTargetDocumentIds,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
updateDocumentCaches,
|
||||
});
|
||||
|
||||
@@ -625,7 +575,7 @@ const useDocumentsWorkspace = ({
|
||||
clearUploadQueue,
|
||||
resetUploadsState,
|
||||
} = useDocumentUploads({
|
||||
apiClient: api,
|
||||
apiClient,
|
||||
token,
|
||||
selectedFolder,
|
||||
currentFolderName,
|
||||
@@ -633,7 +583,6 @@ const useDocumentsWorkspace = ({
|
||||
refreshCurrentFolder,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
shellRef,
|
||||
});
|
||||
|
||||
@@ -656,20 +605,6 @@ const useDocumentsWorkspace = ({
|
||||
documentsViewMode,
|
||||
});
|
||||
|
||||
const {
|
||||
correspondentLookupByName,
|
||||
handleDocumentCorrespondentAttach,
|
||||
handleCorrespondentRemove,
|
||||
handleCorrespondentAdd,
|
||||
} = useDocumentCorrespondentActions({
|
||||
apiClient: api,
|
||||
correspondents,
|
||||
handleCorrespondentCreate,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
updateDocumentCaches,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSortRefreshReadyRef.current) {
|
||||
activeSortRefreshReadyRef.current = true;
|
||||
@@ -816,7 +751,6 @@ const useDocumentsWorkspace = ({
|
||||
handleDocumentIssuedUpdate,
|
||||
handleTagRemove,
|
||||
} = useDocumentMutations({
|
||||
api,
|
||||
token,
|
||||
documentLookup,
|
||||
folderLabelMap,
|
||||
@@ -836,7 +770,6 @@ const useDocumentsWorkspace = ({
|
||||
focusedRowKey,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
mapDocumentCaches,
|
||||
applySelectedFolder,
|
||||
folderNodes,
|
||||
@@ -862,7 +795,6 @@ const useDocumentsWorkspace = ({
|
||||
handleFolderDelete,
|
||||
folderClickHandlers,
|
||||
} = useFolderTreeActions({
|
||||
api,
|
||||
token,
|
||||
folderNodes,
|
||||
setFolderNodes,
|
||||
@@ -874,7 +806,6 @@ const useDocumentsWorkspace = ({
|
||||
applySelectedFolder,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
setFolderContents,
|
||||
setCurrentFolder,
|
||||
setSearchResultIds,
|
||||
@@ -913,18 +844,10 @@ const useDocumentsWorkspace = ({
|
||||
isFolderRowKey,
|
||||
});
|
||||
const initializeAfterLogin = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([refreshTags(), refreshCorrespondents()]);
|
||||
const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root';
|
||||
await loadFolder(initialFolder, { showLoading: false });
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Failed to initialize data.');
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder, notifyApiError]);
|
||||
await Promise.all([refreshTags(), refreshCorrespondents()]);
|
||||
const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root';
|
||||
await loadFolder(initialFolder, {} );
|
||||
}, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
@@ -997,7 +920,6 @@ const useDocumentsWorkspace = ({
|
||||
handleBulkCorrespondentRemove,
|
||||
handleDeleteSelection,
|
||||
} = useBulkDocumentActions({
|
||||
api,
|
||||
resolveTargetDocumentIds,
|
||||
correspondentLookupByName,
|
||||
handleCorrespondentCreate,
|
||||
@@ -1007,7 +929,6 @@ const useDocumentsWorkspace = ({
|
||||
handleDocumentsDelete,
|
||||
handleFolderDelete,
|
||||
clearDocumentSelection,
|
||||
setLoading,
|
||||
updateDocumentCaches,
|
||||
});
|
||||
|
||||
@@ -1233,7 +1154,6 @@ const useDocumentsWorkspace = ({
|
||||
inspectDocument,
|
||||
previewActive,
|
||||
previewWorkspaceDocument,
|
||||
documentLink,
|
||||
resolveFolderPath,
|
||||
} = useDetailWorkspace({
|
||||
documents: viewDocuments,
|
||||
@@ -1244,7 +1164,6 @@ const useDocumentsWorkspace = ({
|
||||
ensureFolderData,
|
||||
detailPanelControlRef,
|
||||
detailFolderFetchRef,
|
||||
documentLinks,
|
||||
previewDocumentId,
|
||||
activePreviewId,
|
||||
openDocumentPreview: openDocumentPreviewForDetail,
|
||||
@@ -1295,94 +1214,21 @@ const useDocumentsWorkspace = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => {
|
||||
const chain = [];
|
||||
const seen = new Set();
|
||||
const pending = new Set();
|
||||
let currentId = selectedFolder || 'root';
|
||||
let guard = 0;
|
||||
|
||||
while (currentId && !seen.has(currentId) && guard < 32) {
|
||||
guard += 1;
|
||||
seen.add(currentId);
|
||||
|
||||
if (currentId === 'root') {
|
||||
chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
||||
currentId = null;
|
||||
break;
|
||||
}
|
||||
|
||||
const node = folderNodes.get(currentId);
|
||||
if (node) {
|
||||
chain.push({ id: currentId, name: node.name || 'Folder' });
|
||||
currentId = node.parentId ?? 'root';
|
||||
continue;
|
||||
}
|
||||
|
||||
let fallbackName = '…';
|
||||
let parentId = null;
|
||||
|
||||
if (currentFolder && currentFolder.id === currentId) {
|
||||
fallbackName = currentFolder.name;
|
||||
parentId = currentFolder.parent_id ?? 'root';
|
||||
}
|
||||
|
||||
chain.push({ id: currentId, name: fallbackName });
|
||||
pending.add(currentId);
|
||||
currentId = parentId;
|
||||
}
|
||||
|
||||
if (!chain.some((crumb) => crumb.id === 'root')) {
|
||||
chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
||||
}
|
||||
|
||||
const ordered = [];
|
||||
const seenOrdered = new Set();
|
||||
chain
|
||||
.slice()
|
||||
.reverse()
|
||||
.forEach((crumb) => {
|
||||
if (!seenOrdered.has(crumb.id)) {
|
||||
seenOrdered.add(crumb.id);
|
||||
ordered.push(crumb);
|
||||
}
|
||||
});
|
||||
|
||||
return { breadcrumbs: ordered, missingBreadcrumbAncestors: Array.from(pending) };
|
||||
}, [selectedFolder, folderNodes, currentFolder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!missingBreadcrumbAncestors.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
missingBreadcrumbAncestors.forEach((folderId) => {
|
||||
if (!folderId || folderId === 'root') {
|
||||
return;
|
||||
}
|
||||
if (breadcrumbFetchRef.current.has(folderId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
breadcrumbFetchRef.current.add(folderId);
|
||||
ensureFolderData(folderId, { force: false })
|
||||
.catch((error) => {
|
||||
console.warn('Failed to preload breadcrumb ancestor', folderId, error);
|
||||
})
|
||||
.finally(() => {
|
||||
breadcrumbFetchRef.current.delete(folderId);
|
||||
});
|
||||
});
|
||||
}, [missingBreadcrumbAncestors, ensureFolderData]);
|
||||
const breadcrumbs = useWorkspaceBreadcrumbs({
|
||||
selectedFolder,
|
||||
folderNodes,
|
||||
currentFolder,
|
||||
breadcrumbFetchRef,
|
||||
ensureFolderData,
|
||||
});
|
||||
|
||||
const { handleTenantSelect } = useTenantManager({
|
||||
apiClient: api,
|
||||
apiClient,
|
||||
appDispatch,
|
||||
currentTenantId,
|
||||
resetWorkspaceState,
|
||||
setStatusMessage,
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
refreshTags,
|
||||
refreshCorrespondents,
|
||||
loadFolder,
|
||||
@@ -1393,93 +1239,27 @@ const useDocumentsWorkspace = ({
|
||||
});
|
||||
|
||||
|
||||
const handleDeskDocumentStackSelect = useCallback(
|
||||
(docIds: Array<Identifier | string>) => {
|
||||
if (!Array.isArray(docIds) || docIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rowKeys = docIds
|
||||
.map((id) => resolveDocumentRowKey(id as Identifier))
|
||||
.filter((value): value is string => typeof value === 'string');
|
||||
|
||||
if (!rowKeys.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextKeys = [...selectedEntries];
|
||||
rowKeys.forEach((key) => {
|
||||
if (!nextKeys.includes(key)) {
|
||||
nextKeys.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
const anchor = (rowKeys[0]
|
||||
|| selectionAnchorRef.current
|
||||
|| nextKeys[nextKeys.length - 1]) as Identifier | string | null;
|
||||
|
||||
applySelection(nextKeys, {
|
||||
anchor,
|
||||
interactedKeys: rowKeys,
|
||||
});
|
||||
},
|
||||
[applySelection, selectedEntries, selectionAnchorRef],
|
||||
);
|
||||
|
||||
const deskViewId = useMemo(() => {
|
||||
if (showingSearchResults) {
|
||||
const trimmedQuery = searchQuery.trim();
|
||||
const tagsKey = [...activeTagFilters].sort().join(',');
|
||||
const correspondentsKey = [...activeCorrespondentFilters].sort().join(',');
|
||||
return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`;
|
||||
}
|
||||
|
||||
const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root';
|
||||
return `folder:${folderKey}`;
|
||||
}, [
|
||||
const deskWorkspaceProps = useWorkspaceDeskProps({
|
||||
viewDocuments,
|
||||
inspectDocumentForDesk,
|
||||
handleEntryPointer: handleEntryPointerCore,
|
||||
selectedEntries,
|
||||
selectionAnchorRef,
|
||||
applySelection,
|
||||
resolveDocumentRowKey,
|
||||
showingSearchResults,
|
||||
searchQuery,
|
||||
activeTagFilters,
|
||||
activeCorrespondentFilters,
|
||||
selectedFolder,
|
||||
]);
|
||||
|
||||
const deskWorkspaceProps = useMemo(
|
||||
() => ({
|
||||
documents: viewDocuments,
|
||||
onInspectDocument: inspectDocumentForDesk,
|
||||
onEntryPointer: handleEntryPointerCore,
|
||||
onDocumentStackSelect: handleDeskDocumentStackSelect,
|
||||
onPromoteSelection: promoteSelectionOrder,
|
||||
onAssignTagToDocument: handleDocumentTagDrop,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
activeTagIds: activeTagFilters,
|
||||
selectedDocumentIds,
|
||||
onClearSelection: clearDocumentSelection,
|
||||
tenantId: currentTenantId,
|
||||
viewId: deskViewId,
|
||||
documentLinks,
|
||||
ensureDownloadUrl,
|
||||
}),
|
||||
[
|
||||
viewDocuments,
|
||||
inspectDocumentForDesk,
|
||||
handleEntryPointerCore,
|
||||
handleDeskDocumentStackSelect,
|
||||
promoteSelectionOrder,
|
||||
handleDocumentTagDrop,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
activeTagFilters,
|
||||
selectedDocumentIds,
|
||||
clearDocumentSelection,
|
||||
currentTenantId,
|
||||
deskViewId,
|
||||
documentLinks,
|
||||
ensureDownloadUrl,
|
||||
],
|
||||
);
|
||||
promoteSelectionOrder,
|
||||
handleDocumentTagDrop,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
currentTenantId,
|
||||
documentLinks,
|
||||
ensureDownloadUrl,
|
||||
});
|
||||
|
||||
const documentsPanelProps = useDocumentsPanelProps({
|
||||
currentFolderName,
|
||||
@@ -1513,7 +1293,7 @@ const useDocumentsWorkspace = ({
|
||||
clearDocumentSelection,
|
||||
handleDeleteSelection,
|
||||
handleEntryPointerCore,
|
||||
inspectDocument,
|
||||
onDocumentActivate: inspectDocument,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
@@ -1554,7 +1334,6 @@ const useDocumentsWorkspace = ({
|
||||
correspondents,
|
||||
handleCorrespondentCreate,
|
||||
appStatus,
|
||||
loading,
|
||||
previewActive,
|
||||
handleLogout,
|
||||
status,
|
||||
@@ -1599,7 +1378,6 @@ const useDocumentsWorkspace = ({
|
||||
revokePasskey,
|
||||
previewActive,
|
||||
previewWorkspaceDocument,
|
||||
documentLink,
|
||||
previewDocumentId,
|
||||
closeDocumentPreview,
|
||||
handleThumbnailRegeneration,
|
||||
@@ -1650,7 +1428,6 @@ const useDocumentsWorkspace = ({
|
||||
revokePasskey,
|
||||
previewActive,
|
||||
previewWorkspaceDocument,
|
||||
documentLink,
|
||||
previewDocumentId,
|
||||
closeDocumentPreview,
|
||||
handleThumbnailRegeneration,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { DragEvent } from 'react';
|
||||
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils';
|
||||
import {
|
||||
createFolder,
|
||||
deleteFolder,
|
||||
moveFolder as moveFolderRequest,
|
||||
renameFolder as renameFolderRequest,
|
||||
} from '../../lib/apiClient';
|
||||
|
||||
type FolderId = string | number;
|
||||
type FolderKey = FolderId | 'root';
|
||||
@@ -22,12 +28,6 @@ interface FolderContentsState {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ApiClient {
|
||||
patch: (url: string, data?: unknown) => Promise<any>;
|
||||
post: (url: string, data?: unknown) => Promise<{ data: any }>;
|
||||
delete: (url: string) => Promise<any>;
|
||||
}
|
||||
|
||||
interface EnsureFolderOptions {
|
||||
force?: boolean;
|
||||
includeDocuments?: boolean;
|
||||
@@ -35,7 +35,6 @@ interface EnsureFolderOptions {
|
||||
}
|
||||
|
||||
interface LoadFolderOptions {
|
||||
showLoading?: boolean;
|
||||
preserveSearch?: boolean;
|
||||
}
|
||||
|
||||
@@ -53,7 +52,6 @@ interface FolderClickHandlers {
|
||||
}
|
||||
|
||||
interface UseFolderTreeActionsOptions {
|
||||
api: ApiClient;
|
||||
token?: string | null;
|
||||
folderNodes: Map<FolderKey, FolderNode>;
|
||||
setFolderNodes: (updater: (prev: Map<FolderKey, FolderNode>) => Map<FolderKey, FolderNode>) => void;
|
||||
@@ -65,7 +63,6 @@ interface UseFolderTreeActionsOptions {
|
||||
applySelectedFolder: (folderId: FolderKey, contents: any) => void;
|
||||
notifyApiError: (error: unknown, message?: string) => void;
|
||||
setStatusMessage: (message: string, level?: string) => void;
|
||||
setLoading: (value: boolean) => void;
|
||||
setFolderContents: (
|
||||
updater: (prev: Map<FolderKey, FolderContentsState>) => Map<FolderKey, FolderContentsState>,
|
||||
) => void;
|
||||
@@ -84,7 +81,6 @@ interface UseFolderTreeActionsOptions {
|
||||
}
|
||||
|
||||
const useFolderTreeActions = ({
|
||||
api,
|
||||
token,
|
||||
folderNodes,
|
||||
setFolderNodes,
|
||||
@@ -96,7 +92,6 @@ const useFolderTreeActions = ({
|
||||
applySelectedFolder,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
setFolderContents,
|
||||
setCurrentFolder,
|
||||
setSearchResultIds,
|
||||
@@ -129,7 +124,7 @@ const useFolderTreeActions = ({
|
||||
const parent_id = targetKey === 'root' ? null : targetKey;
|
||||
|
||||
try {
|
||||
await api.patch(`/folders/${folderId}`, { parent_id });
|
||||
await moveFolderRequest(folderId, parent_id);
|
||||
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -202,7 +197,6 @@ const useFolderTreeActions = ({
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
ensureFolderData,
|
||||
folderNodes,
|
||||
notifyApiError,
|
||||
@@ -214,12 +208,11 @@ const useFolderTreeActions = ({
|
||||
);
|
||||
|
||||
const loadFolder = useCallback(
|
||||
async (folderId: FolderKey | null, { showLoading = true, preserveSearch = false }: LoadFolderOptions = {}) => {
|
||||
async (folderId: FolderKey | null, { preserveSearch = false }: LoadFolderOptions = {}) => {
|
||||
const targetId = folderId || 'root';
|
||||
setSelectedFolder(targetId);
|
||||
await ensureFolderAncestorsLoaded(targetId);
|
||||
expandFolderAncestors(targetId);
|
||||
if (showLoading) setLoading(true);
|
||||
try {
|
||||
const contents = await ensureFolderData(targetId, { force: true, prefetchDepth: 1 });
|
||||
if (targetId !== 'root') {
|
||||
@@ -239,8 +232,6 @@ const useFolderTreeActions = ({
|
||||
}
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Failed to load folder contents.');
|
||||
} finally {
|
||||
if (showLoading) setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -249,7 +240,6 @@ const useFolderTreeActions = ({
|
||||
ensureFolderData,
|
||||
expandFolderAncestors,
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
setSearchResultIds,
|
||||
setSelectedFolder,
|
||||
],
|
||||
@@ -292,10 +282,8 @@ const useFolderTreeActions = ({
|
||||
setStatusMessage('Folder name cannot be empty.', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.patch(`/folders/${folderId}`, { name: trimmed });
|
||||
await renameFolderRequest(folderId, trimmed);
|
||||
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -326,17 +314,13 @@ const useFolderTreeActions = ({
|
||||
const message = error.response?.data?.error || 'Failed to rename folder.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
notifyApiError,
|
||||
setCurrentFolder,
|
||||
setFolderContents,
|
||||
setFolderNodes,
|
||||
setLoading,
|
||||
setStatusMessage,
|
||||
token,
|
||||
],
|
||||
@@ -359,33 +343,37 @@ const useFolderTreeActions = ({
|
||||
setCreatingFolder(true);
|
||||
let succeeded = false;
|
||||
try {
|
||||
const { data } = await api.post('/folders', payload);
|
||||
const data = await createFolder(payload);
|
||||
const folderData = (data as { folder?: { id?: FolderKey; name?: string; parent_id?: FolderKey | null; children?: FolderKey[] } }).folder;
|
||||
if (!folderData?.id) {
|
||||
throw new Error('Folder creation failed.');
|
||||
}
|
||||
setStatusMessage('Folder created.', 'success');
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
const parentId = payload.parent_id || 'root';
|
||||
const parentId = folderData.parent_id ?? payload.parent_id ?? 'root';
|
||||
const parentNode = next.get(parentId);
|
||||
if (parentNode) {
|
||||
next.set(parentId, {
|
||||
...parentNode,
|
||||
children: parentNode.children.concat([data.folder.id]),
|
||||
children: parentNode.children.concat([folderData.id]),
|
||||
loaded: true,
|
||||
hasChildren: true,
|
||||
});
|
||||
}
|
||||
next.set(data.folder.id, {
|
||||
id: data.folder.id,
|
||||
name: data.folder.name,
|
||||
next.set(folderData.id, {
|
||||
id: folderData.id,
|
||||
name: folderData.name ?? payload.name,
|
||||
parentId: parentId,
|
||||
children: [],
|
||||
children: folderData.children || [],
|
||||
expanded: false,
|
||||
loaded: false,
|
||||
hasChildren: false,
|
||||
hasChildren: Array.isArray(folderData.children) ? folderData.children.length > 0 : false,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
await ensureFolderData(selectedFolder, { force: true, prefetchDepth: 1 });
|
||||
await selectFolder(data.folder.id, { immediate: true });
|
||||
await selectFolder(folderData.id, { immediate: true });
|
||||
succeeded = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -400,7 +388,6 @@ const useFolderTreeActions = ({
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
ensureFolderData,
|
||||
notifyApiError,
|
||||
selectFolder,
|
||||
@@ -413,7 +400,7 @@ const useFolderTreeActions = ({
|
||||
);
|
||||
|
||||
const handleFolderDelete = useCallback(
|
||||
async (folderId: FolderKey, { showMessage = true, manageLoading = true }: { showMessage?: boolean; manageLoading?: boolean } = {}) => {
|
||||
async (folderId: FolderKey, { showMessage = true }: { showMessage?: boolean } = {}) => {
|
||||
if (!token) {
|
||||
if (showMessage) {
|
||||
setStatusMessage('Log in to manage folders.', 'error');
|
||||
@@ -427,10 +414,6 @@ const useFolderTreeActions = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (manageLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const contents = await ensureFolderData(folderId, {
|
||||
force: true,
|
||||
@@ -445,7 +428,7 @@ const useFolderTreeActions = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
await api.delete(`/folders/${folderId}`);
|
||||
await deleteFolder(folderId);
|
||||
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
@@ -496,14 +479,9 @@ const useFolderTreeActions = ({
|
||||
setStatusMessage(message, 'error');
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (manageLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
token,
|
||||
applySelectedFolder,
|
||||
ensureFolderData,
|
||||
@@ -512,7 +490,6 @@ const useFolderTreeActions = ({
|
||||
selectedFolder,
|
||||
setFolderContents,
|
||||
setFolderNodes,
|
||||
setLoading,
|
||||
setSelectedFolder,
|
||||
setStatusMessage,
|
||||
],
|
||||
|
||||
@@ -19,10 +19,9 @@ interface UseTenantManagerOptions {
|
||||
resetWorkspaceState: () => void;
|
||||
setStatusMessage: (message: string, variant?: string) => void;
|
||||
notifyApiError: (error: unknown, message: string) => void;
|
||||
setLoading: (state: boolean) => void;
|
||||
refreshTags: () => Promise<void>;
|
||||
refreshCorrespondents: () => Promise<void>;
|
||||
loadFolder: (folderId: string, options?: { showLoading?: boolean; preserveSearch?: boolean }) => Promise<void>;
|
||||
loadFolder: (folderId: string, options?: { preserveSearch?: boolean }) => Promise<void>;
|
||||
handleDocumentsViewModeChange: (mode: string) => void;
|
||||
navigate: NavigateFunction;
|
||||
tokenRef?: MutableRefObject<string | null>;
|
||||
@@ -36,7 +35,6 @@ const useTenantManager = ({
|
||||
resetWorkspaceState,
|
||||
setStatusMessage,
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
refreshTags,
|
||||
refreshCorrespondents,
|
||||
loadFolder,
|
||||
@@ -52,7 +50,6 @@ const useTenantManager = ({
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (!refreshOnly) {
|
||||
setStatusMessage('Switching tenant…', 'info');
|
||||
@@ -99,14 +96,12 @@ const useTenantManager = ({
|
||||
navigate('/documents', { replace: true });
|
||||
|
||||
await Promise.all([refreshTags(), refreshCorrespondents()]);
|
||||
await loadFolder('root', { showLoading: false, preserveSearch: false });
|
||||
await loadFolder('root', { preserveSearch: false });
|
||||
|
||||
const tenantLabel = data?.tenant?.name || data?.tenant?.id || 'tenant';
|
||||
setStatusMessage(`Switched to ${tenantLabel}.`, 'info');
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Failed to switch tenant.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -120,7 +115,6 @@ const useTenantManager = ({
|
||||
refreshCorrespondents,
|
||||
refreshTags,
|
||||
resetWorkspaceState,
|
||||
setLoading,
|
||||
setStatusMessage,
|
||||
tenantIdRef,
|
||||
tokenRef,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { DEFAULT_FOLDER_NAME } from '../../app/appLayoutUtils';
|
||||
|
||||
type Identifier = string | number;
|
||||
type FolderId = Identifier | 'root';
|
||||
|
||||
interface UseWorkspaceBreadcrumbsArgs {
|
||||
selectedFolder: FolderId | null;
|
||||
folderNodes: Map<FolderId, { id?: FolderId; name?: string | null; parentId?: FolderId | null; parent_id?: FolderId | null }>;
|
||||
currentFolder: { id?: FolderId; name?: string | null; parentId?: FolderId | null; parent_id?: FolderId | null } | null;
|
||||
breadcrumbFetchRef: React.MutableRefObject<Set<FolderId>>;
|
||||
ensureFolderData: (folderId: FolderId, options?: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
const useWorkspaceBreadcrumbs = ({
|
||||
selectedFolder,
|
||||
folderNodes,
|
||||
currentFolder,
|
||||
breadcrumbFetchRef,
|
||||
ensureFolderData,
|
||||
}: UseWorkspaceBreadcrumbsArgs) => {
|
||||
const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => {
|
||||
const chain: Array<{ id: FolderId; name?: string | null }> = [];
|
||||
const seen = new Set<FolderId>();
|
||||
const pending = new Set<FolderId>();
|
||||
let currentId: FolderId | null = (selectedFolder || 'root') as FolderId;
|
||||
let guard = 0;
|
||||
|
||||
while (currentId && !seen.has(currentId) && guard < 32) {
|
||||
guard += 1;
|
||||
seen.add(currentId);
|
||||
|
||||
if (currentId === 'root') {
|
||||
chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
||||
currentId = null;
|
||||
break;
|
||||
}
|
||||
|
||||
const node = folderNodes.get(currentId as FolderId);
|
||||
if (node) {
|
||||
chain.push({ id: currentId, name: node.name || 'Folder' });
|
||||
currentId = (node.parentId ?? node.parent_id ?? 'root') as FolderId;
|
||||
continue;
|
||||
}
|
||||
|
||||
let fallbackName: string | null | undefined = '…';
|
||||
let parentId: FolderId | null | undefined = null;
|
||||
|
||||
if (currentFolder && currentFolder.id === currentId) {
|
||||
fallbackName = currentFolder.name;
|
||||
parentId = (currentFolder.parent_id ?? currentFolder.parentId ?? 'root') as FolderId;
|
||||
}
|
||||
|
||||
chain.push({ id: currentId, name: fallbackName });
|
||||
pending.add(currentId);
|
||||
currentId = parentId as FolderId | null;
|
||||
}
|
||||
|
||||
if (!chain.some((crumb) => crumb.id === 'root')) {
|
||||
chain.push({ id: 'root', name: DEFAULT_FOLDER_NAME });
|
||||
}
|
||||
|
||||
const ordered: Array<{ id: FolderId; name?: string | null }> = [];
|
||||
const seenOrdered = new Set<FolderId>();
|
||||
chain
|
||||
.slice()
|
||||
.reverse()
|
||||
.forEach((crumb) => {
|
||||
if (!seenOrdered.has(crumb.id)) {
|
||||
seenOrdered.add(crumb.id);
|
||||
ordered.push(crumb);
|
||||
}
|
||||
});
|
||||
|
||||
return { breadcrumbs: ordered, missingBreadcrumbAncestors: Array.from(pending) };
|
||||
}, [selectedFolder, folderNodes, currentFolder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!missingBreadcrumbAncestors.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
missingBreadcrumbAncestors.forEach((folderId) => {
|
||||
if (!folderId || folderId === 'root') {
|
||||
return;
|
||||
}
|
||||
if (breadcrumbFetchRef.current.has(folderId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
breadcrumbFetchRef.current.add(folderId);
|
||||
ensureFolderData(folderId, { force: false })
|
||||
.catch((error) => {
|
||||
console.warn('Failed to preload breadcrumb ancestor', folderId, error);
|
||||
})
|
||||
.finally(() => {
|
||||
breadcrumbFetchRef.current.delete(folderId);
|
||||
});
|
||||
});
|
||||
}, [missingBreadcrumbAncestors, ensureFolderData, breadcrumbFetchRef]);
|
||||
|
||||
return breadcrumbs;
|
||||
};
|
||||
|
||||
export default useWorkspaceBreadcrumbs;
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface UseWorkspaceDeskPropsArgs {
|
||||
viewDocuments: any[];
|
||||
inspectDocumentForDesk: (doc: any) => void;
|
||||
handleEntryPointer: (params: { rowKey?: string | null; id?: Identifier | null; type?: string; event?: any }) => void;
|
||||
selectedEntries: Array<string | number>;
|
||||
selectionAnchorRef: MutableRefObject<Identifier | string | null>;
|
||||
applySelection: (rowKeys: Array<string | number>, options?: { anchor?: Identifier | string | null; interactedKeys?: Array<string | number> }) => void;
|
||||
resolveDocumentRowKey: (id?: Identifier | null) => string | null;
|
||||
showingSearchResults: boolean;
|
||||
searchQuery: string;
|
||||
activeTagFilters: Array<string | number>;
|
||||
activeCorrespondentFilters: Array<string | number>;
|
||||
selectedFolder: Identifier | 'root' | null;
|
||||
promoteSelectionOrder: () => void;
|
||||
handleDocumentTagDrop: (docId: Identifier, tagId: Identifier) => Promise<void> | void;
|
||||
ensureAssetUrl: (docId: Identifier, asset: any, options?: Record<string, unknown>) => Promise<any> | null;
|
||||
getDocumentAsset: (doc: any, type: string) => any;
|
||||
currentTenantId: Identifier | null;
|
||||
documentLinks: Map<Identifier, unknown> | null;
|
||||
ensureDownloadUrl?: (docId: Identifier, options?: { force?: boolean }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
const useWorkspaceDeskProps = ({
|
||||
viewDocuments,
|
||||
inspectDocumentForDesk,
|
||||
handleEntryPointer,
|
||||
selectedEntries,
|
||||
selectionAnchorRef,
|
||||
applySelection,
|
||||
resolveDocumentRowKey,
|
||||
showingSearchResults,
|
||||
searchQuery,
|
||||
activeTagFilters,
|
||||
activeCorrespondentFilters,
|
||||
selectedFolder,
|
||||
promoteSelectionOrder,
|
||||
handleDocumentTagDrop,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
currentTenantId,
|
||||
documentLinks,
|
||||
ensureDownloadUrl,
|
||||
}: UseWorkspaceDeskPropsArgs) => {
|
||||
const handleDeskDocumentStackSelect = useCallback(
|
||||
(docIds: Array<Identifier | string>) => {
|
||||
if (!Array.isArray(docIds) || docIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rowKeys = docIds
|
||||
.map((id) => resolveDocumentRowKey(id as Identifier))
|
||||
.filter((value): value is string => typeof value === 'string');
|
||||
|
||||
if (!rowKeys.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextKeys = [...selectedEntries];
|
||||
rowKeys.forEach((key) => {
|
||||
if (!nextKeys.includes(key)) {
|
||||
nextKeys.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
const anchor = (rowKeys[0]
|
||||
|| selectionAnchorRef.current
|
||||
|| nextKeys[nextKeys.length - 1]) as Identifier | string | null;
|
||||
|
||||
applySelection(nextKeys, {
|
||||
anchor,
|
||||
interactedKeys: rowKeys,
|
||||
});
|
||||
},
|
||||
[applySelection, resolveDocumentRowKey, selectedEntries, selectionAnchorRef],
|
||||
);
|
||||
|
||||
const deskViewId = useMemo(() => {
|
||||
if (showingSearchResults) {
|
||||
const trimmedQuery = searchQuery.trim();
|
||||
const tagsKey = [...activeTagFilters].sort().join(',');
|
||||
const correspondentsKey = [...activeCorrespondentFilters].sort().join(',');
|
||||
return `search:${trimmedQuery}|tags:${tagsKey}|corr:${correspondentsKey}`;
|
||||
}
|
||||
|
||||
const folderKey = selectedFolder && selectedFolder !== '' ? selectedFolder : 'root';
|
||||
return `folder:${folderKey}`;
|
||||
}, [
|
||||
showingSearchResults,
|
||||
searchQuery,
|
||||
activeTagFilters,
|
||||
activeCorrespondentFilters,
|
||||
selectedFolder,
|
||||
]);
|
||||
|
||||
const deskWorkspaceProps = useMemo(
|
||||
() => ({
|
||||
entries: viewDocuments,
|
||||
onDocumentActivate: inspectDocumentForDesk,
|
||||
onDocumentClick: handleEntryPointer,
|
||||
onDocumentStackSelect: handleDeskDocumentStackSelect,
|
||||
onPromoteSelection: promoteSelectionOrder,
|
||||
onDocumentTagDrop: handleDocumentTagDrop,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
activeTagFilters,
|
||||
tenantId: currentTenantId,
|
||||
viewId: deskViewId,
|
||||
documentLinks,
|
||||
ensureDownloadUrl,
|
||||
}),
|
||||
[
|
||||
viewDocuments,
|
||||
inspectDocumentForDesk,
|
||||
handleEntryPointer,
|
||||
handleDeskDocumentStackSelect,
|
||||
promoteSelectionOrder,
|
||||
handleDocumentTagDrop,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
activeTagFilters,
|
||||
currentTenantId,
|
||||
deskViewId,
|
||||
documentLinks,
|
||||
ensureDownloadUrl,
|
||||
],
|
||||
);
|
||||
|
||||
return deskWorkspaceProps;
|
||||
};
|
||||
|
||||
export default useWorkspaceDeskProps;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface UseWorkspaceSelectionSyncArgs {
|
||||
showingSearchResults: boolean;
|
||||
searchQuery: string;
|
||||
setSelectedEntries: (entries: Array<string | number>) => void;
|
||||
setSelectionOrder: (order: Array<string | number>) => void;
|
||||
selectionOrderRef: MutableRefObject<Array<string | number>>;
|
||||
selectionAnchorRef: MutableRefObject<Identifier | string | null>;
|
||||
setFocusedDocumentId: (id: Identifier | null) => void;
|
||||
selectedDocumentIds: Identifier[];
|
||||
activePreviewId: Identifier | null;
|
||||
setActivePreviewId: (id: Identifier | null) => void;
|
||||
selectionInitializedRef: MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
const useWorkspaceSelectionSync = ({
|
||||
showingSearchResults,
|
||||
searchQuery,
|
||||
setSelectedEntries,
|
||||
setSelectionOrder,
|
||||
selectionOrderRef,
|
||||
selectionAnchorRef,
|
||||
setFocusedDocumentId,
|
||||
selectedDocumentIds,
|
||||
activePreviewId,
|
||||
setActivePreviewId,
|
||||
selectionInitializedRef,
|
||||
}: UseWorkspaceSelectionSyncArgs) => {
|
||||
useEffect(() => {
|
||||
if (!showingSearchResults) {
|
||||
return;
|
||||
}
|
||||
setSelectedEntries([]);
|
||||
setSelectionOrder([]);
|
||||
selectionOrderRef.current = [];
|
||||
selectionAnchorRef.current = null;
|
||||
setFocusedDocumentId(null);
|
||||
}, [
|
||||
showingSearchResults,
|
||||
searchQuery,
|
||||
setSelectedEntries,
|
||||
setSelectionOrder,
|
||||
selectionOrderRef,
|
||||
selectionAnchorRef,
|
||||
setFocusedDocumentId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDocumentIds.length) {
|
||||
return;
|
||||
}
|
||||
if (!selectedDocumentIds.includes(activePreviewId as Identifier)) {
|
||||
setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]);
|
||||
}
|
||||
selectionInitializedRef.current = true;
|
||||
}, [selectedDocumentIds, activePreviewId, selectionInitializedRef, setActivePreviewId]);
|
||||
};
|
||||
|
||||
export default useWorkspaceSelectionSync;
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
import usePasskeys from '../../settings/usePasskeys';
|
||||
import TagManager from '../../tag_manager';
|
||||
import useCorrespondents from './useCorrespondents';
|
||||
import useDocumentCorrespondentActions from './useDocumentCorrespondentActions';
|
||||
import useTags from './useTags';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface UseWorkspaceTaxonomiesArgs {
|
||||
apiClient: any;
|
||||
notifyApiError: (error: unknown, fallbackMessage?: string, variant?: string) => void;
|
||||
setStatusMessage: (message: string, variant?: string) => void;
|
||||
tagManager: TagManager;
|
||||
tenantIdRef: MutableRefObject<Identifier | null>;
|
||||
currentTenantId: Identifier | null;
|
||||
setActiveTagFilters: Dispatch<SetStateAction<Identifier[]>>;
|
||||
mapDocumentCaches: (mapper: (doc: any) => any | undefined) => void;
|
||||
updateDocumentCaches: (id: Identifier, updater: (doc: any) => any) => void;
|
||||
token: string;
|
||||
}
|
||||
|
||||
const useWorkspaceTaxonomies = ({
|
||||
apiClient,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
tagManager,
|
||||
tenantIdRef,
|
||||
currentTenantId,
|
||||
setActiveTagFilters,
|
||||
mapDocumentCaches,
|
||||
updateDocumentCaches,
|
||||
token,
|
||||
}: UseWorkspaceTaxonomiesArgs) => {
|
||||
const {
|
||||
tags,
|
||||
refreshTags,
|
||||
handleTagCreate,
|
||||
handleTagUpdate,
|
||||
handleTagDelete,
|
||||
setTags,
|
||||
} = useTags({
|
||||
apiClient,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
tagManager,
|
||||
tenantIdRef,
|
||||
setActiveTagFilters,
|
||||
mapDocumentCaches,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
tenantIdRef.current = currentTenantId;
|
||||
}, [currentTenantId, tenantIdRef]);
|
||||
|
||||
const tagLookupById = useMemo(() => {
|
||||
const map = new Map();
|
||||
tags.forEach((tag) => {
|
||||
if (tag?.id) {
|
||||
map.set(tag.id, tag);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [tags]);
|
||||
|
||||
const {
|
||||
correspondents,
|
||||
refreshCorrespondents,
|
||||
handleCorrespondentCreate,
|
||||
handleCorrespondentUpdate,
|
||||
handleCorrespondentDelete,
|
||||
setCorrespondents,
|
||||
} = useCorrespondents({
|
||||
apiClient,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
tenantIdRef,
|
||||
mapDocumentCaches,
|
||||
});
|
||||
|
||||
const {
|
||||
correspondentLookupByName,
|
||||
handleDocumentCorrespondentAttach,
|
||||
handleCorrespondentRemove,
|
||||
handleCorrespondentAdd,
|
||||
} = useDocumentCorrespondentActions({
|
||||
apiClient,
|
||||
correspondents,
|
||||
handleCorrespondentCreate,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
updateDocumentCaches,
|
||||
});
|
||||
|
||||
const {
|
||||
passkeys,
|
||||
passkeysSupported,
|
||||
passkeysLoading,
|
||||
registeringPasskey,
|
||||
revokingPasskeyId,
|
||||
refreshPasskeys,
|
||||
registerPasskey,
|
||||
revokePasskey,
|
||||
} = usePasskeys({
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
token,
|
||||
});
|
||||
|
||||
return {
|
||||
tags,
|
||||
refreshTags,
|
||||
handleTagCreate,
|
||||
handleTagUpdate,
|
||||
handleTagDelete,
|
||||
setTags,
|
||||
tagLookupById,
|
||||
correspondents,
|
||||
refreshCorrespondents,
|
||||
handleCorrespondentCreate,
|
||||
handleCorrespondentUpdate,
|
||||
handleCorrespondentDelete,
|
||||
setCorrespondents,
|
||||
correspondentLookupByName,
|
||||
handleDocumentCorrespondentAttach,
|
||||
handleCorrespondentRemove,
|
||||
handleCorrespondentAdd,
|
||||
passkeys,
|
||||
passkeysSupported,
|
||||
passkeysLoading,
|
||||
registeringPasskey,
|
||||
revokingPasskeyId,
|
||||
refreshPasskeys,
|
||||
registerPasskey,
|
||||
revokePasskey,
|
||||
};
|
||||
};
|
||||
|
||||
export default useWorkspaceTaxonomies;
|
||||
Reference in New Issue
Block a user