From d71b67a2976a33a140ac137975638039525497ee Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Wed, 3 Jun 2026 19:33:54 -0700 Subject: [PATCH] feat(ui): global react-query error handler + plan-status a11y (Sprint 4 F7+F6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F7: surface every failed query/mutation as a toast via react-query QueryCache/MutationCache onError, with a single error normalizer that extracts FastAPI's response.data.detail (string or Pydantic 422 array). - lib/toast.tsx: new extractErrorMessage(err, fallback) and showApiError(err, fallback). Reads response.data.detail when present (string or [{loc, msg, type}, ...] array), then err.message, then the fallback. No more '[object Object]' or raw stack traces. - App.tsx: QueryClient is now created with QueryCache and MutationCache onError handlers wired to showApiError. Added defaultOptions.queries: { retry: 1, refetchOnWindowFocus: false } so background refetch failures are no longer silent (the audit's H9 finding). - Dashboard.tsx: removed 6 local try/catch toasts (move/approve/deny/ delete/generate) since the global handler now covers them. Kept VoteEmailButton.handleSend and handleDelete's undo-callback with showApiError(err, 'Failed to ...') for action-specific fallback strings — those are user-initiated recovery paths where a contextual default is more useful than the bare FastAPI detail. - Pantry.tsx: removed 3 local onError handlers (addMutation, removeMutation, handleAdd's createIngredient path) and handleRemove's outer catch. Kept 3 pre-flight client-side checks (missing ingredient link, empty name, unresolved ingredient) that never reach the network. handleRemove's undo callback now uses showApiError for the restore failure. - MealDetail.tsx: removed submitMutation.onError. The local 'Failed to save feedback. Please try again.' string is replaced by the actual FastAPI detail (e.g. 'Feedback for this meal already exists' or the Pydantic 422 msg). Net result: 10 backend-error try/catch blocks deleted, error messages are now identical to what the backend actually says, and any future mutation that forgets to add a local onError still gets surfaced. F6: Dashboard plan-status Badge (variant driven by status: draft / awaiting_approval / approved / rejected) now passes an explicit aria-label='Plan status: ' so a screen reader announces both the category and the value instead of just the colour-encoded text. This matches the pattern already used for the per-item approval status Badge in Dashboard.tsx (added in Sprint 3) and completes the audit §Sprint 3 a11y sweep for that page. build: tsc 0 errors, vite 0 errors. 5 files, +72/-19. --- frontend/src/App.tsx | 22 ++++++++++++-- frontend/src/lib/toast.tsx | 50 +++++++++++++++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 35 ++++++++++++---------- frontend/src/pages/MealDetail.tsx | 3 -- frontend/src/pages/Pantry.tsx | 17 ++++------- 5 files changed, 94 insertions(+), 33 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 80ebfb7..55d236f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { BrowserRouter, Routes, Route, Link, useLocation, Navigate } from 'react-router-dom' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query' import { ErrorBoundary } from './components/ErrorBoundary' +import { showApiError } from './lib/toast' import Dashboard from './pages/Dashboard' import MealDetail from './pages/MealDetail' import Pantry from './pages/Pantry' @@ -10,7 +11,24 @@ import RecipeDetail from './pages/RecipeDetail' import ShoppingList from './pages/ShoppingList' import NotFound from './pages/NotFound' -const queryClient = new QueryClient() +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, + queryCache: new QueryCache({ + onError: (error) => { + showApiError(error, 'Failed to load data') + }, + }), + mutationCache: new MutationCache({ + onError: (error) => { + showApiError(error) + }, + }), +}) function Navigation() { const location = useLocation() diff --git a/frontend/src/lib/toast.tsx b/frontend/src/lib/toast.tsx index eb2e856..5808944 100644 --- a/frontend/src/lib/toast.tsx +++ b/frontend/src/lib/toast.tsx @@ -1,5 +1,55 @@ import toast from 'react-hot-toast'; +export type ApiError = unknown; + +function isHttpError(err: unknown): err is { response?: { data?: { detail?: unknown } }; message?: string } { + return typeof err === 'object' && err !== null && 'response' in (err as object); +} + +function getFastApiDetail(err: unknown): string | null { + if (!isHttpError(err)) return null; + const detail = err.response?.data?.detail; + if (typeof detail === 'string') return detail; + if (Array.isArray(detail) && detail.length > 0) { + const first = detail[0] as { msg?: string } | string; + if (typeof first === 'string') return first; + if (typeof first === 'object' && first !== null && 'msg' in first) { + return (first as { msg?: string }).msg ?? null; + } + } + return null; +} + +function getNetworkMessage(err: unknown): string | null { + if (!isHttpError(err)) return null; + const msg = err.message; + if (typeof msg === 'string' && msg && msg !== 'Network Error') return msg; + return null; +} + +/** + * Normalize any thrown value (FastAPI HTTP error, network error, JS error) + * to a short user-facing string. Falls back to a generic message so we + * never surface a raw "[object Object]" or stack trace. + */ +export function extractErrorMessage(err: unknown, fallback = 'Something went wrong'): string { + return ( + getFastApiDetail(err) ?? + getNetworkMessage(err) ?? + (err instanceof Error ? err.message : null) ?? + fallback + ); +} + +/** + * Show a toast for any error. Use as the MutationCache/QueryCache + * global onError, or call directly from per-mutation onError handlers + * that need a normalized message. + */ +export function showApiError(err: unknown, fallback?: string): void { + showToast.error(extractErrorMessage(err, fallback)); +} + export const showToast = { success: (message: string) => toast.success(message), error: (message: string) => toast.error(message), diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index ee5f381..abb2a69 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -6,7 +6,7 @@ import { GripVertical, X } from 'lucide-react' import toast from 'react-hot-toast' -import { showToast } from '../lib/toast' +import { showToast, showApiError } from '../lib/toast' import { DragDropContext, Droppable, @@ -282,8 +282,8 @@ function VoteEmailButton() { const res = await mealPlannerApi.admin.triggerOrchestrate('email') const status = res.data?.status || res.data?.message || 'sent' toast.success(status) - } catch (err: any) { - toast.error(err?.response?.data?.detail || 'Failed to send vote emails') + } catch (err) { + showApiError(err, 'Failed to send vote emails') } finally { setSendingVoteEmail(false) } @@ -316,8 +316,8 @@ export default function Dashboard() { await mealPlannerApi.meals.moveItem(itemId, newDay, newType) toast.success('Meal moved') queryClient.invalidateQueries({ queryKey: ['mealPlan'] }) - } catch (err: any) { - toast.error(err?.response?.data?.detail || 'Failed to move meal') + } catch { + // Error toast fires from the global MutationCache handler. } } @@ -334,8 +334,8 @@ export default function Dashboard() { await mealPlannerApi.meals.approveItem(itemId) toast.success('Meal approved') queryClient.invalidateQueries({ queryKey: ['mealPlan'] }) - } catch (err: any) { - toast.error(err?.response?.data?.detail || 'Failed to approve meal') + } catch { + // Error toast fires from the global MutationCache handler. } } @@ -344,8 +344,8 @@ export default function Dashboard() { await mealPlannerApi.meals.denyItem(itemId) toast.success('Meal denied') queryClient.invalidateQueries({ queryKey: ['mealPlan'] }) - } catch (err: any) { - toast.error(err?.response?.data?.detail || 'Failed to deny meal') + } catch { + // Error toast fires from the global MutationCache handler. } } @@ -367,13 +367,13 @@ export default function Dashboard() { ) queryClient.invalidateQueries({ queryKey: ['mealPlan'] }) toast.success('Slot filled with a new meal') - } catch (err: any) { - toast.error(err?.response?.data?.detail || 'Failed to refill slot') + } catch (err) { + showApiError(err, 'Failed to refill slot') } } ) - } catch (err: any) { - toast.error(err?.response?.data?.detail || 'Failed to delete meal') + } catch { + // Error toast fires from the global MutationCache handler. } } @@ -383,8 +383,8 @@ export default function Dashboard() { await mealPlannerApi.meals.generateItem(mealPlan.id, dayIndex + 1, mealType) toast.success('Meal generated') queryClient.invalidateQueries({ queryKey: ['mealPlan'] }) - } catch (err: any) { - toast.error(err?.response?.data?.detail || 'Failed to generate meal') + } catch { + // Error toast fires from the global MutationCache handler. } } @@ -435,7 +435,10 @@ export default function Dashboard() {
- + {mealPlan.status.replace(/_/g, ' ')} {mealPlan.total_estimated_cost !== undefined && ( diff --git a/frontend/src/pages/MealDetail.tsx b/frontend/src/pages/MealDetail.tsx index bffd713..619317b 100644 --- a/frontend/src/pages/MealDetail.tsx +++ b/frontend/src/pages/MealDetail.tsx @@ -113,9 +113,6 @@ export default function MealDetail() { setEditingFeedback(false) showToast.success('Feedback saved!') }, - onError: () => { - showToast.error('Failed to save feedback. Please try again.') - }, }) if (isLoading) { diff --git a/frontend/src/pages/Pantry.tsx b/frontend/src/pages/Pantry.tsx index 0f254ef..591854a 100644 --- a/frontend/src/pages/Pantry.tsx +++ b/frontend/src/pages/Pantry.tsx @@ -9,7 +9,7 @@ import { Input } from '../components/ui/Input' import { Select } from '../components/ui/Select' import { Skeleton, SkeletonText } from '../components/ui/Skeleton' import { EmptyState } from '../components/ui/EmptyState' -import { showToast } from '../lib/toast' +import { showToast, showApiError } from '../lib/toast' const AISLE_OPTIONS = [ { value: '', label: 'Select aisle…' }, @@ -62,9 +62,6 @@ export default function Pantry() { setAisle('') showToast.success('Item added to pantry') }, - onError: () => { - showToast.error('Failed to add item') - }, }) const removeMutation = useMutation({ @@ -74,10 +71,6 @@ export default function Pantry() { showToast.success('Item removed') setRemoveId(null) }, - onError: () => { - showToast.error('Failed to remove item') - setRemoveId(null) - }, }) async function handleRemove(item: HomePantryItem) { @@ -100,13 +93,13 @@ export default function Pantry() { }) queryClient.invalidateQueries({ queryKey: ['pantry'] }) showToast.success('Item restored') - } catch { - showToast.error('Failed to restore item') + } catch (err) { + showApiError(err, 'Failed to restore item') } } ) } catch { - showToast.error('Failed to remove item') + // Error toast fires from the global MutationCache handler. } finally { setRemoveId(null) } @@ -133,7 +126,7 @@ export default function Pantry() { // refresh ingredient list so next add sees it await queryClient.invalidateQueries({ queryKey: ['ingredients'] }) } catch { - showToast.error('Failed to create ingredient') + // Error toast fires from the global MutationCache handler. return } }