diff --git a/.env.example b/.env.example index f0508e1..a08b797 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,26 @@ REDIS_ENABLED=false -TOKEN_VERIFICATION_ENABLED=false +TOKEN_VERIFICATION_ENABLED=true +PRODUCT_ID=DPS118 + +HMPPS_AUTH_URL=https://sign-in-dev.hmpps.service.justice.gov.uk/auth +TOKEN_VERIFICATION_API_URL=https://token-verification-api-dev.prison.service.justice.gov.uk + +LOCATIONS_INSIDE_PRISON_API_URL=https://locations-inside-prison-api-dev.hmpps.service.justice.gov.uk +PRISON_API_URL=https://prison-api-dev.prison.service.justice.gov.uk +PRISONER_SEARCH_API_URL=https://prisoner-search-dev.prison.service.justice.gov.uk +COMPONENT_API_URL=https://frontend-components-dev.hmpps.service.justice.gov.uk +COMMON_COMPONENTS_ENABLED=true + +DIGITAL_PRISONS_URL=https://digital-dev.prison.service.justice.gov.uk +PRISONER_PROFILE_URL=https://prisoner-dev.digital.prison.service.justice.gov.uk +CHANGE_SOMEONES_CELL_URL=https://change-someones-cell-dev.prison.service.justice.gov.uk # Credentials for allowing user access -AUTH_CODE_CLIENT_ID=hmpps-typescript-template -AUTH_CODE_CLIENT_SECRET=clientsecret +AUTH_CODE_CLIENT_ID= +AUTH_CODE_CLIENT_SECRET= # Credentials for API calls -CLIENT_CREDS_CLIENT_ID=hmpps-typescript-template-system -CLIENT_CREDS_CLIENT_SECRET=clientsecret +CLIENT_CREDS_CLIENT_ID= +CLIENT_CREDS_CLIENT_SECRET= + +ENVIRONMENT_NAME=DEV diff --git a/assets/images/printer_icon.png b/assets/images/printer_icon.png new file mode 100644 index 0000000..14f892c Binary files /dev/null and b/assets/images/printer_icon.png differ diff --git a/assets/images/prisoner-profile-image.png b/assets/images/prisoner-profile-image.png new file mode 100644 index 0000000..1fed4c7 Binary files /dev/null and b/assets/images/prisoner-profile-image.png differ diff --git a/assets/js/establishment-roll.js b/assets/js/establishment-roll.js new file mode 100644 index 0000000..7a874b8 --- /dev/null +++ b/assets/js/establishment-roll.js @@ -0,0 +1,49 @@ +const landingRows = document.querySelectorAll('.establishment-roll__table__landing-row') +const spurRows = document.querySelectorAll('.establishment-roll__table__spur-row') +const wingRows = document.querySelectorAll('.establishment-roll__table__wing-row') +const totalsRow = document.querySelector('#roll-table-totals-row') + +function init() { + ;[...landingRows, ...spurRows].forEach(row => { + row.setAttribute('hidden', 'hidden') + }) + + wingRows.forEach((wingRow, index) => { + const wingId = wingRow.getAttribute('id') + const wingNameCell = wingRow.getElementsByTagName('td')[0] + const wingNameText = wingNameCell.innerText + const childRows = document.querySelectorAll('[data-wing-id="' + wingId + '"]') + const childrenIds = [...childRows].map(row => row.getAttribute('id')) + + const wingLink = document.createElement('a') + wingLink.setAttribute('href', '#') + wingLink.setAttribute('class', 'govuk-details__summary govuk-link--no-visited-state') + wingLink.setAttribute('aria-controls', childrenIds.join(' ')) + wingLink.innerHTML = wingNameText + + wingLink.addEventListener('click', function (event) { + event.preventDefault() + const nextRow = wingRows[index + 1] ? wingRows[index + 1] : totalsRow + + childRows.forEach(row => { + const isOpen = !row.getAttribute('hidden') + + if (isOpen) { + row.setAttribute('hidden', 'hidden') + wingLink.setAttribute('aria-expanded', 'false') + wingRow.classList.remove('open') + nextRow.classList.remove('next-wing-to-open') + } else { + row.removeAttribute('hidden') + wingLink.setAttribute('aria-expanded', 'true') + wingRow.classList.add('open') + nextRow.classList.add('next-wing-to-open') + } + }) + }) + + wingNameCell.replaceChildren(wingLink) + }) +} + +init() diff --git a/assets/js/govukFrontendInit.js b/assets/js/govukFrontendInit.js new file mode 100644 index 0000000..ee1be48 --- /dev/null +++ b/assets/js/govukFrontendInit.js @@ -0,0 +1,3 @@ +import { initAll } from '/assets/govuk/govuk-frontend.min.js' + +initAll() diff --git a/assets/js/index.js b/assets/js/index.js index 9243e49..a1e2d25 100644 --- a/assets/js/index.js +++ b/assets/js/index.js @@ -3,3 +3,4 @@ import * as mojFrontend from '@ministryofjustice/frontend' govukFrontend.initAll() mojFrontend.initAll() + diff --git a/assets/js/initMoj.js b/assets/js/initMoj.js new file mode 100644 index 0000000..5dd458d --- /dev/null +++ b/assets/js/initMoj.js @@ -0,0 +1 @@ +window.MOJFrontend.initAll() diff --git a/assets/js/printPage.js b/assets/js/printPage.js new file mode 100644 index 0000000..e9d8d56 --- /dev/null +++ b/assets/js/printPage.js @@ -0,0 +1,10 @@ +const printLinks = document.querySelectorAll('.print-link') + +if (printLinks?.length) { + printLinks.forEach(el => + el.addEventListener('click', evt => { + evt.preventDefault() + window.print() + }), + ) +} diff --git a/assets/scss/application.scss b/assets/scss/application.scss index 2050c2a..41dbc9d 100644 --- a/assets/scss/application.scss +++ b/assets/scss/application.scss @@ -6,6 +6,23 @@ $govuk-page-width: $moj-page-width; @import "govuk-frontend/dist/govuk/all"; @import "@ministryofjustice/frontend/moj/all"; +@import "node_modules/govuk-frontend/dist/govuk/base"; +@import "node_modules/govuk-frontend/dist/govuk/core"; +@import "node_modules/govuk-frontend/dist/govuk/objects"; +@import "node_modules/govuk-frontend/dist/govuk/components"; +@import "node_modules/govuk-frontend/dist/govuk/utilities"; +@import "node_modules/govuk-frontend/dist/govuk/overrides"; +@import 'node_modules/@ministryofjustice/hmpps-connect-dps-components/dist/assets/footer'; +@import 'node_modules/@ministryofjustice/hmpps-connect-dps-components/dist/assets/header-bar'; +@import 'node_modules/@ministryofjustice/hmpps-connect-dps-shared-items/dist/assets/scss/all'; + +@import './components/print-link'; +@import './components/alert-flags'; @import './components/header-bar'; +@import './components/results-table'; +@import './components/sortable-table'; +@import './pages/establishment-roll'; @import './local'; +@import './print'; + diff --git a/assets/scss/components/_alert-flags.scss b/assets/scss/components/_alert-flags.scss new file mode 100644 index 0000000..625a70c --- /dev/null +++ b/assets/scss/components/_alert-flags.scss @@ -0,0 +1,45 @@ +.alerts-list { + @extend %govuk-list; + margin-bottom: 0; + + @media screen { + display: flex; + flex-wrap: wrap; + } + + li { + margin-bottom: 10px; + margin-right: 10px; + } +} + +.cat-a-status { + @extend %status-style; + + &--a, + &--e { + color: #00437b; + border-color: #00437b; + } + + &--h { + background-color: #00437b; + color: white; + border-color: #00437b; + } + + &--p { + border: 3px dashed; + color: #00437b; + border-color: #00437b; + } +} + +.non-association { + @extend %status-style; + + background: lighten(#D4351C,40); + border-color: lighten(#D4351C,40); + font-weight: bold; + color: #D4351C; +} diff --git a/assets/scss/components/_print-link.scss b/assets/scss/components/_print-link.scss new file mode 100644 index 0000000..c3b836d --- /dev/null +++ b/assets/scss/components/_print-link.scss @@ -0,0 +1,9 @@ +.hmpps-print-link { + display: inline-block; + margin: 0 0 15px -10px; + position: relative; + padding: 0.5em 0.5em 0.5em 38px; + background: url('/assets/images/printer_icon.png') no-repeat 10px 50%; + background-size: 16px 18px; + cursor: pointer; +} diff --git a/assets/scss/components/_results-table.scss b/assets/scss/components/_results-table.scss new file mode 100644 index 0000000..fddf00c --- /dev/null +++ b/assets/scss/components/_results-table.scss @@ -0,0 +1,25 @@ +.results-table { + &__results { + overflow: auto; + + &__image { + width: 90px; + + @media print { + width: 60px; + } + } + + .govuk-table__header { + vertical-align: bottom; + } + + .govuk-table__cell { + vertical-align: middle; + } + + .alerts-list { + display: flex; + } + } +} diff --git a/assets/scss/components/_sortable-table.scss b/assets/scss/components/_sortable-table.scss new file mode 100644 index 0000000..fd8ea4e --- /dev/null +++ b/assets/scss/components/_sortable-table.scss @@ -0,0 +1,59 @@ +[aria-sort] a, +[aria-sort] a:hover { + background-color: rgba(0, 0, 0, 0); + border-width: 0; + -webkit-box-shadow: 0 0 0 0; + -moz-box-shadow: 0 0 0 0; + box-shadow: 0 0 0 0; + color: #005ea5; + cursor: pointer; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + padding: 0 10px 0 0; + position: relative; + text-align: inherit; + font-size: 1em; + margin: 0; + text-decoration: none; +} + +[aria-sort] a:before { + content: ' ▼'; + position: absolute; + right: -1px; + top: 9px; + font-size: 0.5em; +} + +[aria-sort] a:after { + content: ' ▲'; + position: absolute; + right: -1px; + top: 1px; + font-size: 0.5em; +} + +[aria-sort='descending'] a:before { + display: none; +} + +[aria-sort='descending'] a:after { + content: ' ▼'; + font-size: 0.8em; + position: absolute; + right: -5px; + top: 2px; +} + +[aria-sort='ascending'] a:before { + display: none; +} + +[aria-sort='ascending'] a:after { + content: ' ▲'; + font-size: 0.8em; + position: absolute; + right: -5px; + top: 2px; +} diff --git a/assets/scss/local.scss b/assets/scss/local.scss index 35cba2b..69a726e 100644 --- a/assets/scss/local.scss +++ b/assets/scss/local.scss @@ -1,3 +1,33 @@ .govuk-main-wrapper { min-height: 600px; + padding-top: 0; +} + +.text-align-right { + text-align: right; +} + +.text-align-left { + text-align: left; +} + +// Creates width helper classes to set input, paragraph or other element width based on EMs +// e.g. hmpps-width-20 sets the width of the element to 20em +// Classes created got from 1em to 40em +@mixin hmpps-width-x { + @for $i from 1 through 40 { + .hmpps-width-#{$i} { width: #{$i}em; } + } +} +@include hmpps-width-x; + +.govuk-link { + text-underline-offset: .1em; +} + +// Print styles +@include govuk-media-query($media-type: print) { + .govuk-template { + background-color: govuk-colour('white'); + } } diff --git a/assets/scss/pages/_establishment-roll.scss b/assets/scss/pages/_establishment-roll.scss new file mode 100644 index 0000000..fc2073c --- /dev/null +++ b/assets/scss/pages/_establishment-roll.scss @@ -0,0 +1,110 @@ +.establishment-roll { + &__label-and-value { + display: flex; + justify-content: space-between; + + @media (min-width: #{map-get($govuk-breakpoints, 'desktop')}) { + flex-basis: 50%; + padding-right: 90px; + } + + @media print { + padding-right: 0; + h2, p { + font-size: 14pt !important; + } + } + + &--with-space { + @media print, (min-width: #{map-get($govuk-breakpoints, 'desktop')}) { + flex-basis: 33%; + } + } + } + + &__table { + border-collapse: separate; + tbody { + tr:last-child { + * { + border-bottom: none; + } + } + } + + &__wing-row { + td { + padding: 13px 51px 7px 0; + } + + td:first-of-type { + a[aria-expanded='true']:before { + display: block; + width: 0; + height: 0; + -webkit-clip-path: polygon(0 0, 50% 100%, 100% 0); + clip-path: polygon(0 0, 50% 100%, 100% 0); + border-color: transparent; + border-style: solid; + border-width: 12.124px 7px 0; + border-top-color: inherit; + } + } + &.open { + td { + border-bottom: none; + } + } + + + } + + &__landing-row { + font-size: 16px; + + td:first-of-type { + display: block; + margin-left: 46px; + border-collapse: separate; + + a { + color: govuk-colour('blue'); + } + } + + &.last-in-group { + td { + border-bottom: none; + margin-bottom: 10px; + } + } + + } + + &__spur-row { + font-size: 16px; + + td:first-of-type { + display: block; + margin-left: 30px; + border-collapse: separate; + font-weight: bold; + padding-left: 15px; + font-size: 19px; + padding-top: 8px; + } + + td { + background-color: govuk-colour('light-grey'); + border-bottom: none; + padding-top: 10px; + } + } + + tr.next-wing-to-open { + td { + border-top: 1px solid govuk-colour('mid-grey'); + } + } + } +} \ No newline at end of file diff --git a/assets/scss/print.scss b/assets/scss/print.scss new file mode 100644 index 0000000..0fce2b0 --- /dev/null +++ b/assets/scss/print.scss @@ -0,0 +1,62 @@ +@page { + margin: 0; +} + +@media screen { + .print-only { + display: none; + } +} + +@include govuk-media-query($media-type: print) { + // govuk-frontend overrides + .govuk-heading-l { + font-size: 19pt; + } + + .govuk-heading-m { + font-size: 16pt; + } + + .govuk-heading-s { + font-size: 14pt; + } + + .govuk-body-l { + font-size: 12pt; + } + + .printed-page { + background: none; + } + + .govuk-table { + font-size: 10pt; + + // Remove styling from MOJ sortable table colum headers + [aria-sort] button { + color: inherit; + + &:before, + &:after { + content: ''; + } + } + } + + .govuk-link { + text-decoration: none; + + &:link, + &:visited { + color: $govuk-text-colour; + } + + // Remove url from links in printed view within a table + &[href^="/"], &[href^="http://"], &[href^="https://"] { + &::after { + content: ''; + } + } + } +} diff --git a/cypress.config.ts b/cypress.config.ts index 8a5d695..2dbd00b 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,10 +1,11 @@ import { defineConfig } from 'cypress' import { resetStubs } from './integration_tests/mockApis/wiremock' import auth from './integration_tests/mockApis/auth' -import tokenVerification from './integration_tests/mockApis/tokenVerification' -import prisonerSearch from './integration_tests/mockApis/prisonerSearch' +import feComponents from './integration_tests/mockApis/feComponents' import locations from './integration_tests/mockApis/locations' import prison from './integration_tests/mockApis/prison' +import prisonerSearch from './integration_tests/mockApis/prisonerSearch' +import tokenVerification from './integration_tests/mockApis/tokenVerification' export default defineConfig({ chromeWebSecurity: false, @@ -21,10 +22,11 @@ export default defineConfig({ on('task', { reset: resetStubs, ...auth, - ...tokenVerification, - ...prison, + ...feComponents, ...locations, + ...prison, ...prisonerSearch, + ...tokenVerification, }) }, baseUrl: 'http://localhost:3007', diff --git a/eslint.config.mjs b/eslint.config.mjs index 91d53fb..de1f844 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,3 +1,5 @@ import hmppsConfig from '@ministryofjustice/eslint-config-hmpps' -export default hmppsConfig() +export default hmppsConfig({ + extraIgnorePaths: ['assets'], +}) diff --git a/feature.env b/feature.env index 9a44faa..d2138f5 100644 --- a/feature.env +++ b/feature.env @@ -1,4 +1,5 @@ PORT=3007 +PRODUCT_ID=DPS118 HMPPS_AUTH_URL=http://localhost:9091/auth TOKEN_VERIFICATION_API_URL=http://localhost:9091/verification TOKEN_VERIFICATION_ENABLED=true @@ -10,7 +11,8 @@ CLIENT_CREDS_CLIENT_ID=clientid CLIENT_CREDS_CLIENT_SECRET=clientsecret LOCATIONS_INSIDE_PRISON_API_URL=http://localhost:9091/locations PRISON_API_URL=http://localhost:9091/prison +DIGITAL_PRISONS_URL=http://localhost:9091/dps PRISONER_SEARCH_API_URL=http://localhost:9091/prisoner-search +PRISONER_PROFILE_URL=http://localhost:9091/prisoner-profile COMPONENT_API_URL=http://localhost:9091/frontend-components -MANAGE_USERS_API_URL=http://localhost:9091/manage-users-api -ENVIRONMENT_NAME=DEV +ENVIRONMENT_NAME=dev diff --git a/helm_deploy/values-dev.yaml b/helm_deploy/values-dev.yaml index dce09ab..d88a7e5 100644 --- a/helm_deploy/values-dev.yaml +++ b/helm_deploy/values-dev.yaml @@ -9,12 +9,24 @@ generic-service: env: INGRESS_URL: "https://prison-roll-count-dev.hmpps.service.justice.gov.uk" + + ENVIRONMENT_NAME: DEV + PRODUCT_ID: "DPS118" HMPPS_AUTH_URL: "https://sign-in-dev.hmpps.service.justice.gov.uk/auth" - TOKEN_VERIFICATION_API_URL: "https://token-verification-api-dev.prison.service.justice.gov.uk" + HMPPS_COOKIE_DOMAIN: digital-dev.prison.service.justice.gov.uk + HMPPS_COOKIE_NAME: hmpps-session-dev + + # APIs + LOCATIONS_INSIDE_PRISON_API_URL: "https://locations-inside-prison-api-dev.hmpps.service.justice.gov.uk" PRISON_API_URL: "https://prison-api-dev.prison.service.justice.gov.uk" PRISONER_SEARCH_API_URL: "https://prisoner-search-dev.prison.service.justice.gov.uk" - LOCATIONS_INSIDE_PRISON_API_URL: "https://locations-inside-prison-api-dev.hmpps.service.justice.gov.uk" - ENVIRONMENT_NAME: DEV + TOKEN_VERIFICATION_API_URL: "https://token-verification-api-dev.prison.service.justice.gov.uk" + + # Service URLs + CHANGE_SOMEONES_CELL_URL: https://change-someones-cell-dev.prison.service.justice.gov.uk + DIGITAL_PRISONS_URL: https://digital-dev.prison.service.justice.gov.uk + PRISONER_PROFILE_URL: https://prisoner-dev.digital.prison.service.justice.gov.uk + AUDIT_ENABLED: "false" generic-prometheus-alerts: diff --git a/integration_tests/e2e/health.cy.ts b/integration_tests/e2e/health.cy.ts index 8d40eec..f40f11b 100644 --- a/integration_tests/e2e/health.cy.ts +++ b/integration_tests/e2e/health.cy.ts @@ -4,6 +4,7 @@ context('Healthcheck', () => { cy.task('reset') cy.task('stubAuthPing') cy.task('stubLocationsApiPing') + cy.task('stubFeComponentsPing') cy.task('stubPrisonApiPing') cy.task('stubPrisonerSearchApiPing') cy.task('stubTokenVerificationPing') diff --git a/integration_tests/mockApis/feComponents.ts b/integration_tests/mockApis/feComponents.ts new file mode 100644 index 0000000..336ebba --- /dev/null +++ b/integration_tests/mockApis/feComponents.ts @@ -0,0 +1,78 @@ +import type Service from '@ministryofjustice/hmpps-connect-dps-components/dist/types/Service' +import { stubFor } from './wiremock' +import { CaseLoad } from '../../server/data/interfaces/caseLoad' + +export default { + stubFeComponentsPing: () => + stubFor({ + request: { + method: 'GET', + urlPath: '/frontend-components/health/ping', + }, + response: { + status: 200, + }, + }), + + stubFeComponents: ( + options: { + caseLoads?: CaseLoad[] + services?: Service[] + residentialLocationsActive?: boolean + } = {}, + ) => { + const caseLoads = options.caseLoads || [ + { + caseLoadId: 'LEI', + currentlyActive: true, + description: 'Leeds (HMP)', + type: '', + caseloadFunction: '', + }, + ] + + return stubFor({ + request: { + method: 'GET', + urlPattern: '/frontend-components/components\\?.*', + }, + response: { + status: 200, + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + }, + jsonBody: { + header: { html: '', css: [], javascript: [] }, + footer: { html: '', css: [], javascript: [] }, + meta: { + caseLoads, + activeCaseLoad: caseLoads.find(caseLoad => caseLoad.currentlyActive === true), + services: options.services || [ + { + id: 'check-my-diary', + heading: 'Check my diary', + description: 'View your prison staff detail (staff rota) from home.', + href: 'http://localhost:3001', + navEnabled: true, + }, + { + id: 'key-worker-allocations', + heading: 'My key worker allocation', + description: 'View your key worker cases.', + href: 'http://localhost:3001/key-worker/111111', + navEnabled: true, + }, + { + id: 'residential-locations', + heading: 'Residential Locations', + description: 'Manage residential locations.', + href: 'http://localhost:3001/locations', + navEnabled: options.residentialLocationsActive, + }, + ], + }, + }, + }, + }) + }, +} diff --git a/package-lock.json b/package-lock.json index fd0d52f..0cc6ffc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@aws-sdk/client-sqs": "^3.699.0", "@ministryofjustice/frontend": "^3.1.0", + "@ministryofjustice/hmpps-connect-dps-components": "2.0.0", "@ministryofjustice/hmpps-connect-dps-shared-items": "^1.2.1", "@ministryofjustice/hmpps-monitoring": "^0.0.1-beta.2", "agentkeepalive": "^4.5.0", @@ -2394,6 +2395,65 @@ "jquery": "^3.6.0" } }, + "node_modules/@ministryofjustice/hmpps-connect-dps-components": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ministryofjustice/hmpps-connect-dps-components/-/hmpps-connect-dps-components-2.0.0.tgz", + "integrity": "sha512-WF7tkLbghfCTP/AN+dFfeme/RgkEaHDITApPEiITvvG1/P/Mluta+TFCSx6lL49sxShshcA5/OuDbNa7hGvJOg==", + "license": "MIT", + "dependencies": { + "@types/node": "^20.17.6", + "@types/nunjucks": "^3.2.6", + "nunjucks": "^3.2.4", + "superagent": "^9.0.2" + } + }, + "node_modules/@ministryofjustice/hmpps-connect-dps-components/node_modules/@types/node": { + "version": "20.17.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.17.tgz", + "integrity": "sha512-/WndGO4kIfMicEQLTi/mDANUu/iVUhT7KboZPdEqqHQ4aTS+3qT3U5gIqWDFV+XouorjfgGqvKILJeHhuQgFYg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@ministryofjustice/hmpps-connect-dps-components/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/@ministryofjustice/hmpps-connect-dps-components/node_modules/superagent": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz", + "integrity": "sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==", + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^3.5.1", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/@ministryofjustice/hmpps-connect-dps-components/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, "node_modules/@ministryofjustice/hmpps-connect-dps-shared-items": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@ministryofjustice/hmpps-connect-dps-shared-items/-/hmpps-connect-dps-shared-items-1.2.1.tgz", @@ -3605,7 +3665,6 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/@types/nunjucks/-/nunjucks-3.2.6.tgz", "integrity": "sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w==", - "dev": true, "license": "MIT" }, "node_modules/@types/oauth": { diff --git a/package.json b/package.json index b079f43..47b3d1a 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "@aws-sdk/client-sqs": "^3.699.0", "@ministryofjustice/frontend": "^3.1.0", "@ministryofjustice/hmpps-connect-dps-shared-items": "^1.2.1", + "@ministryofjustice/hmpps-connect-dps-components": "2.0.0", "@ministryofjustice/hmpps-monitoring": "^0.0.1-beta.2", "agentkeepalive": "^4.5.0", "applicationinsights": "^2.9.6", diff --git a/server/app.ts b/server/app.ts index e5827e9..6e872d9 100755 --- a/server/app.ts +++ b/server/app.ts @@ -2,6 +2,7 @@ import express from 'express' import createError from 'http-errors' +import getFrontendComponents from './middleware/getFeComponents' import nunjucksSetup from './utils/nunjucksSetup' import errorHandler from './errorHandler' import { appInsightsMiddleware } from './utils/azureAppInsights' @@ -21,6 +22,7 @@ import type { Services } from './services' import populateClientToken from './middleware/populateClientToken' import setUpEnvironmentName from './middleware/setUpEnvironmentName' +import setUpPageNotFound from './middleware/setUpPageNotFound' import { ensureActiveCaseLoadSet } from './middleware/ensureActiveCaseLoadSet' @@ -45,9 +47,11 @@ export default function createApp(services: Services): express.Application { app.use(setUpCurrentUser(services)) app.use(populateClientToken()) + app.get('*', getFrontendComponents(services)) app.use(ensureActiveCaseLoadSet(services.userService)) app.use(routes(services)) + app.use(setUpPageNotFound) app.use((req, res, next) => next(createError(404, 'Not found'))) app.use(errorHandler(process.env.NODE_ENV === 'production')) diff --git a/server/applicationInfo.ts b/server/applicationInfo.ts index c28c127..804d1b1 100644 --- a/server/applicationInfo.ts +++ b/server/applicationInfo.ts @@ -14,7 +14,7 @@ export type ApplicationInfo = { } export default (): ApplicationInfo => { - const packageJson = path.join(__dirname, '../../package.json') + const packageJson = path.join(__dirname, `${__dirname.endsWith('/dist/server') ? '../' : ''}../package.json`) const { name: applicationName } = JSON.parse(fs.readFileSync(packageJson).toString()) return { applicationName, buildNumber, gitRef, gitShortHash: gitRef.substring(0, 7), productId, branchName } } diff --git a/server/config.ts b/server/config.ts index 8a1a4d2..1826ce4 100755 --- a/server/config.ts +++ b/server/config.ts @@ -68,6 +68,16 @@ export default { expiryMinutes: Number(get('WEB_SESSION_TIMEOUT_IN_MINUTES', 120)), }, apis: { + frontendComponents: { + url: get('COMPONENT_API_URL', 'http://localhost:8082', requiredInProduction), + healthPath: '/health/ping', + timeout: { + response: Number(get('COMPONENT_API_TIMEOUT_SECONDS', 10000)), + deadline: Number(get('COMPONENT_API_TIMEOUT_SECONDS', 10000)), + }, + agent: new AgentConfig(Number(get('COMPONENT_API_TIMEOUT_SECONDS', 10000))), + enabled: get('COMMON_COMPONENTS_ENABLED', 'true') === 'true', + }, hmppsAuth: { url: get('HMPPS_AUTH_URL', 'http://localhost:9090/auth', requiredInProduction), healthPath: '/health/ping', @@ -120,6 +130,11 @@ export default { enabled: get('TOKEN_VERIFICATION_ENABLED', 'false') === 'true', }, }, + serviceUrls: { + digitalPrisons: get('DIGITAL_PRISONS_URL', 'http://localhost:3001', requiredInProduction), + prisonerProfile: get('PRISONER_PROFILE_URL', 'http://localhost:3002', requiredInProduction), + changeSomeonesCell: get('CHANGE_SOMEONES_CELL_URL', 'http://localhost:3002', requiredInProduction), + }, domain: get('INGRESS_URL', 'http://localhost:3000', requiredInProduction), sqs: { audit: auditConfig(), diff --git a/server/controllers/imageController.ts b/server/controllers/imageController.ts new file mode 100644 index 0000000..d73a928 --- /dev/null +++ b/server/controllers/imageController.ts @@ -0,0 +1,31 @@ +import { Request, RequestHandler, Response } from 'express' +import { RestClientBuilder } from '../data' +import { PrisonApiClient } from '../data/interfaces/prisonApiClient' + +const placeHolderImage = '/assets/images/prisoner-profile-image.png' + +export default class ImageController { + constructor(private readonly prisonApiClientBuilder: RestClientBuilder) {} + + public prisonerImage: RequestHandler = (req: Request, res: Response) => { + const prisonApiClient = this.prisonApiClientBuilder(req.middleware.clientToken) + const { prisonerNumber } = req.params + const fullSizeImage = req.query.fullSizeImage ? req.query.fullSizeImage === 'true' : true + + if (prisonerNumber === 'placeholder') { + res.redirect(placeHolderImage) + } else { + prisonApiClient + .getPrisonerImage(prisonerNumber, fullSizeImage) + .then(data => { + res.set('Cache-control', 'private, max-age=86400') + res.removeHeader('pragma') + res.type('image/jpeg') + data.pipe(res) + }) + .catch(_error => { + res.redirect(placeHolderImage) + }) + } + } +} diff --git a/server/data/feComponentsClient.ts b/server/data/feComponentsClient.ts new file mode 100644 index 0000000..0e32c02 --- /dev/null +++ b/server/data/feComponentsClient.ts @@ -0,0 +1,51 @@ +import config from '../config' +import RestClient from './restClient' + +export interface Component { + html: string + css: string[] + javascript: string[] +} + +export type AvailableComponent = 'header' | 'footer' + +type CaseLoad = { + caseLoadId: string + description: string + type: string + caseloadFunction: string + currentlyActive: boolean +} + +type Service = { + description: string + heading: string + href: string + id: string +} + +export interface FeComponentsMeta { + activeCaseLoad: CaseLoad + caseLoads: CaseLoad[] + services: Service[] +} + +export interface FeComponentsResponse { + header?: Component + footer?: Component + meta: FeComponentsMeta +} + +export default class FeComponentsClient { + private static restClient(token: string): RestClient { + return new RestClient('HMPPS Components Client', config.apis.frontendComponents, token) + } + + getComponents(components: T, userToken: string): Promise { + return FeComponentsClient.restClient(userToken).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 0eb9f84..72eec3f 100644 --- a/server/data/index.ts +++ b/server/data/index.ts @@ -18,6 +18,7 @@ import config, { ApiConfig } from '../config' import HmppsAuditClient from './hmppsAuditClient' import LocationsInsidePrisonApiRestClient from './locationsInsidePrisonApiClient' import PrisonApiRestClient from './prisonApiRestClient' +import FeComponentsClient from './feComponentsClient' import { PrisonApiClient } from './interfaces/prisonApiClient' import PrisonerSearchRestClient from './prisonerSearchClient' import RestClient, { RestClientBuilder as CreateRestClientBuilder } from './restClient' @@ -41,7 +42,13 @@ export const dataAccess = () => { applicationInfo, hmppsAuthClient, systemToken: (username?: string) => hmppsAuthClient.getSystemClientToken(username), + feComponentsClient: new FeComponentsClient(), hmppsAuditClient: new HmppsAuditClient(config.sqs.audit), + locationsInsidePrisonApiClientBuilder: restClientBuilder( + 'Locations Inside Prison API', + config.apis.locationsInsidePrisonApi, + LocationsInsidePrisonApiRestClient, + ), prisonApiClientBuilder: restClientBuilder( 'Prison API', config.apis.prisonApi, @@ -52,11 +59,6 @@ export const dataAccess = () => { config.apis.prisonerSearchApi, PrisonerSearchRestClient, ), - locationsInsidePrisonApiClientBuilder: restClientBuilder( - 'Locations Inside Prison API', - config.apis.locationsInsidePrisonApi, - LocationsInsidePrisonApiRestClient, - ), } } diff --git a/server/data/prisonApiRestClient.ts b/server/data/prisonApiRestClient.ts index 635d339..7c91849 100644 --- a/server/data/prisonApiRestClient.ts +++ b/server/data/prisonApiRestClient.ts @@ -1,19 +1,19 @@ import * as querystring from 'querystring' import { Readable } from 'stream' import RestClient from './restClient' -import { BedAssignment } from './interfaces/bedAssignment' +import { PrisonApiClient } from './interfaces/prisonApiClient' import { CaseLoad } from './interfaces/caseLoad' -import EstablishmentRollSummary from '../services/interfaces/EstablishmentRollSummary' import { Location } from './interfaces/location' import { Movements } from './interfaces/movements' import { OffenderIn } from './interfaces/offenderIn' -import { OffenderInReception } from './interfaces/offenderInReception' -import { OffenderMovement } from './interfaces/offenderMovement' import { OffenderOut } from './interfaces/offenderOut' +import { OffenderMovement } from './interfaces/offenderMovement' +import { OffenderInReception } from './interfaces/offenderInReception' +import { UserDetail } from './interfaces/userDetail' +import { BedAssignment } from './interfaces/bedAssignment' import { PagedList } from './interfaces/pagedList' import PrisonRollCount from './interfaces/prisonRollCount' -import { PrisonApiClient } from './interfaces/prisonApiClient' -import { UserDetail } from './interfaces/userDetail' +import EstablishmentRollSummary from '../services/interfaces/EstablishmentRollSummary' export default class PrisonApiRestClient implements PrisonApiClient { constructor(private restClient: RestClient) {} diff --git a/server/middleware/getFeComponents.ts b/server/middleware/getFeComponents.ts new file mode 100644 index 0000000..74a950e --- /dev/null +++ b/server/middleware/getFeComponents.ts @@ -0,0 +1,33 @@ +import type { RequestHandler } from 'express' + +import logger from '../../logger' +import { Services } from '../services' +import config from '../config' + +export default function getFrontendComponents({ feComponentsService }: Services): RequestHandler { + return async (_req, res, next) => { + if (!config.apis.frontendComponents.enabled) { + res.locals.feComponents = {} + return next() + } + + try { + const { header, footer, meta } = await feComponentsService.getComponents( + ['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], + meta, + } + return next() + } catch (error) { + logger.error(error, 'Failed to retrieve front end components') + return next() + } + } +} diff --git a/server/middleware/setUpPageNotFound.ts b/server/middleware/setUpPageNotFound.ts new file mode 100644 index 0000000..13fc914 --- /dev/null +++ b/server/middleware/setUpPageNotFound.ts @@ -0,0 +1,14 @@ +import { Request, Response } from 'express' + +export default (req: Request, res: Response) => { + res.status(404) + res.locals = { + ...res.locals, + user: { + ...res.locals.user, + showFeedbackBanner: false, + }, + hideBackLink: true, + } + res.render('notFound', { url: req.headers.referer || '/' }) +} diff --git a/server/middleware/setUpStaticResources.ts b/server/middleware/setUpStaticResources.ts index a1cc157..b9aa847 100644 --- a/server/middleware/setUpStaticResources.ts +++ b/server/middleware/setUpStaticResources.ts @@ -14,6 +14,9 @@ export default function setUpStaticResources(): Router { const staticResourcesConfig = { maxAge: config.staticResourceCacheDuration, redirect: false } Array.of( + '/assets', + '/assets/stylesheets', + '/assets/js', '/dist/assets', '/node_modules/govuk-frontend/dist/govuk/assets', '/node_modules/govuk-frontend/dist', @@ -23,6 +26,14 @@ export default function setUpStaticResources(): Router { router.use('/assets', express.static(path.join(process.cwd(), dir), staticResourcesConfig)) }) + Array.of('/node_modules/govuk_frontend_toolkit/images').forEach(dir => { + router.use('/assets/images/icons', express.static(path.join(process.cwd(), dir), staticResourcesConfig)) + }) + + Array.of('/node_modules/jquery/dist/jquery.min.js').forEach(dir => { + router.use('/assets/js/jquery.min.js', express.static(path.join(process.cwd(), dir), staticResourcesConfig)) + }) + // Don't cache dynamic resources router.use(noCache()) diff --git a/server/middleware/setUpWebSecurity.ts b/server/middleware/setUpWebSecurity.ts index 0a25aae..333d5c5 100644 --- a/server/middleware/setUpWebSecurity.ts +++ b/server/middleware/setUpWebSecurity.ts @@ -6,6 +6,9 @@ import config from '../config' export default function setUpWebSecurity(): Router { const router = express.Router() + const gaHosts = ['*.googletagmanager.com', '*.google-analytics.com', '*.analytics.google.com'] + + // // Secure code best practice - see: // 1. https://expressjs.com/en/advanced/best-practice-security.html, // 2. https://www.npmjs.com/package/helmet @@ -24,10 +27,13 @@ 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}'`], + scriptSrc: ["'self'", (_req: Request, res: Response) => `'nonce-${res.locals.cspNonce}'`, ...gaHosts], styleSrc: ["'self'", (_req: Request, res: Response) => `'nonce-${res.locals.cspNonce}'`], - fontSrc: ["'self'"], + fontSrc: ["'self'", config.apis.frontendComponents.url], formAction: [`'self' ${config.apis.hmppsAuth.externalUrl}`], + connectSrc: ["'self'", ...gaHosts], + imgSrc: ["'self'", ...gaHosts], + mediaSrc: ["'self'", ...gaHosts], }, }, crossOriginEmbedderPolicy: true, diff --git a/server/routes/index.ts b/server/routes/index.ts index eb2675e..7cfa872 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -2,6 +2,8 @@ import { RequestHandler, Router } from 'express' import { Services } from '../services' import asyncMiddleware from '../middleware/asyncMiddleware' import EstablishmentRollController from '../controllers/establishmentRollController' +import ImageController from '../controllers/imageController' +import { dataAccess } from '../data' export default function establishmentRollRouter(services: Services): Router { const router = Router() @@ -12,28 +14,32 @@ export default function establishmentRollRouter(services: Services): Router { handlers.map(handler => asyncMiddleware(handler)), ) + const { prisonApiClientBuilder } = dataAccess() + const establishmentRollController = new EstablishmentRollController( services.establishmentRollService, services.movementsService, services.locationsService, ) - // TODO: IS /LOCATIONS NEEDED? + const imageController = new ImageController(prisonApiClientBuilder) + get('/', establishmentRollController.getEstablishmentRoll()) get('/locations/', establishmentRollController.getEstablishmentRoll(true)) - // TODO: REMAINING ROUTES - // get( - // ['/wing/:wingId/landing/:landingId', '/wing/:wingId/spur/:spurId/landing/:landingId'], - // establishmentRollController.getEstablishmentRollForLanding(), - // ) - // get('/arrived-today', establishmentRollController.getArrivedToday()) - // get('/out-today', establishmentRollController.getOutToday()) - // get('/en-route', establishmentRollController.getEnRoute()) - // get('/in-reception', establishmentRollController.getInReception()) - // get('/no-cell-allocated', establishmentRollController.getUnallocated()) - // get('/total-currently-out', establishmentRollController.getTotalCurrentlyOut()) - // get('/:livingUnitId/currently-out', establishmentRollController.getCurrentlyOut()) + get( + ['/wing/:wingId/landing/:landingId', '/wing/:wingId/spur/:spurId/landing/:landingId'], + establishmentRollController.getEstablishmentRollForLanding(), + ) + get('/arrived-today', establishmentRollController.getArrivedToday()) + get('/out-today', establishmentRollController.getOutToday()) + get('/en-route', establishmentRollController.getEnRoute()) + get('/in-reception', establishmentRollController.getInReception()) + get('/no-cell-allocated', establishmentRollController.getUnallocated()) + get('/total-currently-out', establishmentRollController.getTotalCurrentlyOut()) + get('/:livingUnitId/currently-out', establishmentRollController.getCurrentlyOut()) + + get('/prisonerImage/:prisonerNumber', imageController.prisonerImage) return router } diff --git a/server/services/feComponentsService.ts b/server/services/feComponentsService.ts new file mode 100644 index 0000000..1ca4d95 --- /dev/null +++ b/server/services/feComponentsService.ts @@ -0,0 +1,34 @@ +import FeComponentsClient, { AvailableComponent } from '../data/feComponentsClient' +import { kebabCase } from '../utils/utils' +import renderMacro from '../utils/renderMacro' + +export default class FeComponentsService { + constructor(private readonly feComponentsClient: FeComponentsClient) {} + + async getComponents(components: T, token: string) { + return this.feComponentsClient.getComponents(components, token) + } + + /** + * Return a filename name for a macro + * @param {string} macroName + * @returns {string} returns naming convention based macro name + */ + private macroNameToFilepath(macroName: string): string { + if (macroName.includes('govuk')) { + return `govuk/components/${kebabCase(macroName.replace(/^\b(govuk)/, ''))}` + } + + if (macroName.includes('moj')) { + return `moj/components/${kebabCase(macroName.replace(/^\b(moj)/, ''))}` + } + + return kebabCase(macroName.replace(/^\b(app)/, '')) + } + + getComponent(macroName: string, params = {}) { + const filename = this.macroNameToFilepath(macroName) + + return renderMacro(`${filename}/macro`, macroName, params) + } +} diff --git a/server/services/index.ts b/server/services/index.ts index ba6739d..5765009 100644 --- a/server/services/index.ts +++ b/server/services/index.ts @@ -1,39 +1,44 @@ import { dataAccess } from '../data' import AuditService from './auditService' -import UserService from './userService' import EstablishmentRollService from './establishmentRollService' -import MovementsService from './movementsService' +import FeComponentsService from './feComponentsService' import LocationService from './locationsService' +import MovementsService from './movementsService' +import UserService from './userService' export const services = () => { const { - prisonApiClientBuilder, - prisonerSearchApiClientBuilder, - locationsInsidePrisonApiClientBuilder, applicationInfo, + feComponentsClient, hmppsAuditClient, + locationsInsidePrisonApiClientBuilder, + prisonApiClientBuilder, + prisonerSearchApiClientBuilder, } = dataAccess() - const userService = new UserService(prisonApiClientBuilder) const auditService = new AuditService(hmppsAuditClient) const establishmentRollService = new EstablishmentRollService( prisonApiClientBuilder, locationsInsidePrisonApiClientBuilder, ) + const feComponentsService = new FeComponentsService(feComponentsClient) + const locationsService = new LocationService(prisonApiClientBuilder, locationsInsidePrisonApiClientBuilder) const movementsService = new MovementsService( prisonApiClientBuilder, prisonerSearchApiClientBuilder, locationsInsidePrisonApiClientBuilder, ) - const locationsService = new LocationService(prisonApiClientBuilder, locationsInsidePrisonApiClientBuilder) + const userService = new UserService(prisonApiClientBuilder) + return { dataAccess, applicationInfo, auditService, - userService, establishmentRollService, - movementsService, + feComponentsService, locationsService, + movementsService, + userService, } } diff --git a/server/services/movementsService.ts b/server/services/movementsService.ts index b234610..382c02d 100644 --- a/server/services/movementsService.ts +++ b/server/services/movementsService.ts @@ -1,13 +1,13 @@ import dpsShared from '@ministryofjustice/hmpps-connect-dps-shared-items' -import { BedAssignment } from '../data/interfaces/bedAssignment' -import { OffenderMovement } from '../data/interfaces/offenderMovement' +import { RestClientBuilder } from '../data' import { PrisonApiClient } from '../data/interfaces/prisonApiClient' -import { Prisoner } from '../data/interfaces/prisoner' import { PrisonerSearchClient } from '../data/interfaces/prisonerSearchClient' import { PrisonerWithAlerts } from './interfaces/PrisonerWithAlerts' -import { RestClientBuilder } from '../data' -import { LocationsInsidePrisonApiClient } from '../data/interfaces/locationsInsidePrisonApiClient' import { stripAgencyPrefix } from '../utils/utils' +import { Prisoner } from '../data/interfaces/prisoner' +import { BedAssignment } from '../data/interfaces/bedAssignment' +import { OffenderMovement } from '../data/interfaces/offenderMovement' +import { LocationsInsidePrisonApiClient } from '../data/interfaces/locationsInsidePrisonApiClient' export default class MovementsService { constructor( diff --git a/server/utils/nunjucksSetup.ts b/server/utils/nunjucksSetup.ts index 342c830..3a8503b 100644 --- a/server/utils/nunjucksSetup.ts +++ b/server/utils/nunjucksSetup.ts @@ -3,7 +3,7 @@ import path from 'path' import nunjucks from 'nunjucks' import express from 'express' import fs from 'fs' -import { initialiseName, prisonerBelongsToUsersCaseLoad, userHasAllRoles, userHasRoles } from './utils' +import { formatName, initialiseName, prisonerBelongsToUsersCaseLoad, userHasAllRoles, userHasRoles } from './utils' import { formatDate, formatDateTime, formatTime, timeFromDate, toUnixTimeStamp } from './dateHelpers' import config from '../config' import logger from '../../logger' @@ -12,7 +12,9 @@ export default function nunjucksSetup(app: express.Express): void { app.set('view engine', 'njk') app.locals.asset_path = '/assets/' - app.locals.applicationName = 'HMPPS Prison Roll Count' + app.locals.applicationName = 'Establishment roll' + app.locals.config = config + app.locals.dpsUrl = config.serviceUrls.digitalPrisons app.locals.environmentName = config.environmentName app.locals.environmentNameColour = config.environmentName === 'PRE-PRODUCTION' ? 'govuk-tag--green' : '' let assetManifest: Record = {} @@ -30,7 +32,11 @@ export default function nunjucksSetup(app: express.Express): void { [ path.join(__dirname, '../../server/views'), 'node_modules/govuk-frontend/dist/', + 'node_modules/govuk-frontend/dist/components/', 'node_modules/@ministryofjustice/frontend/', + 'node_modules/@ministryofjustice/frontend/moj/components/', + 'node_modules/@ministryofjustice/hmpps-connect-dps-components/dist/assets/', + 'node_modules/@ministryofjustice/hmpps-connect-dps-shared-items/dist/assets/', ], { autoescape: true, @@ -42,6 +48,7 @@ export default function nunjucksSetup(app: express.Express): void { njkEnv.addGlobal('userHasAllRoles', userHasAllRoles) njkEnv.addFilter('initialiseName', initialiseName) + njkEnv.addFilter('formatName', formatName) njkEnv.addFilter('assetMap', (url: string) => assetManifest[url] || url) njkEnv.addFilter('formatDate', formatDate) njkEnv.addFilter('formatDateTime', formatDateTime) diff --git a/server/utils/utils.ts b/server/utils/utils.ts index febb466..37625d0 100644 --- a/server/utils/utils.ts +++ b/server/utils/utils.ts @@ -24,6 +24,43 @@ export const initialiseName = (fullName?: string): string | null => { return `${array[0][0]}. ${array.reverse()[0]}` } +/** + * Format a person's name with proper capitalisation + * + * Correctly handles names with apostrophes, hyphens and spaces + * + * Examples, "James O'Reilly", "Jane Smith-Doe", "Robert Henry Jones" + * + * @param firstName - first name + * @param middleNames - middle names as space separated list + * @param lastName - last name + * @param options + * @param options.style - format to use for output name, e.g. `NameStyleFormat.lastCommaFirst` + * @returns formatted name string + */ + +export const formatName = ( + firstName: string, + middleNames: string, + lastName: string, + options?: { style: 'firstMiddleLast' | 'lastCommaFirstMiddle' | 'lastCommaFirst' | 'firstLast' }, +): string => { + const names = [firstName, middleNames, lastName] + if (options?.style === 'lastCommaFirstMiddle') { + names.unshift(`${names.pop()},`) + } else if (options?.style === 'lastCommaFirst') { + names.unshift(`${names.pop()},`) + names.pop() // Remove middleNames + } else if (options?.style === 'firstLast') { + names.splice(1, 1) + } + return names + .filter(s => s) + .map(s => s.trim().toLowerCase()) + .join(' ') + .replace(/(^\w)|([\s'-]+\w)/g, letter => letter.toUpperCase()) +} + /** * Whether or not the prisoner belongs to any of the users case loads * diff --git a/server/views/macros/hmppsPagedListFooter.njk b/server/views/macros/hmppsPagedListFooter.njk new file mode 100644 index 0000000..020677c --- /dev/null +++ b/server/views/macros/hmppsPagedListFooter.njk @@ -0,0 +1,14 @@ +{%- from './hmppsPagination.njk' import hmppsPagination -%} +{%- from './hmppsPaginationSummary.njk' import hmppsPaginationSummary -%} +{% macro hmppsPagedListFooter(listMetadata) %} + {% if listMetadata.pagination.totalElements %} + + {% endif %} +{% endmacro %} diff --git a/server/views/macros/hmppsPagedListHeader.njk b/server/views/macros/hmppsPagedListHeader.njk new file mode 100644 index 0000000..c4a0219 --- /dev/null +++ b/server/views/macros/hmppsPagedListHeader.njk @@ -0,0 +1,17 @@ +{%- from './hmppsPagination.njk' import hmppsPagination -%} +{%- from './hmppsPaginationSummary.njk' import hmppsPaginationSummary -%} +{%- from './hmppsSortSelector.njk' import hmppsSortSelector -%} + +{% macro hmppsPagedListHeader(listMetadata, options = {}) %} +
+
+ {% if listMetadata.sorting %} + {{ hmppsSortSelector(listMetadata.sorting) }} + {% endif %} + {{ hmppsPagination(listMetadata.pagination) }} +
+
+ {{ hmppsPaginationSummary(listMetadata.pagination) }} +
+
+{% endmacro %} \ No newline at end of file diff --git a/server/views/macros/hmppsSortSelector.njk b/server/views/macros/hmppsSortSelector.njk new file mode 100644 index 0000000..9da58f9 --- /dev/null +++ b/server/views/macros/hmppsSortSelector.njk @@ -0,0 +1,36 @@ +{% from "govuk/components/button/macro.njk" import govukButton %} + +{% macro hmppsSortSelector(params) %} +
+
+ + + {% for key, val in params.queryParams %} + {% if val and val is iterable and val is not string %} + {% for i in val %} + + {% endfor %} + {% elseif val %} + + {% endif %} + {% endfor %} + {{ govukButton({ + text: "Sort", + classes: "hmpps-sort-selector__sort-button", + preventDoubleClick: true + }) }} +
+
+ {% block pageScripts %} + + {% endblock %} +{% endmacro %} \ No newline at end of file diff --git a/server/views/macros/printLink.njk b/server/views/macros/printLink.njk index 68b8184..4d88f3c 100644 --- a/server/views/macros/printLink.njk +++ b/server/views/macros/printLink.njk @@ -1,6 +1,6 @@ {% macro printLink(linkText = 'Print this page', align = "left") %} diff --git a/server/views/notFound.njk b/server/views/notFound.njk new file mode 100644 index 0000000..23ddb27 --- /dev/null +++ b/server/views/notFound.njk @@ -0,0 +1,29 @@ +{% extends "./partials/layout.njk" %} +{% set mainClasses = "govuk-main-wrapper--auto-spacing" %} +{% from "govuk/components/button/macro.njk" import govukButton %} + +{% set pageTitle = "Page not found" %} + +{% block content %} +
+
+

