fix(errors) : humanize backend error messages for end users
Add centralized error translation layer (humanizeError) that converts raw Symfony/Doctrine/API Platform messages into user-friendly French. Fix useApi to extract errors from all backend response formats (violations, error, message, hydra:description, detail). Add toast deduplication to prevent double display. Replace error toast icon (X → CircleX) to distinguish from the dismiss button. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,7 @@
|
|||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<IconLucideX
|
<IconLucideCircleX
|
||||||
v-else-if="toast.type === 'error'"
|
v-else-if="toast.type === 'error'"
|
||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import IconLucideCheck from '~icons/lucide/check'
|
import IconLucideCheck from '~icons/lucide/check'
|
||||||
import IconLucideX from '~icons/lucide/x'
|
import IconLucideX from '~icons/lucide/x'
|
||||||
|
import IconLucideCircleX from '~icons/lucide/circle-x'
|
||||||
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
||||||
import IconLucideInfo from '~icons/lucide/info'
|
import IconLucideInfo from '~icons/lucide/info'
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ import {
|
|||||||
type ModelTypeListResponse,
|
type ModelTypeListResponse,
|
||||||
} from "~/services/modelTypes";
|
} from "~/services/modelTypes";
|
||||||
import { useToast } from "~/composables/useToast";
|
import { useToast } from "~/composables/useToast";
|
||||||
|
import { humanizeError } from "~/shared/utils/errorMessages";
|
||||||
import { invalidateEntityTypeCache } from "~/composables/useEntityTypes";
|
import { invalidateEntityTypeCache } from "~/composables/useEntityTypes";
|
||||||
|
|
||||||
const DEFAULT_DESCRIPTION =
|
const DEFAULT_DESCRIPTION =
|
||||||
@@ -183,7 +184,8 @@ useHead(() => ({
|
|||||||
title: headingText.value,
|
title: headingText.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const extractErrorMessage = (error: unknown) => {
|
const extractErrorMessage = (error: unknown): string => {
|
||||||
|
let raw: string | null = null;
|
||||||
if (error && typeof error === "object") {
|
if (error && typeof error === "object") {
|
||||||
const maybeFetchError = error as {
|
const maybeFetchError = error as {
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
@@ -192,21 +194,16 @@ const extractErrorMessage = (error: unknown) => {
|
|||||||
};
|
};
|
||||||
if (maybeFetchError.data) {
|
if (maybeFetchError.data) {
|
||||||
const data = maybeFetchError.data;
|
const data = maybeFetchError.data;
|
||||||
if (typeof data.message === "string") {
|
if (typeof data['hydra:description'] === "string") raw = data['hydra:description'];
|
||||||
return data.message;
|
else if (typeof data.detail === "string") raw = data.detail;
|
||||||
}
|
else if (typeof data.message === "string") raw = data.message;
|
||||||
if (Array.isArray(data.message) && data.message.length > 0) {
|
else if (Array.isArray(data.message) && data.message.length > 0) raw = data.message[0];
|
||||||
return data.message[0];
|
else if (typeof data.error === "string") raw = data.error;
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof maybeFetchError.statusMessage === "string") {
|
|
||||||
return maybeFetchError.statusMessage;
|
|
||||||
}
|
|
||||||
if (typeof maybeFetchError.message === "string") {
|
|
||||||
return maybeFetchError.message;
|
|
||||||
}
|
}
|
||||||
|
if (!raw && typeof maybeFetchError.statusMessage === "string") raw = maybeFetchError.statusMessage;
|
||||||
|
if (!raw && typeof maybeFetchError.message === "string") raw = maybeFetchError.message;
|
||||||
}
|
}
|
||||||
return "Une erreur est survenue lors de la communication avec le serveur.";
|
return humanizeError(raw);
|
||||||
};
|
};
|
||||||
|
|
||||||
const refresh = async ({
|
const refresh = async ({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
|
import { humanizeError, extractApiErrorMessage } from '~/shared/utils/errorMessages'
|
||||||
|
|
||||||
export interface ApiResponse<T = any> {
|
export interface ApiResponse<T = any> {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -59,23 +60,26 @@ export function useApi() {
|
|||||||
} else {
|
} else {
|
||||||
const contentType = response.headers.get('content-type') || ''
|
const contentType = response.headers.get('content-type') || ''
|
||||||
let errorData: Record<string, unknown> = {}
|
let errorData: Record<string, unknown> = {}
|
||||||
if (contentType.includes('application/json')) {
|
if (contentType.includes('json')) {
|
||||||
errorData = await response.json().catch(() => ({}))
|
errorData = await response.json().catch(() => ({}))
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text().catch(() => '')
|
const text = await response.text().catch(() => '')
|
||||||
errorData = text ? { message: text } : {}
|
errorData = text ? { message: text } : {}
|
||||||
}
|
}
|
||||||
const errorMessage = response.status === 403
|
const rawMessage = response.status === 403
|
||||||
? 'Permissions insuffisantes pour cette action.'
|
? 'Permissions insuffisantes pour cette action.'
|
||||||
: (errorData.message as string) || `Erreur ${response.status}: ${response.statusText}`
|
: extractApiErrorMessage(errorData) || `Erreur ${response.status}: ${response.statusText}`
|
||||||
|
const errorMessage = humanizeError(rawMessage)
|
||||||
showError(errorMessage)
|
showError(errorMessage)
|
||||||
return { success: false, error: errorMessage, status: response.status }
|
return { success: false, error: errorMessage, status: response.status }
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
const err = error as Error & { name?: string }
|
const err = error as Error & { name?: string }
|
||||||
const errorMessage = err.name === 'AbortError' ? 'Timeout de la requête' : err.message || 'Erreur réseau'
|
const errorMessage = err.name === 'AbortError'
|
||||||
showError(`Erreur réseau: ${errorMessage}`)
|
? 'La requête a pris trop de temps. Veuillez réessayer.'
|
||||||
|
: 'Impossible de contacter le serveur. Vérifiez votre connexion.'
|
||||||
|
showError(errorMessage)
|
||||||
return { success: false, error: errorMessage }
|
return { success: false, error: errorMessage }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { ref, type Ref } from 'vue'
|
import { ref, type Ref } from 'vue'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import {
|
import {
|
||||||
listModelTypes,
|
listModelTypes,
|
||||||
createModelType,
|
createModelType,
|
||||||
@@ -102,8 +103,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
return { success: true, data: state.types.value }
|
return { success: true, data: state.types.value }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error & { message?: string }
|
const err = error as Error & { message?: string }
|
||||||
const message = err?.message || 'Erreur inconnue'
|
const message = humanizeError(err?.message)
|
||||||
showError(`Impossible de charger les types de ${label}: ${message}`)
|
showError(`Impossible de charger les types de ${label}.`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
state.loading.value = false
|
state.loading.value = false
|
||||||
@@ -127,8 +128,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
return { success: true, data: normalized }
|
return { success: true, data: normalized }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
const raw = err?.data?.message || err?.message
|
||||||
showError(`Erreur lors de la création du type de ${label}: ${message}`)
|
const message = humanizeError(raw)
|
||||||
|
showError(`Impossible de créer le type de ${label} : ${message}`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
state.loading.value = false
|
state.loading.value = false
|
||||||
@@ -152,8 +154,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
return { success: true, data: normalized }
|
return { success: true, data: normalized }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
const raw = err?.data?.message || err?.message
|
||||||
showError(`Erreur lors de la mise à jour du type de ${label}: ${message}`)
|
const message = humanizeError(raw)
|
||||||
|
showError(`Impossible de mettre à jour le type de ${label} : ${message}`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
state.loading.value = false
|
state.loading.value = false
|
||||||
@@ -169,8 +172,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
const raw = err?.data?.message || err?.message
|
||||||
showError(`Erreur lors de la suppression du type de ${label}: ${message}`)
|
const message = humanizeError(raw)
|
||||||
|
showError(`Impossible de supprimer le type de ${label} : ${message}`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
state.loading.value = false
|
state.loading.value = false
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { usePieces } from '~/composables/usePieces'
|
|||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useMachineCreateSelections } from '~/composables/useMachineCreateSelections'
|
import { useMachineCreateSelections } from '~/composables/useMachineCreateSelections'
|
||||||
import {
|
import {
|
||||||
useMachineCreatePreview,
|
useMachineCreatePreview,
|
||||||
@@ -365,10 +366,10 @@ export function useMachineCreatePage() {
|
|||||||
clearRequirementSelections()
|
clearRequirementSelections()
|
||||||
await navigateTo('/machines')
|
await navigateTo('/machines')
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
toast.showError(`Impossible de créer la machine: ${result.error}`)
|
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.showError(`Erreur lors de la création: ${error.message}`)
|
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
import { useApi } from './useApi'
|
import { useApi } from './useApi'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||||
@@ -168,9 +169,9 @@ export function useProducts() {
|
|||||||
return result as ProductListResult
|
return result as ProductListResult
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur lors du chargement des produits:', err)
|
console.error('Erreur lors du chargement des produits:', err)
|
||||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
const message = humanizeError((err as Error)?.message)
|
||||||
error.value = message
|
error.value = message
|
||||||
showError(`Impossible de charger les produits: ${message}`)
|
showError(`Impossible de charger les produits.`)
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -197,9 +198,9 @@ export function useProducts() {
|
|||||||
return { success: false, error: result.error }
|
return { success: false, error: result.error }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur lors de la création du produit:', err)
|
console.error('Erreur lors de la création du produit:', err)
|
||||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
const message = humanizeError((err as Error)?.message)
|
||||||
error.value = message
|
error.value = message
|
||||||
showError(message)
|
showError('Impossible de créer le produit.')
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -223,9 +224,9 @@ export function useProducts() {
|
|||||||
return { success: false, error: result.error }
|
return { success: false, error: result.error }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur lors de la mise à jour du produit:', err)
|
console.error('Erreur lors de la mise à jour du produit:', err)
|
||||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
const message = humanizeError((err as Error)?.message)
|
||||||
error.value = message
|
error.value = message
|
||||||
showError(message)
|
showError('Impossible de mettre à jour le produit.')
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -248,9 +249,9 @@ export function useProducts() {
|
|||||||
return { success: false, error: result.error }
|
return { success: false, error: result.error }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Erreur lors de la suppression du produit:', err)
|
console.error('Erreur lors de la suppression du produit:', err)
|
||||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
const message = humanizeError((err as Error)?.message)
|
||||||
error.value = message
|
error.value = message
|
||||||
showError(message)
|
showError('Impossible de supprimer le produit.')
|
||||||
return { success: false, error: message }
|
return { success: false, error: message }
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|||||||
import { navigateTo, useRoute } from '#imports'
|
import { navigateTo, useRoute } from '#imports'
|
||||||
import { useSites } from '~/composables/useSites'
|
import { useSites } from '~/composables/useSites'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConfirm } from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm'
|
||||||
import { getFileIcon } from '~/utils/fileIcons'
|
import { getFileIcon } from '~/utils/fileIcons'
|
||||||
@@ -274,10 +275,10 @@ export function useSiteManagement() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
showSuccess(`Site "${site.name}" supprimé avec succès`)
|
showSuccess(`Site "${site.name}" supprimé avec succès`)
|
||||||
} else {
|
} else {
|
||||||
showError(`Erreur lors de la suppression: ${result.error}`)
|
showError(`Impossible de supprimer le site : ${humanizeError(result.error)}`)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
showError(`Erreur lors de la suppression: ${error.message}`)
|
showError(`Impossible de supprimer le site : ${humanizeError(error.message)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,19 @@ const toasts = ref<Toast[]>([])
|
|||||||
const MAX_TOASTS = 3
|
const MAX_TOASTS = 3
|
||||||
let nextId = 1
|
let nextId = 1
|
||||||
|
|
||||||
|
// Anti-doublon : ignore un toast identique affiché dans les 2 dernières secondes
|
||||||
|
const recentMessages = new Map<string, number>()
|
||||||
|
const DEDUP_WINDOW = 2000
|
||||||
|
|
||||||
export function useToast() {
|
export function useToast() {
|
||||||
const showToast = (message: string, type: ToastType = 'info', duration = 3500): number => {
|
const showToast = (message: string, type: ToastType = 'info', duration = 3500): number => {
|
||||||
|
const dedupKey = `${type}::${message}`
|
||||||
|
const lastShown = recentMessages.get(dedupKey)
|
||||||
|
if (lastShown && Date.now() - lastShown < DEDUP_WINDOW) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
recentMessages.set(dedupKey, Date.now())
|
||||||
|
|
||||||
const id = nextId++
|
const id = nextId++
|
||||||
const toast: Toast = {
|
const toast: Toast = {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -370,6 +370,7 @@ import { useProducts } from '~/composables/useProducts'
|
|||||||
import { useProductTypes } from '~/composables/useProductTypes'
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||||
@@ -998,7 +999,7 @@ const submitCreation = async () => {
|
|||||||
toast.showError(result.error)
|
toast.showError(result.error)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.showError(error?.message || 'Erreur lors de la création du composant')
|
toast.showError(humanizeError(error?.message) || 'Impossible de créer le composant')
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
uploadingDocuments.value = false
|
uploadingDocuments.value = false
|
||||||
|
|||||||
@@ -472,6 +472,7 @@ import { useSites } from '~/composables/useSites'
|
|||||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||||
import { useMachines } from '~/composables/useMachines'
|
import { useMachines } from '~/composables/useMachines'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import IconLucideFactory from '~icons/lucide/factory'
|
import IconLucideFactory from '~icons/lucide/factory'
|
||||||
import IconLucideMapPin from '~icons/lucide/map-pin'
|
import IconLucideMapPin from '~icons/lucide/map-pin'
|
||||||
import IconLucideUser from '~icons/lucide/user'
|
import IconLucideUser from '~icons/lucide/user'
|
||||||
@@ -731,10 +732,10 @@ const confirmDeleteMachine = async (machine) => {
|
|||||||
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
|
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
|
||||||
await loadMachines()
|
await loadMachines()
|
||||||
} else {
|
} else {
|
||||||
showError(`Erreur lors de la suppression: ${result.error}`)
|
showError(`Impossible de supprimer la machine : ${result.error}`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Erreur lors de la suppression: ${error.message}`)
|
showError(`Impossible de supprimer la machine : ${humanizeError(error.message)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useMachineTypesApi } from "~/composables/useMachineTypesApi";
|
import { useMachineTypesApi } from "~/composables/useMachineTypesApi";
|
||||||
import { useToast } from "~/composables/useToast";
|
import { useToast } from "~/composables/useToast";
|
||||||
|
import { humanizeError } from "~/shared/utils/errorMessages";
|
||||||
import IconLucidePlus from "~icons/lucide/plus";
|
import IconLucidePlus from "~icons/lucide/plus";
|
||||||
import IconLucidePackage from "~icons/lucide/package";
|
import IconLucidePackage from "~icons/lucide/package";
|
||||||
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
|
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
|
||||||
@@ -148,10 +149,10 @@ const confirmDeleteType = async (type) => {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
showSuccess(`Type "${type.name}" supprimé avec succès`);
|
showSuccess(`Type "${type.name}" supprimé avec succès`);
|
||||||
} else {
|
} else {
|
||||||
showError(`Erreur lors de la suppression: ${result.error}`);
|
showError(`Impossible de supprimer le type : ${result.error}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Erreur lors de la suppression: ${error.message}`);
|
showError(`Impossible de supprimer le type : ${humanizeError(error.message)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ import { useMachines } from '~/composables/useMachines'
|
|||||||
import { useSites } from '~/composables/useSites'
|
import { useSites } from '~/composables/useSites'
|
||||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
import IconLucideFactory from '~icons/lucide/factory'
|
import IconLucideFactory from '~icons/lucide/factory'
|
||||||
import IconLucideMapPin from '~icons/lucide/map-pin'
|
import IconLucideMapPin from '~icons/lucide/map-pin'
|
||||||
@@ -214,10 +215,10 @@ const confirmDeleteMachine = async (machine) => {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
|
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
|
||||||
} else {
|
} else {
|
||||||
showError(`Erreur lors de la suppression: ${result.error}`)
|
showError(`Impossible de supprimer la machine : ${result.error}`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Erreur lors de la suppression: ${error.message}`)
|
showError(`Impossible de supprimer la machine : ${humanizeError(error.message)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ import SearchSelect from '~/components/common/SearchSelect.vue'
|
|||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||||
@@ -599,7 +600,7 @@ const submitCreation = async () => {
|
|||||||
toast.showError(result.error)
|
toast.showError(result.error)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.showError(error?.message || 'Erreur lors de la création de la pièce')
|
toast.showError(humanizeError(error?.message) || 'Impossible de créer la pièce')
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
uploadingDocuments.value = false
|
uploadingDocuments.value = false
|
||||||
|
|||||||
@@ -407,6 +407,7 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
|||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
import { useCustomFields } from '~/composables/useCustomFields'
|
import { useCustomFields } from '~/composables/useCustomFields'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
|
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
|
||||||
@@ -700,7 +701,7 @@ const submitEdition = async () => {
|
|||||||
await router.push('/product-catalog')
|
await router.push('/product-catalog')
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.showError(error?.message || 'Erreur lors de la mise à jour du produit')
|
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
152
app/shared/utils/errorMessages.ts
Normal file
152
app/shared/utils/errorMessages.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Translates raw backend error messages (Symfony, Doctrine, API Platform)
|
||||||
|
* into user-friendly French messages for display in toasts/alerts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const EXACT_MATCHES: Record<string, string> = {
|
||||||
|
// UniqueConstraintSubscriber (HTTP 409)
|
||||||
|
'nom duplique': 'Un élément avec ce nom existe déjà.',
|
||||||
|
|
||||||
|
// English backend messages → French
|
||||||
|
'Machine not found.': 'Machine introuvable.',
|
||||||
|
'Composant not found.': 'Composant introuvable.',
|
||||||
|
'Piece not found.': 'Pièce introuvable.',
|
||||||
|
'Product not found.': 'Produit introuvable.',
|
||||||
|
'Site not found.': 'Site introuvable.',
|
||||||
|
'Custom field not found.': 'Champ personnalisé introuvable.',
|
||||||
|
'Custom field value not found.': 'Valeur du champ personnalisé introuvable.',
|
||||||
|
'Document not found.': 'Document introuvable.',
|
||||||
|
'File not found on disk.': 'Le fichier n\'a pas été trouvé sur le serveur.',
|
||||||
|
'Invalid document data.': 'Les données du document sont invalides.',
|
||||||
|
'Invalid JSON payload.': 'Les données envoyées sont invalides.',
|
||||||
|
'Unsupported entity type.': 'Type d\'entité non supporté.',
|
||||||
|
'Entity target is missing.': 'La cible de l\'entité est manquante.',
|
||||||
|
'customFieldId or customFieldName is required.': 'L\'identifiant du champ personnalisé est requis.',
|
||||||
|
|
||||||
|
// Symfony validator messages
|
||||||
|
'This value should not be blank.': 'Ce champ ne peut pas être vide.',
|
||||||
|
'This value is not a valid email address.': 'L\'adresse email n\'est pas valide.',
|
||||||
|
'This value is already used.': 'Cette valeur est déjà utilisée.',
|
||||||
|
'This field is missing.': 'Un champ obligatoire est manquant.',
|
||||||
|
|
||||||
|
// HTTP status texts (used in "Erreur XXX: StatusText" fallback)
|
||||||
|
'Internal Server Error': 'Erreur interne du serveur. Veuillez réessayer.',
|
||||||
|
'Bad Request': 'Requête invalide.',
|
||||||
|
'Not Found': 'Ressource introuvable.',
|
||||||
|
'Conflict': 'Un élément similaire existe déjà.',
|
||||||
|
'Unprocessable Entity': 'Données invalides.',
|
||||||
|
'Unprocessable Content': 'Données invalides.',
|
||||||
|
'Service Unavailable': 'Service temporairement indisponible. Veuillez réessayer.',
|
||||||
|
'Gateway Timeout': 'Le serveur met trop de temps à répondre. Veuillez réessayer.',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TECHNICAL_PATTERNS: Array<[RegExp, string]> = [
|
||||||
|
// Database / Doctrine errors
|
||||||
|
[/SQLSTATE\[/i, 'Une erreur est survenue. Veuillez réessayer.'],
|
||||||
|
[/An exception occurred/i, 'Une erreur est survenue. Veuillez réessayer.'],
|
||||||
|
[/Duplicate entry/i, 'Un élément avec ces données existe déjà.'],
|
||||||
|
[/unique.*constraint.*violation/i, 'Un élément avec ces données existe déjà.'],
|
||||||
|
[/foreign key constraint/i, 'Impossible de supprimer cet élément car il est utilisé ailleurs.'],
|
||||||
|
[/violates not-null constraint/i, 'Un champ obligatoire n\'a pas été renseigné.'],
|
||||||
|
[/violates check constraint/i, 'Une valeur saisie est invalide.'],
|
||||||
|
|
||||||
|
// Symfony / API Platform internal messages
|
||||||
|
[/Expected argument of type/i, 'Les données envoyées sont invalides.'],
|
||||||
|
[/Could not denormalize/i, 'Les données envoyées sont invalides.'],
|
||||||
|
[/The JSON value could not be decoded/i, 'Les données envoyées sont invalides.'],
|
||||||
|
[/Syntax error.*JSON/i, 'Les données envoyées sont invalides.'],
|
||||||
|
[/No route found/i, 'Ressource introuvable.'],
|
||||||
|
[/Access Denied/i, 'Permissions insuffisantes pour cette action.'],
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects if a message contains technical jargon that should not be shown to users.
|
||||||
|
*/
|
||||||
|
function containsTechnicalJargon(message: string): boolean {
|
||||||
|
const patterns = [
|
||||||
|
/stack trace/i,
|
||||||
|
/exception/i,
|
||||||
|
/\bat\s+[\w\\]+::/,
|
||||||
|
/vendor\//,
|
||||||
|
/\.php/,
|
||||||
|
/doctrine/i,
|
||||||
|
/symfony/i,
|
||||||
|
/SQLSTATE/i,
|
||||||
|
/PDOException/i,
|
||||||
|
/DBALException/i,
|
||||||
|
/RuntimeException/i,
|
||||||
|
/TypeError/i,
|
||||||
|
/LogicException/i,
|
||||||
|
/InvalidArgumentException/i,
|
||||||
|
/UnexpectedValueException/i,
|
||||||
|
/constraint.*violation/i,
|
||||||
|
/entity.*manager/i,
|
||||||
|
/Hydra error/i,
|
||||||
|
]
|
||||||
|
return patterns.some((p) => p.test(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates a raw backend error message into a user-friendly French message.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
|
* showError(humanizeError(rawMessage))
|
||||||
|
*/
|
||||||
|
export function humanizeError(rawMessage: string | undefined | null): string {
|
||||||
|
if (!rawMessage) return 'Une erreur est survenue.'
|
||||||
|
|
||||||
|
const trimmed = rawMessage.trim()
|
||||||
|
if (!trimmed) return 'Une erreur est survenue.'
|
||||||
|
|
||||||
|
// 1. Exact match
|
||||||
|
if (EXACT_MATCHES[trimmed]) return EXACT_MATCHES[trimmed]
|
||||||
|
|
||||||
|
// 2. "Erreur XXX: StatusText" pattern — translate the status text
|
||||||
|
const httpMatch = trimmed.match(/^Erreur (\d{3})\s*:\s*(.+)$/)
|
||||||
|
if (httpMatch) {
|
||||||
|
const statusText = httpMatch[2]!.trim()
|
||||||
|
if (EXACT_MATCHES[statusText]) return EXACT_MATCHES[statusText]
|
||||||
|
return `Erreur serveur (${httpMatch[1]}). Veuillez réessayer.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Regex patterns for technical errors
|
||||||
|
for (const [pattern, replacement] of TECHNICAL_PATTERNS) {
|
||||||
|
if (pattern.test(trimmed)) return replacement
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. If it contains technical jargon, replace with generic message
|
||||||
|
if (containsTechnicalJargon(trimmed)) {
|
||||||
|
return 'Une erreur est survenue. Veuillez réessayer.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Already user-friendly — return as-is
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the best error message from various backend response formats.
|
||||||
|
* Handles: { message }, { error }, { detail }, { "hydra:description" }
|
||||||
|
*/
|
||||||
|
export function extractApiErrorMessage(errorData: Record<string, unknown>): string | null {
|
||||||
|
if (!errorData || typeof errorData !== 'object') return null
|
||||||
|
|
||||||
|
// Symfony validator violations — priorité max (message propre sans préfixe champ)
|
||||||
|
if (Array.isArray(errorData.violations) && errorData.violations.length > 0) {
|
||||||
|
const first = errorData.violations[0] as Record<string, unknown>
|
||||||
|
if (typeof first?.message === 'string') return first.message
|
||||||
|
}
|
||||||
|
|
||||||
|
// UniqueConstraintSubscriber format ({ success: false, error: "nom duplique" })
|
||||||
|
if (typeof errorData.error === 'string') return errorData.error
|
||||||
|
|
||||||
|
// Custom controllers format
|
||||||
|
if (typeof errorData.message === 'string') return errorData.message
|
||||||
|
if (Array.isArray(errorData.message) && typeof errorData.message[0] === 'string') return errorData.message[0]
|
||||||
|
|
||||||
|
// API Platform hydra format (fallback — peut contenir "propertyPath: message")
|
||||||
|
if (typeof errorData['hydra:description'] === 'string') return errorData['hydra:description']
|
||||||
|
if (typeof errorData.detail === 'string') return errorData.detail
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user