refactor: centralize API error notifications with a new useNotifyApiError hook

This commit is contained in:
2025-12-07 23:55:47 +01:00
parent 595c170c00
commit d46db2c701
14 changed files with 59 additions and 55 deletions
+3 -2
View File
@@ -12,6 +12,8 @@ import type { Document } from '../types/documents';
type NavigateHandler = (path: string, options?: { replace?: boolean }) => void;
import useNotifyApiError from '../hooks/useNotifyApiError';
interface UseDocumentPreviewArgs {
routeDocumentId?: DocumentId | null;
documentsManager: {
@@ -22,7 +24,6 @@ interface UseDocumentPreviewArgs {
ingest: (docs: unknown[]) => { canonical: Document[]; changed: boolean };
};
selectedFolder?: FolderId | null;
notifyApiError: (error: unknown, message: string) => void;
navigate: NavigateHandler;
locationPathname: string;
locationSearch: string;
@@ -44,7 +45,6 @@ const useDocumentPreview = ({
routeDocumentId,
documentsManager,
selectedFolder,
notifyApiError,
navigate,
locationPathname,
locationSearch,
@@ -52,6 +52,7 @@ const useDocumentPreview = ({
setActivePreviewId,
}: UseDocumentPreviewArgs): UseDocumentPreviewResult => {
const previewReturnPathRef = useRef<string | null>(null);
const notifyApiError = useNotifyApiError();
const resetPreviewState = useCallback(() => {
previewReturnPathRef.current = null;
+3 -2
View File
@@ -10,6 +10,8 @@ type ApiClient = {
get: <T = unknown>(url: string, config?: { params?: Record<string, unknown> }) => Promise<{ data: T }>;
};
import useNotifyApiError from '../hooks/useNotifyApiError';
interface UseDocumentsSearchArgs {
api: ApiClient;
token?: string | null;
@@ -20,7 +22,6 @@ interface UseDocumentsSearchArgs {
searchIncludeDescendants?: boolean;
documentsSortField?: string;
documentsSortDirection?: string;
notifyApiError: (error: unknown, message: string) => void;
setSearchIncludeDescendants: (value: boolean) => void;
documentsManager: {
ingest: (docs: unknown[]) => { canonical: Document[]; changed: boolean };
@@ -72,7 +73,6 @@ const useDocumentsSearch = ({
searchIncludeDescendants,
documentsSortField,
documentsSortDirection,
notifyApiError,
setSearchIncludeDescendants,
documentsManager,
}: UseDocumentsSearchArgs): UseDocumentsSearchResult => {
@@ -82,6 +82,7 @@ const useDocumentsSearch = ({
const [searchResultIds, setSearchResultIds] = useState<Identifier[] | null>(null);
const [searchLoading, setSearchLoading] = useState<boolean>(false);
const [searchTrigger, setSearchTrigger] = useState<number>(0);
const notifyApiError = useNotifyApiError();
const toggleTagFilter = useCallback((tagId: Identifier) => {
if (!tagId) return;
@@ -4,19 +4,20 @@ import type { Correspondent } from '../../types/documents';
import { listCorrespondents, createCorrespondent, updateCorrespondent, deleteCorrespondent } from '../../lib/api/apiClient';
import useNotifyApiError from '../../hooks/useNotifyApiError';
interface UseCorrespondentsOptions {
notifyApiError: (error: unknown, fallback: string) => void;
tenantIdRef: MutableRefObject<string | null>;
mapDocumentCaches?: (mapper: (doc: any) => any) => void;
}
const useCorrespondents = ({
notifyApiError,
tenantIdRef,
mapDocumentCaches,
}: UseCorrespondentsOptions) => {
const [correspondents, setCorrespondents] = useState<Correspondent[]>([]);
const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError();
const refreshCorrespondents = useCallback(async () => {
const requestTenantId = tenantIdRef.current;
@@ -40,8 +40,6 @@ type RemoveDocumentsFromCaches = (documentIds: DocumentId[]) => void;
type CloseDocumentPreview = () => void;
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
interface Tag {
id: DocumentId;
label: string;
@@ -73,6 +71,8 @@ interface DocumentTagExtras {
input?: { value?: string } | null;
}
import useNotifyApiError from '../../hooks/useNotifyApiError';
interface UseDocumentMutationsArgs {
token?: string | null;
documentLookup: Map<DocumentId, Document>;
@@ -90,7 +90,6 @@ interface UseDocumentMutationsArgs {
focusedDocumentId: DocumentId | null;
setFocusedEntryKey: Dispatch<SetStateAction<string | null>>;
focusedEntryKey: string | null;
notifyApiError: NotifyApiError;
mapDocumentCaches: MapDocumentCaches;
folderNodes: Map<FolderId, FolderNode>;
setFolderNodes: Dispatch<SetStateAction<Map<FolderId, FolderNode>>>;
@@ -160,7 +159,6 @@ const useDocumentMutations = ({
focusedDocumentId,
setFocusedEntryKey,
focusedEntryKey,
notifyApiError,
mapDocumentCaches,
folderNodes,
setFolderNodes,
@@ -177,6 +175,7 @@ const useDocumentMutations = ({
ingestDocuments,
}: UseDocumentMutationsArgs): UseDocumentMutationsResult => {
const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError();
const moveDocumentsToFolder = useCallback(
async (documentIds: Array<DocumentId | Document>, targetFolderId?: NullableFolderId) => {
@@ -13,7 +13,7 @@ import {
useNavigate,
} from 'react-router-dom';
import AssetManager, { getAssetFromVersion } from '../../lib/assets/AssetManager';
import useApiError from '../../hooks/useApiError';
import useNotifyApiError from '../../hooks/useNotifyApiError';
import TagManager from '../../lib/assets/TagManager';
import { useManagementModals } from '../../app/useManagementModals';
import { useAppDispatch, useAppState } from '../../lib/store/appState';
@@ -144,19 +144,7 @@ const useDocumentsWorkspace = ({
? (tenantOptionsRaw as TenantOption[])
: [];
const { showToast } = useStatusToast();
const reportApiError = useApiError({
onReport: useCallback(
({ message, variant }) => showToast(message, variant),
[showToast],
),
});
const notifyApiError = useCallback(
(error, fallbackMessage, variant = 'error') =>
reportApiError(error, { message: fallbackMessage, variant }),
[reportApiError],
);
const notifyApiError = useNotifyApiError();
const [creatingFolder, setCreatingFolder] = useState(false);
@@ -389,7 +377,6 @@ const useDocumentsWorkspace = ({
searchIncludeDescendants,
documentsSortField,
documentsSortDirection,
notifyApiError,
setSearchIncludeDescendants,
documentsManager,
});
@@ -457,7 +444,6 @@ const useDocumentsWorkspace = ({
routeDocumentId: previewDocumentId,
documentsManager,
selectedFolder,
notifyApiError,
navigate,
locationPathname: location.pathname,
locationSearch: location.search,
@@ -506,7 +492,6 @@ const useDocumentsWorkspace = ({
handleTagDelete,
setTags,
} = useTags({
notifyApiError,
tenantIdRef,
tagManager,
setActiveTagFilters,
@@ -535,7 +520,6 @@ const useDocumentsWorkspace = ({
handleCorrespondentDelete,
setCorrespondents,
} = useCorrespondents({
notifyApiError,
tenantIdRef,
mapDocumentCaches,
});
@@ -548,7 +532,6 @@ const useDocumentsWorkspace = ({
} = useDocumentCorrespondentActions({
correspondents,
handleCorrespondentCreate,
notifyApiError,
updateDocumentCaches,
});
@@ -562,7 +545,6 @@ const useDocumentsWorkspace = ({
registerPasskey,
revokePasskey,
} = usePasskeys({
notifyApiError,
token,
});
@@ -594,7 +576,6 @@ const useDocumentsWorkspace = ({
tagManager,
refreshTags,
resolveTargetDocumentIds,
notifyApiError,
updateDocumentCaches,
});
@@ -612,7 +593,6 @@ const useDocumentsWorkspace = ({
ensureFolderData,
refreshCurrentFolder,
shellRef,
notifyApiError,
});
const {
@@ -747,7 +727,6 @@ const useDocumentsWorkspace = ({
focusedDocumentId,
setFocusedEntryKey,
focusedEntryKey,
notifyApiError,
mapDocumentCaches,
folderNodes,
setFolderNodes,
@@ -777,7 +756,6 @@ const useDocumentsWorkspace = ({
setFolderNodes,
selectedFolder,
setSelectedFolder,
notifyApiError,
navigate,
handleFileDrop,
moveDocumentsToFolder,
@@ -1105,7 +1083,6 @@ const useDocumentsWorkspace = ({
appDispatch,
currentTenantId,
resetWorkspaceState,
notifyApiError,
refreshTags,
refreshCorrespondents,
loadFolder,
+3 -2
View File
@@ -9,9 +9,10 @@ interface TagManagerInterface {
buildPayload: (input: { label?: string; color?: string | null }) => { label: string; color: string | null };
}
import useNotifyApiError from '../../hooks/useNotifyApiError';
interface UseTagsOptions {
// apiClient removed
notifyApiError: (error: unknown, fallback: string) => void;
tagManager: TagManagerInterface;
tenantIdRef: MutableRefObject<TenantId | null>;
setActiveTagFilters: (updater: (prev: Array<TagId>) => Array<TagId>) => void;
@@ -20,7 +21,6 @@ interface UseTagsOptions {
const useTags = ({
// apiClient removed
notifyApiError,
tagManager,
tenantIdRef,
setActiveTagFilters,
@@ -28,6 +28,7 @@ const useTags = ({
}: UseTagsOptions) => {
const [tags, setTags] = useState<Tag[]>([]);
const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError();
const refreshTags = useCallback(async () => {
const requestTenantId = tenantIdRef.current;
@@ -10,11 +10,12 @@ interface TenantOption {
name?: string;
}
import useNotifyApiError from '../../hooks/useNotifyApiError';
interface UseTenantManagerOptions {
appDispatch: (action: any) => void;
currentTenantId: TenantId | null;
resetWorkspaceState: () => void;
notifyApiError: (error: unknown, message: string) => void;
refreshTags: () => Promise<void>;
refreshCorrespondents: () => Promise<void>;
loadFolder: (folderId: FolderId, options?: { preserveSearch?: boolean }) => Promise<void>;
@@ -28,7 +29,6 @@ const useTenantManager = ({
appDispatch,
currentTenantId,
resetWorkspaceState,
notifyApiError,
refreshTags,
refreshCorrespondents,
loadFolder,
@@ -38,6 +38,7 @@ const useTenantManager = ({
tenantIdRef,
}: UseTenantManagerOptions) => {
const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError();
const handleTenantSelect = useCallback(
async (tenantOption: TenantOption | null, { refreshOnly = false }: { refreshOnly?: boolean } = {}) => {
const requestedTenantId = tenantOption?.id ?? null;
@@ -11,10 +11,11 @@ interface CorrespondentOption {
[key: string]: unknown;
}
import useNotifyApiError from '../../../hooks/useNotifyApiError';
interface UseDocumentCorrespondentActionsArgs {
correspondents: CorrespondentOption[];
handleCorrespondentCreate: (payload: { name: string }) => Promise<CorrespondentOption | null>;
notifyApiError: (error: unknown, fallback: string) => void;
updateDocumentCaches?: (
id: Identifier,
updater: (doc: { correspondents?: CorrespondentOption[] } | null) => { correspondents?: CorrespondentOption[] } | null,
@@ -24,10 +25,10 @@ interface UseDocumentCorrespondentActionsArgs {
const useDocumentCorrespondentActions = ({
correspondents,
handleCorrespondentCreate,
notifyApiError,
updateDocumentCaches,
}: UseDocumentCorrespondentActionsArgs) => {
const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError();
const correspondentLookupByName = useMemo(() => {
const map = new Map<string, CorrespondentOption>();
@@ -29,13 +29,14 @@ interface FolderClickHandlers {
onDragLeave: (event: DragEvent<HTMLElement>) => void;
}
import useNotifyApiError from '../../../hooks/useNotifyApiError';
interface UseFolderTreeActionsOptions {
token?: string | null;
folderNodes: Map<FolderKey, FolderNode>;
setFolderNodes: (updater: (prev: Map<FolderKey, FolderNode>) => Map<FolderKey, FolderNode>) => void;
selectedFolder: FolderKey;
setSelectedFolder: (folderId: FolderKey) => void;
notifyApiError: (error: unknown, message?: string) => void;
navigate?: (path: string, options?: { replace?: boolean }) => void;
handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderKey) => Promise<void> | void;
moveDocumentsToFolder: (docIds: FolderId[], folderId: FolderKey) => Promise<void>;
@@ -53,7 +54,6 @@ const useFolderTreeActions = ({
setFolderNodes,
selectedFolder,
setSelectedFolder,
notifyApiError,
navigate,
handleFileDrop,
moveDocumentsToFolder,
@@ -65,6 +65,7 @@ const useFolderTreeActions = ({
setCreatingFolder,
}: UseFolderTreeActionsOptions) => {
const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError();
const moveFolder = useCallback(
async (folderId: FolderKey, targetFolderId: FolderKey | null) => {
@@ -15,12 +15,13 @@ interface TagManager {
buildPayload: (input: { label: string }) => Record<string, unknown>;
}
import useNotifyApiError from '../../../hooks/useNotifyApiError';
interface UseDocumentTaggingArgs {
tags: TagRecord[];
tagManager: TagManager;
refreshTags: () => Promise<void> | void;
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
notifyApiError: (error: unknown, message: string) => void;
updateDocumentCaches?: (id: Identifier, updater: (doc: TagRecord | null) => TagRecord | null) => void;
}
@@ -43,10 +44,10 @@ const useDocumentTagActions = ({
tagManager,
refreshTags,
resolveTargetDocumentIds,
notifyApiError,
updateDocumentCaches,
}: UseDocumentTaggingArgs) => {
const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError();
const bulkTagOperation = useCallback(
async ({ labels, action, documentIds }: BulkTagOperationArgs): Promise<BulkTagOperationResult> => {
@@ -118,6 +118,8 @@ interface UseDocumentUploadsResult {
clearUploadQueue: () => void;
}
import useNotifyApiError from '../../../hooks/useNotifyApiError';
const useDocumentUploads = ({
token,
selectedFolder,
@@ -125,7 +127,6 @@ const useDocumentUploads = ({
ensureFolderData,
refreshCurrentFolder,
shellRef,
notifyApiError,
}: UseDocumentUploadsArgs): UseDocumentUploadsResult => {
const [dropOverlayState, setDropOverlayState] = useState<DropOverlayState>({
active: false,
@@ -136,6 +137,7 @@ const useDocumentUploads = ({
const queueIdRef = useRef(0);
const [uploadQueue, setUploadQueue] = useState<UploadQueueItem[]>([]);
const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError();
const uploadFile = useCallback(
async (file: File, targetFolderId: FolderId) => {
+5 -5
View File
@@ -16,9 +16,7 @@ interface UseApiErrorOptions {
onReport?: (payload: ReportPayload) => void;
}
const noop = () => {};
const normalizeMessage = (error: unknown): string => {
export const normalizeMessage = (error: unknown): string => {
if (!error) return 'Something went wrong.';
if (typeof (error as { trim?: () => string })?.trim === 'function') {
return (error as { trim: () => string }).trim();
@@ -31,7 +29,7 @@ const normalizeMessage = (error: unknown): string => {
const useApiError = ({
logger = console,
onReport = noop,
onReport,
}: UseApiErrorOptions = {}) => {
return useCallback(
(
@@ -40,7 +38,9 @@ const useApiError = ({
) => {
const normalizedMessage = message || normalizeMessage(error);
logger.error('[API]', normalizedMessage, error);
onReport({ message: normalizedMessage, variant, retry, error });
if (onReport) {
onReport({ message: normalizedMessage, variant, retry, error });
}
return normalizedMessage;
},
[logger, onReport],
+18
View File
@@ -0,0 +1,18 @@
import { useCallback } from 'react';
import { useStatusToast, ToastVariant } from '../lib/context/StatusToastContext';
import { normalizeMessage } from './useApiError';
const useNotifyApiError = () => {
const { showToast } = useStatusToast();
return useCallback(
(error: unknown, fallbackMessage?: string, variant: ToastVariant = 'error') => {
const message = fallbackMessage || normalizeMessage(error);
console.error('[API]', message, error);
showToast(message, variant);
},
[showToast],
);
};
export default useNotifyApiError;
+4 -4
View File
@@ -15,8 +15,6 @@ import {
} from '../lib/api/apiClient';
import type { PasskeyId } from '../types/identifiers';
type NotifyApiErrorFn = (error: unknown, message: string) => void;
type ApiError = {
response?: {
status?: number;
@@ -69,8 +67,9 @@ type RevokePasskeyResult =
| { ok: true }
| { ok: false; reason: RevokePasskeyFailureReason; message?: string };
import useNotifyApiError from '../hooks/useNotifyApiError';
interface UsePasskeysArgs {
notifyApiError: NotifyApiErrorFn;
token?: string | null;
}
@@ -88,13 +87,14 @@ interface UsePasskeysResult {
) => Promise<RevokePasskeyResult>;
}
const usePasskeys = ({ notifyApiError, token }: UsePasskeysArgs): UsePasskeysResult => {
const usePasskeys = ({ token }: UsePasskeysArgs): UsePasskeysResult => {
const [passkeys, setPasskeys] = useState<PasskeyRecord[]>([]);
const [passkeysSupported, setPasskeysSupported] = useState<boolean | null>(null);
const [passkeysLoading, setPasskeysLoading] = useState(false);
const [registeringPasskey, setRegisteringPasskey] = useState(false);
const [revokingPasskeyId, setRevokingPasskeyId] = useState<PasskeyId | null>(null);
const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError();
const refreshPasskeys = useCallback(async (): Promise<void> => {
if (!token) {