{{ pageTitle }}

+

+ If you typed or pasted the web address, check it is correct. +

+

+ If you have clicked to go back, the details you entered have been saved to the service and you cannot return to the last page. +

+

+ You might also be trying to view a page that you do not have access to. +

+ +

+ {{ govukButton({ + text: "Continue", + href: url + }) }} +

+
+
+{% endblock %} \ No newline at end of file diff --git a/server/views/pages/arrivingToday.njk b/server/views/pages/arrivingToday.njk new file mode 100644 index 0000000..a5850ab --- /dev/null +++ b/server/views/pages/arrivingToday.njk @@ -0,0 +1,60 @@ +{% extends "../partials/layout.njk" %} +{% from "../macros/alertFlags.njk" import alertFlags %} +{% from "../macros/categoryFlag.njk" import categoryFlag %} + +{% set pageTitle = "Arrived today" %} +{% set mainClasses = "govuk-body govuk-main-wrapper--auto-spacing" %} + +{% set breadCrumbs = [ + { + text: 'Digital Prison Services', + href: dpsUrl + }, + { + text: 'Establishment roll', + href: '/' + } +] %} + +{% block content %} +
+
+

{{ pageTitle }}

+
+ +
+
+ + + + + + + + + + + + + + + {% for prisoner in prisoners %} + {% set prisonerName = prisoner.firstName | formatName("", prisoner.lastName, { style: 'lastCommaFirst' }) %} + + + + + + + + + + + {% endfor %} + + +
PictureNamePrison numberDate of birthLocationTime arrivedArrived fromAlert flags
Image of {{ prisonerName }}{{ prisonerName }}{{ prisoner.prisonerNumber }}{{ prisoner.dateOfBirth | formatDate('short')}}{{ prisoner.cellLocation }}{{ prisoner.movementTime | formatTime}}{{ prisoner.arrivedFrom }}{{ alertFlags(prisoner.alertFlags) }}{{categoryFlag("", prisoner.category) | safe}}
+
+
+
+{% endblock %} diff --git a/server/views/pages/currentlyOut.njk b/server/views/pages/currentlyOut.njk new file mode 100644 index 0000000..43537ae --- /dev/null +++ b/server/views/pages/currentlyOut.njk @@ -0,0 +1,60 @@ +{% extends "../partials/layout.njk" %} +{% from "../macros/alertFlags.njk" import alertFlags %} +{% from "../macros/categoryFlag.njk" import categoryFlag %} + +{% set pageTitle = "Currently out - " + locationName if locationName else "Total currently out"%} +{% set mainClasses = "govuk-body govuk-main-wrapper--auto-spacing" %} + +{% set breadCrumbs = [ + { + text: 'Digital Prison Services', + href: dpsUrl + }, + { + text: 'Establishment roll', + href: '/' + } +] %} + +{% block content %} +
+
+

