From 7e0198abd7f6987610875d162a3146ffcf39e97a Mon Sep 17 00:00:00 2001 From: phlipsterit <131464883+phlipsterit@users.noreply.github.com> Date: Fri, 15 May 2026 15:26:55 +0200 Subject: [PATCH] feat: Add an accordion to the unknown error page that lets the user see information about the error (#4210) * Create a unknown error details component * clean up css * Add copied feedback * add translations * Add error to usages of UnknownError * Add unit test * add comment about availability of clipboard * Add error when using UknownError from InstantiateContainer * await and try-catch clipboard.writeText * Delete InstantiationError since its not in use * Fix tests * improve test --- src/App.test.tsx | 3 + src/components/altinnError.tsx | 2 +- src/core/errorHandling/DisplayError.tsx | 2 +- .../instantiate/InstantiationError.tsx | 24 ---- .../containers/InstantiateContainer.tsx | 2 +- .../containers/UnknownError.module.css | 3 + .../containers/UnknownError.test.tsx | 23 +++- .../instantiate/containers/UnknownError.tsx | 16 ++- .../containers/UnknownErrorDetails.module.css | 5 + .../containers/UnknownErrorDetails.tsx | 118 ++++++++++++++++++ src/language/texts/en.ts | 7 +- src/language/texts/nb.ts | 3 + src/language/texts/nn.ts | 3 + .../PanelAwaitingCurrentUserSignature.tsx | 2 +- 14 files changed, 181 insertions(+), 32 deletions(-) delete mode 100644 src/features/instantiate/InstantiationError.tsx create mode 100644 src/features/instantiate/containers/UnknownError.module.css create mode 100644 src/features/instantiate/containers/UnknownErrorDetails.module.css create mode 100644 src/features/instantiate/containers/UnknownErrorDetails.tsx diff --git a/src/App.test.tsx b/src/App.test.tsx index 59e31eda63..1b0e84692b 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -7,6 +7,9 @@ import { App } from 'src/App'; import { fetchApplicationMetadata } from 'src/queries/queries'; import { renderWithInstanceAndLayout, renderWithoutInstanceAndLayout } from 'src/test/renderWithProviders'; +// Need to unmock axios to get actual implementation of isAxiosError +jest.unmock('axios'); + describe('App', () => { beforeEach(() => { jest.spyOn(window, 'logError').mockImplementation(() => {}); diff --git a/src/components/altinnError.tsx b/src/components/altinnError.tsx index 6dc925ddc9..8440745c04 100644 --- a/src/components/altinnError.tsx +++ b/src/components/altinnError.tsx @@ -44,7 +44,7 @@ export const AltinnError = ({ )}

{title}

-

{content}

+
{content}
{showContactInfo && (

; } - return ; + return ; } diff --git a/src/features/instantiate/InstantiationError.tsx b/src/features/instantiate/InstantiationError.tsx deleted file mode 100644 index d62d664411..0000000000 --- a/src/features/instantiate/InstantiationError.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { useParams } from 'react-router-dom'; - -import { InstantiateValidationError } from 'src/features/instantiate/containers/InstantiateValidationError'; -import { MissingRolesError } from 'src/features/instantiate/containers/MissingRolesError'; -import { UnknownError } from 'src/features/instantiate/containers/UnknownError'; -import { isInstantiationValidationResult } from 'src/features/instantiate/InstantiationValidation'; -import { useInstantiation } from 'src/features/instantiate/useInstantiation'; -import { isAxiosError } from 'src/utils/isAxiosError'; - -export function InstantiationError() { - const error = useParams()?.error; - const exception = useInstantiation().error; - - if (error === 'forbidden') { - if (isAxiosError(exception) && isInstantiationValidationResult(exception.response?.data)) { - return ; - } - - return ; - } - - return ; -} diff --git a/src/features/instantiate/containers/InstantiateContainer.tsx b/src/features/instantiate/containers/InstantiateContainer.tsx index 5371b59f8f..664ab75fb9 100644 --- a/src/features/instantiate/containers/InstantiateContainer.tsx +++ b/src/features/instantiate/containers/InstantiateContainer.tsx @@ -30,7 +30,7 @@ export const InstantiateContainer = () => { } return ; } else if (instantiation.error) { - return ; + return ; } return ; diff --git a/src/features/instantiate/containers/UnknownError.module.css b/src/features/instantiate/containers/UnknownError.module.css new file mode 100644 index 0000000000..81c293c8f5 --- /dev/null +++ b/src/features/instantiate/containers/UnknownError.module.css @@ -0,0 +1,3 @@ +.errorDetails { + margin-top: 16px; +} diff --git a/src/features/instantiate/containers/UnknownError.test.tsx b/src/features/instantiate/containers/UnknownError.test.tsx index d4baca48dc..599c1be9dd 100644 --- a/src/features/instantiate/containers/UnknownError.test.tsx +++ b/src/features/instantiate/containers/UnknownError.test.tsx @@ -2,15 +2,25 @@ import React from 'react'; import { jest } from '@jest/globals'; import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { UnknownError } from 'src/features/instantiate/containers/UnknownError'; import { renderWithMinimalProviders } from 'src/test/renderWithProviders'; +// Need to unmock axios to get actual implementation of isAxiosError +jest.unmock('axios'); + describe('Unknown error', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + it('should be able to render with minimal providers', async () => { + const user = userEvent.setup({ delay: null }); jest.spyOn(console, 'error').mockImplementation(() => {}); await renderWithMinimalProviders({ - renderer: () => , + renderer: () => , }); expect(screen.getByTestId('StatusCode')).toBeInTheDocument(); @@ -20,5 +30,16 @@ describe('Unknown error', () => { ); expect(console.error).not.toHaveBeenCalled(); + + const showDetailsButton = screen.getByRole('button', { name: 'Vis detaljer om feilen' }); + await user.click(showDetailsButton); + expect(screen.getByText('Error test message')).toBeInTheDocument(); + + const writeTextMock = jest.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); + + const copyButton = screen.getByRole('button', { name: 'Kopier' }); + await user.click(copyButton); + expect(writeTextMock).toHaveBeenCalledWith(expect.stringContaining('Error test message')); + expect(copyButton).toHaveAccessibleName('Kopiert'); }); }); diff --git a/src/features/instantiate/containers/UnknownError.tsx b/src/features/instantiate/containers/UnknownError.tsx index 2c0ac49a1c..3b6e909e40 100644 --- a/src/features/instantiate/containers/UnknownError.tsx +++ b/src/features/instantiate/containers/UnknownError.tsx @@ -1,13 +1,21 @@ import React from 'react'; +import { type AxiosError } from 'axios'; + import { Button } from 'src/app-components/Button/Button'; import { useDevToolsStore } from 'src/features/devtools/data/DevToolsStore'; import { DevToolsTab } from 'src/features/devtools/data/types'; import { InstantiationErrorPage } from 'src/features/instantiate/containers/InstantiationErrorPage'; +import styles from 'src/features/instantiate/containers/UnknownError.module.css'; +import { UnknownErrorDetails } from 'src/features/instantiate/containers/UnknownErrorDetails'; import { Lang } from 'src/features/language/Lang'; import { isDev } from 'src/utils/isDev'; -export function UnknownError() { +interface Props { + error: Error | AxiosError; +} + +export function UnknownError({ error }: Props) { const open = useDevToolsStore((s) => s.actions.open); const setActiveTab = useDevToolsStore((s) => s.actions.setActiveTab); @@ -34,6 +42,12 @@ export function UnknownError() { />, ]} /> + + + {isDev() && ( <>
diff --git a/src/features/instantiate/containers/UnknownErrorDetails.module.css b/src/features/instantiate/containers/UnknownErrorDetails.module.css new file mode 100644 index 0000000000..ccf27d8430 --- /dev/null +++ b/src/features/instantiate/containers/UnknownErrorDetails.module.css @@ -0,0 +1,5 @@ +.detailsContainer { + display: flex; + gap: 16px; + flex-direction: column; +} diff --git a/src/features/instantiate/containers/UnknownErrorDetails.tsx b/src/features/instantiate/containers/UnknownErrorDetails.tsx new file mode 100644 index 0000000000..59c9c961fa --- /dev/null +++ b/src/features/instantiate/containers/UnknownErrorDetails.tsx @@ -0,0 +1,118 @@ +import React, { useState } from 'react'; + +import { CheckmarkCircleIcon } from '@navikt/aksel-icons'; +import { type AxiosError, isAxiosError } from 'axios'; + +import { AccordionItem } from 'src/app-components/Accordion/AccordionItem'; +import { Button } from 'src/app-components/Button/Button'; +import { Flex } from 'src/app-components/Flex/Flex'; +import classes from 'src/features/instantiate/containers/UnknownErrorDetails.module.css'; +import { Lang } from 'src/features/language/Lang'; + +interface UnknownErrorDetailsProps { + error: Error | AxiosError; + className?: string; +} + +export function UnknownErrorDetails({ error, className }: UnknownErrorDetailsProps) { + const [now] = useState(new Date()); + const [copied, setCopied] = useState(false); + const [axiosError] = useState(() => { + if (isAxiosError(error)) { + return { + responseStatus: error.response?.status, + responseData: error.response?.data, + }; + } + return null; + }); + const [location] = useState(window?.location.href); + + async function handleCopyErrorClicked() { + const errorInfo = { + name: error.name, + message: error.message, + stack: error.stack, + cause: error.cause, + location, + time: now.toISOString(), + ...axiosError, + }; + if (navigator.clipboard) { + // clipboard is only available in secure contexts (https) + try { + await navigator.clipboard.writeText(JSON.stringify(errorInfo, null, 2)); + setCopied(true); + } catch (err) { + window.logError('Failed to copy error info to clipboard', err); + } + } + } + + return ( + } + className={className} + > +

+ + + + + + + + + + {axiosError && ( + + )} + {error.stack && ( + + )} +
+ + ); +} + +function DetailItem({ name, value }: { name: string; value: string }) { + return ( +
+
+ {name}: +
+
{value}
+
+ ); +} diff --git a/src/language/texts/en.ts b/src/language/texts/en.ts index 7125b9ef23..aec2559bcb 100644 --- a/src/language/texts/en.ts +++ b/src/language/texts/en.ts @@ -150,6 +150,8 @@ export function en() { 'general.close': 'Close', 'general.contains': 'Contains{0}', 'general.control_submit': 'Control and submit', + 'general.copy': 'Copy', + 'general.copied': 'Copied', 'general.create_new': 'Create new', 'general.create': 'Create', 'general.customer_service_phone_number': '+47 75 00 60 00', @@ -233,10 +235,11 @@ export function en() { 'instantiate.all_forms': 'all forms', 'instantiate.inbox': 'inbox', 'instantiate.profile': 'profile', - 'instantiate.unknown_error_title': 'Unknow error', + 'instantiate.unknown_error_title': 'Unknown error', 'instantiate.unknown_error_text': 'An unknown error occcurred, please try again later.', - 'instantiate.unknown_error_status': 'Unknow error', + 'instantiate.unknown_error_status': 'Unknown error', 'instantiate.unknown_error_customer_support': 'If the problem persists, contact us at customer service at {0}.', + 'instantiate.unknown_error_show_details': 'Show error details', 'instantiate.forbidden_action_error_title': 'You do not have permission to perform this action.', 'instantiate.forbidden_action_error_text': 'It looks like you do not have permission to perform this action.', 'instantiate.forbidden_action_error_status': '403 - Forbidden', diff --git a/src/language/texts/nb.ts b/src/language/texts/nb.ts index c2f404c9f7..f3fb69edd4 100644 --- a/src/language/texts/nb.ts +++ b/src/language/texts/nb.ts @@ -151,6 +151,8 @@ export function nb() { 'general.close': 'Lukk', 'general.contains': 'Inneholder', 'general.control_submit': 'Kontroller og send inn', + 'general.copy': 'Kopier', + 'general.copied': 'Kopiert', 'general.create_new': 'Opprett ny', 'general.create': 'Opprett', 'general.customer_service_phone_number': '+47 75 00 60 00', @@ -238,6 +240,7 @@ export function nb() { 'instantiate.unknown_error_text': 'Det har skjedd en ukjent feil, vennligst prøv igjen senere.', 'instantiate.unknown_error_status': 'Ukjent feil', 'instantiate.unknown_error_customer_support': 'Om problemet vedvarer, ta kontakt med oss på brukerservice {0}.', + 'instantiate.unknown_error_show_details': 'Vis detaljer om feilen', 'instantiate.forbidden_action_error_title': 'Du mangler rettigheter til å utføre denne handlingen', 'instantiate.forbidden_action_error_text': 'Det ser ut til at du mangler rettigheter til å utføre denne handlingen.', diff --git a/src/language/texts/nn.ts b/src/language/texts/nn.ts index c3fec52308..71dfce9aae 100644 --- a/src/language/texts/nn.ts +++ b/src/language/texts/nn.ts @@ -151,6 +151,8 @@ export function nn() { 'general.close': 'Lukk', 'general.contains': 'Inneheld', 'general.control_submit': 'Kontroller og send inn', + 'general.copy': 'Kopier', + 'general.copied': 'Kopiert', 'general.create_new': 'Opprett ny', 'general.create': 'Opprett', 'general.customer_service_phone_number': '+47 75 00 60 00', @@ -238,6 +240,7 @@ export function nn() { 'instantiate.unknown_error_text': 'Det har skjedd ein ukjent feil, ver venleg prøv igjen seinare.', 'instantiate.unknown_error_status': 'Ukjent feil', 'instantiate.unknown_error_customer_support': 'Om problemet hald fram, ta kontakt med oss på brukarservice {0}.', + 'instantiate.unknown_error_show_details': 'Vis detaljar om feilen', 'instantiate.forbidden_action_error_title': 'Du manglar rett til å utføre denne handlinga', 'instantiate.forbidden_action_error_text': 'Det ser ut til at du ikkje har rett til å utføre denne handlinga.', 'instantiate.forbidden_action_error_status': '403 - Forbidden', diff --git a/src/layout/SigningActions/PanelAwaitingCurrentUserSignature.tsx b/src/layout/SigningActions/PanelAwaitingCurrentUserSignature.tsx index 616d5cfd70..a3102d2314 100644 --- a/src/layout/SigningActions/PanelAwaitingCurrentUserSignature.tsx +++ b/src/layout/SigningActions/PanelAwaitingCurrentUserSignature.tsx @@ -102,7 +102,7 @@ export function AwaitingCurrentUserSignaturePanel({ // This shouldn't really happen, but if it does it indicates that our backend is out of sync with Autorisasjon somehow if (!canSign) { - return ; + return ; } if (isApiLoading) {