Skip to content

Commit

Permalink
Merge pull request #2293 from ministryofjustice/feature/APS-1678_AP_o…
Browse files Browse the repository at this point in the history
…ccupancy_day_view

APS-1678 AP day summary view
  • Loading branch information
bobmeredith authored Jan 21, 2025
2 parents 515cfda + a3cb31d commit a345cd4
Show file tree
Hide file tree
Showing 22 changed files with 564 additions and 18 deletions.
1 change: 1 addition & 0 deletions assets/sass/application.sass
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ $path: "/assets/images/"
@import './components/moj-identity-bar'
@import './components/status-tag'
@import './components/notification-banner'
@import './components/previous-next'

@import './pages/assessments-index'
@import './pages/applications-pages-attach-document'
Expand Down
21 changes: 21 additions & 0 deletions assets/sass/components/_previous-next.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.prev-next {
font-weight: bold;
margin-bottom: govuk-spacing(3);

.prev-next--prev {
margin-right: govuk-spacing(3);

&::before {
content: "\2190";
margin-right: govuk-spacing(2);
font-size:30px;
}
}

.prev-next--next::after {
content: "\2192";
margin-left: govuk-spacing(2);

font-size:30px;
}
}
28 changes: 28 additions & 0 deletions integration_tests/mockApis/premises.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
Cas1PremiseCapacity,
Cas1Premises,
Cas1PremisesBasicSummary,
Cas1PremisesDaySummary,
ExtendedPremisesSummary,
ApprovedPremisesSummary as PremisesSummary,
StaffMember,
Expand Down Expand Up @@ -110,11 +111,38 @@ const stubPremiseCapacity = (args: {
},
})

const stubPremiseDaySummary = (args: {
premisesId: string
date: string
premisesDaySummary: Cas1PremisesDaySummary
sortBy?: string
sortDirection?: string
}) => {
const queryString: string = createQueryString({
sortBy: args.sortBy || undefined,
sortDirection: args.sortDirection || undefined,
})
return stubFor({
request: {
method: 'GET',
url: `${paths.premises.daySummary({ premisesId: args.premisesId, date: args.date })}${queryString ? `?${queryString}` : ''}`,
},
response: {
status: 200,
headers: {
'Content-Type': 'application/json;charset=UTF-8',
},
jsonBody: args.premisesDaySummary,
},
})
}

export default {
stubAllPremises,
stubCas1AllPremises,
stubPremisesSummary,
stubSinglePremises,
stubPremisesStaffMembers,
stubPremiseCapacity,
stubPremiseDaySummary,
}
32 changes: 32 additions & 0 deletions integration_tests/pages/manage/occupancyDayView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Cas1PremisesBasicSummary, Cas1PremisesDaySummary } from '@approved-premises/api'
import Page from '../page'
import { DateFormats } from '../../../server/utils/dateUtils'
import paths from '../../../server/paths/manage'
import { daySummaryRows } from '../../../server/utils/premises/occupancy'

export default class OccupancyDayViewPage extends Page {
constructor(private pageTitle: string) {
super(pageTitle)
}

static visit(premises: Cas1PremisesBasicSummary, date: string): OccupancyDayViewPage {
cy.visit(paths.premises.occupancy.day({ premisesId: premises.id, date }))
return new OccupancyDayViewPage(DateFormats.isoDateToUIDate(date))
}

shouldShowDaySummaryDetails(premisesDaySummary: Cas1PremisesDaySummary) {
this.shouldContainSummaryListItems(daySummaryRows(premisesDaySummary).rows)
}

shouldNavigateToDay(linkLabel: string, date: string) {
cy.contains(linkLabel).click()
cy.get('h1').contains(DateFormats.isoDateToUIDate(date))
}

static visitUnauthorised(premises: Cas1PremisesBasicSummary, date: string): OccupancyDayViewPage {
cy.visit(paths.premises.occupancy.day({ premisesId: premises.id, date }), {
failOnStatusCode: false,
})
return new OccupancyDayViewPage(`Authorisation Error`)
}
}
4 changes: 4 additions & 0 deletions integration_tests/pages/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ export default abstract class Page {
cy.get('.govuk-notification-banner').contains(copy)
}

shouldNotShowBanner(): void {
cy.get('.govuk-notification-banner').should('not.exist')
}

