Repository: DEFRA/marine-licensing-frontend
Pull request #25: ML 16: Update Coordinates Site page
Author: login: garethfergusonee
Created: 2025-05-16T16:56:22Z | Merged: 2025-05-20T11:03:45Z

ML-150 Subtask

Create a template for radio button choice pages to avoid repeating this pattern in future stories
Create a page at `/how-do-you-want-to-provide-the-coordinates`

Validation and errors to come in next subtask


Diff:
diff --git a/src/server/common/components/radio-page/macro.njk b/src/server/common/components/radio-page/macro.njk
new file mode 100644
index 00000000..ecc2bee1
--- /dev/null
+++ b/src/server/common/components/radio-page/macro.njk
@@ -0,0 +1,3 @@
+{% macro appRadioPage(params) %}
+    {%- include "./template.njk" -%}
+{% endmacro %}
\ No newline at end of file
diff --git a/src/server/common/components/radio-page/radio-page.test.js b/src/server/common/components/radio-page/radio-page.test.js
new file mode 100644
index 00000000..43988d88
--- /dev/null
+++ b/src/server/common/components/radio-page/radio-page.test.js
@@ -0,0 +1,91 @@
+import { renderComponent } from '~/src/server/test-helpers/component-helpers.js'
+
+describe('radioPage Component', () => {
+  /** @type {CheerioAPI} */
+  let $radioPage
+
+  const commonTestData = {
+    name: 'test-name',
+    items: [
+      {
+        value: 'yes',
+        text: 'Yes'
+      },
+      {
+        value: 'no',
+        text: 'No'
+      }
+    ],
+    projectName: 'test project'
+  }
+
+  test('Should render radio buttons page correctly with a heading inside the form element', () => {
+    $radioPage = renderComponent('radio-page', {
+      ...commonTestData,
+      isPageHeading: true,
+      heading: 'Test heading'
+    })
+
+    const legend = $radioPage('.govuk-fieldset__legend')
+
+    expect(legend).toHaveLength(1)
+    expect(legend.children('h1').text().trim()).toBe('Test heading')
+
+    expect($radioPage('.govuk-caption-l').text().trim()).toBe('test project')
+
+    expect($radioPage('input[name="test-name"]')).toHaveLength(2)
+    expect($radioPage('input[value="yes"]')).toHaveLength(1)
+    expect($radioPage('input[value="no"]')).toHaveLength(1)
+
+    expect($radioPage('button').text().trim()).toBe('Continue')
+    expect($radioPage('a').text().trim()).toBe('Cancel')
+  })
+
+  test('Should render radio buttons page correctly with a seperate heading', () => {
+    $radioPage = renderComponent('radio-page', {
+      ...commonTestData,
+      heading: 'Test heading'
+    })
+
+    expect($radioPage('h1').text().trim()).toBe('Test heading')
+    expect($radioPage('.govuk-fieldset__legend')).toHaveLength(0)
+    expect($radioPage('.govuk-caption-l').text().trim()).toBe('test project')
+
+    expect($radioPage('input[name="test-name"]')).toHaveLength(2)
+    expect($radioPage('input[value="yes"]')).toHaveLength(1)
+    expect($radioPage('input[value="no"]')).toHaveLength(1)
+
+    expect($radioPage('button').text().trim()).toBe('Continue')
+    expect($radioPage('a').text().trim()).toBe('Cancel')
+  })
+
+  test('Should render errors', () => {
+    $radioPage = renderComponent('radio-page', {
+      ...commonTestData,
+      isPageHeading: true,
+      heading: 'Test heading',
+      errorSummary: [
+        {
+          href: '#test-name',
+          text: 'test error',
+          field: ['test-name']
+        }
+      ],
+      errorMessage: { text: 'test error' }
+    })
+
+    expect($radioPage('.govuk-error-summary')).toHaveLength(1)
+    expect($radioPage('.govuk-error-summary').find('a').text().trim()).toBe(
+      'test error'
+    )
+
+    expect($radioPage('.govuk-error-message')).toHaveLength(1)
+    expect($radioPage('.govuk-error-message').text().trim()).toBe(
+      'Error: test error'
+    )
+  })
+})
+
+/**
+ * @import { CheerioAPI } from 'cheerio'
+ */
diff --git a/src/server/common/components/radio-page/template.njk b/src/server/common/components/radio-page/template.njk
new file mode 100644
index 00000000..ca8652fd
--- /dev/null
+++ b/src/server/common/components/radio-page/template.njk
@@ -0,0 +1,55 @@
+{% from "govuk/components/button/macro.njk" import govukButton %}
+{% from "govuk/components/radios/macro.njk" import govukRadios %}
+{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}
+
+  <div class="govuk-grid-row">
+    <div class="govuk-grid-column-two-thirds">
+
+        {% if params.errorSummary %}
+            {{ govukErrorSummary({
+                titleText: "There is a problem",
+                errorList: params.errorSummary
+            }) }}
+        {% endif %}
+
+        {% if params.projectName %}
+            <span class="govuk-caption-l">{{ params.projectName }}</span>
+        {% endif %}
+
+        {% if params.isPageHeading %} {% else %}
+            <h1 class="govuk-heading-l">{{ params.heading }}</h1>
+        {% endif %}
+
+        <form method="POST">
+
+            {{ govukRadios({
+                idPrefix: params.name,
+                name: params.name,
+                value: params.value,
+                fieldset: {
+                    legend: {
+                    text: params.heading,
+                    isPageHeading: true,
+                    classes: "govuk-fieldset__legend govuk-fieldset__legend--l govuk-!-margin-bottom-6"
+                    }
+                } if params.isPageHeading,
+                hint: {
+                    text: params.hint
+                } if params.hint,
+                errorMessage: params.errorMessage,
+                items: params.items
+            }) }}
+
+            <div class="govuk-button-group">
+                {{ govukButton({
+                text: "Continue",
+                classes: "govuk-button--primary"
+                }) }}
+
+                <a class="govuk-link" href="/exemption/task-list">Cancel</a>
+            </div>
+
+        </form>
+
+    </div>
+<div>
diff --git a/src/server/exemption/index.js b/src/server/exemption/index.js
index 6a45c545..38cf65ab 100644
--- a/src/server/exemption/index.js
+++ b/src/server/exemption/index.js
@@ -1,6 +1,7 @@
 import { projectNameRoutes } from '~/src/server/exemption/project-name/index.js'
 import { publicRegisterRoutes } from '~/src/server/exemption/public-register/index.js'
 import { taskListRoutes } from '~/src/server/exemption/task-list/index.js'
+import { siteDetailsRoutes } from '~/src/server/exemption/site-details/index.js'
 
 /**
  * Sets up the routes used in the exemption home page.
@@ -18,6 +19,7 @@ export const exemption = {
         ...projectNameRoutes,
         ...publicRegisterRoutes,
         ...taskListRoutes,
+        ...siteDetailsRoutes,
         {
           method: 'GET',
           path: '/exemption',
diff --git a/src/server/exemption/index.test.js b/src/server/exemption/index.test.js
index 894e12fa..a2b8ef73 100644
--- a/src/server/exemption/index.test.js
+++ b/src/server/exemption/index.test.js
@@ -30,6 +30,14 @@ describe('exemption route', () => {
         method: 'GET',
         path: '/exemption/task-list'
       }),
+      expect.objectContaining({
+        method: 'GET',
+        path: '/exemption/how-do-you-want-to-provide-the-coordinates'
+      }),
+      expect.objectContaining({
+        method: 'POST',
+        path: '/exemption/how-do-you-want-to-provide-the-coordinates'
+      }),
       expect.objectContaining({
         method: 'GET',
         path: '/exemption'
@@ -48,7 +56,7 @@ describe('exemption route', () => {
 
     const routes = server.route.mock.calls[0][0]
 
-    const handler = routes[5].handler
+    const handler = routes.at(-1).handler
 
     const redirectSpy = jest.fn().mockReturnValue('redirected')
 
diff --git a/src/server/exemption/site-details/coordinates-type/controller.js b/src/server/exemption/site-details/coordinates-type/controller.js
new file mode 100644
index 00000000..312c83b5
--- /dev/null
+++ b/src/server/exemption/site-details/coordinates-type/controller.js
@@ -0,0 +1,41 @@
+import { getExemptionCache } from '~/src/server/common/helpers/session-cache/utils.js'
+
+export const PROVIDE_COORDINATES_CHOICE_ROUTE =
+  '/exemption/how-do-you-want-to-provide-the-coordinates'
+export const PROVIDE_COORDINATES_CHOICE_VIEW_ROUTE =
+  'exemption/site-details/coordinates-type/index'
+
+const provideCoordinatesSettings = {
+  pageTitle: 'How do you want to provide the site location?',
+  heading: 'How do you want to provide the site location?'
+}
+
+/**
+ * A GDS styled page controller for the coordinates type page.
+ * @satisfies {Partial<ServerRoute>}
+ */
+export const coordinatesTypeController = {
+  handler(request, h) {
+    const exemption = getExemptionCache(request)
+
+    return h.view(PROVIDE_COORDINATES_CHOICE_VIEW_ROUTE, {
+      ...provideCoordinatesSettings,
+      projectName: exemption.projectName
+    })
+  }
+}
+
+/**
+ * A GDS styled page controller for the POST route in the coordinates type page.
+ * @satisfies {Partial<ServerRoute>}
+ */
+export const coordinatesTypeSubmitController = {
+  handler(request, h) {
+    const exemption = getExemptionCache(request)
+
+    return h.view(PROVIDE_COORDINATES_CHOICE_VIEW_ROUTE, {
+      ...provideCoordinatesSettings,
+      projectName: exemption.projectName
+    })
+  }
+}
diff --git a/src/server/exemption/site-details/coordinates-type/controller.test.js b/src/server/exemption/site-details/coordinates-type/controller.test.js
new file mode 100644
index 00000000..5addc0ae
--- /dev/null
+++ b/src/server/exemption/site-details/coordinates-type/controller.test.js
@@ -0,0 +1,106 @@
+import { createServer } from '~/src/server/index.js'
+import {
+  coordinatesTypeController,
+  coordinatesTypeSubmitController,
+  PROVIDE_COORDINATES_CHOICE_VIEW_ROUTE,
+  PROVIDE_COORDINATES_CHOICE_ROUTE
+} from '~/src/server/exemption/site-details/coordinates-type/controller.js'
+import * as cacheUtils from '~/src/server/common/helpers/session-cache/utils.js'
+import { mockExemption } from '~/src/server/test-helpers/mocks.js'
+import { statusCodes } from '~/src/server/common/constants/status-codes.js'
+import { config } from '~/src/config/config.js'
+import { JSDOM } from 'jsdom'
+
+jest.mock('~/src/server/common/helpers/session-cache/utils.js')
+
+describe('#coordinatesTypeController', () => {
+  /** @type {Server} */
+  let server
+
+  beforeAll(async () => {
+    server = await createServer()
+    await server.initialize()
+  })
+
+  beforeEach(() => {
+    jest.resetAllMocks()
+    jest.spyOn(cacheUtils, 'getExemptionCache').mockReturnValue(mockExemption)
+  })
+
+  afterAll(async () => {
+    await server.stop({ timeout: 0 })
+  })
+
+  test('coordinatesTypeController handler should render with correct context', () => {
+    const h = { view: jest.fn() }
+
+    coordinatesTypeController.handler({}, h)
+
+    expect(h.view).toHaveBeenCalledWith(PROVIDE_COORDINATES_CHOICE_VIEW_ROUTE, {
+      pageTitle: 'How do you want to provide the site location?',
+      heading: 'How do you want to provide the site location?',
+      projectName: mockExemption.projectName
+    })
+  })
+
+  test('Should provide expected response and correctly pre populate data', async () => {
+    const { result, statusCode } = await server.inject({
+      method: 'GET',
+      url: PROVIDE_COORDINATES_CHOICE_ROUTE
+    })
+
+    expect(result).toEqual(
+      expect.stringContaining(
+        `How do you want to provide the site location? | ${config.get('serviceName')}`
+      )
+    )
+
+    const { document } = new JSDOM(result).window
+
+    expect(document.querySelector('h1').textContent.trim()).toBe(
+      'How do you want to provide the site location?'
+    )
+
+    expect(document.querySelector('.govuk-caption-l').textContent.trim()).toBe(
+      mockExemption.projectName
+    )
+
+    expect(document.querySelector('#coordinatesType').value).toBe('file')
+
+    expect(document.querySelector('#coordinatesType-2').value).toBe(
+      'coordinates'
+    )
+
+    expect(
+      document
+        .querySelector('.govuk-back-link[href="/exemption/task-list"')
+        .textContent.trim()
+    ).toBe('Back')
+
+    expect(
+      document
+        .querySelector('.govuk-link[href="/exemption/task-list"')
+        .textContent.trim()
+    ).toBe('Cancel')
+
+    expect(statusCode).toBe(statusCodes.ok)
+  })
+
+  test('Should correctly remain on the same page when POST is successful', async () => {
+    const h = {
+      view: jest.fn()
+    }
+
+    await coordinatesTypeSubmitController.handler({}, h)
+
+    expect(h.view).toHaveBeenCalledWith(PROVIDE_COORDINATES_CHOICE_VIEW_ROUTE, {
+      pageTitle: 'How do you want to provide the site location?',
+      heading: 'How do you want to provide the site location?',
+      projectName: mockExemption.projectName
+    })
+  })
+})
+
+/**
+ * @import { Server } from '@hapi/hapi'
+ */
diff --git a/src/server/exemption/site-details/coordinates-type/index.js b/src/server/exemption/site-details/coordinates-type/index.js
new file mode 100644
index 00000000..a82c1d3a
--- /dev/null
+++ b/src/server/exemption/site-details/coordinates-type/index.js
@@ -0,0 +1,30 @@
+import {
+  coordinatesTypeController,
+  coordinatesTypeSubmitController,
+  PROVIDE_COORDINATES_CHOICE_ROUTE
+} from '~/src/server/exemption/site-details/coordinates-type/controller.js'
+
+/**
+ * Sets up the routes used in the provide the coordinates choice page.
+ * These routes are registered in src/server/router.js.
+ */
+
+/**
+ * @satisfies {ServerRegisterPluginObject<void>}
+ */
+export const coordinatesTypeRoutes = [
+  {
+    method: 'GET',
+    path: PROVIDE_COORDINATES_CHOICE_ROUTE,
+    ...coordinatesTypeController
+  },
+  {
+    method: 'POST',
+    path: PROVIDE_COORDINATES_CHOICE_ROUTE,
+    ...coordinatesTypeSubmitController
+  }
+]
+
+/**
+ * @import { ServerRegisterPluginObject } from '@hapi/hapi'
+ */
diff --git a/src/server/exemption/site-details/coordinates-type/index.njk b/src/server/exemption/site-details/coordinates-type/index.njk
new file mode 100644
index 00000000..9d39dcf3
--- /dev/null
+++ b/src/server/exemption/site-details/coordinates-type/index.njk
@@ -0,0 +1,35 @@
+{% extends 'layouts/page.njk' %}
+{% from "govuk/components/back-link/macro.njk" import govukBackLink %}
+{% from "radio-page/macro.njk" import appRadioPage %}
+
+{% block beforeContent %}
+  {{ govukBackLink({
+    text: "Back",
+    href: '/exemption/task-list'
+  }) }}
+{% endblock %}
+
+{% block content %}
+
+  {{ appRadioPage({
+      name: "coordinatesType",
+      errorSummary: errorSummary,
+      errors: errors,
+      heading: "How do you want to provide the site location?",
+      isPageHeading: true,
+      value: payload.coordinatesType,
+      errorMessage: errors['coordinatesType'],
+      projectName: projectName,
+      items: [
+        {
+          value: "file",
+          text: "Upload a file with the coordinates of the site"
+        },
+        {
+          value: "coordinates",
+          text: "Enter the coordinates of the site manually"
+        }
+      ]
+    }) }}
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/server/exemption/site-details/index.js b/src/server/exemption/site-details/index.js
new file mode 100644
index 00000000..a1026529
--- /dev/null
+++ b/src/server/exemption/site-details/index.js
@@ -0,0 +1,15 @@
+import { coordinatesTypeRoutes } from '~/src/server/exemption/site-details/coordinates-type/index.js'
+
+/**
+ * Sets up the routes used in the Site Details section
+ * These routes are registered in src/server/router.js.
+ */
+
+/**
+ * @satisfies {ServerRegisterPluginObject<void>}
+ */
+export const siteDetailsRoutes = [...coordinatesTypeRoutes]
+
+/**
+ * @import { ServerRegisterPluginObject } from '@hapi/hapi'
+ */
diff --git a/src/server/test-helpers/component-helpers.js b/src/server/test-helpers/component-helpers.js
index f9d7c700..c60e8f2b 100644
--- a/src/server/test-helpers/component-helpers.js
+++ b/src/server/test-helpers/component-helpers.js
@@ -9,7 +9,7 @@ import * as globals from '~/src/config/nunjucks/globals.js'
 const dirname = path.dirname(fileURLToPath(import.meta.url))
 const nunjucksTestEnv = nunjucks.configure(
   [
-    '~/node_modules/govuk-frontend/dist/',
+    'node_modules/govuk-frontend/dist/',
     path.normalize(path.resolve(dirname, '../common/templates')),
     path.normalize(path.resolve(dirname, '../common/components'))
   ],
diff --git a/src/server/test-helpers/mocks.js b/src/server/test-helpers/mocks.js
index 902181e7..ed2b30f2 100644
--- a/src/server/test-helpers/mocks.js
+++ b/src/server/test-helpers/mocks.js
@@ -9,5 +9,6 @@ export const mockExemption = {
   id: faker.database.mongodbObjectId(),
   projectName: 'Test Project',
   publicRegister: { consent: 'yes', reason: 'Test reason' },
+  siteDetails: { coordinatesType: 'coordinates' },
   taskList: mockExemptionTaskList
 }


Reviews:
user: login: dhansak79
body: We have discussed and you are introducing CSRF protection in the the next ticket.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T09:16:36Z
user: login: garethfergusonee
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T09:16:49Z

Review comments:
diff_hunk: @@ -0,0 +1,55 @@
+{% from "govuk/components/button/macro.njk" import govukButton %}
+{% from "govuk/components/radios/macro.njk" import govukRadios %}
+{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}
+
+  <div class="govuk-grid-row">
+    <div class="govuk-grid-column-two-thirds">
+
+        {% if params.errorSummary %}
+            {{ govukErrorSummary({
+                titleText: "There is a problem",
+                errorList: params.errorSummary
+            }) }}
+        {% endif %}
+
+        {% if params.projectName %}
+            <span class="govuk-caption-l">{{ params.projectName }}</span>
+        {% endif %}
+
+        {% if params.isPageHeading %} {% else %}
+            <h1 class="govuk-heading-l">{{ params.heading }}</h1>
+        {% endif %}
+
+        <form method="POST">
path: src/server/common/components/radio-page/template.njk
user: login: dhansak79
body: We should consider introducing CSRF protection to any form methods that `POST`
created_at: 2025-05-20T09:14:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,55 @@
+{% from "govuk/components/button/macro.njk" import govukButton %}
+{% from "govuk/components/radios/macro.njk" import govukRadios %}
+{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}
+
+  <div class="govuk-grid-row">
+    <div class="govuk-grid-column-two-thirds">
+
+        {% if params.errorSummary %}
+            {{ govukErrorSummary({
+                titleText: "There is a problem",
+                errorList: params.errorSummary
+            }) }}
+        {% endif %}
+
+        {% if params.projectName %}
+            <span class="govuk-caption-l">{{ params.projectName }}</span>
+        {% endif %}
+
+        {% if params.isPageHeading %} {% else %}
+            <h1 class="govuk-heading-l">{{ params.heading }}</h1>
+        {% endif %}
+
+        <form method="POST">
path: src/server/common/components/radio-page/template.njk
user: login: garethfergusonee
body: Thanks, CSRF will be added as part of the next ticket
created_at: 2025-05-20T09:16:49Z
author_association: COLLABORATOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-19T17:03:34Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=DEFRA_marine-licensing-frontend&pullRequest=25) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=DEFRA_marine-licensing-frontend&pullRequest=25&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=DEFRA_marine-licensing-frontend&pullRequest=25&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=DEFRA_marine-licensing-frontend&pullRequest=25&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=DEFRA_marine-licensing-frontend&pullRequest=25&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=DEFRA_marine-licensing-frontend&pullRequest=25&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=DEFRA_marine-licensing-frontend&pullRequest=25)


author_association: NONE