{{ pageTitle }}

+
+ +
+
+ + + + + + + + + + + + + + + {% for prisoner in prisoners %} + {% set prisonerName = prisoner.firstName | formatName("", prisoner.lastName, { style: 'lastCommaFirst' }) %} + + + + + + + + + + + {% endfor %} + + +
PictureNamePrison numberDate of birthLocationAlert flagsCurrent locationComment
Image of {{ prisonerName }}{{ prisonerName }}{{ prisoner.prisonerNumber }}{{ prisoner.dateOfBirth | formatDate('short')}}{{ prisoner.cellLocation }}{{ alertFlags(prisoner.alertFlags) }}{{categoryFlag("", prisoner.category) | safe}}{{ prisoner.currentLocation }}{{ prisoner.movementComment }}
+
+
+
+{% endblock %} diff --git a/server/views/pages/enRoute.njk b/server/views/pages/enRoute.njk new file mode 100644 index 0000000..bdf1620 --- /dev/null +++ b/server/views/pages/enRoute.njk @@ -0,0 +1,60 @@ +{% extends "../partials/layout.njk" %} +{% from "../macros/alertFlags.njk" import alertFlags %} +{% from "../macros/categoryFlag.njk" import categoryFlag %} + +{% set pageTitle = "En route to " + prison %} +{% set mainClasses = "govuk-body govuk-main-wrapper--auto-spacing" %} + +{% set breadCrumbs = [ + { + text: 'Digital Prison Services', + href: dpsUrl + }, + { + text: 'Establishment roll', + href: '/' + } +] %} + +{% block content %} +
+
+

