diff --git a/frontend/dashboard/index.tsx b/frontend/dashboard/index.tsx index a476ceb711f..e71b69995ff 100644 --- a/frontend/dashboard/index.tsx +++ b/frontend/dashboard/index.tsx @@ -8,7 +8,7 @@ import { initReactI18next } from 'react-i18next'; import nb from '../language/src/nb.json'; import en from '../language/src/en.json'; import { DEFAULT_LANGUAGE } from 'app-shared/constants'; -import { QueryClient } from '@tanstack/react-query'; +import { QueryClientConfig } from '@tanstack/react-query'; import { ServicesContextProvider } from 'app-shared/contexts/ServicesContext'; import * as queries from 'app-shared/api/queries'; import * as mutations from 'app-shared/api/mutations'; @@ -26,18 +26,18 @@ i18next.use(initReactI18next).init({ const container = document.getElementById('root'); const root = createRoot(container); -const queryClient = new QueryClient({ +const queryClientConfig: QueryClientConfig = { defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false, }, }, -}); +}; root.render( - + diff --git a/frontend/language/src/en.json b/frontend/language/src/en.json index 2044845b8ac..d938ee97745 100644 --- a/frontend/language/src/en.json +++ b/frontend/language/src/en.json @@ -242,6 +242,7 @@ "general.disabled": "Disabled", "general.edit": "Edit", "general.enabled": "Enabled", + "general.error_message": "An error has occurred! If the problem persists, contact us on Slack.", "general.error_message_with_colon": "Error message:", "general.fetch_error_message": "Failed to load required data for this page. Please reload the page to try again", "general.fetch_error_title": "An error occurred while loading", @@ -270,6 +271,7 @@ "general.sign_out": "Sign out", "general.submit": "Submit", "general.text": "Text", + "general.try_again": "Try again", "general.unknown_error": "Unknown error ocurred while loading data.", "general.validate_changes": "Validate changes", "general.value": "Value", diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 4a529e892c4..0f2e2a87591 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -267,6 +267,7 @@ "general.disabled": "Deaktivert", "general.edit": "Endre", "general.enabled": "Aktivert", + "general.error_message": "Det har oppstått en feil! Om problemet vedvarer, ta kontakt med oss på Slack.", "general.error_message_with_colon": "Feilmelding:", "general.fetch_error_message": "Kunne ikke laste inn nødvendig data for denne siden. Last siden på nytt for å prøve igjen.", "general.fetch_error_title": "En feil oppstod ved innlasting av", @@ -296,6 +297,7 @@ "general.sign_out": "Logg ut", "general.submit": "Send inn", "general.text": "Tekst", + "general.try_again": "Prøv igjen", "general.unknown_error": "Ukjent feil oppstod ved innlasting av data.", "general.validate_changes": "Valider endringer", "general.value": "Verdi", diff --git a/frontend/packages/shared/src/components/ErrorBoundaryFallback.module.css b/frontend/packages/shared/src/components/ErrorBoundaryFallback.module.css new file mode 100644 index 00000000000..db98c0f780d --- /dev/null +++ b/frontend/packages/shared/src/components/ErrorBoundaryFallback.module.css @@ -0,0 +1,9 @@ +.container { + padding: var(--fds-spacing-10); + width: auto; +} + +.alert > [class*='Alert-module_content'] { + display: flex; + gap: 1rem; +} diff --git a/frontend/packages/shared/src/components/ErrorBoundaryFallback.tsx b/frontend/packages/shared/src/components/ErrorBoundaryFallback.tsx new file mode 100644 index 00000000000..a3113656dbf --- /dev/null +++ b/frontend/packages/shared/src/components/ErrorBoundaryFallback.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { useErrorBoundary } from 'react-error-boundary'; +import { useTranslation } from 'react-i18next'; +import { _useIsProdHack } from 'app-shared/utils/_useIsProdHack'; +import { Trans } from 'react-i18next'; +import { Alert, Button, ErrorMessage, Link, Paragraph } from '@digdir/design-system-react'; +import { Center } from './Center'; +import classes from './ErrorBoundaryFallback.module.css'; + +export type ErrorBoundaryFallbackProps = { + error: Error; +} + +export const ErrorBoundaryFallback = ({ error }: ErrorBoundaryFallbackProps) => { + const { t } = useTranslation(); + const { resetBoundary } = useErrorBoundary(); + + return ( +
+ + Slack }}/> + {!_useIsProdHack && {error.message}} +
+ +
+
+
+ ); +}; diff --git a/frontend/packages/shared/src/contexts/ServicesContext.test.tsx b/frontend/packages/shared/src/contexts/ServicesContext.test.tsx new file mode 100644 index 00000000000..d2c2c2b2c38 --- /dev/null +++ b/frontend/packages/shared/src/contexts/ServicesContext.test.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { render, renderHook, screen, waitFor } from '@testing-library/react'; +import { ServicesContextProvider } from './ServicesContext'; +import { queriesMock } from 'app-shared/mocks/queriesMock'; +import { useQuery } from '@tanstack/react-query'; +import { textMock } from '../../../../testing/mocks/i18nMock'; + +const wrapper = ({ children }) => ( + + {children} + +); + +describe('ServicesContext', () => { + it('displays a default error message if an API call fails', async () => { + const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(); + const { result } = renderHook(() => useQuery(['fetchData'],() => Promise.reject(), { retry: false }), { wrapper }); + + await waitFor(() => result.current.isError); + + expect(await screen.findByText(textMock('general.error_message'))).toBeInTheDocument(); + expect(mockConsoleError).toHaveBeenCalled(); + }); + + it('displays a default error message if a component throws an error while rendering', () => { + const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(); + + const ErrorComponent = () => { throw new Error('Intentional render error'); }; + render(, { wrapper }); + + expect(screen.getByText(textMock('general.error_message'))).toBeInTheDocument(); + expect(screen.getByText(textMock('general.try_again'))).toBeInTheDocument(); + expect(mockConsoleError).toHaveBeenCalled(); + }); +}); diff --git a/frontend/packages/shared/src/contexts/ServicesContext.tsx b/frontend/packages/shared/src/contexts/ServicesContext.tsx index df9e8c35e90..df274841694 100644 --- a/frontend/packages/shared/src/contexts/ServicesContext.tsx +++ b/frontend/packages/shared/src/contexts/ServicesContext.tsx @@ -1,27 +1,93 @@ -import React, { createContext, useContext, ReactNode } from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React, { createContext, useContext, ReactNode, useState } from 'react'; +import { + MutationCache, + MutationMeta, + QueryCache, + QueryClient, + QueryClientConfig, + QueryClientProvider, + QueryMeta, +} from '@tanstack/react-query'; import type * as queries from '../api/queries'; import type * as mutations from '../api/mutations'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { ToastContainer, Slide, toast } from 'react-toastify'; +import { ErrorBoundary } from 'react-error-boundary'; +import { AxiosError } from 'axios'; +import { Trans, useTranslation } from 'react-i18next'; +import { Link } from '@digdir/design-system-react'; +import { ErrorBoundaryFallback } from '../components/ErrorBoundaryFallback'; + +import 'react-toastify/dist/ReactToastify.css'; +import 'app-shared/styles/toast.css'; export type ServicesContextProps = typeof queries & typeof mutations; export type ServicesContextProviderProps = ServicesContextProps & { children?: ReactNode; - client?: QueryClient; + client?: QueryClient; // TODO : #10913 should probably be removed to force the use of QueryCache and MutationCache + clientConfig?: QueryClientConfig; }; const ServicesContext = createContext(null); -const queryClient = new QueryClient(); + +const handleError = ( + error: AxiosError, + t: (key: string) => string, + meta: QueryMeta | MutationMeta, +): void => { + // TODO : log axios errors + // TODO : handle messages from API + // TODO : logout user when session is expired + + if ( + meta?.hideDefaultError === true || + (meta?.hideDefaultError instanceof Function && meta?.hideDefaultError?.(error)) + ) + return; + + toast.error(() => ( + Slack }}/> + ), { toastId: 'default' }); +}; + export const ServicesContextProvider = ({ children, client, + clientConfig, ...queries }: ServicesContextProviderProps) => { + const { t } = useTranslation(); + + const [queryClient] = useState( + () => client || new QueryClient({ + ...clientConfig, + queryCache: new QueryCache({ + onError: (error: AxiosError, query) => handleError(error, t, query.options?.meta), + }), + mutationCache: new MutationCache({ + onError: (error: AxiosError, variables, context, mutation) => handleError(error, t, mutation.options?.meta), + }), + }) + ); + return ( - - {children} - - + { + // TODO : log rendering errors + }} + > + + + {children} + + + ); }; diff --git a/frontend/packages/shared/src/styles/toast.css b/frontend/packages/shared/src/styles/toast.css new file mode 100644 index 00000000000..33b81e6b453 --- /dev/null +++ b/frontend/packages/shared/src/styles/toast.css @@ -0,0 +1,12 @@ +:root { + --toastify-color-info: var(--fds-semantic-surface-action-primary-default); + --toastify-color-success: var(--fds-semantic-surface-success-default); + --toastify-color-warning: var(--fds-semantic-surface-warning-default); + --toastify-color-error: var(--fds-semantic-surface-danger-default); + --toastify-toast-width: 400px; +} + +.Toastify__toast { + line-height: 1.5rem; + padding: var(--fds-spacing-3) var(--fds-spacing-3) var(--fds-spacing-4) var(--fds-spacing-3); +} diff --git a/frontend/packages/ux-editor/src/components/TextResourceEdit.test.tsx b/frontend/packages/ux-editor/src/components/TextResourceEdit.test.tsx index 4c9a83308ea..a3f6cfe8bd1 100644 --- a/frontend/packages/ux-editor/src/components/TextResourceEdit.test.tsx +++ b/frontend/packages/ux-editor/src/components/TextResourceEdit.test.tsx @@ -50,8 +50,10 @@ describe('TextResourceEdit', () => { }); it('Does not render anything if edit id is undefined', async () => { - const { renderResult } = await render(); - expect(renderResult.container).toBeEmptyDOMElement(); + await render(); + expect(screen.queryByText(legendText)).not.toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: closeText })).not.toBeInTheDocument(); }); it('Renders correctly when a valid edit id is given', async () => { diff --git a/frontend/resourceadm/index.tsx b/frontend/resourceadm/index.tsx index 15b4f7feab2..a9338e902bd 100644 --- a/frontend/resourceadm/index.tsx +++ b/frontend/resourceadm/index.tsx @@ -9,7 +9,7 @@ import nb from '../language/src/nb.json'; import en from '../language/src/en.json'; import { DEFAULT_LANGUAGE } from 'app-shared/constants'; -import { QueryClient } from '@tanstack/react-query'; +import { QueryClientConfig } from '@tanstack/react-query'; import { ServicesContextProvider } from 'app-shared/contexts/ServicesContext'; import * as queries from 'app-shared/api/queries'; import * as mutations from 'app-shared/api/mutations'; @@ -27,7 +27,7 @@ i18next.use(initReactI18next).init({ const container = document.getElementById('root'); const root = createRoot(container); -const queryClient = new QueryClient({ +const queryClientConfig: QueryClientConfig = { defaultOptions: { queries: { retry: false, @@ -35,11 +35,11 @@ const queryClient = new QueryClient({ refetchOnWindowFocus: false, }, }, -}); +}; root.render( - + diff --git a/package.json b/package.json index f71bc3a0db3..b59854383f8 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,10 @@ "@tanstack/react-query-devtools": "4.33.0", "ajv": "8.12.0", "ajv-formats": "2.1.1", + "react-error-boundary": "^4.0.11", "react-i18next": "13.1.2", - "react-router-dom": "6.15.0" + "react-router-dom": "6.15.0", + "react-toastify": "^9.1.3" }, "devDependencies": { "@emotion/react": "11.11.1", diff --git a/yarn.lock b/yarn.lock index 4fcaa11489d..a4eceb2b6ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5126,8 +5126,10 @@ __metadata: prettier: 2.8.8 react: 18.2.0 react-dom: 18.2.0 + react-error-boundary: ^4.0.11 react-i18next: 13.1.2 react-router-dom: 6.15.0 + react-toastify: ^9.1.3 redux-mock-store: 1.5.4 redux-saga: 1.2.3 redux-saga-test-plan: 4.0.6 @@ -6368,7 +6370,7 @@ __metadata: languageName: node linkType: hard -"clsx@npm:^1.2.1": +"clsx@npm:^1.1.1, clsx@npm:^1.2.1": version: 1.2.1 resolution: "clsx@npm:1.2.1" checksum: 30befca8019b2eb7dbad38cff6266cf543091dae2825c856a62a8ccf2c3ab9c2907c4d12b288b73101196767f66812365400a227581484a05f968b0307cfaf12 @@ -13153,6 +13155,17 @@ __metadata: languageName: node linkType: hard +"react-error-boundary@npm:^4.0.11": + version: 4.0.11 + resolution: "react-error-boundary@npm:4.0.11" + dependencies: + "@babel/runtime": ^7.12.5 + peerDependencies: + react: ">=16.13.1" + checksum: b3c157fea4e8f78411e9aa0fbf5241f6907b66ede1cd8b7bb22faaeb0339ebeb3dc8e63bf90ef3f740bfa8fd994ca6edf975089cd371b664ad6c2735e7512d38 + languageName: node + linkType: hard + "react-i18next@npm:13.1.2": version: 13.1.2 resolution: "react-i18next@npm:13.1.2" @@ -13367,6 +13380,18 @@ __metadata: languageName: node linkType: hard +"react-toastify@npm:^9.1.3": + version: 9.1.3 + resolution: "react-toastify@npm:9.1.3" + dependencies: + clsx: ^1.1.1 + peerDependencies: + react: ">=16" + react-dom: ">=16" + checksum: e8bd92c5cbf831b43a042644ab9bc69abe6ceb3ce91ba71f5cd2d8b6a2c9885ca52770e1f1ba64c5632607f6df962db344a26c7fba57606faf5aa0e7bfc8535f + languageName: node + linkType: hard + "react-transition-group@npm:^4.3.0, react-transition-group@npm:^4.4.5": version: 4.4.5 resolution: "react-transition-group@npm:4.4.5"