radioByNameAndValueShouldNotExist(name: string, option: string): void {
cy.get(`input[name = "${name}"][value = "${option}"]`).should('not.exist')
}
Expand Down
94 changes: 94 additions & 0 deletions integration_tests/tests/manage/occupancyView.cy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { addDays } from 'date-fns'
import { faker } from '@faker-js/faker'
import {
cas1PremiseCapacityFactory,
cas1PremiseCapacityForDayFactory,
cas1PremisesBasicSummaryFactory,
cas1PremisesDaySummaryFactory,
cas1PremisesFactory,
cas1SpaceBookingSummaryFactory,
staffMemberFactory,
Expand All @@ -11,6 +15,8 @@ import { OccupancyViewPage, PremisesShowPage } from '../../pages/manage'
import { signIn } from '../signIn'
import { DateFormats } from '../../../server/utils/dateUtils'
import Page from '../../pages/page'
import { premiseCharacteristicAvailability } from '../../../server/testutils/factories/cas1PremiseCapacity'
import OccupancyDayViewPage from '../../pages/manage/occupancyDayView'

context('Premises occupancy', () => {
describe('show', () => {
Expand Down Expand Up @@ -159,3 +165,91 @@ context('Premises occupancy', () => {
})
})
})

context('Premises day occupancy', () => {
describe('day', () => {
const dateObj = faker.date.soon()
const date = DateFormats.dateObjToIsoDate(dateObj)
const premises = cas1PremisesBasicSummaryFactory.build()

const getDaySummary = (overBook = false) => {
const characteristicAvailability = overBook
? [
premiseCharacteristicAvailability.strictlyOverbooked().build({ characteristic: 'isSingle' }),
premiseCharacteristicAvailability.strictlyOverbooked().build({ characteristic: 'hasEnSuite' }),
]
: [
premiseCharacteristicAvailability.available().build({ characteristic: 'isSingle' }),
premiseCharacteristicAvailability.available().build({ characteristic: 'hasEnSuite' }),
]

const capacity = cas1PremiseCapacityForDayFactory.available().build({
characteristicAvailability,
})
return cas1PremisesDaySummaryFactory.build({ forDate: date, capacity })
}

beforeEach(() => {
cy.task('reset')
// Given there is a premises in the database
cy.task('stubSinglePremises', premises)
})

describe('with premises view permission', () => {
beforeEach(() => {
// Given I am logged in as a future manager with premises_view permission
signIn(['future_manager'], ['cas1_premises_view'])
})

it('should show the day summary if spaces available', () => {
const premisesDaySummary = getDaySummary()
cy.task('stubPremiseDaySummary', { premisesId: premises.id, date, premisesDaySummary })
// When I visit premises day summary page for a day with no characteristic overbooking
const summaryPage = OccupancyDayViewPage.visit(premises, date)
// I should see the occupancy summary for the day
summaryPage.shouldShowDaySummaryDetails(premisesDaySummary)
// And I should see a warning banner
summaryPage.shouldNotShowBanner()
})

it('should show the day summary and warning if overbooked', () => {
const premisesDaySummary = getDaySummary(true)
cy.task('stubPremiseDaySummary', { premisesId: premises.id, date, premisesDaySummary })
// When I visit premises day summary page for a day with a characteristic overbooking
const summaryPage = OccupancyDayViewPage.visit(premises, date)
// I should see the occupancy summary for the day
summaryPage.shouldShowDaySummaryDetails(premisesDaySummary)
// And I should see a warning banner
summaryPage.shouldShowBanner(
'This AP is overbooked on spaces with the following criteria: single room, en-suite',
)
})

it('should allow navigation to the next day and back again', () => {
cy.task('stubPremiseDaySummary', { premisesId: premises.id, date, premisesDaySummary: getDaySummary() })
const nextDate = DateFormats.dateObjToIsoDate(addDays(dateObj, 1))
const premisesNextDaySummary = cas1PremisesDaySummaryFactory.build({ forDate: nextDate })
cy.task('stubPremiseDaySummary', {
premisesId: premises.id,
date: nextDate,
premisesDaySummary: premisesNextDaySummary,
})
// Given I visit premises day summary page
const summaryPage = OccupancyDayViewPage.visit(premises, date)
// When I click on Next day, Then I navigate to the next day
summaryPage.shouldNavigateToDay('Next day', nextDate)
// When I click on Previous day, Then I navigate back to the date I started on
summaryPage.shouldNavigateToDay('Previous day', date)
})
})
describe('Without premises view permission', () => {
it('should not be availble if the user lacks premises_view permission', () => {
// Given I am logged in as a future manager without premises_view permission
signIn(['future_manager'])
// When I navigate to the view premises occupancy page
// Then I should see an error
OccupancyDayViewPage.visitUnauthorised(premises, date)
})
})
})
})
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import type { Cas1Premises } from '@approved-premises/api'
import type { Cas1Premises, Cas1PremisesDaySummary } from '@approved-premises/api'
import type { NextFunction, Request, Response } from 'express'
import { DeepMocked, createMock } from '@golevelup/ts-jest'

import { PremisesService } from 'server/services'
import ApOccupancyViewController from './apOccupancyViewController'

import { cas1PremiseCapacityFactory, cas1PremisesFactory } from '../../../testutils/factories'
import {
cas1PremiseCapacityFactory,
cas1PremisesDaySummaryFactory,
cas1PremisesFactory,
} from '../../../testutils/factories'

import paths from '../../../paths/manage'
import { occupancyCalendar } from '../../../utils/premises/occupancy'
import { daySummaryRows, generateDaySummaryText, occupancyCalendar } from '../../../utils/premises/occupancy'
import { DateFormats } from '../../../utils/dateUtils'

describe('AP occupancyViewController', () => {
Expand Down Expand Up @@ -111,4 +115,39 @@ describe('AP occupancyViewController', () => {
expect(premisesService.getCapacity).not.toHaveBeenCalled()
})
})
describe('dayView', () => {
const mockPremises = async (date: string = DateFormats.dateObjToIsoDate(new Date())) => {
const premisesSummary: Cas1Premises = cas1PremisesFactory.build({ id: premisesId })
const premisesDaySummary: Cas1PremisesDaySummary = cas1PremisesDaySummaryFactory.build({ forDate: date })
premisesService.getDaySummary.mockResolvedValue(premisesDaySummary)
premisesService.find.mockResolvedValue(premisesSummary)
request.params.date = date
const requestHandler = occupancyViewController.dayView()
await requestHandler(request, response, next)
return {
premisesSummary,
premisesDaySummary,
}
}

it('should render the premises day summary', async () => {
const date = '2025-01-01'
const { premisesSummary, premisesDaySummary } = await mockPremises(date)

expect(response.render).toHaveBeenCalledWith(
'manage/premises/occupancy/dayView',
expect.objectContaining({
premises: premisesSummary,
pageHeading: DateFormats.isoDateToUIDate(date),
backLink: paths.premises.occupancy.view({ premisesId }),
previousDayLink: paths.premises.occupancy.day({ premisesId, date: '2024-12-31' }),
nextDayLink: paths.premises.occupancy.day({ premisesId, date: '2025-01-02' }),
daySummaryRows: daySummaryRows(premisesDaySummary),
daySummaryText: generateDaySummaryText(premisesDaySummary),
}),
)
expect(premisesService.find).toHaveBeenCalledWith(token, premisesId)
expect(premisesService.getDaySummary).toHaveBeenCalledWith({ token, premisesId, date })
})
})
})
27 changes: 26 additions & 1 deletion server/controllers/manage/premises/apOccupancyViewController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { ObjectWithDateParts } from '@approved-premises/ui'
import { PremisesService } from '../../../services'

