From dd13ef3469f92ceaea509645934868cf3e8550da Mon Sep 17 00:00:00 2001 From: Bob Meredith Date: Wed, 27 Nov 2024 10:35:55 +0000 Subject: [PATCH 1/4] APS-1384 Withdraw space booking --- integration_tests/mockApis/cancellation.ts | 21 +++- integration_tests/mockApis/spaceBooking.ts | 18 ++++ .../pages/manage/cancellationCreate.ts | 9 +- .../tests/manage/cancellation.cy.ts | 45 +++++++- server/controllers/apply/index.ts | 3 +- .../apply/withdrawablesController.test.ts | 62 +++++++++-- .../apply/withdrawablesController.ts | 37 +++++-- .../manage/cancellationsController.test.ts | 80 +++++++++----- .../manage/cancellationsController.ts | 61 ++++++++--- server/controllers/manage/index.ts | 6 +- .../manage/placementController.test.ts | 1 + .../controllers/manage/placementController.ts | 6 +- .../spaceBookingsController.test.ts | 10 +- .../spaceBookingsController.ts | 27 +++-- server/data/placementClient.test.ts | 56 +++++++++- server/data/placementClient.ts | 22 +++- server/paths/api.ts | 6 +- server/paths/manage.ts | 5 + server/routes/manage.ts | 16 +++ server/services/placementService.test.ts | 30 ++++++ server/services/placementService.ts | 23 +++- server/testutils/factories/index.ts | 2 + .../newCas1SpaceBookingCancellation.ts | 10 ++ server/testutils/jest.setup.ts | 8 +- .../applications/withdrawables/index.test.ts | 101 +++++++++++++----- .../utils/applications/withdrawables/index.ts | 33 +++++- server/utils/placements/index.test.ts | 8 +- server/utils/placements/index.ts | 12 ++- .../views/applications/withdrawables/show.njk | 2 +- server/views/cancellations/new.njk | 2 +- .../views/manage/premises/placements/show.njk | 10 +- .../placementRequests/spaceBookings/new.njk | 6 +- 32 files changed, 600 insertions(+), 138 deletions(-) create mode 100644 server/testutils/factories/newCas1SpaceBookingCancellation.ts diff --git a/integration_tests/mockApis/cancellation.ts b/integration_tests/mockApis/cancellation.ts index bb64bf6591..87fc8a45a7 100644 --- a/integration_tests/mockApis/cancellation.ts +++ b/integration_tests/mockApis/cancellation.ts @@ -8,15 +8,19 @@ import { cancellationReasons } from '../../server/testutils/referenceData/stubs/ export default { stubCancellationReferenceData: (): SuperAgentRequest => stubFor(cancellationReasons), + stubCancellationCreate: (args: { premisesId: string - bookingId: string + bookingId?: string + placementId?: string cancellation: Cancellation }): SuperAgentRequest => stubFor({ request: { method: 'POST', - url: `/premises/${args.premisesId}/bookings/${args.bookingId}/cancellations`, + url: args.bookingId + ? `/premises/${args.premisesId}/bookings/${args.bookingId}/cancellations` + : `/cas1/premises/${args.premisesId}/space-bookings/${args.placementId}/cancellations`, }, response: { status: 201, @@ -26,6 +30,7 @@ export default { }), stubCancellationErrors: (args: { premisesId: string; bookingId: string; params: Array }) => stubFor(errorStub(args.params, `/premises/${args.premisesId}/bookings/${args.bookingId}/cancellations`)), + verifyCancellationCreate: async (args: { premisesId: string; bookingId: string; cancellation: Cancellation }) => ( await getMatchingRequests({ @@ -33,4 +38,16 @@ export default { url: `/premises/${args.premisesId}/bookings/${args.bookingId}/cancellations`, }) ).body.requests, + + verifySpaceBookingCancellationCreate: async (args: { + premisesId: string + placementId: string + cancellation: Cancellation + }) => + ( + await getMatchingRequests({ + method: 'POST', + url: `/cas1/premises/${args.premisesId}/space-bookings/${args.placementId}/cancellations`, + }) + ).body.requests, } diff --git a/integration_tests/mockApis/spaceBooking.ts b/integration_tests/mockApis/spaceBooking.ts index adad73c5ad..0336d3980b 100644 --- a/integration_tests/mockApis/spaceBooking.ts +++ b/integration_tests/mockApis/spaceBooking.ts @@ -152,6 +152,24 @@ export default { status: 200, }, }), + + stubSpaceBookingGetWithoutPremises: (placement: Cas1SpaceBooking) => + stubFor({ + request: { + method: 'GET', + urlPattern: paths.placements.placementWithoutPremises({ + placementId: placement.id, + }), + }, + response: { + status: 200, + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + }, + jsonBody: placement, + }, + }), + verifySpaceBookingDepartureCreate: async (placement: Cas1SpaceBooking) => ( await getMatchingRequests({ diff --git a/integration_tests/pages/manage/cancellationCreate.ts b/integration_tests/pages/manage/cancellationCreate.ts index c7f3406123..405b341c13 100644 --- a/integration_tests/pages/manage/cancellationCreate.ts +++ b/integration_tests/pages/manage/cancellationCreate.ts @@ -8,6 +8,7 @@ export default class CancellationCreatePage extends Page { constructor( public readonly premisesId: string, public readonly bookingId: string, + public readonly spaceBookingId: string, ) { super('Confirm withdrawn placement') } @@ -15,7 +16,13 @@ export default class CancellationCreatePage extends Page { static visit(premisesId: string, bookingId: string): CancellationCreatePage { cy.visit(paths.bookings.cancellations.new({ premisesId, bookingId })) - return new CancellationCreatePage(premisesId, bookingId) + return new CancellationCreatePage(premisesId, bookingId, undefined) + } + + static visitWithSpaceBooking(premisesId: string, placementId: string): CancellationCreatePage { + cy.visit(paths.premises.placements.cancellations.new({ premisesId, placementId })) + + return new CancellationCreatePage(premisesId, undefined, placementId) } completeForm(cancellation: NewCancellation): void { diff --git a/integration_tests/tests/manage/cancellation.cy.ts b/integration_tests/tests/manage/cancellation.cy.ts index 87f264d0ea..d52fd5e30e 100644 --- a/integration_tests/tests/manage/cancellation.cy.ts +++ b/integration_tests/tests/manage/cancellation.cy.ts @@ -2,6 +2,7 @@ import { applicationFactory, bookingFactory, cancellationFactory, + cas1SpaceBookingFactory, extendedPremisesSummaryFactory, newCancellationFactory, premisesFactory, @@ -18,7 +19,7 @@ context('Cancellation', () => { cy.task('stubCancellationReferenceData') // Given I am signed in - signIn(['workflow_manager'], ['cas1_booking_withdraw']) + signIn(['workflow_manager'], ['cas1_booking_withdraw', 'cas1_space_booking_withdraw']) }) it('should allow me to create a cancellation with a reason of "other" ', () => { @@ -138,4 +139,46 @@ context('Cancellation', () => { // And the back link should be populated correctly page.shouldShowBackLinkToApplicationWithdraw(booking.applicationId) }) + + it('should allow me to create a cancellation for a space booking ', () => { + // Given a booking is available + const application = applicationFactory.build() + const placement = cas1SpaceBookingFactory.upcoming().build({ applicationId: application.id }) + const premises = extendedPremisesSummaryFactory.build({ bookings: [placement], id: placement.premises.id }) + + const placementId = placement.id + + cy.task('stubSpaceBookingShow', placement) + + const cancellation = newCancellationFactory.withOtherReason().build({ + otherReason: 'other reason', + }) + const withdrawable = withdrawableFactory.build({ id: placement.id, type: 'space_booking' }) + cy.task('stubPremisesSummary', premises) + cy.task('stubWithdrawablesWithNotes', { applicationId: application.id, withdrawables: [withdrawable] }) + cy.task('stubSpaceBookingGetWithoutPremises', placement) + cy.task('stubCancellationCreate', { premisesId: premises.id, placementId, cancellation }) + + // When I navigate to the booking's cancellation page + const cancellationPage = CancellationCreatePage.visitWithSpaceBooking(premises.id, placement.id) + + // And I complete the reason and notes + cancellationPage.completeForm(cancellation) + + // Then a cancellation should have been created in the API + cy.task('verifySpaceBookingCancellationCreate', { + premisesId: premises.id, + placementId, + cancellation, + }).then(requests => { + expect(requests).to.have.length(1) + const requestBody = JSON.parse(requests[0].body) + expect(requestBody.reasonId).equal(cancellation.reason) + expect(requestBody.reasonNotes).equal(cancellation.otherReason) + }) + + // And I should see a confirmation message + const confirmationPage = new BookingCancellationConfirmPage() + confirmationPage.shouldShowPanel() + }) }) diff --git a/server/controllers/apply/index.ts b/server/controllers/apply/index.ts index ae7a226b5a..19ec751dce 100644 --- a/server/controllers/apply/index.ts +++ b/server/controllers/apply/index.ts @@ -20,6 +20,7 @@ export const controllers = (services: Services) => { apAreaService, bookingService, appealService, + placementService, } = services const applicationsController = new ApplicationsController(applicationService, personService) const pagesController = new PagesController(applicationService, { @@ -33,7 +34,7 @@ export const controllers = (services: Services) => { const documentsController = new DocumentsController(personService) const withdrawalsController = new WithdrawalsController(applicationService) const notesController = new NotesController(applicationService) - const withdrawablesController = new WithdrawablesController(applicationService, bookingService) + const withdrawablesController = new WithdrawablesController(applicationService, bookingService, placementService) const appealsController = new AppealsController(appealService, applicationService) return { diff --git a/server/controllers/apply/withdrawablesController.test.ts b/server/controllers/apply/withdrawablesController.test.ts index 1728bf4ee9..e37e476110 100644 --- a/server/controllers/apply/withdrawablesController.test.ts +++ b/server/controllers/apply/withdrawablesController.test.ts @@ -1,9 +1,9 @@ import type { Request, Response } from 'express' import { DeepMocked, createMock } from '@golevelup/ts-jest' import { NextFunction } from 'express' -import { ApplicationService, BookingService } from '../../services' +import { ApplicationService, BookingService, PlacementService } from '../../services' import WithdrawablesController from './withdrawablesController' -import { bookingFactory, withdrawableFactory } from '../../testutils/factories' +import { bookingFactory, cas1SpaceBookingFactory, withdrawableFactory } from '../../testutils/factories' import adminPaths from '../../paths/admin' import managePaths from '../../paths/manage' import placementAppPaths from '../../paths/placementApplications' @@ -25,11 +25,12 @@ describe('withdrawablesController', () => { const applicationService = createMock({}) const bookingService = createMock({}) + const placementService = createMock({}) let withdrawablesController: WithdrawablesController beforeEach(() => { - withdrawablesController = new WithdrawablesController(applicationService, bookingService) + withdrawablesController = new WithdrawablesController(applicationService, bookingService, placementService) request = createMock({ user: { token }, flash }) response = createMock({}) jest.clearAllMocks() @@ -77,19 +78,25 @@ describe('withdrawablesController', () => { describe('Bookings', () => { it(`renders the view, calling the booking service to retrieve bookings`, async () => { const selectedWithdrawableType = 'placement' - const placementWithdrawables = withdrawableFactory.buildList(2, { type: 'booking' }) + const bookingWithdrawables = withdrawableFactory.buildList(2, { type: 'booking' }) + const spaceBookingWithdrawables = withdrawableFactory.buildList(2, { type: 'space_booking' }) + const allPlacementWithdrawables = [...bookingWithdrawables, ...spaceBookingWithdrawables] const applicationWithdrawable = withdrawableFactory.build({ type: 'application' }) const bookings = bookingFactory.buildList(2).map((b, i) => { - return { ...b, id: placementWithdrawables[i].id } + return { ...b, id: bookingWithdrawables[i].id } }) - const withdrawable = [applicationWithdrawable, ...placementWithdrawables] + const spaceBookings = cas1SpaceBookingFactory.buildList(2).map((b, i) => { + return { ...b, id: spaceBookingWithdrawables[i].id } + }) + const withdrawable = [applicationWithdrawable, ...allPlacementWithdrawables] const withdrawables = withdrawablesFactory.build({ withdrawables: withdrawable }) applicationService.getWithdrawablesWithNotes.mockResolvedValue(withdrawables) ;(sortAndFilterWithdrawables as jest.MockedFunction).mockReturnValue( - placementWithdrawables, + allPlacementWithdrawables, ) bookings.forEach(b => bookingService.findWithoutPremises.mockResolvedValueOnce(b)) + spaceBookings.forEach(b => placementService.getPlacement.mockResolvedValueOnce(b)) const requestHandler = withdrawablesController.show() @@ -98,20 +105,24 @@ describe('withdrawablesController', () => { response, next, ) - expect(sortAndFilterWithdrawables).toHaveBeenCalledWith(withdrawable, ['booking']) + expect(sortAndFilterWithdrawables).toHaveBeenCalledWith(withdrawable, ['booking', 'space_booking']) expect(applicationService.getWithdrawablesWithNotes).toHaveBeenCalledWith(token, applicationId) expect(response.render).toHaveBeenCalledWith('applications/withdrawables/show', { pageHeading: 'Select your placement', id: applicationId, - withdrawables: placementWithdrawables, + withdrawables: allPlacementWithdrawables, bookings, + placements: spaceBookings, withdrawableType: 'placement', notes: withdrawables.notes, }) expect(bookingService.findWithoutPremises).toHaveBeenCalledTimes(2) - expect(bookingService.findWithoutPremises).toHaveBeenCalledWith(token, placementWithdrawables[0].id) - expect(bookingService.findWithoutPremises).toHaveBeenCalledWith(token, placementWithdrawables[1].id) + expect(bookingService.findWithoutPremises).toHaveBeenCalledWith(token, bookingWithdrawables[0].id) + expect(bookingService.findWithoutPremises).toHaveBeenCalledWith(token, bookingWithdrawables[1].id) expect(bookingService.findWithoutPremises).not.toHaveBeenCalledWith(token, applicationWithdrawable.id) + expect(placementService.getPlacement).toHaveBeenCalledTimes(2) + expect(placementService.getPlacement).toHaveBeenCalledWith(token, spaceBookingWithdrawables[0].id) + expect(placementService.getPlacement).toHaveBeenCalledWith(token, spaceBookingWithdrawables[1].id) }) }) }) @@ -182,5 +193,34 @@ describe('withdrawablesController', () => { managePaths.bookings.cancellations.new({ bookingId: selectedWithdrawable, premisesId: booking.premises.id }), ) }) + + it('redirects to the booking withdrawal page if the withdrawable is a space_booking (placement)', async () => { + const placementId = 'some-id' + const withdrawable = withdrawableFactory.build({ + type: 'space_booking', + id: placementId, + }) + const withdrawables = withdrawablesFactory.build({ withdrawables: [withdrawable] }) + + const placement = cas1SpaceBookingFactory.build({ id: placementId }) + + applicationService.getWithdrawablesWithNotes.mockResolvedValue(withdrawables) + placementService.getPlacement.mockResolvedValue(placement) + + const requestHandler = withdrawablesController.create() + + await requestHandler( + { ...request, params: { id: applicationId }, body: { selectedWithdrawable: placementId } }, + response, + next, + ) + + expect(applicationService.getWithdrawablesWithNotes).toHaveBeenCalledWith(token, applicationId) + expect(placementService.getPlacement).toHaveBeenCalledWith(token, placementId) + expect(response.redirect).toHaveBeenCalledWith( + 302, + managePaths.premises.placements.cancellations.new({ placementId, premisesId: placement.premises.id }), + ) + }) }) }) diff --git a/server/controllers/apply/withdrawablesController.ts b/server/controllers/apply/withdrawablesController.ts index 405b60c1f2..b914b31749 100644 --- a/server/controllers/apply/withdrawablesController.ts +++ b/server/controllers/apply/withdrawablesController.ts @@ -1,16 +1,17 @@ import type { Request, RequestHandler, Response } from 'express' -import { ApplicationService, BookingService } from '../../services' +import { ApplicationService, BookingService, PlacementService } from '../../services' import applyPaths from '../../paths/apply' import adminPaths from '../../paths/admin' import placementAppPaths from '../../paths/placementApplications' import managePaths from '../../paths/manage' -import { ApprovedPremisesApplication as Application, Withdrawable } from '../../@types/shared' +import type { ApprovedPremisesApplication as Application, Withdrawable } from '../../@types/shared' import { SelectedWithdrawableType, sortAndFilterWithdrawables } from '../../utils/applications/withdrawables' export default class WithdrawalsController { constructor( private readonly applicationService: ApplicationService, private readonly bookingService: BookingService, + private readonly placementService: PlacementService, ) {} show(): RequestHandler { @@ -19,20 +20,31 @@ export default class WithdrawalsController { const selectedWithdrawableType = req.query?.selectedWithdrawableType as SelectedWithdrawableType | undefined const withdrawables = await this.applicationService.getWithdrawablesWithNotes(req.user.token, id) - if (selectedWithdrawableType === 'placement') { - const placementWithdrawables = sortAndFilterWithdrawables(withdrawables.withdrawables, ['booking']) + const placementAndBookingWithdrawables = sortAndFilterWithdrawables(withdrawables.withdrawables, [ + 'booking', + 'space_booking', + ]) + const bookingWithdrawables = placementAndBookingWithdrawables.filter(({ type }) => type === 'booking') const bookings = await Promise.all( - placementWithdrawables.map(async withdrawable => { + bookingWithdrawables.map(async withdrawable => { return this.bookingService.findWithoutPremises(req.user.token, withdrawable.id) }), ) - + const spaceBookingWithdrawables = placementAndBookingWithdrawables.filter( + ({ type }) => type === 'space_booking', + ) + const placements = await Promise.all( + spaceBookingWithdrawables.map(async withdrawable => { + return this.placementService.getPlacement(req.user.token, withdrawable.id) + }), + ) return res.render('applications/withdrawables/show', { pageHeading: 'Select your placement', id, - withdrawables: placementWithdrawables, + withdrawables: placementAndBookingWithdrawables, bookings, + placements, withdrawableType: 'placement', notes: withdrawables.notes, }) @@ -80,6 +92,17 @@ export default class WithdrawalsController { ) } + if (withdrawable.type === 'space_booking') { + const placement = await this.placementService.getPlacement(req.user.token, selectedWithdrawable) + return res.redirect( + 302, + managePaths.premises.placements.cancellations.new({ + placementId: placement.id, + premisesId: placement.premises.id, + }), + ) + } + if (withdrawable.type === 'application') { return res.redirect(302, applyPaths.applications.withdraw.new({ id: selectedWithdrawable })) } diff --git a/server/controllers/manage/cancellationsController.test.ts b/server/controllers/manage/cancellationsController.test.ts index 50d9a8f092..1c03178632 100644 --- a/server/controllers/manage/cancellationsController.test.ts +++ b/server/controllers/manage/cancellationsController.test.ts @@ -2,13 +2,16 @@ import type { NextFunction, Request, Response } from 'express' import { DeepMocked, createMock } from '@golevelup/ts-jest' import type { ErrorsAndUserInput } from '@approved-premises/ui' - -import CancellationService from '../../services/cancellationService' -import BookingService from '../../services/bookingService' +import { BookingService, CancellationService, PlacementService } from '../../services' import CancellationsController from './cancellationsController' import { catchValidationErrorOrPropogate, fetchErrorsAndUserInput } from '../../utils/validation' -import { bookingFactory, cancellationFactory, referenceDataFactory } from '../../testutils/factories' +import { + bookingFactory, + cancellationFactory, + cas1SpaceBookingFactory, + referenceDataFactory, +} from '../../testutils/factories' import paths from '../../paths/manage' import applyPaths from '../../paths/apply' import { DateFormats } from '../../utils/dateUtils' @@ -18,6 +21,7 @@ jest.mock('../../utils/validation') describe('cancellationsController', () => { const token = 'SOME_TOKEN' const booking = bookingFactory.build() + const placement = cas1SpaceBookingFactory.build({ applicationId: booking.applicationId }) const backLink = `${applyPaths.applications.withdrawables.show({ id: booking.applicationId })}?selectedWithdrawableType=placement` const cancellationReasons = referenceDataFactory.buildList(4) @@ -31,17 +35,19 @@ describe('cancellationsController', () => { const cancellationService = createMock({}) const bookingService = createMock({}) + const placementService = createMock({}) - const cancellationsController = new CancellationsController(cancellationService, bookingService) + const cancellationsController = new CancellationsController(cancellationService, bookingService, placementService) beforeEach(() => { jest.resetAllMocks() bookingService.find.mockResolvedValue(booking) + placementService.getPlacement.mockResolvedValue(placement) cancellationService.getCancellationReasons.mockResolvedValue(cancellationReasons) }) describe('new', () => { - it('should render the form', async () => { + it('should render the form with a legacy booking', async () => { ;(fetchErrorsAndUserInput as jest.Mock).mockImplementation(() => { return { errors: {}, errorSummary: [], userInput: {} } }) @@ -50,11 +56,14 @@ describe('cancellationsController', () => { await requestHandler({ ...request, params: { premisesId, bookingId } }, response, next) + const { arrivalDate, departureDate, person, id } = booking + expect(response.render).toHaveBeenCalledWith('cancellations/new', { premisesId, - booking, + booking: { arrivalDate, departureDate, person, id }, backLink, cancellationReasons, + formAction: paths.bookings.cancellations.create({ premisesId, bookingId }), pageHeading: 'Confirm withdrawn placement', errors: {}, errorSummary: [], @@ -64,25 +73,49 @@ describe('cancellationsController', () => { expect(cancellationService.getCancellationReasons).toHaveBeenCalledWith(token) }) - it('renders the form with errors and user input if an error has been sent to the flash', async () => { - const errorsAndUserInput = createMock({}) - - ;(fetchErrorsAndUserInput as jest.Mock).mockReturnValue(errorsAndUserInput) + it('should render the form with a placement (space_booking)', async () => { + ;(fetchErrorsAndUserInput as jest.Mock).mockImplementation(() => { + return { errors: {}, errorSummary: [], userInput: {} } + }) const requestHandler = cancellationsController.new() - await requestHandler({ ...request, params: { premisesId, bookingId } }, response, next) + await requestHandler({ ...request, params: { premisesId, placementId: placement.id } }, response, next) + + const { canonicalArrivalDate, canonicalDepartureDate, person, id } = placement expect(response.render).toHaveBeenCalledWith('cancellations/new', { premisesId, - booking, + booking: { arrivalDate: canonicalArrivalDate, departureDate: canonicalDepartureDate, person, id }, backLink, cancellationReasons, pageHeading: 'Confirm withdrawn placement', - errors: errorsAndUserInput.errors, - errorSummary: errorsAndUserInput.errorSummary, - ...errorsAndUserInput.userInput, + formAction: paths.premises.placements.cancellations.create({ premisesId, placementId: placement.id }), + errors: {}, + errorSummary: [], }) + + expect(placementService.getPlacement).toHaveBeenCalledWith(token, placement.id) + expect(cancellationService.getCancellationReasons).toHaveBeenCalledWith(token) + }) + + it('renders the form with errors and user input if an error has been sent to the flash', async () => { + const errorsAndUserInput = createMock({}) + + ;(fetchErrorsAndUserInput as jest.Mock).mockReturnValue(errorsAndUserInput) + + const requestHandler = cancellationsController.new() + + await requestHandler({ ...request, params: { premisesId, bookingId } }, response, next) + + expect(response.render).toHaveBeenCalledWith( + 'cancellations/new', + expect.objectContaining({ + errors: errorsAndUserInput.errors, + errorSummary: errorsAndUserInput.errorSummary, + ...errorsAndUserInput.userInput, + }), + ) }) it('sets the backlink to the withdrawables show page if there is an applicationId on the booking', async () => { @@ -97,15 +130,12 @@ describe('cancellationsController', () => { await requestHandler({ ...request, params: { premisesId, bookingId }, headers: {} }, response, next) - expect(response.render).toHaveBeenCalledWith('cancellations/new', { - premisesId, - booking: bookingWithoutAnApplication, - backLink: paths.bookings.show({ premisesId, bookingId }), - cancellationReasons, - pageHeading: 'Confirm withdrawn placement', - errors: {}, - errorSummary: [], - }) + expect(response.render).toHaveBeenCalledWith( + 'cancellations/new', + expect.objectContaining({ + backLink: paths.bookings.show({ premisesId, bookingId }), + }), + ) }) }) diff --git a/server/controllers/manage/cancellationsController.ts b/server/controllers/manage/cancellationsController.ts index 76db8437ce..8874a53bdc 100644 --- a/server/controllers/manage/cancellationsController.ts +++ b/server/controllers/manage/cancellationsController.ts @@ -1,8 +1,8 @@ import type { Request, RequestHandler, Response } from 'express' -import type { NewCancellation } from '@approved-premises/api' +import type { NewCancellation, NewCas1SpaceBookingCancellation } from '@approved-premises/api' -import { BookingService, CancellationService } from '../../services' +import { BookingService, CancellationService, PlacementService } from '../../services' import { catchValidationErrorOrPropogate, fetchErrorsAndUserInput } from '../../utils/validation' import { DateFormats } from '../../utils/dateUtils' @@ -13,27 +13,40 @@ export default class CancellationsController { constructor( private readonly cancellationService: CancellationService, private readonly bookingService: BookingService, + private readonly placementService: PlacementService, ) {} new(): RequestHandler { return async (req: Request, res: Response) => { - const { premisesId, bookingId } = req.params + const { premisesId, bookingId, placementId } = req.params const { errors, errorSummary, userInput } = fetchErrorsAndUserInput(req) - const booking = await this.bookingService.find(req.user.token, premisesId, bookingId) + const booking = bookingId && (await this.bookingService.find(req.user.token, premisesId, bookingId)) + const placement = placementId && (await this.placementService.getPlacement(req.user.token, placementId)) + const cancellationReasons = await this.cancellationService.getCancellationReasons(req.user.token) + const applicationId: string = booking?.applicationId || placement?.applicationId let backLink: string - if (booking?.applicationId) { - backLink = `${applyPaths.applications.withdrawables.show({ id: booking.applicationId })}?selectedWithdrawableType=placement` + if (applicationId) { + backLink = `${applyPaths.applications.withdrawables.show({ id: applicationId })}?selectedWithdrawableType=placement` } else { - backLink = paths.bookings.show({ premisesId, bookingId }) + backLink = bookingId ? paths.bookings.show({ premisesId, bookingId }) : '' } - + const consolidatedBooking = { + id: booking?.id || placement?.id, + person: booking?.person || placement?.person, + arrivalDate: booking?.arrivalDate || placement?.canonicalArrivalDate, + departureDate: booking?.departureDate || placement?.canonicalDepartureDate, + } + const formAction = booking + ? paths.bookings.cancellations.create({ premisesId, bookingId }) + : paths.premises.placements.cancellations.create({ premisesId, placementId }) res.render('cancellations/new', { premisesId, - booking, + booking: consolidatedBooking, backLink, + formAction, cancellationReasons, pageHeading: 'Confirm withdrawn placement', errors, @@ -45,7 +58,7 @@ export default class CancellationsController { create(): RequestHandler { return async (req: Request, res: Response) => { - const { premisesId, bookingId } = req.params + const { premisesId, bookingId, placementId } = req.params let date: string @@ -61,8 +74,24 @@ export default class CancellationsController { date, } as NewCancellation + const spaceBookingCancellation = { + occurredAt: date, + reasonId: cancellation.reason, + reasonNotes: cancellation.otherReason, + } as NewCas1SpaceBookingCancellation + try { - await this.cancellationService.createCancellation(req.user.token, premisesId, bookingId, cancellation) + if (bookingId) { + await this.cancellationService.createCancellation(req.user.token, premisesId, bookingId, cancellation) + } + if (placementId) { + await this.placementService.createCancellation( + req.user.token, + premisesId, + placementId, + spaceBookingCancellation, + ) + } res.render('cancellations/confirm', { pageHeading: 'Booking withdrawn' }) } catch (error) { @@ -70,10 +99,12 @@ export default class CancellationsController { req, res, error as Error, - paths.bookings.cancellations.new({ - bookingId, - premisesId, - }), + bookingId + ? paths.bookings.cancellations.new({ + bookingId, + premisesId, + }) + : paths.premises.placements.cancellations.new({ premisesId, placementId }), ) } } diff --git a/server/controllers/manage/index.ts b/server/controllers/manage/index.ts index 8b7d285198..978e00d098 100644 --- a/server/controllers/manage/index.ts +++ b/server/controllers/manage/index.ts @@ -28,7 +28,11 @@ export const controllers = (services: Services) => { const bookingsController = new BookingsController(services.bookingService) const bookingExtensionsController = new BookingExtensionsController(services.bookingService) - const cancellationsController = new CancellationsController(services.cancellationService, services.bookingService) + const cancellationsController = new CancellationsController( + services.cancellationService, + services.bookingService, + services.placementService, + ) const dateChangesController = new DateChangesController(services.bookingService) const placementController = new PlacementController(services.premisesService) const arrivalsController = new ArrivalsController(services.premisesService, services.placementService) diff --git a/server/controllers/manage/placementController.test.ts b/server/controllers/manage/placementController.test.ts index 5beac87479..7a8558f16c 100644 --- a/server/controllers/manage/placementController.test.ts +++ b/server/controllers/manage/placementController.test.ts @@ -43,6 +43,7 @@ describe('placementController', () => { placement, pageHeading: '16 Nov 2024 to 26 Mar 2025', user, + backLink: null, }), ) }) diff --git a/server/controllers/manage/placementController.ts b/server/controllers/manage/placementController.ts index 4aadba8576..dbbd840737 100644 --- a/server/controllers/manage/placementController.ts +++ b/server/controllers/manage/placementController.ts @@ -2,6 +2,7 @@ import type { Request, RequestHandler, Response } from 'express' import { PremisesService } from '../../services' import { DateFormats } from '../../utils/dateUtils' +import { getBackLink } from '../../utils/placements' export default class PlacementController { constructor(private readonly premisesService: PremisesService) {} @@ -11,10 +12,13 @@ export default class PlacementController { const { premisesId, placementId } = req.params const referrer = req.headers.referer const { user } = res.locals + const placement = await this.premisesService.getPlacement({ token: req.user.token, premisesId, placementId }) + + const backLink = getBackLink(req.headers.referer, premisesId) const pageHeading = `${DateFormats.isoDateToUIDate(placement.canonicalArrivalDate, { format: 'short' })} to ${DateFormats.isoDateToUIDate(placement.canonicalDepartureDate, { format: 'short' })}` - return res.render(`manage/premises/placements/show`, { placement, pageHeading, referrer, user }) + return res.render(`manage/premises/placements/show`, { placement, pageHeading, referrer, user, backLink }) } } } diff --git a/server/controllers/match/placementRequests/spaceBookingsController.test.ts b/server/controllers/match/placementRequests/spaceBookingsController.test.ts index 196cd750da..66195de10c 100644 --- a/server/controllers/match/placementRequests/spaceBookingsController.test.ts +++ b/server/controllers/match/placementRequests/spaceBookingsController.test.ts @@ -13,7 +13,9 @@ import { } from '../../../testutils/factories' import { filterOutAPTypes, placementDates } from '../../../utils/match' import paths from '../../../paths/admin' +import { fetchErrorsAndUserInput } from '../../../utils/validation' +jest.mock('../../../utils/validation') describe('SpaceBookingsController', () => { const token = 'SOME_TOKEN' @@ -40,7 +42,7 @@ describe('SpaceBookingsController', () => { const premisesName = 'Hope House' const premisesId = 'abc123' const apType = 'esap' - + ;(fetchErrorsAndUserInput as jest.Mock).mockReturnValue({ errors: [], errorSummary: {}, userInput: {} }) placementRequestService.getPlacementRequest.mockResolvedValue(placementRequestDetail) const query = { @@ -54,8 +56,10 @@ describe('SpaceBookingsController', () => { const params = { id: placementRequestDetail.id } const requestHandler = spaceBookingsController.new() + request.params = params + request.query = query - await requestHandler({ ...request, params, query }, response, next) + await requestHandler(request, response, next) expect(response.render).toHaveBeenCalledWith('match/placementRequests/spaceBookings/new', { pageHeading: `Book space in ${premisesName}`, @@ -63,6 +67,8 @@ describe('SpaceBookingsController', () => { premisesName, premisesId, apType, + errorSummary: {}, + errors: [], dates: placementDates(startDate, durationDays), essentialCharacteristics: filterOutAPTypes(placementRequestDetail.essentialCriteria), desirableCharacteristics: filterOutAPTypes(placementRequestDetail.desirableCriteria), diff --git a/server/controllers/match/placementRequests/spaceBookingsController.ts b/server/controllers/match/placementRequests/spaceBookingsController.ts index 3a9ccbbb1e..c22e93048b 100644 --- a/server/controllers/match/placementRequests/spaceBookingsController.ts +++ b/server/controllers/match/placementRequests/spaceBookingsController.ts @@ -2,9 +2,10 @@ import type { Request, RequestHandler, Response, TypedRequestHandler } from 'exp import { ApType, NewCas1SpaceBooking as NewSpaceBooking } from '@approved-premises/api' import { PlacementRequestService, SpaceService } from '../../../services' import { filterOutAPTypes, placementDates } from '../../../utils/match' -import { catchValidationErrorOrPropogate } from '../../../utils/validation' +import { catchValidationErrorOrPropogate, fetchErrorsAndUserInput } from '../../../utils/validation' import paths from '../../../paths/admin' import matchPaths from '../../../paths/match' +import { createQueryString } from '../../../utils/utils' interface NewRequest extends Request { params: { id: string } @@ -21,6 +22,7 @@ export default class { return async (req: NewRequest, res: Response) => { const placementRequest = await this.placementRequestService.getPlacementRequest(req.user.token, req.params.id) const { startDate, durationDays, premisesName, premisesId, apType } = req.query + const { errors, errorSummary } = fetchErrorsAndUserInput(req) res.render('match/placementRequests/spaceBookings/new', { pageHeading: `Book space in ${premisesName}`, @@ -31,20 +33,24 @@ export default class { dates: placementDates(startDate, durationDays), essentialCharacteristics: filterOutAPTypes(placementRequest.essentialCriteria), desirableCharacteristics: filterOutAPTypes(placementRequest.desirableCriteria), + errors, + errorSummary, }) } } create(): RequestHandler { return async (req: Request, res: Response) => { - const { body } = req + const { + body: { arrivalDate, departureDate, durationDays, premisesId, premisesName, essentialCharacteristics, apType }, + } = req const newSpaceBooking: NewSpaceBooking = { - arrivalDate: body.arrivalDate, - departureDate: body.departureDate, - premisesId: body.premisesId, + arrivalDate, + departureDate, + premisesId, requirements: { - essentialCharacteristics: body.essentialCharacteristics ? body.essentialCharacteristics.split(',') : [], + essentialCharacteristics: essentialCharacteristics ? essentialCharacteristics.split(',') : [], }, } try { @@ -52,11 +58,18 @@ export default class { req.flash('success', `Space booked for ${req.body.personName} in ${req.body.premisesName}`) return res.redirect(`${paths.admin.cruDashboard.index({})}?status=matched`) } catch (error) { + const queryString = createQueryString({ + startDate: arrivalDate, + durationDays, + premisesName, + premisesId, + apType, + }) return catchValidationErrorOrPropogate( req, res, error as Error, - matchPaths.v2Match.placementRequests.spaceBookings.new({ id: req.params.id }), + `${matchPaths.v2Match.placementRequests.spaceBookings.new({ id: req.params.id })}?${queryString}`, ) } } diff --git a/server/data/placementClient.test.ts b/server/data/placementClient.test.ts index 91bcb67337..4feb240603 100644 --- a/server/data/placementClient.test.ts +++ b/server/data/placementClient.test.ts @@ -1,3 +1,4 @@ +import type { Cas1SpaceBooking } from '@approved-premises/api' import PlacementClient from './placementClient' import paths from '../paths/api' import { describeCas1NamespaceClient } from '../testutils/describeClient' @@ -6,13 +7,14 @@ import { cas1NewArrivalFactory, cas1NewDepartureFactory, cas1NonArrivalFactory, + cas1SpaceBookingFactory, + newCas1SpaceBookingCancellationFactory, } from '../testutils/factories' +const token = 'TEST_TOKEN' + describeCas1NamespaceClient('PlacementClient', provider => { let placementClient: PlacementClient - - const token = 'SOME_TOKEN' - const placementId = 'placementId' const premisesId = 'premisesId' @@ -20,6 +22,30 @@ describeCas1NamespaceClient('PlacementClient', provider => { placementClient = new PlacementClient(token) }) + describe('getPlacement', () => { + it('gets the details for a placement by id', async () => { + const placement: Cas1SpaceBooking = cas1SpaceBookingFactory.build() + provider.addInteraction({ + state: 'Server is healthy', + uponReceiving: 'A request for placement details', + withRequest: { + method: 'GET', + path: paths.placements.placementWithoutPremises({ placementId: placement.id }), + headers: { + authorization: `Bearer ${token}`, + }, + }, + willRespondWith: { + status: 200, + body: placement, + }, + }) + const result = await placementClient.getPlacement(placement.id) + + expect(result).toEqual(placement) + }) + }) + describe('createArrival', () => { it('creates and returns an arrival for a given placement', async () => { const newPlacementArrival = cas1NewArrivalFactory.build() @@ -117,9 +143,31 @@ describeCas1NamespaceClient('PlacementClient', provider => { status: 200, }, }) - const result = await placementClient.createDeparture(premisesId, placementId, newPlacementDeparture) + expect(result).toEqual({}) + }) + }) + + describe('createCancellation', () => { + it('cancels the given placement', async () => { + const cancellation = newCas1SpaceBookingCancellationFactory.build() + provider.addInteraction({ + state: 'Server is healthy', + uponReceiving: 'A request to cancel a placement', + withRequest: { + method: 'POST', + path: paths.premises.placements.cancel({ premisesId, placementId }), + body: cancellation, + headers: { + authorization: `Bearer ${token}`, + }, + }, + willRespondWith: { + status: 200, + }, + }) + const result = await placementClient.cancel(premisesId, placementId, cancellation) expect(result).toEqual({}) }) }) diff --git a/server/data/placementClient.ts b/server/data/placementClient.ts index 9e23901319..25e347eb69 100644 --- a/server/data/placementClient.ts +++ b/server/data/placementClient.ts @@ -1,4 +1,11 @@ -import { Cas1AssignKeyWorker, Cas1NewArrival, Cas1NewDeparture, Cas1NonArrival } from '@approved-premises/api' +import type { + Cas1AssignKeyWorker, + Cas1NewArrival, + Cas1NewDeparture, + Cas1NonArrival, + Cas1SpaceBooking, + NewCas1SpaceBookingCancellation, +} from '@approved-premises/api' import RestClient from './restClient' import config, { ApiConfig } from '../config' import paths from '../paths/api' @@ -10,6 +17,12 @@ export default class PlacementClient { this.restClient = new RestClient('placementClient', config.apis.approvedPremises as ApiConfig, token) } + async getPlacement(placementId: string): Promise { + return (await this.restClient.get({ + path: paths.placements.placementWithoutPremises({ placementId }), + })) as Cas1SpaceBooking + } + async createArrival(premisesId: string, placementId: string, newPlacementArrival: Cas1NewArrival): Promise { return this.restClient.post({ path: paths.premises.placements.arrival({ premisesId, placementId }), @@ -41,4 +54,11 @@ export default class PlacementClient { data: newPlacementDeparture, }) } + + async cancel(premisesId: string, placementId: string, cancellation: NewCas1SpaceBookingCancellation) { + return this.restClient.post({ + path: paths.premises.placements.cancel({ premisesId, placementId }), + data: cancellation, + }) + } } diff --git a/server/paths/api.ts b/server/paths/api.ts index a8f51f9d88..0a5d2a0bf2 100644 --- a/server/paths/api.ts +++ b/server/paths/api.ts @@ -91,18 +91,22 @@ export default { dateChange: booking.path('date-changes'), }, placements: { - index: cas1PremisesSingle.path('space-bookings'), show: cas1SpaceBookingSingle, + index: cas1PremisesSingle.path('space-bookings'), arrival: cas1SpaceBookingSingle.path('arrival'), nonArrival: cas1SpaceBookingSingle.path('non-arrival'), keyworker: cas1SpaceBookingSingle.path('keyworker'), departure: cas1SpaceBookingSingle.path('departure'), + cancel: cas1SpaceBookingSingle.path('cancellations'), }, calendar: premisesSingle.path('calendar'), }, bookings: { bookingWithoutPremisesPath: path('/bookings/:bookingId'), }, + placements: { + placementWithoutPremises: cas1Namespace.path('space-bookings/:placementId'), + }, applications: { show: applicationsSingle, index: applications, diff --git a/server/paths/manage.ts b/server/paths/manage.ts index 05448b7016..db71469ab8 100644 --- a/server/paths/manage.ts +++ b/server/paths/manage.ts @@ -55,6 +55,7 @@ const premisesPath = managePath.path('premises') const singlePremisesPath = premisesPath.path(':premisesId') const singlePlacementPath = singlePremisesPath.path('placements/:placementId') const departurePath = singlePlacementPath.path('departure') +const placementCancellationsPath = singlePlacementPath.path('cancellations') const bookingsPath = singlePremisesPath.path('bookings') const bookingPath = bookingsPath.path(':bookingId') const bedsPath = singlePremisesPath.path('beds') @@ -83,6 +84,10 @@ const paths = { moveOnCategory: departurePath.path('move-on'), notes: departurePath.path('notes'), }, + cancellations: { + new: placementCancellationsPath.path('new'), + create: placementCancellationsPath.path('create'), + }, }, }, diff --git a/server/routes/manage.ts b/server/routes/manage.ts index e9d23f4605..d0e4d9a213 100644 --- a/server/routes/manage.ts +++ b/server/routes/manage.ts @@ -170,6 +170,7 @@ export default function routes(controllers: Controllers, router: Router, service }, ], }) + // Placement departures get(paths.premises.placements.departure.new.pattern, departuresController.new(), { auditEvent: 'NEW_DEPARTURE', allowedPermissions: ['cas1_space_booking_record_departure'], @@ -206,6 +207,21 @@ export default function routes(controllers: Controllers, router: Router, service auditEvent: 'NEW_DEPARTURE_CREATE', allowedPermissions: ['cas1_space_booking_record_departure'], }) + // Placement cancellations + get(paths.premises.placements.cancellations.new.pattern, cancellationsController.new(), { + auditEvent: 'NEW_CANCELLATION', + allowedPermissions: ['cas1_space_booking_withdraw'], + }) + post(paths.premises.placements.cancellations.create.pattern, cancellationsController.create(), { + auditEvent: 'CREATE_CANCELLATION_SUCCESS', + redirectAuditEventSpecs: [ + { + path: paths.premises.placements.cancellations.new.pattern, + auditEvent: 'CREATE_CANCELLATION_FAILURE', + }, + ], + allowedPermissions: ['cas1_space_booking_withdraw'], + }) // Bookings get(paths.bookings.show.pattern, bookingsController.show(), { diff --git a/server/services/placementService.test.ts b/server/services/placementService.test.ts index abf3628945..dc68703697 100644 --- a/server/services/placementService.test.ts +++ b/server/services/placementService.test.ts @@ -1,5 +1,6 @@ import { createMock } from '@golevelup/ts-jest' import type { Request } from 'express' +import type { Cas1SpaceBooking } from '@approved-premises/api' import PlacementService from './placementService' import PlacementClient from '../data/placementClient' import { ReferenceDataClient } from '../data' @@ -8,7 +9,9 @@ import { cas1NewArrivalFactory, cas1NewDepartureFactory, cas1NonArrivalFactory, + cas1SpaceBookingFactory, departureReasonFactory, + newCas1SpaceBookingCancellationFactory, nonArrivalReasonsFactory, referenceDataFactory, } from '../testutils/factories' @@ -36,6 +39,20 @@ describe('PlacementService', () => { referenceDataClientFactory.mockReturnValue(referenceDataClient) }) + describe('getPlacement', () => { + it('gets a placement summary by id', async () => { + const placementSummary: Cas1SpaceBooking = cas1SpaceBookingFactory.build() + + placementClient.getPlacement.mockResolvedValue(placementSummary) + + const result = await placementService.getPlacement(token, placementId) + + expect(result).toEqual(placementSummary) + expect(placementClientFactory).toHaveBeenCalledWith(token) + expect(placementClient.getPlacement).toHaveBeenCalledWith(placementId) + }) + }) + describe('createArrival', () => { it('calls the createArrival method of the placement client and returns a response', async () => { const newPlacementArrival = cas1NewArrivalFactory.build() @@ -187,4 +204,17 @@ describe('PlacementService', () => { expect(referenceDataClient.getReferenceData).toHaveBeenCalledWith('move-on-categories') }) }) + + describe('cancel', () => { + it('calls the cancel method of the placement client and returns a response', async () => { + const cancellation = newCas1SpaceBookingCancellationFactory.build() + placementClient.cancel.mockResolvedValue({}) + + const result = await placementService.createCancellation(token, premisesId, placementId, cancellation) + + expect(result).toEqual({}) + expect(placementClientFactory).toHaveBeenCalledWith(token) + expect(placementClient.cancel).toHaveBeenCalledWith(premisesId, placementId, cancellation) + }) + }) }) diff --git a/server/services/placementService.ts b/server/services/placementService.ts index 75014e7659..5725abee00 100644 --- a/server/services/placementService.ts +++ b/server/services/placementService.ts @@ -1,14 +1,16 @@ -import { +import type { Cas1AssignKeyWorker, Cas1NewArrival, Cas1NewDeparture, Cas1NonArrival, + Cas1SpaceBooking, DepartureReason, + NewCas1SpaceBookingCancellation, NonArrivalReason, } from '@approved-premises/api' import type { Request } from 'express' import { DepartureFormSessionData, ReferenceData } from '@approved-premises/ui' -import { ReferenceDataClient, RestClientBuilder } from '../data' +import type { ReferenceDataClient, RestClientBuilder } from '../data' import PlacementClient from '../data/placementClient' export default class PlacementService { @@ -17,6 +19,12 @@ export default class PlacementService { private readonly referenceDataClientFactory: RestClientBuilder, ) {} + async getPlacement(token: string, placementId: string): Promise { + const placementClient = this.placementClientFactory(token) + + return placementClient.getPlacement(placementId) + } + async createArrival(token: string, premisesId: string, placementId: string, newPlacementArrival: Cas1NewArrival) { const placementClient = this.placementClientFactory(token) @@ -86,4 +94,15 @@ export default class PlacementService { removeDepartureSessionData(placementId: string, session: Request['session']) { delete session?.departureForms?.[placementId] } + + async createCancellation( + token: string, + premisesId: string, + placementId: string, + cancellation: NewCas1SpaceBookingCancellation, + ) { + const placementClient = this.placementClientFactory(token) + + return placementClient.cancel(premisesId, placementId, cancellation) + } } diff --git a/server/testutils/factories/index.ts b/server/testutils/factories/index.ts index 5e8b989d93..0caa43397c 100644 --- a/server/testutils/factories/index.ts +++ b/server/testutils/factories/index.ts @@ -93,6 +93,7 @@ import cas1NewArrivalFactory from './cas1NewArrival' import cas1NewDepartureFactory from './cas1NewDeparture' import cas1SpaceBookingDepartureFactory from './cas1SpaceBookingDeparture' import cas1KeyworkerAllocationFactory from './cas1KeyworkerAllocation' +import newCas1SpaceBookingCancellationFactory from './newCas1SpaceBookingCancellation' export { acctAlertFactory, @@ -129,6 +130,7 @@ export { cas1AssignKeyWorkerFactory, cas1NewArrivalFactory, cas1NewDepartureFactory, + newCas1SpaceBookingCancellationFactory, cas1NonArrivalFactory, cas1KeyworkerAllocationFactory, clarificationNoteFactory, diff --git a/server/testutils/factories/newCas1SpaceBookingCancellation.ts b/server/testutils/factories/newCas1SpaceBookingCancellation.ts new file mode 100644 index 0000000000..226e0eb5a3 --- /dev/null +++ b/server/testutils/factories/newCas1SpaceBookingCancellation.ts @@ -0,0 +1,10 @@ +import { Factory } from 'fishery' +import { NewCas1SpaceBookingCancellation } from '@approved-premises/api' +import { faker } from '@faker-js/faker' +import { DateFormats } from '../../utils/dateUtils' + +export default Factory.define(() => ({ + occurredAt: DateFormats.dateObjToIsoDate(faker.date.recent()), + reasonId: faker.string.uuid(), + reasonNotes: faker.lorem.words(20), +})) diff --git a/server/testutils/jest.setup.ts b/server/testutils/jest.setup.ts index 2111a656d9..420acce8bb 100644 --- a/server/testutils/jest.setup.ts +++ b/server/testutils/jest.setup.ts @@ -33,13 +33,7 @@ const apiSpecs = { url: 'https://raw.githubusercontent.com/ministryofjustice/hmpps-approved-premises-api/main/src/main/resources/static/codegen/built-cas1-api-spec.yml', command: (openAPIUrl: string) => `if [ ! -f ${apiSpecPaths.cas1Spec} ]; then curl -s "${openAPIUrl}" | - sed -E 's@/premises@/cas1/premises@g' | - sed -E 's@ /out-of-service-beds@ /cas1/out-of-service-beds@g' | - sed -E 's@/spaces@/cas1/spaces@g' | - sed -E 's@ /reference-data@ /cas1/reference-data@g' | - sed -E 's@ /placement-requests@ /cas1/placement-requests@g' | - sed -E 's@/reports@/cas1/reports@g' | - sed -E 's@ /users@ /cas1/users@g' > ${apiSpecPaths.cas1Spec} + sed -E 's@^([ ]*)/@&cas1/@g' > ${apiSpecPaths.cas1Spec} fi`, specPath: apiSpecPaths.cas1Spec, }, diff --git a/server/utils/applications/withdrawables/index.test.ts b/server/utils/applications/withdrawables/index.test.ts index 1012b2bb01..fa2976bb8d 100644 --- a/server/utils/applications/withdrawables/index.test.ts +++ b/server/utils/applications/withdrawables/index.test.ts @@ -1,4 +1,4 @@ -import { bookingFactory, withdrawableFactory } from '../../../testutils/factories' +import { bookingFactory, cas1SpaceBookingFactory, withdrawableFactory } from '../../../testutils/factories' import { hintCopy, withdrawableRadioOptions, withdrawableTypeRadioOptions } from '.' import { DateFormats } from '../../dateUtils' import { linkTo } from '../../utils' @@ -53,64 +53,107 @@ describe('withdrawableTypeRadioOptions', () => { expect(withdrawableTypeRadioOptions([placementWithdrawable])).toEqual([placementRadioItem]) }) + it('should return the booking item if passed a space_booking Withdrawable', () => { + const placementWithdrawable = withdrawableFactory.build({ type: 'space_booking' }) + + expect(withdrawableTypeRadioOptions([placementWithdrawable])).toEqual([placementRadioItem]) + }) + it('returns checked: true if an item is selected', () => { const withdrawable = withdrawableFactory.buildList(1, { type: 'booking' }) expect(withdrawableTypeRadioOptions(withdrawable, 'placement')).toEqual([{ ...placementRadioItem, checked: true }]) }) describe('withdrawableRadioOptions', () => { - it('returns the withdrawables in radio input format', () => { - const paWithdrawable = withdrawableFactory.build({ type: 'placement_application' }) - const prWithdrawable = withdrawableFactory.build({ type: 'placement_request' }) - const booking = bookingFactory.build() - const placementWithdrawable = withdrawableFactory.build({ type: 'booking', id: booking.id }) + const paWithdrawable = withdrawableFactory.build({ type: 'placement_application' }) + const prWithdrawable = withdrawableFactory.build({ type: 'placement_request' }) + + const applicationAndAssesRadios = [ + { + text: paWithdrawable.dates + .map(datePeriod => + DateFormats.formatDurationBetweenTwoDates(datePeriod.startDate, datePeriod.endDate, { format: 'short' }), + ) + .join(', '), + checked: true, + value: paWithdrawable.id, + }, + { + text: prWithdrawable.dates + .map(datePeriod => + DateFormats.formatDurationBetweenTwoDates(datePeriod.startDate, datePeriod.endDate, { format: 'short' }), + ) + .join(', '), + checked: false, + hint: { + html: linkTo( + matchPaths.placementRequests.show, + { id: prWithdrawable.id }, + { + text: 'See placement details (opens in a new tab)', + attributes: { 'data-cy-withdrawable-id': prWithdrawable.id }, + openInNewTab: true, + }, + ), + }, + value: prWithdrawable.id, + }, + ] + it('returns the legacy bookings withdrawables in radio input format', () => { + const booking = bookingFactory.build() + const bookingWithdrawable = withdrawableFactory.build({ type: 'booking', id: booking.id }) expect( - withdrawableRadioOptions([paWithdrawable, prWithdrawable, placementWithdrawable], paWithdrawable.id, [booking]), + withdrawableRadioOptions([paWithdrawable, prWithdrawable, bookingWithdrawable], paWithdrawable.id, [booking]), ).toEqual([ + ...applicationAndAssesRadios, { - text: paWithdrawable.dates - .map(datePeriod => - DateFormats.formatDurationBetweenTwoDates(datePeriod.startDate, datePeriod.endDate, { format: 'short' }), - ) - .join(', '), - checked: true, - value: paWithdrawable.id, - }, - { - text: prWithdrawable.dates - .map(datePeriod => - DateFormats.formatDurationBetweenTwoDates(datePeriod.startDate, datePeriod.endDate, { format: 'short' }), - ) - .join(', '), checked: false, hint: { html: linkTo( - matchPaths.placementRequests.show, - { id: prWithdrawable.id }, + managePaths.bookings.show, + { bookingId: booking.id, premisesId: booking.premises.id }, { text: 'See placement details (opens in a new tab)', - attributes: { 'data-cy-withdrawable-id': prWithdrawable.id }, + attributes: { 'data-cy-withdrawable-id': booking.id }, openInNewTab: true, }, ), }, - value: prWithdrawable.id, + text: `${booking.premises.name} - ${bookingWithdrawable.dates + .map(datePeriod => DateFormats.formatDurationBetweenTwoDates(datePeriod.startDate, datePeriod.endDate)) + .join(', ')}`, + value: bookingWithdrawable.id, }, + ]) + }) + + it('returns placement (space_bookings) withdrawables in radio input format', () => { + const placement = cas1SpaceBookingFactory.build() + const placementWithdrawable = withdrawableFactory.build({ type: 'space_booking', id: placement.id }) + expect( + withdrawableRadioOptions( + [paWithdrawable, prWithdrawable, placementWithdrawable], + paWithdrawable.id, + [], + [placement], + ), + ).toEqual([ + ...applicationAndAssesRadios, { checked: false, hint: { html: linkTo( - managePaths.bookings.show, - { bookingId: booking.id, premisesId: booking.premises.id }, + managePaths.premises.placements.show, + { placementId: placement.id, premisesId: placement.premises.id }, { text: 'See placement details (opens in a new tab)', - attributes: { 'data-cy-withdrawable-id': booking.id }, + attributes: { 'data-cy-withdrawable-id': placement.id }, openInNewTab: true, }, ), }, - text: `${booking.premises.name} - ${placementWithdrawable.dates + text: `${placement.premises.name} - ${placementWithdrawable.dates .map(datePeriod => DateFormats.formatDurationBetweenTwoDates(datePeriod.startDate, datePeriod.endDate)) .join(', ')}`, value: placementWithdrawable.id, diff --git a/server/utils/applications/withdrawables/index.ts b/server/utils/applications/withdrawables/index.ts index 2f7c5d3c43..7c26542765 100644 --- a/server/utils/applications/withdrawables/index.ts +++ b/server/utils/applications/withdrawables/index.ts @@ -1,4 +1,4 @@ -import { Booking, Withdrawable } from '../../../@types/shared' +import { Booking, Cas1SpaceBooking, Withdrawable } from '../../../@types/shared' import { RadioItem } from '../../../@types/ui' import matchPaths from '../../../paths/match' import managePaths from '../../../paths/manage' @@ -56,7 +56,7 @@ export const withdrawableTypeRadioOptions = ( hint: { html: hintCopy.placementRequest }, }) - if (withdrawables.find(w => w.type === 'booking')) { + if (withdrawables.find(w => ['booking', 'space_booking'].includes(w.type))) { radioItems.push({ text: 'Placement/Booking', value: 'placement', @@ -74,8 +74,9 @@ export const withdrawableRadioOptions = ( withdrawables: Array, selectedWithdrawable?: Withdrawable['id'], bookings: Array = [], -): Array => { - return withdrawables.map(withdrawable => { + placements: Array = [], +): Array => + withdrawables.map(withdrawable => { if (withdrawable.type === 'placement_application') { return { text: withdrawable.dates @@ -133,7 +134,29 @@ export const withdrawableRadioOptions = ( }, } } + if (withdrawable.type === 'space_booking') { + const placement = placements.find(b => b.id === withdrawable.id) + if (!placement) throw new Error(`Placement not found for withdrawable: ${withdrawable.id}`) + + return { + text: `${placement.premises.name} - ${withdrawable.dates + .map(datePeriod => DateFormats.formatDurationBetweenTwoDates(datePeriod.startDate, datePeriod.endDate)) + .join(', ')}`, + value: withdrawable.id, + checked: selectedWithdrawable === withdrawable.id, + hint: { + html: linkTo( + managePaths.premises.placements.show, + { premisesId: placement.premises.id, placementId: withdrawable.id }, + { + text: 'See placement details (opens in a new tab)', + attributes: { 'data-cy-withdrawable-id': withdrawable.id }, + openInNewTab: true, + }, + ), + }, + } + } throw new Error(`Unknown withdrawable type: ${withdrawable.type}`) }) -} diff --git a/server/utils/placements/index.test.ts b/server/utils/placements/index.test.ts index 7f158b2516..738698e4e1 100644 --- a/server/utils/placements/index.test.ts +++ b/server/utils/placements/index.test.ts @@ -139,10 +139,10 @@ describe('placementUtils', () => { const urlOtherId = `/manage/premises/${faker.string.uuid()}` expect(getBackLink(bareUrl, premisesId)).toEqual(bareUrl) expect(getBackLink(urlWithQuery, premisesId)).toEqual(urlWithQuery) - expect(getBackLink('some string', premisesId)).toEqual(bareUrl) - expect(getBackLink('', premisesId)).toEqual(bareUrl) - expect(getBackLink(null, premisesId)).toEqual(bareUrl) - expect(getBackLink(urlOtherId, premisesId)).toEqual(bareUrl) + expect(getBackLink('some string', premisesId)).toEqual(null) + expect(getBackLink('', premisesId)).toEqual(null) + expect(getBackLink(null, premisesId)).toEqual(null) + expect(getBackLink(urlOtherId, premisesId)).toEqual(null) }) }) diff --git a/server/utils/placements/index.ts b/server/utils/placements/index.ts index 112c2d18fa..9a8299197a 100644 --- a/server/utils/placements/index.ts +++ b/server/utils/placements/index.ts @@ -68,12 +68,16 @@ const formatDate = (date: string | null) => date && DateFormats.isoDateToUIDate( const formatTime = (date: string | null) => date && DateFormats.timeFromDate(DateFormats.isoToDateObj(date)) export const getBackLink = (referrer: string, premisesId: string): string => { - const regString: string = `${paths.premises.show({ premisesId: '([0-9a-f-]{36})' })}[^/]*$` - const result = new RegExp(regString).exec(referrer) - if (result && result[1] === premisesId) { + const premisesShowPagePathRegex = paths.premises.show({ premisesId: '([0-9a-f-]{36})' }) + const premisesViewMatch = new RegExp(`${premisesShowPagePathRegex}[^/]*$`).exec(referrer) + if (premisesViewMatch && premisesViewMatch[1] === premisesId) { return referrer } - return paths.premises.show({ premisesId }) + const premisesChildMatch = new RegExp(premisesShowPagePathRegex).exec(referrer) + if (premisesChildMatch && premisesChildMatch[1] === premisesId) { + return paths.premises.show({ premisesId }) + } + return null } const summaryRow = (key: string, value: string): SummaryListItem => diff --git a/server/views/applications/withdrawables/show.njk b/server/views/applications/withdrawables/show.njk index 1a6fb16f54..007ab50b7c 100644 --- a/server/views/applications/withdrawables/show.njk +++ b/server/views/applications/withdrawables/show.njk @@ -36,7 +36,7 @@ isPageHeading: true } }, - items: ApplyUtils.withdrawableRadioOptions(withdrawables, selectedWithdrawable, bookings), + items: ApplyUtils.withdrawableRadioOptions(withdrawables, selectedWithdrawable, bookings, placements), hint: { html: hintHtml } diff --git a/server/views/cancellations/new.njk b/server/views/cancellations/new.njk index becdc9101b..3ca49ea046 100644 --- a/server/views/cancellations/new.njk +++ b/server/views/cancellations/new.njk @@ -26,7 +26,7 @@

