Skip to content
Draft
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
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
7 changes: 4 additions & 3 deletions src/components/menus/custom-nested-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -24,9 +24,10 @@ const styles = {
},
} as const satisfies MuiStyles;

interface CustomNestedMenuItemProps extends PropsWithChildren, Omit<NestedMenuItemProps, 'parentMenuOpen'> {
type CustomNestedMenuItemProps = Omit<NestedMenuItemProps, 'parentMenuOpen' | 'children'> & {
children?: NestedMenuItemProps['children'];
sx?: SxStyle;
}
};

export function CustomNestedMenuItem({ sx, children, ...other }: Readonly<CustomNestedMenuItemProps>) {
const [subMenuActive, setSubMenuActive] = useState(false);
Expand Down
13 changes: 7 additions & 6 deletions src/components/snackbarProvider/SnackbarProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)(() => ({
Expand All @@ -29,11 +33,9 @@ const styles = {

/* A wrapper around notistack's SnackbarProvider that provides defaults props */
export function SnackbarProvider(props: SnackbarProviderProps) {
const ref = useRef<OrigSnackbarProvider>(null);

const action = (key: SnackbarKey) => (
<IconButton
onClick={() => ref.current?.closeSnackbar(key)}
onClick={() => closeSnackbarFromNotistack(key)}
aria-label="clear-snack"
size="small"
sx={styles.buttonColor}
Expand All @@ -44,7 +46,6 @@ export function SnackbarProvider(props: SnackbarProviderProps) {

return (
<StyledOrigSnackbarProvider
ref={ref}
anchorOrigin={{ horizontal: 'center', vertical: 'bottom' }}
hideIconVariant
action={action}
Expand Down
138 changes: 138 additions & 0 deletions src/components/snackbars/BackendErrorSnackbarContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* 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/.
*/

import { Close, ExpandLess, ExpandMore } from '@mui/icons-material';
import { Button, Collapse, IconButton, Stack, Typography, styled } from '@mui/material';
import { useSnackbar, type SnackbarKey } from 'notistack';
import { forwardRef, useCallback, useId, useMemo, useState } from 'react';

interface BackendErrorDetails {
service: string;
message: string;
path: string;
}

export type BackendErrorDetailLabels = Record<keyof BackendErrorDetails, string>;

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<HTMLDivElement, BackendErrorSnackbarContentProps>(
({ 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<keyof BackendErrorDetails>).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 (
<Root ref={ref} spacing={1} role="alert">
<Header direction="row" alignItems="flex-start" justifyContent="space-between">
<Stack spacing={0.5} flexGrow={1} minWidth={0} pr={1}>
<Typography variant="body2">{message}</Typography>
</Stack>
<IconButton aria-label="close-snackbar" color="inherit" onClick={handleClose} size="small">
<Close fontSize="small" />
</IconButton>
</Header>
<ToggleButton
onClick={toggleDetails}

Check warning on line 98 in src/components/snackbars/BackendErrorSnackbarContent.tsx

View workflow job for this annotation

GitHub Actions / build / build

Delete `·`
endIcon={isExpanded ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
aria-expanded={isExpanded}
aria-controls={detailsId}
>
{isExpanded ? hideDetailsLabel : showDetailsLabel}
</ToggleButton>
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<DetailsList spacing={0.5} id={detailsId}>
{detailEntries.map(({ key, label, value }) => (
<DetailRow key={key} alignItems="flex-start">
<Typography
variant="caption"
component="dt"
sx={{
flexShrink: 0,
fontWeight: (theme) => theme.typography.fontWeightMedium,
}}
>
{label}
</Typography>
<Typography
variant="caption"
component="dd"
sx={{
margin: 0,
wordBreak: 'break-word',
}}
>
{value}
</Typography>
</DetailRow>
))}
</DetailsList>
</Collapse>
</Root>
);
}
);

BackendErrorSnackbarContent.displayName = 'BackendErrorSnackbarContent';
8 changes: 8 additions & 0 deletions src/components/snackbars/index.ts
Original file line number Diff line number Diff line change
@@ -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';
2 changes: 1 addition & 1 deletion src/hooks/useSnackMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { BaseVariant, OptionsObject, closeSnackbar as closeSnackbarFromNotistack
import { IntlShape } from 'react-intl';
import { useIntlRef } from './useIntlRef';

interface SnackInputs extends Omit<OptionsObject, 'variant' | 'style'> {
export interface SnackInputs extends Omit<OptionsObject, 'variant' | 'style'> {
messageTxt?: string;
messageId?: string;
messageValues?: Record<string, string>;
Expand Down
44 changes: 38 additions & 6 deletions src/services/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
*/

import { getUserToken } from '../redux/commonStore';
import {
isBackendErrorLike,
normalizeBackendErrorPayload,
type HttpErrorWithBackendDetails,
} from '../utils/backendErrors';

const parseError = (text: string) => {
try {
Expand All @@ -26,18 +31,45 @@ const prepareRequest = (init: RequestInit | undefined, token?: string) => {
return initCopy;
};

const isRecord = (value: unknown): value is Record<string, unknown> => 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<string, unknown>;
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;
Expand Down
6 changes: 6 additions & 0 deletions src/translations/en/parameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions src/translations/fr/parameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading