diff --git a/assets/sass/application.sass b/assets/sass/application.sass index 9fa60923..dd58fd7d 100755 --- a/assets/sass/application.sass +++ b/assets/sass/application.sass @@ -7,4 +7,6 @@ $path: "/assets/images/" @import 'govuk/all' @import 'moj/all' -@import './local' \ No newline at end of file +@import './components/header-bar' +@import './components/footer' +@import './local' diff --git a/assets/sass/components/_footer.scss b/assets/sass/components/_footer.scss new file mode 100644 index 00000000..5f1e7e2f --- /dev/null +++ b/assets/sass/components/_footer.scss @@ -0,0 +1,3 @@ +.govuk-footer { + min-height: 160px; +} diff --git a/assets/sass/components/_header-bar.scss b/assets/sass/components/_header-bar.scss new file mode 100644 index 00000000..593f2387 --- /dev/null +++ b/assets/sass/components/_header-bar.scss @@ -0,0 +1,127 @@ +.hmpps-header { + @include govuk-responsive-padding(3, 'top'); + @include govuk-responsive-padding(3, 'bottom'); + background-color: govuk-colour('black'); + color: govuk-colour('white'); + + &__container { + @include govuk-width-container; + display: flex; + justify-content: space-between; + align-items: center; + } + + &__logo { + @include govuk-responsive-margin(2, 'right'); + position: relative; + top: -2px; + fill: govuk-colour('white'); + } + + &__title { + @include govuk-responsive-padding(3, 'right'); + display: flex; + align-items: center; + + &__organisation-name { + @include govuk-responsive-margin(2, 'right'); + @include govuk-font($size: 24, $weight: 'bold'); + display: flex; + align-items: center; + } + + &__service-name { + @include govuk-responsive-margin(2, 'right'); + @include govuk-font(24); + display: none; + + @include govuk-media-query($from: desktop) { + display: flex; + align-items: center; + } + } + } + + &__link { + @include govuk-link-common; + @include govuk-link-style-default; + + text-underline-offset: 0.1em; + + &:link, + &:visited, + &:active { + color: govuk-colour('white'); + text-decoration: none; + } + + &:hover { + text-decoration: underline; + } + + &:focus { + color: govuk-colour('black'); + + svg { + fill: govuk-colour('black'); + } + } + + &__sub-text { + @include govuk-font(16); + display: none; + + @include govuk-media-query($from: tablet) { + display: block; + } + } + } + + &__navigation { + display: flex; + flex-direction: column; + align-items: flex-end; + list-style: none; + margin: 0; + padding: 0; + + @include govuk-media-query($from: tablet) { + flex-direction: row; + align-items: center; + } + + &__item { + @include govuk-font(19); + margin-bottom: govuk-spacing(1); + text-align: right; + + @include govuk-media-query($from: tablet) { + @include govuk-responsive-margin(4, 'right'); + @include govuk-responsive-padding(4, 'right'); + margin-bottom: 0; + border-right: 1px solid govuk-colour('white'); + } + + a { + display: inline-block; + } + + &:last-child { + margin-right: 0; + border-right: 0; + padding-right: 0; + } + + } + } + + @media print { + display: none; + } + } + + .govuk-phase-banner { + @include govuk-width-container; + border: none; + } + \ No newline at end of file diff --git a/assets/sass/local.sass b/assets/sass/local.sass index e3333fa2..ce7a3981 100644 --- a/assets/sass/local.sass +++ b/assets/sass/local.sass @@ -1,106 +1,11 @@ @import "palette" -// Header - div.govuk-width-container - max-width: 1150px + max-width: 1170px margin-top: 15px -.header - @include govuk-responsive-padding(3, 'top') - @include govuk-responsive-padding(3, 'bottom') - background-color: govuk-colour('black') - - &__container - @include govuk-width-container - display: flex - justify-content: space-between - align-items: center - max-width: 1150px - - &__logo - @include govuk-responsive-margin(2, 'right') - position: relative - top: -2px - fill: govuk-colour('white') - - &__title - @include govuk-responsive-padding(3, 'right') - display: flex - align-items: center - - &__organisation-name - @include govuk-responsive-margin(2, 'right') - @include govuk-font($size: 24, $weight: 'bold') - display: flex - align-items: center - - &__service-name - display: none - @include govuk-font(24) - - @include govuk-media-query($from: desktop) - display: inline-block - - &__link - @include govuk-link-common - @include govuk-link-style-default - - &:link, - &:visited, - &:active - color: govuk-colour('white') - text-decoration: none - - &:hover - text-decoration: underline - - &:focus - color: govuk-colour('black') - - svg - fill: govuk-colour('black') - - &__sub-text - @include govuk-font(16) - display: none - - @include govuk-media-query($from: tablet) - display: block - - &__navigation - display: flex - flex-direction: column - align-items: flex-end - list-style: none - margin: 0 - padding: 0 - - @include govuk-media-query($from: tablet) - flex-direction: row - align-items: center - - &__item - @include govuk-font(19) - margin-bottom: govuk-spacing(1) - text-align: right - - @include govuk-media-query($from: tablet) - @include govuk-responsive-margin(4, 'right') - @include govuk-responsive-padding(4, 'right') - margin-bottom: 0 - border-right: 1px solid govuk-colour('white') - - a - display: inline-block - - &:last-child - margin-right: 0 - border-right: 0 - padding-right: 0 - - @media print - display: none +.hmpps-header__container + max-width: 1170px // Task list pattern @@ -384,7 +289,7 @@ dt.summary-list__key__wider display: none !important @media print - .no-print, .govuk-header, .govuk-footer + .no-print, .hmpps-header, .govuk-footer display: none !important .print @@ -437,9 +342,5 @@ dt.summary-list__key__wider button margin-bottom: 0 -.feedback-banner - background: $govuk-brand-colour - padding: govuk-spacing(4) govuk-spacing(6) - .govuk-notification-banner__content > * max-width: 700px; diff --git a/cypress.config.ts b/cypress.config.ts index bdf5f477..02878cec 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -9,6 +9,8 @@ import { stringToHash } from './server/utils/hash' import db from './integration-tests/db/db' import config from './dist/server/config' +import components from './integration-tests/mockApis/components' + export default defineConfig({ chromeWebSecurity: false, fixturesFolder: 'integration-tests/fixtures', @@ -67,6 +69,10 @@ export default defineConfig({ stubVerifyToken: active => auth.stubVerifyToken(active), stubClientCredentialsToken: auth.stubClientCredentialsToken, + + stubComponents: components.stubComponents, + + stubComponentsFail: components.stubComponentsFail, }) }, baseUrl: 'http://localhost:3007', diff --git a/feature.env b/feature.env index aa3314df..5deeb834 100644 --- a/feature.env +++ b/feature.env @@ -11,6 +11,9 @@ DB_PORT=5432 NODE_ENV=development EXIT_LOCATION_URL=/ +DPS_URL=/ +COMPONENT_API_URL=http://localhost:9091/components +ENVIRONMENT_NAME=DEV REDIS_HOST=localhost REDIS_PORT=6379 diff --git a/helm_deploy/use-of-force/templates/_envs.tpl b/helm_deploy/use-of-force/templates/_envs.tpl index 0042981c..81157666 100644 --- a/helm_deploy/use-of-force/templates/_envs.tpl +++ b/helm_deploy/use-of-force/templates/_envs.tpl @@ -118,6 +118,15 @@ env: - name: FEATURE_FLAG_OUTAGE_BANNER_ENABLED value: {{ .Values.env.FEATURE_FLAG_OUTAGE_BANNER_ENABLED | quote }} + - name: DPS_URL + value: {{ .Values.env.DPS_URL | quote }} + + - name: COMPONENT_API_URL + value: {{ .Values.env.COMPONENT_API_URL | quote }} + + - name: ENVIRONMENT_NAME + value: {{ .Values.env.ENVIRONMENT_NAME | quote }} + - name: REDIS_HOST valueFrom: secretKeyRef: diff --git a/helm_deploy/values-dev.yaml b/helm_deploy/values-dev.yaml index 150d05a5..c0d88384 100644 --- a/helm_deploy/values-dev.yaml +++ b/helm_deploy/values-dev.yaml @@ -24,3 +24,6 @@ env: TOKENVERIFICATION_API_URL: https://token-verification-api-dev.prison.service.justice.gov.uk TOKENVERIFICATION_API_ENABLED: true FEATURE_FLAG_OUTAGE_BANNER_ENABLED: false + DPS_URL: https://digital-dev.prison.service.justice.gov.uk/ + COMPONENT_API_URL: "https://frontend-components-dev.hmpps.service.justice.gov.uk" + ENVIRONMENT_NAME: 'DEV' diff --git a/helm_deploy/values-preprod.yaml b/helm_deploy/values-preprod.yaml index 4d863bb5..0d84c56e 100644 --- a/helm_deploy/values-preprod.yaml +++ b/helm_deploy/values-preprod.yaml @@ -24,6 +24,9 @@ env: TOKENVERIFICATION_API_URL: https://token-verification-api-preprod.prison.service.justice.gov.uk TOKENVERIFICATION_API_ENABLED: true FEATURE_FLAG_OUTAGE_BANNER_ENABLED: false + DPS_URL: https://digital-preprod.prison.service.justice.gov.uk/ + COMPONENT_API_URL: "https://frontend-components-preprod.hmpps.service.justice.gov.uk" + ENVIRONMENT_NAME: 'PRE-PRODUCTION' allow_list: office: "217.33.148.210/32" diff --git a/helm_deploy/values-prod.yaml b/helm_deploy/values-prod.yaml index 404d2fbd..237835ac 100644 --- a/helm_deploy/values-prod.yaml +++ b/helm_deploy/values-prod.yaml @@ -24,6 +24,9 @@ env: TOKENVERIFICATION_API_URL: https://token-verification-api.prison.service.justice.gov.uk TOKENVERIFICATION_API_ENABLED: true FEATURE_FLAG_OUTAGE_BANNER_ENABLED: false + DPS_URL: https://digital.prison.service.justice.gov.uk/ + COMPONENT_API_URL: "https://frontend-components.hmpps.service.justice.gov.uk" + ENVIRONMENT_NAME: '' allow_list: office: "217.33.148.210/32" diff --git a/integration-tests/integration/commonComponents.cy.js b/integration-tests/integration/commonComponents.cy.js new file mode 100644 index 00000000..59b1e9d8 --- /dev/null +++ b/integration-tests/integration/commonComponents.cy.js @@ -0,0 +1,29 @@ +const { offender } = require('../mockApis/data') +const ReportUseOfForcePage = require('../pages/createReport/reportUseOfForcePage') + +context('Report use of force page', () => { + beforeEach(() => { + cy.task('reset') + cy.task('stubLogin', { firstName: 'James', lastName: 'Stuart' }) + cy.task('stubOffenderDetails', offender) + }) + + it('New components should exist', () => { + cy.task('stubComponents') + cy.login() + + const page = ReportUseOfForcePage.visit(offender.bookingId) + page.commonComponentsHeader().should('exist') + page.commonComponentsFooter().should('exist') + }) + + it('New components should not exist', () => { + cy.task('stubComponentsFail') + cy.login() + + const page = ReportUseOfForcePage.visit(offender.bookingId) + page.commonComponentsHeader().should('not.exist') + page.commonComponentsFooter().should('not.exist') + page.fallbackHeaderUserName().contains('J. Stuart') + }) +}) diff --git a/integration-tests/integration/login.cy.js b/integration-tests/integration/login.cy.js index 88581c69..70e97545 100644 --- a/integration-tests/integration/login.cy.js +++ b/integration-tests/integration/login.cy.js @@ -31,7 +31,7 @@ context('Login functionality', () => { cy.login() const yourStatements = YourStatements.verifyOnPage() yourStatements.loggedInName().contains('J. Stuart') - cy.request('/logout/').its('body').should('contain', 'Sign in') + cy.request('/sign-out').its('body').should('contain', 'Sign in') }) it('New user login should log current user out', () => { diff --git a/integration-tests/integration/reviewer/view-completed-incidents.cy.js b/integration-tests/integration/reviewer/view-completed-incidents.cy.js index b63574db..77a6d7aa 100644 --- a/integration-tests/integration/reviewer/view-completed-incidents.cy.js +++ b/integration-tests/integration/reviewer/view-completed-incidents.cy.js @@ -157,46 +157,6 @@ context('A use of force reviewer can view completed incidents at the current age completedIncidentsPage.getNoCompleteRows().contains('There are no completed incidents') }) - it('PII not sent to survey', () => { - cy.task('stubReviewerLogin') - cy.login() - - cy.task('stubOffenders', [offender3, offender2, offender]) - - cy.task('seedReport', { - status: ReportStatus.COMPLETE, - submittedDate: moment().toDate(), - incidentDate: moment('2019-01-22 09:57:40.000'), - agencyId: offender.agencyId, - bookingId: offender.bookingId, - sequenceNumber: 1, - involvedStaff: [ - { - username: 'TEST_USER', - name: 'TEST_USER name', - email: 'TEST_USER@gov.uk', - }, - ], - }) - - const completedIncidentsPage = CompletedIncidentsPage.goTo() - completedIncidentsPage.getCompleteRows().should('have.length', 1) - - completedIncidentsPage.filter.prisonNumber().type('A1234AC') - completedIncidentsPage.filter.reporter().type('James') - completedIncidentsPage.filter.dateFrom().type('22/01/2019') - completedIncidentsPage.filter.dateTo().type('25/01/2019') - completedIncidentsPage.filter.apply().click() - - completedIncidentsPage - .feedbackBannerLink() - .should('contain', 'Give feedback on this service') - .should('have.attr', 'href') - .then(href => { - expect(href).to.equal('https://eu.surveymonkey.com/r/GYB8Y9Q?source=localhost/completed-incidents') - }) - }) - it('A normal user cannot view all incidents', () => { cy.task('stubLogin') cy.login() diff --git a/integration-tests/integration/statements/view-your-statements.cy.js b/integration-tests/integration/statements/view-your-statements.cy.js index 2a52f775..5e61a99b 100644 --- a/integration-tests/integration/statements/view-your-statements.cy.js +++ b/integration-tests/integration/statements/view-your-statements.cy.js @@ -182,34 +182,4 @@ context('A user views their statements list', () => { ]) ) }) - - it('Page should display the feedback banner with the correct href', () => { - cy.task('stubOffenders', [offender]) - cy.login() - - cy.task( - 'seedReports', - Array.from(Array(62)).map((_, i) => ({ - status: ReportStatus.SUBMITTED, - bookingId: i, - involvedStaff: [ - { - username: 'TEST_USER', - name: 'TEST_USER name', - email: 'TEST_USER@gov.uk', - }, - ], - })) - ) - - const yourStatementsPage = YourStatementsPage.goTo() - - yourStatementsPage - .feedbackBannerLink() - .should('contain', 'Give feedback on this service') - .should('have.attr', 'href') - .then(href => { - expect(href).to.equal('https://eu.surveymonkey.com/r/GYB8Y9Q?source=localhost/your-statements') - }) - }) }) diff --git a/integration-tests/mockApis/components.js b/integration-tests/mockApis/components.js new file mode 100644 index 00000000..549178c2 --- /dev/null +++ b/integration-tests/mockApis/components.js @@ -0,0 +1,44 @@ +const { stubFor } = require('./wiremock') + +module.exports = { + stubComponents: () => { + return stubFor({ + request: { + method: 'GET', + urlPattern: '/components/components\\?component=header&component=footer', + }, + response: { + status: 200, + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + }, + jsonBody: { + header: { + html: '

