Skip to content

Commit ddb0d1c

Browse files
committed
feat: replace react-toastify with custom NotificationsContext
Introduce a NotificationsProvider/useNotificationsContext system backed by HDS Notification components and migrate all notification call-sites away from react-toastify. - Add NotificationsContext, NotificationsProvider and useNotificationsContext hook - Wrap App in NotificationsProvider; move hook usage to AppContent child so it is always called within the provider tree - Add NotificationsProvider to shared test wrappers (customRender, renderWithRoute) and to per-file render helpers that bypass them - Migrate all addNotification call-sites: Apollo client, auth callback, event/enrolment/occurrence pages, image/keyword/profile utilities, occurrences form and actions dropdowns, events list load-more error Refs: PT-2015
1 parent 4a36901 commit ddb0d1c

37 files changed

Lines changed: 477 additions & 220 deletions

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@
7979
"react-i18next": "^15.5.1",
8080
"react-idle-timer": "^5.7.2",
8181
"react-router": "^7.15.0",
82-
"react-toastify": "^11.0.5",
8382
"sass": "^1.87.0",
8483
"typescript": "^5.8.3",
8584
"use-deep-compare-effect": "^1.8.1",

pnpm-lock.yaml

Lines changed: 0 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/clients/apiReportClient/useReportClientQuery.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useTranslation } from 'react-i18next';
2-
import { toast } from 'react-toastify';
32

43
import apiReportClient, { ROUTES } from './apiReportClient';
4+
import { useNotificationsContext } from '../../common/components/notificationsContext/hooks/useNotificationsContext';
55

