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
+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
}
}