diff --git a/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/page.tsx b/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/page.tsx
new file mode 100644
index 000000000..4b8815e65
--- /dev/null
+++ b/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/page.tsx
@@ -0,0 +1,115 @@
+import type { Metadata } from 'next';
+import { redirect, RedirectType } from 'next/navigation';
+import { type MessagePlanPageProps } from 'nhs-notify-web-template-management-utils';
+import { NHSNotifyButton } from '@atoms/NHSNotifyButton/NHSNotifyButton';
+import * as NHSNotifyForm from '@atoms/NHSNotifyForm';
+import { NHSNotifyMain } from '@atoms/NHSNotifyMain/NHSNotifyMain';
+import {
+ NHSNotifySummaryList,
+ NHSNotifySummaryListKey,
+ NHSNotifySummaryListRow,
+ NHSNotifySummaryListValue,
+} from '@atoms/NHSNotifySummaryList/NHSNotifySummaryList';
+import content from '@content/content';
+import { MessagePlanCascadePreview } from '@molecules/MessagePlanCascadePreview/MessagePlanCascadePreview';
+import { NHSNotifyFormProvider } from '@providers/form-provider';
+import { getBasePath } from '@utils/get-base-path';
+import { interpolate } from '@utils/interpolate';
+import {
+ getMessagePlanTemplates,
+ getRoutingConfig,
+} from '@utils/message-plans';
+import { moveToProductionAction } from './server-action';
+
+const pageContent = content.pages.reviewAndMoveToProduction;
+const basePath = getBasePath();
+
+export const metadata: Metadata = {
+ title: pageContent.pageTitle,
+};
+
+export default async function ReviewAndMoveMessagePlanPage({
+ params,
+}: MessagePlanPageProps) {
+ const { routingConfigId } = await params;
+
+ const messagePlan = await getRoutingConfig(routingConfigId);
+
+ if (!messagePlan) {
+ return redirect('/message-plans/invalid', RedirectType.replace);
+ }
+
+ if (messagePlan.status !== 'DRAFT') {
+ return redirect(
+ `/message-plans/preview-message-plan/${routingConfigId}`,
+ RedirectType.replace
+ );
+ }
+
+ const templates = await getMessagePlanTemplates(messagePlan);
+
+ return (
+
+
+
+
+
+ {pageContent.headerCaption}
+
{pageContent.pageHeading}
+
+
+
+
+ {pageContent.summaryTable.rowHeadings.name}
+
+
+ {messagePlan.name}
+
+
+
+
+
+
+
+
+
+
+
+ {pageContent.buttons.moveToProduction.text}
+
+
+ {pageContent.buttons.keepInDraft.text}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/server-action.ts b/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/server-action.ts
new file mode 100644
index 000000000..c0b5dbfef
--- /dev/null
+++ b/frontend/src/app/message-plans/review-and-move-to-production/[routingConfigId]/server-action.ts
@@ -0,0 +1,39 @@
+'use server';
+
+import { redirect, RedirectType } from 'next/navigation';
+import { z } from 'zod/v4';
+import type { FormState } from 'nhs-notify-web-template-management-utils';
+import { $LockNumber } from 'nhs-notify-backend-client';
+import { submitRoutingConfig } from '@utils/message-plans';
+import content from '@content/content';
+
+const $MoveToProductionFormData = z.object({
+ routingConfigId: z.string({ error: 'Invalid message plan id' }),
+ lockNumber: $LockNumber,
+});
+
+const pageContent = content.pages.reviewAndMoveToProduction;
+
+export async function moveToProductionAction(
+ state: FormState,
+ formData: FormData
+): Promise {
+ const parsed = $MoveToProductionFormData.safeParse(
+ Object.fromEntries(formData.entries())
+ );
+
+ if (!parsed.success) {
+ return {
+ ...state,
+ errorState: z.flattenError(parsed.error),
+ };
+ }
+
+ delete state.errorState;
+
+ await submitRoutingConfig(
+ parsed.data.routingConfigId,
+ parsed.data.lockNumber
+ );
+ redirect(pageContent.buttons.moveToProduction.href, RedirectType.replace);
+}
diff --git a/frontend/src/components/molecules/MessagePlanCascadePreview/MessagePlanCascadePreview.tsx b/frontend/src/components/molecules/MessagePlanCascadePreview/MessagePlanCascadePreview.tsx
new file mode 100644
index 000000000..04970dcab
--- /dev/null
+++ b/frontend/src/components/molecules/MessagePlanCascadePreview/MessagePlanCascadePreview.tsx
@@ -0,0 +1,215 @@
+'use client';
+
+import { Fragment } from 'react';
+import Link from 'next/link';
+import type { RoutingConfig, TemplateDto } from 'nhs-notify-backend-client';
+import {
+ accessibleFormatDisplayMappings,
+ channelDisplayMappings,
+} from 'nhs-notify-web-template-management-utils';
+import { DetailsSummary, DetailsText } from '@atoms/nhsuk-components';
+import content from '@content/content';
+import { MessagePlanBlock } from '@molecules/MessagePlanBlock/MessagePlanBlock';
+import { MessagePlanChannelList } from '@molecules/MessagePlanChannelList/MessagePlanChannelList';
+import { MessagePlanChannelCard } from '@molecules/MessagePlanChannelCard/MessagePlanChannelCard';
+import {
+ MessagePlanConditionalTemplatesList,
+ MessagePlanConditionalTemplatesListItem,
+} from '@molecules/MessagePlanConditionalTemplatesList/MessagePlanConditionalTemplatesList';
+import {
+ MessagePlanFallbackConditionsDetails,
+ MessagePlanFallbackConditionsListItem,
+} from '@molecules/MessagePlanFallbackConditions/MessagePlanFallbackConditions';
+import {
+ ControlledDetails,
+ DetailsOpenButton,
+ DetailsOpenProvider,
+} from '@providers/details-open';
+import { interpolate } from '@utils/interpolate';
+import { renderTemplateMarkdown } from '@utils/render-template-markdown';
+import {
+ getAccessibleTemplatesForCascadeItem,
+ getDefaultTemplateForItem,
+ getLanguageTemplatesForCascadeItem,
+ type MessagePlanTemplates,
+} from '@utils/routing-utils';
+
+const pageContent = content.components.messagePlanCascadePreview;
+
+function getLetterTemplatePreviewHref(template: TemplateDto): string {
+ const linkTemplate =
+ template.templateStatus === 'SUBMITTED'
+ ? pageContent.letterTemplateLinks.previewSubmitted
+ : pageContent.letterTemplateLinks.preview;
+ return interpolate(linkTemplate, { id: template.id });
+}
+
+export type MessagePlanCascadePreviewProps = {
+ messagePlan: RoutingConfig;
+ templates: MessagePlanTemplates;
+};
+
+export function MessagePlanCascadePreview({
+ messagePlan,
+ templates,
+}: MessagePlanCascadePreviewProps) {
+ return (
+
+ {messagePlan.cascade.some((item) => item.channel !== 'LETTER') ? (
+
+
+
+ ) : null}
+
+
+ {messagePlan.cascade.map((cascadeItem, index) => {
+ const channelDisplayName = channelDisplayMappings(
+ cascadeItem.channel
+ );
+
+ const defaultTemplate = getDefaultTemplateForItem(
+ cascadeItem,
+ templates
+ );
+
+ const accessibleTemplates = getAccessibleTemplatesForCascadeItem(
+ cascadeItem,
+ templates
+ );
+
+ const languageTemplates = getLanguageTemplatesForCascadeItem(
+ cascadeItem,
+ templates
+ );
+
+ const conditionalTemplatesCount =
+ accessibleTemplates.length + languageTemplates.length;
+
+ if (!defaultTemplate) {
+ return null;
+ }
+
+ return (
+
+
+
+ {cascadeItem.channel === 'LETTER' ? (
+
+
+ {defaultTemplate.name}
+
+
+ ) : (
+ <>
+ {defaultTemplate.name}
+
+
+ {pageContent.previewTemplateSummary.prefix}{' '}
+
+ {channelDisplayName}
+ {' '}
+ {pageContent.previewTemplateSummary.suffix}
+
+
+
+
+
+ >
+ )}
+
+
+ {conditionalTemplatesCount > 0 && (
+
+
+
+
+ {accessibleTemplates.map(([accessibleFormat, template]) => (
+
+
+
+
+ {template.name}
+
+
+
+
+ ))}
+
+ {languageTemplates.length > 0 && (
+
+
+ {languageTemplates.map((template) => (
+
+
+ {template.name}
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+ {messagePlan.cascade.length > 1 &&
+ index < messagePlan.cascade.length - 1 && (
+
+
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend/src/content/content.ts b/frontend/src/content/content.ts
index 3711c1299..a01b18a2d 100644
--- a/frontend/src/content/content.ts
+++ b/frontend/src/content/content.ts
@@ -1215,6 +1215,23 @@ const messagePlanConditionalLetterTemplates = {
languageFormats: 'Other language letters',
};
+const messagePlanCascadePreview = {
+ detailsOpenButton: {
+ openText: 'Close all template previews',
+ closedText: 'Open all template previews',
+ },
+ languageFormatsCardHeading: 'Other language letters (optional)',
+ accessibleFormatCardHeading: '{{format}} (optional)',
+ previewTemplateSummary: {
+ prefix: 'Preview',
+ suffix: 'template',
+ },
+ letterTemplateLinks: {
+ previewSubmitted: '/preview-submitted-letter-template/{{id}}',
+ preview: '/preview-letter-template/{{id}}',
+ },
+};
+
const chooseTemplatesForMessagePlan = {
pageTitle: generatePageTitle('Choose templates for your message plan'),
headerCaption: 'Message plan',
@@ -1590,11 +1607,6 @@ const previewMessagePlan = {
status: 'Status',
},
},
- detailsOpenButton: {
- openText: 'Close all template previews',
- closedText: 'Open all template previews',
- },
- languageFormatsCardHeading: 'Other language letters (optional)',
};
const uploadDocxLetterTemplateForm = {
@@ -1699,6 +1711,27 @@ const uploadDocxLetterTemplatePage = (type: DocxTemplateType) => {
};
};
+const reviewAndMoveToProduction = {
+ pageTitle: generatePageTitle('Review and move message plan to production'),
+ headerCaption: 'Step 2 of 2',
+ pageHeading: 'Review and move message plan to production',
+ summaryTable: {
+ rowHeadings: {
+ name: 'Name',
+ },
+ },
+ buttons: {
+ moveToProduction: {
+ text: 'Move to production',
+ href: '/message-plans',
+ },
+ keepInDraft: {
+ text: 'Keep in draft',
+ href: '{{basePath}}/message-plans/choose-templates/{{routingConfigId}}',
+ },
+ },
+};
+
const editTemplateNamePage = {
pageTitle: generatePageTitle('Edit template name'),
form: {
@@ -1736,6 +1769,7 @@ const content = {
logoutWarning,
messageFormatting,
messagePlanBlock,
+ messagePlanCascadePreview,
messagePlanChannelTemplate,
messagePlanFallbackConditions,
messagePlanForm,
@@ -1782,6 +1816,7 @@ const content = {
previewLargePrintLetterTemplate,
previewOtherLanguageLetterTemplate,
previewMessagePlan,
+ reviewAndMoveToProduction,
submitLetterTemplate: submitLetterTemplatePage,
uploadDocxLetterTemplatePage,
},
diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts
index d609ce09f..d9fb179e8 100644
--- a/frontend/src/middleware.ts
+++ b/frontend/src/middleware.ts
@@ -39,6 +39,7 @@ const protectedPaths = [
/^\/message-plans\/get-ready-to-move\/[^/]+$/,
/^\/message-plans\/invalid$/,
/^\/message-plans\/preview-message-plan\/[^/]+$/,
+ /^\/message-plans\/review-and-move-to-production\/[^/]+$/,
/^\/message-plans$/,
/^\/message-templates$/,
/^\/nhs-app-template-submitted\/[^/]+$/,
diff --git a/frontend/src/utils/message-plans.ts b/frontend/src/utils/message-plans.ts
index 21ee06049..b2b4b3b14 100644
--- a/frontend/src/utils/message-plans.ts
+++ b/frontend/src/utils/message-plans.ts
@@ -225,3 +225,34 @@ export async function getRoutingConfigReferencesByTemplateId(
return data;
}
+
+/**
+ * Submits a routing configuration to move it from DRAFT to COMPLETED status.
+ */
+export async function submitRoutingConfig(
+ routingConfigId: string,
+ lockNumber: number
+): Promise {
+ const { accessToken } = await getSessionServer();
+
+ if (!accessToken) {
+ throw new Error('Failed to get access token');
+ }
+
+ const { data, error } = await routingConfigurationApiClient.submit(
+ accessToken,
+ routingConfigId,
+ lockNumber
+ );
+
+ if (error) {
+ logger.error('Failed to submit message plan', error);
+ throw new Error('Failed to submit message plan');
+ }
+
+ if (!data) {
+ throw new Error('No data returned from submit');
+ }
+
+ return data;
+}
diff --git a/lambdas/backend-client/src/__tests__/routing-config-api-client.test.ts b/lambdas/backend-client/src/__tests__/routing-config-api-client.test.ts
index 79c2d816e..faaab1ac3 100644
--- a/lambdas/backend-client/src/__tests__/routing-config-api-client.test.ts
+++ b/lambdas/backend-client/src/__tests__/routing-config-api-client.test.ts
@@ -378,4 +378,64 @@ describe('RoutingConfigurationApiClient', () => {
expect(axiosMock.history.get.length).toBe(1);
});
});
+
+ describe('submit', () => {
+ it('should return error when failing to submit', async () => {
+ axiosMock
+ .onPatch(`/v1/routing-configuration/${validRoutingConfigId}/submit`)
+ .reply(400, {
+ statusCode: 400,
+ technicalMessage: 'Bad request',
+ details: {
+ message: 'Cannot submit routing configuration',
+ },
+ });
+
+ const response = await client.submit('token', validRoutingConfigId, 1);
+
+ expect(response.error).toEqual({
+ errorMeta: {
+ code: 400,
+ description: 'Bad request',
+ details: {
+ message: 'Cannot submit routing configuration',
+ },
+ },
+ });
+ expect(response.data).toBeUndefined();
+ expect(axiosMock.history.patch.length).toBe(1);
+ });
+
+ it('should return routing configuration on successful submit', async () => {
+ const data: RoutingConfig = {
+ id: validRoutingConfigId,
+ name: 'Test message plan',
+ status: 'COMPLETED',
+ clientId: 'client-1',
+ campaignId: 'campaign-1',
+ createdAt: '2025-01-01T00:00:00.000Z',
+ updatedAt: '2025-01-01T00:00:00.000Z',
+ cascade: [],
+ cascadeGroupOverrides: [],
+ lockNumber: 2,
+ defaultCascadeGroup: 'standard',
+ };
+
+ axiosMock
+ .onPatch(`/v1/routing-configuration/${validRoutingConfigId}/submit`)
+ .reply(200, {
+ data,
+ });
+
+ const response = await client.submit('token', validRoutingConfigId, 1);
+
+ expect(response.data).toEqual(data);
+ expect(response.error).toBeUndefined();
+ expect(axiosMock.history.patch.length).toBe(1);
+ expect(axiosMock.history.patch[0].headers).toMatchObject({
+ Authorization: 'token',
+ 'X-Lock-Number': '1',
+ });
+ });
+ });
});
diff --git a/lambdas/backend-client/src/routing-config-api-client.ts b/lambdas/backend-client/src/routing-config-api-client.ts
index 0531ae462..1e45f4b02 100644
--- a/lambdas/backend-client/src/routing-config-api-client.ts
+++ b/lambdas/backend-client/src/routing-config-api-client.ts
@@ -154,4 +154,31 @@ export const routingConfigurationApiClient = {
return { ...data };
},
+
+ async submit(
+ token: string,
+ id: RoutingConfig['id'],
+ lockNumber: number
+ ): Promise> {
+ const url = `/v1/routing-configuration/${encodeURIComponent(id)}/submit`;
+
+ const { data, error } = await catchAxiosError(
+ httpClient.patch(
+ url,
+ {},
+ {
+ headers: {
+ Authorization: token,
+ 'X-Lock-Number': String(lockNumber),
+ },
+ }
+ )
+ );
+
+ if (error) {
+ return { error };
+ }
+
+ return { ...data };
+ },
};
diff --git a/tests/test-team/pages/routing/index.ts b/tests/test-team/pages/routing/index.ts
index 8052b1bfb..d5fab41f7 100644
--- a/tests/test-team/pages/routing/index.ts
+++ b/tests/test-team/pages/routing/index.ts
@@ -4,3 +4,4 @@ export * from './choose-templates-page';
export * from './create-message-plan-page';
export * from './message-plans-page';
export * from './invalid-message-plan-page';
+export * from './review-and-move-to-production-page';
diff --git a/tests/test-team/pages/routing/review-and-move-to-production-page.ts b/tests/test-team/pages/routing/review-and-move-to-production-page.ts
new file mode 100644
index 000000000..a38437e7a
--- /dev/null
+++ b/tests/test-team/pages/routing/review-and-move-to-production-page.ts
@@ -0,0 +1,70 @@
+import type { Locator, Page } from '@playwright/test';
+import type { Channel, LetterType } from 'nhs-notify-backend-client';
+import { TemplateMgmtBasePage } from 'pages/template-mgmt-base-page';
+
+export class RoutingReviewAndMoveToProductionPage extends TemplateMgmtBasePage {
+ static readonly pathTemplate =
+ '/message-plans/review-and-move-to-production/:messagePlanId';
+
+ public readonly messagePlanName: Locator;
+
+ public readonly previewToggleButton: Locator;
+
+ public readonly detailsSections: Locator;
+
+ public readonly moveToProductionButton: Locator;
+
+ public readonly keepInDraftButton: Locator;
+
+ constructor(page: Page) {
+ super(page);
+
+ this.messagePlanName = page.getByTestId('plan-name');
+ this.previewToggleButton = page.getByRole('button', {
+ name: /^(Open|Close) all template previews$/,
+ });
+ this.detailsSections = page.locator('details');
+ this.moveToProductionButton = page.getByTestId('move-to-production-button');
+ this.keepInDraftButton = page.getByTestId('keep-in-draft-link');
+ }
+
+ getTemplateBlock(channel: Channel) {
+ const block = this.page.getByTestId(`message-plan-block-${channel}`);
+
+ const defaultTemplateCard = this.getCard(block);
+
+ const conditionalTemplates = block.getByTestId('conditional-templates');
+
+ return {
+ locator: block,
+ number: block.locator('[class*=message-plan-block-number]'),
+ defaultTemplateCard,
+ getAccessibilityFormatCard: (format: LetterType) => {
+ return this.getCard(
+ conditionalTemplates.getByTestId(`conditional-template-${format}`)
+ );
+ },
+ getLanguagesCard: () => {
+ return this.getCard(
+ conditionalTemplates.getByTestId('conditional-template-languages')
+ );
+ },
+ };
+ }
+
+ getFallbackBlock(channel: Channel) {
+ return this.page.getByTestId(`message-plan-fallback-conditions-${channel}`);
+ }
+
+ private getCard(templateBlock: Locator) {
+ const card = templateBlock.getByTestId('channel-card').first();
+
+ return {
+ locator: card,
+ templateName: card.getByTestId('template-name'),
+ previewTemplateSummary: card.getByTestId('preview-template-summary'),
+ previewTemplateText: card.getByTestId('preview-template-text'),
+ templateLink: card.getByRole('link'),
+ };
+ }
+}
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 b61c75dc5..cd364da72 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
@@ -59,6 +59,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 { RoutingPreviewMessagePlanPage } from 'pages/routing/preview-message-plan-page';
+import { RoutingReviewAndMoveToProductionPage } from 'pages/routing';
// Reset storage state for this file to avoid being authenticated
test.use({ storageState: { cookies: [], origins: [] } });
@@ -85,6 +86,7 @@ const protectedPages = [
RoutingPreviewLargePrintLetterTemplatePage,
RoutingPreviewOtherLanguageLetterTemplatePage,
RoutingPreviewSmsTemplatePage,
+ RoutingReviewAndMoveToProductionPage,
TemplateMgmtChoosePage,
TemplateMgmtCopyPage,
TemplateMgmtCreateEmailPage,
diff --git a/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts
new file mode 100644
index 000000000..3e5d5da6c
--- /dev/null
+++ b/tests/test-team/template-mgmt-routing-component-tests/review-and-move-to-production.routing-component.spec.ts
@@ -0,0 +1,375 @@
+import { randomUUID } from 'node:crypto';
+import { Channel } from 'nhs-notify-backend-client';
+import { test, expect } from '@playwright/test';
+import {
+ createAuthHelper,
+ TestUser,
+ testUsers,
+} from 'helpers/auth/cognito-auth-helper';
+import { RoutingConfigStorageHelper } from 'helpers/db/routing-config-storage-helper';
+import { TemplateStorageHelper } from 'helpers/db/template-storage-helper';
+import { RoutingConfigFactory } from 'helpers/factories/routing-config-factory';
+import { TemplateFactory } from 'helpers/factories/template-factory';
+import {
+ assertFooterLinks,
+ assertSignOutLink,
+ assertHeaderLogoLink,
+ assertSkipToMainContent,
+} from 'helpers/template-mgmt-common.steps';
+import { RoutingReviewAndMoveToProductionPage } from 'pages/routing/review-and-move-to-production-page';
+import { RoutingChooseTemplatesPage } from 'pages/routing';
+import { RoutingMessagePlansPage } from 'pages/routing/message-plans-page';
+
+const routingConfigStorageHelper = new RoutingConfigStorageHelper();
+const templateStorageHelper = new TemplateStorageHelper();
+
+function createTemplates(user: TestUser) {
+ const templateIds = {
+ NHSAPP: randomUUID(),
+ EMAIL: randomUUID(),
+ SMS: randomUUID(),
+ LETTER: randomUUID(),
+ LARGE_PRINT_LETTER: randomUUID(),
+ FRENCH_LETTER: randomUUID(),
+ SPANISH_LETTER: randomUUID(),
+ };
+
+ return {
+ NHSAPP: TemplateFactory.createNhsAppTemplate(
+ templateIds.NHSAPP,
+ user,
+ `Test NHS App template - ${templateIds.NHSAPP}`,
+ 'SUBMITTED'
+ ),
+ EMAIL: TemplateFactory.createEmailTemplate(
+ templateIds.EMAIL,
+ user,
+ `Test Email template - ${templateIds.EMAIL}`,
+ 'SUBMITTED'
+ ),
+ SMS: TemplateFactory.createSmsTemplate(
+ templateIds.SMS,
+ user,
+ `Test SMS template - ${templateIds.SMS}`,
+ 'SUBMITTED'
+ ),
+ LETTER: TemplateFactory.uploadLetterTemplate(
+ templateIds.LETTER,
+ user,
+ `Test Letter template - ${templateIds.LETTER}`,
+ 'SUBMITTED'
+ ),
+ LARGE_PRINT_LETTER: TemplateFactory.uploadLetterTemplate(
+ templateIds.LARGE_PRINT_LETTER,
+ user,
+ `Test Large Print Letter template - ${templateIds.LARGE_PRINT_LETTER}`,
+ 'SUBMITTED',
+ 'PASSED',
+ { letterType: 'x1' }
+ ),
+ FRENCH_LETTER: TemplateFactory.uploadLetterTemplate(
+ templateIds.FRENCH_LETTER,
+ user,
+ `Test Letter template French - ${templateIds.FRENCH_LETTER}`,
+ 'PROOF_APPROVED',
+ 'PASSED',
+ { language: 'fr' }
+ ),
+ SPANISH_LETTER: TemplateFactory.uploadLetterTemplate(
+ templateIds.SPANISH_LETTER,
+ user,
+ `Test Spanish Letter template - ${templateIds.SPANISH_LETTER}`,
+ 'PROOF_APPROVED',
+ 'PASSED',
+ { language: 'es' }
+ ),
+ };
+}
+
+test.describe('Routing - Review and Move to Production page', () => {
+ let templates: ReturnType;
+
+ let user: TestUser;
+
+ test.beforeAll(async () => {
+ user = await createAuthHelper().getTestUser(testUsers.User1.userId);
+ templates = createTemplates(user);
+
+ await templateStorageHelper.seedTemplateData(Object.values(templates));
+ });
+
+ test.afterAll(async () => {
+ await routingConfigStorageHelper.deleteSeeded();
+ await templateStorageHelper.deleteSeededTemplates();
+ });
+
+ test('common page tests', async ({ page, baseURL }) => {
+ const { dbEntry } = RoutingConfigFactory.createWithChannels(
+ user,
+ ['NHSAPP'],
+ { status: 'DRAFT' }
+ ).addTemplate('NHSAPP', templates.NHSAPP.id);
+
+ await routingConfigStorageHelper.seed([dbEntry]);
+
+ const props = {
+ page: new RoutingReviewAndMoveToProductionPage(page).setPathParam(
+ 'messagePlanId',
+ dbEntry.id
+ ),
+ baseURL,
+ };
+
+ await assertSkipToMainContent(props);
+ await assertHeaderLogoLink(props);
+ await assertFooterLinks(props);
+ await assertSignOutLink(props);
+ });
+
+ test('redirects to invalid message plan page when message plan cannot be found', async ({
+ page,
+ baseURL,
+ }) => {
+ const reviewPage = new RoutingReviewAndMoveToProductionPage(
+ page
+ ).setPathParam('messagePlanId', 'does-not-exist');
+
+ await reviewPage.loadPage();
+
+ await expect(page).toHaveURL(`${baseURL}/templates/message-plans/invalid`);
+ });
+
+ test('redirects to preview message plan page when message plan is not DRAFT', async ({
+ page,
+ baseURL,
+ }) => {
+ const { dbEntry } = RoutingConfigFactory.createWithChannels(
+ user,
+ ['NHSAPP'],
+ { status: 'COMPLETED' }
+ ).addTemplate('NHSAPP', templates.NHSAPP.id);
+
+ await routingConfigStorageHelper.seed([dbEntry]);
+
+ const reviewPage = new RoutingReviewAndMoveToProductionPage(
+ page
+ ).setPathParam('messagePlanId', dbEntry.id);
+
+ await reviewPage.loadPage();
+
+ await expect(page).toHaveURL(
+ `${baseURL}/templates/message-plans/preview-message-plan/${dbEntry.id}`
+ );
+ });
+
+ test('displays message plan name in summary list', async ({ page }) => {
+ const { dbEntry } = RoutingConfigFactory.createWithChannels(
+ user,
+ ['NHSAPP'],
+ { status: 'DRAFT' }
+ ).addTemplate('NHSAPP', templates.NHSAPP.id);
+
+ await routingConfigStorageHelper.seed([dbEntry]);
+
+ const reviewPage = new RoutingReviewAndMoveToProductionPage(
+ page
+ ).setPathParam('messagePlanId', dbEntry.id);
+
+ await reviewPage.loadPage();
+
+ await expect(reviewPage.messagePlanName).toHaveText(dbEntry.name);
+ });
+
+ test('displays preview of full routing config', async ({ page }) => {
+ const { dbEntry } = RoutingConfigFactory.createWithChannels(
+ user,
+ ['NHSAPP', 'EMAIL', 'SMS', 'LETTER'],
+ { status: 'DRAFT' }
+ )
+ .addTemplate('NHSAPP', templates.NHSAPP.id)
+ .addTemplate('EMAIL', templates.EMAIL.id)
+ .addTemplate('SMS', templates.SMS.id)
+ .addTemplate('LETTER', templates.LETTER.id)
+ .addAccessibleFormatTemplate('x1', templates.LARGE_PRINT_LETTER.id)
+ .addLanguageTemplate('fr', templates.FRENCH_LETTER.id)
+ .addLanguageTemplate('es', templates.SPANISH_LETTER.id);
+
+ await routingConfigStorageHelper.seed([dbEntry]);
+
+ const reviewPage = new RoutingReviewAndMoveToProductionPage(
+ page
+ ).setPathParam('messagePlanId', dbEntry.id);
+
+ await reviewPage.loadPage();
+
+ await test.step('opens and closes all details sections', async () => {
+ for (const section of await reviewPage.detailsSections.all()) {
+ await expect(section).not.toHaveAttribute('open');
+ }
+
+ await expect(reviewPage.previewToggleButton).toHaveText(
+ 'Open all template previews'
+ );
+
+ await reviewPage.previewToggleButton.click();
+
+ for (const section of await reviewPage.detailsSections.all()) {
+ await expect(section).toHaveAttribute('open');
+ }
+
+ await expect(reviewPage.previewToggleButton).toHaveText(
+ 'Close all template previews'
+ );
+
+ await reviewPage.previewToggleButton.click();
+
+ for (const section of await reviewPage.detailsSections.all()) {
+ await expect(section).not.toHaveAttribute('open');
+ }
+
+ await expect(reviewPage.previewToggleButton).toHaveText(
+ 'Open all template previews'
+ );
+ });
+
+ for (const [index, channel] of (
+ ['NHSAPP', 'EMAIL', 'SMS'] satisfies Channel[]
+ ).entries()) {
+ await test.step(`renders ${channel} template preview and fallback blocks`, async () => {
+ const templateBlock = await reviewPage.getTemplateBlock(channel);
+
+ await expect(templateBlock.number).toHaveText(`${index + 1}`);
+ await expect(templateBlock.defaultTemplateCard.templateName).toHaveText(
+ templates[channel].name
+ );
+
+ await expect(
+ templateBlock.defaultTemplateCard.previewTemplateText
+ ).toBeHidden();
+
+ await templateBlock.defaultTemplateCard.previewTemplateSummary.click();
+
+ await expect(
+ templateBlock.defaultTemplateCard.previewTemplateText
+ ).toBeVisible();
+
+ await expect(
+ templateBlock.defaultTemplateCard.previewTemplateText
+ ).toHaveText(templates[channel].message as string);
+
+ await expect(reviewPage.getFallbackBlock(channel)).toBeVisible();
+ });
+ }
+
+ await test.step('for LETTER channel renders template links for default and accessible templates along with conditional template fallback conditions', async () => {
+ const templateBlock = await reviewPage.getTemplateBlock('LETTER');
+
+ await expect(templateBlock.number).toHaveText('4');
+
+ await expect(
+ templateBlock.defaultTemplateCard.previewTemplateSummary
+ ).toBeHidden();
+
+ await expect(templateBlock.defaultTemplateCard.templateLink).toHaveText(
+ templates.LETTER.name
+ );
+ await expect(
+ templateBlock.defaultTemplateCard.templateLink
+ ).toHaveAttribute(
+ 'href',
+ `/templates/preview-submitted-letter-template/${templates.LETTER.id}`
+ );
+
+ await expect(
+ templateBlock.getAccessibilityFormatCard('x1').templateLink
+ ).toHaveText(templates.LARGE_PRINT_LETTER.name);
+
+ await expect(
+ templateBlock.getAccessibilityFormatCard('x1').templateLink
+ ).toHaveAttribute(
+ 'href',
+ `/templates/preview-submitted-letter-template/${templates.LARGE_PRINT_LETTER.id}`
+ );
+
+ for (const [index, language] of (
+ ['FRENCH_LETTER', 'SPANISH_LETTER'] satisfies (keyof ReturnType<
+ typeof createTemplates
+ >)[]
+ ).entries()) {
+ const links = await templateBlock.getLanguagesCard().templateLink.all();
+ await expect(links[index]).toHaveText(templates[language].name);
+
+ await expect(links[index]).toHaveAttribute(
+ 'href',
+ `/templates/preview-letter-template/${templates[language].id}`
+ );
+ }
+ });
+ });
+
+ test('keep in draft button navigates to choose templates page', async ({
+ page,
+ baseURL,
+ }) => {
+ const { dbEntry } = RoutingConfigFactory.createWithChannels(
+ user,
+ ['NHSAPP'],
+ { status: 'DRAFT' }
+ ).addTemplate('NHSAPP', templates.NHSAPP.id);
+
+ await routingConfigStorageHelper.seed([dbEntry]);
+
+ const reviewPage = new RoutingReviewAndMoveToProductionPage(
+ page
+ ).setPathParam('messagePlanId', dbEntry.id);
+
+ await reviewPage.loadPage();
+
+ await reviewPage.keepInDraftButton.click();
+
+ await expect(page).toHaveURL(
+ `${baseURL}/templates/message-plans/choose-templates/${dbEntry.id}`
+ );
+
+ const chooseTemplatesPage = new RoutingChooseTemplatesPage(page);
+
+ await expect(chooseTemplatesPage.messagePlanStatus).toHaveText('Draft');
+ });
+
+ test('move to production button submits plan and navigates to message plans page', async ({
+ page,
+ baseURL,
+ }) => {
+ const { dbEntry } = RoutingConfigFactory.createWithChannels(
+ user,
+ ['NHSAPP'],
+ { status: 'DRAFT' }
+ ).addTemplate('NHSAPP', templates.NHSAPP.id);
+
+ await routingConfigStorageHelper.seed([dbEntry]);
+
+ const reviewPage = new RoutingReviewAndMoveToProductionPage(
+ page
+ ).setPathParam('messagePlanId', dbEntry.id);
+
+ await reviewPage.loadPage();
+
+ await reviewPage.moveToProductionButton.click();
+
+ await expect(page).toHaveURL(`${baseURL}/templates/message-plans`);
+
+ const messagePlansPage = new RoutingMessagePlansPage(page);
+
+ // Verify the plan now appears in the production section
+ const productionIdCells =
+ messagePlansPage.productionMessagePlansTable.getByTestId(
+ 'message-plan-id-cell'
+ );
+
+ const productionCellsText = await productionIdCells.allTextContents();
+
+ expect(productionCellsText).toContainEqual(
+ expect.stringContaining(dbEntry.id)
+ );
+ });
+});