Email
-
- Template 1
-
+
+ Template 1
+
+
@@ -662,14 +692,14 @@ exports[`MessagePlanChannelList should match snapshot for a routing plan with a
- Letter
+ Standard English letter
-
- Template 2
-
+
+ Template 2
+
+
@@ -825,7 +861,7 @@ exports[`MessagePlanChannelList should match snapshot for a routing plan with a
- Letter
+ Standard English letter
template
@@ -833,14 +869,14 @@ exports[`MessagePlanChannelList should match snapshot for a routing plan with a
+
+
+
+
+
+
+ Conditions for accessible and language letters
+
+
+
+
+
+
+ The relevant accessible or language letter will be sent instead of the standard English letter if, both:
+
+
+ the recipient has requested an accessible or language letter in PDS
+
+
+ you've included the relevant template in this message plan
+
+
+
+
+
+
+
+
+
+
+
+ Large print letter (optional)
+
+
+
+
+
+
+
+
+
+ Other language letters (optional)
+
+
+
+
+
+
diff --git a/frontend/src/__tests__/utils/get-message-plan-template-ids.test.ts b/frontend/src/__tests__/utils/get-message-plan-template-ids.test.ts
deleted file mode 100644
index 5dd7bc155..000000000
--- a/frontend/src/__tests__/utils/get-message-plan-template-ids.test.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { getMessagePlanTemplateIds } from '@utils/get-message-plan-template-ids';
-import type { RoutingConfig } from 'nhs-notify-backend-client';
-
-const baseConfig: RoutingConfig = {
- id: 'test-id',
- name: 'Test message plan',
- status: 'DRAFT',
- clientId: 'client-1',
- campaignId: 'campaign-1',
- createdAt: '2025-01-01T00:00:00.000Z',
- updatedAt: '2025-01-01T00:00:00.000Z',
- cascade: [],
- cascadeGroupOverrides: [],
- defaultCascadeGroup: 'standard',
-};
-
-describe('getMessagePlanTemplateIds', () => {
- it('should collect unique template IDs from defaults and conditionals', () => {
- const plan: RoutingConfig = {
- ...baseConfig,
- cascade: [
- {
- cascadeGroups: ['standard'],
- channel: 'NHSAPP',
- channelType: 'primary',
- defaultTemplateId: 'template-1',
- },
- {
- cascadeGroups: ['standard'],
- channel: 'EMAIL',
- channelType: 'primary',
- conditionalTemplates: [
- { templateId: 'template-2', language: 'fr' },
- { templateId: 'template-3', language: 'fr' },
- ],
- },
- {
- cascadeGroups: ['standard'],
- channel: 'SMS',
- channelType: 'primary',
- defaultTemplateId: 'template-1',
- conditionalTemplates: [{ templateId: 'template-2', language: 'fr' }],
- },
- ],
- };
-
- const ids = [...getMessagePlanTemplateIds(plan)].sort();
- expect(ids).toEqual(['template-1', 'template-2', 'template-3']);
- });
-
- it('should return empty set when there are no templates', () => {
- const plan: RoutingConfig = {
- ...baseConfig,
- cascade: [
- {
- cascadeGroups: ['standard'],
- channel: 'NHSAPP',
- channelType: 'primary',
- defaultTemplateId: '',
- },
- ],
- };
- const ids = getMessagePlanTemplateIds(plan);
- expect(ids.size).toBe(0);
- });
-});
diff --git a/frontend/src/__tests__/utils/interpolate.test.ts b/frontend/src/__tests__/utils/interpolate.test.ts
index 4653d4a9c..584f890e0 100644
--- a/frontend/src/__tests__/utils/interpolate.test.ts
+++ b/frontend/src/__tests__/utils/interpolate.test.ts
@@ -5,6 +5,10 @@ describe('interpolate', () => {
expect(interpolate('', { name: 'Test' })).toBe('');
});
+ it('uses default empty object when no variables provided', () => {
+ expect(interpolate('Hello {{name}}!')).toBe('Hello !');
+ });
+
it('returns the same string if no placeholders are present', () => {
expect(interpolate('No interpolation needed.', {})).toBe(
'No interpolation needed.'
@@ -24,6 +28,14 @@ describe('interpolate', () => {
it('replaces plural based on numeric value', () => {
expect(interpolate('{{count|item|items}}', { count: 1 })).toBe('item');
expect(interpolate('{{count|item|items}}', { count: 3 })).toBe('items');
+ expect(interpolate('{{count|item|items}}', { count: 0 })).toBe('items');
+ });
+
+ it('uses empty string in singular position', () => {
+ expect(interpolate('Remove{{count|| all}}', { count: 1 })).toBe('Remove');
+ expect(interpolate('Remove{{count|| all}}', { count: 2 })).toBe(
+ 'Remove all'
+ );
});
it('falls back to plural if variable is not a number', () => {
diff --git a/frontend/src/__tests__/utils/routing-utils.test.ts b/frontend/src/__tests__/utils/routing-utils.test.ts
new file mode 100644
index 000000000..ea899c6b1
--- /dev/null
+++ b/frontend/src/__tests__/utils/routing-utils.test.ts
@@ -0,0 +1,774 @@
+import {
+ getMessagePlanTemplateIds,
+ shouldRemoveTemplate,
+ removeTemplatesFromConditionalTemplates,
+ removeTemplatesFromCascadeItem,
+ getRemainingAccessibleFormats,
+ getRemainingLanguages,
+ updateCascadeGroupOverrides,
+ buildCascadeGroupsForItem,
+ getConditionalTemplatesForItem,
+ type ConditionalTemplate,
+ type MessagePlanTemplates,
+} from '@utils/routing-utils';
+import type {
+ CascadeItem,
+ Language,
+ LetterType,
+ RoutingConfig,
+ TemplateDto,
+} from 'nhs-notify-backend-client';
+
+const baseConfig: RoutingConfig = {
+ id: 'test-id',
+ name: 'Test message plan',
+ status: 'DRAFT',
+ clientId: 'client-1',
+ campaignId: 'campaign-1',
+ createdAt: '2025-01-01T00:00:00.000Z',
+ updatedAt: '2025-01-01T00:00:00.000Z',
+ cascade: [],
+ cascadeGroupOverrides: [],
+ defaultCascadeGroup: 'standard',
+};
+
+describe('getMessagePlanTemplateIds', () => {
+ it('should collect unique template IDs from defaults and conditionals', () => {
+ const plan: RoutingConfig = {
+ ...baseConfig,
+ cascade: [
+ {
+ cascadeGroups: ['standard'],
+ channel: 'NHSAPP',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ },
+ {
+ cascadeGroups: ['standard'],
+ channel: 'EMAIL',
+ channelType: 'primary',
+ conditionalTemplates: [
+ { templateId: 'template-2', language: 'fr' },
+ { templateId: 'template-3', language: 'fr' },
+ ],
+ },
+ {
+ cascadeGroups: ['standard'],
+ channel: 'SMS',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ conditionalTemplates: [{ templateId: 'template-2', language: 'fr' }],
+ },
+ ],
+ };
+
+ const ids = [...getMessagePlanTemplateIds(plan)].sort();
+ expect(ids).toEqual(['template-1', 'template-2', 'template-3']);
+ });
+
+ it('should return empty set when there are no templates', () => {
+ const plan: RoutingConfig = {
+ ...baseConfig,
+ cascade: [
+ {
+ cascadeGroups: ['standard'],
+ channel: 'NHSAPP',
+ channelType: 'primary',
+ defaultTemplateId: '',
+ },
+ ],
+ };
+ const ids = getMessagePlanTemplateIds(plan);
+ expect(ids.size).toBe(0);
+ });
+});
+
+describe('shouldRemoveTemplate', () => {
+ it('should return true when template ID is in removal list', () => {
+ expect(
+ shouldRemoveTemplate('template-1', ['template-1', 'template-2'])
+ ).toBe(true);
+ });
+
+ it('should return false when template ID is not in removal list', () => {
+ expect(
+ shouldRemoveTemplate('template-3', ['template-1', 'template-2'])
+ ).toBe(false);
+ });
+
+ it('should return false when template ID is null', () => {
+ expect(shouldRemoveTemplate(null, ['template-1', 'template-2'])).toBe(
+ false
+ );
+ });
+
+ it('should return false when template ID is undefined', () => {
+ expect(shouldRemoveTemplate(undefined, ['template-1', 'template-2'])).toBe(
+ false
+ );
+ });
+
+ it('should return false when template ID is empty string', () => {
+ expect(shouldRemoveTemplate('', ['template-1', 'template-2'])).toBe(false);
+ });
+});
+
+describe('removeTemplatesFromConditionalTemplates', () => {
+ it('should remove templates matching the IDs to remove', () => {
+ const conditionalTemplates: ConditionalTemplate[] = [
+ { templateId: 'template-1', language: 'fr' },
+ { templateId: 'template-2', language: 'es' },
+ { templateId: 'template-3', accessibleFormat: 'q4' },
+ ];
+
+ const result = removeTemplatesFromConditionalTemplates(
+ conditionalTemplates,
+ ['template-1', 'template-3']
+ );
+
+ expect(result).toEqual([{ templateId: 'template-2', language: 'es' }]);
+ });
+
+ it('should keep templates with null templateId', () => {
+ const conditionalTemplates: ConditionalTemplate[] = [
+ { templateId: 'template-1', language: 'fr' },
+ { templateId: null, language: 'es' },
+ ];
+
+ const result = removeTemplatesFromConditionalTemplates(
+ conditionalTemplates,
+ ['template-1']
+ );
+
+ expect(result).toEqual([{ templateId: null, language: 'es' }]);
+ });
+
+ it('should return empty array when all templates are removed', () => {
+ const conditionalTemplates: ConditionalTemplate[] = [
+ { templateId: 'template-1', language: 'fr' },
+ { templateId: 'template-2', language: 'es' },
+ ];
+
+ const result = removeTemplatesFromConditionalTemplates(
+ conditionalTemplates,
+ ['template-1', 'template-2']
+ );
+
+ expect(result).toEqual([]);
+ });
+});
+
+describe('removeTemplatesFromCascadeItem', () => {
+ it('should remove default template when in removal list', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard'],
+ channel: 'EMAIL',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ };
+
+ const result = removeTemplatesFromCascadeItem(cascadeItem, ['template-1']);
+
+ expect(result.defaultTemplateId).toBeNull();
+ });
+
+ it('should keep default template when not in removal list', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard'],
+ channel: 'EMAIL',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ };
+
+ const result = removeTemplatesFromCascadeItem(cascadeItem, ['template-2']);
+
+ expect(result.defaultTemplateId).toBe('template-1');
+ });
+
+ it('should remove conditional templates in removal list', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['translations'],
+ channel: 'EMAIL',
+ channelType: 'primary',
+ defaultTemplateId: null,
+ conditionalTemplates: [
+ { templateId: 'template-1', language: 'fr' },
+ { templateId: 'template-2', language: 'es' },
+ { templateId: 'template-3', language: 'de' },
+ ],
+ };
+
+ const result = removeTemplatesFromCascadeItem(cascadeItem, [
+ 'template-1',
+ 'template-3',
+ ]);
+
+ expect(result.conditionalTemplates).toHaveLength(1);
+ expect(result.conditionalTemplates).toEqual([
+ { templateId: 'template-2', language: 'es' },
+ ]);
+ });
+
+ it('should remove both default and conditional templates', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['translations'],
+ channel: 'SMS',
+ channelType: 'primary',
+ defaultTemplateId: 'template-default',
+ conditionalTemplates: [
+ { templateId: 'template-1', language: 'fr' },
+ { templateId: 'template-2', language: 'es' },
+ ],
+ };
+
+ const result = removeTemplatesFromCascadeItem(cascadeItem, [
+ 'template-default',
+ 'template-1',
+ ]);
+
+ expect(result.defaultTemplateId).toBeNull();
+ expect(result.conditionalTemplates).toHaveLength(1);
+ expect(result.conditionalTemplates).toEqual([
+ { templateId: 'template-2', language: 'es' },
+ ]);
+ });
+
+ it('should handle cascade item with no conditional templates', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard'],
+ channel: 'NHSAPP',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ };
+
+ const result = removeTemplatesFromCascadeItem(cascadeItem, ['template-2']);
+
+ expect(result.defaultTemplateId).toBe('template-1');
+ expect(result.conditionalTemplates).toBeUndefined();
+ });
+
+ it('should not mutate the original cascade item', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard'],
+ channel: 'EMAIL',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ conditionalTemplates: [{ templateId: 'template-2', language: 'fr' }],
+ };
+
+ const originalDefaultId = cascadeItem.defaultTemplateId;
+ const originalConditionalLength = cascadeItem.conditionalTemplates?.length;
+
+ removeTemplatesFromCascadeItem(cascadeItem, ['template-1', 'template-2']);
+
+ expect(cascadeItem.defaultTemplateId).toBe(originalDefaultId);
+ expect(cascadeItem.conditionalTemplates).toHaveLength(
+ originalConditionalLength!
+ );
+ });
+
+ it('should update cascadeGroups when removing all conditional templates', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard', 'translations'],
+ channel: 'LETTER',
+ channelType: 'primary',
+ defaultTemplateId: 'standard-template',
+ conditionalTemplates: [{ templateId: 'template-1', language: 'fr' }],
+ };
+
+ const result = removeTemplatesFromCascadeItem(cascadeItem, ['template-1']);
+
+ expect(result.conditionalTemplates).toBeUndefined();
+ expect(result.cascadeGroups).toEqual(['standard']);
+ });
+
+ it('should update cascadeGroups when removing some but not all conditional templates', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard', 'accessible', 'translations'],
+ channel: 'LETTER',
+ channelType: 'primary',
+ defaultTemplateId: 'standard-template',
+ conditionalTemplates: [
+ { templateId: 'template-1', language: 'fr' },
+ { templateId: 'template-2', accessibleFormat: 'q4' },
+ ],
+ };
+
+ const result = removeTemplatesFromCascadeItem(cascadeItem, ['template-1']);
+
+ expect(result.cascadeGroups).toEqual(['standard', 'accessible']);
+ });
+});
+
+describe('getConditionalTemplatesForItem', () => {
+ const templates: MessagePlanTemplates = {
+ 'template-1': { id: 'template-1', name: 'Template 1' } as TemplateDto,
+ 'template-2': { id: 'template-2', name: 'Template 2' } as TemplateDto,
+ 'template-3': { id: 'template-3', name: 'Template 3' } as TemplateDto,
+ };
+
+ it('should return empty object when no conditional templates', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard'],
+ channel: 'EMAIL',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ };
+
+ const result = getConditionalTemplatesForItem(cascadeItem, templates);
+
+ expect(result).toEqual({});
+ });
+
+ it('should return empty object when conditionalTemplates is empty array', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard'],
+ channel: 'LETTER',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ conditionalTemplates: [],
+ };
+
+ const result = getConditionalTemplatesForItem(cascadeItem, templates);
+
+ expect(result).toEqual({});
+ });
+
+ it('should return templates that exist in templates object', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard', 'translations'],
+ channel: 'LETTER',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ conditionalTemplates: [
+ { templateId: 'template-2', language: 'fr' },
+ { templateId: 'template-3', language: 'es' },
+ ],
+ };
+
+ const result = getConditionalTemplatesForItem(cascadeItem, templates);
+
+ expect(result).toEqual({
+ 'template-2': templates['template-2'],
+ 'template-3': templates['template-3'],
+ });
+ });
+
+ it('should filter out templates with a missing/invalid templateId', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard', 'translations'],
+ channel: 'LETTER',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ conditionalTemplates: [
+ { templateId: 'template-2', language: 'fr' },
+ { templateId: null, language: 'es' },
+ { accessibleFormat: 'x1' } as ConditionalTemplate,
+ ],
+ };
+
+ const result = getConditionalTemplatesForItem(cascadeItem, templates);
+
+ expect(result).toEqual({
+ 'template-2': templates['template-2'],
+ });
+ });
+
+ it('should not include templates that are missing from templates object', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard', 'translations'],
+ channel: 'LETTER',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ conditionalTemplates: [
+ { templateId: 'template-2', language: 'fr' },
+ { templateId: 'template-999', language: 'es' },
+ ],
+ };
+
+ const result = getConditionalTemplatesForItem(cascadeItem, templates);
+
+ expect(result).toEqual({
+ 'template-2': templates['template-2'],
+ });
+ });
+
+ it('should handle mix of accessible and language templates', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard', 'accessible', 'translations'],
+ channel: 'LETTER',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ conditionalTemplates: [
+ { templateId: 'template-2', accessibleFormat: 'q4' },
+ { templateId: 'template-3', language: 'fr' },
+ ],
+ };
+
+ const result = getConditionalTemplatesForItem(cascadeItem, templates);
+
+ expect(result).toEqual({
+ 'template-2': templates['template-2'],
+ 'template-3': templates['template-3'],
+ });
+ });
+});
+
+describe('buildCascadeGroupsForItem', () => {
+ it('should return only standard group when no conditional templates', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard'],
+ channel: 'EMAIL',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ };
+
+ expect(buildCascadeGroupsForItem(cascadeItem)).toEqual(['standard']);
+ });
+
+ it('should return standard and accessible groups when accessible format present', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard'],
+ channel: 'LETTER',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ conditionalTemplates: [
+ { templateId: 'template-2', accessibleFormat: 'q4' },
+ ],
+ };
+
+ expect(buildCascadeGroupsForItem(cascadeItem)).toEqual([
+ 'standard',
+ 'accessible',
+ ]);
+ });
+
+ it('should return standard and translations groups when language present', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard'],
+ channel: 'LETTER',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ conditionalTemplates: [{ templateId: 'template-2', language: 'fr' }],
+ };
+
+ expect(buildCascadeGroupsForItem(cascadeItem)).toEqual([
+ 'standard',
+ 'translations',
+ ]);
+ });
+
+ it('should return all groups when both accessible format and language present', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard'],
+ channel: 'LETTER',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ conditionalTemplates: [
+ { templateId: 'template-2', accessibleFormat: 'q4' },
+ { templateId: 'template-3', language: 'fr' },
+ ],
+ };
+
+ expect(buildCascadeGroupsForItem(cascadeItem)).toEqual([
+ 'standard',
+ 'accessible',
+ 'translations',
+ ]);
+ });
+
+ it('should return only standard group when conditional templates have missing templateIds', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard'],
+ channel: 'LETTER',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ conditionalTemplates: [
+ { templateId: null, accessibleFormat: 'q4' },
+ { templateId: 'template-2', language: 'fr' },
+ ],
+ };
+
+ expect(buildCascadeGroupsForItem(cascadeItem)).toEqual([
+ 'standard',
+ 'translations',
+ ]);
+ });
+
+ it('should return only standard when conditional templates array is empty', () => {
+ const cascadeItem: CascadeItem = {
+ cascadeGroups: ['standard'],
+ channel: 'LETTER',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ conditionalTemplates: [],
+ };
+
+ expect(buildCascadeGroupsForItem(cascadeItem)).toEqual(['standard']);
+ });
+});
+
+describe('getRemainingAccessibleFormats', () => {
+ it('should collect all unique accessible format types', () => {
+ const cascade: CascadeItem[] = [
+ {
+ cascadeGroups: ['accessible'],
+ channel: 'LETTER',
+ channelType: 'primary',
+ defaultTemplateId: null,
+ conditionalTemplates: [
+ { templateId: 'template-1', accessibleFormat: 'q4' },
+ { templateId: 'template-2', accessibleFormat: 'x0' },
+ ],
+ },
+ {
+ cascadeGroups: ['accessible'],
+ channel: 'LETTER',
+ channelType: 'secondary',
+ defaultTemplateId: null,
+ conditionalTemplates: [
+ { templateId: 'template-3', accessibleFormat: 'q4' },
+ ],
+ },
+ ];
+
+ const result = getRemainingAccessibleFormats(cascade);
+
+ expect(result.sort()).toEqual(['q4', 'x0'].sort());
+ });
+
+ it('should ignore templates with null templateId', () => {
+ const cascade: CascadeItem[] = [
+ {
+ cascadeGroups: ['accessible'],
+ channel: 'LETTER',
+ channelType: 'primary',
+ defaultTemplateId: null,
+ conditionalTemplates: [
+ { templateId: 'template-1', accessibleFormat: 'q4' },
+ { templateId: null, accessibleFormat: 'x0' },
+ ],
+ },
+ ];
+
+ const result = getRemainingAccessibleFormats(cascade);
+
+ expect(result).toEqual(['q4']);
+ });
+
+ it('should return empty array when no accessible templates exist', () => {
+ const cascade: CascadeItem[] = [
+ {
+ cascadeGroups: ['standard'],
+ channel: 'EMAIL',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ },
+ ];
+
+ const result = getRemainingAccessibleFormats(cascade);
+
+ expect(result).toEqual([]);
+ });
+});
+
+describe('getRemainingLanguages', () => {
+ it('should collect all unique language types', () => {
+ const cascade: CascadeItem[] = [
+ {
+ cascadeGroups: ['translations'],
+ channel: 'EMAIL',
+ channelType: 'primary',
+ defaultTemplateId: null,
+ conditionalTemplates: [
+ { templateId: 'template-1', language: 'fr' },
+ { templateId: 'template-2', language: 'es' },
+ ],
+ },
+ {
+ cascadeGroups: ['translations'],
+ channel: 'SMS',
+ channelType: 'primary',
+ defaultTemplateId: null,
+ conditionalTemplates: [{ templateId: 'template-3', language: 'fr' }],
+ },
+ ];
+
+ const result = getRemainingLanguages(cascade);
+
+ expect(result.sort()).toEqual(['es', 'fr']);
+ });
+
+ it('should ignore templates with null templateId', () => {
+ const cascade: CascadeItem[] = [
+ {
+ cascadeGroups: ['translations'],
+ channel: 'EMAIL',
+ channelType: 'primary',
+ defaultTemplateId: null,
+ conditionalTemplates: [
+ { templateId: 'template-1', language: 'fr' },
+ { templateId: null, language: 'es' },
+ ],
+ },
+ ];
+
+ const result = getRemainingLanguages(cascade);
+
+ expect(result).toEqual(['fr']);
+ });
+
+ it('should return empty array when no language templates exist', () => {
+ const cascade: CascadeItem[] = [
+ {
+ cascadeGroups: ['standard'],
+ channel: 'EMAIL',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ },
+ ];
+
+ const result = getRemainingLanguages(cascade);
+
+ expect(result).toEqual([]);
+ });
+});
+
+describe('updateCascadeGroupOverrides', () => {
+ it('should update accessible group with remaining formats', () => {
+ const cascadeGroupOverrides = [
+ {
+ name: 'accessible' as const,
+ accessibleFormat: ['q4', 'x0', 'x1'] as LetterType[],
+ },
+ { name: 'standard' as const },
+ ];
+
+ const updatedCascade: CascadeItem[] = [
+ {
+ cascadeGroups: ['accessible'],
+ channel: 'LETTER',
+ channelType: 'primary',
+ defaultTemplateId: null,
+ conditionalTemplates: [
+ { templateId: 'template-1', accessibleFormat: 'q4' },
+ ],
+ },
+ ];
+
+ const result = updateCascadeGroupOverrides(
+ cascadeGroupOverrides,
+ updatedCascade
+ );
+
+ expect(result).toEqual([
+ { name: 'accessible', accessibleFormat: ['q4'] },
+ { name: 'standard' },
+ ]);
+ });
+
+ it('should update translations group with remaining languages', () => {
+ const cascadeGroupOverrides = [
+ {
+ name: 'translations' as const,
+ language: ['fr', 'es', 'de'] as Language[],
+ },
+ { name: 'standard' as const },
+ ];
+
+ const updatedCascade: CascadeItem[] = [
+ {
+ cascadeGroups: ['translations'],
+ channel: 'EMAIL',
+ channelType: 'primary',
+ defaultTemplateId: null,
+ conditionalTemplates: [
+ { templateId: 'template-1', language: 'fr' },
+ { templateId: 'template-2', language: 'es' },
+ ],
+ },
+ ];
+
+ const result = updateCascadeGroupOverrides(
+ cascadeGroupOverrides,
+ updatedCascade
+ );
+
+ expect(result.length).toBe(2);
+ expect(result[0]).toMatchObject({
+ name: 'translations',
+ language: ['fr', 'es'],
+ });
+
+ expect(result[1]).toEqual({ name: 'standard' });
+ });
+
+ it('should remove accessible group when no formats remain', () => {
+ const cascadeGroupOverrides = [
+ {
+ name: 'accessible' as const,
+ accessibleFormat: ['q4'] as LetterType[],
+ },
+ { name: 'standard' as const },
+ ];
+
+ const updatedCascade: CascadeItem[] = [
+ {
+ cascadeGroups: ['standard'],
+ channel: 'EMAIL',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ },
+ ];
+
+ const result = updateCascadeGroupOverrides(
+ cascadeGroupOverrides,
+ updatedCascade
+ );
+
+ expect(result).toEqual([{ name: 'standard' }]);
+ });
+
+ it('should remove translations group when no languages remain', () => {
+ const cascadeGroupOverrides = [
+ { name: 'translations' as const, language: ['fr'] as Language[] },
+ { name: 'standard' as const },
+ ];
+
+ const updatedCascade: CascadeItem[] = [
+ {
+ cascadeGroups: ['standard'],
+ channel: 'EMAIL',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ },
+ ];
+
+ const result = updateCascadeGroupOverrides(
+ cascadeGroupOverrides,
+ updatedCascade
+ );
+
+ expect(result).toEqual([{ name: 'standard' }]);
+ });
+
+ it('should keep standard group unchanged', () => {
+ const cascadeGroupOverrides = [{ name: 'standard' as const }];
+
+ const updatedCascade: CascadeItem[] = [
+ {
+ cascadeGroups: ['standard'],
+ channel: 'EMAIL',
+ channelType: 'primary',
+ defaultTemplateId: 'template-1',
+ },
+ ];
+
+ const result = updateCascadeGroupOverrides(
+ cascadeGroupOverrides,
+ updatedCascade
+ );
+
+ expect(result).toEqual([{ name: 'standard' }]);
+ });
+});
diff --git a/frontend/src/app/message-plans/choose-templates/[routingConfigId]/actions.test.ts b/frontend/src/app/message-plans/choose-templates/[routingConfigId]/actions.test.ts
index b3c52f48f..ac98dd366 100644
--- a/frontend/src/app/message-plans/choose-templates/[routingConfigId]/actions.test.ts
+++ b/frontend/src/app/message-plans/choose-templates/[routingConfigId]/actions.test.ts
@@ -2,9 +2,13 @@ import { randomUUID } from 'node:crypto';
import { removeTemplateFromMessagePlan } from './actions';
import { getRoutingConfig, updateRoutingConfig } from '@utils/message-plans';
import {
+ CascadeGroupAccessible,
CascadeGroupName,
+ CascadeGroupTranslations,
Channel,
ChannelType,
+ Language,
+ LetterType,
RoutingConfigStatus,
} from 'nhs-notify-backend-client';
import { redirect } from 'next/navigation';
@@ -18,6 +22,13 @@ const mockGetRoutingConfig = jest.mocked(getRoutingConfig);
const mockUpdateRoutingConfig = jest.mocked(updateRoutingConfig);
const routingConfigId = randomUUID();
+const emailTemplateId = randomUUID();
+const smsTemplateId = randomUUID();
+const polishTemplateId = randomUUID();
+const frenchTemplateId = randomUUID();
+const accessibleFormatId = randomUUID();
+const largePrintId = randomUUID();
+
const baseConfig = {
id: routingConfigId,
campaignId: 'campaign1',
@@ -31,13 +42,13 @@ const baseConfig = {
channel: 'EMAIL' as Channel,
channelType: 'primary' as ChannelType,
cascadeGroups: ['standard' as CascadeGroupName],
- defaultTemplateId: 'template-1',
+ defaultTemplateId: emailTemplateId,
},
{
channel: 'SMS' as Channel,
channelType: 'primary' as ChannelType,
cascadeGroups: ['standard' as CascadeGroupName],
- defaultTemplateId: 'template-2',
+ defaultTemplateId: smsTemplateId,
},
],
cascadeGroupOverrides: [],
@@ -49,12 +60,12 @@ describe('removeTemplateFromMessagePlan', () => {
jest.clearAllMocks();
});
- it('removes the template from the correct channel and updates the routing configuration', async () => {
+ it('removes the correct template from the cascade item and updates the routing configuration', async () => {
mockGetRoutingConfig.mockResolvedValue(baseConfig);
const formData = new FormData();
formData.set('routingConfigId', routingConfigId);
- formData.set('channel', 'EMAIL');
+ formData.set('templateId', emailTemplateId);
await removeTemplateFromMessagePlan(formData);
@@ -70,20 +81,272 @@ describe('removeTemplateFromMessagePlan', () => {
}),
expect.objectContaining({
channel: 'SMS',
- defaultTemplateId: 'template-2',
+ defaultTemplateId: smsTemplateId,
+ }),
+ ],
+ })
+ );
+ });
+
+ it('removes multiple templates at once', async () => {
+ const configWithConditionalTemplates = {
+ ...baseConfig,
+ cascade: [
+ {
+ ...baseConfig.cascade[0],
+ conditionalTemplates: [
+ {
+ accessibleFormat: 'x1' as LetterType,
+ templateId: largePrintId,
+ },
+ { language: 'pl' as Language, templateId: polishTemplateId },
+ { language: 'fr' as Language, templateId: frenchTemplateId },
+ ],
+ },
+ baseConfig.cascade[1],
+ ],
+ };
+
+ mockGetRoutingConfig.mockResolvedValue(configWithConditionalTemplates);
+
+ const formData = new FormData();
+ formData.set('routingConfigId', routingConfigId);
+ formData.append('templateId', polishTemplateId);
+ formData.append('templateId', frenchTemplateId);
+
+ await removeTemplateFromMessagePlan(formData);
+
+ expect(mockUpdateRoutingConfig).toHaveBeenCalledWith(
+ routingConfigId,
+ expect.objectContaining({
+ cascade: [
+ expect.objectContaining({
+ channel: 'EMAIL',
+ defaultTemplateId: emailTemplateId,
+ conditionalTemplates: [
+ {
+ accessibleFormat: 'x1',
+ templateId: largePrintId,
+ },
+ ],
+ }),
+ expect.objectContaining({
+ channel: 'SMS',
+ }),
+ ],
+ })
+ );
+ });
+
+ it('removes conditional templates from cascade items', async () => {
+ const configWithConditionalTemplate = {
+ ...baseConfig,
+ cascade: [
+ {
+ ...baseConfig.cascade[0],
+ channel: 'LETTER' as Channel,
+ conditionalTemplates: [
+ { accessibleFormat: 'x1' as LetterType, templateId: largePrintId },
+ ],
+ },
+ ],
+ };
+
+ mockGetRoutingConfig.mockResolvedValue(configWithConditionalTemplate);
+
+ const formData = new FormData();
+ formData.set('routingConfigId', routingConfigId);
+ formData.set('templateId', largePrintId);
+
+ await removeTemplateFromMessagePlan(formData);
+
+ expect(mockUpdateRoutingConfig).toHaveBeenCalledWith(
+ routingConfigId,
+ expect.objectContaining({
+ cascade: [
+ expect.objectContaining({
+ channel: 'LETTER',
+ }),
+ ],
+ })
+ );
+
+ // Verify conditionalTemplates was removed
+ const [[, updateConfig]] = mockUpdateRoutingConfig.mock.calls;
+ expect(updateConfig.cascade?.[0].conditionalTemplates).toBeUndefined();
+ });
+
+ it('updates cascadeGroupOverrides when templates are removed', async () => {
+ const configWithOverrides = {
+ ...baseConfig,
+ cascade: [
+ {
+ channel: 'LETTER' as Channel,
+ channelType: 'primary' as ChannelType,
+ cascadeGroups: [
+ 'standard' as CascadeGroupName,
+ 'accessible' as CascadeGroupName,
+ 'translations' as CascadeGroupName,
+ ],
+ defaultTemplateId: emailTemplateId,
+ conditionalTemplates: [
+ {
+ accessibleFormat: 'q4' as LetterType,
+ templateId: accessibleFormatId,
+ },
+ {
+ accessibleFormat: 'x1' as LetterType,
+ templateId: largePrintId,
+ },
+ { language: 'pl' as Language, templateId: polishTemplateId },
+ { language: 'fr' as Language, templateId: frenchTemplateId },
+ ],
+ },
+ ],
+ cascadeGroupOverrides: [
+ {
+ name: 'accessible' as CascadeGroupName,
+ accessibleFormat: ['q4' as LetterType, 'x1' as LetterType],
+ } as CascadeGroupAccessible,
+ {
+ name: 'translations' as CascadeGroupName,
+ language: ['pl' as Language, 'fr' as Language],
+ } as CascadeGroupTranslations,
+ ],
+ };
+
+ mockGetRoutingConfig.mockResolvedValue(configWithOverrides);
+
+ const formData = new FormData();
+ formData.set('routingConfigId', routingConfigId);
+ formData.append('templateId', accessibleFormatId);
+ formData.append('templateId', polishTemplateId);
+ formData.append('templateId', frenchTemplateId);
+
+ await removeTemplateFromMessagePlan(formData);
+
+ expect(mockUpdateRoutingConfig).toHaveBeenCalledWith(
+ routingConfigId,
+ expect.objectContaining({
+ cascadeGroupOverrides: [
+ {
+ name: 'accessible',
+ accessibleFormat: ['x1'],
+ },
+ ],
+ })
+ );
+ });
+
+ it('updates cascadeGroups on cascade items when templates are removed', async () => {
+ const configWithConditionalTemplates = {
+ ...baseConfig,
+ cascade: [
+ {
+ channel: 'LETTER' as Channel,
+ channelType: 'primary' as ChannelType,
+ cascadeGroups: [
+ 'standard' as CascadeGroupName,
+ 'accessible' as CascadeGroupName,
+ 'translations' as CascadeGroupName,
+ ],
+ defaultTemplateId: emailTemplateId,
+ conditionalTemplates: [
+ {
+ accessibleFormat: 'x1' as LetterType,
+ templateId: largePrintId,
+ },
+ { language: 'pl' as Language, templateId: polishTemplateId },
+ { language: 'fr' as Language, templateId: frenchTemplateId },
+ ],
+ },
+ ],
+ cascadeGroupOverrides: [],
+ };
+
+ mockGetRoutingConfig.mockResolvedValue(configWithConditionalTemplates);
+
+ const formData = new FormData();
+ formData.set('routingConfigId', routingConfigId);
+ formData.append('templateId', polishTemplateId);
+ formData.append('templateId', frenchTemplateId);
+
+ await removeTemplateFromMessagePlan(formData);
+
+ expect(mockUpdateRoutingConfig).toHaveBeenCalledWith(
+ routingConfigId,
+ expect.objectContaining({
+ cascade: [
+ expect.objectContaining({
+ channel: 'LETTER',
+ cascadeGroups: ['standard', 'accessible'],
+ conditionalTemplates: [
+ {
+ accessibleFormat: 'x1',
+ templateId: largePrintId,
+ },
+ ],
}),
],
})
);
});
+ it('updates cascadeGroups to only standard when all conditional templates are removed', async () => {
+ const configWithConditionalTemplates = {
+ ...baseConfig,
+ cascade: [
+ {
+ channel: 'LETTER' as Channel,
+ channelType: 'primary' as ChannelType,
+ cascadeGroups: [
+ 'standard' as CascadeGroupName,
+ 'accessible' as CascadeGroupName,
+ ],
+ defaultTemplateId: emailTemplateId,
+ conditionalTemplates: [
+ {
+ accessibleFormat: 'x1' as LetterType,
+ templateId: largePrintId,
+ },
+ ],
+ },
+ ],
+ cascadeGroupOverrides: [],
+ };
+
+ mockGetRoutingConfig.mockResolvedValue(configWithConditionalTemplates);
+
+ const formData = new FormData();
+ formData.set('routingConfigId', routingConfigId);
+ formData.set('templateId', largePrintId);
+
+ await removeTemplateFromMessagePlan(formData);
+
+ expect(mockUpdateRoutingConfig).toHaveBeenCalledWith(
+ routingConfigId,
+ expect.objectContaining({
+ cascade: [
+ expect.objectContaining({
+ channel: 'LETTER',
+ cascadeGroups: ['standard'],
+ }),
+ ],
+ })
+ );
+
+ // Verify conditionalTemplates was removed
+ const [[, updateConfig]] = mockUpdateRoutingConfig.mock.calls;
+ expect(updateConfig.cascade?.[0].conditionalTemplates).toBeUndefined();
+ });
+
it('refreshes the choose-templates page after successful removal', async () => {
mockGetRoutingConfig.mockResolvedValue(baseConfig);
mockUpdateRoutingConfig.mockResolvedValue(undefined);
const formData = new FormData();
formData.set('routingConfigId', routingConfigId);
- formData.set('channel', 'EMAIL');
+ formData.set('templateId', emailTemplateId);
await removeTemplateFromMessagePlan(formData);
@@ -97,7 +360,7 @@ describe('removeTemplateFromMessagePlan', () => {
const formData = new FormData();
formData.set('routingConfigId', routingConfigId);
- formData.set('channel', 'EMAIL');
+ formData.set('templateId', emailTemplateId);
await expect(removeTemplateFromMessagePlan(formData)).rejects.toThrow(
/not found/
@@ -115,7 +378,7 @@ describe('removeTemplateFromMessagePlan', () => {
it('throws an error if form data is invalid', async () => {
const formData = new FormData();
formData.set('routingConfigId', 'invalid-id');
- formData.set('channel', 'test');
+ formData.set('templateId', '');
await expect(removeTemplateFromMessagePlan(formData)).rejects.toThrow(
/Invalid form data/
diff --git a/frontend/src/app/message-plans/choose-templates/[routingConfigId]/actions.ts b/frontend/src/app/message-plans/choose-templates/[routingConfigId]/actions.ts
index 8ffe8618f..75166f3b4 100644
--- a/frontend/src/app/message-plans/choose-templates/[routingConfigId]/actions.ts
+++ b/frontend/src/app/message-plans/choose-templates/[routingConfigId]/actions.ts
@@ -2,25 +2,28 @@
import { z } from 'zod';
import { getRoutingConfig, updateRoutingConfig } from '@utils/message-plans';
-import { $Channel } from 'nhs-notify-backend-client';
+import {
+ removeTemplatesFromCascadeItem,
+ updateCascadeGroupOverrides,
+} from '@utils/routing-utils';
import { redirect } from 'next/navigation';
export async function removeTemplateFromMessagePlan(formData: FormData) {
const parseResult = z
.object({
routingConfigId: z.uuidv4(),
- channel: $Channel,
+ templateIds: z.array(z.uuidv4()).min(1),
})
.safeParse({
routingConfigId: formData.get('routingConfigId'),
- channel: formData.get('channel'),
+ templateIds: formData.getAll('templateId'),
});
if (!parseResult.success) {
throw new Error('Invalid form data');
}
- const { routingConfigId, channel } = parseResult.data;
+ const { routingConfigId, templateIds } = parseResult.data;
const routingConfig = await getRoutingConfig(routingConfigId);
@@ -30,13 +33,16 @@ export async function removeTemplateFromMessagePlan(formData: FormData) {
const { cascade, cascadeGroupOverrides } = routingConfig;
const updatedCascade = cascade.map((cascadeItem) =>
- cascadeItem.channel === channel
- ? { ...cascadeItem, defaultTemplateId: null }
- : cascadeItem
+ removeTemplatesFromCascadeItem(cascadeItem, templateIds)
);
- const updatedConfig = {
+ const updatedCascadeGroupOverrides = updateCascadeGroupOverrides(
cascadeGroupOverrides,
+ updatedCascade
+ );
+
+ const updatedConfig = {
+ cascadeGroupOverrides: updatedCascadeGroupOverrides,
cascade: updatedCascade,
};
diff --git a/frontend/src/components/molecules/MessagePlanBlock/MessagePlanBlock.tsx b/frontend/src/components/molecules/MessagePlanBlock/MessagePlanBlock.tsx
index 72f989d79..33d64df7d 100644
--- a/frontend/src/components/molecules/MessagePlanBlock/MessagePlanBlock.tsx
+++ b/frontend/src/components/molecules/MessagePlanBlock/MessagePlanBlock.tsx
@@ -6,6 +6,8 @@ import {
import { interpolate } from '@utils/interpolate';
import { ORDINALS } from 'nhs-notify-web-template-management-utils';
import { MessagePlanChannelTemplate } from '@molecules/MessagePlanChannelTemplate/MessagePlanChannelTemplate';
+import { MessagePlanConditionalLetterTemplates } from '@molecules/MessagePlanConditionalTemplates/MessagePlanConditionalTemplates';
+import { MessagePlanTemplates } from '@utils/routing-utils';
import styles from '@molecules/MessagePlanBlock/MessagePlanBlock.module.scss';
@@ -15,13 +17,15 @@ const { messagePlanBlock: content } = copy.components;
export function MessagePlanBlock({
index,
channelItem,
- template,
+ defaultTemplate,
routingConfigId,
+ conditionalTemplates,
}: {
index: number;
channelItem: CascadeItem;
- template?: TemplateDto;
+ defaultTemplate?: TemplateDto;
routingConfigId: RoutingConfig['id'];
+ conditionalTemplates: MessagePlanTemplates;
}) {
return (
+
+
);
}
diff --git a/frontend/src/components/molecules/MessagePlanChannelTemplate/MessagePlanChannelTemplate.tsx b/frontend/src/components/molecules/MessagePlanChannelTemplate/MessagePlanChannelTemplate.tsx
index 99e54dc81..64d0b776c 100644
--- a/frontend/src/components/molecules/MessagePlanChannelTemplate/MessagePlanChannelTemplate.tsx
+++ b/frontend/src/components/molecules/MessagePlanChannelTemplate/MessagePlanChannelTemplate.tsx
@@ -1,5 +1,10 @@
import Link from 'next/link';
-import { Channel, RoutingConfig, TemplateDto } from 'nhs-notify-backend-client';
+import {
+ Channel,
+ LetterType,
+ RoutingConfig,
+ TemplateDto,
+} from 'nhs-notify-backend-client';
import {
channelDisplayMappings,
channelToTemplateType,
@@ -7,94 +12,127 @@ import {
} from 'nhs-notify-web-template-management-utils';
import classNames from 'classnames';
import { removeTemplateFromMessagePlan } from '@app/message-plans/choose-templates/[routingConfigId]/actions';
+import { interpolate } from '@utils/interpolate';
import styles from '@molecules/MessagePlanChannelTemplate/MessagePlanChannelTemplate.module.scss';
import copy from '@content/content';
const { messagePlanChannelTemplate: content } = copy.components;
-export function MessagePlanChannelTemplate({
- channel,
- template,
+function MessagePlanChannelTemplateBase({
+ channelTemplateType,
+ required,
+ templates,
routingConfigId,
- required = true,
+ chooseTemplateUrl,
+ removeTemplateAction,
+ testIdSuffix,
+ multipleTemplates = false,
}: {
- channel: Channel;
- routingConfigId: RoutingConfig['id'];
- template?: TemplateDto;
- required?: boolean;
+ channelTemplateType: string;
+ required: boolean;
+ templates: TemplateDto[];
+ routingConfigId: string;
+ chooseTemplateUrl: string;
+ removeTemplateAction: (formData: FormData) => Promise
;
+ testIdSuffix: string;
+ multipleTemplates?: boolean;
}) {
- const channelDisplayText =
- channelDisplayMappings(channel) + (required ? '' : ` ${content.optional}`);
+ const templateCount = templates.length;
+ const hasTemplates = templateCount > 0;
return (
-
+
-
{channelDisplayText}
+
{`${channelTemplateType}${required ? '' : ' (optional)'}`}
- {template && (
-
- {template.name}
-
+ {hasTemplates && (
+
+ {templates.map((template) => (
+
+ {template.name}
+
+ ))}
+
)}
- {!template && (
+ {!hasTemplates && (
{content.templateLinks.choose}
- {channelDisplayText}
+ {channelTemplateType}
{' '}
- {content.templateLinks.template}
+ {interpolate(content.templateLinks.templateWord, {
+ templateCount: multipleTemplates ? 2 : 1,
+ })}
)}
- {template && (
+ {hasTemplates && (
<>
{content.templateLinks.change}
- {channelDisplayText}
+ {channelTemplateType}
{' '}
- {content.templateLinks.template}
+ {interpolate(content.templateLinks.templateWord, {
+ templateCount,
+ })}
+
@@ -105,3 +143,82 @@ export function MessagePlanChannelTemplate({
);
}
+
+export function MessagePlanChannelTemplate({
+ channel,
+ template,
+ routingConfigId,
+ required = true,
+}: {
+ channel: Channel;
+ routingConfigId: RoutingConfig['id'];
+ template?: TemplateDto;
+ required?: boolean;
+}) {
+ const chooseTemplateUrl = `/message-plans/${messagePlanChooseTemplateUrl(channelToTemplateType(channel))}/${routingConfigId}`;
+
+ return (
+
+ );
+}
+
+export function MessagePlanAccessibleFormatTemplate({
+ accessibleFormat,
+ template,
+ routingConfigId,
+}: {
+ accessibleFormat: LetterType;
+ template?: TemplateDto;
+ routingConfigId: string;
+}) {
+ const chooseTemplateUrl = `/message-plans/${messagePlanChooseTemplateUrl('LETTER', accessibleFormat)}/${routingConfigId}`;
+
+ return (
+
+ );
+}
+
+export function MessagePlanLanguageTemplate({
+ selectedTemplates,
+ routingConfigId,
+}: {
+ selectedTemplates: TemplateDto[];
+ routingConfigId: string;
+}) {
+ const chooseTemplateUrl = `/message-plans/${messagePlanChooseTemplateUrl('LETTER', 'language')}/${routingConfigId}`;
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/molecules/MessagePlanConditionalTemplates/MessagePlanConditionalTemplates.module.scss b/frontend/src/components/molecules/MessagePlanConditionalTemplates/MessagePlanConditionalTemplates.module.scss
new file mode 100644
index 000000000..4f286e3f3
--- /dev/null
+++ b/frontend/src/components/molecules/MessagePlanConditionalTemplates/MessagePlanConditionalTemplates.module.scss
@@ -0,0 +1,52 @@
+.message-plan-conditional-templates {
+ position: relative;
+ margin: 70px 0 0 60px;
+ list-style: none;
+ padding: 0;
+
+ :global(.fallback-conditions) {
+ &::before {
+ content: '';
+ height: 70px;
+ top: -70px;
+ display: block;
+ border: dashed 2px var(--nhsuk-brand-colour);
+ position: absolute;
+ left: 18px;
+ }
+
+ &::after {
+ height: calc(100% + 80px);
+ }
+ }
+
+ &__list-item {
+ margin-bottom: 70px;
+ position: relative;
+
+ &::before {
+ content: '';
+ width: 42px;
+ height: 0;
+ display: block;
+ border: dashed 2px var(--nhsuk-brand-colour);
+ position: relative;
+ top: 56px;
+ left: 18px;
+ }
+
+ &::after {
+ content: '';
+ height: calc(100% + 66px);
+ top: 56px;
+ display: block;
+ border: dashed 2px var(--nhsuk-brand-colour);
+ position: absolute;
+ left: 18px;
+ }
+
+ &:last-child::after {
+ content: none;
+ }
+ }
+}
diff --git a/frontend/src/components/molecules/MessagePlanConditionalTemplates/MessagePlanConditionalTemplates.tsx b/frontend/src/components/molecules/MessagePlanConditionalTemplates/MessagePlanConditionalTemplates.tsx
new file mode 100644
index 000000000..2e1f78964
--- /dev/null
+++ b/frontend/src/components/molecules/MessagePlanConditionalTemplates/MessagePlanConditionalTemplates.tsx
@@ -0,0 +1,105 @@
+import {
+ CascadeItem,
+ ConditionalTemplateAccessible,
+ ConditionalTemplateLanguage,
+ LetterType,
+ RoutingConfig,
+ TemplateDto,
+} from 'nhs-notify-backend-client';
+import {
+ MessagePlanAccessibleFormatTemplate,
+ MessagePlanLanguageTemplate,
+} from '@molecules/MessagePlanChannelTemplate/MessagePlanChannelTemplate';
+import {
+ ConditionalTemplate,
+ MessagePlanTemplates,
+} from '@utils/routing-utils';
+import { MessagePlanFallbackConditions } from '@molecules/MessagePlanFallbackConditions/MessagePlanFallbackConditions';
+
+import styles from './MessagePlanConditionalTemplates.module.scss';
+
+const ACCESSIBLE_FORMATS: LetterType[] = ['x1']; // Large print only
+
+export function MessagePlanConditionalLetterTemplates({
+ cascadeItem,
+ cascadeIndex,
+ routingConfigId,
+ conditionalTemplates: templates,
+}: {
+ cascadeItem: CascadeItem;
+ cascadeIndex: number;
+ routingConfigId: RoutingConfig['id'];
+ conditionalTemplates: MessagePlanTemplates;
+}) {
+ if (cascadeItem.channel !== 'LETTER') {
+ return null;
+ }
+
+ const accessibleFormats = ACCESSIBLE_FORMATS;
+
+ const languageTemplates: TemplateDto[] = (
+ cascadeItem.conditionalTemplates || []
+ )
+ .filter(
+ (
+ template: ConditionalTemplate
+ ): template is ConditionalTemplateLanguage =>
+ 'language' in template && !!template.templateId
+ )
+ .map(
+ (template: ConditionalTemplateLanguage) => templates[template.templateId!]
+ )
+ .filter(Boolean);
+
+ return (
+
+
+
+ {accessibleFormats.map((format) => (
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+const getTemplateForAccessibleFormat = (
+ format: LetterType,
+ cascadeItem: CascadeItem,
+ templates: MessagePlanTemplates
+): TemplateDto | undefined => {
+ const conditionalTemplate = (cascadeItem.conditionalTemplates || []).find(
+ (
+ template: ConditionalTemplate
+ ): template is ConditionalTemplateAccessible =>
+ 'accessibleFormat' in template && template.accessibleFormat === format
+ );
+ return conditionalTemplate?.templateId
+ ? templates[conditionalTemplate.templateId]
+ : undefined;
+};
diff --git a/frontend/src/components/molecules/MessagePlanFallbackConditions/MessagePlanFallbackConditions.tsx b/frontend/src/components/molecules/MessagePlanFallbackConditions/MessagePlanFallbackConditions.tsx
index 2915d3e21..3bbcc2d47 100644
--- a/frontend/src/components/molecules/MessagePlanFallbackConditions/MessagePlanFallbackConditions.tsx
+++ b/frontend/src/components/molecules/MessagePlanFallbackConditions/MessagePlanFallbackConditions.tsx
@@ -7,6 +7,7 @@ import {
ORDINALS,
} from 'nhs-notify-web-template-management-utils';
import Image from 'next/image';
+import classNames from 'classnames';
import styles from '@molecules/MessagePlanFallbackConditions/MessagePlanFallbackConditions.module.scss';
@@ -30,7 +31,10 @@ export function MessagePlanFallbackConditions({
return (
{/* Show fallback conditions only if there is more than one channel, and not for the last channel */}
- {/* TODO: CCM-11494 Update this logic for letter formats */}
{messagePlan.cascade.length > 1 &&
index < messagePlan.cascade.length - 1 && (
{
return `${title} - NHS Notify`;
@@ -1056,14 +1060,24 @@ export type FallbackConditionBlock = {
};
};
+const messagePlanConditionalLetterTemplates = {
+ accessibleFormats: {
+ q4: 'British Sign Language letter',
+ x0: 'Standard letter',
+ x1: 'Large print letter',
+ } satisfies Record,
+ languageFormats: 'Other language letters',
+};
+
const messagePlanChannelTemplate = {
templateLinks: {
choose: 'Choose',
change: 'Change',
- remove: 'Remove',
- template: 'template',
+ remove: 'Remove{{templateCount|| all}}',
+ templateWord: '{{templateCount|template|templates}}',
},
optional: '(optional)',
+ messagePlanConditionalLetterTemplates,
};
const messagePlanFallbackConditions: Record<
diff --git a/frontend/src/utils/get-message-plan-template-ids.ts b/frontend/src/utils/get-message-plan-template-ids.ts
deleted file mode 100644
index a83ce4fa5..000000000
--- a/frontend/src/utils/get-message-plan-template-ids.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { RoutingConfig } from 'nhs-notify-backend-client';
-
-/**
- * Extracts all template IDs from a RoutingConfig
- */
-export function getMessagePlanTemplateIds(
- messagePlan: RoutingConfig
-): Set {
- const templateIds = new Set();
-
- for (const cascadeItem of messagePlan.cascade) {
- if (cascadeItem.defaultTemplateId)
- templateIds.add(cascadeItem.defaultTemplateId);
- if (cascadeItem.conditionalTemplates) {
- for (const conditionalTemplate of cascadeItem.conditionalTemplates) {
- if (conditionalTemplate.templateId)
- templateIds.add(conditionalTemplate.templateId);
- }
- }
- }
-
- return templateIds;
-}
diff --git a/frontend/src/utils/interpolate.ts b/frontend/src/utils/interpolate.ts
index ce44db815..3685878c1 100644
--- a/frontend/src/utils/interpolate.ts
+++ b/frontend/src/utils/interpolate.ts
@@ -13,7 +13,7 @@
*/
// eslint-disable-next-line security/detect-unsafe-regex, sonarjs/slow-regex
-const interpolationPattern = /{{([^|}]+)(?:\|([^|}]+)\|([^}]+))?}}/g;
+const interpolationPattern = /{{([^|}]+)(?:\|([^|}]*)\|([^}]*))?}}/g;
export function interpolate(
template: string,
diff --git a/frontend/src/utils/message-plans.ts b/frontend/src/utils/message-plans.ts
index 4bed922d0..a3dc63b69 100644
--- a/frontend/src/utils/message-plans.ts
+++ b/frontend/src/utils/message-plans.ts
@@ -5,10 +5,12 @@ import {
CreateRoutingConfig,
RoutingConfig,
RoutingConfigStatusActive,
- TemplateDto,
UpdateRoutingConfig,
} from 'nhs-notify-backend-client';
-import { getMessagePlanTemplateIds } from './get-message-plan-template-ids';
+import {
+ getMessagePlanTemplateIds,
+ type MessagePlanTemplates,
+} from './routing-utils';
import { getSessionServer } from './amplify-utils';
import { logger } from 'nhs-notify-web-template-management-utils/logger';
import { getTemplate } from './form-actions';
@@ -149,8 +151,9 @@ export async function updateRoutingConfig(
return result.data;
}
-export type MessagePlanTemplates = Record;
-
+/**
+ * Fetches all templates referenced in a message plan
+ */
export async function getMessagePlanTemplates(
messagePlan: RoutingConfig
): Promise {
@@ -161,6 +164,9 @@ export async function getMessagePlanTemplates(
return getTemplatesByIds([...templateIds]);
}
+/**
+ * Fetches templates by their IDs and returns a map of template ID to template object
+ */
export async function getTemplatesByIds(
templateIds: string[]
): Promise {
diff --git a/frontend/src/utils/routing-utils.ts b/frontend/src/utils/routing-utils.ts
new file mode 100644
index 000000000..8d604db6b
--- /dev/null
+++ b/frontend/src/utils/routing-utils.ts
@@ -0,0 +1,219 @@
+import {
+ CascadeGroup,
+ CascadeGroupName,
+ CascadeItem,
+ ConditionalTemplateAccessible,
+ ConditionalTemplateLanguage,
+ Language,
+ LetterType,
+ RoutingConfig,
+ TemplateDto,
+} from 'nhs-notify-backend-client';
+
+export type ConditionalTemplate =
+ | ConditionalTemplateAccessible
+ | ConditionalTemplateLanguage;
+
+export type MessagePlanTemplates = Record;
+
+/**
+ * Gets the conditional templates for a cascade item, from the provided templates object
+ */
+export function getConditionalTemplatesForItem(
+ channelItem: CascadeItem,
+ templates: MessagePlanTemplates
+): MessagePlanTemplates {
+ const conditionalTemplateIds =
+ channelItem.conditionalTemplates?.map(({ templateId }) => templateId) || [];
+
+ return Object.fromEntries(
+ conditionalTemplateIds
+ .filter((id): id is string => id != null && id in templates)
+ .map((id) => [id, templates[id]])
+ );
+}
+
+/**
+ * Extracts all template IDs from a RoutingConfig
+ */
+export function getMessagePlanTemplateIds(
+ messagePlan: RoutingConfig
+): Set {
+ const templateIds = new Set();
+
+ for (const cascadeItem of messagePlan.cascade) {
+ if (cascadeItem.defaultTemplateId)
+ templateIds.add(cascadeItem.defaultTemplateId);
+ if (cascadeItem.conditionalTemplates) {
+ for (const conditionalTemplate of cascadeItem.conditionalTemplates) {
+ if (conditionalTemplate.templateId)
+ templateIds.add(conditionalTemplate.templateId);
+ }
+ }
+ }
+
+ return templateIds;
+}
+
+/**
+ * Checks if a template ID should be removed based on the removal list
+ */
+export function shouldRemoveTemplate(
+ templateId: string | null | undefined,
+ templateIdsToRemove: string[]
+): boolean {
+ return !!templateId && templateIdsToRemove.includes(templateId);
+}
+
+/**
+ * Filters out conditional templates whose IDs match the removal list
+ */
+export function removeTemplatesFromConditionalTemplates(
+ conditionalTemplates: ConditionalTemplate[],
+ templateIdsToRemove: string[]
+): ConditionalTemplate[] {
+ return conditionalTemplates.filter(
+ (template) =>
+ !shouldRemoveTemplate(template.templateId, templateIdsToRemove)
+ );
+}
+
+/**
+ * Determines which cascade groups should be present based on conditional templates
+ */
+export function buildCascadeGroupsForItem(
+ cascadeItem: CascadeItem
+): CascadeGroupName[] {
+ const groups: CascadeGroupName[] = ['standard'];
+
+ if (
+ cascadeItem.conditionalTemplates &&
+ cascadeItem.conditionalTemplates.length > 0
+ ) {
+ const hasAccessibleFormat = cascadeItem.conditionalTemplates.some(
+ (template) => 'accessibleFormat' in template && template.templateId
+ );
+ const hasLanguage = cascadeItem.conditionalTemplates.some(
+ (template) => 'language' in template && template.templateId
+ );
+
+ if (hasAccessibleFormat) {
+ groups.push('accessible');
+ }
+ if (hasLanguage) {
+ groups.push('translations');
+ }
+ }
+
+ return groups;
+}
+
+/**
+ * Removes templates from a cascade item by ID, updating both default and conditional templates
+ */
+export function removeTemplatesFromCascadeItem(
+ cascadeItem: CascadeItem,
+ templateIdsToRemove: string[]
+): CascadeItem {
+ const updatedCascadeItem: CascadeItem = { ...cascadeItem };
+
+ const defaultTemplateId = shouldRemoveTemplate(
+ cascadeItem.defaultTemplateId,
+ templateIdsToRemove
+ )
+ ? null
+ : cascadeItem.defaultTemplateId;
+
+ updatedCascadeItem.defaultTemplateId = defaultTemplateId ?? null;
+
+ if (cascadeItem.conditionalTemplates) {
+ const remainingTemplates = removeTemplatesFromConditionalTemplates(
+ cascadeItem.conditionalTemplates,
+ templateIdsToRemove
+ );
+
+ if (remainingTemplates.length > 0) {
+ updatedCascadeItem.conditionalTemplates = remainingTemplates;
+ } else {
+ delete updatedCascadeItem.conditionalTemplates;
+ }
+ }
+
+ updatedCascadeItem.cascadeGroups =
+ buildCascadeGroupsForItem(updatedCascadeItem);
+
+ return updatedCascadeItem;
+}
+
+/**
+ * Collects all remaining accessible format types from the cascade
+ */
+export function getRemainingAccessibleFormats(
+ cascade: CascadeItem[]
+): LetterType[] {
+ const formats = new Set();
+
+ for (const item of cascade) {
+ if (!item.conditionalTemplates) continue;
+ for (const template of item.conditionalTemplates) {
+ if ('accessibleFormat' in template && template.templateId) {
+ formats.add(template.accessibleFormat);
+ }
+ }
+ }
+ return [...formats];
+}
+
+/**
+ * Collects all remaining language types from the cascade
+ */
+export function getRemainingLanguages(cascade: CascadeItem[]): Language[] {
+ const languages = new Set();
+
+ for (const item of cascade) {
+ if (!item.conditionalTemplates) continue;
+ for (const template of item.conditionalTemplates) {
+ if ('language' in template && template.templateId) {
+ languages.add(template.language);
+ }
+ }
+ }
+ return [...languages];
+}
+
+/**
+ * Updates cascadeGroupOverrides by removing groups with no templates
+ * or updating their arrays to reflect remaining templates
+ */
+export function updateCascadeGroupOverrides(
+ cascadeGroupOverrides: CascadeGroup[],
+ updatedCascade: CascadeItem[]
+): CascadeGroup[] {
+ return cascadeGroupOverrides
+ .map((group): CascadeGroup => {
+ if ('accessibleFormat' in group) {
+ return {
+ ...group,
+ accessibleFormat: getRemainingAccessibleFormats(updatedCascade),
+ };
+ }
+
+ if ('language' in group) {
+ return {
+ ...group,
+ language: getRemainingLanguages(updatedCascade),
+ };
+ }
+
+ return group;
+ })
+ .filter((group) => {
+ if ('accessibleFormat' in group) {
+ return group.accessibleFormat.length > 0;
+ }
+ if ('language' in group) {
+ return group.language.length > 0;
+ }
+ return true;
+ });
+}
diff --git a/tests/test-team/helpers/factories/routing-config-factory.ts b/tests/test-team/helpers/factories/routing-config-factory.ts
index 835160459..25f885a95 100644
--- a/tests/test-team/helpers/factories/routing-config-factory.ts
+++ b/tests/test-team/helpers/factories/routing-config-factory.ts
@@ -5,6 +5,8 @@ import {
ChannelType,
CreateRoutingConfig,
RoutingConfig,
+ Language,
+ LetterType,
} from 'nhs-notify-backend-client';
import type {
FactoryRoutingConfigWithModifiers,
@@ -29,9 +31,7 @@ export const RoutingConfigFactory = {
defaultTemplateId: null,
},
],
- cascadeGroupOverrides: routingConfig.cascadeGroupOverrides ?? [
- { name: 'standard' },
- ],
+ cascadeGroupOverrides: routingConfig.cascadeGroupOverrides ?? [],
name: routingConfig.name ?? 'Test config',
};
@@ -75,6 +75,41 @@ export const RoutingConfigFactory = {
}
return this;
},
+
+ addLanguageTemplate(language: Language, templateId?: string) {
+ const id = templateId ?? randomUUID();
+ for (const cascadeItem of this.dbEntry.cascade) {
+ if (cascadeItem.channel === 'LETTER') {
+ if (!cascadeItem.conditionalTemplates) {
+ cascadeItem.conditionalTemplates = [];
+ }
+ cascadeItem.conditionalTemplates.push({
+ language: language as Language,
+ templateId: id,
+ });
+ }
+ }
+ return this;
+ },
+
+ addAccessibleFormatTemplate(
+ accessibleFormat: LetterType,
+ templateId?: string
+ ) {
+ const id = templateId ?? randomUUID();
+ for (const cascadeItem of this.dbEntry.cascade) {
+ if (cascadeItem.channel === 'LETTER') {
+ if (!cascadeItem.conditionalTemplates) {
+ cascadeItem.conditionalTemplates = [];
+ }
+ cascadeItem.conditionalTemplates.push({
+ accessibleFormat: accessibleFormat as LetterType,
+ templateId: id,
+ });
+ }
+ }
+ return this;
+ },
};
return factoryObj;
diff --git a/tests/test-team/helpers/types.ts b/tests/test-team/helpers/types.ts
index 45c3dac20..11d6eb0ce 100644
--- a/tests/test-team/helpers/types.ts
+++ b/tests/test-team/helpers/types.ts
@@ -2,6 +2,8 @@ import type {
Channel,
CreateRoutingConfig,
RoutingConfig,
+ Language,
+ LetterType,
} from 'nhs-notify-backend-client';
export const templateTypeDisplayMappings: Record = {
@@ -22,7 +24,7 @@ export const expectedChannelLabels: Record = {
NHSAPP: 'NHS App',
SMS: 'Text message (SMS)',
EMAIL: 'Email',
- LETTER: 'Letter',
+ LETTER: 'Standard English letter',
};
export const allChannels: Channel[] = ['NHSAPP', 'EMAIL', 'SMS', 'LETTER'];
@@ -101,4 +103,12 @@ export type FactoryRoutingConfigWithModifiers = FactoryRoutingConfig & {
templateId?: string
) => FactoryRoutingConfigWithModifiers;
withTemplates: (...channels: Channel[]) => FactoryRoutingConfigWithModifiers;
+ addLanguageTemplate: (
+ language: Language,
+ templateId?: string
+ ) => FactoryRoutingConfigWithModifiers;
+ addAccessibleFormatTemplate: (
+ accessibleFormat: LetterType,
+ templateId?: string
+ ) => FactoryRoutingConfigWithModifiers;
};
diff --git a/tests/test-team/package.json b/tests/test-team/package.json
index a8e1b872f..caabff168 100644
--- a/tests/test-team/package.json
+++ b/tests/test-team/package.json
@@ -8,13 +8,13 @@
"@aws-sdk/client-sqs": "3.911.0",
"@aws-sdk/client-ssm": "3.911.0",
"@aws-sdk/lib-dynamodb": "3.911.0",
+ "@axe-core/playwright": "^4.11.0",
"@faker-js/faker": "^9.9.0",
"@nhsdigital/nhs-notify-event-schemas-template-management": "*",
"@playwright/test": "^1.51.1",
- "@axe-core/playwright": "^4.11.0",
- "axe-core": "^4.11.0",
"async-mutex": "^0.5.0",
"aws-amplify": "^6.13.6",
+ "axe-core": "^4.11.0",
"date-fns": "^4.1.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-base": "^15.0.0",
diff --git a/tests/test-team/pages/routing/choose-templates-page.ts b/tests/test-team/pages/routing/choose-templates-page.ts
index 9c7903416..f394bbd86 100644
--- a/tests/test-team/pages/routing/choose-templates-page.ts
+++ b/tests/test-team/pages/routing/choose-templates-page.ts
@@ -18,6 +18,8 @@ export class RoutingChooseTemplatesPage extends TemplateMgmtBasePageDynamic {
public readonly saveAndCloseButton: Locator;
+ public readonly conditionalLetterTemplates: Locator;
+
constructor(page: Page) {
super(page);
this.errorSummary = page.locator('.nhsuk-error-summary');
@@ -29,29 +31,29 @@ export class RoutingChooseTemplatesPage extends TemplateMgmtBasePageDynamic {
this.channelBlocks = page.locator('[data-testid^="message-plan-block-"]');
this.moveToProductionButton = page.getByTestId('move-to-production-cta');
this.saveAndCloseButton = page.getByTestId('save-and-close-cta');
+ this.conditionalLetterTemplates = page.getByTestId(
+ 'message-plan-conditional-templates'
+ );
}
- public messagePlanChannel(channel: string) {
+ public messagePlanItem(identifier: string) {
return {
- block: this.page.getByTestId(`message-plan-block-${channel}`),
- number: this.page
- .getByTestId(`message-plan-block-${channel}`)
- .locator('[class*=message-plan-block-number]'),
+ templateItem: this.page.getByTestId(`channel-template-${identifier}`),
heading: this.page
- .getByTestId(`message-plan-block-${channel}`)
+ .getByTestId(`channel-template-${identifier}`)
.getByRole('heading', { level: 3 }),
- templateName: this.page.getByTestId(`template-name-${channel}`),
- fallbackConditions: this.page.getByTestId(
- `message-plan-fallback-conditions-${channel}`
- ),
+ templateName: this.page
+ .getByTestId(`template-name-${identifier}`)
+ .first(),
+ templateNames: this.page.getByTestId(`template-name-${identifier}`),
changeTemplateLink: this.page.getByTestId(
- `change-template-link-${channel}`
+ `change-template-link-${identifier}`
),
chooseTemplateLink: this.page.getByTestId(
- `choose-template-link-${channel}`
+ `choose-template-link-${identifier}`
),
removeTemplateLink: this.page.getByTestId(
- `remove-template-link-${channel}`
+ `remove-template-link-${identifier}`
),
async clickChooseTemplateLink() {
await this.chooseTemplateLink.click();
@@ -65,6 +67,19 @@ export class RoutingChooseTemplatesPage extends TemplateMgmtBasePageDynamic {
};
}
+ public messagePlanChannel(channel: string) {
+ return {
+ ...this.messagePlanItem(channel),
+ block: this.page.getByTestId(`message-plan-block-${channel}`),
+ number: this.page
+ .getByTestId(`message-plan-block-${channel}`)
+ .locator('[class*=message-plan-block-number]'),
+ fallbackConditions: this.page.getByTestId(
+ `message-plan-fallback-conditions-${channel}`
+ ),
+ };
+ }
+
public readonly nhsApp = this.messagePlanChannel('NHSAPP');
public readonly sms = this.messagePlanChannel('SMS');
@@ -73,6 +88,23 @@ export class RoutingChooseTemplatesPage extends TemplateMgmtBasePageDynamic {
public readonly letter = this.messagePlanChannel('LETTER');
+ public alternativeLetterFormats() {
+ const conditionalTemplates = this.page.getByTestId(
+ 'message-plan-conditional-templates'
+ );
+ return {
+ conditionalTemplates,
+ fallbackConditions: conditionalTemplates.getByTestId(
+ 'message-plan-fallback-conditions-LETTER'
+ ),
+ listItems: conditionalTemplates.locator(
+ '[class*=message-plan-conditional-templates__list-item]'
+ ),
+ largePrint: this.messagePlanItem('x1'),
+ otherLanguages: this.messagePlanItem('foreign-language'),
+ };
+ }
+
async clickMoveToProduction() {
await this.moveToProductionButton.click();
}
diff --git a/tests/test-team/template-mgmt-api-tests/create-routing-configuration.api.spec.ts b/tests/test-team/template-mgmt-api-tests/create-routing-configuration.api.spec.ts
index 7b9184823..445aa0598 100644
--- a/tests/test-team/template-mgmt-api-tests/create-routing-configuration.api.spec.ts
+++ b/tests/test-team/template-mgmt-api-tests/create-routing-configuration.api.spec.ts
@@ -77,7 +77,6 @@ test.describe('POST /v1/routing-configuration', () => {
}) => {
const payload = RoutingConfigFactory.create(user1, {
cascadeGroupOverrides: [
- { name: 'standard' },
{ name: 'translations', language: ['ar'] },
{ name: 'accessible', accessibleFormat: ['x0'] },
],
diff --git a/tests/test-team/template-mgmt-routing-component-tests/choose-templates.routing-component.spec.ts b/tests/test-team/template-mgmt-routing-component-tests/choose-templates.routing-component.spec.ts
index 3cb590664..639e304b4 100644
--- a/tests/test-team/template-mgmt-routing-component-tests/choose-templates.routing-component.spec.ts
+++ b/tests/test-team/template-mgmt-routing-component-tests/choose-templates.routing-component.spec.ts
@@ -33,22 +33,33 @@ import { TemplateFactory } from 'helpers/factories/template-factory';
const routingConfigStorageHelper = new RoutingConfigStorageHelper();
const templateStorageHelper = new TemplateStorageHelper();
-const validRoutingConfigId = randomUUID();
-const invalidRoutingConfigId = 'invalid-id';
-const notFoundRoutingConfigId = randomUUID();
-
const templateIds = {
NHSAPP: randomUUID(),
EMAIL: randomUUID(),
SMS: randomUUID(),
LETTER: randomUUID(),
+ LARGE_PRINT_LETTER: randomUUID(),
+ FRENCH_LETTER: randomUUID(),
+ SPANISH_LETTER: randomUUID(),
+};
+
+const routingConfigIds = {
+ valid: randomUUID(),
+ validWithLetterTemplates: randomUUID(),
+ invalid: 'invalid-id',
+ notFound: randomUUID(),
};
function createRoutingConfigs(
user: TestUser
-): Record {
- const routingConfigs: Record =
- {} as Record;
+): Record {
+ const routingConfigs: Record<
+ MessageOrder | keyof typeof routingConfigIds,
+ RoutingConfigDbEntry
+ > = {} as Record<
+ MessageOrder | keyof typeof routingConfigIds,
+ RoutingConfigDbEntry
+ >;
for (const messageOrder of MESSAGE_ORDERS) {
const routingConfig = RoutingConfigFactory.createForMessageOrder(
@@ -61,11 +72,24 @@ function createRoutingConfigs(
routingConfigs.valid = RoutingConfigFactory.createForMessageOrder(
user,
'NHSAPP,EMAIL,SMS,LETTER',
- { id: validRoutingConfigId, name: 'Test plan with some templates' }
+ { id: routingConfigIds.valid, name: 'Test plan with some templates' }
)
.addTemplate('NHSAPP', templateIds.NHSAPP)
.addTemplate('SMS', templateIds.SMS).dbEntry;
+ routingConfigs.validWithLetterTemplates =
+ RoutingConfigFactory.createForMessageOrder(user, 'LETTER', {
+ id: routingConfigIds.validWithLetterTemplates,
+ name: 'Letter plan',
+ })
+ .addTemplate('LETTER', templateIds.LETTER)
+ .addLanguageTemplate('fr', templateIds.FRENCH_LETTER)
+ .addLanguageTemplate('es', templateIds.SPANISH_LETTER)
+ .addAccessibleFormatTemplate(
+ 'x1',
+ templateIds.LARGE_PRINT_LETTER
+ ).dbEntry;
+
return routingConfigs;
}
@@ -91,6 +115,21 @@ function createTemplates(user: TestUser) {
user,
'Test Letter template'
),
+ LARGE_PRINT_LETTER: TemplateFactory.uploadLetterTemplate(
+ templateIds.LARGE_PRINT_LETTER,
+ user,
+ 'Test Large Print Letter template'
+ ),
+ FRENCH_LETTER: TemplateFactory.uploadLetterTemplate(
+ templateIds.FRENCH_LETTER,
+ user,
+ 'Test French Letter template'
+ ),
+ SPANISH_LETTER: TemplateFactory.uploadLetterTemplate(
+ templateIds.SPANISH_LETTER,
+ user,
+ 'Test Spanish Letter template'
+ ),
};
}
@@ -152,7 +191,18 @@ test.describe('Routing - Choose Templates page', () => {
});
// eslint-disable-next-line unicorn/prefer-ternary
- if (
+ if (channel === 'LETTER') {
+ await test.step('letter channel displays section for fallback conditions and additional letter formats', async () => {
+ const alternativeLetterFormats =
+ chooseTemplatesPage.alternativeLetterFormats();
+ await expect(
+ alternativeLetterFormats.conditionalTemplates
+ ).toBeVisible();
+ await expect(
+ alternativeLetterFormats.fallbackConditions
+ ).toBeVisible();
+ });
+ } else if (
messagePlanChannels.length > 1 &&
channelIndexInPlan < messagePlanChannels.length - 1
) {
@@ -186,7 +236,7 @@ test.describe('Routing - Choose Templates page', () => {
test('common page tests', async ({ page, baseURL }) => {
const props = {
page: new RoutingChooseTemplatesPage(page),
- id: validRoutingConfigId,
+ id: routingConfigIds.valid,
baseURL,
};
await assertSkipToMainContent(props);
@@ -201,10 +251,10 @@ test.describe('Routing - Choose Templates page', () => {
baseURL,
}) => {
const chooseTemplatesPage = new RoutingChooseTemplatesPage(page);
- await chooseTemplatesPage.loadPage(validRoutingConfigId);
+ await chooseTemplatesPage.loadPage(routingConfigIds.valid);
await expect(page).toHaveURL(
- `${baseURL}/templates/message-plans/choose-templates/${validRoutingConfigId}`
+ `${baseURL}/templates/message-plans/choose-templates/${routingConfigIds.valid}`
);
await expect(chooseTemplatesPage.pageHeading).toHaveText(
messagePlans.valid.name
@@ -234,7 +284,7 @@ test.describe('Routing - Choose Templates page', () => {
);
await expect(chooseTemplatesPage.moveToProductionButton).toHaveAttribute(
'href',
- `/templates/message-plans/move-to-production/${validRoutingConfigId}`
+ `/templates/message-plans/move-to-production/${routingConfigIds.valid}`
);
await expect(chooseTemplatesPage.saveAndCloseButton).toHaveText(
'Save and close'
@@ -306,7 +356,7 @@ test.describe('Routing - Choose Templates page', () => {
}) => {
const chooseTemplatesPage = new RoutingChooseTemplatesPage(page);
- await chooseTemplatesPage.loadPage(validRoutingConfigId);
+ await chooseTemplatesPage.loadPage(routingConfigIds.valid);
await test.step('app channel with template has template name and change link', async () => {
await expect(chooseTemplatesPage.nhsApp.templateName).toHaveText(
@@ -317,7 +367,7 @@ test.describe('Routing - Choose Templates page', () => {
chooseTemplatesPage.nhsApp.changeTemplateLink
).toHaveAttribute(
'href',
- `/templates/message-plans/choose-nhs-app-template/${validRoutingConfigId}`
+ `/templates/message-plans/choose-nhs-app-template/${routingConfigIds.valid}`
);
});
@@ -333,14 +383,19 @@ test.describe('Routing - Choose Templates page', () => {
await expect(chooseTemplatesPage.sms.changeTemplateLink).toBeVisible();
await expect(chooseTemplatesPage.sms.changeTemplateLink).toHaveAttribute(
'href',
- `/templates/message-plans/choose-text-message-template/${validRoutingConfigId}`
+ `/templates/message-plans/choose-text-message-template/${routingConfigIds.valid}`
);
});
+ await test.step('letter channel with no template selected has no name or change link', async () => {
+ await expect(chooseTemplatesPage.letter.templateName).toBeHidden();
+ await expect(chooseTemplatesPage.letter.changeTemplateLink).toBeHidden();
+ });
+
await chooseTemplatesPage.nhsApp.clickChangeTemplateLink();
await expect(page).toHaveURL(
- `${baseURL}/templates/message-plans/choose-nhs-app-template/${validRoutingConfigId}`
+ `${baseURL}/templates/message-plans/choose-nhs-app-template/${routingConfigIds.valid}`
);
// TODO: CCM-11537 Choose template then return and assert updated
@@ -352,7 +407,7 @@ test.describe('Routing - Choose Templates page', () => {
}) => {
const chooseTemplatesPage = new RoutingChooseTemplatesPage(page);
- await chooseTemplatesPage.loadPage(validRoutingConfigId);
+ await chooseTemplatesPage.loadPage(routingConfigIds.valid);
await expect(chooseTemplatesPage.nhsApp.templateName).toHaveText(
templates.NHSAPP.name
@@ -368,8 +423,10 @@ test.describe('Routing - Choose Templates page', () => {
await chooseTemplatesPage.nhsApp.clickRemoveTemplateLink();
+ await expect(chooseTemplatesPage.letter.removeTemplateLink).toBeHidden();
+
await expect(page).toHaveURL(
- `${baseURL}/templates/message-plans/choose-templates/${validRoutingConfigId}`
+ `${baseURL}/templates/message-plans/choose-templates/${routingConfigIds.valid}`
);
await expect(chooseTemplatesPage.nhsApp.templateName).toBeHidden();
@@ -377,13 +434,153 @@ test.describe('Routing - Choose Templates page', () => {
await expect(chooseTemplatesPage.nhsApp.chooseTemplateLink).toBeVisible();
});
+ test('user can choose alternative letter format options', async ({
+ page,
+ baseURL,
+ }) => {
+ const chooseTemplatesPage = new RoutingChooseTemplatesPage(page);
+
+ await chooseTemplatesPage.loadPage(routingConfigIds.valid);
+
+ const alternativeLetterFormats =
+ chooseTemplatesPage.alternativeLetterFormats();
+
+ await expect(alternativeLetterFormats.conditionalTemplates).toBeVisible();
+ await expect(alternativeLetterFormats.fallbackConditions).toBeVisible();
+
+ const listItems = await alternativeLetterFormats.listItems;
+ expect(await listItems.count()).toBe(2);
+
+ const largePrintItem = alternativeLetterFormats.largePrint;
+ const otherLanguagesItem = alternativeLetterFormats.otherLanguages;
+
+ await expect(largePrintItem.heading).toHaveText(
+ 'Large print letter (optional)'
+ );
+ await expect(largePrintItem.templateName).toBeHidden();
+ await expect(largePrintItem.chooseTemplateLink).toBeVisible();
+ await expect(largePrintItem.chooseTemplateLink).toHaveAttribute(
+ 'href',
+ `/templates/message-plans/choose-large-print-letter-template/${routingConfigIds.valid}`
+ );
+ await expect(largePrintItem.changeTemplateLink).toBeHidden();
+ await expect(largePrintItem.removeTemplateLink).toBeHidden();
+
+ await expect(otherLanguagesItem.heading).toHaveText(
+ 'Other language letters (optional)'
+ );
+ await expect(otherLanguagesItem.templateName).toBeHidden();
+ await expect(otherLanguagesItem.chooseTemplateLink).toBeVisible();
+ await expect(otherLanguagesItem.chooseTemplateLink).toHaveAttribute(
+ 'href',
+ `/templates/message-plans/choose-other-language-letter-template/${routingConfigIds.valid}`
+ );
+ await expect(otherLanguagesItem.changeTemplateLink).toBeHidden();
+ await expect(otherLanguagesItem.removeTemplateLink).toBeHidden();
+
+ await largePrintItem.clickChooseTemplateLink();
+
+ await expect(page).toHaveURL(
+ `${baseURL}/templates/message-plans/choose-large-print-letter-template/${routingConfigIds.valid}`
+ );
+ });
+
+ test('user can manage the various letter templates selected on their message plan', async ({
+ page,
+ baseURL,
+ }) => {
+ const chooseTemplatesPage = new RoutingChooseTemplatesPage(page);
+
+ await chooseTemplatesPage.loadPage(
+ routingConfigIds.validWithLetterTemplates
+ );
+
+ await test.step('standard letter channel with default template has template name and change link', async () => {
+ await expect(chooseTemplatesPage.letter.templateName).toHaveText(
+ templates.LETTER.name
+ );
+ await expect(chooseTemplatesPage.letter.changeTemplateLink).toBeVisible();
+ await expect(
+ chooseTemplatesPage.letter.changeTemplateLink
+ ).toHaveAttribute(
+ 'href',
+ `/templates/message-plans/choose-standard-english-letter-template/${routingConfigIds.validWithLetterTemplates}`
+ );
+ await expect(chooseTemplatesPage.letter.removeTemplateLink).toBeVisible();
+ await expect(chooseTemplatesPage.letter.chooseTemplateLink).toBeHidden();
+ });
+
+ const alternativeLetterFormats =
+ chooseTemplatesPage.alternativeLetterFormats();
+
+ await test.step('standard letter is followed by alternative formats', async () => {
+ await expect(alternativeLetterFormats.conditionalTemplates).toBeVisible();
+ await expect(alternativeLetterFormats.fallbackConditions).toBeVisible();
+
+ const listItems = await alternativeLetterFormats.listItems;
+ expect(await listItems.count()).toBe(2);
+ });
+
+ const largePrintItem = alternativeLetterFormats.largePrint;
+ const otherLanguagesItem = alternativeLetterFormats.otherLanguages;
+
+ await test.step('accessible formats - large print template has name and change link', async () => {
+ await expect(largePrintItem.templateName).toHaveText(
+ templates.LARGE_PRINT_LETTER.name
+ );
+ await expect(largePrintItem.changeTemplateLink).toBeVisible();
+ await expect(largePrintItem.changeTemplateLink).toHaveAttribute(
+ 'href',
+ `/templates/message-plans/choose-large-print-letter-template/${routingConfigIds.validWithLetterTemplates}`
+ );
+ await expect(largePrintItem.removeTemplateLink).toBeVisible();
+ await expect(largePrintItem.chooseTemplateLink).toBeHidden();
+ });
+
+ await test.step('foreign language templates are displayed with names and change link', async () => {
+ const templateNames = await otherLanguagesItem.templateNames.all();
+ expect(templateNames.length).toBe(2);
+
+ await expect(templateNames[0]).toHaveText(templates.FRENCH_LETTER.name);
+ await expect(templateNames[1]).toHaveText(templates.SPANISH_LETTER.name);
+
+ await expect(otherLanguagesItem.changeTemplateLink).toBeVisible();
+ await expect(otherLanguagesItem.changeTemplateLink).toHaveAttribute(
+ 'href',
+ `/templates/message-plans/choose-other-language-letter-template/${routingConfigIds.validWithLetterTemplates}`
+ );
+ await expect(otherLanguagesItem.removeTemplateLink).toBeVisible();
+ await expect(otherLanguagesItem.chooseTemplateLink).toBeHidden();
+ });
+
+ await test.step('can remove all foreign language templates', async () => {
+ await otherLanguagesItem.clickRemoveTemplateLink();
+
+ await expect(page).toHaveURL(
+ `${baseURL}/templates/message-plans/choose-templates/${routingConfigIds.validWithLetterTemplates}`
+ );
+
+ await expect(otherLanguagesItem.templateName).toBeHidden();
+ await expect(otherLanguagesItem.removeTemplateLink).toBeHidden();
+ await expect(otherLanguagesItem.chooseTemplateLink).toBeVisible();
+ });
+
+ await test.step('can change large print template', async () => {
+ await largePrintItem.clickChangeTemplateLink();
+
+ await expect(page).toHaveURL(
+ `${baseURL}/templates/message-plans/choose-large-print-letter-template/${routingConfigIds.validWithLetterTemplates}`
+ );
+ });
+ });
+
test('returns to the message plans list when choosing to "Save and close"', async ({
page,
baseURL,
}) => {
const chooseTemplatesPage = new RoutingChooseTemplatesPage(page);
- await chooseTemplatesPage.loadPage(validRoutingConfigId);
+ await chooseTemplatesPage.loadPage(routingConfigIds.valid);
await chooseTemplatesPage.saveAndCloseButton.click();
@@ -402,7 +599,7 @@ test.describe('Routing - Choose Templates page', () => {
test('when message plan cannot be found', async ({ page, baseURL }) => {
const chooseTemplatesPage = new RoutingChooseTemplatesPage(page);
- await chooseTemplatesPage.loadPage(notFoundRoutingConfigId);
+ await chooseTemplatesPage.loadPage(routingConfigIds.notFound);
await expect(page).toHaveURL(
`${baseURL}/templates/message-plans/invalid`
@@ -412,7 +609,7 @@ test.describe('Routing - Choose Templates page', () => {
test('when routing config ID is invalid', async ({ page, baseURL }) => {
const chooseTemplatesPage = new RoutingChooseTemplatesPage(page);
- await chooseTemplatesPage.loadPage(invalidRoutingConfigId);
+ await chooseTemplatesPage.loadPage(routingConfigIds.invalid);
await expect(page).toHaveURL(
`${baseURL}/templates/message-plans/invalid`
diff --git a/utils/utils/src/__tests__/enum.test.ts b/utils/utils/src/__tests__/enum.test.ts
index 36c4f92d9..7893870b3 100644
--- a/utils/utils/src/__tests__/enum.test.ts
+++ b/utils/utils/src/__tests__/enum.test.ts
@@ -241,6 +241,22 @@ describe('messagePlanChooseTemplateUrl', () => {
] as const)('should map %s to "%s"', (type, expected) => {
expect(messagePlanChooseTemplateUrl(type)).toBe(expected);
});
+
+ describe('conditional letter templates', () => {
+ test.each([
+ ['q4', 'choose-british-sign-language-letter-template'],
+ ['x0', 'choose-standard-english-letter-template'],
+ ['x1', 'choose-large-print-letter-template'],
+ ['language', 'choose-other-language-letter-template'],
+ ] as const)(
+ 'should map LETTER with conditionalType %s to "%s"',
+ (conditionalType, expected) => {
+ expect(messagePlanChooseTemplateUrl('LETTER', conditionalType)).toBe(
+ expected
+ );
+ }
+ );
+ });
});
describe('previewTemplatePages', () => {
@@ -401,7 +417,7 @@ describe('channelDisplayMappings', () => {
['NHSAPP', 'NHS App'],
['SMS', 'Text message (SMS)'],
['EMAIL', 'Email'],
- ['LETTER', 'Letter'],
+ ['LETTER', 'Standard English letter'],
] as const)('should map %s to "%s"', (channel, expected) => {
expect(channelDisplayMappings(channel)).toBe(expected);
});
diff --git a/utils/utils/src/enum.ts b/utils/utils/src/enum.ts
index b182c6e96..70ccad623 100644
--- a/utils/utils/src/enum.ts
+++ b/utils/utils/src/enum.ts
@@ -176,12 +176,20 @@ export const templateTypeToUrlTextMappings = (type: TemplateType) =>
LETTER: 'letter',
})[type];
-export const cascadeTemplateTypeToUrlTextMappings = (type: TemplateType) =>
+export const cascadeTemplateTypeToUrlTextMappings = (
+ type: TemplateType,
+ conditionalType?: LetterType | 'language'
+) =>
({
NHS_APP: 'nhs-app',
SMS: 'text-message',
EMAIL: 'email',
- LETTER: 'standard-english-letter',
+ LETTER: {
+ q4: 'british-sign-language-letter',
+ x0: 'standard-english-letter',
+ x1: 'large-print-letter',
+ language: 'other-language-letter',
+ }[conditionalType || 'x0'],
})[type];
const creationAction = (type: TemplateType) =>
@@ -200,10 +208,11 @@ export const previewTemplatePages = (type: TemplateType) =>
export const previewSubmittedTemplatePages = (type: TemplateType) =>
`preview-submitted-${templateTypeToUrlTextMappings(type)}-template`;
-export const messagePlanChooseTemplateUrl = (type: TemplateType) =>
- type === 'LETTER'
- ? 'choose-standard-english-letter-template'
- : `choose-${templateTypeToUrlTextMappings(type)}-template`;
+export const messagePlanChooseTemplateUrl = (
+ type: TemplateType,
+ conditionalType?: LetterType | 'language'
+) =>
+ `choose-${cascadeTemplateTypeToUrlTextMappings(type, conditionalType)}-template`;
const templateStatusCopyAction = (status: TemplateStatus) =>
(
@@ -322,7 +331,7 @@ export const channelDisplayMappings = (channel: Channel) => {
NHSAPP: 'NHS App',
SMS: 'Text message (SMS)',
EMAIL: 'Email',
- LETTER: 'Letter',
+ LETTER: 'Standard English letter',
};
return map[channel];
};