diff --git a/src/App.tsx b/src/App.tsx index 7d2cacf2fd..70ebf02159 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,12 +32,10 @@ export const App = () => ( path='/instance-selection/*' element={} /> - } /> - + show={formErrors.length > 0 || taskErrors.length > 0} + errors={ + + } + > + {errorReportIds.map((id) => ( + + ))} + diff --git a/src/components/message/ErrorReport.module.css b/src/components/message/ErrorReport.module.css index 45d2272b17..6c26056466 100644 --- a/src/components/message/ErrorReport.module.css +++ b/src/components/message/ErrorReport.module.css @@ -1,3 +1,7 @@ +.errorReport { + width: 100%; +} + .errorList { list-style-position: outside; margin: 0 0 0 24px; diff --git a/src/components/message/ErrorReport.tsx b/src/components/message/ErrorReport.tsx index 4d3f55f0ab..e7612f8122 100644 --- a/src/components/message/ErrorReport.tsx +++ b/src/components/message/ErrorReport.tsx @@ -1,4 +1,6 @@ -import React from 'react'; +import React, { createContext, useContext } from 'react'; +import { Link } from 'react-router-dom'; +import type { PropsWithChildren } from 'react'; import { Flex } from 'src/app-components/Flex/Flex'; import { PANEL_VARIANT } from 'src/app-components/Panel/constants'; @@ -7,15 +9,16 @@ import { FullWidthWrapper } from 'src/components/form/FullWidthWrapper'; import classes from 'src/components/message/ErrorReport.module.css'; import { useNavigateToNode } from 'src/features/form/layout/NavigateToNode'; import { Lang } from 'src/features/language/Lang'; -import { GenericComponentById } from 'src/layout/GenericComponent'; +import { useCurrentParty } from 'src/features/party/PartiesProvider'; +import { isAxiosError } from 'src/utils/isAxiosError'; import { Hidden, useNode } from 'src/utils/layout/NodesContext'; +import { HttpStatusCodes } from 'src/utils/network/networking'; import { useGetUniqueKeyFromObject } from 'src/utils/useGetKeyFromObject'; import type { AnyValidation, BaseValidation, NodeRefValidation } from 'src/features/validation'; -export interface IErrorReportProps { - renderIds: string[]; - formErrors: NodeRefValidation>[]; - taskErrors: BaseValidation<'error'>[]; +export interface IErrorReportProps extends PropsWithChildren { + show: boolean; + errors: React.ReactNode | undefined; } const ArrowForwardSvg = ` @@ -23,65 +26,122 @@ const ArrowForwardSvg = ` { - const hasErrors = Boolean(formErrors.length) || Boolean(taskErrors.length); - const getUniqueKeyFromObject = useGetUniqueKeyFromObject(); +// It is possible to render multiple error reports inside each other. If that happens, we should detect it and only +// render the outermost one. This may be a case in stateless apps, where you can have both validation errors and +// instantiation errors at the same time. +const ErrorReportContext = createContext(false); - if (!hasErrors) { - return null; +export const ErrorReport = ({ children, errors, show }: IErrorReportProps) => { + const hasErrorReport = useContext(ErrorReportContext); + if (errors === undefined || hasErrorReport || !show) { + return children; } return ( -
- - } - variant={PANEL_VARIANT.Error} - > - +
+ + } + variant={PANEL_VARIANT.Error} > -
    - {taskErrors.map((error) => ( -
  • - -
  • - ))} - {formErrors.map((error) => ( - - ))} -
+ +
    {errors}
+
+ {children}
- {renderIds.map((id) => ( - - ))} - -
-
-
+
+
+
+ ); }; -function Error({ error }: { error: NodeRefValidation }) { +function ErrorReportListItem({ children }: PropsWithChildren) { + return
  • {children}
  • ; +} + +interface ErrorReportListProps { + formErrors: NodeRefValidation>[]; + taskErrors: BaseValidation<'error'>[]; +} + +export function ErrorReportList({ formErrors, taskErrors }: ErrorReportListProps) { + const getUniqueKeyFromObject = useGetUniqueKeyFromObject(); + + return ( + <> + {taskErrors.map((error) => ( + + + + ))} + {formErrors.map((error) => ( + + ))} + + ); +} + +/** + * @see InstantiateContainer Contains somewhat similar logic, but for a full-screen error page. + */ +export function ErrorListFromInstantiation({ error }: { error: unknown }) { + const selectedParty = useCurrentParty(); + + if (isAxiosError(error) && error.response?.status === HttpStatusCodes.Forbidden) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const message = (error.response?.data as any)?.message; + if (message) { + return ( + + + + ); + } + return ( + + + {' '} + ( + + + + ). + + + ); + } + + return ( + + + + ); +} + +function ErrorWithLink({ error }: { error: NodeRefValidation }) { const node = useNode(error.nodeId); const navigateTo = useNavigateToNode(); const isHidden = Hidden.useIsHidden(node); @@ -100,7 +160,7 @@ function Error({ error }: { error: NodeRefValidation }) { }; return ( -
  • + -
  • + ); } diff --git a/src/features/instantiate/InstantiationContext.tsx b/src/features/instantiate/InstantiationContext.tsx index 1ef1643a44..3007d88cdd 100644 --- a/src/features/instantiate/InstantiationContext.tsx +++ b/src/features/instantiate/InstantiationContext.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useEffect } from 'react'; +import type { MutableRefObject } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { QueryClient } from '@tanstack/react-query'; @@ -32,6 +33,8 @@ interface InstantiationContext { error: AxiosError | undefined | null; lastResult: IInstance | undefined; clear: () => void; + clearTimeout: MutableRefObject | undefined>; + cancelClearTimeout: () => void; } const { Provider, useCtx } = createContext({ name: 'InstantiationContext', required: true }); @@ -78,6 +81,7 @@ export function InstantiationProvider({ children }: React.PropsWithChildren) { const queryClient = useQueryClient(); const instantiate = useInstantiateMutation(); const instantiateWithPrefill = useInstantiateWithPrefillMutation(); + const clearRef = React.useRef | undefined>(undefined); return ( { - removeMutations(queryClient); + if (clearRef.current) { + clearTimeout(clearRef.current); + } + clearRef.current = setTimeout(() => { + removeMutations(queryClient); + instantiate.reset(); + instantiateWithPrefill.reset(); + }, TIMEOUT); + }, + cancelClearTimeout: () => { + if (clearRef.current) { + clearTimeout(clearRef.current); + } }, + clearTimeout: clearRef, error: instantiate.error || instantiateWithPrefill.error, lastResult: instantiate.data ?? instantiateWithPrefill.data, @@ -116,3 +133,21 @@ function removeMutations(queryClient: QueryClient) { const mutations = queryClient.getMutationCache().findAll({ mutationKey: ['instantiate'] }); mutations.forEach((mutation) => queryClient.getMutationCache().remove(mutation)); } + +/* When this component is unmounted, we clear the instantiation to allow users to start a new instance later. This is + * needed for (for example) navigating back to party selection or instance selection, and then creating a new instance + * from there. However, React may decide to unmount this component and then mount it again quickly, so in those + * cases we want to avoid clearing the instantiation too soon (and cause a bug we had for a while where two instances + * would be created in quick succession). */ +const TIMEOUT = 500; + +export function useClearInstantiation(force: boolean = false) { + const instantiation = useInstantiation(); + const shouldClear = !!instantiation.error || force; + const clearInstantiation = instantiation.clear; + instantiation.cancelClearTimeout(); + + // Clear the instantiation when the component is unmounted to allow users to start a new instance later (without + // having the baggage of the previous instantiation error). + useEffect(() => () => (shouldClear ? clearInstantiation() : undefined), [clearInstantiation, shouldClear]); +} diff --git a/src/features/instantiate/InstantiationError.tsx b/src/features/instantiate/InstantiationError.tsx new file mode 100644 index 0000000000..37191199be --- /dev/null +++ b/src/features/instantiate/InstantiationError.tsx @@ -0,0 +1,25 @@ +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 { useInstantiation } from 'src/features/instantiate/InstantiationContext'; +import { isAxiosError } from 'src/utils/isAxiosError'; + +export function InstantiationError() { + const error = useParams()?.error; + const exception = useInstantiation().error; + + if (error === 'forbidden') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const message = isAxiosError(exception) ? (exception.response?.data as any)?.message : undefined; + if (message) { + return ; + } + + return ; + } + + return ; +} diff --git a/src/features/instantiate/containers/InstantiateContainer.tsx b/src/features/instantiate/containers/InstantiateContainer.tsx index 76c71748b5..a52d344c21 100644 --- a/src/features/instantiate/containers/InstantiateContainer.tsx +++ b/src/features/instantiate/containers/InstantiateContainer.tsx @@ -4,9 +4,8 @@ import { Loader } from 'src/core/loading/Loader'; 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 { useInstantiation } from 'src/features/instantiate/InstantiationContext'; +import { useClearInstantiation, useInstantiation } from 'src/features/instantiate/InstantiationContext'; import { useCurrentParty } from 'src/features/party/PartiesProvider'; -import { useAsRef } from 'src/hooks/useAsRef'; import { AltinnPalette } from 'src/theme/altinnAppTheme'; import { changeBodyBackground } from 'src/utils/bodyStyling'; import { isAxiosError } from 'src/utils/isAxiosError'; @@ -16,12 +15,8 @@ export const InstantiateContainer = () => { changeBodyBackground(AltinnPalette.greyLight); const party = useCurrentParty(); const instantiation = useInstantiation(); - const clearRef = useAsRef(instantiation.clear); - if (instantiationCleanupTimeout) { - // If we render this again before the cleanup timeout has run, we should clear it to avoid the cleanup. - clearTimeout(instantiationCleanupTimeout); - } + useClearInstantiation(true); useEffect(() => { const shouldCreateInstance = !!party; @@ -30,37 +25,16 @@ export const InstantiateContainer = () => { } }, [instantiation, party]); - // Clear the instantiation when the component is unmounted, to allow users to start a new instance later - useEffect(() => { - const clear = clearRef.current; - return () => { - if (instantiationCleanupTimeout) { - clearTimeout(instantiationCleanupTimeout); - } - instantiationCleanupTimeout = setTimeout(clear, TIMEOUT); - }; - }, [clearRef]); - - if (isAxiosError(instantiation.error)) { + if (isAxiosError(instantiation.error) && instantiation.error.response?.status === HttpStatusCodes.Forbidden) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const message = (instantiation.error.response?.data as any)?.message; - if (instantiation.error.response?.status === HttpStatusCodes.Forbidden) { - if (message) { - return ; - } - return ; + if (message) { + return ; } - + return ; + } else if (instantiation.error) { return ; } return ; }; - -/* When this component is unmounted, we clear the instantiation to allow users to start a new instance later. This is - * needed for (for example) navigating back to party selection or instance selection, and then creating a new instance - * from there. However, React may decide to unmount this component and then mount it again quickly, so in those - * cases we want to avoid clearing the instantiation too soon (and cause a bug we had for a while where two instances - * would be created in quick succession). */ -const TIMEOUT = 500; -let instantiationCleanupTimeout: ReturnType | undefined; diff --git a/src/features/instantiate/containers/InstantiateValidationError.tsx b/src/features/instantiate/containers/InstantiateValidationError.tsx index 65f3cc999d..8a58c2cbc0 100644 --- a/src/features/instantiate/containers/InstantiateValidationError.tsx +++ b/src/features/instantiate/containers/InstantiateValidationError.tsx @@ -7,25 +7,20 @@ import { useLanguage } from 'src/features/language/useLanguage'; export function InstantiateValidationError(props: { message: string }) { const { langAsString } = useLanguage(); - function createErrorContent() { - const errorCustomerService = langAsString( - 'instantiate.authorization_error_instantiate_validation_info_customer_service', - [langAsString('general.customer_service_phone_number')], - ); - return ( - <> - {props.message && } -
    -
    - {errorCustomerService} - - ); - } - return ( } - content={createErrorContent()} + content={ + <> + {props.message && } +
    +
    + + + } statusCode={`${langAsString('party_selection.error_caption_prefix')} 403`} /> ); diff --git a/src/features/instantiate/selection/InstanceSelection.module.css b/src/features/instantiate/selection/InstanceSelection.module.css index d2ecf3330a..cf41f1dbae 100644 --- a/src/features/instantiate/selection/InstanceSelection.module.css +++ b/src/features/instantiate/selection/InstanceSelection.module.css @@ -19,11 +19,6 @@ TODO(1779): Remove these styles after going through all the different Table styles in Altinn, and making sure they are consistent. */ -.table { - box-shadow: - 0 1px 1px rgba(0, 0, 0, 0.12), - 0 2px 2px rgba(0, 0, 0, 0.12); -} @media (max-width: 992px) { .table { border-top: 1px solid #dde3e5; diff --git a/src/features/instantiate/selection/InstanceSelection.tsx b/src/features/instantiate/selection/InstanceSelection.tsx index ba4d3e7fe0..3a13ca8fc9 100644 --- a/src/features/instantiate/selection/InstanceSelection.tsx +++ b/src/features/instantiate/selection/InstanceSelection.tsx @@ -7,13 +7,14 @@ import { PencilIcon } from '@navikt/aksel-icons'; import { Button } from 'src/app-components/Button/Button'; import { Pagination } from 'src/app-components/Pagination/Pagination'; +import { ErrorListFromInstantiation, ErrorReport } from 'src/components/message/ErrorReport'; import { PresentationComponent } from 'src/components/presentation/Presentation'; import { ReadyForPrint } from 'src/components/ReadyForPrint'; import { useIsProcessing } from 'src/core/contexts/processingContext'; import { TaskStoreProvider } from 'src/core/contexts/taskStoreContext'; import { useAppName, useAppOwner } from 'src/core/texts/appTexts'; import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; -import { useInstantiation } from 'src/features/instantiate/InstantiationContext'; +import { useClearInstantiation, useInstantiation } from 'src/features/instantiate/InstantiationContext'; import { ActiveInstancesProvider, useActiveInstances, @@ -64,7 +65,7 @@ function InstanceSelection() { const { langAsString } = useLanguage(); const mobileView = useIsMobileOrTablet(); const rowsPerPageOptions = instanceSelectionOptions?.rowsPerPageOptions ?? [10, 25, 50]; - const instantiate = useInstantiation().instantiate; + const instantiation = useInstantiation(); const currentParty = useCurrentParty(); const storeCallback = useSetNavigationEffect(); const { performProcess, isAnyProcessing, isThisProcessing: isLoading } = useIsProcessing(); @@ -82,6 +83,8 @@ function InstanceSelection() { const instances = instanceSelectionOptions?.sortDirection === 'desc' ? [..._instances].reverse() : _instances; const paginatedInstances = instances.slice(currentPage * rowsPerPage, (currentPage + 1) * rowsPerPage); + useClearInstantiation(); + function handleRowsPerPageChanged(newRowsPerPage: number) { setRowsPerPage(newRowsPerPage); if (instances.length < currentPage * newRowsPerPage) { @@ -267,22 +270,27 @@ function InstanceSelection() { {mobileView && renderMobileTable()} {!mobileView && renderTable()}
    - + +
    diff --git a/src/layout/InstantiationButton/InstantiationButton.module.css b/src/layout/InstantiationButton/InstantiationButton.module.css index b8cbf4a239..7a46cf133b 100644 --- a/src/layout/InstantiationButton/InstantiationButton.module.css +++ b/src/layout/InstantiationButton/InstantiationButton.module.css @@ -1,4 +1,5 @@ .container { display: flex; gap: var(--button-gap); + width: 100%; } diff --git a/src/layout/InstantiationButton/InstantiationButton.tsx b/src/layout/InstantiationButton/InstantiationButton.tsx index 59c731b02d..14bbb9213e 100644 --- a/src/layout/InstantiationButton/InstantiationButton.tsx +++ b/src/layout/InstantiationButton/InstantiationButton.tsx @@ -1,6 +1,7 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { Button } from 'src/app-components/Button/Button'; +import { ErrorListFromInstantiation, ErrorReport } from 'src/components/message/ErrorReport'; import { useIsProcessing } from 'src/core/contexts/processingContext'; import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { FD } from 'src/features/formData/FormDataWrite'; @@ -12,46 +13,36 @@ type Props = Omit { - const { instantiateWithPrefill, error } = useInstantiation(); + const instantiation = useInstantiation(); const { performProcess, isAnyProcessing, isThisProcessing: isLoading } = useIsProcessing(); const prefill = FD.useMapping(props.mapping, DataModels.useDefaultDataType()); const party = useCurrentParty(); - // const onClick = () => { - // instantiateWithPrefill(props.node, { - // prefill, - // instanceOwner: { - // partyId: party?.partyId.toString(), - // }, - // }); - // }; - - useEffect(() => { - if (error) { - throw error; - } - }, [error]); - return ( - + + ); }; diff --git a/test/e2e/integration/frontend-test/instantiation.ts b/test/e2e/integration/frontend-test/instantiation.ts new file mode 100644 index 0000000000..dd185c4db9 --- /dev/null +++ b/test/e2e/integration/frontend-test/instantiation.ts @@ -0,0 +1,52 @@ +import { AppFrontend } from 'test/e2e/pageobjects/app-frontend'; +import { cyMockResponses } from 'test/e2e/pageobjects/party-mocks'; + +const appFrontend = new AppFrontend(); + +describe('Instantiation', () => { + // See ttd/frontend-test/App/logic/Instantiation/InstantiationValidator.cs + const invalidParty = + Cypress.env('type') === 'localtest' + ? /950474084/ // Localtest: Oslos Vakreste borettslag + : /310732001/; // TT02: Søvnig Impulsiv Tiger AS + + it('should show an error message when going directly to instantiation', () => { + cyMockResponses({ + doNotPromptForParty: false, + onEntryShow: 'new-instance', + }); + cy.startAppInstance(appFrontend.apps.frontendTest, { user: 'manager' }); + cy.findByRole('button', { name: invalidParty }).click(); + + cy.findByText('Du kan ikke starte denne tjenesten').should('be.visible'); + assertErrorMessage(); + }); + + it('should show an error message when starting a new instance from instance-selection', () => { + cyMockResponses({ + doNotPromptForParty: false, + onEntryShow: 'select-instance', + activeInstances: [ + { id: 'abc123', lastChanged: '2023-01-01T00:00:00.000Z', lastChangedBy: 'user' }, + { id: 'def456', lastChanged: '2023-01-02T00:00:00.000Z', lastChangedBy: 'user' }, + ], + }); + cy.startAppInstance(appFrontend.apps.frontendTest, { user: 'manager' }); + cy.findByRole('button', { name: invalidParty }).click(); + + cy.findByText('Du har allerede startet å fylle ut dette skjemaet.').should('be.visible'); + cy.findByRole('button', { name: 'Start på nytt' }).click(); + + assertErrorMessage(); + cy.findByText('Du kan ikke starte denne tjenesten').should('not.exist'); + }); + + function assertErrorMessage() { + cy.findByText( + /Aktøren du valgte kan ikke opprette en instans av dette skjemaet. Dette er en egendefinert feilmelding for akkurat denne appen./, + ).should('be.visible'); + cy.findByRole('link', { name: 'Vennligst velg en annen aktør' }).click(); + + cy.findByRole('button', { name: invalidParty }).should('be.visible'); + } +}); diff --git a/test/e2e/pageobjects/party-mocks.ts b/test/e2e/pageobjects/party-mocks.ts index f4146ca53b..89cc7d9a1d 100644 --- a/test/e2e/pageobjects/party-mocks.ts +++ b/test/e2e/pageobjects/party-mocks.ts @@ -1,5 +1,6 @@ import { PartyType } from 'src/types/shared'; -import type { IncomingApplicationMetadata } from 'src/features/applicationMetadata/types'; +import type { IncomingApplicationMetadata, ShowTypes } from 'src/features/applicationMetadata/types'; +import type { ISimpleInstance } from 'src/types'; import type { IParty } from 'src/types/shared'; const ExampleOrgWithSubUnit: IParty = { @@ -100,7 +101,8 @@ interface Mockable { doNotPromptForParty?: boolean; appPromptForPartyOverride?: IncomingApplicationMetadata['promptForParty']; partyTypesAllowed?: IncomingApplicationMetadata['partyTypesAllowed']; - noActiveInstances?: boolean; // Defaults to true + activeInstances?: false | ISimpleInstance[]; // Defaults to false + onEntryShow?: ShowTypes; } export function cyMockResponses(whatToMock: Mockable) { @@ -138,22 +140,28 @@ export function cyMockResponses(whatToMock: Mockable) { }, }); } - if (whatToMock.appPromptForPartyOverride !== undefined || whatToMock.partyTypesAllowed !== undefined) { + if ( + whatToMock.appPromptForPartyOverride !== undefined || + whatToMock.partyTypesAllowed !== undefined || + whatToMock.onEntryShow !== undefined + ) { cy.intercept('GET', '**/api/v1/applicationmetadata', (req) => { req.on('response', (res) => { + const body = res.body as IncomingApplicationMetadata; if (whatToMock.appPromptForPartyOverride !== undefined) { - res.body.promptForParty = whatToMock.appPromptForPartyOverride; + body.promptForParty = whatToMock.appPromptForPartyOverride; } if (whatToMock.partyTypesAllowed !== undefined) { - res.body.partyTypesAllowed = whatToMock.partyTypesAllowed; + body.partyTypesAllowed = whatToMock.partyTypesAllowed; + } + if (whatToMock.onEntryShow !== undefined) { + body.onEntry = { show: whatToMock.onEntryShow }; } }); }); } - if (whatToMock.noActiveInstances !== false) { - cy.intercept('**/active', []).as('noActiveInstances'); - } + cy.intercept('**/active', whatToMock.activeInstances || []).as('activeInstances'); } export function removeAllButOneOrg(parties: IParty[]): IParty[] {