{{ pageTitle }}

+
+ +
+
+ + + + + + + + + + + + + + + {% for prisoner in prisoners %} + {% set prisonerName = prisoner.firstName | formatName("", prisoner.lastName, { style: 'lastCommaFirst' }) %} + + + + + + + + + + + {% endfor %} + + +
PictureNamePrison numberDate of birthDepartedEn route fromReasonAlert flags
Image of {{ prisonerName }}{{ prisonerName }}{{ prisoner.prisonerNumber }}{{ prisoner.dateOfBirth | formatDate('short')}}{{ prisoner.movementTime | formatTime }}
{{ prisoner.movementDate | formatDate('short')}}
{{ prisoner.from }}{{ prisoner.reason}}{{ alertFlags(prisoner.alertFlags) }}{{categoryFlag("", prisoner.category) | safe}}
+
+
+
+{% endblock %} diff --git a/server/views/pages/establishmentRoll.njk b/server/views/pages/establishmentRoll.njk index 72830a2..f863cc7 100644 --- a/server/views/pages/establishmentRoll.njk +++ b/server/views/pages/establishmentRoll.njk @@ -2,7 +2,6 @@ {% from "../macros/printLink.njk" import printLink %} {% from "../macros/establishmentRollStat.njk" import establishmentRollStat %} -{% set pageTitle = "Establishment roll" %} {% set mainClasses = "govuk-body govuk-main-wrapper--auto-spacing" %} {% set todayStats = establishmentRollCounts.todayStats %} {% set totals = establishmentRollCounts.totals %} @@ -11,7 +10,7 @@ {% set breadCrumbs = [ { text: 'Digital Prison Services', - href: '/' + href: dpsUrl } ] %} @@ -22,7 +21,7 @@ > {% if type === "LANDING" %} - {{ block.localName or block.locationCode }} + {{ block.localName or block.locationCode }} {% else %} {{ block.localName or block.locationCode }} {% endif %} @@ -31,7 +30,7 @@ {{ block.rollCount.currentlyInCell }} {% if block.rollCount.currentlyOut > 0 %} - {{block.rollCount.currentlyOut}} + {{block.rollCount.currentlyOut}} {% else %} 0 {% endif %} {{ block.rollCount.workingCapacity }} @@ -43,7 +42,7 @@ {% block content %}
-

