Merge remote-tracking branch 'ui/ui' into dev
This commit is contained in:
@@ -1,13 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export const useDocumentsStore = () => {
|
||||
const [status, setStatus] = useState(null);
|
||||
|
||||
const setStatusMessage = useCallback((message, variant = 'info') => {
|
||||
setStatus(message ? { message, variant } : null);
|
||||
}, []);
|
||||
|
||||
return { status, setStatusMessage };
|
||||
};
|
||||
|
||||
export default useDocumentsStore;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export type StatusVariant = 'info' | 'success' | 'error';
|
||||
|
||||
export interface StatusMessage {
|
||||
message: string;
|
||||
variant: StatusVariant;
|
||||
}
|
||||
|
||||
export const useDocumentsStore = () => {
|
||||
const [status, setStatus] = useState<StatusMessage | null>(null);
|
||||
|
||||
const setStatusMessage = useCallback((message?: string | null, variant: StatusVariant = 'info') => {
|
||||
setStatus(message ? { message, variant } : null);
|
||||
}, []);
|
||||
|
||||
return { status, setStatusMessage };
|
||||
};
|
||||
|
||||
export default useDocumentsStore;
|
||||
+63
-14
@@ -1,4 +1,52 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { AxiosHeaders } from 'axios';
|
||||
|
||||
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 {
|
||||
tokenRef: MutableRefObject<string | null>;
|
||||
refreshAccessToken: () => Promise<string>;
|
||||
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,
|
||||
@@ -8,16 +56,16 @@ const useAuthManager = ({
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
}) => {
|
||||
const tokenRef = useRef(token);
|
||||
const refreshPromiseRef = useRef(null);
|
||||
}: UseAuthManagerArgs): UseAuthManagerResult => {
|
||||
const tokenRef = useRef<string | null>(token);
|
||||
const refreshPromiseRef = useRef<Promise<string> | null>(null);
|
||||
const initialRefreshAttemptedRef = useRef(Boolean(token));
|
||||
|
||||
const refreshAccessToken = useCallback(async () => {
|
||||
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('/auth/refresh');
|
||||
const { data } = await apiClient.post<{ access_token?: string; tenant?: unknown }>('/auth/refresh');
|
||||
if (data?.access_token) {
|
||||
appDispatch({
|
||||
type: 'TOKEN_REFRESH_SUCCESS',
|
||||
@@ -30,7 +78,7 @@ const useAuthManager = ({
|
||||
throw new Error('Missing access token in refresh response');
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Failed to refresh access token', error);
|
||||
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: error?.message || null });
|
||||
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: (error as Error)?.message || null });
|
||||
throw error;
|
||||
}
|
||||
}, [apiClient, appDispatch]);
|
||||
@@ -51,10 +99,11 @@ const useAuthManager = ({
|
||||
const requestInterceptor = apiClient.interceptors.request.use((config) => {
|
||||
const currentToken = tokenRef.current;
|
||||
if (currentToken) {
|
||||
config.headers = config.headers || {};
|
||||
if (!config.headers.Authorization) {
|
||||
config.headers.Authorization = `Bearer ${currentToken}`;
|
||||
const headers = ensureAxiosHeaders(config.headers);
|
||||
if (!headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${currentToken}`);
|
||||
}
|
||||
config.headers = headers;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
@@ -62,13 +111,14 @@ const useAuthManager = ({
|
||||
const responseInterceptor = apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const { response, config } = 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 = typeof config.url === 'string' ? config.url : '';
|
||||
const url = String(config?.url ?? '');
|
||||
const isAuthRoute = url.includes('/auth/login') || url.includes('/auth/refresh');
|
||||
|
||||
if (status === 401 && !config._retry && !isAuthRoute) {
|
||||
@@ -90,13 +140,12 @@ const useAuthManager = ({
|
||||
throw new Error('No token returned from refresh');
|
||||
}
|
||||
config._retry = true;
|
||||
config.headers = config.headers || {};
|
||||
config.headers.Authorization = `Bearer ${newToken}`;
|
||||
setHeaderAuthorization(config, newToken);
|
||||
console.log('[Auth] Retrying original request', url);
|
||||
try {
|
||||
return await apiClient(config);
|
||||
} catch (retryError) {
|
||||
if (retryError?.response?.status === 401) {
|
||||
if ((retryError as AxiosError)?.response?.status === 401) {
|
||||
notifyApiError(retryError, 'Session expired. Please log in again.');
|
||||
}
|
||||
throw retryError;
|
||||
+34
-15
@@ -1,4 +1,25 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { MutableRefObject, useCallback, useState } from 'react';
|
||||
|
||||
type 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 CorrespondentEntry {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseCorrespondentsOptions {
|
||||
apiClient: ApiClient;
|
||||
notifyApiError: (error: unknown, fallback: string) => void;
|
||||
setStatusMessage: (message: string, variant?: string) => void;
|
||||
tenantIdRef: MutableRefObject<string | number | null>;
|
||||
mapDocumentCaches?: (mapper: (doc: any) => any) => void;
|
||||
}
|
||||
|
||||
const useCorrespondents = ({
|
||||
apiClient,
|
||||
@@ -6,8 +27,8 @@ const useCorrespondents = ({
|
||||
setStatusMessage,
|
||||
tenantIdRef,
|
||||
mapDocumentCaches,
|
||||
}) => {
|
||||
const [correspondents, setCorrespondents] = useState([]);
|
||||
}: UseCorrespondentsOptions) => {
|
||||
const [correspondents, setCorrespondents] = useState<CorrespondentEntry[]>([]);
|
||||
|
||||
const refreshCorrespondents = useCallback(async () => {
|
||||
const requestTenantId = tenantIdRef.current;
|
||||
@@ -26,13 +47,13 @@ const useCorrespondents = ({
|
||||
}, [apiClient, notifyApiError, tenantIdRef]);
|
||||
|
||||
const handleCorrespondentUpdate = useCallback(
|
||||
async (correspondentId, changes) => {
|
||||
if (!correspondentId) {
|
||||
async (correspondentId: string | number, changes: { name?: string }) => {
|
||||
if (correspondentId == null) {
|
||||
throw new Error('Missing correspondent identifier.');
|
||||
}
|
||||
|
||||
const payload = {};
|
||||
if (typeof changes.name === 'string') {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (changes?.name != null) {
|
||||
const trimmed = changes.name.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Correspondent name cannot be empty.');
|
||||
@@ -59,8 +80,8 @@ const useCorrespondents = ({
|
||||
);
|
||||
|
||||
const handleCorrespondentCreate = useCallback(
|
||||
async ({ name }) => {
|
||||
const trimmed = typeof name === 'string' ? name.trim() : '';
|
||||
async ({ name }: { name?: string }) => {
|
||||
const trimmed = name?.trim?.() || '';
|
||||
if (!trimmed) {
|
||||
throw new Error('Correspondent name is required.');
|
||||
}
|
||||
@@ -79,12 +100,12 @@ const useCorrespondents = ({
|
||||
);
|
||||
|
||||
const handleCorrespondentDelete = useCallback(
|
||||
async (correspondentId) => {
|
||||
if (!correspondentId) {
|
||||
async (correspondentId: string | number) => {
|
||||
if (correspondentId == null) {
|
||||
throw new Error('Missing correspondent identifier.');
|
||||
}
|
||||
|
||||
const stripFromDoc = (doc) => {
|
||||
const stripFromDoc = (doc: any) => {
|
||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||
return doc;
|
||||
}
|
||||
@@ -99,9 +120,7 @@ const useCorrespondents = ({
|
||||
await apiClient.delete(`/correspondents/${correspondentId}`);
|
||||
await refreshCorrespondents();
|
||||
|
||||
if (typeof mapDocumentCaches === 'function') {
|
||||
mapDocumentCaches(stripFromDoc);
|
||||
}
|
||||
mapDocumentCaches?.(stripFromDoc);
|
||||
|
||||
setStatusMessage('Correspondent deleted.', 'success');
|
||||
return true;
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
const useDocumentCorrespondentActions = ({
|
||||
apiClient,
|
||||
correspondents,
|
||||
handleCorrespondentCreate,
|
||||
refreshCurrentFolder,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
}) => {
|
||||
const correspondentLookupByName = useMemo(() => {
|
||||
const map = new Map();
|
||||
correspondents.forEach((correspondent) => {
|
||||
if (correspondent?.name) {
|
||||
map.set(correspondent.name.toLowerCase(), correspondent);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [correspondents]);
|
||||
|
||||
const handleDocumentCorrespondentAttach = useCallback(
|
||||
async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
|
||||
if (!documentId || !correspondentId) {
|
||||
throw new Error('Missing document or correspondent.');
|
||||
}
|
||||
try {
|
||||
await apiClient.post(`/documents/${documentId}/correspondents`, {
|
||||
assignments: [{ correspondent_id: correspondentId }],
|
||||
replace: false,
|
||||
});
|
||||
if (refresh) {
|
||||
await refreshCurrentFolder();
|
||||
}
|
||||
if (notify) {
|
||||
setStatusMessage('Correspondent assigned.', 'success');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to assign correspondent.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[apiClient, refreshCurrentFolder, notifyApiError, setStatusMessage],
|
||||
);
|
||||
|
||||
const handleCorrespondentRemove = useCallback(
|
||||
async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
|
||||
if (!documentId || !correspondentId) {
|
||||
throw new Error('Missing document or correspondent.');
|
||||
}
|
||||
try {
|
||||
await apiClient.delete(`/documents/${documentId}/correspondents/${correspondentId}`);
|
||||
if (refresh) {
|
||||
await refreshCurrentFolder();
|
||||
}
|
||||
if (notify) {
|
||||
setStatusMessage('Correspondent removed.', 'success');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to remove correspondent.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[apiClient, refreshCurrentFolder, notifyApiError, setStatusMessage],
|
||||
);
|
||||
|
||||
const handleCorrespondentAdd = useCallback(
|
||||
async ({ document, name, input = null, option = null }) => {
|
||||
if (!document?.id) {
|
||||
throw new Error('Missing document for correspondent assignment.');
|
||||
}
|
||||
const trimmed = typeof name === 'string' ? name.trim() : '';
|
||||
if (!trimmed) {
|
||||
setStatusMessage('Correspondent name is required.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let target = null;
|
||||
if (option && option.id) {
|
||||
target = correspondentLookupByName.get(trimmed.toLowerCase()) || option;
|
||||
} else {
|
||||
target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
|
||||
}
|
||||
if (!target) {
|
||||
try {
|
||||
target = await handleCorrespondentCreate({ name: trimmed });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!target?.id) {
|
||||
setStatusMessage('Unable to resolve correspondent.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await handleDocumentCorrespondentAttach({
|
||||
documentId: document.id,
|
||||
correspondentId: target.id,
|
||||
});
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
} catch (error) {
|
||||
setStatusMessage('Failed to assign correspondent.', 'error');
|
||||
console.error('[documents] assign correspondent failed', error);
|
||||
}
|
||||
},
|
||||
[
|
||||
correspondentLookupByName,
|
||||
handleCorrespondentCreate,
|
||||
handleDocumentCorrespondentAttach,
|
||||
setStatusMessage,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
correspondentLookupByName,
|
||||
handleDocumentCorrespondentAttach,
|
||||
handleCorrespondentRemove,
|
||||
handleCorrespondentAdd,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocumentCorrespondentActions;
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { isPlainObject, isStringValue } from '../../utils/typeGuards';
|
||||
|
||||
type ApiClient = {
|
||||
post: (path: string, body?: unknown) => Promise<{ data: unknown }>;
|
||||
delete: (path: string) => Promise<{ data: unknown }>;
|
||||
};
|
||||
|
||||
interface CorrespondentOption {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface UseDocumentCorrespondentActionsArgs {
|
||||
apiClient: ApiClient;
|
||||
correspondents: CorrespondentOption[];
|
||||
handleCorrespondentCreate: (payload: { name: string }) => Promise<CorrespondentOption | null>;
|
||||
notifyApiError: (error: unknown, fallback: string) => void;
|
||||
setStatusMessage: (message: string, variant?: string) => void;
|
||||
updateDocumentCaches?: (
|
||||
id: Identifier,
|
||||
updater: (doc: { correspondents?: CorrespondentOption[] } | null) => { correspondents?: CorrespondentOption[] } | null,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const useDocumentCorrespondentActions = ({
|
||||
apiClient,
|
||||
correspondents,
|
||||
handleCorrespondentCreate,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
updateDocumentCaches,
|
||||
}: UseDocumentCorrespondentActionsArgs) => {
|
||||
const correspondentLookupByName = useMemo(() => {
|
||||
const map = new Map<string, CorrespondentOption>();
|
||||
correspondents.forEach((correspondent) => {
|
||||
if (correspondent?.name) {
|
||||
map.set(correspondent.name.toLowerCase(), correspondent);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [correspondents]);
|
||||
|
||||
const handleDocumentCorrespondentAttach = useCallback(
|
||||
async (
|
||||
{ documentId, correspondentId }: { documentId: Identifier; correspondentId: Identifier },
|
||||
{ notify = true }: { notify?: boolean } = {},
|
||||
) => {
|
||||
if (documentId == null || correspondentId == null) {
|
||||
throw new Error('Missing document or correspondent.');
|
||||
}
|
||||
try {
|
||||
await apiClient.post(`/documents/${documentId}/correspondents`, {
|
||||
assignments: [{ correspondent_id: correspondentId }],
|
||||
replace: false,
|
||||
});
|
||||
if (updateDocumentCaches) {
|
||||
const correspondent = correspondents.find((entry) => entry?.id === correspondentId) || null;
|
||||
updateDocumentCaches(documentId, (doc) => {
|
||||
if (!doc) {
|
||||
return doc;
|
||||
}
|
||||
const current = Array.isArray(doc.correspondents) ? doc.correspondents : [];
|
||||
if (current.some((entry) => entry?.id === correspondentId)) {
|
||||
return doc;
|
||||
}
|
||||
const nextEntry = correspondent
|
||||
? { id: correspondent.id, name: correspondent.name }
|
||||
: { id: correspondentId };
|
||||
return { ...doc, correspondents: [...current, nextEntry] };
|
||||
});
|
||||
}
|
||||
if (notify) {
|
||||
setStatusMessage('Correspondent assigned.', 'success');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to assign correspondent.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[apiClient, correspondents, notifyApiError, setStatusMessage, updateDocumentCaches],
|
||||
);
|
||||
|
||||
const handleCorrespondentRemove = useCallback(
|
||||
async (
|
||||
{ documentId, correspondentId }: { documentId: Identifier; correspondentId: Identifier },
|
||||
{ notify = true }: { notify?: boolean } = {},
|
||||
) => {
|
||||
if (documentId == null || correspondentId == null) {
|
||||
throw new Error('Missing document or correspondent.');
|
||||
}
|
||||
try {
|
||||
await apiClient.delete(`/documents/${documentId}/correspondents/${correspondentId}`);
|
||||
if (updateDocumentCaches) {
|
||||
updateDocumentCaches(documentId, (doc) => {
|
||||
if (!doc || !Array.isArray(doc.correspondents)) {
|
||||
return doc;
|
||||
}
|
||||
const filtered = doc.correspondents.filter((entry) => entry?.id !== correspondentId);
|
||||
return filtered.length === doc.correspondents.length ? doc : { ...doc, correspondents: filtered };
|
||||
});
|
||||
}
|
||||
if (notify) {
|
||||
setStatusMessage('Correspondent removed.', 'success');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to remove correspondent.';
|
||||
notifyApiError(error, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
[apiClient, notifyApiError, setStatusMessage, updateDocumentCaches],
|
||||
);
|
||||
|
||||
const normalizeOption = (
|
||||
option: CorrespondentOption | string | null,
|
||||
): CorrespondentOption | null => {
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
if (isPlainObject(option) && 'id' in option) {
|
||||
return option as CorrespondentOption;
|
||||
}
|
||||
if (isStringValue(option)) {
|
||||
const trimmed = option.trim();
|
||||
if (trimmed) {
|
||||
return { id: null, name: trimmed };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleCorrespondentAdd = useCallback(
|
||||
async ({ document, name, input = null, option = null }: { document?: { id?: string | number }; name?: string; input?: HTMLInputElement | null; option?: CorrespondentOption | string | null }) => {
|
||||
if (!document?.id) {
|
||||
throw new Error('Missing document for correspondent assignment.');
|
||||
}
|
||||
const trimmed = name?.trim?.() || '';
|
||||
if (!trimmed) {
|
||||
setStatusMessage('Correspondent name is required.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let target = correspondentLookupByName.get(trimmed.toLowerCase()) || normalizeOption(option);
|
||||
if (!target) {
|
||||
try {
|
||||
target = await handleCorrespondentCreate({ name: trimmed });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!target?.id) {
|
||||
setStatusMessage('Unable to resolve correspondent.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await handleDocumentCorrespondentAttach({
|
||||
documentId: document.id,
|
||||
correspondentId: target.id,
|
||||
});
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
} catch (error) {
|
||||
setStatusMessage('Failed to assign correspondent.', 'error');
|
||||
console.error('[documents] assign correspondent failed', error);
|
||||
}
|
||||
},
|
||||
[
|
||||
correspondentLookupByName,
|
||||
handleCorrespondentCreate,
|
||||
handleDocumentCorrespondentAttach,
|
||||
setStatusMessage,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
correspondentLookupByName,
|
||||
handleDocumentCorrespondentAttach,
|
||||
handleCorrespondentRemove,
|
||||
handleCorrespondentAdd,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocumentCorrespondentActions;
|
||||
+91
-35
@@ -1,4 +1,39 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { DragEvent } from 'react';
|
||||
import { isPlainObject, isFunctionValue } from '../../utils/typeGuards';
|
||||
|
||||
type Identifier = string | number;
|
||||
type FolderIdentifier = Identifier | 'root';
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier | null;
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type ApplySelectionFn = (
|
||||
keys: string[],
|
||||
options?: { anchor?: string | null; interactedKeys?: string[] },
|
||||
) => void;
|
||||
|
||||
type HandleEntrySelectionFn = (
|
||||
key: string,
|
||||
event: { preventDefault?: () => void },
|
||||
) => void;
|
||||
|
||||
interface UseDocumentDragHandlersOptions {
|
||||
selectedEntries: string[];
|
||||
selectedDocumentIds: Identifier[];
|
||||
selectedFolderIds: FolderIdentifier[];
|
||||
applySelection: ApplySelectionFn;
|
||||
handleEntrySelection: HandleEntrySelectionFn;
|
||||
documentLookup: Map<Identifier, DocumentLike>;
|
||||
setDraggedDocumentIds: (ids: Identifier[] | []) => void;
|
||||
setDraggedFolderId: (id: FolderIdentifier | null) => void;
|
||||
resolveDocumentRowKey: (id: Identifier) => string | null;
|
||||
resolveFolderRowKey: (id: FolderIdentifier) => string | null;
|
||||
documentsViewMode: string;
|
||||
}
|
||||
|
||||
const useDocumentDragHandlers = ({
|
||||
selectedEntries,
|
||||
@@ -12,8 +47,8 @@ const useDocumentDragHandlers = ({
|
||||
resolveDocumentRowKey,
|
||||
resolveFolderRowKey,
|
||||
documentsViewMode,
|
||||
}) => {
|
||||
const dragPreviewRef = useRef(null);
|
||||
}: UseDocumentDragHandlersOptions) => {
|
||||
const dragPreviewRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const destroyDragPreview = useCallback(() => {
|
||||
const node = dragPreviewRef.current;
|
||||
@@ -26,13 +61,9 @@ const useDocumentDragHandlers = ({
|
||||
useEffect(() => destroyDragPreview, [destroyDragPreview]);
|
||||
|
||||
const createDragPreview = useCallback(
|
||||
({ documents = [], folders = [] } = {}) => {
|
||||
({ documents = [], folders = [] }: { documents?: DocumentLike[]; folders?: Array<FolderIdentifier | Identifier> } = {}) => {
|
||||
destroyDragPreview();
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const docEntries = (documents || []).filter(Boolean);
|
||||
const folderEntries = (folders || []).filter(Boolean);
|
||||
const totalCount = docEntries.length + folderEntries.length;
|
||||
@@ -72,12 +103,18 @@ const useDocumentDragHandlers = ({
|
||||
if (item.type === 'document') {
|
||||
const doc = item.payload;
|
||||
const rowEl = doc?.id
|
||||
? document.getElementById(`document-row-${doc.id}`)
|
||||
|| document.getElementById(`document-card-${doc.id}`)
|
||||
? (document.getElementById(`document-row-${doc.id}`)
|
||||
|| document.getElementById(`document-card-${doc.id}`))
|
||||
: null;
|
||||
const wrapperEl = rowEl instanceof HTMLElement
|
||||
? rowEl.querySelector<HTMLElement>('.document-thumbnail-wrapper')
|
||||
: null;
|
||||
const thumbnailEl = rowEl instanceof HTMLElement
|
||||
? rowEl.querySelector<HTMLImageElement>('.document-thumbnail')
|
||||
: null;
|
||||
const placeholderEl = rowEl instanceof HTMLElement
|
||||
? rowEl.querySelector<HTMLElement>('.thumb-placeholder')
|
||||
: null;
|
||||
const wrapperEl = rowEl?.querySelector('.document-thumbnail-wrapper');
|
||||
const thumbnailEl = rowEl?.querySelector('.document-thumbnail');
|
||||
const placeholderEl = rowEl?.querySelector('.thumb-placeholder');
|
||||
const aspectAttr = wrapperEl?.dataset?.thumbnailAspect;
|
||||
const aspectRatio = aspectAttr ? parseFloat(aspectAttr) : null;
|
||||
|
||||
@@ -100,7 +137,7 @@ const useDocumentDragHandlers = ({
|
||||
layer.classList.add('document-drag-preview__item--image');
|
||||
layer.style.backgroundImage = `url("${thumbSrc}")`;
|
||||
} else if (placeholderEl instanceof HTMLElement) {
|
||||
const clone = placeholderEl.cloneNode(true);
|
||||
const clone = placeholderEl.cloneNode(true) as HTMLElement;
|
||||
clone.style.pointerEvents = 'none';
|
||||
layer.appendChild(clone);
|
||||
} else {
|
||||
@@ -108,27 +145,42 @@ const useDocumentDragHandlers = ({
|
||||
}
|
||||
} else {
|
||||
const payload = item.payload;
|
||||
const folderId = typeof payload === 'string' ? payload : payload?.id;
|
||||
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 rowEl = folderId
|
||||
? document.getElementById(`folder-row-${folderId}`)
|
||||
|| document.getElementById(`folder-card-${folderId}`)
|
||||
? (document.getElementById(`folder-row-${folderId}`)
|
||||
|| document.getElementById(`folder-card-${folderId}`))
|
||||
: null;
|
||||
const iconEl = rowEl instanceof HTMLElement
|
||||
? rowEl.querySelector('.thumb-icon, .folder-card__icon')
|
||||
: null;
|
||||
const iconEl = rowEl?.querySelector('.thumb-icon, .folder-card__icon');
|
||||
layer.style.width = `${size}px`;
|
||||
layer.style.height = `${size}px`;
|
||||
layer.classList.add('document-drag-preview__item--folder');
|
||||
|
||||
let content = null;
|
||||
let content: HTMLElement | null = null;
|
||||
if (iconEl instanceof HTMLElement) {
|
||||
const cloneSource = iconEl.classList.contains('folder-card__icon')
|
||||
? iconEl.querySelector('svg') || iconEl
|
||||
: iconEl;
|
||||
content = cloneSource.cloneNode(true);
|
||||
content.classList.add('document-drag-preview__folder-thumb');
|
||||
const svg = content.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.setAttribute('width', '48');
|
||||
svg.setAttribute('height', '48');
|
||||
const clone = cloneSource.cloneNode(true);
|
||||
if (clone instanceof HTMLElement) {
|
||||
content = clone;
|
||||
content.classList.add('document-drag-preview__folder-thumb');
|
||||
const svg = content.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.setAttribute('width', '48');
|
||||
svg.setAttribute('height', '48');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,8 +211,10 @@ const useDocumentDragHandlers = ({
|
||||
);
|
||||
|
||||
const handleDocumentDragStart = useCallback(
|
||||
(event, documentOrId) => {
|
||||
const documentId = typeof documentOrId === 'string' ? documentOrId : documentOrId?.id;
|
||||
(event: DragEvent<HTMLElement>, documentOrId: DocumentLike | Identifier | null) => {
|
||||
const documentId: Identifier | null = Object(documentOrId) === documentOrId
|
||||
? (documentOrId as DocumentLike)?.id ?? null
|
||||
: (documentOrId as Identifier | null);
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
@@ -172,12 +226,12 @@ const useDocumentDragHandlers = ({
|
||||
|
||||
const isGridView = documentsViewMode === 'grid';
|
||||
const isAlreadySelected = selectedDocumentIds.includes(documentId);
|
||||
const selection = isAlreadySelected
|
||||
const selection: Identifier[] = isAlreadySelected
|
||||
? [...selectedDocumentIds]
|
||||
: isGridView
|
||||
? [...selectedDocumentIds, documentId]
|
||||
: [documentId];
|
||||
const folderSelection = [];
|
||||
const folderSelection: FolderIdentifier[] = [];
|
||||
|
||||
if (!isAlreadySelected && !isGridView) {
|
||||
applySelection([documentKey], {
|
||||
@@ -186,7 +240,9 @@ const useDocumentDragHandlers = ({
|
||||
});
|
||||
}
|
||||
|
||||
const previewDocs = selection.map((id) => documentLookup.get(id) || null).filter(Boolean);
|
||||
const previewDocs = selection
|
||||
.map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null)
|
||||
.filter(Boolean);
|
||||
const previewNode = createDragPreview({
|
||||
documents: previewDocs,
|
||||
folders: folderSelection,
|
||||
@@ -234,7 +290,7 @@ const useDocumentDragHandlers = ({
|
||||
);
|
||||
|
||||
const handleDocumentDragEnd = useCallback(
|
||||
(event) => {
|
||||
(event: DragEvent<HTMLElement>) => {
|
||||
setDraggedDocumentIds([]);
|
||||
event.currentTarget.classList.remove('dragging');
|
||||
destroyDragPreview();
|
||||
@@ -244,7 +300,7 @@ const useDocumentDragHandlers = ({
|
||||
);
|
||||
|
||||
const handleFolderDragStart = useCallback(
|
||||
(event, folderId) => {
|
||||
(event: DragEvent<HTMLElement>, folderId: FolderIdentifier) => {
|
||||
if (folderId === 'root') {
|
||||
return;
|
||||
}
|
||||
@@ -252,8 +308,8 @@ const useDocumentDragHandlers = ({
|
||||
const folderKey = resolveFolderRowKey(folderId);
|
||||
const isAlreadySelected = folderKey ? selectedEntries.includes(folderKey) : false;
|
||||
|
||||
let effectiveFolderSelection = selectedFolderIds;
|
||||
let effectiveDocumentSelection = selectedDocumentIds;
|
||||
let effectiveFolderSelection: FolderIdentifier[] = selectedFolderIds;
|
||||
let effectiveDocumentSelection: Identifier[] = selectedDocumentIds;
|
||||
|
||||
if (!isAlreadySelected && folderKey) {
|
||||
effectiveFolderSelection = [folderId];
|
||||
@@ -291,7 +347,7 @@ const useDocumentDragHandlers = ({
|
||||
|
||||
const previewNode = createDragPreview({
|
||||
documents: effectiveDocumentSelection
|
||||
.map((id) => documentLookup.get(id) || null)
|
||||
.map((id) => documentLookup.get(id) || documentLookup.get(String(id)) || null)
|
||||
.filter(Boolean),
|
||||
folders: uniqueFolders,
|
||||
});
|
||||
@@ -317,7 +373,7 @@ const useDocumentDragHandlers = ({
|
||||
);
|
||||
|
||||
const handleFolderDragEnd = useCallback(
|
||||
(event) => {
|
||||
(event?: DragEvent<HTMLElement>) => {
|
||||
if (event?.currentTarget) {
|
||||
event.currentTarget.classList.remove('dragging');
|
||||
}
|
||||
+355
-172
@@ -1,12 +1,183 @@
|
||||
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';
|
||||
|
||||
const normalizeDocumentId = (value) => {
|
||||
type DocumentId = string | number;
|
||||
type FolderId = DocumentId | 'root';
|
||||
type NullableFolderId = FolderId | null;
|
||||
|
||||
type StatusLevel = 'success' | 'error' | 'info' | string;
|
||||
|
||||
type DocumentCacheMapper = (
|
||||
doc: DocumentLike | null,
|
||||
) => DocumentLike | null;
|
||||
|
||||
type MapDocumentCaches = (mapper: DocumentCacheMapper) => void;
|
||||
|
||||
type UpdateDocumentCaches = (
|
||||
documentId: DocumentId,
|
||||
updater: DocumentCacheMapper,
|
||||
) => void;
|
||||
|
||||
type EnsureFolderData = (
|
||||
folderId: FolderId,
|
||||
options?: { force?: boolean; includeDocuments?: boolean; prefetchDepth?: number },
|
||||
) => Promise<FolderContents>;
|
||||
|
||||
type ApplySelectedFolder = (folderId: FolderId, contents?: FolderContents | null) => void;
|
||||
|
||||
type RemoveDocumentsFromCaches = (documentIds: DocumentId[]) => void;
|
||||
|
||||
type CloseDocumentPreview = () => void;
|
||||
|
||||
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;
|
||||
color?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DocumentLike {
|
||||
id?: DocumentId;
|
||||
folder_id?: NullableFolderId;
|
||||
folder_path?: string | null;
|
||||
folder_name?: string | null;
|
||||
issued_at?: number | null;
|
||||
title?: string;
|
||||
tags?: Tag[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderContents {
|
||||
documents?: DocumentLike[];
|
||||
subfolders?: Array<{ id?: FolderId; [key: string]: unknown }>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderNode {
|
||||
id: FolderId;
|
||||
parentId?: FolderId;
|
||||
children: FolderId[];
|
||||
hasChildren?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TagManager {
|
||||
normalizeLabel: (label: string) => string;
|
||||
buildPayload: (args: { label: string }) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DocumentTagExtras {
|
||||
option?: Tag | null;
|
||||
input?: { value?: string } | null;
|
||||
}
|
||||
|
||||
interface DeleteOptions {
|
||||
showMessage?: boolean;
|
||||
manageLoading?: boolean;
|
||||
}
|
||||
|
||||
interface TagAttachArgs {
|
||||
documentId?: DocumentId;
|
||||
tagId?: DocumentId;
|
||||
tag?: Tag | null;
|
||||
}
|
||||
|
||||
interface TagRemoveOptions {
|
||||
refreshTagList?: boolean;
|
||||
showMessage?: boolean;
|
||||
}
|
||||
|
||||
interface FolderDeleteOptions {
|
||||
showMessage?: boolean;
|
||||
manageLoading?: boolean;
|
||||
}
|
||||
|
||||
interface UseDocumentMutationsArgs {
|
||||
api: ApiClient;
|
||||
token?: string | null;
|
||||
documentLookup: Map<DocumentId, DocumentLike>;
|
||||
folderLabelMap: Map<FolderId, string>;
|
||||
ensureFolderData: EnsureFolderData;
|
||||
selectedFolder: FolderId;
|
||||
setSelectedFolder: Dispatch<SetStateAction<FolderId>>;
|
||||
setDocuments: Dispatch<SetStateAction<DocumentLike[]>>;
|
||||
setFolderContents: Dispatch<SetStateAction<Map<FolderId, FolderContents>>>;
|
||||
setSearchResultIds: Dispatch<SetStateAction<DocumentId[] | null>>;
|
||||
setSelectedEntries: Dispatch<SetStateAction<string[]>>;
|
||||
setSelectionOrder: Dispatch<SetStateAction<string[]>>;
|
||||
selectionOrderRef: MutableRefObject<string[] | null>;
|
||||
selectionAnchorRef: MutableRefObject<string | null>;
|
||||
setFocusedDocumentId: Dispatch<SetStateAction<DocumentId | null>>;
|
||||
focusedDocumentId: DocumentId | null;
|
||||
setFocusedRowKey: Dispatch<SetStateAction<string | null>>;
|
||||
focusedRowKey: string | null;
|
||||
notifyApiError: NotifyApiError;
|
||||
setStatusMessage: SetStatusMessage;
|
||||
setLoading: (next: boolean) => void;
|
||||
mapDocumentCaches: MapDocumentCaches;
|
||||
applySelectedFolder: ApplySelectedFolder;
|
||||
folderNodes: Map<FolderId, FolderNode>;
|
||||
setFolderNodes: Dispatch<SetStateAction<Map<FolderId, FolderNode>>>;
|
||||
removeDocumentsFromCaches: RemoveDocumentsFromCaches;
|
||||
closeDocumentPreview: CloseDocumentPreview;
|
||||
previewDocumentId?: DocumentId | null;
|
||||
refreshCurrentFolder: () => Promise<void>;
|
||||
updateDocumentCaches: UpdateDocumentCaches;
|
||||
tagLookupById: Map<DocumentId, Tag>;
|
||||
tags: Tag[];
|
||||
refreshTags: () => Promise<void>;
|
||||
tagManager: TagManager;
|
||||
extractDocumentFromResponse?: (payload: unknown) => DocumentLike | null;
|
||||
ingestDocuments?: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean };
|
||||
}
|
||||
|
||||
interface UseDocumentMutationsResult {
|
||||
moveDocumentsToFolder: (
|
||||
documentIds: Array<DocumentId | DocumentLike>,
|
||||
targetFolderId?: NullableFolderId,
|
||||
) => Promise<void>;
|
||||
handleThumbnailRegeneration: (documentId: DocumentId) => Promise<void>;
|
||||
handleDocumentsDelete: (
|
||||
documentIds: DocumentId[],
|
||||
options?: DeleteOptions,
|
||||
) => Promise<boolean>;
|
||||
handleDocumentTagAdd: (
|
||||
document: DocumentLike,
|
||||
label: string,
|
||||
extras?: DocumentTagExtras | null,
|
||||
) => Promise<void>;
|
||||
handleDocumentTagAttach: (args: TagAttachArgs) => Promise<boolean>;
|
||||
handleDocumentTitleUpdate: (documentId: DocumentId, nextTitle: string) => Promise<boolean>;
|
||||
handleDocumentIssuedUpdate: (
|
||||
documentId: DocumentId,
|
||||
nextIssuedDate: number | null,
|
||||
) => Promise<boolean>;
|
||||
handleTagRemove: (
|
||||
documentId?: DocumentId,
|
||||
tagId?: DocumentId,
|
||||
options?: TagRemoveOptions,
|
||||
) => Promise<boolean>;
|
||||
handleFolderDelete: (folderId?: FolderId, options?: FolderDeleteOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const normalizeDocumentId = (value: unknown): DocumentId | null => {
|
||||
if (!value) return null;
|
||||
if (typeof value === 'object' && value.id) {
|
||||
return value.id;
|
||||
if (isPlainObject(value) && 'id' in value && value.id != null) {
|
||||
return value.id as DocumentId;
|
||||
}
|
||||
return value;
|
||||
return value as DocumentId;
|
||||
};
|
||||
|
||||
const useDocumentMutations = ({
|
||||
@@ -19,7 +190,7 @@ const useDocumentMutations = ({
|
||||
setSelectedFolder,
|
||||
setDocuments,
|
||||
setFolderContents,
|
||||
setSearchResults,
|
||||
setSearchResultIds,
|
||||
setSelectedEntries,
|
||||
setSelectionOrder,
|
||||
selectionOrderRef,
|
||||
@@ -39,43 +210,41 @@ const useDocumentMutations = ({
|
||||
closeDocumentPreview,
|
||||
previewDocumentId,
|
||||
refreshCurrentFolder,
|
||||
documentsViewMode,
|
||||
updateDocumentCaches,
|
||||
tagLookupById,
|
||||
tags,
|
||||
refreshTags,
|
||||
tagManager,
|
||||
extractDocumentFromResponse,
|
||||
}) => {
|
||||
ingestDocuments,
|
||||
}: UseDocumentMutationsArgs): UseDocumentMutationsResult => {
|
||||
const moveDocumentsToFolder = useCallback(
|
||||
async (documentIds, targetFolderId) => {
|
||||
async (documentIds: Array<DocumentId | DocumentLike>, targetFolderId?: NullableFolderId) => {
|
||||
const uniqueIds = Array.from(
|
||||
new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean)),
|
||||
new Set((documentIds || []).map((value) => normalizeDocumentId(value)).filter(Boolean) as DocumentId[]),
|
||||
);
|
||||
if (!uniqueIds.length) return;
|
||||
|
||||
const uniqueIdSet = new Set(uniqueIds);
|
||||
const target = targetFolderId === 'root' ? null : targetFolderId;
|
||||
const target = targetFolderId === 'root' ? null : targetFolderId ?? null;
|
||||
const targetLabel =
|
||||
target === null
|
||||
? DEFAULT_FOLDER_NAME
|
||||
: folderLabelMap.get(targetFolderId) || 'target folder';
|
||||
target === null ? DEFAULT_FOLDER_NAME : folderLabelMap.get(targetFolderId as FolderId) || 'target folder';
|
||||
|
||||
const movedDocs = uniqueIds
|
||||
.map((id) => {
|
||||
const doc = documentLookup.get(id);
|
||||
const doc = documentLookup.get(id) || null;
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
sourceFolderId: doc.folder_id ?? null,
|
||||
sourceFolderId: (doc.folder_id ?? null) as NullableFolderId,
|
||||
document: doc,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
.filter(Boolean) as Array<{ id: DocumentId; sourceFolderId: NullableFolderId; document: DocumentLike }>;
|
||||
|
||||
const updatedDocsMap = new Map();
|
||||
const updatedDocsMap = new Map<DocumentId, DocumentLike>();
|
||||
const resolveTargetName = () => {
|
||||
if (!targetLabel) {
|
||||
return null;
|
||||
@@ -89,7 +258,7 @@ const useDocumentMutations = ({
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
const updated = {
|
||||
const updated: DocumentLike = {
|
||||
...document,
|
||||
folder_id: target,
|
||||
};
|
||||
@@ -105,13 +274,13 @@ const useDocumentMutations = ({
|
||||
updatedDocsMap.set(id, updated);
|
||||
});
|
||||
|
||||
const pruneRow = (collection) =>
|
||||
const pruneRow = (collection: string[]): string[] =>
|
||||
collection.filter((key) => {
|
||||
if (!isDocumentRowKey(key)) {
|
||||
return true;
|
||||
}
|
||||
const id = getRowId(key);
|
||||
return id ? !uniqueIdSet.has(id) : true;
|
||||
return id ? !uniqueIdSet.has(id as DocumentId) : true;
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
@@ -131,10 +300,10 @@ const useDocumentMutations = ({
|
||||
|
||||
if (updatedDocsMap.size) {
|
||||
mapDocumentCaches((doc) => {
|
||||
if (!doc || !uniqueIdSet.has(doc.id)) {
|
||||
if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
|
||||
return doc;
|
||||
}
|
||||
const updated = updatedDocsMap.get(doc.id);
|
||||
const updated = updatedDocsMap.get(doc.id as DocumentId);
|
||||
if (updated) {
|
||||
return updated;
|
||||
}
|
||||
@@ -142,7 +311,7 @@ const useDocumentMutations = ({
|
||||
});
|
||||
} else {
|
||||
mapDocumentCaches((doc) => {
|
||||
if (!doc || !uniqueIdSet.has(doc.id)) {
|
||||
if (!doc || !uniqueIdSet.has(doc.id as DocumentId)) {
|
||||
return doc;
|
||||
}
|
||||
return { ...doc, folder_id: target };
|
||||
@@ -150,22 +319,22 @@ const useDocumentMutations = ({
|
||||
}
|
||||
|
||||
if (uniqueIdSet.size) {
|
||||
setSearchResults((prev) => {
|
||||
setSearchResultIds((prev) => {
|
||||
if (!Array.isArray(prev) || !prev.length) {
|
||||
return prev;
|
||||
}
|
||||
const filtered = prev.filter((doc) => doc && !uniqueIdSet.has(doc.id));
|
||||
const filtered = prev.filter((id) => !uniqueIdSet.has(id as DocumentId));
|
||||
return filtered.length === prev.length ? prev : filtered;
|
||||
});
|
||||
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id)));
|
||||
setFolderContents((prev) => {
|
||||
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId)));
|
||||
setFolderContents((prev: Map<FolderId, FolderContents>) => {
|
||||
if (!prev.size) {
|
||||
return prev;
|
||||
}
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
const next = new Map<FolderId, FolderContents>(prev);
|
||||
movedDocs.forEach(({ id, sourceFolderId }) => {
|
||||
const sourceKey = sourceFolderId || 'root';
|
||||
const sourceKey = (sourceFolderId || 'root') as FolderId;
|
||||
const entry = next.get(sourceKey);
|
||||
if (!entry?.documents?.length) {
|
||||
return;
|
||||
@@ -179,13 +348,14 @@ const useDocumentMutations = ({
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
setSelectedEntries((prev) => pruneRow(prev, uniqueIdSet));
|
||||
setSelectionOrder((prev) => pruneRow(prev, uniqueIdSet));
|
||||
selectionOrderRef.current = pruneRow(selectionOrderRef.current || [], uniqueIdSet);
|
||||
setSelectedEntries((prev) => pruneRow(prev));
|
||||
setSelectionOrder((prev) => pruneRow(prev));
|
||||
const nextSelectionOrder = pruneRow(selectionOrderRef.current || []);
|
||||
selectionOrderRef.current = nextSelectionOrder;
|
||||
if (
|
||||
selectionAnchorRef.current &&
|
||||
isDocumentRowKey(selectionAnchorRef.current) &&
|
||||
uniqueIdSet.has(getRowId(selectionAnchorRef.current))
|
||||
uniqueIdSet.has(getRowId(selectionAnchorRef.current) as DocumentId)
|
||||
) {
|
||||
selectionAnchorRef.current = null;
|
||||
}
|
||||
@@ -195,17 +365,17 @@ const useDocumentMutations = ({
|
||||
if (
|
||||
focusedRowKey &&
|
||||
isDocumentRowKey(focusedRowKey) &&
|
||||
uniqueIdSet.has(getRowId(focusedRowKey))
|
||||
uniqueIdSet.has(getRowId(focusedRowKey) as DocumentId)
|
||||
) {
|
||||
setFocusedRowKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetFolderId && targetFolderId !== selectedFolder) {
|
||||
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
|
||||
await ensureFolderData(targetFolderId as FolderId, { force: true, prefetchDepth: 1 });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to move documents.';
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to move documents.';
|
||||
notifyApiError(error, message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -217,7 +387,7 @@ const useDocumentMutations = ({
|
||||
folderLabelMap,
|
||||
ensureFolderData,
|
||||
selectedFolder,
|
||||
setSearchResults,
|
||||
setSearchResultIds,
|
||||
setDocuments,
|
||||
setFolderContents,
|
||||
setSelectedEntries,
|
||||
@@ -236,7 +406,7 @@ const useDocumentMutations = ({
|
||||
);
|
||||
|
||||
const handleThumbnailRegeneration = useCallback(
|
||||
async (documentId) => {
|
||||
async (documentId: DocumentId) => {
|
||||
if (!token) {
|
||||
setStatusMessage('Log in to manage assets.', 'error');
|
||||
return;
|
||||
@@ -249,7 +419,7 @@ const useDocumentMutations = ({
|
||||
setStatusMessage('Document re-analysis queued.', 'info');
|
||||
await refreshCurrentFolder();
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to request thumbnail generation.';
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to request thumbnail generation.';
|
||||
notifyApiError(error, message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -259,7 +429,7 @@ const useDocumentMutations = ({
|
||||
);
|
||||
|
||||
const handleDocumentsDelete = useCallback(
|
||||
async (documentIds, { showMessage = true, manageLoading = true } = {}) => {
|
||||
async (documentIds: DocumentId[], { showMessage = true, manageLoading = true }: DeleteOptions = {}) => {
|
||||
if (!documentIds || documentIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -274,43 +444,11 @@ const useDocumentMutations = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const softDeleteTargets = [];
|
||||
const hardDeleteTargets = [];
|
||||
|
||||
documentIds.forEach((documentId) => {
|
||||
const lookupDoc =
|
||||
documentLookup && typeof documentLookup.get === 'function'
|
||||
? documentLookup.get(documentId)
|
||||
: documentLookup?.[documentId];
|
||||
|
||||
if (lookupDoc && lookupDoc.deleted_at) {
|
||||
hardDeleteTargets.push(documentId);
|
||||
} else {
|
||||
softDeleteTargets.push(documentId);
|
||||
}
|
||||
});
|
||||
|
||||
const operations = [];
|
||||
if (softDeleteTargets.length) {
|
||||
operations.push(
|
||||
Promise.all(
|
||||
softDeleteTargets.map((documentId) => api.post(`/documents/${documentId}/trash`)),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (hardDeleteTargets.length) {
|
||||
operations.push(
|
||||
Promise.all(
|
||||
hardDeleteTargets.map((documentId) => api.delete(`/documents/${documentId}`)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(operations);
|
||||
await Promise.all(documentIds.map((documentId) => api.post(`/documents/${documentId}/trash`)));
|
||||
|
||||
removeDocumentsFromCaches(documentIds);
|
||||
|
||||
if (documentIds.includes(previewDocumentId)) {
|
||||
if (previewDocumentId && documentIds.includes(previewDocumentId)) {
|
||||
closeDocumentPreview();
|
||||
}
|
||||
|
||||
@@ -320,7 +458,7 @@ const useDocumentMutations = ({
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to delete documents.';
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete documents.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
} finally {
|
||||
@@ -343,8 +481,8 @@ const useDocumentMutations = ({
|
||||
);
|
||||
|
||||
const handleDocumentTitleUpdate = useCallback(
|
||||
async (documentId, nextTitle) => {
|
||||
const trimmed = typeof nextTitle === 'string' ? nextTitle.trim() : '';
|
||||
async (documentId: DocumentId, nextTitle: string) => {
|
||||
const trimmed = nextTitle?.trim?.() || '';
|
||||
if (!trimmed) {
|
||||
setStatusMessage('Document title cannot be empty.', 'error');
|
||||
return false;
|
||||
@@ -355,159 +493,200 @@ const useDocumentMutations = ({
|
||||
const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed });
|
||||
const updatedDocument = extractDocumentFromResponse?.(data);
|
||||
|
||||
updateDocumentCaches(documentId, (doc) => {
|
||||
if (updatedDocument) {
|
||||
return { ...doc, ...updatedDocument };
|
||||
}
|
||||
return { ...doc, title: trimmed };
|
||||
});
|
||||
if (updatedDocument && ingestDocuments) {
|
||||
ingestDocuments([updatedDocument]);
|
||||
} else {
|
||||
updateDocumentCaches(documentId, (doc) => {
|
||||
if (updatedDocument) {
|
||||
return { ...doc, ...updatedDocument };
|
||||
}
|
||||
return { ...doc, title: trimmed };
|
||||
});
|
||||
}
|
||||
|
||||
setStatusMessage('Document title updated.', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to update document title.';
|
||||
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, notifyApiError, setLoading, setStatusMessage, updateDocumentCaches],
|
||||
[
|
||||
api,
|
||||
extractDocumentFromResponse,
|
||||
ingestDocuments,
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
setStatusMessage,
|
||||
updateDocumentCaches,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDocumentIssuedUpdate = useCallback(
|
||||
async (documentId, nextIssuedDate) => {
|
||||
async (documentId: DocumentId, nextIssuedDate: number | null) => {
|
||||
setLoading(true);
|
||||
const payload = { issued_at: nextIssuedDate || null };
|
||||
try {
|
||||
const { data } = await api.patch(`/documents/${documentId}`, payload);
|
||||
const updatedDocument = extractDocumentFromResponse?.(data);
|
||||
|
||||
updateDocumentCaches(documentId, (doc) => {
|
||||
if (updatedDocument) {
|
||||
return { ...doc, ...updatedDocument };
|
||||
}
|
||||
return { ...doc, issued_at: payload.issued_at };
|
||||
});
|
||||
if (updatedDocument && ingestDocuments) {
|
||||
ingestDocuments([updatedDocument]);
|
||||
} else {
|
||||
updateDocumentCaches(documentId, (doc) => {
|
||||
if (updatedDocument) {
|
||||
return { ...doc, ...updatedDocument };
|
||||
}
|
||||
return { ...doc, issued_at: payload.issued_at };
|
||||
});
|
||||
}
|
||||
|
||||
const message = payload.issued_at ? 'Issued date updated.' : 'Issued date cleared.';
|
||||
setStatusMessage(message, 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to update issued date.';
|
||||
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, notifyApiError, setLoading, setStatusMessage, updateDocumentCaches],
|
||||
[
|
||||
api,
|
||||
extractDocumentFromResponse,
|
||||
ingestDocuments,
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
setStatusMessage,
|
||||
updateDocumentCaches,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDocumentTagAdd = useCallback(
|
||||
async (document, label, extras = null) => {
|
||||
const normalizedLabel = tagManager.normalizeLabel(label);
|
||||
const optionCandidate =
|
||||
extras && typeof extras === 'object' && 'option' in extras ? extras.option : null;
|
||||
const input =
|
||||
extras && typeof extras === 'object' && 'input' in extras ? extras.input : null;
|
||||
|
||||
let tag = null;
|
||||
if (optionCandidate && optionCandidate.id) {
|
||||
tag = tags.find((item) => item.id === optionCandidate.id) || optionCandidate;
|
||||
}
|
||||
if (!tag) {
|
||||
tag =
|
||||
tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
|
||||
}
|
||||
try {
|
||||
if (!tag) {
|
||||
const payload = tagManager.buildPayload({ label: normalizedLabel });
|
||||
const { data } = await api.post('/tags', payload);
|
||||
tag = data;
|
||||
await refreshTags();
|
||||
}
|
||||
await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] });
|
||||
setStatusMessage('Tag assigned.', 'success');
|
||||
if (input && typeof input === 'object') {
|
||||
input.value = '';
|
||||
}
|
||||
await refreshCurrentFolder();
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Failed to assign tag.');
|
||||
}
|
||||
},
|
||||
[api, tags, refreshTags, refreshCurrentFolder, notifyApiError, setStatusMessage, tagManager],
|
||||
);
|
||||
|
||||
const handleDocumentTagAttach = useCallback(
|
||||
async ({ documentId, tagId, tag: tagData = null }) => {
|
||||
if (!documentId || !tagId) {
|
||||
const attachTagToDocument = useCallback(
|
||||
async ({
|
||||
documentId,
|
||||
tag,
|
||||
}: {
|
||||
documentId?: DocumentId;
|
||||
tag?: Tag | null;
|
||||
}) => {
|
||||
if (!documentId || !tag?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolveTagForCache = () => {
|
||||
const lookupTag = tagLookupById.get(tagId);
|
||||
const source = lookupTag ?? tagData;
|
||||
if (!source || source.id == null || typeof source.label !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: source.id,
|
||||
label: source.label,
|
||||
color: Object.prototype.hasOwnProperty.call(source, 'color') ? source.color : null,
|
||||
};
|
||||
const cachedTag: Tag = {
|
||||
id: tag.id,
|
||||
label: tag.label,
|
||||
color: Object.prototype.hasOwnProperty.call(tag, 'color') ? tag.color ?? null : null,
|
||||
};
|
||||
|
||||
try {
|
||||
await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] });
|
||||
await api.post(`/documents/${documentId}/tags`, { tag_ids: [cachedTag.id] });
|
||||
updateDocumentCaches(documentId, (doc) => {
|
||||
if (!doc) {
|
||||
return doc;
|
||||
}
|
||||
const currentTags = Array.isArray(doc.tags) ? doc.tags : [];
|
||||
if (currentTags.some((existing) => existing?.id === tagId)) {
|
||||
if (currentTags.some((entry) => entry?.id === cachedTag.id)) {
|
||||
return doc;
|
||||
}
|
||||
const resolvedTag = resolveTagForCache();
|
||||
if (!resolvedTag) {
|
||||
return doc;
|
||||
}
|
||||
return { ...doc, tags: [...currentTags, resolvedTag] };
|
||||
return { ...doc, tags: [...currentTags, cachedTag] };
|
||||
});
|
||||
setStatusMessage('Tag assigned.', 'success');
|
||||
if (documentsViewMode !== 'desk') {
|
||||
await refreshCurrentFolder();
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to assign tag.';
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to assign tag.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[api, notifyApiError, setStatusMessage, updateDocumentCaches],
|
||||
);
|
||||
|
||||
const handleDocumentTagAdd = useCallback(
|
||||
async (document: DocumentLike, label: string, extras: DocumentTagExtras | null = null) => {
|
||||
const normalizedLabel = tagManager.normalizeLabel(label);
|
||||
const optionCandidate = extras?.option ?? null;
|
||||
const input = extras?.input ?? null;
|
||||
|
||||
let tag: Tag | null = null;
|
||||
if (optionCandidate && optionCandidate.id) {
|
||||
tag = tags.find((item) => item.id === optionCandidate.id) || optionCandidate;
|
||||
}
|
||||
if (!tag) {
|
||||
tag = tags.find((item) => item.label.toLowerCase() === normalizedLabel.toLowerCase()) || null;
|
||||
}
|
||||
try {
|
||||
if (!tag) {
|
||||
const payload = tagManager.buildPayload({ label: normalizedLabel });
|
||||
const { data } = await api.post('/tags', payload);
|
||||
tag = data as Tag;
|
||||
await refreshTags();
|
||||
}
|
||||
await attachTagToDocument({
|
||||
documentId: document.id as DocumentId,
|
||||
tag,
|
||||
});
|
||||
if (input && Object(input) === input && 'value' in (input as Record<string, unknown>)) {
|
||||
(input as { value?: string }).value = '';
|
||||
}
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Failed to assign tag.');
|
||||
}
|
||||
},
|
||||
[api, tags, refreshTags, attachTagToDocument, notifyApiError, tagManager],
|
||||
);
|
||||
|
||||
const handleDocumentTagAttach = useCallback(
|
||||
async ({ documentId, tagId, tag: tagData = null }: TagAttachArgs) => {
|
||||
if (!documentId || !tagId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolveTagForCache = (): Tag | null => {
|
||||
const lookupTag = tagLookupById.get(tagId);
|
||||
const source = lookupTag ?? tagData;
|
||||
if (!source || source.id == null) {
|
||||
return null;
|
||||
}
|
||||
const labelText = `${source.label ?? ''}`.trim();
|
||||
if (!labelText) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: source.id,
|
||||
label: labelText,
|
||||
color: Object.prototype.hasOwnProperty.call(source, 'color') ? (source as Tag).color ?? null : null,
|
||||
};
|
||||
};
|
||||
|
||||
const resolvedTag = resolveTagForCache();
|
||||
return attachTagToDocument({
|
||||
documentId,
|
||||
tag: resolvedTag,
|
||||
});
|
||||
},
|
||||
[
|
||||
api,
|
||||
refreshCurrentFolder,
|
||||
documentsViewMode,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
updateDocumentCaches,
|
||||
attachTagToDocument,
|
||||
tagLookupById,
|
||||
],
|
||||
);
|
||||
|
||||
const applyTagRemovalToCaches = useCallback(
|
||||
(documentId, tagId) => {
|
||||
(documentId?: DocumentId, tagId?: DocumentId) => {
|
||||
if (!documentId || !tagId) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDocumentCaches(documentId, (doc) => {
|
||||
if (!Array.isArray(doc.tags)) {
|
||||
if (!doc || !Array.isArray(doc.tags)) {
|
||||
return doc;
|
||||
}
|
||||
const nextTags = doc.tags.filter((tag) => tag.id !== tagId);
|
||||
const nextTags = doc.tags.filter((tagEntry) => tagEntry.id !== tagId);
|
||||
if (nextTags.length === doc.tags.length) {
|
||||
return doc;
|
||||
}
|
||||
@@ -518,7 +697,11 @@ const useDocumentMutations = ({
|
||||
);
|
||||
|
||||
const handleTagRemove = useCallback(
|
||||
async (documentId, tagId, { refreshTagList = true, showMessage = true } = {}) => {
|
||||
async (
|
||||
documentId?: DocumentId,
|
||||
tagId?: DocumentId,
|
||||
{ refreshTagList = true, showMessage = true }: TagRemoveOptions = {},
|
||||
) => {
|
||||
if (!documentId || !tagId) {
|
||||
return false;
|
||||
}
|
||||
@@ -534,7 +717,7 @@ const useDocumentMutations = ({
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to remove tag.';
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to remove tag.';
|
||||
notifyApiError(error, message);
|
||||
return false;
|
||||
}
|
||||
@@ -543,7 +726,7 @@ const useDocumentMutations = ({
|
||||
);
|
||||
|
||||
const handleFolderDelete = useCallback(
|
||||
async (folderId, { showMessage = true, manageLoading = true } = {}) => {
|
||||
async (folderId?: FolderId, { showMessage = true, manageLoading = true }: FolderDeleteOptions = {}) => {
|
||||
if (!token) {
|
||||
if (showMessage) {
|
||||
setStatusMessage('Log in to manage folders.', 'error');
|
||||
@@ -577,8 +760,8 @@ const useDocumentMutations = ({
|
||||
|
||||
await api.delete(`/folders/${folderId}`);
|
||||
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
setFolderNodes((prev: Map<FolderId, FolderNode>) => {
|
||||
const next = new Map<FolderId, FolderNode>(prev);
|
||||
const node = next.get(folderId);
|
||||
next.delete(folderId);
|
||||
if (node) {
|
||||
@@ -596,8 +779,8 @@ const useDocumentMutations = ({
|
||||
return next;
|
||||
});
|
||||
|
||||
setFolderContents((prev) => {
|
||||
const next = new Map(prev);
|
||||
setFolderContents((prev: Map<FolderId, FolderContents>) => {
|
||||
const next = new Map<FolderId, FolderContents>(prev);
|
||||
next.delete(folderId);
|
||||
return next;
|
||||
});
|
||||
@@ -620,7 +803,7 @@ const useDocumentMutations = ({
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to delete folder.';
|
||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to delete folder.';
|
||||
notifyApiError(error, message);
|
||||
if (showMessage) {
|
||||
setStatusMessage(message, 'error');
|
||||
+130
-30
@@ -1,18 +1,60 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
type Identifier = string | number;
|
||||
|
||||
interface TagRecord {
|
||||
id?: Identifier;
|
||||
label: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ApiClient {
|
||||
post: <T = { data: unknown }>(path: string, payload: unknown) => Promise<{ data: T } | T>;
|
||||
}
|
||||
|
||||
interface TagManager {
|
||||
buildPayload: (input: { label: string }) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface UseDocumentTaggingArgs {
|
||||
apiClient: ApiClient;
|
||||
tags: TagRecord[];
|
||||
tagManager: TagManager;
|
||||
refreshTags: () => Promise<void> | void;
|
||||
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;
|
||||
}
|
||||
|
||||
interface BulkTagOperationArgs {
|
||||
labels: string[];
|
||||
action: 'add' | 'remove';
|
||||
documentIds?: Identifier[];
|
||||
}
|
||||
|
||||
interface BulkTagOperationResult {
|
||||
ok: boolean;
|
||||
reason?: 'no-labels' | 'no-selection' | 'tag-missing' | 'no-tags' | 'request-failed';
|
||||
label?: string;
|
||||
tagCount?: number;
|
||||
docsCount?: number;
|
||||
}
|
||||
|
||||
const useDocumentTagging = ({
|
||||
apiClient,
|
||||
tags,
|
||||
tagManager,
|
||||
refreshTags,
|
||||
refreshCurrentFolder,
|
||||
resolveTargetDocumentIds,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
}) => {
|
||||
updateDocumentCaches,
|
||||
}: UseDocumentTaggingArgs) => {
|
||||
const bulkTagOperation = useCallback(
|
||||
async ({ labels, action, documentIds }) => {
|
||||
async ({ labels, action, documentIds }: BulkTagOperationArgs): Promise<BulkTagOperationResult> => {
|
||||
const normalized = labels.map((label) => label.trim()).filter((label) => label.length > 0);
|
||||
if (!normalized.length) {
|
||||
return { ok: false, reason: 'no-labels' };
|
||||
@@ -22,7 +64,7 @@ const useDocumentTagging = ({
|
||||
return { ok: false, reason: 'no-selection' };
|
||||
}
|
||||
|
||||
let tagIds = [];
|
||||
let tagIds: Identifier[] = [];
|
||||
|
||||
if (action === 'remove') {
|
||||
const missing = normalized.find(
|
||||
@@ -40,22 +82,59 @@ const useDocumentTagging = ({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (action === 'add') {
|
||||
const createdIds = [];
|
||||
for (const label of normalized) {
|
||||
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
|
||||
if (!tag) {
|
||||
const payload = tagManager.buildPayload({ label });
|
||||
const { data } = await apiClient.post('/tags', payload);
|
||||
tag = data;
|
||||
await refreshTags();
|
||||
}
|
||||
createdIds.push(tag.id);
|
||||
if (action === 'add') {
|
||||
const createdIds: Identifier[] = [];
|
||||
const createdTags: TagRecord[] = [];
|
||||
for (const label of normalized) {
|
||||
let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null;
|
||||
if (!tag) {
|
||||
const payload = tagManager.buildPayload({ label });
|
||||
const response = await apiClient.post<{ id?: Identifier; label: string }>('/tags', payload);
|
||||
tag = 'data' in response ? response.data : response;
|
||||
await refreshTags();
|
||||
}
|
||||
tagIds = Array.from(new Set(createdIds));
|
||||
createdIds.push(tag.id);
|
||||
createdTags.push(tag);
|
||||
}
|
||||
tagIds = Array.from(new Set(createdIds));
|
||||
|
||||
tagIds = Array.from(new Set(tagIds));
|
||||
if (updateDocumentCaches) {
|
||||
const tagById = new Map<Identifier, TagRecord>();
|
||||
tags.forEach((tag) => {
|
||||
if (tag?.id != null) {
|
||||
tagById.set(tag.id, tag);
|
||||
}
|
||||
});
|
||||
createdTags.forEach((tag) => {
|
||||
if (tag?.id != null) {
|
||||
tagById.set(tag.id, tag);
|
||||
}
|
||||
});
|
||||
targetDocumentIds.forEach((docId) => {
|
||||
tagIds.forEach((tagId) => {
|
||||
const cachedTag = tagById.get(tagId);
|
||||
if (!cachedTag) {
|
||||
return;
|
||||
}
|
||||
updateDocumentCaches(docId, (doc) => {
|
||||
if (!doc) {
|
||||
return doc;
|
||||
}
|
||||
const currentTags = Array.isArray((doc as any).tags) ? (doc as any).tags : [];
|
||||
if (currentTags.some((entry: any) => entry?.id === tagId)) {
|
||||
return doc;
|
||||
}
|
||||
return {
|
||||
...(doc as any),
|
||||
tags: [...currentTags, { ...cachedTag }],
|
||||
};
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
tagIds = Array.from(new Set(tagIds));
|
||||
|
||||
if (!tagIds.length) {
|
||||
return { ok: false, reason: 'no-tags' };
|
||||
@@ -67,7 +146,23 @@ const useDocumentTagging = ({
|
||||
action,
|
||||
});
|
||||
|
||||
await refreshCurrentFolder();
|
||||
if (updateDocumentCaches) {
|
||||
targetDocumentIds.forEach((docId) => {
|
||||
tagIds.forEach((tagId) => {
|
||||
updateDocumentCaches(docId, (doc) => {
|
||||
if (!doc || !Array.isArray((doc as any).tags)) {
|
||||
return doc;
|
||||
}
|
||||
const currentTags = (doc as any).tags;
|
||||
if (action === 'remove') {
|
||||
const filtered = currentTags.filter((entry: any) => entry?.id !== tagId);
|
||||
return filtered.length === currentTags.length ? doc : { ...(doc as any), tags: filtered };
|
||||
}
|
||||
return doc;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -88,17 +183,17 @@ const useDocumentTagging = ({
|
||||
resolveTargetDocumentIds,
|
||||
tags,
|
||||
refreshTags,
|
||||
refreshCurrentFolder,
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
tagManager,
|
||||
apiClient,
|
||||
updateDocumentCaches,
|
||||
],
|
||||
);
|
||||
|
||||
const handleBulkTagAddFromDetail = useCallback(
|
||||
async ({ label, input, documentIds }) => {
|
||||
const trimmed = typeof label === 'string' ? label.trim() : '';
|
||||
async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||
const trimmed = label?.trim?.() || '';
|
||||
if (!trimmed) {
|
||||
setStatusMessage('Enter a tag label.', 'error');
|
||||
return;
|
||||
@@ -130,8 +225,8 @@ const useDocumentTagging = ({
|
||||
);
|
||||
|
||||
const handleBulkTagRemoveFromDetail = useCallback(
|
||||
async ({ label, input, documentIds }) => {
|
||||
const trimmed = typeof label === 'string' ? label.trim() : '';
|
||||
async ({ label, input, documentIds }: { label?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
|
||||
const trimmed = label?.trim?.() || '';
|
||||
if (!trimmed) {
|
||||
setStatusMessage('Enter a tag label to remove.', 'error');
|
||||
return;
|
||||
@@ -163,7 +258,7 @@ const useDocumentTagging = ({
|
||||
);
|
||||
|
||||
const handleBulkSelectionReanalyze = useCallback(
|
||||
async (documentIdsOverride = null) => {
|
||||
async (documentIdsOverride: Identifier[] | null = null) => {
|
||||
const targetIds = resolveTargetDocumentIds(documentIdsOverride);
|
||||
if (!targetIds.length) {
|
||||
setStatusMessage('Select documents before requesting re-analysis.', 'error');
|
||||
@@ -172,11 +267,17 @@ const useDocumentTagging = ({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await apiClient.post('/documents/bulk/reanalyze', {
|
||||
document_ids: targetIds,
|
||||
force: true,
|
||||
});
|
||||
const queued = data?.queued ?? targetIds.length;
|
||||
const response = await apiClient.post<{ queued?: number }>(
|
||||
'/documents/bulk/reanalyze',
|
||||
{
|
||||
document_ids: targetIds,
|
||||
force: true,
|
||||
},
|
||||
);
|
||||
const payload = 'data' in response ? response.data : response;
|
||||
const queued = Number.isFinite(payload?.queued)
|
||||
? Number(payload.queued)
|
||||
: targetIds.length;
|
||||
setStatusMessage(
|
||||
`Queued re-analysis for ${queued} document${queued === 1 ? '' : 's'}.`,
|
||||
'success',
|
||||
@@ -201,4 +302,3 @@ const useDocumentTagging = ({
|
||||
};
|
||||
|
||||
export default useDocumentTagging;
|
||||
|
||||
+158
-52
@@ -1,8 +1,81 @@
|
||||
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';
|
||||
|
||||
const mapFilesToEntries = (filesInput) => {
|
||||
type Identifier = string | number;
|
||||
type FolderId = Identifier | 'root' | null;
|
||||
|
||||
type FileEntry = {
|
||||
file: File;
|
||||
segments: string[];
|
||||
};
|
||||
|
||||
type UploadStatus = 'pending' | 'uploading' | 'success' | 'duplicate' | 'error';
|
||||
|
||||
type UploadQueueItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number | null;
|
||||
folderId: FolderId;
|
||||
status: UploadStatus;
|
||||
error: string | null;
|
||||
code: number | null;
|
||||
document: unknown;
|
||||
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 NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
|
||||
type SetStatusMessage = (message: string, level?: StatusLevel) => void;
|
||||
|
||||
type DropOverlayState = {
|
||||
active: boolean;
|
||||
folderName: string;
|
||||
};
|
||||
|
||||
type FileSystemEntryLike = FileSystemEntry;
|
||||
|
||||
type ExtendedDataTransferItem = DataTransferItem & {
|
||||
webkitGetAsEntry?: () => FileSystemEntry | null;
|
||||
};
|
||||
|
||||
interface FileSystemDirectoryReaderLike {
|
||||
readEntries: (
|
||||
successCallback: (entries: FileSystemEntryLike[]) => void,
|
||||
errorCallback: (error: DOMException) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
interface FileSystemFileEntryLike {
|
||||
isFile: true;
|
||||
isDirectory: false;
|
||||
name: string;
|
||||
file: (
|
||||
successCallback: (file: File) => void,
|
||||
errorCallback: (error: DOMException) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
interface FileSystemDirectoryEntryLike {
|
||||
isFile: false;
|
||||
isDirectory: true;
|
||||
name: string;
|
||||
createReader: () => FileSystemDirectoryReaderLike;
|
||||
}
|
||||
|
||||
const mapFilesToEntries = (filesInput?: FileList | File[] | null): FileEntry[] => {
|
||||
if (!filesInput) {
|
||||
return [];
|
||||
}
|
||||
@@ -10,8 +83,7 @@ const mapFilesToEntries = (filesInput) => {
|
||||
return files
|
||||
.filter(Boolean)
|
||||
.map((file) => {
|
||||
const relativePath =
|
||||
typeof file.webkitRelativePath === 'string' ? file.webkitRelativePath : '';
|
||||
const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
|
||||
const segments = relativePath
|
||||
? relativePath
|
||||
.split('/')
|
||||
@@ -22,6 +94,37 @@ const mapFilesToEntries = (filesInput) => {
|
||||
});
|
||||
};
|
||||
|
||||
interface UseDocumentUploadsArgs {
|
||||
apiClient: ApiClient;
|
||||
token?: string | null;
|
||||
selectedFolder?: FolderId;
|
||||
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;
|
||||
}
|
||||
|
||||
interface UseDocumentUploadsResult {
|
||||
dropOverlayState: DropOverlayState;
|
||||
setDropOverlayState: Dispatch<SetStateAction<DropOverlayState>>;
|
||||
dragCounterRef: MutableRefObject<number>;
|
||||
handleFileDrop: (dataTransfer: DataTransfer, targetFolderId?: FolderId) => Promise<void>;
|
||||
handleFileSelection: (files?: FileList | null, targetFolderId?: FolderId) => Promise<void>;
|
||||
uploadFile: (file: File, targetFolderId: FolderId) => Promise<{
|
||||
document: unknown;
|
||||
duplicate: boolean;
|
||||
statusCode: number | null;
|
||||
conflictDocumentId: Identifier | null;
|
||||
}>;
|
||||
extractFilesFromDataTransfer: (dataTransfer: DataTransfer) => Promise<FileEntry[]>;
|
||||
resetUploadsState: () => void;
|
||||
uploadQueue: UploadQueueItem[];
|
||||
clearUploadQueue: () => void;
|
||||
}
|
||||
|
||||
const useDocumentUploads = ({
|
||||
apiClient,
|
||||
token,
|
||||
@@ -31,26 +134,28 @@ const useDocumentUploads = ({
|
||||
refreshCurrentFolder,
|
||||
setLoading,
|
||||
shellRef,
|
||||
}) => {
|
||||
const [dropOverlayState, setDropOverlayState] = useState({
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
}: UseDocumentUploadsArgs): UseDocumentUploadsResult => {
|
||||
const [dropOverlayState, setDropOverlayState] = useState<DropOverlayState>({
|
||||
active: false,
|
||||
folderName: DEFAULT_FOLDER_NAME,
|
||||
folderName: currentFolderName || DEFAULT_FOLDER_NAME,
|
||||
});
|
||||
const dragCounterRef = useRef(0);
|
||||
const folderPathCacheRef = useRef(new Map());
|
||||
const folderPathCacheRef = useRef<Map<string, FolderId>>(new Map());
|
||||
const queueIdRef = useRef(0);
|
||||
const [uploadQueue, setUploadQueue] = useState([]);
|
||||
const [uploadQueue, setUploadQueue] = useState<UploadQueueItem[]>([]);
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file, targetFolderId) => {
|
||||
async (file: File, targetFolderId: FolderId) => {
|
||||
if (!file || file.size === 0) {
|
||||
return { document: null, duplicate: false, statusCode: null, conflictDocumentId: null };
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
if (targetFolderId && targetFolderId !== 'root') {
|
||||
formData.append('folder_id', targetFolderId);
|
||||
if (targetFolderId != null && targetFolderId !== 'root') {
|
||||
formData.append('folder_id', String(targetFolderId));
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -63,14 +168,14 @@ const useDocumentUploads = ({
|
||||
statusCode: status ?? (duplicate ? 200 : 201),
|
||||
conflictDocumentId: null,
|
||||
};
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 409) {
|
||||
const conflictId = error.response?.data?.details?.conflict_document_id ?? null;
|
||||
let conflictDocument = null;
|
||||
if (conflictId) {
|
||||
try {
|
||||
const { data } = await apiClient.get(`/documents/${conflictId}`);
|
||||
conflictDocument = data?.document ?? data ?? null;
|
||||
conflictDocument = (data as any)?.document ?? data ?? null;
|
||||
} catch (fetchError) {
|
||||
console.warn('[Uploads] failed to fetch conflict document', fetchError);
|
||||
}
|
||||
@@ -83,14 +188,16 @@ const useDocumentUploads = ({
|
||||
};
|
||||
}
|
||||
const message = error.response?.data?.error || `Failed to upload ${file.name}.`;
|
||||
notifyApiError?.(error, message);
|
||||
setStatusMessage?.(message, 'error');
|
||||
const wrapped = Object.assign(new Error(message), { response: error.response });
|
||||
throw wrapped;
|
||||
}
|
||||
},
|
||||
[apiClient],
|
||||
[apiClient, notifyApiError, setStatusMessage],
|
||||
);
|
||||
|
||||
const appendQueueItems = useCallback((entries, targetFolderId) => {
|
||||
const appendQueueItems = useCallback((entries: FileEntry[], targetFolderId?: FolderId) => {
|
||||
const baseId = Date.now();
|
||||
const items = entries.map(({ file }) => {
|
||||
queueIdRef.current += 1;
|
||||
@@ -99,12 +206,12 @@ const useDocumentUploads = ({
|
||||
name: file?.name || 'Unnamed file',
|
||||
size: file?.size ?? null,
|
||||
folderId: targetFolderId ?? selectedFolder ?? 'root',
|
||||
status: 'pending',
|
||||
status: 'pending' as UploadStatus,
|
||||
error: null,
|
||||
code: null,
|
||||
document: null,
|
||||
conflictDocumentId: null,
|
||||
};
|
||||
} satisfies UploadQueueItem;
|
||||
});
|
||||
if (items.length) {
|
||||
setUploadQueue((current) => [...current, ...items]);
|
||||
@@ -112,7 +219,7 @@ const useDocumentUploads = ({
|
||||
return items;
|
||||
}, [selectedFolder]);
|
||||
|
||||
const updateQueueItem = useCallback((id, patch) => {
|
||||
const updateQueueItem = useCallback((id: string, patch: Partial<UploadQueueItem>) => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
@@ -122,7 +229,7 @@ const useDocumentUploads = ({
|
||||
}, []);
|
||||
|
||||
const ensureFolderPathOnServer = useCallback(
|
||||
async (baseFolderId, segments) => {
|
||||
async (baseFolderId: FolderId, segments: string[]): Promise<FolderId> => {
|
||||
const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean);
|
||||
if (trimmedSegments.length === 0) {
|
||||
return baseFolderId ?? null;
|
||||
@@ -131,7 +238,7 @@ const useDocumentUploads = ({
|
||||
const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`;
|
||||
const cache = folderPathCacheRef.current;
|
||||
if (cache.has(cacheKey)) {
|
||||
return cache.get(cacheKey);
|
||||
return cache.get(cacheKey) ?? null;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
@@ -139,28 +246,32 @@ const useDocumentUploads = ({
|
||||
segments: trimmedSegments,
|
||||
};
|
||||
|
||||
const { data } = await apiClient.post('/folders/path', payload);
|
||||
cache.set(cacheKey, data.folder.id);
|
||||
return data.folder.id;
|
||||
const { data } = await apiClient.post<{ folder?: { id?: FolderId | null } }>(
|
||||
'/folders/path',
|
||||
payload,
|
||||
);
|
||||
const resolvedId = (data?.folder?.id ?? null) as FolderId;
|
||||
cache.set(cacheKey, resolvedId);
|
||||
return resolvedId;
|
||||
},
|
||||
[apiClient],
|
||||
);
|
||||
|
||||
const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => {
|
||||
const extractFilesFromDataTransfer = useCallback(async (dataTransfer: DataTransfer) => {
|
||||
if (!dataTransfer) {
|
||||
throw new Error('No drop payload found.');
|
||||
}
|
||||
|
||||
const items = Array.from(dataTransfer.items || []);
|
||||
const items = Array.from(dataTransfer.items || []) as ExtendedDataTransferItem[];
|
||||
console.info('[Uploads] drop start', {
|
||||
items: items.length,
|
||||
files: (dataTransfer.files || []).length,
|
||||
});
|
||||
|
||||
const results = [];
|
||||
const results: FileEntry[] = [];
|
||||
const seenKeys = new Set();
|
||||
|
||||
const pushFile = (file, ancestors = []) => {
|
||||
const pushFile = (file?: File | null, ancestors: string[] = []) => {
|
||||
if (!file) return;
|
||||
const segments = (ancestors || []).filter(Boolean);
|
||||
const key = `${segments.join('/')}/${file.name}:${file.size}`;
|
||||
@@ -171,11 +282,11 @@ const useDocumentUploads = ({
|
||||
results.push({ file, segments });
|
||||
};
|
||||
|
||||
const readAllEntries = async (reader) => {
|
||||
const entries = [];
|
||||
let batch = [];
|
||||
const readAllEntries = async (reader: FileSystemDirectoryReaderLike) => {
|
||||
const entries: FileSystemEntryLike[] = [];
|
||||
let batch: FileSystemEntryLike[] = [];
|
||||
do {
|
||||
batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
|
||||
batch = await new Promise<FileSystemEntryLike[]>((resolve, reject) => reader.readEntries(resolve, reject));
|
||||
if (batch.length) {
|
||||
entries.push(...batch);
|
||||
}
|
||||
@@ -183,15 +294,15 @@ const useDocumentUploads = ({
|
||||
return entries;
|
||||
};
|
||||
|
||||
const walkEntry = async (entry, ancestors = []) => {
|
||||
const walkEntry = async (entry: FileSystemEntryLike | null, ancestors: string[] = []) => {
|
||||
if (!entry) return;
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise((resolve, reject) => {
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
try {
|
||||
entry.file(resolve, reject);
|
||||
(entry as unknown as FileSystemFileEntryLike).file(resolve, reject);
|
||||
} catch (error) {
|
||||
console.warn('[Uploads] entry.file failed', error);
|
||||
reject(error);
|
||||
reject(error as Error);
|
||||
}
|
||||
});
|
||||
pushFile(file, ancestors);
|
||||
@@ -199,7 +310,7 @@ const useDocumentUploads = ({
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
const nextAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors];
|
||||
const reader = entry.createReader();
|
||||
const reader = (entry as unknown as FileSystemDirectoryEntryLike).createReader();
|
||||
const entries = await readAllEntries(reader);
|
||||
for (const child of entries) {
|
||||
await walkEntry(child, nextAncestors);
|
||||
@@ -211,12 +322,9 @@ const useDocumentUploads = ({
|
||||
items.map(async (item, index) => {
|
||||
if (item.kind !== 'file') return;
|
||||
|
||||
const fileFromItem = typeof item.getAsFile === 'function' ? item.getAsFile() : null;
|
||||
const fileFromItem = item.getAsFile?.() ?? null;
|
||||
if (fileFromItem) {
|
||||
const relativePath =
|
||||
typeof fileFromItem.webkitRelativePath === 'string'
|
||||
? fileFromItem.webkitRelativePath
|
||||
: '';
|
||||
const relativePath = (fileFromItem as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
|
||||
const segments = relativePath
|
||||
? relativePath
|
||||
.split('/')
|
||||
@@ -226,9 +334,9 @@ const useDocumentUploads = ({
|
||||
pushFile(fileFromItem, segments);
|
||||
}
|
||||
|
||||
if (typeof item.webkitGetAsEntry === 'function') {
|
||||
if ((item as ExtendedDataTransferItem).webkitGetAsEntry) {
|
||||
try {
|
||||
const entry = item.webkitGetAsEntry();
|
||||
const entry = (item as ExtendedDataTransferItem).webkitGetAsEntry?.();
|
||||
if (entry) {
|
||||
await walkEntry(entry, []);
|
||||
return;
|
||||
@@ -246,8 +354,7 @@ const useDocumentUploads = ({
|
||||
|
||||
Array.from(dataTransfer.files || []).forEach((file) => {
|
||||
if (!file) return;
|
||||
const relativePath =
|
||||
typeof file.webkitRelativePath === 'string' ? file.webkitRelativePath : '';
|
||||
const relativePath = (file as File & { webkitRelativePath?: string })?.webkitRelativePath ?? '';
|
||||
const segments = relativePath
|
||||
? relativePath
|
||||
.split('/')
|
||||
@@ -327,7 +434,7 @@ const useDocumentUploads = ({
|
||||
updateQueueItem(queueItem.id, patch);
|
||||
Object.assign(queueItem, patch);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (queueItem) {
|
||||
const patch = {
|
||||
status: 'error',
|
||||
@@ -350,7 +457,7 @@ const useDocumentUploads = ({
|
||||
) {
|
||||
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
const message = error.message || 'Failed to upload files.';
|
||||
queueItems.forEach((item) => {
|
||||
if (item.status === 'success' || item.status === 'duplicate' || item.status === 'error') {
|
||||
@@ -383,8 +490,8 @@ const useDocumentUploads = ({
|
||||
);
|
||||
|
||||
const handleFileDrop = useCallback(
|
||||
async (dataTransfer, targetFolderId) => {
|
||||
let extracted;
|
||||
async (dataTransfer: DataTransfer, targetFolderId?: FolderId) => {
|
||||
let extracted: FileEntry[];
|
||||
try {
|
||||
extracted = await extractFilesFromDataTransfer(dataTransfer);
|
||||
} catch (error) {
|
||||
@@ -398,7 +505,7 @@ const useDocumentUploads = ({
|
||||
);
|
||||
|
||||
const handleFileSelection = useCallback(
|
||||
async (files, targetFolderId) => {
|
||||
async (files?: FileList | null, targetFolderId?: FolderId) => {
|
||||
const entries = mapFilesToEntries(files);
|
||||
await uploadFileEntries(entries, targetFolderId);
|
||||
},
|
||||
@@ -414,7 +521,6 @@ const useDocumentUploads = ({
|
||||
hasFiles,
|
||||
defaultFolderName: DEFAULT_FOLDER_NAME,
|
||||
dragCounterRef,
|
||||
dropOverlayState,
|
||||
setDropOverlayState,
|
||||
});
|
||||
|
||||
@@ -439,7 +545,7 @@ const useDocumentUploads = ({
|
||||
resetUploadsState,
|
||||
uploadQueue,
|
||||
clearUploadQueue,
|
||||
};
|
||||
} satisfies UseDocumentUploadsResult;
|
||||
};
|
||||
|
||||
export default useDocumentUploads;
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
const useDocuments = ({ setSearchResults, setFolderContents }) => {
|
||||
const [documents, setDocuments] = useState([]);
|
||||
|
||||
const mapDocumentCaches = useCallback(
|
||||
(mapper) => {
|
||||
if (typeof mapper !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyToList = (list) => {
|
||||
let changed = false;
|
||||
const next = list.map((doc) => {
|
||||
const updated = mapper(doc);
|
||||
if (updated === undefined || updated === doc) {
|
||||
return doc;
|
||||
}
|
||||
changed = true;
|
||||
return updated;
|
||||
});
|
||||
return changed ? next : list;
|
||||
};
|
||||
|
||||
setDocuments((prev) => applyToList(prev));
|
||||
setSearchResults((prev) => {
|
||||
if (!Array.isArray(prev)) {
|
||||
return prev;
|
||||
}
|
||||
return applyToList(prev);
|
||||
});
|
||||
setFolderContents((prev) => {
|
||||
if (!prev.size) {
|
||||
return prev;
|
||||
}
|
||||
let changed = false;
|
||||
const next = new Map();
|
||||
prev.forEach((contents, key) => {
|
||||
const docs = Array.isArray(contents?.documents) ? contents.documents : null;
|
||||
if (!docs || docs.length === 0) {
|
||||
next.set(key, contents);
|
||||
return;
|
||||
}
|
||||
let docsChanged = false;
|
||||
const updatedDocs = docs.map((doc) => {
|
||||
const updated = mapper(doc);
|
||||
if (updated === undefined || updated === doc) {
|
||||
return doc;
|
||||
}
|
||||
docsChanged = true;
|
||||
return updated;
|
||||
});
|
||||
if (docsChanged) {
|
||||
changed = true;
|
||||
next.set(key, { ...contents, documents: updatedDocs });
|
||||
} else {
|
||||
next.set(key, contents);
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
},
|
||||
[setFolderContents, setSearchResults],
|
||||
);
|
||||
|
||||
const updateDocumentCaches = useCallback(
|
||||
(documentId, updater) => {
|
||||
if (!documentId || typeof updater !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
mapDocumentCaches((doc) => {
|
||||
if (!doc || doc.id !== documentId) {
|
||||
return doc;
|
||||
}
|
||||
const updated = updater(doc);
|
||||
return updated === undefined ? doc : updated;
|
||||
});
|
||||
},
|
||||
[mapDocumentCaches],
|
||||
);
|
||||
|
||||
return {
|
||||
documents,
|
||||
setDocuments,
|
||||
mapDocumentCaches,
|
||||
updateDocumentCaches,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocuments;
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import DocumentsManager from '../../documents/DocumentsManager';
|
||||
|
||||
type DocumentId = string | number;
|
||||
|
||||
interface DocumentLike {
|
||||
id?: DocumentId;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderContentsEntry {
|
||||
documents?: DocumentLike[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseDocumentsOptions {
|
||||
setFolderContents: Dispatch<SetStateAction<Map<string, FolderContentsEntry>>>;
|
||||
fetchDocumentById?: (id: DocumentId) => Promise<DocumentLike | null>;
|
||||
}
|
||||
|
||||
const useDocuments = ({
|
||||
setFolderContents,
|
||||
fetchDocumentById,
|
||||
}: UseDocumentsOptions) => {
|
||||
const managerRef = useRef(
|
||||
new DocumentsManager<DocumentLike>(fetchDocumentById),
|
||||
);
|
||||
const [documents, setDocumentsState] = useState<DocumentLike[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
managerRef.current.setFetcher(fetchDocumentById);
|
||||
}, [fetchDocumentById]);
|
||||
|
||||
const setDocuments = useCallback(
|
||||
(value: DocumentLike[] | ((prev: DocumentLike[]) => DocumentLike[])) => {
|
||||
setDocumentsState((prev) => {
|
||||
const resolved = typeof value === 'function' ? value(prev) : value;
|
||||
if (!Array.isArray(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
const { canonical } = managerRef.current.ingest(resolved);
|
||||
return canonical;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const mapDocumentCaches = useCallback(
|
||||
(mapper: (doc: DocumentLike) => DocumentLike | undefined) => {
|
||||
managerRef.current.map(mapper);
|
||||
const lookupSnapshot = managerRef.current.getSnapshot();
|
||||
|
||||
setDocumentsState((prev) => {
|
||||
if (!Array.isArray(prev) || prev.length === 0) {
|
||||
return prev;
|
||||
}
|
||||
let changed = false;
|
||||
const next = prev.map((doc) => {
|
||||
const id = doc?.id;
|
||||
if (id != null && lookupSnapshot.has(id as DocumentId)) {
|
||||
const canonical = lookupSnapshot.get(id as DocumentId) as DocumentLike;
|
||||
if (canonical !== doc) {
|
||||
changed = true;
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
const updated = mapper(doc);
|
||||
const nextDoc = updated === undefined ? doc : updated;
|
||||
if (nextDoc !== doc) {
|
||||
changed = true;
|
||||
}
|
||||
return nextDoc;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
setFolderContents((prev) => {
|
||||
if (!prev.size) {
|
||||
return prev;
|
||||
}
|
||||
let changed = false;
|
||||
const next = new Map();
|
||||
prev.forEach((contents, key) => {
|
||||
const docs = Array.isArray(contents?.documents) ? contents.documents : null;
|
||||
if (!docs || docs.length === 0) {
|
||||
next.set(key, contents);
|
||||
return;
|
||||
}
|
||||
let docsChanged = false;
|
||||
const updatedDocs = docs.map((doc) => {
|
||||
const id = doc?.id;
|
||||
if (id != null && lookupSnapshot.has(id as DocumentId)) {
|
||||
const canonical = lookupSnapshot.get(id as DocumentId) as DocumentLike;
|
||||
if (canonical !== doc) {
|
||||
docsChanged = true;
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
const updated = mapper(doc);
|
||||
const nextDoc = updated === undefined ? doc : updated;
|
||||
if (nextDoc !== doc) {
|
||||
docsChanged = true;
|
||||
}
|
||||
return nextDoc;
|
||||
});
|
||||
if (docsChanged) {
|
||||
changed = true;
|
||||
next.set(key, { ...contents, documents: updatedDocs });
|
||||
} else {
|
||||
next.set(key, contents);
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
},
|
||||
[setFolderContents],
|
||||
);
|
||||
|
||||
const updateDocumentCaches = useCallback(
|
||||
(documentId, updater) => {
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
mapDocumentCaches((doc) => {
|
||||
if (!doc || doc.id !== documentId) {
|
||||
return doc;
|
||||
}
|
||||
const updated = updater(doc);
|
||||
return updated === undefined ? doc : updated;
|
||||
});
|
||||
},
|
||||
[mapDocumentCaches],
|
||||
);
|
||||
|
||||
const removeDocumentsFromLookup = useCallback(
|
||||
(documentIds: Array<DocumentId>) => {
|
||||
if (!Array.isArray(documentIds) || !documentIds.length) {
|
||||
return;
|
||||
}
|
||||
managerRef.current.remove(documentIds);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
documents,
|
||||
setDocuments,
|
||||
removeDocumentsFromLookup,
|
||||
mapDocumentCaches,
|
||||
updateDocumentCaches,
|
||||
documentsManager: managerRef.current,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocuments;
|
||||
+335
-223
@@ -1,4 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
MutableRefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react';
|
||||
import {
|
||||
matchPath,
|
||||
useLocation,
|
||||
@@ -18,7 +26,6 @@ import useDocumentsSelection from '../../documents/hooks/useDocumentsSelection';
|
||||
import useBulkDocumentActions from '../../documents/hooks/useBulkDocumentActions';
|
||||
import useDocumentsPanelProps from '../../documents/hooks/useDocumentsPanelProps';
|
||||
import useDocumentPreview from '../../app/useDocumentPreview';
|
||||
import useDeskWorkspaceProps from '../../desktop/useDeskWorkspaceProps';
|
||||
import useSidebarProps from '../../sidebar/useSidebarProps';
|
||||
import {
|
||||
ASSET_PRESIGN_TTL_MS,
|
||||
@@ -57,6 +64,47 @@ const EntryType = Object.freeze({
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
type Identifier = string | number;
|
||||
type DocumentId = Identifier;
|
||||
type FolderId = Identifier | 'root';
|
||||
|
||||
interface DocumentLike {
|
||||
id?: DocumentId | null;
|
||||
title?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderContentsEntry {
|
||||
folder?: { id?: FolderId; name?: string | null } | null;
|
||||
documents?: DocumentLike[];
|
||||
subfolders?: Array<{ id?: FolderId; name?: string | null; [key: string]: unknown }>;
|
||||
__includesDocuments?: boolean;
|
||||
__sortField?: string | null;
|
||||
__sortDirection?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TenantOption {
|
||||
id?: Identifier | null;
|
||||
name?: string | null;
|
||||
slug?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseDocumentsWorkspaceOptions {
|
||||
documentsViewMode?: string;
|
||||
documentsSortField?: string;
|
||||
documentsSortDirection?: string;
|
||||
documentsSortFieldRef?: MutableRefObject<string>;
|
||||
documentsSortDirectionRef?: MutableRefObject<string>;
|
||||
onDocumentsViewModeChange?: (mode: string) => void;
|
||||
onDocumentsSortFieldChange?: (field: string) => void;
|
||||
onDocumentsSortDirectionToggle?: () => void;
|
||||
searchIncludeDescendants?: boolean;
|
||||
onSetSearchIncludeDescendants?: (value: boolean) => void;
|
||||
sortRefreshReadyRef?: MutableRefObject<boolean>;
|
||||
}
|
||||
|
||||
const useDocumentsWorkspace = ({
|
||||
documentsViewMode = 'list',
|
||||
documentsSortField = DEFAULT_SORT_FIELD,
|
||||
@@ -67,17 +115,13 @@ const useDocumentsWorkspace = ({
|
||||
onDocumentsSortFieldChange,
|
||||
onDocumentsSortDirectionToggle,
|
||||
searchIncludeDescendants = true,
|
||||
onToggleSearchIncludeDescendants,
|
||||
onSetSearchIncludeDescendants,
|
||||
sortRefreshReadyRef,
|
||||
handleDeskExit,
|
||||
} = {}) => {
|
||||
}: UseDocumentsWorkspaceOptions = {}) => {
|
||||
const handleDocumentsViewModeChange = onDocumentsViewModeChange || noop;
|
||||
const handleDocumentsSortFieldChange = onDocumentsSortFieldChange || noop;
|
||||
const handleDocumentsSortDirectionToggle = onDocumentsSortDirectionToggle || noop;
|
||||
const toggleSearchIncludeDescendants = onToggleSearchIncludeDescendants || noop;
|
||||
const setSearchIncludeDescendants = onSetSearchIncludeDescendants || noop;
|
||||
const handleDeskExitSafe = handleDeskExit || noop;
|
||||
|
||||
const fallbackSortFieldRef = useRef(documentsSortField);
|
||||
const activeSortFieldRef = documentsSortFieldRef || fallbackSortFieldRef;
|
||||
@@ -102,9 +146,22 @@ const useDocumentsWorkspace = ({
|
||||
const routeFolderId = folderMatch?.params?.folderId || null;
|
||||
const routeDocumentId = docMatch?.params?.documentId || null;
|
||||
const previewDocumentId = routeDocumentId;
|
||||
const { status: appStatus, token, tenant, tenants: tenantOptions = [] } = appState;
|
||||
const tenantName = tenant?.name || tenant?.slug || null;
|
||||
const currentTenantId = tenant?.id || null;
|
||||
const {
|
||||
status: appStatus,
|
||||
token,
|
||||
tenant,
|
||||
tenants: tenantOptionsRaw = [],
|
||||
} = appState;
|
||||
|
||||
const tenantRecord = (tenant ?? null) as TenantOption | null;
|
||||
const tenantNameCandidate = tenantRecord?.name ?? tenantRecord?.slug ?? null;
|
||||
const tenantName = tenantNameCandidate ? String(tenantNameCandidate) : null;
|
||||
|
||||
const currentTenantId: Identifier | null = (tenantRecord?.id ?? null) as Identifier | null;
|
||||
|
||||
const tenantOptions: TenantOption[] = Array.isArray(tenantOptionsRaw)
|
||||
? (tenantOptionsRaw as TenantOption[])
|
||||
: [];
|
||||
const { status, setStatusMessage } = useDocumentsStore();
|
||||
const handleApiReport = useCallback(
|
||||
({ message, variant }) => setStatusMessage(message, variant),
|
||||
@@ -135,9 +192,6 @@ const useDocumentsWorkspace = ({
|
||||
const tenantIdRef = useRef(currentTenantId);
|
||||
const detailPanelControlRef = useRef({ open: () => {}, close: () => {} });
|
||||
const setTagRemovalCursor = useCallback((active) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (tagRemovalCursorActiveRef.current === active) {
|
||||
return;
|
||||
}
|
||||
@@ -159,9 +213,9 @@ const useDocumentsWorkspace = ({
|
||||
documentsRouteMatch || documentsFolderRouteMatch || documentsDetailRouteMatch,
|
||||
);
|
||||
|
||||
const [draggedDocumentIds, setDraggedDocumentIds] = useState([]);
|
||||
const [draggedFolderId, setDraggedFolderId] = useState(null);
|
||||
const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null);
|
||||
const [draggedDocumentIds, setDraggedDocumentIds] = useState<DocumentId[]>([]);
|
||||
const [draggedFolderId, setDraggedFolderId] = useState<FolderId | null>(null);
|
||||
const [activePreviewId, setActivePreviewId] = useState<DocumentId | null>(routeDocumentId || null);
|
||||
const shellRef = useRef(null);
|
||||
const assetManagerRef = useRef(null);
|
||||
if (!assetManagerRef.current) {
|
||||
@@ -174,10 +228,20 @@ const useDocumentsWorkspace = ({
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
const hydratedDetail = assetManager.hydrateDetail(payload);
|
||||
return hydratedDetail?.document || payload.document || payload;
|
||||
return payload.document || payload;
|
||||
},
|
||||
[assetManager],
|
||||
[],
|
||||
);
|
||||
|
||||
const fetchDocumentById = useCallback(
|
||||
async (documentId: DocumentId) => {
|
||||
if (!documentId) {
|
||||
return null;
|
||||
}
|
||||
const { data } = await api.get(`/documents/${documentId}`);
|
||||
return extractDocumentFromResponse(data);
|
||||
},
|
||||
[extractDocumentFromResponse],
|
||||
);
|
||||
|
||||
const tagManagerRef = useRef(null);
|
||||
@@ -209,8 +273,8 @@ const useDocumentsWorkspace = ({
|
||||
focusedRowKey,
|
||||
setFocusedRowKey,
|
||||
applySelection,
|
||||
clearSelection,
|
||||
handleEntrySelection,
|
||||
clearSelection,
|
||||
promoteSelectionOrder: promoteSelectionOrderRaw,
|
||||
configureSelectionEnvironment,
|
||||
} = selection;
|
||||
@@ -236,29 +300,32 @@ const useDocumentsWorkspace = ({
|
||||
],
|
||||
);
|
||||
|
||||
const [folderContents, setFolderContents] = useState(() => new Map());
|
||||
const [folderContents, setFolderContents] = useState<Map<FolderId, FolderContentsEntry>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const folderContentsRef = useRef(folderContents);
|
||||
useEffect(() => {
|
||||
folderContentsRef.current = folderContents;
|
||||
}, [folderContents]);
|
||||
|
||||
const setSearchResultsRef = useRef(() => {});
|
||||
const setSearchResultsProxy = useCallback((value) => {
|
||||
if (typeof setSearchResultsRef.current === 'function') {
|
||||
setSearchResultsRef.current(value);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const {
|
||||
documents,
|
||||
setDocuments,
|
||||
removeDocumentsFromLookup,
|
||||
mapDocumentCaches,
|
||||
updateDocumentCaches,
|
||||
documentsManager,
|
||||
} = useDocuments({
|
||||
setSearchResults: setSearchResultsProxy,
|
||||
setFolderContents,
|
||||
fetchDocumentById,
|
||||
});
|
||||
|
||||
const documentLookup = useSyncExternalStore(
|
||||
(onStoreChange) => documentsManager.subscribe(onStoreChange),
|
||||
() => documentsManager.getSnapshot(),
|
||||
() => documentsManager.getSnapshot(),
|
||||
);
|
||||
|
||||
const {
|
||||
folderNodes,
|
||||
setFolderNodes,
|
||||
@@ -278,7 +345,6 @@ const useDocumentsWorkspace = ({
|
||||
isInvalidFolderDrop,
|
||||
} = useFolderTree({
|
||||
initialSelectedFolder: routeFolderId || 'root',
|
||||
assetManager,
|
||||
apiClient: api,
|
||||
tenantIdRef,
|
||||
documentsSortFieldRef: activeSortFieldRef,
|
||||
@@ -292,83 +358,59 @@ const useDocumentsWorkspace = ({
|
||||
const {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchResults,
|
||||
setSearchResults,
|
||||
searchResultIds,
|
||||
setSearchResultIds,
|
||||
searchLoading,
|
||||
activeTagFilters,
|
||||
setActiveTagFilters,
|
||||
activeCorrespondentFilters,
|
||||
setActiveCorrespondentFilters,
|
||||
toggleTagFilter,
|
||||
toggleCorrespondentFilter,
|
||||
isFilterActive,
|
||||
clearFilters,
|
||||
handleSearchChange,
|
||||
handleSearchSubmit,
|
||||
documentsFilterValue,
|
||||
} = useDocumentsSearch({
|
||||
api,
|
||||
assetManager,
|
||||
token,
|
||||
selectedFolder,
|
||||
navigate,
|
||||
locationPathname: location.pathname,
|
||||
isDocumentsRoute,
|
||||
selectionHelpers,
|
||||
searchIncludeDescendants,
|
||||
documentsSortField,
|
||||
documentsSortDirection,
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
setSearchIncludeDescendants,
|
||||
documentsManager,
|
||||
});
|
||||
|
||||
const documentsFilter = documentsFilterValue;
|
||||
|
||||
const [visibleDocumentIds, setVisibleDocumentIds] = useState<DocumentId[]>([]);
|
||||
|
||||
const showingSearchResults = searchResultIds !== null;
|
||||
|
||||
useEffect(() => {
|
||||
setSearchResultsRef.current = setSearchResults;
|
||||
}, [setSearchResults]);
|
||||
const arraysEqual = (a: DocumentId[], b: DocumentId[]) =>
|
||||
a.length === b.length && a.every((value, index) => value === b[index]);
|
||||
|
||||
const {
|
||||
previewEntries,
|
||||
previewDocuments,
|
||||
ensurePreviewData,
|
||||
openDocumentPreview,
|
||||
closeDocumentPreview,
|
||||
resetPreviewState,
|
||||
removePreviewEntries,
|
||||
} = useDocumentPreview({
|
||||
routeDocumentId: previewDocumentId,
|
||||
documents,
|
||||
searchResults,
|
||||
selectedFolder,
|
||||
assetManager,
|
||||
api,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
navigate,
|
||||
locationPathname: location.pathname,
|
||||
locationSearch: location.search,
|
||||
detailPanelControlRef,
|
||||
setActivePreviewId,
|
||||
});
|
||||
if (showingSearchResults && Array.isArray(searchResultIds)) {
|
||||
const ids = searchResultIds.filter((id): id is DocumentId => id != null);
|
||||
setVisibleDocumentIds((prev) => (arraysEqual(prev, ids) ? prev : ids));
|
||||
return;
|
||||
}
|
||||
|
||||
const getDocumentAsset = useCallback((doc, type) => {
|
||||
if (!doc || !type) return null;
|
||||
return getAssetFromVersion(doc.current_version || null, type);
|
||||
}, []);
|
||||
const folderIds = documents
|
||||
.map((doc) => (doc?.id ?? null) as DocumentId | null)
|
||||
.filter((id): id is DocumentId => id != null);
|
||||
setVisibleDocumentIds((prev) => (arraysEqual(prev, folderIds) ? prev : folderIds));
|
||||
}, [showingSearchResults, searchResultIds, documents]);
|
||||
|
||||
const bootstrapInitializedRef = useRef(false);
|
||||
const detailFolderFetchRef = useRef(new Set());
|
||||
|
||||
|
||||
const showingSearchResults = searchResults !== null;
|
||||
|
||||
const visibleDocuments = useMemo(
|
||||
() => (showingSearchResults ? searchResults : documents),
|
||||
[showingSearchResults, searchResults, documents],
|
||||
);
|
||||
|
||||
const visibleDocumentIds = useMemo(
|
||||
() => visibleDocuments.map((doc) => doc.id),
|
||||
[visibleDocuments],
|
||||
const viewDocuments = useMemo(
|
||||
() =>
|
||||
visibleDocumentIds
|
||||
.map((id) => documentLookup.get(id) || null)
|
||||
.filter((doc): doc is DocumentLike => Boolean(doc)),
|
||||
[visibleDocumentIds, documentLookup],
|
||||
);
|
||||
|
||||
const visibleDocumentKeys = useMemo(
|
||||
@@ -396,27 +438,65 @@ const useDocumentsWorkspace = ({
|
||||
[visibleRowKeys],
|
||||
);
|
||||
|
||||
const documentLookup = useMemo(() => {
|
||||
const map = new Map();
|
||||
const push = (items) => {
|
||||
(items || []).forEach((doc) => {
|
||||
if (doc?.id) {
|
||||
map.set(doc.id, doc);
|
||||
}
|
||||
});
|
||||
};
|
||||
const {
|
||||
documentLinks,
|
||||
ensureDownloadUrl,
|
||||
openDocumentPreview,
|
||||
closeDocumentPreview,
|
||||
resetPreviewState,
|
||||
removeDocumentLinks,
|
||||
} = useDocumentPreview({
|
||||
routeDocumentId: previewDocumentId,
|
||||
documentsManager,
|
||||
selectedFolder,
|
||||
api,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
navigate,
|
||||
locationPathname: location.pathname,
|
||||
locationSearch: location.search,
|
||||
detailPanelControlRef,
|
||||
setActivePreviewId,
|
||||
});
|
||||
|
||||
push(documents);
|
||||
if (Array.isArray(searchResults)) {
|
||||
push(searchResults);
|
||||
}
|
||||
previewDocuments.forEach((doc, id) => {
|
||||
if (doc && id && !map.has(id)) {
|
||||
map.set(id, doc);
|
||||
const openDocumentPreviewForDetail = useCallback(
|
||||
({ documentIds }: { documentIds?: Identifier[] } = {}) => {
|
||||
const targetId = documentIds?.find((value): value is Identifier => value != null);
|
||||
if (targetId == null) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [documents, searchResults, previewDocuments]);
|
||||
openDocumentPreview(targetId, { replace: true });
|
||||
},
|
||||
[openDocumentPreview],
|
||||
);
|
||||
|
||||
const getDocumentAsset = useCallback((doc, type) => {
|
||||
if (!doc || !type) return null;
|
||||
return getAssetFromVersion(doc.current_version || null, type);
|
||||
}, []);
|
||||
|
||||
const bootstrapInitializedRef = useRef(false);
|
||||
const detailFolderFetchRef = useRef(new Set());
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!showingSearchResults) {
|
||||
return;
|
||||
}
|
||||
setSelectedEntries([]);
|
||||
setSelectionOrder([]);
|
||||
selectionOrderRef.current = [];
|
||||
selectionAnchorRef.current = null;
|
||||
setFocusedDocumentId(null);
|
||||
}, [
|
||||
showingSearchResults,
|
||||
searchQuery,
|
||||
setSelectedEntries,
|
||||
setSelectionOrder,
|
||||
selectionOrderRef,
|
||||
selectionAnchorRef,
|
||||
setFocusedDocumentId,
|
||||
]);
|
||||
|
||||
const {
|
||||
tags,
|
||||
@@ -530,11 +610,11 @@ const useDocumentsWorkspace = ({
|
||||
tags,
|
||||
tagManager,
|
||||
refreshTags,
|
||||
refreshCurrentFolder,
|
||||
resolveTargetDocumentIds,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
setLoading,
|
||||
updateDocumentCaches,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -585,9 +665,9 @@ const useDocumentsWorkspace = ({
|
||||
apiClient: api,
|
||||
correspondents,
|
||||
handleCorrespondentCreate,
|
||||
refreshCurrentFolder,
|
||||
notifyApiError,
|
||||
setStatusMessage,
|
||||
updateDocumentCaches,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -622,7 +702,7 @@ const useDocumentsWorkspace = ({
|
||||
selectionAnchorRef.current = null;
|
||||
setDraggedDocumentIds([]);
|
||||
setDraggedFolderId(null);
|
||||
setSearchResults(null);
|
||||
setSearchResultIds(null);
|
||||
setTags([]);
|
||||
setCorrespondents([]);
|
||||
setSearchQuery('');
|
||||
@@ -655,7 +735,7 @@ const useDocumentsWorkspace = ({
|
||||
setDocuments,
|
||||
setDraggedDocumentIds,
|
||||
setDraggedFolderId,
|
||||
setSearchResults,
|
||||
setSearchResultIds,
|
||||
setTags,
|
||||
setCorrespondents,
|
||||
setSearchQuery,
|
||||
@@ -675,28 +755,28 @@ const useDocumentsWorkspace = ({
|
||||
|
||||
|
||||
const removeDocumentsFromCaches = useCallback(
|
||||
(documentIds) => {
|
||||
if (!documentIds || documentIds.length === 0) {
|
||||
(documentIds: DocumentId[]) => {
|
||||
if (!documentIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idSet = new Set(documentIds);
|
||||
const idSet = new Set<DocumentId>(documentIds);
|
||||
|
||||
setDocuments((prev) => prev.filter((doc) => !idSet.has(doc.id)));
|
||||
setSearchResults((prev) => {
|
||||
setSearchResultIds((prev) => {
|
||||
if (!Array.isArray(prev)) {
|
||||
return prev;
|
||||
}
|
||||
const filtered = prev.filter((doc) => !idSet.has(doc.id));
|
||||
const filtered = prev.filter((id) => !idSet.has(id as DocumentId));
|
||||
return filtered.length === prev.length ? prev : filtered;
|
||||
});
|
||||
|
||||
setFolderContents((prev) => {
|
||||
setFolderContents((prev: Map<FolderId, FolderContentsEntry>) => {
|
||||
if (!prev.size) {
|
||||
return prev;
|
||||
}
|
||||
let changed = false;
|
||||
const next = new Map();
|
||||
const next = new Map<FolderId, FolderContentsEntry>();
|
||||
prev.forEach((contents, key) => {
|
||||
const docs = Array.isArray(contents?.documents) ? contents.documents : null;
|
||||
if (!docs || docs.length === 0) {
|
||||
@@ -714,9 +794,16 @@ const useDocumentsWorkspace = ({
|
||||
return changed ? next : prev;
|
||||
});
|
||||
|
||||
removePreviewEntries(Array.from(idSet));
|
||||
removeDocumentsFromLookup(Array.from(idSet));
|
||||
removeDocumentLinks(Array.from(idSet));
|
||||
},
|
||||
[setDocuments, setSearchResults, setFolderContents, removePreviewEntries],
|
||||
[
|
||||
setDocuments,
|
||||
setSearchResultIds,
|
||||
setFolderContents,
|
||||
removeDocumentsFromLookup,
|
||||
removeDocumentLinks,
|
||||
],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -738,7 +825,7 @@ const useDocumentsWorkspace = ({
|
||||
setSelectedFolder,
|
||||
setDocuments,
|
||||
setFolderContents,
|
||||
setSearchResults,
|
||||
setSearchResultIds,
|
||||
setSelectedEntries,
|
||||
setSelectionOrder,
|
||||
selectionOrderRef,
|
||||
@@ -758,13 +845,13 @@ const useDocumentsWorkspace = ({
|
||||
closeDocumentPreview,
|
||||
previewDocumentId,
|
||||
refreshCurrentFolder,
|
||||
documentsViewMode,
|
||||
updateDocumentCaches,
|
||||
tagLookupById,
|
||||
tags,
|
||||
refreshTags,
|
||||
tagManager,
|
||||
extractDocumentFromResponse,
|
||||
ingestDocuments: (docs) => documentsManager.ingest(docs),
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -790,7 +877,7 @@ const useDocumentsWorkspace = ({
|
||||
setLoading,
|
||||
setFolderContents,
|
||||
setCurrentFolder,
|
||||
setSearchResults,
|
||||
setSearchResultIds,
|
||||
isFilterActive,
|
||||
navigate,
|
||||
handleFileDrop,
|
||||
@@ -809,7 +896,7 @@ const useDocumentsWorkspace = ({
|
||||
} = useDocumentsSelection({
|
||||
showingSearchResults,
|
||||
currentSubfolders,
|
||||
visibleDocuments,
|
||||
visibleDocuments: viewDocuments,
|
||||
resolveFolderRowKey,
|
||||
resolveDocumentRowKey,
|
||||
configureSelectionEnvironment,
|
||||
@@ -914,7 +1001,6 @@ const useDocumentsWorkspace = ({
|
||||
resolveTargetDocumentIds,
|
||||
correspondentLookupByName,
|
||||
handleCorrespondentCreate,
|
||||
refreshCurrentFolder,
|
||||
setStatusMessage,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
@@ -922,12 +1008,13 @@ const useDocumentsWorkspace = ({
|
||||
handleFolderDelete,
|
||||
clearDocumentSelection,
|
||||
setLoading,
|
||||
updateDocumentCaches,
|
||||
});
|
||||
|
||||
|
||||
|
||||
const ensureAssetUrl = useCallback(
|
||||
async (documentId, asset, { force = false, start = null, limit = null } = {}) => {
|
||||
async (documentId, asset, { force = false } = {}) => {
|
||||
if (!documentId || !asset?.id) {
|
||||
return null;
|
||||
}
|
||||
@@ -935,23 +1022,13 @@ const useDocumentsWorkspace = ({
|
||||
try {
|
||||
const entry = await assetManager.ensureAsset(documentId, asset, {
|
||||
force,
|
||||
start,
|
||||
limit,
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setDocuments((prev) =>
|
||||
prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc)),
|
||||
);
|
||||
|
||||
setSearchResults((prev) =>
|
||||
Array.isArray(prev)
|
||||
? prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc))
|
||||
: prev,
|
||||
);
|
||||
updateDocumentCaches(documentId, (doc) => mergeAssetIntoDocument(doc, entry));
|
||||
|
||||
return entry;
|
||||
} catch (error) {
|
||||
@@ -959,7 +1036,7 @@ const useDocumentsWorkspace = ({
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[assetManager, setDocuments, setSearchResults, notifyApiError],
|
||||
[assetManager, updateDocumentCaches, notifyApiError],
|
||||
);
|
||||
|
||||
|
||||
@@ -1153,18 +1230,13 @@ const useDocumentsWorkspace = ({
|
||||
detailPanelProps,
|
||||
detailPanelOpen,
|
||||
openDetailPanel,
|
||||
handleDetailPanelClose,
|
||||
inspectDocument,
|
||||
previewActive,
|
||||
previewWorkspaceDocument,
|
||||
previewWorkspaceEntry,
|
||||
resolveThumbnailUrlForDoc,
|
||||
documentLink,
|
||||
resolveFolderPath,
|
||||
} = useDetailWorkspace({
|
||||
documents,
|
||||
searchResults,
|
||||
previewDocuments,
|
||||
focusedDocumentId,
|
||||
documents: viewDocuments,
|
||||
selectionOrder,
|
||||
selectedDocumentIds,
|
||||
documentLookup,
|
||||
@@ -1172,18 +1244,16 @@ const useDocumentsWorkspace = ({
|
||||
ensureFolderData,
|
||||
detailPanelControlRef,
|
||||
detailFolderFetchRef,
|
||||
previewEntries,
|
||||
documentLinks,
|
||||
previewDocumentId,
|
||||
activePreviewId,
|
||||
openDocumentPreview,
|
||||
promoteSelectionOrder,
|
||||
openDocumentPreview: openDocumentPreviewForDetail,
|
||||
handleDocumentTitleUpdate,
|
||||
handleDocumentIssuedUpdate,
|
||||
handleDocumentTagAdd,
|
||||
handleTagRemove,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
ensurePreviewData,
|
||||
correspondents,
|
||||
handleCorrespondentAdd,
|
||||
handleCorrespondentRemove,
|
||||
@@ -1193,6 +1263,22 @@ const useDocumentsWorkspace = ({
|
||||
tagLookupById,
|
||||
});
|
||||
|
||||
const inspectDocumentForDesk = useCallback(
|
||||
(docOrId?: DocumentLike | Identifier | null) => {
|
||||
if (docOrId == null) {
|
||||
return;
|
||||
}
|
||||
const docId: Identifier | null = Object(docOrId) === docOrId
|
||||
? (docOrId as DocumentLike)?.id ?? null
|
||||
: (docOrId as Identifier | null);
|
||||
if (docId == null) {
|
||||
return;
|
||||
}
|
||||
inspectDocument(docId);
|
||||
},
|
||||
[inspectDocument],
|
||||
);
|
||||
|
||||
const handleEntryPointerCore = useEntryPointerCore({
|
||||
resolveDocumentRowKey,
|
||||
resolveFolderRowKey,
|
||||
@@ -1307,14 +1393,101 @@ const useDocumentsWorkspace = ({
|
||||
});
|
||||
|
||||
|
||||
const documentsTableProps = useDocumentsPanelProps({
|
||||
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}`;
|
||||
}, [
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
const documentsPanelProps = useDocumentsPanelProps({
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
refreshCurrentFolder,
|
||||
currentSubfolders,
|
||||
documents,
|
||||
searchResults,
|
||||
isFilterActive,
|
||||
documents: viewDocuments,
|
||||
searchResultIds,
|
||||
folderClickHandlers,
|
||||
handleFolderDragStart,
|
||||
handleFolderDragEnd,
|
||||
@@ -1322,35 +1495,25 @@ const useDocumentsWorkspace = ({
|
||||
handleFolderRename,
|
||||
openDocumentPreview,
|
||||
handleDocumentTitleUpdate,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
focusedRowKey,
|
||||
draggedDocumentIds,
|
||||
handleDocumentDragStart,
|
||||
handleDocumentDragEnd,
|
||||
searchLoading,
|
||||
tagLookupById,
|
||||
activeCorrespondentFilters,
|
||||
selectedEntries,
|
||||
setFocusedRowKey,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
toggleTagFilter,
|
||||
toggleCorrespondentFilter,
|
||||
handleDocumentTagDrop,
|
||||
documentsViewMode,
|
||||
documentsSortField,
|
||||
documentsSortDirection,
|
||||
handleDocumentsSortFieldChange,
|
||||
handleDocumentsSortDirectionToggle,
|
||||
searchIncludeDescendants,
|
||||
toggleSearchIncludeDescendants,
|
||||
handleDocumentsViewModeChange,
|
||||
clearDocumentSelection,
|
||||
handleDeleteSelection,
|
||||
handleEntryPointerCore,
|
||||
inspectDocument,
|
||||
handleEntrySelection,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
@@ -1362,8 +1525,19 @@ const useDocumentsWorkspace = ({
|
||||
folderOptions,
|
||||
moveDocumentsToFolder,
|
||||
selectFolder,
|
||||
documentLinks,
|
||||
ensureDownloadUrl,
|
||||
selectionValue: selection,
|
||||
});
|
||||
|
||||
const documentsTableProps = useMemo(
|
||||
() => ({
|
||||
...documentsPanelProps,
|
||||
deskWorkspaceProps,
|
||||
}),
|
||||
[documentsPanelProps, deskWorkspaceProps],
|
||||
);
|
||||
|
||||
const sidebarProps = useSidebarProps({
|
||||
folderNodes,
|
||||
folderClickHandlers,
|
||||
@@ -1376,21 +1550,12 @@ const useDocumentsWorkspace = ({
|
||||
handlePromptCreateFolder,
|
||||
creatingFolder,
|
||||
tags,
|
||||
activeTagFilters,
|
||||
toggleTagFilter,
|
||||
handleTagCreate,
|
||||
correspondents,
|
||||
activeCorrespondentFilters,
|
||||
toggleCorrespondentFilter,
|
||||
handleCorrespondentCreate,
|
||||
appStatus,
|
||||
loading,
|
||||
previewActive,
|
||||
searchQuery,
|
||||
handleSearchChange,
|
||||
handleSearchSubmit,
|
||||
clearFilters,
|
||||
isFilterActive,
|
||||
handleLogout,
|
||||
status,
|
||||
tenantName,
|
||||
@@ -1402,57 +1567,6 @@ const useDocumentsWorkspace = ({
|
||||
uploadQueue,
|
||||
});
|
||||
|
||||
|
||||
|
||||
const deskWorkspaceProps = useDeskWorkspaceProps({
|
||||
documents,
|
||||
searchResults,
|
||||
breadcrumbs,
|
||||
currentFolderName,
|
||||
documentsViewMode,
|
||||
handleDocumentsViewModeChange,
|
||||
handleDeskExit: handleDeskExitSafe,
|
||||
refreshCurrentFolder,
|
||||
inspectDocument,
|
||||
handleEntryPointerCore,
|
||||
promoteSelectionOrder,
|
||||
currentTenantId,
|
||||
selectedDocumentIds,
|
||||
selectedFolderIds,
|
||||
clearDocumentSelection,
|
||||
detailPanelOpen,
|
||||
handleDetailPanelClose,
|
||||
resolveThumbnailUrlForDoc,
|
||||
handleDocumentTagDrop,
|
||||
handleTagRemove,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
activeTagFilters,
|
||||
handleDeleteSelection,
|
||||
tags,
|
||||
correspondents,
|
||||
documentLookup,
|
||||
tagLookupById,
|
||||
handleBulkTagAddFromDetail,
|
||||
handleBulkTagRemoveFromDetail,
|
||||
handleBulkCorrespondentAdd,
|
||||
handleBulkCorrespondentRemove,
|
||||
handleBulkSelectionReanalyze,
|
||||
folderOptions,
|
||||
moveDocumentsToFolder,
|
||||
searchIncludeDescendants,
|
||||
toggleSearchIncludeDescendants,
|
||||
selectedEntries,
|
||||
selectionAnchorRef,
|
||||
applySelection,
|
||||
resolveDocumentRowKey,
|
||||
showingSearchResults,
|
||||
searchQuery,
|
||||
activeCorrespondentFilters,
|
||||
selectedFolder,
|
||||
openDetailPanel,
|
||||
});
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
token,
|
||||
@@ -1485,15 +1599,13 @@ const useDocumentsWorkspace = ({
|
||||
revokePasskey,
|
||||
previewActive,
|
||||
previewWorkspaceDocument,
|
||||
previewWorkspaceEntry,
|
||||
documentLink,
|
||||
previewDocumentId,
|
||||
closeDocumentPreview,
|
||||
handleThumbnailRegeneration,
|
||||
documentsTableProps,
|
||||
detailPanelProps,
|
||||
documentsViewMode,
|
||||
deskWorkspaceProps,
|
||||
ensurePreviewData,
|
||||
ensureAssetUrl,
|
||||
resolveFolderPath,
|
||||
getDocumentAsset,
|
||||
@@ -1505,6 +1617,7 @@ const useDocumentsWorkspace = ({
|
||||
openDetailPanel,
|
||||
uploadQueue,
|
||||
clearUploadQueue,
|
||||
documentsFilter,
|
||||
}),
|
||||
[
|
||||
token,
|
||||
@@ -1537,15 +1650,13 @@ const useDocumentsWorkspace = ({
|
||||
revokePasskey,
|
||||
previewActive,
|
||||
previewWorkspaceDocument,
|
||||
previewWorkspaceEntry,
|
||||
documentLink,
|
||||
previewDocumentId,
|
||||
closeDocumentPreview,
|
||||
handleThumbnailRegeneration,
|
||||
documentsTableProps,
|
||||
detailPanelProps,
|
||||
documentsViewMode,
|
||||
deskWorkspaceProps,
|
||||
ensurePreviewData,
|
||||
ensureAssetUrl,
|
||||
resolveFolderPath,
|
||||
getDocumentAsset,
|
||||
@@ -1557,6 +1668,7 @@ const useDocumentsWorkspace = ({
|
||||
openDetailPanel,
|
||||
uploadQueue,
|
||||
clearUploadQueue,
|
||||
documentsFilter,
|
||||
],
|
||||
);
|
||||
|
||||
+25
-6
@@ -1,4 +1,23 @@
|
||||
import { useEffect } from 'react';
|
||||
import { MutableRefObject, useEffect } from 'react';
|
||||
|
||||
type FolderId = string | number | 'root' | null;
|
||||
|
||||
interface DropOverlayState {
|
||||
active: boolean;
|
||||
folderName: string | null;
|
||||
}
|
||||
|
||||
interface UseFileDropOptions {
|
||||
shellRef: MutableRefObject<HTMLElement | null>;
|
||||
token?: string | null;
|
||||
currentFolderName: string | null;
|
||||
selectedFolder: FolderId;
|
||||
handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderId) => Promise<void>;
|
||||
hasFiles: (event: DragEvent) => boolean;
|
||||
defaultFolderName: string;
|
||||
dragCounterRef: MutableRefObject<number>;
|
||||
setDropOverlayState: (updater: ((prev: DropOverlayState) => DropOverlayState) | DropOverlayState) => void;
|
||||
}
|
||||
|
||||
const useFileDrop = ({
|
||||
shellRef,
|
||||
@@ -10,7 +29,7 @@ const useFileDrop = ({
|
||||
defaultFolderName,
|
||||
dragCounterRef,
|
||||
setDropOverlayState,
|
||||
}) => {
|
||||
}: UseFileDropOptions) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
@@ -19,20 +38,20 @@ const useFileDrop = ({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleDragEnter = (event) => {
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragCounterRef.current += 1;
|
||||
setDropOverlayState({ active: true, folderName: currentFolderName });
|
||||
};
|
||||
|
||||
const handleDragOver = (event) => {
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
};
|
||||
|
||||
const handleDragLeave = (event) => {
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
if (!hasFiles(event)) return;
|
||||
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
|
||||
if (dragCounterRef.current === 0) {
|
||||
@@ -40,7 +59,7 @@ const useFileDrop = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (event) => {
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragCounterRef.current = 0;
|
||||
+129
-59
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
import {
|
||||
DEFAULT_FOLDER_NAME,
|
||||
createRootNode,
|
||||
@@ -9,9 +10,76 @@ import {
|
||||
resolveFolderRowKey,
|
||||
} from '../../app/appLayoutUtils';
|
||||
|
||||
type Identifier = string | number;
|
||||
type FolderId = Identifier | 'root';
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderSummary {
|
||||
id?: FolderId;
|
||||
name?: string;
|
||||
parent_id?: FolderId | null;
|
||||
parentId?: FolderId | null;
|
||||
children?: FolderId[];
|
||||
subfolders?: FolderSummary[];
|
||||
has_children?: boolean;
|
||||
hasChildren?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderContentsEntry {
|
||||
folder?: FolderSummary | null;
|
||||
documents?: DocumentLike[];
|
||||
subfolders?: FolderSummary[];
|
||||
__includesDocuments?: boolean;
|
||||
__sortField?: string | null;
|
||||
__sortDirection?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FolderTreeNode extends FolderSummary {
|
||||
id: FolderId;
|
||||
children: FolderId[];
|
||||
expanded?: boolean;
|
||||
loaded?: boolean;
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
|
||||
interface ApiClient {
|
||||
get<T = FolderContentsEntry>(path: string, config?: { params?: Record<string, unknown> }): Promise<{ data: T }>;
|
||||
}
|
||||
|
||||
interface SelectionHelpers {
|
||||
focusedDocumentId: Identifier | null;
|
||||
setFocusedDocumentId: Dispatch<SetStateAction<Identifier | null>>;
|
||||
setSelectedEntries: Dispatch<SetStateAction<string[]>>;
|
||||
setSelectionOrder: Dispatch<SetStateAction<string[]>>;
|
||||
selectionOrderRef: MutableRefObject<string[] | null>;
|
||||
selectionAnchorRef: MutableRefObject<string | null>;
|
||||
}
|
||||
|
||||
interface UseFolderTreeOptions {
|
||||
initialSelectedFolder?: FolderId;
|
||||
apiClient: ApiClient;
|
||||
tenantIdRef: MutableRefObject<Identifier | null>;
|
||||
documentsSortFieldRef: MutableRefObject<string>;
|
||||
documentsSortDirectionRef: MutableRefObject<string>;
|
||||
selectionHelpers: SelectionHelpers;
|
||||
setDocuments: Dispatch<SetStateAction<DocumentLike[]>>;
|
||||
setFolderContents: Dispatch<SetStateAction<Map<FolderId, FolderContentsEntry>>>;
|
||||
folderContentsRef: MutableRefObject<Map<FolderId, FolderContentsEntry>>;
|
||||
}
|
||||
|
||||
interface FolderOption {
|
||||
id: FolderId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const useFolderTree = ({
|
||||
initialSelectedFolder = 'root',
|
||||
assetManager,
|
||||
apiClient,
|
||||
tenantIdRef,
|
||||
documentsSortFieldRef,
|
||||
@@ -20,15 +88,15 @@ const useFolderTree = ({
|
||||
setDocuments,
|
||||
setFolderContents,
|
||||
folderContentsRef,
|
||||
}) => {
|
||||
const [folderNodes, setFolderNodes] = useState(() => {
|
||||
const rootNode = createRootNode();
|
||||
}: UseFolderTreeOptions) => {
|
||||
const [folderNodes, setFolderNodes] = useState<Map<FolderId, FolderTreeNode>>(() => {
|
||||
const rootNode = createRootNode() as FolderTreeNode;
|
||||
return new Map([[rootNode.id, rootNode]]);
|
||||
});
|
||||
|
||||
const [selectedFolder, setSelectedFolder] = useState(initialSelectedFolder || 'root');
|
||||
const [currentFolder, setCurrentFolder] = useState(null);
|
||||
const [currentSubfolders, setCurrentSubfolders] = useState([]);
|
||||
const [selectedFolder, setSelectedFolder] = useState<FolderId>(initialSelectedFolder || 'root');
|
||||
const [currentFolder, setCurrentFolder] = useState<FolderSummary | null>(null);
|
||||
const [currentSubfolders, setCurrentSubfolders] = useState<FolderSummary[]>([]);
|
||||
|
||||
const {
|
||||
focusedDocumentId,
|
||||
@@ -40,9 +108,9 @@ const useFolderTree = ({
|
||||
} = selectionHelpers;
|
||||
|
||||
const applySelectedFolder = useCallback(
|
||||
(folderId, contents) => {
|
||||
const subfolders = contents?.subfolders ?? [];
|
||||
const docs = assetManager.hydrateDocuments(contents?.documents ?? []);
|
||||
(folderId: FolderId, contents?: FolderContentsEntry | null) => {
|
||||
const subfolders = Array.isArray(contents?.subfolders) ? contents.subfolders : [];
|
||||
const docs = Array.isArray(contents?.documents) ? contents.documents : [];
|
||||
const folderInfo = contents?.folder ?? null;
|
||||
|
||||
setCurrentSubfolders(subfolders);
|
||||
@@ -50,12 +118,12 @@ const useFolderTree = ({
|
||||
setCurrentFolder(folderInfo);
|
||||
|
||||
const availableDocKeys = docs
|
||||
.map((doc) => resolveDocumentRowKey(doc.id))
|
||||
.map((doc) => resolveDocumentRowKey(doc?.id as Identifier))
|
||||
.filter(Boolean);
|
||||
const availableDocKeySet = new Set(availableDocKeys);
|
||||
const availableFolderKeys = new Set(
|
||||
subfolders
|
||||
.map((folder) => resolveFolderRowKey(folder.id))
|
||||
.map((folder) => resolveFolderRowKey(folder?.id as Identifier))
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
@@ -91,7 +159,6 @@ const useFolderTree = ({
|
||||
setSelectionOrder(mergedSelection);
|
||||
},
|
||||
[
|
||||
assetManager,
|
||||
focusedDocumentId,
|
||||
selectionAnchorRef,
|
||||
selectionOrderRef,
|
||||
@@ -102,20 +169,20 @@ const useFolderTree = ({
|
||||
],
|
||||
);
|
||||
|
||||
const expandFolderAncestors = useCallback((targetId) => {
|
||||
const expandFolderAncestors = useCallback((targetId: FolderId | null) => {
|
||||
if (!targetId || targetId === 'root') {
|
||||
setFolderNodes((prev) => {
|
||||
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
|
||||
const root = prev.get('root');
|
||||
if (root?.expanded) return prev;
|
||||
const next = new Map(prev);
|
||||
const next = new Map<FolderId, FolderTreeNode>(prev);
|
||||
next.set('root', { ...root, expanded: true });
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
|
||||
const next = new Map<FolderId, FolderTreeNode>(prev);
|
||||
let currentId = targetId;
|
||||
let guard = 0;
|
||||
while (currentId && guard < 32) {
|
||||
@@ -133,15 +200,21 @@ const useFolderTree = ({
|
||||
|
||||
const ensureFolderData = useCallback(
|
||||
async (
|
||||
folderId,
|
||||
folderId: FolderId,
|
||||
{
|
||||
includeDocuments = true,
|
||||
prefetchDepth = 0,
|
||||
force = false,
|
||||
sortField = documentsSortFieldRef.current,
|
||||
sortDirection = documentsSortDirectionRef.current,
|
||||
}: {
|
||||
includeDocuments?: boolean;
|
||||
prefetchDepth?: number;
|
||||
force?: boolean;
|
||||
sortField?: string;
|
||||
sortDirection?: string;
|
||||
} = {},
|
||||
) => {
|
||||
): Promise<FolderContentsEntry> => {
|
||||
const requestTenantId = tenantIdRef.current;
|
||||
const cached = folderContentsRef.current.get(folderId);
|
||||
const cachedSortField = cached?.__sortField || documentsSortFieldRef.current;
|
||||
@@ -168,7 +241,7 @@ const useFolderTree = ({
|
||||
}
|
||||
|
||||
const path = folderId === 'root' ? 'root' : folderId;
|
||||
const params = {};
|
||||
const params: Record<string, unknown> = {};
|
||||
if (!includeDocuments) {
|
||||
params.include_documents = false;
|
||||
} else {
|
||||
@@ -176,13 +249,14 @@ const useFolderTree = ({
|
||||
params.dir = sortDirection;
|
||||
}
|
||||
const requestConfig = Object.keys(params).length ? { params } : {};
|
||||
const { data } = await apiClient.get(`/folders/${path}/contents`, requestConfig);
|
||||
const hydrated = assetManager.hydrateFolderContents(data);
|
||||
const { data } = await apiClient.get<FolderContentsEntry>(`/folders/${path}/contents`, requestConfig);
|
||||
const childFolders = Array.isArray(data.subfolders) ? data.subfolders : [];
|
||||
const childIds = childFolders.map((child) => child.id);
|
||||
const childIds = childFolders
|
||||
.map((child) => (child?.id ?? null) as FolderId | null)
|
||||
.filter((id): id is FolderId => Boolean(id));
|
||||
|
||||
const enriched = {
|
||||
...hydrated,
|
||||
...data,
|
||||
__includesDocuments: includeDocuments,
|
||||
__sortField: includeDocuments ? sortField : cachedSortField,
|
||||
__sortDirection: includeDocuments ? sortDirection : cachedSortDirection,
|
||||
@@ -192,8 +266,8 @@ const useFolderTree = ({
|
||||
return enriched;
|
||||
}
|
||||
|
||||
setFolderNodes((prev) => {
|
||||
const next = new Map(prev);
|
||||
setFolderNodes((prev: Map<FolderId, FolderTreeNode>) => {
|
||||
const next = new Map<FolderId, FolderTreeNode>(prev);
|
||||
const existingNode = next.get(folderId) || {
|
||||
id: folderId,
|
||||
name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || 'Folder',
|
||||
@@ -215,7 +289,11 @@ const useFolderTree = ({
|
||||
});
|
||||
|
||||
childFolders.forEach((child) => {
|
||||
const childNode = next.get(child.id);
|
||||
const childId = (child?.id ?? null) as FolderId | null;
|
||||
if (!childId) {
|
||||
return;
|
||||
}
|
||||
const childNode = next.get(childId);
|
||||
const previousChildren = Array.isArray(childNode?.children) ? childNode.children : [];
|
||||
const childHasChildren = (() => {
|
||||
if (childNode?.loaded) {
|
||||
@@ -224,21 +302,14 @@ const useFolderTree = ({
|
||||
if (Array.isArray(child?.subfolders)) {
|
||||
return child.subfolders.length > 0;
|
||||
}
|
||||
if (typeof child?.has_children === 'boolean') {
|
||||
return child.has_children;
|
||||
}
|
||||
if (typeof child?.hasChildren === 'boolean') {
|
||||
return child.hasChildren;
|
||||
}
|
||||
if (typeof childNode?.hasChildren === 'boolean') {
|
||||
return childNode.hasChildren;
|
||||
}
|
||||
return false;
|
||||
const flag = [child?.has_children, child?.hasChildren, childNode?.hasChildren]
|
||||
.find((value) => value != null);
|
||||
return Boolean(flag);
|
||||
})();
|
||||
next.set(child.id, {
|
||||
id: child.id,
|
||||
next.set(childId, {
|
||||
id: childId,
|
||||
name: child.name,
|
||||
parentId: child.parent_id ?? 'root',
|
||||
parentId: (child.parent_id ?? 'root') as FolderId,
|
||||
children: previousChildren,
|
||||
expanded: childNode?.expanded ?? false,
|
||||
loaded: childNode?.loaded ?? false,
|
||||
@@ -261,11 +332,11 @@ const useFolderTree = ({
|
||||
);
|
||||
}
|
||||
|
||||
setFolderContents((prev) => {
|
||||
setFolderContents((prev: Map<FolderId, FolderContentsEntry>) => {
|
||||
if (tenantIdRef.current !== requestTenantId) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
const next = new Map<FolderId, FolderContentsEntry>(prev);
|
||||
if (includeDocuments) {
|
||||
next.set(folderId, enriched);
|
||||
} else {
|
||||
@@ -273,10 +344,10 @@ const useFolderTree = ({
|
||||
if (existingEntry) {
|
||||
next.set(folderId, {
|
||||
...existingEntry,
|
||||
...hydrated,
|
||||
...data,
|
||||
documents: existingEntry.__includesDocuments
|
||||
? existingEntry.documents
|
||||
: hydrated.documents,
|
||||
: data.documents,
|
||||
__includesDocuments: existingEntry.__includesDocuments || false,
|
||||
__sortField: existingEntry.__sortField ?? enriched.__sortField,
|
||||
__sortDirection: existingEntry.__sortDirection ?? enriched.__sortDirection,
|
||||
@@ -292,7 +363,6 @@ const useFolderTree = ({
|
||||
},
|
||||
[
|
||||
apiClient,
|
||||
assetManager,
|
||||
documentsSortDirectionRef,
|
||||
documentsSortFieldRef,
|
||||
tenantIdRef,
|
||||
@@ -302,7 +372,7 @@ const useFolderTree = ({
|
||||
);
|
||||
|
||||
const ensureFolderAncestorsLoaded = useCallback(
|
||||
async (targetId) => {
|
||||
async (targetId: FolderId | null) => {
|
||||
if (!targetId || targetId === 'root') {
|
||||
return;
|
||||
}
|
||||
@@ -323,7 +393,7 @@ const useFolderTree = ({
|
||||
);
|
||||
|
||||
const isInvalidFolderDrop = useCallback(
|
||||
(sourceId, targetId) => {
|
||||
(sourceId: FolderId | null, targetId: FolderId | null) => {
|
||||
if (!sourceId) return false;
|
||||
if (!targetId || targetId === 'root') {
|
||||
return false;
|
||||
@@ -349,9 +419,9 @@ const useFolderTree = ({
|
||||
);
|
||||
|
||||
const resetFolderTreeState = useCallback(() => {
|
||||
const rootNode = createRootNode();
|
||||
setFolderNodes(new Map([[rootNode.id, rootNode]]));
|
||||
setFolderContents(new Map());
|
||||
const rootNode = createRootNode() as FolderTreeNode;
|
||||
setFolderNodes(new Map<FolderId, FolderTreeNode>([[rootNode.id, rootNode]]));
|
||||
setFolderContents(new Map<FolderId, FolderContentsEntry>());
|
||||
setSelectedFolder('root');
|
||||
setCurrentFolder(null);
|
||||
setCurrentSubfolders([]);
|
||||
@@ -362,11 +432,11 @@ const useFolderTree = ({
|
||||
return currentFolder.name;
|
||||
}, [selectedFolder, currentFolder]);
|
||||
|
||||
const folderOptions = useMemo(() => {
|
||||
const cache = new Map();
|
||||
const computePath = (id) => {
|
||||
if (cache.has(id)) {
|
||||
return cache.get(id);
|
||||
const folderOptions: FolderOption[] = useMemo(() => {
|
||||
const cache = new Map<FolderId, string>();
|
||||
const computePath = (id: FolderId | null): string => {
|
||||
if (cache.has(id as FolderId)) {
|
||||
return cache.get(id as FolderId) as string;
|
||||
}
|
||||
if (!id || id === 'root') {
|
||||
cache.set('root', DEFAULT_FOLDER_NAME);
|
||||
@@ -376,7 +446,7 @@ const useFolderTree = ({
|
||||
if (!node) {
|
||||
return 'Folder';
|
||||
}
|
||||
const parentId = node.parentId || 'root';
|
||||
const parentId = (node.parentId || 'root') as FolderId;
|
||||
const parentPath = computePath(parentId);
|
||||
const name = node.name || 'Folder';
|
||||
const fullPath = parentId === 'root' ? name : `${parentPath}/${name}`;
|
||||
@@ -384,7 +454,7 @@ const useFolderTree = ({
|
||||
return fullPath;
|
||||
};
|
||||
|
||||
const entries = [];
|
||||
const entries: FolderOption[] = [];
|
||||
folderNodes.forEach((node, id) => {
|
||||
if (!node) return;
|
||||
entries.push({ id, label: computePath(id) });
|
||||
@@ -400,7 +470,7 @@ const useFolderTree = ({
|
||||
}, [folderNodes]);
|
||||
|
||||
const folderLabelMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
const map = new Map<FolderId, string>();
|
||||
folderOptions.forEach((option) => {
|
||||
map.set(option.id, option.label);
|
||||
});
|
||||
+110
-24
@@ -1,6 +1,88 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { DragEvent } from 'react';
|
||||
import { DEFAULT_FOLDER_NAME, hasFiles } from '../../app/appLayoutUtils';
|
||||
|
||||
type FolderId = string | number;
|
||||
type FolderKey = FolderId | 'root';
|
||||
|
||||
interface FolderNode {
|
||||
id: FolderKey;
|
||||
name?: string;
|
||||
parentId?: FolderKey | null;
|
||||
children: FolderId[];
|
||||
expanded?: boolean;
|
||||
loaded?: boolean;
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
|
||||
interface FolderContentsState {
|
||||
folder?: { id: FolderKey; name?: string };
|
||||
subfolders?: any[];
|
||||
documents?: any[];
|
||||
[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;
|
||||
prefetchDepth?: number;
|
||||
}
|
||||
|
||||
interface LoadFolderOptions {
|
||||
showLoading?: boolean;
|
||||
preserveSearch?: boolean;
|
||||
}
|
||||
|
||||
interface SelectFolderOptions {
|
||||
replace?: boolean;
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
interface FolderClickHandlers {
|
||||
onToggle: (folderId: FolderKey) => Promise<void>;
|
||||
onSelect: (folderId: FolderKey, options?: SelectFolderOptions) => Promise<void>;
|
||||
onDrop: (event: DragEvent<HTMLElement>, folderId: FolderKey) => Promise<void>;
|
||||
onDragOver: (event: DragEvent<HTMLElement>, folderId: FolderKey) => void;
|
||||
onDragLeave: (event: DragEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
interface UseFolderTreeActionsOptions {
|
||||
api: ApiClient;
|
||||
token?: string | null;
|
||||
folderNodes: Map<FolderKey, FolderNode>;
|
||||
setFolderNodes: (updater: (prev: Map<FolderKey, FolderNode>) => Map<FolderKey, FolderNode>) => void;
|
||||
selectedFolder: FolderKey;
|
||||
setSelectedFolder: (folderId: FolderKey) => void;
|
||||
ensureFolderData: (folderId: FolderKey, options?: EnsureFolderOptions) => Promise<any>;
|
||||
ensureFolderAncestorsLoaded: (folderId: FolderKey) => Promise<void>;
|
||||
expandFolderAncestors: (folderId: FolderKey) => void;
|
||||
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;
|
||||
setCurrentFolder: (updater: (prev: any) => any) => void;
|
||||
setSearchResultIds: (value: any) => void;
|
||||
isFilterActive: boolean;
|
||||
navigate?: (path: string, options?: { replace?: boolean }) => void;
|
||||
handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderKey) => Promise<void> | void;
|
||||
moveDocumentsToFolder: (docIds: FolderId[], folderId: FolderKey) => Promise<void>;
|
||||
draggedDocumentIds: FolderId[];
|
||||
draggedFolderId: FolderKey | null;
|
||||
setDraggedDocumentIds: (ids: FolderId[]) => void;
|
||||
setDraggedFolderId: (id: FolderKey | null) => void;
|
||||
isInvalidFolderDrop: (sourceFolderId: FolderKey, targetFolderId: FolderKey) => boolean;
|
||||
setCreatingFolder: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const useFolderTreeActions = ({
|
||||
api,
|
||||
token,
|
||||
@@ -17,7 +99,7 @@ const useFolderTreeActions = ({
|
||||
setLoading,
|
||||
setFolderContents,
|
||||
setCurrentFolder,
|
||||
setSearchResults,
|
||||
setSearchResultIds,
|
||||
isFilterActive,
|
||||
navigate,
|
||||
handleFileDrop,
|
||||
@@ -28,9 +110,9 @@ const useFolderTreeActions = ({
|
||||
setDraggedFolderId,
|
||||
isInvalidFolderDrop,
|
||||
setCreatingFolder,
|
||||
}) => {
|
||||
}: UseFolderTreeActionsOptions) => {
|
||||
const moveFolder = useCallback(
|
||||
async (folderId, targetFolderId) => {
|
||||
async (folderId: FolderKey, targetFolderId: FolderKey | null) => {
|
||||
const node = folderNodes.get(folderId);
|
||||
if (!node) {
|
||||
setStatusMessage('Folder metadata unavailable. Try refreshing.', 'error');
|
||||
@@ -95,9 +177,11 @@ const useFolderTreeActions = ({
|
||||
});
|
||||
|
||||
const refreshTargets = new Set([previousParentKey, targetKey]);
|
||||
for (const key of refreshTargets) {
|
||||
await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 });
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from(refreshTargets).map((key) =>
|
||||
ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }),
|
||||
),
|
||||
);
|
||||
|
||||
if (selectedFolder === folderId) {
|
||||
await ensureFolderData(folderId, { force: true, prefetchDepth: 1 });
|
||||
@@ -110,9 +194,11 @@ const useFolderTreeActions = ({
|
||||
notifyApiError(error, message);
|
||||
|
||||
const refreshTargets = new Set([previousParentKey, targetKey]);
|
||||
for (const key of refreshTargets) {
|
||||
await ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 });
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from(refreshTargets).map((key) =>
|
||||
ensureFolderData(key === 'root' ? 'root' : key, { force: true, prefetchDepth: 1 }),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -128,7 +214,7 @@ const useFolderTreeActions = ({
|
||||
);
|
||||
|
||||
const loadFolder = useCallback(
|
||||
async (folderId, { showLoading = true, preserveSearch = false } = {}) => {
|
||||
async (folderId: FolderKey | null, { showLoading = true, preserveSearch = false }: LoadFolderOptions = {}) => {
|
||||
const targetId = folderId || 'root';
|
||||
setSelectedFolder(targetId);
|
||||
await ensureFolderAncestorsLoaded(targetId);
|
||||
@@ -149,7 +235,7 @@ const useFolderTreeActions = ({
|
||||
}
|
||||
applySelectedFolder(targetId, contents);
|
||||
if (!preserveSearch) {
|
||||
setSearchResults(null);
|
||||
setSearchResultIds(null);
|
||||
}
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Failed to load folder contents.');
|
||||
@@ -164,13 +250,13 @@ const useFolderTreeActions = ({
|
||||
expandFolderAncestors,
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
setSearchResults,
|
||||
setSearchResultIds,
|
||||
setSelectedFolder,
|
||||
],
|
||||
);
|
||||
|
||||
const selectFolder = useCallback(
|
||||
async (folderId, { replace = false, immediate = false } = {}) => {
|
||||
async (folderId: FolderKey | null, { replace = false, immediate = false }: SelectFolderOptions = {}) => {
|
||||
const targetId = folderId && folderId !== 'root' ? folderId : 'root';
|
||||
|
||||
await ensureFolderAncestorsLoaded(targetId);
|
||||
@@ -196,12 +282,12 @@ const useFolderTreeActions = ({
|
||||
);
|
||||
|
||||
const handleFolderRename = useCallback(
|
||||
async (folderId, nextName) => {
|
||||
async (folderId: FolderKey, nextName: string) => {
|
||||
if (!token) {
|
||||
setStatusMessage('Log in to rename folders.', 'error');
|
||||
return false;
|
||||
}
|
||||
const trimmed = typeof nextName === 'string' ? nextName.trim() : '';
|
||||
const trimmed = nextName?.trim?.() || '';
|
||||
if (!trimmed) {
|
||||
setStatusMessage('Folder name cannot be empty.', 'error');
|
||||
return false;
|
||||
@@ -257,7 +343,7 @@ const useFolderTreeActions = ({
|
||||
);
|
||||
|
||||
const handleFolderCreate = useCallback(
|
||||
async (name) => {
|
||||
async (name: string) => {
|
||||
if (!token) {
|
||||
setStatusMessage('Log in to create folders.', 'error');
|
||||
return false;
|
||||
@@ -327,7 +413,7 @@ const useFolderTreeActions = ({
|
||||
);
|
||||
|
||||
const handleFolderDelete = useCallback(
|
||||
async (folderId, { showMessage = true, manageLoading = true } = {}) => {
|
||||
async (folderId: FolderKey, { showMessage = true, manageLoading = true }: { showMessage?: boolean; manageLoading?: boolean } = {}) => {
|
||||
if (!token) {
|
||||
if (showMessage) {
|
||||
setStatusMessage('Log in to manage folders.', 'error');
|
||||
@@ -432,9 +518,9 @@ const useFolderTreeActions = ({
|
||||
],
|
||||
);
|
||||
|
||||
const folderClickHandlers = useMemo(
|
||||
const folderClickHandlers: FolderClickHandlers = useMemo(
|
||||
() => ({
|
||||
onToggle: async (folderId) => {
|
||||
onToggle: async (folderId: FolderKey) => {
|
||||
const node = folderNodes.get(folderId);
|
||||
const nextExpanded = !(node?.expanded ?? false);
|
||||
if (nextExpanded) {
|
||||
@@ -465,12 +551,12 @@ const useFolderTreeActions = ({
|
||||
});
|
||||
},
|
||||
onSelect: selectFolder,
|
||||
onDrop: async (event, folderId) => {
|
||||
onDrop: async (event: DragEvent<HTMLElement>, folderId: FolderKey) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.classList.remove('is-drop-target');
|
||||
|
||||
let folderIds = [];
|
||||
let folderIds: FolderId[] = [];
|
||||
try {
|
||||
const rawFolderList = event.dataTransfer.getData('application/x-papercrate-folder-list');
|
||||
if (rawFolderList) {
|
||||
@@ -523,7 +609,7 @@ const useFolderTreeActions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
let docIds = [];
|
||||
let docIds: FolderId[] = [];
|
||||
try {
|
||||
const raw = event.dataTransfer.getData('application/x-papercrate-doc-list');
|
||||
if (raw) {
|
||||
@@ -560,7 +646,7 @@ const useFolderTreeActions = ({
|
||||
setDraggedDocumentIds([]);
|
||||
await moveDocumentsToFolder(docIds, folderId);
|
||||
},
|
||||
onDragOver: (event, folderId) => {
|
||||
onDragOver: (event: DragEvent<HTMLElement>, folderId: FolderKey) => {
|
||||
const folderDragActive = Boolean(draggedFolderId);
|
||||
if (folderDragActive && isInvalidFolderDrop(draggedFolderId, folderId)) {
|
||||
return;
|
||||
@@ -579,7 +665,7 @@ const useFolderTreeActions = ({
|
||||
event.currentTarget.classList.add('is-drop-target');
|
||||
}
|
||||
},
|
||||
onDragLeave: (event) => {
|
||||
onDragLeave: (event: DragEvent<HTMLElement>) => {
|
||||
event.currentTarget.classList.remove('is-drop-target');
|
||||
},
|
||||
}),
|
||||
@@ -1,4 +1,32 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { MutableRefObject, useCallback, useState } from 'react';
|
||||
|
||||
type 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 {
|
||||
buildPayload: (input: { label?: string; color?: string | null }) => { label: string; color: string | null };
|
||||
}
|
||||
|
||||
interface TagEntry {
|
||||
id?: string | number;
|
||||
label?: string;
|
||||
color?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseTagsOptions {
|
||||
apiClient: ApiClient;
|
||||
notifyApiError: (error: unknown, fallback: string) => void;
|
||||
setStatusMessage: (message: string, variant?: string) => void;
|
||||
tagManager: TagManagerInterface;
|
||||
tenantIdRef: MutableRefObject<string | number | null>;
|
||||
setActiveTagFilters: (updater: (prev: Array<string | number>) => Array<string | number>) => void;
|
||||
mapDocumentCaches?: (mapper: (doc: any) => any) => void;
|
||||
}
|
||||
|
||||
const useTags = ({
|
||||
apiClient,
|
||||
@@ -8,8 +36,8 @@ const useTags = ({
|
||||
tenantIdRef,
|
||||
setActiveTagFilters,
|
||||
mapDocumentCaches,
|
||||
}) => {
|
||||
const [tags, setTags] = useState([]);
|
||||
}: UseTagsOptions) => {
|
||||
const [tags, setTags] = useState<TagEntry[]>([]);
|
||||
|
||||
const refreshTags = useCallback(async () => {
|
||||
const requestTenantId = tenantIdRef.current;
|
||||
@@ -28,13 +56,13 @@ const useTags = ({
|
||||
}, [apiClient, notifyApiError, tenantIdRef]);
|
||||
|
||||
const handleTagUpdate = useCallback(
|
||||
async (tagId, changes) => {
|
||||
if (!tagId) {
|
||||
async (tagId: string | number, changes: { label?: string; color?: string | null }) => {
|
||||
if (tagId == null) {
|
||||
throw new Error('Missing tag identifier.');
|
||||
}
|
||||
|
||||
const payload = {};
|
||||
if (typeof changes.label === 'string') {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (changes?.label != null) {
|
||||
payload.label = changes.label;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'color')) {
|
||||
@@ -60,7 +88,7 @@ const useTags = ({
|
||||
);
|
||||
|
||||
const handleTagCreate = useCallback(
|
||||
async ({ label, color } = {}) => {
|
||||
async ({ label, color }: { label?: string; color?: string | null } = {}) => {
|
||||
const payload = tagManager.buildPayload({ label, color });
|
||||
try {
|
||||
await apiClient.post('/tags', payload);
|
||||
@@ -76,8 +104,8 @@ const useTags = ({
|
||||
);
|
||||
|
||||
const handleTagDelete = useCallback(
|
||||
async (tagId) => {
|
||||
if (!tagId) {
|
||||
async (tagId: string | number) => {
|
||||
if (tagId == null) {
|
||||
throw new Error('Missing tag identifier.');
|
||||
}
|
||||
|
||||
@@ -85,7 +113,7 @@ const useTags = ({
|
||||
await apiClient.delete(`/tags/${tagId}`);
|
||||
setActiveTagFilters((prev) => prev.filter((id) => id !== tagId));
|
||||
|
||||
const stripTagFromDoc = (doc) => {
|
||||
const stripTagFromDoc = (doc: any) => {
|
||||
if (!doc || !Array.isArray(doc.tags)) {
|
||||
return doc;
|
||||
}
|
||||
@@ -96,9 +124,7 @@ const useTags = ({
|
||||
return { ...doc, tags: nextTags };
|
||||
};
|
||||
|
||||
if (typeof mapDocumentCaches === 'function') {
|
||||
mapDocumentCaches(stripTagFromDoc);
|
||||
}
|
||||
mapDocumentCaches?.(stripTagFromDoc);
|
||||
|
||||
await refreshTags();
|
||||
setStatusMessage('Tag deleted.', 'success');
|
||||
+32
-3
@@ -1,4 +1,33 @@
|
||||
import { useCallback } from 'react';
|
||||
import { MutableRefObject, useCallback } from 'react';
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
interface ApiClient {
|
||||
get: (path: string) => Promise<{ data: unknown }>;
|
||||
post: (path: string, body?: unknown) => Promise<{ data: any }>;
|
||||
defaults: { headers: { common: Record<string, unknown> } };
|
||||
}
|
||||
|
||||
interface TenantOption {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface UseTenantManagerOptions {
|
||||
apiClient: ApiClient;
|
||||
appDispatch: (action: any) => void;
|
||||
currentTenantId: string | number | null;
|
||||
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>;
|
||||
handleDocumentsViewModeChange: (mode: string) => void;
|
||||
navigate: NavigateFunction;
|
||||
tokenRef?: MutableRefObject<string | null>;
|
||||
tenantIdRef?: MutableRefObject<string | number | null>;
|
||||
}
|
||||
|
||||
const useTenantManager = ({
|
||||
apiClient,
|
||||
@@ -15,9 +44,9 @@ const useTenantManager = ({
|
||||
navigate,
|
||||
tokenRef,
|
||||
tenantIdRef,
|
||||
}) => {
|
||||
}: UseTenantManagerOptions) => {
|
||||
const handleTenantSelect = useCallback(
|
||||
async (tenantOption, { refreshOnly = false } = {}) => {
|
||||
async (tenantOption: TenantOption | null, { refreshOnly = false }: { refreshOnly?: boolean } = {}) => {
|
||||
const requestedTenantId = tenantOption?.id ?? null;
|
||||
if (!refreshOnly && (!requestedTenantId || requestedTenantId === currentTenantId)) {
|
||||
return;
|
||||
Reference in New Issue
Block a user