From ea7995872e3a5674e980798c907ed9135a158239 Mon Sep 17 00:00:00 2001 From: onselakin Date: Tue, 30 Sep 2025 10:24:37 +0300 Subject: [PATCH 1/2] feat: add reusable error handling utilities and integrate into user management components --- docs/error-handling-guidelines.md | 396 ++++++++++++++++++++++++ src/components/users/UserCreateForm.vue | 7 +- src/components/users/UserEditForm.vue | 8 +- src/composables/useErrorToast.ts | 139 +++++++++ src/utils/error-formatting.ts | 125 ++++++++ src/views/users/UserView.vue | 22 +- src/views/users/UsersListView.vue | 7 + 7 files changed, 685 insertions(+), 19 deletions(-) create mode 100644 docs/error-handling-guidelines.md create mode 100644 src/composables/useErrorToast.ts create mode 100644 src/utils/error-formatting.ts diff --git a/docs/error-handling-guidelines.md b/docs/error-handling-guidelines.md new file mode 100644 index 00000000..a4fe89f6 --- /dev/null +++ b/docs/error-handling-guidelines.md @@ -0,0 +1,396 @@ +# Error Handling Guidelines + +## Overview + +This document describes the standardized approach for handling errors from Axios HTTP requests in the CCF UI application. We provide two complementary utilities to ensure consistent, user-friendly error messages across the application. + +## Available Utilities + +### 1. `formatAxiosError` (Utility Function) + +**Location:** `src/utils/error-formatting.ts` + +A utility function that formats `AxiosError` objects into user-friendly messages with HTTP status codes and API error details. + +### 2. `useErrorToast` (Composable) + +**Location:** `src/composables/useErrorToast.ts` + +A Vue composable that automatically watches error refs and displays toast notifications. Built on top of `formatAxiosError`. + +--- + +## When to Use Each Approach + +### Use `useErrorToast` Composable For: + +✅ **Simple list/detail views** - When you just need to show an error toast +✅ **Reactive error refs** - Errors from `useDataApi` or `useGuestApi` +✅ **Automatic error handling** - When no additional logic is needed +✅ **Loading/error states** - Simple data fetching scenarios + +**Example use cases:** + +- User list views +- Data tables +- Simple detail pages +- Read-only displays + +### Use `formatAxiosError` Utility Directly For: + +✅ **Try-catch blocks** - Errors caught in async functions +✅ **Complex error handling** - When you need additional logic +✅ **Form submissions** - Create/update/delete operations +✅ **Custom error messages** - When you need full control +✅ **Multiple error scenarios** - Different handling for different errors + +**Example use cases:** + +- Form submissions +- Multi-step operations +- Error handling with redirects +- Conditional error responses + +--- + +## Usage Examples + +### Pattern 1: Simple List View (useErrorToast) + +**Before:** + +```vue + + + +``` + +**After:** + +```vue + + + +``` + +**Benefits:** + +- ✅ Users get a toast notification (won't miss the error) +- ✅ Toast includes HTTP status code (e.g., "Error loading users: 404 Not Found") +- ✅ Detailed API error message shown +- ✅ Only 1 line of code added + +--- + +### Pattern 2: Try-Catch in Form Submission (formatAxiosError) + +**Before:** + +```typescript +try { + await executeCreate({ data: assessmentResults }); + toast.add({ + severity: 'success', + summary: 'Success', + detail: 'Assessment Results created successfully', + }); +} catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + toast.add({ + severity: 'error', + summary: 'Error', + detail: `Failed to create Assessment Results: ${errorMessage}`, + life: 5000, + }); +} +``` + +**After:** + +```typescript +import { formatAxiosError } from '@/utils/error-formatting'; +import type { AxiosError } from 'axios'; +import type { ErrorResponse, ErrorBody } from '@/stores/types'; + +try { + await executeCreate({ data: assessmentResults }); + toast.add({ + severity: 'success', + summary: 'Success', + detail: 'Assessment Results created successfully', + }); +} catch (err) { + const error = err as AxiosError>; + const formatted = formatAxiosError(error); + + toast.add({ + severity: 'error', + summary: `Failed to create Assessment Results: ${formatted.summary}`, + detail: formatted.detail, + life: 5000, + }); +} +``` + +**Benefits:** + +- ✅ Shows HTTP status code in summary +- ✅ Extracts API error message from response +- ✅ Handles network errors gracefully +- ✅ Consistent error formatting + +--- + +### Pattern 3: Watch Error with Custom Logic (useErrorToast with manual control) + +**Before:** + +```typescript +watch(error, (err) => { + if (err) { + const errorResponse = err as AxiosError>; + toast.add({ + severity: 'error', + summary: 'Error loading user', + detail: + errorResponse.response?.data.errors.body || + 'An error occurred while loading the user data.', + life: 3000, + }); + router.push({ name: 'users-list' }); + } +}); +``` + +**After:** + +```typescript +import { useErrorToast } from '@/composables/useErrorToast'; + +const { showErrorToast } = useErrorToast(error, { + summary: 'Error loading user', + autoShow: false, // Disable automatic toast +}); + +watch(error, (err) => { + if (err) { + showErrorToast(err); + router.push({ name: 'users-list' }); + } +}); +``` + +**Benefits:** + +- ✅ Simplified error formatting +- ✅ Consistent with other error handling +- ✅ Still allows custom logic (redirect) + +--- + +## Advanced Usage + +### Custom Error Message Extraction + +When you need different error messages based on status codes or other conditions: + +```typescript +import { useErrorToast } from '@/composables/useErrorToast'; + +const { data, error } = useDataApi('/api/assessment-results'); + +useErrorToast(error, { + summary: 'Assessment Error', + extractMessage: (err) => { + // Custom logic based on status code + if (err.response?.status === 404) { + return 'Assessment result not found. It may have been deleted.'; + } + if (err.response?.status === 403) { + return 'You do not have permission to view this assessment.'; + } + // Fallback to API error message + return ( + err.response?.data?.errors?.body || 'Failed to load assessment results' + ); + }, +}); +``` + +### Configuring Toast Lifetime + +```typescript +useErrorToast(error, { + summary: 'Critical Error', + life: 10000, // Show for 10 seconds instead of default 5 +}); +``` + +--- + +## Error Message Priority + +The utilities extract error messages in this order of preference: + +1. **API Error Body** - `response.data.errors.body` (most specific) +2. **Axios Error Message** - `error.message` (general error) +3. **Network Error Message** - Special message for connection issues +4. **Generic Fallback** - "An unexpected error occurred. Please try again." + +--- + +## HTTP Status Code Mapping + +The utilities automatically map status codes to user-friendly titles: + +| Status Code | Title | +| ----------- | ------------------- | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Not Found | +| 409 | Conflict | +| 422 | Validation Error | +| 429 | Too Many Requests | +| 500 | Server Error | +| 502 | Bad Gateway | +| 503 | Service Unavailable | +| 504 | Gateway Timeout | +| Other | Error {code} | + +--- + +## Best Practices + +### ✅ Do This: + +1. **Always provide a summary** - Helps users understand what operation failed + + ```typescript + useErrorToast(error, { summary: 'Error loading users' }); + ``` + +2. **Keep inline error displays** - For accessibility (screen readers, visual cues) + + ```vue + + ``` + +3. **Use appropriate toast lifetime** - 3-5 seconds for info, 5-10 for errors + + ```typescript + useErrorToast(error, { life: 5000 }); + ``` + +4. **Cast errors properly in try-catch** - Ensures type safety + ```typescript + const error = err as AxiosError>; + ``` + +### ❌ Avoid This: + +1. **Don't remove inline error displays** - Some users may miss toasts +2. **Don't use generic summaries** - "Error" is less helpful than "Error loading users" +3. **Don't ignore status codes** - They provide valuable context +4. **Don't skip error handling** - All API calls should handle errors + +--- + +## Migration Checklist + +When updating a component to use the new utilities: + +- [ ] Import the appropriate utility (`useErrorToast` or `formatAxiosError`) +- [ ] Import required types (`AxiosError`, `ErrorResponse`, `ErrorBody`) +- [ ] Add error toast handling (composable call or try-catch formatting) +- [ ] Provide a descriptive summary for the error context +- [ ] Test with different error scenarios (404, 500, network error) +- [ ] Keep any existing inline error displays for accessibility +- [ ] Update any custom error watchers to use the new utilities + +--- + +## Testing Error Scenarios + +To properly test error handling: + +1. **404 Not Found** - Request non-existent resource +2. **500 Server Error** - Backend error +3. **422 Validation Error** - Invalid form data +4. **Network Error** - Disconnect network/stop API server +5. **Timeout** - Slow network conditions + +--- + +## Examples by Feature Area + +### Users Management + +```typescript +// UsersListView.vue +useErrorToast(error, { summary: 'Error loading users' }); + +// UserView.vue +useErrorToast(error, { summary: 'Error loading user' }); + +// UserCreateView.vue (in try-catch) +const formatted = formatAxiosError(error); +toast.add({ + severity: 'error', + summary: `Error creating user: ${formatted.summary}`, + detail: formatted.detail, +}); +``` + +### Assessment Results + +```typescript +// AssessmentResultsListView.vue +useErrorToast(error, { summary: 'Error loading assessment results' }); + +// AssessmentResultsCreateView.vue (in try-catch) +const formatted = formatAxiosError(error); +toast.add({ + severity: 'error', + summary: `Failed to create assessment: ${formatted.summary}`, + detail: formatted.detail, +}); +``` + +--- + +## Summary + +- **Two utilities:** `formatAxiosError` (function) and `useErrorToast` (composable) +- **Choose based on context:** Composable for reactive refs, function for try-catch +- **Consistent error messages:** HTTP status codes + API error details +- **Better UX:** Users always see informative error notifications +- **Easy to use:** Minimal code changes required + +For questions or issues, please consult the team or create a GitHub issue. diff --git a/src/components/users/UserCreateForm.vue b/src/components/users/UserCreateForm.vue index f77e92e9..7f0a1981 100644 --- a/src/components/users/UserCreateForm.vue +++ b/src/components/users/UserCreateForm.vue @@ -62,6 +62,7 @@ import PrimaryButton from '../PrimaryButton.vue'; import { useDataApi } from '@/composables/axios'; import type { AxiosError } from 'axios'; import { useToast } from 'primevue/usetoast'; +import { formatAxiosError } from '@/utils/error-formatting'; const passwords = reactive({ password: '', @@ -120,11 +121,11 @@ async function createUser() { emit('create', createdUser.value); } catch (error) { const errorResponse = error as AxiosError>; + const formattedError = formatAxiosError(errorResponse); toast.add({ severity: 'error', - summary: 'Error creating user', - detail: - errorResponse.response?.data.errors.body ?? 'Unknown error occurred', + summary: `Error creating user${formattedError.statusCode ? `: ${formattedError.summary}` : ''}`, + detail: formattedError.detail, life: 3000, }); return; diff --git a/src/components/users/UserEditForm.vue b/src/components/users/UserEditForm.vue index 1ae75746..d278c32d 100644 --- a/src/components/users/UserEditForm.vue +++ b/src/components/users/UserEditForm.vue @@ -43,6 +43,7 @@ import PrimaryButton from '../PrimaryButton.vue'; import { useDataApi } from '@/composables/axios'; import type { AxiosError } from 'axios'; import { useToast } from 'primevue/usetoast'; +import { formatAxiosError } from '@/utils/error-formatting'; const props = defineProps<{ user: CCFUser; @@ -79,12 +80,11 @@ async function updateUser() { emit('saved', updatedUser.value); } catch (error) { const errorResponse = error as AxiosError>; + const formattedError = formatAxiosError(errorResponse); toast.add({ severity: 'error', - summary: 'Error updating user', - detail: - errorResponse.response?.data.errors.body ?? - 'An error occurred while updating the user.', + summary: `Error updating user${formattedError.statusCode ? `: ${formattedError.summary}` : ''}`, + detail: formattedError.detail, life: 3000, }); } diff --git a/src/composables/useErrorToast.ts b/src/composables/useErrorToast.ts new file mode 100644 index 00000000..cd96b8c3 --- /dev/null +++ b/src/composables/useErrorToast.ts @@ -0,0 +1,139 @@ +import { watch, type Ref } from 'vue'; +import { useToast } from 'primevue/usetoast'; +import type { AxiosError } from 'axios'; +import type { ErrorResponse, ErrorBody } from '@/stores/types'; +import { formatAxiosError } from '@/utils/error-formatting'; + +/** + * Configuration options for the useErrorToast composable + */ +export interface ErrorToastOptions { + /** + * Summary prefix to show before the error status. + * If not provided, only the status code message will be shown. + * + * @example "Error loading user" becomes "Error loading user: 404 Not Found" + */ + summary?: string; + + /** + * Toast lifetime in milliseconds + * @default 5000 + */ + life?: number; + + /** + * Whether to automatically show the toast when error occurs + * Set to false if you need custom handling before showing the toast + * @default true + */ + autoShow?: boolean; + + /** + * Custom error message extractor function. + * Use this to provide custom logic for extracting error messages + * based on your specific error scenarios. + * + * @param error - The AxiosError object + * @returns Custom error detail message + */ + extractMessage?: (error: AxiosError>) => string; +} + +/** + * Composable to automatically display toast notifications for Axios errors + * + * This composable watches a reactive error ref (typically from useDataApi or useGuestApi) + * and automatically displays a toast notification when an error occurs. It extracts + * HTTP status codes and API error messages to provide informative feedback to users. + * + * @example + * Basic usage with automatic toast: + * ```typescript + * const { data, error, isLoading } = useDataApi('/api/users'); + * useErrorToast(error, { summary: 'Error loading users' }); + * ``` + * + * @example + * Manual control with custom handling: + * ```typescript + * const { data, error } = useDataApi(`/api/users/${id}`); + * const { showErrorToast } = useErrorToast(error, { + * summary: 'Error loading user', + * autoShow: false + * }); + * + * watch(error, (err) => { + * if (err) { + * showErrorToast(err); + * // Additional custom handling + * router.push({ name: 'users-list' }); + * } + * }); + * ``` + * + * @example + * Custom error message extraction: + * ```typescript + * const { data, error } = useDataApi('/api/assessment-results'); + * useErrorToast(error, { + * summary: 'Assessment Results Error', + * extractMessage: (err) => { + * if (err.response?.status === 404) { + * return 'Assessment result not found. It may have been deleted.'; + * } + * return err.response?.data?.errors?.body || 'Failed to load assessment results'; + * } + * }); + * ``` + * + * @param errorRef - Reactive reference to an AxiosError (typically from useDataApi) + * @param options - Configuration options for the error toast + * @returns Object with showErrorToast function for manual error toast display + */ +export function useErrorToast( + errorRef: Ref> | null | undefined>, + options: ErrorToastOptions = {}, +) { + const toast = useToast(); + + const { summary, life = 5000, autoShow = true, extractMessage } = options; + + // Automatically watch the error ref and show toast when error occurs + watch(errorRef, (error) => { + if (error && autoShow) { + showErrorToast(error); + } + }); + + /** + * Manually show an error toast for the given error + * + * Use this when autoShow is false or when you need to show + * an error toast outside of the automatic watcher. + * + * @param error - The AxiosError to display + */ + function showErrorToast(error: AxiosError>) { + const formatted = formatAxiosError(error); + + // Use custom message extractor if provided + const detail = extractMessage ? extractMessage(error) : formatted.detail; + + // Build summary with optional prefix + const toastSummary = summary + ? `${summary}: ${formatted.summary}` + : formatted.summary; + + toast.add({ + severity: 'error', + summary: toastSummary, + detail, + life, + }); + } + + return { + showErrorToast, + }; +} diff --git a/src/utils/error-formatting.ts b/src/utils/error-formatting.ts new file mode 100644 index 00000000..eca2c5fd --- /dev/null +++ b/src/utils/error-formatting.ts @@ -0,0 +1,125 @@ +import type { AxiosError } from 'axios'; +import type { ErrorResponse, ErrorBody } from '@/stores/types'; + +/** + * Formatted error information for display in toast notifications + */ +export interface FormattedError { + /** + * User-friendly error summary/title + */ + summary: string; + + /** + * Detailed error message + */ + detail: string; + + /** + * HTTP status code (if available) + */ + statusCode?: number; +} + +/** + * Format an AxiosError into user-friendly toast message components + * + * Extracts HTTP status codes, API error messages, and provides fallback messages + * for better user experience. + * + * @example + * ```typescript + * try { + * await api.post('/users', userData); + * } catch (err) { + * const formatted = formatAxiosError(err as AxiosError>); + * toast.add({ + * severity: 'error', + * summary: formatted.summary, + * detail: formatted.detail, + * life: 5000, + * }); + * } + * ``` + * + * @param error - The AxiosError to format + * @returns Formatted error object with summary, detail, and status code + */ +export function formatAxiosError( + error: AxiosError>, +): FormattedError { + const statusCode = error.response?.status; + const apiErrorMessage = error.response?.data?.errors?.body; + + return { + summary: getErrorSummary(statusCode), + detail: apiErrorMessage || getErrorDetail(error), + statusCode, + }; +} + +/** + * Get user-friendly error summary based on HTTP status code + * + * Maps common HTTP status codes to human-readable titles. + * + * @param statusCode - HTTP status code from the error response + * @returns User-friendly error title + * + * @internal + */ +function getErrorSummary(statusCode?: number): string { + const statusMessages: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 409: 'Conflict', + 422: 'Validation Error', + 429: 'Too Many Requests', + 500: 'Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + }; + + if (!statusCode) { + return 'Error'; + } + + return statusMessages[statusCode] || `Error ${statusCode}`; +} + +/** + * Get detailed error message with fallbacks + * + * Attempts to extract error message from various parts of the AxiosError + * in order of preference: + * 1. API error body (response.data.errors.body) + * 2. Error message property + * 3. Generic fallback message + * + * @param error - The AxiosError to extract details from + * @returns Detailed error message + * + * @internal + */ +function getErrorDetail(error: AxiosError>): string { + // Try API error body (already checked in formatAxiosError, but kept for completeness) + if (error.response?.data?.errors?.body) { + return error.response.data.errors.body; + } + + // Try error message + if (error.message) { + return error.message; + } + + // Network error without response + if (!error.response) { + return 'Unable to connect to the server. Please check your network connection and try again.'; + } + + // Default fallback + return 'An unexpected error occurred. Please try again.'; +} diff --git a/src/views/users/UserView.vue b/src/views/users/UserView.vue index 4abc178b..45f8b332 100644 --- a/src/views/users/UserView.vue +++ b/src/views/users/UserView.vue @@ -71,6 +71,7 @@ import UserEditForm from '@/components/users/UserEditForm.vue'; import { useConfirm } from 'primevue/useconfirm'; import { useDataApi } from '@/composables/axios'; import type { AxiosError } from 'axios'; +import { formatAxiosError } from '@/utils/error-formatting'; const route = useRoute(); const router = useRouter(); @@ -96,12 +97,11 @@ const { data: updatedUserData, execute: lockExecute } = useDataApi( watch(error, (err) => { if (err) { const errorResponse = err as AxiosError>; + const formattedError = formatAxiosError(errorResponse); toast.add({ severity: 'error', - summary: 'Error loading user', - detail: - errorResponse.response?.data.errors.body || - 'An error occurred while loading the user data.', + summary: `Error loading user${formattedError.statusCode ? `: ${formattedError.summary}` : ''}`, + detail: formattedError.detail, life: 3000, }); router.push({ name: 'users-list' }); @@ -133,12 +133,11 @@ async function updateLock() { }); } catch (error) { const errorResponse = error as AxiosError>; + const formattedError = formatAxiosError(errorResponse); toast.add({ severity: 'error', - summary: `Error ${newIsLocked ? 'locking' : 'unlocking'} user`, - detail: - errorResponse.response?.data.errors.body || - 'An error occurred while updating the user lock status.', + summary: `Error ${newIsLocked ? 'locking' : 'unlocking'} user${formattedError.statusCode ? `: ${formattedError.summary}` : ''}`, + detail: formattedError.detail, life: 3000, }); } @@ -171,12 +170,11 @@ function deleteUser() { router.push({ name: 'users-list' }); } catch (error) { const errorResponse = error as AxiosError>; + const formattedError = formatAxiosError(errorResponse); toast.add({ severity: 'error', - summary: 'Error deleting user', - detail: - errorResponse.response?.data.errors.body || - 'An error occurred while deleting the user.', + summary: `Error deleting user${formattedError.statusCode ? `: ${formattedError.summary}` : ''}`, + detail: formattedError.detail, life: 3000, }); } diff --git a/src/views/users/UsersListView.vue b/src/views/users/UsersListView.vue index b257843c..de0bc978 100644 --- a/src/views/users/UsersListView.vue +++ b/src/views/users/UsersListView.vue @@ -71,6 +71,7 @@ import UserCreateForm from '@/components/users/UserCreateForm.vue'; import Dialog from '@/volt/Dialog.vue'; import { useToast } from 'primevue/usetoast'; import { useDataApi } from '@/composables/axios'; +import { useErrorToast } from '@/composables/useErrorToast'; const showDialog = ref(false); const toast = useToast(); @@ -82,6 +83,12 @@ const { execute, } = useDataApi('/api/users', {}, { immediate: false }); +// Automatically show toast when error occurs +useErrorToast(error, { + summary: 'Error loading users', + life: 3000, +}); + function completed(newUser: CCFUser) { showDialog.value = false; users.value?.push(newUser); From aa73a262148b171ddc6873c3587293cf0d384b95 Mon Sep 17 00:00:00 2001 From: onselakin Date: Thu, 9 Oct 2025 08:49:26 +0300 Subject: [PATCH 2/2] feat: introduce reusable error dialog utilities and integrate global error handling --- docs/error-handling-guidelines.md | 425 ++++-------------- src/App.vue | 2 + .../notifications/ErrorDialogHost.vue | 71 +++ src/composables/useAxiosErrorDialog.ts | 70 +++ src/composables/useErrorToast.ts | 52 +-- src/services/error-dialog.ts | 134 ++++++ src/utils/error-formatting.spec.ts | 40 ++ src/utils/error-formatting.ts | 70 +++ 8 files changed, 490 insertions(+), 374 deletions(-) create mode 100644 src/components/notifications/ErrorDialogHost.vue create mode 100644 src/composables/useAxiosErrorDialog.ts create mode 100644 src/services/error-dialog.ts create mode 100644 src/utils/error-formatting.spec.ts diff --git a/docs/error-handling-guidelines.md b/docs/error-handling-guidelines.md index a4fe89f6..3a427d81 100644 --- a/docs/error-handling-guidelines.md +++ b/docs/error-handling-guidelines.md @@ -2,94 +2,78 @@ ## Overview -This document describes the standardized approach for handling errors from Axios HTTP requests in the CCF UI application. We provide two complementary utilities to ensure consistent, user-friendly error messages across the application. +The CCF UI exposes a consistent set of utilities for surfacing errors. These helpers separate transport concerns (Axios responses) from application-level failures and give developers the choice of presenting feedback as a toast or a modal dialog. ## Available Utilities -### 1. `formatAxiosError` (Utility Function) +### `formatAxiosError` **Location:** `src/utils/error-formatting.ts` -A utility function that formats `AxiosError` objects into user-friendly messages with HTTP status codes and API error details. +Converts an `AxiosError` into `{ summary, detail, statusCode }`, pulling useful information from the HTTP response. -### 2. `useErrorToast` (Composable) +### `formatError` + +**Location:** `src/utils/error-formatting.ts` + +Normalizes any thrown value (plain `Error`, string, custom object) into the same structure as `formatAxiosError`. Use this when the failure is not an Axios response. + +### `useErrorToast` **Location:** `src/composables/useErrorToast.ts` -A Vue composable that automatically watches error refs and displays toast notifications. Built on top of `formatAxiosError`. +Watches a reactive Axios error ref (e.g., from `useDataApi`) and automatically emits a PrimeVue toast with formatted details. Returns a `showErrorToast` helper for manual invocation. ---- +### `useAxiosErrorDialog` -## When to Use Each Approach +**Location:** `src/composables/useAxiosErrorDialog.ts` -### Use `useErrorToast` Composable For: +Watches an Axios error ref and opens the shared error dialog when a failure occurs. Also exposes `showAxiosErrorDialog` for manual use. -✅ **Simple list/detail views** - When you just need to show an error toast -✅ **Reactive error refs** - Errors from `useDataApi` or `useGuestApi` -✅ **Automatic error handling** - When no additional logic is needed -✅ **Loading/error states** - Simple data fetching scenarios +### `errorDialog` service -**Example use cases:** +**Location:** `src/services/error-dialog.ts` -- User list views -- Data tables -- Simple detail pages -- Read-only displays +Service-style API with methods: -### Use `formatAxiosError` Utility Directly For: +- `errorDialog.show({...})` – display a dialog given explicit summary/detail. +- `errorDialog.showAxiosError(error, options)` – convenience wrapper around `formatAxiosError`. +- `errorDialog.showError(error, options)` – convenience wrapper around `formatError`. +- `errorDialog.hide()` – close the dialog programmatically. -✅ **Try-catch blocks** - Errors caught in async functions -✅ **Complex error handling** - When you need additional logic -✅ **Form submissions** - Create/update/delete operations -✅ **Custom error messages** - When you need full control -✅ **Multiple error scenarios** - Different handling for different errors +### `ErrorDialogHost` component -**Example use cases:** +**Location:** `src/components/notifications/ErrorDialogHost.vue` -- Form submissions -- Multi-step operations -- Error handling with redirects -- Conditional error responses +Renders a global dialog surface driven by the service above. It is already mounted in `src/App.vue`. --- -## Usage Examples - -### Pattern 1: Simple List View (useErrorToast) +## Choosing a Tool -**Before:** +- Use **`useErrorToast`** for straightforward Axios error refs (list/detail views, data tables) when a toast is sufficient. +- Use **`useAxiosErrorDialog`** when the user must acknowledge a failure before continuing. +- Call **`errorDialog.showAxiosError`** inside `catch` blocks for manual flows (e.g., form submissions, CRUD actions). +- Use **`formatError`** for non-Axios failures before calling `toast.add` or `errorDialog.showError`. -```vue - +--- - -``` +## Usage Examples -**After:** +### 1. Toast on List Fetch Failure ```vue - - ``` -**Benefits:** +### 2. Dialog on Critical Fetch Failure -- ✅ Users get a toast notification (won't miss the error) -- ✅ Toast includes HTTP status code (e.g., "Error loading users: 404 Not Found") -- ✅ Detailed API error message shown -- ✅ Only 1 line of code added - ---- +```vue + ``` -**After:** +### 3. Form Submission with Toast -```typescript +```ts +import { useToast } from 'primevue/usetoast'; import { formatAxiosError } from '@/utils/error-formatting'; import type { AxiosError } from 'axios'; -import type { ErrorResponse, ErrorBody } from '@/stores/types'; +import type { ErrorBody, ErrorResponse } from '@/stores/types'; -try { - await executeCreate({ data: assessmentResults }); - toast.add({ - severity: 'success', - summary: 'Success', - detail: 'Assessment Results created successfully', - }); -} catch (err) { - const error = err as AxiosError>; - const formatted = formatAxiosError(error); - - toast.add({ - severity: 'error', - summary: `Failed to create Assessment Results: ${formatted.summary}`, - detail: formatted.detail, - life: 5000, - }); -} -``` - -**Benefits:** - -- ✅ Shows HTTP status code in summary -- ✅ Extracts API error message from response -- ✅ Handles network errors gracefully -- ✅ Consistent error formatting - ---- - -### Pattern 3: Watch Error with Custom Logic (useErrorToast with manual control) +const toast = useToast(); -**Before:** - -```typescript -watch(error, (err) => { - if (err) { - const errorResponse = err as AxiosError>; +async function saveUser() { + try { + await apiCall(); toast.add({ - severity: 'error', - summary: 'Error loading user', - detail: - errorResponse.response?.data.errors.body || - 'An error occurred while loading the user data.', + severity: 'success', + summary: 'Success', + detail: 'User updated successfully.', life: 3000, }); - router.push({ name: 'users-list' }); - } -}); -``` - -**After:** - -```typescript -import { useErrorToast } from '@/composables/useErrorToast'; - -const { showErrorToast } = useErrorToast(error, { - summary: 'Error loading user', - autoShow: false, // Disable automatic toast -}); - -watch(error, (err) => { - if (err) { - showErrorToast(err); - router.push({ name: 'users-list' }); - } -}); -``` - -**Benefits:** - -- ✅ Simplified error formatting -- ✅ Consistent with other error handling -- ✅ Still allows custom logic (redirect) - ---- - -## Advanced Usage - -### Custom Error Message Extraction - -When you need different error messages based on status codes or other conditions: - -```typescript -import { useErrorToast } from '@/composables/useErrorToast'; - -const { data, error } = useDataApi('/api/assessment-results'); - -useErrorToast(error, { - summary: 'Assessment Error', - extractMessage: (err) => { - // Custom logic based on status code - if (err.response?.status === 404) { - return 'Assessment result not found. It may have been deleted.'; - } - if (err.response?.status === 403) { - return 'You do not have permission to view this assessment.'; - } - // Fallback to API error message - return ( - err.response?.data?.errors?.body || 'Failed to load assessment results' + } catch (err) { + const formatted = formatAxiosError( + err as AxiosError>, ); - }, -}); -``` - -### Configuring Toast Lifetime - -```typescript -useErrorToast(error, { - summary: 'Critical Error', - life: 10000, // Show for 10 seconds instead of default 5 -}); -``` - ---- - -## Error Message Priority - -The utilities extract error messages in this order of preference: - -1. **API Error Body** - `response.data.errors.body` (most specific) -2. **Axios Error Message** - `error.message` (general error) -3. **Network Error Message** - Special message for connection issues -4. **Generic Fallback** - "An unexpected error occurred. Please try again." - ---- - -## HTTP Status Code Mapping - -The utilities automatically map status codes to user-friendly titles: - -| Status Code | Title | -| ----------- | ------------------- | -| 400 | Bad Request | -| 401 | Unauthorized | -| 403 | Forbidden | -| 404 | Not Found | -| 409 | Conflict | -| 422 | Validation Error | -| 429 | Too Many Requests | -| 500 | Server Error | -| 502 | Bad Gateway | -| 503 | Service Unavailable | -| 504 | Gateway Timeout | -| Other | Error {code} | - ---- - -## Best Practices - -### ✅ Do This: - -1. **Always provide a summary** - Helps users understand what operation failed - - ```typescript - useErrorToast(error, { summary: 'Error loading users' }); - ``` - -2. **Keep inline error displays** - For accessibility (screen readers, visual cues) - - ```vue - - ``` - -3. **Use appropriate toast lifetime** - 3-5 seconds for info, 5-10 for errors - - ```typescript - useErrorToast(error, { life: 5000 }); - ``` - -4. **Cast errors properly in try-catch** - Ensures type safety - ```typescript - const error = err as AxiosError>; - ``` - -### ❌ Avoid This: - -1. **Don't remove inline error displays** - Some users may miss toasts -2. **Don't use generic summaries** - "Error" is less helpful than "Error loading users" -3. **Don't ignore status codes** - They provide valuable context -4. **Don't skip error handling** - All API calls should handle errors - ---- - -## Migration Checklist - -When updating a component to use the new utilities: - -- [ ] Import the appropriate utility (`useErrorToast` or `formatAxiosError`) -- [ ] Import required types (`AxiosError`, `ErrorResponse`, `ErrorBody`) -- [ ] Add error toast handling (composable call or try-catch formatting) -- [ ] Provide a descriptive summary for the error context -- [ ] Test with different error scenarios (404, 500, network error) -- [ ] Keep any existing inline error displays for accessibility -- [ ] Update any custom error watchers to use the new utilities - ---- - -## Testing Error Scenarios - -To properly test error handling: - -1. **404 Not Found** - Request non-existent resource -2. **500 Server Error** - Backend error -3. **422 Validation Error** - Invalid form data -4. **Network Error** - Disconnect network/stop API server -5. **Timeout** - Slow network conditions - ---- - -## Examples by Feature Area - -### Users Management - -```typescript -// UsersListView.vue -useErrorToast(error, { summary: 'Error loading users' }); - -// UserView.vue -useErrorToast(error, { summary: 'Error loading user' }); - -// UserCreateView.vue (in try-catch) -const formatted = formatAxiosError(error); -toast.add({ - severity: 'error', - summary: `Error creating user: ${formatted.summary}`, - detail: formatted.detail, -}); + toast.add({ + severity: 'error', + summary: `Error updating user: ${formatted.summary}`, + detail: formatted.detail, + life: 4000, + }); + } +} ``` -### Assessment Results +### 4. Manual Dialog from Catch Block -```typescript -// AssessmentResultsListView.vue -useErrorToast(error, { summary: 'Error loading assessment results' }); +```ts +import { errorDialog } from '@/services/error-dialog'; -// AssessmentResultsCreateView.vue (in try-catch) -const formatted = formatAxiosError(error); -toast.add({ - severity: 'error', - summary: `Failed to create assessment: ${formatted.summary}`, - detail: formatted.detail, -}); +try { + await deleteThing(); +} catch (err) { + errorDialog.showError(err, { + summary: 'Delete failed', + onClose: () => console.log('Dialog dismissed'), + }); +} ``` --- -## Summary - -- **Two utilities:** `formatAxiosError` (function) and `useErrorToast` (composable) -- **Choose based on context:** Composable for reactive refs, function for try-catch -- **Consistent error messages:** HTTP status codes + API error details -- **Better UX:** Users always see informative error notifications -- **Easy to use:** Minimal code changes required +## Checklist Before Shipping -For questions or issues, please consult the team or create a GitHub issue. +- [ ] Decide between toast or dialog based on UX (non-blocking vs requiring acknowledgment). +- [ ] Use `useErrorToast` or `useAxiosErrorDialog` when working with `useDataApi` error refs. +- [ ] In manual `try/catch` blocks: + - [ ] Call `formatAxiosError` for Axios failures. + - [ ] Call `formatError` for non-Axios failures. + - [ ] Pass the formatted output to `toast.add` **or** `errorDialog.show`. +- [ ] Ensure any navigation or recovery logic runs after the toast/dialog logic so the user sees the feedback. diff --git a/src/App.vue b/src/App.vue index 7ade6044..e1f431bb 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,10 +2,12 @@ import { RouterView } from 'vue-router'; import Toast from '@/volt/Toast.vue'; import ConfirmDialog from '@/volt/ConfirmDialog.vue'; +import ErrorDialogHost from '@/components/notifications/ErrorDialogHost.vue'; diff --git a/src/components/notifications/ErrorDialogHost.vue b/src/components/notifications/ErrorDialogHost.vue new file mode 100644 index 00000000..6c9df38b --- /dev/null +++ b/src/components/notifications/ErrorDialogHost.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/src/composables/useAxiosErrorDialog.ts b/src/composables/useAxiosErrorDialog.ts new file mode 100644 index 00000000..d1e97408 --- /dev/null +++ b/src/composables/useAxiosErrorDialog.ts @@ -0,0 +1,70 @@ +import { watch, type Ref } from 'vue'; +import type { AxiosError } from 'axios'; +import type { ErrorResponse, ErrorBody } from '@/stores/types'; +import { errorDialog } from '@/services/error-dialog'; + +export interface AxiosErrorDialogOptions { + /** + * Summary prefix displayed before the formatted status message. + */ + summary?: string; + + /** + * Whether to automatically show the dialog when error occurs. + * @default true + */ + autoShow?: boolean; + + /** + * Custom detail to override the formatted error detail. + */ + detailOverride?: string; + + /** + * Optional label for the close button. + * @default 'Close' + */ + closeLabel?: string; + + /** + * Optional callback invoked after the dialog is dismissed. + */ + onClose?: () => void; +} + +/** + * Composable to automatically display error dialogs for Axios errors. + * + * Watches a reactive Axios error ref and opens the shared error dialog when an error occurs. + */ +export function useAxiosErrorDialog( + errorRef: Ref> | null | undefined>, + options: AxiosErrorDialogOptions = {}, +) { + const { + summary, + autoShow = true, + detailOverride, + closeLabel, + onClose, + } = options; + + watch(errorRef, (error) => { + if (error && autoShow) { + showAxiosErrorDialog(error); + } + }); + + function showAxiosErrorDialog(error: AxiosError>) { + errorDialog.showAxiosError(error, { + summary, + detailOverride, + closeLabel, + onClose, + }); + } + + return { + showAxiosErrorDialog, + }; +} diff --git a/src/composables/useErrorToast.ts b/src/composables/useErrorToast.ts index cd96b8c3..123c81c0 100644 --- a/src/composables/useErrorToast.ts +++ b/src/composables/useErrorToast.ts @@ -5,7 +5,7 @@ import type { ErrorResponse, ErrorBody } from '@/stores/types'; import { formatAxiosError } from '@/utils/error-formatting'; /** - * Configuration options for the useErrorToast composable + * Configuration options for the Axios error toast composable */ export interface ErrorToastOptions { /** @@ -23,8 +23,8 @@ export interface ErrorToastOptions { life?: number; /** - * Whether to automatically show the toast when error occurs - * Set to false if you need custom handling before showing the toast + * Whether to automatically show the toast when error occurs. + * Set to false if you need custom handling before showing the toast. * @default true */ autoShow?: boolean; @@ -41,52 +41,12 @@ export interface ErrorToastOptions { } /** - * Composable to automatically display toast notifications for Axios errors + * Composable to automatically display toast notifications for Axios errors. * - * This composable watches a reactive error ref (typically from useDataApi or useGuestApi) + * This composable watches a reactive Axios error ref (typically from useDataApi or useGuestApi) * and automatically displays a toast notification when an error occurs. It extracts * HTTP status codes and API error messages to provide informative feedback to users. * - * @example - * Basic usage with automatic toast: - * ```typescript - * const { data, error, isLoading } = useDataApi('/api/users'); - * useErrorToast(error, { summary: 'Error loading users' }); - * ``` - * - * @example - * Manual control with custom handling: - * ```typescript - * const { data, error } = useDataApi(`/api/users/${id}`); - * const { showErrorToast } = useErrorToast(error, { - * summary: 'Error loading user', - * autoShow: false - * }); - * - * watch(error, (err) => { - * if (err) { - * showErrorToast(err); - * // Additional custom handling - * router.push({ name: 'users-list' }); - * } - * }); - * ``` - * - * @example - * Custom error message extraction: - * ```typescript - * const { data, error } = useDataApi('/api/assessment-results'); - * useErrorToast(error, { - * summary: 'Assessment Results Error', - * extractMessage: (err) => { - * if (err.response?.status === 404) { - * return 'Assessment result not found. It may have been deleted.'; - * } - * return err.response?.data?.errors?.body || 'Failed to load assessment results'; - * } - * }); - * ``` - * * @param errorRef - Reactive reference to an AxiosError (typically from useDataApi) * @param options - Configuration options for the error toast * @returns Object with showErrorToast function for manual error toast display @@ -107,7 +67,7 @@ export function useErrorToast( }); /** - * Manually show an error toast for the given error + * Manually show an error toast for the given Axios error. * * Use this when autoShow is false or when you need to show * an error toast outside of the automatic watcher. diff --git a/src/services/error-dialog.ts b/src/services/error-dialog.ts new file mode 100644 index 00000000..eb8df925 --- /dev/null +++ b/src/services/error-dialog.ts @@ -0,0 +1,134 @@ +import { reactive, readonly } from 'vue'; +import type { AxiosError } from 'axios'; +import type { ErrorResponse, ErrorBody } from '@/stores/types'; +import { + formatAxiosError, + formatError, + type FormattedError, +} from '@/utils/error-formatting'; + +export interface ErrorDialogPayload { + summary: string; + detail: string; + statusCode?: number; + closeLabel?: string; + onClose?: () => void; +} + +interface ErrorDialogState + extends Omit { + visible: boolean; + summary: string; + detail: string; +} + +const state = reactive({ + visible: false, + summary: '', + detail: '', + statusCode: undefined, + closeLabel: 'Close', + onClose: undefined, +}); + +/** + * Reset the dialog state after dismissal. + */ +function resetState() { + state.summary = ''; + state.detail = ''; + state.statusCode = undefined; + state.closeLabel = 'Close'; + state.onClose = undefined; +} + +function buildSummary( + formatted: FormattedError, + overrideSummary?: string, +): string { + if (!overrideSummary) { + return formatted.summary || 'Error'; + } + + if (!formatted.summary || formatted.summary === overrideSummary) { + return overrideSummary; + } + + return `${overrideSummary}: ${formatted.summary}`; +} + +function show(payload: ErrorDialogPayload) { + state.summary = payload.summary || 'Error'; + state.detail = payload.detail; + state.statusCode = payload.statusCode; + state.closeLabel = payload.closeLabel ?? 'Close'; + state.onClose = payload.onClose; + state.visible = true; +} + +function hide(options: { invokeCallback?: boolean } = {}) { + const { invokeCallback = true } = options; + const callback = state.onClose; + + state.visible = false; + resetState(); + + if (invokeCallback && typeof callback === 'function') { + callback(); + } +} + +function showAxiosError( + error: AxiosError>, + options: { + summary?: string; + detailOverride?: string; + closeLabel?: string; + onClose?: () => void; + } = {}, +) { + const formatted = formatAxiosError(error); + show({ + summary: buildSummary(formatted, options.summary), + detail: options.detailOverride ?? formatted.detail, + statusCode: formatted.statusCode, + closeLabel: options.closeLabel, + onClose: options.onClose, + }); +} + +function showError( + error: unknown, + options: { + summary?: string; + closeLabel?: string; + onClose?: () => void; + defaultDetail?: string; + } = {}, +) { + const formatted = formatError(error, { + defaultSummary: options.summary, + defaultDetail: options.defaultDetail, + }); + show({ + summary: buildSummary(formatted, options.summary), + detail: formatted.detail, + statusCode: formatted.statusCode, + closeLabel: options.closeLabel, + onClose: options.onClose, + }); +} + +export const errorDialog = { + show, + hide, + showAxiosError, + showError, +}; + +export function useErrorDialogController() { + return { + state: readonly(state), + hide, + }; +} diff --git a/src/utils/error-formatting.spec.ts b/src/utils/error-formatting.spec.ts new file mode 100644 index 00000000..839f66f4 --- /dev/null +++ b/src/utils/error-formatting.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import type { AxiosError } from 'axios'; +import type { ErrorBody, ErrorResponse } from '@/stores/types'; +import { formatError } from './error-formatting'; + +describe('formatError', () => { + it('formats axios errors with status code and API detail', () => { + const axiosError = { + isAxiosError: true, + response: { + status: 404, + data: { + errors: { + body: 'User not found', + }, + }, + }, + message: 'Request failed', + } as AxiosError>; + + const formatted = formatError(axiosError); + + expect(formatted.summary).toBe('Not Found'); + expect(formatted.detail).toBe('User not found'); + expect(formatted.statusCode).toBe(404); + }); + + it('falls back to Error message when non-Axios error is thrown', () => { + const formatted = formatError(new Error('Something went wrong')); + expect(formatted.summary).toBe('Error'); + expect(formatted.detail).toBe('Something went wrong'); + expect(formatted.statusCode).toBeUndefined(); + }); + + it('supports simple string errors', () => { + const formatted = formatError('plain string error'); + expect(formatted.summary).toBe('Error'); + expect(formatted.detail).toBe('plain string error'); + }); +}); diff --git a/src/utils/error-formatting.ts b/src/utils/error-formatting.ts index eca2c5fd..ecf1aaac 100644 --- a/src/utils/error-formatting.ts +++ b/src/utils/error-formatting.ts @@ -1,3 +1,4 @@ +import { isAxiosError } from 'axios'; import type { AxiosError } from 'axios'; import type { ErrorResponse, ErrorBody } from '@/stores/types'; @@ -58,6 +59,75 @@ export function formatAxiosError( }; } +/** + * Optional overrides when formatting unknown errors + */ +export interface FormatErrorOptions { + /** + * Override the default summary ("Error") when no better title is available. + */ + defaultSummary?: string; + + /** + * Override the default detail fallback when the error is not descriptive. + */ + defaultDetail?: string; +} + +/** + * Format unknown errors (Axios, Error, string, or otherwise) into a common structure. + * + * This helper lets any caller rely on consistent summary/detail strings regardless of the + * error origin. Axios errors continue to surface their status codes and response messages. + * + * @param error - The error to format (may be AxiosError, Error, string, or any value) + * @param options - Optional overrides for summary/detail defaults + * @returns Formatted error object with summary, detail, and optional status code + */ +export function formatError( + error: unknown, + options: FormatErrorOptions = {}, +): FormattedError { + if (isAxiosError>(error)) { + return formatAxiosError(error); + } + + const defaultSummary = options.defaultSummary ?? 'Error'; + const defaultDetail = + options.defaultDetail ?? 'An unexpected error occurred. Please try again.'; + + if (error instanceof Error) { + return { + summary: defaultSummary, + detail: error.message || defaultDetail, + }; + } + + if (typeof error === 'string') { + return { + summary: defaultSummary, + detail: error || defaultDetail, + }; + } + + if ( + error && + typeof error === 'object' && + 'message' in error && + typeof (error as { message?: unknown }).message === 'string' + ) { + return { + summary: defaultSummary, + detail: (error as { message: string }).message || defaultDetail, + }; + } + + return { + summary: defaultSummary, + detail: defaultDetail, + }; +} + /** * Get user-friendly error summary based on HTTP status code *