{{pageHeading}}

-
+ {{ govukSummaryList({ rows: [ diff --git a/server/views/manage/premises/placements/show.njk b/server/views/manage/premises/placements/show.njk index f578c74031..ed8cc84f53 100644 --- a/server/views/manage/premises/placements/show.njk +++ b/server/views/manage/premises/placements/show.njk @@ -13,10 +13,12 @@ {% set mainClasses = "app-container govuk-body" %} {% block beforeContent %} - {{ govukBackLink({ - text: "Back", - href: PlacementUtils.getBackLink(referrer,placement.premises.id) - }) }} + {% if backLink %} + {{ govukBackLink({ + text: "Back", + href: backLink + }) }} + {% endif %} {% endblock %} {% block header %} diff --git a/server/views/match/placementRequests/spaceBookings/new.njk b/server/views/match/placementRequests/spaceBookings/new.njk index 45c86bfd66..6cf41f1619 100644 --- a/server/views/match/placementRequests/spaceBookings/new.njk +++ b/server/views/match/placementRequests/spaceBookings/new.njk @@ -1,6 +1,7 @@ {% from "govuk/components/summary-list/macro.njk" import govukSummaryList %} {% from "govuk/components/button/macro.njk" import govukButton %} {% from "govuk/components/back-link/macro.njk" import govukBackLink %} +{% from "../../../partials/showErrorSummary.njk" import showErrorSummary %} {% extends "../../layout-with-details.njk" %} @@ -14,6 +15,7 @@ {% endblock %} {% block content %} + {{ showErrorSummary(errorSummary, errorTitle) }}
@@ -47,9 +49,11 @@ + - + + {{ govukButton({ From d0d38e1c1469e8fc6fd28a11f65a2e4a08e6037b Mon Sep 17 00:00:00 2001 From: Bob Meredith Date: Mon, 2 Dec 2024 10:40:18 +0000 Subject: [PATCH 2/4] Review responses --- integration_tests/mockApis/cancellation.ts | 12 -- .../pages/manage/cancellationCreate.ts | 6 + .../tests/manage/cancellation.cy.ts | 33 ++--- .../apply/withdrawablesController.test.ts | 3 +- .../apply/withdrawablesController.ts | 3 +- .../manage/cancellationsController.test.ts | 70 +++++++--- server/data/placementClient.test.ts | 4 +- server/services/placementService.test.ts | 4 +- ....ts => cas1NewSpaceBookingCancellation.ts} | 0 server/testutils/factories/index.ts | 4 +- server/testutils/factories/newCancellation.ts | 2 +- server/utils/applications/utils.ts | 20 ++- .../applications/withdrawables/index.test.ts | 48 +++---- .../utils/applications/withdrawables/index.ts | 127 ++++++++---------- server/utils/assessments/tableUtils.test.ts | 12 +- server/utils/assessments/tableUtils.ts | 14 +- server/utils/assessments/utils.test.ts | 18 ++- server/utils/assessments/utils.ts | 9 +- server/utils/bedUtils.ts | 17 +-- server/utils/bookings/index.test.ts | 18 ++- server/utils/bookings/index.ts | 18 ++- server/utils/outOfServiceBedUtils.ts | 14 +- .../utils/placementApplications/table.test.ts | 28 ++-- server/utils/placementApplications/table.ts | 12 +- .../placementRequests/applicationLink.test.ts | 9 +- .../placementRequests/applicationLink.ts | 2 +- server/utils/placementRequests/table.test.ts | 44 +++--- server/utils/placementRequests/table.ts | 24 ++-- server/utils/placementRequests/utils.test.ts | 12 +- server/utils/placementRequests/utils.ts | 11 +- server/utils/premises/index.test.ts | 27 ++-- server/utils/premises/index.ts | 2 +- server/utils/tasks/listTable.test.ts | 36 ++--- server/utils/tasks/listTable.ts | 2 +- server/utils/users/tableUtils.test.ts | 4 +- server/utils/users/tableUtils.ts | 8 +- server/utils/utils.test.ts | 12 +- server/utils/utils.ts | 8 +- .../views/applications/withdrawables/show.njk | 2 +- server/views/cancellations/new.njk | 3 +- 40 files changed, 307 insertions(+), 395 deletions(-) rename server/testutils/factories/{newCas1SpaceBookingCancellation.ts => cas1NewSpaceBookingCancellation.ts} (100%) diff --git a/integration_tests/mockApis/cancellation.ts b/integration_tests/mockApis/cancellation.ts index 87fc8a45a7..1a62a9a38b 100644 --- a/integration_tests/mockApis/cancellation.ts +++ b/integration_tests/mockApis/cancellation.ts @@ -38,16 +38,4 @@ export default { url: `/premises/${args.premisesId}/bookings/${args.bookingId}/cancellations`, }) ).body.requests, - - verifySpaceBookingCancellationCreate: async (args: { - premisesId: string - placementId: string - cancellation: Cancellation - }) => - ( - await getMatchingRequests({ - method: 'POST', - url: `/cas1/premises/${args.premisesId}/space-bookings/${args.placementId}/cancellations`, - }) - ).body.requests, } diff --git a/integration_tests/pages/manage/cancellationCreate.ts b/integration_tests/pages/manage/cancellationCreate.ts index 405b341c13..8764d0f164 100644 --- a/integration_tests/pages/manage/cancellationCreate.ts +++ b/integration_tests/pages/manage/cancellationCreate.ts @@ -36,6 +36,12 @@ export default class CancellationCreatePage extends Page { this.clickSubmit() } + shouldShowBacklinkToSpaceBooking(): void { + cy.get('.govuk-back-link') + .should('have.attr', 'href') + .and('include', paths.bookings.show({ premisesId: this.premisesId, bookingId: this.spaceBookingId })) + } + shouldShowBacklinkToBooking(): void { cy.get('.govuk-back-link') .should('have.attr', 'href') diff --git a/integration_tests/tests/manage/cancellation.cy.ts b/integration_tests/tests/manage/cancellation.cy.ts index d52fd5e30e..59c607c02b 100644 --- a/integration_tests/tests/manage/cancellation.cy.ts +++ b/integration_tests/tests/manage/cancellation.cy.ts @@ -96,15 +96,8 @@ context('Cancellation', () => { page.completeForm(cancellation) // Then a cancellation should have been created in the API - cy.task('verifyCancellationCreate', { - premisesId: premises.id, - bookingId: booking.id, - cancellation, - }).then(requests => { - expect(requests).to.have.length(1) - const requestBody = JSON.parse(requests[0].body) - - expect(requestBody.reason).equal(cancellation.reason) + cy.task('verifyApiPost', `/premises/${premises.id}/bookings/${booking.id}/cancellations`).then(({ reason }) => { + expect(reason).equal(cancellation.reason) }) // And I should see a confirmation message @@ -150,9 +143,7 @@ context('Cancellation', () => { cy.task('stubSpaceBookingShow', placement) - const cancellation = newCancellationFactory.withOtherReason().build({ - otherReason: 'other reason', - }) + const cancellation = newCancellationFactory.withOtherReason().build() const withdrawable = withdrawableFactory.build({ id: placement.id, type: 'space_booking' }) cy.task('stubPremisesSummary', premises) cy.task('stubWithdrawablesWithNotes', { applicationId: application.id, withdrawables: [withdrawable] }) @@ -162,20 +153,18 @@ context('Cancellation', () => { // When I navigate to the booking's cancellation page const cancellationPage = CancellationCreatePage.visitWithSpaceBooking(premises.id, placement.id) + cancellationPage.shouldShowBackLinkToApplicationWithdraw(application.id) + // And I complete the reason and notes cancellationPage.completeForm(cancellation) // Then a cancellation should have been created in the API - cy.task('verifySpaceBookingCancellationCreate', { - premisesId: premises.id, - placementId, - cancellation, - }).then(requests => { - expect(requests).to.have.length(1) - const requestBody = JSON.parse(requests[0].body) - expect(requestBody.reasonId).equal(cancellation.reason) - expect(requestBody.reasonNotes).equal(cancellation.otherReason) - }) + cy.task('verifyApiPost', `/cas1/premises/${premises.id}/space-bookings/${placementId}/cancellations`).then( + ({ reasonNotes, reasonId }) => { + expect(reasonNotes).equal(cancellation.otherReason) + expect(reasonId).equal(cancellation.reason) + }, + ) // And I should see a confirmation message const confirmationPage = new BookingCancellationConfirmPage() diff --git a/server/controllers/apply/withdrawablesController.test.ts b/server/controllers/apply/withdrawablesController.test.ts index e37e476110..348cae3d65 100644 --- a/server/controllers/apply/withdrawablesController.test.ts +++ b/server/controllers/apply/withdrawablesController.test.ts @@ -111,8 +111,7 @@ describe('withdrawablesController', () => { pageHeading: 'Select your placement', id: applicationId, withdrawables: allPlacementWithdrawables, - bookings, - placements: spaceBookings, + allBookings: [...bookings, ...spaceBookings], withdrawableType: 'placement', notes: withdrawables.notes, }) diff --git a/server/controllers/apply/withdrawablesController.ts b/server/controllers/apply/withdrawablesController.ts index b914b31749..5d55925377 100644 --- a/server/controllers/apply/withdrawablesController.ts +++ b/server/controllers/apply/withdrawablesController.ts @@ -43,8 +43,7 @@ export default class WithdrawalsController { pageHeading: 'Select your placement', id, withdrawables: placementAndBookingWithdrawables, - bookings, - placements, + allBookings: [...bookings, ...placements], withdrawableType: 'placement', notes: withdrawables.notes, }) diff --git a/server/controllers/manage/cancellationsController.test.ts b/server/controllers/manage/cancellationsController.test.ts index 1c03178632..8f4b088508 100644 --- a/server/controllers/manage/cancellationsController.test.ts +++ b/server/controllers/manage/cancellationsController.test.ts @@ -19,7 +19,7 @@ import { DateFormats } from '../../utils/dateUtils' jest.mock('../../utils/validation') describe('cancellationsController', () => { - const token = 'SOME_TOKEN' + const token = 'TEST_TOKEN' const booking = bookingFactory.build() const placement = cas1SpaceBookingFactory.build({ applicationId: booking.applicationId }) const backLink = `${applyPaths.applications.withdrawables.show({ id: booking.applicationId })}?selectedWithdrawableType=placement` @@ -30,8 +30,9 @@ describe('cancellationsController', () => { const response: DeepMocked = createMock({ locals: { user: [] } }) const next: DeepMocked = createMock({}) - const premisesId = 'premisesId' - const bookingId = 'bookingId' + const premisesId = 'premises-id' + const bookingId = 'booking-id' + const placementId = 'placement-id' const cancellationService = createMock({}) const bookingService = createMock({}) @@ -140,6 +141,12 @@ describe('cancellationsController', () => { }) describe('create', () => { + beforeEach(() => { + jest.resetAllMocks() + bookingService.find.mockResolvedValue(booking) + placementService.getPlacement.mockResolvedValue(placement) + cancellationService.getCancellationReasons.mockResolvedValue(cancellationReasons) + }) it('creates a Cancellation and redirects to the confirmation page', async () => { const cancellation = cancellationFactory.build() @@ -222,13 +229,17 @@ describe('cancellationsController', () => { expect(response.render).toHaveBeenCalledWith('cancellations/confirm', { pageHeading: 'Booking withdrawn' }) }) - it('should catch the validation errors when the API returns an error', async () => { + it('should catch the validation errors when the API returns an error creating a booking cancellation', async () => { const requestHandler = cancellationsController.create() - - request.params = { - bookingId, - premisesId, - } + const localResponse: DeepMocked = createMock({ locals: { user: [] } }) + const localRequest: DeepMocked = createMock({ + user: { token }, + headers: { referer: backLink }, + params: { + bookingId, + premisesId, + }, + }) const err = new Error() @@ -236,15 +247,44 @@ describe('cancellationsController', () => { throw err }) - await requestHandler(request, response, next) - + await requestHandler(localRequest, localResponse, next) expect(catchValidationErrorOrPropogate).toHaveBeenCalledWith( - request, - response, + localRequest, + localResponse, err, paths.bookings.cancellations.new({ - bookingId: request.params.bookingId, - premisesId: request.params.premisesId, + bookingId, + premisesId, + }), + ) + }) + + it('should catch the validation errors when the API returns an error creating a placement cancellation', async () => { + const requestHandler = cancellationsController.create() + const localResponse: DeepMocked = createMock({ locals: { user: [] } }) + const localRequest: DeepMocked = createMock({ + user: { token }, + headers: { referer: backLink }, + params: { + placementId, + premisesId, + }, + }) + + const err = new Error() + + placementService.createCancellation.mockImplementation(() => { + throw err + }) + + await requestHandler(localRequest, localResponse, next) + expect(catchValidationErrorOrPropogate).toHaveBeenCalledWith( + localRequest, + localResponse, + err, + paths.premises.placements.cancellations.new({ + placementId, + premisesId, }), ) }) diff --git a/server/data/placementClient.test.ts b/server/data/placementClient.test.ts index 4feb240603..7711bea767 100644 --- a/server/data/placementClient.test.ts +++ b/server/data/placementClient.test.ts @@ -6,9 +6,9 @@ import { cas1AssignKeyWorkerFactory, cas1NewArrivalFactory, cas1NewDepartureFactory, + cas1NewSpaceBookingCancellationFactory, cas1NonArrivalFactory, cas1SpaceBookingFactory, - newCas1SpaceBookingCancellationFactory, } from '../testutils/factories' const token = 'TEST_TOKEN' @@ -150,7 +150,7 @@ describeCas1NamespaceClient('PlacementClient', provider => { describe('createCancellation', () => { it('cancels the given placement', async () => { - const cancellation = newCas1SpaceBookingCancellationFactory.build() + const cancellation = cas1NewSpaceBookingCancellationFactory.build() provider.addInteraction({ state: 'Server is healthy', diff --git a/server/services/placementService.test.ts b/server/services/placementService.test.ts index dc68703697..c1c51e155e 100644 --- a/server/services/placementService.test.ts +++ b/server/services/placementService.test.ts @@ -8,10 +8,10 @@ import { cas1AssignKeyWorkerFactory, cas1NewArrivalFactory, cas1NewDepartureFactory, + cas1NewSpaceBookingCancellationFactory, cas1NonArrivalFactory, cas1SpaceBookingFactory, departureReasonFactory, - newCas1SpaceBookingCancellationFactory, nonArrivalReasonsFactory, referenceDataFactory, } from '../testutils/factories' @@ -207,7 +207,7 @@ describe('PlacementService', () => { describe('cancel', () => { it('calls the cancel method of the placement client and returns a response', async () => { - const cancellation = newCas1SpaceBookingCancellationFactory.build() + const cancellation = cas1NewSpaceBookingCancellationFactory.build() placementClient.cancel.mockResolvedValue({}) const result = await placementService.createCancellation(token, premisesId, placementId, cancellation) diff --git a/server/testutils/factories/newCas1SpaceBookingCancellation.ts b/server/testutils/factories/cas1NewSpaceBookingCancellation.ts similarity index 100% rename from server/testutils/factories/newCas1SpaceBookingCancellation.ts rename to server/testutils/factories/cas1NewSpaceBookingCancellation.ts diff --git a/server/testutils/factories/index.ts b/server/testutils/factories/index.ts index 0caa43397c..4c82ffa616 100644 --- a/server/testutils/factories/index.ts +++ b/server/testutils/factories/index.ts @@ -93,7 +93,7 @@ import cas1NewArrivalFactory from './cas1NewArrival' import cas1NewDepartureFactory from './cas1NewDeparture' import cas1SpaceBookingDepartureFactory from './cas1SpaceBookingDeparture' import cas1KeyworkerAllocationFactory from './cas1KeyworkerAllocation' -import newCas1SpaceBookingCancellationFactory from './newCas1SpaceBookingCancellation' +import cas1NewSpaceBookingCancellationFactory from './cas1NewSpaceBookingCancellation' export { acctAlertFactory, @@ -130,7 +130,7 @@ export { cas1AssignKeyWorkerFactory, cas1NewArrivalFactory, cas1NewDepartureFactory, - newCas1SpaceBookingCancellationFactory, + cas1NewSpaceBookingCancellationFactory, cas1NonArrivalFactory, cas1KeyworkerAllocationFactory, clarificationNoteFactory, diff --git a/server/testutils/factories/newCancellation.ts b/server/testutils/factories/newCancellation.ts index 87b15bda08..5158be540d 100644 --- a/server/testutils/factories/newCancellation.ts +++ b/server/testutils/factories/newCancellation.ts @@ -10,7 +10,7 @@ export const otherCancellationReasonId = cancellationReasonsJson.find(r => r.nam class NewCancellationFactory extends Factory { withOtherReason() { - return this.params({ reason: otherCancellationReasonId }) + return this.params({ reason: otherCancellationReasonId, otherReason: 'other reason' }) } } diff --git a/server/utils/applications/utils.ts b/server/utils/applications/utils.ts index c185b04615..e8f8478fff 100644 --- a/server/utils/applications/utils.ts +++ b/server/utils/applications/utils.ts @@ -119,23 +119,21 @@ export const applicationSuitableStatuses: ReadonlyArray = [ export const actionsLink = (application: ApplicationSummary) => { if (application.hasRequestsForPlacement) { - return linkTo( - paths.applications.show, - { id: application.id }, - { text: 'View placement request(s)', query: { tab: applicationShowPageTabs.placementRequests } }, - ) + return linkTo(paths.applications.show({ id: application.id }), { + text: 'View placement request(s)', + query: { tab: applicationShowPageTabs.placementRequests }, + }) } if (application.status === 'started' || application.status === 'requestedFurtherInformation') { - return linkTo(paths.applications.withdraw.new, { id: application.id }, { text: 'Withdraw' }) + return linkTo(paths.applications.withdraw.new({ id: application.id }), { text: 'Withdraw' }) } if (applicationSuitableStatuses.includes(application.status) && !application.hasRequestsForPlacement) { - return linkTo( - placementApplicationPaths.placementApplications.create, - {}, - { text: 'Create request for placement', query: { id: application.id } }, - ) + return linkTo(placementApplicationPaths.placementApplications.create({}), { + text: 'Create request for placement', + query: { id: application.id }, + }) } return '' diff --git a/server/utils/applications/withdrawables/index.test.ts b/server/utils/applications/withdrawables/index.test.ts index fa2976bb8d..550f794c6a 100644 --- a/server/utils/applications/withdrawables/index.test.ts +++ b/server/utils/applications/withdrawables/index.test.ts @@ -86,15 +86,11 @@ describe('withdrawableTypeRadioOptions', () => { .join(', '), checked: false, hint: { - html: linkTo( - matchPaths.placementRequests.show, - { id: prWithdrawable.id }, - { - text: 'See placement details (opens in a new tab)', - attributes: { 'data-cy-withdrawable-id': prWithdrawable.id }, - openInNewTab: true, - }, - ), + html: linkTo(matchPaths.placementRequests.show({ id: prWithdrawable.id }), { + text: 'See placement details (opens in a new tab)', + attributes: { 'data-cy-withdrawable-id': prWithdrawable.id }, + openInNewTab: true, + }), }, value: prWithdrawable.id, }, @@ -110,18 +106,16 @@ describe('withdrawableTypeRadioOptions', () => { { checked: false, hint: { - html: linkTo( - managePaths.bookings.show, - { bookingId: booking.id, premisesId: booking.premises.id }, - { - text: 'See placement details (opens in a new tab)', - attributes: { 'data-cy-withdrawable-id': booking.id }, - openInNewTab: true, - }, - ), + html: linkTo(managePaths.bookings.show({ bookingId: booking.id, premisesId: booking.premises.id }), { + text: 'See placement details (opens in a new tab)', + attributes: { 'data-cy-withdrawable-id': booking.id }, + openInNewTab: true, + }), }, text: `${booking.premises.name} - ${bookingWithdrawable.dates - .map(datePeriod => DateFormats.formatDurationBetweenTwoDates(datePeriod.startDate, datePeriod.endDate)) + .map(datePeriod => + DateFormats.formatDurationBetweenTwoDates(datePeriod.startDate, datePeriod.endDate, { format: 'short' }), + ) .join(', ')}`, value: bookingWithdrawable.id, }, @@ -132,20 +126,16 @@ describe('withdrawableTypeRadioOptions', () => { const placement = cas1SpaceBookingFactory.build() const placementWithdrawable = withdrawableFactory.build({ type: 'space_booking', id: placement.id }) expect( - withdrawableRadioOptions( - [paWithdrawable, prWithdrawable, placementWithdrawable], - paWithdrawable.id, - [], - [placement], - ), + withdrawableRadioOptions([paWithdrawable, prWithdrawable, placementWithdrawable], paWithdrawable.id, [ + placement, + ]), ).toEqual([ ...applicationAndAssesRadios, { checked: false, hint: { html: linkTo( - managePaths.premises.placements.show, - { placementId: placement.id, premisesId: placement.premises.id }, + managePaths.premises.placements.show({ placementId: placement.id, premisesId: placement.premises.id }), { text: 'See placement details (opens in a new tab)', attributes: { 'data-cy-withdrawable-id': placement.id }, @@ -154,7 +144,9 @@ describe('withdrawableTypeRadioOptions', () => { ), }, text: `${placement.premises.name} - ${placementWithdrawable.dates - .map(datePeriod => DateFormats.formatDurationBetweenTwoDates(datePeriod.startDate, datePeriod.endDate)) + .map(datePeriod => + DateFormats.formatDurationBetweenTwoDates(datePeriod.startDate, datePeriod.endDate, { format: 'short' }), + ) .join(', ')}`, value: placementWithdrawable.id, }, diff --git a/server/utils/applications/withdrawables/index.ts b/server/utils/applications/withdrawables/index.ts index 7c26542765..b4530fd18f 100644 --- a/server/utils/applications/withdrawables/index.ts +++ b/server/utils/applications/withdrawables/index.ts @@ -1,4 +1,4 @@ -import { Booking, Cas1SpaceBooking, Withdrawable } from '../../../@types/shared' +import { Booking, BookingPremisesSummary, Cas1SpaceBooking, Withdrawable } from '../../../@types/shared' import { RadioItem } from '../../../@types/ui' import matchPaths from '../../../paths/match' import managePaths from '../../../paths/manage' @@ -73,90 +73,69 @@ export const withdrawableTypeRadioOptions = ( export const withdrawableRadioOptions = ( withdrawables: Array, selectedWithdrawable?: Withdrawable['id'], - bookings: Array = [], - placements: Array = [], -): Array => - withdrawables.map(withdrawable => { - if (withdrawable.type === 'placement_application') { - return { - text: withdrawable.dates - .map(datePeriod => - DateFormats.formatDurationBetweenTwoDates(datePeriod.startDate, datePeriod.endDate, { format: 'short' }), - ) - .join(', '), - value: withdrawable.id, - checked: selectedWithdrawable === withdrawable.id, - } - } - if (withdrawable.type === 'placement_request') { - return { - text: withdrawable.dates - .map(datePeriod => - DateFormats.formatDurationBetweenTwoDates(datePeriod.startDate, datePeriod.endDate, { format: 'short' }), - ) - .join(', '), - value: withdrawable.id, - checked: selectedWithdrawable === withdrawable.id, - hint: { - html: linkTo( - matchPaths.placementRequests.show, - { id: withdrawable.id }, - { + allBookings: Array = [], +): Array => { + const withDrawableRadioSection = ( + withdrawable: Withdrawable, + premises: BookingPremisesSummary, + hintLinkPath: string, + ) => { + const hint = hintLinkPath + ? { + hint: { + html: linkTo(hintLinkPath, { text: 'See placement details (opens in a new tab)', attributes: { 'data-cy-withdrawable-id': withdrawable.id }, openInNewTab: true, - }, - ), - }, - } + }), + }, + } + : {} + return { + text: `${premises ? `${premises.name} - ` : ''}${withdrawable.dates + .map(datePeriod => + DateFormats.formatDurationBetweenTwoDates(datePeriod.startDate, datePeriod.endDate, { format: 'short' }), + ) + .join(', ')}`, + value: withdrawable.id, + checked: selectedWithdrawable === withdrawable.id, + ...hint, + } + } + + return withdrawables.map(withdrawable => { + if (withdrawable.type === 'placement_application') { + return withDrawableRadioSection(withdrawable, null, null) + } + if (withdrawable.type === 'placement_request') { + return withDrawableRadioSection(withdrawable, null, matchPaths.placementRequests.show({ id: withdrawable.id })) } if (withdrawable.type === 'booking') { - const booking = bookings.find(b => b.id === withdrawable.id) + const booking = allBookings.find(b => b.id === withdrawable.id) as Booking if (!booking) throw new Error(`Booking not found for withdrawable: ${withdrawable.id}`) - - return { - text: `${booking.premises.name} - ${withdrawable.dates - .map(datePeriod => DateFormats.formatDurationBetweenTwoDates(datePeriod.startDate, datePeriod.endDate)) - .join(', ')}`, - value: withdrawable.id, - checked: selectedWithdrawable === withdrawable.id, - hint: { - html: linkTo( - managePaths.bookings.show, - { premisesId: booking.premises.id, bookingId: booking.id }, - { - text: 'See placement details (opens in a new tab)', - attributes: { 'data-cy-withdrawable-id': withdrawable.id }, - openInNewTab: true, - }, - ), - }, - } + return withDrawableRadioSection( + withdrawable, + booking.premises, + managePaths.bookings.show({ + premisesId: booking.premises.id, + bookingId: booking.id, + }), + ) } if (withdrawable.type === 'space_booking') { - const placement = placements.find(b => b.id === withdrawable.id) + const placement = allBookings.find(b => b.id === withdrawable.id) as Cas1SpaceBooking if (!placement) throw new Error(`Placement not found for withdrawable: ${withdrawable.id}`) - - return { - text: `${placement.premises.name} - ${withdrawable.dates - .map(datePeriod => DateFormats.formatDurationBetweenTwoDates(datePeriod.startDate, datePeriod.endDate)) - .join(', ')}`, - value: withdrawable.id, - checked: selectedWithdrawable === withdrawable.id, - hint: { - html: linkTo( - managePaths.premises.placements.show, - { premisesId: placement.premises.id, placementId: withdrawable.id }, - { - text: 'See placement details (opens in a new tab)', - attributes: { 'data-cy-withdrawable-id': withdrawable.id }, - openInNewTab: true, - }, - ), - }, - } + return withDrawableRadioSection( + withdrawable, + placement.premises, + managePaths.premises.placements.show({ + premisesId: placement.premises.id, + placementId: withdrawable.id, + }), + ) } throw new Error(`Unknown withdrawable type: ${withdrawable.type}`) }) +} diff --git a/server/utils/assessments/tableUtils.test.ts b/server/utils/assessments/tableUtils.test.ts index 585274dc79..e9b79665b7 100644 --- a/server/utils/assessments/tableUtils.test.ts +++ b/server/utils/assessments/tableUtils.test.ts @@ -34,14 +34,10 @@ describe('tableUtils', () => { it('returns a link to an assessment', () => { expect(assessmentLink(assessment, person)).toBe( - linkTo( - paths.assessments.show, - { id: assessment.id }, - { - text: laoName(person), - attributes: { 'data-cy-assessmentId': assessment.id, 'data-cy-applicationId': assessment.applicationId }, - }, - ), + linkTo(paths.assessments.show({ id: assessment.id }), { + text: laoName(person), + attributes: { 'data-cy-assessmentId': assessment.id, 'data-cy-applicationId': assessment.applicationId }, + }), ) }) diff --git a/server/utils/assessments/tableUtils.ts b/server/utils/assessments/tableUtils.ts index 584eb4c074..0656ed7dc1 100644 --- a/server/utils/assessments/tableUtils.ts +++ b/server/utils/assessments/tableUtils.ts @@ -15,15 +15,11 @@ import { sortHeader } from '../sortHeader' import { AssessmentStatusTag } from './statusTag' const assessmentLink = (assessment: AssessmentSummary, person: FullPerson, linkText = '', hiddenText = ''): string => { - return linkTo( - paths.assessments.show, - { id: assessment.id }, - { - text: linkText || laoName(person), - hiddenText, - attributes: { 'data-cy-assessmentId': assessment.id, 'data-cy-applicationId': assessment.applicationId }, - }, - ) + return linkTo(paths.assessments.show({ id: assessment.id }), { + text: linkText || laoName(person), + hiddenText, + attributes: { 'data-cy-assessmentId': assessment.id, 'data-cy-applicationId': assessment.applicationId }, + }) } export const restrictedPersonCell = (person: RestrictedPerson) => { diff --git a/server/utils/assessments/utils.test.ts b/server/utils/assessments/utils.test.ts index 5329f601b3..311750bea2 100644 --- a/server/utils/assessments/utils.test.ts +++ b/server/utils/assessments/utils.test.ts @@ -366,11 +366,10 @@ describe('utils', () => { }, { value: { - html: linkTo( - applyPaths.applications.show, - { id: application.id }, - { text: 'View application (opens in new window)', attributes: { target: '_blank' } }, - ), + html: linkTo(applyPaths.applications.show({ id: application.id }), { + text: 'View application (opens in new window)', + attributes: { target: '_blank' }, + }), }, }, ], @@ -400,11 +399,10 @@ describe('utils', () => { }, { value: { - html: linkTo( - applyPaths.applications.show, - { id: application.id }, - { text: 'View application (opens in new window)', attributes: { target: '_blank' } }, - ), + html: linkTo(applyPaths.applications.show({ id: application.id }), { + text: 'View application (opens in new window)', + attributes: { target: '_blank' }, + }), }, }, ], diff --git a/server/utils/assessments/utils.ts b/server/utils/assessments/utils.ts index 65d41be1d1..b6a3a09e52 100644 --- a/server/utils/assessments/utils.ts +++ b/server/utils/assessments/utils.ts @@ -168,11 +168,10 @@ const keyDetails = (assessment: Assessment): KeyDetailsArgs => { }, { value: { - html: linkTo( - applyPaths.applications.show, - { id: assessment.application.id }, - { text: 'View application (opens in new window)', attributes: { target: '_blank' } }, - ), + html: linkTo(applyPaths.applications.show({ id: assessment.application.id }), { + text: 'View application (opens in new window)', + attributes: { target: '_blank' }, + }), }, }, ], diff --git a/server/utils/bedUtils.ts b/server/utils/bedUtils.ts index 3e6d522414..37382e31ff 100644 --- a/server/utils/bedUtils.ts +++ b/server/utils/bedUtils.ts @@ -56,14 +56,9 @@ export const bedActions = (bed: BedDetail, premisesId: string) => { } } -export const bedLink = (bed: BedSummary, premisesId: string): string => { - return linkTo( - paths.premises.beds.show, - { bedId: bed.id, premisesId }, - { - text: 'Manage', - hiddenText: `bed ${bed.name}`, - attributes: { 'data-cy-bedId': bed.id }, - }, - ) -} +export const bedLink = (bed: BedSummary, premisesId: string): string => + linkTo(paths.premises.beds.show({ bedId: bed.id, premisesId }), { + text: 'Manage', + hiddenText: `bed ${bed.name}`, + attributes: { 'data-cy-bedId': bed.id }, + }) diff --git a/server/utils/bookings/index.test.ts b/server/utils/bookings/index.test.ts index 1d71a3939a..c616f3bd46 100644 --- a/server/utils/bookings/index.test.ts +++ b/server/utils/bookings/index.test.ts @@ -284,11 +284,10 @@ describe('bookingUtils', () => { text: 'Application', }, value: { - html: linkTo( - applyPaths.applications.show, - { id: booking.applicationId }, - { text: 'View document', hiddenText: 'View application' }, - ), + html: linkTo(applyPaths.applications.show({ id: booking.applicationId }), { + text: 'View document', + hiddenText: 'View application', + }), }, }, { @@ -296,11 +295,10 @@ describe('bookingUtils', () => { text: 'Assessment', }, value: { - html: linkTo( - assessPaths.assessments.show, - { id: booking.assessmentId }, - { text: 'View document', hiddenText: 'View assessment' }, - ), + html: linkTo(assessPaths.assessments.show({ id: booking.assessmentId }), { + text: 'View document', + hiddenText: 'View assessment', + }), }, }, ]) diff --git a/server/utils/bookings/index.ts b/server/utils/bookings/index.ts index 7254fdf0b2..21ef3f51d0 100644 --- a/server/utils/bookings/index.ts +++ b/server/utils/bookings/index.ts @@ -335,11 +335,10 @@ export const bookingShowDocumentRows = (booking: Booking): Array { } const bedLink = (bed: OutOfServiceBed, premisesId: Premises['id']): string => - linkTo( - paths.outOfServiceBeds.show, - { id: bed.id, bedId: bed.bed.id, premisesId, tab: 'details' }, - { - text: 'View', - hiddenText: `Out of service bed ${bed.bed.name}`, - attributes: { 'data-cy-bedId': bed.bed.id }, - }, - ) + linkTo(paths.outOfServiceBeds.show({ id: bed.id, bedId: bed.bed.id, premisesId, tab: 'details' }), { + text: 'View', + hiddenText: `Out of service bed ${bed.bed.name}`, + attributes: { 'data-cy-bedId': bed.bed.id }, + }) export const bedRevisionDetails = (revision: Cas1OutOfServiceBedRevision): SummaryList['rows'] => { const summaryListItems: Array = [] diff --git a/server/utils/placementApplications/table.test.ts b/server/utils/placementApplications/table.test.ts index 1234db1a5f..7b514b6754 100644 --- a/server/utils/placementApplications/table.test.ts +++ b/server/utils/placementApplications/table.test.ts @@ -52,14 +52,10 @@ describe('table', () => { nameCell(task) - expect(linkTo).toHaveBeenCalledWith( - paths.placementApplications.review.show, - { id: task.id }, - { - text: task.personName, - attributes: { 'data-cy-placementApplicationId': task.id, 'data-cy-applicationId': task.applicationId }, - }, - ) + expect(linkTo).toHaveBeenCalledWith(paths.placementApplications.review.show({ id: task.id }), { + text: task.personName, + attributes: { 'data-cy-placementApplicationId': task.id, 'data-cy-applicationId': task.applicationId }, + }) }) }) @@ -95,17 +91,13 @@ describe('table', () => { statusCell(tasks[0]), ], ]) - expect(linkTo).toHaveBeenCalledWith( - paths.placementApplications.review.show, - { id: tasks[0].id }, - { - text: tasks[0].personName, - attributes: { - 'data-cy-placementApplicationId': tasks[0].id, - 'data-cy-applicationId': tasks[0].applicationId, - }, + expect(linkTo).toHaveBeenCalledWith(paths.placementApplications.review.show({ id: tasks[0].id }), { + text: tasks[0].personName, + attributes: { + 'data-cy-placementApplicationId': tasks[0].id, + 'data-cy-applicationId': tasks[0].applicationId, }, - ) + }) }) }) }) diff --git a/server/utils/placementApplications/table.ts b/server/utils/placementApplications/table.ts index 4e36b2d8dd..79906e97e8 100644 --- a/server/utils/placementApplications/table.ts +++ b/server/utils/placementApplications/table.ts @@ -46,14 +46,10 @@ export const tableRows = (tasks: Array): Array { return { - html: linkTo( - paths.placementApplications.review.show, - { id: task.id }, - { - text: task.personName, - attributes: { 'data-cy-placementApplicationId': task.id, 'data-cy-applicationId': task.applicationId }, - }, - ), + html: linkTo(paths.placementApplications.review.show({ id: task.id }), { + text: task.personName, + attributes: { 'data-cy-placementApplicationId': task.id, 'data-cy-applicationId': task.applicationId }, + }), } } diff --git a/server/utils/placementRequests/applicationLink.test.ts b/server/utils/placementRequests/applicationLink.test.ts index 7e55c9540f..06387fc2d3 100644 --- a/server/utils/placementRequests/applicationLink.test.ts +++ b/server/utils/placementRequests/applicationLink.test.ts @@ -11,10 +11,9 @@ describe('applicationLink', () => { applicationLink(placementRequest, 'link text', 'hidden text') - expect(linkTo).toHaveBeenCalledWith( - applyPaths.applications.show, - { id: placementRequest.applicationId }, - { text: 'link text', hiddenText: 'hidden text' }, - ) + expect(linkTo).toHaveBeenCalledWith(applyPaths.applications.show({ id: placementRequest.applicationId }), { + text: 'link text', + hiddenText: 'hidden text', + }) }) }) diff --git a/server/utils/placementRequests/applicationLink.ts b/server/utils/placementRequests/applicationLink.ts index b8c850cc37..7c886c787b 100644 --- a/server/utils/placementRequests/applicationLink.ts +++ b/server/utils/placementRequests/applicationLink.ts @@ -6,4 +6,4 @@ export const applicationLink = ( placementRequestOrApplication: PlacementRequest | PlacementApplication, text: string, hiddenText: string, -) => linkTo(applyPaths.applications.show, { id: placementRequestOrApplication.applicationId }, { text, hiddenText }) +) => linkTo(applyPaths.applications.show({ id: placementRequestOrApplication.applicationId }), { text, hiddenText }) diff --git a/server/utils/placementRequests/table.test.ts b/server/utils/placementRequests/table.test.ts index a95b3aae2f..88c558a0ec 100644 --- a/server/utils/placementRequests/table.test.ts +++ b/server/utils/placementRequests/table.test.ts @@ -46,14 +46,10 @@ describe('tableUtils', () => { nameCell(task) - expect(linkTo).toHaveBeenCalledWith( - matchPaths.placementRequests.show, - { id: task.id }, - { - text: task.personName, - attributes: { 'data-cy-placementRequestId': task.id, 'data-cy-applicationId': task.applicationId }, - }, - ) + expect(linkTo).toHaveBeenCalledWith(matchPaths.placementRequests.show({ id: task.id }), { + text: task.personName, + attributes: { 'data-cy-placementRequestId': task.id, 'data-cy-applicationId': task.applicationId }, + }) }) it('returns the name of the service user and a link with a placement request', () => { @@ -61,17 +57,13 @@ describe('tableUtils', () => { nameCell(placementRequest) - expect(linkTo).toHaveBeenCalledWith( - adminPaths.admin.placementRequests.show, - { id: placementRequest.id }, - { - text: laoName(placementRequest.person as FullPerson), - attributes: { - 'data-cy-placementRequestId': placementRequest.id, - 'data-cy-applicationId': placementRequest.applicationId, - }, + expect(linkTo).toHaveBeenCalledWith(adminPaths.admin.placementRequests.show({ id: placementRequest.id }), { + text: laoName(placementRequest.person as FullPerson), + attributes: { + 'data-cy-placementRequestId': placementRequest.id, + 'data-cy-applicationId': placementRequest.applicationId, }, - ) + }) }) it('returns an empty cell if the personName is blank', () => { @@ -105,17 +97,13 @@ describe('tableUtils', () => { nameCell(restrictedPersonTask) - expect(linkTo).toHaveBeenCalledWith( - adminPaths.admin.placementRequests.show, - { id: restrictedPersonTask.id }, - { - text: laoName(restrictedPersonTask.person as FullPerson), - attributes: { - 'data-cy-placementRequestId': restrictedPersonTask.id, - 'data-cy-applicationId': restrictedPersonTask.applicationId, - }, + expect(linkTo).toHaveBeenCalledWith(adminPaths.admin.placementRequests.show({ id: restrictedPersonTask.id }), { + text: laoName(restrictedPersonTask.person as FullPerson), + attributes: { + 'data-cy-placementRequestId': restrictedPersonTask.id, + 'data-cy-applicationId': restrictedPersonTask.applicationId, }, - ) + }) }) it('returns the crn cell if the person is a unknown person', () => { diff --git a/server/utils/placementRequests/table.ts b/server/utils/placementRequests/table.ts index e0c4024ccf..7b627bc80d 100644 --- a/server/utils/placementRequests/table.ts +++ b/server/utils/placementRequests/table.ts @@ -101,26 +101,18 @@ export const applicationDateCell = (item: PlacementRequest): TableCell => ({ export const nameCell = (item: PlacementRequestTask | PlacementRequest): TableCell => { if ('personName' in item && item.personName) { return { - html: linkTo( - matchPaths.placementRequests.show, - { id: item.id }, - { - text: item.personName, - attributes: { 'data-cy-placementRequestId': item.id, 'data-cy-applicationId': item.applicationId }, - }, - ), + html: linkTo(matchPaths.placementRequests.show({ id: item.id }), { + text: item.personName, + attributes: { 'data-cy-placementRequestId': item.id, 'data-cy-applicationId': item.applicationId }, + }), } } if ('person' in item && item.person && isFullPerson(item.person)) { return { - html: linkTo( - adminPaths.admin.placementRequests.show, - { id: item.id }, - { - text: laoName(item.person), - attributes: { 'data-cy-placementRequestId': item.id, 'data-cy-applicationId': item.applicationId }, - }, - ), + html: linkTo(adminPaths.admin.placementRequests.show({ id: item.id }), { + text: laoName(item.person), + attributes: { 'data-cy-placementRequestId': item.id, 'data-cy-applicationId': item.applicationId }, + }), } } diff --git a/server/utils/placementRequests/utils.test.ts b/server/utils/placementRequests/utils.test.ts index 5961d7d82d..2b1425d23c 100644 --- a/server/utils/placementRequests/utils.test.ts +++ b/server/utils/placementRequests/utils.test.ts @@ -54,8 +54,7 @@ describe('utils', () => { searchButton(placementRequest) expect(utils.linkTo).toHaveBeenCalledWith( - paths.v2Match.placementRequests.search.spaces, - { id: placementRequest.id }, + paths.v2Match.placementRequests.search.spaces({ id: placementRequest.id }), { text: 'Search', attributes: { class: 'govuk-button' } }, ) }) @@ -68,11 +67,10 @@ describe('utils', () => { assessmentLink(placementRequest, 'link text', 'hidden text') - expect(utils.linkTo).toHaveBeenCalledWith( - assessPaths.assessments.show, - { id: placementRequest.assessmentId }, - { text: 'link text', hiddenText: 'hidden text' }, - ) + expect(utils.linkTo).toHaveBeenCalledWith(assessPaths.assessments.show({ id: placementRequest.assessmentId }), { + text: 'link text', + hiddenText: 'hidden text', + }) }) }) diff --git a/server/utils/placementRequests/utils.ts b/server/utils/placementRequests/utils.ts index d36a8c9b3a..df27c4a217 100644 --- a/server/utils/placementRequests/utils.ts +++ b/server/utils/placementRequests/utils.ts @@ -34,14 +34,13 @@ export const mapPlacementRequestToSpaceSearchParams = ({ export const formatReleaseType = (placementRequest: PlacementRequest) => allReleaseTypes[placementRequest.releaseType] export const searchButton = (placementRequest: PlacementRequest) => - linkTo( - paths.v2Match.placementRequests.search.spaces, - { id: placementRequest.id }, - { text: 'Search', attributes: { class: 'govuk-button' } }, - ) + linkTo(paths.v2Match.placementRequests.search.spaces({ id: placementRequest.id }), { + text: 'Search', + attributes: { class: 'govuk-button' }, + }) export const assessmentLink = (placementRequest: PlacementRequest, text: string, hiddenText: string) => - linkTo(assessPaths.assessments.show, { id: placementRequest.assessmentId }, { text, hiddenText }) + linkTo(assessPaths.assessments.show({ id: placementRequest.assessmentId }), { text, hiddenText }) export const requestTypes = [ { name: 'Parole', value: 'parole' }, diff --git a/server/utils/premises/index.test.ts b/server/utils/premises/index.test.ts index 67478a26a4..0bfa56335d 100644 --- a/server/utils/premises/index.test.ts +++ b/server/utils/premises/index.test.ts @@ -145,11 +145,10 @@ describe('premisesUtils', () => { text: premises2.bedCount.toString(), }, { - html: linkTo( - paths.premises.show, - { premisesId: premises2.id }, - { text: 'View', hiddenText: `about ${premises2.name}` }, - ), + html: linkTo(paths.premises.show({ premisesId: premises2.id }), { + text: 'View', + hiddenText: `about ${premises2.name}`, + }), }, ], [ @@ -163,11 +162,10 @@ describe('premisesUtils', () => { text: premises3.bedCount.toString(), }, { - html: linkTo( - paths.premises.show, - { premisesId: premises3.id }, - { text: 'View', hiddenText: `about ${premises3.name}` }, - ), + html: linkTo(paths.premises.show({ premisesId: premises3.id }), { + text: 'View', + hiddenText: `about ${premises3.name}`, + }), }, ], [ @@ -181,11 +179,10 @@ describe('premisesUtils', () => { text: premises1.bedCount.toString(), }, { - html: linkTo( - paths.premises.show, - { premisesId: premises1.id }, - { text: 'View', hiddenText: `about ${premises1.name}` }, - ), + html: linkTo(paths.premises.show({ premisesId: premises1.id }), { + text: 'View', + hiddenText: `about ${premises1.name}`, + }), }, ], ]) diff --git a/server/utils/premises/index.ts b/server/utils/premises/index.ts index c032ad7833..cc46649368 100644 --- a/server/utils/premises/index.ts +++ b/server/utils/premises/index.ts @@ -89,7 +89,7 @@ export const premisesTableRows = (premisesSummaries: Array { personSummary, }) expect(nameAnchorCell(task)).toEqual({ - html: linkTo( - paths.tasks.show, - { id: task.id, taskType: kebabCase(task.taskType) }, - { - text: personSummary.name, - attributes: { 'data-cy-taskId': task.id, 'data-cy-applicationId': task.applicationId }, - }, - ), + html: linkTo(paths.tasks.show({ id: task.id, taskType: kebabCase(task.taskType) }), { + text: personSummary.name, + attributes: { 'data-cy-taskId': task.id, 'data-cy-applicationId': task.applicationId }, + }), }) }) it('returns the Limited Access Offender (LAO) CRN when the person summary is RestrictedPersonSummary in the task', () => { @@ -222,14 +218,10 @@ describe('table', () => { personSummary, }) expect(nameAnchorCell(task)).toEqual({ - html: linkTo( - paths.tasks.show, - { id: task.id, taskType: kebabCase(task.taskType) }, - { - text: `LAO CRN: ${personSummary.crn}`, - attributes: { 'data-cy-taskId': task.id, 'data-cy-applicationId': task.applicationId }, - }, - ), + html: linkTo(paths.tasks.show({ id: task.id, taskType: kebabCase(task.taskType) }), { + text: `LAO CRN: ${personSummary.crn}`, + attributes: { 'data-cy-taskId': task.id, 'data-cy-applicationId': task.applicationId }, + }), }) }) it('returns the not found CRN when the person summary is UnknownPersonSummary in the task', () => { @@ -239,14 +231,10 @@ describe('table', () => { personSummary, }) expect(nameAnchorCell(task)).toEqual({ - html: linkTo( - paths.tasks.show, - { id: task.id, taskType: kebabCase(task.taskType) }, - { - text: `Not Found CRN: ${personSummary.crn}`, - attributes: { 'data-cy-taskId': task.id, 'data-cy-applicationId': task.applicationId }, - }, - ), + html: linkTo(paths.tasks.show({ id: task.id, taskType: kebabCase(task.taskType) }), { + text: `Not Found CRN: ${personSummary.crn}`, + attributes: { 'data-cy-taskId': task.id, 'data-cy-applicationId': task.applicationId }, + }), }) }) }) diff --git a/server/utils/tasks/listTable.ts b/server/utils/tasks/listTable.ts index ba2c5bf61a..001b2a9783 100644 --- a/server/utils/tasks/listTable.ts +++ b/server/utils/tasks/listTable.ts @@ -105,7 +105,7 @@ const getPersonName = (personSummary: PersonSummary): string => { } const nameAnchorCell = (task: Task): TableCell => ({ - html: linkTo(paths.tasks.show, taskParams(task), { + html: linkTo(paths.tasks.show(taskParams(task)), { text: getPersonName(task.personSummary), attributes: { 'data-cy-taskId': task.id, 'data-cy-applicationId': task.applicationId }, }), diff --git a/server/utils/users/tableUtils.test.ts b/server/utils/users/tableUtils.test.ts index 7aaf1ba778..f68b6e7f36 100644 --- a/server/utils/users/tableUtils.test.ts +++ b/server/utils/users/tableUtils.test.ts @@ -55,7 +55,7 @@ describe('tableUtils', () => { const user = users[0] expect(managementDashboardTableRows(users)).toEqual([ [ - { html: linkTo(paths.admin.userManagement.edit, { id: user.id }, { text: user.name }) }, + { html: linkTo(paths.admin.userManagement.edit({ id: user.id }), { text: user.name }) }, { text: '' }, { text: 'Standard' }, { text: user.email }, @@ -69,7 +69,7 @@ describe('tableUtils', () => { it('returns a cell with the persons name as a link to the edit page', () => { const user = userFactory.build() expect(nameCell(user)).toEqual({ - html: linkTo(paths.admin.userManagement.edit, { id: user.id }, { text: user.name }), + html: linkTo(paths.admin.userManagement.edit({ id: user.id }), { text: user.name }), }) }) }) diff --git a/server/utils/users/tableUtils.ts b/server/utils/users/tableUtils.ts index de22ec7340..0fa7aa1828 100644 --- a/server/utils/users/tableUtils.ts +++ b/server/utils/users/tableUtils.ts @@ -37,11 +37,9 @@ export const managementDashboardTableRows = (users: Array): Array [nameCell(user), roleCell(user), allocationCell(user), emailCell(user), apAreaCell(user)]) } -export const nameCell = (user: User): TableCell => { - return { - html: linkTo(paths.admin.userManagement.edit, { id: user.id }, { text: user.name }), - } -} +export const nameCell = (user: User): TableCell => ({ + html: linkTo(paths.admin.userManagement.edit({ id: user.id }), { text: user.name }), +}) export const roleCell = (user: User): TableCell => { return { diff --git a/server/utils/utils.test.ts b/server/utils/utils.test.ts index 2ad0e16eb2..93d622cc81 100644 --- a/server/utils/utils.test.ts +++ b/server/utils/utils.test.ts @@ -224,36 +224,36 @@ describe('mapApiPersonRiskForUI', () => { describe('linkTo', () => { it('returns a generic link', () => { - expect(linkTo(path('/foo'), {}, { text: 'Hello' })).toMatchStringIgnoringWhitespace('Hello') + expect(linkTo(path('/foo')({}), { text: 'Hello' })).toMatchStringIgnoringWhitespace('Hello') }) it('allows params to be specified', () => { - expect(linkTo(path('/foo/:id'), { id: '123' }, { text: 'Hello' })).toMatchStringIgnoringWhitespace( + expect(linkTo(path('/foo/:id')({ id: '123' }), { text: 'Hello' })).toMatchStringIgnoringWhitespace( 'Hello', ) }) it('allows hidden text to be specified', () => { expect( - linkTo(path('/foo/:id'), { id: '123' }, { text: 'Hello', hiddenText: 'Hidden' }), + linkTo(path('/foo/:id')({ id: '123' }), { text: 'Hello', hiddenText: 'Hidden' }), ).toMatchStringIgnoringWhitespace('Hello Hidden') }) it('allows attributes to be specified', () => { expect( - linkTo(path('/foo/:id'), { id: '123' }, { text: 'Hello', attributes: { class: 'some-class' } }), + linkTo(path('/foo/:id')({ id: '123' }), { text: 'Hello', attributes: { class: 'some-class' } }), ).toMatchStringIgnoringWhitespace('Hello') }) it('allows a query to be passed', () => { - expect(linkTo(path('/foo'), {}, { text: 'Hello', query: { foo: 'bar' } })).toMatchStringIgnoringWhitespace( + expect(linkTo(path('/foo')({}), { text: 'Hello', query: { foo: 'bar' } })).toMatchStringIgnoringWhitespace( 'Hello', ) }) it('returns a link that will open in a new tab', () => { expect( - linkTo(path('/foo'), {}, { text: 'Hello', query: { foo: 'bar' }, openInNewTab: true }), + linkTo(path('/foo')({}), { text: 'Hello', query: { foo: 'bar' }, openInNewTab: true }), ).toMatchStringIgnoringWhitespace('Hello') }) }) diff --git a/server/utils/utils.ts b/server/utils/utils.ts index a71d49005f..3b2afec349 100644 --- a/server/utils/utils.ts +++ b/server/utils/utils.ts @@ -1,5 +1,4 @@ import Case from 'case' -import { Params, Path } from 'static-path' import qs, { IStringifyOptions } from 'qs' import type { PersonRisksUI, SummaryListItem } from '@approved-premises/ui' @@ -106,9 +105,8 @@ export const mapApiPersonRisksForUi = (risks: PersonRisks): PersonRisksUI => { } } -export const linkTo = ( - path: Path, - params: Params, +export const linkTo = ( + path: string, { text, query = {}, @@ -133,7 +131,7 @@ export const linkTo = ( .map(a => `${a}="${attributes[a]}"`) .join(' ') - return `${linkBody}` + return `${linkBody}` } /** diff --git a/server/views/applications/withdrawables/show.njk b/server/views/applications/withdrawables/show.njk index 007ab50b7c..087d2c3b5b 100644 --- a/server/views/applications/withdrawables/show.njk +++ b/server/views/applications/withdrawables/show.njk @@ -36,7 +36,7 @@ isPageHeading: true } }, - items: ApplyUtils.withdrawableRadioOptions(withdrawables, selectedWithdrawable, bookings, placements), + items: ApplyUtils.withdrawableRadioOptions(withdrawables, selectedWithdrawable, allBookings), hint: { html: hintHtml } diff --git a/server/views/cancellations/new.njk b/server/views/cancellations/new.njk index 3ca49ea046..b4f1985fc0 100644 --- a/server/views/cancellations/new.njk +++ b/server/views/cancellations/new.njk @@ -21,6 +21,7 @@ {% endblock %} {% block content %} + {{ showErrorSummary(errorSummary) }}
@@ -66,7 +67,7 @@ }) }} - {{ showErrorSummary(errorSummary) }} + {% set noteConditional %} {{ From d60034c00eddfcda5960bd247d1af08d01dedf48 Mon Sep 17 00:00:00 2001 From: Bob Meredith Date: Mon, 2 Dec 2024 14:10:52 +0000 Subject: [PATCH 3/4] Fix back-link on cancel of space booking linked to offline application. --- .../pages/manage/cancellationCreate.ts | 7 ++--- .../tests/manage/cancellation.cy.ts | 30 +++++++++++++++++++ .../manage/cancellationsController.ts | 4 ++- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/integration_tests/pages/manage/cancellationCreate.ts b/integration_tests/pages/manage/cancellationCreate.ts index 8764d0f164..da3578a101 100644 --- a/integration_tests/pages/manage/cancellationCreate.ts +++ b/integration_tests/pages/manage/cancellationCreate.ts @@ -8,7 +8,6 @@ export default class CancellationCreatePage extends Page { constructor( public readonly premisesId: string, public readonly bookingId: string, - public readonly spaceBookingId: string, ) { super('Confirm withdrawn placement') } @@ -16,13 +15,13 @@ export default class CancellationCreatePage extends Page { static visit(premisesId: string, bookingId: string): CancellationCreatePage { cy.visit(paths.bookings.cancellations.new({ premisesId, bookingId })) - return new CancellationCreatePage(premisesId, bookingId, undefined) + return new CancellationCreatePage(premisesId, bookingId) } static visitWithSpaceBooking(premisesId: string, placementId: string): CancellationCreatePage { cy.visit(paths.premises.placements.cancellations.new({ premisesId, placementId })) - return new CancellationCreatePage(premisesId, undefined, placementId) + return new CancellationCreatePage(premisesId, placementId) } completeForm(cancellation: NewCancellation): void { @@ -39,7 +38,7 @@ export default class CancellationCreatePage extends Page { shouldShowBacklinkToSpaceBooking(): void { cy.get('.govuk-back-link') .should('have.attr', 'href') - .and('include', paths.bookings.show({ premisesId: this.premisesId, bookingId: this.spaceBookingId })) + .and('include', paths.premises.placements.show({ premisesId: this.premisesId, placementId: this.bookingId })) } shouldShowBacklinkToBooking(): void { diff --git a/integration_tests/tests/manage/cancellation.cy.ts b/integration_tests/tests/manage/cancellation.cy.ts index 59c607c02b..637982512d 100644 --- a/integration_tests/tests/manage/cancellation.cy.ts +++ b/integration_tests/tests/manage/cancellation.cy.ts @@ -170,4 +170,34 @@ context('Cancellation', () => { const confirmationPage = new BookingCancellationConfirmPage() confirmationPage.shouldShowPanel() }) + + it('should allow me to create a cancellation for a space-booking without an applicationId', () => { + // Given a placement is available + const premises = premisesFactory.build() + const placement = cas1SpaceBookingFactory.build({ applicationId: undefined }) + cy.task('stubSpaceBookingGetWithoutPremises', placement) + + // When I navigate to the placements's cancellation page + const cancellation = newCancellationFactory.build() + cy.task('stubCancellationCreate', { premisesId: premises.id, placementId: placement.id, cancellation }) + + const page = CancellationCreatePage.visitWithSpaceBooking(premises.id, placement.id) + + // Then the backlink should be populated correctly + page.shouldShowBacklinkToSpaceBooking() + + // When I fill out the cancellation form + page.completeForm(cancellation) + + // Then a cancellation should have been created in the API + cy.task('verifyApiPost', `/cas1/premises/${premises.id}/space-bookings/${placement.id}/cancellations`).then( + ({ reasonId }) => { + expect(reasonId).equal(cancellation.reason) + }, + ) + + // And I should see a confirmation message + const confirmationPage = new BookingCancellationConfirmPage() + confirmationPage.shouldShowPanel() + }) }) diff --git a/server/controllers/manage/cancellationsController.ts b/server/controllers/manage/cancellationsController.ts index 8874a53bdc..e53a390e02 100644 --- a/server/controllers/manage/cancellationsController.ts +++ b/server/controllers/manage/cancellationsController.ts @@ -31,7 +31,9 @@ export default class CancellationsController { if (applicationId) { backLink = `${applyPaths.applications.withdrawables.show({ id: applicationId })}?selectedWithdrawableType=placement` } else { - backLink = bookingId ? paths.bookings.show({ premisesId, bookingId }) : '' + backLink = bookingId + ? paths.bookings.show({ premisesId, bookingId }) + : paths.premises.placements.show({ premisesId, placementId }) } const consolidatedBooking = { id: booking?.id || placement?.id, From 657778884ca6ede4092b040fad977664f539af45 Mon Sep 17 00:00:00 2001 From: Bob Meredith Date: Mon, 2 Dec 2024 15:36:07 +0000 Subject: [PATCH 4/4] Use api paths in tests. --- .../tests/manage/cancellation.cy.ts | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/integration_tests/tests/manage/cancellation.cy.ts b/integration_tests/tests/manage/cancellation.cy.ts index 637982512d..fff4f22e49 100644 --- a/integration_tests/tests/manage/cancellation.cy.ts +++ b/integration_tests/tests/manage/cancellation.cy.ts @@ -12,6 +12,7 @@ import { import { CancellationCreatePage } from '../../pages/manage' import { signIn } from '../signIn' import BookingCancellationConfirmPage from '../../pages/manage/bookingCancellationConfirmation' +import apiPaths from '../../../server/paths/api' context('Cancellation', () => { beforeEach(() => { @@ -140,18 +141,19 @@ context('Cancellation', () => { const premises = extendedPremisesSummaryFactory.build({ bookings: [placement], id: placement.premises.id }) const placementId = placement.id + const premisesId = premises.id cy.task('stubSpaceBookingShow', placement) const cancellation = newCancellationFactory.withOtherReason().build() - const withdrawable = withdrawableFactory.build({ id: placement.id, type: 'space_booking' }) + const withdrawable = withdrawableFactory.build({ id: placementId, type: 'space_booking' }) cy.task('stubPremisesSummary', premises) cy.task('stubWithdrawablesWithNotes', { applicationId: application.id, withdrawables: [withdrawable] }) cy.task('stubSpaceBookingGetWithoutPremises', placement) - cy.task('stubCancellationCreate', { premisesId: premises.id, placementId, cancellation }) + cy.task('stubCancellationCreate', { premisesId, placementId, cancellation }) // When I navigate to the booking's cancellation page - const cancellationPage = CancellationCreatePage.visitWithSpaceBooking(premises.id, placement.id) + const cancellationPage = CancellationCreatePage.visitWithSpaceBooking(premisesId, placementId) cancellationPage.shouldShowBackLinkToApplicationWithdraw(application.id) @@ -159,7 +161,7 @@ context('Cancellation', () => { cancellationPage.completeForm(cancellation) // Then a cancellation should have been created in the API - cy.task('verifyApiPost', `/cas1/premises/${premises.id}/space-bookings/${placementId}/cancellations`).then( + cy.task('verifyApiPost', apiPaths.premises.placements.cancel({ premisesId, placementId })).then( ({ reasonNotes, reasonId }) => { expect(reasonNotes).equal(cancellation.otherReason) expect(reasonId).equal(cancellation.reason) @@ -175,13 +177,15 @@ context('Cancellation', () => { // Given a placement is available const premises = premisesFactory.build() const placement = cas1SpaceBookingFactory.build({ applicationId: undefined }) + const placementId = placement.id + const premisesId = premises.id cy.task('stubSpaceBookingGetWithoutPremises', placement) // When I navigate to the placements's cancellation page const cancellation = newCancellationFactory.build() - cy.task('stubCancellationCreate', { premisesId: premises.id, placementId: placement.id, cancellation }) + cy.task('stubCancellationCreate', { premisesId, placementId, cancellation }) - const page = CancellationCreatePage.visitWithSpaceBooking(premises.id, placement.id) + const page = CancellationCreatePage.visitWithSpaceBooking(premisesId, placementId) // Then the backlink should be populated correctly page.shouldShowBacklinkToSpaceBooking() @@ -190,11 +194,9 @@ context('Cancellation', () => { page.completeForm(cancellation) // Then a cancellation should have been created in the API - cy.task('verifyApiPost', `/cas1/premises/${premises.id}/space-bookings/${placement.id}/cancellations`).then( - ({ reasonId }) => { - expect(reasonId).equal(cancellation.reason) - }, - ) + cy.task('verifyApiPost', apiPaths.premises.placements.cancel({ premisesId, placementId })).then(({ reasonId }) => { + expect(reasonId).equal(cancellation.reason) + }) // And I should see a confirmation message const confirmationPage = new BookingCancellationConfirmPage()