From 388fd63abe13abb999bdb26f1e5061d2677a0f3b Mon Sep 17 00:00:00 2001 From: Dani Burnley Date: Wed, 22 Jan 2025 11:10:52 +0000 Subject: [PATCH] CDPS-1085: Create dietary requirements page (#185) * CDPS-1085: Create dietary requirements page * CDPS-1085: Add role check to dietary requirements page * CDPS-1085: Linting * CDPS-1085: Fix integration tests * CDPS-1085: Print page * CDPS-1085: Print page role protection * CDPS-1085: Update layout --- assets/scss/application.scss | 2 + assets/scss/components/_pagination.scss | 52 +++++ assets/scss/components/_print-link.scss | 2 +- assets/scss/components/_sortable-table.scss | 59 ++++++ .../e2e/dieteryRequirements.cy.ts | 55 +++++ .../pages/dietaryRequirements.ts | 23 +++ .../dietaryRequirementsController.ts | 152 ++++++++++++++ server/enums/role.ts | 1 + server/routes/dietaryRequirementsRouter.ts | 21 ++ server/routes/index.ts | 2 + server/utils/generateListMetadata.test.ts | 98 +++++++++ server/utils/generateListMetadata.ts | 191 ++++++++++++++++++ server/utils/nunjucksSetup.ts | 2 + server/utils/pluralise.test.ts | 33 +++ server/utils/pluralise.ts | 46 +++++ server/views/macros/hmppsPagedListFooter.njk | 14 ++ server/views/macros/hmppsPagedListHeader.njk | 17 ++ .../views/macros/hmppsPaginationSummary.njk | 10 + server/views/macros/hmppsSortSelector.njk | 36 ++++ server/views/macros/printLink.njk | 2 +- server/views/pages/dietaryRequirements.njk | 107 ++++++++++ .../views/pages/printDietaryRequirements.njk | 94 +++++++++ 22 files changed, 1017 insertions(+), 2 deletions(-) create mode 100644 assets/scss/components/_pagination.scss create mode 100644 assets/scss/components/_sortable-table.scss create mode 100644 integration_tests/e2e/dieteryRequirements.cy.ts create mode 100644 integration_tests/pages/dietaryRequirements.ts create mode 100644 server/controllers/dietaryRequirementsController.ts create mode 100644 server/routes/dietaryRequirementsRouter.ts create mode 100644 server/utils/generateListMetadata.test.ts create mode 100644 server/utils/generateListMetadata.ts create mode 100644 server/utils/pluralise.test.ts create mode 100644 server/utils/pluralise.ts create mode 100644 server/views/macros/hmppsPagedListFooter.njk create mode 100644 server/views/macros/hmppsPagedListHeader.njk create mode 100644 server/views/macros/hmppsPaginationSummary.njk create mode 100644 server/views/macros/hmppsSortSelector.njk create mode 100644 server/views/pages/dietaryRequirements.njk create mode 100644 server/views/pages/printDietaryRequirements.njk diff --git a/assets/scss/application.scss b/assets/scss/application.scss index 37c4d69..d77cd76 100755 --- a/assets/scss/application.scss +++ b/assets/scss/application.scss @@ -21,6 +21,8 @@ $govuk-page-width: $moj-page-width; @import './components/print-link'; @import 'components/alert-flags'; @import './components/results-table'; +@import './components/pagination'; +@import './components/sortable-table'; @import './pages/help'; @import './pages/homepage'; @import './pages/managed-page'; diff --git a/assets/scss/components/_pagination.scss b/assets/scss/components/_pagination.scss new file mode 100644 index 0000000..a3399f8 --- /dev/null +++ b/assets/scss/components/_pagination.scss @@ -0,0 +1,52 @@ +.hmpps-pagination-view-all { + text-align: right; + margin: 15px 0 7px 0; +} + +.moj-pagination__results { + padding: 5px 0; +} + +.hmpps-paged-list-header { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + padding-bottom: 30px; + margin-bottom: 30px; + + .moj-pagination { + margin-top: 30px; + margin-left: 0; + margin-right: 0; + } + + p.moj-pagination__results { + margin-left: -5px; + } +} + +.hmpps-paged-list-footer { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 30px; + + &__no-pagination { + justify-content: flex-end; + } + + .moj-pagination { + margin-left: 0; + margin-right: 0; + } + + p.moj-pagination__results { + margin-left: -5px; + } +} + +.hmpps-paged-list-header__with-divider { + border-bottom: 1px solid govuk-colour('dark-grey'); +} \ No newline at end of file diff --git a/assets/scss/components/_print-link.scss b/assets/scss/components/_print-link.scss index 4fab87d..c3b836d 100644 --- a/assets/scss/components/_print-link.scss +++ b/assets/scss/components/_print-link.scss @@ -1,4 +1,4 @@ -.print-link { +.hmpps-print-link { display: inline-block; margin: 0 0 15px -10px; position: relative; 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/integration_tests/e2e/dieteryRequirements.cy.ts b/integration_tests/e2e/dieteryRequirements.cy.ts new file mode 100644 index 0000000..722685e --- /dev/null +++ b/integration_tests/e2e/dieteryRequirements.cy.ts @@ -0,0 +1,55 @@ +import { Role } from '../../server/enums/role' +import DietaryRequirementsPage from '../pages/dietaryRequirements' +import Page from '../pages/page' + +context('Currently Out Page', () => { + beforeEach(() => { + cy.task('reset') + cy.setupUserAuth({ roles: [`ROLE_PRISON`, `ROLE_${Role.GlobalSearch}`, `ROLE_${Role.DpsApplicationDeveloper}`] }) + cy.setupComponentsData() + }) + + it('Page is visible for mock data', () => { + cy.signIn({ redirectPath: `/dietary-requirements/LEI` }) + cy.visit(`/dietary-requirements/LEI`) + Page.verifyOnPage(DietaryRequirementsPage) + }) + + it('Displays the prisoner information', () => { + cy.signIn({ redirectPath: `/dietary-requirements/LEI` }) + cy.visit(`/dietary-requirements/LEI`) + const page = Page.verifyOnPage(DietaryRequirementsPage) + page.dietaryRequirements().row(0).nameAndPrisonNumber().should('include.text', 'Richard Smith') + page.dietaryRequirements().row(0).nameAndPrisonNumber().should('include.text', 'G4879UP') + page.dietaryRequirements().row(0).location().should('include.text', 'C-3-010') + page.dietaryRequirements().row(0).dietaryRequirements().medical().should('include.text', 'Nutrient Deficiency') + page.dietaryRequirements().row(0).dietaryRequirements().foodAllergies().should('include.text', 'Egg') + page.dietaryRequirements().row(2).dietaryRequirements().personal().should('include.text', 'Kosher') + }) + + context('Sorting', () => { + it('Defaults to no sorting', () => { + cy.signIn({ redirectPath: `/dietary-requirements/LEI` }) + cy.visit(`/dietary-requirements/LEI`) + const page = Page.verifyOnPage(DietaryRequirementsPage) + page.dietaryRequirements().sorting().nameAndNumber().should('have.attr', 'aria-sort', 'none') + page.dietaryRequirements().sorting().location().should('have.attr', 'aria-sort', 'none') + }) + + it('Allows sorting', () => { + cy.signIn({ redirectPath: `/dietary-requirements/LEI` }) + cy.visit(`/dietary-requirements/LEI`) + const page = Page.verifyOnPage(DietaryRequirementsPage) + page.dietaryRequirements().sorting().nameAndNumber().click() + page.dietaryRequirements().sorting().nameAndNumber().should('have.attr', 'aria-sort', 'ascending') + page.dietaryRequirements().sorting().location().should('have.attr', 'aria-sort', 'none') + page.dietaryRequirements().sorting().nameAndNumber().click() + page.dietaryRequirements().sorting().nameAndNumber().should('have.attr', 'aria-sort', 'descending') + page.dietaryRequirements().sorting().location().click() + page.dietaryRequirements().sorting().nameAndNumber().should('have.attr', 'aria-sort', 'none') + page.dietaryRequirements().sorting().location().should('have.attr', 'aria-sort', 'ascending') + page.dietaryRequirements().sorting().location().click() + page.dietaryRequirements().sorting().location().should('have.attr', 'aria-sort', 'descending') + }) + }) +}) diff --git a/integration_tests/pages/dietaryRequirements.ts b/integration_tests/pages/dietaryRequirements.ts new file mode 100644 index 0000000..5105fd4 --- /dev/null +++ b/integration_tests/pages/dietaryRequirements.ts @@ -0,0 +1,23 @@ +import Page from './page' + +export default class DietaryRequirementsPage extends Page { + constructor() { + super(`People with dietary requirements in Prison Name`) + } + + dietaryRequirements = () => ({ + row: (i: number) => ({ + nameAndPrisonNumber: () => cy.get('table tbody tr').eq(i).find('td').eq(0), + location: () => cy.get('table tbody tr').eq(i).find('td').eq(1), + dietaryRequirements: () => ({ + medical: () => cy.get('table tbody tr').eq(i).find('td').eq(2).find('[data-qa="medical-requirements"]'), + foodAllergies: () => cy.get('table tbody tr').eq(i).find('td').eq(2).find('[data-qa="food-allergies"]'), + personal: () => cy.get('table tbody tr').eq(i).find('td').eq(2).find('[data-qa="personal-requirements"]'), + }), + }), + sorting: () => ({ + nameAndNumber: () => cy.get('table thead th').eq(0), + location: () => cy.get('table thead th').eq(1), + }), + }) +} diff --git a/server/controllers/dietaryRequirementsController.ts b/server/controllers/dietaryRequirementsController.ts new file mode 100644 index 0000000..22b0495 --- /dev/null +++ b/server/controllers/dietaryRequirementsController.ts @@ -0,0 +1,152 @@ +import { Request, RequestHandler, Response } from 'express' +import { format } from 'date-fns' +import { DietaryRequirementsQueryParams, generateListMetadata, mapToQueryString } from '../utils/generateListMetadata' +import { userHasRoles } from '../utils/utils' +import { Role } from '../enums/role' + +export default class DietaryRequirementsController { + private content = [ + { + name: 'Richard Smith', + prisonerNumber: 'G4879UP', + location: 'C-3-010', + dietaryRequirements: { + medical: ['Coeliac (cannot eat gluten)', 'Nutrient Deficiency', 'Other: has to eat a low copper diet'], + foodAllergies: ['Egg', 'Other: broccoli allergy'], + personal: [] as string[], + }, + }, + { + name: 'George Harrison', + prisonerNumber: 'G6333VK', + location: 'B-1-042', + dietaryRequirements: { + medical: [], + foodAllergies: ['Sesame'], + personal: [], + }, + }, + { + name: 'Harry Thompson', + prisonerNumber: 'G3101UO', + location: 'F-5-031', + dietaryRequirements: { + medical: [], + foodAllergies: [], + personal: ['Kosher'], + }, + }, + ] + + public get(): RequestHandler { + return async (req: Request, res: Response) => { + if (!userHasRoles([Role.DpsApplicationDeveloper], res.locals.user.userRoles)) { + return res.render('notFound', { url: '/' }) + } + + const queryParams: DietaryRequirementsQueryParams = {} + if (req.query.page) queryParams.page = +req.query.page + if (req.query.showAll) queryParams.showAll = Boolean(req.query.showAll) + if (req.query.nameAndNumber) queryParams.nameAndNumber = req.query.nameAndNumber as string + if (req.query.location) queryParams.location = req.query.location as string + + const sortNameQuery = () => { + let direction = 'ASC' + + if (queryParams.nameAndNumber && queryParams.nameAndNumber === 'ASC') { + direction = 'DESC' + } + + return mapToQueryString({ ...queryParams, nameAndNumber: direction, location: null }) + } + + const sortLocationQuery = () => { + let direction = 'ASC' + + if (queryParams.location && queryParams.location === 'ASC') { + direction = 'DESC' + } + + return mapToQueryString({ ...queryParams, location: direction, nameAndNumber: null }) + } + + const sortParamToDirection = (param: string) => { + switch (param) { + case 'ASC': + return 'ascending' + case 'DESC': + return 'descending' + default: + return 'none' + } + } + + const sorting = { + nameAndNumber: { + direction: sortParamToDirection(queryParams.nameAndNumber as string), + url: `/dietary-requirements/${req.params.locationId}?${sortNameQuery()}`, + }, + location: { + direction: sortParamToDirection(queryParams.location as string), + url: `/dietary-requirements/${req.params.locationId}?${sortLocationQuery()}`, + }, + } + + // Remove page as this comes from the API + delete queryParams.page + + const listMetadata = generateListMetadata( + { + content: this.content, + totalElements: 200, + last: false, + totalPages: 10, + size: 10, + number: 0, + sort: { empty: false, sorted: false, unsorted: true }, + first: false, + numberOfElements: 20, + empty: false, + pageable: { + pageNumber: 5, + pageSize: 20, + sort: { + empty: true, + sorted: false, + unsorted: true, + }, + offset: 0, + unpaged: false, + paged: true, + }, + }, + queryParams, + 'result', + [], + '', + true, + ) + + return res.render('pages/dietaryRequirements', { + content: this.content, + listMetadata, + sorting, + locationId: req.params.locationId, + }) + } + } + + public printAll(): RequestHandler { + return async (req: Request, res: Response) => { + if (!userHasRoles([Role.DpsApplicationDeveloper], res.locals.user.userRoles)) { + return res.render('notFound', { url: '/' }) + } + + return res.render('pages/printDietaryRequirements', { + date: format(new Date(), 'cccc c MMMM yyyy'), + content: this.content, + locationId: req.params.locationId, + }) + } + } +} diff --git a/server/enums/role.ts b/server/enums/role.ts index cf27db3..1392944 100644 --- a/server/enums/role.ts +++ b/server/enums/role.ts @@ -25,4 +25,5 @@ export enum Role { ReceptionUser = 'PRISON_RECEPTION', CellMove = 'CELL_MOVE', KeyWorker = 'KW', + DpsApplicationDeveloper = 'DPS_APPLICATION_DEVELOPER', } diff --git a/server/routes/dietaryRequirementsRouter.ts b/server/routes/dietaryRequirementsRouter.ts new file mode 100644 index 0000000..dedc857 --- /dev/null +++ b/server/routes/dietaryRequirementsRouter.ts @@ -0,0 +1,21 @@ +import { RequestHandler, Router } from 'express' +import { Services } from '../services' +import asyncMiddleware from '../middleware/asyncMiddleware' +import DietaryRequirementsController from '../controllers/dietaryRequirementsController' + +export default function dietaryRequirementsRouter(_services: Services): Router { + const router = Router() + + const get = (path: string | string[], ...handlers: RequestHandler[]) => + router.get( + path, + handlers.map(handler => asyncMiddleware(handler)), + ) + + const dietaryRequirementsController = new DietaryRequirementsController() + + get('/:locationId', dietaryRequirementsController.get()) + get('/:locationId/print-all', dietaryRequirementsController.printAll()) + + return router +} diff --git a/server/routes/index.ts b/server/routes/index.ts index 104a8e5..78b77a9 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -6,6 +6,7 @@ import whatsNewRouter from './whatsNewRouter' import managedPageRouter from './managedPageRouter' import establishmentRollRouter from './establishmentRollRouter' import apiRouter from './apiRouter' +import dietaryRequirementsRouter from './dietaryRequirementsRouter' export default function routes(services: Services): Router { const router = Router() @@ -32,6 +33,7 @@ export default function routes(services: Services): Router { router.use('/establishment-roll', establishmentRollRouter(services)) router.use('/whats-new', whatsNewRouter(services)) router.use('/api', apiRouter()) + router.use('/dietary-requirements', dietaryRequirementsRouter(services)) return router } diff --git a/server/utils/generateListMetadata.test.ts b/server/utils/generateListMetadata.test.ts new file mode 100644 index 0000000..8039402 --- /dev/null +++ b/server/utils/generateListMetadata.test.ts @@ -0,0 +1,98 @@ +import { PagedList } from '../data/interfaces/pagedList' +import { generateListMetadata, QueryParams } from './generateListMetadata' + +export const mockPagedData = ( + content: T[], + options?: { totalPages?: number; pageNumber?: number }, +): PagedList => ({ + content, + totalElements: content.length, + last: options?.pageNumber === (options?.totalPages ?? 1) - 1, + totalPages: options?.totalPages ?? 1, + size: content.length, + number: 0, + sort: { empty: false, sorted: false, unsorted: true }, + first: (options?.pageNumber ?? 0) === 0, + numberOfElements: content.length, + empty: content.length === 0, + pageable: { + pageNumber: options?.pageNumber ?? 0, + pageSize: 20, + sort: { + empty: true, + sorted: false, + unsorted: true, + }, + offset: 0, + unpaged: false, + paged: true, + }, +}) + +describe('generateListMetadata', () => { + it('Can handle 0 pages', () => { + const res = generateListMetadata(mockPagedData([], { totalPages: 0 }), { example: 'foo' }, 'items') + expect(res.pagination.pages).toEqual([]) + }) + + it('Can handle a single page', () => { + const res = generateListMetadata(mockPagedData([], { totalPages: 1 }), { example: 'foo' }, 'items') + expect(res.pagination.pages).toEqual([]) + }) + + it('Can handle multiple pages that would not require elipses', () => { + const res = generateListMetadata(mockPagedData([], { totalPages: 3 }), { example: 'foo' }, 'items') + expect(res.pagination.pages).toEqual([ + { href: '?page=1&example=foo', selected: true, text: '1' }, + { href: '?page=2&example=foo', selected: false, text: '2' }, + { href: '?page=3&example=foo', selected: false, text: '3' }, + ]) + }) + + it('Can handle multiple pages that would require elipses at the end', () => { + const res = generateListMetadata(mockPagedData([], { totalPages: 10 }), { example: 'foo' }, 'items') + expect(res.pagination.next).toEqual({ href: '?page=2&example=foo', text: 'Next' }) + expect(res.pagination.previous).toBeUndefined() + expect(res.pagination.pages).toEqual([ + { href: '?page=1&example=foo', selected: true, text: '1' }, + { href: '?page=2&example=foo', selected: false, text: '2' }, + { text: '...', type: 'dots' }, + { href: '?page=10&example=foo', selected: false, text: '10' }, + ]) + }) + + it('Can handle multiple pages that would require elipses at the start', () => { + const res = generateListMetadata( + mockPagedData([], { pageNumber: 9, totalPages: 10 }), + { example: 'foo' }, + 'items', + ) + expect(res.pagination.next).toBeUndefined() + expect(res.pagination.previous).toEqual({ href: '?page=9&example=foo', text: 'Previous' }) + expect(res.pagination.pages).toEqual([ + { href: '?page=1&example=foo', selected: false, text: '1' }, + { text: '...', type: 'dots' }, + { href: '?page=9&example=foo', selected: false, text: '9' }, + { href: '?page=10&example=foo', selected: true, text: '10' }, + ]) + }) + + it('Can handle multiple pages that would require elipses surrounding the current', () => { + const res = generateListMetadata( + mockPagedData([], { pageNumber: 4, totalPages: 10 }), + { example: 'foo' }, + 'items', + ) + expect(res.pagination.next).toEqual({ href: '?page=6&example=foo', text: 'Next' }) + expect(res.pagination.previous).toEqual({ href: '?page=4&example=foo', text: 'Previous' }) + expect(res.pagination.pages).toEqual([ + { href: '?page=1&example=foo', selected: false, text: '1' }, + { text: '...', type: 'dots' }, + { href: '?page=4&example=foo', selected: false, text: '4' }, + { href: '?page=5&example=foo', selected: true, text: '5' }, + { href: '?page=6&example=foo', selected: false, text: '6' }, + { text: '...', type: 'dots' }, + { href: '?page=10&example=foo', selected: false, text: '10' }, + ]) + }) +}) diff --git a/server/utils/generateListMetadata.ts b/server/utils/generateListMetadata.ts new file mode 100644 index 0000000..a94a829 --- /dev/null +++ b/server/utils/generateListMetadata.ts @@ -0,0 +1,191 @@ +import { PagedList } from '../data/interfaces/pagedList' + +export type QueryParamValue = string | number | boolean +export type QueryParams = Record +export interface PagedListQueryParams extends QueryParams { + page?: number + size?: number + sort?: string + showAll?: boolean +} + +export interface DietaryRequirementsQueryParams extends PagedListQueryParams { + nameAndNumber?: string + location?: string +} + +export interface PagedListItem { + // Extended by: + // Dietary Requirement +} + +export interface SortOption { + value: string + description: string +} + +export interface SortParams { + id: string + label: string + options: SortOption[] + sort: string + queryParams: QueryParams +} + +export interface ListMetadata { + filtering: { + queryParams?: { [key: string]: string | number | boolean } + } & TGeneric + sorting?: SortParams + pagination: { + itemDescription: string + previous: { href: string; text: string } + next: { href: string; text: string } + page: number + offset: number + pageSize: number + totalPages: number + totalElements: number + elementsOnPage: number + pages: { href: string; text: string; selected: boolean; type?: string }[] + viewAllUrl?: string + } +} + +export const arrayToQueryString = (array: QueryParamValue[], key: string): string => + array && array.map(item => `${key}=${encodeURIComponent(item)}`).join('&') + +export const mapToQueryString = (params: QueryParams): string => { + if (!params) return '' + return Object.keys(params) + .filter(key => params[key] !== undefined && params[key] !== null) + .map(key => { + if (Array.isArray(params[key])) return arrayToQueryString(params[key], key) + return `${key}=${encodeURIComponent(params[key])}` + }) + .join('&') +} + +/** + * Generate metadata for list pages, including pagination, sorting, filtering + * + * For the current page and pages array, the value is incremented by 1 as the API uses a zero based array + * but users expect page numbers in url, etc to be one based. + * + * @param pagedList + * @param queryParams + * @param itemDescription + * @param sortOptions + * @param sortLabel + */ +export const generateListMetadata = ( + pagedList: PagedList, + queryParams: T, + itemDescription: string, + sortOptions?: SortOption[], + sortLabel?: string, + enableShowAll?: boolean, +): ListMetadata => { + const query = mapToQueryString(queryParams) + const currentPage = pagedList?.pageable ? pagedList.pageable.pageNumber + 1 : undefined + + let pages = [] + + if (pagedList?.totalPages > 1 && pagedList?.totalPages < 8) { + pages = [...Array(pagedList.totalPages).keys()].map(page => { + return { + text: `${page + 1}`, + href: [`?page=${page + 1}`, query].filter(Boolean).join('&'), + selected: currentPage === page + 1, + } + }) + } else if (pagedList?.totalPages > 7) { + pages.push({ + text: '1', + href: [`?page=1`, query].filter(Boolean).join('&'), + selected: currentPage === 1, + }) + + const pageRange = [currentPage - 1, currentPage, currentPage + 1] + let preDots = false + let postDots = false + // eslint-disable-next-line no-plusplus + for (let i = 2; i < pagedList.totalPages; i++) { + if (pageRange.includes(i)) { + pages.push({ + text: `${i}`, + href: [`?page=${i}`, query].filter(Boolean).join('&'), + selected: currentPage === i, + }) + } else if (i < pageRange[0] && !preDots) { + pages.push({ + text: '...', + type: 'dots', + }) + preDots = true + } else if (i > pageRange[2] && !postDots) { + pages.push({ + text: '...', + type: 'dots', + }) + postDots = true + } + } + + pages.push({ + text: `${pagedList.totalPages}`, + href: [`?page=${pagedList.totalPages}`, query].filter(Boolean).join('&'), + selected: currentPage === pagedList.totalPages, + }) + } + + const next = pagedList?.last + ? undefined + : { + href: [`?page=${currentPage + 1}`, query].filter(Boolean).join('&'), + text: 'Next', + } + + const previous = pagedList?.first + ? undefined + : { + href: [`?page=${currentPage - 1}`, query].filter(Boolean).join('&'), + text: 'Previous', + } + + const viewAllUrl = [`?${mapToQueryString(queryParams)}`, 'showAll=true'].filter(Boolean).join('&') + + return >{ + filtering: { + ...queryParams, + queryParams: { sort: queryParams.sort }, + }, + sorting: + sortOptions && sortLabel + ? { + id: 'sort', + label: sortLabel, + options: sortOptions, + sort: queryParams.sort, + queryParams: { + ...queryParams, + sort: undefined, + }, + } + : null, + pagination: { + itemDescription, + previous, + next, + page: currentPage, + offset: pagedList?.pageable?.offset, + pageSize: pagedList?.size, + totalPages: pagedList?.totalPages, + totalElements: pagedList?.totalElements, + elementsOnPage: pagedList?.numberOfElements, + pages, + viewAllUrl, + enableShowAll: enableShowAll === undefined ? false : enableShowAll, + }, + } +} diff --git a/server/utils/nunjucksSetup.ts b/server/utils/nunjucksSetup.ts index 1ca92c1..b92873e 100644 --- a/server/utils/nunjucksSetup.ts +++ b/server/utils/nunjucksSetup.ts @@ -15,6 +15,7 @@ import { import { ApplicationInfo } from '../applicationInfo' import { formatDate, formatDateTime, formatTime, timeFromDate, toUnixTimeStamp } from './dateHelpers' import config from '../config' +import { pluralise } from './pluralise' const production = process.env.NODE_ENV === 'production' @@ -72,4 +73,5 @@ export default function nunjucksSetup(app: express.Express, applicationInfo: App njkEnv.addFilter('formatName', formatName) njkEnv.addFilter('toUnixTimeStamp', toUnixTimeStamp) njkEnv.addFilter('timeFromDate', timeFromDate) + njkEnv.addFilter('pluralise', pluralise) } diff --git a/server/utils/pluralise.test.ts b/server/utils/pluralise.test.ts new file mode 100644 index 0000000..82a7e9a --- /dev/null +++ b/server/utils/pluralise.test.ts @@ -0,0 +1,33 @@ +import { pluralise } from './pluralise' + +describe('pluralise a word', () => { + it.each([ + ['Standard word, no options - singular count', 1, 'cat', undefined, '1 cat'], + ['Standard word, no options - plural count', 2, 'cat', undefined, '2 cats'], + ['Standard word, no options - zero count', 0, 'cat', undefined, '0 cats'], + ['Standard word, no options - negative singular count', -1, 'cat', undefined, '-1 cat'], + ['Standard word, no options - negative plural count', -7, 'cat', undefined, '-7 cats'], + ['Standard word, forced plural - plural count', 3, 'cat', { plural: 'kittens' }, '3 kittens'], + ['Standard word, no includeCount - plural count', 4, 'cat', { includeCount: false }, 'cats'], + ['Standard word, empty message - zero count', 0, 'cat', { emptyMessage: 'No cats' }, 'No cats'], + ['Standard word, empty message - non-zero count', 2, 'cat', { emptyMessage: 'No cats' }, '2 cats'], + ['Non-standard word, no options - plural count', 2, 'person', undefined, '2 people'], + ['Non-standard word, forced plural - plural count', 4, 'person', { plural: 'humans' }, '4 humans'], + ['Non-standard word, no includeCount - plural count', 4, 'person', { includeCount: false }, 'people'], + [ + 'Non-standard word, forced plural, no includeCount - plural count', + 4, + 'person', + { plural: 'humans', includeCount: false }, + 'humans', + ], + ['Non-standard word, empty message - plural count', 0, 'person', { emptyMessage: 'Nobody here' }, 'Nobody here'], + ['Standard word, multiple - singular count', 1, 'active punishment', undefined, '1 active punishment'], + ['Standard word, multiple - plural count', 2, 'active punishment', undefined, '2 active punishments'], + ])( + '%s pluralise(%s, %s, %s)', + (_: string, count: number, word: string, options: { [key: string]: string | boolean }, expected: string) => { + expect(pluralise(count, word, options)).toEqual(expected) + }, + ) +}) diff --git a/server/utils/pluralise.ts b/server/utils/pluralise.ts new file mode 100644 index 0000000..15f4916 --- /dev/null +++ b/server/utils/pluralise.ts @@ -0,0 +1,46 @@ +const notStandardPlurals: Record = { + person: 'people', + man: 'men', + woman: 'women', + child: 'children', + knife: 'knives', + life: 'lives', + wife: 'wives', + foot: 'feet', + tooth: 'teeth', +} + +/** + * Pluralises a given word based on a count provided. + * + * Uses `nonStandardPlurals` if the word is defined, else just adds an 's' + * + * Both can be overridden by using the `plural` option + * + * Added as Nunjucks filter for use in templates, e.g. + * - {{ prisonersArray.length | pluralise('person') }} // 'people' + * - {{ countOfChildren | pluralise('child') }} // 'children' + * - {{ countOfChildren | pluralise('child', 'kids') }} // 'kids' + * + * Limitations: entries in `nonStandardPlurals` are case-sensitive, so 'Person' will return 'Persons' + * + * @param count - denotes if `word` should be pluralised or not + * @param word - the singular form of the word to be pluralised + * @param options - plural, includeCount, emptyMessage + * @param options.plural - return this specific plural if count !== [1,-1] + * @param options.includeCount - prefix the return string with `count` (e.g. '3 people') - defaults to true + * @param options.emptyMessage - return this string if `count` is Falsy + * @return pluralised form of word if count !== [1,-1] else just word + */ +export const pluralise = ( + count: number, + word: string, + options?: { plural?: string; includeCount?: boolean; emptyMessage?: string }, +): string => { + const includeCount = options?.includeCount ?? true + if (!count && options?.emptyMessage) { + return options.emptyMessage + } + const pluralised = [1, -1].includes(count) ? word : options?.plural || notStandardPlurals[word] || `${word}s` + return includeCount ? `${count} ${pluralised}` : pluralised +} 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/hmppsPaginationSummary.njk b/server/views/macros/hmppsPaginationSummary.njk new file mode 100644 index 0000000..18cf653 --- /dev/null +++ b/server/views/macros/hmppsPaginationSummary.njk @@ -0,0 +1,10 @@ +{% macro hmppsPaginationSummary(params) %} + {% if params.totalElements %} +

Showing {{ params.offset+1 }} to {{ params.offset+params.elementsOnPage }} of {{ params.totalElements }} {{ params.totalElements | pluralise(params.itemDescription, { includeCount: false }) }}

+ {% else %} +

0 {{ params.totalElements | pluralise(params.itemDescription, { includeCount: false }) }}

+ {% endif %} + {% if params.totalPages > 1 and not params.showingAll and params.enableShowAll %} + + {% endif %} +{% endmacro %} 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/pages/dietaryRequirements.njk b/server/views/pages/dietaryRequirements.njk new file mode 100644 index 0000000..5f9cd70 --- /dev/null +++ b/server/views/pages/dietaryRequirements.njk @@ -0,0 +1,107 @@ +{% extends "../partials/layout.njk" %} +{% from "../macros/hmppsPagedListHeader.njk" import hmppsPagedListHeader %} +{% from "../macros/hmppsPagedListFooter.njk" import hmppsPagedListFooter %} +{% from "govuk/components/table/macro.njk" import govukTable %} + +{% set pageTitle = "Dietary requirements" %} +{% set mainClasses = "govuk-body govuk-main-wrapper--auto-spacing" %} + +{% set breadCrumbs = [ + { + text: 'Digital Prison Services', + href: '/' + } +] %} + +{% macro dietaryRequirementsHtml(dietaryRequirements) %} + {% if(dietaryRequirements.medical.length > 0) %} +

Medical

+

+ {% for need in dietaryRequirements.medical %} + {{ need }}
+ {% endfor %} +

+ {% endif %} + + {% if(dietaryRequirements.foodAllergies.length > 0) %} +

Food allergies

+

+ {% for need in dietaryRequirements.foodAllergies %} + {{ need }}
+ {% endfor %} +

+ {% endif %} + + {% if(dietaryRequirements.personal.length > 0) %} +

Personal

+

+ {% for need in dietaryRequirements.personal %} + {{ need }}
+ {% endfor %} +

+ {% endif %} +{% endmacro %} + +{% set rows = [] %} +{% for row in content %} + {% set rows = (rows.push([ + { html: row.name + "
" + row.prisonerNumber }, + { text: row.location }, + { html: dietaryRequirementsHtml(row.dietaryRequirements) } + ]), rows) %} +{% endfor %} + +{% block content %} +
+
+
+

People with dietary requirements in {{ "Prison Name" }}

+

This information is from the diet and food allergies secion on the DPS prisoner profile.

+
+
+
+
+

+ Print all +

+
+
+
+
+ {{ hmppsPagedListHeader(listMetadata) }} +
+
+
+
+ {{ govukTable({ + head: [ + { + html: 'Name and prison number', + attributes: { + "data-qa": "name-and-number-header", + "aria-sort": sorting.nameAndNumber.direction + } + }, + { + html: 'Location', + attributes: { + "data-qa": "location-header", + "aria-sort": sorting.location.direction + } + }, + { + text: 'Dietary requirements' + } + ], + rows: rows + }) }} +
+
+
+
+ {{ hmppsPagedListFooter(listMetadata) }} +
+
+
+{% endblock %} + diff --git a/server/views/pages/printDietaryRequirements.njk b/server/views/pages/printDietaryRequirements.njk new file mode 100644 index 0000000..feed9d5 --- /dev/null +++ b/server/views/pages/printDietaryRequirements.njk @@ -0,0 +1,94 @@ +{% extends "../partials/layout.njk" %} +{% from "govuk/components/table/macro.njk" import govukTable %} +{% from "govuk/components/back-link/macro.njk" import govukBackLink %} + +{% set pageTitle = "Dietary requirements" %} +{% set mainClasses = "govuk-body govuk-main-wrapper--auto-spacing" %} + +{% set breadCrumbs = [ + { + text: 'Digital Prison Services', + href: '/' + } +] %} + +{% macro dietaryRequirementsHtml(dietaryRequirements) %} + {% if(dietaryRequirements.medical.length > 0) %} +
+ Medical
+ {% for need in dietaryRequirements.medical %} + {{ need }}
+ {% endfor %} +
+ {% endif %} + + {% if(dietaryRequirements.foodAllergies.length > 0) %} +
+ Food allergies
+ {% for need in dietaryRequirements.foodAllergies %} + {{ need }}
+ {% endfor %} +
+ {% endif %} + + {% if(dietaryRequirements.personal.length > 0) %} +
+ Personal
+ {% for need in dietaryRequirements.personal %} + {{ need }}
+ {% endfor %} +
+ {% endif %} +{% endmacro %} + +{% set rows = [] %} +{% for row in content %} + {% set rows = (rows.push([ + { html: row.name + "
" + row.prisonerNumber }, + { text: row.location }, + { html: dietaryRequirementsHtml(row.dietaryRequirements) } + ]), rows) %} +{% endfor %} + +{% block content %} +
+
+
+ {{ govukBackLink({ + text: "Back", + href: "/dietary-requirements/" + locationId, + classes: "govuk-!-display-none-print" + }) }} +
+
+
+
+

+ {{ date }} + People with dietary requirements in {{ "Prison Name" }} +

+

This document may include sensitive information about prisoner dietary requirements.

+
+
+
+
+
+
+ +
+
+ {{ govukTable({ + head: [ + { text: 'Name and prison number' }, + { text: 'Location' }, + { text: 'Dietary requirements' } + ], + rows: rows + }) }} +
+
+
+{% endblock %} + +