diff --git a/frontend/src/__tests__/app/preview-letter-template/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/preview-letter-template/__snapshots__/page.test.tsx.snap index 0133487bb..8f328ab0b 100644 --- a/frontend/src/__tests__/app/preview-letter-template/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/preview-letter-template/__snapshots__/page.test.tsx.snap @@ -272,7 +272,7 @@ exports[`authoring letter template with VALIDATION_FAILED status matches snapsho role="tabpanel" >
+ + +
+ + + - - )} -

- {backLinkText} -

+ + {backLinkText} + - - + + + +
+
+ +
+
+
+ {showRenderer && } + + {showSubmitForm && ( + + + + {submitText} + + )} +

+ {backLinkText} +

+
+
+ + ); diff --git a/frontend/src/components/molecules/LetterRender/LetterRenderForm.tsx b/frontend/src/components/molecules/LetterRender/LetterRenderForm.tsx index 690696e74..8bbca6e03 100644 --- a/frontend/src/components/molecules/LetterRender/LetterRenderForm.tsx +++ b/frontend/src/components/molecules/LetterRender/LetterRenderForm.tsx @@ -9,8 +9,10 @@ import { } from '@content/example-recipients'; import { NHSNotifyButton } from '@atoms/NHSNotifyButton/NHSNotifyButton'; import * as NHSNotifyForm from '@atoms/NHSNotifyForm'; +import { useLetterRenderPolling } from '@providers/letter-render-polling-provider'; import type { PersonalisedRenderKey } from '@utils/types'; import styles from './LetterRenderForm.module.scss'; +import { PERSONALISATION_FORMDATA_PREFIX } from '@utils/constants'; type LetterRenderFormProps = { template: AuthoringLetterTemplate; @@ -19,6 +21,7 @@ type LetterRenderFormProps = { export function LetterRenderForm({ template, tab }: LetterRenderFormProps) { const { letterRender: copy } = content.components; + const { isAnyTabPolling } = useLetterRenderPolling(); const exampleRecipients = tab === 'shortFormRender' @@ -33,14 +36,14 @@ export function LetterRenderForm({ template, tab }: LetterRenderFormProps) {

{copy.pdsSection.heading}

{copy.pdsSection.hint}

- + - + @@ -68,7 +71,7 @@ export function LetterRenderForm({ template, tab }: LetterRenderFormProps) { @@ -78,7 +81,16 @@ export function LetterRenderForm({ template, tab }: LetterRenderFormProps) { )} - + + + + + {copy.updatePreviewButton} diff --git a/frontend/src/components/molecules/LetterRender/LetterRenderTab.module.scss b/frontend/src/components/molecules/LetterRender/LetterRenderTab.module.scss index d6d138f76..0bcb4e7dc 100644 --- a/frontend/src/components/molecules/LetterRender/LetterRenderTab.module.scss +++ b/frontend/src/components/molecules/LetterRender/LetterRenderTab.module.scss @@ -1,7 +1,13 @@ +.tabRow { + display: flex; +} + .iframeColumn { + min-height: 1200px; + iframe { width: 100%; - height: 1200px; + height: 100%; border: none; } } diff --git a/frontend/src/components/molecules/LetterRender/LetterRenderTab.tsx b/frontend/src/components/molecules/LetterRender/LetterRenderTab.tsx index 41be7ab5d..d34ac8e4d 100644 --- a/frontend/src/components/molecules/LetterRender/LetterRenderTab.tsx +++ b/frontend/src/components/molecules/LetterRender/LetterRenderTab.tsx @@ -5,13 +5,21 @@ import type { FormState, } from 'nhs-notify-web-template-management-utils'; import { getBasePath } from '@utils/get-base-path'; -import { NHSNotifyFormProvider } from '@providers/form-provider'; +import { + NHSNotifyFormProvider, + useNHSNotifyForm, +} from '@providers/form-provider'; import type { RenderDetails } from 'nhs-notify-web-template-management-types'; import { LetterRenderForm } from './LetterRenderForm'; import { LetterRenderIframe } from './LetterRenderIframe'; import { updateLetterPreview } from './server-action'; import type { PersonalisedRenderKey } from '@utils/types'; import styles from './LetterRenderTab.module.scss'; +import { PollLetterRender } from '@molecules/PollLetterRender/PollLetterRender'; +import { PERSONALISATION_FORMDATA_PREFIX } from '@utils/constants'; +import content from '@content/content'; + +const { loadingText } = content.components.letterRender; type LetterRenderTabProps = { template: AuthoringLetterTemplate; @@ -23,18 +31,10 @@ function buildPdfUrl(template: AuthoringLetterTemplate, fileName: string) { return `${basePath}/files/${template.clientId}/renders/${template.id}/${fileName}`; } -function getPersonalisedRender( +function derivePdfUrl( template: AuthoringLetterTemplate, - tab: PersonalisedRenderKey -): RenderDetails | undefined { - return template.files[tab]; -} - -function initialisePdfUrl( - template: AuthoringLetterTemplate, - tab: PersonalisedRenderKey + personalisedRender: RenderDetails | undefined ): string | null { - const personalisedRender = getPersonalisedRender(template, tab); const initialRender = template.files.initialRender; const render = @@ -47,12 +47,10 @@ function initialisePdfUrl( : null; } -function initialiseFormState( +function deriveFormState( template: AuthoringLetterTemplate, - tab: PersonalisedRenderKey + personalisedRender: RenderDetails | undefined ): FormState { - const personalisedRender = getPersonalisedRender(template, tab); - const renderedPersonalisation = personalisedRender?.status === 'RENDERED' ? personalisedRender : null; @@ -64,32 +62,57 @@ function initialiseFormState( return { fields: Object.fromEntries([ ...customPersonalisationFields.map((f) => [ - f, + `${PERSONALISATION_FORMDATA_PREFIX}${f}`, personalisationParameters?.[f] ?? '', ]), - ['__systemPersonalisationPackId', systemPersonalisationPackId ?? ''], + ['systemPersonalisationPackId', systemPersonalisationPackId ?? ''], ]), }; } +function LetterRenderTabContent({ + template, + tab, + pdfUrl, +}: { + template: AuthoringLetterTemplate; + tab: PersonalisedRenderKey; + pdfUrl: string | null; +}) { + const [_state, _dispatch, isPending] = useNHSNotifyForm(); + + return ( +
+
+ +
+ +
+ {loadingText}

} + forcePolling={isPending} + > + +
+
+
+ ); +} + export function LetterRenderTab({ template, tab }: LetterRenderTabProps) { - const formState = initialiseFormState(template, tab); - const pdfUrl = initialisePdfUrl(template, tab); + const personalisedRender = template.files[tab]; + + const formState = deriveFormState(template, personalisedRender); + const pdfUrl = derivePdfUrl(template, personalisedRender); return ( -
-
- -
- -
- -
-
+
); } diff --git a/frontend/src/components/molecules/LetterRender/LetterSubmitButton.tsx b/frontend/src/components/molecules/LetterRender/LetterSubmitButton.tsx new file mode 100644 index 000000000..f348c3ba5 --- /dev/null +++ b/frontend/src/components/molecules/LetterRender/LetterSubmitButton.tsx @@ -0,0 +1,20 @@ +'use client'; + +import type { PropsWithChildren } from 'react'; +import { NHSNotifyButton } from '@atoms/NHSNotifyButton/NHSNotifyButton'; +import { useLetterRenderPolling } from '@providers/letter-render-polling-provider'; + +export function LetterSubmitButton({ children }: PropsWithChildren) { + const { isAnyTabPolling } = useLetterRenderPolling(); + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/molecules/LetterRender/server-action.ts b/frontend/src/components/molecules/LetterRender/server-action.ts index 951eb0d02..6cb4631b5 100644 --- a/frontend/src/components/molecules/LetterRender/server-action.ts +++ b/frontend/src/components/molecules/LetterRender/server-action.ts @@ -3,15 +3,27 @@ import { z } from 'zod/v4'; import type { FormState } from 'nhs-notify-web-template-management-utils'; import copy from '@content/content'; -import { EXAMPLE_RECIPIENT_IDS } from '@content/example-recipients'; +import { + EXAMPLE_RECIPIENT_IDS, + LONG_EXAMPLE_RECIPIENTS, + SHORT_EXAMPLE_RECIPIENTS, +} from '@content/example-recipients'; import { formDataToFormStateFields } from '@utils/form-data-to-form-state'; +import { $LockNumber } from 'nhs-notify-backend-client'; +import { generateLetterProof } from '@utils/form-actions'; +import type { LetterProofRequest } from 'nhs-notify-web-template-management-types'; +import { PERSONALISATION_FORMDATA_PREFIX } from '@utils/constants'; +import { format as formatDate } from 'date-fns'; const { pdsSection } = copy.components.letterRender; const $FormSchema = z.object({ - __systemPersonalisationPackId: z.enum(EXAMPLE_RECIPIENT_IDS, { + systemPersonalisationPackId: z.enum(EXAMPLE_RECIPIENT_IDS, { message: pdsSection.error.invalid, }), + templateId: z.string().nonempty(), + lockNumber: $LockNumber, + tab: z.enum(['longFormRender', 'shortFormRender']), }); export async function updateLetterPreview( @@ -29,6 +41,37 @@ export async function updateLetterPreview( }; } + const { templateId, systemPersonalisationPackId, tab, lockNumber } = + result.data; + + const customPersonalisation = Object.fromEntries( + Object.entries(fields).flatMap(([k, v]) => + k.startsWith(PERSONALISATION_FORMDATA_PREFIX) + ? [[k.slice(PERSONALISATION_FORMDATA_PREFIX.length), String(v)]] + : [] + ) + ); + + const systemPersonalisation = ( + tab === 'longFormRender' + ? LONG_EXAMPLE_RECIPIENTS + : SHORT_EXAMPLE_RECIPIENTS + ).find((r) => r.id === systemPersonalisationPackId)?.data; + + const personalisation = { + ...customPersonalisation, + ...systemPersonalisation, + date: formatDate(new Date(), 'd LLLL yyyy'), + }; + + const request: LetterProofRequest = { + personalisation, + systemPersonalisationPackId, + requestTypeVariant: tab === 'longFormRender' ? 'long' : 'short', + }; + + await generateLetterProof(templateId, lockNumber, request); + return { fields, }; diff --git a/frontend/src/components/molecules/PollLetterRender/PollLetterRender.tsx b/frontend/src/components/molecules/PollLetterRender/PollLetterRender.tsx index 77b6fc591..928b057a9 100644 --- a/frontend/src/components/molecules/PollLetterRender/PollLetterRender.tsx +++ b/frontend/src/components/molecules/PollLetterRender/PollLetterRender.tsx @@ -1,37 +1,44 @@ 'use client'; -import type { PropsWithChildren, ReactNode } from 'react'; +import { + type PropsWithChildren, + type ReactNode, + useEffect, + useRef, + useState, +} from 'react'; +import { useRouter } from 'next/navigation'; import type { AuthoringLetterTemplate } from 'nhs-notify-web-template-management-utils'; import { LoadingSpinner } from '@atoms/LoadingSpinner/LoadingSpinner'; -import { - useLetterTemplatePoll, - RENDER_TIMEOUT_MS, -} from '@hooks/use-letter-template-poll'; +import { useLetterRenderPolling } from '@providers/letter-render-polling-provider'; import type { RenderKey } from '@utils/types'; -function shouldPollLetterRender( +export const RENDER_TIMEOUT_MS = 20_000; +export const POLL_INTERVAL_MS = 2000; + +function templateRequiresPolling( + template: AuthoringLetterTemplate, mode: RenderKey -): (template: AuthoringLetterTemplate) => boolean { - return ({ files, templateStatus }: AuthoringLetterTemplate): boolean => { - const render = files[mode]; +): boolean { + const render = template.files[mode]; - if ( - render?.status !== 'PENDING' || - templateStatus === 'VALIDATION_FAILED' - ) { - return false; - } + if ( + render?.status !== 'PENDING' || + template.templateStatus === 'VALIDATION_FAILED' + ) { + return false; + } - const elapsed = Date.now() - new Date(render.requestedAt).getTime(); + const elapsed = Date.now() - new Date(render.requestedAt).getTime(); - return elapsed < RENDER_TIMEOUT_MS; - }; + return elapsed < RENDER_TIMEOUT_MS; } type PollLetterRenderProps = PropsWithChildren<{ template: AuthoringLetterTemplate; mode: RenderKey; loadingElement: ReactNode; + forcePolling?: boolean; }>; export function PollLetterRender({ @@ -39,13 +46,82 @@ export function PollLetterRender({ children, mode, loadingElement, + forcePolling = false, }: Readonly) { - const { isPolling } = useLetterTemplatePoll({ - template, - shouldPoll: shouldPollLetterRender(mode), - }); + const router = useRouter(); + const { registerPolling } = useLetterRenderPolling(); + + const [isPolling, setIsPolling] = useState( + forcePolling || templateRequiresPolling(template, mode) + ); + + // was the force flag active on the previous render? + const forcedRef = useRef(forcePolling); + + const staleTemplateRef = useRef( + forcePolling ? template : null + ); + + useEffect(() => { + const forcedPollingBegan = !forcedRef.current && forcePolling; + + if (forcedPollingBegan) { + // track the template identity from before any updates + staleTemplateRef.current = template; + + if (!isPolling) { + setIsPolling(true); + } + } + + forcedRef.current = forcePolling; + + const templateHasUpdated = + staleTemplateRef.current && staleTemplateRef.current !== template; + + if (templateHasUpdated) { + // clear the ref so once the template is RENDERED, polling can end + staleTemplateRef.current = null; + } + + if ( + isPolling && + !forcePolling && + !staleTemplateRef.current && + !templateRequiresPolling(template, mode) + ) { + setIsPolling(false); + } + }, [template, forcePolling, isPolling, mode]); + + useEffect(() => { + if (!isPolling) return; + + const pollTimerId = setInterval(() => { + router.refresh(); + }, POLL_INTERVAL_MS); + + const timeoutTimerId = setTimeout(() => { + setIsPolling(false); + }, RENDER_TIMEOUT_MS); + + return () => { + clearInterval(pollTimerId); + clearTimeout(timeoutTimerId); + }; + }, [isPolling, router]); + + const pollActive = isPolling || forcePolling; + + useEffect(() => { + registerPolling(mode, pollActive); + + return () => { + registerPolling(mode, false); + }; + }, [mode, pollActive, registerPolling]); - if (isPolling) { + if (pollActive) { return {loadingElement}; } diff --git a/frontend/src/components/providers/letter-render-polling-provider.tsx b/frontend/src/components/providers/letter-render-polling-provider.tsx new file mode 100644 index 000000000..84e8a64e3 --- /dev/null +++ b/frontend/src/components/providers/letter-render-polling-provider.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { + createContext, + useCallback, + useContext, + useRef, + useState, + type PropsWithChildren, +} from 'react'; + +type LetterRenderPollingContextValue = { + isAnyTabPolling: boolean; + registerPolling: (key: string, polling: boolean) => void; +}; + +const LetterRenderPollingContext = + createContext(null); + +export function useLetterRenderPolling() { + const context = useContext(LetterRenderPollingContext); + + if (!context) { + throw new Error( + 'useLetterRenderPolling must be used within LetterRenderPollingProvider' + ); + } + + return context; +} + +export function LetterRenderPollingProvider({ children }: PropsWithChildren) { + const [isAnyTabPolling, setIsAnyTabPolling] = useState(false); + + const pollingMapRef = useRef>({}); + + const registerPolling = useCallback((key: string, polling: boolean) => { + pollingMapRef.current[key] = polling; + + setIsAnyTabPolling(Object.values(pollingMapRef.current).some(Boolean)); + }, []); + + return ( + + {children} + + ); +} diff --git a/frontend/src/content/content.ts b/frontend/src/content/content.ts index c4425c79e..d6c8282b2 100644 --- a/frontend/src/content/content.ts +++ b/frontend/src/content/content.ts @@ -581,6 +581,7 @@ const letterRender = { short: 'Short examples', long: 'Long examples', }, + loadingText: 'Loading letter preview', pdsSection: { heading: 'PDS personalisation fields', hint: 'The PDS fields will be pre-filled with example data when you choose a test recipient.', diff --git a/frontend/src/hooks/use-letter-template-poll.ts b/frontend/src/hooks/use-letter-template-poll.ts deleted file mode 100644 index 19bcb4617..000000000 --- a/frontend/src/hooks/use-letter-template-poll.ts +++ /dev/null @@ -1,50 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import type { AuthoringLetterTemplate } from 'nhs-notify-web-template-management-utils'; - -export const RENDER_TIMEOUT_MS = 20_000; -export const POLL_INTERVAL_MS = 2000; - -export function useLetterTemplatePoll({ - template, - shouldPoll, -}: { - template: AuthoringLetterTemplate; - shouldPoll: (template: AuthoringLetterTemplate) => boolean; -}) { - const router = useRouter(); - const [isPolling, setIsPolling] = useState(() => shouldPoll(template)); - - const shouldPollRef = useRef(shouldPoll); - shouldPollRef.current = shouldPoll; - - const templateRef = useRef(template); - templateRef.current = template; - - useEffect(() => { - if (!isPolling) return; - - const pollTimerId = setInterval(() => { - router.refresh(); - }, POLL_INTERVAL_MS); - - const timeoutTimerId = setTimeout(() => { - setIsPolling(false); - }, RENDER_TIMEOUT_MS); - - return () => { - clearInterval(pollTimerId); - clearTimeout(timeoutTimerId); - }; - }, [isPolling, router]); - - useEffect(() => { - if (isPolling && !shouldPollRef.current(templateRef.current)) { - setIsPolling(false); - } - }, [template, isPolling]); - - return { isPolling }; -} diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index b5ed7bdae..bea5af716 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -11,3 +11,7 @@ export const INVALID_PERSONALISATION_FIELDS = [ 'address_line_6', 'address_line_7', ] as const; + +// pipes are banned in personalisation keys, so this +// cannot clash with user provided keys +export const PERSONALISATION_FORMDATA_PREFIX = 'personalisation|'; diff --git a/frontend/src/utils/form-actions.ts b/frontend/src/utils/form-actions.ts index 147744e37..efdb9da3f 100644 --- a/frontend/src/utils/form-actions.ts +++ b/frontend/src/utils/form-actions.ts @@ -4,6 +4,7 @@ import { getSessionServer } from '@utils/amplify-utils'; import { $TemplateDto } from 'nhs-notify-backend-client'; import type { CreateUpdateTemplate, + LetterProofRequest, PatchTemplate, TemplateDto, } from 'nhs-notify-web-template-management-types'; @@ -216,6 +217,32 @@ export async function requestTemplateProof( return data; } +export async function generateLetterProof( + templateId: string, + lockNumber: number, + request: LetterProofRequest +): Promise { + const { accessToken } = await getSessionServer(); + + if (!accessToken) { + throw new Error('Failed to get access token'); + } + + const { data, error } = await templateApiClient.generateLetterProof( + templateId, + accessToken, + lockNumber, + request + ); + + if (error) { + logger.error('Failed to initiate letter proof generation', error); + throw new Error('Failed to initiate letter proof generation'); + } + + return data; +} + export async function getTemplate( templateId: string ): Promise { diff --git a/infrastructure/terraform/modules/backend-api/README.md b/infrastructure/terraform/modules/backend-api/README.md index b3ffa7f60..303696a36 100644 --- a/infrastructure/terraform/modules/backend-api/README.md +++ b/infrastructure/terraform/modules/backend-api/README.md @@ -45,7 +45,7 @@ No requirements. | [create\_template\_lambda](#module\_create\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [delete\_routing\_config\_lambda](#module\_delete\_routing\_config\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [delete\_template\_lambda](#module\_delete\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | -| [generate\_letter\_proof\_lambda](#module\_generate\_letter\_proof\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [generate\_letter\_proof\_lambda](#module\_generate\_letter\_proof\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip | n/a | | [get\_client\_lambda](#module\_get\_client\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [get\_routing\_config\_lambda](#module\_get\_routing\_config\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [get\_routing\_configs\_by\_template\_id\_lambda](#module\_get\_routing\_configs\_by\_template\_id\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | diff --git a/infrastructure/terraform/modules/backend-api/module_letter_proof_lambda.tf b/infrastructure/terraform/modules/backend-api/module_generate_letter_proof_lambda.tf similarity index 97% rename from infrastructure/terraform/modules/backend-api/module_letter_proof_lambda.tf rename to infrastructure/terraform/modules/backend-api/module_generate_letter_proof_lambda.tf index fce5e205d..2351996d8 100644 --- a/infrastructure/terraform/modules/backend-api/module_letter_proof_lambda.tf +++ b/infrastructure/terraform/modules/backend-api/module_generate_letter_proof_lambda.tf @@ -1,5 +1,5 @@ module "generate_letter_proof_lambda" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/3.0.6/terraform-lambda.zip" project = var.project environment = var.environment diff --git a/lambdas/backend-api/src/app/template-client.ts b/lambdas/backend-api/src/app/template-client.ts index aedf92835..557e5e84b 100644 --- a/lambdas/backend-api/src/app/template-client.ts +++ b/lambdas/backend-api/src/app/template-client.ts @@ -979,8 +979,6 @@ export class TemplateClient { ); } - console.log(JSON.stringify(templateDTO, null, 2)); - const sendQueueResult = await this.renderQueue.send( templateId, user.clientId, diff --git a/lambdas/backend-client/src/__tests__/schemas/template.test.ts b/lambdas/backend-client/src/__tests__/schemas/template.test.ts index ca63fb70d..ace426ea1 100644 --- a/lambdas/backend-client/src/__tests__/schemas/template.test.ts +++ b/lambdas/backend-client/src/__tests__/schemas/template.test.ts @@ -6,6 +6,7 @@ import { $CreatePdfLetterProperties, $CreateUpdateNonLetter, $CreateUpdateTemplate, + $LetterProofRequest, $LetterProperties, $PatchTemplate, $PdfLetterProperties, @@ -859,4 +860,47 @@ describe('Template schemas', () => { ); }); }); + + describe('$LetterProofRequest', () => { + test('should pass validation for valid letter proof request', () => { + const request = { + systemPersonalisationPackId: 'short-1', + personalisation: { firstName: 'Jo', lastName: 'Bloggs' }, + requestTypeVariant: 'short', + }; + + const result = $LetterProofRequest.safeParse(request); + + expect(result.success).toBe(true); + expect(result.data).toEqual(request); + }); + + test('should pass validation for long variant', () => { + const request = { + systemPersonalisationPackId: 'long-1', + personalisation: { firstName: 'Michael' }, + requestTypeVariant: 'long', + }; + + const result = $LetterProofRequest.safeParse(request); + + expect(result.success).toBe(true); + expect(result.data).toEqual(request); + }); + + test('should fail validation for invalid requestTypeVariant', () => { + const request = { + systemPersonalisationPackId: 'short-1', + personalisation: { firstName: 'Jo' }, + requestTypeVariant: 'invalid', + }; + + const result = $LetterProofRequest.safeParse(request); + + expect(result.success).toBe(false); + expect(result.error?.flatten().fieldErrors).toHaveProperty( + 'requestTypeVariant' + ); + }); + }); }); diff --git a/lambdas/backend-client/src/__tests__/template-api-client.test.ts b/lambdas/backend-client/src/__tests__/template-api-client.test.ts index 38c02b539..e609673df 100644 --- a/lambdas/backend-client/src/__tests__/template-api-client.test.ts +++ b/lambdas/backend-client/src/__tests__/template-api-client.test.ts @@ -585,6 +585,75 @@ describe('TemplateAPIClient', () => { }); }); + describe('generateLetterProof', () => { + const letterProofRequest = { + systemPersonalisationPackId: 'short-1', + personalisation: { firstName: 'Jo' }, + requestTypeVariant: 'short' as const, + }; + + test('should return error', async () => { + axiosMock + .onPost('/v1/template/real-id/generate-letter-proof') + .reply(400, { + statusCode: 400, + technicalMessage: 'Bad request', + details: { + message: 'Template cannot generate letter proof', + }, + }); + + const result = await client.generateLetterProof( + 'real-id', + testToken, + 5, + letterProofRequest + ); + + expect(result.error).toEqual({ + errorMeta: { + code: 400, + description: 'Bad request', + details: { + message: 'Template cannot generate letter proof', + }, + }, + }); + + expect(result.data).toBeUndefined(); + + expect(axiosMock.history.post.length).toBe(1); + + const headers = axiosMock.history.at(0)?.headers; + + expect(headers ? headers['X-Lock-Number'] : null).toEqual('5'); + }); + + test('should return content', async () => { + const data = { + id: 'real-id', + name: 'name', + templateStatus: 'NOT_YET_SUBMITTED', + templateType: 'LETTER', + }; + + axiosMock + .onPost('/v1/template/real-id/generate-letter-proof') + .reply(200, { data }); + + const result = await client.generateLetterProof( + 'real-id', + testToken, + 5, + letterProofRequest + ); + + expect(result.data).toEqual(data); + + expect(result.error).toBeUndefined(); + }); + }); + describe('requestProof', () => { test('should return error', async () => { axiosMock.onPost('/v1/template/real-id/proof').reply(400, { diff --git a/lambdas/backend-client/src/template-api-client.ts b/lambdas/backend-client/src/template-api-client.ts index 1a16e593b..13deab85a 100644 --- a/lambdas/backend-client/src/template-api-client.ts +++ b/lambdas/backend-client/src/template-api-client.ts @@ -4,6 +4,7 @@ import type { TemplateSuccessList, TemplateDto, PatchTemplate, + LetterProofRequest, } from 'nhs-notify-web-template-management-types'; import { Result } from './types/result'; import { catchAxiosError, createAxiosClient } from './axios-client'; @@ -290,4 +291,35 @@ export const templateApiClient = { data: response.data.data, }; }, + + async generateLetterProof( + templateId: string, + owner: string, + lockNumber: number, + letterProofRequest: LetterProofRequest + ): Promise> { + const response = await catchAxiosError( + httpClient.post( + `/v1/template/${encodeURIComponent(templateId)}/generate-letter-proof`, + letterProofRequest, + { + headers: { + 'Content-Type': 'application/json', + Authorization: owner, + 'X-Lock-Number': String(lockNumber), + }, + } + ) + ); + + if (response.error) { + return { + error: response.error, + }; + } + + return { + data: response.data.data, + }; + }, }; diff --git a/lambdas/letter-preview-renderer/src/__tests__/app/app.test.ts b/lambdas/letter-preview-renderer/src/__tests__/app/app.test.ts index cd8043dec..e42522941 100644 --- a/lambdas/letter-preview-renderer/src/__tests__/app/app.test.ts +++ b/lambdas/letter-preview-renderer/src/__tests__/app/app.test.ts @@ -184,7 +184,7 @@ describe('App', () => { currentVersion, fileName, pageCount, - [{ name: 'INVALID_MARKERS', issues: ['nested.object.access'] }] + [{ name: 'INVALID_MARKERS', issues: ['{d.nested.object.access}'] }] ); }); }); @@ -224,7 +224,7 @@ describe('App', () => { ], custom: ['first_name'], }, - [{ name: 'INVALID_MARKERS', issues: ['c.compliment'] }] + [{ name: 'INVALID_MARKERS', issues: ['{c.compliment}'] }] ); }); }); diff --git a/lambdas/letter-preview-renderer/src/__tests__/domain/personalisation.test.ts b/lambdas/letter-preview-renderer/src/__tests__/domain/personalisation.test.ts index 4e7d109c0..2df38aafb 100644 --- a/lambdas/letter-preview-renderer/src/__tests__/domain/personalisation.test.ts +++ b/lambdas/letter-preview-renderer/src/__tests__/domain/personalisation.test.ts @@ -89,7 +89,7 @@ describe('analyseMarkers', () => { const result = analyseMarkers(markers); expect(result.validationErrors).toEqual([ - { name: 'INVALID_MARKERS', issues: ['c.compliment'] }, + { name: 'INVALID_MARKERS', issues: ['{c.compliment}'] }, ]); }); }); @@ -102,7 +102,7 @@ describe('analyseMarkers', () => { expect(result.canRender).toBe(true); expect(result.validationErrors).toEqual([ - { name: 'INVALID_MARKERS', issues: ['no.d'] }, + { name: 'INVALID_MARKERS', issues: ['{no.d}'] }, ]); }); @@ -113,7 +113,7 @@ describe('analyseMarkers', () => { expect(result.canRender).toBe(true); expect(result.validationErrors).toEqual([ - { name: 'INVALID_MARKERS', issues: ['exclaimation!point'] }, + { name: 'INVALID_MARKERS', issues: ['{d.exclaimation!point}'] }, ]); }); @@ -124,10 +124,34 @@ describe('analyseMarkers', () => { expect(result.passthroughPersonalisation).toEqual( expect.objectContaining({ - no_prefix: '{d.no_prefix}', + no_prefix: '{no_prefix}', }) ); }); + + it('reconstructs all renderable marker types correctly in passthrough personalisation', () => { + const markers = new Set([ + ...ALL_ADDRESS_MARKERS, + 'd.customField', + 'd.nested.object.access', + 'no_prefix', + ]); + + const result = analyseMarkers(markers); + + expect(result.passthroughPersonalisation).toEqual({ + address_line_1: '{d.address_line_1}', + address_line_2: '{d.address_line_2}', + address_line_3: '{d.address_line_3}', + address_line_4: '{d.address_line_4}', + address_line_5: '{d.address_line_5}', + address_line_6: '{d.address_line_6}', + address_line_7: '{d.address_line_7}', + customField: '{d.customField}', + 'nested.object.access': '{d.nested.object.access}', + no_prefix: '{no_prefix}', + }); + }); }); describe('mixed invalid markers', () => { @@ -145,7 +169,7 @@ describe('analyseMarkers', () => { expect.arrayContaining([ { name: 'INVALID_MARKERS', - issues: ['c.compliment', 'no_prefix'], + issues: ['{c.compliment}', '{no_prefix}'], }, ]) ); @@ -197,7 +221,7 @@ describe('analyseMarkers', () => { const result = analyseMarkers(markers); expect(result.validationErrors).toEqual([ - { name: 'UNEXPECTED_ADDRESS_LINES', issues: ['address_line_8'] }, + { name: 'UNEXPECTED_ADDRESS_LINES', issues: ['{d.address_line_8}'] }, ]); }); @@ -213,7 +237,7 @@ describe('analyseMarkers', () => { expect(result.validationErrors).toEqual([ { name: 'UNEXPECTED_ADDRESS_LINES', - issues: ['address_line_8', 'address_line_9'], + issues: ['{d.address_line_8}', '{d.address_line_9}'], }, ]); }); @@ -224,7 +248,7 @@ describe('analyseMarkers', () => { const result = analyseMarkers(markers); expect(result.validationErrors).toEqual([ - { name: 'UNEXPECTED_ADDRESS_LINES', issues: ['address_line_0'] }, + { name: 'UNEXPECTED_ADDRESS_LINES', issues: ['{d.address_line_0}'] }, ]); }); @@ -239,7 +263,7 @@ describe('analyseMarkers', () => { expect(result.validationErrors).toEqual([ { name: 'MISSING_ADDRESS_LINES' }, - { name: 'UNEXPECTED_ADDRESS_LINES', issues: ['address_line_8'] }, + { name: 'UNEXPECTED_ADDRESS_LINES', issues: ['{d.address_line_8}'] }, ]); }); }); diff --git a/lambdas/letter-preview-renderer/src/domain/personalisation.ts b/lambdas/letter-preview-renderer/src/domain/personalisation.ts index 32acd725a..2e27bba9a 100644 --- a/lambdas/letter-preview-renderer/src/domain/personalisation.ts +++ b/lambdas/letter-preview-renderer/src/domain/personalisation.ts @@ -4,16 +4,18 @@ import { DEFAULT_PERSONALISATION_LIST, } from 'nhs-notify-backend-client/src/schemas/constants'; -type ClassifiedMarkers = { - valid: Set; - renderable: Set; - nonRenderable: Set; +type Markers = { + valid: string[]; + invalidRenderableData: string[]; + invalidRenderableNonData: string[]; + nonRenderable: string[]; }; -function classifyMarkers(carboneMarkers: Set): ClassifiedMarkers { - const valid = new Set(); - const renderable = new Set(); - const nonRenderable = new Set(); +function classifyMarkers(carboneMarkers: Set): Markers { + const valid: string[] = []; + const invalidRenderableData: string[] = []; + const invalidRenderableNonData: string[] = []; + const nonRenderable: string[] = []; for (const marker of carboneMarkers) { if ( @@ -28,29 +30,58 @@ function classifyMarkers(carboneMarkers: Set): ClassifiedMarkers { marker.startsWith('#') || /^t\(.*\)/.test(marker) ) { - nonRenderable.add(marker); + nonRenderable.push(marker); continue; } if (!marker.startsWith('d.')) { - renderable.add(marker); + invalidRenderableNonData.push(marker); continue; } const dataMarker = marker.slice(2); if (!/^[\w-]+$/.test(dataMarker)) { - renderable.add(dataMarker); + invalidRenderableData.push(dataMarker); continue; } - valid.add(dataMarker); + valid.push(dataMarker); } - return { valid, renderable, nonRenderable }; + return { + invalidRenderableData, + invalidRenderableNonData, + nonRenderable, + valid, + }; +} + +function passthroughData(s: string) { + return `{d.${s}}`; +} + +function passthroughNonData(s: string) { + return `{${s}}`; +} + +function reconstructMarkers({ + valid, + invalidRenderableData, + invalidRenderableNonData, + nonRenderable, +}: Markers): Markers { + return { + valid: valid.map((m) => passthroughData(m)), + invalidRenderableData: invalidRenderableData.map((m) => passthroughData(m)), + invalidRenderableNonData: invalidRenderableNonData.map((m) => + passthroughNonData(m) + ), + nonRenderable: nonRenderable.map((m) => passthroughNonData(m)), + }; } -function classifyPersonalisation(parameters: Set) { +function classifyPersonalisation(parameters: string[]) { const custom: string[] = []; const system: string[] = []; @@ -66,19 +97,33 @@ function classifyPersonalisation(parameters: Set) { } function buildPassthroughPersonalisation( - keys: Set + classified: Markers, + reconstructed: Markers ): Record { - return Object.fromEntries([...keys].map((key) => [key, `{d.${key}}`])); + const keys = [ + ...classified.valid, + ...classified.invalidRenderableData, + ...classified.invalidRenderableNonData, + ]; + const values = [ + ...reconstructed.valid, + ...reconstructed.invalidRenderableData, + ...reconstructed.invalidRenderableNonData, + ]; + + return Object.fromEntries(keys.map((k, i) => [k, values[i]])); } function buildValidationErrors( - classified: ClassifiedMarkers + classified: Markers, + reconstructed: Markers ): ValidationErrorDetail[] { const errors: ValidationErrorDetail[] = []; const invalidMarkers = [ - ...classified.nonRenderable, - ...classified.renderable, + ...reconstructed.invalidRenderableData, + ...reconstructed.nonRenderable, + ...reconstructed.invalidRenderableNonData, ]; if (invalidMarkers.length > 0) { @@ -86,7 +131,7 @@ function buildValidationErrors( } const hasAllAddressLines = ADDRESS_PERSONALISATIONS.every((line) => - classified.valid.has(line) + classified.valid.includes(line) ); if (!hasAllAddressLines) { @@ -95,16 +140,16 @@ function buildValidationErrors( const addressLinePattern = /^address_line_\d+$/; - const unexpectedAddressLines = [...classified.valid].filter( - (marker) => - addressLinePattern.test(marker) && - !ADDRESS_PERSONALISATIONS.includes(marker) + const unexpectedAddressLineIssues = classified.valid.flatMap((m, i) => + addressLinePattern.test(m) && !ADDRESS_PERSONALISATIONS.includes(m) + ? [reconstructed.valid[i]] + : [] ); - if (unexpectedAddressLines.length > 0) { + if (unexpectedAddressLineIssues.length > 0) { errors.push({ name: 'UNEXPECTED_ADDRESS_LINES', - issues: unexpectedAddressLines, + issues: unexpectedAddressLineIssues, }); } @@ -120,13 +165,15 @@ export type MarkerAnalysis = { export function analyseMarkers(markers: Set): MarkerAnalysis { const classified = classifyMarkers(markers); + const reconstructed = reconstructMarkers(classified); return { personalisation: classifyPersonalisation(classified.valid), passthroughPersonalisation: buildPassthroughPersonalisation( - classified.valid.union(classified.renderable) + classified, + reconstructed ), - validationErrors: buildValidationErrors(classified), - canRender: classified.nonRenderable.size === 0, + validationErrors: buildValidationErrors(classified, reconstructed), + canRender: classified.nonRenderable.length === 0, }; } diff --git a/package-lock.json b/package-lock.json index 41c037ffa..e3729f3e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7719,13 +7719,13 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.18.tgz", - "integrity": "sha512-kINzc5BBxdYBkPZ0/i1AMPMOk5b5QaFNbYMElVw5QTX13AKj6jcxnv/YNl9oW9mg+Y08ti19hh01HhyEAxsSJQ==", + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.19.tgz", + "integrity": "sha512-jOXdZ1o+CywQKr6gyxgxuUmnGwTTnY2Kxs1PM7fI6AYtDWDnmW/yKXayNqkF8KjP1unflqMWKVbVt5VgmE3L0g==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.19", - "@aws-sdk/nested-clients": "^3.996.8", + "@aws-sdk/nested-clients": "^3.996.9", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", @@ -7826,9 +7826,9 @@ } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/nested-clients": { - "version": "3.996.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.8.tgz", - "integrity": "sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA==", + "version": "3.996.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.9.tgz", + "integrity": "sha512-+RpVtpmQbbtzFOKhMlsRcXM/3f1Z49qTOHaA8gEpHOYruERmog6f2AUtf/oTRLCWjR9H2b3roqryV/hI7QMW8w==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -7842,7 +7842,7 @@ "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.5", + "@aws-sdk/util-user-agent-node": "^3.973.6", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/fetch-http-handler": "^5.3.13", @@ -7932,15 +7932,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.5.tgz", - "integrity": "sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw==", + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.6.tgz", + "integrity": "sha512-iF7G0prk7AvmOK64FcLvc/fW+Ty1H+vttajL7PvJFReU8urMxfYmynTTuFKDTA76Wgpq3FzTPKwabMQIXQHiXQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/types": "^3.973.5", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", + "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -7970,9 +7971,9 @@ } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws/lambda-invoke-store": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", - "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" diff --git a/tests/test-team/pages/letter/template-mgmt-preview-letter-page.ts b/tests/test-team/pages/letter/template-mgmt-preview-letter-page.ts index 8192a35f3..0eb9e7ed5 100644 --- a/tests/test-team/pages/letter/template-mgmt-preview-letter-page.ts +++ b/tests/test-team/pages/letter/template-mgmt-preview-letter-page.ts @@ -62,7 +62,7 @@ export class TemplateMgmtPreviewLetterPage extends TemplateMgmtPreviewBasePage { const panel = this.page.getByRole('tabpanel', { name: tabName }); const tab = this.page.getByRole('tab', { name: tabName }); const recipientSelect = panel.locator( - 'select[name="__systemPersonalisationPackId"]' + 'select[name="systemPersonalisationPackId"]' ); const updatePreviewButton = panel.getByRole('button', { name: 'Update preview', @@ -72,6 +72,8 @@ export class TemplateMgmtPreviewLetterPage extends TemplateMgmtPreviewBasePage { name: 'Custom personalisation fields', }); + const tabSpinner = panel.getByRole('status'); + return { tab, panel, @@ -79,6 +81,7 @@ export class TemplateMgmtPreviewLetterPage extends TemplateMgmtPreviewBasePage { updatePreviewButton, previewIframe, customFieldsHeading, + tabSpinner, getCustomFieldInput: (fieldName: string): Locator => panel.locator(`input[id="custom-${fieldName}-${variant}"]`), getRecipientOptions: (): Locator => recipientSelect.locator('option'), diff --git a/tests/test-team/template-mgmt-backend-tests/letter-rendering.backend.spec.ts b/tests/test-team/template-mgmt-backend-tests/letter-rendering.backend.spec.ts index c45d9d923..c730c71f0 100644 --- a/tests/test-team/template-mgmt-backend-tests/letter-rendering.backend.spec.ts +++ b/tests/test-team/template-mgmt-backend-tests/letter-rendering.backend.spec.ts @@ -346,7 +346,7 @@ test.describe('Letter rendering', () => { expect.objectContaining({ templateStatus: 'VALIDATION_FAILED', validationErrors: [ - { name: 'INVALID_MARKERS', issues: ['c.fullName'] }, + { name: 'INVALID_MARKERS', issues: ['{c.fullName}'] }, ], files: expect.objectContaining({ initialRender: { status: 'FAILED' }, @@ -381,7 +381,7 @@ test.describe('Letter rendering', () => { expect.objectContaining({ templateStatus: 'VALIDATION_FAILED', validationErrors: [ - { name: 'INVALID_MARKERS', issues: ['parameter!'] }, + { name: 'INVALID_MARKERS', issues: ['{d.parameter!}'] }, ], files: expect.objectContaining({ initialRender: expect.objectContaining({ status: 'RENDERED' }), @@ -418,7 +418,7 @@ test.describe('Letter rendering', () => { validationErrors: [ { name: 'UNEXPECTED_ADDRESS_LINES', - issues: ['address_line_8'], + issues: ['{d.address_line_8}'], }, ], files: expect.objectContaining({ diff --git a/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-preview-letter-page.component.spec.ts b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-preview-letter-page.component.spec.ts index ae12ebff0..c632e3e83 100644 --- a/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-preview-letter-page.component.spec.ts +++ b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-preview-letter-page.component.spec.ts @@ -856,6 +856,135 @@ test.describe('Preview Letter template Page', () => { await expect(longDoctorName).toHaveValue('Dr Jones'); }); + test.describe('polling state for personalised renders', () => { + test('shows spinner in tab when personalised render is PENDING', async ({ + page, + }) => { + const user = await authHelper.getTestUser(testUsers.User1.userId); + + // Seed here so requestedAt is within the render timeout window + const template = TemplateFactory.createAuthoringLetterTemplate( + 'E1F2A3B4-C5D6-7890-ABCD-111111111111', + user, + 'authoring-pending-short-render', + 'NOT_YET_SUBMITTED', + { + letterVariantId: 'variant-pending-short', + initialRender: { + fileName: 'initial-render.pdf', + currentVersion: 'v1-initial', + pageCount: 4, + }, + shortFormRender: { + status: 'PENDING', + requestedAt: new Date().toISOString(), + systemPersonalisationPackId: 'short-1', + personalisationParameters: { + firstName: 'Jo', + lastName: 'Bloggs', + }, + }, + } + ); + + await templateStorageHelper.seedTemplateData([template]); + + const previewPage = new TemplateMgmtPreviewLetterPage( + page + ).setPathParam('templateId', template.id); + + await previewPage.loadPage(); + + await expect(previewPage.shortTab.tabSpinner).toBeVisible(); + + await expect(previewPage.shortTab.previewIframe).toBeHidden(); + }); + + test('disables submit button while a tab render is polling', async ({ + page, + }) => { + const user = await authHelper.getTestUser(testUsers.User1.userId); + + const template = TemplateFactory.createAuthoringLetterTemplate( + 'E1F2A3B4-C5D6-7890-ABCD-222222222222', + user, + 'authoring-polling-submit-disabled', + 'NOT_YET_SUBMITTED', + { + letterVariantId: 'variant-polling-submit', + initialRender: { + fileName: 'initial-render.pdf', + currentVersion: 'v1-initial', + pageCount: 4, + }, + shortFormRender: { + status: 'PENDING', + requestedAt: new Date().toISOString(), + systemPersonalisationPackId: 'short-1', + personalisationParameters: { + firstName: 'Jo', + lastName: 'Bloggs', + }, + }, + } + ); + + await templateStorageHelper.seedTemplateData([template]); + + const previewPage = new TemplateMgmtPreviewLetterPage( + page + ).setPathParam('templateId', template.id); + + await previewPage.loadPage(); + + await expect(previewPage.continueButton).toBeDisabled(); + }); + + test('disables update preview buttons in both tabs while a tab render is polling', async ({ + page, + }) => { + const user = await authHelper.getTestUser(testUsers.User1.userId); + + const template = TemplateFactory.createAuthoringLetterTemplate( + 'E1F2A3B4-C5D6-7890-ABCD-333333333333', + user, + 'authoring-polling-buttons-disabled', + 'NOT_YET_SUBMITTED', + { + letterVariantId: 'variant-polling-buttons', + initialRender: { + fileName: 'initial-render.pdf', + currentVersion: 'v1-initial', + pageCount: 4, + }, + shortFormRender: { + status: 'PENDING', + requestedAt: new Date().toISOString(), + systemPersonalisationPackId: 'short-1', + personalisationParameters: { + firstName: 'Jo', + lastName: 'Bloggs', + }, + }, + } + ); + + await templateStorageHelper.seedTemplateData([template]); + + const previewPage = new TemplateMgmtPreviewLetterPage( + page + ).setPathParam('templateId', template.id); + + await previewPage.loadPage(); + + await expect(previewPage.shortTab.updatePreviewButton).toBeDisabled(); + + await previewPage.longTab.clickTab(); + + await expect(previewPage.longTab.updatePreviewButton).toBeDisabled(); + }); + }); + test.describe('existing personalised renders', () => { test('short tab displays personalised render when shortFormRender exists', async ({ page,