From 868fe239f0fe1f0fd9c1132c3674c11b3989781b Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 11:10:28 +0100 Subject: [PATCH 01/14] feat: add NotificationBanner content component --- .../components/NotificationBanner.test.ts | 85 +++++++++++++++++++ .../engine/components/NotificationBanner.ts | 31 +++++++ .../engine/components/helpers/components.ts | 5 ++ src/server/plugins/engine/components/index.ts | 1 + 4 files changed, 122 insertions(+) create mode 100644 src/server/plugins/engine/components/NotificationBanner.test.ts create mode 100644 src/server/plugins/engine/components/NotificationBanner.ts diff --git a/src/server/plugins/engine/components/NotificationBanner.test.ts b/src/server/plugins/engine/components/NotificationBanner.test.ts new file mode 100644 index 000000000..7c2179666 --- /dev/null +++ b/src/server/plugins/engine/components/NotificationBanner.test.ts @@ -0,0 +1,85 @@ +import { + ComponentType, + type NotificationBannerComponent +} from '@defra/forms-model' + +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { type Guidance } from '~/src/server/plugins/engine/components/helpers/components.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import definition from '~/test/form/definitions/basic.js' + +describe('NotificationBanner', () => { + let model: FormModel + + beforeEach(() => { + model = new FormModel(definition, { + basePath: 'test' + }) + }) + + describe('Defaults', () => { + let def: NotificationBannerComponent + let collection: ComponentCollection + let guidance: Guidance + + beforeEach(() => { + def = { + title: 'Important', + name: 'myComponent', + type: ComponentType.NotificationBanner, + content: 'You have 30 days to appeal this decision.', + options: {} + } satisfies NotificationBannerComponent + + collection = new ComponentCollection([def], { model }) + guidance = collection.guidance[0] + }) + + describe('View model', () => { + it('sets Nunjucks component defaults', () => { + const viewModel = guidance.getViewModel() + + expect(viewModel).toEqual( + expect.objectContaining({ + attributes: {}, + titleHtml: def.title, + html: def.content + }) + ) + }) + }) + }) + + describe('Success variant', () => { + let def: NotificationBannerComponent + let collection: ComponentCollection + let guidance: Guidance + + beforeEach(() => { + def = { + title: 'Success', + name: 'myComponent', + type: ComponentType.NotificationBanner, + content: 'Your application has been submitted.', + options: { type: 'success' } + } satisfies NotificationBannerComponent + + collection = new ComponentCollection([def], { model }) + guidance = collection.guidance[0] + }) + + describe('View model', () => { + it('includes type: success', () => { + const viewModel = guidance.getViewModel() + + expect(viewModel).toEqual( + expect.objectContaining({ + titleHtml: def.title, + html: def.content, + type: 'success' + }) + ) + }) + }) + }) +}) diff --git a/src/server/plugins/engine/components/NotificationBanner.ts b/src/server/plugins/engine/components/NotificationBanner.ts new file mode 100644 index 000000000..03695e911 --- /dev/null +++ b/src/server/plugins/engine/components/NotificationBanner.ts @@ -0,0 +1,31 @@ +import { type NotificationBannerComponent } from '@defra/forms-model' + +import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' + +export class NotificationBanner extends ComponentBase { + declare options: NotificationBannerComponent['options'] + content: NotificationBannerComponent['content'] + + constructor( + def: NotificationBannerComponent, + props: ConstructorParameters[1] + ) { + super(def, props) + + const { content, options } = def + + this.content = content + this.options = options + } + + getViewModel() { + const { content, title, viewModel } = this + + return { + ...viewModel, + titleHtml: title, + html: content, + type: this.options.type + } + } +} diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index 87e1cc264..fbda7be88 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -46,6 +46,7 @@ export type Guidance = | InstanceType | InstanceType | InstanceType + | InstanceType // List component instances only export type ListField = InstanceType< @@ -134,6 +135,10 @@ export function createComponent( component = new Components.Markdown(def, options) break + case ComponentType.NotificationBanner: + component = new Components.NotificationBanner(def, options) + break + case ComponentType.MultilineTextField: component = new Components.MultilineTextField(def, options) break diff --git a/src/server/plugins/engine/components/index.ts b/src/server/plugins/engine/components/index.ts index 7b59f6b11..8662e8ad2 100644 --- a/src/server/plugins/engine/components/index.ts +++ b/src/server/plugins/engine/components/index.ts @@ -15,6 +15,7 @@ export { Html } from '~/src/server/plugins/engine/components/Html.js' export { InsetText } from '~/src/server/plugins/engine/components/InsetText.js' export { List } from '~/src/server/plugins/engine/components/List.js' export { Markdown } from '~/src/server/plugins/engine/components/Markdown.js' +export { NotificationBanner } from '~/src/server/plugins/engine/components/NotificationBanner.js' export { MonthYearField } from '~/src/server/plugins/engine/components/MonthYearField.js' export { MultilineTextField } from '~/src/server/plugins/engine/components/MultilineTextField.js' export { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' From ab766a10dcec31976fd2f99b1132f36c83bc05c3 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 11:12:04 +0100 Subject: [PATCH 02/14] feat: add NotificationBanner Nunjucks template --- .../plugins/engine/views/components/notificationbanner.html | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/server/plugins/engine/views/components/notificationbanner.html diff --git a/src/server/plugins/engine/views/components/notificationbanner.html b/src/server/plugins/engine/views/components/notificationbanner.html new file mode 100644 index 000000000..f04336622 --- /dev/null +++ b/src/server/plugins/engine/views/components/notificationbanner.html @@ -0,0 +1,5 @@ +{% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %} + +{% macro NotificationBanner(component) %} + {{ govukNotificationBanner(component.model) }} +{% endmacro %} From d09a70a88b31eb874caca122ae1075b452f4f22f Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 11:13:11 +0100 Subject: [PATCH 03/14] chore: add NotificationBanner demo page to simple-form --- src/server/forms/simple-form.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/server/forms/simple-form.yaml b/src/server/forms/simple-form.yaml index e43cabd66..3e9a293d8 100644 --- a/src/server/forms/simple-form.yaml +++ b/src/server/forms/simple-form.yaml @@ -78,6 +78,24 @@ pages: id: '00738799-3489-4ab2-a57b-542eecb31bfa' next: [] id: da0fbdb4-a2de-4650-be16-9ba552af135f + - title: '' + path: '/notification-demo' + components: + - type: NotificationBanner + title: Important + name: notificationImportant + content: '

