From 2b4913766ebd9add8af6ce1cdf8a3caf1c32d72f Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Wed, 27 Aug 2025 12:37:09 +0200 Subject: [PATCH 01/21] Refactoring to not require the type argument, calling useIsReceiptPage() instead. Moving things around to make tests/mocking work. --- src/App.tsx | 10 ++----- src/components/form/Form.tsx | 3 +- src/components/form/LinkToPotentialNode.tsx | 2 +- .../presentation/Presentation.test.tsx | 21 ++++++++++---- src/components/presentation/Presentation.tsx | 28 +++++++------------ src/components/wrappers/ProcessWrapper.tsx | 14 +++------- src/core/loading/Loader.tsx | 2 -- src/core/routing/types.ts | 7 +++++ src/core/routing/useIsReceiptPage.ts | 7 +++++ .../expressions/expression-functions.ts | 2 +- .../selection/InstanceSelection.tsx | 6 +--- src/features/navigation/AppNavigation.tsx | 3 +- src/features/navigation/utils.ts | 2 +- src/features/pdf/PDFWrapper.test.tsx | 3 +- src/features/propagateTraceWhenPdf/index.ts | 2 +- src/features/receipt/ReceiptContainer.tsx | 6 +--- src/hooks/navigation.ts | 17 +---------- src/hooks/useIsPdf.ts | 3 +- src/hooks/useNavigatePage.ts | 8 ++---- src/layout/GenericComponent.tsx | 2 +- .../RepeatingGroupEditContext.tsx | 2 +- .../Providers/RepeatingGroupFocusContext.tsx | 2 +- src/layout/Subform/SubformWrapper.tsx | 3 +- src/layout/Tabs/Tabs.tsx | 2 +- 24 files changed, 67 insertions(+), 90 deletions(-) create mode 100644 src/core/routing/types.ts create mode 100644 src/core/routing/useIsReceiptPage.ts diff --git a/src/App.tsx b/src/App.tsx index dcae1b66c8..1ad4128235 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,7 +12,6 @@ import { InstanceSelectionWrapper } from 'src/features/instantiate/selection/Ins import { PDFWrapper } from 'src/features/pdf/PDFWrapper'; import { CustomReceipt, DefaultReceipt } from 'src/features/receipt/ReceiptContainer'; import { TaskKeys } from 'src/hooks/useNavigatePage'; -import { PresentationType, ProcessTaskType } from 'src/types'; export const App = () => ( @@ -34,7 +33,7 @@ export const App = () => ( +
} @@ -66,10 +65,7 @@ export const App = () => ( + @@ -121,7 +117,7 @@ export const App = () => ( index element={ - + diff --git a/src/components/form/Form.tsx b/src/components/form/Form.tsx index 8828321cd8..4bb11abd3e 100644 --- a/src/components/form/Form.tsx +++ b/src/components/form/Form.tsx @@ -7,6 +7,7 @@ import { MessageBanner } from 'src/components/form/MessageBanner'; import { ErrorReport, ErrorReportList } from 'src/components/message/ErrorReport'; import { ReadyForPrint } from 'src/components/ReadyForPrint'; import { NavigateToStartUrl } from 'src/components/wrappers/ProcessWrapper'; +import { SearchParams } from 'src/core/routing/types'; import { useAppName, useAppOwner } from 'src/core/texts/appTexts'; import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; import { useAllAttachments } from 'src/features/attachments/hooks'; @@ -18,7 +19,7 @@ import { useLaxInstanceId } from 'src/features/instance/InstanceContext'; import { useLanguage } from 'src/features/language/useLanguage'; import { useOnFormSubmitValidation } from 'src/features/validation/callbacks/onFormSubmitValidation'; import { useTaskErrors } from 'src/features/validation/selectors/taskErrors'; -import { SearchParams, useQueryKey } from 'src/hooks/navigation'; +import { useQueryKey } from 'src/hooks/navigation'; import { useAsRef } from 'src/hooks/useAsRef'; import { useCurrentView, useNavigatePage } from 'src/hooks/useNavigatePage'; import { getComponentCapabilities } from 'src/layout'; diff --git a/src/components/form/LinkToPotentialNode.tsx b/src/components/form/LinkToPotentialNode.tsx index 2de4740287..030bd861cb 100644 --- a/src/components/form/LinkToPotentialNode.tsx +++ b/src/components/form/LinkToPotentialNode.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Link } from 'react-router-dom'; import type { LinkProps } from 'react-router-dom'; -import { SearchParams } from 'src/hooks/navigation'; +import { SearchParams } from 'src/core/routing/types'; import { useIsHidden } from 'src/utils/layout/hidden'; import { useExternalItem } from 'src/utils/layout/hooks'; import { splitDashedKey } from 'src/utils/splitDashedKey'; diff --git a/src/components/presentation/Presentation.test.tsx b/src/components/presentation/Presentation.test.tsx index 3f8c23c753..69bf45a106 100644 --- a/src/components/presentation/Presentation.test.tsx +++ b/src/components/presentation/Presentation.test.tsx @@ -5,6 +5,7 @@ import { screen } from '@testing-library/react'; import { getPartyMock } from 'src/__mocks__/getPartyMock'; import { PresentationComponent } from 'src/components/presentation/Presentation'; +import { useIsReceiptPage } from 'src/core/routing/useIsReceiptPage'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import { AltinnPalette } from 'src/theme/altinnAppTheme'; import { ProcessTaskType } from 'src/types'; @@ -14,11 +15,18 @@ import type { AppQueries } from 'src/queries/types'; jest.mock('axios'); +jest.mock('src/core/routing/useIsReceiptPage', () => ({ + useIsReceiptPage: jest.fn(), +})); + +const mockUseIsReceiptPage = useIsReceiptPage as jest.MockedFunction; + describe('Presentation', () => { let realLocation: Location = window.location; beforeEach(() => { realLocation = window.location; + mockUseIsReceiptPage.mockReturnValue(false); }); afterEach(() => { @@ -31,7 +39,7 @@ describe('Presentation', () => { const mockedLocation = { ...realLocation, search: `?returnUrl=${returnUrl}` }; jest.spyOn(window, 'location', 'get').mockReturnValue(mockedLocation); - await render({ type: ProcessTaskType.Data }, { fetchReturnUrl: async () => returnUrl }); + await render({}, { fetchReturnUrl: async () => returnUrl }); const closeButton = screen.getByRole('link', { name: 'Tilbake', @@ -47,7 +55,7 @@ describe('Presentation', () => { jest.spyOn(window, 'location', 'get').mockReturnValue(mockedLocation); const messageBoxUrl = getMessageBoxUrl(getPartyMock().partyId); - await render({ type: ProcessTaskType.Data }); + await render(); const closeButton = screen.getByRole('link', { name: 'Tilbake til innboks', @@ -63,7 +71,7 @@ describe('Presentation', () => { jest.spyOn(window, 'location', 'get').mockReturnValue(mockedLocation); const messageBoxUrl = getMessageBoxUrl(partyId); - await render({ type: ProcessTaskType.Data }); + await render(); const closeButton = screen.getByRole('link', { name: 'Tilbake til innboks', @@ -74,7 +82,6 @@ describe('Presentation', () => { it('should render children', async () => { await render({ - type: ProcessTaskType.Data, children:
, }); @@ -82,7 +89,7 @@ describe('Presentation', () => { }); it('the background color should be greyLight if type is "ProcessTaskType.Data"', async () => { - await render({ type: ProcessTaskType.Data }); + await render(); const appHeader = screen.getByTestId('AppHeader'); @@ -90,7 +97,9 @@ describe('Presentation', () => { }); it('the background color should be lightGreen if type is "ProcessTaskType.Archived"', async () => { - await render({ type: ProcessTaskType.Archived }); + mockUseIsReceiptPage.mockReturnValue(true); + + await render(); const appHeader = screen.getByTestId('AppHeader'); diff --git a/src/components/presentation/Presentation.tsx b/src/components/presentation/Presentation.tsx index cda7500c85..debfae8091 100644 --- a/src/components/presentation/Presentation.tsx +++ b/src/components/presentation/Presentation.tsx @@ -12,6 +12,7 @@ import { NavBar } from 'src/components/presentation/NavBar'; import classes from 'src/components/presentation/Presentation.module.css'; import { Progress } from 'src/components/presentation/Progress'; import { createContext } from 'src/core/contexts/context'; +import { useIsReceiptPage } from 'src/core/routing/useIsReceiptPage'; import { RenderStart } from 'src/core/ui/RenderStart'; import { Footer } from 'src/features/footer/Footer'; import { useUiConfigContext } from 'src/features/form/layout/UiConfigContext'; @@ -21,33 +22,28 @@ import { Lang } from 'src/features/language/Lang'; import { SideBarNavigation } from 'src/features/navigation/SidebarNavigation'; import { useHasGroupedNavigation } from 'src/features/navigation/utils'; import { AltinnPalette } from 'src/theme/altinnAppTheme'; -import { ProcessTaskType } from 'src/types'; -import type { PresentationType } from 'src/types'; export interface IPresentationProvidedProps extends PropsWithChildren { header?: React.ReactNode; - type: ProcessTaskType | PresentationType; showNavbar?: boolean; showNavigation?: boolean; } export const PresentationComponent = ({ header, - type, children, showNavbar = true, - showNavigation = true, + showNavigation: _showNavigation = true, }: IPresentationProvidedProps) => { const instanceStatus = useInstanceDataQuery({ select: (instance) => instance.status, }).data; const { expandedWidth } = useUiConfigContext(); const hasGroupedNavigation = useHasGroupedNavigation(); - - const realHeader = header || (type === ProcessTaskType.Archived ? : undefined); - - const isProcessStepsArchived = Boolean(type === ProcessTaskType.Archived); - const backgroundColor = isProcessStepsArchived ? AltinnPalette.greenLight : AltinnPalette.greyLight; + const isReceipt = useIsReceiptPage(); + const realHeader = isReceipt ? : header; + const backgroundColor = isReceipt ? AltinnPalette.greenLight : AltinnPalette.greyLight; + const showNavigation = _showNavigation && !isReceipt; useLayoutEffect(() => { document.body.style.background = backgroundColor; @@ -74,7 +70,7 @@ export const PresentationComponent = ({ className={classes.page} style={!showNavbar ? { marginTop: 54 } : undefined} > - {isProcessStepsArchived && instanceStatus?.substatus && ( + {isReceipt && instanceStatus?.substatus && ( } description={} @@ -85,9 +81,7 @@ export const PresentationComponent = ({ className={classes.modal} tabIndex={-1} > -
- -
+
{!isReceipt && }
{children}
@@ -98,11 +92,9 @@ export const PresentationComponent = ({ ); }; -function ProgressBar({ type }: { type: ProcessTaskType | PresentationType }) { +function ProgressBar() { const { showProgress } = usePageSettings(); - const enabled = type !== ProcessTaskType.Archived && showProgress; - - if (!enabled) { + if (!showProgress) { return null; } diff --git a/src/components/wrappers/ProcessWrapper.tsx b/src/components/wrappers/ProcessWrapper.tsx index ddedf88866..8af6629da0 100644 --- a/src/components/wrappers/ProcessWrapper.tsx +++ b/src/components/wrappers/ProcessWrapper.tsx @@ -118,10 +118,7 @@ export function ProcessWrapper({ children }: PropsWithChildren) { if (!isValidTaskId) { return ( - + ); @@ -129,10 +126,7 @@ export function ProcessWrapper({ children }: PropsWithChildren) { if (!isCurrentTask) { return ( - + ); @@ -140,7 +134,7 @@ export function ProcessWrapper({ children }: PropsWithChildren) { if (taskType === ProcessTaskType.Confirm) { return ( - + ); @@ -148,7 +142,7 @@ export function ProcessWrapper({ children }: PropsWithChildren) { if (taskType === ProcessTaskType.Feedback) { return ( - + ); diff --git a/src/core/loading/Loader.tsx b/src/core/loading/Loader.tsx index c099f1cce8..41d0709eea 100644 --- a/src/core/loading/Loader.tsx +++ b/src/core/loading/Loader.tsx @@ -5,7 +5,6 @@ import { AltinnContentLoader } from 'src/components/molecules/AltinnContentLoade import { PresentationComponent, useHasPresentation } from 'src/components/presentation/Presentation'; import { LoadingProvider } from 'src/core/loading/LoadingContext'; import { Lang } from 'src/features/language/Lang'; -import { ProcessTaskType } from 'src/types'; interface LoaderProps { reason: string; // The reason is used by developers to identify the reason for the loader @@ -20,7 +19,6 @@ export const Loader = (props: LoaderProps) => { } - type={ProcessTaskType.Unknown} showNavbar={false} showNavigation={false} > diff --git a/src/core/routing/types.ts b/src/core/routing/types.ts new file mode 100644 index 0000000000..b0e0b8a866 --- /dev/null +++ b/src/core/routing/types.ts @@ -0,0 +1,7 @@ +export enum SearchParams { + FocusComponentId = 'focusComponentId', + FocusErrorBinding = 'focusErrorBinding', + ExitSubform = 'exitSubform', + Validate = 'validate', + Pdf = 'pdf', +} diff --git a/src/core/routing/useIsReceiptPage.ts b/src/core/routing/useIsReceiptPage.ts new file mode 100644 index 0000000000..f39814f709 --- /dev/null +++ b/src/core/routing/useIsReceiptPage.ts @@ -0,0 +1,7 @@ +import { useNavigationParam } from 'src/hooks/navigation'; +import { TaskKeys } from 'src/hooks/useNavigatePage'; + +export const useIsReceiptPage = () => { + const taskId = useNavigationParam('taskId'); + return taskId === TaskKeys.CustomReceipt || taskId === TaskKeys.ProcessEnd; +}; diff --git a/src/features/expressions/expression-functions.ts b/src/features/expressions/expression-functions.ts index 11280ef25e..9b466f5427 100644 --- a/src/features/expressions/expression-functions.ts +++ b/src/features/expressions/expression-functions.ts @@ -1,13 +1,13 @@ import dot from 'dot-object'; import escapeStringRegexp from 'escape-string-regexp'; +import { SearchParams } from 'src/core/routing/types'; import { exprCastValue } from 'src/features/expressions'; import { ExprRuntimeError, NodeRelationNotFound } from 'src/features/expressions/errors'; import { ExprVal } from 'src/features/expressions/types'; import { addError } from 'src/features/expressions/validation'; import { makeIndexedId } from 'src/features/form/layout/utils/makeIndexedId'; import { CodeListPending } from 'src/features/options/CodeListsProvider'; -import { SearchParams } from 'src/hooks/navigation'; import { buildAuthContext } from 'src/utils/authContext'; import { transposeDataBinding } from 'src/utils/databindings/DataBinding'; import { formatDateLocale } from 'src/utils/dateUtils'; diff --git a/src/features/instantiate/selection/InstanceSelection.tsx b/src/features/instantiate/selection/InstanceSelection.tsx index 572d52e90e..9df86b2114 100644 --- a/src/features/instantiate/selection/InstanceSelection.tsx +++ b/src/features/instantiate/selection/InstanceSelection.tsx @@ -25,7 +25,6 @@ import { useSetNavigationEffect } from 'src/features/navigation/NavigationEffect import { useSelectedParty } from 'src/features/party/PartiesProvider'; import { useIsMobileOrTablet } from 'src/hooks/useDeviceWidths'; import { focusMainContent } from 'src/hooks/useNavigatePage'; -import { ProcessTaskType } from 'src/types'; import { getPageTitle } from 'src/utils/getPageTitle'; import { getInstanceUiUrl } from 'src/utils/urls/appUrlHelper'; import type { ISimpleInstance } from 'src/types'; @@ -46,10 +45,7 @@ function getDateDisplayString(timeStamp: string) { export const InstanceSelectionWrapper = () => ( - + diff --git a/src/features/navigation/AppNavigation.tsx b/src/features/navigation/AppNavigation.tsx index 5316ba46bd..035b2f8a2a 100644 --- a/src/features/navigation/AppNavigation.tsx +++ b/src/features/navigation/AppNavigation.tsx @@ -4,6 +4,7 @@ import { Heading } from '@digdir/designsystemet-react'; import { XMarkIcon } from '@navikt/aksel-icons'; import { Button } from 'src/app-components/Button/Button'; +import { useIsReceiptPage } from 'src/core/routing/useIsReceiptPage'; import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; import { usePageGroups, usePageSettings } from 'src/features/form/layoutSettings/LayoutSettingsContext'; import { useProcessTaskId } from 'src/features/instance/useProcessTaskId'; @@ -12,7 +13,7 @@ import { useLanguage } from 'src/features/language/useLanguage'; import classes from 'src/features/navigation/AppNavigation.module.css'; import { PageGroup } from 'src/features/navigation/components/PageGroup'; import { TaskGroup } from 'src/features/navigation/components/TaskGroup'; -import { useIsReceiptPage, useIsSubformPage } from 'src/hooks/navigation'; +import { useIsSubformPage } from 'src/hooks/navigation'; import type { NavigationReceipt, NavigationTask } from 'src/layout/common.generated'; export function AppNavigation({ onNavigate }: { onNavigate?: () => void }) { diff --git a/src/features/navigation/utils.ts b/src/features/navigation/utils.ts index 36da027560..1c25a88c50 100644 --- a/src/features/navigation/utils.ts +++ b/src/features/navigation/utils.ts @@ -10,10 +10,10 @@ import { } from '@navikt/aksel-icons'; import { ContextNotProvided } from 'src/core/contexts/context'; +import { useIsReceiptPage } from 'src/core/routing/useIsReceiptPage'; import { usePageGroups, usePageSettings } from 'src/features/form/layoutSettings/LayoutSettingsContext'; import { useGetAltinnTaskType } from 'src/features/instance/useProcessQuery'; import { ValidationMask } from 'src/features/validation'; -import { useIsReceiptPage } from 'src/hooks/navigation'; import { useVisitedPages } from 'src/hooks/useNavigatePage'; import { useHiddenPages } from 'src/utils/layout/hidden'; import { NodesInternal } from 'src/utils/layout/NodesContext'; diff --git a/src/features/pdf/PDFWrapper.test.tsx b/src/features/pdf/PDFWrapper.test.tsx index 24ddf7d7d5..d45a55683d 100644 --- a/src/features/pdf/PDFWrapper.test.tsx +++ b/src/features/pdf/PDFWrapper.test.tsx @@ -14,7 +14,6 @@ import { InstanceProvider } from 'src/features/instance/InstanceContext'; import { PDFWrapper } from 'src/features/pdf/PDFWrapper'; import { fetchApplicationMetadata, fetchInstanceData, fetchProcessState } from 'src/queries/queries'; import { InstanceRouter, renderWithoutInstanceAndLayout } from 'src/test/renderWithProviders'; -import { ProcessTaskType } from 'src/types'; import type { AppQueries } from 'src/queries/types'; const exampleGuid = '75154373-aed4-41f7-95b4-e5b5115c2edc'; @@ -54,7 +53,7 @@ const render = async (renderAs: RenderAs, queriesOverride?: Partial) - + diff --git a/src/features/propagateTraceWhenPdf/index.ts b/src/features/propagateTraceWhenPdf/index.ts index a03d90b04a..a2e9fb7fa2 100644 --- a/src/features/propagateTraceWhenPdf/index.ts +++ b/src/features/propagateTraceWhenPdf/index.ts @@ -1,6 +1,6 @@ import axios from 'axios'; -import { SearchParams } from 'src/hooks/navigation'; +import { SearchParams } from 'src/core/routing/types'; import { appPath } from 'src/utils/urls/appUrlHelper'; function getCookies(): { [key: string]: string } { diff --git a/src/features/receipt/ReceiptContainer.tsx b/src/features/receipt/ReceiptContainer.tsx index f5813fd882..6dc588a390 100644 --- a/src/features/receipt/ReceiptContainer.tsx +++ b/src/features/receipt/ReceiptContainer.tsx @@ -22,7 +22,6 @@ import { useInstanceOwnerParty } from 'src/features/party/PartiesProvider'; import { getInstanceSender } from 'src/features/processEnd/confirm/helpers/returnConfirmSummaryObject'; import { useNavigationParam } from 'src/hooks/navigation'; import { TaskKeys } from 'src/hooks/useNavigatePage'; -import { ProcessTaskType } from 'src/types'; import { filterOutDataModelRefDataAsPdfAndAppOwnedDataTypes, getAttachmentsWithDataType, @@ -81,10 +80,7 @@ export const getSummaryDataObject = ({ export function DefaultReceipt() { return ( - + ); diff --git a/src/hooks/navigation.ts b/src/hooks/navigation.ts index 9cb01a0a27..dcd71f30be 100644 --- a/src/hooks/navigation.ts +++ b/src/hooks/navigation.ts @@ -1,6 +1,7 @@ import { matchPath, useLocation } from 'react-router-dom'; import { useAsRef } from 'src/hooks/useAsRef'; +import type { SearchParams } from 'src/core/routing/types'; interface PathParams { instanceOwnerPartyId?: string; @@ -12,15 +13,6 @@ interface PathParams { mainPageKey?: string; } -export enum SearchParams { - FocusComponentId = 'focusComponentId', - FocusErrorBinding = 'focusErrorBinding', - ExitSubform = 'exitSubform', - Validate = 'validate', - Pdf = 'pdf', - BackToPage = 'backToPage', -} - const matchers: string[] = [ '/instance/:instanceOwnerPartyId/:instanceGuid', '/instance/:instanceOwnerPartyId/:instanceGuid/:taskId', @@ -74,10 +66,3 @@ export const useIsSubformPage = () => { const subformPageKey = paramFrom(matches, 'pageKey'); return !!(mainPageKey && subformPageKey); }; - -export const useIsReceiptPage = () => { - const location = useLocation(); - const matches = matchers.map((matcher) => matchPath(matcher, location.pathname)); - const taskId = paramFrom(matches, 'taskId'); - return taskId === 'ProcessEnd' || taskId === 'CustomReceipt'; -}; diff --git a/src/hooks/useIsPdf.ts b/src/hooks/useIsPdf.ts index ee65ac2ff5..b142513280 100644 --- a/src/hooks/useIsPdf.ts +++ b/src/hooks/useIsPdf.ts @@ -1,4 +1,5 @@ -import { SearchParams, useQueryKey } from 'src/hooks/navigation'; +import { SearchParams } from 'src/core/routing/types'; +import { useQueryKey } from 'src/hooks/navigation'; /** * Hook checking whether we are in PDF generation mode diff --git a/src/hooks/useNavigatePage.ts b/src/hooks/useNavigatePage.ts index 37da2ec7cc..0f33244145 100644 --- a/src/hooks/useNavigatePage.ts +++ b/src/hooks/useNavigatePage.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import type { NavigateOptions } from 'react-router-dom'; +import { SearchParams } from 'src/core/routing/types'; import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; import { useLayoutLookups } from 'src/features/form/layout/LayoutsContext'; import { useSetReturnToView, useSetSummaryNodeOfOrigin } from 'src/features/form/layout/PageNavigationContext'; @@ -11,12 +12,7 @@ import { FD } from 'src/features/formData/FormDataWrite'; import { useGetTaskTypeById, useProcessQuery } from 'src/features/instance/useProcessQuery'; import { useSetNavigationEffect } from 'src/features/navigation/NavigationEffectContext'; import { useRefetchInitialValidations } from 'src/features/validation/backendValidation/backendValidationQuery'; -import { - SearchParams, - useAllNavigationParams, - useAllNavigationParamsAsRef, - useNavigationParam, -} from 'src/hooks/navigation'; +import { useAllNavigationParams, useAllNavigationParamsAsRef, useNavigationParam } from 'src/hooks/navigation'; import { useAsRef } from 'src/hooks/useAsRef'; import { useLocalStorageState } from 'src/hooks/useLocalStorageState'; import { ProcessTaskType } from 'src/types'; diff --git a/src/layout/GenericComponent.tsx b/src/layout/GenericComponent.tsx index a78584fdf9..7387aa5d46 100644 --- a/src/layout/GenericComponent.tsx +++ b/src/layout/GenericComponent.tsx @@ -5,10 +5,10 @@ import type { SetURLSearchParams } from 'react-router-dom'; import classNames from 'classnames'; import { Flex } from 'src/app-components/Flex/Flex'; +import { SearchParams } from 'src/core/routing/types'; import { useDevToolsStore } from 'src/features/devtools/data/DevToolsStore'; import { ExprVal } from 'src/features/expressions/types'; import { Lang } from 'src/features/language/Lang'; -import { SearchParams } from 'src/hooks/navigation'; import { FormComponentContextProvider } from 'src/layout/FormComponentContext'; import classes from 'src/layout/GenericComponent.module.css'; import { getComponentDef } from 'src/layout/index'; diff --git a/src/layout/RepeatingGroup/EditContainer/RepeatingGroupEditContext.tsx b/src/layout/RepeatingGroup/EditContainer/RepeatingGroupEditContext.tsx index 3468681b6d..9ea7177f83 100644 --- a/src/layout/RepeatingGroup/EditContainer/RepeatingGroupEditContext.tsx +++ b/src/layout/RepeatingGroup/EditContainer/RepeatingGroupEditContext.tsx @@ -3,8 +3,8 @@ import { useSearchParams } from 'react-router-dom'; import type { PropsWithChildren } from 'react'; import { createContext } from 'src/core/contexts/context'; +import { SearchParams } from 'src/core/routing/types'; import { useLayoutLookups } from 'src/features/form/layout/LayoutsContext'; -import { SearchParams } from 'src/hooks/navigation'; import { useMemoDeepEqual } from 'src/hooks/useStateDeepEqual'; import { useRepeatingGroupComponentId } from 'src/layout/RepeatingGroup/Providers/RepeatingGroupContext'; import { RepGroupHooks } from 'src/layout/RepeatingGroup/utils'; diff --git a/src/layout/RepeatingGroup/Providers/RepeatingGroupFocusContext.tsx b/src/layout/RepeatingGroup/Providers/RepeatingGroupFocusContext.tsx index 52eb843bd1..f7283027f9 100644 --- a/src/layout/RepeatingGroup/Providers/RepeatingGroupFocusContext.tsx +++ b/src/layout/RepeatingGroup/Providers/RepeatingGroupFocusContext.tsx @@ -3,10 +3,10 @@ import { useSearchParams } from 'react-router-dom'; import type { PropsWithChildren } from 'react'; import { createContext } from 'src/core/contexts/context'; +import { SearchParams } from 'src/core/routing/types'; import { useLayoutLookups } from 'src/features/form/layout/LayoutsContext'; import { isRepeatingComponentType } from 'src/features/form/layout/utils/repeating'; import { FD } from 'src/features/formData/FormDataWrite'; -import { SearchParams } from 'src/hooks/navigation'; import { RepGroupContext, useRepeatingGroupComponentId, diff --git a/src/layout/Subform/SubformWrapper.tsx b/src/layout/Subform/SubformWrapper.tsx index b338c50298..7d40847a22 100644 --- a/src/layout/Subform/SubformWrapper.tsx +++ b/src/layout/Subform/SubformWrapper.tsx @@ -10,7 +10,6 @@ import { useDataTypeFromLayoutSet } from 'src/features/form/layout/LayoutsContex import { PDFWrapper } from 'src/features/pdf/PDFWrapper'; import { useNavigationParam } from 'src/hooks/navigation'; import { useNavigatePage } from 'src/hooks/useNavigatePage'; -import { ProcessTaskType } from 'src/types'; import { useItemWhenType } from 'src/utils/layout/useNodeItem'; export function SubformWrapper({ baseComponentId, children }: PropsWithChildren<{ baseComponentId: string }>) { @@ -26,7 +25,7 @@ export function SubformWrapper({ baseComponentId, children }: PropsWithChildren< export function SubformForm() { return ( - + diff --git a/src/layout/Tabs/Tabs.tsx b/src/layout/Tabs/Tabs.tsx index 612a27bc15..eef88da092 100644 --- a/src/layout/Tabs/Tabs.tsx +++ b/src/layout/Tabs/Tabs.tsx @@ -4,10 +4,10 @@ import { useSearchParams } from 'react-router-dom'; import { Tabs as DesignsystemetTabs } from '@digdir/designsystemet-react'; import { Flex } from 'src/app-components/Flex/Flex'; +import { SearchParams } from 'src/core/routing/types'; import { useLayoutLookups } from 'src/features/form/layout/LayoutsContext'; import { Lang } from 'src/features/language/Lang'; import { useLanguage } from 'src/features/language/useLanguage'; -import { SearchParams } from 'src/hooks/navigation'; import { ComponentStructureWrapper } from 'src/layout/ComponentStructureWrapper'; import { GenericComponent } from 'src/layout/GenericComponent'; import classes from 'src/layout/Tabs/Tabs.module.css'; From 4be016f15a80ef939fa29fa13d9bfc773706a18b Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Wed, 27 Aug 2025 14:17:35 +0200 Subject: [PATCH 02/21] Removing route for TaskKeys.CustomReceipt, as this is practically the same as for every other form page, and can be rendered out more easily in ProcessWrapper.tsx --- src/App.tsx | 38 +--------------------- src/components/wrappers/ProcessWrapper.tsx | 12 +++++++ 2 files changed, 13 insertions(+), 37 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1ad4128235..175a646453 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,7 @@ import { InstanceProvider } from 'src/features/instance/InstanceContext'; import { PartySelection } from 'src/features/instantiate/containers/PartySelection'; import { InstanceSelectionWrapper } from 'src/features/instantiate/selection/InstanceSelection'; import { PDFWrapper } from 'src/features/pdf/PDFWrapper'; -import { CustomReceipt, DefaultReceipt } from 'src/features/receipt/ReceiptContainer'; +import { DefaultReceipt } from 'src/features/receipt/ReceiptContainer'; import { TaskKeys } from 'src/hooks/useNavigatePage'; export const App = () => ( @@ -62,42 +62,6 @@ export const App = () => ( element={} /> - - - - - - } - > - } - /> - - - - - } - /> - - } - /> - } - /> - - - - + + + + + ); + } + if (taskType === ProcessTaskType.Confirm) { return ( From 1662ebf230be2609cef0ad3c9856570f68f169e0 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Wed, 27 Aug 2025 14:18:50 +0200 Subject: [PATCH 03/21] Replacing task redirects with a listener in Feedback.tsx and a way to check and fix wrong navigation in FixWrongReceiptType.tsx. --- src/components/wrappers/ProcessWrapper.tsx | 4 -- src/features/instance/useProcessQuery.ts | 34 +--------------- src/features/processEnd/feedback/Feedback.tsx | 26 ++++++++----- src/features/receipt/FixWrongReceiptType.tsx | 39 +++++++++++++++++++ src/features/receipt/ReceiptContainer.tsx | 17 +++++--- 5 files changed, 68 insertions(+), 52 deletions(-) create mode 100644 src/features/receipt/FixWrongReceiptType.tsx diff --git a/src/components/wrappers/ProcessWrapper.tsx b/src/components/wrappers/ProcessWrapper.tsx index 1e780ccf86..50882d2628 100644 --- a/src/components/wrappers/ProcessWrapper.tsx +++ b/src/components/wrappers/ProcessWrapper.tsx @@ -114,10 +114,6 @@ export function ProcessWrapper({ children }: PropsWithChildren) { return ; } - if (process?.ended) { - return ; - } - if (!isValidTaskId) { return ( diff --git a/src/features/instance/useProcessQuery.ts b/src/features/instance/useProcessQuery.ts index 50eb3cd411..b31a47c792 100644 --- a/src/features/instance/useProcessQuery.ts +++ b/src/features/instance/useProcessQuery.ts @@ -1,12 +1,9 @@ -import { useEffect } from 'react'; - import { queryOptions, skipToken, useQuery, useQueryClient } from '@tanstack/react-query'; import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; import { useLayoutSets } from 'src/features/form/layoutSets/LayoutSetsProvider'; import { useLaxInstanceId } from 'src/features/instance/InstanceContext'; -import { useNavigationParam } from 'src/hooks/navigation'; -import { TaskKeys, useNavigateToTask } from 'src/hooks/useNavigatePage'; +import { TaskKeys } from 'src/hooks/useNavigatePage'; import { fetchProcessState } from 'src/queries/queries'; import { isProcessTaskType, ProcessTaskType } from 'src/types'; import { behavesLikeDataTask } from 'src/utils/formLayout'; @@ -25,34 +22,7 @@ export const processQueries = { export function useProcessQuery() { const instanceId = useLaxInstanceId(); - const taskId = useNavigationParam('taskId'); - const layoutSets = useLayoutSets(); - const navigateToTask = useNavigateToTask(); - - const query = useQuery(processQueries.processState(instanceId)); - - const { data, error } = query; - const ended = !!data?.ended; - - // TODO: move this to a layout file on task id change instead - useEffect(() => { - if (ended) { - // Catch cases where there is a custom receipt, but we've navigated - // to the wrong one (i.e. mocking in all-process-steps.ts) - const hasCustomReceipt = behavesLikeDataTask(TaskKeys.CustomReceipt, layoutSets); - if (taskId === TaskKeys.ProcessEnd && hasCustomReceipt) { - navigateToTask(TaskKeys.CustomReceipt); - } else if (taskId === TaskKeys.CustomReceipt && !hasCustomReceipt) { - navigateToTask(TaskKeys.ProcessEnd); - } - } - }, [ended, layoutSets, navigateToTask, taskId]); - - useEffect(() => { - error && window.logError('Fetching process state failed:\n', error); - }, [error]); - - return query; + return useQuery(processQueries.processState(instanceId)); } export const useIsAuthorized = () => { diff --git a/src/features/processEnd/feedback/Feedback.tsx b/src/features/processEnd/feedback/Feedback.tsx index 998ccb26ae..e50e54c34c 100644 --- a/src/features/processEnd/feedback/Feedback.tsx +++ b/src/features/processEnd/feedback/Feedback.tsx @@ -5,19 +5,29 @@ import { useAppName, useAppOwner } from 'src/core/texts/appTexts'; import { useProcessQuery } from 'src/features/instance/useProcessQuery'; import { LangAsParagraph } from 'src/features/language/Lang'; import { useLanguage } from 'src/features/language/useLanguage'; +import { TaskKeys, useNavigateToTask } from 'src/hooks/useNavigatePage'; import { getPageTitle } from 'src/utils/getPageTitle'; export function Feedback() { - const { refetch: reFetchProcessData } = useProcessQuery(); - const currentTask = useProcessQuery().data?.currentTask?.elementId; + const { refetch: reFetchProcessData, data: previousData } = useProcessQuery(); + const navigateToTask = useNavigateToTask(); const appName = useAppName(); const appOwner = useAppOwner(); const { langAsString } = useLanguage(); // Continually re-fetch process data while the user is on the feedback page useBackoff({ - enabled: !!currentTask, - callback: async () => void (await reFetchProcessData()), + callback: async () => { + const result = await reFetchProcessData(); + if ( + result.data?.currentTask?.elementId && + result?.data.currentTask.elementId !== previousData?.currentTask?.elementId + ) { + navigateToTask(result.data.currentTask.elementId); + } else if (result.data?.ended) { + navigateToTask(TaskKeys.ProcessEnd); + } + }, }); return ( @@ -30,7 +40,7 @@ export function Feedback() { ); } -function useBackoff({ enabled, callback }: { enabled: boolean; callback: () => Promise }) { +function useBackoff({ callback }: { callback: () => Promise }) { // The backoff algorithm is used to check the process data, and slow down the requests after a while. // At first, it starts off once a second (every 1000ms) for 10 seconds. // After that, it slows down by one more second for every request. @@ -38,10 +48,6 @@ function useBackoff({ enabled, callback }: { enabled: boolean; callback: () => P const attempts = useRef(0); useEffect(() => { - if (!enabled) { - return () => {}; - } - let shouldContinue = true; function continueCalling() { const backoff = attempts.current < 10 ? 1000 : Math.min(30000, 1000 + (attempts.current - 10) * 1000); @@ -60,5 +66,5 @@ function useBackoff({ enabled, callback }: { enabled: boolean; callback: () => P return () => { shouldContinue = false; }; - }, [callback, enabled]); + }, [callback]); } diff --git a/src/features/receipt/FixWrongReceiptType.tsx b/src/features/receipt/FixWrongReceiptType.tsx new file mode 100644 index 0000000000..945b006497 --- /dev/null +++ b/src/features/receipt/FixWrongReceiptType.tsx @@ -0,0 +1,39 @@ +import React, { useEffect } from 'react'; +import type { PropsWithChildren } from 'react'; + +import { Loader } from 'src/core/loading/Loader'; +import { useLayoutSets } from 'src/features/form/layoutSets/LayoutSetsProvider'; +import { useNavigationParam } from 'src/hooks/navigation'; +import { TaskKeys, useNavigateToTask } from 'src/hooks/useNavigatePage'; +import { behavesLikeDataTask } from 'src/utils/formLayout'; + +/** + * Wrap this around any components rendered in a receipt route. This will check if you were actually supposed to + * go to a different route (either the built-in receipt or the custom receipt), and will redirect you there if + * you're in the wrong place + */ +export function FixWrongReceiptType({ children }: PropsWithChildren) { + const taskId = useNavigationParam('taskId'); + const layoutSets = useLayoutSets(); + const hasCustomReceipt = behavesLikeDataTask(TaskKeys.CustomReceipt, layoutSets); + const navigateToTask = useNavigateToTask(); + + let redirectTo: undefined | TaskKeys = undefined; + if (taskId === TaskKeys.ProcessEnd && hasCustomReceipt) { + redirectTo = TaskKeys.CustomReceipt; + } else if (taskId === TaskKeys.CustomReceipt && !hasCustomReceipt) { + redirectTo = TaskKeys.ProcessEnd; + } + + useEffect(() => { + if (redirectTo) { + navigateToTask(redirectTo, { replace: true }); + } + }, [navigateToTask, redirectTo]); + + if (redirectTo) { + return ; + } + + return children; +} diff --git a/src/features/receipt/ReceiptContainer.tsx b/src/features/receipt/ReceiptContainer.tsx index 6dc588a390..82c0a2e146 100644 --- a/src/features/receipt/ReceiptContainer.tsx +++ b/src/features/receipt/ReceiptContainer.tsx @@ -20,6 +20,7 @@ import { Lang } from 'src/features/language/Lang'; import { useLanguage } from 'src/features/language/useLanguage'; import { useInstanceOwnerParty } from 'src/features/party/PartiesProvider'; import { getInstanceSender } from 'src/features/processEnd/confirm/helpers/returnConfirmSummaryObject'; +import { FixWrongReceiptType } from 'src/features/receipt/FixWrongReceiptType'; import { useNavigationParam } from 'src/hooks/navigation'; import { TaskKeys } from 'src/hooks/useNavigatePage'; import { @@ -80,9 +81,11 @@ export const getSummaryDataObject = ({ export function DefaultReceipt() { return ( - - - + + + + + ); } @@ -100,9 +103,11 @@ export function CustomReceipt() { } return ( - - - + + + + + ); } From 8cd0e7dabbb1a462db71693e3b050f5f862a1b52 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Wed, 27 Aug 2025 15:01:14 +0200 Subject: [PATCH 04/21] Adding something extra to the test to make sure the 'this task is not the current one' page shows up correctly --- test/e2e/integration/stateless-app/receipt.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/e2e/integration/stateless-app/receipt.ts b/test/e2e/integration/stateless-app/receipt.ts index abd74af08d..12a18bc4cc 100644 --- a/test/e2e/integration/stateless-app/receipt.ts +++ b/test/e2e/integration/stateless-app/receipt.ts @@ -15,6 +15,12 @@ describe('Receipt', () => { cy.get('#firmanavn').type('Foo bar AS'); cy.get('#orgnr').type('12345678901'); + + // Making sure a manual navigation to the previous task still works and gives you a message + cy.url().then((url) => cy.visit(url.replace(/\/Task_2\/1$/, '/Task_1'))); + cy.findByRole('button', { name: 'Gå til riktig prosessteg' }).click(); + cy.get('#firmanavn').should('have.value', 'Foo bar AS'); + cy.get(appFrontend.sendinButton).click(); cy.wait('@nextProcess').its('response.statusCode').should('eq', 200); @@ -45,5 +51,14 @@ describe('Receipt', () => { // TODO: Should this even work? How can we load a deleted instance and confirm that it is, indeed, deleted // by observing this text in the receipt page? cy.get(appFrontend.receipt.container).should('contain.text', texts.securityReasons); + + // Making sure a manual navigation to the previous task still works and gives you a message + cy.url().then((url) => cy.visit(url.replace(/\/ProcessEnd$/, '/Task_4'))); + cy.get('body').should( + 'contain.text', + 'Denne delen av skjemaet er ikke tilgjengelig. Du kan ikke gjøre endringer her nå.', + ); + cy.findByRole('button', { name: 'Gå til riktig prosessteg' }).should('not.exist'); + cy.get(appFrontend.receipt.container).should('not.exist'); }); }); From 752fd3ca022a1334c4e98b084a08e76ff56d9cce Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 28 Aug 2025 09:40:19 +0200 Subject: [PATCH 05/21] Extracting this method of checking if react-router-dom is navigating out to a separate hook for re-use --- src/core/routing/useIsNavigating.ts | 7 +++++++ src/layout/GenericComponent.tsx | 10 ++++------ 2 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 src/core/routing/useIsNavigating.ts diff --git a/src/core/routing/useIsNavigating.ts b/src/core/routing/useIsNavigating.ts new file mode 100644 index 0000000000..800630f288 --- /dev/null +++ b/src/core/routing/useIsNavigating.ts @@ -0,0 +1,7 @@ +import { useLocation, useNavigation } from 'react-router-dom'; + +export function useIsNavigating() { + const isIdle = useNavigation().state !== 'idle'; + const location = useLocation(); + return !window.location.hash.endsWith(location.search) || !isIdle; +} diff --git a/src/layout/GenericComponent.tsx b/src/layout/GenericComponent.tsx index 7387aa5d46..de0806d5de 100644 --- a/src/layout/GenericComponent.tsx +++ b/src/layout/GenericComponent.tsx @@ -1,11 +1,12 @@ import React, { useEffect, useMemo, useRef } from 'react'; -import { useLocation, useNavigation, useSearchParams } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; import type { SetURLSearchParams } from 'react-router-dom'; import classNames from 'classnames'; import { Flex } from 'src/app-components/Flex/Flex'; import { SearchParams } from 'src/core/routing/types'; +import { useIsNavigating } from 'src/core/routing/useIsNavigating'; import { useDevToolsStore } from 'src/features/devtools/data/DevToolsStore'; import { ExprVal } from 'src/features/expressions/types'; import { Lang } from 'src/features/language/Lang'; @@ -216,14 +217,11 @@ function useHandleFocusComponent(nodeId: string, containerDivRef: React.RefObjec const [searchParams, setSearchParams] = useSearchParams(); const indexedId = searchParams.get(SearchParams.FocusComponentId); const errorBinding = searchParams.get(SearchParams.FocusErrorBinding); - const isNavigating = useNavigation().state !== 'idle'; - const location = useLocation(); const abortController = useRef(new AbortController()); - const hashWas = window.location.hash; - const locationIsUpdated = hashWas.endsWith(location.search); - const shouldFocus = indexedId && indexedId == nodeId && !isNavigating && locationIsUpdated; + const isNavigating = useIsNavigating(); + const shouldFocus = indexedId && indexedId == nodeId && !isNavigating; useEffect(() => { const div = containerDivRef.current; From 30efec06d2974d583ab417406bdcb66059b9fb84 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 28 Aug 2025 09:41:02 +0200 Subject: [PATCH 06/21] Using the same methods for updating process data and instance data in feedback --- src/features/instance/useProcessNext.tsx | 3 +-- src/features/processEnd/feedback/Feedback.tsx | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/features/instance/useProcessNext.tsx b/src/features/instance/useProcessNext.tsx index 29377b0c2f..57736260a2 100644 --- a/src/features/instance/useProcessNext.tsx +++ b/src/features/instance/useProcessNext.tsx @@ -93,8 +93,7 @@ export function useProcessNext({ action }: ProcessNextProps = {}) { onSuccess: async ([processData, validationIssues]) => { if (processData) { optimisticallyUpdateProcess(processData); - refetchProcessData(); - reFetchInstanceData(); + await Promise.all([refetchProcessData(), reFetchInstanceData()]); await invalidateFormDataQueries(queryClient); const task = getTargetTaskFromProcess(processData); diff --git a/src/features/processEnd/feedback/Feedback.tsx b/src/features/processEnd/feedback/Feedback.tsx index e50e54c34c..4a546bfe1b 100644 --- a/src/features/processEnd/feedback/Feedback.tsx +++ b/src/features/processEnd/feedback/Feedback.tsx @@ -2,7 +2,8 @@ import React, { useEffect, useRef } from 'react'; import { ReadyForPrint } from 'src/components/ReadyForPrint'; import { useAppName, useAppOwner } from 'src/core/texts/appTexts'; -import { useProcessQuery } from 'src/features/instance/useProcessQuery'; +import { useInstanceDataQuery } from 'src/features/instance/InstanceContext'; +import { useOptimisticallyUpdateProcess, useProcessQuery } from 'src/features/instance/useProcessQuery'; import { LangAsParagraph } from 'src/features/language/Lang'; import { useLanguage } from 'src/features/language/useLanguage'; import { TaskKeys, useNavigateToTask } from 'src/hooks/useNavigatePage'; @@ -14,18 +15,27 @@ export function Feedback() { const appName = useAppName(); const appOwner = useAppOwner(); const { langAsString } = useLanguage(); + const optimisticallyUpdateProcess = useOptimisticallyUpdateProcess(); + const reFetchInstanceData = useInstanceDataQuery().refetch; // Continually re-fetch process data while the user is on the feedback page useBackoff({ callback: async () => { const result = await reFetchProcessData(); + let navigateTo: undefined | string; if ( result.data?.currentTask?.elementId && result?.data.currentTask.elementId !== previousData?.currentTask?.elementId ) { - navigateToTask(result.data.currentTask.elementId); + navigateTo = result.data.currentTask.elementId; } else if (result.data?.ended) { - navigateToTask(TaskKeys.ProcessEnd); + navigateTo = TaskKeys.ProcessEnd; + } + + if (navigateTo && result.data) { + optimisticallyUpdateProcess(result.data); + await reFetchInstanceData(); + navigateToTask(navigateTo); } }, }); From ada4177646d92c3da4ea7f1f610527ad42825e38 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 28 Aug 2025 10:21:19 +0200 Subject: [PATCH 07/21] When going backwards in the process, I found a case where the instance data is not yet fetched again when we arrive at the DataModelsProvider. This causes it to use outdated instance data, marking the data model as read-only when it has been unlocked. This should fix the problem, and hopefully also solve the earlier bug with subforms. --- src/features/datamodel/DataModelsProvider.tsx | 40 ++++++------------- src/features/datamodel/utils.ts | 11 +++-- 2 files changed, 20 insertions(+), 31 deletions(-) diff --git a/src/features/datamodel/DataModelsProvider.tsx b/src/features/datamodel/DataModelsProvider.tsx index 09e134e6cf..3cefab94d7 100644 --- a/src/features/datamodel/DataModelsProvider.tsx +++ b/src/features/datamodel/DataModelsProvider.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import type { PropsWithChildren } from 'react'; -import { useMutationState, useQueryClient } from '@tanstack/react-query'; +import { useMutationState } from '@tanstack/react-query'; import deepEqual from 'fast-deep-equal'; import { createStore } from 'zustand'; import type { JSONSchema7 } from 'json-schema'; @@ -27,11 +27,7 @@ import { import { useLayouts } from 'src/features/form/layout/LayoutsContext'; import { useCurrentLayoutSetId } from 'src/features/form/layoutSets/useCurrentLayoutSet'; import { useFormDataQuery } from 'src/features/formData/useFormDataQuery'; -import { - instanceQueries, - useInstanceDataElements, - useInstanceDataQueryArgs, -} from 'src/features/instance/InstanceContext'; +import { useInstanceDataElements, useInstanceDataQuery } from 'src/features/instance/InstanceContext'; import { MissingRolesError } from 'src/features/instantiate/containers/MissingRolesError'; import { useIsPdf } from 'src/hooks/useIsPdf'; import { isAxiosError } from 'src/utils/isAxiosError'; @@ -160,12 +156,11 @@ function DataModelsLoader() { const layouts = useLayouts(); const defaultDataType = useCurrentDataModelName(); const isStateless = useApplicationMetadata().isStatelessApp; - const queryClient = useQueryClient(); - const { instanceOwnerPartyId, instanceGuid } = useInstanceDataQueryArgs(); - const dataElements = - queryClient.getQueryData(instanceQueries.instanceData({ instanceOwnerPartyId, instanceGuid }).queryKey)?.data ?? - emptyArray; + const { data: dataElements, isFetching } = useInstanceDataQuery({ + enabled: !isStateless, + select: (data) => data.data.map((element) => [element.dataType, element.locked] as const), + }); const layoutSetId = useCurrentLayoutSetId(); @@ -175,6 +170,10 @@ function DataModelsLoader() { // Find all data types referenced in dataModelBindings in the layout useEffect(() => { + if (isFetching) { + return; + } + const referencedDataTypes = getAllReferencedDataTypes(layouts, defaultDataType); const allValidDataTypes: string[] = []; const writableDataTypes: string[] = []; @@ -194,10 +193,7 @@ function DataModelsLoader() { continue; } - // We don't check this if the data model is overridden, because dataElements (from the instance) may not - // even be up to date yet when (for example) a subform has just been added. - const isOverridden = overriddenDataType === dataType && !!overriddenDataElementId; - if (!isStateless && !isOverridden && !dataElements.find((data) => data.dataType === dataType)) { + if (!isStateless && !dataElements?.find(([dt]) => dt === dataType)) { const error = new MissingDataElementException(dataType); window.logErrorOnce(error.message); continue; @@ -205,23 +201,13 @@ function DataModelsLoader() { allValidDataTypes.push(dataType); - if (isDataTypeWritable(dataType, isStateless, dataElements)) { + if (isDataTypeWritable(dataType, isStateless, dataElements ?? [])) { writableDataTypes.push(dataType); } } setDataTypes(allValidDataTypes, writableDataTypes, defaultDataType, layoutSetId); - }, [ - applicationMetadata, - defaultDataType, - isStateless, - layouts, - setDataTypes, - dataElements, - layoutSetId, - overriddenDataType, - overriddenDataElementId, - ]); + }, [applicationMetadata, defaultDataType, isStateless, layouts, setDataTypes, dataElements, layoutSetId, isFetching]); // We should load form data and schema for all referenced data models, schema is used for dataModelBinding validation which we want to do even if it is readonly // We only need to load expression validation config for data types that are not readonly. Additionally, backend will error if we try to validate a model we are not supposed to diff --git a/src/features/datamodel/utils.ts b/src/features/datamodel/utils.ts index 53aace27f7..80f7603121 100644 --- a/src/features/datamodel/utils.ts +++ b/src/features/datamodel/utils.ts @@ -1,7 +1,6 @@ import { isDataModelReference } from 'src/utils/databindings'; import type { ApplicationMetadata } from 'src/features/applicationMetadata/types'; import type { ILayouts } from 'src/layout/layout'; -import type { IData } from 'src/types/shared'; export class MissingDataTypeException extends Error { public readonly dataType: string; @@ -102,15 +101,19 @@ function addDataTypesFromExpressionsRecursive(obj: unknown, dataTypes: Set data.dataType === dataType); - return !!dataElement && dataElement.locked === false; + const dataElement = dataElements.find(([dt]) => dt === dataType); + return !!dataElement && !dataElement[1]; } export interface QueryParamPrefill { From 2bf50b055127b3b4d241439210c16389de85f29e Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 28 Aug 2025 10:22:28 +0200 Subject: [PATCH 08/21] Adding back checks to properly find out if we're on the wrong task, without flickering this message when navigating. --- src/components/wrappers/ProcessWrapper.tsx | 66 +++++++++++++++++----- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/src/components/wrappers/ProcessWrapper.tsx b/src/components/wrappers/ProcessWrapper.tsx index 50882d2628..5f7b432cba 100644 --- a/src/components/wrappers/ProcessWrapper.tsx +++ b/src/components/wrappers/ProcessWrapper.tsx @@ -3,12 +3,14 @@ import { useLocation, useNavigate } from 'react-router-dom'; import type { PropsWithChildren } from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import type { QueryClient } from '@tanstack/react-query'; import { Button } from 'src/app-components/Button/Button'; import { Flex } from 'src/app-components/Flex/Flex'; import { PresentationComponent } from 'src/components/presentation/Presentation'; import classes from 'src/components/wrappers/ProcessWrapper.module.css'; import { Loader } from 'src/core/loading/Loader'; +import { useIsNavigating } from 'src/core/routing/useIsNavigating'; import { useAppName, useAppOwner } from 'src/core/texts/appTexts'; import { FormProvider } from 'src/features/form/FormContext'; import { useLayoutLookups } from 'src/features/form/layout/LayoutsContext'; @@ -94,26 +96,21 @@ export function NavigateToStartUrl({ forceCurrentTask = true }: { forceCurrentTa } export function ProcessWrapper({ children }: PropsWithChildren) { - const { data: process } = useProcessQuery(); - const currentTaskId = process?.currentTask?.elementId; const taskId = useNavigationParam('taskId'); - const isCurrentTask = - currentTaskId === undefined && taskId === TaskKeys.CustomReceipt ? true : currentTaskId === taskId; - + const isWrongTask = useIsWrongTask(taskId); const isValidTaskId = useIsValidTaskId()(taskId); const taskType = useGetTaskTypeById()(taskId); - const queryClient = useQueryClient(); - - const [isRunningProcessNext, setIsRunningProcessNext] = useState(null); + const isRunningProcessNext = useIsRunningProcessNext(); - useEffect(() => { - setIsRunningProcessNext(queryClient.isMutating({ mutationKey: getProcessNextMutationKey() }) > 0); - }, [queryClient]); - - if (isRunningProcessNext) { + if (isRunningProcessNext === null || isRunningProcessNext || isWrongTask === null) { return ; } + if (taskType === ProcessTaskType.Archived && taskId !== TaskKeys.CustomReceipt) { + // Someone else will redirect us to the receipt shortly. If a CustomReceipt is set up, we'll end back here. + return ; + } + if (!isValidTaskId) { return ( @@ -122,7 +119,7 @@ export function ProcessWrapper({ children }: PropsWithChildren) { ); } - if (!isCurrentTask) { + if (isWrongTask) { return ( @@ -188,3 +185,44 @@ export const ComponentRouting = () => { // If node exists but does not implement sub routing throw new Error(`Component ${componentId} does not have subRouting`); }; + +function isRunningProcessNext(queryClient: QueryClient) { + return queryClient.isMutating({ mutationKey: getProcessNextMutationKey() }) > 0; +} + +function useIsRunningProcessNext() { + const queryClient = useQueryClient(); + const [isMutating, setIsMutating] = useState(null); + + // Intentionally wrapped in a useEffect() and saved as a state. If this happens, we'll seemingly be locked out + // with a forever, but when this happens, we also know we'll be re-rendered soon. This is only meant to + // block rendering when we're calling process/next. + useEffect(() => { + setIsMutating(isRunningProcessNext(queryClient)); + }, [queryClient]); + + return isMutating; +} + +function useIsWrongTask(taskId: string | undefined) { + const isNavigating = useIsNavigating(); + const { data: process } = useProcessQuery(); + const currentTaskId = process?.currentTask?.elementId; + + const [isWrongTask, setIsWrongTask] = useState(null); + const isCurrentTask = + currentTaskId === undefined && taskId === TaskKeys.CustomReceipt ? true : currentTaskId === taskId; + + // We intentionally delay this state from being set until after a useEffect(), so the navigation error does not + // show up while we're navigating. Without this, the message will flash over the screen shortly in-between all the + // components. + useEffect(() => { + if (isCurrentTask) { + setIsWrongTask(false); + } else { + setTimeout(() => setIsWrongTask(true), 100); + } + }, [isCurrentTask]); + + return isWrongTask && !isCurrentTask && !isNavigating; +} From a5091567b6b3f7a991f2d85d9bbba637badd38e1 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 28 Aug 2025 10:22:43 +0200 Subject: [PATCH 09/21] Adding a new test for this bugfix --- .../e2e/integration/stateless-app/feedback.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 test/e2e/integration/stateless-app/feedback.ts diff --git a/test/e2e/integration/stateless-app/feedback.ts b/test/e2e/integration/stateless-app/feedback.ts new file mode 100644 index 0000000000..9af53fde22 --- /dev/null +++ b/test/e2e/integration/stateless-app/feedback.ts @@ -0,0 +1,73 @@ +import texts from 'test/e2e/fixtures/texts.json'; +import { AppFrontend } from 'test/e2e/pageobjects/app-frontend'; + +const appFrontend = new AppFrontend(); + +type BadMessageAppeared = { + appeared: boolean; +}; + +describe('Feedback task', () => { + it('feedback task can fail and go to data task', () => { + cy.startAppInstance(appFrontend.apps.stateless); + + cy.wrap({ appeared: false }).as('badMessage'); + cy.document().then(function (doc) { + // Observe if the target text ever appeared. We've been having a recurring issue where this text briefly + // flashes over the screen when doing a process/next, but regular Cypress assertions like should('not.exist') + // won't catch it since it disappears so fast. Instead we register a mutation observer to catch the message + // if it appears and fail the test in case that happens. + const obs = new doc.defaultView!.MutationObserver(() => { + if (doc.body.innerText.includes('Denne delen av skjemaet er ikke tilgjengelig')) { + this.badMessage.appeared = true; + } + }); + obs.observe(doc.body, { childList: true, subtree: true, characterData: true }); + }); + + cy.startStatefulFromStateless(); + + cy.intercept('PUT', '**/process/next*').as('nextProcess'); + cy.get(appFrontend.sendinButton).click(); + cy.findByText('Denne delen av skjemaet er ikke tilgjengelig', { timeout: 0 }).should('not.exist'); + cy.wait('@nextProcess').its('response.statusCode').should('eq', 200); + + cy.get('#firmanavn').type('Foo bar AS'); + cy.get('#orgnr').type('12345678901'); + cy.get('#gatewayShouldFail').find('[value="true"]').click(); + cy.waitUntilSaved(); + cy.get(appFrontend.sendinButton).click(); + cy.findByText('Denne delen av skjemaet er ikke tilgjengelig').should('not.exist'); + cy.wait('@nextProcess').its('response.statusCode').should('eq', 200); + + cy.get(appFrontend.feedback).should( + 'contain.text', + 'Du må flytte instansen til neste prosess med et API kall til process/next', + ); + cy.get(appFrontend.feedback).should('contain.text', 'Firmanavn: Foo bar AS'); + cy.get(appFrontend.feedback).should('contain.text', 'Org.nr: 12345678901'); + + cy.moveProcessNext(); + cy.get(appFrontend.feedback).should('not.exist'); + + cy.findByText('Det forrige prosess-steget feilet. Trykk på knappen under for å prøve på nytt.').should('exist'); + cy.findByRole('button', { name: 'Prøv igjen' }).click(); + cy.get('#gatewayShouldFail').find('[value="true"]').should('be.checked'); + cy.get('#gatewayShouldFail').find('[value="false"]').click(); + cy.waitUntilSaved(); + cy.get(appFrontend.sendinButton).click(); + cy.get(appFrontend.feedback).should( + 'contain.text', + 'Du må flytte instansen til neste prosess med et API kall til process/next', + ); + cy.moveProcessNext(); + + cy.get(appFrontend.receipt.container).should('contain.text', texts.securityReasons); + }); + + afterEach(() => { + cy.get('@badMessage').then((badMessage) => { + expect(badMessage.appeared, 'Forbidden message should never appear').to.be.false; + }); + }); +}); From 25be067523d03f10f14300cba2b0df1ea7828ada Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 28 Aug 2025 12:56:43 +0200 Subject: [PATCH 10/21] Whoopsie, I broke this hook when I just copied and pasted code without looking all that hard. --- src/core/routing/useIsNavigating.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/routing/useIsNavigating.ts b/src/core/routing/useIsNavigating.ts index 800630f288..b985afaf95 100644 --- a/src/core/routing/useIsNavigating.ts +++ b/src/core/routing/useIsNavigating.ts @@ -1,7 +1,10 @@ import { useLocation, useNavigation } from 'react-router-dom'; export function useIsNavigating() { - const isIdle = useNavigation().state !== 'idle'; + const isIdle = useNavigation().state === 'idle'; const location = useLocation(); - return !window.location.hash.endsWith(location.search) || !isIdle; + const expectedLocation = `${location.pathname}${location.search}`; + const locationIsUpToDate = window.location.hash.endsWith(expectedLocation); + + return !locationIsUpToDate || !isIdle; } From bbcf1c85391bbb47c0d3d6a6ae755adb158bc620 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 28 Aug 2025 13:04:59 +0200 Subject: [PATCH 11/21] Adding suggestion from coderabbit --- src/components/presentation/Presentation.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/presentation/Presentation.test.tsx b/src/components/presentation/Presentation.test.tsx index 69bf45a106..0e3140ed5f 100644 --- a/src/components/presentation/Presentation.test.tsx +++ b/src/components/presentation/Presentation.test.tsx @@ -31,6 +31,7 @@ describe('Presentation', () => { afterEach(() => { jest.clearAllMocks(); + jest.restoreAllMocks(); }); it('should link to query parameter returnUrl if valid URL', async () => { From c203df361225c3cb3049503d91e3174e8c96e2c9 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 28 Aug 2025 13:53:39 +0200 Subject: [PATCH 12/21] Whoops, I didn't notice that just rendered , so it depended on having its own routing. Baking what's left into FixWrongReceiptType.tsx and removing everything that makes it look like CustomReceipt isn't just any other taskId --- src/App.tsx | 13 +++++---- src/components/wrappers/ProcessWrapper.tsx | 12 --------- src/features/receipt/FixWrongReceiptType.tsx | 10 ++++++- src/features/receipt/ReceiptContainer.tsx | 28 -------------------- 4 files changed, 17 insertions(+), 46 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 175a646453..29a2b07b6d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { InstanceProvider } from 'src/features/instance/InstanceContext'; import { PartySelection } from 'src/features/instantiate/containers/PartySelection'; import { InstanceSelectionWrapper } from 'src/features/instantiate/selection/InstanceSelection'; import { PDFWrapper } from 'src/features/pdf/PDFWrapper'; +import { FixWrongReceiptType } from 'src/features/receipt/FixWrongReceiptType'; import { DefaultReceipt } from 'src/features/receipt/ReceiptContainer'; import { TaskKeys } from 'src/hooks/useNavigatePage'; @@ -65,11 +66,13 @@ export const App = () => ( - - - - + + + + + + + } > - - - - - ); - } - if (taskType === ProcessTaskType.Confirm) { return ( diff --git a/src/features/receipt/FixWrongReceiptType.tsx b/src/features/receipt/FixWrongReceiptType.tsx index 945b006497..98cf9ad32c 100644 --- a/src/features/receipt/FixWrongReceiptType.tsx +++ b/src/features/receipt/FixWrongReceiptType.tsx @@ -2,6 +2,7 @@ import React, { useEffect } from 'react'; import type { PropsWithChildren } from 'react'; import { Loader } from 'src/core/loading/Loader'; +import { useCurrentDataModelGuid } from 'src/features/datamodel/useBindingSchema'; import { useLayoutSets } from 'src/features/form/layoutSets/LayoutSetsProvider'; import { useNavigationParam } from 'src/hooks/navigation'; import { TaskKeys, useNavigateToTask } from 'src/hooks/useNavigatePage'; @@ -17,11 +18,18 @@ export function FixWrongReceiptType({ children }: PropsWithChildren) { const layoutSets = useLayoutSets(); const hasCustomReceipt = behavesLikeDataTask(TaskKeys.CustomReceipt, layoutSets); const navigateToTask = useNavigateToTask(); + const dataModelGuid = useCurrentDataModelGuid(); + const customReceiptDataModelNotFound = hasCustomReceipt && !dataModelGuid; let redirectTo: undefined | TaskKeys = undefined; if (taskId === TaskKeys.ProcessEnd && hasCustomReceipt) { redirectTo = TaskKeys.CustomReceipt; - } else if (taskId === TaskKeys.CustomReceipt && !hasCustomReceipt) { + } else if (taskId === TaskKeys.CustomReceipt && (!hasCustomReceipt || customReceiptDataModelNotFound)) { + if (customReceiptDataModelNotFound) { + window.logWarnOnce( + 'You specified a custom receipt, but the data model is missing. Falling back to default receipt.', + ); + } redirectTo = TaskKeys.ProcessEnd; } diff --git a/src/features/receipt/ReceiptContainer.tsx b/src/features/receipt/ReceiptContainer.tsx index 82c0a2e146..e65dec4c1e 100644 --- a/src/features/receipt/ReceiptContainer.tsx +++ b/src/features/receipt/ReceiptContainer.tsx @@ -1,5 +1,4 @@ import React, { useMemo } from 'react'; -import { Outlet } from 'react-router-dom'; import { formatDate } from 'date-fns'; @@ -12,9 +11,6 @@ import { PresentationComponent } from 'src/components/presentation/Presentation' import { ReadyForPrint } from 'src/components/ReadyForPrint'; import { useAppName, useAppOwner, useAppReceiver } from 'src/core/texts/appTexts'; import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; -import { useCurrentDataModelDataElementId } from 'src/features/datamodel/useBindingSchema'; -import { FormProvider } from 'src/features/form/FormContext'; -import { useLayoutSets } from 'src/features/form/layoutSets/LayoutSetsProvider'; import { useInstanceDataQuery } from 'src/features/instance/InstanceContext'; import { Lang } from 'src/features/language/Lang'; import { useLanguage } from 'src/features/language/useLanguage'; @@ -22,14 +18,12 @@ import { useInstanceOwnerParty } from 'src/features/party/PartiesProvider'; import { getInstanceSender } from 'src/features/processEnd/confirm/helpers/returnConfirmSummaryObject'; import { FixWrongReceiptType } from 'src/features/receipt/FixWrongReceiptType'; import { useNavigationParam } from 'src/hooks/navigation'; -import { TaskKeys } from 'src/hooks/useNavigatePage'; import { filterOutDataModelRefDataAsPdfAndAppOwnedDataTypes, getAttachmentsWithDataType, getRefAsPdfAttachments, toDisplayAttachments, } from 'src/utils/attachmentsUtils'; -import { behavesLikeDataTask } from 'src/utils/formLayout'; import { getPageTitle } from 'src/utils/getPageTitle'; import { returnUrlToArchive } from 'src/utils/urls/urlHelper'; import type { SummaryDataObject } from 'src/components/table/AltinnSummaryTable'; @@ -89,28 +83,6 @@ export function DefaultReceipt() { ); } -export function CustomReceipt() { - const layoutSets = useLayoutSets(); - const dataElementId = useCurrentDataModelDataElementId(); - const hasCustomReceipt = behavesLikeDataTask(TaskKeys.CustomReceipt, layoutSets); - const customReceiptDataModelNotFound = hasCustomReceipt && !dataElementId; - - if (customReceiptDataModelNotFound) { - window.logWarnOnce( - 'You specified a custom receipt, but the data model is missing. Falling back to default receipt.', - ); - return ; - } - - return ( - - - - - - ); -} - export const ReceiptContainer = () => { const applicationMetadata = useApplicationMetadata(); const { From 37b1ca54152ff7d7269183d35e8952622cda89c1 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 28 Aug 2025 14:03:45 +0200 Subject: [PATCH 13/21] Setting a timeout that can be cancelled --- src/components/wrappers/ProcessWrapper.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/wrappers/ProcessWrapper.tsx b/src/components/wrappers/ProcessWrapper.tsx index b9fd6ae1e1..47f7044796 100644 --- a/src/components/wrappers/ProcessWrapper.tsx +++ b/src/components/wrappers/ProcessWrapper.tsx @@ -201,15 +201,27 @@ function useIsWrongTask(taskId: string | undefined) { const isCurrentTask = currentTaskId === undefined && taskId === TaskKeys.CustomReceipt ? true : currentTaskId === taskId; + const timeoutRef = React.useRef | null>(null); + // We intentionally delay this state from being set until after a useEffect(), so the navigation error does not // show up while we're navigating. Without this, the message will flash over the screen shortly in-between all the // components. useEffect(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } if (isCurrentTask) { setIsWrongTask(false); } else { - setTimeout(() => setIsWrongTask(true), 100); + timeoutRef.current = setTimeout(() => { + setIsWrongTask(true); + timeoutRef.current = null; + }, 100); } + + const timeout = timeoutRef.current; + return () => void (timeout && clearTimeout(timeout)); }, [isCurrentTask]); return isWrongTask && !isCurrentTask && !isNavigating; From 4a85963011711f1ad62429c7f5dde3add01c83db Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 28 Aug 2025 14:04:09 +0200 Subject: [PATCH 14/21] Suggestion from coderabbit: disabling this query here when only using it for refetching --- src/features/instance/useProcessNext.tsx | 2 +- src/features/processEnd/feedback/Feedback.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/instance/useProcessNext.tsx b/src/features/instance/useProcessNext.tsx index 57736260a2..be67d132fc 100644 --- a/src/features/instance/useProcessNext.tsx +++ b/src/features/instance/useProcessNext.tsx @@ -36,7 +36,7 @@ export function getProcessNextMutationKey(action?: IActionType) { } export function useProcessNext({ action }: ProcessNextProps = {}) { - const reFetchInstanceData = useInstanceDataQuery().refetch; + const reFetchInstanceData = useInstanceDataQuery({ enabled: false }).refetch; const language = useCurrentLanguage(); const { data: process, refetch: refetchProcessData } = useProcessQuery(); const navigateToTask = useNavigateToTask(); diff --git a/src/features/processEnd/feedback/Feedback.tsx b/src/features/processEnd/feedback/Feedback.tsx index 4a546bfe1b..2f8ede718d 100644 --- a/src/features/processEnd/feedback/Feedback.tsx +++ b/src/features/processEnd/feedback/Feedback.tsx @@ -16,7 +16,7 @@ export function Feedback() { const appOwner = useAppOwner(); const { langAsString } = useLanguage(); const optimisticallyUpdateProcess = useOptimisticallyUpdateProcess(); - const reFetchInstanceData = useInstanceDataQuery().refetch; + const reFetchInstanceData = useInstanceDataQuery({ enabled: false }).refetch; // Continually re-fetch process data while the user is on the feedback page useBackoff({ From f950c51d3a7f96d31e492aea3590dfae69fdae9d Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 28 Aug 2025 14:06:55 +0200 Subject: [PATCH 15/21] Wrapping in useCallback() to avoid new calls to useEffect() for every render --- src/features/processEnd/feedback/Feedback.tsx | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/src/features/processEnd/feedback/Feedback.tsx b/src/features/processEnd/feedback/Feedback.tsx index 2f8ede718d..94e1318e13 100644 --- a/src/features/processEnd/feedback/Feedback.tsx +++ b/src/features/processEnd/feedback/Feedback.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { ReadyForPrint } from 'src/components/ReadyForPrint'; import { useAppName, useAppOwner } from 'src/core/texts/appTexts'; @@ -18,27 +18,33 @@ export function Feedback() { const optimisticallyUpdateProcess = useOptimisticallyUpdateProcess(); const reFetchInstanceData = useInstanceDataQuery({ enabled: false }).refetch; - // Continually re-fetch process data while the user is on the feedback page - useBackoff({ - callback: async () => { - const result = await reFetchProcessData(); - let navigateTo: undefined | string; - if ( - result.data?.currentTask?.elementId && - result?.data.currentTask.elementId !== previousData?.currentTask?.elementId - ) { - navigateTo = result.data.currentTask.elementId; - } else if (result.data?.ended) { - navigateTo = TaskKeys.ProcessEnd; - } + const callback = useCallback(async () => { + const result = await reFetchProcessData(); + let navigateTo: undefined | string; + if ( + result.data?.currentTask?.elementId && + result?.data.currentTask.elementId !== previousData?.currentTask?.elementId + ) { + navigateTo = result.data.currentTask.elementId; + } else if (result.data?.ended) { + navigateTo = TaskKeys.ProcessEnd; + } - if (navigateTo && result.data) { - optimisticallyUpdateProcess(result.data); - await reFetchInstanceData(); - navigateToTask(navigateTo); - } - }, - }); + if (navigateTo && result.data) { + optimisticallyUpdateProcess(result.data); + await reFetchInstanceData(); + navigateToTask(navigateTo); + } + }, [ + navigateToTask, + optimisticallyUpdateProcess, + previousData?.currentTask?.elementId, + reFetchInstanceData, + reFetchProcessData, + ]); + + // Continually re-fetch process data while the user is on the feedback page + useBackoff(callback); return (
@@ -50,7 +56,7 @@ export function Feedback() { ); } -function useBackoff({ callback }: { callback: () => Promise }) { +function useBackoff(callback: () => Promise) { // The backoff algorithm is used to check the process data, and slow down the requests after a while. // At first, it starts off once a second (every 1000ms) for 10 seconds. // After that, it slows down by one more second for every request. From 0e30e7caca050cdd1fa58174db3add6a5d5dc21a Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 28 Aug 2025 14:08:34 +0200 Subject: [PATCH 16/21] Flipping if, as suggest by coderabbit --- src/features/processEnd/feedback/Feedback.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/features/processEnd/feedback/Feedback.tsx b/src/features/processEnd/feedback/Feedback.tsx index 94e1318e13..0ac490dcf8 100644 --- a/src/features/processEnd/feedback/Feedback.tsx +++ b/src/features/processEnd/feedback/Feedback.tsx @@ -21,13 +21,13 @@ export function Feedback() { const callback = useCallback(async () => { const result = await reFetchProcessData(); let navigateTo: undefined | string; - if ( + if (result.data?.ended) { + navigateTo = TaskKeys.ProcessEnd; + } else if ( result.data?.currentTask?.elementId && - result?.data.currentTask.elementId !== previousData?.currentTask?.elementId + result.data.currentTask.elementId !== previousData?.currentTask?.elementId ) { navigateTo = result.data.currentTask.elementId; - } else if (result.data?.ended) { - navigateTo = TaskKeys.ProcessEnd; } if (navigateTo && result.data) { From 4e0e117d73d549a0a7a3b5a2498fc7d7c662d075 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 28 Aug 2025 15:32:34 +0200 Subject: [PATCH 17/21] A bit cleaner --- src/features/processEnd/feedback/Feedback.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/features/processEnd/feedback/Feedback.tsx b/src/features/processEnd/feedback/Feedback.tsx index 0ac490dcf8..4da9ad0a6b 100644 --- a/src/features/processEnd/feedback/Feedback.tsx +++ b/src/features/processEnd/feedback/Feedback.tsx @@ -20,17 +20,21 @@ export function Feedback() { const callback = useCallback(async () => { const result = await reFetchProcessData(); + if (!result.data) { + return; + } + let navigateTo: undefined | string; - if (result.data?.ended) { + if (result.data.ended) { navigateTo = TaskKeys.ProcessEnd; } else if ( - result.data?.currentTask?.elementId && + result.data.currentTask?.elementId && result.data.currentTask.elementId !== previousData?.currentTask?.elementId ) { navigateTo = result.data.currentTask.elementId; } - if (navigateTo && result.data) { + if (navigateTo) { optimisticallyUpdateProcess(result.data); await reFetchInstanceData(); navigateToTask(navigateTo); From 50f182f9eae280ea8819b43bb10535a13af14247 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 28 Aug 2025 15:33:46 +0200 Subject: [PATCH 18/21] Checking to make sure we're not already navigating when potentially navigating you to the first page. Updating the hook to make it work without a current location as well. --- src/components/wrappers/ProcessWrapper.tsx | 5 +++-- src/core/routing/useIsNavigating.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/wrappers/ProcessWrapper.tsx b/src/components/wrappers/ProcessWrapper.tsx index 47f7044796..85e2068fde 100644 --- a/src/components/wrappers/ProcessWrapper.tsx +++ b/src/components/wrappers/ProcessWrapper.tsx @@ -77,14 +77,15 @@ export function NavigateToStartUrl({ forceCurrentTask = true }: { forceCurrentTa const processNextKey = getProcessNextMutationKey(); const queryClient = useQueryClient(); const isRunningProcessNext = queryClient.isMutating({ mutationKey: processNextKey }); + const isNavigating = useIsNavigating(); const currentLocation = location.pathname + location.search; useEffect(() => { - if (currentLocation !== startUrl && !isRunningProcessNext) { + if (currentLocation !== startUrl && !isRunningProcessNext && !isNavigating) { navigate(startUrl, { replace: true }); } - }, [currentLocation, isRunningProcessNext, navigate, startUrl]); + }, [currentLocation, isRunningProcessNext, navigate, startUrl, isNavigating]); if (isRunningProcessNext) { return ; diff --git a/src/core/routing/useIsNavigating.ts b/src/core/routing/useIsNavigating.ts index b985afaf95..00dab179e8 100644 --- a/src/core/routing/useIsNavigating.ts +++ b/src/core/routing/useIsNavigating.ts @@ -3,7 +3,7 @@ import { useLocation, useNavigation } from 'react-router-dom'; export function useIsNavigating() { const isIdle = useNavigation().state === 'idle'; const location = useLocation(); - const expectedLocation = `${location.pathname}${location.search}`; + const expectedLocation = `${location.pathname}${location.search}`.replace(/^\//, ''); const locationIsUpToDate = window.location.hash.endsWith(expectedLocation); return !locationIsUpToDate || !isIdle; From a928bbf9388fa3e245e7e73532e27992508a1ec3 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 28 Aug 2025 16:00:13 +0200 Subject: [PATCH 19/21] Tests bumped into a case where queries/mutations are en-route, but because network takes time the navigation hadn't happened quite yet. Waiting for these to finish before showing this message to the user fixes things. --- src/components/wrappers/ProcessWrapper.tsx | 36 ++++++++++++---------- src/hooks/useWaitForQueries.ts | 31 +++++++++++++++++++ 2 files changed, 50 insertions(+), 17 deletions(-) create mode 100644 src/hooks/useWaitForQueries.ts diff --git a/src/components/wrappers/ProcessWrapper.tsx b/src/components/wrappers/ProcessWrapper.tsx index 85e2068fde..c2c3ea228c 100644 --- a/src/components/wrappers/ProcessWrapper.tsx +++ b/src/components/wrappers/ProcessWrapper.tsx @@ -21,6 +21,7 @@ import { Confirm } from 'src/features/processEnd/confirm/containers/Confirm'; import { Feedback } from 'src/features/processEnd/feedback/Feedback'; import { useNavigationParam } from 'src/hooks/navigation'; import { TaskKeys, useIsValidTaskId, useNavigateToTask, useStartUrl } from 'src/hooks/useNavigatePage'; +import { useWaitForQueries } from 'src/hooks/useWaitForQueries'; import { getComponentDef, implementsSubRouting } from 'src/layout'; import { RedirectBackToMainForm } from 'src/layout/Subform/SubformWrapper'; import { ProcessTaskType } from 'src/types'; @@ -197,33 +198,34 @@ function useIsWrongTask(taskId: string | undefined) { const isNavigating = useIsNavigating(); const { data: process } = useProcessQuery(); const currentTaskId = process?.currentTask?.elementId; + const waitForQueries = useWaitForQueries(); const [isWrongTask, setIsWrongTask] = useState(null); const isCurrentTask = currentTaskId === undefined && taskId === TaskKeys.CustomReceipt ? true : currentTaskId === taskId; - const timeoutRef = React.useRef | null>(null); - - // We intentionally delay this state from being set until after a useEffect(), so the navigation error does not - // show up while we're navigating. Without this, the message will flash over the screen shortly in-between all the - // components. + // We intentionally delay this state from being set until after queries/mutations finish, so the navigation error + // does not show up while we're navigating. Without this, the message will flash over the screen shortly + // in-between all the components. useEffect(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } if (isCurrentTask) { setIsWrongTask(false); } else { - timeoutRef.current = setTimeout(() => { - setIsWrongTask(true); - timeoutRef.current = null; - }, 100); + let cancelled = false; + const delayedCheck = async () => { + await waitForQueries(); + await new Promise((resolve) => setTimeout(resolve, 100)); // Wait a bit longer, for navigation to maybe occur + if (!cancelled) { + setIsWrongTask(true); + } + }; + delayedCheck().then(); + + return () => { + cancelled = true; + }; } - - const timeout = timeoutRef.current; - return () => void (timeout && clearTimeout(timeout)); - }, [isCurrentTask]); + }, [isCurrentTask, waitForQueries]); return isWrongTask && !isCurrentTask && !isNavigating; } diff --git a/src/hooks/useWaitForQueries.ts b/src/hooks/useWaitForQueries.ts new file mode 100644 index 0000000000..0985dbd1fd --- /dev/null +++ b/src/hooks/useWaitForQueries.ts @@ -0,0 +1,31 @@ +import { useCallback } from 'react'; + +import { useQueryClient } from '@tanstack/react-query'; +import type { MutationFilters, QueryFilters } from '@tanstack/react-query'; + +interface WaitForQueriesOptions { + queryFilters?: QueryFilters; + mutationFilters?: MutationFilters; +} + +export function useWaitForQueries(options?: WaitForQueriesOptions) { + const queryClient = useQueryClient(); + return useCallback( + async (): Promise => + new Promise((resolve) => { + const checkQueries = () => { + const isFetching = queryClient.isFetching(options?.queryFilters) > 0; + const isMutating = queryClient.isMutating(options?.mutationFilters) > 0; + + if (!isFetching && !isMutating) { + resolve(); + } else { + setTimeout(checkQueries, 10); + } + }; + + checkQueries(); + }), + [queryClient, options?.queryFilters, options?.mutationFilters], + ); +} From be34020eb1368e85a8ab289d2a76651af10e1822 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Wed, 3 Sep 2025 16:00:15 +0200 Subject: [PATCH 20/21] The reason the payment.ts cypress test fails is that useNavigatePage() needs info about pages that are hidden, and so it needs to run expressions. In one case in the ttd/payment-test app, when navigating to process/next, the FormDataWrite provider is no longer provided when useNavigatePage() is called, so everything crashes. This uses the lax selector instead, making things not crash anymore. --- src/features/expressions/expression-functions.ts | 4 ++++ src/features/formData/FormDataWrite.tsx | 6 +++--- src/layout/index.ts | 2 ++ src/utils/layout/useExpressionDataSources.ts | 6 +++--- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/features/expressions/expression-functions.ts b/src/features/expressions/expression-functions.ts index 9b466f5427..c5bd4b424a 100644 --- a/src/features/expressions/expression-functions.ts +++ b/src/features/expressions/expression-functions.ts @@ -1,6 +1,7 @@ import dot from 'dot-object'; import escapeStringRegexp from 'escape-string-regexp'; +import { ContextNotProvided } from 'src/core/contexts/context'; import { SearchParams } from 'src/core/routing/types'; import { exprCastValue } from 'src/features/expressions'; import { ExprRuntimeError, NodeRelationNotFound } from 'src/features/expressions/errors'; @@ -859,6 +860,9 @@ function pickSimpleValue( } const value = params.dataSources.formDataSelector(path); + if (value === ContextNotProvided) { + return null; + } if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return value; } diff --git a/src/features/formData/FormDataWrite.tsx b/src/features/formData/FormDataWrite.tsx index f084dc845a..18381a345f 100644 --- a/src/features/formData/FormDataWrite.tsx +++ b/src/features/formData/FormDataWrite.tsx @@ -67,7 +67,7 @@ const { useLaxMemoSelector, useLaxDelayedSelector, useDelayedSelector, - useDelayedSelectorProps, + useLaxDelayedSelectorProps, useLaxSelector, useLaxStore, useStore, @@ -707,8 +707,8 @@ export const FD = { }); }, - useDebouncedSelectorProps() { - return useDelayedSelectorProps({ + useLaxDebouncedSelectorProps() { + return useLaxDelayedSelectorProps({ mode: 'simple', selector: debouncedSelector, }); diff --git a/src/layout/index.ts b/src/layout/index.ts index 3ad10cd871..a30aeef1b8 100644 --- a/src/layout/index.ts +++ b/src/layout/index.ts @@ -1,6 +1,7 @@ import type { ReactNode, RefObject } from 'react'; import { getComponentConfigs } from 'src/layout/components.generated'; +import type { ContextNotProvided } from 'src/core/contexts/context'; import type { DisplayData } from 'src/features/displayData'; import type { LayoutLookups } from 'src/features/form/layout/makeLayoutLookups'; import type { BaseValidation, ComponentValidation } from 'src/features/validation'; @@ -88,6 +89,7 @@ export interface ValidationFilter { } export type FormDataSelector = (reference: IDataModelReference) => unknown; +export type FormDataSelectorLax = (reference: IDataModelReference) => unknown | typeof ContextNotProvided; export type FormDataRowsSelector = (reference: IDataModelReference) => BaseRow[]; export function implementsDisplayData(def: Def): def is Def & DisplayData { diff --git a/src/utils/layout/useExpressionDataSources.ts b/src/utils/layout/useExpressionDataSources.ts index fa1ecc3722..9830c04ea6 100644 --- a/src/utils/layout/useExpressionDataSources.ts +++ b/src/utils/layout/useExpressionDataSources.ts @@ -30,7 +30,7 @@ import type { LayoutLookups } from 'src/features/form/layout/makeLayoutLookups'; import type { IUseLanguage } from 'src/features/language/useLanguage'; import type { CodeListSelector } from 'src/features/options/CodeListsProvider'; import type { DSProps, DSPropsMatching } from 'src/hooks/delayedSelectors'; -import type { FormDataSelector } from 'src/layout'; +import type { FormDataSelectorLax } from 'src/layout'; import type { IDataModelReference } from 'src/layout/common.generated'; import type { IApplicationSettings, IInstanceDataSources, IProcess } from 'src/types/shared'; @@ -40,7 +40,7 @@ export interface ExpressionDataSources { applicationSettings: IApplicationSettings | null; dataElementSelector: ReturnType; dataModelNames: string[]; - formDataSelector: FormDataSelector; + formDataSelector: FormDataSelectorLax; attachmentsSelector: AttachmentsSelector; langToolsSelector: (dataModelPath: IDataModelReference | undefined) => IUseLanguage; currentLanguage: string; @@ -55,7 +55,7 @@ export interface ExpressionDataSources { } const multiSelectors = { - formDataSelector: () => FD.useDebouncedSelectorProps(), + formDataSelector: () => FD.useLaxDebouncedSelectorProps(), attachmentsSelector: () => NodesInternal.useAttachmentsSelectorProps(), codeListSelector: () => useCodeListSelectorProps(), } satisfies { From d3d6e698c79ab4e0e34dea5b8ee681d98cce489e Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 18 Sep 2025 12:28:57 +0200 Subject: [PATCH 21/21] Fixes after rebasing onto main --- src/core/routing/types.ts | 1 + src/features/receipt/FixWrongReceiptType.tsx | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/core/routing/types.ts b/src/core/routing/types.ts index b0e0b8a866..9ae686519e 100644 --- a/src/core/routing/types.ts +++ b/src/core/routing/types.ts @@ -4,4 +4,5 @@ export enum SearchParams { ExitSubform = 'exitSubform', Validate = 'validate', Pdf = 'pdf', + BackToPage = 'backToPage', } diff --git a/src/features/receipt/FixWrongReceiptType.tsx b/src/features/receipt/FixWrongReceiptType.tsx index 98cf9ad32c..682b53674e 100644 --- a/src/features/receipt/FixWrongReceiptType.tsx +++ b/src/features/receipt/FixWrongReceiptType.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import type { PropsWithChildren } from 'react'; import { Loader } from 'src/core/loading/Loader'; -import { useCurrentDataModelGuid } from 'src/features/datamodel/useBindingSchema'; +import { useCurrentDataModelDataElementId } from 'src/features/datamodel/useBindingSchema'; import { useLayoutSets } from 'src/features/form/layoutSets/LayoutSetsProvider'; import { useNavigationParam } from 'src/hooks/navigation'; import { TaskKeys, useNavigateToTask } from 'src/hooks/useNavigatePage'; @@ -18,8 +18,8 @@ export function FixWrongReceiptType({ children }: PropsWithChildren) { const layoutSets = useLayoutSets(); const hasCustomReceipt = behavesLikeDataTask(TaskKeys.CustomReceipt, layoutSets); const navigateToTask = useNavigateToTask(); - const dataModelGuid = useCurrentDataModelGuid(); - const customReceiptDataModelNotFound = hasCustomReceipt && !dataModelGuid; + const dataElementId = useCurrentDataModelDataElementId(); + const customReceiptDataModelNotFound = hasCustomReceipt && !dataElementId; let redirectTo: undefined | TaskKeys = undefined; if (taskId === TaskKeys.ProcessEnd && hasCustomReceipt) {