diff --git a/src/components/message/ErrorReport.tsx b/src/components/message/ErrorReport.tsx index 98c112cc6c..05841b2526 100644 --- a/src/components/message/ErrorReport.tsx +++ b/src/components/message/ErrorReport.tsx @@ -8,6 +8,8 @@ import { FullWidthWrapper } from 'src/app-components/FullWidthWrapper/FullWidthW import classes from 'src/components/message/ErrorReport.module.css'; import { useAllAttachments } from 'src/features/attachments/hooks'; import { FileScanResults } from 'src/features/attachments/types'; +import { InstantiationClosedText } from 'src/features/instantiate/containers/InstantiationClosedError'; +import { isInstantiationClosedError, useInstantiationClosedReason } from 'src/features/instantiate/instantiationClosed'; import { InstantiationValidation, isInstantiationValidationResult, @@ -22,6 +24,7 @@ import { HttpStatusCodes } from 'src/utils/network/networking'; import { splitDashedKey } from 'src/utils/splitDashedKey'; import { useGetUniqueKeyFromObject } from 'src/utils/useGetKeyFromObject'; import type { UploadedAttachment } from 'src/features/attachments'; +import type { InstantiationClosedReason } from 'src/features/instantiate/instantiationClosed'; import type { AnyValidation, BaseValidation, NodeRefValidation } from 'src/features/validation'; export interface IErrorReportProps extends PropsWithChildren { @@ -142,11 +145,24 @@ export function ErrorReportList({ formErrors, taskErrors }: ErrorReportListProps ); } +export function ErrorListForClosedInstantiation({ reason }: { reason: InstantiationClosedReason }) { + return ( + + + + ); +} + /** * @see InstantiateContainer Contains somewhat similar logic, but for a full-screen error page. */ export function ErrorListFromInstantiation({ error }: { error: unknown }) { const selectedParty = useSelectedParty(); + const closedReason = useInstantiationClosedReason(); + + if (isInstantiationClosedError(error)) { + return ; + } if (isAxiosError(error) && error.response?.status === HttpStatusCodes.Forbidden) { if (isInstantiationValidationResult(error.response.data)) { diff --git a/src/features/applicationMetadata/types.ts b/src/features/applicationMetadata/types.ts index 61ffb5143b..9eda9ab050 100644 --- a/src/features/applicationMetadata/types.ts +++ b/src/features/applicationMetadata/types.ts @@ -22,6 +22,9 @@ export interface IncomingApplicationMetadata { altinnNugetVersion?: string; logo?: ILogoOptions; messageBoxConfig?: MessageBoxConfig; + validFrom?: string | null; + validTo?: string | null; + disallowUserInstantiation?: boolean; } type HideAlwaysSettings = { diff --git a/src/features/instantiate/containers/InstantiateContainer.test.tsx b/src/features/instantiate/containers/InstantiateContainer.test.tsx new file mode 100644 index 0000000000..cc97a7d7be --- /dev/null +++ b/src/features/instantiate/containers/InstantiateContainer.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import { jest } from '@jest/globals'; +import { screen, waitFor } from '@testing-library/react'; + +import { getIncomingApplicationMetadataMock } from 'src/__mocks__/getApplicationMetadataMock'; +import { InstantiateContainer } from 'src/features/instantiate/containers/InstantiateContainer'; +import { fetchApplicationMetadata } from 'src/queries/queries'; +import { renderWithoutInstanceAndLayout } from 'src/test/renderWithProviders'; + +describe('InstantiateContainer', () => { + it('should show that the service cannot be started when instantiation responds with 410', async () => { + jest.spyOn(window, 'logError').mockImplementation(() => {}); + const { mutations } = await renderWithoutInstanceAndLayout({ + renderer: () => , + waitUntilLoaded: false, + }); + + await waitFor(() => expect(mutations.doInstantiate.mock).toHaveBeenCalledTimes(1)); + mutations.doInstantiate.reject(Object.assign(new Error('Gone'), { isAxiosError: true, response: { status: 410 } })); + + expect(await screen.findByText('Du kan ikke starte denne tjenesten')).toBeInTheDocument(); + expect(screen.getByTestId('StatusCode')).toHaveTextContent('Feil 410'); + expect(screen.queryByText(/du mangler rettigheter/i)).not.toBeInTheDocument(); + }); + + it('should not try to instantiate when validTo has passed', async () => { + jest + .mocked(fetchApplicationMetadata) + .mockImplementationOnce(async () => getIncomingApplicationMetadataMock({ validTo: '2020-01-01T00:00:00Z' })); + const { mutations } = await renderWithoutInstanceAndLayout({ + renderer: () => , + waitUntilLoaded: false, + }); + + expect(await screen.findByText('Du kan ikke starte denne tjenesten')).toBeInTheDocument(); + expect(mutations.doInstantiate.mock).not.toHaveBeenCalled(); + }); + + it('should not try to instantiate before validFrom, and say when the service opens', async () => { + jest + .mocked(fetchApplicationMetadata) + .mockImplementationOnce(async () => getIncomingApplicationMetadataMock({ validFrom: '2099-01-01T10:00:00Z' })); + const { mutations } = await renderWithoutInstanceAndLayout({ + renderer: () => , + waitUntilLoaded: false, + }); + + expect(await screen.findByText('Denne tjenesten har ikke åpnet ennå')).toBeInTheDocument(); + expect(screen.getByText(/Tjenesten åpner 01\.01\.2099/)).toBeInTheDocument(); + expect(mutations.doInstantiate.mock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/instantiate/containers/InstantiateContainer.tsx b/src/features/instantiate/containers/InstantiateContainer.tsx index 1ea2c0990e..d014dc27a1 100644 --- a/src/features/instantiate/containers/InstantiateContainer.tsx +++ b/src/features/instantiate/containers/InstantiateContainer.tsx @@ -2,8 +2,10 @@ import React, { useEffect } from 'react'; import { Loader } from 'src/core/loading/Loader'; import { InstantiateValidationError } from 'src/features/instantiate/containers/InstantiateValidationError'; +import { InstantiationClosedError } from 'src/features/instantiate/containers/InstantiationClosedError'; import { MissingRolesError } from 'src/features/instantiate/containers/MissingRolesError'; import { UnknownError } from 'src/features/instantiate/containers/UnknownError'; +import { isInstantiationClosedError, useInstantiationClosedReason } from 'src/features/instantiate/instantiationClosed'; import { isInstantiationValidationResult } from 'src/features/instantiate/InstantiationValidation'; import { useInstantiation } from 'src/features/instantiate/useInstantiation'; import { useSelectedParty } from 'src/features/party/PartiesProvider'; @@ -17,13 +19,18 @@ export const InstantiateContainer = () => { changeBodyBackground(AltinnPalette.greyLight); const party = useSelectedParty(); const instantiation = useInstantiation(); + const closedReason = useInstantiationClosedReason(); useEffect(() => { - const shouldCreateInstance = !!party; + const shouldCreateInstance = !!party && !closedReason; if (shouldCreateInstance) { instantiation.instantiate(party.partyId); } - }, [instantiation, party]); + }, [instantiation, party, closedReason]); + + if (closedReason) { + return ; + } // A 403 with RequiredAuthenticationLevel triggers a step-up redirect (see useInstantiation.onError). Block further // rendering with a loader until the browser navigates away, so we never flash the "missing roles" error page. After a @@ -33,6 +40,10 @@ export const InstantiateContainer = () => { return ; } + if (isInstantiationClosedError(instantiation.error)) { + return ; + } + if (isAxiosError(instantiation.error) && instantiation.error.response?.status === HttpStatusCodes.Forbidden) { if (isInstantiationValidationResult(instantiation.error.response?.data)) { return ; diff --git a/src/features/instantiate/containers/InstantiationClosedError.tsx b/src/features/instantiate/containers/InstantiationClosedError.tsx new file mode 100644 index 0000000000..84572131c7 --- /dev/null +++ b/src/features/instantiate/containers/InstantiationClosedError.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import { InstantiationErrorPage } from 'src/features/instantiate/containers/InstantiationErrorPage'; +import { Lang } from 'src/features/language/Lang'; +import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; +import { useLanguage } from 'src/features/language/useLanguage'; +import { formatDateLocale } from 'src/utils/dateUtils'; +import { HttpStatusCodes } from 'src/utils/network/networking'; +import type { InstantiationClosedReason } from 'src/features/instantiate/instantiationClosed'; + +/** + * Explains why the service cannot be started, as a text fragment for use in error reports and on the error page. + */ +export function InstantiationClosedText({ reason }: { reason: InstantiationClosedReason }) { + const language = useCurrentLanguage(); + + if (reason.reason === 'notYetOpen') { + return ( + + ); + } + + return ; +} + +export function InstantiationClosedError({ reason }: { reason: InstantiationClosedReason }) { + const { langAsString } = useLanguage(); + + return ( + + } + content={} + showContactInfo={true} + statusCode={`${langAsString('party_selection.error_caption_prefix')} ${HttpStatusCodes.Gone}`} + /> + ); +} diff --git a/src/features/instantiate/instantiationClosed.test.ts b/src/features/instantiate/instantiationClosed.test.ts new file mode 100644 index 0000000000..d682380b6e --- /dev/null +++ b/src/features/instantiate/instantiationClosed.test.ts @@ -0,0 +1,52 @@ +import { getInstantiationClosedReason } from 'src/features/instantiate/instantiationClosed'; + +describe('getInstantiationClosedReason', () => { + const now = new Date('2026-10-21T12:00:00Z'); + const tt02Host = 'ttd.apps.tt02.altinn.no'; + const prodHost = 'ttd.apps.altinn.no'; + + it.each([ + [undefined, undefined], + [null, undefined], + ['2026-10-21T11:59:59Z', 'closed'], + ['2026-10-21T12:00:01Z', undefined], + ['2026-10-21T11:59:59.1504337Z', 'closed'], + ['2026-10-21T13:59:59+02:00', 'closed'], + ['2026-10-21T14:00:01+02:00', undefined], + ['2026-10-21T11:59:59', 'closed'], + ['2026-10-21T12:00:01', undefined], + ['2026-10-21', 'closed'], + ['2026-10-22', undefined], + ['not a date', undefined], + ])('validTo %s gives %s', (validTo, expected) => { + expect(getInstantiationClosedReason({ validTo }, now, tt02Host)?.reason).toBe(expected); + }); + + it.each([ + [undefined, undefined], + [null, undefined], + ['2026-10-21T12:00:01Z', 'notYetOpen'], + ['2026-10-21T11:59:59Z', undefined], + ['2026-10-21T14:00:01+02:00', 'notYetOpen'], + ['2026-10-21T12:00:01', 'notYetOpen'], + ['2026-10-22', 'notYetOpen'], + ['2026-10-21', undefined], + ['not a date', undefined], + ])('validFrom %s gives %s', (validFrom, expected) => { + expect(getInstantiationClosedReason({ validFrom }, now, tt02Host)?.reason).toBe(expected); + }); + + it('reports when a not yet open service opens', () => { + const result = getInstantiationClosedReason({ validFrom: '2026-10-22T08:00:00Z' }, now, tt02Host); + expect(result).toEqual({ reason: 'notYetOpen', opensAt: new Date('2026-10-22T08:00:00Z') }); + }); + + it.each([ + [prodHost, true, 'closed'], + [prodHost, false, undefined], + [tt02Host, true, undefined], + ['local.altinn.cloud:8000', true, undefined], + ])('on %s with disallowUserInstantiation %s gives %s', (host, disallowUserInstantiation, expected) => { + expect(getInstantiationClosedReason({ disallowUserInstantiation }, now, host)?.reason).toBe(expected); + }); +}); diff --git a/src/features/instantiate/instantiationClosed.ts b/src/features/instantiate/instantiationClosed.ts new file mode 100644 index 0000000000..219c5f9104 --- /dev/null +++ b/src/features/instantiate/instantiationClosed.ts @@ -0,0 +1,65 @@ +import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; +import { isAxiosError } from 'src/utils/isAxiosError'; +import { HttpStatusCodes } from 'src/utils/network/networking'; +import { isProductionHost } from 'src/utils/urls/urlHelper'; +import type { IncomingApplicationMetadata } from 'src/features/applicationMetadata/types'; + +type InstantiationPreconditions = Pick< + IncomingApplicationMetadata, + 'validFrom' | 'validTo' | 'disallowUserInstantiation' +>; + +export type InstantiationClosedReason = { reason: 'notYetOpen'; opensAt: Date } | { reason: 'closed' }; + +// The app backend reads a validity date without an offset as UTC, while JavaScript reads a date-time without an +// offset as local time. +const hasTimeWithoutOffset = /T[^Z+-]*$/i; + +function parseValidityDate(value: string): Date { + return new Date(hasTimeWithoutOffset.test(value) ? `${value}Z` : value); +} + +/** + * Mirrors the checks the app backend does before creating an instance, so that we can tell the user why the service + * cannot be started instead of sending a request we know will be rejected. The backend responds with 410 Gone in + * every case. Service owners are exempt from disallowUserInstantiation, but they do not use the app frontend. + * + * An unparsable validity date is left for the backend to judge. + */ +export function getInstantiationClosedReason( + { validFrom, validTo, disallowUserInstantiation }: InstantiationPreconditions, + now: Date, + host: string, +): InstantiationClosedReason | undefined { + if (validFrom) { + const opensAt = parseValidityDate(validFrom); + if (opensAt.getTime() > now.getTime()) { + return { reason: 'notYetOpen', opensAt }; + } + } + + if (validTo && parseValidityDate(validTo).getTime() < now.getTime()) { + return { reason: 'closed' }; + } + + // The app backend only enforces disallowUserInstantiation in production, so that apps can still be tried out in + // test environments. Mirror that here, or the frontend would block instantiation where the backend allows it. + if (disallowUserInstantiation && isProductionHost(host)) { + return { reason: 'closed' }; + } + + return undefined; +} + +/** + * The app backend responds with 410 Gone when it refuses to create an instance because the service is closed, see + * getInstantiationClosedReason for the conditions. + */ +export function isInstantiationClosedError(error: unknown): boolean { + return isAxiosError(error) && error.response?.status === HttpStatusCodes.Gone; +} + +export function useInstantiationClosedReason(): InstantiationClosedReason | undefined { + const applicationMetadata = useApplicationMetadata(); + return getInstantiationClosedReason(applicationMetadata, new Date(), window.location.host); +} diff --git a/src/features/instantiate/selection/InstanceSelection.test.tsx b/src/features/instantiate/selection/InstanceSelection.test.tsx index e928585cbc..ccbfb80415 100644 --- a/src/features/instantiate/selection/InstanceSelection.test.tsx +++ b/src/features/instantiate/selection/InstanceSelection.test.tsx @@ -1,11 +1,13 @@ import React from 'react'; -import { screen, within } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; +import { getIncomingApplicationMetadataMock } from 'src/__mocks__/getApplicationMetadataMock'; import { InstanceSelectionWrapper } from 'src/features/instantiate/selection/InstanceSelection'; +import { fetchApplicationMetadata } from 'src/queries/queries'; import { mockMediaQuery } from 'src/test/mockMediaQuery'; -import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; +import { renderWithInstanceAndLayout, renderWithoutInstanceAndLayout } from 'src/test/renderWithProviders'; import type { ISimpleInstance } from 'src/types'; const mockActiveInstances: ISimpleInstance[] = [ @@ -75,6 +77,45 @@ describe('InstanceSelection', () => { expect(mutations.doInstantiate.mock).toHaveBeenCalledTimes(1); }); + it('should explain that new instances cannot be started when instantiation responds with 410', async () => { + jest.spyOn(window, 'logError').mockImplementation(() => {}); + const { mutations } = await renderWithoutInstanceAndLayout({ + renderer: () => , + queries: { fetchActiveInstances: () => Promise.resolve(mockActiveInstances) }, + }); + await userEvent.click(screen.getByText(/start på nytt/i)); + mutations.doInstantiate.reject(Object.assign(new Error('Gone'), { isAxiosError: true, response: { status: 410 } })); + + await waitFor(() => + expect(screen.getByText(/det er ikke mulig å starte nye skjema i denne tjenesten/i)).toBeInTheDocument(), + ); + expect(screen.queryByText(/ikke har rettigheter til å starte denne tjenesten/i)).not.toBeInTheDocument(); + }); + + it('should list active instances but not offer to start a new one when validTo has passed', async () => { + jest + .mocked(fetchApplicationMetadata) + .mockImplementationOnce(async () => getIncomingApplicationMetadataMock({ validTo: '2020-01-01T00:00:00Z' })); + const { mutations } = await render(); + + expect(await screen.findByText(mockActiveInstances[0].lastChangedBy)).toBeInTheDocument(); + expect(screen.getByText(/det er ikke mulig å starte nye skjema i denne tjenesten/i)).toBeInTheDocument(); + expect(screen.queryByText(/start på nytt/i)).not.toBeInTheDocument(); + expect(mutations.doInstantiate.mock).not.toHaveBeenCalled(); + }); + + it('should list active instances but not offer to start a new one before validFrom', async () => { + jest + .mocked(fetchApplicationMetadata) + .mockImplementationOnce(async () => getIncomingApplicationMetadataMock({ validFrom: '2099-01-01T10:00:00Z' })); + const { mutations } = await render(); + + expect(await screen.findByText(mockActiveInstances[0].lastChangedBy)).toBeInTheDocument(); + expect(screen.getByText(/Tjenesten åpner 01\.01\.2099/)).toBeInTheDocument(); + expect(screen.queryByText(/start på nytt/i)).not.toBeInTheDocument(); + expect(mutations.doInstantiate.mock).not.toHaveBeenCalled(); + }); + it('should trigger openInstance on editButton click', async () => { const { mutations, routerRef } = await render(); const row = screen.getByRole('row', { diff --git a/src/features/instantiate/selection/InstanceSelection.tsx b/src/features/instantiate/selection/InstanceSelection.tsx index 90770ba8b9..9fb9a5dc9d 100644 --- a/src/features/instantiate/selection/InstanceSelection.tsx +++ b/src/features/instantiate/selection/InstanceSelection.tsx @@ -12,6 +12,8 @@ import { ReadyForPrint } from 'src/components/ReadyForPrint'; import { useIsProcessing } from 'src/core/contexts/processingContext'; import { useAppName, useAppOwner } from 'src/core/texts/appTexts'; import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; +import { InstantiationClosedText } from 'src/features/instantiate/containers/InstantiationClosedError'; +import { useInstantiationClosedReason } from 'src/features/instantiate/instantiationClosed'; import { ActiveInstancesProvider, useActiveInstances, @@ -58,6 +60,7 @@ function InstanceSelection() { const mobileView = useIsMobileOrTablet(); const rowsPerPageOptions = instanceSelectionOptions?.rowsPerPageOptions ?? [10, 25, 50]; const instantiation = useInstantiation(); + const instantiationClosedReason = useInstantiationClosedReason(); const selectedParty = useSelectedParty(); const setNavigationEffect = useSetNavigationEffect(); const { performProcess, isAnyProcessing, isThisProcessing: isLoading } = useIsProcessing(); @@ -251,34 +254,40 @@ function InstanceSelection() { {mobileView && renderMobileTable()} {!mobileView && renderTable()}
- : undefined} - > - - + + + )}
diff --git a/src/language/texts/en.ts b/src/language/texts/en.ts index 4f5863f306..aabe325885 100644 --- a/src/language/texts/en.ts +++ b/src/language/texts/en.ts @@ -243,6 +243,11 @@ export function en() { '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.not_yet_open_error_title': 'This service has not opened yet', + 'instantiate.not_yet_open_error_text': 'The service opens {0}. It is not possible to start new forms before then.', + 'instantiate.closed_error_title': 'You cannot start this service', + 'instantiate.closed_error_text': + 'It is not possible to start new forms in this service. You can still complete forms you have already started from your inbox.', '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 077184fe62..006e29aab5 100644 --- a/src/language/texts/nb.ts +++ b/src/language/texts/nb.ts @@ -244,6 +244,11 @@ export function nb() { '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.not_yet_open_error_title': 'Denne tjenesten har ikke åpnet ennå', + 'instantiate.not_yet_open_error_text': 'Tjenesten åpner {0}. Det er ikke mulig å starte nye skjema før det.', + 'instantiate.closed_error_title': 'Du kan ikke starte denne tjenesten', + 'instantiate.closed_error_text': + 'Det er ikke mulig å starte nye skjema i denne tjenesten. Skjema du allerede har startet, kan du fortsatt fullføre fra innboksen.', '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 1035e2e2b4..2ff67db754 100644 --- a/src/language/texts/nn.ts +++ b/src/language/texts/nn.ts @@ -244,6 +244,11 @@ export function nn() { '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.not_yet_open_error_title': 'Denne tenesta har ikkje opna enno', + 'instantiate.not_yet_open_error_text': 'Tenesta opnar {0}. Det er ikkje mogleg å starte nye skjema før det.', + 'instantiate.closed_error_title': 'Du kan ikkje starte denne tenesta', + 'instantiate.closed_error_text': + 'Det er ikkje mogleg å starte nye skjema i denne tenesta. Skjema du allereie har starta, kan du framleis fullføre frå innboksen.', '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/InstantiationButton/InstantiationButton.test.tsx b/src/layout/InstantiationButton/InstantiationButton.test.tsx index d30fa21dbc..3e6fa31313 100644 --- a/src/layout/InstantiationButton/InstantiationButton.test.tsx +++ b/src/layout/InstantiationButton/InstantiationButton.test.tsx @@ -12,12 +12,13 @@ import { InstantiationButtonComponent } from 'src/layout/InstantiationButton/Ins import { fetchApplicationMetadata } from 'src/queries/queries'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; -const render = async () => { +const render = async (validity: { validFrom?: string; validTo?: string } = {}) => { jest.mocked(fetchApplicationMetadata).mockImplementationOnce(async () => getIncomingApplicationMetadataMock({ onEntry: { show: 'stateless', }, + ...validity, }), ); return await renderGenericComponentTest({ @@ -80,4 +81,34 @@ describe('InstantiationButton', () => { expect(screen.getByText('You are now looking at the instance')).toBeInTheDocument(); }); }); + + it('should explain that the service cannot be started, and disable the button, when validTo has passed', async () => { + const { mutations } = await render({ validTo: '2020-01-01T00:00:00Z' }); + + expect(await screen.findByText(/det er ikke mulig å starte nye skjema i denne tjenesten/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Instantiate' })).toBeDisabled(); + expect(mutations.doInstantiateWithPrefill.mock).not.toHaveBeenCalled(); + }); + + it('should explain that the service cannot be started, and disable the button, after a 410 response', async () => { + jest.spyOn(window, 'logError').mockImplementation(() => {}); + const { mutations } = await render(); + + await userEvent.click(screen.getByRole('button', { name: 'Instantiate' })); + await waitFor(() => expect(mutations.doInstantiateWithPrefill.mock).toHaveBeenCalledTimes(1)); + mutations.doInstantiateWithPrefill.reject( + Object.assign(new Error('Gone'), { isAxiosError: true, response: { status: 410 } }), + ); + + expect(await screen.findByText(/det er ikke mulig å starte nye skjema i denne tjenesten/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Instantiate' })).toBeDisabled(); + }); + + it('should say when the service opens, and disable the button, before validFrom', async () => { + const { mutations } = await render({ validFrom: '2099-01-01T10:00:00Z' }); + + expect(await screen.findByText(/Tjenesten åpner 01\.01\.2099/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Instantiate' })).toBeDisabled(); + expect(mutations.doInstantiateWithPrefill.mock).not.toHaveBeenCalled(); + }); }); diff --git a/src/layout/InstantiationButton/InstantiationButton.tsx b/src/layout/InstantiationButton/InstantiationButton.tsx index 7d9397c865..76b255f795 100644 --- a/src/layout/InstantiationButton/InstantiationButton.tsx +++ b/src/layout/InstantiationButton/InstantiationButton.tsx @@ -1,15 +1,21 @@ import React from 'react'; import { Button } from 'src/app-components/Button/Button'; -import { ErrorListFromInstantiation, ErrorReport } from 'src/components/message/ErrorReport'; +import { + ErrorListForClosedInstantiation, + 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'; +import { isInstantiationClosedError, useInstantiationClosedReason } from 'src/features/instantiate/instantiationClosed'; import { useInstantiation } from 'src/features/instantiate/useInstantiation'; import { useSetNavigationEffect } from 'src/features/navigation/NavigationEffectContext'; import { useSelectedParty } from 'src/features/party/PartiesProvider'; import { focusMainContent } from 'src/hooks/useNavigatePage'; import { useIndexedId } from 'src/utils/layout/DataModelLocation'; +import type { InstantiationClosedReason } from 'src/features/instantiate/instantiationClosed'; import type { IInstantiationButtonComponentProvidedProps } from 'src/layout/InstantiationButton/InstantiationButtonComponent'; type Props = Omit, 'text'>; @@ -21,11 +27,20 @@ export const InstantiationButton = ({ children, ...props }: Props) => { const prefill = FD.useMapping(props.mapping, DataModels.useDefaultDataType()); const party = useSelectedParty(); const setNavigationEffect = useSetNavigationEffect(); + const metadataClosedReason = useInstantiationClosedReason(); + const closedReason: InstantiationClosedReason | undefined = + metadataClosedReason ?? (isInstantiationClosedError(instantiation.error) ? { reason: 'closed' } : undefined); return ( : undefined} + show={closedReason !== undefined || instantiation.error !== undefined} + errors={ + closedReason ? ( + + ) : instantiation.error ? ( + + ) : undefined + } >