diff --git a/src/components/index.ts b/src/components/index.ts index e5fecb8e..b37b9f6b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -20,6 +20,7 @@ export * from './grid'; export * from './inputs'; export * from './multipleSelectionDialog'; export * from './overflowableText'; +export * from './snackbars'; export * from './snackbarProvider'; export * from './topBar'; export * from './treeViewFinder'; diff --git a/src/components/menus/custom-nested-menu.tsx b/src/components/menus/custom-nested-menu.tsx index 68e37721..c8d1cdf2 100644 --- a/src/components/menus/custom-nested-menu.tsx +++ b/src/components/menus/custom-nested-menu.tsx @@ -4,7 +4,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { PropsWithChildren, useState } from 'react'; +import { useState } from 'react'; import { NestedMenuItem, NestedMenuItemProps } from 'mui-nested-menu'; import { Box, MenuItem, type MenuItemProps } from '@mui/material'; import { mergeSx, type SxStyle, type MuiStyles } from '../../utils/styles'; @@ -24,9 +24,10 @@ const styles = { }, } as const satisfies MuiStyles; -interface CustomNestedMenuItemProps extends PropsWithChildren, Omit { +type CustomNestedMenuItemProps = Omit & { + children?: NestedMenuItemProps['children']; sx?: SxStyle; -} +}; export function CustomNestedMenuItem({ sx, children, ...other }: Readonly) { const [subMenuActive, setSubMenuActive] = useState(false); diff --git a/src/components/snackbarProvider/SnackbarProvider.tsx b/src/components/snackbarProvider/SnackbarProvider.tsx index 736fb7c5..b9d91a41 100644 --- a/src/components/snackbarProvider/SnackbarProvider.tsx +++ b/src/components/snackbarProvider/SnackbarProvider.tsx @@ -5,10 +5,14 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { useRef } from 'react'; import { IconButton, styled } from '@mui/material'; import { Clear as ClearIcon } from '@mui/icons-material'; -import { type SnackbarKey, SnackbarProvider as OrigSnackbarProvider, type SnackbarProviderProps } from 'notistack'; +import { + type SnackbarKey, + SnackbarProvider as OrigSnackbarProvider, + type SnackbarProviderProps, + closeSnackbar as closeSnackbarFromNotistack, +} from 'notistack'; import type { MuiStyles } from '../../utils/styles'; const StyledOrigSnackbarProvider = styled(OrigSnackbarProvider)(() => ({ @@ -29,11 +33,9 @@ const styles = { /* A wrapper around notistack's SnackbarProvider that provides defaults props */ export function SnackbarProvider(props: SnackbarProviderProps) { - const ref = useRef(null); - const action = (key: SnackbarKey) => ( ref.current?.closeSnackbar(key)} + onClick={() => closeSnackbarFromNotistack(key)} aria-label="clear-snack" size="small" sx={styles.buttonColor} @@ -44,7 +46,6 @@ export function SnackbarProvider(props: SnackbarProviderProps) { return ( ; + +export interface BackendErrorSnackbarContentProps { + message: string; + detailLabels: BackendErrorDetailLabels; + details: BackendErrorDetails; + showDetailsLabel: string; + hideDetailsLabel: string; + snackbarKey?: SnackbarKey; +} + +const Root = styled(Stack)(({ theme }) => ({ + width: '100%', + color: theme.palette.common.white, + backgroundColor: theme.palette.error.main, + borderRadius: theme.shape.borderRadius, + padding: theme.spacing(1.5), + boxShadow: theme.shadows[6], +})); + +const Header = styled(Stack)(({ theme }) => ({ + width: '100%', + columnGap: theme.spacing(1), +})); + +const ToggleButton = styled(Button)(({ theme }) => ({ + alignSelf: 'flex-start', + padding: 0, + minWidth: 0, + textTransform: 'none', + color: 'inherit', + fontWeight: theme.typography.fontWeightMedium, + '&:hover': { + backgroundColor: 'transparent', + }, +})); + +const DetailsList = styled(Stack)(() => ({ + width: '100%', +})); + +const DetailRow = styled(Stack)(({ theme }) => ({ + flexDirection: 'row', + columnGap: theme.spacing(1), +})); + +export const BackendErrorSnackbarContent = forwardRef( + ({ message, detailLabels, details, showDetailsLabel, hideDetailsLabel, snackbarKey }, ref) => { + const { closeSnackbar } = useSnackbar(); + const [isExpanded, setIsExpanded] = useState(false); + const detailsId = useId(); + + const detailEntries = useMemo(() => { + return (Object.keys(detailLabels) as Array).map((key) => ({ + key, + label: detailLabels[key], + value: details[key], + })); + }, [detailLabels, details]); + + const toggleDetails = useCallback(() => { + setIsExpanded((prev) => !prev); + }, []); + + const handleClose = useCallback(() => { + closeSnackbar(snackbarKey); + }, [closeSnackbar, snackbarKey]); + + return ( + +
+ + {message} + + + + +
+ : } + aria-expanded={isExpanded} + aria-controls={detailsId} + > + {isExpanded ? hideDetailsLabel : showDetailsLabel} + + + + {detailEntries.map(({ key, label, value }) => ( + + theme.typography.fontWeightMedium, + }} + > + {label} + + + {value} + + + ))} + + +
+ ); + } +); + +BackendErrorSnackbarContent.displayName = 'BackendErrorSnackbarContent'; diff --git a/src/components/snackbars/index.ts b/src/components/snackbars/index.ts new file mode 100644 index 00000000..669f587a --- /dev/null +++ b/src/components/snackbars/index.ts @@ -0,0 +1,8 @@ +/** + * Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +export * from './BackendErrorSnackbarContent'; diff --git a/src/hooks/useSnackMessage.ts b/src/hooks/useSnackMessage.ts index e71166d0..7879acd9 100644 --- a/src/hooks/useSnackMessage.ts +++ b/src/hooks/useSnackMessage.ts @@ -10,7 +10,7 @@ import { BaseVariant, OptionsObject, closeSnackbar as closeSnackbarFromNotistack import { IntlShape } from 'react-intl'; import { useIntlRef } from './useIntlRef'; -interface SnackInputs extends Omit { +export interface SnackInputs extends Omit { messageTxt?: string; messageId?: string; messageValues?: Record; diff --git a/src/services/utils.ts b/src/services/utils.ts index 042e7f9e..8f1a51ef 100644 --- a/src/services/utils.ts +++ b/src/services/utils.ts @@ -6,6 +6,11 @@ */ import { getUserToken } from '../redux/commonStore'; +import { + isBackendErrorLike, + normalizeBackendErrorPayload, + type HttpErrorWithBackendDetails, +} from '../utils/backendErrors'; const parseError = (text: string) => { try { @@ -26,18 +31,45 @@ const prepareRequest = (init: RequestInit | undefined, token?: string) => { return initCopy; }; +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + const handleError = (response: Response) => { return response.text().then((text: string) => { const errorName = 'HttpResponseError : '; - const errorJson = parseError(text); - let customError: Error & { status?: number }; - if (errorJson && errorJson.status && errorJson.error && errorJson.message) { + const errorJson = parseError(text) as unknown; + let customError: HttpErrorWithBackendDetails; + + if (isBackendErrorLike(errorJson)) { + const backendError = normalizeBackendErrorPayload(errorJson); + const status = backendError.status ?? response.status; + const jsonRecord = errorJson as Record; + const errorLabel = + typeof jsonRecord.error === 'string' + ? (jsonRecord.error as string) + : (backendError.errorCode ?? response.statusText); + const message = + backendError.message ?? + (typeof jsonRecord.message === 'string' ? (jsonRecord.message as string) : text); customError = new Error( - `${errorName + errorJson.status} ${errorJson.error}, message : ${errorJson.message}` - ); + `${errorName + status} ${errorLabel}, message : ${message}` + ) as HttpErrorWithBackendDetails; + customError.status = status; + customError.backendError = backendError; + } else if ( + isRecord(errorJson) && + typeof errorJson.status === 'number' && + (typeof errorJson.error === 'string' || typeof errorJson.message === 'string') + ) { + const errorLabel = typeof errorJson.error === 'string' ? errorJson.error : response.statusText; + const message = typeof errorJson.message === 'string' ? errorJson.message : text; + customError = new Error( + `${errorName + errorJson.status} ${errorLabel}, message : ${message}` + ) as HttpErrorWithBackendDetails; customError.status = errorJson.status; } else { - customError = new Error(`${errorName + response.status} ${response.statusText}, message : ${text}`); + customError = new Error( + `${errorName + response.status} ${response.statusText}, message : ${text}` + ) as HttpErrorWithBackendDetails; customError.status = response.status; } throw customError; diff --git a/src/translations/en/parameters.ts b/src/translations/en/parameters.ts index 79361916..65df672b 100644 --- a/src/translations/en/parameters.ts +++ b/src/translations/en/parameters.ts @@ -230,6 +230,12 @@ export const parametersEn = { Optional: ' (optional)', // Computed translations used in the snackbars + serverLabel: 'Server', + messageLabel: 'Message', + pathLabel: 'Path', + showDetails: 'Show details', + hideDetails: 'Hide details', + genericMessage: 'We were unable to complete your request.', // LoadFlow fetchDefaultLoadFlowProviderError: 'An error occured when fetching default load flow provider', fetchLoadFlowParametersError: 'An error occured when fetching the load flow parameters', diff --git a/src/translations/fr/parameters.ts b/src/translations/fr/parameters.ts index f36a5835..96a0460c 100644 --- a/src/translations/fr/parameters.ts +++ b/src/translations/fr/parameters.ts @@ -235,6 +235,12 @@ export const parametersFr = { Optional: ' (optionnel)', // Computed translations used in the snackbars + serverLabel: 'Serveur', + messageLabel: 'Message', + pathLabel: 'Chemin', + showDetails: 'Afficher les détails', + hideDetails: 'Masquer les détails', + genericMessage: "Nous n'avons pas pu finaliser votre requête.", // LoadFlow fetchDefaultLoadFlowProviderError: 'Une erreur est survenue lors de la récupération du fournisseur de calcul de répartition par défaut', diff --git a/src/utils/backendErrors.ts b/src/utils/backendErrors.ts new file mode 100644 index 00000000..e98dfbc8 --- /dev/null +++ b/src/utils/backendErrors.ts @@ -0,0 +1,193 @@ +/** + * Copyright (c) 2025, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { createElement } from 'react'; +import { IntlShape } from 'react-intl'; +import { + BackendErrorSnackbarContent, + type BackendErrorSnackbarContentProps, +} from '../components/snackbars/BackendErrorSnackbarContent'; +import { type SnackInputs, type UseSnackMessageReturn } from '../hooks/useSnackMessage'; + +const BACKEND_DETAIL_FALLBACK = '-'; + +type BackendErrorDetails = BackendErrorSnackbarContentProps['details']; + +type BackendErrorDetailLabels = BackendErrorSnackbarContentProps['detailLabels']; + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + +export interface BackendErrorPayload { + service?: string; + errorCode?: string; + message?: string; + status?: number; + timestamp?: string; + path?: string; + correlationId?: string; +} + +export interface HttpErrorWithBackendDetails extends Error { + status: number; + backendError?: BackendErrorPayload; +} + +export const isBackendErrorLike = (value: unknown): value is BackendErrorPayload => { + if (!isRecord(value)) { + return false; + } + return ( + 'service' in value || + 'errorCode' in value || + 'message' in value || + 'status' in value || + 'timestamp' in value || + 'path' in value || + 'correlationId' in value + ); +}; + +export const normalizeBackendErrorPayload = (payload: BackendErrorPayload): BackendErrorPayload => { + const record = payload as BackendErrorPayload & { server?: unknown }; + return { + service: typeof record.service === 'string' ? record.service : undefined, + errorCode: typeof record.errorCode === 'string' ? record.errorCode : undefined, + message: typeof record.message === 'string' ? record.message : undefined, + status: typeof record.status === 'number' ? record.status : undefined, + timestamp: typeof record.timestamp === 'string' ? record.timestamp : undefined, + path: typeof record.path === 'string' ? record.path : undefined, + correlationId: typeof record.correlationId === 'string' ? record.correlationId : undefined, + }; +}; + +const toBackendErrorPayload = (value: unknown): BackendErrorPayload | undefined => { + if (!isBackendErrorLike(value)) { + return undefined; + } + return normalizeBackendErrorPayload(value); +}; + +const hasBackendError = (value: unknown): value is { backendError?: unknown } => + isRecord(value) && 'backendError' in value; + +export const extractBackendErrorPayload = (error: unknown): BackendErrorPayload | undefined => { + if (hasBackendError(error) && error.backendError) { + return toBackendErrorPayload(error.backendError); + } + return toBackendErrorPayload(error); +}; + +export const createBackendErrorDetails = ( + payload: BackendErrorPayload +): BackendErrorSnackbarContentProps['details'] => ({ + service: typeof payload.service === 'string' ? payload.service : '', + message: typeof payload.message === 'string' ? payload.message : '', + path: typeof payload.path === 'string' ? payload.path : '', +}); + +const formatBackendDetailValue = (value: string): string => (value.trim().length > 0 ? value : BACKEND_DETAIL_FALLBACK); + +const formatBackendErrorDetails = (details: BackendErrorDetails): BackendErrorDetails => ({ + service: formatBackendDetailValue(details.service), + message: formatBackendDetailValue(details.message), + path: formatBackendDetailValue(details.path), +}); + +const createBackendErrorDetailLabels = (intl: IntlShape): BackendErrorDetailLabels => ({ + service: intl.formatMessage({ id: 'serverLabel' }), + message: intl.formatMessage({ id: 'messageLabel' }), + path: intl.formatMessage({ id: 'pathLabel' }), +}); + +interface BackendErrorPresentation { + message: string; + detailLabels: BackendErrorDetailLabels; + formattedDetails: BackendErrorDetails; + showDetailsLabel: string; + hideDetailsLabel: string; +} + +const createBackendErrorPresentation = ( + intl: IntlShape, + details: BackendErrorDetails, + firstLine?: string +): BackendErrorPresentation => ({ + message: firstLine ?? intl.formatMessage({ id: 'genericMessage' }), + detailLabels: createBackendErrorDetailLabels(intl), + formattedDetails: formatBackendErrorDetails(details), + showDetailsLabel: intl.formatMessage({ id: 'showDetails' }), + hideDetailsLabel: intl.formatMessage({ id: 'hideDetails' }), +}); + +export const snackErrorWithBackendFallback = ( + error: unknown, + snackError: UseSnackMessageReturn['snackError'], + intl: IntlShape, + additionalSnack?: Partial +) => { + const backendPayload = extractBackendErrorPayload(error); + const backendDetails = backendPayload ? createBackendErrorDetails(backendPayload) : undefined; + + if (backendDetails) { + const { headerId, headerTxt, headerValues, persist, messageId, messageTxt, messageValues, ...rest } = + additionalSnack ?? {}; + const otherSnackProps: Partial = rest ? { ...(rest as Partial) } : {}; + + const firstLine = messageTxt ?? (messageId ? intl.formatMessage({ id: messageId }, messageValues) : undefined); + + const presentation = createBackendErrorPresentation(intl, backendDetails, firstLine); + + const snackInputs: SnackInputs = { + ...(otherSnackProps as SnackInputs), + messageTxt: presentation.message, + persist: persist ?? true, + content: (snackbarKey, snackMessage) => + createElement(BackendErrorSnackbarContent, { + snackbarKey, + message: + typeof snackMessage === 'string' && snackMessage.length > 0 + ? snackMessage + : presentation.message, + detailLabels: presentation.detailLabels, + details: presentation.formattedDetails, + showDetailsLabel: presentation.showDetailsLabel, + hideDetailsLabel: presentation.hideDetailsLabel, + }), + }; + + if (headerId !== undefined) { + snackInputs.headerId = headerId; + } + if (headerTxt !== undefined) { + snackInputs.headerTxt = headerTxt; + } + if (headerValues !== undefined) { + snackInputs.headerValues = headerValues; + } + + snackError(snackInputs); + return; + } + + if (additionalSnack) { + const { messageTxt: additionalMessageTxt, messageId: additionalMessageId } = additionalSnack; + if (additionalMessageTxt !== undefined || additionalMessageId !== undefined) { + snackError(additionalSnack as SnackInputs); + return; + } + } + + const message = error instanceof Error ? error.message : String(error); + const restSnackInputs: Partial = additionalSnack ? { ...additionalSnack } : {}; + delete restSnackInputs.messageId; + delete restSnackInputs.messageTxt; + delete restSnackInputs.messageValues; + snackError({ + ...(restSnackInputs as SnackInputs), + messageTxt: message, + }); +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 89c23fe9..603dc8ff 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -8,6 +8,7 @@ export * from './algos'; export * from './constants'; export * from './conversionUtils'; export * from './functions'; +export * from './backendErrors'; export * from './langs'; export * from './mapper'; export * from './constants/notificationsProvider';