Skip to content

Commit ad2c4d3

Browse files
committed
Add Toaster/notification component
1 parent c4c729c commit ad2c4d3

File tree

9 files changed

+205
-13
lines changed

9 files changed

+205
-13
lines changed

src/Root.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Provider } from 'react-redux';
44
import { BrowserRouter, Outlet, Route, Routes, useLocation } from 'react-router-dom';
55

66
import ErrorBoundary from './components/errorBoundary/ErrorBoundary';
7+
import Toaster from './components/toast/Toaster';
78
import WithDocTitle from './components/WithDocTitle';
89
import * as routes from './lib/routes';
910
import BrevPage from './pages/saksbehandling/brev/BrevPage';
@@ -62,6 +63,7 @@ const Root = () => (
6263
<BrowserRouter>
6364
<ContentWrapper>
6465
<Suspense fallback={<Loader />}>
66+
<Toaster />
6567
<ScrollToTop />
6668
<AppRoutes />
6769
</Suspense>

src/components/apiErrorAlert/ApiErrorAlert.tsx

+6
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ interface Props {
1717
variant?: 'error' | 'warning' | 'info' | 'success';
1818
}
1919

20+
export const useApiErrorMessages = () => {
21+
const { formatMessage } = useI18n({ messages });
22+
23+
return (error: ApiErrorAlertErrorType) => konstruerMeldingForAlert(formatMessage, error);
24+
};
25+
2026
const konstruerMeldingForAlert = (formatMessage: MessageFormatter<typeof messages>, error: ApiErrorAlertErrorType) => {
2127
try {
2228
if (error.statusCode === 503) {

src/components/toast/Toast.ts

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { useEffect } from 'react';
2+
3+
import toastsSlice from '~src/features/ToastSlice';
4+
import { useAppDispatch, useAppSelector } from '~src/redux/Store';
5+
6+
export enum ToastType {
7+
SUCCESS = 'success',
8+
INFO = 'info',
9+
WARNING = 'warning',
10+
ERROR = 'error',
11+
}
12+
13+
export interface Toast {
14+
id: string;
15+
type: ToastType;
16+
message: string | string[];
17+
duration: number;
18+
createdAt: number;
19+
}
20+
21+
export const createToast = (args: { type: ToastType; message: string | string[]; duration?: number }): Toast => {
22+
const { type, message, duration = 5000 } = args;
23+
24+
return {
25+
id: Math.random().toString(),
26+
type: type,
27+
message: message,
28+
duration: duration,
29+
createdAt: Date.now(),
30+
};
31+
};
32+
33+
export const useToast = () => {
34+
const dispatch = useAppDispatch();
35+
const { toasts } = useAppSelector((s) => s.toast);
36+
37+
const insert = (toast: Toast) => dispatch(toastsSlice.actions.insert(toast));
38+
39+
useEffect(() => {
40+
const now = Date.now();
41+
const timeouts = toasts.map((t) => {
42+
if (t.duration === Infinity) {
43+
return;
44+
}
45+
46+
const durationLeft = t.duration - (now - t.createdAt);
47+
return setTimeout(() => dispatch(toastsSlice.actions.remove(t)), durationLeft);
48+
});
49+
50+
return () => {
51+
timeouts.forEach((timeout) => timeout && clearTimeout(timeout));
52+
};
53+
}, [toasts]);
54+
55+
return { insert, toasts };
56+
};
57+
58+
export default Toast;
+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
@import '@styles/variables.less';
2+
3+
.toasterContainer {
4+
position: fixed;
5+
z-index: 9999;
6+
display: flex;
7+
flex-direction: column;
8+
gap: @spacing-xs;
9+
}
10+
11+
.toastContainer {
12+
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.3);
13+
animation: onEnter 0.35s cubic-bezier(0.21, 1.02, 0.73, 1) forwards;
14+
}
15+
16+
@keyframes onEnter {
17+
0% {
18+
transform: translateY(-100%);
19+
}
20+
21+
100% {
22+
transform: translateY(0);
23+
}
24+
}

src/components/toast/Toaster.tsx

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Alert } from '@navikt/ds-react';
2+
3+
import Toast, { ToastType, useToast } from './Toast';
4+
import styles from './Toaster.module.less';
5+
6+
const Toaster = () => {
7+
const { toasts } = useToast();
8+
9+
return (
10+
<ul className={styles.toasterContainer}>
11+
{toasts.map((t) => (
12+
<li key={t.id} className={styles.toastContainer}>
13+
{getAlert(t)}
14+
</li>
15+
))}
16+
</ul>
17+
);
18+
};
19+
20+
const getAlert = (t: Toast) => {
21+
const message = Array.isArray(t.message) ? (
22+
<ul>
23+
{t.message.map((err) => (
24+
<li key={err}>{err}</li>
25+
))}
26+
</ul>
27+
) : (
28+
t.message
29+
);
30+
31+
switch (t.type) {
32+
case ToastType.ERROR:
33+
return <Alert variant="error">{message}</Alert>;
34+
case ToastType.INFO:
35+
return <Alert variant="info">{message}</Alert>;
36+
case ToastType.SUCCESS:
37+
return <Alert variant="success">{message}</Alert>;
38+
case ToastType.WARNING:
39+
return <Alert variant="warning">{message}</Alert>;
40+
}
41+
};
42+
43+
export default Toaster;

src/features/ToastSlice.ts

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
2+
3+
import Toast from '~src/components/toast/Toast';
4+
5+
interface InitialState {
6+
toasts: Toast[];
7+
}
8+
9+
const initialState: InitialState = {
10+
toasts: [],
11+
};
12+
13+
const toastsSlice = createSlice({
14+
name: 'toasts',
15+
initialState: initialState,
16+
reducers: {
17+
insert: (state, action: PayloadAction<Toast>) => {
18+
state.toasts.push(action.payload);
19+
},
20+
remove: (state, action: PayloadAction<Toast>) => {
21+
state.toasts = state.toasts.filter((n) => n.id !== action.payload.id);
22+
},
23+
get: (state) => state,
24+
},
25+
});
26+
27+
export default toastsSlice;

src/pages/saksbehandling/sakintro/Vedtakstabell/Vedtakstabell.module.less

-4
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,3 @@
1111
margin-bottom: @spacing-xs;
1212
}
1313
}
14-
15-
.startNyBehandlingDataCellContainer {
16-
width: 15%;
17-
}

