Skip to content

Commit

Permalink
Merge pull request #2258 from ministryofjustice/feature/APS-1606-day-…
Browse files Browse the repository at this point in the history
…availability-details

APS-1606: Day availability details
  • Loading branch information
froddd authored Jan 7, 2025
2 parents 669c2fe + ea86662 commit 7af098c
Show file tree
Hide file tree
Showing 17 changed files with 580 additions and 86 deletions.
9 changes: 9 additions & 0 deletions assets/sass/components/_summary-list.scss
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,12 @@
color: govuk-colour("dark-grey");
}

.govuk-summary-list--swap-bolding {
.govuk-summary-list__key {
font-weight: normal;
}

.govuk-summary-list__value {
font-weight: 700;
}
}
71 changes: 71 additions & 0 deletions integration_tests/pages/match/dayAvailabilityPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Cas1PremiseCapacityForDay, Cas1SpaceBookingCharacteristic } from '@approved-premises/api'
import Page from '../page'
import { dayAvailabilityCount, occupancyCriteriaMap } from '../../../server/utils/match/occupancy'
import { DateFormats } from '../../../server/utils/dateUtils'

type Availability = 'Available' | 'Overbooked' | 'Available for your criteria'

export default class DayAvailabilityPage extends Page {
availability: Availability

constructor(
private readonly dayCapacity: Cas1PremiseCapacityForDay,
private readonly criteria: Array<Cas1SpaceBookingCharacteristic> = [],
) {
let availability: Availability = dayAvailabilityCount(dayCapacity) > 0 ? 'Available' : 'Overbooked'

if (criteria.length) {
if (dayAvailabilityCount(dayCapacity, criteria) > 0 && availability === 'Overbooked') {
availability = 'Available for your criteria'
}
}

super(availability)

this.availability = availability
}

shouldShowDayAvailability() {
const uiDate = DateFormats.isoDateToUIDate(this.dayCapacity.date, { format: 'long' })
cy.get('h2').should('contain.text', uiDate)

if (this.availability === 'Available') {
cy.get('p').should('contain.text', 'The space you require is available.')
} else if (this.availability === 'Overbooked') {
cy.get('p').should('contain.text', 'This AP is full or overbooked. The space you require is not available.')
} else if (this.availability === 'Available for your criteria') {
cy.get('p').should(
'contain.text',
'This AP is full or overbooked, but the space you require is available as it is occupied by someone who does not need it.',
)
}

const summaryList = {
'AP capacity': this.dayCapacity.totalBedCount,
'Booked spaces': this.dayCapacity.bookingCount,
}

if (this.criteria.length) {
this.criteria.forEach(criteria => {
const criteriaLabel = occupancyCriteriaMap[criteria]
const criteriaAvailability = this.dayCapacity.characteristicAvailability.find(
characteristic => characteristic.characteristic === criteria,
)
summaryList[`${criteriaLabel} spaces available`] =
criteriaAvailability.availableBedsCount - criteriaAvailability.bookingsCount
})
} else {
summaryList['Available spaces'] = dayAvailabilityCount(this.dayCapacity)
}
this.shouldContainAvailabilitySummary(summaryList)
}

shouldContainAvailabilitySummary(items: Record<string, string | number>) {
Object.entries(items).forEach(([key, value]) => {
cy.get('.govuk-summary-list__key')
.contains(key)
.siblings('.govuk-summary-list__value')
.should('contain.text', value)
})
}
}
11 changes: 11 additions & 0 deletions integration_tests/pages/match/occupancyViewPage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ApType,
Cas1PremiseCapacity,
Cas1PremiseCapacityForDay,
Cas1PremisesSummary,
Cas1SpaceBookingCharacteristic,
PlacementRequestDetail,
Expand Down Expand Up @@ -112,4 +113,14 @@ export default class OccupancyViewPage extends Page {
cy.get('.govuk-error-summary').should('contain', message)
cy.get(`.govuk-error-message`).should('contain', message)
}

getOccupancyForDate(date: Date, capacity: Cas1PremiseCapacity): Cas1PremiseCapacityForDay {
return capacity.capacity.find(day => day.date === DateFormats.dateObjToIsoDate(date))
}

clickCalendarDay(date: string) {
const calendarDate = DateFormats.isoDateToUIDate(date, { format: 'longNoYear' })

cy.get('.calendar__day').contains(calendarDate).click()
}
}
85 changes: 47 additions & 38 deletions integration_tests/tests/match/match.cy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { Cas1SpaceSearchParameters, PlacementCriteria } from '@approved-premises/api'
import {
Cas1PremiseCapacity,
Cas1PremisesSummary,
Cas1SpaceSearchParameters,
PlacementCriteria,
} from '@approved-premises/api'
import { addDays } from 'date-fns'
import SearchPage from '../../pages/match/searchPage'
import UnableToMatchPage from '../../pages/match/unableToMatchPage'

