diff --git a/docs/error-handling-guidelines.md b/docs/error-handling-guidelines.md new file mode 100644 index 00000000..3a427d81 --- /dev/null +++ b/docs/error-handling-guidelines.md @@ -0,0 +1,165 @@ +# Error Handling Guidelines + +## Overview + +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 + +### `formatAxiosError` + +**Location:** `src/utils/error-formatting.ts` + +Converts an `AxiosError` into `{ summary, detail, statusCode }`, pulling useful information from the HTTP response. + +### `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` + +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` + +**Location:** `src/composables/useAxiosErrorDialog.ts` + +Watches an Axios error ref and opens the shared error dialog when a failure occurs. Also exposes `showAxiosErrorDialog` for manual use. + +### `errorDialog` service + +**Location:** `src/services/error-dialog.ts` + +Service-style API with methods: + +- `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. + +### `ErrorDialogHost` component + +**Location:** `src/components/notifications/ErrorDialogHost.vue` + +Renders a global dialog surface driven by the service above. It is already mounted in `src/App.vue`. + +--- + +## Choosing a Tool + +- 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`. + +--- + +## Usage Examples + +### 1. Toast on List Fetch Failure + +```vue + +``` + +### 2. Dialog on Critical Fetch Failure + +```vue + +``` + +### 3. Form Submission with Toast + +```ts +import { useToast } from 'primevue/usetoast'; +import { formatAxiosError } from '@/utils/error-formatting'; +import type { AxiosError } from 'axios'; +import type { ErrorBody, ErrorResponse } from '@/stores/types'; + +const toast = useToast(); + +async function saveUser() { + try { + await apiCall(); + toast.add({ + severity: 'success', + summary: 'Success', + detail: 'User updated successfully.', + life: 3000, + }); + } catch (err) { + const formatted = formatAxiosError( + err as AxiosError>, + ); + toast.add({ + severity: 'error', + summary: `Error updating user: ${formatted.summary}`, + detail: formatted.detail, + life: 4000, + }); + } +} +``` + +### 4. Manual Dialog from Catch Block + +```ts +import { errorDialog } from '@/services/error-dialog'; + +try { + await deleteThing(); +} catch (err) { + errorDialog.showError(err, { + summary: 'Delete failed', + onClose: () => console.log('Dialog dismissed'), + }); +} +``` + +--- + +## Checklist Before Shipping + +- [ ] 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/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/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 new file mode 100644 index 00000000..123c81c0 --- /dev/null +++ b/src/composables/useErrorToast.ts @@ -0,0 +1,99 @@ +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 Axios error toast 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 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. + * + * @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 Axios 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/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 new file mode 100644 index 00000000..ecf1aaac --- /dev/null +++ b/src/utils/error-formatting.ts @@ -0,0 +1,195 @@ +import { isAxiosError } from 'axios'; +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, + }; +} + +/** + * 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 + * + * 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 cd5a4437..91c5fbe1 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, }); }