You have 30 days to appeal this decision.

' + options: {} + id: a3f8e1b2-4c56-7d89-0e12-3f456789abcd + - type: NotificationBanner + title: Application submitted + name: notificationSuccess + content: '

Your application for {{ applicantFirstName }} {{ applicantLastName }} has been received.

' + options: + type: success + id: b4e9f2c3-5d67-8e90-1f23-4g567890bcde + next: [] + id: c5f0a3d4-6e78-9f01-2a34-5h678901cdef - id: 449a45f6-4541-4a46-91bd-8b8931b07b50 title: '' path: '/summary' From 29b1e7aa888558a206dbf5c58513e644de1e1555 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 11:42:55 +0100 Subject: [PATCH 04/14] feat: render notification banner content via markdown parser --- src/server/forms/simple-form.yaml | 10 +++++----- .../engine/components/NotificationBanner.test.ts | 6 +++--- .../plugins/engine/components/NotificationBanner.ts | 3 ++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/server/forms/simple-form.yaml b/src/server/forms/simple-form.yaml index 3e9a293d8..a21d4576f 100644 --- a/src/server/forms/simple-form.yaml +++ b/src/server/forms/simple-form.yaml @@ -84,18 +84,18 @@ pages: - type: NotificationBanner title: Important name: notificationImportant - content: '

You have 30 days to appeal this decision.

' + content: 'You have 30 days to appeal this decision.' options: {} - id: a3f8e1b2-4c56-7d89-0e12-3f456789abcd + id: ca6b6590-1608-49f4-82f9-9d92e38e66ea - type: NotificationBanner title: Application submitted name: notificationSuccess - content: '

Your application for {{ applicantFirstName }} {{ applicantLastName }} has been received.