{{ pageTitle }} for {{ date | formatDate('full') }}

+

Establishment roll for {{ date | formatDate('full') }}

{{ printLink(align = "right") }}
@@ -73,7 +72,7 @@ establishmentRollStat( heading = "Arrived today", value = todayStats.inToday, - href = "/establishment-roll/arrived-today", + href = "/arrived-today", qaTag = "in-today" ) }} @@ -84,7 +83,7 @@ establishmentRollStat( heading = "In reception", value = todayStats.unassignedIn, - href = "/establishment-roll/in-reception", + href = "/in-reception", qaTag = "unassigned-in" ) }} @@ -95,7 +94,7 @@ establishmentRollStat( heading = "Still to arrive", value = todayStats.enroute, - href = "/establishment-roll/en-route", + href = "/en-route", qaTag = "enroute" ) }} @@ -107,7 +106,7 @@ establishmentRollStat( heading = "Out today", value = todayStats.outToday, - href = "/establishment-roll/out-today", + href = "/out-today", qaTag = "out-today" ) }} @@ -118,7 +117,7 @@ establishmentRollStat( heading = "No cell allocated", value = todayStats.noCellAllocated, - href = "/establishment-roll/no-cell-allocated", + href = "/no-cell-allocated", qaTag = "no-cell-allocated" ) }} @@ -161,7 +160,7 @@ {{ totals.currentlyInCell }} {% if totals.currentlyOut > 0 %} - {{totals.currentlyOut}} + {{totals.currentlyOut}} {% else %} 0 {% endif %} {{ totals.workingCapacity }} @@ -175,4 +174,4 @@ {% block pageScripts %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/server/views/pages/establishmentRollLanding.njk b/server/views/pages/establishmentRollLanding.njk new file mode 100644 index 0000000..b4ea48c --- /dev/null +++ b/server/views/pages/establishmentRollLanding.njk @@ -0,0 +1,63 @@ +{% extends "../partials/layout.njk" %} +{% from "../macros/printLink.njk" import printLink %} +{% from "../macros/establishmentRollStat.njk" import establishmentRollStat %} + +{% set pageTitle = wingName + " - " + (spurName + " - " if spurName else "") + landingName %} +{% set mainClasses = "govuk-body govuk-main-wrapper--auto-spacing" %} +{% set todayStats = establishmentRollCounts.todayStats %} +{% set capacityLabel = "Working capacity" if useWorkingCapacity else "Operational capacity" %} + +{% set breadCrumbs = [ + { + text: 'Digital Prison Services', + href: dpsUrl + }, + { + text: 'Establishment roll', + href: '/' + } +] %} + + +{% block content %} +
+
+

{{ pageTitle }}

+ {{ printLink(align = "right") }} +
+ +
+
+ + + + + + + + + + + + + {% for cell in cellRollCounts %} + + + + + + + + + {% endfor %} + + +
Beds in useCurrently in cellCurrently out{{ capacityLabel }}Net vacancies
{{ cell.localName or cell.locationCode }}{{ cell.rollCount.bedsInUse }}{{ cell.rollCount.currentlyInCell }} + {% if cell.rollCount.currentlyOut > 0 %} + {{cell.rollCount.currentlyOut}} + {% else %} 0 {% endif %} + {{ cell.rollCount.workingCapacity }}{{ cell.rollCount.netVacancies }}
+
+
+
+{% endblock %} diff --git a/server/views/pages/inReception.njk b/server/views/pages/inReception.njk new file mode 100644 index 0000000..dc75b02 --- /dev/null +++ b/server/views/pages/inReception.njk @@ -0,0 +1,58 @@ +{% extends "../partials/layout.njk" %} +{% from "../macros/alertFlags.njk" import alertFlags %} +{% from "../macros/categoryFlag.njk" import categoryFlag %} + +{% set pageTitle = "In reception"%} +{% set mainClasses = "govuk-body govuk-main-wrapper--auto-spacing" %} + +{% set breadCrumbs = [ + { + text: 'Digital Prison Services', + href: dpsUrl + }, + { + text: 'Establishment roll', + href: '/' + } +] %} + +{% block content %} +
+
+

{{ pageTitle }}

+
+ +
+
+ + + + + + + + + + + + + + {% for prisoner in prisoners %} + {% set prisonerName = prisoner.firstName | formatName("", prisoner.lastName, { style: 'lastCommaFirst' }) %} + + + + + + + + + + {% endfor %} + + +
PictureNamePrison numberDate of birthTime arrivedArrived fromAlert flags
Image of {{ prisonerName }}{{ prisonerName }}{{ prisoner.prisonerNumber }}{{ prisoner.dateOfBirth | formatDate('short')}}{{ prisoner.timeArrived | formatTime }}{{ prisoner.from }}{{ alertFlags(prisoner.alertFlags) }}{{categoryFlag("", prisoner.category) | safe}}
+
+
+
+{% endblock %} diff --git a/server/views/pages/noCellAllocated.njk b/server/views/pages/noCellAllocated.njk new file mode 100644 index 0000000..5423f33 --- /dev/null +++ b/server/views/pages/noCellAllocated.njk @@ -0,0 +1,73 @@ +{% extends "../partials/layout.njk" %} + +{% set pageTitle = "No cell allocated"%} +{% set mainClasses = "govuk-body govuk-main-wrapper--auto-spacing" %} + +{% set breadCrumbs = [ + { + text: 'Digital Prison Services', + href: dpsUrl + }, + { + text: 'Establishment roll', + href: '/' + } +] %} + +{% block content %} +
+ {% if prisoners.length %} +
+
+

{{ pageTitle }}

+

These people have been moved out of their cell to create a space for someone else and do not currently have a cell allocated.

+
+
+ +
+
+ + + + + + + + + + {% if userCanAllocateCell %} + + {% endif %} + + + + {% for prisoner in prisoners %} + {% set prisonerName = prisoner.firstName | formatName("", prisoner.lastName, { style: 'lastCommaFirst' }) %} + + + + + + + + {% if userCanAllocateCell %} + + {% endif %} + + {% endfor %} + +
PictureNamePrisoner numberPrevious cellTime moved outMoved out byAllocate
Image of {{ prisonerName }}{{ prisonerName }}{{ prisoner.prisonerNumber }}{{ prisoner.previousCell }}{{ prisoner.timeOut | timeFromDate }}{{ prisoner.movedBy }} + Allocate cell +
+
+
+ {% else %} +
+
+

{{ pageTitle }}

+

There are no prisoners without a cell.

+
+
+ {% endif %} +
+{% endblock %} diff --git a/server/views/pages/outToday.njk b/server/views/pages/outToday.njk new file mode 100644 index 0000000..306fdbc --- /dev/null +++ b/server/views/pages/outToday.njk @@ -0,0 +1,58 @@ +{% extends "../partials/layout.njk" %} +{% from "../macros/alertFlags.njk" import alertFlags %} +{% from "../macros/categoryFlag.njk" import categoryFlag %} + +{% set pageTitle = "Out today" %} +{% set mainClasses = "govuk-body govuk-main-wrapper--auto-spacing" %} + +{% set breadCrumbs = [ + { + text: 'Digital Prison Services', + href: dpsUrl + }, + { + text: 'Establishment roll', + href: '/' + } +] %} + +{% block content %} +
+
+

{{ pageTitle }}

+
+ +
+
+ + + + + + + + + + + + + + {% for prisoner in prisoners %} + {% set prisonerName = prisoner.firstName | formatName("", prisoner.lastName, { style: 'lastCommaFirst' }) %} + + + + + + + + + + {% endfor %} + + +
PictureNamePrison numberDate of birthTime outReasonAlert flags
Image of {{ prisonerName }}{{ prisonerName }}{{ prisoner.prisonerNumber }}{{ prisoner.dateOfBirth | formatDate('short')}}{{ prisoner.timeOut | formatTime }}{{ prisoner.reasonDescription}}{{ alertFlags(prisoner.alertFlags) }}{{categoryFlag("", prisoner.category) | safe}}
+
+
+
+{% endblock %} diff --git a/server/views/partials/layout.njk b/server/views/partials/layout.njk index 65779e3..601fefe 100644 --- a/server/views/partials/layout.njk +++ b/server/views/partials/layout.njk @@ -1,18 +1,62 @@ +{% from "govuk/components/breadcrumbs/macro.njk" import govukBreadcrumbs %} {% extends "govuk/template.njk" %} {% 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 %} +{% block pageTitle %}{{ pageTitle + ' - ' + applicationName if pageTitle else applicationName }}{% endblock %} {% block header %} - {% include "./header.njk" %} + {% if feComponents.header %} + {{ feComponents.header | safe }} + {% else %} + {% include "./header.njk" %} + {% endif %} +{% endblock %} + +{% block beforeContent %} + {{ govukBreadcrumbs({ + items: breadCrumbs | default([]), + classes: 'govuk-!-display-none-print' + }) }} {% endblock %} {% block bodyStart %} {% endblock %} {% block bodyEnd %} - + + + + + + {% block pageScripts %} + {% endblock %} +{% endblock %} + +{% block footer %} + {{ feComponents.footer | safe }} {% endblock %}