import paths from '../../../paths/manage'
import { Calendar, durationSelectOptions, occupancyCalendar } from '../../../utils/premises/occupancy'
import {
Calendar,
daySummaryRows,
durationSelectOptions,
generateDaySummaryText,
occupancyCalendar,
} from '../../../utils/premises/occupancy'
import { DateFormats, dateAndTimeInputsAreValidDates, daysToWeeksAndDays } from '../../../utils/dateUtils'
import { placementDates } from '../../../utils/match'
import { fetchErrorsAndUserInput, generateErrorMessages, generateErrorSummary } from '../../../utils/validation'
Expand Down Expand Up @@ -61,4 +67,23 @@ export default class ApOccupancyViewController {
})
}
}

dayView(): RequestHandler {
return async (req: Request, res: Response) => {
const { token } = req.user
const { premisesId, date } = req.params
const premises = await this.premisesService.find(token, premisesId)
const daySummary = await this.premisesService.getDaySummary({ token, premisesId, date })

return res.render('manage/premises/occupancy/dayView', {
premises,
pageHeading: daySummary ? DateFormats.isoDateToUIDate(daySummary.forDate) : '',
backLink: paths.premises.occupancy.view({ premisesId }),
previousDayLink: daySummary && paths.premises.occupancy.day({ premisesId, date: daySummary.previousDate }),
nextDayLink: daySummary && paths.premises.occupancy.day({ premisesId, date: daySummary.nextDate }),
daySummaryRows: daySummary && daySummaryRows(daySummary),
daySummaryText: daySummary && generateDaySummaryText(daySummary),
})
}
}
}
Loading

0 comments on commit a345cd4

Please sign in to comment.