Skip to content

Commit

Permalink
APS-1677 AP occupancy view
Browse files Browse the repository at this point in the history
Changed permission and only show if space bookings supported
  • Loading branch information
Bob Meredith committed Dec 23, 2024
1 parent 6fdd1f2 commit 7c0a7ee
Show file tree
Hide file tree
Showing 20 changed files with 678 additions and 48 deletions.
2 changes: 2 additions & 0 deletions integration_tests/pages/manage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import NewDateChangePage from './booking/dateChanges/new'
import BookingShowPage from './booking/show'
import DepartureDateChangeConfirmationPage from './booking/dateChanges/confirmation'
import DepartureDateChangePage from './booking/dateChanges/create'
import OccupancyViewPage from './occupancyView'

export {
CancellationCreatePage,
Expand All @@ -22,4 +23,5 @@ export {
WithdrawConfirmPage,
UnableToMatchPage,
PremisesListPage,
OccupancyViewPage,
}
43 changes: 43 additions & 0 deletions integration_tests/pages/manage/occupancyView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Cas1PremiseCapacity, Cas1PremisesSummary } from '@approved-premises/api'
import Page from '../page'
import { DateFormats, daysToWeeksAndDays } from '../../../server/utils/dateUtils'
import paths from '../../../server/paths/manage'

export default class OccupancyViewPage extends Page {
constructor(private readonly premises: Cas1PremisesSummary) {
super(`View spaces in ${premises.name}`)
}

static visit(premises: Cas1PremisesSummary): OccupancyViewPage {
cy.visit(paths.premises.occupancy.view({ premisesId: premises.id }))
return new OccupancyViewPage(premises)
}

shouldShowCalendarHeading(startDate: string, durationDays: number): void {
const calendarTitle = `Showing ${DateFormats.formatDuration(daysToWeeksAndDays(String(durationDays)))} from ${DateFormats.isoDateToUIDate(startDate, { format: 'short' })}`
cy.contains(calendarTitle)
}

shouldShowCalendar(premisesCapacity: Cas1PremiseCapacity): void {
cy.get('#calendar-key').within(() => {
cy.contains('Available')
cy.contains('Full')
cy.contains('Overbooked')
})
cy.get('#calendar').find('li').should('have.length', premisesCapacity.capacity.length)
cy.get('#calendar')
.find('li')
.each((day, index) => {
const dayCapacity = premisesCapacity.capacity[index]
const expectedClass = { '-1': 'govuk-tag--red', '0': 'govuk-tag--yellow', '1': '' }[
String(Math.sign(dayCapacity.availableBedCount - dayCapacity.bookingCount))
]
cy.wrap(day).within(() => {
cy.contains(`${dayCapacity.bookingCount} booked`)
cy.contains(`${dayCapacity.availableBedCount - dayCapacity.bookingCount} available`)
cy.contains(DateFormats.isoDateToUIDate(dayCapacity.date, { format: 'longNoYear' }))
})
if (expectedClass) cy.wrap(day).should('have.class', expectedClass)
})
}
}
5 changes: 5 additions & 0 deletions integration_tests/pages/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ export default abstract class Page {
cy.get('[role="menuitem"]').contains(actionLabel).click()
}

actionShouldNotExist(actionLabel: string): void {
cy.get('.moj-button-menu > button').contains('Actions').click()
cy.get('[role="menuitem"]').contains(actionLabel).should('not.exist')
}

actionMenuShouldNotExist(): void {
cy.get('.moj-button-menu > button').should('not.exist')
}
Expand Down
152 changes: 152 additions & 0 deletions integration_tests/tests/manage/occupancyView.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { addDays } from 'date-fns'
import {
cas1PremiseCapacityFactory,
cas1PremisesSummaryFactory,
cas1SpaceBookingSummaryFactory,
staffMemberFactory,
} from '../../../server/testutils/factories'

import { OccupancyViewPage, PremisesShowPage } from '../../pages/manage'

import { signIn } from '../signIn'
import { DateFormats } from '../../../server/utils/dateUtils'
import Page from '../../pages/page'

