Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions docs/error-handling-guidelines.md
Original file line number Diff line number Diff line change
@@ -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
<script setup lang="ts">
import { useDataApi } from '@/composables/axios';
import { useErrorToast } from '@/composables/useErrorToast';
import type { CCFUser } from '@/stores/types';

const {
data: users,
isLoading,
error,
} = useDataApi<CCFUser[]>('/api/users', {}, { immediate: false });

useErrorToast(error, {
summary: 'Error loading users',
life: 3000,
});
</script>
```

### 2. Dialog on Critical Fetch Failure

```vue
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router';
import { useDataApi } from '@/composables/axios';
import { useAxiosErrorDialog } from '@/composables/useAxiosErrorDialog';
import type { CCFUser } from '@/stores/types';

const route = useRoute();
const router = useRouter();

const { data: user, error } = useDataApi<CCFUser>(
`/api/users/${route.params.id}`,
);

useAxiosErrorDialog(error, {
summary: 'Error loading user',
onClose: () => router.push({ name: 'users-list' }),
});
</script>
```

### 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<ErrorResponse<ErrorBody>>,
);
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.
2 changes: 2 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
</script>

<template>
<Toast />
<ErrorDialogHost />
<ConfirmDialog />
<RouterView />
</template>
71 changes: 71 additions & 0 deletions src/components/notifications/ErrorDialogHost.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<template>
<Dialog
v-model:visible="visible"
modal
header="Error"
:closable="true"
:draggable="false"
size="sm"
>
<template #title>
<span class="flex items-center gap-2">
<span
class="inline-flex h-6 w-6 items-center justify-center rounded-full bg-red-500/15 text-red-500"
>
!
</span>
<span class="font-semibold">
{{ header }}
</span>
</span>
</template>

<div class="space-y-4">
<p class="text-gray-700 dark:text-slate-200 whitespace-pre-line">
{{ state.detail }}
</p>
</div>

<template #footer>
<PrimaryButton type="button" @click="dismiss">
{{ state.closeLabel ?? 'Close' }}
</PrimaryButton>
</template>
</Dialog>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import Dialog from '@/volt/Dialog.vue';
import PrimaryButton from '@/components/PrimaryButton.vue';
import { useErrorDialogController } from '@/services/error-dialog';

const { state, hide } = useErrorDialogController();

const visible = computed({
get: () => state.visible,
set: (value: boolean) => {
if (!value) {
hide();
}
},
});

const header = computed(() => {
if (state.summary) {
return state.summary;
}
if (state.statusCode) {
return `Error ${state.statusCode}`;
}
return 'Error';
});

function dismiss() {
hide();
}
</script>

<style scoped>
@reference '@/assets/base.css';
</style>
7 changes: 4 additions & 3 deletions src/components/users/UserCreateForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand Down Expand Up @@ -120,11 +121,11 @@ async function createUser() {
emit('create', createdUser.value);
} catch (error) {
const errorResponse = error as AxiosError<ErrorResponse<ErrorBody>>;
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;
Expand Down
8 changes: 4 additions & 4 deletions src/components/users/UserEditForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -79,12 +80,11 @@ async function updateUser() {
emit('saved', updatedUser.value);
} catch (error) {
const errorResponse = error as AxiosError<ErrorResponse<ErrorBody>>;
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,
});
}
Expand Down
70 changes: 70 additions & 0 deletions src/composables/useAxiosErrorDialog.ts
Original file line number Diff line number Diff line change
@@ -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<AxiosError<ErrorResponse<ErrorBody>> | null | undefined>,
options: AxiosErrorDialogOptions = {},
) {
const {
summary,
autoShow = true,
detailOverride,
closeLabel,
onClose,
} = options;

watch(errorRef, (error) => {
if (error && autoShow) {
showAxiosErrorDialog(error);
}
});

function showAxiosErrorDialog(error: AxiosError<ErrorResponse<ErrorBody>>) {
errorDialog.showAxiosError(error, {
summary,
detailOverride,
closeLabel,
onClose,
});
}

return {
showAxiosErrorDialog,
};
}
Loading
Loading