diff --git a/README.md b/README.md index f24a54c0..4052ffda 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ API_CLIENT_SECRET=clientsecret SYSTEM_CLIENT_ID=clientid SYSTEM_CLIENT_SECRET=clientsecret +COMPONENT_API_URL="https://frontend-components-dev.hmpps.service.justice.gov.uk" NOMIS_USER_ROLES_API_URL="https://nomis-user-roles-api-dev.prison.service.justice.gov.uk" ORCHESTRATION_API_URL="https://hmpps-manage-prison-visits-orchestration-dev.prison.service.justice.gov.uk" PRISONER_SEARCH_API_URL="https://prisoner-search-dev.prison.service.justice.gov.uk" diff --git a/assets/sass/application.scss b/assets/sass/application.scss index c2fde00c..ab4df452 100755 --- a/assets/sass/application.scss +++ b/assets/sass/application.scss @@ -12,6 +12,7 @@ $govuk-page-width: $moj-page-width; @import './components/block-background'; @import './components/date-picker'; @import './components/filter'; +@import './components/footer'; @import './components/notificationTypes'; @import './components/prisoner-profile'; @import './components/restrictions'; diff --git a/assets/sass/components/_footer.scss b/assets/sass/components/_footer.scss new file mode 100644 index 00000000..f83d3ec9 --- /dev/null +++ b/assets/sass/components/_footer.scss @@ -0,0 +1,3 @@ +.govuk-footer { + min-height: 160px; +} diff --git a/cypress.config.ts b/cypress.config.ts index 38e4774d..b4f17530 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'cypress' import { resetStubs } from './integration_tests/mockApis/wiremock' import auth from './integration_tests/mockApis/auth' +import frontendComponents from './integration_tests/mockApis/frontendComponents' import manageUsersApi from './integration_tests/mockApis/manageUsersApi' import tokenVerification from './integration_tests/mockApis/tokenVerification' import nomisUserRoles from './integration_tests/mockApis/nomisUserRoles' @@ -32,6 +33,7 @@ export default defineConfig({ ...manageUsersApi, ...tokenVerification, + ...frontendComponents, ...nomisUserRoles, ...orchestrationService, ...prisonerContactRegistry, diff --git a/feature.env b/feature.env index 46ca7cb1..48e90d75 100644 --- a/feature.env +++ b/feature.env @@ -13,6 +13,7 @@ SYSTEM_CLIENT_ID=clientid SYSTEM_CLIENT_SECRET=clientsecret FEATURE_REVIEW_BOOKINGS=true +COMPONENT_API_URL=http://localhost:9091/frontendComponents NOMIS_USER_ROLES_API_URL=http://localhost:9091/nomisUserRoles ORCHESTRATION_API_URL=http://localhost:9091/orchestration PRISONER_SEARCH_API_URL=http://localhost:9091/offenderSearch diff --git a/helm_deploy/values-dev.yaml b/helm_deploy/values-dev.yaml index cf32d473..d2c2dfec 100644 --- a/helm_deploy/values-dev.yaml +++ b/helm_deploy/values-dev.yaml @@ -19,6 +19,7 @@ generic-service: PRISONER_CONTACT_REGISTRY_API_URL: "https://prisoner-contact-registry-dev.prison.service.justice.gov.uk" WHEREABOUTS_API_URL: "https://whereabouts-api-dev.service.justice.gov.uk" PRISON_REGISTER_API_URL: "https://prison-register-dev.hmpps.service.justice.gov.uk" + COMPONENT_API_URL: https://frontend-components-dev.hmpps.service.justice.gov.uk DPS_URL: "https://digital-dev.prison.service.justice.gov.uk/" REDIS_KEY: "bapv-staff-dev" FEATURE_REVIEW_BOOKINGS: "true" diff --git a/helm_deploy/values-preprod.yaml b/helm_deploy/values-preprod.yaml index 20b44972..8f13c75b 100644 --- a/helm_deploy/values-preprod.yaml +++ b/helm_deploy/values-preprod.yaml @@ -20,6 +20,7 @@ generic-service: PRISONER_CONTACT_REGISTRY_API_URL: "https://prisoner-contact-registry-preprod.prison.service.justice.gov.uk" WHEREABOUTS_API_URL: "https://whereabouts-api-preprod.service.justice.gov.uk" PRISON_REGISTER_API_URL: "https://prison-register-preprod.hmpps.service.justice.gov.uk" + COMPONENT_API_URL: https://frontend-components-preprod.hmpps.service.justice.gov.uk DPS_URL: "https://digital-preprod.prison.service.justice.gov.uk/" REDIS_KEY: "bapv-staff-preprod" FEATURE_REVIEW_BOOKINGS: "true" diff --git a/helm_deploy/values-prod.yaml b/helm_deploy/values-prod.yaml index 2e2ad494..89070c56 100644 --- a/helm_deploy/values-prod.yaml +++ b/helm_deploy/values-prod.yaml @@ -20,6 +20,7 @@ generic-service: PRISONER_CONTACT_REGISTRY_API_URL: "https://prisoner-contact-registry.prison.service.justice.gov.uk" WHEREABOUTS_API_URL: "https://whereabouts-api.service.justice.gov.uk" PRISON_REGISTER_API_URL: "https://prison-register.hmpps.service.justice.gov.uk" + COMPONENT_API_URL: https://frontend-components.hmpps.service.justice.gov.uk DPS_URL: "https://digital.prison.service.justice.gov.uk/" REDIS_KEY: "bapv-staff-prod" FEATURE_REVIEW_BOOKINGS: "true" diff --git a/helm_deploy/values-staging.yaml b/helm_deploy/values-staging.yaml index 384304bd..e1746e00 100644 --- a/helm_deploy/values-staging.yaml +++ b/helm_deploy/values-staging.yaml @@ -19,6 +19,7 @@ generic-service: PRISONER_CONTACT_REGISTRY_API_URL: "https://prisoner-contact-registry-staging.prison.service.justice.gov.uk" WHEREABOUTS_API_URL: "https://whereabouts-api-dev.service.justice.gov.uk" PRISON_REGISTER_API_URL: "https://prison-register-dev.hmpps.service.justice.gov.uk" + COMPONENT_API_URL: https://frontend-components-dev.hmpps.service.justice.gov.uk DPS_URL: "https://digital-dev.prison.service.justice.gov.uk/" APPLICATIONINSIGHTS_CONNECTION_STRING: null # disable App Insights for staging REDIS_KEY: "bapv-staff-staging" diff --git a/integration_tests/integration/bookAVisit.cy.ts b/integration_tests/integration/bookAVisit.cy.ts index 17c69ecf..81578dd1 100644 --- a/integration_tests/integration/bookAVisit.cy.ts +++ b/integration_tests/integration/bookAVisit.cy.ts @@ -36,6 +36,7 @@ context('Book a visit', () => { beforeEach(() => { cy.task('reset') cy.task('stubSignIn') + cy.task('stubFrontendComponents') cy.task('stubManageUser') cy.task('stubSupportedPrisonIds') cy.task('stubPrisonNames') diff --git a/integration_tests/integration/cancelVisit.cy.ts b/integration_tests/integration/cancelVisit.cy.ts index 163f785a..91ba36df 100644 --- a/integration_tests/integration/cancelVisit.cy.ts +++ b/integration_tests/integration/cancelVisit.cy.ts @@ -28,6 +28,7 @@ context('Cancel visit journey', () => { beforeEach(() => { cy.task('reset') cy.task('stubSignIn') + cy.task('stubFrontendComponents') cy.task('stubManageUser') cy.task('stubSupportedPrisonIds') cy.task('stubPrisonNames') diff --git a/integration_tests/integration/changeEstablishment.cy.ts b/integration_tests/integration/changeEstablishment.cy.ts index f973c809..486971c7 100644 --- a/integration_tests/integration/changeEstablishment.cy.ts +++ b/integration_tests/integration/changeEstablishment.cy.ts @@ -7,6 +7,7 @@ context('Change establishment', () => { beforeEach(() => { cy.task('reset') cy.task('stubSignIn') + cy.task('stubFrontendComponents') cy.task('stubManageUser') cy.task('stubSupportedPrisonIds') cy.task('stubPrisonNames') diff --git a/integration_tests/integration/checkYourBooking.cy.ts b/integration_tests/integration/checkYourBooking.cy.ts index d9e86bad..9938e397 100644 --- a/integration_tests/integration/checkYourBooking.cy.ts +++ b/integration_tests/integration/checkYourBooking.cy.ts @@ -18,6 +18,7 @@ context('Check visit details page', () => { beforeEach(() => { cy.task('reset') cy.task('stubSignIn') + cy.task('stubFrontendComponents') cy.task('stubManageUser') cy.task('stubSupportedPrisonIds') cy.task('stubPrisonNames') diff --git a/integration_tests/integration/datePicker.cy.ts b/integration_tests/integration/datePicker.cy.ts index f9d81684..6f569ca9 100644 --- a/integration_tests/integration/datePicker.cy.ts +++ b/integration_tests/integration/datePicker.cy.ts @@ -12,6 +12,7 @@ context('Date picker', () => { beforeEach(() => { cy.task('reset') cy.task('stubSignIn') + cy.task('stubFrontendComponents') cy.task('stubManageUser') cy.task('stubSupportedPrisonIds') cy.task('stubPrisonNames') diff --git a/integration_tests/integration/frontendComponents.cy.ts b/integration_tests/integration/frontendComponents.cy.ts new file mode 100644 index 00000000..096cda84 --- /dev/null +++ b/integration_tests/integration/frontendComponents.cy.ts @@ -0,0 +1,33 @@ +import TestData from '../../server/routes/testutils/testData' +import HomePage from '../pages/home' +import Page from '../pages/page' + +context('Frontend components', () => { + const notificationCount = TestData.notificationCount() + + beforeEach(() => { + cy.task('reset') + cy.task('stubSignIn') + cy.task('stubManageUser') + cy.task('stubSupportedPrisonIds') + cy.task('stubPrisonNames') + cy.task('stubGetPrison') + cy.task('stubGetNotificationCount', { notificationCount }) + }) + + it('should render the footer from the frontend components', () => { + cy.task('stubFrontendComponents') + cy.signIn() + + Page.verifyOnPage(HomePage) + cy.get('footer').contains('Footer component') + }) + + it('should render the fallback footer when frontend components load fails', () => { + cy.task('stubFrontendComponentsFail') + cy.signIn() + + Page.verifyOnPage(HomePage) + cy.get('footer').should('not.contain.text', 'Footer component') + }) +}) diff --git a/integration_tests/integration/home.cy.ts b/integration_tests/integration/home.cy.ts index 34998475..058d6f82 100644 --- a/integration_tests/integration/home.cy.ts +++ b/integration_tests/integration/home.cy.ts @@ -8,6 +8,7 @@ context('Home page', () => { beforeEach(() => { cy.task('reset') cy.task('stubSignIn') + cy.task('stubFrontendComponents') cy.task('stubManageUser') cy.task('stubSupportedPrisonIds') cy.task('stubPrisonNames') diff --git a/integration_tests/integration/login.cy.ts b/integration_tests/integration/login.cy.ts index 2bdc405a..05fcebc6 100644 --- a/integration_tests/integration/login.cy.ts +++ b/integration_tests/integration/login.cy.ts @@ -8,6 +8,7 @@ context('SignIn', () => { beforeEach(() => { cy.task('reset') cy.task('stubSignIn') + cy.task('stubFrontendComponents') cy.task('stubManageUser') cy.task('stubSupportedPrisonIds') cy.task('stubPrisonNames') diff --git a/integration_tests/integration/prisonerProfile.cy.ts b/integration_tests/integration/prisonerProfile.cy.ts index 923163a0..fca9f46e 100644 --- a/integration_tests/integration/prisonerProfile.cy.ts +++ b/integration_tests/integration/prisonerProfile.cy.ts @@ -9,6 +9,7 @@ context('Prisoner profile page', () => { beforeEach(() => { cy.task('reset') cy.task('stubSignIn') + cy.task('stubFrontendComponents') cy.task('stubManageUser') cy.task('stubSupportedPrisonIds') cy.task('stubPrisonNames') diff --git a/integration_tests/integration/review.cy.ts b/integration_tests/integration/review.cy.ts index e34b6221..9747f085 100644 --- a/integration_tests/integration/review.cy.ts +++ b/integration_tests/integration/review.cy.ts @@ -45,6 +45,7 @@ context('Bookings review page', () => { beforeEach(() => { cy.task('reset') cy.task('stubSignIn') + cy.task('stubFrontendComponents') cy.task('stubManageUser') cy.task('stubSupportedPrisonIds') cy.task('stubPrisonNames') diff --git a/integration_tests/integration/searchForABooking.cy.ts b/integration_tests/integration/searchForABooking.cy.ts index caf79963..f2a46219 100644 --- a/integration_tests/integration/searchForABooking.cy.ts +++ b/integration_tests/integration/searchForABooking.cy.ts @@ -20,6 +20,7 @@ context('Search for a booking by reference', () => { beforeEach(() => { cy.task('reset') cy.task('stubSignIn') + cy.task('stubFrontendComponents') cy.task('stubManageUser') cy.task('stubSupportedPrisonIds') cy.task('stubPrisonNames') diff --git a/integration_tests/integration/searchForAPrisoner.cy.ts b/integration_tests/integration/searchForAPrisoner.cy.ts index 0b962772..7e744c63 100644 --- a/integration_tests/integration/searchForAPrisoner.cy.ts +++ b/integration_tests/integration/searchForAPrisoner.cy.ts @@ -11,6 +11,7 @@ context('Search for a prisoner', () => { beforeEach(() => { cy.task('reset') cy.task('stubSignIn') + cy.task('stubFrontendComponents') cy.task('stubManageUser') cy.task('stubSupportedPrisonIds') cy.task('stubPrisonNames') diff --git a/integration_tests/integration/updateAVisit.cy.ts b/integration_tests/integration/updateAVisit.cy.ts index 273c2312..ed4208bf 100644 --- a/integration_tests/integration/updateAVisit.cy.ts +++ b/integration_tests/integration/updateAVisit.cy.ts @@ -24,6 +24,7 @@ context('Update a visit', () => { beforeEach(() => { cy.task('reset') cy.task('stubSignIn') + cy.task('stubFrontendComponents') cy.task('stubManageUser') cy.task('stubSupportedPrisonIds') cy.task('stubPrisonNames') diff --git a/integration_tests/integration/visitDetails.cy.ts b/integration_tests/integration/visitDetails.cy.ts index f87cf3a1..abe749d5 100644 --- a/integration_tests/integration/visitDetails.cy.ts +++ b/integration_tests/integration/visitDetails.cy.ts @@ -13,6 +13,7 @@ context('Visit details page', () => { beforeEach(() => { cy.task('reset') cy.task('stubSignIn') + cy.task('stubFrontendComponents') cy.task('stubManageUser') cy.task('stubSupportedPrisonIds') cy.task('stubPrisonNames') diff --git a/integration_tests/integration/visitTimetable.cy.ts b/integration_tests/integration/visitTimetable.cy.ts index b8f16b93..8eb52694 100644 --- a/integration_tests/integration/visitTimetable.cy.ts +++ b/integration_tests/integration/visitTimetable.cy.ts @@ -15,6 +15,7 @@ context('View visit schedule timetable', () => { beforeEach(() => { cy.task('reset') cy.task('stubSignIn') + cy.task('stubFrontendComponents') cy.task('stubManageUser') cy.task('stubSupportedPrisonIds') cy.task('stubPrisonNames') diff --git a/integration_tests/integration/visitsByDate.cy.ts b/integration_tests/integration/visitsByDate.cy.ts index 6a1aff46..44aba0b9 100644 --- a/integration_tests/integration/visitsByDate.cy.ts +++ b/integration_tests/integration/visitsByDate.cy.ts @@ -34,6 +34,7 @@ context('View visits by date', () => { beforeEach(() => { cy.task('reset') cy.task('stubSignIn') + cy.task('stubFrontendComponents') cy.task('stubManageUser') cy.task('stubSupportedPrisonIds') cy.task('stubPrisonNames') diff --git a/integration_tests/mockApis/frontendComponents.ts b/integration_tests/mockApis/frontendComponents.ts new file mode 100644 index 00000000..7ce9f5f3 --- /dev/null +++ b/integration_tests/mockApis/frontendComponents.ts @@ -0,0 +1,36 @@ +import { SuperAgentRequest } from 'superagent' +import { stubFor } from './wiremock' + +export default { + stubFrontendComponents: (): SuperAgentRequest => { + return stubFor({ + request: { + method: 'GET', + url: '/frontendComponents/components?component=footer', + }, + response: { + status: 200, + headers: { 'Content-Type': 'application/json;charset=UTF-8' }, + jsonBody: { + footer: { + html: '', + css: [], + javascript: [], + }, + }, + }, + }) + }, + + stubFrontendComponentsFail: () => { + return stubFor({ + request: { + method: 'GET', + url: '/frontendComponents/components?component=footer', + }, + response: { + status: 500, + }, + }) + }, +} diff --git a/server/app.ts b/server/app.ts index ea20ca6f..b834545c 100755 --- a/server/app.ts +++ b/server/app.ts @@ -26,6 +26,7 @@ import timetableRoutes from './routes/timetable' import visitRoutes from './routes/visit' import visitsRoutes from './routes/visits' import type { Services } from './services' +import getFrontendComponents from './middleware/setupFrontendComponents' export default function createApp(services: Services): express.Application { const app = express() @@ -46,6 +47,8 @@ export default function createApp(services: Services): express.Application { app.use(setUpCurrentUser(services)) app.use(appInsightsOperationId) + app.get('*', getFrontendComponents(services)) + app.use('/', indexRoutes(services)) app.use('/book-a-visit', bookAVisitRoutes(services)) app.use('/change-establishment', establishmentRoutes(services)) diff --git a/server/config.ts b/server/config.ts index 9f90203f..0ae9324e 100755 --- a/server/config.ts +++ b/server/config.ts @@ -145,6 +145,14 @@ export default { }, agent: new AgentConfig(Number(get('ORCHESTRATION_API_TIMEOUT_RESPONSE', 10000))), }, + frontendComponents: { + url: get('COMPONENT_API_URL', 'http://localhost:8082/frontend-components', requiredInProduction), + timeout: { + response: Number(get('FRONTEND_COMPONENTS_TIMEOUT_RESPONSE', 3000)), + deadline: Number(get('FRONTEND_COMPONENTS_TIMEOUT_DEADLINE', 3000)), + }, + agent: new AgentConfig(Number(get('FRONTEND_COMPONENTS_TIMEOUT_RESPONSE', 3000))), + }, }, features: { reviewBookings: get('FEATURE_REVIEW_BOOKINGS', 'false', requiredInProduction) === 'true', diff --git a/server/data/frontendComponentsClient.test.ts b/server/data/frontendComponentsClient.test.ts new file mode 100644 index 00000000..93752df1 --- /dev/null +++ b/server/data/frontendComponentsClient.test.ts @@ -0,0 +1,36 @@ +import nock from 'nock' +import config from '../config' +import FrontendComponentsClient from './frontendComponentsClient' + +describe('FrontendComponentsClient', () => { + const frontendComponentsClient = new FrontendComponentsClient('not used') + let fakeFrontendComponentsApi: nock.Scope + + beforeEach(() => { + fakeFrontendComponentsApi = nock(config.apis.frontendComponents.url) + }) + + afterEach(() => { + if (!nock.isDone()) { + nock.cleanAll() + throw new Error('Not all nock interceptors were used!') + } + nock.abortPendingRequests() + nock.cleanAll() + }) + + describe('getComponents', () => { + it('should get frontend components', async () => { + const userToken = 'user1' + const response = { some: 'response' } + + fakeFrontendComponentsApi + .get('/components?component=header&component=footer') + .matchHeader('x-user-token', userToken) + .reply(200, response) + + const result = await frontendComponentsClient.getComponents(['header', 'footer'], userToken) + expect(result).toStrictEqual(response) + }) + }) +}) diff --git a/server/data/frontendComponentsClient.ts b/server/data/frontendComponentsClient.ts new file mode 100644 index 00000000..13c621b2 --- /dev/null +++ b/server/data/frontendComponentsClient.ts @@ -0,0 +1,29 @@ +import RestClient from './restClient' +import config from '../config' + +export interface Component { + html: string + css: string[] + javascript: string[] +} + +export type AvailableComponent = 'header' | 'footer' + +export default class FrontendComponentsClient { + restClient: RestClient + + constructor(token: string) { + this.restClient = new RestClient('Frontend components', config.apis.frontendComponents, token) + } + + async getComponents( + components: T, + userToken: string, + ): Promise> { + return this.restClient.get({ + path: `/components`, + query: `component=${components.join('&component=')}`, + headers: { 'x-user-token': userToken }, + }) + } +} diff --git a/server/data/index.ts b/server/data/index.ts index addf7383..c001f520 100644 --- a/server/data/index.ts +++ b/server/data/index.ts @@ -21,6 +21,7 @@ import PrisonRegisterApiClient from './prisonRegisterApiClient' import { createRedisClient } from './redisClient' import TokenStore from './tokenStore' import WhereaboutsApiClient from './whereaboutsApiClient' +import FrontendComponentsClient from './frontendComponentsClient' type RestClientBuilder = (token: string) => T @@ -29,6 +30,8 @@ export const dataAccess = () => ({ hmppsAuthClient: new HmppsAuthClient(new TokenStore(createRedisClient())), manageUsersApiClient: new ManageUsersApiClient(), nomisUserRolesApiClient: new NomisUserRolesApiClient(), + frontendComponentsClientBuilder: ((token: string) => + new FrontendComponentsClient(token)) as RestClientBuilder, orchestrationApiClientBuilder: ((token: string) => new OrchestrationApiClient(token)) as RestClientBuilder, prisonApiClientBuilder: ((token: string) => new PrisonApiClient(token)) as RestClientBuilder, @@ -46,6 +49,7 @@ export type DataAccess = ReturnType export { HmppsAuthClient, + FrontendComponentsClient, ManageUsersApiClient, NomisUserRolesApiClient, OrchestrationApiClient, diff --git a/server/data/testutils/mocks.ts b/server/data/testutils/mocks.ts index decb67c2..8a7e63b6 100644 --- a/server/data/testutils/mocks.ts +++ b/server/data/testutils/mocks.ts @@ -17,6 +17,7 @@ jest.mock('../../applicationInfo', () => { }) import { + FrontendComponentsClient, HmppsAuthClient, ManageUsersApiClient, NomisUserRolesApiClient, @@ -29,6 +30,8 @@ import { } from '..' jest.mock('..') +export const createMockFrontendComponentsClient = () => + new FrontendComponentsClient(null) as jest.Mocked export const createMockHmppsAuthClient = () => new HmppsAuthClient(null) as jest.Mocked diff --git a/server/middleware/setUpWebSecurity.ts b/server/middleware/setUpWebSecurity.ts index c79e1901..aa6c6098 100644 --- a/server/middleware/setUpWebSecurity.ts +++ b/server/middleware/setUpWebSecurity.ts @@ -24,10 +24,19 @@ export default function setUpWebSecurity(): Router { // // This ensures only scripts we trust are loaded, and not anything injected into the // page by an attacker. - scriptSrc: ["'self'", (_req: Request, res: Response) => `'nonce-${res.locals.cspNonce}'`], - styleSrc: ["'self'", (_req: Request, res: Response) => `'nonce-${res.locals.cspNonce}'`], - fontSrc: ["'self'"], - formAction: [`'self' ${config.apis.hmppsAuth.externalUrl}`], + scriptSrc: [ + "'self'", + (_req: Request, res: Response) => `'nonce-${res.locals.cspNonce}'`, + config.apis.frontendComponents.url, + ], + styleSrc: [ + "'self'", + (_req: Request, res: Response) => `'nonce-${res.locals.cspNonce}'`, + config.apis.frontendComponents.url, + ], + fontSrc: ["'self'", config.apis.frontendComponents.url], + imgSrc: ["'self'", 'data:', config.apis.frontendComponents.url], + formAction: [`'self' ${config.apis.hmppsAuth.externalUrl} ${config.dpsHome}`], upgradeInsecureRequests: process.env.NODE_ENV === 'development' ? null : [], }, }, diff --git a/server/middleware/setupFrontendComponents.test.ts b/server/middleware/setupFrontendComponents.test.ts new file mode 100644 index 00000000..284531f4 --- /dev/null +++ b/server/middleware/setupFrontendComponents.test.ts @@ -0,0 +1,62 @@ +import type { Request, Response } from 'express' +import { createMockFrontendComponentsService } from '../services/testutils/mocks' +import getFrontendComponents from './setupFrontendComponents' +import { Services } from '../services' +import logger from '../../logger' + +jest.mock('../../logger') + +const frontendComponentsService = createMockFrontendComponentsService() +let req: Request +let res: Response +const next = jest.fn() + +describe('getFrontendComponents', () => { + beforeEach(() => { + res = { + locals: { + user: { + token: 'user-token', + }, + }, + } as Response + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('should call frontend components service and populate res.locals', async () => { + const response = { + header: { + html: 'header html', + css: ['header css'], + javascript: ['header js'], + }, + footer: { + html: 'footer html', + css: ['footer css'], + javascript: ['footer js'], + }, + } + frontendComponentsService.getComponents.mockResolvedValue(response) + + await getFrontendComponents({ frontendComponentsService } as unknown as Services)(req, res, next) + + expect(frontendComponentsService.getComponents).toHaveBeenCalledWith(['footer'], 'user-token') + expect(res.locals.feComponents).toStrictEqual({ + footer: response.footer.html, + cssIncludes: [...response.footer.css], + jsIncludes: [...response.footer.javascript], + }) + }) + + it('should log errors', async () => { + const error = new Error('Oops') + frontendComponentsService.getComponents.mockRejectedValue(error) + + await getFrontendComponents({ frontendComponentsService } as unknown as Services)(req, res, next) + + expect(logger.error).toHaveBeenCalledWith(error, 'Failed to retrieve front end components') + }) +}) diff --git a/server/middleware/setupFrontendComponents.ts b/server/middleware/setupFrontendComponents.ts new file mode 100644 index 00000000..0d429f37 --- /dev/null +++ b/server/middleware/setupFrontendComponents.ts @@ -0,0 +1,21 @@ +import { RequestHandler } from 'express' +import { Services } from '../services' +import logger from '../../logger' + +export default function getFrontendComponents({ frontendComponentsService }: Services): RequestHandler { + return async (req, res, next) => { + try { + const { footer } = await frontendComponentsService.getComponents(['footer'], res.locals.user.token) + + res.locals.feComponents = { + footer: footer.html, + cssIncludes: [...footer.css], + jsIncludes: [...footer.javascript], + } + next() + } catch (error) { + logger.error(error, 'Failed to retrieve front end components') + next() + } + } +} diff --git a/server/services/frontendComponentsService.test.ts b/server/services/frontendComponentsService.test.ts new file mode 100644 index 00000000..814ff872 --- /dev/null +++ b/server/services/frontendComponentsService.test.ts @@ -0,0 +1,44 @@ +import { AvailableComponent } from '../data/frontendComponentsClient' +import { createMockFrontendComponentsClient } from '../data/testutils/mocks' +import FrontendComponentsService from './frontendComponentsService' + +describe('FrontendComponentsService', () => { + const frontendComponentsClient = createMockFrontendComponentsClient() + let frontendComponentsService: FrontendComponentsService + const FrontendComponentsClientBuilder = jest.fn() + + const components: AvailableComponent[] = ['header', 'footer'] + const userToken = 'user1' + + beforeEach(() => { + FrontendComponentsClientBuilder.mockReturnValue(frontendComponentsClient) + frontendComponentsService = new FrontendComponentsService(FrontendComponentsClientBuilder) + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + describe('getComponents', () => { + it('should call API and return component data', async () => { + const response = { + header: { + html: 'header html', + css: ['header css'], + javascript: ['header js'], + }, + footer: { + html: 'footer html', + css: ['footer css'], + javascript: ['footer js'], + }, + } + frontendComponentsClient.getComponents.mockResolvedValue(response) + + const result = await frontendComponentsService.getComponents(components, userToken) + + expect(frontendComponentsClient.getComponents).toHaveBeenCalledWith(components, userToken) + expect(result).toStrictEqual(response) + }) + }) +}) diff --git a/server/services/frontendComponentsService.ts b/server/services/frontendComponentsService.ts new file mode 100644 index 00000000..491592f3 --- /dev/null +++ b/server/services/frontendComponentsService.ts @@ -0,0 +1,13 @@ +import { FrontendComponentsClient, RestClientBuilder } from '../data' +import { AvailableComponent, Component } from '../data/frontendComponentsClient' + +export default class FrontendComponentsService { + constructor(private readonly frontendComponentsClientBuilder: RestClientBuilder) {} + + async getComponents( + components: T, + userToken: string, + ): Promise> { + return this.frontendComponentsClientBuilder(userToken).getComponents(components, userToken) + } +} diff --git a/server/services/index.ts b/server/services/index.ts index 9157b397..e4929d94 100644 --- a/server/services/index.ts +++ b/server/services/index.ts @@ -1,6 +1,7 @@ import { dataAccess } from '../data' import AdditionalSupportService from './additionalSupportService' import AuditService from './auditService' +import FrontendComponentsService from './frontendComponentsService' import PrisonerProfileService from './prisonerProfileService' import PrisonerSearchService from './prisonerSearchService' import PrisonerVisitorsService from './prisonerVisitorsService' @@ -15,6 +16,7 @@ export const services = () => { hmppsAuthClient, manageUsersApiClient, nomisUserRolesApiClient, + frontendComponentsClientBuilder, orchestrationApiClientBuilder, prisonApiClientBuilder, prisonerContactRegistryApiClientBuilder, @@ -28,6 +30,8 @@ export const services = () => { const auditService = new AuditService() + const frontendComponentsService = new FrontendComponentsService(frontendComponentsClientBuilder) + const supportedPrisonsService = new SupportedPrisonsService( orchestrationApiClientBuilder, prisonRegisterApiClientBuilder, @@ -69,6 +73,7 @@ export const services = () => { return { additionalSupportService, auditService, + frontendComponentsService, prisonerProfileService, prisonerSearchService, prisonerVisitorsService, @@ -86,6 +91,7 @@ export type Services = ReturnType export { AdditionalSupportService, AuditService, + FrontendComponentsService, PrisonerProfileService, PrisonerSearchService, PrisonerVisitorsService, diff --git a/server/services/testutils/mocks.ts b/server/services/testutils/mocks.ts index 48e9ae1e..9ce58f37 100644 --- a/server/services/testutils/mocks.ts +++ b/server/services/testutils/mocks.ts @@ -19,6 +19,7 @@ jest.mock('../../applicationInfo', () => { import { AdditionalSupportService, AuditService, + FrontendComponentsService, PrisonerProfileService, PrisonerSearchService, PrisonerVisitorsService, @@ -36,6 +37,9 @@ export const createMockAdditionalSupportService = () => export const createMockAuditService = () => new AuditService(null) as jest.Mocked +export const createMockFrontendComponentsService = () => + new FrontendComponentsService(null) as jest.Mocked + export const createMockPrisonerProfileService = () => new PrisonerProfileService(null, null, null) as jest.Mocked diff --git a/server/views/layout.njk b/server/views/layout.njk index 162fda6a..d63ffb88 100644 --- a/server/views/layout.njk +++ b/server/views/layout.njk @@ -1,10 +1,21 @@ {% extends "govuk/template.njk" %} {% from "govuk/components/back-link/macro.njk" import govukBackLink %} -{% from "govuk/components/footer/macro.njk" import govukFooter %} {% block head %} + + {% if feComponents.jsIncludes %} + {% for js in feComponents.jsIncludes %} + + {% endfor %} + {% endif %} + + {% if feComponents.cssIncludes %} + {% for css in feComponents.cssIncludes %} + + {% endfor %} + {% endif %} {% endblock %} {% block pageTitle %}{{pageTitle | default(applicationName)}}{% endblock %} @@ -28,16 +39,11 @@ {% endblock %} {% block footer %} - {{ govukFooter({ - meta: { - items: [ - { - href: "https://it-incident-hub.hmpps.service.justice.gov.uk/manage-prison-visits ", - text: "Get help" - } - ] - } - }) }} + {% if feComponents.footer %} + {{ feComponents.footer | safe }} + {% else %} + {% include "partials/footer.njk" %} + {% endif %} {% endblock %} {% block bodyEnd %} diff --git a/server/views/partials/footer.njk b/server/views/partials/footer.njk new file mode 100644 index 00000000..ff10ee3e --- /dev/null +++ b/server/views/partials/footer.njk @@ -0,0 +1 @@ +