feat(ui): global react-query error handler + plan-status a11y (Sprint 4 F7+F6)

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: <text>' 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.
This commit is contained in:
2026-06-03 19:33:54 -07:00
parent 83d8c700a5
commit d71b67a297
5 changed files with 94 additions and 33 deletions
+20 -2
View File
@@ -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()
+50
View File
@@ -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),
+19 -16
View File
@@ -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() {
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant={statusVariant}>
<Badge
variant={statusVariant}
aria-label={`Plan status: ${mealPlan.status.replace(/_/g, ' ')}`}
>
{mealPlan.status.replace(/_/g, ' ')}
</Badge>
{mealPlan.total_estimated_cost !== undefined && (
-3
View File
@@ -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) {
+5 -12
View File
@@ -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
}
}