Expand All @@ -20,6 +26,7 @@ import { filterOutAPTypes, placementDates } from '../../../server/utils/match'
import BookASpacePage from '../../pages/match/bookASpacePage'
import OccupancyViewPage from '../../pages/match/occupancyViewPage'
import applicationFactory from '../../../server/testutils/factories/application'
import DayAvailabilityPage from '../../pages/match/dayAvailabilityPage'

context('Placement Requests', () => {
beforeEach(() => {
Expand Down Expand Up @@ -200,49 +207,45 @@ context('Placement Requests', () => {
placementRequest,
managerDetails,
)
return { occupancyViewPage, placementRequest, premiseCapacity, premises }
return { occupancyViewPage, placementRequest, premiseCapacity, premises, startDate }
}

it('allows me to view spaces and occupancy capacity and filter the result', () => {
const apType = 'normal'
const durationDays = 15
const startDate = '2024-07-23'
const endDate = '2024-08-07'
const totalCapacity = 10
const managerDetails = 'John Doe'

// Given I am signed in as a cru_member
signIn(['cru_member'], ['cas1_space_booking_create'])

// And there is a placement request waiting for me to match
const person = personFactory.build()
const premises = cas1PremisesSummaryFactory.build({ bedCount: totalCapacity })
const placementRequest = placementRequestDetailFactory.build({
person,
expectedArrival: startDate,
duration: durationDays,
const shouldShowDayDetailsAndReturn = (
occupancyViewPage: OccupancyViewPage,
date: Date,
premises: Cas1PremisesSummary,
premiseCapacity: Cas1PremiseCapacity,
) => {
const dayCapacity = occupancyViewPage.getOccupancyForDate(date, premiseCapacity)
const premiseCapacityForDay = cas1PremiseCapacityFactory.build({
premise: premiseCapacity.premise,
startDate: dayCapacity.date,
endDate: dayCapacity.date,
capacity: [dayCapacity],
})
const premiseCapacity = cas1PremiseCapacityFactory.build({
premise: { id: premises.id, bedCount: totalCapacity, managerDetails },
startDate,
endDate,
cy.task('stubPremiseCapacity', {
premisesId: premises.id,
startDate: dayCapacity.date,
endDate: dayCapacity.date,
premiseCapacity: premiseCapacityForDay,
})

cy.task('stubSinglePremises', premises)
cy.task('stubPlacementRequest', placementRequest)
cy.task('stubPremiseCapacity', { premisesId: premises.id, startDate, endDate, premiseCapacity })
// When I click on a day on the calendar
occupancyViewPage.clickCalendarDay(dayCapacity.date)

// When I visit the occupancy view page
const occupancyViewPage = OccupancyViewPage.visit(placementRequest, premises, apType)
// Then I should see the page showing details for the day
const dayAvailabilityPage = new DayAvailabilityPage(dayCapacity)

// Then I should see the details of the case I am matching
occupancyViewPage.shouldShowMatchingDetails(
totalCapacity,
startDate,
durationDays,
placementRequest,
managerDetails,
)
// And I should see availability details
dayAvailabilityPage.shouldShowDayAvailability()

// When I click back
dayAvailabilityPage.clickBack()
}

it('allows me to view spaces and occupancy capacity and filter the result', () => {
const { occupancyViewPage, premiseCapacity, premises, startDate } =
shouldVisitOccupancyViewPageAndShowMatchingDetails(defaultLicenceExpiryDate)

// And I should see the filter form with populated values
occupancyViewPage.shouldShowFilters(startDate, 'Up to 6 weeks', [])
Expand All @@ -253,6 +256,12 @@ context('Placement Requests', () => {
// And I should see an occupancy calendar
occupancyViewPage.shouldShowOccupancyCalendar(premiseCapacity)

// And I should be able to see the day's availability details
shouldShowDayDetailsAndReturn(occupancyViewPage, addDays(startDate, 10), premises, premiseCapacity)

// Then I should see the calendar again
occupancyViewPage.shouldShowOccupancyCalendar(premiseCapacity)

// When I filter with an invalid date
occupancyViewPage.filterAvailability('2025-02-35')

Expand All @@ -267,7 +276,7 @@ context('Placement Requests', () => {
const newDuration = 'Up to 1 week'
const newCriteria = ['Wheelchair accessible', 'Step-free']
const newPremiseCapacity = cas1PremiseCapacityFactory.build({
premise: { id: premises.id, bedCount: totalCapacity, managerDetails },
premise: { id: premises.id, bedCount: premises.bedCount },
startDate: newStartDate,
endDate: newEndDate,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'

import { when } from 'jest-when'
import { addDays } from 'date-fns'
import { Cas1SpaceBookingCharacteristic } from '@approved-premises/api'
import { PlacementRequestService, PremisesService } from '../../../services'
import {
cas1PremiseCapacityFactory,
cas1PremiseCapacityForDayFactory,
cas1PremisesSummaryFactory,
placementRequestDetailFactory,
} from '../../../testutils/factories'
Expand All @@ -15,6 +17,11 @@ import matchPaths from '../../../paths/match'
import { occupancyCalendar } from '../../../utils/match/occupancyCalendar'
import * as validationUtils from '../../../utils/validation'
import { DateFormats } from '../../../utils/dateUtils'
import {
dayAvailabilityStatus,
dayAvailabilityStatusMap,
dayAvailabilitySummaryListItems,
} from '../../../utils/match/occupancy'

describe('OccupancyViewController', () => {
const token = 'SOME_TOKEN'
Expand All @@ -33,6 +40,11 @@ describe('OccupancyViewController', () => {
let request: Readonly<DeepMocked<Request>>

const apType = 'esap'
const placeholderDetailsUrl = matchPaths.v2Match.placementRequests.search.dayOccupancy({
id: placementRequestDetail.id,
premisesId: premises.id,
date: ':date',
})

beforeEach(() => {
jest.resetAllMocks()
Expand All @@ -41,6 +53,9 @@ describe('OccupancyViewController', () => {
request = createMock<Request>({
user: { token },
flash: flashSpy,
headers: {
referer: '/referrerPath',
},
})

placementRequestService.getPlacementRequest.mockResolvedValue(placementRequestDetail)
Expand Down Expand Up @@ -111,7 +126,7 @@ describe('OccupancyViewController', () => {
premiseCapacity.premise.managerDetails,
),
summary: occupancySummary(premiseCapacity.capacity),
calendar: occupancyCalendar(premiseCapacity.capacity),
calendar: occupancyCalendar(premiseCapacity.capacity, placeholderDetailsUrl),
errors: {},
errorSummary: [],
})
Expand Down Expand Up @@ -170,7 +185,7 @@ describe('OccupancyViewController', () => {
'departureDate-day': '1',
'departureDate-month': '5',
'departureDate-year': '2026',
calendar: occupancyCalendar(premiseCapacity.capacity),
calendar: occupancyCalendar(premiseCapacity.capacity, placeholderDetailsUrl),
summary: occupancySummary(premiseCapacity.capacity),
}),
)
Expand Down Expand Up @@ -219,7 +234,10 @@ describe('OccupancyViewController', () => {
'startDate-month': '4',
'startDate-year': '2025',
summary: occupancySummary(premiseCapacity.capacity, ['isSingle', 'isWheelchairDesignated']),
calendar: occupancyCalendar(premiseCapacity.capacity, ['isSingle', 'isWheelchairDesignated']),
calendar: occupancyCalendar(premiseCapacity.capacity, placeholderDetailsUrl, [
'isSingle',
'isWheelchairDesignated',
]),
}),
)
})
Expand Down Expand Up @@ -319,4 +337,44 @@ describe('OccupancyViewController', () => {
)
})
})

describe('viewDay', () => {
it('should render the day occupancy view template with given approved premises, date and criteria', async () => {
const date = '2025-03-23'
const criteria: Array<Cas1SpaceBookingCharacteristic> = ['isWheelchairDesignated', 'isArsonSuitable']

const dayCapacity = cas1PremiseCapacityForDayFactory.build({})
const premisesCapacityForDay = cas1PremiseCapacityFactory.build({
premise: premises,
startDate: date,
endDate: date,
capacity: [dayCapacity],
})
when(premisesService.getCapacity)
.calledWith(request.user.token, premises.id, date)
.mockResolvedValue(premisesCapacityForDay)

const query = {
criteria,
}
const params = { id: placementRequestDetail.id, premisesId: premises.id, date }

const requestHandler = occupancyViewController.viewDay()

await requestHandler({ ...request, params, query }, response, next)

const expectedStatus = dayAvailabilityStatus(dayCapacity, criteria)

expect(premisesService.getCapacity).toHaveBeenCalledWith('SOME_TOKEN', premises.id, date)
expect(response.render).toHaveBeenCalledWith('match/placementRequests/occupancyView/viewDay', {
backlink: '/referrerPath',
pageHeading: dayAvailabilityStatusMap[expectedStatus],
placementRequest: placementRequestDetail,
premises,
date,
status: expectedStatus,
availabilitySummaryListItems: dayAvailabilitySummaryListItems(dayCapacity, criteria),
})
})
})
})
Loading

0 comments on commit 7af098c

Please sign in to comment.