From 245d1cb551ba1557e8c9d68abe94cc324c6df4ea Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Thu, 25 Sep 2025 17:53:01 +0100 Subject: [PATCH] add a dismiss all errors button and change the wording of invalid file errors --- .../public/locales/en-GB/translation.json | 1 + .../src/components/fileEditor/FileEditor.tsx | 16 +++--- .../fileEditor/FileEditorThumbnail.tsx | 6 +-- frontend/src/components/layout/Workbench.tsx | 4 ++ .../shared/DismissAllErrorsButton.tsx | 51 ++++++++++++++++++ frontend/src/components/shared/RightRail.tsx | 4 +- .../src/components/toast/ToastContext.tsx | 2 +- .../src/hooks/tools/shared/useToolApiCalls.ts | 12 ++++- .../hooks/tools/shared/useToolOperation.ts | 20 +++---- frontend/src/services/http.ts | 53 ++++++++++++++++--- frontend/src/utils/toolErrorHandler.ts | 2 +- 11 files changed, 138 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/shared/DismissAllErrorsButton.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 5d0ad075e..4e73f3b7f 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -75,6 +75,7 @@ "error": { "pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect", "_value": "Error", + "dismissAllErrors": "Dismiss All Errors", "sorry": "Sorry for the issue!", "needHelp": "Need help / Found an issue?", "contactTip": "If you're still having trouble, don't hesitate to reach out to us for help. You can submit a ticket on our GitHub page or contact us through Discord:", diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index 5ba24b513..8dbd83480 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useRef, useMemo } from 'react'; import { - Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal + Text, Center, Box, LoadingOverlay, Stack, Group } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext'; @@ -47,8 +47,8 @@ const FileEditor = ({ // Get file selection context const { setSelectedFiles } = useFileSelection(); - const [status, setStatus] = useState(null); - const [error, setError] = useState(null); + const [_status, _setStatus] = useState(null); + const [_error, _setError] = useState(null); // Toast helpers const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => { @@ -91,7 +91,7 @@ const FileEditor = ({ // Process uploaded files using context const handleFileUpload = useCallback(async (uploadedFiles: File[]) => { - setError(null); + _setError(null); try { const allExtractedFiles: File[] = []; @@ -224,7 +224,7 @@ const FileEditor = ({ // Update context (this automatically updates tool selection since they use the same action) setSelectedFiles(newSelection); - }, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]); + }, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]); // File reordering handler for drag and drop @@ -281,7 +281,7 @@ const FileEditor = ({ // Update status const moveCount = filesToMove.length; showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); - }, [activeStirlingFileStubs, reorderFiles, setStatus]); + }, [activeStirlingFileStubs, reorderFiles, _setStatus]); @@ -306,7 +306,7 @@ const FileEditor = ({ if (record && file) { downloadBlob(file, file.name); } - }, [activeStirlingFileStubs, selectors, setStatus]); + }, [activeStirlingFileStubs, selectors, _setStatus]); const handleViewFile = useCallback((fileId: FileId) => { const record = activeStirlingFileStubs.find(r => r.id === fileId); @@ -417,7 +417,7 @@ const FileEditor = ({ onToggleFile={toggleFile} onDeleteFile={handleDeleteFile} onViewFile={handleViewFile} - onSetStatus={showStatus} + _onSetStatus={showStatus} onReorderFiles={handleReorderFiles} onDownloadFile={handleDownloadFile} toolMode={toolMode} diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index ad3681dd7..355d37c51 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -29,7 +29,7 @@ interface FileEditorThumbnailProps { onToggleFile: (fileId: FileId) => void; onDeleteFile: (fileId: FileId) => void; onViewFile: (fileId: FileId) => void; - onSetStatus: (status: string) => void; + _onSetStatus: (status: string) => void; onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void; onDownloadFile: (fileId: FileId) => void; toolMode?: boolean; @@ -42,7 +42,7 @@ const FileEditorThumbnail = ({ selectedFiles, onToggleFile, onDeleteFile, - onSetStatus, + _onSetStatus, onReorderFiles, onDownloadFile, isSupported = true, @@ -193,7 +193,7 @@ const FileEditorThumbnail = ({ if (!isSupported) return; // Clear error state if file has an error (click to clear error) if (hasError) { - try { fileActions.clearFileError(file.id); } catch {} + try { fileActions.clearFileError(file.id); } catch (_e) { void _e; } } onToggleFile(file.id); }; diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx index 5a8f36747..77a5082a1 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/components/layout/Workbench.tsx @@ -13,6 +13,7 @@ import PageEditorControls from '../pageEditor/PageEditorControls'; import Viewer from '../viewer/Viewer'; import LandingPage from '../shared/LandingPage'; import Footer from '../shared/Footer'; +import DismissAllErrorsButton from '../shared/DismissAllErrorsButton'; // No props needed - component uses contexts directly export default function Workbench() { @@ -151,6 +152,9 @@ export default function Workbench() { selectedToolKey={selectedToolId} /> + {/* Dismiss All Errors Button */} + + {/* Main content area */} = ({ className }) => { + const { t } = useTranslation(); + const { state } = useFileState(); + const { actions } = useFileActions(); + + // Check if there are any files in error state + const hasErrors = state.ui.errorFileIds.length > 0; + + // Don't render if there are no errors + if (!hasErrors) { + return null; + } + + const handleDismissAllErrors = () => { + actions.clearAllFileErrors(); + }; + + return ( + + + + ); +}; + +export default DismissAllErrorsButton; diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index fed1ef023..8e970e551 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -72,7 +72,7 @@ export default function RightRail() { const allIds = state.files.ids; setSelectedFiles(allIds); // Clear any previous error flags when selecting all - try { fileActions.clearAllFileErrors(); } catch {} + try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; } return; } @@ -86,7 +86,7 @@ export default function RightRail() { if (currentView === 'fileEditor' || currentView === 'viewer') { setSelectedFiles([]); // Clear any previous error flags when deselecting all - try { fileActions.clearAllFileErrors(); } catch {} + try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; } return; } if (currentView === 'pageEditor') { diff --git a/frontend/src/components/toast/ToastContext.tsx b/frontend/src/components/toast/ToastContext.tsx index 481f6ff78..1b0cc1539 100644 --- a/frontend/src/components/toast/ToastContext.tsx +++ b/frontend/src/components/toast/ToastContext.tsx @@ -57,7 +57,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { progress: normalizeProgress(options.progressBarPercentage), justCompleted: false, expandable: hasButton ? false : (options.expandable !== false), - isExpanded: hasButton ? true : (options.expandable === false ? true : false), + isExpanded: hasButton ? true : (options.expandable === false ? true : (options.alertType === 'error' ? true : false)), createdAt: Date.now(), } as ToastInstance; setToasts(prev => { diff --git a/frontend/src/hooks/tools/shared/useToolApiCalls.ts b/frontend/src/hooks/tools/shared/useToolApiCalls.ts index 6c83e11e2..f0a0bf704 100644 --- a/frontend/src/hooks/tools/shared/useToolApiCalls.ts +++ b/frontend/src/hooks/tools/shared/useToolApiCalls.ts @@ -61,7 +61,11 @@ export const useToolApiCalls = () => { if (empty) { console.warn('[processFiles] Empty output treated as failure', { name: file.name }); failedFiles.push(file.name); - try { (markFileError as any)?.((file as any).fileId); } catch {} + try { + (markFileError as any)?.((file as any).fileId); + } catch (e) { + console.debug('markFileError', e); + } continue; } processedFiles.push(...responseFiles); @@ -76,7 +80,11 @@ export const useToolApiCalls = () => { console.error('[processFiles] Failed', { name: file.name, error }); failedFiles.push(file.name); // mark errored file so UI can highlight - try { (markFileError as any)?.((file as any).fileId); } catch {} + try { + (markFileError as any)?.((file as any).fileId); + } catch (e) { + console.debug('markFileError', e); + } } } diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index ee5bb471a..2e9c67bd3 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -6,7 +6,7 @@ import { useToolState, type ProcessingProgress } from './useToolState'; import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls'; import { useToolResources } from './useToolResources'; import { extractErrorMessage } from '../../../utils/toolErrorHandler'; -import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext'; +import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile } from '../../../types/fileContext'; import { FILE_EVENTS } from '../../../services/errorUtils'; import { ResponseHandler } from '../../../utils/toolResponseProcessor'; import { createChildStub, generateProcessedFileMetadata } from '../../../contexts/file/fileActions'; @@ -177,7 +177,9 @@ export const useToolOperation = ( for (const f of zeroByteFiles) { (fileActions.markFileError as any)((f as any).fileId); } - } catch {} + } catch (e) { + console.log('markFileError', e); + } } const validFiles = selectedFiles.filter(file => (file as any)?.size > 0); if (validFiles.length === 0) { @@ -298,15 +300,15 @@ export const useToolOperation = ( const okSet = new Set((successSourceIds as unknown as string[]) || []); // Clear errors on successes for (const okId of okSet) { - try { (fileActions.clearFileError as any)(okId); } catch {} + try { (fileActions.clearFileError as any)(okId); } catch (_e) { void _e; } } // Mark errors on inputs that didn't succeed for (const id of allInputIds) { if (!okSet.has(id)) { - try { (fileActions.markFileError as any)(id); } catch {} + try { (fileActions.markFileError as any)(id); } catch (_e) { void _e; } } } - } catch {} + } catch (_e) { void _e; } if (externalErrorFileIds.length > 0) { // If backend told us which sources failed, prefer that mapping @@ -318,7 +320,7 @@ export const useToolOperation = ( for (const badId of externalErrorFileIds) { (fileActions.markFileError as any)(badId); } - } catch {} + } catch (_e) { void _e; } } if (processedFiles.length > 0) { @@ -426,14 +428,14 @@ export const useToolOperation = ( } if (ids && ids.length > 0) { for (const badId of ids) { - try { (fileActions.markFileError as any)(badId); } catch {} + try { (fileActions.markFileError as any)(badId); } catch (_e) { void _e; } } - actions.setStatus('Some files could not be processed'); + actions.setStatus('Process failed due to invalid/corrupted file(s)'); // Avoid duplicating toast messaging here return; } } - } catch {} + } catch (_e) { void _e; } const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error); actions.setError(errorMessage); diff --git a/frontend/src/services/http.ts b/frontend/src/services/http.ts index 7e3a0568d..c8cca7930 100644 --- a/frontend/src/services/http.ts +++ b/frontend/src/services/http.ts @@ -25,7 +25,7 @@ function titleForStatus(status?: number): string { function extractAxiosErrorMessage(error: any): { title: string; body: string } { if (axios.isAxiosError(error)) { const status = error.response?.status; - const statusText = error.response?.statusText || ''; + const _statusText = error.response?.statusText || ''; let parsed: any = undefined; const raw = error.response?.data; if (typeof raw === 'string') { @@ -49,20 +49,37 @@ function extractAxiosErrorMessage(error: any): { title: string; body: string } { if (typeof raw === 'string') return raw; try { return JSON.stringify(data); } catch { return ''; } })(); - const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body; + const ids = extractIds(); const title = titleForStatus(status); + if (ids && ids.length > 0) { + return { title, body: 'Process failed due to invalid/corrupted file(s)' }; + } + if (status === 422) { + const fallbackMsg = 'Process failed due to invalid/corrupted file(s)'; + const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body; + return { title, body: bodyMsg }; + } + const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body; return { title, body: bodyMsg }; } try { const msg = (error?.message || String(error)) as string; return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg }; - } catch { + } catch (e) { + // ignore extraction errors + console.debug('extractAxiosErrorMessage', e); return { title: 'Network error', body: FRIENDLY_FALLBACK }; } } -// Install Axios response error interceptor -axios.interceptors.response.use( +// Install Axios response error interceptor (guard against double-registration in HMR) +const __globalAny = (typeof window !== 'undefined' ? (window as any) : undefined); +if (__globalAny?.__SPDF_HTTP_ERR_INTERCEPTOR_ID !== undefined) { + try { axios.interceptors.response.eject(__globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID); } catch (_e) { void _e; } +} +const __recentSpecialByEndpoint: Record = (__globalAny?.__SPDF_RECENT_SPECIAL || {}); +const __SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate after special toast +const __INTERCEPTOR_ID__ = axios.interceptors.response.use( (response) => response, async (error) => { const { title, body } = extractAxiosErrorMessage(error); @@ -71,12 +88,34 @@ axios.interceptors.response.use( const raw = (error?.response?.data) as any; const data = await normalizeAxiosErrorData(raw); const ids = extractErrorFileIds(data); - if (ids && ids.length > 0) broadcastErroredFiles(ids); - } catch {} + if (ids && ids.length > 0) { + broadcastErroredFiles(ids); + } + } catch (_e) { void _e; } + + // Generic-vs-special dedupe by endpoint + const url: string | undefined = error?.config?.url; + const status: number | undefined = error?.response?.status; + const now = Date.now(); + const isSpecial = status === 422 || /Failed files:/.test(body) || /invalid\/corrupted file\(s\)/i.test(body); + if (isSpecial && url) { + __recentSpecialByEndpoint[url] = now; + if (__globalAny) __globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint; + } + if (!isSpecial && url) { + const last = __recentSpecialByEndpoint[url] || 0; + if (now - last < __SPECIAL_SUPPRESS_MS) { + return Promise.reject(error); + } + } + alert({ alertType: 'error', title, body, expandable: true, isPersistentPopup: false }); return Promise.reject(error); } ); +if (__globalAny) { + __globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID = __INTERCEPTOR_ID__; +} export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise { const res = await fetch(input, { credentials: init?.credentials ?? 'include', ...init }); diff --git a/frontend/src/utils/toolErrorHandler.ts b/frontend/src/utils/toolErrorHandler.ts index ee1efe4d9..637970adf 100644 --- a/frontend/src/utils/toolErrorHandler.ts +++ b/frontend/src/utils/toolErrorHandler.ts @@ -12,7 +12,7 @@ export const extractErrorMessage = (error: any): string => { if (error.message) { return error.message; } - return 'Operation failed'; + return 'There was an error processing your request.'; }; /**