src/pages/saksbehandling/sakintro/Vedtakstabell/Vedtakstabell.tsx

+43-9
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,19 @@ import { BodyShort, Button, Table } from '@navikt/ds-react';
44
import * as arr from 'fp-ts/Array';
55
import * as Ord from 'fp-ts/Ord';
66
import * as S from 'fp-ts/string';
7+
import { useEffect } from 'react';
78
import { Link } from 'react-router-dom';
89

910
import * as VedtakActions from 'src/features/VedtakActions';
1011
import * as DokumentApi from '~src/api/dokumentApi';
1112
import { forhåndsvisVedtaksbrevTilbakekrevingsbehandling } from '~src/api/tilbakekrevingApi';
12-
import ApiErrorAlert from '~src/components/apiErrorAlert/ApiErrorAlert';
13+
import { useApiErrorMessages } from '~src/components/apiErrorAlert/ApiErrorAlert';
1314
import Oppsummeringspanel, {
1415
Oppsummeringsfarge,
1516
Oppsummeringsikon,
1617
} from '~src/components/oppsummering/oppsummeringspanel/Oppsummeringspanel';
1718
import SuTabell, { AriaSortVerdi } from '~src/components/tabell/SuTabell';
19+
import { createToast, ToastType, useToast } from '~src/components/toast/Toast';
1820
import { pipe } from '~src/lib/fp';
1921
import { useApiCall, useAsyncActionCreator } from '~src/lib/hooks';
2022
import { useI18n } from '~src/lib/i18n';
@@ -48,6 +50,8 @@ const isOversendtKlage = (v: Vedtak | Klage): v is Klage => !('periode' in v);
4850
const isVedtak = (v: VedtakEllerOversendtKlage): v is Vedtak => 'periode' in v;
4951