' + content: 'Your application for {{ applicantFirstName }} {{ applicantLastName }} has been received.' options: type: success - id: b4e9f2c3-5d67-8e90-1f23-4g567890bcde + id: b6960f4b-d771-4956-9c9b-17da5c603062 next: [] - id: c5f0a3d4-6e78-9f01-2a34-5h678901cdef + id: be4b4b86-514c-43bb-8cdc-f9aed6155924 - id: 449a45f6-4541-4a46-91bd-8b8931b07b50 title: '' path: '/summary' diff --git a/src/server/plugins/engine/components/NotificationBanner.test.ts b/src/server/plugins/engine/components/NotificationBanner.test.ts index 7c2179666..3d301db6f 100644 --- a/src/server/plugins/engine/components/NotificationBanner.test.ts +++ b/src/server/plugins/engine/components/NotificationBanner.test.ts @@ -27,7 +27,7 @@ describe('NotificationBanner', () => { title: 'Important', name: 'myComponent', type: ComponentType.NotificationBanner, - content: 'You have 30 days to appeal this decision.', + content: 'You have 30 days to [appeal this decision](/appeal).', options: {} } satisfies NotificationBannerComponent @@ -43,7 +43,7 @@ describe('NotificationBanner', () => { expect.objectContaining({ attributes: {}, titleHtml: def.title, - html: def.content + html: 'You have 30 days to appeal this decision.
' }) ) }) @@ -75,7 +75,7 @@ describe('NotificationBanner', () => { expect(viewModel).toEqual( expect.objectContaining({ titleHtml: def.title, - html: def.content, + html: 'Your application has been submitted.', type: 'success' }) ) diff --git a/src/server/plugins/engine/components/NotificationBanner.ts b/src/server/plugins/engine/components/NotificationBanner.ts index 03695e911..3ddf10460 100644 --- a/src/server/plugins/engine/components/NotificationBanner.ts +++ b/src/server/plugins/engine/components/NotificationBanner.ts @@ -1,6 +1,7 @@ import { type NotificationBannerComponent } from '@defra/forms-model' import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' +import { markdown } from '~/src/server/plugins/engine/components/markdownParser.js' export class NotificationBanner extends ComponentBase { declare options: NotificationBannerComponent['options'] @@ -24,7 +25,7 @@ export class NotificationBanner extends ComponentBase { return { ...viewModel, titleHtml: title, - html: content, + html: markdown.parse(content, { async: false }).trim(), type: this.options.type } } From 28932aedcf3610c2787761320ee6a99479ecf110 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 11:54:03 +0100 Subject: [PATCH 05/14] fix: use Nunjucks markdown filter for banner content rendering --- .../plugins/engine/components/NotificationBanner.test.ts | 6 +++--- src/server/plugins/engine/components/NotificationBanner.ts | 3 +-- .../engine/views/components/notificationbanner.html | 7 ++++++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/server/plugins/engine/components/NotificationBanner.test.ts b/src/server/plugins/engine/components/NotificationBanner.test.ts index 3d301db6f..4446fbde7 100644 --- a/src/server/plugins/engine/components/NotificationBanner.test.ts +++ b/src/server/plugins/engine/components/NotificationBanner.test.ts @@ -27,7 +27,7 @@ describe('NotificationBanner', () => { title: 'Important', name: 'myComponent', type: ComponentType.NotificationBanner, - content: 'You have 30 days to [appeal this decision](/appeal).', + content: 'You have 30 days to appeal this decision.', options: {} } satisfies NotificationBannerComponent @@ -43,7 +43,7 @@ describe('NotificationBanner', () => { expect.objectContaining({ attributes: {}, titleHtml: def.title, - html: 'You have 30 days to appeal this decision.
' + content: def.content }) ) }) @@ -75,7 +75,7 @@ describe('NotificationBanner', () => { expect(viewModel).toEqual( expect.objectContaining({ titleHtml: def.title, - html: 'Your application has been submitted.', + content: def.content, type: 'success' }) ) diff --git a/src/server/plugins/engine/components/NotificationBanner.ts b/src/server/plugins/engine/components/NotificationBanner.ts index 3ddf10460..6bed16a1f 100644 --- a/src/server/plugins/engine/components/NotificationBanner.ts +++ b/src/server/plugins/engine/components/NotificationBanner.ts @@ -1,7 +1,6 @@ import { type NotificationBannerComponent } from '@defra/forms-model' import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' -import { markdown } from '~/src/server/plugins/engine/components/markdownParser.js' export class NotificationBanner extends ComponentBase { declare options: NotificationBannerComponent['options'] @@ -25,7 +24,7 @@ export class NotificationBanner extends ComponentBase { return { ...viewModel, titleHtml: title, - html: markdown.parse(content, { async: false }).trim(), + content, type: this.options.type } } diff --git a/src/server/plugins/engine/views/components/notificationbanner.html b/src/server/plugins/engine/views/components/notificationbanner.html index f04336622..a9728703a 100644 --- a/src/server/plugins/engine/views/components/notificationbanner.html +++ b/src/server/plugins/engine/views/components/notificationbanner.html @@ -1,5 +1,10 @@ {% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %} {% macro NotificationBanner(component) %} - {{ govukNotificationBanner(component.model) }} + {% set bannerHtml %}{{ component.model.content | markdown | safe }}{% endset %} + {{ govukNotificationBanner({ + titleHtml: component.model.titleHtml, + html: bannerHtml, + type: component.model.type + }) }} {% endmacro %} From dca06d47d559e3619a9f6dd22ee5af3186ea6ed1 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 12:02:08 +0100 Subject: [PATCH 06/14] test: use markdown content in NotificationBanner test fixture --- src/server/plugins/engine/components/NotificationBanner.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/engine/components/NotificationBanner.test.ts b/src/server/plugins/engine/components/NotificationBanner.test.ts index 4446fbde7..e35903779 100644 --- a/src/server/plugins/engine/components/NotificationBanner.test.ts +++ b/src/server/plugins/engine/components/NotificationBanner.test.ts @@ -27,7 +27,7 @@ describe('NotificationBanner', () => { title: 'Important', name: 'myComponent', type: ComponentType.NotificationBanner, - content: 'You have 30 days to appeal this decision.', + content: 'You have 30 days to [appeal this decision](/appeal).', options: {} } satisfies NotificationBannerComponent From 30a1c29b9c69e5445e5a89b70b1fdceb458cc54f Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 12:26:54 +0100 Subject: [PATCH 07/14] feat: add heading option and fix template whitespace on notification banner --- src/server/forms/simple-form.yaml | 9 ++--- .../components/NotificationBanner.test.ts | 35 +++++++++++++++++++ .../engine/components/NotificationBanner.ts | 1 + .../views/components/notificationbanner.html | 5 ++- 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/server/forms/simple-form.yaml b/src/server/forms/simple-form.yaml index a21d4576f..e1173bb45 100644 --- a/src/server/forms/simple-form.yaml +++ b/src/server/forms/simple-form.yaml @@ -84,13 +84,14 @@ pages: - type: NotificationBanner title: Important name: notificationImportant - content: 'You have 30 days to appeal this decision.' - options: {} + content: 'Contact us if you need [help with your application](/help).' + options: + heading: There may be a delay in processing your application. id: ca6b6590-1608-49f4-82f9-9d92e38e66ea - type: NotificationBanner - title: Application submitted + title: Success name: notificationSuccess - content: 'Your application for {{ applicantFirstName }} {{ applicantLastName }} has been received.' + content: 'Your application has been submitted.' options: type: success id: b6960f4b-d771-4956-9c9b-17da5c603062 diff --git a/src/server/plugins/engine/components/NotificationBanner.test.ts b/src/server/plugins/engine/components/NotificationBanner.test.ts index e35903779..f6ed5eaa2 100644 --- a/src/server/plugins/engine/components/NotificationBanner.test.ts +++ b/src/server/plugins/engine/components/NotificationBanner.test.ts @@ -50,6 +50,41 @@ describe('NotificationBanner', () => { }) }) + describe('With heading', () => { + let def: NotificationBannerComponent + let collection: ComponentCollection + let guidance: Guidance + + beforeEach(() => { + def = { + title: 'Important', + name: 'myComponent', + type: ComponentType.NotificationBanner, + content: 'Contact us if you need help.', + options: { + heading: 'There may be a delay in processing your application.' + } + } satisfies NotificationBannerComponent + + collection = new ComponentCollection([def], { model }) + guidance = collection.guidance[0] + }) + + describe('View model', () => { + it('includes heading', () => { + const viewModel = guidance.getViewModel() + + expect(viewModel).toEqual( + expect.objectContaining({ + titleHtml: def.title, + content: def.content, + heading: def.options.heading + }) + ) + }) + }) + }) + describe('Success variant', () => { let def: NotificationBannerComponent let collection: ComponentCollection diff --git a/src/server/plugins/engine/components/NotificationBanner.ts b/src/server/plugins/engine/components/NotificationBanner.ts index 6bed16a1f..2ee78cf3b 100644 --- a/src/server/plugins/engine/components/NotificationBanner.ts +++ b/src/server/plugins/engine/components/NotificationBanner.ts @@ -25,6 +25,7 @@ export class NotificationBanner extends ComponentBase { ...viewModel, titleHtml: title, content, + heading: this.options.heading, type: this.options.type } } diff --git a/src/server/plugins/engine/views/components/notificationbanner.html b/src/server/plugins/engine/views/components/notificationbanner.html index a9728703a..9907a0a5f 100644 --- a/src/server/plugins/engine/views/components/notificationbanner.html +++ b/src/server/plugins/engine/views/components/notificationbanner.html @@ -1,7 +1,10 @@ {% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %} {% macro NotificationBanner(component) %} - {% set bannerHtml %}{{ component.model.content | markdown | safe }}{% endset %} + {%- set bannerHtml -%} + {%- if component.model.heading %}

{{ component.model.heading }}

{% endif -%} + {{ component.model.content | markdown | safe }} + {%- endset -%} {{ govukNotificationBanner({ titleHtml: component.model.titleHtml, html: bannerHtml, From f0a003a1a6e2853c68ec2bf33c417b1a03e0e1d1 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 13:33:53 +0100 Subject: [PATCH 08/14] fix: wrap banner markdown content in app-prose-scope for correct paragraph styling --- .../plugins/engine/views/components/notificationbanner.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/engine/views/components/notificationbanner.html b/src/server/plugins/engine/views/components/notificationbanner.html index 9907a0a5f..319347f00 100644 --- a/src/server/plugins/engine/views/components/notificationbanner.html +++ b/src/server/plugins/engine/views/components/notificationbanner.html @@ -3,7 +3,7 @@ {% macro NotificationBanner(component) %} {%- set bannerHtml -%} {%- if component.model.heading %}

{{ component.model.heading }}

{% endif -%} - {{ component.model.content | markdown | safe }} +
{{ component.model.content | markdown | safe }}
{%- endset -%} {{ govukNotificationBanner({ titleHtml: component.model.titleHtml, From 9290170bffb940af31cbedc5741ed9bd9b0dc163 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 13:42:53 +0100 Subject: [PATCH 09/14] fix: remove trailing margin from last child in app-prose-scope --- src/client/stylesheets/_prose.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/client/stylesheets/_prose.scss b/src/client/stylesheets/_prose.scss index a929dceba..ec7801fe7 100644 --- a/src/client/stylesheets/_prose.scss +++ b/src/client/stylesheets/_prose.scss @@ -26,6 +26,10 @@ @extend %govuk-body-m; } + > :last-child { + margin-bottom: 0; + } + strong, b { @include govuk-typography-weight-bold; From 85fa6e7259a666afef8d4abb401816c20370bb63 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 15:24:35 +0100 Subject: [PATCH 10/14] chore: update demo link to defra.gov.uk --- src/server/forms/simple-form.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/forms/simple-form.yaml b/src/server/forms/simple-form.yaml index e1173bb45..af1f92f38 100644 --- a/src/server/forms/simple-form.yaml +++ b/src/server/forms/simple-form.yaml @@ -84,7 +84,7 @@ pages: - type: NotificationBanner title: Important name: notificationImportant - content: 'Contact us if you need [help with your application](/help).' + content: 'Contact us if you need [help with your application](https://www.defra.gov.uk).' options: heading: There may be a delay in processing your application. id: ca6b6590-1608-49f4-82f9-9d92e38e66ea From fa1d2e8fa8b293bcb51b6e2caa7db4d4ee95bb96 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 15:28:27 +0100 Subject: [PATCH 11/14] refactor: inline test setup into individual NotificationBanner tests --- .../components/NotificationBanner.test.ts | 89 +++++++------------ 1 file changed, 32 insertions(+), 57 deletions(-) diff --git a/src/server/plugins/engine/components/NotificationBanner.test.ts b/src/server/plugins/engine/components/NotificationBanner.test.ts index f6ed5eaa2..8a01be296 100644 --- a/src/server/plugins/engine/components/NotificationBanner.test.ts +++ b/src/server/plugins/engine/components/NotificationBanner.test.ts @@ -4,7 +4,6 @@ import { } from '@defra/forms-model' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' -import { type Guidance } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import definition from '~/test/form/definitions/basic.js' @@ -18,26 +17,18 @@ describe('NotificationBanner', () => { }) describe('Defaults', () => { - let def: NotificationBannerComponent - let collection: ComponentCollection - let guidance: Guidance - - beforeEach(() => { - def = { - title: 'Important', - name: 'myComponent', - type: ComponentType.NotificationBanner, - content: 'You have 30 days to [appeal this decision](/appeal).', - options: {} - } satisfies NotificationBannerComponent - - collection = new ComponentCollection([def], { model }) - guidance = collection.guidance[0] - }) - describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = guidance.getViewModel() + const def = { + title: 'Important', + name: 'myComponent', + type: ComponentType.NotificationBanner, + content: 'You have 30 days to [appeal this decision](/appeal).', + options: {} + } satisfies NotificationBannerComponent + + const collection = new ComponentCollection([def], { model }) + const viewModel = collection.guidance[0].getViewModel() expect(viewModel).toEqual( expect.objectContaining({ @@ -51,28 +42,20 @@ describe('NotificationBanner', () => { }) describe('With heading', () => { - let def: NotificationBannerComponent - let collection: ComponentCollection - let guidance: Guidance - - beforeEach(() => { - def = { - title: 'Important', - name: 'myComponent', - type: ComponentType.NotificationBanner, - content: 'Contact us if you need help.', - options: { - heading: 'There may be a delay in processing your application.' - } - } satisfies NotificationBannerComponent - - collection = new ComponentCollection([def], { model }) - guidance = collection.guidance[0] - }) - describe('View model', () => { it('includes heading', () => { - const viewModel = guidance.getViewModel() + const def = { + title: 'Important', + name: 'myComponent', + type: ComponentType.NotificationBanner, + content: 'Contact us if you need help.', + options: { + heading: 'There may be a delay in processing your application.' + } + } satisfies NotificationBannerComponent + + const collection = new ComponentCollection([def], { model }) + const viewModel = collection.guidance[0].getViewModel() expect(viewModel).toEqual( expect.objectContaining({ @@ -86,26 +69,18 @@ describe('NotificationBanner', () => { }) describe('Success variant', () => { - let def: NotificationBannerComponent - let collection: ComponentCollection - let guidance: Guidance - - beforeEach(() => { - def = { - title: 'Success', - name: 'myComponent', - type: ComponentType.NotificationBanner, - content: 'Your application has been submitted.', - options: { type: 'success' } - } satisfies NotificationBannerComponent - - collection = new ComponentCollection([def], { model }) - guidance = collection.guidance[0] - }) - describe('View model', () => { it('includes type: success', () => { - const viewModel = guidance.getViewModel() + const def = { + title: 'Success', + name: 'myComponent', + type: ComponentType.NotificationBanner, + content: 'Your application has been submitted.', + options: { type: 'success' } + } satisfies NotificationBannerComponent + + const collection = new ComponentCollection([def], { model }) + const viewModel = collection.guidance[0].getViewModel() expect(viewModel).toEqual( expect.objectContaining({ From 6cb6e54650ab54661787670f5b7a1bc0460ff186 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 15:30:03 +0100 Subject: [PATCH 12/14] feat: support markdown in notification banner heading via app-prose-scope --- .../plugins/engine/components/NotificationBanner.test.ts | 2 +- .../plugins/engine/views/components/notificationbanner.html | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/components/NotificationBanner.test.ts b/src/server/plugins/engine/components/NotificationBanner.test.ts index 8a01be296..84d632501 100644 --- a/src/server/plugins/engine/components/NotificationBanner.test.ts +++ b/src/server/plugins/engine/components/NotificationBanner.test.ts @@ -50,7 +50,7 @@ describe('NotificationBanner', () => { type: ComponentType.NotificationBanner, content: 'Contact us if you need help.', options: { - heading: 'There may be a delay in processing your application.' + heading: 'There may be a **delay** in processing your application.' } } satisfies NotificationBannerComponent diff --git a/src/server/plugins/engine/views/components/notificationbanner.html b/src/server/plugins/engine/views/components/notificationbanner.html index 319347f00..8aaa7fb78 100644 --- a/src/server/plugins/engine/views/components/notificationbanner.html +++ b/src/server/plugins/engine/views/components/notificationbanner.html @@ -2,8 +2,10 @@ {% macro NotificationBanner(component) %} {%- set bannerHtml -%} - {%- if component.model.heading %}

{{ component.model.heading }}

{% endif -%} -
{{ component.model.content | markdown | safe }}
+
+ {%- if component.model.heading %}{{ component.model.heading | markdown | safe }}{% endif -%} + {{ component.model.content | markdown | safe }} +
{%- endset -%} {{ govukNotificationBanner({ titleHtml: component.model.titleHtml, From f99f9e834eaa8b7ea5d49af92c00f1a4b5feab02 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 18 May 2026 16:02:28 +0100 Subject: [PATCH 13/14] refactor: flatten NotificationBanner tests, remove single-test describe blocks --- .../components/NotificationBanner.test.ts | 112 ++++++++---------- 1 file changed, 50 insertions(+), 62 deletions(-) diff --git a/src/server/plugins/engine/components/NotificationBanner.test.ts b/src/server/plugins/engine/components/NotificationBanner.test.ts index 84d632501..de8efd586 100644 --- a/src/server/plugins/engine/components/NotificationBanner.test.ts +++ b/src/server/plugins/engine/components/NotificationBanner.test.ts @@ -16,80 +16,68 @@ describe('NotificationBanner', () => { }) }) - describe('Defaults', () => { - describe('View model', () => { - it('sets Nunjucks component defaults', () => { - const def = { - title: 'Important', - name: 'myComponent', - type: ComponentType.NotificationBanner, - content: 'You have 30 days to [appeal this decision](/appeal).', - options: {} - } satisfies NotificationBannerComponent + it('sets Nunjucks component defaults', () => { + const def = { + title: 'Important', + name: 'myComponent', + type: ComponentType.NotificationBanner, + content: 'You have 30 days to [appeal this decision](/appeal).', + options: {} + } satisfies NotificationBannerComponent - const collection = new ComponentCollection([def], { model }) - const viewModel = collection.guidance[0].getViewModel() + const collection = new ComponentCollection([def], { model }) + const viewModel = collection.guidance[0].getViewModel() - expect(viewModel).toEqual( - expect.objectContaining({ - attributes: {}, - titleHtml: def.title, - content: def.content - }) - ) + expect(viewModel).toEqual( + expect.objectContaining({ + attributes: {}, + titleHtml: def.title, + content: def.content }) - }) + ) }) - describe('With heading', () => { - describe('View model', () => { - it('includes heading', () => { - const def = { - title: 'Important', - name: 'myComponent', - type: ComponentType.NotificationBanner, - content: 'Contact us if you need help.', - options: { - heading: 'There may be a **delay** in processing your application.' - } - } satisfies NotificationBannerComponent + it('includes heading in view model', () => { + const def = { + title: 'Important', + name: 'myComponent', + type: ComponentType.NotificationBanner, + content: 'Contact us if you need help.', + options: { + heading: 'There may be a **delay** in processing your application.' + } + } satisfies NotificationBannerComponent - const collection = new ComponentCollection([def], { model }) - const viewModel = collection.guidance[0].getViewModel() + const collection = new ComponentCollection([def], { model }) + const viewModel = collection.guidance[0].getViewModel() - expect(viewModel).toEqual( - expect.objectContaining({ - titleHtml: def.title, - content: def.content, - heading: def.options.heading - }) - ) + expect(viewModel).toEqual( + expect.objectContaining({ + titleHtml: def.title, + content: def.content, + heading: def.options.heading }) - }) + ) }) - describe('Success variant', () => { - describe('View model', () => { - it('includes type: success', () => { - const def = { - title: 'Success', - name: 'myComponent', - type: ComponentType.NotificationBanner, - content: 'Your application has been submitted.', - options: { type: 'success' } - } satisfies NotificationBannerComponent + it('sets type: success for success variant', () => { + const def = { + title: 'Success', + name: 'myComponent', + type: ComponentType.NotificationBanner, + content: 'Your application has been submitted.', + options: { type: 'success' } + } satisfies NotificationBannerComponent - const collection = new ComponentCollection([def], { model }) - const viewModel = collection.guidance[0].getViewModel() + const collection = new ComponentCollection([def], { model }) + const viewModel = collection.guidance[0].getViewModel() - expect(viewModel).toEqual( - expect.objectContaining({ - titleHtml: def.title, - content: def.content, - type: 'success' - }) - ) + expect(viewModel).toEqual( + expect.objectContaining({ + titleHtml: def.title, + content: def.content, + type: 'success' }) - }) + ) }) }) From 8cbfd16eeedbd9f5d7e6d81484273c4ac117f1b7 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 3 Jun 2026 10:04:09 +0100 Subject: [PATCH 14/14] use latest model --- package-lock.json | 43 ++++++++++++++++++++++++++++++++++++++----- package.json | 2 +- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c65db7f6..d538a0d8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.668", + "@defra/forms-model": "^3.0.673", "@defra/hapi-tracing": "^1.29.0", "@defra/interactive-map": "^0.0.22-alpha", "@elastic/ecs-pino-format": "^1.5.0", @@ -236,6 +236,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2081,6 +2082,7 @@ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -2223,6 +2225,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2262,6 +2265,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -3513,9 +3517,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.668", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.668.tgz", - "integrity": "sha512-H0FBwHZu+joIlBXdGOiqDguZMtVT8WDxIFUUw737wJNdFrqj0PxlpVQn/hPHc5Juvt0s19JUZhrNlI0sXoucZw==", + "version": "3.0.673", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.673.tgz", + "integrity": "sha512-CyPKSpnWYv+mBhZXNjc2L2mNxrzuGp3iToxHbzv3QU8ckmPUbocS6jgB7rXsgg98Fvza7hiG7CCCRG3wUwyCbg==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", @@ -4412,6 +4416,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.10.1.tgz", "integrity": "sha512-3pf2fXXw0eVk8WnC3T4LIigRDupcpvngpKo9Vy7mYyBhuddc0klDUuZAIfzMoK6z05pdlk6EFC/vBSX43+1O5w==", "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/babel": "3.10.1", "@docusaurus/bundler": "3.10.1", @@ -4586,6 +4591,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.1.tgz", "integrity": "sha512-2jRVrtzjf8LClGTHQlwlwuD3wQXRx3WEoF7XUarJ8Ou+0onV+SLtejsyfY9JLpfUh9hPhXM4pbBGkyAY4Bi3HQ==", "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/core": "3.10.1", "@docusaurus/logger": "3.10.1", @@ -4619,6 +4625,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.10.1.tgz", "integrity": "sha512-0YtmIeoNo1fIw65LO8+/1dPgmDV86UmhMkow37gzjytuiCSQm9xob6PJy0L4kuQEMTLfUOGvkXvZr7GPrHquMA==", "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/mdx-loader": "3.10.1", "@docusaurus/module-type-aliases": "3.10.1", @@ -7358,6 +7365,7 @@ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "license": "MIT", + "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -11305,6 +11313,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -11501,6 +11510,7 @@ "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", @@ -11540,6 +11550,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -12344,6 +12355,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -12420,6 +12432,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -13468,6 +13481,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -13826,6 +13840,7 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "license": "MIT", + "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -17367,6 +17382,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -17554,6 +17570,7 @@ "integrity": "sha512-vPZZsiOKaBAIATpFE2uMI4w5IRwdv/FpQ+qZZMR4E+PeOcM4OeoEbqxRMnywdxP19TyB/3h6QBB0EWon7letSQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/types": "^8.35.0", "comment-parser": "^1.4.1", @@ -21347,6 +21364,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -22564,6 +22582,7 @@ "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", @@ -22630,6 +22649,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -27543,6 +27563,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -28939,6 +28960,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -29516,6 +29538,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -29525,6 +29548,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -29569,6 +29593,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz", "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/react": "*" }, @@ -29597,6 +29622,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -31123,6 +31149,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -32375,6 +32402,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-syntax-patches-for-csstree": "^1.0.19", @@ -33124,6 +33152,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -33360,7 +33389,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.21.0", @@ -33368,6 +33398,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -33562,6 +33593,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -34238,6 +34270,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/package.json b/package.json index 42a2515be..205d8827c 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.668", + "@defra/forms-model": "^3.0.673", "@defra/hapi-tracing": "^1.29.0", "@defra/interactive-map": "^0.0.22-alpha", "@elastic/ecs-pino-format": "^1.5.0",