Common Components Header

', + javascript: ['/common-components/header.js'], + css: ['/common-components/header.css'], + }, + footer: { + html: '', + javascript: ['/common-components/footer.js'], + css: ['/common-components/footer.css'], + }, + }, + }, + }) + }, + stubComponentsFail: () => { + return stubFor({ + request: { + method: 'GET', + urlPattern: '/components/components\\?component=header&component=footer', + }, + response: { + status: 500, + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + }, + }, + }) + }, +} diff --git a/integration-tests/pages/createReport/reportUseOfForcePage.js b/integration-tests/pages/createReport/reportUseOfForcePage.js index 7003ec44..bb1dd916 100644 --- a/integration-tests/pages/createReport/reportUseOfForcePage.js +++ b/integration-tests/pages/createReport/reportUseOfForcePage.js @@ -53,6 +53,10 @@ const tasklistPage = () => evidence: 'NOT_STARTED', }) }, + + fallbackHeaderUserName: () => cy.get('[data-qa=header-user-name]'), + commonComponentsHeader: () => cy.get('header').contains('Common Components Header'), + commonComponentsFooter: () => cy.get('footer').contains('Common Components Footer'), }) module.exports = { diff --git a/integration-tests/pages/page.js b/integration-tests/pages/page.js index 4f9e89e2..5e3f0212 100644 --- a/integration-tests/pages/page.js +++ b/integration-tests/pages/page.js @@ -1,6 +1,5 @@ export default (name, pageObject = {}, checkOnPage = () => cy.get('h1').contains(name)) => { - const logout = () => cy.get('[data-qa=logout]') - const feedbackBannerLink = () => cy.get('[data-qa="feedback-banner"]').find('a') + const logout = () => cy.get('[data-qa=signOut]') checkOnPage() - return { ...pageObject, checkStillOnPage: checkOnPage, logout, feedbackBannerLink } + return { ...pageObject, checkStillOnPage: checkOnPage, logout } } diff --git a/integration-tests/pages/yourStatements/yourStatementsPage.js b/integration-tests/pages/yourStatements/yourStatementsPage.js index 932c037c..0ab3f514 100644 --- a/integration-tests/pages/yourStatements/yourStatementsPage.js +++ b/integration-tests/pages/yourStatements/yourStatementsPage.js @@ -24,7 +24,7 @@ const yourStatementsPage = () => .invoke('attr', 'href') .then(link => link.match(/\/(.*?)\/your-statement/)[1]), }), - loggedInName: () => cy.get('[data-qa=logged-in-name]'), + loggedInName: () => cy.get('[data-qa=header-user-name]'), }) module.exports = { diff --git a/package-lock.json b/package-lock.json index b784fd08..565863fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,7 +93,7 @@ }, "engines": { "node": "^18", - "npm": "^8" + "npm": "<10" } }, "node_modules/@aashutoshrathi/word-wrap": { diff --git a/server/app.ts b/server/app.ts index 17e21998..26421dfe 100755 --- a/server/app.ts +++ b/server/app.ts @@ -1,4 +1,4 @@ -import express, { Express, RequestHandler, Response } from 'express' +import express, { Express, RequestHandler, Response, Request } from 'express' import { v4 as uuidv4 } from 'uuid' import helmet from 'helmet' import noCache from 'nocache' @@ -31,6 +31,8 @@ import errorHandler from './errorHandler' import config from './config' import unauthenticatedRoutes from './routes/unauthenticated' import asyncMiddleware from './middleware/asyncMiddleware' +import getFrontendComponents from './middleware/feComponentsMiddleware' +import setUpEnvironmentName from './middleware/setUpEnvironmentName' const authenticationMiddleware: RequestHandler = authenticationMiddlewareFactory( tokenVerifierFactory(config.apis.tokenVerification) @@ -54,6 +56,8 @@ export default function createApp(services: Services): Express { // View Engine Configuration app.set('view engine', 'html') + setUpEnvironmentName(app) + nunjucksSetup(app) // Server Configuration @@ -66,6 +70,25 @@ export default function createApp(services: Services): Express { res.locals.cspNonce = crypto.randomBytes(16).toString('base64') next() }) + + const scriptSrc = [ + "'self'", + 'code.jquery.com', + "'sha256-+6WnXIl4mbFTCARd8N3COQmT3bJJmo32N8q8ZSQAIcU='", + (_req: Request, res: Response) => `'nonce-${res.locals.cspNonce}'`, + ] + const styleSrc = ["'self'", 'code.jquery.com', (_req: Request, res: Response) => `'nonce-${res.locals.cspNonce}'`] + const imgSrc = ["'self'", 'data:', 'www.googletagmanager.com', 'www.google-analytics.com', 'https://code.jquery.com'] + const fontSrc = ["'self'"] + const connectSrc = ["'self'", 'www.googletagmanager.com', 'www.google-analytics.com'] + + if (config.apis.frontendComponents.url) { + scriptSrc.push(config.apis.frontendComponents.url) + styleSrc.push(config.apis.frontendComponents.url) + imgSrc.push(config.apis.frontendComponents.url) + fontSrc.push(config.apis.frontendComponents.url) + } + app.use( helmet({ crossOriginEmbedderPolicy: false, @@ -73,16 +96,11 @@ export default function createApp(services: Services): Express { directives: { defaultSrc: ["'self'"], // Hash allows inline script pulled in from https://github.com/alphagov/govuk-frontend/blob/master/src/govuk/template.njk - scriptSrc: [ - "'self'", - (req, res: Response) => `'nonce-${res.locals.cspNonce}'`, - 'code.jquery.com', - "'sha256-+6WnXIl4mbFTCARd8N3COQmT3bJJmo32N8q8ZSQAIcU='", - ], - imgSrc: ["'self'", 'www.googletagmanager.com', 'www.google-analytics.com', 'https://code.jquery.com'], - connectSrc: ["'self'", 'www.googletagmanager.com', 'www.google-analytics.com'], - styleSrc: ["'self'", 'code.jquery.com'], - fontSrc: ["'self'"], + scriptSrc, + imgSrc, + connectSrc, + styleSrc, + fontSrc, }, }, }) @@ -213,7 +231,7 @@ export default function createApp(services: Services): Express { // JWT token refresh app.use(async (req, res, next) => { - if (req.user && req.originalUrl !== '/logout') { + if (req.user && req.originalUrl !== '/sign-out') { const timeToRefresh = new Date() > req.user.refreshTime if (timeToRefresh) { try { @@ -229,7 +247,7 @@ export default function createApp(services: Services): Express { req.user.refreshTime = newToken.refreshTime } catch (error) { logger.error(`Token refresh error: ${req.user.username}`, error.stack) - return res.redirect('/logout') + return res.redirect('/sign-out') } } } @@ -265,7 +283,7 @@ export default function createApp(services: Services): Express { ) app.use( - '/logout', + '/sign-out', asyncMiddleware((req, res) => { if (req.user) { req.logout(() => req.session.destroy()) @@ -276,6 +294,8 @@ export default function createApp(services: Services): Express { app.use(populateCurrentUser(services.userService)) + app.get('*', getFrontendComponents(services.feComponentsService)) + app.use(unauthenticatedRoutes(services)) app.use(authorisationMiddleware) diff --git a/server/config.js b/server/config.js index 6f13efad..17f06720 100755 --- a/server/config.js +++ b/server/config.js @@ -104,18 +104,30 @@ module.exports = { }, enabled: process.env.TOKENVERIFICATION_API_ENABLED === 'true', }, + frontendComponents: { + url: get('COMPONENT_API_URL', 'http://localhost:8082', requiredInProduction), + timeout: { + response: get('COMPONENT_API_TIMEOUT_SECONDS', 2000), + deadline: get('COMPONENT_API_TIMEOUT_SECONDS', 2000), + }, + agent: { + maxSockets: 100, + maxFreeSockets: 10, + freeSocketTimeout: 30000, + }, + }, + digitalPrisonServiceUrl: get('DPS_URL', 'http://localhost:3000', requiredInProduction), }, domain: `${get('INGRESS_URL', 'http://localhost:3000', requiredInProduction)}`, links: { emailUrl: get('EMAIL_LOCATION_URL', 'http://localhost:3000', requiredInProduction), exitUrl: get('EXIT_LOCATION_URL', '/', requiredInProduction), }, - supportTelephone: '0800 917 5148', - supportExtension: '#6598', https: production, googleTagManager: { key: get('TAG_MANAGER_KEY', null), environment: get('TAG_MANAGER_ENVIRONMENT', ''), // The additional GTM snippet string that configures a non-prod environment }, featureFlagOutageBannerEnabled: get('FEATURE_FLAG_OUTAGE_BANNER_ENABLED', 'false', requiredInProduction) === 'true', + environmentName: get('ENVIRONMENT_NAME', ''), } diff --git a/server/data/feComponentsClient.test.ts b/server/data/feComponentsClient.test.ts new file mode 100644 index 00000000..f382c960 --- /dev/null +++ b/server/data/feComponentsClient.test.ts @@ -0,0 +1,47 @@ +import nock from 'nock' +import config from '../config' +import FeComponentsClient, { Component } from './feComponentsClient' +import restClientBuilder from '.' + +describe('feComponentsClient', () => { + let fakeComponentsApi + let componentsClient: FeComponentsClient + const token = 'token-1' + + beforeEach(() => { + fakeComponentsApi = nock(config.apis.frontendComponents.url) + componentsClient = restClientBuilder('feComponentApi', config.apis.frontendComponents, FeComponentsClient)(token) + }) + + afterEach(() => { + jest.resetAllMocks() + nock.cleanAll() + }) + + describe('getComponents', () => { + it('should return data from api', async () => { + const response: { data: { header: Component; footer: Component } } = { + data: { + header: { + html: '
', + css: [], + javascript: [], + }, + footer: { + html: '', + css: [], + javascript: [], + }, + }, + } + + fakeComponentsApi + .get('/components?component=header&component=footer') + .matchHeader('x-user-token', token) + .reply(200, response) + + const output = await componentsClient.getComponents(['header', 'footer'], token) + expect(output).toEqual(response) + }) + }) +}) diff --git a/server/data/feComponentsClient.ts b/server/data/feComponentsClient.ts new file mode 100644 index 00000000..2e9bb7c3 --- /dev/null +++ b/server/data/feComponentsClient.ts @@ -0,0 +1,24 @@ +import type { RestClient } from './restClient' + +export interface Component { + html: string + css: string[] + javascript: string[] +} + +export type AvailableComponent = 'header' | 'footer' + +export default class FeComponentsClient { + constructor(private restClient: RestClient) {} + + async getComponents( + components: T, + token: string + ): Promise> { + return this.restClient.get({ + path: `/components`, + query: `component=${components.join('&component=')}`, + headers: { 'x-user-token': token }, + }) as Promise> + } +} diff --git a/server/data/index.ts b/server/data/index.ts index 4362803c..f5ac0322 100755 --- a/server/data/index.ts +++ b/server/data/index.ts @@ -8,6 +8,7 @@ import DraftReportClient from './draftReportClient' import StatementsClient from './statementsClient' import PrisonClient from './prisonClient' +import FeComponentsClient from './feComponentsClient' import config from '../config' import { AuthClient, systemTokenBuilder } from './authClient' @@ -41,6 +42,11 @@ export const dataAccess = { systemToken: systemTokenBuilder(new TokenStore(createRedisClient({ legacyMode: false }))), authClientBuilder: ((token: string) => new AuthClient(token)) as RestClientBuilder, prisonClientBuilder: restClientBuilder('prisonApi', config.apis.prison, PrisonClient), + feComponentsClientBuilder: restClientBuilder( + 'feComponentApi', + config.apis.frontendComponents, + FeComponentsClient + ), prisonerSearchClientBuilder: restClientBuilder('prisonerSearchApi', config.apis.prisonerSearch, PrisonerSearchClient), } @@ -51,6 +57,7 @@ export { RestClientBuilder, IncidentClient, PrisonClient, + FeComponentsClient, PrisonerSearchClient, AuthClient, DraftReportClient, diff --git a/server/middleware/feComponentsMiddleware.test.ts b/server/middleware/feComponentsMiddleware.test.ts new file mode 100644 index 00000000..0ef07639 --- /dev/null +++ b/server/middleware/feComponentsMiddleware.test.ts @@ -0,0 +1,48 @@ +import { Response } from 'express' +import feComponentsMiddleware from './feComponentsMiddleware' +import FeComponentsService from '../services/feComponentsService' +import { Services } from '../services' +import logger from '../../log' + +jest.mock('../services/feComponentsService') +jest.mock('../../log') + +const feComponentsService = new FeComponentsService(null) as jest.Mocked +feComponentsService.getFeComponents = jest.fn().mockResolvedValue({}) + +let req +let res +const next = jest.fn() + +describe('feComponentsMiddleware', () => { + beforeEach(() => { + jest.resetAllMocks() + res = { locals: { user: {} } } as unknown as Response + res.locals.user.token = 'token-1' + }) + + test('Should call components service correctly', async () => { + const header = { html: '
', javascript: [], css: [] } + const footer = { html: '
', javascript: [], css: [] } + feComponentsService.getFeComponents.mockResolvedValue({ header, footer }) + + await feComponentsMiddleware(feComponentsService)(req, res, next) + + expect(feComponentsService.getFeComponents).toHaveBeenCalledWith(['header', 'footer'], 'token-1') + expect(res.locals.feComponents).toEqual({ + header: '
', + footer: '
', + cssIncludes: [], + jsIncludes: [], + }) + expect(next).toHaveBeenCalled() + }) + + test('Should log errors', async () => { + const error = new Error('Failed to retrieve front end components') + feComponentsService.getFeComponents.mockRejectedValue(error) + + await feComponentsMiddleware(feComponentsService as unknown as Services)(req, res, next) + expect(logger.error).toBeCalledWith(error, 'Failed to retrieve front end components') + }) +}) diff --git a/server/middleware/feComponentsMiddleware.ts b/server/middleware/feComponentsMiddleware.ts new file mode 100644 index 00000000..c8474e2b --- /dev/null +++ b/server/middleware/feComponentsMiddleware.ts @@ -0,0 +1,21 @@ +import type { RequestHandler } from 'express' +import logger from '../../log' + +export default function getFrontendComponents(feComponentsService): RequestHandler { + return async (req, res, next) => { + try { + const { header, footer } = await feComponentsService.getFeComponents(['header', 'footer'], res.locals.user.token) + + res.locals.feComponents = { + header: header.html, + footer: footer.html, + cssIncludes: [...header.css, ...footer.css], + jsIncludes: [...header.javascript, ...footer.javascript], + } + return next() + } catch (error) { + logger.error(error, 'Failed to retrieve front end components') + return next() + } + } +} diff --git a/server/middleware/setUpEnvironmentName.ts b/server/middleware/setUpEnvironmentName.ts new file mode 100644 index 00000000..5eb88800 --- /dev/null +++ b/server/middleware/setUpEnvironmentName.ts @@ -0,0 +1,8 @@ +/* eslint-disable no-param-reassign */ +import express from 'express' +import config from '../config' + +export default (app: express.Express) => { + app.locals.environmentName = config.environmentName + app.locals.environmentNameColour = config.environmentName === 'PRE-PRODUCTION' ? 'govuk-tag--green' : '' +} diff --git a/server/routes/__test/appSetup.ts b/server/routes/__test/appSetup.ts index 450b8caf..065d0910 100644 --- a/server/routes/__test/appSetup.ts +++ b/server/routes/__test/appSetup.ts @@ -19,6 +19,7 @@ import type { DraftReportService, LocationService, PrisonerSearchService, + FeComponentsService, } from '../../services' import UserService from '../../services/userService' import unauthenticatedRoutes from '../unauthenticated' @@ -129,6 +130,7 @@ export const appWithAllRoutes = ( userService: {} as UserService, // eslint-disable-next-line @typescript-eslint/no-explicit-any signInService: {} as any, + feComponentsService: {} as FeComponentsService, ...overrides, } const authenticated = authenticatedRoutes(authenticationMiddleware, services) diff --git a/server/routes/unauthenticated/helpReportingUseOfForce.test.ts b/server/routes/unauthenticated/helpReportingUseOfForce.test.ts deleted file mode 100644 index 1111cb01..00000000 --- a/server/routes/unauthenticated/helpReportingUseOfForce.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import request from 'supertest' -import { appWithAllRoutes } from '../__test/appSetup' -import config from '../../config' - -describe('GET Help reporting a use of force incident', () => { - afterEach(() => { - jest.resetAllMocks() - }) - - describe('GET Help reporting a use of force incident', () => { - test("should render the 'Help reporting a use of force incident' page", () => { - return request(appWithAllRoutes(undefined, undefined, false)) - .get(`/get-help`) - .expect('Content-Type', /html/) - .expect(res => { - expect(res.status).toBe(200) - expect(res.text).toContain('Help reporting a use of force incident') - }) - }) - - test('should display the telephone numbers', () => { - return request(appWithAllRoutes(undefined, undefined, true)) - .get(`/get-help`) - .expect('Content-Type', /html/) - .expect(res => { - expect(res.status).toBe(200) - expect(res.text).toContain(config.supportTelephone) - expect(res.text).toContain(config.supportExtension) - }) - }) - }) -}) diff --git a/server/routes/unauthenticated/helpReportingUseOfForce.ts b/server/routes/unauthenticated/helpReportingUseOfForce.ts deleted file mode 100644 index 9af30c34..00000000 --- a/server/routes/unauthenticated/helpReportingUseOfForce.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Request, Response } from 'express' -import config from '../../config' - -export default function HelpReportingUseOfForce() { - return (req: Request, res: Response): void => { - res.render(`help-reporting-use-of-force.html`, { - supportTelephone: config.supportTelephone, - supportExtension: config.supportExtension, - }) - } -} diff --git a/server/routes/unauthenticated/index.ts b/server/routes/unauthenticated/index.ts index f073eb2b..3c42b2e9 100644 --- a/server/routes/unauthenticated/index.ts +++ b/server/routes/unauthenticated/index.ts @@ -5,7 +5,6 @@ import asyncMiddleware from '../../middleware/asyncMiddleware' import type { Services } from '../../services' import RemovalRequest from './requestRemoval' import csrf from '../../middleware/csrfMiddleware' -import HelpReportingUseOfForce from './helpReportingUseOfForce' export default function UnauthenticatedRoutes(services: Services): Router { const { reportService, statementService, systemToken } = services @@ -20,7 +19,5 @@ export default function UnauthenticatedRoutes(services: Services): Router { router.get('/already-removed', asyncMiddleware(removalRequest.viewAlreadyRemoved)) router.get('/removal-already-requested', asyncMiddleware(removalRequest.viewRemovalAlreadyRequested)) - router.get('/get-help', asyncMiddleware(HelpReportingUseOfForce())) - return router } diff --git a/server/services/feComponentsService.test.ts b/server/services/feComponentsService.test.ts new file mode 100644 index 00000000..97c6e1c4 --- /dev/null +++ b/server/services/feComponentsService.test.ts @@ -0,0 +1,48 @@ +import { FeComponentsClient } from '../data' +import { AvailableComponent, Component } from '../data/feComponentsClient' +import FeComponentsService from './feComponentsService' + +jest.mock('../data') + +const FeComponentsClientBuilder = jest.fn() +const token = 'token' +const components = ['header', 'footer'] +const feComponentsClient = new FeComponentsClient(null) as jest.Mocked +let feComponentsService + +beforeEach(() => { + FeComponentsClientBuilder.mockReturnValue(feComponentsClient) + feComponentsService = new FeComponentsService(FeComponentsClientBuilder) +}) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('feComponentsService', () => { + describe('getFeComponents', () => { + it('should use token', async () => { + feComponentsClient.getComponents.mockResolvedValue({} as Record) + + await feComponentsService.getFeComponents(components, token) + expect(FeComponentsClientBuilder).toBeCalledWith(token) + }) + it('should call upstream client correctly', async () => { + const response = { + header: { + html: '', + css: [], + javascript: [], + }, + footer: { + html: '', + css: [], + javascript: [], + }, + } + feComponentsClient.getComponents.mockResolvedValue(response) + const result = await feComponentsService.getFeComponents(components, token) + expect(result).toEqual(response) + }) + }) +}) diff --git a/server/services/feComponentsService.ts b/server/services/feComponentsService.ts new file mode 100644 index 00000000..7445850e --- /dev/null +++ b/server/services/feComponentsService.ts @@ -0,0 +1,15 @@ +import { RestClientBuilder } from '../data' +import FeComponentsClient, { AvailableComponent, Component } from '../data/feComponentsClient' + +export default class FeComponentsService { + constructor(private readonly feComponentsClientBuilder: RestClientBuilder) {} + + async getFeComponents( + components: T, + token: string + ): Promise> { + const feComponentsClient = this.feComponentsClientBuilder(token) + const allComponents = await feComponentsClient.getComponents(components, token) + return allComponents + } +} diff --git a/server/services/index.ts b/server/services/index.ts index 4397e230..6aab58f6 100755 --- a/server/services/index.ts +++ b/server/services/index.ts @@ -21,12 +21,14 @@ import createSignInService from '../authentication/signInService' import { notificationServiceFactory } from './notificationService' import { DraftInvolvedStaffService } from './drafts/draftInvolvedStaffService' +import FeComponentsService from './feComponentsService' const { authClientBuilder, draftReportClient, incidentClient, prisonClientBuilder, + feComponentsClientBuilder, prisonerSearchClientBuilder, statementsClient, systemToken, @@ -97,6 +99,7 @@ const reviewService = new ReviewService( ) const prisonerSearchService = new PrisonerSearchService(prisonerSearchClientBuilder, prisonClientBuilder, systemToken) const reportDetailBuilder = new ReportDetailBuilder(involvedStaffService, locationService, offenderService, systemToken) +const feComponentsService = new FeComponentsService(feComponentsClientBuilder) export const services = { involvedStaffService, @@ -111,6 +114,7 @@ export const services = { locationService, reportDetailBuilder, draftReportService, + feComponentsService, } export type Services = typeof services @@ -126,4 +130,5 @@ export { PrisonerSearchService, ReportDetailBuilder, UserService, + FeComponentsService, } diff --git a/server/utils/nunjucksSetup.ts b/server/utils/nunjucksSetup.ts index c2790da4..06b62e0a 100644 --- a/server/utils/nunjucksSetup.ts +++ b/server/utils/nunjucksSetup.ts @@ -8,6 +8,7 @@ import config from '../config' import { PageMetaData } from './page' import { LabelledValue } from '../config/types' import { SectionStatus } from '../services/drafts/reportStatusChecker' +import { initialiseName } from './utils' const { googleTagManager: { key: tagManagerKey, environment: tagManagerEnvironment }, @@ -38,6 +39,7 @@ export default function configureNunjucks(app: Express.Application): nunjucks.En njkEnv.addGlobal('authUrl', config.apis.oauth2.url) njkEnv.addGlobal('apiClientId', config.apis.oauth2.apiClientId) njkEnv.addGlobal('featureFlagOutageBannerEnabled', config.featureFlagOutageBannerEnabled) + njkEnv.addGlobal('digitalPrisonServiceUrl', config.apis.digitalPrisonServiceUrl) // eslint-disable-next-line default-param-last njkEnv.addFilter('findError', (array: Error[] = [], formFieldId: string) => { @@ -172,5 +174,7 @@ export default function configureNunjucks(app: Express.Application): nunjucks.En return SectionStatus.NOT_STARTED }) + njkEnv.addFilter('initialiseName', initialiseName) + return njkEnv } diff --git a/server/utils/utils.ts b/server/utils/utils.ts index 03f0d42a..6cb2aec8 100644 --- a/server/utils/utils.ts +++ b/server/utils/utils.ts @@ -49,3 +49,11 @@ export function forenameToInitial(name: string): string { if (!name) return null return `${name.charAt(0)}. ${name.split(' ').pop()}` } + +export const initialiseName = (fullName?: string): string | null => { + // this check is for the authError page + if (!fullName) return null + + const array = fullName.split(' ') + return `${array[0][0]}. ${array.reverse()[0]}` +} diff --git a/server/views/help-reporting-use-of-force.html b/server/views/help-reporting-use-of-force.html deleted file mode 100644 index bf4f726b..00000000 --- a/server/views/help-reporting-use-of-force.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends "./partials/layout.html" %} -{% from "govuk/components/footer/macro.njk" import govukFooter %} - -{% block content %} -
-
-
-

