From 7fd73ab633667772f3bf2036cc81533715c89344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Tue, 20 Oct 2026 08:34:12 +0200 Subject: [PATCH 1/8] feat: render PDF service task previews in frontend v4 --- doc/pdf-preview.md | 16 ++ src/components/wrappers/ProcessWrapper.tsx | 17 +- src/features/datamodel/DataModelsProvider.tsx | 8 +- src/features/datamodel/useBindingSchema.tsx | 11 + src/features/form/FormContext.tsx | 9 +- src/features/formData/FormDataWrite.tsx | 4 +- .../FormDataWriteStateMachine.test.ts | 41 +++ .../formData/FormDataWriteStateMachine.tsx | 4 +- src/features/instance/useProcessQuery.ts | 4 +- src/features/instance/useProcessTaskId.ts | 4 +- .../pdf/PdfServiceTaskPreview.test.tsx | 245 ++++++++++++++++++ src/features/pdf/usePdfFormatQuery.ts | 6 +- src/hooks/useIsPdfOfOtherTask.ts | 14 + src/utils/layout/NodesContext.tsx | 7 + 14 files changed, 376 insertions(+), 14 deletions(-) create mode 100644 doc/pdf-preview.md create mode 100644 src/features/formData/FormDataWriteStateMachine.test.ts create mode 100644 src/features/pdf/PdfServiceTaskPreview.test.tsx create mode 100644 src/hooks/useIsPdfOfOtherTask.ts diff --git a/doc/pdf-preview.md b/doc/pdf-preview.md new file mode 100644 index 0000000000..d7b245dcbe --- /dev/null +++ b/doc/pdf-preview.md @@ -0,0 +1,16 @@ +# Preview PDF service tasks with app-lib v8 + +This frontend supports the optional `taskId` and `dataElementId` parameters on +the app-lib v8 PDF preview API. It requires the matching backend support. + +The PDF generator opens a URL containing the selected service task, and for a +subform PDF, its component and data element. The renderer uses that task's layout +set and the selected subform, even when the instance is on an earlier task. +Auto-generated PDFs can still summarize multiple tasks using the `task` query +parameters supplied by the backend. + +When rendering a task other than the current process task, form data is read-only: +component effects, data writes, and legacy calculations cannot modify it. The +legacy PDF formatter endpoint is skipped because it only understands the current +task. Existing current-task previews and normal form navigation retain their +behavior. diff --git a/src/components/wrappers/ProcessWrapper.tsx b/src/components/wrappers/ProcessWrapper.tsx index e27bb809f1..12461c99a8 100644 --- a/src/components/wrappers/ProcessWrapper.tsx +++ b/src/components/wrappers/ProcessWrapper.tsx @@ -12,9 +12,10 @@ 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'; import { getProcessNextMutationKey, getTargetTaskFromProcess } from 'src/features/instance/useProcessNext'; -import { useGetTaskTypeById, useProcessQuery } from 'src/features/instance/useProcessQuery'; +import { useGetAltinnTaskType, useGetTaskTypeById, useProcessQuery } from 'src/features/instance/useProcessQuery'; import { Lang } from 'src/features/language/Lang'; import { useLanguage } from 'src/features/language/useLanguage'; import { PdfWrapper } from 'src/features/pdf/PdfWrapper'; @@ -22,6 +23,7 @@ import { Confirm } from 'src/features/process/confirm/containers/Confirm'; import { Feedback } from 'src/features/process/feedback/Feedback'; import { ServiceTask } from 'src/features/process/service/ServiceTask'; import { useNavigationParam } from 'src/hooks/navigation'; +import { useIsPdf } from 'src/hooks/useIsPdf'; import { TaskKeys, useIsValidTaskId, useNavigateToTask, useStartUrl } from 'src/hooks/useNavigatePage'; import { useWaitForQueries } from 'src/hooks/useWaitForQueries'; import { getComponentDef, implementsSubRouting } from 'src/layout'; @@ -103,6 +105,8 @@ export function ProcessWrapper({ children }: PropsWithChildren) { const isValidTaskId = useIsValidTaskId()(taskId); const taskType = useGetTaskTypeById()(taskId); const isRunningProcessNext = useIsRunningProcessNext(); + const isPdf = useIsPdf(); + const altinnTaskType = useGetAltinnTaskType()(taskId); if (isRunningProcessNext === null || isRunningProcessNext || isWrongTask === null) { return ; @@ -121,7 +125,7 @@ export function ProcessWrapper({ children }: PropsWithChildren) { ); } - if (isWrongTask) { + if (isWrongTask && !isPdf) { return ( @@ -156,6 +160,15 @@ export function ProcessWrapper({ children }: PropsWithChildren) { } if (taskType === ProcessTaskType.Data) { + // A PDF service task renders its own layout set directly. The subform URL identifies + // the data element; its component belongs to the input task, not the PDF layout. + if (isPdf && (altinnTaskType === 'pdf' || altinnTaskType === 'subformPdf')) { + return ( + + + + ); + } return children; } diff --git a/src/features/datamodel/DataModelsProvider.tsx b/src/features/datamodel/DataModelsProvider.tsx index 720c6a8a42..425ed23195 100644 --- a/src/features/datamodel/DataModelsProvider.tsx +++ b/src/features/datamodel/DataModelsProvider.tsx @@ -28,6 +28,7 @@ import { useCurrentLayoutSetId } from 'src/features/form/layoutSets/useCurrentLa import { useFormDataQuery } from 'src/features/formData/useFormDataQuery'; import { useInstanceDataElements, useInstanceDataQuery } from 'src/features/instance/InstanceContext'; import { MissingRolesError } from 'src/features/instantiate/containers/MissingRolesError'; +import { useNavigationParam } from 'src/hooks/navigation'; import { useIsPdf } from 'src/hooks/useIsPdf'; import { isAxiosError } from 'src/utils/isAxiosError'; import { HttpStatusCodes } from 'src/utils/network/networking'; @@ -306,7 +307,12 @@ function LoadInitialData({ dataType, overrideDataElement }: LoaderProps & { over const setDataElementId = useSelector((state) => state.setDataElementId); const setError = useSelector((state) => state.setError); const dataElements = useInstanceDataElements(dataType); - const dataElementId = overrideDataElement ?? getFirstDataElementId(dataElements, dataType); + const isPdf = useIsPdf(); + const subformDataElementId = useNavigationParam('dataElementId'); + // A subform PDF layout uses the subform's data type as its default model. Select the + // requested element before the usual single-element lookup (there can be many subforms). + const pdfDataElementId = isPdf ? dataElements?.find((element) => element.id === subformDataElementId)?.id : undefined; + const dataElementId = overrideDataElement ?? pdfDataElementId ?? getFirstDataElementId(dataElements, dataType); const metaData = useApplicationMetadata(); const url = useDataModelUrl({ diff --git a/src/features/datamodel/useBindingSchema.tsx b/src/features/datamodel/useBindingSchema.tsx index 0fb64249e2..53ca53762b 100644 --- a/src/features/datamodel/useBindingSchema.tsx +++ b/src/features/datamodel/useBindingSchema.tsx @@ -14,7 +14,9 @@ import { useInstanceDataQuery, useLaxInstanceId } from 'src/features/instance/In import { useProcessTaskId } from 'src/features/instance/useProcessTaskId'; import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; import { useAllowAnonymous } from 'src/features/stateless/getAllowAnonymous'; +import { useNavigationParam } from 'src/hooks/navigation'; import { useAsRef } from 'src/hooks/useAsRef'; +import { useIsPdf } from 'src/hooks/useIsPdf'; import { getAnonymousStatelessDataModelUrl, getStatefulDataModelUrl, @@ -35,6 +37,8 @@ export function useCurrentDataModelDataElementId() { const taskId = useProcessTaskId(); const overriddenDataElementId = useTaskOverrides()?.dataModelElementId; + const isPdf = useIsPdf(); + const pdfSubformId = useNavigationParam('dataElementId'); // Instance data elements will update often (after each save), so we have to use a selector to make // sure components don't re-render too often. @@ -44,6 +48,13 @@ export function useCurrentDataModelDataElementId() { return overriddenDataElementId; } + if (isPdf && pdfSubformId) { + const dataType = getCurrentDataTypeForApplication({ application, layoutSets, taskId }); + const subform = data.data.find((element) => element.id === pdfSubformId && element.dataType === dataType); + if (subform) { + return subform.id; + } + } return getCurrentTaskDataElementId({ application, dataElements: data.data, taskId, layoutSets }); }, }).data; diff --git a/src/features/form/FormContext.tsx b/src/features/form/FormContext.tsx index 3bf365c10b..f7028fd727 100644 --- a/src/features/form/FormContext.tsx +++ b/src/features/form/FormContext.tsx @@ -16,6 +16,7 @@ import { PaymentInformationProvider } from 'src/features/payment/PaymentInformat import { PaymentProvider } from 'src/features/payment/PaymentProvider'; import { ValidationProvider } from 'src/features/validation/validationContext'; import { useNavigationParam } from 'src/hooks/navigation'; +import { useIsPdfOfOtherTask } from 'src/hooks/useIsPdfOfOtherTask'; import { FormPrefetcher } from 'src/queries/formPrefetcher'; import { NodesProvider } from 'src/utils/layout/NodesContext'; @@ -42,6 +43,8 @@ export function useIsInFormContext() { */ export function FormProvider({ children, readOnly = false }: React.PropsWithChildren) { const isEmbedded = useIsInFormContext(); + const isPdfOfOtherTask = useIsPdfOfOtherTask(); + const effectiveReadOnly = readOnly || isPdfOfOtherTask; const instanceOwnerPartyId = useNavigationParam('instanceOwnerPartyId'); const instanceGuid = useNavigationParam('instanceGuid'); const hasProcess = !!(instanceOwnerPartyId && instanceGuid); @@ -56,16 +59,16 @@ export function FormProvider({ children, readOnly = false }: React.PropsWithChil - + - + {children} diff --git a/src/features/formData/FormDataWrite.tsx b/src/features/formData/FormDataWrite.tsx index 20695e34d9..3500d5fdf7 100644 --- a/src/features/formData/FormDataWrite.tsx +++ b/src/features/formData/FormDataWrite.tsx @@ -368,7 +368,7 @@ function useIsSavingFormData() { ); } -export function FormDataWriteProvider({ children }: PropsWithChildren) { +export function FormDataWriteProvider({ children, locked = false }: PropsWithChildren<{ locked?: boolean }>) { const proxies = useFormDataWriteProxies(); const ruleConnections = useRuleConnections(); const allDataTypes = DataModels.useReadableDataTypes(); @@ -394,7 +394,7 @@ export function FormDataWriteProvider({ children }: PropsWithChildren) { lastSavedData: initialData[dt], hasUnsavedChanges: false, dataElementId: dataElementIds[dt], - readonly: !writableDataTypes.includes(dt), + readonly: locked || !writableDataTypes.includes(dt), isDefault: dt === defaultDataType, }; return dm; diff --git a/src/features/formData/FormDataWriteStateMachine.test.ts b/src/features/formData/FormDataWriteStateMachine.test.ts new file mode 100644 index 0000000000..8e797a62d0 --- /dev/null +++ b/src/features/formData/FormDataWriteStateMachine.test.ts @@ -0,0 +1,41 @@ +import { jest } from '@jest/globals'; + +import { createFormDataWriteStore } from 'src/features/formData/FormDataWriteStateMachine'; +import { makeFormDataMethodProxies } from 'src/test/renderWithProviders'; +import type { DataModelState } from 'src/features/formData/FormDataWriteStateMachine'; + +describe('read-only PDF data', () => { + it.each([true, false])('runs legacy calculations only on writable data (readonly=%s)', (readonly) => { + const calculate = jest.fn(() => 'calculated'); + window.ruleHandlerObject = { calculate }; + const currentData = { input: 'new', output: 'original' }; + const debouncedData = { input: 'old', output: 'original' }; + const model: DataModelState = { + currentData, + debouncedCurrentData: debouncedData, + lastSavedData: debouncedData, + invalidCurrentData: {}, + invalidDebouncedCurrentData: {}, + dataElementId: 'element', + readonly, + isDefault: true, + }; + const { proxies } = makeFormDataMethodProxies({ current: true }); + const store = createFormDataWriteStore( + { model }, + true, + proxies, + { rule: { selectedFunction: 'calculate', inputParams: { input: 'input' }, outParams: { outParam0: 'output' } } }, + {}, + jest.fn(), + ); + + store.getState().debounce('beforeSave'); + + expect(calculate).toHaveBeenCalledTimes(readonly ? 0 : 1); + expect(store.getState().dataModels.model.currentData).toEqual({ + input: 'new', + output: readonly ? 'original' : 'calculated', + }); + }); +}); diff --git a/src/features/formData/FormDataWriteStateMachine.tsx b/src/features/formData/FormDataWriteStateMachine.tsx index 2e468b046c..b44c08ff49 100644 --- a/src/features/formData/FormDataWriteStateMachine.tsx +++ b/src/features/formData/FormDataWriteStateMachine.tsx @@ -293,7 +293,7 @@ function makeActions( // Run rules again, against current data. Now that we have updates from the backend, some rules may // have caused data to change. - if (isDefault) { + if (isDefault && !state.dataModels[dataType].readonly) { const ruleResults = runLegacyRules( ruleConnections, savedData[dataType], @@ -332,7 +332,7 @@ function makeActions( continue; } - if (isDefault) { + if (isDefault && !state.dataModels[dataType].readonly) { const ruleChanges = runLegacyRules( ruleConnections, state.dataModels[dataType].debouncedCurrentData, diff --git a/src/features/instance/useProcessQuery.ts b/src/features/instance/useProcessQuery.ts index 48fef670f9..2d7de8af63 100644 --- a/src/features/instance/useProcessQuery.ts +++ b/src/features/instance/useProcessQuery.ts @@ -65,9 +65,9 @@ export function useGetTaskTypeById() { return (taskId: string | undefined) => { const task = - (processData?.processTasks?.find((t) => t.elementId === taskId) ?? processData?.currentTask?.elementId === taskId) + processData?.currentTask?.elementId === taskId ? processData?.currentTask - : undefined; + : processData?.processTasks?.find((t) => t.elementId === taskId); if (isStateless || taskId === TaskKeys.CustomReceipt || behavesLikeDataTask(taskId, layoutSets)) { // Stateless apps only have data tasks. As soon as they start creating an instance from that stateless step, diff --git a/src/features/instance/useProcessTaskId.ts b/src/features/instance/useProcessTaskId.ts index 7da47a19c3..fe4d77a0d3 100644 --- a/src/features/instance/useProcessTaskId.ts +++ b/src/features/instance/useProcessTaskId.ts @@ -1,10 +1,12 @@ import { useTaskOverrides } from 'src/core/contexts/TaskOverrides'; import { useProcessQuery } from 'src/features/instance/useProcessQuery'; import { useNavigationParam } from 'src/hooks/navigation'; +import { useIsPdf } from 'src/hooks/useIsPdf'; export function useProcessTaskId() { const overriddenTaskId = useTaskOverrides()?.taskId; const processTaskId = useProcessQuery().data?.currentTask?.elementId; const urlTaskId = useNavigationParam('taskId'); - return overriddenTaskId ?? processTaskId ?? urlTaskId; + const isPdf = useIsPdf(); + return overriddenTaskId ?? (isPdf ? (urlTaskId ?? processTaskId) : (processTaskId ?? urlTaskId)); } diff --git a/src/features/pdf/PdfServiceTaskPreview.test.tsx b/src/features/pdf/PdfServiceTaskPreview.test.tsx new file mode 100644 index 0000000000..ad9f2e403b --- /dev/null +++ b/src/features/pdf/PdfServiceTaskPreview.test.tsx @@ -0,0 +1,245 @@ +import React from 'react'; + +import { jest } from '@jest/globals'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; + +import { getInstanceDataMock } from 'src/__mocks__/getInstanceDataMock'; +import { getLayoutSetsMock } from 'src/__mocks__/getLayoutSetsMock'; +import { getProcessDataMock } from 'src/__mocks__/getProcessDataMock'; +import { ProcessWrapper } from 'src/components/wrappers/ProcessWrapper'; +import { TaskOverrides } from 'src/core/contexts/TaskOverrides'; +import { FormProvider } from 'src/features/form/FormContext'; +import { FD } from 'src/features/formData/FormDataWrite'; +import { InstanceProvider } from 'src/features/instance/InstanceContext'; +import { useProcessTaskId } from 'src/features/instance/useProcessTaskId'; +import { PdfWrapper } from 'src/features/pdf/PdfWrapper'; +import { fetchInstanceData, fetchProcessState } from 'src/queries/queries'; +import { InstanceRouter, renderWithoutInstanceAndLayout } from 'src/test/renderWithProviders'; +import type { AppQueries } from 'src/queries/types'; + +const firstId = '2f5c6a01-30e1-4d72-a19e-162a90517d14'; +const secondId = '85b7f187-80f8-40aa-800e-39ffbc47c751'; +const taskId = 'SubformPdf'; + +function setup() { + jest.mocked(fetchProcessState).mockImplementation(async () => + getProcessDataMock((p) => { + p.processTasks = [ + p.currentTask!, + { elementId: taskId, elementType: 'ServiceTask', altinnTaskType: 'subformPdf' }, + { elementId: 'PdfAuto', elementType: 'ServiceTask', altinnTaskType: 'pdf' }, + { elementId: 'PdfLayout', elementType: 'ServiceTask', altinnTaskType: 'pdf' }, + ]; + }), + ); + jest.mocked(fetchInstanceData).mockImplementation(async () => + getInstanceDataMock((instance) => { + const template = instance.data[0]; + instance.data.push( + { ...template, id: firstId, dataType: 'subform-data' }, + { ...template, id: secondId, dataType: 'subform-data' }, + ); + }), + ); +} + +function queries(): Partial { + return { + fetchLayoutSets: async () => ({ + sets: [ + ...getLayoutSetsMock().sets, + { id: 'subform-pdf', dataType: 'subform-data', tasks: [taskId] }, + { id: 'pdf-layout', dataType: 'test-data-model', tasks: ['PdfLayout'] }, + ], + }), + fetchLayouts: async (layoutSetId) => ({ + Pdf: { + data: { + layout: [ + { + id: 'value', + type: 'Input', + dataModelBindings: { + simpleBinding: { + dataType: layoutSetId === 'subform-pdf' ? 'subform-data' : 'test-data-model', + field: 'value', + }, + }, + textResourceBindings: { title: 'Value' }, + }, + ], + }, + }, + }), + fetchLayoutSettings: async () => ({ pages: { order: ['Pdf'], pdfLayoutName: 'Pdf' } }), + fetchDataModelSchema: async () => ({ type: 'object', properties: { value: { type: 'string' } } }), + fetchFormData: async (url) => ({ value: url.includes(secondId) ? 'Second subform' : 'First subform' }), + }; +} + +async function renderPreview({ + selectedId = secondId, + pdf = true, + target = taskId, + extraQuery = '', + renderChildren, + queryOverrides, +}: { + selectedId?: string; + pdf?: boolean; + target?: string; + extraQuery?: string; + renderChildren?: React.ReactNode; + queryOverrides?: Partial; +} = {}) { + setup(); + const page = target === taskId ? `subform/subform-component/${selectedId}` : ''; + const query = pdf ? `pdf=1${extraQuery}` : ''; + const suffix = query ? `?${query}` : ''; + window.location.hash = `#/instance/512345/75154373-aed4-41f7-95b4-e5b5115c2edc/${target}/${page}${suffix}`; + return renderWithoutInstanceAndLayout({ + renderer: () => ( + + {renderChildren ?? ( + + Normal form + + )} + + ), + router: ({ children }) => ( + + {children} + + ), + queries: { ...queries(), ...queryOverrides }, + }); +} + +describe('PDF service task previews in frontend v4', () => { + it.each([ + [firstId, 'First subform', 'Second subform'], + [secondId, 'Second subform', 'First subform'], + ])('renders the requested subform %s while an earlier task is current', async (selectedId, expected, absent) => { + const result = await renderPreview({ selectedId }); + await waitFor(() => expect(result.container.querySelector('#readyForPrint')).not.toBeNull()); + expect(screen.getByDisplayValue(expected)).toBeInTheDocument(); + expect(screen.queryByDisplayValue(absent)).not.toBeInTheDocument(); + expect(screen.queryByText('Normal form')).not.toBeInTheDocument(); + expect(result.queries.fetchLayouts).toHaveBeenCalledWith('subform-pdf'); + expect(result.queries.fetchFormData).toHaveBeenCalledWith(expect.stringContaining(selectedId), {}); + expect(result.queries.fetchPdfFormat).not.toHaveBeenCalled(); + result.unmount(); + expect(result.mutations.doPatchFormData.mock).not.toHaveBeenCalled(); + expect(result.mutations.doPatchMultipleFormData.mock).not.toHaveBeenCalled(); + }); + + it('keeps normal navigation to a future task blocked', async () => { + await renderPreview({ pdf: false }); + await waitFor(() => expect(screen.queryByText('Normal form')).not.toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Gå til riktig prosessteg' })).toBeInTheDocument(); + }); + + it('renders auto-PDF tasks without a layout while another task is current', async () => { + const result = await renderPreview({ + target: 'PdfAuto', + extraQuery: '&task=Task_1', + queryOverrides: { fetchLayoutSettings: async () => ({ pages: { order: ['Pdf'] } }) }, + }); + await waitFor(() => expect(result.container.querySelector('#readyForPrint')).not.toBeNull()); + expect(screen.getByText('First subform')).toBeInTheDocument(); + expect(result.queries.fetchLayouts).toHaveBeenCalledWith('some-data-task'); + }); + + it('renders a future PDF task with its own layout', async () => { + const result = await renderPreview({ target: 'PdfLayout' }); + await waitFor(() => expect(result.container.querySelector('#readyForPrint')).not.toBeNull()); + expect(screen.getByDisplayValue('First subform')).toBeInTheDocument(); + expect(result.queries.fetchLayouts).toHaveBeenCalledWith('pdf-layout'); + expect(result.queries.fetchPdfFormat).not.toHaveBeenCalled(); + result.unmount(); + expect(result.mutations.doPatchMultipleFormData.mock).not.toHaveBeenCalled(); + }); + + it('keeps the legacy formatter for current-task PDF previews', async () => { + const result = await renderPreview({ + target: 'Task_1', + renderChildren: ( + + + + ), + queryOverrides: { fetchLayoutSettings: async () => ({ pages: { order: ['Pdf'] } }) }, + }); + await waitFor(() => expect(result.container.querySelector('#readyForPrint')).not.toBeNull()); + expect(result.queries.fetchPdfFormat).toHaveBeenCalled(); + }); + + it('does not allow writes or saves from another task preview', async () => { + const log = jest.spyOn(window, 'logError').mockImplementation(() => {}); + try { + const result = await renderPreview({ + renderChildren: ( + + + + ), + }); + expect(screen.getByText('Second subform')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Try writing' })); + expect(screen.getByText('Second subform')).toBeInTheDocument(); + expect(screen.queryByText('Changed')).not.toBeInTheDocument(); + expect(log).toHaveBeenCalledWith('Tried to write to readOnly dataType "subform-data"'); + result.unmount(); + expect(result.mutations.doPatchFormData.mock).not.toHaveBeenCalled(); + expect(result.mutations.doPatchMultipleFormData.mock).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + } + }); + + it('preserves writable data in the existing current-task preview', async () => { + await renderPreview({ + target: 'Task_1', + renderChildren: ( + + + + ), + }); + fireEvent.click(screen.getByRole('button', { name: 'Try writing' })); + expect(screen.getByText('Changed')).toBeInTheDocument(); + }); + + it('preserves explicit task overrides within a PDF preview', async () => { + await renderPreview({ + renderChildren: ( + + + + ), + }); + expect(screen.getByText('Selected task: Task_1')).toBeInTheDocument(); + }); +}); + +function WriteProbe({ dataType = 'subform-data' }: { dataType?: string }) { + const reference = { dataType, field: 'value' }; + const value = FD.useCurrentPick(reference); + const setValue = FD.useSetLeafValue(); + return ( + <> + {String(value)} + + + ); +} + +function TaskProbe() { + return Selected task: {useProcessTaskId()}; +} diff --git a/src/features/pdf/usePdfFormatQuery.ts b/src/features/pdf/usePdfFormatQuery.ts index 06b9aa3e46..57634c5061 100644 --- a/src/features/pdf/usePdfFormatQuery.ts +++ b/src/features/pdf/usePdfFormatQuery.ts @@ -7,6 +7,7 @@ import { useAppQueries } from 'src/core/contexts/AppQueriesProvider'; import { type QueryDefinition } from 'src/core/queries/usePrefetchQuery'; import { useCurrentDataModelDataElementId } from 'src/features/datamodel/useBindingSchema'; import { useLaxInstanceId } from 'src/features/instance/InstanceContext'; +import { useIsPdfOfOtherTask } from 'src/hooks/useIsPdfOfOtherTask'; import type { IPdfFormat } from 'src/features/pdf/types'; // Also used for prefetching @see formPrefetcher.ts @@ -16,10 +17,13 @@ export function usePdfFormatQueryDef( dataElementId?: string, ): QueryDefinition { const { fetchPdfFormat } = useAppQueries(); + // The legacy formatter only understands the current process task. Other task + // previews use their own layout settings, including when prefetching. + const isPdfOfOtherTask = useIsPdfOfOtherTask(); return { queryKey: ['fetchPdfFormat', { instanceId, dataElementId }], queryFn: instanceId && dataElementId ? () => fetchPdfFormat(instanceId, dataElementId) : skipToken, - enabled: enabled && !!instanceId && !!dataElementId, + enabled: enabled && !isPdfOfOtherTask && !!instanceId && !!dataElementId, gcTime: 0, }; } diff --git a/src/hooks/useIsPdfOfOtherTask.ts b/src/hooks/useIsPdfOfOtherTask.ts new file mode 100644 index 0000000000..25604e8d8e --- /dev/null +++ b/src/hooks/useIsPdfOfOtherTask.ts @@ -0,0 +1,14 @@ +import { useProcessQuery } from 'src/features/instance/useProcessQuery'; +import { useNavigationParam } from 'src/hooks/navigation'; +import { useIsPdf } from 'src/hooks/useIsPdf'; + +/** + * A preview can render a later PDF task while the current task's data is still writable. + * Rendering that other task must not change the current task's form data. + */ +export function useIsPdfOfOtherTask() { + const isPdf = useIsPdf(); + const taskId = useNavigationParam('taskId'); + const currentTaskId = useProcessQuery().data?.currentTask?.elementId; + return isPdf && taskId !== undefined && currentTaskId !== undefined && taskId !== currentTaskId; +} diff --git a/src/utils/layout/NodesContext.tsx b/src/utils/layout/NodesContext.tsx index eca7564a6b..e1a098c6b9 100644 --- a/src/utils/layout/NodesContext.tsx +++ b/src/utils/layout/NodesContext.tsx @@ -22,6 +22,7 @@ import { } from 'src/features/validation/validationContext'; import { ValidationStorePlugin } from 'src/features/validation/ValidationStorePlugin'; import { useNavigationParam } from 'src/hooks/navigation'; +import { useIsPdf } from 'src/hooks/useIsPdf'; import { TaskKeys } from 'src/hooks/useNavigatePage'; import { useComponentIdMutator } from 'src/utils/layout/DataModelLocation'; import { GeneratorGlobalProvider, GeneratorInternal } from 'src/utils/layout/generator/GeneratorContext'; @@ -344,6 +345,12 @@ function ProvideGlobalContext({ children, registry }: PropsWithChildren<{ regist function useIsInTaskTransition() { const currentTask = useProcessQuery().data?.currentTask?.elementId; const taskIdFromUrl = useNavigationParam('taskId'); + const isPdf = useIsPdf(); + + // A PDF intentionally renders its URL task, which may differ from the current process task. + if (isPdf) { + return false; + } if ([TaskKeys.ProcessEnd, TaskKeys.CustomReceipt].includes(taskIdFromUrl as TaskKeys) && !currentTask) { // Receipt indicates that the process ended - it cannot be compared directly with the taskId From 2f9b8a9decadc9f68f74f36ffb533136ff1fbe57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Fri, 23 Oct 2026 12:21:57 +0200 Subject: [PATCH 2/8] docs: drop the repository usage guide for PDF previews --- doc/pdf-preview.md | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 doc/pdf-preview.md diff --git a/doc/pdf-preview.md b/doc/pdf-preview.md deleted file mode 100644 index d7b245dcbe..0000000000 --- a/doc/pdf-preview.md +++ /dev/null @@ -1,16 +0,0 @@ -# Preview PDF service tasks with app-lib v8 - -This frontend supports the optional `taskId` and `dataElementId` parameters on -the app-lib v8 PDF preview API. It requires the matching backend support. - -The PDF generator opens a URL containing the selected service task, and for a -subform PDF, its component and data element. The renderer uses that task's layout -set and the selected subform, even when the instance is on an earlier task. -Auto-generated PDFs can still summarize multiple tasks using the `task` query -parameters supplied by the backend. - -When rendering a task other than the current process task, form data is read-only: -component effects, data writes, and legacy calculations cannot modify it. The -legacy PDF formatter endpoint is skipped because it only understands the current -task. Existing current-task previews and normal form navigation retain their -behavior. From 58dcb91d4e7f6a4dc0382bc4962d7c3a7f6412a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Fri, 23 Oct 2026 12:34:34 +0200 Subject: [PATCH 3/8] fix: route subform PDF previews through the subform component copy --- src/components/wrappers/ProcessWrapper.tsx | 13 +- .../pdf/PdfServiceTaskPreview.test.tsx | 222 +++++++++++++----- 2 files changed, 169 insertions(+), 66 deletions(-) diff --git a/src/components/wrappers/ProcessWrapper.tsx b/src/components/wrappers/ProcessWrapper.tsx index 12461c99a8..af854f7e06 100644 --- a/src/components/wrappers/ProcessWrapper.tsx +++ b/src/components/wrappers/ProcessWrapper.tsx @@ -12,10 +12,9 @@ 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'; import { getProcessNextMutationKey, getTargetTaskFromProcess } from 'src/features/instance/useProcessNext'; -import { useGetAltinnTaskType, useGetTaskTypeById, useProcessQuery } from 'src/features/instance/useProcessQuery'; +import { useGetTaskTypeById, useProcessQuery } from 'src/features/instance/useProcessQuery'; import { Lang } from 'src/features/language/Lang'; import { useLanguage } from 'src/features/language/useLanguage'; import { PdfWrapper } from 'src/features/pdf/PdfWrapper'; @@ -106,7 +105,6 @@ export function ProcessWrapper({ children }: PropsWithChildren) { const taskType = useGetTaskTypeById()(taskId); const isRunningProcessNext = useIsRunningProcessNext(); const isPdf = useIsPdf(); - const altinnTaskType = useGetAltinnTaskType()(taskId); if (isRunningProcessNext === null || isRunningProcessNext || isWrongTask === null) { return ; @@ -160,15 +158,6 @@ export function ProcessWrapper({ children }: PropsWithChildren) { } if (taskType === ProcessTaskType.Data) { - // A PDF service task renders its own layout set directly. The subform URL identifies - // the data element; its component belongs to the input task, not the PDF layout. - if (isPdf && (altinnTaskType === 'pdf' || altinnTaskType === 'subformPdf')) { - return ( - - - - ); - } return children; } diff --git a/src/features/pdf/PdfServiceTaskPreview.test.tsx b/src/features/pdf/PdfServiceTaskPreview.test.tsx index ad9f2e403b..0e86bab0ae 100644 --- a/src/features/pdf/PdfServiceTaskPreview.test.tsx +++ b/src/features/pdf/PdfServiceTaskPreview.test.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { createMemoryRouter, Outlet, RouterProvider } from 'react-router-dom'; +import type { PropsWithChildren } from 'react'; import { jest } from '@jest/globals'; import { fireEvent, screen, waitFor } from '@testing-library/react'; @@ -6,7 +8,9 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import { getInstanceDataMock } from 'src/__mocks__/getInstanceDataMock'; import { getLayoutSetsMock } from 'src/__mocks__/getLayoutSetsMock'; import { getProcessDataMock } from 'src/__mocks__/getProcessDataMock'; -import { ProcessWrapper } from 'src/components/wrappers/ProcessWrapper'; +import { Form } from 'src/components/form/Form'; +import { PresentationComponent } from 'src/components/presentation/Presentation'; +import { ComponentRouting, NavigateToStartUrl, ProcessWrapper } from 'src/components/wrappers/ProcessWrapper'; import { TaskOverrides } from 'src/core/contexts/TaskOverrides'; import { FormProvider } from 'src/features/form/FormContext'; import { FD } from 'src/features/formData/FormDataWrite'; @@ -14,14 +18,17 @@ import { InstanceProvider } from 'src/features/instance/InstanceContext'; import { useProcessTaskId } from 'src/features/instance/useProcessTaskId'; import { PdfWrapper } from 'src/features/pdf/PdfWrapper'; import { fetchInstanceData, fetchProcessState } from 'src/queries/queries'; -import { InstanceRouter, renderWithoutInstanceAndLayout } from 'src/test/renderWithProviders'; +import { renderWithoutInstanceAndLayout } from 'src/test/renderWithProviders'; +import type { ILayoutSettings } from 'src/layout/common.generated'; +import type { ILayoutCollection } from 'src/layout/layout'; import type { AppQueries } from 'src/queries/types'; const firstId = '2f5c6a01-30e1-4d72-a19e-162a90517d14'; const secondId = '85b7f187-80f8-40aa-800e-39ffbc47c751'; const taskId = 'SubformPdf'; +const instanceId = '512345/75154373-aed4-41f7-95b4-e5b5115c2edc'; -function setup() { +function setup(currentTaskId = 'Task_1') { jest.mocked(fetchProcessState).mockImplementation(async () => getProcessDataMock((p) => { p.processTasks = [ @@ -30,6 +37,9 @@ function setup() { { elementId: 'PdfAuto', elementType: 'ServiceTask', altinnTaskType: 'pdf' }, { elementId: 'PdfLayout', elementType: 'ServiceTask', altinnTaskType: 'pdf' }, ]; + if (currentTaskId !== 'Task_1') { + p.currentTask = { ...p.currentTask!, elementId: currentTaskId, altinnTaskType: 'subformPdf' }; + } }), ); jest.mocked(fetchInstanceData).mockImplementation(async () => @@ -43,6 +53,68 @@ function setup() { ); } +function input(id: string, dataType: string, title: string) { + return { + id, + type: 'Input', + dataModelBindings: { simpleBinding: { dataType, field: 'value' } }, + textResourceBindings: { title }, + }; +} + +/** + * The documented v4 setup for subform PDFs: the service task's layout set uses the subform data type and holds a + * hidden copy of the Subform component, which the PDF URL routes into. The subform's own layout set has a PDF layout. + */ +function layouts(layoutSetId: string): ILayoutCollection { + switch (layoutSetId) { + case 'subform-pdf': + return { + ServiceTask: { + data: { + layout: [ + { id: 'service-task-failed', type: 'Paragraph', textResourceBindings: { title: 'Service task failed' } }, + { id: 'subform-component', type: 'Subform', layoutSet: 'subform-layout', hidden: true, tableColumns: [] }, + ], + }, + }, + } as ILayoutCollection; + case 'subform-layout': + return { + Subform: { data: { layout: [input('subform-input', 'subform-data', 'Subform value')] } }, + Pdf: { + data: { + layout: [ + { id: 'subform-pdf-title', type: 'Header', size: 'L', textResourceBindings: { title: 'Subform PDF' } }, + input('subform-pdf-value', 'subform-data', 'Subform value'), + ], + }, + }, + } as ILayoutCollection; + case 'pdf-layout': + return { + Pdf: { data: { layout: [input('pdf-layout-value', 'test-data-model', 'Main field')] } }, + } as ILayoutCollection; + default: + return { + FormLayout: { data: { layout: [input('main-input', 'test-data-model', 'Main field')] } }, + } as ILayoutCollection; + } +} + +function layoutSettings(layoutSetId: string): ILayoutSettings { + switch (layoutSetId) { + case 'subform-pdf': + return { pages: { order: ['ServiceTask'] } }; + case 'subform-layout': + return { pages: { order: ['Subform'], pdfLayoutName: 'Pdf' } }; + case 'pdf-layout': + return { pages: { order: ['Pdf'], pdfLayoutName: 'Pdf' } }; + default: + return { pages: { order: ['FormLayout'] } }; + } +} + function queries(): Partial { return { fetchLayoutSets: async () => ({ @@ -52,35 +124,69 @@ function queries(): Partial { { id: 'pdf-layout', dataType: 'test-data-model', tasks: ['PdfLayout'] }, ], }), - fetchLayouts: async (layoutSetId) => ({ - Pdf: { - data: { - layout: [ - { - id: 'value', - type: 'Input', - dataModelBindings: { - simpleBinding: { - dataType: layoutSetId === 'subform-pdf' ? 'subform-data' : 'test-data-model', - field: 'value', - }, - }, - textResourceBindings: { title: 'Value' }, - }, - ], - }, - }, - }), - fetchLayoutSettings: async () => ({ pages: { order: ['Pdf'], pdfLayoutName: 'Pdf' } }), + fetchLayouts: async (layoutSetId) => layouts(layoutSetId), + fetchLayoutSettings: async (layoutSetId) => layoutSettings(layoutSetId), + fetchLayoutSettingsForInstance: async (layoutSetId) => layoutSettings(layoutSetId), fetchDataModelSchema: async () => ({ type: 'object', properties: { value: { type: 'string' } } }), - fetchFormData: async (url) => ({ value: url.includes(secondId) ? 'Second subform' : 'First subform' }), + fetchFormData: async (url) => ({ + value: url.includes(secondId) ? 'Second subform' : url.includes(firstId) ? 'First subform' : 'Main value', + }), }; } +/** + * The instance routes from App.tsx, so a subform PDF URL routes into the Subform component + */ +function PreviewRouter({ children, path }: PropsWithChildren<{ path: string }>) { + const router = createMemoryRouter( + [ + { + path: 'instance/:instanceOwnerPartyId/:instanceGuid/:taskId', + element: children, + children: [ + { index: true, element: }, + { + path: ':pageKey', + children: [ + { + index: true, + element: ( + + +
+ + + ), + }, + { + path: ':componentId', + children: [ + { index: true, element: }, + { path: '*', element: }, + ], + }, + ], + }, + ], + }, + { path: '*', element: children }, + ], + { basename: '/ttd/test', initialEntries: [`/ttd/test${path}`], future: { v7_relativeSplatPath: true } }, + ); + + return ( + + ); +} + async function renderPreview({ selectedId = secondId, pdf = true, target = taskId, + currentTaskId = 'Task_1', extraQuery = '', renderChildren, queryOverrides, @@ -88,78 +194,87 @@ async function renderPreview({ selectedId?: string; pdf?: boolean; target?: string; + currentTaskId?: string; extraQuery?: string; renderChildren?: React.ReactNode; queryOverrides?: Partial; } = {}) { - setup(); - const page = target === taskId ? `subform/subform-component/${selectedId}` : ''; + setup(currentTaskId); + // The same URL shape the backend sends to the PDF generator for a subform PDF + const page = target === taskId ? `subform/subform-component/${selectedId}/` : ''; const query = pdf ? `pdf=1${extraQuery}` : ''; const suffix = query ? `?${query}` : ''; - window.location.hash = `#/instance/512345/75154373-aed4-41f7-95b4-e5b5115c2edc/${target}/${page}${suffix}`; + const path = `/instance/${instanceId}/${target}/${page}${suffix}`; + window.location.hash = `#${path}`; return renderWithoutInstanceAndLayout({ renderer: () => ( {renderChildren ?? ( - Normal form + + + )} ), - router: ({ children }) => ( - - {children} - - ), + router: ({ children }) => {children}, queries: { ...queries(), ...queryOverrides }, }); } +async function expectSubformPdf( + result: Awaited>, + selectedId: string, + expected: string, + absent: string, +) { + await waitFor(() => expect(result.container.querySelector('#readyForPrint')).not.toBeNull()); + expect(screen.getByText('Subform PDF')).toBeInTheDocument(); + expect(screen.getByDisplayValue(expected)).toBeInTheDocument(); + expect(screen.queryByDisplayValue(absent)).not.toBeInTheDocument(); + expect(screen.queryByText('Service task failed')).not.toBeInTheDocument(); + expect(screen.queryByDisplayValue('Main value')).not.toBeInTheDocument(); + expect(result.queries.fetchLayouts).toHaveBeenCalledWith('subform-pdf'); + expect(result.queries.fetchLayouts).toHaveBeenCalledWith('subform-layout'); + expect(result.queries.fetchFormData).toHaveBeenCalledWith(expect.stringContaining(selectedId), {}); +} + describe('PDF service task previews in frontend v4', () => { it.each([ [firstId, 'First subform', 'Second subform'], [secondId, 'Second subform', 'First subform'], ])('renders the requested subform %s while an earlier task is current', async (selectedId, expected, absent) => { const result = await renderPreview({ selectedId }); - await waitFor(() => expect(result.container.querySelector('#readyForPrint')).not.toBeNull()); - expect(screen.getByDisplayValue(expected)).toBeInTheDocument(); - expect(screen.queryByDisplayValue(absent)).not.toBeInTheDocument(); - expect(screen.queryByText('Normal form')).not.toBeInTheDocument(); - expect(result.queries.fetchLayouts).toHaveBeenCalledWith('subform-pdf'); - expect(result.queries.fetchFormData).toHaveBeenCalledWith(expect.stringContaining(selectedId), {}); + await expectSubformPdf(result, selectedId, expected, absent); expect(result.queries.fetchPdfFormat).not.toHaveBeenCalled(); result.unmount(); expect(result.mutations.doPatchFormData.mock).not.toHaveBeenCalled(); expect(result.mutations.doPatchMultipleFormData.mock).not.toHaveBeenCalled(); }); + it('renders the requested subform when the subform PDF task is current', async () => { + const result = await renderPreview({ selectedId: firstId, currentTaskId: taskId }); + await expectSubformPdf(result, firstId, 'First subform', 'Second subform'); + }); + it('keeps normal navigation to a future task blocked', async () => { await renderPreview({ pdf: false }); - await waitFor(() => expect(screen.queryByText('Normal form')).not.toBeInTheDocument()); - expect(screen.getByRole('button', { name: 'Gå til riktig prosessteg' })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Gå til riktig prosessteg' })).toBeInTheDocument()); + expect(screen.queryByText('Subform PDF')).not.toBeInTheDocument(); }); it('renders auto-PDF tasks without a layout while another task is current', async () => { - const result = await renderPreview({ - target: 'PdfAuto', - extraQuery: '&task=Task_1', - queryOverrides: { fetchLayoutSettings: async () => ({ pages: { order: ['Pdf'] } }) }, - }); + const result = await renderPreview({ target: 'PdfAuto', extraQuery: '&task=Task_1' }); await waitFor(() => expect(result.container.querySelector('#readyForPrint')).not.toBeNull()); - expect(screen.getByText('First subform')).toBeInTheDocument(); + expect(screen.getByText('Main value')).toBeInTheDocument(); expect(result.queries.fetchLayouts).toHaveBeenCalledWith('some-data-task'); }); it('renders a future PDF task with its own layout', async () => { const result = await renderPreview({ target: 'PdfLayout' }); await waitFor(() => expect(result.container.querySelector('#readyForPrint')).not.toBeNull()); - expect(screen.getByDisplayValue('First subform')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Main value')).toBeInTheDocument(); expect(result.queries.fetchLayouts).toHaveBeenCalledWith('pdf-layout'); expect(result.queries.fetchPdfFormat).not.toHaveBeenCalled(); result.unmount(); @@ -174,7 +289,6 @@ describe('PDF service task previews in frontend v4', () => { ), - queryOverrides: { fetchLayoutSettings: async () => ({ pages: { order: ['Pdf'] } }) }, }); await waitFor(() => expect(result.container.querySelector('#readyForPrint')).not.toBeNull()); expect(result.queries.fetchPdfFormat).toHaveBeenCalled(); From a732c2789a287633db0e217f61924dc01ad00d05 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Tue, 27 Oct 2026 15:28:54 +0100 Subject: [PATCH 4/8] test: remove obsolete PDF preview routing mocks The preview provider tree is being simplified at the task route. These tests duplicate the old routing assembly and are superseded by real-app Cypress coverage of PDF output and unchanged instance data. --- .../pdf/PdfServiceTaskPreview.test.tsx | 359 ------------------ 1 file changed, 359 deletions(-) delete mode 100644 src/features/pdf/PdfServiceTaskPreview.test.tsx diff --git a/src/features/pdf/PdfServiceTaskPreview.test.tsx b/src/features/pdf/PdfServiceTaskPreview.test.tsx deleted file mode 100644 index 0e86bab0ae..0000000000 --- a/src/features/pdf/PdfServiceTaskPreview.test.tsx +++ /dev/null @@ -1,359 +0,0 @@ -import React from 'react'; -import { createMemoryRouter, Outlet, RouterProvider } from 'react-router-dom'; -import type { PropsWithChildren } from 'react'; - -import { jest } from '@jest/globals'; -import { fireEvent, screen, waitFor } from '@testing-library/react'; - -import { getInstanceDataMock } from 'src/__mocks__/getInstanceDataMock'; -import { getLayoutSetsMock } from 'src/__mocks__/getLayoutSetsMock'; -import { getProcessDataMock } from 'src/__mocks__/getProcessDataMock'; -import { Form } from 'src/components/form/Form'; -import { PresentationComponent } from 'src/components/presentation/Presentation'; -import { ComponentRouting, NavigateToStartUrl, ProcessWrapper } from 'src/components/wrappers/ProcessWrapper'; -import { TaskOverrides } from 'src/core/contexts/TaskOverrides'; -import { FormProvider } from 'src/features/form/FormContext'; -import { FD } from 'src/features/formData/FormDataWrite'; -import { InstanceProvider } from 'src/features/instance/InstanceContext'; -import { useProcessTaskId } from 'src/features/instance/useProcessTaskId'; -import { PdfWrapper } from 'src/features/pdf/PdfWrapper'; -import { fetchInstanceData, fetchProcessState } from 'src/queries/queries'; -import { renderWithoutInstanceAndLayout } from 'src/test/renderWithProviders'; -import type { ILayoutSettings } from 'src/layout/common.generated'; -import type { ILayoutCollection } from 'src/layout/layout'; -import type { AppQueries } from 'src/queries/types'; - -const firstId = '2f5c6a01-30e1-4d72-a19e-162a90517d14'; -const secondId = '85b7f187-80f8-40aa-800e-39ffbc47c751'; -const taskId = 'SubformPdf'; -const instanceId = '512345/75154373-aed4-41f7-95b4-e5b5115c2edc'; - -function setup(currentTaskId = 'Task_1') { - jest.mocked(fetchProcessState).mockImplementation(async () => - getProcessDataMock((p) => { - p.processTasks = [ - p.currentTask!, - { elementId: taskId, elementType: 'ServiceTask', altinnTaskType: 'subformPdf' }, - { elementId: 'PdfAuto', elementType: 'ServiceTask', altinnTaskType: 'pdf' }, - { elementId: 'PdfLayout', elementType: 'ServiceTask', altinnTaskType: 'pdf' }, - ]; - if (currentTaskId !== 'Task_1') { - p.currentTask = { ...p.currentTask!, elementId: currentTaskId, altinnTaskType: 'subformPdf' }; - } - }), - ); - jest.mocked(fetchInstanceData).mockImplementation(async () => - getInstanceDataMock((instance) => { - const template = instance.data[0]; - instance.data.push( - { ...template, id: firstId, dataType: 'subform-data' }, - { ...template, id: secondId, dataType: 'subform-data' }, - ); - }), - ); -} - -function input(id: string, dataType: string, title: string) { - return { - id, - type: 'Input', - dataModelBindings: { simpleBinding: { dataType, field: 'value' } }, - textResourceBindings: { title }, - }; -} - -/** - * The documented v4 setup for subform PDFs: the service task's layout set uses the subform data type and holds a - * hidden copy of the Subform component, which the PDF URL routes into. The subform's own layout set has a PDF layout. - */ -function layouts(layoutSetId: string): ILayoutCollection { - switch (layoutSetId) { - case 'subform-pdf': - return { - ServiceTask: { - data: { - layout: [ - { id: 'service-task-failed', type: 'Paragraph', textResourceBindings: { title: 'Service task failed' } }, - { id: 'subform-component', type: 'Subform', layoutSet: 'subform-layout', hidden: true, tableColumns: [] }, - ], - }, - }, - } as ILayoutCollection; - case 'subform-layout': - return { - Subform: { data: { layout: [input('subform-input', 'subform-data', 'Subform value')] } }, - Pdf: { - data: { - layout: [ - { id: 'subform-pdf-title', type: 'Header', size: 'L', textResourceBindings: { title: 'Subform PDF' } }, - input('subform-pdf-value', 'subform-data', 'Subform value'), - ], - }, - }, - } as ILayoutCollection; - case 'pdf-layout': - return { - Pdf: { data: { layout: [input('pdf-layout-value', 'test-data-model', 'Main field')] } }, - } as ILayoutCollection; - default: - return { - FormLayout: { data: { layout: [input('main-input', 'test-data-model', 'Main field')] } }, - } as ILayoutCollection; - } -} - -function layoutSettings(layoutSetId: string): ILayoutSettings { - switch (layoutSetId) { - case 'subform-pdf': - return { pages: { order: ['ServiceTask'] } }; - case 'subform-layout': - return { pages: { order: ['Subform'], pdfLayoutName: 'Pdf' } }; - case 'pdf-layout': - return { pages: { order: ['Pdf'], pdfLayoutName: 'Pdf' } }; - default: - return { pages: { order: ['FormLayout'] } }; - } -} - -function queries(): Partial { - return { - fetchLayoutSets: async () => ({ - sets: [ - ...getLayoutSetsMock().sets, - { id: 'subform-pdf', dataType: 'subform-data', tasks: [taskId] }, - { id: 'pdf-layout', dataType: 'test-data-model', tasks: ['PdfLayout'] }, - ], - }), - fetchLayouts: async (layoutSetId) => layouts(layoutSetId), - fetchLayoutSettings: async (layoutSetId) => layoutSettings(layoutSetId), - fetchLayoutSettingsForInstance: async (layoutSetId) => layoutSettings(layoutSetId), - fetchDataModelSchema: async () => ({ type: 'object', properties: { value: { type: 'string' } } }), - fetchFormData: async (url) => ({ - value: url.includes(secondId) ? 'Second subform' : url.includes(firstId) ? 'First subform' : 'Main value', - }), - }; -} - -/** - * The instance routes from App.tsx, so a subform PDF URL routes into the Subform component - */ -function PreviewRouter({ children, path }: PropsWithChildren<{ path: string }>) { - const router = createMemoryRouter( - [ - { - path: 'instance/:instanceOwnerPartyId/:instanceGuid/:taskId', - element: children, - children: [ - { index: true, element: }, - { - path: ':pageKey', - children: [ - { - index: true, - element: ( - - - - - - ), - }, - { - path: ':componentId', - children: [ - { index: true, element: }, - { path: '*', element: }, - ], - }, - ], - }, - ], - }, - { path: '*', element: children }, - ], - { basename: '/ttd/test', initialEntries: [`/ttd/test${path}`], future: { v7_relativeSplatPath: true } }, - ); - - return ( - - ); -} - -async function renderPreview({ - selectedId = secondId, - pdf = true, - target = taskId, - currentTaskId = 'Task_1', - extraQuery = '', - renderChildren, - queryOverrides, -}: { - selectedId?: string; - pdf?: boolean; - target?: string; - currentTaskId?: string; - extraQuery?: string; - renderChildren?: React.ReactNode; - queryOverrides?: Partial; -} = {}) { - setup(currentTaskId); - // The same URL shape the backend sends to the PDF generator for a subform PDF - const page = target === taskId ? `subform/subform-component/${selectedId}/` : ''; - const query = pdf ? `pdf=1${extraQuery}` : ''; - const suffix = query ? `?${query}` : ''; - const path = `/instance/${instanceId}/${target}/${page}${suffix}`; - window.location.hash = `#${path}`; - return renderWithoutInstanceAndLayout({ - renderer: () => ( - - {renderChildren ?? ( - - - - - - )} - - ), - router: ({ children }) => {children}, - queries: { ...queries(), ...queryOverrides }, - }); -} - -async function expectSubformPdf( - result: Awaited>, - selectedId: string, - expected: string, - absent: string, -) { - await waitFor(() => expect(result.container.querySelector('#readyForPrint')).not.toBeNull()); - expect(screen.getByText('Subform PDF')).toBeInTheDocument(); - expect(screen.getByDisplayValue(expected)).toBeInTheDocument(); - expect(screen.queryByDisplayValue(absent)).not.toBeInTheDocument(); - expect(screen.queryByText('Service task failed')).not.toBeInTheDocument(); - expect(screen.queryByDisplayValue('Main value')).not.toBeInTheDocument(); - expect(result.queries.fetchLayouts).toHaveBeenCalledWith('subform-pdf'); - expect(result.queries.fetchLayouts).toHaveBeenCalledWith('subform-layout'); - expect(result.queries.fetchFormData).toHaveBeenCalledWith(expect.stringContaining(selectedId), {}); -} - -describe('PDF service task previews in frontend v4', () => { - it.each([ - [firstId, 'First subform', 'Second subform'], - [secondId, 'Second subform', 'First subform'], - ])('renders the requested subform %s while an earlier task is current', async (selectedId, expected, absent) => { - const result = await renderPreview({ selectedId }); - await expectSubformPdf(result, selectedId, expected, absent); - expect(result.queries.fetchPdfFormat).not.toHaveBeenCalled(); - result.unmount(); - expect(result.mutations.doPatchFormData.mock).not.toHaveBeenCalled(); - expect(result.mutations.doPatchMultipleFormData.mock).not.toHaveBeenCalled(); - }); - - it('renders the requested subform when the subform PDF task is current', async () => { - const result = await renderPreview({ selectedId: firstId, currentTaskId: taskId }); - await expectSubformPdf(result, firstId, 'First subform', 'Second subform'); - }); - - it('keeps normal navigation to a future task blocked', async () => { - await renderPreview({ pdf: false }); - await waitFor(() => expect(screen.getByRole('button', { name: 'Gå til riktig prosessteg' })).toBeInTheDocument()); - expect(screen.queryByText('Subform PDF')).not.toBeInTheDocument(); - }); - - it('renders auto-PDF tasks without a layout while another task is current', async () => { - const result = await renderPreview({ target: 'PdfAuto', extraQuery: '&task=Task_1' }); - await waitFor(() => expect(result.container.querySelector('#readyForPrint')).not.toBeNull()); - expect(screen.getByText('Main value')).toBeInTheDocument(); - expect(result.queries.fetchLayouts).toHaveBeenCalledWith('some-data-task'); - }); - - it('renders a future PDF task with its own layout', async () => { - const result = await renderPreview({ target: 'PdfLayout' }); - await waitFor(() => expect(result.container.querySelector('#readyForPrint')).not.toBeNull()); - expect(screen.getByDisplayValue('Main value')).toBeInTheDocument(); - expect(result.queries.fetchLayouts).toHaveBeenCalledWith('pdf-layout'); - expect(result.queries.fetchPdfFormat).not.toHaveBeenCalled(); - result.unmount(); - expect(result.mutations.doPatchMultipleFormData.mock).not.toHaveBeenCalled(); - }); - - it('keeps the legacy formatter for current-task PDF previews', async () => { - const result = await renderPreview({ - target: 'Task_1', - renderChildren: ( - - - - ), - }); - await waitFor(() => expect(result.container.querySelector('#readyForPrint')).not.toBeNull()); - expect(result.queries.fetchPdfFormat).toHaveBeenCalled(); - }); - - it('does not allow writes or saves from another task preview', async () => { - const log = jest.spyOn(window, 'logError').mockImplementation(() => {}); - try { - const result = await renderPreview({ - renderChildren: ( - - - - ), - }); - expect(screen.getByText('Second subform')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Try writing' })); - expect(screen.getByText('Second subform')).toBeInTheDocument(); - expect(screen.queryByText('Changed')).not.toBeInTheDocument(); - expect(log).toHaveBeenCalledWith('Tried to write to readOnly dataType "subform-data"'); - result.unmount(); - expect(result.mutations.doPatchFormData.mock).not.toHaveBeenCalled(); - expect(result.mutations.doPatchMultipleFormData.mock).not.toHaveBeenCalled(); - } finally { - log.mockRestore(); - } - }); - - it('preserves writable data in the existing current-task preview', async () => { - await renderPreview({ - target: 'Task_1', - renderChildren: ( - - - - ), - }); - fireEvent.click(screen.getByRole('button', { name: 'Try writing' })); - expect(screen.getByText('Changed')).toBeInTheDocument(); - }); - - it('preserves explicit task overrides within a PDF preview', async () => { - await renderPreview({ - renderChildren: ( - - - - ), - }); - expect(screen.getByText('Selected task: Task_1')).toBeInTheDocument(); - }); -}); - -function WriteProbe({ dataType = 'subform-data' }: { dataType?: string }) { - const reference = { dataType, field: 'value' }; - const value = FD.useCurrentPick(reference); - const setValue = FD.useSetLeafValue(); - return ( - <> - {String(value)} - - - ); -} - -function TaskProbe() { - return Selected task: {useProcessTaskId()}; -} From 6f60d5d8616a025dd7face7bc8281cd258530e0c Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Tue, 27 Oct 2026 15:28:55 +0100 Subject: [PATCH 5/8] refactor: establish PDF context before process routing PDF requests must render collected data independently of the active process task. Establish explicit task and model overrides in PdfWrapper before loading forms, and reuse subform component routing for element selection. Restore normal process and data-model hook behavior. Propagate the existing readOnly context through nested forms and writes, and omit the obsolete formatter for the new preview modes. --- src/App.tsx | 16 ++++---- src/components/wrappers/ProcessWrapper.tsx | 4 +- src/features/datamodel/DataModelsProvider.tsx | 8 +--- src/features/datamodel/useBindingSchema.tsx | 11 ------ src/features/form/FormContext.tsx | 23 +++++------ src/features/formData/FormDataWrite.tsx | 4 +- src/features/instance/useProcessTaskId.ts | 4 +- src/features/pdf/PdfWrapper.tsx | 38 +++++++++++++++---- src/features/pdf/usePdfFormatQuery.ts | 14 ++++--- src/hooks/useIsPdfOfOtherTask.ts | 14 ------- src/utils/layout/NodesContext.tsx | 7 ++-- 11 files changed, 67 insertions(+), 76 deletions(-) delete mode 100644 src/hooks/useIsPdfOfOtherTask.ts diff --git a/src/App.tsx b/src/App.tsx index 7c2122d2ea..4a3591f11c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -66,13 +66,15 @@ export const App = () => ( - - - - - - + + + + + + + + + } > ; @@ -123,7 +121,7 @@ export function ProcessWrapper({ children }: PropsWithChildren) { ); } - if (isWrongTask && !isPdf) { + if (isWrongTask) { return ( diff --git a/src/features/datamodel/DataModelsProvider.tsx b/src/features/datamodel/DataModelsProvider.tsx index 425ed23195..720c6a8a42 100644 --- a/src/features/datamodel/DataModelsProvider.tsx +++ b/src/features/datamodel/DataModelsProvider.tsx @@ -28,7 +28,6 @@ import { useCurrentLayoutSetId } from 'src/features/form/layoutSets/useCurrentLa import { useFormDataQuery } from 'src/features/formData/useFormDataQuery'; import { useInstanceDataElements, useInstanceDataQuery } from 'src/features/instance/InstanceContext'; import { MissingRolesError } from 'src/features/instantiate/containers/MissingRolesError'; -import { useNavigationParam } from 'src/hooks/navigation'; import { useIsPdf } from 'src/hooks/useIsPdf'; import { isAxiosError } from 'src/utils/isAxiosError'; import { HttpStatusCodes } from 'src/utils/network/networking'; @@ -307,12 +306,7 @@ function LoadInitialData({ dataType, overrideDataElement }: LoaderProps & { over const setDataElementId = useSelector((state) => state.setDataElementId); const setError = useSelector((state) => state.setError); const dataElements = useInstanceDataElements(dataType); - const isPdf = useIsPdf(); - const subformDataElementId = useNavigationParam('dataElementId'); - // A subform PDF layout uses the subform's data type as its default model. Select the - // requested element before the usual single-element lookup (there can be many subforms). - const pdfDataElementId = isPdf ? dataElements?.find((element) => element.id === subformDataElementId)?.id : undefined; - const dataElementId = overrideDataElement ?? pdfDataElementId ?? getFirstDataElementId(dataElements, dataType); + const dataElementId = overrideDataElement ?? getFirstDataElementId(dataElements, dataType); const metaData = useApplicationMetadata(); const url = useDataModelUrl({ diff --git a/src/features/datamodel/useBindingSchema.tsx b/src/features/datamodel/useBindingSchema.tsx index 53ca53762b..0fb64249e2 100644 --- a/src/features/datamodel/useBindingSchema.tsx +++ b/src/features/datamodel/useBindingSchema.tsx @@ -14,9 +14,7 @@ import { useInstanceDataQuery, useLaxInstanceId } from 'src/features/instance/In import { useProcessTaskId } from 'src/features/instance/useProcessTaskId'; import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; import { useAllowAnonymous } from 'src/features/stateless/getAllowAnonymous'; -import { useNavigationParam } from 'src/hooks/navigation'; import { useAsRef } from 'src/hooks/useAsRef'; -import { useIsPdf } from 'src/hooks/useIsPdf'; import { getAnonymousStatelessDataModelUrl, getStatefulDataModelUrl, @@ -37,8 +35,6 @@ export function useCurrentDataModelDataElementId() { const taskId = useProcessTaskId(); const overriddenDataElementId = useTaskOverrides()?.dataModelElementId; - const isPdf = useIsPdf(); - const pdfSubformId = useNavigationParam('dataElementId'); // Instance data elements will update often (after each save), so we have to use a selector to make // sure components don't re-render too often. @@ -48,13 +44,6 @@ export function useCurrentDataModelDataElementId() { return overriddenDataElementId; } - if (isPdf && pdfSubformId) { - const dataType = getCurrentDataTypeForApplication({ application, layoutSets, taskId }); - const subform = data.data.find((element) => element.id === pdfSubformId && element.dataType === dataType); - if (subform) { - return subform.id; - } - } return getCurrentTaskDataElementId({ application, dataElements: data.data, taskId, layoutSets }); }, }).data; diff --git a/src/features/form/FormContext.tsx b/src/features/form/FormContext.tsx index f7028fd727..3487621a1b 100644 --- a/src/features/form/FormContext.tsx +++ b/src/features/form/FormContext.tsx @@ -16,16 +16,12 @@ import { PaymentInformationProvider } from 'src/features/payment/PaymentInformat import { PaymentProvider } from 'src/features/payment/PaymentProvider'; import { ValidationProvider } from 'src/features/validation/validationContext'; import { useNavigationParam } from 'src/hooks/navigation'; -import { useIsPdfOfOtherTask } from 'src/hooks/useIsPdfOfOtherTask'; import { FormPrefetcher } from 'src/queries/formPrefetcher'; import { NodesProvider } from 'src/utils/layout/NodesContext'; export interface FormContext { - // Set this if this form context is provided somewhere it's not expected we should write data to the data model. - // By setting this to true, no effects like 'preselectedOptionIndex' runs (which might try to change the data model). - // This should always be set to true when summarizing a previous task. It's important to note that it doesn't - // prevent any write operations from happening in case components inside try to write new form data, but it will - // prevent automatic effects from happening. + // Suppress automatic component effects and prevent writes to form data. + // Use this when rendering a summary or an immutable PDF preview. readOnly?: boolean; } @@ -41,10 +37,11 @@ export function useIsInFormContext() { /** * This helper-context provider is used to provide all the contexts needed for forms to work */ -export function FormProvider({ children, readOnly = false }: React.PropsWithChildren) { - const isEmbedded = useIsInFormContext(); - const isPdfOfOtherTask = useIsPdfOfOtherTask(); - const effectiveReadOnly = readOnly || isPdfOfOtherTask; +export function FormProvider({ children, readOnly: ownReadOnly = false }: React.PropsWithChildren) { + const parent = useLaxCtx(); + const isEmbedded = parent !== ContextNotProvided; + // Embedded subforms must preserve a read-only summary or preview context. + const readOnly = ownReadOnly || (parent !== ContextNotProvided && parent.readOnly === true); const instanceOwnerPartyId = useNavigationParam('instanceOwnerPartyId'); const instanceGuid = useNavigationParam('instanceGuid'); const hasProcess = !!(instanceOwnerPartyId && instanceGuid); @@ -59,16 +56,16 @@ export function FormProvider({ children, readOnly = false }: React.PropsWithChil - + - + {children} diff --git a/src/features/formData/FormDataWrite.tsx b/src/features/formData/FormDataWrite.tsx index 3500d5fdf7..bf6c972197 100644 --- a/src/features/formData/FormDataWrite.tsx +++ b/src/features/formData/FormDataWrite.tsx @@ -368,7 +368,7 @@ function useIsSavingFormData() { ); } -export function FormDataWriteProvider({ children, locked = false }: PropsWithChildren<{ locked?: boolean }>) { +export function FormDataWriteProvider({ children, readOnly = false }: PropsWithChildren<{ readOnly?: boolean }>) { const proxies = useFormDataWriteProxies(); const ruleConnections = useRuleConnections(); const allDataTypes = DataModels.useReadableDataTypes(); @@ -394,7 +394,7 @@ export function FormDataWriteProvider({ children, locked = false }: PropsWithChi lastSavedData: initialData[dt], hasUnsavedChanges: false, dataElementId: dataElementIds[dt], - readonly: locked || !writableDataTypes.includes(dt), + readonly: readOnly || !writableDataTypes.includes(dt), isDefault: dt === defaultDataType, }; return dm; diff --git a/src/features/instance/useProcessTaskId.ts b/src/features/instance/useProcessTaskId.ts index fe4d77a0d3..7da47a19c3 100644 --- a/src/features/instance/useProcessTaskId.ts +++ b/src/features/instance/useProcessTaskId.ts @@ -1,12 +1,10 @@ import { useTaskOverrides } from 'src/core/contexts/TaskOverrides'; import { useProcessQuery } from 'src/features/instance/useProcessQuery'; import { useNavigationParam } from 'src/hooks/navigation'; -import { useIsPdf } from 'src/hooks/useIsPdf'; export function useProcessTaskId() { const overriddenTaskId = useTaskOverrides()?.taskId; const processTaskId = useProcessQuery().data?.currentTask?.elementId; const urlTaskId = useNavigationParam('taskId'); - const isPdf = useIsPdf(); - return overriddenTaskId ?? (isPdf ? (urlTaskId ?? processTaskId) : (processTaskId ?? urlTaskId)); + return overriddenTaskId ?? processTaskId ?? urlTaskId; } diff --git a/src/features/pdf/PdfWrapper.tsx b/src/features/pdf/PdfWrapper.tsx index 38930b65ea..a98249b82b 100644 --- a/src/features/pdf/PdfWrapper.tsx +++ b/src/features/pdf/PdfWrapper.tsx @@ -1,15 +1,18 @@ import React, { useEffect } from 'react'; +import { Outlet } from 'react-router-dom'; import type { PropsWithChildren } from 'react'; import cn from 'classnames'; +import { TaskOverrides } from 'src/core/contexts/TaskOverrides'; import { useDevToolsStore } from 'src/features/devtools/data/DevToolsStore'; -import { useGetTaskTypeById } from 'src/features/instance/useProcessQuery'; +import { FormProvider } from 'src/features/form/FormContext'; +import { useCurrentLayoutSet } from 'src/features/form/layoutSets/useCurrentLayoutSet'; +import { useInstanceDataElements } from 'src/features/instance/InstanceContext'; import { PdfForServiceTask, PdfFromLayout } from 'src/features/pdf/PdfFromLayout'; import classes from 'src/features/pdf/PDFView.module.css'; import { useNavigationParam } from 'src/hooks/navigation'; import { useIsPdf } from 'src/hooks/useIsPdf'; -import { ProcessTaskType } from 'src/types'; export const usePdfModeActive = (): boolean => { const previewPDF = useDevToolsStore((state) => state.pdfPreview); @@ -17,16 +20,19 @@ export const usePdfModeActive = (): boolean => { return pdfIsSetInUrl || previewPDF; }; -export function PdfWrapper({ children }: PropsWithChildren) { +export function PdfWrapper({ children, isTaskRoute = false }: PropsWithChildren<{ isTaskRoute?: boolean }>) { const previewPDF = useDevToolsStore((state) => state.pdfPreview); const setPdfPreview = useDevToolsStore((state) => state.actions.setPdfPreview); const renderInstead = useIsPdf(); + const componentId = useNavigationParam('componentId'); const taskId = useNavigationParam('taskId'); - const taskType = useGetTaskTypeById()(taskId); + const layoutSet = useCurrentLayoutSet(taskId); + const requestedElementId = useNavigationParam('dataElementId'); + const dataElementId = useInstanceDataElements(layoutSet?.dataType).find((e) => e.id === requestedElementId)?.id; useEffect(() => { - if (previewPDF) { + if (previewPDF && !isTaskRoute) { waitForPrint().then((success) => { if (success) { window.print(); @@ -36,14 +42,32 @@ export function PdfWrapper({ children }: PropsWithChildren) { setPdfPreview(false); }); } - }, [previewPDF, setPdfPreview]); + }, [previewPDF, setPdfPreview, isTaskRoute]); - const PdfComponent = taskType === ProcessTaskType.Service ? PdfForServiceTask : PdfFromLayout; + const PdfComponent = layoutSet ? PdfFromLayout : PdfForServiceTask; if (renderInstead) { + if (isTaskRoute && layoutSet) { + // PDF requests select their rendering context independently of the active process task. + // Subforms keep using component routing to select their layout and data element. + return ( + + {componentId ? : } + + ); + } return ; } + // The task-route wrapper only intercepts PDF requests. Developer previews render inside the form. + if (isTaskRoute) { + return children; + } + return ( <>
{children}
diff --git a/src/features/pdf/usePdfFormatQuery.ts b/src/features/pdf/usePdfFormatQuery.ts index 57634c5061..1ef35195be 100644 --- a/src/features/pdf/usePdfFormatQuery.ts +++ b/src/features/pdf/usePdfFormatQuery.ts @@ -7,7 +7,9 @@ import { useAppQueries } from 'src/core/contexts/AppQueriesProvider'; import { type QueryDefinition } from 'src/core/queries/usePrefetchQuery'; import { useCurrentDataModelDataElementId } from 'src/features/datamodel/useBindingSchema'; import { useLaxInstanceId } from 'src/features/instance/InstanceContext'; -import { useIsPdfOfOtherTask } from 'src/hooks/useIsPdfOfOtherTask'; +import { useGetAltinnTaskType, useProcessQuery } from 'src/features/instance/useProcessQuery'; +import { useProcessTaskId } from 'src/features/instance/useProcessTaskId'; +import { useIsPdf } from 'src/hooks/useIsPdf'; import type { IPdfFormat } from 'src/features/pdf/types'; // Also used for prefetching @see formPrefetcher.ts @@ -17,13 +19,15 @@ export function usePdfFormatQueryDef( dataElementId?: string, ): QueryDefinition { const { fetchPdfFormat } = useAppQueries(); - // The legacy formatter only understands the current process task. Other task - // previews use their own layout settings, including when prefetching. - const isPdfOfOtherTask = useIsPdfOfOtherTask(); + const taskId = useProcessTaskId(); + const currentTaskId = useProcessQuery().data?.currentTask?.elementId; + const taskType = useGetAltinnTaskType()(taskId); + // The obsolete formatter supports current data-task PDFs only. + const isNewPdf = useIsPdf() && (taskId !== currentTaskId || taskType === 'pdf' || taskType === 'subformPdf'); return { queryKey: ['fetchPdfFormat', { instanceId, dataElementId }], queryFn: instanceId && dataElementId ? () => fetchPdfFormat(instanceId, dataElementId) : skipToken, - enabled: enabled && !isPdfOfOtherTask && !!instanceId && !!dataElementId, + enabled: enabled && !isNewPdf && !!instanceId && !!dataElementId, gcTime: 0, }; } diff --git a/src/hooks/useIsPdfOfOtherTask.ts b/src/hooks/useIsPdfOfOtherTask.ts deleted file mode 100644 index 25604e8d8e..0000000000 --- a/src/hooks/useIsPdfOfOtherTask.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useProcessQuery } from 'src/features/instance/useProcessQuery'; -import { useNavigationParam } from 'src/hooks/navigation'; -import { useIsPdf } from 'src/hooks/useIsPdf'; - -/** - * A preview can render a later PDF task while the current task's data is still writable. - * Rendering that other task must not change the current task's form data. - */ -export function useIsPdfOfOtherTask() { - const isPdf = useIsPdf(); - const taskId = useNavigationParam('taskId'); - const currentTaskId = useProcessQuery().data?.currentTask?.elementId; - return isPdf && taskId !== undefined && currentTaskId !== undefined && taskId !== currentTaskId; -} diff --git a/src/utils/layout/NodesContext.tsx b/src/utils/layout/NodesContext.tsx index e1a098c6b9..d3b047d8ed 100644 --- a/src/utils/layout/NodesContext.tsx +++ b/src/utils/layout/NodesContext.tsx @@ -22,7 +22,6 @@ import { } from 'src/features/validation/validationContext'; import { ValidationStorePlugin } from 'src/features/validation/ValidationStorePlugin'; import { useNavigationParam } from 'src/hooks/navigation'; -import { useIsPdf } from 'src/hooks/useIsPdf'; import { TaskKeys } from 'src/hooks/useNavigatePage'; import { useComponentIdMutator } from 'src/utils/layout/DataModelLocation'; import { GeneratorGlobalProvider, GeneratorInternal } from 'src/utils/layout/generator/GeneratorContext'; @@ -345,10 +344,10 @@ function ProvideGlobalContext({ children, registry }: PropsWithChildren<{ regist function useIsInTaskTransition() { const currentTask = useProcessQuery().data?.currentTask?.elementId; const taskIdFromUrl = useNavigationParam('taskId'); - const isPdf = useIsPdf(); + const readOnly = Store.useSelector((state) => state.readOnly); - // A PDF intentionally renders its URL task, which may differ from the current process task. - if (isPdf) { + // Read-only forms can intentionally render another process task. + if (readOnly) { return false; } From f091fb196db82c8feb612df07831d4aa302cb9e9 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Tue, 27 Oct 2026 15:29:02 +0100 Subject: [PATCH 6/8] test: cover PDF previews with one shared app instance Exercise legacy, configured-layout, automatic and selected-subform PDFs against the real preview endpoint. Verify that rendering preserves persisted data and process state, including while a service task is active. Share one form setup between two test groups to avoid repeated instance creation and form filling. Keep the suite localtest-only until the backend preview release and test-app deployment are available. --- .../integration/service-task/pdf-preview.ts | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 test/e2e/integration/service-task/pdf-preview.ts diff --git a/test/e2e/integration/service-task/pdf-preview.ts b/test/e2e/integration/service-task/pdf-preview.ts new file mode 100644 index 0000000000..edab3f06a4 --- /dev/null +++ b/test/e2e/integration/service-task/pdf-preview.ts @@ -0,0 +1,201 @@ +import { AppFrontend } from 'test/e2e/pageobjects/app-frontend'; + +import { getInstanceIdRegExp } from 'src/utils/instanceIdRegExp'; +import type { IInstance, IProcess } from 'src/types/shared'; + +type PreviewInstance = IInstance & { process?: IProcess }; + +const appFrontend = new AppFrontend(); +const firstSubform = 'First preview subform'; +const secondSubform = 'Second preview subform'; + +// Enable in shared environments after the backend preview release and test-app deployment. +const describePreview = Cypress.env('type') === 'localtest' ? describe : describe.skip; + +// Render data already collected in earlier tasks without advancing the process. +describePreview('PDF service task previews', { testIsolation: false }, () => { + // Reuse one filled instance. Only the final test advances it to a service task. + let formUrl = ''; + + before(() => { + cy.startAppInstance(appFrontend.apps.serviceTask, { cyUser: 'manager' }); + cy.findByRole('textbox', { name: 'En tekst i Task_Utfylling1' }).type('Preview main form'); + for (const value of [firstSubform, secondSubform]) { + cy.get('#subform-Subform-z8we7d-add-button').click(); + cy.findByRole('textbox', { name: 'Subform tekstfelt' }).type(value); + cy.findByRole('button', { name: 'Ferdig' }).click(); + } + cy.waitUntilSaved(); + cy.findByRole('button', { name: 'Neste' }).click(); + cy.get('#finishedLoading').should('exist'); + cy.waitForNetworkIdle('*', '*', 500); + cy.findByRole('textbox', { name: 'En tekst i Task_Utfylling2' }).type('Preview second task'); + cy.findByRole('textbox', { name: 'En tekst i Task_Utfylling2' }).should('have.value', 'Preview second task'); + cy.get('body').click(); + cy.waitUntilSaved(); + if (Cypress.env('type') === 'localtest') { + cy.setCookie('frontendVersion', encodeURIComponent(`http://${Cypress.env('host') || 'localhost:8080'}/`)); + } + cy.location('href').then((href) => { + formUrl = href; + }); + }); + + beforeEach(() => { + cy.setEmulatedMedia(); + cy.visit(formUrl); + cy.get('#finishedLoading').should('exist'); + const instanceId = getInstanceIdRegExp().exec(formUrl)?.[1]; + expect(instanceId).to.be.a('string'); + const appRoot = formUrl.split('#')[0].replace(/\/$/, ''); + const instanceUrl = `${appRoot}/instances/${instanceId}`; + cy.wrap(instanceUrl).as('instanceUrl'); + snapshotInstance(instanceUrl, 'Task_Utfylling2'); + }); + + it('previews current and configured tasks without changing the instance', { retries: 0 }, () => { + cy.log('Normal navigation still blocks another task'); + cy.location('href').then((href) => { + cy.visit(previewUrl(href, 'Task_PDF_Layout/Side1').replace('?pdf=1', '')); + }); + cy.findByRole('button', { name: 'Gå til riktig prosessteg' }).should('be.visible').click(); + cy.get('#finishedLoading').should('exist'); + assertInstanceUnchanged(); + + cy.log('Current-task PDF'); + requestPreview(); + cy.testPdf({ + freeze: false, + callback: () => cy.findByText('Preview second task').should('be.visible'), + }); + assertInstanceUnchanged(); + + cy.log('Configured PDF layout'); + requestPreview('Task_PDF_Layout'); + cy.testPdf({ + freeze: false, + buildUrl: (href) => previewUrl(href, 'Task_PDF_Layout'), + callback: () => { + cy.findByText('Preview main form').should('be.visible'); + cy.findByText('Preview second task').should('be.visible'); + }, + }); + assertInstanceUnchanged(); + + cy.log('Automatic PDF assembled from multiple tasks'); + requestPreview('Task_PDF_Auto'); + cy.testPdf({ + freeze: false, + buildUrl: (href) => `${previewUrl(href, 'Task_PDF_Auto')}&task=Task_Utfylling1&task=Task_Utfylling2`, + callback: () => { + cy.findByText('Preview main form').should('be.visible'); + cy.findByText('Preview second task').should('be.visible'); + cy.findByText(firstSubform).should('be.visible'); + cy.findByText(secondSubform).should('be.visible'); + }, + }); + assertInstanceUnchanged(); + }); + + it('renders previous-task subforms while a service task is active', { retries: 0 }, () => { + cy.findByRole('radiogroup', { name: /Skal Task_Fail servicetask feile\?/ }) + .findByRole('radio', { name: 'Ja' }) + .click(); + cy.waitUntilSaved(); + cy.get('@instanceUrl').then((instanceUrl) => { + cy.getCookie('XSRF-TOKEN').then((token) => { + // The deliberate failure leaves the real backend process on Task_Fail. + cy.request({ + method: 'PUT', + url: `${instanceUrl}/process/next`, + headers: { 'X-XSRF-TOKEN': token?.value }, + failOnStatusCode: false, + timeout: 120_000, + }) + .its('status') + .should('eq', 500); + }); + cy.location('href').then((href) => { + cy.visit(previewUrl(href, 'Task_Fail/Side1').replace('?pdf=1', '')); + }); + cy.get('#finishedLoading').should('exist'); + snapshotInstance(instanceUrl, 'Task_Fail'); + }); + cy.get('@instanceBefore').then((instance) => { + const subforms = instance.data.filter((element) => element.dataType === 'SubformModel'); + expect(subforms).to.have.length(2); + subforms.forEach((element, index) => { + requestPreview('SubformPdf', element.id); + cy.testPdf({ + freeze: false, + buildUrl: (href) => previewUrl(href, `SubformPdf/subform/Subform-z8we7d/${element.id}/`), + callback: () => { + cy.findByRole('heading', { name: 'Selected subform PDF layout' }).should('be.visible'); + cy.findByRole('textbox', { name: 'Selected subform' }).should( + 'have.value', + index === 0 ? firstSubform : secondSubform, + ); + cy.findByDisplayValue(index === 0 ? secondSubform : firstSubform, { timeout: 0 }).should('not.exist'); + cy.findByDisplayValue('Preview main form', { timeout: 0 }).should('not.exist'); + cy.findByText('Service task wrapper must not appear in the subform PDF', { timeout: 0 }).should( + 'not.exist', + ); + }, + }); + }); + }); + assertInstanceUnchanged('Task_Fail'); + cy.setEmulatedMedia(); + }); +}); + +function previewUrl(href: string, taskPath: string) { + const instanceId = getInstanceIdRegExp().exec(href)?.[1]; + const before = href.split(getInstanceIdRegExp())[0]; + return `${before}${instanceId}/${taskPath}?pdf=1`; +} + +function requestPreview(taskId?: string, dataElementId?: string) { + cy.get('@instanceUrl').then((instanceUrl) => { + cy.request({ + url: `${instanceUrl}/pdf/preview`, + qs: { ...(taskId ? { taskId } : {}), ...(dataElementId ? { dataElementId } : {}) }, + encoding: 'binary', + timeout: 120_000, + }).then((response) => { + expect(response.status).to.equal(200); + expect(response.headers['content-type']).to.include('application/pdf'); + expect(response.body.slice(0, 5)).to.equal('%PDF-'); + }); + }); +} + +function snapshotInstance(instanceUrl: string, currentTask: string) { + cy.request(instanceUrl).then(({ body }) => { + expect(body.process?.currentTask?.elementId).to.equal(currentTask); + cy.wrap(body).as('instanceBefore'); + const models: Record = {}; + body.data.forEach((element) => { + cy.request(`${instanceUrl}/data/${element.id}`).then(({ body: data }) => { + models[element.id] = data; + }); + }); + cy.wrap(models).as('modelsBefore'); + }); +} + +function assertInstanceUnchanged(currentTask = 'Task_Utfylling2') { + cy.get('@instanceUrl').then((instanceUrl) => { + cy.get('@instanceBefore').then((before) => { + cy.request(instanceUrl).then(({ body }) => { + expect(body).to.deep.equal(before); + expect(body.process?.currentTask?.elementId).to.equal(currentTask); + cy.get>('@modelsBefore').then((models) => { + body.data.forEach((element) => { + cy.request(`${instanceUrl}/data/${element.id}`).its('body').should('deep.equal', models[element.id]); + }); + }); + }); + }); + }); +} From e5b6e5e10da6bbd2526f1bd0e67ce268943267e8 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Tue, 27 Oct 2026 18:21:45 +0100 Subject: [PATCH 7/8] test: render the configured automatic PDF task Limit the failure-screen layout mock to its initial request. Render the real automatic-PDF task on reload so PDF coverage uses unmodified app configuration. --- test/e2e/integration/service-task/service-task.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/test/e2e/integration/service-task/service-task.ts b/test/e2e/integration/service-task/service-task.ts index 10398d59d4..8f44820bea 100644 --- a/test/e2e/integration/service-task/service-task.ts +++ b/test/e2e/integration/service-task/service-task.ts @@ -92,12 +92,10 @@ describe('Service task', () => { }); it('should display something sensible when there is no layout-set for the service task', { retries: 0 }, () => { - cy.intercept('**/layoutsets', (req) => { + cy.intercept({ url: '**/layoutsets', times: 1 }, (req) => { req.on('response', (res) => { - // We do some trickery here. Ordinarily it's the PDF service task we would test, but we can't really reach - // that from the test (it will only be reached from the real PDF generator). However, the 'Fail' task is also - // a service task, so we can remove the custom layout-set here to simulate what happens in the PDF generator - // for a PDF-generating service-task. + // Remove the custom layout for the initial failure-screen check only. + // The PDF reload below uses the real automatic-PDF task and unmodified layout sets. const layoutSets: ILayoutSets = JSON.parse(res.body); layoutSets.sets = layoutSets.sets.filter((set) => set.id !== 'Fail'); res.send(layoutSets); @@ -122,8 +120,8 @@ describe('Service task', () => { queryArgs.append(SearchParams.PdfForTask, task); } - const baseUrl = href.split('?')[0]; - return `${baseUrl}?${queryArgs.toString()}`; + const appInstanceUrl = href.split('/Task_Fail')[0]; + return `${appInstanceUrl}/Task_PDF_Auto?${queryArgs.toString()}`; }, callback: () => { cy.expectPageBreaks(2); From 57f63528dc663b15876886dd2ebdf6fdd56f2c0d Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Tue, 27 Oct 2026 18:21:48 +0100 Subject: [PATCH 8/8] test: enable PDF preview coverage in staging The Service Task app with the preview backend and subform fixtures has been deployed to staging. Remove the localtest-only skip so CI exercises the real preview endpoints there. --- test/e2e/integration/service-task/pdf-preview.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/e2e/integration/service-task/pdf-preview.ts b/test/e2e/integration/service-task/pdf-preview.ts index edab3f06a4..2596103737 100644 --- a/test/e2e/integration/service-task/pdf-preview.ts +++ b/test/e2e/integration/service-task/pdf-preview.ts @@ -9,11 +9,8 @@ const appFrontend = new AppFrontend(); const firstSubform = 'First preview subform'; const secondSubform = 'Second preview subform'; -// Enable in shared environments after the backend preview release and test-app deployment. -const describePreview = Cypress.env('type') === 'localtest' ? describe : describe.skip; - // Render data already collected in earlier tasks without advancing the process. -describePreview('PDF service task previews', { testIsolation: false }, () => { +describe('PDF service task previews', { testIsolation: false }, () => { // Reuse one filled instance. Only the final test advances it to a service task. let formUrl = '';