Public Access
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:
+20
-2
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user