5052
const Vedtakstabell = (props: { sakId: string; vedtakOgOversendteKlager: VedtakOgOversendteKlager }) => {
53+
const { insert } = useToast();
54+
const apiErrorMessages = useApiErrorMessages();
5155
const { formatMessage } = useI18n({ messages });
5256

5357
const sorterTabell = (
@@ -128,6 +132,43 @@ const Vedtakstabell = (props: { sakId: string; vedtakOgOversendteKlager: VedtakO
128132
VedtakActions.startNySøknadsbehandling,
129133
);
130134

135+
//pakker hver status inn i egen useEffect, så flere feil ikke blir vist samtidig i toasts
136+
useEffect(() => {
137+
if (RemoteData.isFailure(startNysøknadsbehandlingStatus)) {
138+
insert(
139+
createToast({
140+
type: ToastType.ERROR,
141+
duration: 5000,
142+
message: apiErrorMessages(startNysøknadsbehandlingStatus.error),
143+
}),
144+
);
145+
}
146+
}, [startNysøknadsbehandlingStatus]);
147+
148+
useEffect(() => {
149+
if (RemoteData.isFailure(tilbakekrevingsbrevStatus)) {
150+
insert(
151+
createToast({
152+
type: ToastType.ERROR,
153+
duration: 5000,
154+
message: apiErrorMessages(tilbakekrevingsbrevStatus.error),
155+
}),
156+
);
157+
}
158+
}, [tilbakekrevingsbrevStatus]);
159+
160+
useEffect(() => {
161+
if (RemoteData.isFailure(hentDokumenterStatus)) {
162+
insert(
163+
createToast({
164+
type: ToastType.ERROR,
165+
duration: 5000,
166+
message: apiErrorMessages(hentDokumenterStatus.error),
167+
}),
168+
);
169+
}
170+
}, [hentDokumenterStatus]);
171+
131172
return (
132173
<Table.Row key={vedtak.id}>
133174
<Table.DataCell>
@@ -203,11 +244,8 @@ const Vedtakstabell = (props: { sakId: string; vedtakOgOversendteKlager: VedtakO
203244
<EnvelopeClosedIcon />
204245
</Button>
205246
)}
206-
{RemoteData.isFailure(tilbakekrevingsbrevStatus) && (
207-
<ApiErrorAlert size="small" error={tilbakekrevingsbrevStatus.error} />
208-
)}
209247
</Table.DataCell>
210-
<Table.DataCell className={styles.startNyBehandlingDataCellContainer}>
248+
<Table.DataCell>
211249
{isVedtak(vedtak) && vedtak.kanStarteNyBehandling && (
212250
<Button
213251
size="small"
@@ -222,10 +260,6 @@ const Vedtakstabell = (props: { sakId: string; vedtakOgOversendteKlager: VedtakO
222260
{formatMessage('dataCell.startNyBehandling')}
223261
</Button>
224262
)}
225-
226-
{RemoteData.isFailure(startNysøknadsbehandlingStatus) && (
227-
<ApiErrorAlert size="small" error={startNysøknadsbehandlingStatus.error} />
228-
)}
229263
</Table.DataCell>
230264
</Table.Row>
231265
);

src/redux/Store.ts

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import person from '~src/features/person/person.slice';
66
import sakSlice from '~src/features/saksoversikt/sak.slice';
77
import innsending from '~src/features/søknad/innsending.slice';
88
import søknadSlice from '~src/features/søknad/søknad.slice';
9+
import toastsSlice from '~src/features/ToastSlice';
910

1011
const store = configureStore({
1112
reducer: {
@@ -14,6 +15,7 @@ const store = configureStore({
1415
sak: sakSlice.reducer,
1516
innsending: innsending.reducer,
1617
me: me.reducer,
18+
toast: toastsSlice.reducer,
1719
},
1820
});
1921

0 commit comments

Comments
 (0)