From be5c1cc0927fd1abc19ec9d8b1445e6888de766a Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Wed, 28 Jan 2026 16:42:55 +0000 Subject: [PATCH 01/26] CCM-13489: add standard letter docx upload page --- .../__snapshots__/page.test.tsx.snap | 1050 +++++++++++++++++ .../page.test.tsx | 188 +++ .../server-action.test.ts | 166 +++ .../forms/MessagePlan/MessagePlan.test.tsx | 164 +-- .../__snapshots__/MessagePlan.test.tsx.snap | 32 + .../NhsAppTemplateForm.test.tsx.snap | 90 +- .../SmsTemplateForm/SmsTemplateForm.test.tsx | 2 +- .../SmsTemplateForm.test.tsx.snap | 60 +- .../molecules/MarkdownContent.test.tsx | 23 - .../providers/form-provider.test.tsx | 8 +- .../create-message-plan/page.tsx | 7 +- .../[routingConfigId]/page.tsx | 6 +- .../upload-standard-letter-template/form.tsx | 115 ++ .../upload-standard-letter-template/page.tsx | 54 + .../server-action.ts | 39 + .../atoms/FileUpload/FileUpload.tsx | 21 +- .../NHSNotifyBackLink/NHSNotifyBackLink.tsx | 19 + .../NHSNotifyFormGroup/NHSNotifyFormGroup.tsx | 25 + .../forms/MessagePlan/MessagePlan.tsx | 7 +- .../NhsAppTemplateForm/NhsAppTemplateForm.tsx | 4 +- .../ContentRenderer/ContentRenderer.tsx | 19 +- .../MarkdownContent/MarkdownContent.tsx | 3 - .../components/providers/form-provider.tsx | 68 +- frontend/src/content/content.ts | 110 +- frontend/src/middleware.ts | 1 + .../letters/docx/standard-template.docx | Bin 0 -> 85034 bytes .../index.ts} | 4 + .../incomplete-address.pdf | Bin .../no-custom-personalisation/password.pdf | Bin .../no-custom-personalisation/template.pdf | Bin .../eicar-threat-test.csv | 0 .../with-personalisation/empty-params.csv | 2 + .../with-personalisation/empty-params.pdf | Bin .../letters/with-personalisation/nonsense.csv | 2 + .../with-personalisation/nonsense.pdf | Bin .../with-personalisation/password.pdf | Bin .../with-personalisation/template.pdf | Bin .../with-personalisation/test-data.csv | 2 +- .../with-personalisation/wrong-params.csv | 2 +- .../with-personalisation/empty-params.csv | 2 - .../with-personalisation/nonsense.csv | 2 - ...mt-upload-standard-letter-template-page.ts | 32 + .../template-mgmt-create-nhs-app-page.ts | 4 +- .../template-mgmt-edit-nhs-app-page.ts | 4 +- .../sms/template-mgmt-create-sms-page.ts | 6 +- .../pages/sms/template-mgmt-edit-sms-page.ts | 6 +- .../update-template.api.spec.ts | 2 +- .../upload-letter-template.api.spec.ts | 2 +- ...standard-letter-template.component.spec.ts | 157 +++ ...emplate-protected-routes.component.spec.ts | 2 + ...te-mgmt-letter-file-validation.e2e.spec.ts | 2 +- .../template-mgmt-letter-full.e2e.spec.ts | 2 +- .../template-mgmt-proof-polling.e2e.spec.ts | 2 +- .../template-mgmt-proof-request.e2e.spec.ts | 2 +- utils/utils/src/types.ts | 5 +- 55 files changed, 2267 insertions(+), 258 deletions(-) create mode 100644 frontend/src/__tests__/app/upload-standard-letter-template/__snapshots__/page.test.tsx.snap create mode 100644 frontend/src/__tests__/app/upload-standard-letter-template/page.test.tsx create mode 100644 frontend/src/__tests__/app/upload-standard-letter-template/server-action.test.ts create mode 100644 frontend/src/app/upload-standard-letter-template/form.tsx create mode 100644 frontend/src/app/upload-standard-letter-template/page.tsx create mode 100644 frontend/src/app/upload-standard-letter-template/server-action.ts create mode 100644 frontend/src/components/atoms/NHSNotifyFormGroup/NHSNotifyFormGroup.tsx create mode 100644 tests/test-team/fixtures/letters/docx/standard-template.docx rename tests/test-team/fixtures/{pdf-upload/multipart-pdf-letter-fixtures.ts => letters/index.ts} (95%) rename tests/test-team/fixtures/{pdf-upload => letters}/no-custom-personalisation/incomplete-address.pdf (100%) rename tests/test-team/fixtures/{pdf-upload => letters}/no-custom-personalisation/password.pdf (100%) rename tests/test-team/fixtures/{pdf-upload => letters}/no-custom-personalisation/template.pdf (100%) rename tests/test-team/fixtures/{pdf-upload => letters}/with-personalisation/eicar-threat-test.csv (100%) create mode 100644 tests/test-team/fixtures/letters/with-personalisation/empty-params.csv rename tests/test-team/fixtures/{pdf-upload => letters}/with-personalisation/empty-params.pdf (100%) create mode 100644 tests/test-team/fixtures/letters/with-personalisation/nonsense.csv rename tests/test-team/fixtures/{pdf-upload => letters}/with-personalisation/nonsense.pdf (100%) rename tests/test-team/fixtures/{pdf-upload => letters}/with-personalisation/password.pdf (100%) rename tests/test-team/fixtures/{pdf-upload => letters}/with-personalisation/template.pdf (100%) rename tests/test-team/fixtures/{pdf-upload => letters}/with-personalisation/test-data.csv (85%) rename tests/test-team/fixtures/{pdf-upload => letters}/with-personalisation/wrong-params.csv (84%) delete mode 100644 tests/test-team/fixtures/pdf-upload/with-personalisation/empty-params.csv delete mode 100644 tests/test-team/fixtures/pdf-upload/with-personalisation/nonsense.csv create mode 100644 tests/test-team/pages/letter/template-mgmt-upload-standard-letter-template-page.ts create mode 100644 tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-standard-letter-template.component.spec.ts diff --git a/frontend/src/__tests__/app/upload-standard-letter-template/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/upload-standard-letter-template/__snapshots__/page.test.tsx.snap new file mode 100644 index 000000000..867f49583 --- /dev/null +++ b/frontend/src/__tests__/app/upload-standard-letter-template/__snapshots__/page.test.tsx.snap @@ -0,0 +1,1050 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`client has multiple campaign ids matches snapshot on initial render 1`] = ` + + + Back to choose a template type + +
+
+
+

+ Upload a standard English letter template +

+
+
+
+
+
+ + +
+ +
+ This will not be visible to recipients. +
+
+ + + Naming your templates + + +
+

+ You should name your templates in a way that works best for your service or organisation. +

+

+ Common template names include the: +

+
    +
  • + subject or reason for the message +
  • +
  • + intended audience for the template +
  • +
  • + version number of the template +
  • +
+

+ For example, 'Covid19 2025 - over 65s - version 3' +

+
+
+ +
+
+ +
+ Choose which campaign this letter is for +
+ +
+
+ +
+ Only upload your final letter template file. +
+ Make sure you use one of our blank template files to create the letter. +
+ +
+ +
+
+
+

+ How to create a standard letter template +

+
    +
  1. + Download the blank + + standard letter template file + + . +
  2. +
  3. + Add + + formatting (opens in a new tab) + + . +
  4. +
  5. + Add any + + personalisation (opens in a new tab) + + . +
  6. +
  7. + Save your Microsoft Word file and upload it to this page. +
  8. +
+
+
+
+
+`; + +exports[`client has multiple campaign ids renders errors when blank form is submitted and error state is returned 1`] = ` + + + Back to choose a template type + +
+ +
+
+

+ Upload a standard English letter template +

+
+
+
+
+
+ + +
+ +
+ This will not be visible to recipients. +
+
+ + + Naming your templates + + +
+

+ You should name your templates in a way that works best for your service or organisation. +

+

+ Common template names include the: +

+
    +
  • + subject or reason for the message +
  • +
  • + intended audience for the template +
  • +
  • + version number of the template +
  • +
+

+ For example, 'Covid19 2025 - over 65s - version 3' +

+
+
+ + + Error: + + Enter a template name + + +
+
+ +
+ Choose which campaign this letter is for +
+ + + Error: + + Choose a campaign + + +
+
+ +
+ Only upload your final letter template file. +
+ Make sure you use one of our blank template files to create the letter. +
+ + + Error: + + Choose a template file + + +
+ +
+
+
+

+ How to create a standard letter template +

+
    +
  1. + Download the blank + + standard letter template file + + . +
  2. +
  3. + Add + + formatting (opens in a new tab) + + . +
  4. +
  5. + Add any + + personalisation (opens in a new tab) + + . +
  6. +
  7. + Save your Microsoft Word file and upload it to this page. +
  8. +
+
+
+
+
+`; + +exports[`client has one campaign id matches snapshot on initial render 1`] = ` + + + Back to choose a template type + +
+
+
+

+ Upload a standard English letter template +

+
+
+
+
+
+ + +
+ +
+ This will not be visible to recipients. +
+
+ + + Naming your templates + + +
+

+ You should name your templates in a way that works best for your service or organisation. +

+

+ Common template names include the: +

+
    +
  • + subject or reason for the message +
  • +
  • + intended audience for the template +
  • +
  • + version number of the template +
  • +
+

+ For example, 'Covid19 2025 - over 65s - version 3' +

+
+
+ +
+
+ +
+ This message plan will link to your only campaign: +
+ +

+ Campaign 1 +

+
+
+ +
+ Only upload your final letter template file. +
+ Make sure you use one of our blank template files to create the letter. +
+ +
+ +
+
+
+

+ How to create a standard letter template +

+
    +
  1. + Download the blank + + standard letter template file + + . +
  2. +
  3. + Add + + formatting (opens in a new tab) + + . +
  4. +
  5. + Add any + + personalisation (opens in a new tab) + + . +
  6. +
  7. + Save your Microsoft Word file and upload it to this page. +
  8. +
+
+
+
+
+`; + +exports[`client has one campaign id renders errors when blank form is submitted and error state is returned 1`] = ` + + + Back to choose a template type + +
+ +
+
+

+ Upload a standard English letter template +

+
+
+
+
+
+ + +
+ +
+ This will not be visible to recipients. +
+
+ + + Naming your templates + + +
+

+ You should name your templates in a way that works best for your service or organisation. +

+

+ Common template names include the: +

+
    +
  • + subject or reason for the message +
  • +
  • + intended audience for the template +
  • +
  • + version number of the template +
  • +
+

+ For example, 'Covid19 2025 - over 65s - version 3' +

+
+
+ + + Error: + + Enter a template name + + +
+
+ +
+ This message plan will link to your only campaign: +
+ +

+ Campaign 1 +

+
+
+ +
+ Only upload your final letter template file. +
+ Make sure you use one of our blank template files to create the letter. +
+ + + Error: + + Choose a template file + + +
+ +
+
+
+

+ How to create a standard letter template +

+
    +
  1. + Download the blank + + standard letter template file + + . +
  2. +
  3. + Add + + formatting (opens in a new tab) + + . +
  4. +
  5. + Add any + + personalisation (opens in a new tab) + + . +
  6. +
  7. + Save your Microsoft Word file and upload it to this page. +
  8. +
+
+
+
+
+`; diff --git a/frontend/src/__tests__/app/upload-standard-letter-template/page.test.tsx b/frontend/src/__tests__/app/upload-standard-letter-template/page.test.tsx new file mode 100644 index 000000000..f85d79313 --- /dev/null +++ b/frontend/src/__tests__/app/upload-standard-letter-template/page.test.tsx @@ -0,0 +1,188 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { redirect, RedirectType } from 'next/navigation'; +import { verifyFormCsrfToken } from '@utils/csrf-utils'; +import { fetchClient } from '@utils/server-features'; +import UploadStandardLetterTemplatePage from '@app/upload-standard-letter-template/page'; +import { + DOCX_MIME, + uploadStandardLetterTemplate, +} from '@app/upload-standard-letter-template/server-action'; + +jest.mock('next/navigation'); +jest.mock('@utils/csrf-utils'); +jest.mock('@utils/server-features'); +jest.mock('@app/upload-standard-letter-template/server-action'); + +beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(verifyFormCsrfToken).mockResolvedValue(true); + jest.mocked(uploadStandardLetterTemplate).mockResolvedValue({}); +}); + +describe('client has no campaign ids', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: [], + features: {}, + }); + }); + + it('redirects to campaign id required page', async () => { + await UploadStandardLetterTemplatePage(); + + expect(redirect).toHaveBeenCalledWith( + '/upload-letter-template/client-id-and-campaign-id-required', + RedirectType.replace + ); + }); +}); + +describe('client has one campaign id', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: ['Campaign 1'], + features: {}, + }); + }); + + it('matches snapshot on initial render', async () => { + expect( + render(await UploadStandardLetterTemplatePage()).asFragment() + ).toMatchSnapshot(); + }); + + it('submits the form with correct data', async () => { + const user = userEvent.setup(); + + render(await UploadStandardLetterTemplatePage()); + + await user.click(screen.getByLabelText('Template name')); + await user.keyboard('A new template'); + + const file = new File(['hello'], 'template.docx', { + type: DOCX_MIME, + }); + + await user.upload(screen.getByLabelText('Template file'), file); + + await user.click( + screen.getByRole('button', { name: 'Upload letter template file' }) + ); + + expect(uploadStandardLetterTemplate).toHaveBeenCalledTimes(1); + + const callArgs = jest.mocked(uploadStandardLetterTemplate).mock.calls[0]; + const formData = callArgs[1] as FormData; + + expect(formData.get('name')).toBe('A new template'); + expect(formData.get('campaignId')).toBe('Campaign 1'); + expect(formData.get('file')).toBeInstanceOf(File); + + expect( + screen.queryByRole('alert', { name: 'There is a problem' }) + ).not.toBeInTheDocument(); + }); + + it('renders errors when blank form is submitted and error state is returned', async () => { + jest.mocked(uploadStandardLetterTemplate).mockResolvedValue({ + errorState: { + fieldErrors: { + name: ['Enter a template name'], + file: ['Choose a template file'], + }, + }, + }); + + const user = userEvent.setup(); + + const page = render(await UploadStandardLetterTemplatePage()); + + await user.click( + screen.getByRole('button', { name: 'Upload letter template file' }) + ); + + expect( + screen.queryByRole('alert', { name: 'There is a problem' }) + ).toBeInTheDocument(); + + expect(page.asFragment()).toMatchSnapshot(); + }); +}); + +describe('client has multiple campaign ids', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: ['Campaign 1', 'Campaign 2'], + features: {}, + }); + }); + + it('matches snapshot on initial render', async () => { + expect( + render(await UploadStandardLetterTemplatePage()).asFragment() + ).toMatchSnapshot(); + }); + + it('submits the form with correct data', async () => { + const user = userEvent.setup(); + + render(await UploadStandardLetterTemplatePage()); + + await user.click(screen.getByLabelText('Template name')); + await user.keyboard('A new template'); + + await user.selectOptions(screen.getByLabelText('Campaign'), 'Campaign 2'); + + const file = new File(['hello'], 'template.docx', { + type: DOCX_MIME, + }); + + await user.upload(screen.getByLabelText('Template file'), file); + + await user.click( + screen.getByRole('button', { name: 'Upload letter template file' }) + ); + + expect(uploadStandardLetterTemplate).toHaveBeenCalledTimes(1); + + const callArgs = jest.mocked(uploadStandardLetterTemplate).mock.calls[0]; + const formData = callArgs[1] as FormData; + + expect(formData.get('name')).toBe('A new template'); + expect(formData.get('campaignId')).toBe('Campaign 2'); + expect(formData.get('file')).toBeInstanceOf(File); + + expect( + screen.queryByRole('alert', { name: 'There is a problem' }) + ).not.toBeInTheDocument(); + }); + + it('renders errors when blank form is submitted and error state is returned', async () => { + jest.mocked(uploadStandardLetterTemplate).mockResolvedValue({ + errorState: { + fieldErrors: { + name: ['Enter a template name'], + campaignId: ['Choose a campaign'], + file: ['Choose a template file'], + }, + }, + }); + + const user = userEvent.setup(); + + const page = render(await UploadStandardLetterTemplatePage()); + + await user.click( + screen.getByRole('button', { name: 'Upload letter template file' }) + ); + + expect(uploadStandardLetterTemplate).toHaveBeenCalledTimes(1); + + expect( + screen.queryByRole('alert', { name: 'There is a problem' }) + ).toBeInTheDocument(); + + expect(page.asFragment()).toMatchSnapshot(); + }); +}); diff --git a/frontend/src/__tests__/app/upload-standard-letter-template/server-action.test.ts b/frontend/src/__tests__/app/upload-standard-letter-template/server-action.test.ts new file mode 100644 index 000000000..5cbd614ee --- /dev/null +++ b/frontend/src/__tests__/app/upload-standard-letter-template/server-action.test.ts @@ -0,0 +1,166 @@ +import { + uploadStandardLetterTemplate, + DOCX_MIME, +} from '@app/upload-standard-letter-template/server-action'; + +describe('uploadStandardLetterTemplate', () => { + it('returns success when all fields are valid', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', 'Campaign 1'); + + const file = new File(['content'], 'template.docx', { + type: DOCX_MIME, + }); + formData.append('file', file); + + const result = await uploadStandardLetterTemplate({}, formData); + + expect(result.errorState).toBeUndefined(); + expect(result.fields).toEqual({ + name: 'Test Template', + campaignId: 'Campaign 1', + file, + }); + }); + + it('returns validation error when name is empty', async () => { + const formData = new FormData(); + formData.append('name', ''); + formData.append('campaignId', 'Campaign 1'); + + const file = new File(['content'], 'template.docx', { + type: DOCX_MIME, + }); + formData.append('file', file); + + const result = await uploadStandardLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + name: ['Enter a template name'], + }, + }); + expect(result.fields).toEqual({ + name: '', + campaignId: 'Campaign 1', + file, + }); + }); + + it('returns validation error when name is missing', async () => { + const formData = new FormData(); + formData.append('campaignId', 'Campaign 1'); + + const file = new File(['content'], 'template.docx', { + type: DOCX_MIME, + }); + formData.append('file', file); + + const result = await uploadStandardLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + name: ['Enter a template name'], + }, + }); + }); + + it('returns validation error when campaignId is empty', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', ''); + + const file = new File(['content'], 'template.docx', { + type: DOCX_MIME, + }); + formData.append('file', file); + + const result = await uploadStandardLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + campaignId: ['Choose a campaign'], + }, + }); + expect(result.fields).toEqual({ + name: 'Test Template', + campaignId: '', + file, + }); + }); + + it('returns validation error when campaignId is missing', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + + const file = new File(['content'], 'template.docx', { + type: DOCX_MIME, + }); + formData.append('file', file); + + const result = await uploadStandardLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + campaignId: ['Choose a campaign'], + }, + }); + }); + + it('returns validation error when file is missing', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', 'Campaign 1'); + + const result = await uploadStandardLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + file: ['Choose a template file'], + }, + }); + }); + + it('returns validation error when file has incorrect MIME type', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', 'Campaign 1'); + + const file = new File(['content'], 'template.pdf', { + type: 'application/pdf', + }); + formData.append('file', file); + + const result = await uploadStandardLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + file: ['Choose a template file'], + }, + }); + }); + + it('returns multiple validation errors when multiple fields are invalid', async () => { + const formData = new FormData(); + formData.append('name', ''); + formData.append('campaignId', ''); + + const result = await uploadStandardLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + name: ['Enter a template name'], + campaignId: ['Choose a campaign'], + file: ['Choose a template file'], + }, + }); + }); +}); diff --git a/frontend/src/__tests__/components/forms/MessagePlan/MessagePlan.test.tsx b/frontend/src/__tests__/components/forms/MessagePlan/MessagePlan.test.tsx index 3183e59ac..4ca33f860 100644 --- a/frontend/src/__tests__/components/forms/MessagePlan/MessagePlan.test.tsx +++ b/frontend/src/__tests__/components/forms/MessagePlan/MessagePlan.test.tsx @@ -1,101 +1,111 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MessagePlanForm } from '@forms/MessagePlan/MessagePlan'; -import { useNHSNotifyForm } from '@providers/form-provider'; +import { + MessagePlanForm, + NHSNotifyFormProvider, +} from '@forms/MessagePlan/MessagePlan'; import { verifyFormCsrfToken } from '@utils/csrf-utils'; -jest.mock('@providers/form-provider'); -const mockAction = jest.fn(); -jest.mocked(useNHSNotifyForm).mockReturnValue([{}, mockAction, false]); - jest.mock('@utils/csrf-utils'); -jest.mocked(verifyFormCsrfToken).mockResolvedValue(true); beforeEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); + jest.mocked(verifyFormCsrfToken).mockResolvedValue(true); }); test('renders form with single campaign id displayed', () => { const container = render( - + + + ); expect(container.asFragment()).toMatchSnapshot(); }); test('renders form with select for multiple campaign ids', () => { const container = render( - + + + ); expect(container.asFragment()).toMatchSnapshot(); }); test('renders form with children', () => { const container = render( - - - + + + + + ); expect(container.asFragment()).toMatchSnapshot(); }); test('renders errors', async () => { - jest.mocked(useNHSNotifyForm).mockReturnValueOnce([ - { - errorState: { - fieldErrors: { - name: ['Name error'], - campaignId: ['CampaignId error'], - }, + const user = userEvent.setup(); + + const action = jest.fn().mockResolvedValueOnce({ + errorState: { + fieldErrors: { + name: ['Name error'], + campaignId: ['CampaignId error'], }, }, - jest.fn(), - false, - ]); + }); const container = render( - + + + ); + await user.click(screen.getByRole('button', { name: 'Save and continue' })); + expect(container.asFragment()).toMatchSnapshot(); }); test('invokes the action with the form data when the form is submitted - single campaign id', async () => { const user = userEvent.setup(); + const action = jest.fn().mockResolvedValue({}); + render( - - - + + + + + ); await user.click(screen.getByTestId('name-field')); @@ -104,11 +114,14 @@ test('invokes the action with the form data when the form is submitted - single await user.click(screen.getByTestId('submit-button')); - expect(mockAction).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenCalledTimes(1); - expect(mockAction).toHaveBeenLastCalledWith(expect.any(FormData)); + expect(action).toHaveBeenLastCalledWith( + expect.any(Object), + expect.any(FormData) + ); - const formData = mockAction.mock.lastCall?.at(0) as FormData; + const formData = action.mock.lastCall?.at(1) as FormData; expect(Object.fromEntries(formData.entries())).toMatchObject({ campaignId: 'campaign-id', @@ -120,16 +133,20 @@ test('invokes the action with the form data when the form is submitted - single test('invokes the action with the form data when the form is submitted - multiple campaign id', async () => { const user = userEvent.setup(); + const action = jest.fn().mockResolvedValue({}); + render( - - - + + + + + ); await user.click(screen.getByTestId('name-field')); @@ -143,11 +160,14 @@ test('invokes the action with the form data when the form is submitted - multipl await user.click(screen.getByTestId('submit-button')); - expect(mockAction).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenCalledTimes(1); - expect(mockAction).toHaveBeenLastCalledWith(expect.any(FormData)); + expect(action).toHaveBeenLastCalledWith( + expect.any(Object), + expect.any(FormData) + ); - const formData = mockAction.mock.lastCall?.at(0) as FormData; + const formData = action.mock.lastCall?.at(1) as FormData; expect(Object.fromEntries(formData.entries())).toMatchObject({ campaignId: 'campaign-id-2', diff --git a/frontend/src/__tests__/components/forms/MessagePlan/__snapshots__/MessagePlan.test.tsx.snap b/frontend/src/__tests__/components/forms/MessagePlan/__snapshots__/MessagePlan.test.tsx.snap index aa719cbc6..063744311 100644 --- a/frontend/src/__tests__/components/forms/MessagePlan/__snapshots__/MessagePlan.test.tsx.snap +++ b/frontend/src/__tests__/components/forms/MessagePlan/__snapshots__/MessagePlan.test.tsx.snap @@ -2,6 +2,38 @@ exports[`renders errors 1`] = ` +
diff --git a/frontend/src/__tests__/components/forms/NhsAppTemplateForm/__snapshots__/NhsAppTemplateForm.test.tsx.snap b/frontend/src/__tests__/components/forms/NhsAppTemplateForm/__snapshots__/NhsAppTemplateForm.test.tsx.snap index a48cf6277..c1d1ea8d1 100644 --- a/frontend/src/__tests__/components/forms/NhsAppTemplateForm/__snapshots__/NhsAppTemplateForm.test.tsx.snap +++ b/frontend/src/__tests__/components/forms/NhsAppTemplateForm/__snapshots__/NhsAppTemplateForm.test.tsx.snap @@ -162,7 +162,7 @@ exports[`Client-side validation triggers 1`] = ` class="" >

16 of 5000 characters

@@ -271,7 +271,7 @@ exports[`Client-side validation triggers 1`] = ` data-testid="custom-personalisation-fields-text" >

- You can add + You can add

- Include custom personalisation fields in your content. Then provide your custom personalisation data using + Include custom personalisation fields in your content. Then provide your custom personalisation data using NHS Notify API - or + or - line 1 -line 2 -line 3 + line 1 +line 2 +line 3

@@ -844,7 +844,7 @@ exports[`renders page 1`] = ` class="" >

16 of 5000 characters

@@ -953,7 +953,7 @@ exports[`renders page 1`] = ` data-testid="custom-personalisation-fields-text" >

- You can add + You can add

- Include custom personalisation fields in your content. Then provide your custom personalisation data using + Include custom personalisation fields in your content. Then provide your custom personalisation data using NHS Notify API - or + or - line 1 -line 2 -line 3 + line 1 +line 2 +line 3

@@ -1520,7 +1520,7 @@ exports[`renders page one error 1`] = ` - Error: + Error: Template name error @@ -1561,7 +1561,7 @@ exports[`renders page one error 1`] = ` class="" >

0 of 5000 characters

@@ -1670,7 +1670,7 @@ exports[`renders page one error 1`] = ` data-testid="custom-personalisation-fields-text" >

- You can add + You can add

- Include custom personalisation fields in your content. Then provide your custom personalisation data using + Include custom personalisation fields in your content. Then provide your custom personalisation data using NHS Notify API - or + or - line 1 -line 2 -line 3 + line 1 +line 2 +line 3

@@ -2299,7 +2299,7 @@ exports[`renders page with multiple errors 1`] = ` - Error: + Error: Template name error @@ -2334,7 +2334,7 @@ exports[`renders page with multiple errors 1`] = ` - Error: + Error: Template message error

0 of 5000 characters

@@ -2510,7 +2510,7 @@ exports[`renders page with multiple errors 1`] = ` data-testid="custom-personalisation-fields-text" >

- You can add + You can add

- Include custom personalisation fields in your content. Then provide your custom personalisation data using + Include custom personalisation fields in your content. Then provide your custom personalisation data using NHS Notify API - or + or - line 1 -line 2 -line 3 + line 1 +line 2 +line 3

@@ -3083,7 +3083,7 @@ exports[`renders page with preloaded field values 1`] = ` class="" >

16 of 5000 characters

@@ -3192,7 +3192,7 @@ exports[`renders page with preloaded field values 1`] = ` data-testid="custom-personalisation-fields-text" >

- You can add + You can add

- Include custom personalisation fields in your content. Then provide your custom personalisation data using + Include custom personalisation fields in your content. Then provide your custom personalisation data using NHS Notify API - or + or - line 1 -line 2 -line 3 + line 1 +line 2 +line 3

@@ -3758,7 +3758,7 @@ exports[`renders page without back link for initial state with id - edit mode 1` class="" >

16 of 5000 characters

@@ -3867,7 +3867,7 @@ exports[`renders page without back link for initial state with id - edit mode 1` data-testid="custom-personalisation-fields-text" >

- You can add + You can add

- Include custom personalisation fields in your content. Then provide your custom personalisation data using + Include custom personalisation fields in your content. Then provide your custom personalisation data using NHS Notify API - or + or - line 1 -line 2 -line 3 + line 1 +line 2 +line 3

diff --git a/frontend/src/__tests__/components/forms/SmsTemplateForm/SmsTemplateForm.test.tsx b/frontend/src/__tests__/components/forms/SmsTemplateForm/SmsTemplateForm.test.tsx index 45e187fa8..98a458c5e 100644 --- a/frontend/src/__tests__/components/forms/SmsTemplateForm/SmsTemplateForm.test.tsx +++ b/frontend/src/__tests__/components/forms/SmsTemplateForm/SmsTemplateForm.test.tsx @@ -120,7 +120,7 @@ describe('CreateSmsTemplate component', () => { await user.type(templateMessageBox, longMessage); - const characterCount = screen.getByTestId('character-message-count-0'); + const characterCount = screen.getByTestId('character-message-count'); if (!characterCount) { throw new Error('Character count not found'); diff --git a/frontend/src/__tests__/components/forms/SmsTemplateForm/__snapshots__/SmsTemplateForm.test.tsx.snap b/frontend/src/__tests__/components/forms/SmsTemplateForm/__snapshots__/SmsTemplateForm.test.tsx.snap index 80d1b2b85..5fc26d9d7 100644 --- a/frontend/src/__tests__/components/forms/SmsTemplateForm/__snapshots__/SmsTemplateForm.test.tsx.snap +++ b/frontend/src/__tests__/components/forms/SmsTemplateForm/__snapshots__/SmsTemplateForm.test.tsx.snap @@ -138,7 +138,7 @@ exports[`CreateSmsTemplate component Client-side validation triggers 1`] = ` - Error: + Error: Enter a template name @@ -173,7 +173,7 @@ exports[`CreateSmsTemplate component Client-side validation triggers 1`] = ` - Error: + Error: Enter a template message @@ -191,7 +191,7 @@ exports[`CreateSmsTemplate component Client-side validation triggers 1`] = ` class="" >

0 characters
@@ -200,7 +200,7 @@ exports[`CreateSmsTemplate component Client-side validation triggers 1`] = ` If you're using personalisation fields, it could be charged as more.

- You can add + You can add

- Include custom personalisation fields in your content. Then provide your custom personalisation data using + Include custom personalisation fields in your content. Then provide your custom personalisation data using NHS Notify API - or + or - Error: + Error: Template name error @@ -636,7 +636,7 @@ exports[`CreateSmsTemplate component renders page one error 1`] = ` class="" >

16 characters
@@ -645,7 +645,7 @@ exports[`CreateSmsTemplate component renders page one error 1`] = ` If you're using personalisation fields, it could be charged as more.

- You can add + You can add

- Include custom personalisation fields in your content. Then provide your custom personalisation data using + Include custom personalisation fields in your content. Then provide your custom personalisation data using NHS Notify API - or + or

16 characters
@@ -1060,7 +1060,7 @@ exports[`CreateSmsTemplate component renders page with back link if initial stat If you're using personalisation fields, it could be charged as more.

- You can add + You can add

- Include custom personalisation fields in your content. Then provide your custom personalisation data using + Include custom personalisation fields in your content. Then provide your custom personalisation data using NHS Notify API - or + or - Error: + Error: Template name error @@ -1550,7 +1550,7 @@ exports[`CreateSmsTemplate component renders page with multiple errors 1`] = ` - Error: + Error: Template message error

16 characters
@@ -1628,7 +1628,7 @@ exports[`CreateSmsTemplate component renders page with multiple errors 1`] = ` If you're using personalisation fields, it could be charged as more.

- You can add + You can add

- Include custom personalisation fields in your content. Then provide your custom personalisation data using + Include custom personalisation fields in your content. Then provide your custom personalisation data using NHS Notify API - or + or

16 characters
@@ -2036,7 +2036,7 @@ exports[`CreateSmsTemplate component renders page with no back link if initial s If you're using personalisation fields, it could be charged as more.

- You can add + You can add

- Include custom personalisation fields in your content. Then provide your custom personalisation data using + Include custom personalisation fields in your content. Then provide your custom personalisation data using NHS Notify API - or + or { expect(screen.getByRole('link')).toHaveTextContent('link'); }); - it('passes test ID through if content is a string', () => { - render(); - expect(screen.getByText('This is content')).toHaveAttribute( - 'data-testid', - 'content-id-0' - ); - }); - it('renders multiple segments in correct order', () => { const segments = ['First paragraph', 'Second [link](https://example.com)']; @@ -45,21 +37,6 @@ describe('MarkdownContent', () => { expect(screen.getByRole('link')).toHaveTextContent('link'); }); - it('passes indexed test IDs to each item if content is an array', () => { - render( - - ); - - const first = screen.getByText('First paragraph'); - expect(first).toHaveAttribute('data-testid', 'content-id-0'); - - const second = screen.getByText('Second paragraph'); - expect(second).toHaveAttribute('data-testid', 'content-id-1'); - }); - it('adds correct attributes to links', () => { const segments = ['Click [here](https://example.com)']; diff --git a/frontend/src/__tests__/components/providers/form-provider.test.tsx b/frontend/src/__tests__/components/providers/form-provider.test.tsx index 6ab5ba43d..21a1897ab 100644 --- a/frontend/src/__tests__/components/providers/form-provider.test.tsx +++ b/frontend/src/__tests__/components/providers/form-provider.test.tsx @@ -11,10 +11,7 @@ import type { FormState, } from 'nhs-notify-web-template-management-utils'; import { NhsNotifyErrorSummary } from '@molecules/NhsNotifyErrorSummary/NhsNotifyErrorSummary'; -import { - NHSNotifyFormProvider, - useNHSNotifyForm, -} from '@providers/form-provider'; +import { createNhsNotifyFormContext } from '@providers/form-provider'; import { startTransition } from 'react'; jest.mock('@molecules/NhsNotifyErrorSummary/NhsNotifyErrorSummary'); @@ -27,6 +24,9 @@ beforeEach(() => { jest.mocked(NhsNotifyErrorSummary).mockClear(); }); +const { useNHSNotifyForm, NHSNotifyFormProvider } = + createNhsNotifyFormContext(); + function TestForm() { const [, action] = useNHSNotifyForm(); diff --git a/frontend/src/app/message-plans/create-message-plan/page.tsx b/frontend/src/app/message-plans/create-message-plan/page.tsx index 1d10be201..2476a1c6c 100644 --- a/frontend/src/app/message-plans/create-message-plan/page.tsx +++ b/frontend/src/app/message-plans/create-message-plan/page.tsx @@ -4,10 +4,13 @@ import { z } from 'zod/v4'; import { MESSAGE_ORDER_OPTIONS_LIST } from 'nhs-notify-web-template-management-utils'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import content from '@content/content'; -import { MessagePlanForm } from '@forms/MessagePlan/MessagePlan'; +import { + MessagePlanForm, + NHSNotifyFormProvider, +} from '@forms/MessagePlan/MessagePlan'; import { getCampaignIds } from '@utils/client-config'; import { fetchClient } from '@utils/server-features'; -import { NHSNotifyFormProvider } from '@providers/form-provider'; + import { createMessagePlanServerAction } from './server-action'; const pageContent = content.pages.createMessagePlan; diff --git a/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx b/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx index 1c2785e02..6d4a02ca1 100644 --- a/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx +++ b/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx @@ -3,11 +3,13 @@ import { redirect, RedirectType } from 'next/navigation'; import type { MessagePlanPageProps } from 'nhs-notify-web-template-management-utils'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import content from '@content/content'; -import { MessagePlanForm } from '@forms/MessagePlan/MessagePlan'; +import { + MessagePlanForm, + NHSNotifyFormProvider, +} from '@forms/MessagePlan/MessagePlan'; import { getCampaignIds } from '@utils/client-config'; import { getRoutingConfig } from '@utils/message-plans'; import { fetchClient } from '@utils/server-features'; -import { NHSNotifyFormProvider } from '@providers/form-provider'; import { editMessagePlanSettingsServerAction } from './server-action'; const pageContent = content.pages.editMessagePlanSettings; diff --git a/frontend/src/app/upload-standard-letter-template/form.tsx b/frontend/src/app/upload-standard-letter-template/form.tsx new file mode 100644 index 000000000..0a7b4c237 --- /dev/null +++ b/frontend/src/app/upload-standard-letter-template/form.tsx @@ -0,0 +1,115 @@ +'use client'; + +import classNames from 'classnames'; +import { + Button, + Details, + ErrorMessage, + HintText, + Label, +} from 'nhsuk-react-components'; +import { FileUploadInput } from '@atoms/FileUpload/FileUpload'; +import copy from '@content/content'; +import { NHSNotifyFormWrapper } from '@molecules/NHSNotifyFormWrapper/NHSNotifyFormWrapper'; +import { createNhsNotifyFormContext } from '@providers/form-provider'; +import { DOCX_MIME, type FormSchema } from './server-action'; +import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; +import { NHSNotifyFormGroup } from '@atoms/NHSNotifyFormGroup/NHSNotifyFormGroup'; + +const { useNHSNotifyForm, NHSNotifyFormProvider } = + createNhsNotifyFormContext(); + +export { NHSNotifyFormProvider }; + +type FormProps = { campaignIds: string[] }; + +const content = copy.pages.uploadStandardLetterTemplate.form; + +export function UploadStandardLetterTemplateForm({ campaignIds }: FormProps) { + const [state, action] = useNHSNotifyForm(); + + const nameError = state.errorState?.fieldErrors?.name?.join(','); + const campaignIdError = state.errorState?.fieldErrors?.campaignId?.join(','); + const fileError = state.errorState?.fieldErrors?.file?.join(','); + + return ( + + + + {content.name.hint} + +

+ {content.name.details.summary} + + + +
+ {nameError && {nameError}} + + + + + + {campaignIds.length === 1 ? ( + <> + {content.campaignId.single.hint} + +

{campaignIds[0]}

+ + ) : ( + <> + {content.campaignId.select.hint} + {campaignIdError && {campaignIdError}} + + + )} +
+ + + + + + + {fileError && {fileError}} + + + + + + ); +} diff --git a/frontend/src/app/upload-standard-letter-template/page.tsx b/frontend/src/app/upload-standard-letter-template/page.tsx new file mode 100644 index 000000000..e5f71b7cb --- /dev/null +++ b/frontend/src/app/upload-standard-letter-template/page.tsx @@ -0,0 +1,54 @@ +'use server'; + +import { redirect, RedirectType } from 'next/navigation'; +import { NHSNotifyBackLink } from '@atoms/NHSNotifyBackLink/NHSNotifyBackLink'; +import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; +import copy from '@content/content'; +import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; +import { fetchClient } from '@utils/server-features'; +import { getCampaignIds } from '@utils/client-config'; +import { + UploadStandardLetterTemplateForm, + NHSNotifyFormProvider, +} from './form'; +import { uploadStandardLetterTemplate } from './server-action'; + +const content = copy.pages.uploadStandardLetterTemplate; + +export default async function UploadStandardLetterTemplatePage() { + const client = await fetchClient(); + const campaignIds = getCampaignIds(client); + + if (campaignIds.length === 0) { + return redirect( + '/upload-letter-template/client-id-and-campaign-id-required', + RedirectType.replace + ); + } + + return ( + <> + + {content.backLink.text} + + + +
+
+

{content.heading}

+
+
+
+
+ +
+ +
+ +
+
+
+
+ + ); +} diff --git a/frontend/src/app/upload-standard-letter-template/server-action.ts b/frontend/src/app/upload-standard-letter-template/server-action.ts new file mode 100644 index 000000000..7fac829aa --- /dev/null +++ b/frontend/src/app/upload-standard-letter-template/server-action.ts @@ -0,0 +1,39 @@ +'use server'; + +import { z } from 'zod/v4'; +import { FormState } from 'nhs-notify-web-template-management-utils'; +import copy from '@content/content'; + +const { errors } = copy.pages.uploadStandardLetterTemplate; + +export const DOCX_MIME: z.core.util.MimeTypes = + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + +const $FormSchema = z.object({ + name: z.string(errors.name.empty).nonempty(errors.name.empty), + campaignId: z + .string(errors.campaignId.empty) + .nonempty(errors.campaignId.empty), + file: z.file(errors.file.empty).mime(DOCX_MIME, errors.file.empty), +}); + +export type FormSchema = z.infer; + +export async function uploadStandardLetterTemplate( + _: FormState, + form: FormData +): Promise> { + const fields = Object.fromEntries(form.entries()); + + const validation = $FormSchema.safeParse(fields); + + if (validation.error) { + return { + errorState: z.flattenError(validation.error), + fields, + }; + } + + // TODO: CCM-14211 - submit the form and redirect instead of returning + return { fields }; +} diff --git a/frontend/src/components/atoms/FileUpload/FileUpload.tsx b/frontend/src/components/atoms/FileUpload/FileUpload.tsx index b5ded7787..5bb1a649b 100644 --- a/frontend/src/components/atoms/FileUpload/FileUpload.tsx +++ b/frontend/src/components/atoms/FileUpload/FileUpload.tsx @@ -8,6 +8,19 @@ interface FileUploadProps extends HTMLProps { hint?: string; } +export function FileUploadInput({ + className, + ...props +}: Omit, 'type'>) { + return ( + + ); +} + const FileUpload: React.FC = ({ error, hint, @@ -26,13 +39,7 @@ const FileUpload: React.FC = ({ {label && } {hint && {hint}} {error && {error}} - +
); }; diff --git a/frontend/src/components/atoms/NHSNotifyBackLink/NHSNotifyBackLink.tsx b/frontend/src/components/atoms/NHSNotifyBackLink/NHSNotifyBackLink.tsx index 1dc325ce0..4a058dfd9 100644 --- a/frontend/src/components/atoms/NHSNotifyBackLink/NHSNotifyBackLink.tsx +++ b/frontend/src/components/atoms/NHSNotifyBackLink/NHSNotifyBackLink.tsx @@ -1,5 +1,6 @@ import React, { HTMLProps } from 'react'; import classNames from 'classnames'; +import Link from 'next/link'; interface NotifyBackLinkProps extends HTMLProps { asElement?: React.ElementType; @@ -24,4 +25,22 @@ function NotifyBackLink({ ); } +type LinkProps = React.ComponentProps; + +export function NHSNotifyBackLink({ + children, + className, + ...props +}: LinkProps) { + return ( + + {children} + + ); +} + export default NotifyBackLink; diff --git a/frontend/src/components/atoms/NHSNotifyFormGroup/NHSNotifyFormGroup.tsx b/frontend/src/components/atoms/NHSNotifyFormGroup/NHSNotifyFormGroup.tsx new file mode 100644 index 000000000..93a14d431 --- /dev/null +++ b/frontend/src/components/atoms/NHSNotifyFormGroup/NHSNotifyFormGroup.tsx @@ -0,0 +1,25 @@ +import classNames from 'classnames'; +import { HTMLProps } from 'react'; + +export function NHSNotifyFormGroup({ + children, + className, + error, + ...props +}: HTMLProps & { error: boolean }) { + return ( +
+ {children} +
+ ); +} diff --git a/frontend/src/components/forms/MessagePlan/MessagePlan.tsx b/frontend/src/components/forms/MessagePlan/MessagePlan.tsx index 39ce16891..611f80556 100644 --- a/frontend/src/components/forms/MessagePlan/MessagePlan.tsx +++ b/frontend/src/components/forms/MessagePlan/MessagePlan.tsx @@ -15,11 +15,16 @@ import { NHSNotifyButton } from '@atoms/NHSNotifyButton/NHSNotifyButton'; import content from '@content/content'; import { useTextInput } from '@hooks/use-text-input.hook'; import { NHSNotifyFormWrapper } from '@molecules/NHSNotifyFormWrapper/NHSNotifyFormWrapper'; -import { useNHSNotifyForm } from '@providers/form-provider'; +import { createNhsNotifyFormContext } from '@providers/form-provider'; import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; const formContent = content.components.messagePlanForm; +const { useNHSNotifyForm, NHSNotifyFormProvider } = + createNhsNotifyFormContext(); + +export { NHSNotifyFormProvider }; + export function MessagePlanForm({ backLink, campaignIds, diff --git a/frontend/src/components/forms/NhsAppTemplateForm/NhsAppTemplateForm.tsx b/frontend/src/components/forms/NhsAppTemplateForm/NhsAppTemplateForm.tsx index db2e0da7c..03ecc9dcd 100644 --- a/frontend/src/components/forms/NhsAppTemplateForm/NhsAppTemplateForm.tsx +++ b/frontend/src/components/forms/NhsAppTemplateForm/NhsAppTemplateForm.tsx @@ -131,9 +131,11 @@ export const NhsAppTemplateForm: FC< />
diff --git a/frontend/src/components/molecules/ContentRenderer/ContentRenderer.tsx b/frontend/src/components/molecules/ContentRenderer/ContentRenderer.tsx index de2f66cf0..740f2ca0d 100644 --- a/frontend/src/components/molecules/ContentRenderer/ContentRenderer.tsx +++ b/frontend/src/components/molecules/ContentRenderer/ContentRenderer.tsx @@ -2,21 +2,19 @@ import type { MarkdownToJSX } from 'markdown-to-jsx'; import CodeExample from '@atoms/CodeExample/CodeExample'; import { MarkdownContent } from '@molecules/MarkdownContent/MarkdownContent'; -type StandardBlock = { testId?: string }; - -export type MarkdownTextBlock = StandardBlock & { +export type MarkdownTextBlock = { type: 'text'; text: string; overrides?: MarkdownToJSX.Overrides; }; -export type MarkdownInlineBlock = StandardBlock & { +export type MarkdownInlineBlock = { type: 'inline-text'; text: string; overrides?: MarkdownToJSX.Overrides; }; -export type CodeBlock = StandardBlock & { +export type CodeBlock = { type: 'code'; code: string; aria: { text: string; id: string }; @@ -50,14 +48,11 @@ export function ContentRenderer({ content, variables }: ContentRendererProps) { ); } - const key = block.testId ?? index; - switch (block.type) { case 'text': { return ( diff --git a/frontend/src/components/molecules/MarkdownContent/MarkdownContent.tsx b/frontend/src/components/molecules/MarkdownContent/MarkdownContent.tsx index 5b40846e7..b2cc3ba3c 100644 --- a/frontend/src/components/molecules/MarkdownContent/MarkdownContent.tsx +++ b/frontend/src/components/molecules/MarkdownContent/MarkdownContent.tsx @@ -5,7 +5,6 @@ import Markdown, { type MarkdownToJSX } from 'markdown-to-jsx'; type MarkdownContentProps = { content: string | string[]; variables?: Record; - testId?: string; mode?: 'block' | 'inline'; overrides?: MarkdownToJSX.Overrides; }; @@ -13,7 +12,6 @@ type MarkdownContentProps = { export function MarkdownContent({ content, variables, - testId, mode = 'block', overrides, }: MarkdownContentProps) { @@ -59,7 +57,6 @@ export function MarkdownContent({ {rendered.map((item, index) => ( {item} diff --git a/frontend/src/components/providers/form-provider.tsx b/frontend/src/components/providers/form-provider.tsx index c3e06587f..d6cd066a1 100644 --- a/frontend/src/components/providers/form-provider.tsx +++ b/frontend/src/components/providers/form-provider.tsx @@ -1,6 +1,6 @@ 'use client'; -import { +import React, { type PropsWithChildren, createContext, useActionState, @@ -9,38 +9,48 @@ import { import type { FormState } from 'nhs-notify-web-template-management-utils'; import { NhsNotifyErrorSummary } from '@molecules/NhsNotifyErrorSummary/NhsNotifyErrorSummary'; -type NHSNotifyFormActionState = ReturnType< - typeof useActionState +type NHSNotifyFormActionState> = ReturnType< + typeof useActionState, FormData> >; -const FormContext = createContext(null); +export function createNhsNotifyFormContext< + T extends Record, +>() { + const FormContext = createContext | null>(null); -export function useNHSNotifyForm() { - const context = useContext(FormContext); - if (!context) - throw new Error( - 'useNHSNotifyForm must be used within NHSNotifyFormProvider' - ); - return context; -} + function useNHSNotifyForm() { + const context = useContext(FormContext); + if (!context) { + throw new Error( + 'useNHSNotifyForm must be used within NHSNotifyFormProvider' + ); + } + return context; + } -export function NHSNotifyFormProvider({ - children, - initialState = {}, - serverAction, -}: PropsWithChildren<{ - initialState?: FormState; - serverAction: (state: FormState, data: FormData) => Promise; -}>) { - const [state, action, isPending] = useActionState( + function NHSNotifyFormProvider({ + children, + initialState = {}, serverAction, - initialState - ); + }: PropsWithChildren<{ + initialState?: FormState; + serverAction: ( + state: FormState, + data: FormData + ) => Promise>; + }>) { + const [state, action, isPending] = useActionState, FormData>( + serverAction, + initialState + ); + + return ( + + + {children} + + ); + } - return ( - - - {children} - - ); + return { NHSNotifyFormProvider, useNHSNotifyForm }; } diff --git a/frontend/src/content/content.ts b/frontend/src/content/content.ts index e53060fc6..45f25ad34 100644 --- a/frontend/src/content/content.ts +++ b/frontend/src/content/content.ts @@ -945,13 +945,25 @@ const templateFormEmail = { const smsTemplateFooter: ContentBlock[] = [ { type: 'text', - testId: 'character-message-count', text: `{{characters}} {{characters|character|characters}} \nThis template will be charged as {{count}} {{count|text message|text messages}}. \nIf you're using personalisation fields, it could be charged as more.`, + overrides: { + p: { + props: { + 'data-testid': 'character-message-count', + }, + }, + }, }, { type: 'text', - testId: 'sms-pricing-info', text: '[Learn more about character counts and text messaging pricing (opens in a new tab)](/pricing/text-messages)', + overrides: { + p: { + props: { + 'data-testid': 'sms-pricing-info', + }, + }, + }, }, ]; @@ -1472,6 +1484,99 @@ const lockedTemplateWarning = { }, }; +const uploadStandardLetterTemplateSideBar: ContentBlock[] = [ + { + type: 'text', + text: '## How to create a standard letter template', + overrides: { h2: { props: { className: 'nhsuk-heading-m' } } }, + }, + { + type: 'text', + text: markdownList('ol', [ + 'Download the blank [standard letter template file](https://notify.nhs.uk/assets/worddocs/letter-template-nhs-notify.docx).', + 'Add [formatting (opens in a new tab)](https://notify.nhs.uk).', + 'Add any [personalisation (opens in a new tab)](https://notify.nhs.uk).', + 'Save your Microsoft Word file and upload it to this page.', + ]), + overrides: { + ol: { props: { className: 'nhsuk-list nhsuk-list--number' } }, + li: { props: { className: 'nhsuk-u-margin-bottom-4' } }, + }, + }, +]; + +const uploadStandardLetterTemplate = { + backLink: { + href: '/choose-a-template-type', + text: 'Back to choose a template type', + }, + heading: 'Upload a standard English letter template', + sideBar: uploadStandardLetterTemplateSideBar, + form: { + name: { + label: 'Template name', + hint: 'This will not be visible to recipients.', + details: { + summary: 'Naming your templates', + text: [ + { + type: 'text', + text: 'You should name your templates in a way that works best for your service or organisation.', + }, + { + type: 'text', + text: 'Common template names include the:', + }, + { + type: 'text', + text: markdownList('ul', [ + 'subject or reason for the message', + 'intended audience for the template', + 'version number of the template', + ]), + }, + { + type: 'text', + text: "For example, 'Covid19 2025 - over 65s - version 3'", + }, + ] satisfies ContentBlock[], + }, + }, + campaignId: { + label: 'Campaign', + single: { + hint: 'This message plan will link to your only campaign:', + }, + select: { + hint: 'Choose which campaign this letter is for', + }, + }, + file: { + label: 'Template file', + hint: [ + { + type: 'inline-text', + text: 'Only upload your final letter template file. \nMake sure you use one of our blank template files to create the letter.', + }, + ] satisfies ContentBlock[], + }, + submitButton: { + text: 'Upload letter template file', + }, + }, + errors: { + name: { + empty: 'Enter a template name', + }, + campaignId: { + empty: 'Choose a campaign', + }, + file: { + empty: 'Choose a template file', + }, + }, +}; + const content = { global: { mainLayout }, components: { @@ -1533,6 +1638,7 @@ const content = { previewLargePrintLetterTemplate, previewOtherLanguageLetterTemplate, deleteTemplateErrorPage, + uploadStandardLetterTemplate, }, }; diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index afa88a8e5..a7717994e 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -56,6 +56,7 @@ const protectedPaths = [ /^\/text-message-template-submitted\/[^/]+$/, /^\/upload-letter-template\/client-id-and-campaign-id-required$/, /^\/upload-letter-template$/, + /^\/upload-standard-letter-template$/, ]; const publicPaths = [ diff --git a/tests/test-team/fixtures/letters/docx/standard-template.docx b/tests/test-team/fixtures/letters/docx/standard-template.docx new file mode 100644 index 0000000000000000000000000000000000000000..d309a03611a1d9e00377040933a73292440ad3fc GIT binary patch literal 85034 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
  • ?t1 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 I(D8u&4;+Q_VE_OC literal 0 HcmV?d00001 diff --git a/tests/test-team/fixtures/pdf-upload/multipart-pdf-letter-fixtures.ts b/tests/test-team/fixtures/letters/index.ts similarity index 95% rename from tests/test-team/fixtures/pdf-upload/multipart-pdf-letter-fixtures.ts rename to tests/test-team/fixtures/letters/index.ts index 59712bdaf..b7ef902c6 100644 --- a/tests/test-team/fixtures/pdf-upload/multipart-pdf-letter-fixtures.ts +++ b/tests/test-team/fixtures/letters/index.ts @@ -62,3 +62,7 @@ export const pdfUploadFixtures = { ), }, }; + +export const docxFixtures = { + standard: getFile('docx', 'standard-template.docx'), +}; diff --git a/tests/test-team/fixtures/pdf-upload/no-custom-personalisation/incomplete-address.pdf b/tests/test-team/fixtures/letters/no-custom-personalisation/incomplete-address.pdf similarity index 100% rename from tests/test-team/fixtures/pdf-upload/no-custom-personalisation/incomplete-address.pdf rename to tests/test-team/fixtures/letters/no-custom-personalisation/incomplete-address.pdf diff --git a/tests/test-team/fixtures/pdf-upload/no-custom-personalisation/password.pdf b/tests/test-team/fixtures/letters/no-custom-personalisation/password.pdf similarity index 100% rename from tests/test-team/fixtures/pdf-upload/no-custom-personalisation/password.pdf rename to tests/test-team/fixtures/letters/no-custom-personalisation/password.pdf diff --git a/tests/test-team/fixtures/pdf-upload/no-custom-personalisation/template.pdf b/tests/test-team/fixtures/letters/no-custom-personalisation/template.pdf similarity index 100% rename from tests/test-team/fixtures/pdf-upload/no-custom-personalisation/template.pdf rename to tests/test-team/fixtures/letters/no-custom-personalisation/template.pdf diff --git a/tests/test-team/fixtures/pdf-upload/with-personalisation/eicar-threat-test.csv b/tests/test-team/fixtures/letters/with-personalisation/eicar-threat-test.csv similarity index 100% rename from tests/test-team/fixtures/pdf-upload/with-personalisation/eicar-threat-test.csv rename to tests/test-team/fixtures/letters/with-personalisation/eicar-threat-test.csv diff --git a/tests/test-team/fixtures/letters/with-personalisation/empty-params.csv b/tests/test-team/fixtures/letters/with-personalisation/empty-params.csv new file mode 100644 index 000000000..b0fdc8457 --- /dev/null +++ b/tests/test-team/fixtures/letters/with-personalisation/empty-params.csv @@ -0,0 +1,2 @@ +Personalisation field,Short length data example,Medium length data example,Long length data example +,short example,middle example,super long example diff --git a/tests/test-team/fixtures/pdf-upload/with-personalisation/empty-params.pdf b/tests/test-team/fixtures/letters/with-personalisation/empty-params.pdf similarity index 100% rename from tests/test-team/fixtures/pdf-upload/with-personalisation/empty-params.pdf rename to tests/test-team/fixtures/letters/with-personalisation/empty-params.pdf diff --git a/tests/test-team/fixtures/letters/with-personalisation/nonsense.csv b/tests/test-team/fixtures/letters/with-personalisation/nonsense.csv new file mode 100644 index 000000000..729e28be0 --- /dev/null +++ b/tests/test-team/fixtures/letters/with-personalisation/nonsense.csv @@ -0,0 +1,2 @@ +Personalisation field,Short length data example,Medium length data example,Long length data example +!!!,short example,middle example,super long example diff --git a/tests/test-team/fixtures/pdf-upload/with-personalisation/nonsense.pdf b/tests/test-team/fixtures/letters/with-personalisation/nonsense.pdf similarity index 100% rename from tests/test-team/fixtures/pdf-upload/with-personalisation/nonsense.pdf rename to tests/test-team/fixtures/letters/with-personalisation/nonsense.pdf diff --git a/tests/test-team/fixtures/pdf-upload/with-personalisation/password.pdf b/tests/test-team/fixtures/letters/with-personalisation/password.pdf similarity index 100% rename from tests/test-team/fixtures/pdf-upload/with-personalisation/password.pdf rename to tests/test-team/fixtures/letters/with-personalisation/password.pdf diff --git a/tests/test-team/fixtures/pdf-upload/with-personalisation/template.pdf b/tests/test-team/fixtures/letters/with-personalisation/template.pdf similarity index 100% rename from tests/test-team/fixtures/pdf-upload/with-personalisation/template.pdf rename to tests/test-team/fixtures/letters/with-personalisation/template.pdf diff --git a/tests/test-team/fixtures/pdf-upload/with-personalisation/test-data.csv b/tests/test-team/fixtures/letters/with-personalisation/test-data.csv similarity index 85% rename from tests/test-team/fixtures/pdf-upload/with-personalisation/test-data.csv rename to tests/test-team/fixtures/letters/with-personalisation/test-data.csv index 9b23d9756..450a8b3e0 100644 --- a/tests/test-team/fixtures/pdf-upload/with-personalisation/test-data.csv +++ b/tests/test-team/fixtures/letters/with-personalisation/test-data.csv @@ -1,4 +1,4 @@ -Personalisation field,Short length data example,Medium length data example,Long length data example +Personalisation field,Short length data example,Medium length data example,Long length data example appointment_date,Monday 1 May 2025,Saturday 10 April 2025,Wednesday 10 September 2025 appointment_time,1:56pm,11:56am,12:56pm appointment_location,"The Epping Breast Screening Unit, St Margaret's Hospital, The Plain, Epping, Essex, CM16 6TN","The Royal Shrewsbury Hospital, Breast Screening Office, Treatment Centre, Mytton Oak Road, Shrewsbury, SY3 8XQ","City, Sandwell & Walsall BSS, The Rosewood Centre, Sandwell & West Birmingham Hospitals NHS Trust, The Birmingham Treatment Centre, City Hospital, Dudley Road, Birmingham, B18 7QH" diff --git a/tests/test-team/fixtures/pdf-upload/with-personalisation/wrong-params.csv b/tests/test-team/fixtures/letters/with-personalisation/wrong-params.csv similarity index 84% rename from tests/test-team/fixtures/pdf-upload/with-personalisation/wrong-params.csv rename to tests/test-team/fixtures/letters/with-personalisation/wrong-params.csv index a761ad50f..83d9cc929 100644 --- a/tests/test-team/fixtures/pdf-upload/with-personalisation/wrong-params.csv +++ b/tests/test-team/fixtures/letters/with-personalisation/wrong-params.csv @@ -1,4 +1,4 @@ -Personalisation field,Short length data example,Medium length data example,Long length data example +Personalisation field,Short length data example,Medium length data example,Long length data example appt_date,Wednesday 10 September 2025,Saturday 10 April 2025,Monday 1 May 2025 appt_time,12:56pm,11:56am,1:56pm appt_location,"City, Sandwell & Walsall BSS, The Rosewood Centre, Sandwell & West Birmingham Hospitals NHS Trust, The Birmingham Treatment Centre, City Hospital, Dudley Road, Birmingham, B18 7QH","The Royal Shrewsbury Hospital, Breast Screening Office, Treatment Centre, Mytton Oak Road, Shrewsbury, SY3 8XQ","The Epping Breast Screening Unit, St Margaret's Hospital, The Plain, Epping, Essex, CM16 6TN" diff --git a/tests/test-team/fixtures/pdf-upload/with-personalisation/empty-params.csv b/tests/test-team/fixtures/pdf-upload/with-personalisation/empty-params.csv deleted file mode 100644 index 1a9e2e4f9..000000000 --- a/tests/test-team/fixtures/pdf-upload/with-personalisation/empty-params.csv +++ /dev/null @@ -1,2 +0,0 @@ -Personalisation field,Short length data example,Medium length data example,Long length data example -,short example,middle example,super long example diff --git a/tests/test-team/fixtures/pdf-upload/with-personalisation/nonsense.csv b/tests/test-team/fixtures/pdf-upload/with-personalisation/nonsense.csv deleted file mode 100644 index afe01f01f..000000000 --- a/tests/test-team/fixtures/pdf-upload/with-personalisation/nonsense.csv +++ /dev/null @@ -1,2 +0,0 @@ -Personalisation field,Short length data example,Medium length data example,Long length data example -!!!,short example,middle example,super long example diff --git a/tests/test-team/pages/letter/template-mgmt-upload-standard-letter-template-page.ts b/tests/test-team/pages/letter/template-mgmt-upload-standard-letter-template-page.ts new file mode 100644 index 000000000..8f4e42240 --- /dev/null +++ b/tests/test-team/pages/letter/template-mgmt-upload-standard-letter-template-page.ts @@ -0,0 +1,32 @@ +import type { Locator, Page } from '@playwright/test'; +import { TemplateMgmtBasePage } from '../template-mgmt-base-page'; + +export class TemplateMgmtUploadStandardLetterTemplatePage extends TemplateMgmtBasePage { + static readonly pathTemplate = '/upload-standard-letter-template'; + + nameInput: Locator; + + campaignIdInput: Locator; + + singleCampaignIdText: Locator; + + fileInput: Locator; + + submitButton: Locator; + + constructor(page: Page) { + super(page); + + this.nameInput = page.getByLabel('Template name'); + + this.campaignIdInput = page.getByLabel('Campaign'); + + this.fileInput = page.getByLabel('Template file'); + + this.singleCampaignIdText = page.getByTestId('single-campaign-id-text'); + + this.submitButton = page.getByRole('button', { + name: 'Upload letter template file', + }); + } +} diff --git a/tests/test-team/pages/nhs-app/template-mgmt-create-nhs-app-page.ts b/tests/test-team/pages/nhs-app/template-mgmt-create-nhs-app-page.ts index 111cc55d2..c1210a7ce 100644 --- a/tests/test-team/pages/nhs-app/template-mgmt-create-nhs-app-page.ts +++ b/tests/test-team/pages/nhs-app/template-mgmt-create-nhs-app-page.ts @@ -32,7 +32,7 @@ export class TemplateMgmtCreateNhsAppPage extends TemplateMgmtBasePage { this.namingYourTemplate = page.locator( '[data-testid="how-to-name-your-template-details"]' ); - this.characterCountText = page.getByTestId('character-message-count-0'); + this.characterCountText = page.getByTestId('character-message-count'); this.messageFormatting = new TemplateMgmtMessageFormatting(page); this.saveAndPreviewButton = page.locator( '[id="create-nhs-app-template-submit-button"]' @@ -44,7 +44,7 @@ export class TemplateMgmtCreateNhsAppPage extends TemplateMgmtBasePage { } async waitForPageToLoad() { - const characterCountLocator = this.page.getByTestId('character-count-0'); + const characterCountLocator = this.page.getByTestId('character-count'); await expect(characterCountLocator).toBeVisible(); } diff --git a/tests/test-team/pages/nhs-app/template-mgmt-edit-nhs-app-page.ts b/tests/test-team/pages/nhs-app/template-mgmt-edit-nhs-app-page.ts index 9f4d70f4b..7821cd9b6 100644 --- a/tests/test-team/pages/nhs-app/template-mgmt-edit-nhs-app-page.ts +++ b/tests/test-team/pages/nhs-app/template-mgmt-edit-nhs-app-page.ts @@ -32,7 +32,7 @@ export class TemplateMgmtEditNhsAppPage extends TemplateMgmtBasePage { this.namingYourTemplate = page.locator( '[data-testid="how-to-name-your-template-details"]' ); - this.characterCountText = page.getByTestId('character-message-count-0'); + this.characterCountText = page.getByTestId('character-message-count'); this.messageFormatting = new TemplateMgmtMessageFormatting(page); this.saveAndPreviewButton = page.locator( @@ -45,7 +45,7 @@ export class TemplateMgmtEditNhsAppPage extends TemplateMgmtBasePage { } async waitForPageToLoad() { - const characterCountLocator = this.page.getByTestId('character-count-0'); + const characterCountLocator = this.page.getByTestId('character-count'); await expect(characterCountLocator).toBeVisible(); } diff --git a/tests/test-team/pages/sms/template-mgmt-create-sms-page.ts b/tests/test-team/pages/sms/template-mgmt-create-sms-page.ts index 1e3ef9197..b3601d1d2 100644 --- a/tests/test-team/pages/sms/template-mgmt-create-sms-page.ts +++ b/tests/test-team/pages/sms/template-mgmt-create-sms-page.ts @@ -39,8 +39,8 @@ export class TemplateMgmtCreateSmsPage extends TemplateMgmtBasePage { this.namingYourTemplate = page.locator( '[data-testid="how-to-name-your-template-details"]' ); - this.pricingLink = page.getByTestId('sms-pricing-info-0').locator('a'); - this.characterCountText = page.getByTestId('character-message-count-0'); + this.pricingLink = page.getByTestId('sms-pricing-info').locator('a'); + this.characterCountText = page.getByTestId('character-message-count'); this.messageFormatting = new TemplateMgmtMessageFormatting(page); this.saveAndPreviewButton = page.locator( @@ -55,7 +55,7 @@ export class TemplateMgmtCreateSmsPage extends TemplateMgmtBasePage { async waitForPageToLoad() { const characterCountLocator = this.page.locator( - '[data-testid="character-message-count-0"]' + '[data-testid="character-message-count"]' ); await expect(characterCountLocator).toBeVisible(); } diff --git a/tests/test-team/pages/sms/template-mgmt-edit-sms-page.ts b/tests/test-team/pages/sms/template-mgmt-edit-sms-page.ts index 398cb7985..45cff32c7 100644 --- a/tests/test-team/pages/sms/template-mgmt-edit-sms-page.ts +++ b/tests/test-team/pages/sms/template-mgmt-edit-sms-page.ts @@ -39,8 +39,8 @@ export class TemplateMgmtEditSmsPage extends TemplateMgmtBasePage { this.namingYourTemplate = page.locator( '[data-testid="how-to-name-your-template-details"]' ); - this.pricingLink = page.getByTestId('sms-pricing-info-0').locator('a'); - this.characterCountText = page.getByTestId('character-message-count-0'); + this.pricingLink = page.getByTestId('sms-pricing-info').locator('a'); + this.characterCountText = page.getByTestId('character-message-count'); this.messageFormatting = new TemplateMgmtMessageFormatting(page); this.saveAndPreviewButton = page.locator( @@ -55,7 +55,7 @@ export class TemplateMgmtEditSmsPage extends TemplateMgmtBasePage { async waitForPageToLoad() { const characterCountLocator = this.page.locator( - '[data-testid="character-message-count-0"]' + '[data-testid="character-message-count"]' ); await expect(characterCountLocator).toBeVisible(); } diff --git a/tests/test-team/template-mgmt-api-tests/update-template.api.spec.ts b/tests/test-team/template-mgmt-api-tests/update-template.api.spec.ts index e6bf71caa..3a1de8945 100644 --- a/tests/test-team/template-mgmt-api-tests/update-template.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/update-template.api.spec.ts @@ -10,7 +10,7 @@ import { uuidRegExp, } from 'nhs-notify-web-template-management-test-helper-utils'; import { TemplateAPIPayloadFactory } from '../helpers/factories/template-api-payload-factory'; -import { pdfUploadFixtures } from '../fixtures/pdf-upload/multipart-pdf-letter-fixtures'; +import { pdfUploadFixtures } from '../fixtures/letters'; test.describe('PUT /v1/template/:templateId', () => { const authHelper = createAuthHelper(); diff --git a/tests/test-team/template-mgmt-api-tests/upload-letter-template.api.spec.ts b/tests/test-team/template-mgmt-api-tests/upload-letter-template.api.spec.ts index dd3637129..139934eb0 100644 --- a/tests/test-team/template-mgmt-api-tests/upload-letter-template.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/upload-letter-template.api.spec.ts @@ -10,7 +10,7 @@ import { isoDateRegExp, uuidRegExp, } from 'nhs-notify-web-template-management-test-helper-utils'; -import { pdfUploadFixtures } from '../fixtures/pdf-upload/multipart-pdf-letter-fixtures'; +import { pdfUploadFixtures } from '../fixtures/letters'; test.describe('POST /v1/letter-template', () => { const authHelper = createAuthHelper(); diff --git a/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-standard-letter-template.component.spec.ts b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-standard-letter-template.component.spec.ts new file mode 100644 index 000000000..3bb1067c2 --- /dev/null +++ b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-standard-letter-template.component.spec.ts @@ -0,0 +1,157 @@ +import { test, expect } from '@playwright/test'; +import { docxFixtures } from 'fixtures/letters'; +import { + createAuthHelper, + TestUser, + testUsers, +} from 'helpers/auth/cognito-auth-helper'; +import { loginAsUser } from 'helpers/auth/login-as-user'; +import { + assertAndClickBackLinkTop, + assertBackLinkBottomNotPresent, + assertFooterLinks, + assertHeaderLogoLink, + assertSignOutLink, + assertSkipToMainContent, +} from 'helpers/template-mgmt-common.steps'; +import { TemplateMgmtUploadStandardLetterTemplatePage } from 'pages/letter/template-mgmt-upload-standard-letter-template-page'; + +let userNoCampaignId: TestUser; +let userSingleCampaign: TestUser; +let userMultipleCampaigns: TestUser; + +test.beforeAll(async () => { + const authHelper = createAuthHelper(); + + userSingleCampaign = await authHelper.getTestUser(testUsers.User1.userId); + userNoCampaignId = await authHelper.getTestUser(testUsers.User6.userId); + userMultipleCampaigns = await authHelper.getTestUser( + testUsers.UserWithMultipleCampaigns.userId + ); +}); + +test.describe('Upload Standard Letter Template Page', () => { + test('common page tests', async ({ page, baseURL }) => { + const props = { + page: new TemplateMgmtUploadStandardLetterTemplatePage(page), + baseURL, + }; + + await assertSkipToMainContent(props); + await assertHeaderLogoLink(props); + await assertSignOutLink(props); + await assertFooterLinks(props); + await assertBackLinkBottomNotPresent(props); + await assertAndClickBackLinkTop({ + ...props, + expectedUrl: 'templates/choose-a-template-type', + }); + }); + + test.describe('single campaign client', () => { + test('no validation errors when form is submitted', async ({ page }) => { + const uploadPage = new TemplateMgmtUploadStandardLetterTemplatePage(page); + + await uploadPage.loadPage(); + + await expect(uploadPage.campaignIdInput).toBeHidden(); + await expect(uploadPage.singleCampaignIdText).toHaveText( + userSingleCampaign.campaignIds?.[0] as string + ); + + await uploadPage.nameInput.fill('New Letter Template'); + + await uploadPage.fileInput.click(); + await uploadPage.fileInput.setInputFiles(docxFixtures.standard.filepath); + + await uploadPage.submitButton.click(); + + // TODO: CCM-14211 - test submit behaviour + + await expect(uploadPage.errorSummaryList).toBeHidden(); + }); + + test('displays error messages when blank form is submitted', async ({ + page, + }) => { + const uploadPage = new TemplateMgmtUploadStandardLetterTemplatePage(page); + + await uploadPage.loadPage(); + + await expect(uploadPage.errorSummaryList).toBeHidden(); + + await uploadPage.submitButton.click(); + + await expect(uploadPage.errorSummaryList).toHaveText([ + 'Enter a template name', + 'Choose a template file', + ]); + }); + }); + + test.describe('multi-campaign client', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await loginAsUser(userMultipleCampaigns, page); + }); + + test('no validation errors when form is submitted', async ({ page }) => { + const uploadPage = new TemplateMgmtUploadStandardLetterTemplatePage(page); + + await uploadPage.loadPage(); + + await uploadPage.nameInput.fill('New Letter Template'); + + await expect(uploadPage.singleCampaignIdText).toBeHidden(); + await uploadPage.campaignIdInput.selectOption( + userMultipleCampaigns.campaignIds?.[0] as string + ); + + await uploadPage.fileInput.click(); + await uploadPage.fileInput.setInputFiles(docxFixtures.standard.filepath); + + await uploadPage.submitButton.click(); + + // TODO: CCM-14211 - test submit behaviour + + await expect(uploadPage.errorSummaryList).toBeHidden(); + }); + + test('displays error messages when blank form is submitted', async ({ + page, + }) => { + const uploadPage = new TemplateMgmtUploadStandardLetterTemplatePage(page); + + await uploadPage.loadPage(); + + await expect(uploadPage.errorSummaryList).toBeHidden(); + + await uploadPage.submitButton.click(); + + await expect(uploadPage.errorSummaryList).toHaveText([ + 'Enter a template name', + 'Choose a campaign', + 'Choose a template file', + ]); + }); + }); + + test.describe('client has no campaign id', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await loginAsUser(userNoCampaignId, page); + }); + + test('redirects to invalid config page', async ({ page }) => { + const uploadPage = new TemplateMgmtUploadStandardLetterTemplatePage(page); + + await uploadPage.loadPage(); + + await expect(page).toHaveURL( + '/templates/upload-letter-template/client-id-and-campaign-id-required' + ); + }); + }); +}); diff --git a/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts b/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts index 4b93d93b9..71fb7eb61 100644 --- a/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts +++ b/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts @@ -53,6 +53,7 @@ import { RoutingPreviewLargePrintLetterTemplatePage } from 'pages/routing/letter import { RoutingPreviewOtherLanguageLetterTemplatePage } from 'pages/routing/letter/preview-other-language-letter-template-page'; import { RoutingGetReadyToMovePage } from 'pages/routing/get-ready-to-move-page'; import { TemplateMgmtDeleteErrorPage } from 'pages/template-mgmt-delete-error-page'; +import { TemplateMgmtUploadStandardLetterTemplatePage } from 'pages/letter/template-mgmt-upload-standard-letter-template-page'; // Reset storage state for this file to avoid being authenticated test.use({ storageState: { cookies: [], origins: [] } }); @@ -109,6 +110,7 @@ const protectedPages = [ TemplateMgmtTemplateSubmittedSmsPage, TemplateMgmtUploadLetterMissingCampaignClientIdPage, TemplateMgmtUploadLetterPage, + TemplateMgmtUploadStandardLetterTemplatePage, ]; const publicPages = [TemplateMgmtStartPage]; diff --git a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-letter-file-validation.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/template-mgmt-letter-file-validation.e2e.spec.ts index 0cefa5079..51d008870 100644 --- a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-letter-file-validation.e2e.spec.ts +++ b/tests/test-team/template-mgmt-e2e-tests/template-mgmt-letter-file-validation.e2e.spec.ts @@ -6,7 +6,7 @@ import { testUsers, type TestUser, } from '../helpers/auth/cognito-auth-helper'; -import { pdfUploadFixtures } from '../fixtures/pdf-upload/multipart-pdf-letter-fixtures'; +import { pdfUploadFixtures } from '../fixtures/letters'; import { TemplateMgmtPreviewLetterPage } from '../pages/letter/template-mgmt-preview-letter-page'; test.describe('letter file validation', () => { diff --git a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-letter-full.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/template-mgmt-letter-full.e2e.spec.ts index e300228e1..31ac244d3 100644 --- a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-letter-full.e2e.spec.ts +++ b/tests/test-team/template-mgmt-e2e-tests/template-mgmt-letter-full.e2e.spec.ts @@ -6,7 +6,7 @@ import { testUsers, type TestUser, } from '../helpers/auth/cognito-auth-helper'; -import { pdfUploadFixtures } from '../fixtures/pdf-upload/multipart-pdf-letter-fixtures'; +import { pdfUploadFixtures } from '../fixtures/letters'; import { TemplateMgmtPreviewLetterPage } from '../pages/letter/template-mgmt-preview-letter-page'; import { TemplateMgmtSubmitLetterPage } from '../pages/letter/template-mgmt-submit-letter-page'; import { TemplateMgmtTemplateSubmittedLetterPage } from '../pages/letter/template-mgmt-template-submitted-letter-page'; diff --git a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-polling.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-polling.e2e.spec.ts index ae668633c..f60a80486 100644 --- a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-polling.e2e.spec.ts +++ b/tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-polling.e2e.spec.ts @@ -6,7 +6,7 @@ import { } from '../helpers/auth/cognito-auth-helper'; import { TemplateFactory } from '../helpers/factories/template-factory'; import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; -import { pdfUploadFixtures } from '../fixtures/pdf-upload/multipart-pdf-letter-fixtures'; +import { pdfUploadFixtures } from '../fixtures/letters'; import { SftpHelper } from '../helpers/sftp/sftp-helper'; import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; diff --git a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-request.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-request.e2e.spec.ts index abd257a74..923027877 100644 --- a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-request.e2e.spec.ts +++ b/tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-request.e2e.spec.ts @@ -5,7 +5,7 @@ import { testUsers, type TestUser, } from '../helpers/auth/cognito-auth-helper'; -import { pdfUploadFixtures } from '../fixtures/pdf-upload/multipart-pdf-letter-fixtures'; +import { pdfUploadFixtures } from '../fixtures/letters'; import { TemplateFactory } from '../helpers/factories/template-factory'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; diff --git a/utils/utils/src/types.ts b/utils/utils/src/types.ts index c109f9aa3..6c1f79b7e 100644 --- a/utils/utils/src/types.ts +++ b/utils/utils/src/types.ts @@ -24,8 +24,11 @@ export type ErrorState = { fieldErrors?: Record; }; -export type FormState = { +export type FormState< + T extends Record = Record, +> = { errorState?: ErrorState; + fields?: Partial; }; export type CreateUpdateNHSAppTemplate = Extract< From 68c8264715d1a7420fb0f2ff1841e0b8f984f870 Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Thu, 29 Jan 2026 17:03:55 +0000 Subject: [PATCH 02/26] CCM-13489: page title and links --- .../src/app/upload-standard-english-letter-template/page.tsx | 5 +++++ frontend/src/content/content.ts | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/upload-standard-english-letter-template/page.tsx b/frontend/src/app/upload-standard-english-letter-template/page.tsx index e5f71b7cb..9339f9dec 100644 --- a/frontend/src/app/upload-standard-english-letter-template/page.tsx +++ b/frontend/src/app/upload-standard-english-letter-template/page.tsx @@ -1,5 +1,6 @@ 'use server'; +import type { Metadata } from 'next'; import { redirect, RedirectType } from 'next/navigation'; import { NHSNotifyBackLink } from '@atoms/NHSNotifyBackLink/NHSNotifyBackLink'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; @@ -15,6 +16,10 @@ import { uploadStandardLetterTemplate } from './server-action'; const content = copy.pages.uploadStandardLetterTemplate; +export const metadata: Metadata = { + title: content.pageTitle, +}; + export default async function UploadStandardLetterTemplatePage() { const client = await fetchClient(); const campaignIds = getCampaignIds(client); diff --git a/frontend/src/content/content.ts b/frontend/src/content/content.ts index d17172e8b..cf609d2ee 100644 --- a/frontend/src/content/content.ts +++ b/frontend/src/content/content.ts @@ -1527,8 +1527,8 @@ const uploadStandardLetterTemplateSideBar: ContentBlock[] = [ type: 'text', text: markdownList('ol', [ 'Download the blank [standard letter template file](https://notify.nhs.uk/assets/worddocs/letter-template-nhs-notify.docx).', - 'Add [formatting (opens in a new tab)](https://notify.nhs.uk).', - 'Add any [personalisation (opens in a new tab)](https://notify.nhs.uk).', + 'Add [formatting (opens in a new tab)](https://notify.nhs.uk/using-nhs-notify/formatting).', + 'Add any [personalisation (opens in a new tab)](https://notify.nhs.uk/using-nhs-notify/personalisation).', 'Save your Microsoft Word file and upload it to this page.', ]), overrides: { @@ -1539,6 +1539,7 @@ const uploadStandardLetterTemplateSideBar: ContentBlock[] = [ ]; const uploadStandardLetterTemplate = { + pageTitle: generatePageTitle('Upload a standard English letter template'), backLink: { href: '/choose-a-template-type', text: 'Back to choose a template type', From 11d508dae8e53c5e7a67b62f5793aa3b7eab9a46 Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Thu, 29 Jan 2026 17:14:47 +0000 Subject: [PATCH 03/26] CCM-13489: fix test bits --- .../page.test.tsx | 19 ++++++++++++------- .../server-action.test.ts | 15 ++++++--------- .../form.tsx | 8 ++++++-- .../server-action.ts | 10 ++++++---- lambdas/backend-api/README.md | 4 ++-- ...ate-letter-template-page.component.spec.ts | 6 ++---- .../template-mgmt-proof-polling.e2e.spec.ts | 4 ++-- .../letter-templates.event.spec.ts | 2 +- 8 files changed, 37 insertions(+), 31 deletions(-) diff --git a/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx b/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx index 635f3beac..1023ae11a 100644 --- a/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx +++ b/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx @@ -3,11 +3,10 @@ import userEvent from '@testing-library/user-event'; import { redirect, RedirectType } from 'next/navigation'; import { verifyFormCsrfToken } from '@utils/csrf-utils'; import { fetchClient } from '@utils/server-features'; -import UploadStandardLetterTemplatePage from '@app/upload-standard-english-letter-template/page'; -import { - DOCX_MIME, - uploadStandardLetterTemplate, -} from '@app/upload-standard-english-letter-template/server-action'; +import UploadStandardLetterTemplatePage, { + metadata, +} from '@app/upload-standard-english-letter-template/page'; +import { uploadStandardLetterTemplate } from '@app/upload-standard-english-letter-template/server-action'; jest.mock('next/navigation'); jest.mock('@utils/csrf-utils'); @@ -20,6 +19,12 @@ beforeEach(() => { jest.mocked(uploadStandardLetterTemplate).mockResolvedValue({}); }); +test('metadata', () => { + expect(metadata).toEqual( + 'Upload a standard English letter template - NHS Notify' + ); +}); + describe('client has no campaign ids', () => { beforeEach(() => { jest.mocked(fetchClient).mockResolvedValue({ @@ -61,7 +66,7 @@ describe('client has one campaign id', () => { await user.keyboard('A new template'); const file = new File(['hello'], 'template.docx', { - type: DOCX_MIME, + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', }); await user.upload(screen.getByLabelText('Template file'), file); @@ -135,7 +140,7 @@ describe('client has multiple campaign ids', () => { await user.selectOptions(screen.getByLabelText('Campaign'), 'Campaign 2'); const file = new File(['hello'], 'template.docx', { - type: DOCX_MIME, + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', }); await user.upload(screen.getByLabelText('Template file'), file); diff --git a/frontend/src/__tests__/app/upload-standard-english-letter-template/server-action.test.ts b/frontend/src/__tests__/app/upload-standard-english-letter-template/server-action.test.ts index 63fcb4b7b..933382dce 100644 --- a/frontend/src/__tests__/app/upload-standard-english-letter-template/server-action.test.ts +++ b/frontend/src/__tests__/app/upload-standard-english-letter-template/server-action.test.ts @@ -1,7 +1,4 @@ -import { - uploadStandardLetterTemplate, - DOCX_MIME, -} from '@app/upload-standard-english-letter-template/server-action'; +import { uploadStandardLetterTemplate } from '@app/upload-standard-english-letter-template/server-action'; describe('uploadStandardLetterTemplate', () => { it('returns success when all fields are valid', async () => { @@ -10,7 +7,7 @@ describe('uploadStandardLetterTemplate', () => { formData.append('campaignId', 'Campaign 1'); const file = new File(['content'], 'template.docx', { - type: DOCX_MIME, + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', }); formData.append('file', file); @@ -30,7 +27,7 @@ describe('uploadStandardLetterTemplate', () => { formData.append('campaignId', 'Campaign 1'); const file = new File(['content'], 'template.docx', { - type: DOCX_MIME, + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', }); formData.append('file', file); @@ -54,7 +51,7 @@ describe('uploadStandardLetterTemplate', () => { formData.append('campaignId', 'Campaign 1'); const file = new File(['content'], 'template.docx', { - type: DOCX_MIME, + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', }); formData.append('file', file); @@ -74,7 +71,7 @@ describe('uploadStandardLetterTemplate', () => { formData.append('campaignId', ''); const file = new File(['content'], 'template.docx', { - type: DOCX_MIME, + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', }); formData.append('file', file); @@ -98,7 +95,7 @@ describe('uploadStandardLetterTemplate', () => { formData.append('name', 'Test Template'); const file = new File(['content'], 'template.docx', { - type: DOCX_MIME, + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', }); formData.append('file', file); diff --git a/frontend/src/app/upload-standard-english-letter-template/form.tsx b/frontend/src/app/upload-standard-english-letter-template/form.tsx index 0a7b4c237..175d42e02 100644 --- a/frontend/src/app/upload-standard-english-letter-template/form.tsx +++ b/frontend/src/app/upload-standard-english-letter-template/form.tsx @@ -12,7 +12,7 @@ import { FileUploadInput } from '@atoms/FileUpload/FileUpload'; import copy from '@content/content'; import { NHSNotifyFormWrapper } from '@molecules/NHSNotifyFormWrapper/NHSNotifyFormWrapper'; import { createNhsNotifyFormContext } from '@providers/form-provider'; -import { DOCX_MIME, type FormSchema } from './server-action'; +import { type FormSchema } from './server-action'; import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; import { NHSNotifyFormGroup } from '@atoms/NHSNotifyFormGroup/NHSNotifyFormGroup'; @@ -106,7 +106,11 @@ export function UploadStandardLetterTemplateForm({ campaignIds }: FormProps) { {fileError && {fileError}} - + diff --git a/frontend/src/app/upload-standard-english-letter-template/server-action.ts b/frontend/src/app/upload-standard-english-letter-template/server-action.ts index 7fac829aa..f52ec2e64 100644 --- a/frontend/src/app/upload-standard-english-letter-template/server-action.ts +++ b/frontend/src/app/upload-standard-english-letter-template/server-action.ts @@ -6,15 +6,17 @@ import copy from '@content/content'; const { errors } = copy.pages.uploadStandardLetterTemplate; -export const DOCX_MIME: z.core.util.MimeTypes = - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; - const $FormSchema = z.object({ name: z.string(errors.name.empty).nonempty(errors.name.empty), campaignId: z .string(errors.campaignId.empty) .nonempty(errors.campaignId.empty), - file: z.file(errors.file.empty).mime(DOCX_MIME, errors.file.empty), + file: z + .file(errors.file.empty) + .mime( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + errors.file.empty + ), }); export type FormSchema = z.infer; diff --git a/lambdas/backend-api/README.md b/lambdas/backend-api/README.md index ed081b107..ce6e5ea29 100644 --- a/lambdas/backend-api/README.md +++ b/lambdas/backend-api/README.md @@ -83,8 +83,8 @@ curl -X POST --location "${APIG_STAGE}/v1/template" \ Will create a single letter template. The CSV form part is optional. Do not set a content type header as this must be auto-generated by curl, so that it includes a form-data boundary. ```bash -PDF_PATH="./tests/test-team/fixtures/pdf-upload/with-personalisation/template.pdf" -CSV_PATH="./tests/test-team/fixtures/pdf-upload/with-personalisation/test-data.csv" +PDF_PATH="./tests/test-team/fixtures/letters/with-personalisation/template.pdf" +CSV_PATH="./tests/test-team/fixtures/letters/with-personalisation/test-data.csv" ``` ```bash diff --git a/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-create-letter-template-page.component.spec.ts b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-create-letter-template-page.component.spec.ts index 9cd767248..ec836581a 100644 --- a/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-create-letter-template-page.component.spec.ts +++ b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-create-letter-template-page.component.spec.ts @@ -87,7 +87,7 @@ test.describe('Upload letter Template Page', () => { await page.locator('input[name="letterTemplatePdf"]').click(); await page .locator('input[name="letterTemplatePdf"]') - .setInputFiles('./fixtures/pdf-upload/with-personalisation/template.pdf'); + .setInputFiles('./fixtures/letters/with-personalisation/template.pdf'); await createTemplatePage.clickSaveAndPreviewButton(); @@ -149,9 +149,7 @@ test.describe('Upload letter Template Page', () => { }); await page .locator('input[name="letterTemplatePdf"]') - .setInputFiles( - './fixtures/pdf-upload/with-personalisation/template.pdf' - ); + .setInputFiles('./fixtures/letters/with-personalisation/template.pdf'); await createTemplatePage.clickSaveAndPreviewButton(); diff --git a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-polling.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-polling.e2e.spec.ts index f60a80486..31ab90d98 100644 --- a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-polling.e2e.spec.ts +++ b/tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-polling.e2e.spec.ts @@ -41,7 +41,7 @@ test.describe('Letter Proof Polling', () => { // add proofs to SFTP mock const pdfContent = readFileSync( - './fixtures/pdf-upload/no-custom-personalisation/template.pdf' + './fixtures/letters/no-custom-personalisation/template.pdf' ); const supplierReference = [ @@ -158,7 +158,7 @@ test.describe('Letter Proof Polling', () => { // add proofs to SFTP mock const pdfContent = readFileSync( - './fixtures/pdf-upload/no-custom-personalisation/password.pdf' + './fixtures/letters/no-custom-personalisation/password.pdf' ); const supplierReference = [ diff --git a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts index bce8e5bf1..87e6c96c5 100644 --- a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts @@ -138,7 +138,7 @@ test.describe('Event publishing - Letters', () => { const start = new Date(); const pdfContent = readFileSync( - './fixtures/pdf-upload/no-custom-personalisation/template.pdf' + './fixtures/letters/no-custom-personalisation/template.pdf' ); const supplierReference = [ From 277a50872a1e3ee58c144b777bc754f869dec4fc Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Thu, 29 Jan 2026 17:28:50 +0000 Subject: [PATCH 04/26] CCM-13489: fix unit tests --- .../__snapshots__/page.test.tsx.snap | 16 ++++++++-------- .../page.test.tsx | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/src/__tests__/app/upload-standard-english-letter-template/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/upload-standard-english-letter-template/__snapshots__/page.test.tsx.snap index 867f49583..a2757925c 100644 --- a/frontend/src/__tests__/app/upload-standard-english-letter-template/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/upload-standard-english-letter-template/__snapshots__/page.test.tsx.snap @@ -202,7 +202,7 @@ exports[`client has multiple campaign ids matches snapshot on initial render 1`] > Add @@ -215,7 +215,7 @@ exports[`client has multiple campaign ids matches snapshot on initial render 1`] > Add any @@ -506,7 +506,7 @@ exports[`client has multiple campaign ids renders errors when blank form is subm > Add @@ -519,7 +519,7 @@ exports[`client has multiple campaign ids renders errors when blank form is subm > Add any @@ -735,7 +735,7 @@ exports[`client has one campaign id matches snapshot on initial render 1`] = ` > Add @@ -748,7 +748,7 @@ exports[`client has one campaign id matches snapshot on initial render 1`] = ` > Add any @@ -1016,7 +1016,7 @@ exports[`client has one campaign id renders errors when blank form is submitted > Add @@ -1029,7 +1029,7 @@ exports[`client has one campaign id renders errors when blank form is submitted > Add any diff --git a/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx b/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx index 1023ae11a..2396e2aca 100644 --- a/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx +++ b/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx @@ -20,9 +20,9 @@ beforeEach(() => { }); test('metadata', () => { - expect(metadata).toEqual( - 'Upload a standard English letter template - NHS Notify' - ); + expect(metadata).toEqual({ + title: 'Upload a standard English letter template - NHS Notify', + }); }); describe('client has no campaign ids', () => { From 8fe6122f7b2c93ef6c63fb2b9c731bd8d5cbaa6e Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Fri, 30 Jan 2026 14:11:34 +0000 Subject: [PATCH 05/26] CCM-13489: large print letter page --- .../__snapshots__/page.test.tsx.snap | 2 + .../__snapshots__/page.test.tsx.snap | 1050 +++++++++++++++++ .../page.test.tsx | 187 +++ .../server-action.test.ts | 163 +++ .../__snapshots__/page.test.tsx.snap | 24 +- .../page.test.tsx | 20 +- .../EmailTemplateForm.test.tsx.snap | 42 +- .../LetterTemplateForm.test.tsx.snap | 56 +- .../NhsAppTemplateForm.test.tsx.snap | 49 +- .../SmsTemplateForm.test.tsx.snap | 42 +- .../molecules/TemplateNameGuidance.test.tsx | 24 +- .../TemplateNameGuidance.test.tsx.snap | 184 ++- .../page.tsx | 59 + .../server-action.ts | 27 + .../page.tsx | 12 +- .../server-action.ts | 30 +- .../EmailTemplateForm/EmailTemplateForm.tsx | 2 +- .../LetterTemplateForm/LetterTemplateForm.tsx | 2 +- .../NhsAppTemplateForm/NhsAppTemplateForm.tsx | 2 +- .../forms/SmsTemplateForm/SmsTemplateForm.tsx | 2 +- .../UploadDocxLetterTemplateForm}/form.tsx | 50 +- .../UploadDocxLetterTemplateForm/schema.ts | 19 + .../TemplateNameGuidance.tsx | 43 +- .../template-name-guidance.types.ts | 5 - frontend/src/content/content.ts | 193 +-- .../letters/docx/large-print-template.docx | Bin 0 -> 84314 bytes ...te.docx => standard-english-template.docx} | Bin tests/test-team/fixtures/letters/index.ts | 3 +- .../email/template-mgmt-create-email-page.ts | 6 +- .../email/template-mgmt-edit-email-page.ts | 6 +- ...upload-large-print-letter-template-page.ts | 32 + ...-standard-english-letter-template-page.ts} | 2 +- .../template-mgmt-create-nhs-app-page.ts | 6 +- .../template-mgmt-edit-nhs-app-page.ts | 6 +- .../sms/template-mgmt-create-sms-page.ts | 6 +- .../pages/sms/template-mgmt-edit-sms-page.ts | 6 +- ...ge-print-letter-template.component.spec.ts | 171 +++ ...english-letter-template.component.spec.ts} | 21 +- ...emplate-protected-routes.component.spec.ts | 6 +- 39 files changed, 2140 insertions(+), 420 deletions(-) create mode 100644 frontend/src/__tests__/app/upload-large-print-letter-template/__snapshots__/page.test.tsx.snap create mode 100644 frontend/src/__tests__/app/upload-large-print-letter-template/page.test.tsx create mode 100644 frontend/src/__tests__/app/upload-large-print-letter-template/server-action.test.ts create mode 100644 frontend/src/app/upload-large-print-letter-template/page.tsx create mode 100644 frontend/src/app/upload-large-print-letter-template/server-action.ts rename frontend/src/{app/upload-standard-english-letter-template => components/forms/UploadDocxLetterTemplateForm}/form.tsx (70%) create mode 100644 frontend/src/components/forms/UploadDocxLetterTemplateForm/schema.ts delete mode 100644 frontend/src/components/molecules/TemplateNameGuidance/template-name-guidance.types.ts create mode 100644 tests/test-team/fixtures/letters/docx/large-print-template.docx rename tests/test-team/fixtures/letters/docx/{standard-template.docx => standard-english-template.docx} (100%) create mode 100644 tests/test-team/pages/letter/template-mgmt-upload-large-print-letter-template-page.ts rename tests/test-team/pages/letter/{template-mgmt-upload-standard-letter-template-page.ts => template-mgmt-upload-standard-english-letter-template-page.ts} (88%) create mode 100644 tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-large-print-letter-template.component.spec.ts rename tests/test-team/template-mgmt-component-tests/letter/{template-mgmt-upload-standard-letter-template.component.spec.ts => template-mgmt-upload-standard-english-letter-template.component.spec.ts} (84%) diff --git a/frontend/src/__tests__/app/message-plans/edit-message-plan-settings/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/message-plans/edit-message-plan-settings/__snapshots__/page.test.tsx.snap index 6005e779e..341963630 100644 --- a/frontend/src/__tests__/app/message-plans/edit-message-plan-settings/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/message-plans/edit-message-plan-settings/__snapshots__/page.test.tsx.snap @@ -417,6 +417,8 @@ exports[`multiple campaigns renders errors when form is submitted in invalid sta `; +exports[`multiple campaigns updates the message plan and redirects to the choose templates page 1`] = ``; + exports[`single campaign matches snapshot 1`] = `
    + + Back to choose a template type + +
    +
    +
    +

    + Upload a large print letter template +

    +
    +
    +
    +
    + + + +
    + +
    + This will not be visible to recipients. +
    +
    + + + Naming your templates + + +
    +

    + You should name your templates in a way that works best for your service or organisation. +

    +

    + Common template names include the: +

    +
      +
    • + subject or reason for the message +
    • +
    • + intended audience for the template +
    • +
    • + version number of the template +
    • +
    +

    + For example, 'Covid19 2025 - over 65s - version 3' +

    +
    +
    + +
    +
    + +
    + Choose which campaign this letter is for +
    + +
    +
    + +
    + Only upload your final letter template file. +
    + Make sure you use one of our blank template files to create the letter. +
    + +
    + + +
    +
    +

    + How to create a large print letter template +

    +
      +
    1. + Download the blank + + large print letter template file + + . +
    2. +
    3. + Add + + formatting (opens in a new tab) + + . +
    4. +
    5. + Add any + + personalisation (opens in a new tab) + + . +
    6. +
    7. + Save your Microsoft Word file and upload it to this page. +
    8. +
    +
    +
    +
    + +`; + +exports[`client has multiple campaign ids renders errors when blank form is submitted and error state is returned 1`] = ` + + + Back to choose a template type + +
    + +
    +
    +

    + Upload a large print letter template +

    +
    +
    +
    +
    +
    + + +
    + +
    + This will not be visible to recipients. +
    +
    + + + Naming your templates + + +
    +

    + You should name your templates in a way that works best for your service or organisation. +

    +

    + Common template names include the: +

    +
      +
    • + subject or reason for the message +
    • +
    • + intended audience for the template +
    • +
    • + version number of the template +
    • +
    +

    + For example, 'Covid19 2025 - over 65s - version 3' +

    +
    +
    + + + Error: + + Enter a template name + + +
    +
    + +
    + Choose which campaign this letter is for +
    + + + Error: + + Choose a campaign + + +
    +
    + +
    + Only upload your final letter template file. +
    + Make sure you use one of our blank template files to create the letter. +
    + + + Error: + + Choose a template file + + +
    + +
    +
    +
    +

    + How to create a large print letter template +

    +
      +
    1. + Download the blank + + large print letter template file + + . +
    2. +
    3. + Add + + formatting (opens in a new tab) + + . +
    4. +
    5. + Add any + + personalisation (opens in a new tab) + + . +
    6. +
    7. + Save your Microsoft Word file and upload it to this page. +
    8. +
    +
    +
    +
    +
    +`; + +exports[`client has one campaign id matches snapshot on initial render 1`] = ` + + + Back to choose a template type + +
    +
    +
    +

    + Upload a large print letter template +

    +
    +
    +
    +
    +
    + + +
    + +
    + This will not be visible to recipients. +
    +
    + + + Naming your templates + + +
    +

    + You should name your templates in a way that works best for your service or organisation. +

    +

    + Common template names include the: +

    +
      +
    • + subject or reason for the message +
    • +
    • + intended audience for the template +
    • +
    • + version number of the template +
    • +
    +

    + For example, 'Covid19 2025 - over 65s - version 3' +

    +
    +
    + +
    +
    + +
    + This message plan will link to your only campaign: +
    + +

    + Campaign 1 +

    +
    +
    + +
    + Only upload your final letter template file. +
    + Make sure you use one of our blank template files to create the letter. +
    + +
    + +
    +
    +
    +

    + How to create a large print letter template +

    +
      +
    1. + Download the blank + + large print letter template file + + . +
    2. +
    3. + Add + + formatting (opens in a new tab) + + . +
    4. +
    5. + Add any + + personalisation (opens in a new tab) + + . +
    6. +
    7. + Save your Microsoft Word file and upload it to this page. +
    8. +
    +
    +
    +
    +
    +`; + +exports[`client has one campaign id renders errors when blank form is submitted and error state is returned 1`] = ` + + + Back to choose a template type + +
    + +
    +
    +

    + Upload a large print letter template +

    +
    +
    +
    +
    +
    + + +
    + +
    + This will not be visible to recipients. +
    +
    + + + Naming your templates + + +
    +

    + You should name your templates in a way that works best for your service or organisation. +

    +

    + Common template names include the: +

    +
      +
    • + subject or reason for the message +
    • +
    • + intended audience for the template +
    • +
    • + version number of the template +
    • +
    +

    + For example, 'Covid19 2025 - over 65s - version 3' +

    +
    +
    + + + Error: + + Enter a template name + + +
    +
    + +
    + This message plan will link to your only campaign: +
    + +

    + Campaign 1 +

    +
    +
    + +
    + Only upload your final letter template file. +
    + Make sure you use one of our blank template files to create the letter. +
    + + + Error: + + Choose a template file + + +
    + +
    +
    +
    +

    + How to create a large print letter template +

    +
      +
    1. + Download the blank + + large print letter template file + + . +
    2. +
    3. + Add + + formatting (opens in a new tab) + + . +
    4. +
    5. + Add any + + personalisation (opens in a new tab) + + . +
    6. +
    7. + Save your Microsoft Word file and upload it to this page. +
    8. +
    +
    +
    +
    +
    +`; diff --git a/frontend/src/__tests__/app/upload-large-print-letter-template/page.test.tsx b/frontend/src/__tests__/app/upload-large-print-letter-template/page.test.tsx new file mode 100644 index 000000000..3d13ae0de --- /dev/null +++ b/frontend/src/__tests__/app/upload-large-print-letter-template/page.test.tsx @@ -0,0 +1,187 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { redirect, RedirectType } from 'next/navigation'; +import { verifyFormCsrfToken } from '@utils/csrf-utils'; +import { fetchClient } from '@utils/server-features'; +import Page, { metadata } from '@app/upload-large-print-letter-template/page'; +import { uploadLargePrintLetterTemplate } from '@app/upload-large-print-letter-template/server-action'; + +jest.mock('next/navigation'); +jest.mock('@utils/csrf-utils'); +jest.mock('@utils/server-features'); +jest.mock('@app/upload-large-print-letter-template/server-action'); + +beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(verifyFormCsrfToken).mockResolvedValue(true); + jest.mocked(uploadLargePrintLetterTemplate).mockResolvedValue({}); +}); + +test('metadata', () => { + expect(metadata).toEqual({ + title: 'Upload a large print letter template - NHS Notify', + }); +}); + +describe('client has no campaign ids', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: [], + features: {}, + }); + }); + + it('redirects to campaign id required page', async () => { + await Page(); + + expect(redirect).toHaveBeenCalledWith( + '/upload-letter-template/client-id-and-campaign-id-required', + RedirectType.replace + ); + }); +}); + +describe('client has one campaign id', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: ['Campaign 1'], + features: {}, + }); + }); + + it('matches snapshot on initial render', async () => { + expect(render(await Page()).asFragment()).toMatchSnapshot(); + }); + + it('submits the form with correct data', async () => { + const user = userEvent.setup(); + + render(await Page()); + + await user.click(screen.getByLabelText('Template name')); + await user.keyboard('A new template'); + + const file = new File(['hello'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + + await user.upload(screen.getByLabelText('Template file'), file); + + await user.click( + screen.getByRole('button', { name: 'Upload letter template file' }) + ); + + expect(uploadLargePrintLetterTemplate).toHaveBeenCalledTimes(1); + + const callArgs = jest.mocked(uploadLargePrintLetterTemplate).mock.calls[0]; + const formData = callArgs[1] as FormData; + + expect(formData.get('name')).toBe('A new template'); + expect(formData.get('campaignId')).toBe('Campaign 1'); + expect(formData.get('file')).toBeInstanceOf(File); + + expect( + screen.queryByRole('alert', { name: 'There is a problem' }) + ).not.toBeInTheDocument(); + }); + + it('renders errors when blank form is submitted and error state is returned', async () => { + jest.mocked(uploadLargePrintLetterTemplate).mockResolvedValue({ + errorState: { + fieldErrors: { + name: ['Enter a template name'], + file: ['Choose a template file'], + }, + }, + }); + + const user = userEvent.setup(); + + const page = render(await Page()); + + await user.click( + screen.getByRole('button', { name: 'Upload letter template file' }) + ); + + expect( + screen.queryByRole('alert', { name: 'There is a problem' }) + ).toBeInTheDocument(); + + expect(page.asFragment()).toMatchSnapshot(); + }); +}); + +describe('client has multiple campaign ids', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: ['Campaign 1', 'Campaign 2'], + features: {}, + }); + }); + + it('matches snapshot on initial render', async () => { + expect(render(await Page()).asFragment()).toMatchSnapshot(); + }); + + it('submits the form with correct data', async () => { + const user = userEvent.setup(); + + render(await Page()); + + await user.click(screen.getByLabelText('Template name')); + await user.keyboard('A new template'); + + await user.selectOptions(screen.getByLabelText('Campaign'), 'Campaign 2'); + + const file = new File(['hello'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + + await user.upload(screen.getByLabelText('Template file'), file); + + await user.click( + screen.getByRole('button', { name: 'Upload letter template file' }) + ); + + expect(uploadLargePrintLetterTemplate).toHaveBeenCalledTimes(1); + + const callArgs = jest.mocked(uploadLargePrintLetterTemplate).mock.calls[0]; + const formData = callArgs[1] as FormData; + + expect(formData.get('name')).toBe('A new template'); + expect(formData.get('campaignId')).toBe('Campaign 2'); + expect(formData.get('file')).toBeInstanceOf(File); + + expect( + screen.queryByRole('alert', { name: 'There is a problem' }) + ).not.toBeInTheDocument(); + }); + + it('renders errors when blank form is submitted and error state is returned', async () => { + jest.mocked(uploadLargePrintLetterTemplate).mockResolvedValue({ + errorState: { + fieldErrors: { + name: ['Enter a template name'], + campaignId: ['Choose a campaign'], + file: ['Choose a template file'], + }, + }, + }); + + const user = userEvent.setup(); + + const page = render(await Page()); + + await user.click( + screen.getByRole('button', { name: 'Upload letter template file' }) + ); + + expect(uploadLargePrintLetterTemplate).toHaveBeenCalledTimes(1); + + expect( + screen.queryByRole('alert', { name: 'There is a problem' }) + ).toBeInTheDocument(); + + expect(page.asFragment()).toMatchSnapshot(); + }); +}); diff --git a/frontend/src/__tests__/app/upload-large-print-letter-template/server-action.test.ts b/frontend/src/__tests__/app/upload-large-print-letter-template/server-action.test.ts new file mode 100644 index 000000000..299f57c96 --- /dev/null +++ b/frontend/src/__tests__/app/upload-large-print-letter-template/server-action.test.ts @@ -0,0 +1,163 @@ +import { uploadLargePrintLetterTemplate } from '@app/upload-large-print-letter-template/server-action'; + +describe('uploadLargePrintLetterTemplate', () => { + it('returns success when all fields are valid', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', 'Campaign 1'); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadLargePrintLetterTemplate({}, formData); + + expect(result.errorState).toBeUndefined(); + expect(result.fields).toEqual({ + name: 'Test Template', + campaignId: 'Campaign 1', + file, + }); + }); + + it('returns validation error when name is empty', async () => { + const formData = new FormData(); + formData.append('name', ''); + formData.append('campaignId', 'Campaign 1'); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadLargePrintLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + name: ['Enter a template name'], + }, + }); + expect(result.fields).toEqual({ + name: '', + campaignId: 'Campaign 1', + file, + }); + }); + + it('returns validation error when name is missing', async () => { + const formData = new FormData(); + formData.append('campaignId', 'Campaign 1'); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadLargePrintLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + name: ['Enter a template name'], + }, + }); + }); + + it('returns validation error when campaignId is empty', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', ''); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadLargePrintLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + campaignId: ['Choose a campaign'], + }, + }); + expect(result.fields).toEqual({ + name: 'Test Template', + campaignId: '', + file, + }); + }); + + it('returns validation error when campaignId is missing', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadLargePrintLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + campaignId: ['Choose a campaign'], + }, + }); + }); + + it('returns validation error when file is missing', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', 'Campaign 1'); + + const result = await uploadLargePrintLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + file: ['Choose a template file'], + }, + }); + }); + + it('returns validation error when file has incorrect MIME type', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', 'Campaign 1'); + + const file = new File(['content'], 'template.pdf', { + type: 'application/pdf', + }); + formData.append('file', file); + + const result = await uploadLargePrintLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + file: ['Choose a template file'], + }, + }); + }); + + it('returns multiple validation errors when multiple fields are invalid', async () => { + const formData = new FormData(); + formData.append('name', ''); + formData.append('campaignId', ''); + + const result = await uploadLargePrintLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + name: ['Enter a template name'], + campaignId: ['Choose a campaign'], + file: ['Choose a template file'], + }, + }); + }); +}); diff --git a/frontend/src/__tests__/app/upload-standard-english-letter-template/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/upload-standard-english-letter-template/__snapshots__/page.test.tsx.snap index a2757925c..225f5e096 100644 --- a/frontend/src/__tests__/app/upload-standard-english-letter-template/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/upload-standard-english-letter-template/__snapshots__/page.test.tsx.snap @@ -176,9 +176,9 @@ exports[`client has multiple campaign ids matches snapshot on initial render 1`] >

    - How to create a standard letter template + How to create a standard English letter template

      - standard letter template file + standard English letter template file . @@ -480,9 +480,9 @@ exports[`client has multiple campaign ids renders errors when blank form is subm >

      - How to create a standard letter template + How to create a standard English letter template

        - standard letter template file + standard English letter template file . @@ -709,9 +709,9 @@ exports[`client has one campaign id matches snapshot on initial render 1`] = ` >

        - How to create a standard letter template + How to create a standard English letter template

          - standard letter template file + standard English letter template file . @@ -990,9 +990,9 @@ exports[`client has one campaign id renders errors when blank form is submitted >

          - How to create a standard letter template + How to create a standard English letter template

            - standard letter template file + standard English letter template file . diff --git a/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx b/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx index 2396e2aca..7714f8a13 100644 --- a/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx +++ b/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import { redirect, RedirectType } from 'next/navigation'; import { verifyFormCsrfToken } from '@utils/csrf-utils'; import { fetchClient } from '@utils/server-features'; -import UploadStandardLetterTemplatePage, { +import Page, { metadata, } from '@app/upload-standard-english-letter-template/page'; import { uploadStandardLetterTemplate } from '@app/upload-standard-english-letter-template/server-action'; @@ -34,7 +34,7 @@ describe('client has no campaign ids', () => { }); it('redirects to campaign id required page', async () => { - await UploadStandardLetterTemplatePage(); + await Page(); expect(redirect).toHaveBeenCalledWith( '/upload-letter-template/client-id-and-campaign-id-required', @@ -52,15 +52,13 @@ describe('client has one campaign id', () => { }); it('matches snapshot on initial render', async () => { - expect( - render(await UploadStandardLetterTemplatePage()).asFragment() - ).toMatchSnapshot(); + expect(render(await Page()).asFragment()).toMatchSnapshot(); }); it('submits the form with correct data', async () => { const user = userEvent.setup(); - render(await UploadStandardLetterTemplatePage()); + render(await Page()); await user.click(screen.getByLabelText('Template name')); await user.keyboard('A new template'); @@ -101,7 +99,7 @@ describe('client has one campaign id', () => { const user = userEvent.setup(); - const page = render(await UploadStandardLetterTemplatePage()); + const page = render(await Page()); await user.click( screen.getByRole('button', { name: 'Upload letter template file' }) @@ -124,15 +122,13 @@ describe('client has multiple campaign ids', () => { }); it('matches snapshot on initial render', async () => { - expect( - render(await UploadStandardLetterTemplatePage()).asFragment() - ).toMatchSnapshot(); + expect(render(await Page()).asFragment()).toMatchSnapshot(); }); it('submits the form with correct data', async () => { const user = userEvent.setup(); - render(await UploadStandardLetterTemplatePage()); + render(await Page()); await user.click(screen.getByLabelText('Template name')); await user.keyboard('A new template'); @@ -176,7 +172,7 @@ describe('client has multiple campaign ids', () => { const user = userEvent.setup(); - const page = render(await UploadStandardLetterTemplatePage()); + const page = render(await Page()); await user.click( screen.getByRole('button', { name: 'Upload letter template file' }) diff --git a/frontend/src/__tests__/components/forms/EmailTemplateForm/__snapshots__/EmailTemplateForm.test.tsx.snap b/frontend/src/__tests__/components/forms/EmailTemplateForm/__snapshots__/EmailTemplateForm.test.tsx.snap index edd320313..d1c3709f1 100644 --- a/frontend/src/__tests__/components/forms/EmailTemplateForm/__snapshots__/EmailTemplateForm.test.tsx.snap +++ b/frontend/src/__tests__/components/forms/EmailTemplateForm/__snapshots__/EmailTemplateForm.test.tsx.snap @@ -79,11 +79,9 @@ exports[`Client-side validation triggers - invalid form - errors displayed 1`] =

            You should name your templates in a way that works best for your service or organisation. @@ -115,9 +112,7 @@ exports[`Client-side validation triggers - invalid form - errors displayed 1`] = version number of the template -

            +

            For example, 'Email - covid19 2023 - over 65s - version 3'

            @@ -783,11 +778,9 @@ exports[`Client-side validation triggers - valid form - no errors 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -819,9 +811,7 @@ exports[`Client-side validation triggers - valid form - no errors 1`] = ` version number of the template -

            +

            For example, 'Email - covid19 2023 - over 65s - version 3'

            @@ -1507,11 +1497,9 @@ exports[`renders page one error 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -1543,9 +1530,7 @@ exports[`renders page one error 1`] = ` version number of the template -

            +

            For example, 'Email - covid19 2023 - over 65s - version 3'

            @@ -2310,11 +2295,9 @@ exports[`renders page with multiple errors 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -2346,9 +2328,7 @@ exports[`renders page with multiple errors 1`] = ` version number of the template -

            +

            For example, 'Email - covid19 2023 - over 65s - version 3'

            @@ -3092,11 +3072,9 @@ exports[`renders page with preloaded field values 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -3128,9 +3105,7 @@ exports[`renders page with preloaded field values 1`] = ` version number of the template -

            +

            For example, 'Email - covid19 2023 - over 65s - version 3'

            @@ -3784,11 +3759,9 @@ exports[`renders page without back link for initial state with id - edit mode 1`

            You should name your templates in a way that works best for your service or organisation. @@ -3820,9 +3792,7 @@ exports[`renders page without back link for initial state with id - edit mode 1` version number of the template -

            +

            For example, 'Email - covid19 2023 - over 65s - version 3'

            diff --git a/frontend/src/__tests__/components/forms/LetterTemplateForm/__snapshots__/LetterTemplateForm.test.tsx.snap b/frontend/src/__tests__/components/forms/LetterTemplateForm/__snapshots__/LetterTemplateForm.test.tsx.snap index fc91b2915..81b84ff52 100644 --- a/frontend/src/__tests__/components/forms/LetterTemplateForm/__snapshots__/LetterTemplateForm.test.tsx.snap +++ b/frontend/src/__tests__/components/forms/LetterTemplateForm/__snapshots__/LetterTemplateForm.test.tsx.snap @@ -89,11 +89,9 @@ exports[`Client-side validation triggers - valid form - with errors 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -125,9 +122,7 @@ exports[`Client-side validation triggers - valid form - with errors 1`] = ` version number of the template -

            +

            For example, 'Letter - covid19 2023 - over 65s - version 3'

            @@ -544,11 +539,9 @@ exports[`Client-side validation triggers - valid form - without errors 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -580,9 +572,7 @@ exports[`Client-side validation triggers - valid form - without errors 1`] = ` version number of the template -

            +

            For example, 'Letter - covid19 2023 - over 65s - version 3'

            @@ -977,11 +967,9 @@ exports[`hides right-to-left language warning when language changes 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -1013,9 +1000,7 @@ exports[`hides right-to-left language warning when language changes 1`] = ` version number of the template -

            +

            For example, 'Letter - covid19 2023 - over 65s - version 3'

            @@ -1438,11 +1423,9 @@ exports[`renders page one error 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -1474,9 +1456,7 @@ exports[`renders page one error 1`] = ` version number of the template -

            +

            For example, 'Letter - covid19 2023 - over 65s - version 3'

            @@ -1886,11 +1866,9 @@ exports[`renders page with multiple campaign ids 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -1922,9 +1899,7 @@ exports[`renders page with multiple campaign ids 1`] = ` version number of the template -

            +

            For example, 'Letter - covid19 2023 - over 65s - version 3'

            @@ -2417,11 +2392,9 @@ exports[`renders page with multiple errors 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -2453,9 +2425,7 @@ exports[`renders page with multiple errors 1`] = ` version number of the template -

            +

            For example, 'Letter - covid19 2023 - over 65s - version 3'

            @@ -2883,11 +2853,9 @@ exports[`renders page with preloaded field values 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -2919,9 +2886,7 @@ exports[`renders page with preloaded field values 1`] = ` version number of the template -

            +

            For example, 'Letter - covid19 2023 - over 65s - version 3'

            @@ -3345,11 +3310,9 @@ exports[`shows right-to-left language warning when language changes 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -3381,9 +3343,7 @@ exports[`shows right-to-left language warning when language changes 1`] = ` version number of the template -

            +

            For example, 'Letter - covid19 2023 - over 65s - version 3'

            diff --git a/frontend/src/__tests__/components/forms/NhsAppTemplateForm/__snapshots__/NhsAppTemplateForm.test.tsx.snap b/frontend/src/__tests__/components/forms/NhsAppTemplateForm/__snapshots__/NhsAppTemplateForm.test.tsx.snap index df9803ef8..ebed15eea 100644 --- a/frontend/src/__tests__/components/forms/NhsAppTemplateForm/__snapshots__/NhsAppTemplateForm.test.tsx.snap +++ b/frontend/src/__tests__/components/forms/NhsAppTemplateForm/__snapshots__/NhsAppTemplateForm.test.tsx.snap @@ -86,11 +86,9 @@ exports[`Client-side validation triggers - invalid form - errors displayed 1`] =

            You should name your templates in a way that works best for your service or organisation. @@ -122,9 +119,7 @@ exports[`Client-side validation triggers - invalid form - errors displayed 1`] = version number of the template -

            +

            For example, 'NHS App - covid19 2023 - over 65s - version 3'

            @@ -780,11 +775,9 @@ exports[`Client-side validation triggers - valid form - no errors 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -816,9 +808,7 @@ exports[`Client-side validation triggers - valid form - no errors 1`] = ` version number of the template -

            +

            For example, 'NHS App - covid19 2023 - over 65s - version 3'

            @@ -1462,11 +1452,9 @@ exports[`renders page 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -1498,9 +1485,7 @@ exports[`renders page 1`] = ` version number of the template -

            +

            For example, 'NHS App - covid19 2023 - over 65s - version 3'

            @@ -2169,11 +2154,9 @@ exports[`renders page one error 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -2205,9 +2187,7 @@ exports[`renders page one error 1`] = ` version number of the template -

            +

            For example, 'NHS App - covid19 2023 - over 65s - version 3'

            @@ -2948,11 +2928,9 @@ exports[`renders page with multiple errors 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -2984,9 +2961,7 @@ exports[`renders page with multiple errors 1`] = ` version number of the template -

            +

            For example, 'NHS App - covid19 2023 - over 65s - version 3'

            @@ -3701,11 +3676,9 @@ exports[`renders page with preloaded field values 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -3737,9 +3709,7 @@ exports[`renders page with preloaded field values 1`] = ` version number of the template -

            +

            For example, 'NHS App - covid19 2023 - over 65s - version 3'

            @@ -4376,11 +4346,9 @@ exports[`renders page without back link for initial state with id - edit mode 1`

            You should name your templates in a way that works best for your service or organisation. @@ -4412,9 +4379,7 @@ exports[`renders page without back link for initial state with id - edit mode 1` version number of the template -

            +

            For example, 'NHS App - covid19 2023 - over 65s - version 3'

            diff --git a/frontend/src/__tests__/components/forms/SmsTemplateForm/__snapshots__/SmsTemplateForm.test.tsx.snap b/frontend/src/__tests__/components/forms/SmsTemplateForm/__snapshots__/SmsTemplateForm.test.tsx.snap index 96f94fd38..3fba5f92e 100644 --- a/frontend/src/__tests__/components/forms/SmsTemplateForm/__snapshots__/SmsTemplateForm.test.tsx.snap +++ b/frontend/src/__tests__/components/forms/SmsTemplateForm/__snapshots__/SmsTemplateForm.test.tsx.snap @@ -85,11 +85,9 @@ exports[`CreateSmsTemplate component Client-side validation triggers - invalid f

            You should name your templates in a way that works best for your service or organisation. @@ -121,9 +118,7 @@ exports[`CreateSmsTemplate component Client-side validation triggers - invalid f version number of the template -

            +

            For example, 'SMS - covid19 2023 - over 65s - version 3'

            @@ -515,11 +510,9 @@ exports[`CreateSmsTemplate component Client-side validation triggers - valid for

            You should name your templates in a way that works best for your service or organisation. @@ -551,9 +543,7 @@ exports[`CreateSmsTemplate component Client-side validation triggers - valid for version number of the template -

            +

            For example, 'SMS - covid19 2023 - over 65s - version 3'

            @@ -948,11 +938,9 @@ exports[`CreateSmsTemplate component renders page one error 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -984,9 +971,7 @@ exports[`CreateSmsTemplate component renders page one error 1`] = ` version number of the template -

            +

            For example, 'SMS - covid19 2023 - over 65s - version 3'

            @@ -1375,11 +1360,9 @@ exports[`CreateSmsTemplate component renders page with back link if initial stat

            You should name your templates in a way that works best for your service or organisation. @@ -1411,9 +1393,7 @@ exports[`CreateSmsTemplate component renders page with back link if initial stat version number of the template -

            +

            For example, 'SMS - covid19 2023 - over 65s - version 3'

            @@ -1870,11 +1850,9 @@ exports[`CreateSmsTemplate component renders page with multiple errors 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -1906,9 +1883,7 @@ exports[`CreateSmsTemplate component renders page with multiple errors 1`] = ` version number of the template -

            +

            For example, 'SMS - covid19 2023 - over 65s - version 3'

            @@ -2351,11 +2326,9 @@ exports[`CreateSmsTemplate component renders page with no back link if initial s

            You should name your templates in a way that works best for your service or organisation. @@ -2387,9 +2359,7 @@ exports[`CreateSmsTemplate component renders page with no back link if initial s version number of the template -

            +

            For example, 'SMS - covid19 2023 - over 65s - version 3'

            diff --git a/frontend/src/__tests__/components/molecules/TemplateNameGuidance.test.tsx b/frontend/src/__tests__/components/molecules/TemplateNameGuidance.test.tsx index ad5504c21..4fd2f0784 100644 --- a/frontend/src/__tests__/components/molecules/TemplateNameGuidance.test.tsx +++ b/frontend/src/__tests__/components/molecules/TemplateNameGuidance.test.tsx @@ -1,30 +1,20 @@ import { render } from '@testing-library/react'; import { TemplateNameGuidance } from '@molecules/TemplateNameGuidance'; -import content from '@content/content'; import { TEMPLATE_TYPE_LIST } from 'nhs-notify-backend-client'; describe('TemplateNameGuidance component', () => { - it('renders component correctly as TemplateNameGuidance', () => { - const container = render(); - - expect(container.asFragment()).toMatchSnapshot(); + it('renders component correctly when template type is not given', () => { + expect(render().asFragment()).toMatchSnapshot(); }); it.each(TEMPLATE_TYPE_LIST)( - 'should correctly display the template naming example when templateType is %s', + 'renders component correctly when template type is %s', (templateType) => { - const expectedText = - content.components.nameYourTemplate.templateNameDetailsExample[ - templateType - ]; - - const container = render( - - ); - expect( - container.getByTestId('template-name-example').textContent - ).toEqual(expectedText); + render( + + ).asFragment() + ).toMatchSnapshot(); } ); }); diff --git a/frontend/src/__tests__/components/molecules/__snapshots__/TemplateNameGuidance.test.tsx.snap b/frontend/src/__tests__/components/molecules/__snapshots__/TemplateNameGuidance.test.tsx.snap index 77161ffa1..11a573423 100644 --- a/frontend/src/__tests__/components/molecules/__snapshots__/TemplateNameGuidance.test.tsx.snap +++ b/frontend/src/__tests__/components/molecules/__snapshots__/TemplateNameGuidance.test.tsx.snap @@ -1,14 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`TemplateNameGuidance component renders component correctly as TemplateNameGuidance 1`] = ` +exports[`TemplateNameGuidance component renders component correctly when template type is EMAIL 1`] = `

            You should name your templates in a way that works best for your service or organisation. @@ -40,12 +37,187 @@ exports[`TemplateNameGuidance component renders component correctly as TemplateN version number of the template -

            + For example, 'Email - covid19 2023 - over 65s - version 3' +

            +
            +
            +
            +`; + +exports[`TemplateNameGuidance component renders component correctly when template type is LETTER 1`] = ` + +
            + + + Naming your templates + + +
            +

            + You should name your templates in a way that works best for your service or organisation. +

            +

            + Common template names include the: +

            +
              +
            • + message channel it uses +
            • +
            • + subject or reason for the message +
            • +
            • + intended audience for the template +
            • +
            • + version number of the template +
            • +
            +

            + For example, 'Letter - covid19 2023 - over 65s - version 3' +

            +
            +
            +
            +`; + +exports[`TemplateNameGuidance component renders component correctly when template type is NHS_APP 1`] = ` + +
            + + + Naming your templates + + +
            +

            + You should name your templates in a way that works best for your service or organisation. +

            +

            + Common template names include the: +

            +
              +
            • + message channel it uses +
            • +
            • + subject or reason for the message +
            • +
            • + intended audience for the template +
            • +
            • + version number of the template +
            • +
            +

            For example, 'NHS App - covid19 2023 - over 65s - version 3'

            `; + +exports[`TemplateNameGuidance component renders component correctly when template type is SMS 1`] = ` + +
            + + + Naming your templates + + +
            +

            + You should name your templates in a way that works best for your service or organisation. +

            +

            + Common template names include the: +

            +
              +
            • + message channel it uses +
            • +
            • + subject or reason for the message +
            • +
            • + intended audience for the template +
            • +
            • + version number of the template +
            • +
            +

            + For example, 'SMS - covid19 2023 - over 65s - version 3' +

            +
            +
            +
            +`; + +exports[`TemplateNameGuidance component renders component correctly when template type is not given 1`] = ` + +
            + + + Naming your templates + + +
            +

            + You should name your templates in a way that works best for your service or organisation. +

            +

            + Common template names include the: +

            +
              +
            • + subject or reason for the message +
            • +
            • + intended audience for the template +
            • +
            • + version number of the template +
            • +
            +

            + For example, 'Covid19 2025 - over 65s - version 3' +

            +
            +
            +
            +`; diff --git a/frontend/src/app/upload-large-print-letter-template/page.tsx b/frontend/src/app/upload-large-print-letter-template/page.tsx new file mode 100644 index 000000000..405610680 --- /dev/null +++ b/frontend/src/app/upload-large-print-letter-template/page.tsx @@ -0,0 +1,59 @@ +'use server'; + +import type { Metadata } from 'next'; +import { redirect, RedirectType } from 'next/navigation'; +import { NHSNotifyBackLink } from '@atoms/NHSNotifyBackLink/NHSNotifyBackLink'; +import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; +import copy from '@content/content'; +import { + UploadDocxLetterTemplateForm, + NHSNotifyFormProvider, +} from '@forms/UploadDocxLetterTemplateForm/form'; +import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; +import { fetchClient } from '@utils/server-features'; +import { getCampaignIds } from '@utils/client-config'; +import { uploadLargePrintLetterTemplate } from './server-action'; + +const content = copy.pages.uploadDocxLetterTemplatePage('x1'); + +export const metadata: Metadata = { + title: content.pageTitle, +}; + +export default async function UploadLargePrintLetterTemplatePage() { + const client = await fetchClient(); + const campaignIds = getCampaignIds(client); + + if (campaignIds.length === 0) { + return redirect( + '/upload-letter-template/client-id-and-campaign-id-required', + RedirectType.replace + ); + } + + return ( + <> + + {content.backLink.text} + + + +
            +
            +

            {content.heading}

            +
            +
            +
            +
            + +
            + +
            + +
            +
            +
            +
            + + ); +} diff --git a/frontend/src/app/upload-large-print-letter-template/server-action.ts b/frontend/src/app/upload-large-print-letter-template/server-action.ts new file mode 100644 index 000000000..7c7207044 --- /dev/null +++ b/frontend/src/app/upload-large-print-letter-template/server-action.ts @@ -0,0 +1,27 @@ +'use server'; + +import { z } from 'zod/v4'; +import type { FormState } from 'nhs-notify-web-template-management-utils'; +import { + $UploadDocxLetterTemplateFormSchema, + type UploadDocxLetterTemplateFormSchema, +} from '@forms/UploadDocxLetterTemplateForm/schema'; + +export async function uploadLargePrintLetterTemplate( + _: FormState, + form: FormData +): Promise> { + const fields = Object.fromEntries(form.entries()); + + const validation = $UploadDocxLetterTemplateFormSchema.safeParse(fields); + + if (validation.error) { + return { + errorState: z.flattenError(validation.error), + fields, + }; + } + + // TODO: CCM-14211 - submit the form and redirect instead of returning + return { fields }; +} diff --git a/frontend/src/app/upload-standard-english-letter-template/page.tsx b/frontend/src/app/upload-standard-english-letter-template/page.tsx index 9339f9dec..a4d4777dd 100644 --- a/frontend/src/app/upload-standard-english-letter-template/page.tsx +++ b/frontend/src/app/upload-standard-english-letter-template/page.tsx @@ -5,16 +5,16 @@ import { redirect, RedirectType } from 'next/navigation'; import { NHSNotifyBackLink } from '@atoms/NHSNotifyBackLink/NHSNotifyBackLink'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import copy from '@content/content'; +import { + UploadDocxLetterTemplateForm, + NHSNotifyFormProvider, +} from '@forms/UploadDocxLetterTemplateForm/form'; import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; import { fetchClient } from '@utils/server-features'; import { getCampaignIds } from '@utils/client-config'; -import { - UploadStandardLetterTemplateForm, - NHSNotifyFormProvider, -} from './form'; import { uploadStandardLetterTemplate } from './server-action'; -const content = copy.pages.uploadStandardLetterTemplate; +const content = copy.pages.uploadDocxLetterTemplatePage('x0'); export const metadata: Metadata = { title: content.pageTitle, @@ -45,7 +45,7 @@ export default async function UploadStandardLetterTemplatePage() {
            - +
            diff --git a/frontend/src/app/upload-standard-english-letter-template/server-action.ts b/frontend/src/app/upload-standard-english-letter-template/server-action.ts index f52ec2e64..fc4bcb1d5 100644 --- a/frontend/src/app/upload-standard-english-letter-template/server-action.ts +++ b/frontend/src/app/upload-standard-english-letter-template/server-action.ts @@ -1,33 +1,19 @@ 'use server'; import { z } from 'zod/v4'; -import { FormState } from 'nhs-notify-web-template-management-utils'; -import copy from '@content/content'; - -const { errors } = copy.pages.uploadStandardLetterTemplate; - -const $FormSchema = z.object({ - name: z.string(errors.name.empty).nonempty(errors.name.empty), - campaignId: z - .string(errors.campaignId.empty) - .nonempty(errors.campaignId.empty), - file: z - .file(errors.file.empty) - .mime( - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - errors.file.empty - ), -}); - -export type FormSchema = z.infer; +import type { FormState } from 'nhs-notify-web-template-management-utils'; +import { + $UploadDocxLetterTemplateFormSchema, + type UploadDocxLetterTemplateFormSchema, +} from '@forms/UploadDocxLetterTemplateForm/schema'; export async function uploadStandardLetterTemplate( - _: FormState, + _: FormState, form: FormData -): Promise> { +): Promise> { const fields = Object.fromEntries(form.entries()); - const validation = $FormSchema.safeParse(fields); + const validation = $UploadDocxLetterTemplateFormSchema.safeParse(fields); if (validation.error) { return { diff --git a/frontend/src/components/forms/EmailTemplateForm/EmailTemplateForm.tsx b/frontend/src/components/forms/EmailTemplateForm/EmailTemplateForm.tsx index eee3cf9c6..b023c0576 100644 --- a/frontend/src/components/forms/EmailTemplateForm/EmailTemplateForm.tsx +++ b/frontend/src/components/forms/EmailTemplateForm/EmailTemplateForm.tsx @@ -109,7 +109,7 @@ export const EmailTemplateForm: FC< {templateNameLabelText} {templateNameHintText} - + {templateNameHintText} - + {templateNameHintText} - + {templateNameHintText} - + (); + createNhsNotifyFormContext(); export { NHSNotifyFormProvider }; type FormProps = { campaignIds: string[] }; -const content = copy.pages.uploadStandardLetterTemplate.form; +const { fields } = copy.components.uploadDocxLetterTemplateForm; -export function UploadStandardLetterTemplateForm({ campaignIds }: FormProps) { +export function UploadDocxLetterTemplateForm({ campaignIds }: FormProps) { const [state, action] = useNHSNotifyForm(); const nameError = state.errorState?.fieldErrors?.name?.join(','); @@ -39,16 +34,11 @@ export function UploadStandardLetterTemplateForm({ campaignIds }: FormProps) { > - {content.name.hint} + {fields.name.hint} -
            - {content.name.details.summary} - - - -
            + {nameError && {nameError}} {campaignIds.length === 1 ? ( <> - {content.campaignId.single.hint} + {fields.campaignId.single.hint} ) : ( <> - {content.campaignId.select.hint} + {fields.campaignId.select.hint} {campaignIdError && {campaignIdError}} - +

            {content.heading}

            @@ -43,14 +42,20 @@ export default async function UploadLargePrintLetterTemplatePage() {
            - + + + + +
            - + ); diff --git a/frontend/src/app/upload-standard-english-letter-template/page.tsx b/frontend/src/app/upload-standard-english-letter-template/page.tsx index fe56436b2..314500e34 100644 --- a/frontend/src/app/upload-standard-english-letter-template/page.tsx +++ b/frontend/src/app/upload-standard-english-letter-template/page.tsx @@ -3,10 +3,7 @@ import { redirect, RedirectType } from 'next/navigation'; import { NHSNotifyBackLink } from '@atoms/NHSNotifyBackLink/NHSNotifyBackLink'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import copy from '@content/content'; -import { - UploadDocxLetterTemplateForm, - NHSNotifyFormProvider, -} from '@forms/UploadDocxLetterTemplateForm/form'; +import * as UploadDocxLetterTemplateForm from '@forms/UploadDocxLetterTemplateForm/form'; import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; import { fetchClient } from '@utils/server-features'; import { getCampaignIds } from '@utils/client-config'; @@ -35,7 +32,9 @@ export default async function UploadStandardLetterTemplatePage() { {content.backLink.text} - +

            {content.heading}

            @@ -43,14 +42,20 @@ export default async function UploadStandardLetterTemplatePage() {
            - + + + + +
            - + ); diff --git a/frontend/src/components/forms/UploadDocxLetterTemplateForm/form.tsx b/frontend/src/components/forms/UploadDocxLetterTemplateForm/form.tsx index a8423e072..5ad6826a4 100644 --- a/frontend/src/components/forms/UploadDocxLetterTemplateForm/form.tsx +++ b/frontend/src/components/forms/UploadDocxLetterTemplateForm/form.tsx @@ -10,96 +10,116 @@ import { NHSNotifyFormWrapper } from '@molecules/NHSNotifyFormWrapper/NHSNotifyF import { TemplateNameGuidance } from '@molecules/TemplateNameGuidance'; import { createNhsNotifyFormContext } from '@providers/form-provider'; import { DOCX_MIME, type UploadDocxLetterTemplateFormSchema } from './schema'; +import { PropsWithChildren } from 'react'; const { useNHSNotifyForm, NHSNotifyFormProvider } = createNhsNotifyFormContext(); -export { NHSNotifyFormProvider }; +export const Provider = NHSNotifyFormProvider; -type FormProps = { campaignIds: string[] }; +const content = copy.components.uploadDocxLetterTemplateForm; -const { fields } = copy.components.uploadDocxLetterTemplateForm; +export function NameField() { + const [state] = useNHSNotifyForm(); -export function UploadDocxLetterTemplateForm({ campaignIds }: FormProps) { - const [state, action] = useNHSNotifyForm(); + const error = state.errorState?.fieldErrors?.name?.join(','); - const nameError = state.errorState?.fieldErrors?.name?.join(','); - const campaignIdError = state.errorState?.fieldErrors?.campaignId?.join(','); - const fileError = state.errorState?.fieldErrors?.file?.join(','); + return ( + + + {content.fields.name.hint} + + + {error && {error}} + + + ); +} + +export function CampaignIdField({ campaignIds }: { campaignIds: string[] }) { + const [state] = useNHSNotifyForm(); + + const error = state.errorState?.fieldErrors?.campaignId?.join(','); + + return ( + + + {campaignIds.length === 1 ? ( + <> + {content.fields.campaignId.single.hint} + +

            {campaignIds[0]}

            + + ) : ( + <> + {content.fields.campaignId.select.hint} + {error && {error}} + + + )} +
            + ); +} + +export function FileField() { + const [state] = useNHSNotifyForm(); + + const error = state.errorState?.fieldErrors?.file?.join(','); + + return ( + + + + + + {error && {error}} + + + ); +} + +export function Form({ + children, + formId, +}: PropsWithChildren<{ formId: string }>) { + const [, action] = useNHSNotifyForm(); return ( - - - - {fields.name.hint} - - - {nameError && {nameError}} - - - - - - {campaignIds.length === 1 ? ( - <> - {fields.campaignId.single.hint} - -

            {campaignIds[0]}

            - - ) : ( - <> - {fields.campaignId.select.hint} - {campaignIdError && {campaignIdError}} - - - )} -
            - - - - - - - {fileError && {fileError}} - - - - + + {children} + ); } From e99622449bddc7daf2cee4e72249e3457e3b07be Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Mon, 2 Feb 2026 16:34:03 +0000 Subject: [PATCH 10/26] CCM-13489: add other language page --- .../server-action.test.ts | 11 - .../__snapshots__/page.test.tsx.snap | 2148 +++++++++++++++++ .../page.test.tsx | 237 ++ .../server-action.test.ts | 224 ++ .../server-action.test.ts | 11 - .../forms/MessagePlan/MessagePlan.test.tsx | 6 +- .../providers/form-provider.test.tsx | 8 +- .../utils/form-data-to-form-state.test.ts | 24 + .../[routingConfigId]/page.tsx | 6 +- .../create-message-plan/page.tsx | 6 +- .../[routingConfigId]/page.tsx | 6 +- .../page.tsx | 7 +- .../server-action.ts | 25 +- .../page.tsx | 62 + .../server-action.ts | 38 + .../page.tsx | 7 +- .../server-action.ts | 25 +- .../ChooseTemplates/MovetoProduction.tsx | 7 +- .../forms/MessagePlan/MessagePlan.tsx | 7 +- .../UploadDocxLetterTemplateForm/form.tsx | 88 +- .../UploadDocxLetterTemplateForm/schema.ts | 19 - .../components/providers/form-provider.tsx | 75 +- frontend/src/content/content.ts | 40 +- frontend/src/utils/form-data-to-form-state.ts | 20 + ...oad-other-language-letter-template-page.ts | 36 + ...ate-letter-template-page.component.spec.ts | 26 +- ...language-letter-template.component.spec.ts | 173 ++ ...te-nhs-app-template-page.component.spec.ts | 44 +- ...it-nhs-app-template-page.component.spec.ts | 42 +- ...emplate-protected-routes.component.spec.ts | 8 +- utils/utils/src/__tests__/enum.test.ts | 12 + utils/utils/src/enum.ts | 5 + utils/utils/src/types.ts | 10 +- 33 files changed, 3239 insertions(+), 224 deletions(-) create mode 100644 frontend/src/__tests__/app/upload-other-language-letter-template/__snapshots__/page.test.tsx.snap create mode 100644 frontend/src/__tests__/app/upload-other-language-letter-template/page.test.tsx create mode 100644 frontend/src/__tests__/app/upload-other-language-letter-template/server-action.test.ts create mode 100644 frontend/src/__tests__/utils/form-data-to-form-state.test.ts create mode 100644 frontend/src/app/upload-other-language-letter-template/page.tsx create mode 100644 frontend/src/app/upload-other-language-letter-template/server-action.ts delete mode 100644 frontend/src/components/forms/UploadDocxLetterTemplateForm/schema.ts create mode 100644 frontend/src/utils/form-data-to-form-state.ts create mode 100644 tests/test-team/pages/letter/template-mgmt-upload-other-language-letter-template-page.ts create mode 100644 tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-other-language-letter-template.component.spec.ts diff --git a/frontend/src/__tests__/app/upload-large-print-letter-template/server-action.test.ts b/frontend/src/__tests__/app/upload-large-print-letter-template/server-action.test.ts index 299f57c96..4927774c1 100644 --- a/frontend/src/__tests__/app/upload-large-print-letter-template/server-action.test.ts +++ b/frontend/src/__tests__/app/upload-large-print-letter-template/server-action.test.ts @@ -17,7 +17,6 @@ describe('uploadLargePrintLetterTemplate', () => { expect(result.fields).toEqual({ name: 'Test Template', campaignId: 'Campaign 1', - file, }); }); @@ -39,11 +38,6 @@ describe('uploadLargePrintLetterTemplate', () => { name: ['Enter a template name'], }, }); - expect(result.fields).toEqual({ - name: '', - campaignId: 'Campaign 1', - file, - }); }); it('returns validation error when name is missing', async () => { @@ -83,11 +77,6 @@ describe('uploadLargePrintLetterTemplate', () => { campaignId: ['Choose a campaign'], }, }); - expect(result.fields).toEqual({ - name: 'Test Template', - campaignId: '', - file, - }); }); it('returns validation error when campaignId is missing', async () => { diff --git a/frontend/src/__tests__/app/upload-other-language-letter-template/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/upload-other-language-letter-template/__snapshots__/page.test.tsx.snap new file mode 100644 index 000000000..714dfdb6a --- /dev/null +++ b/frontend/src/__tests__/app/upload-other-language-letter-template/__snapshots__/page.test.tsx.snap @@ -0,0 +1,2148 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`client has multiple campaign ids matches snapshot on initial render 1`] = ` + + + Back to choose a template type + +
            +
            +
            +

            + Upload an other language letter template +

            +
            +
            +
            +
            +
            + + +
            + +
            + This will not be visible to recipients. +
            +
            + + + Naming your templates + + +
            +

            + You should name your templates in a way that works best for your service or organisation. +

            +

            + Common template names include the: +

            +
              +
            • + subject or reason for the message +
            • +
            • + intended audience for the template +
            • +
            • + version number of the template +
            • +
            +

            + For example, 'Covid19 2025 - over 65s - version 3' +

            +
            +
            + +
            +
            + +
            + Choose which campaign this letter is for +
            + +
            +
            + +
            + Choose the language used in this template. +
            + +
            +
            + +
            + Only upload your final letter template file. +
            + Make sure you use one of our blank template files to create the letter. +
            + +
            + +
            +
            +
            +

            + How to create an other language letter template +

            +
              +
            1. + Download the blank + + other language letter template file + + . +
            2. +
            3. + Add + + formatting (opens in a new tab) + + . +
            4. +
            5. + Add any + + personalisation (opens in a new tab) + + . +
            6. +
            7. + Save your Microsoft Word file and upload it to this page. +
            8. +
            +
            +
            +
            +
            +`; + +exports[`client has multiple campaign ids renders errors when blank form is submitted and error state is returned 1`] = ` + + + Back to choose a template type + +
            + +
            +
            +

            + Upload an other language letter template +

            +
            +
            +
            +
            +
            + + +
            + +
            + This will not be visible to recipients. +
            +
            + + + Naming your templates + + +
            +

            + You should name your templates in a way that works best for your service or organisation. +

            +

            + Common template names include the: +

            +
              +
            • + subject or reason for the message +
            • +
            • + intended audience for the template +
            • +
            • + version number of the template +
            • +
            +

            + For example, 'Covid19 2025 - over 65s - version 3' +

            +
            +
            + + + Error: + + Enter a template name + + +
            +
            + +
            + Choose which campaign this letter is for +
            + + + Error: + + Choose a campaign + + +
            +
            + +
            + Choose the language used in this template. +
            + + + Error: + + Choose a language + + +
            +
            + +
            + Only upload your final letter template file. +
            + Make sure you use one of our blank template files to create the letter. +
            + + + Error: + + Choose a template file + + +
            + +
            +
            +
            +

            + How to create an other language letter template +

            +
              +
            1. + Download the blank + + other language letter template file + + . +
            2. +
            3. + Add + + formatting (opens in a new tab) + + . +
            4. +
            5. + Add any + + personalisation (opens in a new tab) + + . +
            6. +
            7. + Save your Microsoft Word file and upload it to this page. +
            8. +
            +
            +
            +
            +
            +`; + +exports[`client has one campaign id matches snapshot on initial render 1`] = ` + + + Back to choose a template type + +
            +
            +
            +

            + Upload an other language letter template +

            +
            +
            +
            +
            +
            + + +
            + +
            + This will not be visible to recipients. +
            +
            + + + Naming your templates + + +
            +

            + You should name your templates in a way that works best for your service or organisation. +

            +

            + Common template names include the: +

            +
              +
            • + subject or reason for the message +
            • +
            • + intended audience for the template +
            • +
            • + version number of the template +
            • +
            +

            + For example, 'Covid19 2025 - over 65s - version 3' +

            +
            +
            + +
            +
            + +
            + This message plan will link to your only campaign: +
            + +

            + Campaign 1 +

            +
            +
            + +
            + Choose the language used in this template. +
            + +
            +
            + +
            + Only upload your final letter template file. +
            + Make sure you use one of our blank template files to create the letter. +
            + +
            + +
            +
            +
            +

            + How to create an other language letter template +

            +
              +
            1. + Download the blank + + other language letter template file + + . +
            2. +
            3. + Add + + formatting (opens in a new tab) + + . +
            4. +
            5. + Add any + + personalisation (opens in a new tab) + + . +
            6. +
            7. + Save your Microsoft Word file and upload it to this page. +
            8. +
            +
            +
            +
            +
            +`; + +exports[`client has one campaign id renders errors when blank form is submitted and error state is returned 1`] = ` + + + Back to choose a template type + +
            + +
            +
            +

            + Upload an other language letter template +

            +
            +
            +
            +
            +
            + + +
            + +
            + This will not be visible to recipients. +
            +
            + + + Naming your templates + + +
            +

            + You should name your templates in a way that works best for your service or organisation. +

            +

            + Common template names include the: +

            +
              +
            • + subject or reason for the message +
            • +
            • + intended audience for the template +
            • +
            • + version number of the template +
            • +
            +

            + For example, 'Covid19 2025 - over 65s - version 3' +

            +
            +
            + + + Error: + + Enter a template name + + +
            +
            + +
            + This message plan will link to your only campaign: +
            + +

            + Campaign 1 +

            +
            +
            + +
            + Choose the language used in this template. +
            + + + Error: + + Choose a language + + +
            +
            + +
            + Only upload your final letter template file. +
            + Make sure you use one of our blank template files to create the letter. +
            + + + Error: + + Choose a template file + + +
            + +
            +
            +
            +

            + How to create an other language letter template +

            +
              +
            1. + Download the blank + + other language letter template file + + . +
            2. +
            3. + Add + + formatting (opens in a new tab) + + . +
            4. +
            5. + Add any + + personalisation (opens in a new tab) + + . +
            6. +
            7. + Save your Microsoft Word file and upload it to this page. +
            8. +
            +
            +
            +
            +
            +`; + +exports[`client has one campaign id renders the rtl template link if an rtl language is selected 1`] = ` + + + Back to choose a template type + +
            +
            +
            +

            + Upload an other language letter template +

            +
            +
            +
            +
            +
            + + +
            + +
            + This will not be visible to recipients. +
            +
            + + + Naming your templates + + +
            +

            + You should name your templates in a way that works best for your service or organisation. +

            +

            + Common template names include the: +

            +
              +
            • + subject or reason for the message +
            • +
            • + intended audience for the template +
            • +
            • + version number of the template +
            • +
            +

            + For example, 'Covid19 2025 - over 65s - version 3' +

            +
            +
            + +
            +
            + +
            + This message plan will link to your only campaign: +
            + +

            + Campaign 1 +

            +
            +
            + +
            + Choose the language used in this template. +
            + +
            +
            + + Information: + +

            + + Right-to-left language selected + +

            +

            + You've selected a language that reads right-to-left. Make sure you use the + + other language (right-aligned) letter template file + + . +

            +
            +
            + +
            + Only upload your final letter template file. +
            + Make sure you use one of our blank template files to create the letter. +
            + +
            + +
            +
            +
            +

            + How to create an other language letter template +

            +
              +
            1. + Download the blank + + other language letter template file + + . +
            2. +
            3. + Add + + formatting (opens in a new tab) + + . +
            4. +
            5. + Add any + + personalisation (opens in a new tab) + + . +
            6. +
            7. + Save your Microsoft Word file and upload it to this page. +
            8. +
            +
            +
            +
            +
            +`; diff --git a/frontend/src/__tests__/app/upload-other-language-letter-template/page.test.tsx b/frontend/src/__tests__/app/upload-other-language-letter-template/page.test.tsx new file mode 100644 index 000000000..05ef4a6bf --- /dev/null +++ b/frontend/src/__tests__/app/upload-other-language-letter-template/page.test.tsx @@ -0,0 +1,237 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { redirect, RedirectType } from 'next/navigation'; +import { verifyFormCsrfToken } from '@utils/csrf-utils'; +import { fetchClient } from '@utils/server-features'; +import Page, { + metadata, +} from '@app/upload-other-language-letter-template/page'; +import { uploadOtherLanguageLetterTemplate } from '@app/upload-other-language-letter-template/server-action'; + +jest.mock('next/navigation'); +jest.mock('@utils/csrf-utils'); +jest.mock('@utils/server-features'); +jest.mock('@app/upload-other-language-letter-template/server-action'); + +beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(verifyFormCsrfToken).mockResolvedValue(true); + jest.mocked(uploadOtherLanguageLetterTemplate).mockResolvedValue({}); +}); + +test('metadata', () => { + expect(metadata).toEqual({ + title: 'Upload an other language letter template - NHS Notify', + }); +}); + +describe('client has no campaign ids', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: [], + features: {}, + }); + }); + + it('redirects to campaign id required page', async () => { + await Page(); + + expect(redirect).toHaveBeenCalledWith( + '/upload-letter-template/client-id-and-campaign-id-required', + RedirectType.replace + ); + }); +}); + +describe('client has one campaign id', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: ['Campaign 1'], + features: {}, + }); + }); + + it('matches snapshot on initial render', async () => { + expect(render(await Page()).asFragment()).toMatchSnapshot(); + }); + + it('submits the form with correct data', async () => { + const user = userEvent.setup(); + + render(await Page()); + + await user.click(screen.getByLabelText('Template name')); + await user.keyboard('A new template'); + + await user.selectOptions(screen.getByLabelText('Template language'), 'sk'); + + const file = new File(['hello'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + + await user.upload(screen.getByLabelText('Template file'), file); + + await user.click( + screen.getByRole('button', { name: 'Upload letter template file' }) + ); + + expect(uploadOtherLanguageLetterTemplate).toHaveBeenCalledTimes(1); + + const callArgs = jest.mocked(uploadOtherLanguageLetterTemplate).mock + .calls[0]; + const formData = callArgs[1] as FormData; + + expect(formData.get('name')).toBe('A new template'); + expect(formData.get('campaignId')).toBe('Campaign 1'); + expect(formData.get('file')).toBeInstanceOf(File); + expect(formData.get('language')).toBe('sk'); + + expect( + screen.queryByRole('alert', { name: 'There is a problem' }) + ).not.toBeInTheDocument(); + }); + + it('renders errors when blank form is submitted and error state is returned', async () => { + jest.mocked(uploadOtherLanguageLetterTemplate).mockResolvedValue({ + errorState: { + fieldErrors: { + name: ['Enter a template name'], + file: ['Choose a template file'], + language: ['Choose a language'], + }, + }, + }); + + const user = userEvent.setup(); + + const page = render(await Page()); + + await user.click( + screen.getByRole('button', { name: 'Upload letter template file' }) + ); + + expect( + screen.queryByRole('alert', { name: 'There is a problem' }) + ).toBeInTheDocument(); + + expect(page.asFragment()).toMatchSnapshot(); + }); + + it('renders the rtl template link if an rtl language is selected', async () => { + const user = userEvent.setup(); + + const page = render(await Page()); + + expect( + page.queryByRole('link', { + name: 'other language (right-aligned) letter template file', + }) + ).not.toBeInTheDocument(); + + await user.selectOptions(screen.getByLabelText('Template language'), 'sk'); + + expect( + page.queryByRole('link', { + name: 'other language (right-aligned) letter template file', + }) + ).not.toBeInTheDocument(); + + await user.selectOptions(screen.getByLabelText('Template language'), 'ar'); + + expect( + page.getByRole('link', { + name: 'other language (right-aligned) letter template file', + }) + ).toBeInTheDocument(); + + expect(page.asFragment()).toMatchSnapshot(); + + await user.selectOptions(screen.getByLabelText('Template language'), 'sk'); + + expect( + page.queryByRole('link', { + name: 'other language (right-aligned) letter template file', + }) + ).not.toBeInTheDocument(); + }); +}); + +describe('client has multiple campaign ids', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: ['Campaign 1', 'Campaign 2'], + features: {}, + }); + }); + + it('matches snapshot on initial render', async () => { + expect(render(await Page()).asFragment()).toMatchSnapshot(); + }); + + it('submits the form with correct data', async () => { + const user = userEvent.setup(); + + render(await Page()); + + await user.click(screen.getByLabelText('Template name')); + await user.keyboard('A new template'); + + await user.selectOptions(screen.getByLabelText('Campaign'), 'Campaign 2'); + + await user.selectOptions(screen.getByLabelText('Template language'), 'sk'); + + const file = new File(['hello'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + + await user.upload(screen.getByLabelText('Template file'), file); + + await user.click( + screen.getByRole('button', { name: 'Upload letter template file' }) + ); + + expect(uploadOtherLanguageLetterTemplate).toHaveBeenCalledTimes(1); + + const callArgs = jest.mocked(uploadOtherLanguageLetterTemplate).mock + .calls[0]; + const formData = callArgs[1] as FormData; + + expect(formData.get('name')).toBe('A new template'); + expect(formData.get('campaignId')).toBe('Campaign 2'); + expect(formData.get('file')).toBeInstanceOf(File); + expect(formData.get('language')).toBe('sk'); + + expect( + screen.queryByRole('alert', { name: 'There is a problem' }) + ).not.toBeInTheDocument(); + }); + + it('renders errors when blank form is submitted and error state is returned', async () => { + jest.mocked(uploadOtherLanguageLetterTemplate).mockResolvedValue({ + errorState: { + fieldErrors: { + name: ['Enter a template name'], + campaignId: ['Choose a campaign'], + language: ['Choose a language'], + file: ['Choose a template file'], + }, + }, + }); + + const user = userEvent.setup(); + + const page = render(await Page()); + + await user.click( + screen.getByRole('button', { name: 'Upload letter template file' }) + ); + + expect(uploadOtherLanguageLetterTemplate).toHaveBeenCalledTimes(1); + + expect( + screen.queryByRole('alert', { name: 'There is a problem' }) + ).toBeInTheDocument(); + + expect(page.asFragment()).toMatchSnapshot(); + }); +}); diff --git a/frontend/src/__tests__/app/upload-other-language-letter-template/server-action.test.ts b/frontend/src/__tests__/app/upload-other-language-letter-template/server-action.test.ts new file mode 100644 index 000000000..2d0ca6cb1 --- /dev/null +++ b/frontend/src/__tests__/app/upload-other-language-letter-template/server-action.test.ts @@ -0,0 +1,224 @@ +import { uploadOtherLanguageLetterTemplate } from '@app/upload-other-language-letter-template/server-action'; + +describe('uploadOtherLanguageLetterTemplate', () => { + it('returns success when all fields are valid', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', 'Campaign 1'); + formData.append('language', 'lv'); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadOtherLanguageLetterTemplate({}, formData); + + expect(result.errorState).toBeUndefined(); + expect(result.fields).toEqual({ + name: 'Test Template', + campaignId: 'Campaign 1', + language: 'lv', + }); + }); + + it('returns validation error when name is empty', async () => { + const formData = new FormData(); + formData.append('name', ''); + formData.append('campaignId', 'Campaign 1'); + formData.append('language', 'lv'); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadOtherLanguageLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + name: ['Enter a template name'], + }, + }); + }); + + it('returns validation error when name is missing', async () => { + const formData = new FormData(); + formData.append('campaignId', 'Campaign 1'); + formData.append('language', 'lv'); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadOtherLanguageLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + name: ['Enter a template name'], + }, + }); + }); + + it('returns validation error when campaignId is empty', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', ''); + formData.append('language', 'lv'); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadOtherLanguageLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + campaignId: ['Choose a campaign'], + }, + }); + }); + + it('returns validation error when campaignId is missing', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('language', 'lv'); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadOtherLanguageLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + campaignId: ['Choose a campaign'], + }, + }); + }); + + it('returns validation error when language is empty', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', 'Campaign 1'); + formData.append('language', ''); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadOtherLanguageLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + language: ['Choose a language'], + }, + }); + }); + + it('returns validation error when language is missing', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', 'Campaign 1'); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadOtherLanguageLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + language: ['Choose a language'], + }, + }); + }); + + it('returns validation error when language is invalid', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', 'Campaign 1'); + formData.append('language', 'not a language code'); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadOtherLanguageLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + language: ['Choose a language'], + }, + }); + }); + + it('returns validation error when file is missing', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', 'Campaign 1'); + formData.append('language', 'lv'); + + const result = await uploadOtherLanguageLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + file: ['Choose a template file'], + }, + }); + }); + + it('returns validation error when file has incorrect MIME type', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', 'Campaign 1'); + formData.append('language', 'lv'); + + const file = new File(['content'], 'template.pdf', { + type: 'application/pdf', + }); + formData.append('file', file); + + const result = await uploadOtherLanguageLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + file: ['Choose a template file'], + }, + }); + }); + + it('returns multiple validation errors when multiple fields are invalid', async () => { + const formData = new FormData(); + formData.append('name', ''); + formData.append('campaignId', ''); + formData.append('language', ''); + + const result = await uploadOtherLanguageLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + name: ['Enter a template name'], + campaignId: ['Choose a campaign'], + language: ['Choose a language'], + file: ['Choose a template file'], + }, + }); + }); +}); diff --git a/frontend/src/__tests__/app/upload-standard-english-letter-template/server-action.test.ts b/frontend/src/__tests__/app/upload-standard-english-letter-template/server-action.test.ts index 933382dce..fb2418dda 100644 --- a/frontend/src/__tests__/app/upload-standard-english-letter-template/server-action.test.ts +++ b/frontend/src/__tests__/app/upload-standard-english-letter-template/server-action.test.ts @@ -17,7 +17,6 @@ describe('uploadStandardLetterTemplate', () => { expect(result.fields).toEqual({ name: 'Test Template', campaignId: 'Campaign 1', - file, }); }); @@ -39,11 +38,6 @@ describe('uploadStandardLetterTemplate', () => { name: ['Enter a template name'], }, }); - expect(result.fields).toEqual({ - name: '', - campaignId: 'Campaign 1', - file, - }); }); it('returns validation error when name is missing', async () => { @@ -83,11 +77,6 @@ describe('uploadStandardLetterTemplate', () => { campaignId: ['Choose a campaign'], }, }); - expect(result.fields).toEqual({ - name: 'Test Template', - campaignId: '', - file, - }); }); it('returns validation error when campaignId is missing', async () => { diff --git a/frontend/src/__tests__/components/forms/MessagePlan/MessagePlan.test.tsx b/frontend/src/__tests__/components/forms/MessagePlan/MessagePlan.test.tsx index 4ca33f860..54206204d 100644 --- a/frontend/src/__tests__/components/forms/MessagePlan/MessagePlan.test.tsx +++ b/frontend/src/__tests__/components/forms/MessagePlan/MessagePlan.test.tsx @@ -1,9 +1,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { - MessagePlanForm, - NHSNotifyFormProvider, -} from '@forms/MessagePlan/MessagePlan'; +import { MessagePlanForm } from '@forms/MessagePlan/MessagePlan'; +import { NHSNotifyFormProvider } from '@providers/form-provider'; import { verifyFormCsrfToken } from '@utils/csrf-utils'; jest.mock('@utils/csrf-utils'); diff --git a/frontend/src/__tests__/components/providers/form-provider.test.tsx b/frontend/src/__tests__/components/providers/form-provider.test.tsx index 21a1897ab..3c99b55b3 100644 --- a/frontend/src/__tests__/components/providers/form-provider.test.tsx +++ b/frontend/src/__tests__/components/providers/form-provider.test.tsx @@ -11,7 +11,10 @@ import type { FormState, } from 'nhs-notify-web-template-management-utils'; import { NhsNotifyErrorSummary } from '@molecules/NhsNotifyErrorSummary/NhsNotifyErrorSummary'; -import { createNhsNotifyFormContext } from '@providers/form-provider'; +import { + useNHSNotifyForm, + NHSNotifyFormProvider, +} from '@providers/form-provider'; import { startTransition } from 'react'; jest.mock('@molecules/NhsNotifyErrorSummary/NhsNotifyErrorSummary'); @@ -24,9 +27,6 @@ beforeEach(() => { jest.mocked(NhsNotifyErrorSummary).mockClear(); }); -const { useNHSNotifyForm, NHSNotifyFormProvider } = - createNhsNotifyFormContext(); - function TestForm() { const [, action] = useNHSNotifyForm(); diff --git a/frontend/src/__tests__/utils/form-data-to-form-state.test.ts b/frontend/src/__tests__/utils/form-data-to-form-state.test.ts new file mode 100644 index 000000000..d4a8cdb39 --- /dev/null +++ b/frontend/src/__tests__/utils/form-data-to-form-state.test.ts @@ -0,0 +1,24 @@ +import { formDataToFormStateFields } from '@utils/form-data-to-form-state'; + +it('returns an object with all of the form data values', () => { + const form = new FormData(); + form.append('foo', 'a'); + form.append('bar', 'b'); + form.append('baz', 'c'); + + expect(formDataToFormStateFields(form)).toEqual({ + foo: 'a', + bar: 'b', + baz: 'c', + }); +}); + +it('removes File objects', () => { + const form = new FormData(); + form.append('foo', 'a'); + form.append('bar', new File(['hello'], 'file.txt')); + + expect(formDataToFormStateFields(form)).toEqual({ + foo: 'a', + }); +}); diff --git a/frontend/src/app/message-plans/choose-templates/[routingConfigId]/page.tsx b/frontend/src/app/message-plans/choose-templates/[routingConfigId]/page.tsx index c024b4761..2f6b15550 100644 --- a/frontend/src/app/message-plans/choose-templates/[routingConfigId]/page.tsx +++ b/frontend/src/app/message-plans/choose-templates/[routingConfigId]/page.tsx @@ -21,10 +21,7 @@ import { } from '@atoms/nhsuk-components'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import copy from '@content/content'; -import { - MessagePlanChooseTemplatesMoveToProductionForm, - NHSNotifyFormProvider, -} from '@forms/ChooseTemplates/MovetoProduction'; +import { MessagePlanChooseTemplatesMoveToProductionForm } from '@forms/ChooseTemplates/MovetoProduction'; import { MessagePlanBlock } from '@molecules/MessagePlanBlock/MessagePlanBlock'; import { MessagePlanChannelCard } from '@molecules/MessagePlanChannelCard/MessagePlanChannelCard'; import { @@ -37,6 +34,7 @@ import { } from '@molecules/MessagePlanFallbackConditions/MessagePlanFallbackConditions'; import { MessagePlanChannelList } from '@molecules/MessagePlanChannelList/MessagePlanChannelList'; import { MessagePlanChooseTemplateCardContent } from '@organisms/MessagePlanChooseTemplateCardContent/MessagePlanChooseTemplateCardContent'; +import { NHSNotifyFormProvider } from '@providers/form-provider'; import { interpolate } from '@utils/interpolate'; import { getRoutingConfig, diff --git a/frontend/src/app/message-plans/create-message-plan/page.tsx b/frontend/src/app/message-plans/create-message-plan/page.tsx index 68387b3f8..62d3895f7 100644 --- a/frontend/src/app/message-plans/create-message-plan/page.tsx +++ b/frontend/src/app/message-plans/create-message-plan/page.tsx @@ -4,10 +4,8 @@ import { z } from 'zod/v4'; import { MESSAGE_ORDER_OPTIONS_LIST } from 'nhs-notify-web-template-management-utils'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import content from '@content/content'; -import { - MessagePlanForm, - NHSNotifyFormProvider, -} from '@forms/MessagePlan/MessagePlan'; +import { MessagePlanForm } from '@forms/MessagePlan/MessagePlan'; +import { NHSNotifyFormProvider } from '@providers/form-provider'; import { getCampaignIds } from '@utils/client-config'; import { fetchClient } from '@utils/server-features'; import { createMessagePlanServerAction } from './server-action'; diff --git a/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx b/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx index 6d4a02ca1..ccc20de71 100644 --- a/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx +++ b/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx @@ -3,10 +3,8 @@ import { redirect, RedirectType } from 'next/navigation'; import type { MessagePlanPageProps } from 'nhs-notify-web-template-management-utils'; import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import content from '@content/content'; -import { - MessagePlanForm, - NHSNotifyFormProvider, -} from '@forms/MessagePlan/MessagePlan'; +import { MessagePlanForm } from '@forms/MessagePlan/MessagePlan'; +import { NHSNotifyFormProvider } from '@providers/form-provider'; import { getCampaignIds } from '@utils/client-config'; import { getRoutingConfig } from '@utils/message-plans'; import { fetchClient } from '@utils/server-features'; diff --git a/frontend/src/app/upload-large-print-letter-template/page.tsx b/frontend/src/app/upload-large-print-letter-template/page.tsx index c768402f9..f008016d8 100644 --- a/frontend/src/app/upload-large-print-letter-template/page.tsx +++ b/frontend/src/app/upload-large-print-letter-template/page.tsx @@ -5,6 +5,7 @@ import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import copy from '@content/content'; import * as UploadDocxLetterTemplateForm from '@forms/UploadDocxLetterTemplateForm/form'; import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; +import { NHSNotifyFormProvider } from '@providers/form-provider'; import { fetchClient } from '@utils/server-features'; import { getCampaignIds } from '@utils/client-config'; import { uploadLargePrintLetterTemplate } from './server-action'; @@ -32,9 +33,7 @@ export default async function UploadLargePrintLetterTemplatePage() { {content.backLink.text} - +

            {content.heading}

            @@ -55,7 +54,7 @@ export default async function UploadLargePrintLetterTemplatePage() {
            -
            +
            ); diff --git a/frontend/src/app/upload-large-print-letter-template/server-action.ts b/frontend/src/app/upload-large-print-letter-template/server-action.ts index 7c7207044..a62644422 100644 --- a/frontend/src/app/upload-large-print-letter-template/server-action.ts +++ b/frontend/src/app/upload-large-print-letter-template/server-action.ts @@ -2,18 +2,27 @@ import { z } from 'zod/v4'; import type { FormState } from 'nhs-notify-web-template-management-utils'; -import { - $UploadDocxLetterTemplateFormSchema, - type UploadDocxLetterTemplateFormSchema, -} from '@forms/UploadDocxLetterTemplateForm/schema'; +import copy from '@content/content'; +import { DOCX_MIME } from '@forms/UploadDocxLetterTemplateForm/form'; +import { formDataToFormStateFields } from '@utils/form-data-to-form-state'; + +const { errors } = copy.components.uploadDocxLetterTemplateForm; + +const $FormSchema = z.object({ + name: z.string(errors.name.empty).nonempty(errors.name.empty), + campaignId: z + .string(errors.campaignId.empty) + .nonempty(errors.campaignId.empty), + file: z.file(errors.file.empty).mime(DOCX_MIME, errors.file.empty), +}); export async function uploadLargePrintLetterTemplate( - _: FormState, + _: FormState, form: FormData -): Promise> { - const fields = Object.fromEntries(form.entries()); +): Promise { + const validation = $FormSchema.safeParse(Object.fromEntries(form.entries())); - const validation = $UploadDocxLetterTemplateFormSchema.safeParse(fields); + const fields = formDataToFormStateFields(form); if (validation.error) { return { diff --git a/frontend/src/app/upload-other-language-letter-template/page.tsx b/frontend/src/app/upload-other-language-letter-template/page.tsx new file mode 100644 index 000000000..8552c3a92 --- /dev/null +++ b/frontend/src/app/upload-other-language-letter-template/page.tsx @@ -0,0 +1,62 @@ +import type { Metadata } from 'next'; +import { redirect, RedirectType } from 'next/navigation'; +import { NHSNotifyBackLink } from '@atoms/NHSNotifyBackLink/NHSNotifyBackLink'; +import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; +import copy from '@content/content'; +import * as UploadDocxLetterTemplateForm from '@forms/UploadDocxLetterTemplateForm/form'; +import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; +import { NHSNotifyFormProvider } from '@providers/form-provider'; +import { fetchClient } from '@utils/server-features'; +import { getCampaignIds } from '@utils/client-config'; +import { uploadOtherLanguageLetterTemplate } from './server-action'; + +const content = copy.pages.uploadDocxLetterTemplatePage('language'); + +export const metadata: Metadata = { + title: content.pageTitle, +}; + +export default async function UploadOtherLanguageLetterTemplatePage() { + const client = await fetchClient(); + const campaignIds = getCampaignIds(client); + + if (campaignIds.length === 0) { + return redirect( + '/upload-letter-template/client-id-and-campaign-id-required', + RedirectType.replace + ); + } + + return ( + <> + + {content.backLink.text} + + + +
            +
            +

            {content.heading}

            +
            +
            +
            +
            + + + + + + +
            + +
            + +
            +
            +
            +
            + + ); +} diff --git a/frontend/src/app/upload-other-language-letter-template/server-action.ts b/frontend/src/app/upload-other-language-letter-template/server-action.ts new file mode 100644 index 000000000..87dcb99c4 --- /dev/null +++ b/frontend/src/app/upload-other-language-letter-template/server-action.ts @@ -0,0 +1,38 @@ +'use server'; + +import { z } from 'zod/v4'; +import { LANGUAGE_LIST } from 'nhs-notify-backend-client'; +import type { FormState } from 'nhs-notify-web-template-management-utils'; +import copy from '@content/content'; +import { DOCX_MIME } from '@forms/UploadDocxLetterTemplateForm/form'; +import { formDataToFormStateFields } from '@utils/form-data-to-form-state'; + +const { errors } = copy.components.uploadDocxLetterTemplateForm; + +const $FormSchema = z.object({ + name: z.string(errors.name.empty).nonempty(errors.name.empty), + campaignId: z + .string(errors.campaignId.empty) + .nonempty(errors.campaignId.empty), + language: z.enum(LANGUAGE_LIST, errors.language.empty).exclude(['en']), + file: z.file(errors.file.empty).mime(DOCX_MIME, errors.file.empty), +}); + +export async function uploadOtherLanguageLetterTemplate( + _: FormState, + form: FormData +): Promise { + const validation = $FormSchema.safeParse(Object.fromEntries(form.entries())); + + const fields = formDataToFormStateFields(form); + + if (validation.error) { + return { + errorState: z.flattenError(validation.error), + fields, + }; + } + + // TODO: CCM-14211 - submit the form and redirect instead of returning + return { fields }; +} diff --git a/frontend/src/app/upload-standard-english-letter-template/page.tsx b/frontend/src/app/upload-standard-english-letter-template/page.tsx index 314500e34..bd79f3004 100644 --- a/frontend/src/app/upload-standard-english-letter-template/page.tsx +++ b/frontend/src/app/upload-standard-english-letter-template/page.tsx @@ -5,6 +5,7 @@ import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import copy from '@content/content'; import * as UploadDocxLetterTemplateForm from '@forms/UploadDocxLetterTemplateForm/form'; import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; +import { NHSNotifyFormProvider } from '@providers/form-provider'; import { fetchClient } from '@utils/server-features'; import { getCampaignIds } from '@utils/client-config'; import { uploadStandardLetterTemplate } from './server-action'; @@ -32,9 +33,7 @@ export default async function UploadStandardLetterTemplatePage() { {content.backLink.text} - +

            {content.heading}

            @@ -55,7 +54,7 @@ export default async function UploadStandardLetterTemplatePage() {
            -
            +
            ); diff --git a/frontend/src/app/upload-standard-english-letter-template/server-action.ts b/frontend/src/app/upload-standard-english-letter-template/server-action.ts index fc4bcb1d5..cb7d97f88 100644 --- a/frontend/src/app/upload-standard-english-letter-template/server-action.ts +++ b/frontend/src/app/upload-standard-english-letter-template/server-action.ts @@ -2,18 +2,27 @@ import { z } from 'zod/v4'; import type { FormState } from 'nhs-notify-web-template-management-utils'; -import { - $UploadDocxLetterTemplateFormSchema, - type UploadDocxLetterTemplateFormSchema, -} from '@forms/UploadDocxLetterTemplateForm/schema'; +import copy from '@content/content'; +import { DOCX_MIME } from '@forms/UploadDocxLetterTemplateForm/form'; +import { formDataToFormStateFields } from '@utils/form-data-to-form-state'; + +const { errors } = copy.components.uploadDocxLetterTemplateForm; + +const $FormSchema = z.object({ + name: z.string(errors.name.empty).nonempty(errors.name.empty), + campaignId: z + .string(errors.campaignId.empty) + .nonempty(errors.campaignId.empty), + file: z.file(errors.file.empty).mime(DOCX_MIME, errors.file.empty), +}); export async function uploadStandardLetterTemplate( - _: FormState, + _: FormState, form: FormData -): Promise> { - const fields = Object.fromEntries(form.entries()); +): Promise { + const validation = $FormSchema.safeParse(Object.fromEntries(form.entries())); - const validation = $UploadDocxLetterTemplateFormSchema.safeParse(fields); + const fields = formDataToFormStateFields(form); if (validation.error) { return { diff --git a/frontend/src/components/forms/ChooseTemplates/MovetoProduction.tsx b/frontend/src/components/forms/ChooseTemplates/MovetoProduction.tsx index 416517b06..993b38a88 100644 --- a/frontend/src/components/forms/ChooseTemplates/MovetoProduction.tsx +++ b/frontend/src/components/forms/ChooseTemplates/MovetoProduction.tsx @@ -3,14 +3,9 @@ import Link from 'next/link'; import { NHSNotifyButton } from '@atoms/NHSNotifyButton/NHSNotifyButton'; import { NHSNotifyFormWrapper } from '@molecules/NHSNotifyFormWrapper/NHSNotifyFormWrapper'; -import { createNhsNotifyFormContext } from '@providers/form-provider'; +import { useNHSNotifyForm } from '@providers/form-provider'; import copy from '@content/content'; -const { useNHSNotifyForm, NHSNotifyFormProvider } = - createNhsNotifyFormContext(); - -export { NHSNotifyFormProvider }; - const content = copy.pages.chooseTemplatesForMessagePlan; export function MessagePlanChooseTemplatesMoveToProductionForm({ diff --git a/frontend/src/components/forms/MessagePlan/MessagePlan.tsx b/frontend/src/components/forms/MessagePlan/MessagePlan.tsx index 611f80556..39ce16891 100644 --- a/frontend/src/components/forms/MessagePlan/MessagePlan.tsx +++ b/frontend/src/components/forms/MessagePlan/MessagePlan.tsx @@ -15,16 +15,11 @@ import { NHSNotifyButton } from '@atoms/NHSNotifyButton/NHSNotifyButton'; import content from '@content/content'; import { useTextInput } from '@hooks/use-text-input.hook'; import { NHSNotifyFormWrapper } from '@molecules/NHSNotifyFormWrapper/NHSNotifyFormWrapper'; -import { createNhsNotifyFormContext } from '@providers/form-provider'; +import { useNHSNotifyForm } from '@providers/form-provider'; import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; const formContent = content.components.messagePlanForm; -const { useNHSNotifyForm, NHSNotifyFormProvider } = - createNhsNotifyFormContext(); - -export { NHSNotifyFormProvider }; - export function MessagePlanForm({ backLink, campaignIds, diff --git a/frontend/src/components/forms/UploadDocxLetterTemplateForm/form.tsx b/frontend/src/components/forms/UploadDocxLetterTemplateForm/form.tsx index 5ad6826a4..18294bbb1 100644 --- a/frontend/src/components/forms/UploadDocxLetterTemplateForm/form.tsx +++ b/frontend/src/components/forms/UploadDocxLetterTemplateForm/form.tsx @@ -1,23 +1,46 @@ 'use client'; +import { useState, type PropsWithChildren } from 'react'; import classNames from 'classnames'; -import { Button, ErrorMessage, HintText, Label } from 'nhsuk-react-components'; +import { + Button, + ErrorMessage, + HintText, + InsetText, + Label, +} from 'nhsuk-react-components'; +import { z } from 'zod'; +import { LANGUAGE_LIST } from 'nhs-notify-backend-client'; +import { + isLanguage, + isRightToLeft, +} from 'nhs-notify-web-template-management-utils'; import { FileUploadInput } from '@atoms/FileUpload/FileUpload'; import { NHSNotifyFormGroup } from '@atoms/NHSNotifyFormGroup/NHSNotifyFormGroup'; import copy from '@content/content'; import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; import { NHSNotifyFormWrapper } from '@molecules/NHSNotifyFormWrapper/NHSNotifyFormWrapper'; import { TemplateNameGuidance } from '@molecules/TemplateNameGuidance'; -import { createNhsNotifyFormContext } from '@providers/form-provider'; -import { DOCX_MIME, type UploadDocxLetterTemplateFormSchema } from './schema'; -import { PropsWithChildren } from 'react'; +import { useNHSNotifyForm } from '@providers/form-provider'; -const { useNHSNotifyForm, NHSNotifyFormProvider } = - createNhsNotifyFormContext(); +const content = copy.components.uploadDocxLetterTemplateForm; -export const Provider = NHSNotifyFormProvider; +export const DOCX_MIME: z.core.util.MimeTypes = + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; -const content = copy.components.uploadDocxLetterTemplateForm; +export function Form({ + children, + formId, +}: PropsWithChildren<{ formId: string }>) { + const [, action] = useNHSNotifyForm(); + + return ( + + {children} + + + ); +} export function NameField() { const [state] = useNHSNotifyForm(); @@ -110,16 +133,47 @@ export function FileField() { ); } -export function Form({ - children, - formId, -}: PropsWithChildren<{ formId: string }>) { - const [, action] = useNHSNotifyForm(); +const OTHER_LANGUAGES = LANGUAGE_LIST.filter((language) => language !== 'en'); + +export function LanguageField() { + const [state] = useNHSNotifyForm(); + + const [selectedLanguage, setLanguage] = useState(state.fields?.language); + + const error = state.errorState?.fieldErrors?.language?.join(','); return ( - - {children} - - + <> + + + + {content.fields.language.hint} + {error && {error}} + + + {isLanguage(selectedLanguage) && isRightToLeft(selectedLanguage) && ( + + + + )} + ); } diff --git a/frontend/src/components/forms/UploadDocxLetterTemplateForm/schema.ts b/frontend/src/components/forms/UploadDocxLetterTemplateForm/schema.ts deleted file mode 100644 index af54236cc..000000000 --- a/frontend/src/components/forms/UploadDocxLetterTemplateForm/schema.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from 'zod/v4'; -import copy from '@content/content'; - -export const DOCX_MIME: z.core.util.MimeTypes = - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; - -const { errors } = copy.components.uploadDocxLetterTemplateForm; - -export const $UploadDocxLetterTemplateFormSchema = z.object({ - name: z.string(errors.name.empty).nonempty(errors.name.empty), - campaignId: z - .string(errors.campaignId.empty) - .nonempty(errors.campaignId.empty), - file: z.file(errors.file.empty).mime(DOCX_MIME, errors.file.empty), -}); - -export type UploadDocxLetterTemplateFormSchema = z.infer< - typeof $UploadDocxLetterTemplateFormSchema ->; diff --git a/frontend/src/components/providers/form-provider.tsx b/frontend/src/components/providers/form-provider.tsx index 0bdb0ca22..11b1a9f66 100644 --- a/frontend/src/components/providers/form-provider.tsx +++ b/frontend/src/components/providers/form-provider.tsx @@ -9,53 +9,44 @@ import React, { import type { FormState } from 'nhs-notify-web-template-management-utils'; import { NhsNotifyErrorSummary } from '@molecules/NhsNotifyErrorSummary/NhsNotifyErrorSummary'; -type NHSNotifyFormActionState> = ReturnType< - typeof useActionState, FormData> +type NHSNotifyFormActionState = ReturnType< + typeof useActionState >; -export function createNhsNotifyFormContext< - T extends Record, ->() { - const FormContext = createContext | null>(null); +const FormContext = createContext(null); - function useNHSNotifyForm() { - const context = useContext(FormContext); - if (!context) { - throw new Error( - 'useNHSNotifyForm must be used within NHSNotifyFormProvider' - ); - } - return context; +export function useNHSNotifyForm() { + const context = useContext(FormContext); + if (!context) { + throw new Error( + 'useNHSNotifyForm must be used within NHSNotifyFormProvider' + ); } + return context; +} - function NHSNotifyFormProvider({ - children, - errorSummaryHint, - initialState = {}, +export function NHSNotifyFormProvider({ + children, + errorSummaryHint, + initialState = {}, + serverAction, +}: PropsWithChildren<{ + errorSummaryHint?: string; + initialState?: FormState; + serverAction: (state: FormState, data: FormData) => Promise; +}>) { + const [state, action, isPending] = useActionState( serverAction, - }: PropsWithChildren<{ - errorSummaryHint?: string; - initialState?: FormState; - serverAction: ( - state: FormState, - data: FormData - ) => Promise>; - }>) { - const [state, action, isPending] = useActionState, FormData>( - serverAction, - initialState - ); - - return ( - - - {children} - - ); - } + initialState + ); - return { NHSNotifyFormProvider, useNHSNotifyForm }; + return ( + + + {children} + + ); } diff --git a/frontend/src/content/content.ts b/frontend/src/content/content.ts index 361c5f396..7fd98573e 100644 --- a/frontend/src/content/content.ts +++ b/frontend/src/content/content.ts @@ -1576,6 +1576,17 @@ const uploadDocxLetterTemplateForm = { hint: 'Choose which campaign this letter is for', }, }, + language: { + label: 'Template language', + hint: 'Choose the language used in this template.', + rtl: [ + { type: 'text', text: '**Right-to-left language selected**' }, + { + type: 'text', + text: "You've selected a language that reads right-to-left. Make sure you use the [other language (right-aligned) letter template file](https://notify.nhs.uk/assets/worddocs/letter-template-nhs-notify-other-language-right-aligned.docx).", + }, + ] satisfies ContentBlock[], + }, file: { label: 'Template file', hint: [ @@ -1599,11 +1610,16 @@ const uploadDocxLetterTemplateForm = { file: { empty: 'Choose a template file', }, + language: { + empty: 'Choose a language', + }, }, }; +type DocxTemplateType = Extract | 'language'; + const uploadDocxLetterTemplateSideBarMappings: Record< - LetterType | 'language', + DocxTemplateType, [string, string] > = { x0: [ @@ -1614,17 +1630,23 @@ const uploadDocxLetterTemplateSideBarMappings: Record< 'large print', 'https://notify.nhs.uk/assets/worddocs/letter-template-nhs-notify-large-print.docx', ], - q4: ['', ''], - language: ['', ''], + language: [ + 'other language', + 'https://notify.nhs.uk/assets/worddocs/letter-template-nhs-notify-other-language.docx', + ], }; -const uploadDocxLetterTemplateSideBar = (type: LetterType): ContentBlock[] => { +const article = (noun: string) => (/^[aeiou]/i.test(noun) ? 'an' : 'a'); + +const uploadDocxLetterTemplateSideBar = ( + type: DocxTemplateType +): ContentBlock[] => { const [display, templateLink] = uploadDocxLetterTemplateSideBarMappings[type]; return [ { type: 'text', - text: `## How to create a ${display} letter template`, + text: `## How to create ${article(display)} ${display} letter template`, overrides: { h2: { props: { className: 'nhsuk-heading-m' } } }, }, { @@ -1643,15 +1665,17 @@ const uploadDocxLetterTemplateSideBar = (type: LetterType): ContentBlock[] => { ]; }; -const uploadDocxLetterTemplatePage = (type: LetterType) => { +const uploadDocxLetterTemplatePage = (type: DocxTemplateType) => { const [display] = uploadDocxLetterTemplateSideBarMappings[type]; return { - pageTitle: generatePageTitle(`Upload a ${display} letter template`), + pageTitle: generatePageTitle( + `Upload ${article(display)} ${display} letter template` + ), backLink: { href: '/choose-a-template-type', text: 'Back to choose a template type', }, - heading: `Upload a ${display} letter template`, + heading: `Upload ${article(display)} ${display} letter template`, sideBar: uploadDocxLetterTemplateSideBar(type), }; }; diff --git a/frontend/src/utils/form-data-to-form-state.ts b/frontend/src/utils/form-data-to-form-state.ts new file mode 100644 index 000000000..5f315b4ab --- /dev/null +++ b/frontend/src/utils/form-data-to-form-state.ts @@ -0,0 +1,20 @@ +// TODO: CCM-13489: unit tests + +import { FormStateFields } from 'nhs-notify-web-template-management-utils'; + +/** + * Use in server actions to parse a form data object into form state that can be returned to the client when using `useActionState` + * @param formData `FormData` object to parse + * @returns `FormStateFields` + */ +export function formDataToFormStateFields(formData: FormData): FormStateFields { + const fields: FormStateFields = {}; + + for (const [key, value] of formData.entries()) { + if (value !== null && !(value instanceof File)) { + fields[key] = value; + } + } + + return fields; +} diff --git a/tests/test-team/pages/letter/template-mgmt-upload-other-language-letter-template-page.ts b/tests/test-team/pages/letter/template-mgmt-upload-other-language-letter-template-page.ts new file mode 100644 index 000000000..15ebd9bcd --- /dev/null +++ b/tests/test-team/pages/letter/template-mgmt-upload-other-language-letter-template-page.ts @@ -0,0 +1,36 @@ +import type { Locator, Page } from '@playwright/test'; +import { TemplateMgmtBasePage } from '../template-mgmt-base-page'; + +export class TemplateMgmtUploadOtherLanguageLetterTemplatePage extends TemplateMgmtBasePage { + static readonly pathTemplate = '/upload-other-language-letter-template'; + + nameInput: Locator; + + campaignIdInput: Locator; + + singleCampaignIdText: Locator; + + languageInput: Locator; + + fileInput: Locator; + + submitButton: Locator; + + constructor(page: Page) { + super(page); + + this.nameInput = page.getByLabel('Template name'); + + this.campaignIdInput = page.getByLabel('Campaign'); + + this.singleCampaignIdText = page.getByTestId('single-campaign-id-text'); + + this.languageInput = page.getByLabel('Template language'); + + this.fileInput = page.getByLabel('Template file'); + + this.submitButton = page.getByRole('button', { + name: 'Upload letter template file', + }); + } +} diff --git a/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-create-letter-template-page.component.spec.ts b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-create-letter-template-page.component.spec.ts index ec836581a..703ee5d5d 100644 --- a/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-create-letter-template-page.component.spec.ts +++ b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-create-letter-template-page.component.spec.ts @@ -185,7 +185,7 @@ test.describe('Upload letter Template Page', () => { ).toHaveText(['Select a letter template PDF']); }); - const detailsSections = ['how-to-name-your-template']; + const detailsSections = ['Naming your templates']; for (const section of detailsSections) { // eslint-disable-next-line no-loop-func @@ -195,18 +195,20 @@ test.describe('Upload letter Template Page', () => { const createTemplatePage = new TemplateMgmtUploadLetterPage(page); await createTemplatePage.loadPage(); - await page.getByTestId(`${section}-summary`).click(); - await expect(page.getByTestId(`${section}-details`)).toHaveAttribute( - 'open', - '' - ); - await expect(page.getByTestId(`${section}-text`)).toBeVisible(); + const details = await page + .getByRole('group') + .filter({ hasText: section }); - await page.getByTestId(`${section}-summary`).click(); - await expect(page.getByTestId(`${section}-details`)).not.toHaveAttribute( - 'open' - ); - await expect(page.getByTestId(`${section}-text`)).toBeHidden(); + const summary = details.locator('summary'); + const text = details.locator('.nhsuk-details__text'); + + await summary.click(); + await expect(details).toHaveAttribute('open'); + await expect(text).toBeVisible(); + + await summary.click(); + await expect(details).not.toHaveAttribute('open'); + await expect(text).toBeHidden(); }); } diff --git a/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-other-language-letter-template.component.spec.ts b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-other-language-letter-template.component.spec.ts new file mode 100644 index 000000000..5790f46a3 --- /dev/null +++ b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-other-language-letter-template.component.spec.ts @@ -0,0 +1,173 @@ +import { test, expect } from '@playwright/test'; +import { docxFixtures } from 'fixtures/letters'; +import { + createAuthHelper, + TestUser, + testUsers, +} from 'helpers/auth/cognito-auth-helper'; +import { loginAsUser } from 'helpers/auth/login-as-user'; +import { + assertAndClickBackLinkTop, + assertBackLinkBottomNotPresent, + assertFooterLinks, + assertHeaderLogoLink, + assertSignOutLink, + assertSkipToMainContent, +} from 'helpers/template-mgmt-common.steps'; +import { TemplateMgmtUploadOtherLanguageLetterTemplatePage } from 'pages/letter/template-mgmt-upload-other-language-letter-template-page'; + +let userNoCampaignId: TestUser; +let userSingleCampaign: TestUser; +let userMultipleCampaigns: TestUser; + +test.beforeAll(async () => { + const authHelper = createAuthHelper(); + + userSingleCampaign = await authHelper.getTestUser(testUsers.User1.userId); + userNoCampaignId = await authHelper.getTestUser(testUsers.User6.userId); + userMultipleCampaigns = await authHelper.getTestUser( + testUsers.UserWithMultipleCampaigns.userId + ); +}); + +test.describe('Upload Other Language Letter Template Page', () => { + test('common page tests', async ({ page, baseURL }) => { + const props = { + page: new TemplateMgmtUploadOtherLanguageLetterTemplatePage(page), + baseURL, + }; + + await assertSkipToMainContent(props); + await assertHeaderLogoLink(props); + await assertSignOutLink(props); + await assertFooterLinks(props); + await assertBackLinkBottomNotPresent(props); + await assertAndClickBackLinkTop({ + ...props, + expectedUrl: 'templates/choose-a-template-type', + }); + }); + + test.describe('single campaign client', () => { + test('no validation errors when form is submitted', async ({ page }) => { + const uploadPage = new TemplateMgmtUploadOtherLanguageLetterTemplatePage( + page + ); + + await uploadPage.loadPage(); + + await expect(uploadPage.campaignIdInput).toBeHidden(); + await expect(uploadPage.singleCampaignIdText).toHaveText( + userSingleCampaign.campaignIds?.[0] as string + ); + + await uploadPage.nameInput.fill('New Spanish Letter Template'); + + await uploadPage.languageInput.selectOption('es'); + + await uploadPage.fileInput.click(); + await uploadPage.fileInput.setInputFiles(docxFixtures.standard.filepath); + + await uploadPage.submitButton.click(); + + // TODO: CCM-14211 - test submit behaviour + + await expect(uploadPage.errorSummaryList).toBeHidden(); + }); + + test('displays error messages when blank form is submitted', async ({ + page, + }) => { + const uploadPage = new TemplateMgmtUploadOtherLanguageLetterTemplatePage( + page + ); + + await uploadPage.loadPage(); + + await expect(uploadPage.errorSummaryList).toBeHidden(); + + await uploadPage.submitButton.click(); + + await expect(uploadPage.errorSummaryList).toHaveText([ + 'Enter a template name', + 'Choose a language', + 'Choose a template file', + ]); + }); + }); + + test.describe('multi-campaign client', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await loginAsUser(userMultipleCampaigns, page); + }); + + test('no validation errors when form is submitted', async ({ page }) => { + const uploadPage = new TemplateMgmtUploadOtherLanguageLetterTemplatePage( + page + ); + + await uploadPage.loadPage(); + + await uploadPage.nameInput.fill('New Spanish Letter Template'); + + await expect(uploadPage.singleCampaignIdText).toBeHidden(); + await uploadPage.campaignIdInput.selectOption( + userMultipleCampaigns.campaignIds?.[0] as string + ); + + await uploadPage.languageInput.selectOption('es'); + + await uploadPage.fileInput.click(); + await uploadPage.fileInput.setInputFiles(docxFixtures.standard.filepath); + + await uploadPage.submitButton.click(); + + // TODO: CCM-14211 - test submit behaviour + + await expect(uploadPage.errorSummaryList).toBeHidden(); + }); + + test('displays error messages when blank form is submitted', async ({ + page, + }) => { + const uploadPage = new TemplateMgmtUploadOtherLanguageLetterTemplatePage( + page + ); + + await uploadPage.loadPage(); + + await expect(uploadPage.errorSummaryList).toBeHidden(); + + await uploadPage.submitButton.click(); + + await expect(uploadPage.errorSummaryList).toHaveText([ + 'Enter a template name', + 'Choose a campaign', + 'Choose a language', + 'Choose a template file', + ]); + }); + }); + + test.describe('client has no campaign id', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await loginAsUser(userNoCampaignId, page); + }); + + test('redirects to invalid config page', async ({ page }) => { + const uploadPage = new TemplateMgmtUploadOtherLanguageLetterTemplatePage( + page + ); + + await uploadPage.loadPage(); + + await expect(page).toHaveURL( + '/templates/upload-letter-template/client-id-and-campaign-id-required' + ); + }); + }); +}); diff --git a/tests/test-team/template-mgmt-component-tests/nhs-app/template-mgmt-create-nhs-app-template-page.component.spec.ts b/tests/test-team/template-mgmt-component-tests/nhs-app/template-mgmt-create-nhs-app-template-page.component.spec.ts index 6527253b5..bb1ed51df 100644 --- a/tests/test-team/template-mgmt-component-tests/nhs-app/template-mgmt-create-nhs-app-template-page.component.spec.ts +++ b/tests/test-team/template-mgmt-component-tests/nhs-app/template-mgmt-create-nhs-app-template-page.component.spec.ts @@ -224,15 +224,15 @@ test.describe('Create NHS App Template Page', () => { }); const detailsSections = [ - 'pds-personalisation-fields', - 'custom-personalisation-fields', - 'line-breaks-and-paragraphs', - 'headings', - 'bold-text', - 'bullet-points', - 'numbered-lists', - 'links-and-urls', - 'how-to-name-your-template', + 'PDS personalisation fields', + 'Custom personalisation fields', + 'Line breaks and paragraphs', + 'Headings', + 'Bold text', + 'Bullet points', + 'Numbered lists', + 'Links and urls', + 'Naming your templates', ]; for (const section of detailsSections) { @@ -243,18 +243,20 @@ test.describe('Create NHS App Template Page', () => { const createTemplatePage = new TemplateMgmtCreateNhsAppPage(page); await createTemplatePage.loadPage(); - await page.getByTestId(`${section}-summary`).click(); - await expect(page.getByTestId(`${section}-details`)).toHaveAttribute( - 'open', - '' - ); - await expect(page.getByTestId(`${section}-text`)).toBeVisible(); - - await page.getByTestId(`${section}-summary`).click(); - await expect(page.getByTestId(`${section}-details`)).not.toHaveAttribute( - 'open' - ); - await expect(page.getByTestId(`${section}-text`)).toBeHidden(); + const details = await page + .getByRole('group') + .filter({ hasText: section }); + + const summary = details.locator('summary'); + const text = details.locator('.nhsuk-details__text'); + + await summary.click(); + await expect(details).toHaveAttribute('open'); + await expect(text).toBeVisible(); + + await summary.click(); + await expect(details).not.toHaveAttribute('open'); + await expect(text).toBeHidden(); }); } diff --git a/tests/test-team/template-mgmt-component-tests/nhs-app/template-mgmt-edit-nhs-app-template-page.component.spec.ts b/tests/test-team/template-mgmt-component-tests/nhs-app/template-mgmt-edit-nhs-app-template-page.component.spec.ts index 9efcbec85..59f9c0010 100644 --- a/tests/test-team/template-mgmt-component-tests/nhs-app/template-mgmt-edit-nhs-app-template-page.component.spec.ts +++ b/tests/test-team/template-mgmt-component-tests/nhs-app/template-mgmt-edit-nhs-app-template-page.component.spec.ts @@ -287,15 +287,15 @@ test.describe('Edit NHS App Template Page', () => { }); const detailsSections = [ - 'pds-personalisation-fields', - 'custom-personalisation-fields', - 'line-breaks-and-paragraphs', - 'headings', - 'bold-text', - 'bullet-points', - 'numbered-lists', - 'links-and-urls', - 'how-to-name-your-template', + 'PDS personalisation fields', + 'Custom personalisation fields', + 'Line breaks and paragraphs', + 'Headings', + 'Bold text', + 'Bullet points', + 'Numbered lists', + 'Links and urls', + 'Naming your templates', ]; for (const section of detailsSections) { @@ -313,18 +313,20 @@ test.describe('Edit NHS App Template Page', () => { `${baseURL}/templates/edit-nhs-app-template/${templates.valid.id}` ); - await page.getByTestId(`${section}-summary`).click(); - await expect(page.getByTestId(`${section}-details`)).toHaveAttribute( - 'open', - '' - ); - await expect(page.getByTestId(`${section}-text`)).toBeVisible(); + const details = await page + .getByRole('group') + .filter({ hasText: section }); - await page.getByTestId(`${section}-summary`).click(); - await expect(page.getByTestId(`${section}-details`)).not.toHaveAttribute( - 'open' - ); - await expect(page.getByTestId(`${section}-text`)).toBeHidden(); + const summary = details.locator('summary'); + const text = details.locator('.nhsuk-details__text'); + + await summary.click(); + await expect(details).toHaveAttribute('open'); + await expect(text).toBeVisible(); + + await summary.click(); + await expect(details).not.toHaveAttribute('open'); + await expect(text).toBeHidden(); }); } diff --git a/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts b/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts index ac62c7d2c..591cc6fde 100644 --- a/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts +++ b/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts @@ -9,6 +9,7 @@ import { TemplateMgmtUploadLetterPage } from '../pages/letter/template-mgmt-uplo import { TemplateMgmtCreateNhsAppPage } from '../pages/nhs-app/template-mgmt-create-nhs-app-page'; import { TemplateMgmtCreateSmsPage } from '../pages/sms/template-mgmt-create-sms-page'; import { TemplateMgmtDeletePage } from '../pages/template-mgmt-delete-page'; +import { TemplateMgmtDeleteErrorPage } from 'pages/template-mgmt-delete-error-page'; import { TemplateMgmtEditEmailPage } from '../pages/email/template-mgmt-edit-email-page'; import { TemplateMgmtEditNhsAppPage } from '../pages/nhs-app/template-mgmt-edit-nhs-app-page'; import { TemplateMgmtEditSmsPage } from '../pages/sms/template-mgmt-edit-sms-page'; @@ -32,6 +33,9 @@ import { TemplateMgmtTemplateSubmittedLetterPage } from '../pages/letter/templat import { TemplateMgmtTemplateSubmittedNhsAppPage } from '../pages/nhs-app/template-mgmt-template-submitted-nhs-app-page'; import { TemplateMgmtTemplateSubmittedSmsPage } from '../pages/sms/template-mgmt-template-submitted-sms-page'; import { TemplateMgmtUploadLetterMissingCampaignClientIdPage } from '../pages/letter/template-mgmt-upload-letter-missing-campaign-client-id-page'; +import { TemplateMgmtUploadStandardEnglishLetterTemplatePage } from 'pages/letter/template-mgmt-upload-standard-english-letter-template-page'; +import { TemplateMgmtUploadLargePrintLetterTemplatePage } from 'pages/letter/template-mgmt-upload-large-print-letter-template-page'; +import { TemplateMgmtUploadOtherLanguageLetterTemplatePage } from 'pages/letter/template-mgmt-upload-other-language-letter-template-page'; import { RoutingChooseMessageOrderPage } from '../pages/routing/choose-message-order-page'; import { RoutingCreateMessagePlanPage } from '../pages/routing/create-message-plan-page'; import { RoutingMessagePlanCampaignIdRequiredPage } from '../pages/routing/campaign-id-required-page'; @@ -53,9 +57,6 @@ import { RoutingPreviewLargePrintLetterTemplatePage } from 'pages/routing/letter import { RoutingPreviewOtherLanguageLetterTemplatePage } from 'pages/routing/letter/preview-other-language-letter-template-page'; import { RoutingGetReadyToMovePage } from 'pages/routing/get-ready-to-move-page'; import { RoutingPreviewMessagePlanPage } from 'pages/routing/preview-message-plan-page'; -import { TemplateMgmtDeleteErrorPage } from 'pages/template-mgmt-delete-error-page'; -import { TemplateMgmtUploadStandardEnglishLetterTemplatePage } from 'pages/letter/template-mgmt-upload-standard-english-letter-template-page'; -import { TemplateMgmtUploadLargePrintLetterTemplatePage } from 'pages/letter/template-mgmt-upload-large-print-letter-template-page'; // Reset storage state for this file to avoid being authenticated test.use({ storageState: { cookies: [], origins: [] } }); @@ -115,6 +116,7 @@ const protectedPages = [ TemplateMgmtUploadLetterPage, TemplateMgmtUploadStandardEnglishLetterTemplatePage, TemplateMgmtUploadLargePrintLetterTemplatePage, + TemplateMgmtUploadOtherLanguageLetterTemplatePage, ]; const publicPages = [TemplateMgmtStartPage]; diff --git a/utils/utils/src/__tests__/enum.test.ts b/utils/utils/src/__tests__/enum.test.ts index 27a544eb8..da1d19831 100644 --- a/utils/utils/src/__tests__/enum.test.ts +++ b/utils/utils/src/__tests__/enum.test.ts @@ -6,6 +6,7 @@ import { TEMPLATE_STATUS_LIST, TemplateStatus, TemplateType, + LANGUAGE_LIST, } from 'nhs-notify-backend-client'; import { alphabeticalLanguageList, @@ -33,6 +34,7 @@ import { accessibleFormatDisplayMappings, type SupportedLetterType, createTemplateUrl, + isLanguage, } from '../enum'; describe('templateTypeDisplayMappings', () => { @@ -490,3 +492,13 @@ describe('accessibleFormatDisplayMappings', () => { expect(accessibleFormatDisplayMappings(format)).toEqual(display); }); }); + +describe('isLanguage', () => { + it.each(LANGUAGE_LIST)('returns true when language is %s', (language) => { + expect(isLanguage(language)).toBe(true); + }); + + it('returns false when language is not valid', () => { + expect(isLanguage('not a language')).toBe(false); + }); +}); diff --git a/utils/utils/src/enum.ts b/utils/utils/src/enum.ts index 1562125a0..f85ea87a1 100644 --- a/utils/utils/src/enum.ts +++ b/utils/utils/src/enum.ts @@ -55,6 +55,11 @@ const languageMap: Record = { ur: { name: 'Urdu', rtl: true }, zh: { name: 'Chinese', rtl: false }, }; + +export const isLanguage = (value: unknown): value is Language => { + return typeof value === 'string' && Object.keys(languageMap).includes(value); +}; + export const languageMapping = (language: Language) => languageMap[language].name; diff --git a/utils/utils/src/types.ts b/utils/utils/src/types.ts index 038c374e5..02b6c92f3 100644 --- a/utils/utils/src/types.ts +++ b/utils/utils/src/types.ts @@ -24,11 +24,13 @@ export type ErrorState = { fieldErrors?: Record; }; -export type FormState< - T extends Record = Record, -> = { +type FormStateFieldValue = string | undefined; + +export type FormStateFields = Record; + +export type FormState = { errorState?: ErrorState; - fields?: Partial; + fields?: FormStateFields; }; export type CreateUpdateNHSAppTemplate = Extract< From 5a3ab866a9301aaaf0b3ed5ff7f193dc381a1862 Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Tue, 3 Feb 2026 10:00:03 +0000 Subject: [PATCH 11/26] CCM-13489: lockfile --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index c707c46ef..07cdf8276 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27645,7 +27645,7 @@ }, "packages/event-schemas": { "name": "@nhsdigital/nhs-notify-event-schemas-template-management", - "version": "1.4.2", + "version": "1.4.3", "license": "MIT", "dependencies": { "zod": "^4.0.17" From 08961128aefa39e4a711a3b87fef41e1cba900cd Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Tue, 3 Feb 2026 11:50:37 +0000 Subject: [PATCH 12/26] CCM-13489: show proper language names, check feature flag --- .../page.test.tsx | 32 +- .../__snapshots__/page.test.tsx.snap | 280 +++++++++--------- .../page.test.tsx | 57 +++- .../page.test.tsx | 32 +- .../page.tsx | 5 + .../page.tsx | 5 + .../page.tsx | 5 + .../UploadDocxLetterTemplateForm/form.tsx | 3 +- .../test-team/helpers/client/client-helper.ts | 8 +- ...ge-print-letter-template.component.spec.ts | 62 ++-- ...language-letter-template.component.spec.ts | 66 +++-- ...-english-letter-template.component.spec.ts | 61 ++-- 12 files changed, 404 insertions(+), 212 deletions(-) diff --git a/frontend/src/__tests__/app/upload-large-print-letter-template/page.test.tsx b/frontend/src/__tests__/app/upload-large-print-letter-template/page.test.tsx index 3d13ae0de..87cc331d8 100644 --- a/frontend/src/__tests__/app/upload-large-print-letter-template/page.test.tsx +++ b/frontend/src/__tests__/app/upload-large-print-letter-template/page.test.tsx @@ -23,11 +23,33 @@ test('metadata', () => { }); }); +describe('client has letter authoring feature flag disabled', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: [], + features: { + letterAuthoring: false, + }, + }); + }); + + it('redirects to campaign id required page', async () => { + await Page(); + + expect(redirect).toHaveBeenCalledWith( + '/choose-a-template-type', + RedirectType.replace + ); + }); +}); + describe('client has no campaign ids', () => { beforeEach(() => { jest.mocked(fetchClient).mockResolvedValue({ campaignIds: [], - features: {}, + features: { + letterAuthoring: true, + }, }); }); @@ -45,7 +67,9 @@ describe('client has one campaign id', () => { beforeEach(() => { jest.mocked(fetchClient).mockResolvedValue({ campaignIds: ['Campaign 1'], - features: {}, + features: { + letterAuthoring: true, + }, }); }); @@ -115,7 +139,9 @@ describe('client has multiple campaign ids', () => { beforeEach(() => { jest.mocked(fetchClient).mockResolvedValue({ campaignIds: ['Campaign 1', 'Campaign 2'], - features: {}, + features: { + letterAuthoring: true, + }, }); }); diff --git a/frontend/src/__tests__/app/upload-other-language-letter-template/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/upload-other-language-letter-template/__snapshots__/page.test.tsx.snap index 714dfdb6a..a8a1550f2 100644 --- a/frontend/src/__tests__/app/upload-other-language-letter-template/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/upload-other-language-letter-template/__snapshots__/page.test.tsx.snap @@ -161,142 +161,142 @@ exports[`client has multiple campaign ids matches snapshot on initial render 1`]
            @@ -634,142 +634,142 @@ exports[`client has multiple campaign ids renders errors when blank form is subm
            @@ -1035,142 +1035,142 @@ exports[`client has one campaign id matches snapshot on initial render 1`] = `
            @@ -1485,142 +1485,142 @@ exports[`client has one campaign id renders errors when blank form is submitted
            @@ -1886,142 +1886,142 @@ exports[`client has one campaign id renders the rtl template link if an rtl lang diff --git a/frontend/src/__tests__/app/upload-other-language-letter-template/page.test.tsx b/frontend/src/__tests__/app/upload-other-language-letter-template/page.test.tsx index 05ef4a6bf..033a99f74 100644 --- a/frontend/src/__tests__/app/upload-other-language-letter-template/page.test.tsx +++ b/frontend/src/__tests__/app/upload-other-language-letter-template/page.test.tsx @@ -25,11 +25,33 @@ test('metadata', () => { }); }); +describe('client has letter authoring feature flag disabled', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: [], + features: { + letterAuthoring: false, + }, + }); + }); + + it('redirects to campaign id required page', async () => { + await Page(); + + expect(redirect).toHaveBeenCalledWith( + '/choose-a-template-type', + RedirectType.replace + ); + }); +}); + describe('client has no campaign ids', () => { beforeEach(() => { jest.mocked(fetchClient).mockResolvedValue({ campaignIds: [], - features: {}, + features: { + letterAuthoring: true, + }, }); }); @@ -47,7 +69,9 @@ describe('client has one campaign id', () => { beforeEach(() => { jest.mocked(fetchClient).mockResolvedValue({ campaignIds: ['Campaign 1'], - features: {}, + features: { + letterAuthoring: true, + }, }); }); @@ -63,7 +87,10 @@ describe('client has one campaign id', () => { await user.click(screen.getByLabelText('Template name')); await user.keyboard('A new template'); - await user.selectOptions(screen.getByLabelText('Template language'), 'sk'); + await user.selectOptions( + screen.getByLabelText('Template language'), + 'Slovak' + ); const file = new File(['hello'], 'template.docx', { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', @@ -128,7 +155,10 @@ describe('client has one campaign id', () => { }) ).not.toBeInTheDocument(); - await user.selectOptions(screen.getByLabelText('Template language'), 'sk'); + await user.selectOptions( + screen.getByLabelText('Template language'), + 'Slovak' + ); expect( page.queryByRole('link', { @@ -136,7 +166,10 @@ describe('client has one campaign id', () => { }) ).not.toBeInTheDocument(); - await user.selectOptions(screen.getByLabelText('Template language'), 'ar'); + await user.selectOptions( + screen.getByLabelText('Template language'), + 'Arabic' + ); expect( page.getByRole('link', { @@ -146,7 +179,10 @@ describe('client has one campaign id', () => { expect(page.asFragment()).toMatchSnapshot(); - await user.selectOptions(screen.getByLabelText('Template language'), 'sk'); + await user.selectOptions( + screen.getByLabelText('Template language'), + 'Slovak' + ); expect( page.queryByRole('link', { @@ -160,7 +196,9 @@ describe('client has multiple campaign ids', () => { beforeEach(() => { jest.mocked(fetchClient).mockResolvedValue({ campaignIds: ['Campaign 1', 'Campaign 2'], - features: {}, + features: { + letterAuthoring: true, + }, }); }); @@ -178,7 +216,10 @@ describe('client has multiple campaign ids', () => { await user.selectOptions(screen.getByLabelText('Campaign'), 'Campaign 2'); - await user.selectOptions(screen.getByLabelText('Template language'), 'sk'); + await user.selectOptions( + screen.getByLabelText('Template language'), + 'Slovak' + ); const file = new File(['hello'], 'template.docx', { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', diff --git a/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx b/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx index 7714f8a13..ddd79ded0 100644 --- a/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx +++ b/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx @@ -25,11 +25,33 @@ test('metadata', () => { }); }); +describe('client has letter authoring feature flag disabled', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: [], + features: { + letterAuthoring: false, + }, + }); + }); + + it('redirects to campaign id required page', async () => { + await Page(); + + expect(redirect).toHaveBeenCalledWith( + '/choose-a-template-type', + RedirectType.replace + ); + }); +}); + describe('client has no campaign ids', () => { beforeEach(() => { jest.mocked(fetchClient).mockResolvedValue({ campaignIds: [], - features: {}, + features: { + letterAuthoring: true, + }, }); }); @@ -47,7 +69,9 @@ describe('client has one campaign id', () => { beforeEach(() => { jest.mocked(fetchClient).mockResolvedValue({ campaignIds: ['Campaign 1'], - features: {}, + features: { + letterAuthoring: true, + }, }); }); @@ -117,7 +141,9 @@ describe('client has multiple campaign ids', () => { beforeEach(() => { jest.mocked(fetchClient).mockResolvedValue({ campaignIds: ['Campaign 1', 'Campaign 2'], - features: {}, + features: { + letterAuthoring: true, + }, }); }); diff --git a/frontend/src/app/upload-large-print-letter-template/page.tsx b/frontend/src/app/upload-large-print-letter-template/page.tsx index f008016d8..ec185aa37 100644 --- a/frontend/src/app/upload-large-print-letter-template/page.tsx +++ b/frontend/src/app/upload-large-print-letter-template/page.tsx @@ -18,6 +18,11 @@ export const metadata: Metadata = { export default async function UploadLargePrintLetterTemplatePage() { const client = await fetchClient(); + + if (!client?.features.letterAuthoring) { + return redirect('/choose-a-template-type', RedirectType.replace); + } + const campaignIds = getCampaignIds(client); if (campaignIds.length === 0) { diff --git a/frontend/src/app/upload-other-language-letter-template/page.tsx b/frontend/src/app/upload-other-language-letter-template/page.tsx index 8552c3a92..e9b41b973 100644 --- a/frontend/src/app/upload-other-language-letter-template/page.tsx +++ b/frontend/src/app/upload-other-language-letter-template/page.tsx @@ -18,6 +18,11 @@ export const metadata: Metadata = { export default async function UploadOtherLanguageLetterTemplatePage() { const client = await fetchClient(); + + if (!client?.features.letterAuthoring) { + return redirect('/choose-a-template-type', RedirectType.replace); + } + const campaignIds = getCampaignIds(client); if (campaignIds.length === 0) { diff --git a/frontend/src/app/upload-standard-english-letter-template/page.tsx b/frontend/src/app/upload-standard-english-letter-template/page.tsx index bd79f3004..425acc552 100644 --- a/frontend/src/app/upload-standard-english-letter-template/page.tsx +++ b/frontend/src/app/upload-standard-english-letter-template/page.tsx @@ -18,6 +18,11 @@ export const metadata: Metadata = { export default async function UploadStandardLetterTemplatePage() { const client = await fetchClient(); + + if (!client?.features.letterAuthoring) { + return redirect('/choose-a-template-type', RedirectType.replace); + } + const campaignIds = getCampaignIds(client); if (campaignIds.length === 0) { diff --git a/frontend/src/components/forms/UploadDocxLetterTemplateForm/form.tsx b/frontend/src/components/forms/UploadDocxLetterTemplateForm/form.tsx index 18294bbb1..6eba3d3ae 100644 --- a/frontend/src/components/forms/UploadDocxLetterTemplateForm/form.tsx +++ b/frontend/src/components/forms/UploadDocxLetterTemplateForm/form.tsx @@ -14,6 +14,7 @@ import { LANGUAGE_LIST } from 'nhs-notify-backend-client'; import { isLanguage, isRightToLeft, + languageMapping, } from 'nhs-notify-web-template-management-utils'; import { FileUploadInput } from '@atoms/FileUpload/FileUpload'; import { NHSNotifyFormGroup } from '@atoms/NHSNotifyFormGroup/NHSNotifyFormGroup'; @@ -164,7 +165,7 @@ export function LanguageField() { ))} diff --git a/tests/test-team/helpers/client/client-helper.ts b/tests/test-team/helpers/client/client-helper.ts index 3cc8f5999..f0676ab54 100644 --- a/tests/test-team/helpers/client/client-helper.ts +++ b/tests/test-team/helpers/client/client-helper.ts @@ -34,7 +34,7 @@ export const testClients: TestClients = { }, }, /** - * Client2 has proofing and routing disabled + * Client2 has all feature flags disabled */ Client2: { campaignIds: ['Campaign2'], @@ -56,8 +56,8 @@ export const testClients: TestClients = { name: 'NHS Test Client 4', features: { proofing: true, - routing: false, - letterAuthoring: false, + routing: true, + letterAuthoring: true, }, }, /** @@ -90,7 +90,7 @@ export const testClients: TestClients = { features: { proofing: true, routing: true, - letterAuthoring: false, + letterAuthoring: true, }, }, diff --git a/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-large-print-letter-template.component.spec.ts b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-large-print-letter-template.component.spec.ts index 7ea5e38fa..1d2a9bb66 100644 --- a/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-large-print-letter-template.component.spec.ts +++ b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-large-print-letter-template.component.spec.ts @@ -19,36 +19,46 @@ import { TemplateMgmtUploadLargePrintLetterTemplatePage } from 'pages/letter/tem let userNoCampaignId: TestUser; let userSingleCampaign: TestUser; let userMultipleCampaigns: TestUser; +let userAuthoringDisabled: TestUser; test.beforeAll(async () => { const authHelper = createAuthHelper(); - userSingleCampaign = await authHelper.getTestUser(testUsers.User1.userId); + userSingleCampaign = await authHelper.getTestUser( + testUsers.UserLetterAuthoringEnabled.userId + ); userNoCampaignId = await authHelper.getTestUser(testUsers.User6.userId); userMultipleCampaigns = await authHelper.getTestUser( testUsers.UserWithMultipleCampaigns.userId ); + userAuthoringDisabled = await authHelper.getTestUser(testUsers.User3.userId); }); test.describe('Upload Large Print Letter Template Page', () => { - test('common page tests', async ({ page, baseURL }) => { - const props = { - page: new TemplateMgmtUploadLargePrintLetterTemplatePage(page), - baseURL, - }; - - await assertSkipToMainContent(props); - await assertHeaderLogoLink(props); - await assertSignOutLink(props); - await assertFooterLinks(props); - await assertBackLinkBottomNotPresent(props); - await assertAndClickBackLinkTop({ - ...props, - expectedUrl: 'templates/choose-a-template-type', + test.describe('single campaign client', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await loginAsUser(userSingleCampaign, page); + }); + + test('common page tests', async ({ page, baseURL }) => { + const props = { + page: new TemplateMgmtUploadLargePrintLetterTemplatePage(page), + baseURL, + }; + + await assertSkipToMainContent(props); + await assertHeaderLogoLink(props); + await assertSignOutLink(props); + await assertFooterLinks(props); + await assertBackLinkBottomNotPresent(props); + await assertAndClickBackLinkTop({ + ...props, + expectedUrl: 'templates/choose-a-template-type', + }); }); - }); - test.describe('single campaign client', () => { test('no validation errors when form is submitted', async ({ page }) => { const uploadPage = new TemplateMgmtUploadLargePrintLetterTemplatePage( page @@ -168,4 +178,22 @@ test.describe('Upload Large Print Letter Template Page', () => { ); }); }); + + test.describe('client has letter authoring flag disabled', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await loginAsUser(userAuthoringDisabled, page); + }); + + test('redirects to choose template type page', async ({ page }) => { + const uploadPage = new TemplateMgmtUploadLargePrintLetterTemplatePage( + page + ); + + await uploadPage.loadPage(); + + await expect(page).toHaveURL('/templates/choose-a-template-type'); + }); + }); }); diff --git a/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-other-language-letter-template.component.spec.ts b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-other-language-letter-template.component.spec.ts index 5790f46a3..7c91a6a1f 100644 --- a/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-other-language-letter-template.component.spec.ts +++ b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-other-language-letter-template.component.spec.ts @@ -19,36 +19,46 @@ import { TemplateMgmtUploadOtherLanguageLetterTemplatePage } from 'pages/letter/ let userNoCampaignId: TestUser; let userSingleCampaign: TestUser; let userMultipleCampaigns: TestUser; +let userAuthoringDisabled: TestUser; test.beforeAll(async () => { const authHelper = createAuthHelper(); - userSingleCampaign = await authHelper.getTestUser(testUsers.User1.userId); + userSingleCampaign = await authHelper.getTestUser( + testUsers.UserLetterAuthoringEnabled.userId + ); userNoCampaignId = await authHelper.getTestUser(testUsers.User6.userId); userMultipleCampaigns = await authHelper.getTestUser( testUsers.UserWithMultipleCampaigns.userId ); + userAuthoringDisabled = await authHelper.getTestUser(testUsers.User3.userId); }); test.describe('Upload Other Language Letter Template Page', () => { - test('common page tests', async ({ page, baseURL }) => { - const props = { - page: new TemplateMgmtUploadOtherLanguageLetterTemplatePage(page), - baseURL, - }; - - await assertSkipToMainContent(props); - await assertHeaderLogoLink(props); - await assertSignOutLink(props); - await assertFooterLinks(props); - await assertBackLinkBottomNotPresent(props); - await assertAndClickBackLinkTop({ - ...props, - expectedUrl: 'templates/choose-a-template-type', + test.describe('single campaign client', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await loginAsUser(userSingleCampaign, page); + }); + + test('common page tests', async ({ page, baseURL }) => { + const props = { + page: new TemplateMgmtUploadOtherLanguageLetterTemplatePage(page), + baseURL, + }; + + await assertSkipToMainContent(props); + await assertHeaderLogoLink(props); + await assertSignOutLink(props); + await assertFooterLinks(props); + await assertBackLinkBottomNotPresent(props); + await assertAndClickBackLinkTop({ + ...props, + expectedUrl: 'templates/choose-a-template-type', + }); }); - }); - test.describe('single campaign client', () => { test('no validation errors when form is submitted', async ({ page }) => { const uploadPage = new TemplateMgmtUploadOtherLanguageLetterTemplatePage( page @@ -63,7 +73,7 @@ test.describe('Upload Other Language Letter Template Page', () => { await uploadPage.nameInput.fill('New Spanish Letter Template'); - await uploadPage.languageInput.selectOption('es'); + await uploadPage.languageInput.selectOption('Spanish'); await uploadPage.fileInput.click(); await uploadPage.fileInput.setInputFiles(docxFixtures.standard.filepath); @@ -117,7 +127,7 @@ test.describe('Upload Other Language Letter Template Page', () => { userMultipleCampaigns.campaignIds?.[0] as string ); - await uploadPage.languageInput.selectOption('es'); + await uploadPage.languageInput.selectOption('Spanish'); await uploadPage.fileInput.click(); await uploadPage.fileInput.setInputFiles(docxFixtures.standard.filepath); @@ -170,4 +180,22 @@ test.describe('Upload Other Language Letter Template Page', () => { ); }); }); + + test.describe('client has letter authoring flag disabled', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await loginAsUser(userAuthoringDisabled, page); + }); + + test('redirects to choose template type page', async ({ page }) => { + const uploadPage = new TemplateMgmtUploadOtherLanguageLetterTemplatePage( + page + ); + + await uploadPage.loadPage(); + + await expect(page).toHaveURL('/templates/choose-a-template-type'); + }); + }); }); diff --git a/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-standard-english-letter-template.component.spec.ts b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-standard-english-letter-template.component.spec.ts index f63c651f5..b65212433 100644 --- a/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-standard-english-letter-template.component.spec.ts +++ b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-standard-english-letter-template.component.spec.ts @@ -19,36 +19,46 @@ import { TemplateMgmtUploadStandardEnglishLetterTemplatePage } from 'pages/lette let userNoCampaignId: TestUser; let userSingleCampaign: TestUser; let userMultipleCampaigns: TestUser; +let userAuthoringDisabled: TestUser; test.beforeAll(async () => { const authHelper = createAuthHelper(); - userSingleCampaign = await authHelper.getTestUser(testUsers.User1.userId); + userSingleCampaign = await authHelper.getTestUser( + testUsers.UserLetterAuthoringEnabled.userId + ); userNoCampaignId = await authHelper.getTestUser(testUsers.User6.userId); userMultipleCampaigns = await authHelper.getTestUser( testUsers.UserWithMultipleCampaigns.userId ); + userAuthoringDisabled = await authHelper.getTestUser(testUsers.User3.userId); }); test.describe('Upload Standard English Letter Template Page', () => { - test('common page tests', async ({ page, baseURL }) => { - const props = { - page: new TemplateMgmtUploadStandardEnglishLetterTemplatePage(page), - baseURL, - }; - - await assertSkipToMainContent(props); - await assertHeaderLogoLink(props); - await assertSignOutLink(props); - await assertFooterLinks(props); - await assertBackLinkBottomNotPresent(props); - await assertAndClickBackLinkTop({ - ...props, - expectedUrl: 'templates/choose-a-template-type', + test.describe('single campaign client', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await loginAsUser(userSingleCampaign, page); + }); + + test('common page tests', async ({ page, baseURL }) => { + const props = { + page: new TemplateMgmtUploadStandardEnglishLetterTemplatePage(page), + baseURL, + }; + + await assertSkipToMainContent(props); + await assertHeaderLogoLink(props); + await assertSignOutLink(props); + await assertFooterLinks(props); + await assertBackLinkBottomNotPresent(props); + await assertAndClickBackLinkTop({ + ...props, + expectedUrl: 'templates/choose-a-template-type', + }); }); - }); - test.describe('single campaign client', () => { test('no validation errors when form is submitted', async ({ page }) => { const uploadPage = new TemplateMgmtUploadStandardEnglishLetterTemplatePage(page); @@ -159,4 +169,21 @@ test.describe('Upload Standard English Letter Template Page', () => { ); }); }); + + test.describe('client has letter authoring flag disabled', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await loginAsUser(userAuthoringDisabled, page); + }); + + test('redirects to choose template type page', async ({ page }) => { + const uploadPage = + new TemplateMgmtUploadStandardEnglishLetterTemplatePage(page); + + await uploadPage.loadPage(); + + await expect(page).toHaveURL('/templates/choose-a-template-type'); + }); + }); }); From e82a23b9c9e3ee584aa7999010ae12cedf19b71d Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Tue, 3 Feb 2026 16:31:22 +0000 Subject: [PATCH 13/26] CCM-13489: fix flaky test --- .../app/upload-large-print-letter-template/page.test.tsx | 4 ++-- .../app/upload-other-language-letter-template/page.test.tsx | 4 ++-- .../app/upload-standard-english-letter-template/page.test.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/__tests__/app/upload-large-print-letter-template/page.test.tsx b/frontend/src/__tests__/app/upload-large-print-letter-template/page.test.tsx index 87cc331d8..bf53e42c3 100644 --- a/frontend/src/__tests__/app/upload-large-print-letter-template/page.test.tsx +++ b/frontend/src/__tests__/app/upload-large-print-letter-template/page.test.tsx @@ -128,7 +128,7 @@ describe('client has one campaign id', () => { ); expect( - screen.queryByRole('alert', { name: 'There is a problem' }) + await screen.findByRole('alert', { name: 'There is a problem' }) ).toBeInTheDocument(); expect(page.asFragment()).toMatchSnapshot(); @@ -205,7 +205,7 @@ describe('client has multiple campaign ids', () => { expect(uploadLargePrintLetterTemplate).toHaveBeenCalledTimes(1); expect( - screen.queryByRole('alert', { name: 'There is a problem' }) + await screen.findByRole('alert', { name: 'There is a problem' }) ).toBeInTheDocument(); expect(page.asFragment()).toMatchSnapshot(); diff --git a/frontend/src/__tests__/app/upload-other-language-letter-template/page.test.tsx b/frontend/src/__tests__/app/upload-other-language-letter-template/page.test.tsx index 033a99f74..3ce9ffcc1 100644 --- a/frontend/src/__tests__/app/upload-other-language-letter-template/page.test.tsx +++ b/frontend/src/__tests__/app/upload-other-language-letter-template/page.test.tsx @@ -138,7 +138,7 @@ describe('client has one campaign id', () => { ); expect( - screen.queryByRole('alert', { name: 'There is a problem' }) + await screen.findByRole('alert', { name: 'There is a problem' }) ).toBeInTheDocument(); expect(page.asFragment()).toMatchSnapshot(); @@ -270,7 +270,7 @@ describe('client has multiple campaign ids', () => { expect(uploadOtherLanguageLetterTemplate).toHaveBeenCalledTimes(1); expect( - screen.queryByRole('alert', { name: 'There is a problem' }) + await screen.findByRole('alert', { name: 'There is a problem' }) ).toBeInTheDocument(); expect(page.asFragment()).toMatchSnapshot(); diff --git a/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx b/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx index ddd79ded0..d3d2772a3 100644 --- a/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx +++ b/frontend/src/__tests__/app/upload-standard-english-letter-template/page.test.tsx @@ -130,7 +130,7 @@ describe('client has one campaign id', () => { ); expect( - screen.queryByRole('alert', { name: 'There is a problem' }) + await screen.findByRole('alert', { name: 'There is a problem' }) ).toBeInTheDocument(); expect(page.asFragment()).toMatchSnapshot(); @@ -207,7 +207,7 @@ describe('client has multiple campaign ids', () => { expect(uploadStandardLetterTemplate).toHaveBeenCalledTimes(1); expect( - screen.queryByRole('alert', { name: 'There is a problem' }) + await screen.findByRole('alert', { name: 'There is a problem' }) ).toBeInTheDocument(); expect(page.asFragment()).toMatchSnapshot(); From d205576f58c5cfc0441544232bcaa08facd03e06 Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Tue, 3 Feb 2026 16:48:52 +0000 Subject: [PATCH 14/26] CCM-13489: self review --- .../forms/MessagePlan/MessagePlan.test.tsx | 160 ++++++++---------- .../__snapshots__/MessagePlan.test.tsx.snap | 32 ---- .../providers/form-provider.test.tsx | 2 +- .../create-message-plan/page.tsx | 2 +- .../[routingConfigId]/page.tsx | 2 +- .../components/providers/form-provider.tsx | 8 +- frontend/src/content/content.ts | 2 +- .../letters/docx/large-print-template.docx | Bin 84314 -> 0 bytes tests/test-team/fixtures/letters/index.ts | 1 - ...ge-print-letter-template.component.spec.ts | 8 +- 10 files changed, 81 insertions(+), 136 deletions(-) delete mode 100644 tests/test-team/fixtures/letters/docx/large-print-template.docx diff --git a/frontend/src/__tests__/components/forms/MessagePlan/MessagePlan.test.tsx b/frontend/src/__tests__/components/forms/MessagePlan/MessagePlan.test.tsx index 54206204d..3183e59ac 100644 --- a/frontend/src/__tests__/components/forms/MessagePlan/MessagePlan.test.tsx +++ b/frontend/src/__tests__/components/forms/MessagePlan/MessagePlan.test.tsx @@ -1,109 +1,101 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MessagePlanForm } from '@forms/MessagePlan/MessagePlan'; -import { NHSNotifyFormProvider } from '@providers/form-provider'; +import { useNHSNotifyForm } from '@providers/form-provider'; import { verifyFormCsrfToken } from '@utils/csrf-utils'; +jest.mock('@providers/form-provider'); +const mockAction = jest.fn(); +jest.mocked(useNHSNotifyForm).mockReturnValue([{}, mockAction, false]); + jest.mock('@utils/csrf-utils'); +jest.mocked(verifyFormCsrfToken).mockResolvedValue(true); beforeEach(() => { - jest.resetAllMocks(); - jest.mocked(verifyFormCsrfToken).mockResolvedValue(true); + jest.clearAllMocks(); }); test('renders form with single campaign id displayed', () => { const container = render( - - - + ); expect(container.asFragment()).toMatchSnapshot(); }); test('renders form with select for multiple campaign ids', () => { const container = render( - - - + ); expect(container.asFragment()).toMatchSnapshot(); }); test('renders form with children', () => { const container = render( - - - - - + + + ); expect(container.asFragment()).toMatchSnapshot(); }); test('renders errors', async () => { - const user = userEvent.setup(); - - const action = jest.fn().mockResolvedValueOnce({ - errorState: { - fieldErrors: { - name: ['Name error'], - campaignId: ['CampaignId error'], + jest.mocked(useNHSNotifyForm).mockReturnValueOnce([ + { + errorState: { + fieldErrors: { + name: ['Name error'], + campaignId: ['CampaignId error'], + }, }, }, - }); + jest.fn(), + false, + ]); const container = render( - - - + ); - await user.click(screen.getByRole('button', { name: 'Save and continue' })); - expect(container.asFragment()).toMatchSnapshot(); }); test('invokes the action with the form data when the form is submitted - single campaign id', async () => { const user = userEvent.setup(); - const action = jest.fn().mockResolvedValue({}); - render( - - - - - + + + ); await user.click(screen.getByTestId('name-field')); @@ -112,14 +104,11 @@ test('invokes the action with the form data when the form is submitted - single await user.click(screen.getByTestId('submit-button')); - expect(action).toHaveBeenCalledTimes(1); + expect(mockAction).toHaveBeenCalledTimes(1); - expect(action).toHaveBeenLastCalledWith( - expect.any(Object), - expect.any(FormData) - ); + expect(mockAction).toHaveBeenLastCalledWith(expect.any(FormData)); - const formData = action.mock.lastCall?.at(1) as FormData; + const formData = mockAction.mock.lastCall?.at(0) as FormData; expect(Object.fromEntries(formData.entries())).toMatchObject({ campaignId: 'campaign-id', @@ -131,20 +120,16 @@ test('invokes the action with the form data when the form is submitted - single test('invokes the action with the form data when the form is submitted - multiple campaign id', async () => { const user = userEvent.setup(); - const action = jest.fn().mockResolvedValue({}); - render( - - - - - + + + ); await user.click(screen.getByTestId('name-field')); @@ -158,14 +143,11 @@ test('invokes the action with the form data when the form is submitted - multipl await user.click(screen.getByTestId('submit-button')); - expect(action).toHaveBeenCalledTimes(1); + expect(mockAction).toHaveBeenCalledTimes(1); - expect(action).toHaveBeenLastCalledWith( - expect.any(Object), - expect.any(FormData) - ); + expect(mockAction).toHaveBeenLastCalledWith(expect.any(FormData)); - const formData = action.mock.lastCall?.at(1) as FormData; + const formData = mockAction.mock.lastCall?.at(0) as FormData; expect(Object.fromEntries(formData.entries())).toMatchObject({ campaignId: 'campaign-id-2', diff --git a/frontend/src/__tests__/components/forms/MessagePlan/__snapshots__/MessagePlan.test.tsx.snap b/frontend/src/__tests__/components/forms/MessagePlan/__snapshots__/MessagePlan.test.tsx.snap index 063744311..aa719cbc6 100644 --- a/frontend/src/__tests__/components/forms/MessagePlan/__snapshots__/MessagePlan.test.tsx.snap +++ b/frontend/src/__tests__/components/forms/MessagePlan/__snapshots__/MessagePlan.test.tsx.snap @@ -2,38 +2,6 @@ exports[`renders errors 1`] = ` -
            diff --git a/frontend/src/__tests__/components/providers/form-provider.test.tsx b/frontend/src/__tests__/components/providers/form-provider.test.tsx index 3c99b55b3..6ab5ba43d 100644 --- a/frontend/src/__tests__/components/providers/form-provider.test.tsx +++ b/frontend/src/__tests__/components/providers/form-provider.test.tsx @@ -12,8 +12,8 @@ import type { } from 'nhs-notify-web-template-management-utils'; import { NhsNotifyErrorSummary } from '@molecules/NhsNotifyErrorSummary/NhsNotifyErrorSummary'; import { - useNHSNotifyForm, NHSNotifyFormProvider, + useNHSNotifyForm, } from '@providers/form-provider'; import { startTransition } from 'react'; diff --git a/frontend/src/app/message-plans/create-message-plan/page.tsx b/frontend/src/app/message-plans/create-message-plan/page.tsx index 62d3895f7..1d10be201 100644 --- a/frontend/src/app/message-plans/create-message-plan/page.tsx +++ b/frontend/src/app/message-plans/create-message-plan/page.tsx @@ -5,9 +5,9 @@ import { MESSAGE_ORDER_OPTIONS_LIST } from 'nhs-notify-web-template-management-u import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import content from '@content/content'; import { MessagePlanForm } from '@forms/MessagePlan/MessagePlan'; -import { NHSNotifyFormProvider } from '@providers/form-provider'; import { getCampaignIds } from '@utils/client-config'; import { fetchClient } from '@utils/server-features'; +import { NHSNotifyFormProvider } from '@providers/form-provider'; import { createMessagePlanServerAction } from './server-action'; const pageContent = content.pages.createMessagePlan; diff --git a/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx b/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx index ccc20de71..1c2785e02 100644 --- a/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx +++ b/frontend/src/app/message-plans/edit-message-plan-settings/[routingConfigId]/page.tsx @@ -4,10 +4,10 @@ import type { MessagePlanPageProps } from 'nhs-notify-web-template-management-ut import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; import content from '@content/content'; import { MessagePlanForm } from '@forms/MessagePlan/MessagePlan'; -import { NHSNotifyFormProvider } from '@providers/form-provider'; import { getCampaignIds } from '@utils/client-config'; import { getRoutingConfig } from '@utils/message-plans'; import { fetchClient } from '@utils/server-features'; +import { NHSNotifyFormProvider } from '@providers/form-provider'; import { editMessagePlanSettingsServerAction } from './server-action'; const pageContent = content.pages.editMessagePlanSettings; diff --git a/frontend/src/components/providers/form-provider.tsx b/frontend/src/components/providers/form-provider.tsx index 11b1a9f66..517e09289 100644 --- a/frontend/src/components/providers/form-provider.tsx +++ b/frontend/src/components/providers/form-provider.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { +import { type PropsWithChildren, createContext, useActionState, @@ -17,11 +17,11 @@ const FormContext = createContext(null); export function useNHSNotifyForm() { const context = useContext(FormContext); - if (!context) { + if (!context) throw new Error( 'useNHSNotifyForm must be used within NHSNotifyFormProvider' ); - } + return context; } @@ -43,8 +43,8 @@ export function NHSNotifyFormProvider({ return ( {children} diff --git a/frontend/src/content/content.ts b/frontend/src/content/content.ts index 9eeefa7b9..2a6750676 100644 --- a/frontend/src/content/content.ts +++ b/frontend/src/content/content.ts @@ -1,8 +1,8 @@ import type { LetterType, + RoutingConfigStatusActive, TemplateStatus, TemplateType, - RoutingConfigStatusActive, } from 'nhs-notify-backend-client'; import type { ContentBlock } from '@molecules/ContentRenderer/ContentRenderer'; diff --git a/tests/test-team/fixtures/letters/docx/large-print-template.docx b/tests/test-team/fixtures/letters/docx/large-print-template.docx deleted file mode 100644 index 0cd655b08e21010380fd05f75a2e7a868fa32b17..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 84314 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

            QU9wjrcZk)ru>9 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 { await uploadPage.nameInput.fill('New Large Print Letter Template'); await uploadPage.fileInput.click(); - await uploadPage.fileInput.setInputFiles( - docxFixtures.largePrint.filepath - ); + await uploadPage.fileInput.setInputFiles(docxFixtures.standard.filepath); await uploadPage.submitButton.click(); @@ -127,9 +125,7 @@ test.describe('Upload Large Print Letter Template Page', () => { ); await uploadPage.fileInput.click(); - await uploadPage.fileInput.setInputFiles( - docxFixtures.largePrint.filepath - ); + await uploadPage.fileInput.setInputFiles(docxFixtures.standard.filepath); await uploadPage.submitButton.click(); From d116986d5ad3f118c3edd4121ab6d0029cf9ecb3 Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Wed, 4 Feb 2026 09:19:05 +0000 Subject: [PATCH 15/26] CCM-13489: debug --- .../upload-other-language-letter-template/server-action.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/app/upload-other-language-letter-template/server-action.ts b/frontend/src/app/upload-other-language-letter-template/server-action.ts index 87dcb99c4..8259c2549 100644 --- a/frontend/src/app/upload-other-language-letter-template/server-action.ts +++ b/frontend/src/app/upload-other-language-letter-template/server-action.ts @@ -22,11 +22,17 @@ export async function uploadOtherLanguageLetterTemplate( _: FormState, form: FormData ): Promise { + const file = form.get('file') as File; + + console.log(file.name, file.type); + const validation = $FormSchema.safeParse(Object.fromEntries(form.entries())); const fields = formDataToFormStateFields(form); if (validation.error) { + console.log(validation.error); + return { errorState: z.flattenError(validation.error), fields, From 5b459bab2cc1057873cbdf515818d075dc1a62b4 Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Wed, 4 Feb 2026 10:08:41 +0000 Subject: [PATCH 16/26] CCM-13489: pr feedback, debug --- .../__snapshots__/page.test.tsx.snap | 20 ++++++++++++++----- .../server-action.ts | 10 +++++++--- .../UploadDocxLetterTemplateForm/form.tsx | 2 +- frontend/src/content/content.ts | 1 + frontend/src/utils/form-data-to-form-state.ts | 2 -- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/frontend/src/__tests__/app/upload-other-language-letter-template/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/upload-other-language-letter-template/__snapshots__/page.test.tsx.snap index a8a1550f2..491f06f6d 100644 --- a/frontend/src/__tests__/app/upload-other-language-letter-template/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/upload-other-language-letter-template/__snapshots__/page.test.tsx.snap @@ -157,7 +157,9 @@ exports[`client has multiple campaign ids matches snapshot on initial render 1`] id="language" name="language" > -

            +
            +
            +

            + Upload a British Sign Language letter template +

            +
            +
            +
            +
            + + + +
            + +
            + This will not be visible to recipients. +
            +
            + + + Naming your templates + + +
            +

            + You should name your templates in a way that works best for your service or organisation. +

            +

            + Common template names include the: +

            +
              +
            • + subject or reason for the message +
            • +
            • + intended audience for the template +
            • +
            • + version number of the template +
            • +
            +

            + For example, 'Covid19 2025 - over 65s - version 3' +

            +
            +
            + +
            +
            + +
            + Choose which campaign this letter is for +
            + +
            +
            + +
            + Only upload your final letter template file. +
            + Make sure you use one of our blank template files to create the letter. +
            + +
            + + +
            +
            +

            + How to create a British Sign Language letter template +

            +
              +
            1. + Download the relevant + + blank letter template file (opens in a new tab) + + . +
            2. +
            3. + Add + + formatting (opens in a new tab) + + . +
            4. +
            5. + Add any + + personalisation (opens in a new tab) + + . +
            6. +
            7. + Save your Microsoft Word file and upload it to this page. +
            8. +
            +
            +
            +
            +
            +`; + +exports[`client has multiple campaign ids renders errors when blank form is submitted and error state is returned 1`] = ` + + + Back to choose a template type + +
            + +
            +
            +

            + Upload a British Sign Language letter template +

            +
            +
            +
            +
            +
            + + +
            + +
            + This will not be visible to recipients. +
            +
            + + + Naming your templates + + +
            +

            + You should name your templates in a way that works best for your service or organisation. +

            +

            + Common template names include the: +

            +
              +
            • + subject or reason for the message +
            • +
            • + intended audience for the template +
            • +
            • + version number of the template +
            • +
            +

            + For example, 'Covid19 2025 - over 65s - version 3' +

            +
            +
            + + + Error: + + Enter a template name + + +
            +
            + +
            + Choose which campaign this letter is for +
            + + + Error: + + Choose a campaign + + +
            +
            + +
            + Only upload your final letter template file. +
            + Make sure you use one of our blank template files to create the letter. +
            + + + Error: + + Choose a template file + + +
            + +
            +
            +
            +

            + How to create a British Sign Language letter template +

            +
              +
            1. + Download the relevant + + blank letter template file (opens in a new tab) + + . +
            2. +
            3. + Add + + formatting (opens in a new tab) + + . +
            4. +
            5. + Add any + + personalisation (opens in a new tab) + + . +
            6. +
            7. + Save your Microsoft Word file and upload it to this page. +
            8. +
            +
            +
            +
            +
            +`; + +exports[`client has one campaign id matches snapshot on initial render 1`] = ` + + + Back to choose a template type + +
            +
            +
            +

            + Upload a British Sign Language letter template +

            +
            +
            +
            +
            +
            + + +
            + +
            + This will not be visible to recipients. +
            +
            + + + Naming your templates + + +
            +

            + You should name your templates in a way that works best for your service or organisation. +

            +

            + Common template names include the: +

            +
              +
            • + subject or reason for the message +
            • +
            • + intended audience for the template +
            • +
            • + version number of the template +
            • +
            +

            + For example, 'Covid19 2025 - over 65s - version 3' +

            +
            +
            + +
            +
            + +
            + This message plan will link to your only campaign: +
            + +

            + Campaign 1 +

            +
            +
            + +
            + Only upload your final letter template file. +
            + Make sure you use one of our blank template files to create the letter. +
            + +
            + +
            +
            +
            +

            + How to create a British Sign Language letter template +

            +
              +
            1. + Download the relevant + + blank letter template file (opens in a new tab) + + . +
            2. +
            3. + Add + + formatting (opens in a new tab) + + . +
            4. +
            5. + Add any + + personalisation (opens in a new tab) + + . +
            6. +
            7. + Save your Microsoft Word file and upload it to this page. +
            8. +
            +
            +
            +
            +
            +`; + +exports[`client has one campaign id renders errors when blank form is submitted and error state is returned 1`] = ` + + + Back to choose a template type + +
            + +
            +
            +

            + Upload a British Sign Language letter template +

            +
            +
            +
            +
            +
            + + +
            + +
            + This will not be visible to recipients. +
            +
            + + + Naming your templates + + +
            +

            + You should name your templates in a way that works best for your service or organisation. +

            +

            + Common template names include the: +

            +
              +
            • + subject or reason for the message +
            • +
            • + intended audience for the template +
            • +
            • + version number of the template +
            • +
            +

            + For example, 'Covid19 2025 - over 65s - version 3' +

            +
            +
            + + + Error: + + Enter a template name + + +
            +
            + +
            + This message plan will link to your only campaign: +
            + +

            + Campaign 1 +

            +
            +
            + +
            + Only upload your final letter template file. +
            + Make sure you use one of our blank template files to create the letter. +
            + + + Error: + + Choose a template file + + +
            + +
            +
            +
            +

            + How to create a British Sign Language letter template +

            +
              +
            1. + Download the relevant + + blank letter template file (opens in a new tab) + + . +
            2. +
            3. + Add + + formatting (opens in a new tab) + + . +
            4. +
            5. + Add any + + personalisation (opens in a new tab) + + . +
            6. +
            7. + Save your Microsoft Word file and upload it to this page. +
            8. +
            +
            +
            +
            +
            +`; diff --git a/frontend/src/__tests__/app/upload-british-sign-language-letter-template/page.test.tsx b/frontend/src/__tests__/app/upload-british-sign-language-letter-template/page.test.tsx new file mode 100644 index 000000000..54a1f9b65 --- /dev/null +++ b/frontend/src/__tests__/app/upload-british-sign-language-letter-template/page.test.tsx @@ -0,0 +1,215 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { redirect, RedirectType } from 'next/navigation'; +import { verifyFormCsrfToken } from '@utils/csrf-utils'; +import { fetchClient } from '@utils/server-features'; +import Page, { + metadata, +} from '@app/upload-british-sign-language-letter-template/page'; +import { uploadBSLLetterTemplate } from '@app/upload-british-sign-language-letter-template/server-action'; + +jest.mock('next/navigation'); +jest.mock('@utils/csrf-utils'); +jest.mock('@utils/server-features'); +jest.mock('@app/upload-british-sign-language-letter-template/server-action'); + +beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(verifyFormCsrfToken).mockResolvedValue(true); + jest.mocked(uploadBSLLetterTemplate).mockResolvedValue({}); +}); + +test('metadata', () => { + expect(metadata).toEqual({ + title: 'Upload a British Sign Language letter template - NHS Notify', + }); +}); + +describe('client has letter authoring feature flag disabled', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: [], + features: { + letterAuthoring: false, + }, + }); + }); + + it('redirects to campaign id required page', async () => { + await Page(); + + expect(redirect).toHaveBeenCalledWith( + '/choose-a-template-type', + RedirectType.replace + ); + }); +}); + +describe('client has no campaign ids', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: [], + features: { + letterAuthoring: true, + }, + }); + }); + + it('redirects to campaign id required page', async () => { + await Page(); + + expect(redirect).toHaveBeenCalledWith( + '/upload-letter-template/client-id-and-campaign-id-required', + RedirectType.replace + ); + }); +}); + +describe('client has one campaign id', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: ['Campaign 1'], + features: { + letterAuthoring: true, + }, + }); + }); + + it('matches snapshot on initial render', async () => { + expect(render(await Page()).asFragment()).toMatchSnapshot(); + }); + + it('submits the form with correct data', async () => { + const user = userEvent.setup(); + + render(await Page()); + + await user.click(screen.getByLabelText('Template name')); + await user.keyboard('A new template'); + + const file = new File(['hello'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + + await user.upload(screen.getByLabelText('Template file'), file); + + await user.click( + screen.getByRole('button', { name: 'Upload letter template file' }) + ); + + expect(uploadBSLLetterTemplate).toHaveBeenCalledTimes(1); + + const callArgs = jest.mocked(uploadBSLLetterTemplate).mock.calls[0]; + const formData = callArgs[1] as FormData; + + expect(formData.get('name')).toBe('A new template'); + expect(formData.get('campaignId')).toBe('Campaign 1'); + expect(formData.get('file')).toBeInstanceOf(File); + + expect( + screen.queryByRole('alert', { name: 'There is a problem' }) + ).not.toBeInTheDocument(); + }); + + it('renders errors when blank form is submitted and error state is returned', async () => { + jest.mocked(uploadBSLLetterTemplate).mockResolvedValue({ + errorState: { + fieldErrors: { + name: ['Enter a template name'], + file: ['Choose a template file'], + }, + }, + }); + + const user = userEvent.setup(); + + const page = render(await Page()); + + await user.click( + screen.getByRole('button', { name: 'Upload letter template file' }) + ); + + expect( + await screen.findByRole('alert', { name: 'There is a problem' }) + ).toBeInTheDocument(); + + expect(page.asFragment()).toMatchSnapshot(); + }); +}); + +describe('client has multiple campaign ids', () => { + beforeEach(() => { + jest.mocked(fetchClient).mockResolvedValue({ + campaignIds: ['Campaign 1', 'Campaign 2'], + features: { + letterAuthoring: true, + }, + }); + }); + + it('matches snapshot on initial render', async () => { + expect(render(await Page()).asFragment()).toMatchSnapshot(); + }); + + it('submits the form with correct data', async () => { + const user = userEvent.setup(); + + render(await Page()); + + await user.click(screen.getByLabelText('Template name')); + await user.keyboard('A new template'); + + await user.selectOptions(screen.getByLabelText('Campaign'), 'Campaign 2'); + + const file = new File(['hello'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + + await user.upload(screen.getByLabelText('Template file'), file); + + await user.click( + screen.getByRole('button', { name: 'Upload letter template file' }) + ); + + expect(uploadBSLLetterTemplate).toHaveBeenCalledTimes(1); + + const callArgs = jest.mocked(uploadBSLLetterTemplate).mock.calls[0]; + const formData = callArgs[1] as FormData; + + expect(formData.get('name')).toBe('A new template'); + expect(formData.get('campaignId')).toBe('Campaign 2'); + expect(formData.get('file')).toBeInstanceOf(File); + + expect( + screen.queryByRole('alert', { name: 'There is a problem' }) + ).not.toBeInTheDocument(); + }); + + it('renders errors when blank form is submitted and error state is returned', async () => { + jest.mocked(uploadBSLLetterTemplate).mockResolvedValue({ + errorState: { + fieldErrors: { + name: ['Enter a template name'], + campaignId: ['Choose a campaign'], + file: ['Choose a template file'], + }, + }, + }); + + const user = userEvent.setup(); + + const page = render(await Page()); + + await user.click( + screen.getByRole('button', { name: 'Upload letter template file' }) + ); + + expect(uploadBSLLetterTemplate).toHaveBeenCalledTimes(1); + + expect( + await screen.findByRole('alert', { name: 'There is a problem' }) + ).toBeInTheDocument(); + + expect(page.asFragment()).toMatchSnapshot(); + }); +}); diff --git a/frontend/src/__tests__/app/upload-british-sign-language-letter-template/server-action.test.ts b/frontend/src/__tests__/app/upload-british-sign-language-letter-template/server-action.test.ts new file mode 100644 index 000000000..11d3851fa --- /dev/null +++ b/frontend/src/__tests__/app/upload-british-sign-language-letter-template/server-action.test.ts @@ -0,0 +1,152 @@ +import { uploadBSLLetterTemplate } from '@app/upload-british-sign-language-letter-template/server-action'; + +describe('uploadBSLLetterTemplate', () => { + it('returns success when all fields are valid', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', 'Campaign 1'); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadBSLLetterTemplate({}, formData); + + expect(result.errorState).toBeUndefined(); + expect(result.fields).toEqual({ + name: 'Test Template', + campaignId: 'Campaign 1', + }); + }); + + it('returns validation error when name is empty', async () => { + const formData = new FormData(); + formData.append('name', ''); + formData.append('campaignId', 'Campaign 1'); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadBSLLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + name: ['Enter a template name'], + }, + }); + }); + + it('returns validation error when name is missing', async () => { + const formData = new FormData(); + formData.append('campaignId', 'Campaign 1'); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadBSLLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + name: ['Enter a template name'], + }, + }); + }); + + it('returns validation error when campaignId is empty', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', ''); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadBSLLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + campaignId: ['Choose a campaign'], + }, + }); + }); + + it('returns validation error when campaignId is missing', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + + const file = new File(['content'], 'template.docx', { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + formData.append('file', file); + + const result = await uploadBSLLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + campaignId: ['Choose a campaign'], + }, + }); + }); + + it('returns validation error when file is missing', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', 'Campaign 1'); + + const result = await uploadBSLLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + file: ['Choose a template file'], + }, + }); + }); + + it('returns validation error when file has incorrect MIME type', async () => { + const formData = new FormData(); + formData.append('name', 'Test Template'); + formData.append('campaignId', 'Campaign 1'); + + const file = new File(['content'], 'template.pdf', { + type: 'application/pdf', + }); + formData.append('file', file); + + const result = await uploadBSLLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + file: ['Choose a template file'], + }, + }); + }); + + it('returns multiple validation errors when multiple fields are invalid', async () => { + const formData = new FormData(); + formData.append('name', ''); + formData.append('campaignId', ''); + + const result = await uploadBSLLetterTemplate({}, formData); + + expect(result.errorState).toEqual({ + formErrors: [], + fieldErrors: { + name: ['Enter a template name'], + campaignId: ['Choose a campaign'], + file: ['Choose a template file'], + }, + }); + }); +}); diff --git a/frontend/src/__tests__/app/upload-large-print-letter-template/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/upload-large-print-letter-template/__snapshots__/page.test.tsx.snap index be327097c..7827c6304 100644 --- a/frontend/src/__tests__/app/upload-large-print-letter-template/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/upload-large-print-letter-template/__snapshots__/page.test.tsx.snap @@ -187,13 +187,13 @@ exports[`client has multiple campaign ids matches snapshot on initial render 1`]
          1. - Download the blank + Download the relevant - large print letter template file + blank letter template file (opens in a new tab) .
          2. @@ -491,13 +491,13 @@ exports[`client has multiple campaign ids renders errors when blank form is subm
          3. - Download the blank + Download the relevant - large print letter template file + blank letter template file (opens in a new tab) .
          4. @@ -720,13 +720,13 @@ exports[`client has one campaign id matches snapshot on initial render 1`] = `
          5. - Download the blank + Download the relevant - large print letter template file + blank letter template file (opens in a new tab) .
          6. @@ -1001,13 +1001,13 @@ exports[`client has one campaign id renders errors when blank form is submitted
          7. - Download the blank + Download the relevant - large print letter template file + blank letter template file (opens in a new tab) .
          8. diff --git a/frontend/src/__tests__/app/upload-other-language-letter-template/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/upload-other-language-letter-template/__snapshots__/page.test.tsx.snap index 491f06f6d..19d32251a 100644 --- a/frontend/src/__tests__/app/upload-other-language-letter-template/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/upload-other-language-letter-template/__snapshots__/page.test.tsx.snap @@ -351,13 +351,13 @@ exports[`client has multiple campaign ids matches snapshot on initial render 1`]
          9. - Download the blank + Download the relevant - other language letter template file + blank letter template file (opens in a new tab) .
          10. @@ -836,13 +836,13 @@ exports[`client has multiple campaign ids renders errors when blank form is subm
          11. - Download the blank + Download the relevant - other language letter template file + blank letter template file (opens in a new tab) .
          12. @@ -1229,13 +1229,13 @@ exports[`client has one campaign id matches snapshot on initial render 1`] = `
          13. - Download the blank + Download the relevant - other language letter template file + blank letter template file (opens in a new tab) .
          14. @@ -1691,13 +1691,13 @@ exports[`client has one campaign id renders errors when blank form is submitted
          15. - Download the blank + Download the relevant - other language letter template file + blank letter template file (opens in a new tab) .
          16. @@ -2051,11 +2051,11 @@ exports[`client has one campaign id renders the rtl template link if an rtl lang

            You've selected a language that reads right-to-left. Make sure you use the - other language (right-aligned) letter template file + other language (right-aligned) letter template file (opens in a new tab) .

            @@ -2109,13 +2109,13 @@ exports[`client has one campaign id renders the rtl template link if an rtl lang
          17. - Download the blank + Download the relevant - other language letter template file + blank letter template file (opens in a new tab) .
          18. diff --git a/frontend/src/__tests__/app/upload-other-language-letter-template/page.test.tsx b/frontend/src/__tests__/app/upload-other-language-letter-template/page.test.tsx index 3ce9ffcc1..76ed94d25 100644 --- a/frontend/src/__tests__/app/upload-other-language-letter-template/page.test.tsx +++ b/frontend/src/__tests__/app/upload-other-language-letter-template/page.test.tsx @@ -149,9 +149,11 @@ describe('client has one campaign id', () => { const page = render(await Page()); + const rtlLinkName = /other language \(right-aligned\) letter template file/; + expect( page.queryByRole('link', { - name: 'other language (right-aligned) letter template file', + name: rtlLinkName, }) ).not.toBeInTheDocument(); @@ -162,7 +164,7 @@ describe('client has one campaign id', () => { expect( page.queryByRole('link', { - name: 'other language (right-aligned) letter template file', + name: rtlLinkName, }) ).not.toBeInTheDocument(); @@ -172,8 +174,8 @@ describe('client has one campaign id', () => { ); expect( - page.getByRole('link', { - name: 'other language (right-aligned) letter template file', + await page.findByRole('link', { + name: rtlLinkName, }) ).toBeInTheDocument(); @@ -186,7 +188,7 @@ describe('client has one campaign id', () => { expect( page.queryByRole('link', { - name: 'other language (right-aligned) letter template file', + name: rtlLinkName, }) ).not.toBeInTheDocument(); }); diff --git a/frontend/src/__tests__/app/upload-standard-english-letter-template/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/upload-standard-english-letter-template/__snapshots__/page.test.tsx.snap index 29c25f007..63f5da8d9 100644 --- a/frontend/src/__tests__/app/upload-standard-english-letter-template/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/upload-standard-english-letter-template/__snapshots__/page.test.tsx.snap @@ -187,13 +187,13 @@ exports[`client has multiple campaign ids matches snapshot on initial render 1`]
          19. - Download the blank + Download the relevant - standard English letter template file + blank letter template file (opens in a new tab) .
          20. @@ -491,13 +491,13 @@ exports[`client has multiple campaign ids renders errors when blank form is subm
          21. - Download the blank + Download the relevant - standard English letter template file + blank letter template file (opens in a new tab) .
          22. @@ -720,13 +720,13 @@ exports[`client has one campaign id matches snapshot on initial render 1`] = `
          23. - Download the blank + Download the relevant - standard English letter template file + blank letter template file (opens in a new tab) .
          24. @@ -1001,13 +1001,13 @@ exports[`client has one campaign id renders errors when blank form is submitted
          25. - Download the blank + Download the relevant - standard English letter template file + blank letter template file (opens in a new tab) .
          26. diff --git a/frontend/src/__tests__/components/forms/SmsTemplateForm/SmsTemplateForm.test.tsx b/frontend/src/__tests__/components/forms/SmsTemplateForm/SmsTemplateForm.test.tsx index e6c9c5a79..63d2f95ab 100644 --- a/frontend/src/__tests__/components/forms/SmsTemplateForm/SmsTemplateForm.test.tsx +++ b/frontend/src/__tests__/components/forms/SmsTemplateForm/SmsTemplateForm.test.tsx @@ -121,26 +121,18 @@ describe('CreateSmsTemplate component', () => { /> ); - const templateMessageBox = document.querySelector('#smsTemplateMessage'); - - if (!templateMessageBox) { - throw new Error('Template message box not found'); - } + const templateMessageBox = screen.getByLabelText('Message'); const longMessage = 'x'.repeat(300); await user.type(templateMessageBox, longMessage); - const characterCount = screen.getByTestId('character-message-count'); - - if (!characterCount) { - throw new Error('Character count not found'); - } + const characterCount = await screen.findByTestId('character-message-count'); expect(characterCount.textContent).toContain( `${longMessage.length} characters` ); - }, 10_000); + }, 15_000); test('Client-side validation triggers - valid form - no errors', () => { const container = render( diff --git a/frontend/src/app/upload-british-sign-language-letter-template/page.tsx b/frontend/src/app/upload-british-sign-language-letter-template/page.tsx new file mode 100644 index 000000000..ab909f5e6 --- /dev/null +++ b/frontend/src/app/upload-british-sign-language-letter-template/page.tsx @@ -0,0 +1,66 @@ +import type { Metadata } from 'next'; +import { redirect, RedirectType } from 'next/navigation'; +import { NHSNotifyBackLink } from '@atoms/NHSNotifyBackLink/NHSNotifyBackLink'; +import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain'; +import copy from '@content/content'; +import * as UploadDocxLetterTemplateForm from '@forms/UploadDocxLetterTemplateForm/form'; +import { ContentRenderer } from '@molecules/ContentRenderer/ContentRenderer'; +import { NHSNotifyFormProvider } from '@providers/form-provider'; +import { fetchClient } from '@utils/server-features'; +import { getCampaignIds } from '@utils/client-config'; +import { uploadBSLLetterTemplate } from './server-action'; + +const content = copy.pages.uploadDocxLetterTemplatePage('q4'); + +export const metadata: Metadata = { + title: content.pageTitle, +}; + +export default async function UploadLargePrintLetterTemplatePage() { + const client = await fetchClient(); + + if (!client?.features.letterAuthoring) { + return redirect('/choose-a-template-type', RedirectType.replace); + } + + const campaignIds = getCampaignIds(client); + + if (campaignIds.length === 0) { + return redirect( + '/upload-letter-template/client-id-and-campaign-id-required', + RedirectType.replace + ); + } + + return ( + <> + + {content.backLink.text} + + + +
            +
            +

            {content.heading}

            +
            +
            +
            +
            + + + + + +
            + +
            + +
            +
            +
            +
            + + ); +} diff --git a/frontend/src/app/upload-british-sign-language-letter-template/server-action.ts b/frontend/src/app/upload-british-sign-language-letter-template/server-action.ts new file mode 100644 index 000000000..f059598bc --- /dev/null +++ b/frontend/src/app/upload-british-sign-language-letter-template/server-action.ts @@ -0,0 +1,40 @@ +'use server'; + +import { z } from 'zod/v4'; +import type { FormState } from 'nhs-notify-web-template-management-utils'; +import copy from '@content/content'; +import { formDataToFormStateFields } from '@utils/form-data-to-form-state'; + +const { errors } = copy.components.uploadDocxLetterTemplateForm; + +const $FormSchema = z.object({ + name: z.string(errors.name.empty).nonempty(errors.name.empty), + campaignId: z + .string(errors.campaignId.empty) + .nonempty(errors.campaignId.empty), + file: z + .file(errors.file.empty) + .mime( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + errors.file.empty + ), +}); + +export async function uploadBSLLetterTemplate( + _: FormState, + form: FormData +): Promise { + const validation = $FormSchema.safeParse(Object.fromEntries(form.entries())); + + const fields = formDataToFormStateFields(form); + + if (validation.error) { + return { + errorState: z.flattenError(validation.error), + fields, + }; + } + + // TODO: CCM-14211 - submit the form and redirect instead of returning + return { fields }; +} diff --git a/frontend/src/app/upload-other-language-letter-template/server-action.ts b/frontend/src/app/upload-other-language-letter-template/server-action.ts index 361c6b402..604c91161 100644 --- a/frontend/src/app/upload-other-language-letter-template/server-action.ts +++ b/frontend/src/app/upload-other-language-letter-template/server-action.ts @@ -15,10 +15,10 @@ const $FormSchema = z.object({ .nonempty(errors.campaignId.empty), language: z.enum(LANGUAGE_LIST, errors.language.empty).exclude(['en']), file: z - .file(errors.language.empty) + .file(errors.file.empty) .mime( 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - errors.language.empty + errors.file.empty ), }); @@ -31,7 +31,6 @@ export async function uploadOtherLanguageLetterTemplate( const fields = formDataToFormStateFields(form); if (validation.error) { - console.log(validation.error); return { errorState: z.flattenError(validation.error), fields, diff --git a/frontend/src/content/content.ts b/frontend/src/content/content.ts index 54fb7f114..8cc0cc3de 100644 --- a/frontend/src/content/content.ts +++ b/frontend/src/content/content.ts @@ -1587,7 +1587,7 @@ const uploadDocxLetterTemplateForm = { { type: 'text', text: '**Right-to-left language selected**' }, { type: 'text', - text: "You've selected a language that reads right-to-left. Make sure you use the [other language (right-aligned) letter template file](https://notify.nhs.uk/assets/worddocs/letter-template-nhs-notify-other-language-right-aligned.docx).", + text: "You've selected a language that reads right-to-left. Make sure you use the [other language (right-aligned) letter template file (opens in a new tab)](https://notify.nhs.uk/using-nhs-notify/upload-a-letter).", }, ] satisfies ContentBlock[], }, @@ -1620,57 +1620,20 @@ const uploadDocxLetterTemplateForm = { }, }; -type DocxTemplateType = Extract | 'language'; +type DocxTemplateType = LetterType | 'language'; -const uploadDocxLetterTemplateSideBarMappings: Record< - DocxTemplateType, - [string, string] -> = { - x0: [ - 'standard English', - 'https://notify.nhs.uk/assets/worddocs/letter-template-nhs-notify.docx', - ], - x1: [ - 'large print', - 'https://notify.nhs.uk/assets/worddocs/letter-template-nhs-notify-large-print.docx', - ], - language: [ - 'other language', - 'https://notify.nhs.uk/assets/worddocs/letter-template-nhs-notify-other-language.docx', - ], +const docxLetterDisplayMappings: Record = { + x0: 'standard English', + x1: 'large print', + q4: 'British Sign Language', + language: 'other language', }; const article = (noun: string) => (/^[aeiou]/i.test(noun) ? 'an' : 'a'); -const uploadDocxLetterTemplateSideBar = ( - type: DocxTemplateType -): ContentBlock[] => { - const [display, templateLink] = uploadDocxLetterTemplateSideBarMappings[type]; - - return [ - { - type: 'text', - text: `## How to create ${article(display)} ${display} letter template`, - overrides: { h2: { props: { className: 'nhsuk-heading-m' } } }, - }, - { - type: 'text', - text: markdownList('ol', [ - `Download the blank [${display} letter template file](${templateLink}).`, - 'Add [formatting (opens in a new tab)](https://notify.nhs.uk/using-nhs-notify/formatting).', - 'Add any [personalisation (opens in a new tab)](https://notify.nhs.uk/using-nhs-notify/personalisation).', - 'Save your Microsoft Word file and upload it to this page.', - ]), - overrides: { - ol: { props: { className: 'nhsuk-list nhsuk-list--number' } }, - li: { props: { className: 'nhsuk-u-margin-bottom-4' } }, - }, - }, - ]; -}; - const uploadDocxLetterTemplatePage = (type: DocxTemplateType) => { - const [display] = uploadDocxLetterTemplateSideBarMappings[type]; + const display = docxLetterDisplayMappings[type]; + return { pageTitle: generatePageTitle( `Upload ${article(display)} ${display} letter template` @@ -1680,7 +1643,26 @@ const uploadDocxLetterTemplatePage = (type: DocxTemplateType) => { text: 'Back to choose a template type', }, heading: `Upload ${article(display)} ${display} letter template`, - sideBar: uploadDocxLetterTemplateSideBar(type), + sideBar: [ + { + type: 'text', + text: `## How to create ${article(display)} ${display} letter template`, + overrides: { h2: { props: { className: 'nhsuk-heading-m' } } }, + }, + { + type: 'text', + text: markdownList('ol', [ + 'Download the relevant [blank letter template file (opens in a new tab)](https://notify.nhs.uk/using-nhs-notify/upload-a-letter).', + 'Add [formatting (opens in a new tab)](https://notify.nhs.uk/using-nhs-notify/formatting).', + 'Add any [personalisation (opens in a new tab)](https://notify.nhs.uk/using-nhs-notify/personalisation).', + 'Save your Microsoft Word file and upload it to this page.', + ]), + overrides: { + ol: { props: { className: 'nhsuk-list nhsuk-list--number' } }, + li: { props: { className: 'nhsuk-u-margin-bottom-4' } }, + }, + }, + ] satisfies ContentBlock[] as ContentBlock[], }; }; diff --git a/tests/test-team/pages/letter/template-mgmt-upload-bsl-letter-template-page.ts b/tests/test-team/pages/letter/template-mgmt-upload-bsl-letter-template-page.ts new file mode 100644 index 000000000..939bfe856 --- /dev/null +++ b/tests/test-team/pages/letter/template-mgmt-upload-bsl-letter-template-page.ts @@ -0,0 +1,33 @@ +import type { Locator, Page } from '@playwright/test'; +import { TemplateMgmtBasePage } from '../template-mgmt-base-page'; + +export class TemplateMgmtUploadBSLLetterTemplatePage extends TemplateMgmtBasePage { + static readonly pathTemplate = + '/upload-british-sign-language-letter-template'; + + nameInput: Locator; + + campaignIdInput: Locator; + + singleCampaignIdText: Locator; + + fileInput: Locator; + + submitButton: Locator; + + constructor(page: Page) { + super(page); + + this.nameInput = page.getByLabel('Template name'); + + this.campaignIdInput = page.getByLabel('Campaign'); + + this.fileInput = page.getByLabel('Template file'); + + this.singleCampaignIdText = page.getByTestId('single-campaign-id-text'); + + this.submitButton = page.getByRole('button', { + name: 'Upload letter template file', + }); + } +} diff --git a/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-bsl-letter-template.component.spec.ts b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-bsl-letter-template.component.spec.ts new file mode 100644 index 000000000..f9083aa8e --- /dev/null +++ b/tests/test-team/template-mgmt-component-tests/letter/template-mgmt-upload-bsl-letter-template.component.spec.ts @@ -0,0 +1,183 @@ +import { test, expect } from '@playwright/test'; +import { docxFixtures } from 'fixtures/letters'; +import { + createAuthHelper, + TestUser, + testUsers, +} from 'helpers/auth/cognito-auth-helper'; +import { loginAsUser } from 'helpers/auth/login-as-user'; +import { + assertAndClickBackLinkTop, + assertBackLinkBottomNotPresent, + assertFooterLinks, + assertHeaderLogoLink, + assertSignOutLink, + assertSkipToMainContent, +} from 'helpers/template-mgmt-common.steps'; +import { TemplateMgmtUploadBSLLetterTemplatePage } from 'pages/letter/template-mgmt-upload-bsl-letter-template-page'; + +let userNoCampaignId: TestUser; +let userSingleCampaign: TestUser; +let userMultipleCampaigns: TestUser; +let userAuthoringDisabled: TestUser; + +test.beforeAll(async () => { + const authHelper = createAuthHelper(); + + userSingleCampaign = await authHelper.getTestUser( + testUsers.UserLetterAuthoringEnabled.userId + ); + userNoCampaignId = await authHelper.getTestUser(testUsers.User6.userId); + userMultipleCampaigns = await authHelper.getTestUser( + testUsers.UserWithMultipleCampaigns.userId + ); + userAuthoringDisabled = await authHelper.getTestUser(testUsers.User3.userId); +}); + +test.describe('Upload BSL Letter Template Page', () => { + test.describe('single campaign client', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await loginAsUser(userSingleCampaign, page); + }); + + test('common page tests', async ({ page, baseURL }) => { + const props = { + page: new TemplateMgmtUploadBSLLetterTemplatePage(page), + baseURL, + }; + + await assertSkipToMainContent(props); + await assertHeaderLogoLink(props); + await assertSignOutLink(props); + await assertFooterLinks(props); + await assertBackLinkBottomNotPresent(props); + await assertAndClickBackLinkTop({ + ...props, + expectedUrl: 'templates/choose-a-template-type', + }); + }); + + test('no validation errors when form is submitted', async ({ page }) => { + const uploadPage = new TemplateMgmtUploadBSLLetterTemplatePage(page); + + await uploadPage.loadPage(); + + await expect(uploadPage.campaignIdInput).toBeHidden(); + await expect(uploadPage.singleCampaignIdText).toHaveText( + userSingleCampaign.campaignIds?.[0] as string + ); + + await uploadPage.nameInput.fill('New Letter Template'); + + await uploadPage.fileInput.click(); + await uploadPage.fileInput.setInputFiles(docxFixtures.standard.filepath); + + await uploadPage.submitButton.click(); + + // TODO: CCM-14211 - test submit behaviour + + await expect(uploadPage.errorSummaryList).toBeHidden(); + }); + + test('displays error messages when blank form is submitted', async ({ + page, + }) => { + const uploadPage = new TemplateMgmtUploadBSLLetterTemplatePage(page); + + await uploadPage.loadPage(); + + await expect(uploadPage.errorSummaryList).toBeHidden(); + + await uploadPage.submitButton.click(); + + await expect(uploadPage.errorSummaryList).toHaveText([ + 'Enter a template name', + 'Choose a template file', + ]); + }); + }); + + test.describe('multi-campaign client', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await loginAsUser(userMultipleCampaigns, page); + }); + + test('no validation errors when form is submitted', async ({ page }) => { + const uploadPage = new TemplateMgmtUploadBSLLetterTemplatePage(page); + + await uploadPage.loadPage(); + + await uploadPage.nameInput.fill('New Letter Template'); + + await expect(uploadPage.singleCampaignIdText).toBeHidden(); + await uploadPage.campaignIdInput.selectOption( + userMultipleCampaigns.campaignIds?.[0] as string + ); + + await uploadPage.fileInput.click(); + await uploadPage.fileInput.setInputFiles(docxFixtures.standard.filepath); + + await uploadPage.submitButton.click(); + + // TODO: CCM-14211 - test submit behaviour + + await expect(uploadPage.errorSummaryList).toBeHidden(); + }); + + test('displays error messages when blank form is submitted', async ({ + page, + }) => { + const uploadPage = new TemplateMgmtUploadBSLLetterTemplatePage(page); + + await uploadPage.loadPage(); + + await expect(uploadPage.errorSummaryList).toBeHidden(); + + await uploadPage.submitButton.click(); + + await expect(uploadPage.errorSummaryList).toHaveText([ + 'Enter a template name', + 'Choose a campaign', + 'Choose a template file', + ]); + }); + }); + + test.describe('client has no campaign id', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await loginAsUser(userNoCampaignId, page); + }); + + test('redirects to invalid config page', async ({ page }) => { + const uploadPage = new TemplateMgmtUploadBSLLetterTemplatePage(page); + + await uploadPage.loadPage(); + + await expect(page).toHaveURL( + '/templates/upload-letter-template/client-id-and-campaign-id-required' + ); + }); + }); + + test.describe('client has letter authoring flag disabled', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await loginAsUser(userAuthoringDisabled, page); + }); + + test('redirects to choose template type page', async ({ page }) => { + const uploadPage = new TemplateMgmtUploadBSLLetterTemplatePage(page); + + await uploadPage.loadPage(); + + await expect(page).toHaveURL('/templates/choose-a-template-type'); + }); + }); +}); diff --git a/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts b/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts index 591cc6fde..232ee82a7 100644 --- a/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts +++ b/tests/test-team/template-mgmt-component-tests/template-protected-routes.component.spec.ts @@ -33,9 +33,10 @@ import { TemplateMgmtTemplateSubmittedLetterPage } from '../pages/letter/templat import { TemplateMgmtTemplateSubmittedNhsAppPage } from '../pages/nhs-app/template-mgmt-template-submitted-nhs-app-page'; import { TemplateMgmtTemplateSubmittedSmsPage } from '../pages/sms/template-mgmt-template-submitted-sms-page'; import { TemplateMgmtUploadLetterMissingCampaignClientIdPage } from '../pages/letter/template-mgmt-upload-letter-missing-campaign-client-id-page'; -import { TemplateMgmtUploadStandardEnglishLetterTemplatePage } from 'pages/letter/template-mgmt-upload-standard-english-letter-template-page'; +import { TemplateMgmtUploadBSLLetterTemplatePage } from 'pages/letter/template-mgmt-upload-bsl-letter-template-page'; import { TemplateMgmtUploadLargePrintLetterTemplatePage } from 'pages/letter/template-mgmt-upload-large-print-letter-template-page'; import { TemplateMgmtUploadOtherLanguageLetterTemplatePage } from 'pages/letter/template-mgmt-upload-other-language-letter-template-page'; +import { TemplateMgmtUploadStandardEnglishLetterTemplatePage } from 'pages/letter/template-mgmt-upload-standard-english-letter-template-page'; import { RoutingChooseMessageOrderPage } from '../pages/routing/choose-message-order-page'; import { RoutingCreateMessagePlanPage } from '../pages/routing/create-message-plan-page'; import { RoutingMessagePlanCampaignIdRequiredPage } from '../pages/routing/campaign-id-required-page'; @@ -114,9 +115,10 @@ const protectedPages = [ TemplateMgmtTemplateSubmittedSmsPage, TemplateMgmtUploadLetterMissingCampaignClientIdPage, TemplateMgmtUploadLetterPage, - TemplateMgmtUploadStandardEnglishLetterTemplatePage, + TemplateMgmtUploadBSLLetterTemplatePage, TemplateMgmtUploadLargePrintLetterTemplatePage, TemplateMgmtUploadOtherLanguageLetterTemplatePage, + TemplateMgmtUploadStandardEnglishLetterTemplatePage, ]; const publicPages = [TemplateMgmtStartPage]; From 9c95a3fbd8505859e1ba51a41d7abcbebaefa8fc Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Wed, 4 Feb 2026 15:50:39 +0000 Subject: [PATCH 24/26] CCM-13489: update test fixture param format --- .../docx/standard-english-template.docx | Bin 85034 -> 85406 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/test-team/fixtures/letters/docx/standard-english-template.docx b/tests/test-team/fixtures/letters/docx/standard-english-template.docx index d309a03611a1d9e00377040933a73292440ad3fc..2f88b459ccad4778115ad4880457f9ae58b2c3fc 100644 GIT binary patch delta 17487 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 Gdl>+u;l=d; delta 17177 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 b&lG2r6ASQWWdrF^1j4Nx3=HwkKvNh1y0B_U From 3b75787adc779c16b8ece354333f500ef184b9e3 Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Thu, 5 Feb 2026 10:42:14 +0000 Subject: [PATCH 25/26] CCM-13489: fix file input padding --- frontend/src/components/atoms/FileUpload/FileUpload.module.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/atoms/FileUpload/FileUpload.module.scss b/frontend/src/components/atoms/FileUpload/FileUpload.module.scss index 3b19dfe2c..775794e83 100644 --- a/frontend/src/components/atoms/FileUpload/FileUpload.module.scss +++ b/frontend/src/components/atoms/FileUpload/FileUpload.module.scss @@ -1,4 +1,5 @@ .file-upload { + height: auto; width: auto; // The default file upload button in Safari does not From 02eb013a91af06512ff247b8243c792596172ea0 Mon Sep 17 00:00:00 2001 From: Michael Harrison Date: Thu, 5 Feb 2026 13:00:39 +0000 Subject: [PATCH 26/26] CCM-13489: full stop --- .../__snapshots__/page.test.tsx.snap | 4 ++-- .../__snapshots__/page.test.tsx.snap | 4 ++-- .../__snapshots__/page.test.tsx.snap | 4 ++-- .../__snapshots__/page.test.tsx.snap | 4 ++-- frontend/src/content/content.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/src/__tests__/app/upload-british-sign-language-letter-template/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/upload-british-sign-language-letter-template/__snapshots__/page.test.tsx.snap index e7e96a340..c6fa1ce4d 100644 --- a/frontend/src/__tests__/app/upload-british-sign-language-letter-template/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/upload-british-sign-language-letter-template/__snapshots__/page.test.tsx.snap @@ -118,7 +118,7 @@ exports[`client has multiple campaign ids matches snapshot on initial render 1`]
            - Choose which campaign this letter is for + Choose which campaign this letter is for.
            - Choose which campaign this letter is for + Choose which campaign this letter is for. - Choose which campaign this letter is for + Choose which campaign this letter is for. - Choose which campaign this letter is for + Choose which campaign this letter is for.