66
/**
77
* A browser hack to download a file instead of just a byte string.
@@ -18,6 +18,8 @@ function downloadFile(data: string, filename: string) {
1818

1919
function useDownloadEventsEnrolmentsCsvQuery(pEventId: string | undefined) {
2020
const { t } = useTranslation();
21+
const { addNotification } = useNotificationsContext();
22+
2123
if (!pEventId) return undefined;
2224
return async () => {
2325
const filename = `kultus_events_approved_enrolments.csv`;
@@ -39,7 +41,10 @@ function useDownloadEventsEnrolmentsCsvQuery(pEventId: string | undefined) {
3941
if (response.status === 200 && response?.data?.type === 'text/csv') {
4042
downloadFile(response.data, filename);
4143
} else {
42-
toast.error(t('eventEnrolmentsReport.downloadError'));
44+
addNotification({
45+
type: 'error',
46+
label: t('eventEnrolmentsReport.downloadError'),
47+
});
4348
}
4449
};
4550
}

src/common/components/autoSuggest/AutoSuggest.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
// an interactive content element. (jsx-a11y/no-static-element-interactions)"
1111

1212
import classNames from 'classnames';
13-
import { IconCheck, IconCross } from 'hds-react';
13+
import { IconCheck, IconCross, NotificationProps } from 'hds-react';
1414
import * as React from 'react';
1515
import { useTranslation } from 'react-i18next';
1616

@@ -23,6 +23,7 @@ import styles from './autoSuggest.module.scss';
2323
import useKeyboardNavigation from '../../../hooks/useDropdownKeyboardNavigation';
2424
import useLocale from '../../../hooks/useLocale';
2525
import { Language } from '../../../types';
26+
import { useNotificationsContext } from '../notificationsContext/hooks/useNotificationsContext';
2627
import ScrollIntoViewWithFocus from '../scrollIntoViewWithFocus/ScrollIntoViewWithFocus';
2728
import InputWrapper from '../textInput/InputWrapper';
2829
import inputStyles from '../textInput/inputWrapper.module.scss';
@@ -92,7 +93,11 @@ export interface AutoSuggestProps {
9293
onBlur: (val: AutoSuggestOption | AutoSuggestOption[] | null) => void;
9394
onChange: (val: AutoSuggestOption | AutoSuggestOption[] | null) => void;
9495
options: AutoSuggestOption[];
95-
optionLabelToString?: (option: AutoSuggestOption, locale: Language) => string;
96+
optionLabelToString?: (
97+
option: AutoSuggestOption,
98+
locale: Language,
99+
addNotification: (props: NotificationProps) => void
100+
) => string;
96101
placeholder?: string;
97102
readOnly?: boolean;
98103
required?: boolean;
@@ -121,6 +126,7 @@ const AutoSuggest: React.FC<AutoSuggestProps> = ({
121126
required,
122127
}) => {
123128
const { t } = useTranslation();
129+
const { addNotification } = useNotificationsContext();
124130
const locale = useLocale();
125131
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
126132
const [isFocused, setIsFocused] = React.useState(false);
@@ -214,7 +220,7 @@ const AutoSuggest: React.FC<AutoSuggestProps> = ({
214220
valueEventAriaMessage({
215221
event,
216222
value: optionLabelToString
217-
? optionLabelToString(option, locale)
223+
? optionLabelToString(option, locale, addNotification)
218224
: option.label.toString(),
219225
t,
220226
})
@@ -365,7 +371,7 @@ const AutoSuggest: React.FC<AutoSuggestProps> = ({
365371
'common.autoSuggest.accessibility.deselectOptionButtonAriaMessage',
366372
{
367373
value: optionLabelToString
368-
? optionLabelToString(item, locale)
374+
? optionLabelToString(item, locale, addNotification)
369375
: item.label.toString(),
370376
}
371377
)}

src/common/components/autoSuggest/__test__/AutoSuggest.test.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
33
import * as React from 'react';
44
import { vi } from 'vitest';
55

6+
import { NotificationsProvider } from '../../notificationsContext/NotificationsContext';
67
import AutoSuggest, { AutoSuggestProps } from '../AutoSuggest';
78

89
const options = [
@@ -34,15 +35,23 @@ function renderAutoSuggest(props?: Partial<AutoSuggestProps>) {
3435
...props,
3536
};
3637

37-
const { rerender } = render(<AutoSuggest {...defaultProps} />);
38+
const { rerender } = render(
39+
<NotificationsProvider>
40+
<AutoSuggest {...defaultProps} />
41+
</NotificationsProvider>
42+
);
3843

3944
return {
4045
...defaultProps,
4146
onChange,
4247
onBlur,
4348
setInputValue,
4449
rerender: (newProps: Partial<AutoSuggestProps>) =>
45-
rerender(<AutoSuggest {...defaultProps} {...newProps} />),
50+
rerender(
51+
<NotificationsProvider>
52+
<AutoSuggest {...defaultProps} {...newProps} />
53+
</NotificationsProvider>
54+
),
4655
};
4756
}
4857

src/common/components/imageInput/ImageInput.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { FormikErrors } from 'formik';
22
import { Button, ButtonVariant } from 'hds-react';
33
import * as React from 'react';
44
import { useTranslation } from 'react-i18next';
5-
import { toast } from 'react-toastify';
65

76
import { useUploadSingleImageMutation } from '../../../generated/graphql';
7+
import { useNotificationsContext } from '../notificationsContext/hooks/useNotificationsContext';
88
import InputWrapper, { InputWrapperProps } from '../textInput/InputWrapper';
99

1010
// 2 megabytes
@@ -27,6 +27,7 @@ const ImageInput: React.FC<ImageInputProps> = ({
2727
const { t } = useTranslation();
2828
const inputRef = React.useRef<HTMLInputElement>(null);
2929
const [uploadImage] = useUploadSingleImageMutation();
30+
const { addNotification } = useNotificationsContext();
3031

3132
const handleChooseImageClick = () => {
3233
inputRef.current?.click();
@@ -42,7 +43,10 @@ const ImageInput: React.FC<ImageInputProps> = ({
4243
const file = e.currentTarget.files?.[0];
4344
const fileSize = file?.size;
4445
if (fileSize && fileSize > IMAGE_MAX_SIZE) {
45-
toast.error(t('form.error.imageTooBig'));
46+
addNotification({
47+
type: 'error',
48+
label: t('form.error.imageTooBig'),
49+
});
4650
resetImageInput();
4751
return;
4852
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { Notification, NotificationProps, NotificationSize } from 'hds-react';
2+
import uniqueId from 'lodash/uniqueId';
3+
import React, {
4+
CSSProperties,
5+
createContext,
6+
FC,
7+
PropsWithChildren,
8+
useCallback,
9+
useMemo,
10+
useState,
11+
} from 'react';
12+
13+
export type NotificationsContextProps = {
14+
addNotification: (props: NotificationProps) => void;
15+
};
16+
17+
export const NotificationsContext = createContext<
18+
NotificationsContextProps | undefined
19+
>(undefined);
20+
21+
const NOTIFICATION_OFFSET = 24;
22+
const NOTIFICATION_Z_INDEX = 1000;
23+
const AUTO_CLOSE_DURATION = 10000;
24+
25+
const getNotificationStyle = (
26+
heights: number[],
27+
index: number
28+
): CSSProperties => {
29+
const topMargin = heights
30+
.slice(0, index)
31+
.reduce(
32+
(acc, curr) => acc + curr + NOTIFICATION_OFFSET,
33+
NOTIFICATION_OFFSET
34+
);
35+
36+
return {
37+
top: topMargin,
38+
transform: 'translate3d(0px, 0px, 0px)',
39+
zIndex: NOTIFICATION_Z_INDEX,
40+
};
41+
};
42+
43+
export const NotificationsProvider: FC<PropsWithChildren> = ({ children }) => {
44+
const [notifications, setNotifications] = useState<NotificationProps[]>([]);
45+
46+
const addNotification = useCallback((props: NotificationProps) => {
47+
setNotifications((items) => [
48+
...items,
49+
{ ...props, id: uniqueId('notification-') },
50+
]);
51+
}, []);
52+
53+
const value = useMemo<NotificationsContextProps>(
54+
() => ({ addNotification }),
55+
[addNotification]
56+
);
57+
58+
const heights = useMemo(
59+
() =>
60+
// Height of last added notification is 0 because it's not rendered yet.
61+
// In this case it doesn't matter because it's not used for top-margin calculation
62+
notifications.map(
63+
({ id }) => document.getElementById(id as string)?.clientHeight ?? 0
64+
),
65+
[notifications]
66+
);
67+
68+
return (
69+
<NotificationsContext.Provider value={value}>
70+
{notifications.map((props, index) => {
71+
return (
72+
<Notification
73+
notificationAriaLabel={
74+
typeof props.label === 'string' ? props.label : undefined
75+
}
76+
autoCloseDuration={AUTO_CLOSE_DURATION}
77+
autoClose={true}
78+
{...props}
79+
size={NotificationSize.Medium}
80+
style={getNotificationStyle(heights, index)}
81+
key={props.id}
82+
position="top-right"
83+
onClose={() =>
84+
setNotifications((items) =>
85+
items.filter((i) => i.id !== props.id)
86+
)
87+
}
88+
/>
89+
);
90+
})}
91+
{children}
92+
</NotificationsContext.Provider>
93+
);
94+
};
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { configure, render, screen, waitFor } from '@testing-library/react';
2+
import React, { useEffect } from 'react';
3+
4+
import { useNotificationsContext } from '../hooks/useNotificationsContext';
5+
import { NotificationsProvider } from '../NotificationsContext';
6+
7+
configure({ defaultHidden: true });
8+
9+
it('should show several notifications', async () => {
10+
const Component = () => {
11+
const { addNotification } = useNotificationsContext();
12+
13+
useEffect(() => {
14+
addNotification({ label: 'Notification 1', type: 'success' });
15+
addNotification({ label: 'Notification 2', type: 'error' });
16+
addNotification({ label: 'Notification 3', type: 'info' });
17+
});
18+
return null;
19+
};
20+
21+
render(
22+
<NotificationsProvider>
23+
<Component />
24+
</NotificationsProvider>
25+
);
26+
27+
await screen.findByRole('alert', { name: 'Notification 1' });
28+
await screen.findByRole('alert', { name: 'Notification 2' });
29+
await screen.findByRole('alert', { name: 'Notification 3' });
30+
});
31+
32+
it('should automatically hide notification after certain period of time', async () => {
33+
const Component = () => {
34+
const { addNotification } = useNotificationsContext();
35+
36+
useEffect(() => {
37+
addNotification({
38+
autoCloseDuration: 50,
39+
label: 'Notification 1',
40+
type: 'success',
41+
});
42+
});
43+
return null;
44+
};
45+
46+
render(
47+
<NotificationsProvider>
48+
<Component />
49+
</NotificationsProvider>
50+
);
51+
52+
await screen.findByRole('alert', { name: 'Notification 1' });
53+
await waitFor(() =>
54+
expect(
55+
screen.queryByRole('alert', { name: 'Notification 1' })
56+
).not.toBeInTheDocument()
57+
);
58+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/* eslint @typescript-eslint/explicit-function-return-type: 0 */
2+
import { useContext } from 'react';
3+
4+
import {
5+
NotificationsContext,
6+
NotificationsContextProps,
7+
} from '../NotificationsContext';
8+
9+
export const useNotificationsContext = (): NotificationsContextProps => {
10+
const context = useContext<NotificationsContextProps | undefined>(
11+
NotificationsContext
12+
);
13+
14+
/* istanbul ignore next */
15+
if (!context) {
16+
throw new Error(
17+
// eslint-disable-next-line max-len
18+
'NotificationsContext context is undefined, please verify you are calling useNotificationsContext() as child of a <NotificationsProvider> component.'
19+
);
20+
}
21+
22+
return context;
23+
};

0 commit comments

Comments
 (0)