context('Premises occupancy', () => {
describe('show', () => {
const startDateObj = new Date()
const endDateObj = new Date(startDateObj)
endDateObj.setDate(endDateObj.getDate() + 84)

const startDate = DateFormats.dateObjToIsoDate(startDateObj)
const endDate = DateFormats.dateObjToIsoDate(endDateObj)
const premisesCapacity = cas1PremiseCapacityFactory.build({ startDate, endDate })

const premises = premisesCapacity.premise
const placements = cas1SpaceBookingSummaryFactory.buildList(30)
const keyworkers = staffMemberFactory.keyworker().buildList(5)

beforeEach(() => {
cy.task('reset')

// Given there is a premises in the database
cy.task('stubSinglePremises', premises)
cy.task('stubPremisesStaffMembers', { premisesId: premises.id, staffMembers: keyworkers })

// And it has a list of upcoming placements
cy.task('stubSpaceBookingSummaryList', { premisesId: premises.id, placements, residency: 'upcoming' })
cy.task('stubPremiseCapacity', { premisesId: premises.id, startDate, endDate, premiseCapacity: premisesCapacity })
})

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 next 12 weeks if navigated from premises page', () => {
// When I visit premises details page
const page = PremisesShowPage.visit(premises)

// And I click the action link view spaces
page.clickAction('View spaces')
// Then I should navigate to the occupancy view
const occPage = Page.verifyOnPage(OccupancyViewPage, premises)
occPage.shouldShowCalendarHeading(startDate, DateFormats.differenceInDays(endDateObj, startDateObj).number)
occPage.shouldShowCalendar(premisesCapacity)
})

it('should allow the user to change the calendar duration', () => {
const endDate26 = DateFormats.dateObjToIsoDate(addDays(startDateObj, 7 * 26))
cy.task('stubPremiseCapacity', {
premisesId: premises.id,
startDate,
endDate: endDate26,
premiseCapacity: premisesCapacity,
})
// When I visit the occupancy view page
const page = OccupancyViewPage.visit(premises)
// Then I should be shown the default period
page.shouldShowCalendarHeading(startDate, DateFormats.differenceInDays(endDateObj, startDateObj).number)
// When I select a different duration
page.getSelectInputByIdAndSelectAnEntry('durationDays', '26 weeks')
// and click submit
page.clickSubmit()
// Then the duration should change
page.shouldShowCalendarHeading(startDate, 26 * 7)
// and the new duration should be selected
page.shouldHaveSelectText('durationDays', '26 weeks')
})

it('should allow the user to change the start date', () => {
const newStartDate = DateFormats.dateObjToIsoDate(addDays(startDateObj, 5))
const newEndDate = DateFormats.dateObjToIsoDate(addDays(startDateObj, 5 + 12 * 7))

cy.task('stubPremiseCapacity', {
premisesId: premises.id,
startDate: newStartDate,
endDate: newEndDate,
premiseCapacity: premisesCapacity,
})
// When I visit the occupancy view page
const page = OccupancyViewPage.visit(premises)
// Then I should be shown the default period
page.shouldShowCalendarHeading(startDate, DateFormats.differenceInDays(endDateObj, startDateObj).number)
// When I select a different start date
page.clearAndCompleteDateInputs('startDate', newStartDate)
// and click submit
page.clickSubmit()
// Then the start date should change
page.shouldShowCalendarHeading(newStartDate, 12 * 7)
// and the new duration should be selected
page.shouldHaveSelectText('durationDays', '12 weeks')
// and the start date should be populated
page.dateInputsShouldContainDate('startDate', newStartDate)
})

it('should validate the start date', () => {
const newStartDate = DateFormats.dateObjToIsoDate(addDays(startDateObj, 5))
const newEndDate = DateFormats.dateObjToIsoDate(addDays(startDateObj, 5 + 12 * 7))
const badStartDate = '2023-02-29'

cy.task('stubPremiseCapacity', {
premisesId: premises.id,
startDate: newStartDate,
endDate: newEndDate,
premiseCapacity: premisesCapacity,
})
// When I visit the occupancy view page
const page = OccupancyViewPage.visit(premises)
// Then I should be shown the default period
page.shouldShowCalendarHeading(startDate, DateFormats.differenceInDays(endDateObj, startDateObj).number)
// When I select a bad start date and submit
page.clearAndCompleteDateInputs('startDate', badStartDate)
page.clickSubmit()
// Then an error should be shown
page.shouldShowErrorMessagesForFields(['startDate'], {
startDate: 'Enter a valid date',
})
// And the bad date I entered should be populated
page.dateInputsShouldContainDate('startDate', badStartDate)
// And the calendar should be blank
cy.get('.calendar').should('not.exist')
// When I select a different start date and click submit
page.clearAndCompleteDateInputs('startDate', newStartDate)
page.clickSubmit()
// Then the new start date should be shown
page.shouldHaveSelectText('durationDays', '12 weeks')
page.dateInputsShouldContainDate('startDate', newStartDate)
})

it('should not be available in the premises actions menu if the premises does not support space bookings', () => {
// Given that I am looking at a premises that does not support space bookings
const nonSpaceBookingPremises = cas1PremisesSummaryFactory.build({ supportsSpaceBookings: false })
cy.task('stubSinglePremises', nonSpaceBookingPremises)
// When I visit premises details page
const page = PremisesShowPage.visit(nonSpaceBookingPremises)
// Then the view spaces action should not be shown
page.actionShouldNotExist('View spaces')
})
})
})
})
2 changes: 1 addition & 1 deletion integration_tests/tests/manage/premises.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ context('Premises', () => {
describe('without placement list view permission', () => {
beforeEach(() => {
// Given I am logged in as a user without placement list view permission
signIn(['future_manager'])
signIn(['future_manager'], ['cas1_premises_view'])
})

it('should not show the placements section', () => {
Expand Down
4 changes: 4 additions & 0 deletions server/controllers/manage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import DateChangesController from './dateChangesController'

import type { Services } from '../../services'
import PremisesController from './premises/premisesController'
import ApOccupancyViewController from './premises/apOccupancyViewController'
import PlacementController from './placementController'
import BedsController from './premises/bedsController'
import OutOfServiceBedsController from './outOfServiceBedsController'
Expand Down Expand Up @@ -45,6 +46,7 @@ export const controllers = (services: Services) => {
const nonArrivalsController = new NonArrivalsController(services.premisesService, services.placementService)
const keyworkerController = new KeyworkerController(services.premisesService, services.placementService)
const departuresController = new DeparturesController(services.premisesService, services.placementService)
const apOccupancyViewController = new ApOccupancyViewController(services.premisesService)

return {
premisesController,
Expand All @@ -60,6 +62,7 @@ export const controllers = (services: Services) => {
cancellationsController,
placementController,
keyworkerController,
apOccupancyViewController,
}
}

Expand All @@ -75,4 +78,5 @@ export {
UpdateOutOfServiceBedsController,
BookingsController,
BookingExtensionsController,
ApOccupancyViewController,
}
113 changes: 113 additions & 0 deletions server/controllers/manage/premises/apOccupancyViewController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { Cas1PremisesSummary } 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, cas1PremisesSummaryFactory } from '../../../testutils/factories'

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

describe('AP occupancyViewController', () => {
const token = 'TEST_TOKEN'
const premisesId = 'some-uuid'

let request: DeepMocked<Request>
let response: DeepMocked<Response> = createMock<Response>({})
const next: DeepMocked<NextFunction> = createMock<NextFunction>({})

const premisesService = createMock<PremisesService>({})
const occupancyViewController = new ApOccupancyViewController(premisesService)

beforeEach(() => {
jest.resetAllMocks()
request = createMock<Request>({ user: { token }, params: { premisesId }, flash: jest.fn() })
response = createMock<Response>({ locals: { user: { permissions: ['cas1_space_booking_list'] } } })

jest.useFakeTimers()
jest.setSystemTime(new Date('2024-01-01'))
})

describe('view', () => {
const mockPremises = async (startDate: string = DateFormats.dateObjToIsoDate(new Date())) => {
const premisesSummary: Cas1PremisesSummary = cas1PremisesSummaryFactory.build({ id: premisesId })
const premisesCapacity = cas1PremiseCapacityFactory.build({ startDate })
premisesService.getCapacity.mockResolvedValue(premisesCapacity)
premisesService.find.mockResolvedValue(premisesSummary)

const requestHandler = occupancyViewController.view()
await requestHandler(request, response, next)

return {
premisesSummary,
premisesCapacity,
}
}

it('should render the premises occupancy view with default date and duration', async () => {
const startDate = '2024-01-01'
const endDate = '2024-03-25'
const { premisesSummary, premisesCapacity } = await mockPremises(startDate)

expect(response.render).toHaveBeenCalledWith(
'manage/premises/occupancy/view',
expect.objectContaining({
calendarHeading: 'Showing 12 weeks from 1 Jan 2024',
premises: premisesSummary,
backLink: paths.premises.show({ premisesId }),
calendar: occupancyCalendar(premisesCapacity.capacity, premisesId),
}),
)
expect(premisesService.find).toHaveBeenCalledWith(token, premisesId)
expect(premisesService.getCapacity).toHaveBeenCalledWith(token, premisesId, startDate, endDate)
})

it('should render the premises occupancy view with specified valid date and duration', async () => {
request = createMock<Request>({
user: { token },
params: { premisesId },
flash: jest.fn(),
query: { 'startDate-year': '2024', 'startDate-month': '06', 'startDate-day': '20', durationDays: '7' },
})
const { premisesSummary, premisesCapacity } = await mockPremises('2024-06-20')

expect(response.render).toHaveBeenCalledWith(
'manage/premises/occupancy/view',
expect.objectContaining({
calendarHeading: 'Showing 1 week from 20 Jun 2024',
premises: premisesSummary,
backLink: paths.premises.show({ premisesId }),
calendar: occupancyCalendar(premisesCapacity.capacity, premisesId),
errorSummary: [],
}),
)
expect(premisesService.find).toHaveBeenCalledWith(token, premisesId)
expect(premisesService.getCapacity).toHaveBeenCalledWith(token, premisesId, '2024-06-20', '2024-06-27')
})

it('should render error if date is invalid', async () => {
request = createMock<Request>({
user: { token },
params: { premisesId },
flash: jest.fn(),
query: { 'startDate-year': '2023', 'startDate-month': '02', 'startDate-day': '29', durationDays: '7' },
})
const { premisesSummary } = await mockPremises('2024-06-20')

expect(response.render).toHaveBeenCalledWith(
'manage/premises/occupancy/view',
expect.objectContaining({
premises: premisesSummary,
backLink: paths.premises.show({ premisesId }),
calendar: [],
errorSummary: [{ text: 'Enter a valid date', href: '#startDate' }],
}),
)
expect(premisesService.find).toHaveBeenCalledWith(token, premisesId)
expect(premisesService.getCapacity).not.toHaveBeenCalled()
})
})
})
Loading

0 comments on commit 7c0a7ee

Please sign in to comment.