Skip to content

Commit 0250bab

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 ed15353 commit 0250bab

35 files changed

Lines changed: 480 additions & 220 deletions

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@
7878
"react-i18next": "^15.5.1",
7979
"react-idle-timer": "^5.7.2",
8080
"react-router": "^7.15.0",
81-
"react-toastify": "^11.0.5",
8281
"sass": "^1.87.0",
8382
"typescript": "^5.8.3",
8483
"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/__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+
useLayoutEffect,
12+
} from 'react';
13+
14+
export type NotificationsContextProps = {
15+
addNotification: (props: NotificationProps) => void;
16+
};
17+
18+
export const NotificationsContext = createContext<
19+
NotificationsContextProps | undefined
20+
>(undefined);
21+
22+
const NOTIFICATION_OFFSET = 24;
23+
const NOTIFICATION_Z_INDEX = 1000;
24+
const AUTO_CLOSE_DURATION = 10000;
25+
26+
const getNotificationStyle = (
27+
heights: number[],
28+
index: number
29+
): CSSProperties => {
30+
const topMargin = heights
31+
.slice(0, index)
32+
.reduce(
33+
(acc, curr) => acc + curr + NOTIFICATION_OFFSET,
34+
NOTIFICATION_OFFSET
35+
);
36+
37+
return {
38+
top: topMargin,
39+
transform: 'translate3d(0px, 0px, 0px)',
40+
zIndex: NOTIFICATION_Z_INDEX,
41+
};
42+
};
43+
44+
export const NotificationsProvider: FC<PropsWithChildren> = ({ children }) => {
45+
const [notifications, setNotifications] = useState<NotificationProps[]>([]);
46+
47+
const addNotification = useCallback((props: NotificationProps) => {
48+
setNotifications((items) => [
49+
...items,
50+
{ ...props, id: uniqueId('notification-') },
51+
]);
52+
}, []);
53+
54+
const value = useMemo<NotificationsContextProps>(
55+
() => ({ addNotification }),
56+
[addNotification]
57+
);
58+
59+
const [heights, setHeights] = useState<number[]>([]);
60+
useLayoutEffect(() => {
61+
setHeights(
62+
notifications.map(
63+
({ id }) => document.getElementById(id as string)?.clientHeight ?? 0
64+
)
65+
);
66+
}, [notifications]);
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+
}, [addNotification]);
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+
};

src/domain/app/App.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,25 @@ import { ApolloProvider } from '@apollo/client';
22
import { ConfigProvider as RHHCConfigProvider } from '@city-of-helsinki/react-helsinki-headless-cms';
33
import * as React from 'react';
44
import { BrowserRouter } from 'react-router';
5-
import { ToastContainer } from 'react-toastify';
65

76
import { useApolloClient } from './apollo/apolloClient';
87
import AppRoutes from './routes/AppRoutes';
98
import { FORCE_SCROLL_TO_TOP, IGNORE_SCROLL_TO_TOP } from './routes/constants';
109
import ScrollToTop from './ScrollToTop';
10+
import { useNotificationsContext } from '../../common/components/notificationsContext/hooks/useNotificationsContext';
11+
import { NotificationsProvider } from '../../common/components/notificationsContext/NotificationsContext';
1112
import { useCMSApolloClient } from '../../headless-cms/apollo/apolloClient';
1213
import useRHHCConfig from '../../hooks/useRHHCConfig';
1314
import IdleTimer from '../auth/IdleTimerProvider';
1415
import KultusAdminHDSLoginProvider from '../auth/KultusAdminHDSLoginProvider';
1516
import { OrganisationProvider } from '../organisation/contextProviders/OrganisationProvider';
1617

17-
const App = () => {
18-
const apolloClient = useApolloClient();
18+
const AppContent = () => {
19+
const { addNotification } = useNotificationsContext();
20+
const apolloClient = useApolloClient({
21+
addNotification,
22+
initialApolloState: null,
23+
});
1924
const cmsClient = useCMSApolloClient();
2025

2126
const rhhcConfig = useRHHCConfig({
@@ -42,9 +47,16 @@ const App = () => {
4247
</ApolloProvider>
4348
</IdleTimer>
4449
</KultusAdminHDSLoginProvider>
45-
<ToastContainer />
4650
</>
4751
);
4852
};
4953

54+
const App = () => {
55+
return (
56+
<NotificationsProvider>
57+
<AppContent />
58+
</NotificationsProvider>
59+
);
60+
};
61+
5062
export default App;

0 commit comments

Comments
 (0)