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 = () => ( - - - - - - + + + + + + + + + } > ) { - const isEmbedded = useIsInFormContext(); +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); @@ -56,7 +56,7 @@ export function FormProvider({ children, readOnly = false }: React.PropsWithChil - + ) { 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: readOnly || !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/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 06b9aa3e46..1ef35195be 100644 --- a/src/features/pdf/usePdfFormatQuery.ts +++ b/src/features/pdf/usePdfFormatQuery.ts @@ -7,6 +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 { 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 @@ -16,10 +19,15 @@ export function usePdfFormatQueryDef( dataElementId?: string, ): QueryDefinition { const { fetchPdfFormat } = useAppQueries(); + 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 && !!instanceId && !!dataElementId, + enabled: enabled && !isNewPdf && !!instanceId && !!dataElementId, gcTime: 0, }; } diff --git a/src/utils/layout/NodesContext.tsx b/src/utils/layout/NodesContext.tsx index eca7564a6b..d3b047d8ed 100644 --- a/src/utils/layout/NodesContext.tsx +++ b/src/utils/layout/NodesContext.tsx @@ -344,6 +344,12 @@ function ProvideGlobalContext({ children, registry }: PropsWithChildren<{ regist function useIsInTaskTransition() { const currentTask = useProcessQuery().data?.currentTask?.elementId; const taskIdFromUrl = useNavigationParam('taskId'); + const readOnly = Store.useSelector((state) => state.readOnly); + + // Read-only forms can intentionally render another process task. + if (readOnly) { + 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 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..2596103737 --- /dev/null +++ b/test/e2e/integration/service-task/pdf-preview.ts @@ -0,0 +1,198 @@ +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'; + +// Render data already collected in earlier tasks without advancing the process. +describe('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]); + }); + }); + }); + }); + }); +} 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);