Help reporting a use of force incident

- -

You can use this service to report a use of force incident in a prison and add a statement to an existing report.

- -

Reporting use of force on a prisoner in a different establishment

-

You can report a use of force on a prisoner in any establishment.

-

Select ‘Use of force incidents’ from the DPS homepage and click ‘Report use of force on a prisoner in another prison’.

- -

Deleting duplicate reports for the same incident

-

You’ll need to contact your local use of force coordinator to ask them to delete the duplicate report.

- -

If you have been incorrectly added to a use of force report

-

You can request to be removed from a use of force report by following the link in any of the emails you have received about the incident. A use of force coordinator will review your request, and you’ll receive an email when you have been removed from the report.

- -

Adding a member of staff to an existing report

-

Contact your local use of force coordinator to add staff members to an existing report.

- -

Adding a regional resource to a report

-

If a regional resource was a witness to the use of force, you can add them as a witness on the ‘Incident details’ page.

-

If a regional resource was involved in the use of force, you'll need to add them as a staff member involved in the use of force incident. The regional resource will need a DPS account before you can add them to the report.

- -

Technical support

-

For technical support, you should contact the helpdesk by calling {{supportTelephone}}, or {{supportExtension}} from inside an establishment.

-
-
-
-{% endblock %} - -{% block footer %} - {{ govukFooter({}) }} -{% endblock %} diff --git a/server/views/partials/feedbackBanner.njk b/server/views/partials/feedbackBanner.njk deleted file mode 100644 index b96aefc1..00000000 --- a/server/views/partials/feedbackBanner.njk +++ /dev/null @@ -1,11 +0,0 @@ -{% macro feedbackBanner(params) %} - -{% endmacro %} \ No newline at end of file diff --git a/server/views/partials/footer.njk b/server/views/partials/footer.njk index 8ad91074..3d14dfa1 100644 --- a/server/views/partials/footer.njk +++ b/server/views/partials/footer.njk @@ -1,20 +1 @@ -{% from "govuk/components/footer/macro.njk" import govukFooter %} -{% from "./feedbackBanner.njk" import feedbackBanner %} - -{{ - feedbackBanner({ - currentUrlPath: currentUrlPath, - hostname: hostname - }) -}} - -{{ govukFooter({ - meta: { - items: [ - { - href: "/get-help", - text: "Get help" - } - ] - } -}) }} \ No newline at end of file +
\ No newline at end of file diff --git a/server/views/partials/header.html b/server/views/partials/header.html index afa19688..cb2b96ba 100644 --- a/server/views/partials/header.html +++ b/server/views/partials/header.html @@ -1,34 +1,45 @@ - diff --git a/server/views/partials/layout.html b/server/views/partials/layout.html index 4322a33c..4a24fe7f 100644 --- a/server/views/partials/layout.html +++ b/server/views/partials/layout.html @@ -23,10 +23,26 @@ +{% if feComponents.jsInclude %} + {% for js in feComponents.jsIncludes %} + + {% endfor %} +{% endif %} + +{% if feComponents.cssIncludes %} + {% for css in feComponents.cssIncludes %} + + {% endfor %} +{% endif %} + {% endblock %} {% block header %} - {% include "./header.html" %} + {% if feComponents.header %} + {{ feComponents.header | safe }} + {% else %} + {% include "./header.html" %} + {% endif %} {% endblock %} {% block bodyStart %} @@ -55,5 +71,9 @@ {% endblock %} {% block footer %} - {% include "./footer.njk" %} + {% if feComponents.footer %} + {{ feComponents.footer | safe }} + {% else %} + {% include "./footer.njk" %} + {% endif %} {% endblock %}