diff --git a/backend/server/routes/establishments/mandatoryTraining/index.js b/backend/server/routes/establishments/mandatoryTraining/index.js index 028366abb8..92b44d8d3b 100644 --- a/backend/server/routes/establishments/mandatoryTraining/index.js +++ b/backend/server/routes/establishments/mandatoryTraining/index.js @@ -14,8 +14,35 @@ const { hasPermission } = require('../../../utils/security/hasPermission'); const viewMandatoryTraining = async (req, res) => { const establishmentId = req.establishmentId; + const previousAllJobsLengths = [29, 31, 32]; + try { - const allMandatoryTrainingRecords = await MandatoryTraining.fetch(establishmentId); + let allMandatoryTrainingRecords = await MandatoryTraining.fetch(establishmentId); + let duplicateJobRolesUpdated = false; + + if (allMandatoryTrainingRecords?.mandatoryTraining?.length) { + for (mandatoryTrainingCategory of allMandatoryTrainingRecords.mandatoryTraining) { + if ( + hasDuplicateJobs(mandatoryTrainingCategory.jobs) && + previousAllJobsLengths.includes(mandatoryTrainingCategory.jobs.length) + ) { + duplicateJobRolesUpdated = true; + + const thisMandatoryTrainingRecord = new MandatoryTraining(establishmentId); + await thisMandatoryTrainingRecord.load({ + trainingCategoryId: mandatoryTrainingCategory.trainingCategoryId, + allJobRoles: true, + jobs: [], + }); + await thisMandatoryTrainingRecord.save(req.userUid); + } + } + + if (duplicateJobRolesUpdated) { + allMandatoryTrainingRecords = await MandatoryTraining.fetch(establishmentId); + } + } + return res.status(200).json(allMandatoryTrainingRecords); } catch (err) { console.error(err); @@ -23,6 +50,19 @@ const viewMandatoryTraining = async (req, res) => { } }; +const hasDuplicateJobs = (jobRoles) => { + const seenJobIds = new Set(); + + for (const jobRole of jobRoles) { + if (seenJobIds.has(jobRole.id)) { + return true; // Duplicate found + } + seenJobIds.add(jobRole.id); + } + + return false; // No duplicates found +}; + /** * Handle GET request for getting all saved mandatory training for view all mandatory training */ @@ -30,6 +70,7 @@ const viewAllMandatoryTraining = async (req, res) => { const establishmentId = req.establishmentId; try { const allMandatoryTrainingRecords = await MandatoryTraining.fetchAllMandatoryTrainings(establishmentId); + return res.status(200).json(allMandatoryTrainingRecords); } catch (err) { console.error(err); @@ -93,3 +134,4 @@ router.route('/:categoryId').delete(deleteMandatoryTrainingById); module.exports = router; module.exports.createAndUpdateMandatoryTraining = createAndUpdateMandatoryTraining; module.exports.deleteMandatoryTrainingById = deleteMandatoryTrainingById; +module.exports.viewMandatoryTraining = viewMandatoryTraining; diff --git a/backend/server/test/unit/routes/establishments/mandatoryTraining/index.spec.js b/backend/server/test/unit/routes/establishments/mandatoryTraining/index.spec.js index 46c7289f6a..45049678b0 100644 --- a/backend/server/test/unit/routes/establishments/mandatoryTraining/index.spec.js +++ b/backend/server/test/unit/routes/establishments/mandatoryTraining/index.spec.js @@ -6,6 +6,7 @@ const sinon = require('sinon'); const { createAndUpdateMandatoryTraining, deleteMandatoryTrainingById, + viewMandatoryTraining, } = require('../../../../../routes/establishments/mandatoryTraining/index.js'); const MandatoryTraining = require('../../../../../models/classes/mandatoryTraining').MandatoryTraining; @@ -34,6 +35,7 @@ describe('mandatoryTraining/index.js', () => { expect(res.statusCode).to.deep.equal(500); }); }); + describe('createAndUpdateMandatoryTraining', () => { it('should save the record for mandatory training if isvalid , not exists and all job role is selected', async () => { sinon.stub(MandatoryTraining.prototype, 'load').callsFake(() => { @@ -84,4 +86,120 @@ describe('mandatoryTraining/index.js', () => { expect(res.statusCode).to.deep.equal(500); }); }); + + describe('viewMandatoryTraining', () => { + let req; + let res; + + beforeEach(() => { + req = httpMocks.createRequest(); + req.establishmentId = 'mockId'; + req.userUid = 'abc123'; + res = httpMocks.createResponse(); + }); + + const createMockFetchData = () => { + return { + allJobRolesCount: 37, + lastUpdated: '2025-01-03T11:55:55.734Z', + mandatoryTraining: [ + { + category: 'Activity provision, wellbeing', + establishmentId: 100, + jobs: [{ id: 22, title: 'Registered manager' }], + trainingCategoryId: 1, + }, + ], + mandatoryTrainingCount: 1, + }; + }; + + it('should fetch all mandatory training for establishment passed in request', async () => { + const fetchSpy = sinon.stub(MandatoryTraining, 'fetch').callsFake(() => createMockFetchData()); + + await viewMandatoryTraining(req, res); + expect(res.statusCode).to.deep.equal(200); + expect(fetchSpy).to.have.been.calledWith(req.establishmentId); + }); + + it('should return data from fetch and 200 status if fetch successful', async () => { + const mockFetchData = createMockFetchData(); + sinon.stub(MandatoryTraining, 'fetch').callsFake(() => mockFetchData); + + await viewMandatoryTraining(req, res); + expect(res.statusCode).to.equal(200); + expect(res._getJSONData()).to.deep.equal(mockFetchData); + }); + + it('should return 500 status if error when fetching data', async () => { + sinon.stub(MandatoryTraining, 'fetch').throws('Unexpected error'); + + await viewMandatoryTraining(req, res); + expect(res.statusCode).to.equal(500); + }); + + describe('Handling duplicate job roles', () => { + it('should not call load or save for mandatory training when no duplicate job roles in retrieved mandatory training', async () => { + const loadSpy = sinon.stub(MandatoryTraining.prototype, 'load'); + const saveSpy = sinon.stub(MandatoryTraining.prototype, 'save'); + + sinon.stub(MandatoryTraining, 'fetch').callsFake(() => createMockFetchData()); + + await viewMandatoryTraining(req, res); + expect(loadSpy).not.to.have.been.called; + expect(saveSpy).not.to.have.been.called; + }); + + const previousAllJobsLengths = [29, 31, 32]; + + previousAllJobsLengths.forEach((allJobsLength) => { + it(`should call load and save for mandatory training instance when duplicate job roles in retrieved mandatory training and has length of previous all job roles (${allJobsLength})`, async () => { + const loadSpy = sinon.stub(MandatoryTraining.prototype, 'load'); + const saveSpy = sinon.stub(MandatoryTraining.prototype, 'save'); + + const mockFetchData = createMockFetchData(); + + const mockJobRoles = Array.from({ length: allJobsLength - 1 }, (_, index) => ({ + id: index + 1, + title: `Job role ${index + 1}`, + })); + mockJobRoles.push({ id: 1, title: 'Job role 1' }); + mockFetchData.mandatoryTraining[0].jobs = mockJobRoles; + + const fetchSpy = sinon.stub(MandatoryTraining, 'fetch').callsFake(() => mockFetchData); + + await viewMandatoryTraining(req, res); + + expect(loadSpy).to.have.been.calledWith({ + trainingCategoryId: mockFetchData.mandatoryTraining[0].trainingCategoryId, + allJobRoles: true, + jobs: [], + }); + expect(saveSpy).to.have.been.calledWith(req.userUid); + expect(fetchSpy).to.have.been.calledTwice; + }); + }); + + it('should not call load or save for mandatory training when number of jobs is previous all job roles length (29) but no duplicate job roles', async () => { + const loadSpy = sinon.stub(MandatoryTraining.prototype, 'load'); + const saveSpy = sinon.stub(MandatoryTraining.prototype, 'save'); + + const mockFetchData = createMockFetchData(); + + const mockJobRoles = Array.from({ length: 29 }, (_, index) => ({ + id: index + 1, + title: `Job role ${index + 1}`, + })); + + mockFetchData.mandatoryTraining[0].jobs = mockJobRoles; + + sinon.stub(MandatoryTraining, 'fetch').callsFake(() => mockFetchData); + + await viewMandatoryTraining(req, res); + + expect(loadSpy).not.to.have.been.called; + expect(saveSpy).not.to.have.been.called; + }); + }); + }); }); diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 4176ff3104..0f877bb15b 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -47,7 +47,7 @@ import { PreviousRouteService } from '@core/services/previous-route.service'; import { QualificationService } from '@core/services/qualification.service'; import { RecruitmentService } from '@core/services/recruitment.service'; import { RegistrationService } from '@core/services/registration.service'; -import { TrainingService } from '@core/services/training.service'; +import { MandatoryTrainingService, TrainingService } from '@core/services/training.service'; import { windowProvider, WindowToken } from '@core/services/window'; import { WindowRef } from '@core/services/window.ref'; import { WorkerService } from '@core/services/worker.service'; @@ -193,6 +193,7 @@ import { SentryErrorHandler } from './SentryErrorHandler.component'; RegistrationService, { provide: ErrorHandler, useClass: SentryErrorHandler }, TrainingService, + MandatoryTrainingService, WindowRef, WorkerService, InternationalRecruitmentService, diff --git a/frontend/src/app/core/model/training.model.ts b/frontend/src/app/core/model/training.model.ts index 76c53b5ec6..6b76d6d0c2 100644 --- a/frontend/src/app/core/model/training.model.ts +++ b/frontend/src/app/core/model/training.model.ts @@ -97,8 +97,9 @@ export interface mandatoryJobs { export interface mandatoryTraining { trainingCategoryId: number; - allJobRoles: boolean; + allJobRoles?: boolean; selectedJobRoles?: boolean; + category?: string; jobs: mandatoryJobs[]; } export interface allMandatoryTrainingCategories { diff --git a/frontend/src/app/core/services/training.service.ts b/frontend/src/app/core/services/training.service.ts index c9e0635028..06687c6a48 100644 --- a/frontend/src/app/core/services/training.service.ts +++ b/frontend/src/app/core/services/training.service.ts @@ -1,6 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Params } from '@angular/router'; +import { mandatoryTraining } from '@core/model/establishment.model'; import { allMandatoryTrainingCategories, SelectedTraining, TrainingCategory } from '@core/model/training.model'; import { Worker } from '@core/model/worker.model'; import { BehaviorSubject, Observable } from 'rxjs'; @@ -125,3 +126,31 @@ export class TrainingService { this.updatingSelectedStaffForMultipleTraining = null; } } + +export class MandatoryTrainingService extends TrainingService { + _onlySelectedJobRoles: boolean = null; + _mandatoryTrainingBeingEdited: mandatoryTraining = null; + public allJobRolesCount: number; + + public get onlySelectedJobRoles(): boolean { + return this._onlySelectedJobRoles; + } + + public set onlySelectedJobRoles(onlySelected: boolean) { + this._onlySelectedJobRoles = onlySelected; + } + + public resetState(): void { + this.onlySelectedJobRoles = null; + this.mandatoryTrainingBeingEdited = null; + super.resetState(); + } + + public set mandatoryTrainingBeingEdited(mandatoryTraining) { + this._mandatoryTrainingBeingEdited = mandatoryTraining; + } + + public get mandatoryTrainingBeingEdited(): mandatoryTraining { + return this._mandatoryTrainingBeingEdited; + } +} diff --git a/frontend/src/app/core/test-utils/MockRouter.ts b/frontend/src/app/core/test-utils/MockRouter.ts new file mode 100644 index 0000000000..3951a9352d --- /dev/null +++ b/frontend/src/app/core/test-utils/MockRouter.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; + +@Injectable() +export class MockRouter extends Router { + public static factory(overrides: any = {}) { + return () => { + const service = new MockRouter(); + + Object.keys(overrides).forEach((overrideName) => { + service[overrideName] = overrides[overrideName]; + }); + + return service; + }; + } +} diff --git a/frontend/src/app/core/test-utils/MockTrainingService.ts b/frontend/src/app/core/test-utils/MockTrainingService.ts index 09dee08a8e..491f94ff6c 100644 --- a/frontend/src/app/core/test-utils/MockTrainingService.ts +++ b/frontend/src/app/core/test-utils/MockTrainingService.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { allMandatoryTrainingCategories, TrainingCategory } from '@core/model/training.model'; -import { TrainingService } from '@core/services/training.service'; +import { MandatoryTrainingService, TrainingService } from '@core/services/training.service'; import { Observable, of } from 'rxjs'; import { AllJobs, JobsWithDuplicates } from '../../../mockdata/jobs'; @@ -40,37 +40,16 @@ export class MockTrainingService extends TrainingService { } public getAllMandatoryTrainings(): Observable { - return of({ - allJobRolesCount: 37, - lastUpdated: new Date(), - mandatoryTraining: [ - { - trainingCategoryId: 123, - allJobRoles: false, - category: 'Autism', - selectedJobRoles: true, - jobs: [ - { - id: 15, - title: 'Activities worker, coordinator', - }, - ], - }, - { - trainingCategoryId: 9, - allJobRoles: true, - category: 'Coshh', - selectedJobRoles: true, - jobs: this._duplicateJobRoles ? JobsWithDuplicates : AllJobs, - }, - ], - mandatoryTrainingCount: 2, - }); + return of(mockMandatoryTraining(this._duplicateJobRoles)); } public deleteCategoryById(establishmentId, categoryId) { return of({}); } + + public deleteAllMandatoryTraining(establishmentId) { + return of({}); + } } @Injectable() @@ -99,3 +78,47 @@ export class MockTrainingServiceWithPreselectedStaff extends MockTrainingService }; } } + +@Injectable() +export class MockMandatoryTrainingService extends MandatoryTrainingService { + public static factory(overrides = {}) { + return (http: HttpClient) => { + const service = new MockMandatoryTrainingService(http); + + Object.keys(overrides).forEach((overrideName) => { + service[overrideName] = overrides[overrideName]; + }); + + return service; + }; + } +} + +export const mockMandatoryTraining = (duplicateJobRoles = false) => { + return { + allJobRolesCount: 37, + lastUpdated: new Date(), + mandatoryTraining: [ + { + trainingCategoryId: 123, + allJobRoles: false, + category: 'Autism', + selectedJobRoles: true, + jobs: [ + { + id: 15, + title: 'Activities worker, coordinator', + }, + ], + }, + { + trainingCategoryId: 9, + allJobRoles: true, + category: 'Coshh', + selectedJobRoles: true, + jobs: duplicateJobRoles ? JobsWithDuplicates : AllJobs, + }, + ], + mandatoryTrainingCount: 2, + }; +}; diff --git a/frontend/src/app/features/training-and-qualifications/add-edit-training/select-training-category/select-training-category.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-edit-training/select-training-category/select-training-category.component.spec.ts index b7ee318c69..897fc8852f 100644 --- a/frontend/src/app/features/training-and-qualifications/add-edit-training/select-training-category/select-training-category.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/add-edit-training/select-training-category/select-training-category.component.spec.ts @@ -27,7 +27,7 @@ describe('SelectTrainingCategoryComponent', () => { const establishment = establishmentBuilder() as Establishment; const worker = workerBuilder(); - const { fixture, getByText, getAllByText, getByTestId } = await render(SelectTrainingCategoryComponent, { + const setupTools = await render(SelectTrainingCategoryComponent, { imports: [HttpClientTestingModule, SharedModule, RouterModule, RouterTestingModule, ReactiveFormsModule], declarations: [ SelectTrainingCategoryComponent, @@ -64,7 +64,7 @@ describe('SelectTrainingCategoryComponent', () => { }, ], }); - const component = fixture.componentInstance; + const component = setupTools.fixture.componentInstance; const injector = getTestBed(); const router = injector.inject(Router) as Router; @@ -75,11 +75,8 @@ describe('SelectTrainingCategoryComponent', () => { const trainingServiceSpy = spyOn(trainingService, 'resetSelectedStaff').and.callThrough(); return { + ...setupTools, component, - fixture, - getByText, - getAllByText, - getByTestId, routerSpy, trainingService, trainingServiceSpy, @@ -132,6 +129,13 @@ describe('SelectTrainingCategoryComponent', () => { expect(getByTestId('groupedAccordion')).toBeTruthy(); }); + it("should display 'The training is not in any of these categories' checkbox", async () => { + const { fixture, getByText } = await setup(); + + expect(getByText('The training is not in any of these categories')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('#otherCheckbox')).toBeTruthy(); + }); + it('should call the training service and navigate to the details page', async () => { const { component, getByText, routerSpy, trainingService } = await setup(true); diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-and-manage-mandatory-training/add-and-manage-mandatory-training.component.html b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-and-manage-mandatory-training/add-and-manage-mandatory-training.component.html index 68f60675c6..ba73dc26a0 100644 --- a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-and-manage-mandatory-training/add-and-manage-mandatory-training.component.html +++ b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-and-manage-mandatory-training/add-and-manage-mandatory-training.component.html @@ -1,50 +1,52 @@
+ Add a mandatory training category

Add and manage mandatory
training categories

-
-
-

- Add the training categories you want to make mandatory for your staff. It will help you identify
- who is missing training and let you know when
- training expires. +

+
+

+ Add the training categories you want to make mandatory for your staff. It will help you to identify who is missing + training and let you know when training expires.

-
- -
-
-
+
- Remove all mandatory training categories +
- - + + + @@ -54,30 +56,29 @@

>

+ diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-and-manage-mandatory-training/add-and-manage-mandatory-training.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-and-manage-mandatory-training/add-and-manage-mandatory-training.component.spec.ts index 6eb72d32b9..f9d4c349a5 100644 --- a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-and-manage-mandatory-training/add-and-manage-mandatory-training.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-and-manage-mandatory-training/add-and-manage-mandatory-training.component.spec.ts @@ -1,25 +1,34 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { getTestBed } from '@angular/core/testing'; -import { ActivatedRoute, RouterModule } from '@angular/router'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; -import { JourneyType } from '@core/breadcrumb/breadcrumb.model'; +import { Establishment } from '@core/model/establishment.model'; import { BreadcrumbService } from '@core/services/breadcrumb.service'; import { EstablishmentService } from '@core/services/establishment.service'; -import { TrainingService } from '@core/services/training.service'; +import { MandatoryTrainingService } from '@core/services/training.service'; import { WindowRef } from '@core/services/window.ref'; import { MockBreadcrumbService } from '@core/test-utils/MockBreadcrumbService'; -import { MockEstablishmentService } from '@core/test-utils/MockEstablishmentService'; -import { MockTrainingService } from '@core/test-utils/MockTrainingService'; -import { ParentSubsidiaryViewService } from '@shared/services/parent-subsidiary-view.service'; +import { establishmentBuilder, MockEstablishmentService } from '@core/test-utils/MockEstablishmentService'; +import { mockMandatoryTraining, MockMandatoryTrainingService } from '@core/test-utils/MockTrainingService'; import { SharedModule } from '@shared/shared.module'; -import { render } from '@testing-library/angular'; +import { fireEvent, render } from '@testing-library/angular'; import { AddAndManageMandatoryTrainingComponent } from './add-and-manage-mandatory-training.component'; describe('AddAndManageMandatoryTrainingComponent', () => { - async function setup(isOwnWorkplace = true, duplicateJobRoles = false) { - const { getByText, getByLabelText, getByTestId, fixture } = await render(AddAndManageMandatoryTrainingComponent, { - imports: [SharedModule, RouterModule, RouterTestingModule, HttpClientTestingModule], + const noMandatoryTraining = { + allJobRolesCount: 37, + lastUpdated: new Date(), + mandatoryTraining: [], + mandatoryTrainingCount: 0, + }; + + async function setup(overrides: any = {}) { + const establishment = establishmentBuilder() as Establishment; + const existingMandatoryTraining = mockMandatoryTraining(); + + const setupTools = await render(AddAndManageMandatoryTrainingComponent, { + imports: [SharedModule, RouterModule, RouterTestingModule.withRoutes([]), HttpClientTestingModule], declarations: [], providers: [ { @@ -31,8 +40,8 @@ describe('AddAndManageMandatoryTrainingComponent', () => { useClass: WindowRef, }, { - provide: TrainingService, - useFactory: MockTrainingService.factory(duplicateJobRoles), + provide: MandatoryTrainingService, + useFactory: MockMandatoryTrainingService.factory(), }, { provide: EstablishmentService, @@ -43,14 +52,9 @@ describe('AddAndManageMandatoryTrainingComponent', () => { useValue: { snapshot: { url: [{ path: 'add-and-manage-mandatory-training' }], - }, - parent: { - snapshot: { - data: { - establishment: { - uid: '123', - }, - }, + data: { + establishment, + existingMandatoryTraining: overrides.mandatoryTraining ?? existingMandatoryTraining, }, }, }, @@ -58,21 +62,25 @@ describe('AddAndManageMandatoryTrainingComponent', () => { ], }); + const component = setupTools.fixture.componentInstance; + const injector = getTestBed(); - const parentSubsidiaryViewService = injector.inject(ParentSubsidiaryViewService) as ParentSubsidiaryViewService; - const establishmentService = injector.inject(EstablishmentService) as EstablishmentService; - spyOn(establishmentService, 'isOwnWorkplace').and.returnValue(isOwnWorkplace); - const component = fixture.componentInstance; + const router = injector.inject(Router) as Router; + const routerSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + const currentRoute = injector.inject(ActivatedRoute) as ActivatedRoute; + + const mandatoryTrainingService = injector.inject(MandatoryTrainingService) as MandatoryTrainingService; return { - getByText, - getByLabelText, - getByTestId, - fixture, + ...setupTools, component, - parentSubsidiaryViewService, - establishmentService, + establishment, + existingMandatoryTraining, + routerSpy, + currentRoute, + injector, + mandatoryTrainingService, }; } @@ -81,43 +89,84 @@ describe('AddAndManageMandatoryTrainingComponent', () => { expect(component).toBeTruthy(); }); - it('should show header, paragraph and links for manage mandatory training', async () => { + it('should show header and paragraph', async () => { const { getByTestId } = await setup(); const mandatoryTrainingHeader = getByTestId('heading'); - const mandatoryTrainingInfo = getByTestId('mandatoryTrainingInfo'); - const addMandatoryTrainingButton = getByTestId('mandatoryTrainingButton'); expect(mandatoryTrainingHeader.textContent).toContain('Add and manage mandatory training categories'); expect(mandatoryTrainingInfo.textContent).toContain( - 'Add the training categories you want to make mandatory for your staff. It will help you identify who is missing training and let you know when training expires.', + 'Add the training categories you want to make mandatory for your staff. It will help you to identify who is missing training and let you know when training expires.', ); - expect(addMandatoryTrainingButton.textContent).toContain('Add a mandatory training category'); }); - it('should show the Remove all mandatory training categories link', async () => { - const { getByTestId } = await setup(); + it("should navigate to the select-training-category page and clear state in training service when 'Add a mandatory training category' link is clicked", async () => { + const { getByRole, routerSpy, currentRoute, mandatoryTrainingService } = await setup(); + + const resetStateSpy = spyOn(mandatoryTrainingService, 'resetState'); - const removeMandatoryTrainingLink = getByTestId('removeMandatoryTrainingLink'); - expect(removeMandatoryTrainingLink).toBeTruthy(); + const addMandatoryTrainingButton = getByRole('button', { name: 'Add a mandatory training category' }); + fireEvent.click(addMandatoryTrainingButton); + + expect(resetStateSpy).toHaveBeenCalled(); + expect(routerSpy).toHaveBeenCalledWith(['select-training-category'], { relativeTo: currentRoute }); }); - it('should show the manage mandatory training table', async () => { - const { getByTestId } = await setup(); + describe('Remove all link', () => { + it('should show with link to the remove all page when there is mandatory training', async () => { + const { getByText, establishment } = await setup(); + + const removeMandatoryTrainingLink = getByText('Remove all') as HTMLAnchorElement; + + expect(removeMandatoryTrainingLink.href).toContain( + `/workplace/${establishment.uid}/add-and-manage-mandatory-training/remove-all-mandatory-training`, + ); + }); + + it('should not show if no mandatory training set up', async () => { + const { queryByText } = await setup({ mandatoryTraining: noMandatoryTraining }); - const mandatoryTrainingTable = getByTestId('training-table'); + expect(queryByText('Remove all')).toBeFalsy(); + }); + + it('should not show if mandatory training only set up for one training category', async () => { + const existingMandatoryTraining = mockMandatoryTraining(); + + existingMandatoryTraining.mandatoryTraining = [existingMandatoryTraining.mandatoryTraining[0]]; + existingMandatoryTraining.mandatoryTrainingCount = 1; - expect(mandatoryTrainingTable).toBeTruthy(); + const { queryByText } = await setup({ mandatoryTraining: existingMandatoryTraining }); + + expect(queryByText('Remove all')).toBeFalsy(); + }); }); - it('should show the manage mandatory training table heading', async () => { - const { getByTestId } = await setup(); + describe('Mandatory training table', () => { + it('should show the manage mandatory training table', async () => { + const { getByTestId } = await setup(); + + const mandatoryTrainingTable = getByTestId('training-table'); + + expect(mandatoryTrainingTable).toBeTruthy(); + }); + + it('should show the manage mandatory training table headings', async () => { + const { getByTestId } = await setup(); - const mandatoryTrainingTableHeading = getByTestId('training-table-heading'); + const mandatoryTrainingTableHeading = getByTestId('training-table-heading'); - expect(mandatoryTrainingTableHeading.textContent).toContain('Mandatory training categories'); - expect(mandatoryTrainingTableHeading.textContent).toContain('Job roles'); + expect(mandatoryTrainingTableHeading.textContent).toContain('Mandatory training category'); + expect(mandatoryTrainingTableHeading.textContent).toContain('Job role'); + }); + + it('should not show if no mandatory training set up', async () => { + const { queryByTestId } = await setup({ mandatoryTraining: noMandatoryTraining }); + + const mandatoryTrainingTableHeadings = queryByTestId('training-table-heading'); + + expect(mandatoryTrainingTableHeadings).toBeFalsy(); + }); }); describe('mandatory training table records', () => { @@ -140,44 +189,39 @@ describe('AddAndManageMandatoryTrainingComponent', () => { expect(autismCategory.textContent).toContain('Activities worker, coordinator'); }); - it('should show all if there are any duplicate job roles and the job roles length is the old all job roles length', async () => { - const { getByTestId } = await setup(true, true); - - const coshCategory = getByTestId('titleAll'); - const autismCategory = getByTestId('titleJob'); - - expect(coshCategory.textContent).toContain('All'); - expect(autismCategory.textContent).toContain('Activities worker, coordinator'); - }); - - it('should show all if there are any duplicate job roles and the job roles length is the old all job roles length', async () => { - const { getByTestId } = await setup(true, true); - - const coshCategory = getByTestId('titleAll'); - const autismCategory = getByTestId('titleJob'); - - expect(coshCategory.textContent).toContain('All'); - expect(autismCategory.textContent).toContain('Activities worker, coordinator'); - }); - }); - - describe('getBreadcrumbsJourney', () => { - it('should return mandatory training journey when viewing sub as parent', async () => { - const { component, parentSubsidiaryViewService } = await setup(); - spyOn(parentSubsidiaryViewService, 'getViewingSubAsParent').and.returnValue(true); - expect(component.getBreadcrumbsJourney()).toBe(JourneyType.MANDATORY_TRAINING); + it('should navigate to select-training-category and set mandatory training being edited in service when category link clicked', async () => { + const { getByText, existingMandatoryTraining, routerSpy, currentRoute, mandatoryTrainingService } = await setup(); + + const setMandatoryTrainingBeingEditedSpy = spyOnProperty( + mandatoryTrainingService, + 'mandatoryTrainingBeingEdited', + 'set', + ).and.stub(); + const resetStateSpy = spyOn(mandatoryTrainingService, 'resetState'); + + existingMandatoryTraining.mandatoryTraining.forEach((trainingCategory) => { + fireEvent.click(getByText(trainingCategory.category)); + + expect(setMandatoryTrainingBeingEditedSpy).toHaveBeenCalledWith(trainingCategory); + expect(resetStateSpy).toHaveBeenCalled(); + expect(routerSpy).toHaveBeenCalledWith(['select-training-category'], { + relativeTo: currentRoute, + }); + }); }); - it('should return mandatory training journey when is own workplace', async () => { - const { component } = await setup(); - - expect(component.getBreadcrumbsJourney()).toBe(JourneyType.MANDATORY_TRAINING); - }); + it(`should have a Remove link for each training category which takes user to its remove page`, async () => { + const { getByTestId, existingMandatoryTraining, routerSpy, currentRoute } = await setup(); - it('should return all workplaces journey when is not own workplace and not in parent sub view', async () => { - const { component } = await setup(false); + existingMandatoryTraining.mandatoryTraining.forEach((trainingCategory) => { + const removeLink = getByTestId('remove-link-' + trainingCategory.category) as HTMLAnchorElement; + fireEvent.click(removeLink); - expect(component.getBreadcrumbsJourney()).toBe(JourneyType.ALL_WORKPLACES); + expect(routerSpy).toHaveBeenCalledWith( + [trainingCategory.trainingCategoryId, 'delete-mandatory-training-category'], + { relativeTo: currentRoute }, + ); + }); }); }); }); diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-and-manage-mandatory-training/add-and-manage-mandatory-training.component.ts b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-and-manage-mandatory-training/add-and-manage-mandatory-training.component.ts index 56cb36d6de..99f54dc421 100644 --- a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-and-manage-mandatory-training/add-and-manage-mandatory-training.component.ts +++ b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-and-manage-mandatory-training/add-and-manage-mandatory-training.component.ts @@ -5,66 +5,33 @@ import { Establishment } from '@core/model/establishment.model'; import { BreadcrumbService } from '@core/services/breadcrumb.service'; import { EstablishmentService } from '@core/services/establishment.service'; import { JobService } from '@core/services/job.service'; -import { TrainingService } from '@core/services/training.service'; -import { ParentSubsidiaryViewService } from '@shared/services/parent-subsidiary-view.service'; -import { Subscription } from 'rxjs'; +import { MandatoryTrainingService } from '@core/services/training.service'; @Component({ selector: 'app-add-and-manage-mandatory-training', templateUrl: './add-and-manage-mandatory-training.component.html', }) export class AddAndManageMandatoryTrainingComponent implements OnInit { - private subscriptions: Subscription = new Subscription(); public establishment: Establishment; public existingMandatoryTrainings: any; public allJobsLength: Number; - public mandatoryTrainingHasDuplicateJobRoles = []; - public previousAllJobsLength = [29, 31, 32]; constructor( - public trainingService: TrainingService, + public trainingService: MandatoryTrainingService, private route: ActivatedRoute, public jobService: JobService, private breadcrumbService: BreadcrumbService, private router: Router, public establishmentService: EstablishmentService, - private parentSubsidiaryViewService: ParentSubsidiaryViewService, ) {} ngOnInit(): void { - this.breadcrumbService.show(this.getBreadcrumbsJourney()); - this.establishment = this.route.parent.snapshot.data.establishment; - this.subscriptions.add( - this.trainingService.getAllMandatoryTrainings(this.establishment.uid).subscribe((trainings) => { - this.existingMandatoryTrainings = trainings; - this.sortTrainingAlphabetically(trainings.mandatoryTraining); - this.allJobsLength = trainings.allJobRolesCount; - this.setMandatoryTrainingHasDuplicateJobRoles(); - }), - ); - } + this.breadcrumbService.show(JourneyType.MANDATORY_TRAINING); - public checkDuplicateJobRoles(jobs): boolean { - for (let i = 0; i < jobs.length; i++) { - for (let j = i + 1; j < jobs.length; j++) { - if (jobs[i].id === jobs[j].id) { - return true; - } - } - } - } - - public setMandatoryTrainingHasDuplicateJobRoles() { - let mandatoryTraining = this.existingMandatoryTrainings.mandatoryTraining; - - mandatoryTraining.forEach((trainingCategory, index) => { - this.mandatoryTrainingHasDuplicateJobRoles.push({ - [trainingCategory.trainingCategoryId]: { - hasDuplicates: this.checkDuplicateJobRoles(trainingCategory.jobs), - hasPreviousAllJobsLength: this.previousAllJobsLength.includes(trainingCategory.jobs.length), - }, - }); - }); + this.establishment = this.route.snapshot.data?.establishment; + this.existingMandatoryTrainings = this.route.snapshot.data?.existingMandatoryTraining; + this.sortTrainingAlphabetically(this.existingMandatoryTrainings.mandatoryTraining); + this.allJobsLength = this.existingMandatoryTrainings.allJobRolesCount; } public sortTrainingAlphabetically(training) { @@ -73,18 +40,19 @@ export class AddAndManageMandatoryTrainingComponent implements OnInit { }); } - public navigateToAddNewMandatoryTraining() { - this.router.navigate([ - '/workplace', - this.establishmentService.establishment.uid, - 'add-and-manage-mandatory-training', - 'add-new-mandatory-training', - ]); + public navigateToAddNewMandatoryTraining(event: Event, mandatoryTrainingToEdit = null): void { + event.preventDefault(); + this.trainingService.resetState(); + + if (mandatoryTrainingToEdit) { + this.trainingService.mandatoryTrainingBeingEdited = mandatoryTrainingToEdit; + } + + this.router.navigate(['select-training-category'], { relativeTo: this.route }); } - public getBreadcrumbsJourney(): JourneyType { - return this.establishmentService.isOwnWorkplace() || this.parentSubsidiaryViewService.getViewingSubAsParent() - ? JourneyType.MANDATORY_TRAINING - : JourneyType.ALL_WORKPLACES; + public navigateToDeletePage(event: Event, trainingCategoryId: number): void { + event.preventDefault(); + this.router.navigate([trainingCategoryId, 'delete-mandatory-training-category'], { relativeTo: this.route }); } } diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-mandatory-routing.module.ts b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-mandatory-routing.module.ts index 3f48735f74..83baee1f5c 100644 --- a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-mandatory-routing.module.ts +++ b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-mandatory-routing.module.ts @@ -1,44 +1,61 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; +import { JobsResolver } from '@core/resolvers/jobs.resolver'; +import { MandatoryTrainingCategoriesResolver } from '@core/resolvers/mandatory-training-categories.resolver'; +import { TrainingCategoriesResolver } from '@core/resolvers/training-categories.resolver'; import { DeleteMandatoryTrainingCategoryComponent } from '@features/training-and-qualifications/add-mandatory-training/delete-mandatory-training-category/delete-mandatory-training-category.component'; import { AddAndManageMandatoryTrainingComponent } from './add-and-manage-mandatory-training/add-and-manage-mandatory-training.component'; -import { AddMandatoryTrainingComponent } from './add-mandatory-training.component'; +import { AllOrSelectedJobRolesComponent } from './all-or-selected-job-roles/all-or-selected-job-roles.component'; import { RemoveAllMandatoryTrainingComponent } from './delete-mandatory-training/delete-all-mandatory-training.component'; +import { SelectJobRolesMandatoryComponent } from './select-job-roles-mandatory/select-job-roles-mandatory.component'; +import { SelectTrainingCategoryMandatoryComponent } from './select-training-category-mandatory/select-training-category-mandatory.component'; const routes: Routes = [ { path: '', - children: [ - { - path: '', - component: AddAndManageMandatoryTrainingComponent, - data: { title: 'List Mandatory Training' }, - }, - { - path: 'remove-all-mandatory-training', - component: RemoveAllMandatoryTrainingComponent, - data: { title: 'Remove All Mandatory Training' }, - }, - { - path: 'add-new-mandatory-training', - component: AddMandatoryTrainingComponent, - data: { title: 'Add New Mandatory Training' }, - }, - ], + component: AddAndManageMandatoryTrainingComponent, + data: { title: 'List Mandatory Training' }, + resolve: { + existingMandatoryTraining: MandatoryTrainingCategoriesResolver, + }, + runGuardsAndResolvers: 'always', + }, + { + path: 'select-training-category', + component: SelectTrainingCategoryMandatoryComponent, + data: { title: 'Select Training Category' }, + resolve: { + trainingCategories: TrainingCategoriesResolver, + existingMandatoryTraining: MandatoryTrainingCategoriesResolver, + }, + }, + { + path: 'all-or-selected-job-roles', + component: AllOrSelectedJobRolesComponent, + data: { title: 'All or Selected Job Roles?' }, + }, + { + path: 'select-job-roles', + component: SelectJobRolesMandatoryComponent, + data: { title: 'Select Job Roles' }, + resolve: { jobs: JobsResolver }, + }, + { + path: 'remove-all-mandatory-training', + component: RemoveAllMandatoryTrainingComponent, + data: { title: 'Remove All Mandatory Training' }, }, { path: ':trainingCategoryId', children: [ - { - path: 'edit-mandatory-training', - component: AddMandatoryTrainingComponent, - data: { title: 'Edit Mandatory Training' }, - }, { path: 'delete-mandatory-training-category', component: DeleteMandatoryTrainingCategoryComponent, data: { title: 'Delete Mandatory Training Category' }, + resolve: { + existingMandatoryTraining: MandatoryTrainingCategoriesResolver, + }, }, ], }, diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-mandatory-training.component.html b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-mandatory-training.component.html deleted file mode 100644 index f915f394af..0000000000 --- a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-mandatory-training.component.html +++ /dev/null @@ -1,163 +0,0 @@ - - -
-
-

- {{ renderAsEditMandatoryTraining ? 'Mandatory training category' : 'Add a mandatory training category' }} -

-
-
- -
-
-
-
-
-
- - - - - - -
-
-
-
- -
- -
-
-
-
-
-
-
- - Which job roles need this training? - - - - - - -
-
- - -
-
-
-
-
-
-
-
-
-
- - - - - -
-
- -
- -
-
-
-
-
-
-
- -
-
-
-
-
- - diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-mandatory-training.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-mandatory-training.component.spec.ts deleted file mode 100644 index b82ce1dbe9..0000000000 --- a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-mandatory-training.component.spec.ts +++ /dev/null @@ -1,518 +0,0 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { getTestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { RouterTestingModule } from '@angular/router/testing'; -import { AlertService } from '@core/services/alert.service'; -import { BackService } from '@core/services/back.service'; -import { DialogService } from '@core/services/dialog.service'; -import { EstablishmentService } from '@core/services/establishment.service'; -import { JobService } from '@core/services/job.service'; -import { TrainingService } from '@core/services/training.service'; -import { WindowRef } from '@core/services/window.ref'; -import { MockEstablishmentService } from '@core/test-utils/MockEstablishmentService'; -import { MockJobService } from '@core/test-utils/MockJobService'; -import { MockTrainingService } from '@core/test-utils/MockTrainingService'; -import { SharedModule } from '@shared/shared.module'; -import { fireEvent, render } from '@testing-library/angular'; - -import { AddMandatoryTrainingComponent } from './add-mandatory-training.component'; -import { AddMandatoryTrainingModule } from './add-mandatory-training.module'; -import { TrainingCategoryService } from '@core/services/training-category.service'; -import { MockTrainingCategoryService } from '@core/test-utils/MockTrainingCategoriesService'; - -describe('AddMandatoryTrainingComponent', () => { - async function setup(renderAsEditMandatoryTraining = false, trainingCategoryId = '9', hasDuplicateJobRoles = false) { - const { getByText, getByLabelText, getAllByLabelText, getAllByText, queryByText, fixture } = await render( - AddMandatoryTrainingComponent, - { - imports: [SharedModule, RouterModule, RouterTestingModule, AddMandatoryTrainingModule, HttpClientTestingModule], - declarations: [], - providers: [ - AlertService, - BackService, - DialogService, - { - provide: WindowRef, - useClass: WindowRef, - }, - { - provide: EstablishmentService, - useClass: MockEstablishmentService, - }, - { - provide: TrainingService, - useFactory: MockTrainingService.factory(hasDuplicateJobRoles), - }, - { - provide: TrainingCategoryService, - useClass: MockTrainingCategoryService, - }, - { - provide: JobService, - useClass: MockJobService, - }, - { - provide: ActivatedRoute, - useValue: { - snapshot: { - parent: { - url: [{ path: trainingCategoryId ? trainingCategoryId : 'add-and-manage-mandatory-training' }], - data: { - establishment: { - uid: '9', - }, - }, - }, - url: [ - { path: renderAsEditMandatoryTraining ? 'edit-mandatory-training' : 'add-new-mandatory-training' }, - ], - }, - }, - }, - ], - }, - ); - const component = fixture.componentInstance; - - const injector = getTestBed(); - const establishmentService = injector.inject(EstablishmentService); - const alertService = injector.inject(AlertService) as AlertService; - const router = injector.inject(Router) as Router; - - const createAndUpdateMandatoryTrainingSpy = spyOn( - establishmentService, - 'createAndUpdateMandatoryTraining', - ).and.callThrough(); - const alertSpy = spyOn(alertService, 'addAlert').and.callThrough(); - const routerSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); - - return { - component, - fixture, - createAndUpdateMandatoryTrainingSpy, - alertSpy, - routerSpy, - getByText, - getByLabelText, - getAllByLabelText, - getAllByText, - queryByText, - }; - } - - describe('component renderings', async () => { - it('should render the component', async () => { - const { component } = await setup(); - expect(component).toBeTruthy(); - }); - - it('Should display the add mandatory training title when not in edit version of page', async () => { - const { getByText } = await setup(); - expect(getByText('Add a mandatory training category')).toBeTruthy(); - }); - - it('Should display the edit mandatory training title when in edit version of page', async () => { - const { getByText } = await setup(true); - expect(getByText('Mandatory training category')).toBeTruthy(); - }); - - it('should render the remove training link when in edit version of the page', async () => { - const { getByText } = await setup(true); - expect(getByText('Remove this mandatory training category')).toBeTruthy(); - }); - - it('Should render save and return button and cancel link', async () => { - const { getByText } = await setup(); - expect(getByText('Save and return')).toBeTruthy(); - expect(getByText('Cancel')).toBeTruthy(); - }); - }); - - describe('prefill', async () => { - it('should prefill the training category and all job roles when a mandatory training with all job roles is selected', async () => { - const { component } = await setup(true); - - expect(component.form.value.trainingCategory).toEqual(9); - expect(component.form.value.allOrSelectedJobRoles).toEqual('all'); - expect(component.form.value.selectedJobRoles).toEqual([]); - }); - - it('should prefill the training category and all job roles when a mandatory training with all job roles has duplicates and previous all job length', async () => { - const { component } = await setup(true, '9', true); - - expect(component.form.value.trainingCategory).toEqual(9); - expect(component.form.value.allOrSelectedJobRoles).toEqual('all'); - expect(component.form.value.selectedJobRoles).toEqual([]); - }); - - it('should prefill the training category and job roles when a mandatory training with only selected job roles', async () => { - const { component } = await setup(true, '123'); - - expect(component.form.value.trainingCategory).toEqual(123); - expect(component.form.value.allOrSelectedJobRoles).toEqual('selected'); - expect(component.form.value.selectedJobRoles).toEqual([ - { - id: 15, - }, - ]); - }); - }); - - describe('trainingCategory form', async () => { - it('Should call createAndUpdateMandatoryTraining on submit when a training is selected and all Job roles is selected', async () => { - const { component, createAndUpdateMandatoryTrainingSpy, fixture, getByLabelText, getByText } = await setup(); - - const mandatoryTrainigCategorySelect = getByLabelText('Training category', { exact: false }); - fireEvent.change(mandatoryTrainigCategorySelect, { target: { value: 1 } }); - - const allJobRolesRadioButton = getByLabelText(component.allOrSelectedJobRoleOptions[0].label); - fireEvent.click(allJobRolesRadioButton); - - fixture.detectChanges(); - - const submitButton = getByText('Save and return'); - fireEvent.click(submitButton); - - expect(createAndUpdateMandatoryTrainingSpy).toHaveBeenCalledWith(component.establishment.uid, { - previousTrainingCategoryId: undefined, - trainingCategoryId: 1, - allJobRoles: true, - jobs: [], - }); - }); - - it('Should call createAndUpdateMandatoryTraining on submit when a training is selected and one specified job role is selected', async () => { - const { component, createAndUpdateMandatoryTrainingSpy, fixture, getByLabelText, getByText } = await setup(); - - const mandatoryTrainigCategorySelect = getByLabelText('Training category', { exact: false }); - fireEvent.change(mandatoryTrainigCategorySelect, { target: { value: 1 } }); - - const allJobRolesRadioButton = getByLabelText(component.allOrSelectedJobRoleOptions[1].label); - fireEvent.click(allJobRolesRadioButton); - - const specficJobRoleSelect = getByLabelText('Job role 1', { exact: true }); - fireEvent.change(specficJobRoleSelect, { target: { value: 27 } }); - - fixture.detectChanges(); - - const submitButton = getByText('Save and return'); - fireEvent.click(submitButton); - - expect(createAndUpdateMandatoryTrainingSpy).toHaveBeenCalledWith(component.establishment.uid, { - previousTrainingCategoryId: undefined, - trainingCategoryId: 1, - allJobRoles: false, - jobs: [{ id: '27' }], - }); - }); - - it('Should call createAndUpdateMandatoryTraining on submit when a training is selected and multiple specified job roles are selected', async () => { - const { component, createAndUpdateMandatoryTrainingSpy, fixture, getByLabelText, getByText } = await setup(); - - const mandatoryTrainigCategorySelect = getByLabelText('Training category', { exact: false }); - fireEvent.change(mandatoryTrainigCategorySelect, { target: { value: 1 } }); - - const allJobRolesRadioButton = getByLabelText(component.allOrSelectedJobRoleOptions[1].label); - fireEvent.click(allJobRolesRadioButton); - - const addAnotherJobRoleButton = getByText('Add another job role'); - fireEvent.click(addAnotherJobRoleButton); - - fixture.detectChanges(); - - const specifiedJobRoleOne = getByLabelText('Job role 1', { exact: true }); - const specifiedJobRoleTwo = getByLabelText('Job role 2', { exact: true }); - - fireEvent.change(specifiedJobRoleOne, { target: { value: 27 } }); - fireEvent.change(specifiedJobRoleTwo, { target: { value: 23 } }); - - const submitButton = getByText('Save and return'); - fireEvent.click(submitButton); - - expect(createAndUpdateMandatoryTrainingSpy).toHaveBeenCalledWith(component.establishment.uid, { - previousTrainingCategoryId: undefined, - trainingCategoryId: 1, - allJobRoles: false, - jobs: [{ id: '27' }, { id: '23' }], - }); - }); - - it('should call createAndUpdateMandatoryTraining with the previous category id when updating an existing mandatory training category', async () => { - const { component, createAndUpdateMandatoryTrainingSpy, fixture, getByLabelText, getByText } = await setup(true); - - const mandatoryTrainigCategorySelect = getByLabelText('Training category', { exact: false }); - fireEvent.change(mandatoryTrainigCategorySelect, { target: { value: 1 } }); - - const allJobRolesRadioButton = getByLabelText(component.allOrSelectedJobRoleOptions[1].label); - fireEvent.click(allJobRolesRadioButton); - - const addAnotherJobRoleButton = getByText('Add another job role'); - fireEvent.click(addAnotherJobRoleButton); - - fixture.detectChanges(); - - const specifiedJobRoleOne = getByLabelText('Job role 1', { exact: true }); - const specifiedJobRoleTwo = getByLabelText('Job role 2', { exact: true }); - - fireEvent.change(specifiedJobRoleOne, { target: { value: 27 } }); - fireEvent.change(specifiedJobRoleTwo, { target: { value: 23 } }); - - const submitButton = getByText('Save and return'); - fireEvent.click(submitButton); - - expect(createAndUpdateMandatoryTrainingSpy).toHaveBeenCalledWith(component.establishment.uid, { - previousTrainingCategoryId: 9, - trainingCategoryId: 1, - allJobRoles: false, - jobs: [{ id: '27' }, { id: '23' }], - }); - }); - }); - - describe('allOrSelectedJobRoles form', async () => { - it('Should not display a job role selection when All job roles is selected', async () => { - const { component, fixture, getByLabelText, queryByText } = await setup(); - - const mandatoryTrainigCategorySelect = getByLabelText('Training category', { exact: false }); - fireEvent.change(mandatoryTrainigCategorySelect, { target: { value: 1 } }); - - const allJobRolesRadioButton = getByLabelText(component.allOrSelectedJobRoleOptions[0].label); - fireEvent.click(allJobRolesRadioButton); - - fixture.detectChanges(); - - expect(queryByText('Job role 1', { exact: true })).toBeFalsy(); - }); - - it('Should display a job role selection when All job roles is not selected selected', async () => { - const { component, fixture, getByLabelText, queryByText } = await setup(); - - const mandatoryTrainigCategorySelect = getByLabelText('Training category', { exact: false }); - fireEvent.change(mandatoryTrainigCategorySelect, { target: { value: 1 } }); - - const allJobRolesRadioButton = getByLabelText(component.allOrSelectedJobRoleOptions[1].label); - fireEvent.click(allJobRolesRadioButton); - - fixture.detectChanges(); - - expect(queryByText('Job role 1', { exact: true })).toBeTruthy(); - }); - }); - - describe('error messages', async () => { - describe('mandatory training category', async () => { - it('Should display a Select the training category you want to be mandatory error message if the form is submitted without a category input and all job roles is selected', async () => { - const { createAndUpdateMandatoryTrainingSpy, component, fixture, getByLabelText, getByText, getAllByText } = - await setup(); - - const allJobRolesRadioButton = getByLabelText(component.allOrSelectedJobRoleOptions[0].label); - fireEvent.click(allJobRolesRadioButton); - - fixture.detectChanges(); - - const submitButton = getByText('Save and return'); - fireEvent.click(submitButton); - - expect(createAndUpdateMandatoryTrainingSpy).not.toHaveBeenCalled(); - expect(component.form.invalid).toBeTruthy(); - expect(getAllByText('Select the training category you want to be mandatory').length).toEqual(2); - }); - - it('Should display a Select the training category you want to be mandatory error message if the form is submitted without a category input and only selected job roles is selected', async () => { - const { createAndUpdateMandatoryTrainingSpy, component, fixture, getByLabelText, getByText, getAllByText } = - await setup(); - - const allJobRolesRadioButton = getByLabelText(component.allOrSelectedJobRoleOptions[1].label); - fireEvent.click(allJobRolesRadioButton); - - const specficJobRoleSelect = getByLabelText('Job role 1', { exact: true }); - fireEvent.change(specficJobRoleSelect, { target: { value: 27 } }); - - fixture.detectChanges(); - - const submitButton = getByText('Save and return'); - fireEvent.click(submitButton); - - expect(createAndUpdateMandatoryTrainingSpy).not.toHaveBeenCalled(); - expect(component.form.invalid).toBeTruthy(); - expect(getAllByText('Select the training category you want to be mandatory').length).toEqual(2); - }); - }); - - describe('allOrSelectedJobRoles', async () => { - it('Should display a Select which job roles need this training error message if the form is submitted without a job role radio button selected', async () => { - const { createAndUpdateMandatoryTrainingSpy, component, fixture, getByLabelText, getByText, getAllByText } = - await setup(); - - const mandatoryTrainigCategorySelect = getByLabelText('Training category', { exact: false }); - fireEvent.change(mandatoryTrainigCategorySelect, { target: { value: 1 } }); - - fixture.detectChanges(); - - const submitButton = getByText('Save and return'); - fireEvent.click(submitButton); - - expect(createAndUpdateMandatoryTrainingSpy).not.toHaveBeenCalled(); - expect(component.form.invalid).toBeTruthy(); - expect(getAllByText('Select which job roles need this training').length).toEqual(2); - }); - }); - - describe('job roles', async () => { - it('Should display a select the job role error message if the form is submitted without a specifc job role input and a mandatory training is selected', async () => { - const { createAndUpdateMandatoryTrainingSpy, component, fixture, getByLabelText, getByText } = await setup(); - - const mandatoryTrainigCategorySelect = getByLabelText('Training category', { exact: false }); - fireEvent.change(mandatoryTrainigCategorySelect, { target: { value: 1 } }); - - const allJobRolesRadioButton = getByLabelText(component.allOrSelectedJobRoleOptions[1].label); - fireEvent.click(allJobRolesRadioButton); - - fixture.detectChanges(); - - const submitButton = getByText('Save and return'); - fireEvent.click(submitButton); - - expect(createAndUpdateMandatoryTrainingSpy).not.toHaveBeenCalled(); - expect(component.form.invalid).toBeTruthy(); - expect(getByText('Select the job role')).toBeTruthy(); - expect(getByText('Select the job role (job role 1)')).toBeTruthy(); - }); - - it('Should display multiple select the job role error messages if the form is submitted when several specifc job role inputs are empty and a mandatory training is selected', async () => { - const { createAndUpdateMandatoryTrainingSpy, component, fixture, getByLabelText, getByText, getAllByText } = - await setup(); - - const mandatoryTrainigCategorySelect = getByLabelText('Training category', { exact: false }); - fireEvent.change(mandatoryTrainigCategorySelect, { target: { value: 1 } }); - - const allJobRolesRadioButton = getByLabelText(component.allOrSelectedJobRoleOptions[1].label); - fireEvent.click(allJobRolesRadioButton); - - const addAnotherJobRoleButton = getByText('Add another job role'); - fireEvent.click(addAnotherJobRoleButton); - - fixture.detectChanges(); - - const submitButton = getByText('Save and return'); - fireEvent.click(submitButton); - - expect(createAndUpdateMandatoryTrainingSpy).not.toHaveBeenCalled(); - expect(component.form.invalid).toBeTruthy(); - expect(getAllByText('Select the job role').length).toEqual(2); - expect(getByText('Select the job role (job role 1)')).toBeTruthy(); - expect(getByText('Select the job role (job role 2)')).toBeTruthy(); - }); - }); - - it('should display a mandatory training error and a job role error if a mandatory training is not provided, only selected job roles is selected and a job role is not specified', async () => { - const { createAndUpdateMandatoryTrainingSpy, component, fixture, getByLabelText, getByText, getAllByText } = - await setup(); - - const allJobRolesRadioButton = getByLabelText(component.allOrSelectedJobRoleOptions[1].label); - fireEvent.click(allJobRolesRadioButton); - - fixture.detectChanges(); - - const submitButton = getByText('Save and return'); - fireEvent.click(submitButton); - - expect(createAndUpdateMandatoryTrainingSpy).not.toHaveBeenCalled(); - expect(component.form.invalid).toBeTruthy(); - expect(getAllByText('Select the training category you want to be mandatory').length).toEqual(2); - expect(getByText('Select the job role')).toBeTruthy(); - expect(getByText('Select the job role (job role 1)')).toBeTruthy(); - }); - }); - - describe('success alert', async () => { - it('should show success banner when a mandatory training is saved', async () => { - const { component, alertSpy, fixture, getByLabelText, getByText } = await setup(); - - const mandatoryTrainigCategorySelect = getByLabelText('Training category', { exact: false }); - fireEvent.change(mandatoryTrainigCategorySelect, { target: { value: 1 } }); - - const allJobRolesRadioButton = getByLabelText(component.allOrSelectedJobRoleOptions[0].label); - fireEvent.click(allJobRolesRadioButton); - - fixture.detectChanges(); - - const submitButton = getByText('Save and return'); - fireEvent.click(submitButton); - - expect(alertSpy).toHaveBeenCalledWith({ - type: 'success', - message: 'Mandatory training category added', - }); - }); - - it('should show update banner when a mandatory training is saved', async () => { - const { component, alertSpy, fixture, getByLabelText, getByText } = await setup(true); - - const mandatoryTrainigCategorySelect = getByLabelText('Training category', { exact: false }); - fireEvent.change(mandatoryTrainigCategorySelect, { target: { value: 1 } }); - - const allJobRolesRadioButton = getByLabelText(component.allOrSelectedJobRoleOptions[0].label); - fireEvent.click(allJobRolesRadioButton); - - fixture.detectChanges(); - - const submitButton = getByText('Save and return'); - fireEvent.click(submitButton); - - expect(alertSpy).toHaveBeenCalledWith({ - type: 'success', - message: 'Mandatory training category updated', - }); - }); - }); - - describe('navigation', async () => { - it('should navigate to add and manage mandatory training categories when a record is saved ', async () => { - const { component, routerSpy, fixture, getByLabelText, getByText } = await setup(); - - const mandatoryTrainigCategorySelect = getByLabelText('Training category', { exact: false }); - fireEvent.change(mandatoryTrainigCategorySelect, { target: { value: 1 } }); - - const allJobRolesRadioButton = getByLabelText(component.allOrSelectedJobRoleOptions[0].label); - fireEvent.click(allJobRolesRadioButton); - - fixture.detectChanges(); - - const submitButton = getByText('Save and return'); - fireEvent.click(submitButton); - - expect(routerSpy).toHaveBeenCalledWith([ - '/workplace', - component.establishment.uid, - 'add-and-manage-mandatory-training', - ]); - }); - - it('should navigate to add and manage mandatory training categories when a record is updated ', async () => { - const { component, routerSpy, getByText } = await setup(true); - - const submitButton = getByText('Save and return'); - fireEvent.click(submitButton); - - expect(routerSpy).toHaveBeenCalledWith([ - '/workplace', - component.establishment.uid, - 'add-and-manage-mandatory-training', - ]); - }); - }); - - it('should update the mandatory training with all job roles when it has duplicates and previous all job length', async () => { - const { component, createAndUpdateMandatoryTrainingSpy } = await setup(true, '9', true); - component.ngOnInit(); - expect(component.form.value.allOrSelectedJobRoles).toEqual('all'); - - expect(createAndUpdateMandatoryTrainingSpy).toHaveBeenCalledWith(component.establishment.uid, { - previousTrainingCategoryId: 9, - trainingCategoryId: 9, - allJobRoles: true, - jobs: [], - }); - }); -}); diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-mandatory-training.component.ts b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-mandatory-training.component.ts deleted file mode 100644 index f33fa9386d..0000000000 --- a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-mandatory-training.component.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; -import { ErrorDefinition, ErrorDetails } from '@core/model/errorSummary.model'; -import { Establishment, mandatoryTrainingJobOption } from '@core/model/establishment.model'; -import { Job } from '@core/model/job.model'; -import { allMandatoryTrainingCategories, TrainingCategory } from '@core/model/training.model'; -import { URLStructure } from '@core/model/url.model'; -import { AlertService } from '@core/services/alert.service'; -import { BackLinkService } from '@core/services/backLink.service'; -import { ErrorSummaryService } from '@core/services/error-summary.service'; -import { EstablishmentService } from '@core/services/establishment.service'; -import { JobService } from '@core/services/job.service'; -import { TrainingCategoryService } from '@core/services/training-category.service'; -import { TrainingService } from '@core/services/training.service'; -import { Subscription } from 'rxjs'; -import { take } from 'rxjs/internal/operators/take'; - -@Component({ - selector: 'app-add-mandatory-training', - templateUrl: './add-mandatory-training.component.html', -}) -export class AddMandatoryTrainingComponent implements OnInit, OnDestroy { - @ViewChild('formEl') formEl: ElementRef; - public form: UntypedFormGroup; - public renderAsEditMandatoryTraining: boolean; - public submitted = false; - public preExistingTraining: any; - public categories: TrainingCategory[]; - public filteredTrainingCategories: TrainingCategory[]; - private subscriptions: Subscription = new Subscription(); - public jobs: Job[] = []; - public allJobsLength: Number; - public previousAllJobsLength = [29, 31, 32]; - public hasDuplicateJobRoles: boolean; - public filteredJobs: Array = []; - public trainings: TrainingCategory[] = []; - public establishment: Establishment; - public primaryWorkplace: Establishment; - public formErrorsMap: Array = []; - public serverError: string; - public serverErrorsMap: Array = []; - public return: URLStructure; - public existingMandatoryTrainings: allMandatoryTrainingCategories; - public allOrSelectedJobRoleOptions = [ - { - label: 'All job roles', - value: mandatoryTrainingJobOption.all, - }, - { - label: `Only selected job roles`, - value: mandatoryTrainingJobOption.selected, - }, - ]; - constructor( - protected backLinkService: BackLinkService, - private trainingService: TrainingService, - private trainingCategoryService: TrainingCategoryService, - protected formBuilder: UntypedFormBuilder, - protected errorSummaryService: ErrorSummaryService, - protected establishmentService: EstablishmentService, - private jobService: JobService, - protected router: Router, - private route: ActivatedRoute, - private alertService: AlertService, - ) {} - - get selectedJobRolesArray(): UntypedFormArray { - return this.form.get('selectedJobRoles') as UntypedFormArray; - } - - ngOnInit(): void { - this.primaryWorkplace = this.establishmentService.primaryWorkplace; - this.establishment = this.route.snapshot.parent.data.establishment; - - this.renderAsEditMandatoryTraining = this.route.snapshot.url[0].path === 'edit-mandatory-training'; - this.return = { url: ['/workplace', this.establishment.uid, 'add-and-manage-mandatory-training'] }; - - this.getAllJobs(); - this.setUpForm(); - this.setupServerErrorsMap(); - this.backLinkService.showBackLink(); - - this.subscriptions.add( - this.trainingService.getAllMandatoryTrainings(this.establishment.uid).subscribe((existingMandatoryTraining) => { - this.existingMandatoryTrainings = existingMandatoryTraining; - this.getAllTrainingCategories(); - }), - ); - } - - private getAllTrainingCategories(): void { - this.subscriptions.add( - this.trainingCategoryService - .getCategories() - .pipe(take(1)) - .subscribe((trainings) => { - this.trainings = this.filterTrainingCategories(trainings); - if (this.renderAsEditMandatoryTraining) { - this.prefill(); - this.updateMandatoryTrainingWithPreviousAllJobsRecordLength(); - } - }), - ); - } - - public filterTrainingCategories(trainings): TrainingCategory[] { - const preSelectedIds = this.existingMandatoryTrainings.mandatoryTraining.map( - (existingMandatoryTrainings) => existingMandatoryTrainings.trainingCategoryId, - ); - if (this.renderAsEditMandatoryTraining) { - this.preExistingTraining = this.existingMandatoryTrainings.mandatoryTraining.find((mandatoryTrainingObject) => { - return mandatoryTrainingObject.trainingCategoryId === parseInt(this.route.snapshot.parent.url[0].path, 10); - }); - - return trainings.filter((training) => { - return this.preExistingTraining.trainingCategoryId === training.id || !preSelectedIds.includes(training.id); - }); - } else { - return trainings.filter((training) => { - return !preSelectedIds.includes(training.id); - }); - } - } - - private getAllJobs(): void { - this.subscriptions.add( - this.jobService - .getJobs() - .pipe(take(1)) - .subscribe((jobs) => { - this.allJobsLength = jobs.length; - this.jobs = jobs; - }), - ); - } - - private setUpForm(trainingId = null): void { - this.form = this.formBuilder.group({ - trainingCategory: [trainingId, [Validators.required]], - allOrSelectedJobRoles: [null, [Validators.required]], - selectedJobRoles: this.formBuilder.array([]), - }); - } - - private setupServerErrorsMap(): void { - const serverErrorMessage = 'There has been a problem saving your mandatory training. Please try again.'; - this.serverErrorsMap = [ - { - name: 500, - message: serverErrorMessage, - }, - { - name: 400, - message: serverErrorMessage, - }, - { - name: 404, - message: serverErrorMessage, - }, - ]; - } - - protected setupFormErrorsMap(): void { - this.formErrorsMap = [ - { - item: 'trainingCategory', - type: [ - { - name: 'required', - message: 'Select the training category you want to be mandatory', - }, - ], - }, - { - item: 'allOrSelectedJobRoles', - type: [ - { - name: 'required', - message: 'Select which job roles need this training', - }, - ], - }, - ]; - this.selectedJobRolesArray.controls.forEach((_, index) => { - this.formErrorsMap.push({ - item: `selectedJobRoles.id.${index}`, - type: [ - { - name: 'required', - message: `Select the job role (job role ${index + 1})`, - }, - ], - }); - }); - } - - public checkDuplicateJobRoles(preExistingTrainingJobsDuplicates): boolean { - for (let i = 0; i < preExistingTrainingJobsDuplicates.length; i++) { - for (let j = i + 1; j < preExistingTrainingJobsDuplicates.length; j++) { - if (preExistingTrainingJobsDuplicates[i].id === preExistingTrainingJobsDuplicates[j].id) { - return true; - } - } - } - } - - public filterPreExistingTrainingJobsDuplicates(preExistingTrainingJobs) { - if (preExistingTrainingJobs > 1) { - let filtered = preExistingTrainingJobs.filter( - (obj1, index, arr) => - arr.findIndex((obj2) => { - obj2.id === obj1.id; - }) === index, - ); - return filtered; - } else { - return preExistingTrainingJobs; - } - } - - public prefill(): void { - this.form.patchValue({ - trainingCategory: this.preExistingTraining.trainingCategoryId, - allOrSelectedJobRoles: - this.preExistingTraining.jobs.length === this.allJobsLength || - (this.previousAllJobsLength.includes(this.preExistingTraining.jobs.length) && - this.checkDuplicateJobRoles(this.preExistingTraining.jobs)) - ? mandatoryTrainingJobOption.all - : mandatoryTrainingJobOption.selected, - selectedJobRoles: this.prefillJobRoles(), - }); - } - - protected prefillJobRoles() { - let filteredPreExistingTrainingJobs = this.filterPreExistingTrainingJobsDuplicates(this.preExistingTraining.jobs); - return this.preExistingTraining.jobs.length === this.allJobsLength || - (this.previousAllJobsLength.includes(this.preExistingTraining.jobs.length) && - this.checkDuplicateJobRoles(this.preExistingTraining.jobs)) - ? null - : filteredPreExistingTrainingJobs.forEach((job) => { - this.selectedJobRolesArray.push(this.createVacancyControl(job.id)); - }); - } - - public addVacancy(): void { - this.selectedJobRolesArray.push(this.createVacancyControl()); - this.setupFormErrorsMap(); - } - - public filterJobList(jobIndex): Job[] { - return this.jobs.filter( - (job) => - !this.selectedJobRolesArray.controls.some( - (jobRole) => - jobRole !== this.selectedJobRolesArray.controls[jobIndex] && - parseInt(jobRole.get('id').value, 10) === job.id, - ), - ); - } - - public removeVacancy(event: Event, jobIndex): void { - event.preventDefault(); - this.selectedJobRolesArray.removeAt(jobIndex); - } - - private createVacancyControl(jobId = null): UntypedFormGroup { - return this.formBuilder.group({ - id: [jobId, [Validators.required]], - }); - } - - protected generateUpdateProps(): any { - return { - previousTrainingCategoryId: this.preExistingTraining?.trainingCategoryId, - trainingCategoryId: parseInt(this.form.get('trainingCategory').value, 10), - allJobRoles: this.form.get('allOrSelectedJobRoles').value === mandatoryTrainingJobOption.all ? true : false, - jobs: this.form.get('selectedJobRoles').value, - }; - } - - protected createAndUpdateMandatoryTraining(props): void { - this.subscriptions.add( - this.establishmentService.createAndUpdateMandatoryTraining(this.establishment.uid, props).subscribe( - () => { - this.router.navigate(['/workplace', this.establishment.uid, 'add-and-manage-mandatory-training']); - this.alertService.addAlert({ - type: 'success', - message: this.renderAsEditMandatoryTraining - ? 'Mandatory training category updated' - : 'Mandatory training category added', - }); - }, - (error) => { - this.serverError = this.errorSummaryService.getServerErrorMessage(error.status, this.serverErrorsMap); - }, - ), - ); - } - - public onVacancyTypeSelectionChange(): void { - const allOrSelectedJobRoles = this.form.get('allOrSelectedJobRoles').value; - if (allOrSelectedJobRoles === mandatoryTrainingJobOption.all) { - while (this.selectedJobRolesArray.length > 0) { - this.selectedJobRolesArray.removeAt(0); - } - this.selectedJobRolesArray.reset([], { emitEvent: false }); - } else if (this.renderAsEditMandatoryTraining) { - this.preExistingTraining.jobs.length === this.allJobsLength || - (this.previousAllJobsLength.includes(this.preExistingTraining.jobs.length) && - this.checkDuplicateJobRoles(this.preExistingTraining.jobs)) - ? this.addVacancy() - : this.prefillJobRoles(); - } else { - this.addVacancy(); - } - } - - public updateMandatoryTrainingWithPreviousAllJobsRecordLength(): void { - const props = this.generateUpdateProps(); - - if ( - this.previousAllJobsLength.includes(this.preExistingTraining.jobs.length) && - this.checkDuplicateJobRoles(this.preExistingTraining.jobs) - ) { - this.subscriptions.add( - this.establishmentService.createAndUpdateMandatoryTraining(this.establishment.uid, props).subscribe( - () => {}, - (error) => { - console.error(error.error.message); - }, - ), - ); - } - } - - public onSubmit(): void { - this.submitted = true; - this.setupFormErrorsMap(); - - if (!this.form.valid) { - this.errorSummaryService.scrollToErrorSummary(); - return; - } - const props = this.generateUpdateProps(); - this.createAndUpdateMandatoryTraining(props); - } - - ngOnDestroy(): void { - this.subscriptions.unsubscribe(); - } -} diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-mandatory-training.module.ts b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-mandatory-training.module.ts index 8194e37cfe..cbb760e410 100644 --- a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-mandatory-training.module.ts +++ b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/add-mandatory-training.module.ts @@ -1,21 +1,28 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; +import { MandatoryTrainingCategoriesResolver } from '@core/resolvers/mandatory-training-categories.resolver'; +import { TrainingCategoriesResolver } from '@core/resolvers/training-categories.resolver'; import { AddMandatoryTrainingRoutingModule } from '@features/training-and-qualifications/add-mandatory-training/add-mandatory-routing.module'; import { DeleteMandatoryTrainingCategoryComponent } from '@features/training-and-qualifications/add-mandatory-training/delete-mandatory-training-category/delete-mandatory-training-category.component'; import { SharedModule } from '@shared/shared.module'; import { AddAndManageMandatoryTrainingComponent } from './add-and-manage-mandatory-training/add-and-manage-mandatory-training.component'; -import { AddMandatoryTrainingComponent } from './add-mandatory-training.component'; +import { AllOrSelectedJobRolesComponent } from './all-or-selected-job-roles/all-or-selected-job-roles.component'; import { RemoveAllMandatoryTrainingComponent } from './delete-mandatory-training/delete-all-mandatory-training.component'; +import { SelectJobRolesMandatoryComponent } from './select-job-roles-mandatory/select-job-roles-mandatory.component'; +import { SelectTrainingCategoryMandatoryComponent } from './select-training-category-mandatory/select-training-category-mandatory.component'; @NgModule({ imports: [CommonModule, AddMandatoryTrainingRoutingModule, ReactiveFormsModule, SharedModule], declarations: [ - AddMandatoryTrainingComponent, + SelectTrainingCategoryMandatoryComponent, RemoveAllMandatoryTrainingComponent, AddAndManageMandatoryTrainingComponent, DeleteMandatoryTrainingCategoryComponent, + AllOrSelectedJobRolesComponent, + SelectJobRolesMandatoryComponent, ], + providers: [TrainingCategoriesResolver, MandatoryTrainingCategoriesResolver], }) export class AddMandatoryTrainingModule {} diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/all-or-selected-job-roles/all-or-selected-job-roles.component.html b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/all-or-selected-job-roles/all-or-selected-job-roles.component.html new file mode 100644 index 0000000000..5ce2d356e0 --- /dev/null +++ b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/all-or-selected-job-roles/all-or-selected-job-roles.component.html @@ -0,0 +1,76 @@ + + +
+
+
+
+ + Add a mandatory training category +

Which job roles need this training?

+
+
+
+
+ + Error: {{ requiredErrorMessage }} + +
+ + +
+
+

+ If you click Continue, '{{ selectedTrainingCategory?.trainingCategory?.category }}' will be mandatory for + everybody in your workplace. +

+
+
+ + +
+
+
+
+ + Cancel +
+
+
+ diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/all-or-selected-job-roles/all-or-selected-job-roles.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/all-or-selected-job-roles/all-or-selected-job-roles.component.spec.ts new file mode 100644 index 0000000000..e6b8b85ab4 --- /dev/null +++ b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/all-or-selected-job-roles/all-or-selected-job-roles.component.spec.ts @@ -0,0 +1,398 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { getTestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { AlertService } from '@core/services/alert.service'; +import { EstablishmentService } from '@core/services/establishment.service'; +import { MandatoryTrainingService } from '@core/services/training.service'; +import { WindowRef } from '@core/services/window.ref'; +import { establishmentBuilder, MockEstablishmentService } from '@core/test-utils/MockEstablishmentService'; +import { MockRouter } from '@core/test-utils/MockRouter'; +import { MockMandatoryTrainingService } from '@core/test-utils/MockTrainingService'; +import { SharedModule } from '@shared/shared.module'; +import { fireEvent, render } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; +import { Observable, throwError } from 'rxjs'; + +import { AddMandatoryTrainingModule } from '../add-mandatory-training.module'; +import { AllOrSelectedJobRolesComponent } from './all-or-selected-job-roles.component'; + +describe('AllOrSelectedJobRolesComponent', () => { + async function setup(overrides: any = {}) { + const establishment = establishmentBuilder(); + const selectedTraining = { + trainingCategory: { + category: 'Activity provision, wellbeing', + id: 1, + seq: 0, + trainingCategoryGroup: 'Care skills and knowledge', + }, + }; + const routerSpy = jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)); + + const setupTools = await render(AllOrSelectedJobRolesComponent, { + imports: [SharedModule, RouterModule, RouterTestingModule, HttpClientTestingModule, AddMandatoryTrainingModule], + providers: [ + { + provide: MandatoryTrainingService, + useFactory: MockMandatoryTrainingService.factory({ selectedTraining, ...overrides }), + }, + { provide: Router, useFactory: MockRouter.factory({ navigate: routerSpy }) }, + { provide: EstablishmentService, useClass: MockEstablishmentService }, + { + provide: ActivatedRoute, + useValue: { + params: Observable.from([{ establishmentuid: establishment.uid }]), + snapshot: { + parent: { + data: { establishment }, + }, + }, + }, + }, + AlertService, + WindowRef, + ], + }); + + const component = setupTools.fixture.componentInstance; + const injector = getTestBed(); + + const trainingService = injector.inject(MandatoryTrainingService) as MandatoryTrainingService; + const resetStateInTrainingServiceSpy = spyOn(trainingService, 'resetState').and.callThrough(); + const onlySelectedJobRolesSpy = spyOnProperty(trainingService, 'onlySelectedJobRoles', 'set'); + + const alertService = injector.inject(AlertService) as AlertService; + const alertSpy = spyOn(alertService, 'addAlert').and.callThrough(); + + const establishmentService = injector.inject(EstablishmentService) as EstablishmentService; + const createAndUpdateMandatoryTrainingSpy = spyOn( + establishmentService, + 'createAndUpdateMandatoryTraining', + ).and.callThrough(); + + return { + ...setupTools, + component, + routerSpy, + resetStateInTrainingServiceSpy, + alertSpy, + createAndUpdateMandatoryTrainingSpy, + establishment, + selectedTraining, + onlySelectedJobRolesSpy, + }; + } + + it('should render component', async () => { + const { component } = await setup(); + expect(component).toBeTruthy(); + }); + + it('should show the page caption', async () => { + const { getByText } = await setup(); + + const caption = getByText('Add a mandatory training category'); + + expect(caption).toBeTruthy(); + }); + + it('should show the page heading', async () => { + const { getByText } = await setup(); + + const heading = getByText('Which job roles need this training?'); + + expect(heading).toBeTruthy(); + }); + + it('should navigate back to the select training category page if no training category set in training service', async () => { + const { component, routerSpy } = await setup({ selectedTraining: null }); + + expect(routerSpy).toHaveBeenCalledWith(['../select-training-category'], { relativeTo: component.route }); + }); + + describe('Mandatory for everybody message', () => { + ['Activity provision, wellbeing', 'Digital leadership skills'].forEach((category) => { + it(`should display with selected training category (${category}) when All job roles radio is clicked`, async () => { + const { fixture, getByText, selectedTraining } = await setup(); + + const expectedMessage = `If you click Continue, '${selectedTraining.trainingCategory.category}' will be mandatory for everybody in your workplace.`; + + const allJobRolesRadio = getByText('All job roles'); + userEvent.click(allJobRolesRadio); + fixture.detectChanges(); + + const mandatoryForEverybodyMessage = getByText(expectedMessage); + + expect(mandatoryForEverybodyMessage).toBeTruthy(); + expect(mandatoryForEverybodyMessage.parentElement).not.toHaveClass('govuk-radios__conditional--hidden'); + }); + }); + + it('should not display on page load when no radio is selected', async () => { + const { queryByText } = await setup(); + + const mandatoryForEverybodyMessage = queryByText('If you click Continue', { exact: false }); + + expect(mandatoryForEverybodyMessage.parentElement).toHaveClass('govuk-radios__conditional--hidden'); + }); + + it('should not display after user clicks Only selected jobs radio', async () => { + const { fixture, getByText, queryByText } = await setup(); + + const selectedJobRolesRadio = getByText('Only selected job roles'); + userEvent.click(selectedJobRolesRadio); + fixture.detectChanges(); + + const mandatoryForEverybodyMessage = queryByText('If you click Continue', { exact: false }); + + expect(mandatoryForEverybodyMessage.parentElement).toHaveClass('govuk-radios__conditional--hidden'); + }); + + it('should stop displaying if user has clicked All job roles and then clicks Only selected jobs radio', async () => { + const { fixture, getByText, queryByText } = await setup(); + + const allJobRolesRadio = getByText('All job roles'); + userEvent.click(allJobRolesRadio); + fixture.detectChanges(); + + const selectedJobRolesRadio = getByText('Only selected job roles'); + userEvent.click(selectedJobRolesRadio); + fixture.detectChanges(); + + const mandatoryForEverybodyMessage = queryByText('If you click Continue', { exact: false }); + + expect(mandatoryForEverybodyMessage.parentElement).toHaveClass('govuk-radios__conditional--hidden'); + }); + }); + + describe('Prefill', () => { + it("should prefill the 'Only selected job roles' radio when set in training service", async () => { + const { getByLabelText } = await setup({ onlySelectedJobRoles: true }); + + const onlySelectedJobRolesRadio = getByLabelText('Only selected job roles') as HTMLInputElement; + const allJobRolesRadio = getByLabelText('All job roles') as HTMLInputElement; + + expect(onlySelectedJobRolesRadio.checked).toBeTruthy(); + expect(allJobRolesRadio.checked).toBeFalsy(); + }); + + const mandatoryTrainingBeingEdited = { + category: 'Activity provision/Well-being', + establishmentId: 4090, + jobs: [{}, {}], + trainingCategoryId: 1, + }; + + it("should prefill the 'Only selected job roles' radio when mandatoryTrainingBeingEdited in training service does not match allJobRolesCount", async () => { + const { getByLabelText } = await setup({ mandatoryTrainingBeingEdited, allJobRolesCount: 37 }); + + const onlySelectedJobRolesRadio = getByLabelText('Only selected job roles') as HTMLInputElement; + const allJobRolesRadio = getByLabelText('All job roles') as HTMLInputElement; + + expect(onlySelectedJobRolesRadio.checked).toBeTruthy(); + expect(allJobRolesRadio.checked).toBeFalsy(); + }); + + it("should prefill the 'All job roles' radio when mandatoryTrainingBeingEdited in training service does match allJobRolesCount", async () => { + const { getByLabelText } = await setup({ + mandatoryTrainingBeingEdited, + allJobRolesCount: mandatoryTrainingBeingEdited.jobs.length, + }); + + const allJobRolesRadio = getByLabelText('All job roles') as HTMLInputElement; + const onlySelectedJobRolesRadio = getByLabelText('Only selected job roles') as HTMLInputElement; + + expect(allJobRolesRadio.checked).toBeTruthy(); + expect(onlySelectedJobRolesRadio.checked).toBeFalsy(); + }); + + it("should prefill the 'Only selected job roles' radio when set in training service even if mandatoryTrainingBeingEdited (for case when user has changed from all to selected and then gone back to this page)", async () => { + const { getByLabelText } = await setup({ + mandatoryTrainingBeingEdited, + allJobRolesCount: mandatoryTrainingBeingEdited.jobs.length, + onlySelectedJobRoles: true, + }); + + const onlySelectedJobRolesRadio = getByLabelText('Only selected job roles') as HTMLInputElement; + const allJobRolesRadio = getByLabelText('All job roles') as HTMLInputElement; + + expect(onlySelectedJobRolesRadio.checked).toBeTruthy(); + expect(allJobRolesRadio.checked).toBeFalsy(); + }); + }); + + describe('Cancel button', () => { + it('should navigate to the add-and-manage-mandatory-training page (relative route ../) when clicked', async () => { + const { component, getByText, routerSpy } = await setup(); + + const cancelButton = getByText('Cancel'); + userEvent.click(cancelButton); + + expect(routerSpy).toHaveBeenCalledWith(['../'], { relativeTo: component.route }); + }); + + it('should clear state in training service when clicked', async () => { + const { getByText, resetStateInTrainingServiceSpy } = await setup(); + + const cancelButton = getByText('Cancel'); + userEvent.click(cancelButton); + + expect(resetStateInTrainingServiceSpy).toHaveBeenCalled(); + }); + }); + + describe('Error messages', () => { + it('should display an error message if option not selected and Continue is clicked', async () => { + const { fixture, getByText, getAllByText } = await setup(); + + fireEvent.click(getByText('Continue')); + fixture.detectChanges(); + + expect( + getAllByText('Select whether this training is for all job roles or only selected job roles').length, + ).toEqual(2); + }); + }); + + describe('On submit', () => { + describe("when 'All job roles' selected", () => { + const selectAllJobRolesAndSubmit = (fixture, getByText) => { + fireEvent.click(getByText('All job roles')); + fixture.detectChanges(); + + fireEvent.click(getByText('Continue')); + fixture.detectChanges(); + }; + + it('should call createAndUpdateMandatoryTraining with training category in service and allJobRoles true', async () => { + const selectedTraining = { + trainingCategory: { + category: 'Activity provision, wellbeing', + id: 3, + seq: 0, + trainingCategoryGroup: 'Care skills and knowledge', + }, + }; + + const { fixture, getByText, establishment, createAndUpdateMandatoryTrainingSpy } = await setup({ + selectedTraining, + }); + + selectAllJobRolesAndSubmit(fixture, getByText); + + expect(createAndUpdateMandatoryTrainingSpy).toHaveBeenCalledWith(establishment.uid, { + trainingCategoryId: selectedTraining.trainingCategory.id, + allJobRoles: true, + jobs: [], + }); + }); + + it('should navigate back to add-and-manage-mandatory-training main page', async () => { + const { component, fixture, getByText, routerSpy } = await setup(); + + selectAllJobRolesAndSubmit(fixture, getByText); + + expect(routerSpy).toHaveBeenCalledWith(['../'], { relativeTo: component.route }); + }); + + it("should display 'Mandatory training category added' banner", async () => { + const { fixture, getByText, alertSpy } = await setup(); + + selectAllJobRolesAndSubmit(fixture, getByText); + await fixture.whenStable(); + + expect(alertSpy).toHaveBeenCalledWith({ + type: 'success', + message: 'Mandatory training category added', + }); + }); + + it('should clear state in training service', async () => { + const { fixture, getByText, resetStateInTrainingServiceSpy } = await setup(); + + selectAllJobRolesAndSubmit(fixture, getByText); + + expect(resetStateInTrainingServiceSpy).toHaveBeenCalled(); + }); + + it('should display server error message if call to backend fails', async () => { + const { fixture, getByText, createAndUpdateMandatoryTrainingSpy } = await setup(); + + createAndUpdateMandatoryTrainingSpy.and.returnValue(throwError(() => new Error('Unexpected error'))); + + selectAllJobRolesAndSubmit(fixture, getByText); + + const expectedErrorMessage = 'There has been a problem saving your mandatory training. Please try again.'; + + expect(getByText(expectedErrorMessage)).toBeTruthy(); + }); + }); + + describe("when 'Only selected job roles' selected", () => { + it('should navigate to select-job-roles page', async () => { + const { component, fixture, getByText, routerSpy } = await setup(); + + fireEvent.click(getByText('Only selected job roles')); + fixture.detectChanges(); + + fireEvent.click(getByText('Continue')); + fixture.detectChanges(); + + expect(routerSpy).toHaveBeenCalledWith(['../', 'select-job-roles'], { relativeTo: component.route }); + }); + + it('should set onlySelectedJobRoles in training service', async () => { + const { fixture, getByText, onlySelectedJobRolesSpy } = await setup(); + + fireEvent.click(getByText('Only selected job roles')); + fixture.detectChanges(); + + fireEvent.click(getByText('Continue')); + fixture.detectChanges(); + + expect(onlySelectedJobRolesSpy).toHaveBeenCalledWith(true); + }); + }); + + describe('Editing existing mandatory training', () => { + const mandatoryTrainingBeingEdited = { + category: 'Activity provision/Well-being', + establishmentId: 4090, + jobs: [{}, {}], + trainingCategoryId: 1, + }; + + it('should include previousTrainingCategoryId in submit props when All job roles selected', async () => { + const { getByText, createAndUpdateMandatoryTrainingSpy, establishment, selectedTraining } = await setup({ + mandatoryTrainingBeingEdited, + allJobRolesCount: mandatoryTrainingBeingEdited.jobs.length, + }); + + fireEvent.click(getByText('Continue')); + + expect(createAndUpdateMandatoryTrainingSpy).toHaveBeenCalledWith(establishment.uid, { + previousTrainingCategoryId: mandatoryTrainingBeingEdited.trainingCategoryId, + trainingCategoryId: selectedTraining.trainingCategory.id, + allJobRoles: true, + jobs: [], + }); + }); + + it("should display 'Mandatory training category updated' banner when All job roles selected", async () => { + const { fixture, getByText, alertSpy } = await setup({ + mandatoryTrainingBeingEdited, + allJobRolesCount: mandatoryTrainingBeingEdited.jobs.length, + }); + + fireEvent.click(getByText('Continue')); + await fixture.whenStable(); + + expect(alertSpy).toHaveBeenCalledWith({ + type: 'success', + message: 'Mandatory training category updated', + }); + }); + }); + }); +}); diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/all-or-selected-job-roles/all-or-selected-job-roles.component.ts b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/all-or-selected-job-roles/all-or-selected-job-roles.component.ts new file mode 100644 index 0000000000..633caf1c5d --- /dev/null +++ b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/all-or-selected-job-roles/all-or-selected-job-roles.component.ts @@ -0,0 +1,175 @@ +import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ErrorDetails } from '@core/model/errorSummary.model'; +import { Establishment, mandatoryTraining } from '@core/model/establishment.model'; +import { SelectedTraining } from '@core/model/training.model'; +import { URLStructure } from '@core/model/url.model'; +import { AlertService } from '@core/services/alert.service'; +import { BackLinkService } from '@core/services/backLink.service'; +import { ErrorSummaryService } from '@core/services/error-summary.service'; +import { EstablishmentService } from '@core/services/establishment.service'; +import { MandatoryTrainingService } from '@core/services/training.service'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-all-or-selected-job-roles', + templateUrl: './all-or-selected-job-roles.component.html', +}) +export class AllOrSelectedJobRolesComponent implements OnInit, OnDestroy, AfterViewInit { + @ViewChild('formEl') formEl: ElementRef; + public form: UntypedFormGroup; + public submitted = false; + public formErrorsMap: ErrorDetails[] = []; + public returnTo: URLStructure; + public workplaceUid: string; + public requiredErrorMessage: string = 'Select whether this training is for all job roles or only selected job roles'; + public selectedTrainingCategory: SelectedTraining; + public selectedRadio: string = null; + public serverError: string; + private subscriptions: Subscription = new Subscription(); + private establishment: Establishment; + private mandatoryTrainingBeingEdited: mandatoryTraining; + + constructor( + private formBuilder: UntypedFormBuilder, + private router: Router, + private errorSummaryService: ErrorSummaryService, + private backLinkService: BackLinkService, + public route: ActivatedRoute, + private trainingService: MandatoryTrainingService, + private alertService: AlertService, + private establishmentService: EstablishmentService, + ) { + this.setupForm(); + } + + ngOnInit(): void { + this.establishment = this.route.snapshot.parent?.data?.establishment; + this.selectedTrainingCategory = this.trainingService.selectedTraining; + this.mandatoryTrainingBeingEdited = this.trainingService.mandatoryTrainingBeingEdited; + const allJobRolesCount = this.trainingService.allJobRolesCount; + + if (this.trainingService.onlySelectedJobRoles) { + this.form.setValue({ allOrSelectedJobRoles: 'selectJobRoles' }); + this.selectedRadio = 'selectJobRoles'; + } else if (this.mandatoryTrainingBeingEdited) { + const selected = + this.mandatoryTrainingBeingEdited.jobs.length == allJobRolesCount ? 'allJobRoles' : 'selectJobRoles'; + + this.form.setValue({ + allOrSelectedJobRoles: selected, + }); + this.selectedRadio = selected; + } + + if (!this.selectedTrainingCategory) { + this.router.navigate(['../select-training-category'], { relativeTo: this.route }); + } + + this.backLinkService.showBackLink(); + this.workplaceUid = this.route.snapshot.data?.establishment?.uid; + } + + ngAfterViewInit(): void { + this.errorSummaryService.formEl$.next(this.formEl); + } + + private navigateToSelectJobRolesPage(): void { + this.router.navigate(['../', 'select-job-roles'], { relativeTo: this.route }); + } + + private navigateBackToAddMandatoryTrainingPage(): Promise { + return this.router.navigate(['../'], { relativeTo: this.route }); + } + + public selectRadio(selectedRadio: string): void { + this.selectedRadio = selectedRadio; + } + + public onSubmit(): void { + this.submitted = true; + this.errorSummaryService.syncFormErrorsEvent.next(true); + + if (this.form.valid) { + if (this.selectedRadio == 'allJobRoles') { + this.createMandatoryTraining(); + } else { + this.trainingService.onlySelectedJobRoles = true; + this.navigateToSelectJobRolesPage(); + } + } else { + this.errorSummaryService.scrollToErrorSummary(); + } + } + + private createMandatoryTraining(): void { + const props = this.generateUpdateProps(); + + this.subscriptions.add( + this.establishmentService.createAndUpdateMandatoryTraining(this.establishment.uid, props).subscribe( + () => { + this.trainingService.resetState(); + + this.navigateBackToAddMandatoryTrainingPage().then(() => { + this.alertService.addAlert({ + type: 'success', + message: `Mandatory training category ${this.mandatoryTrainingBeingEdited ? 'updated' : 'added'}`, + }); + }); + }, + () => { + this.serverError = 'There has been a problem saving your mandatory training. Please try again.'; + }, + ), + ); + } + + private generateUpdateProps(): mandatoryTraining { + const props: mandatoryTraining = { + trainingCategoryId: this.selectedTrainingCategory.trainingCategory.id, + allJobRoles: true, + jobs: [], + }; + + if (this.mandatoryTrainingBeingEdited?.trainingCategoryId) { + props.previousTrainingCategoryId = this.mandatoryTrainingBeingEdited.trainingCategoryId; + } + + return props; + } + + public onCancel(event: Event): void { + event.preventDefault(); + + this.trainingService.resetState(); + this.router.navigate(['../'], { relativeTo: this.route }); + } + + private setupForm(): void { + this.submitted = false; + this.form = this.formBuilder.group({ + allOrSelectedJobRoles: [ + null, + { + validators: [Validators.required], + updateOn: 'submit', + }, + ], + }); + + this.formErrorsMap.push({ + item: 'allOrSelectedJobRoles', + type: [ + { + name: 'required', + message: this.requiredErrorMessage, + }, + ], + }); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } +} diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/delete-mandatory-training-category/delete-mandatory-training-category.component.html b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/delete-mandatory-training-category/delete-mandatory-training-category.component.html index 48d7605290..46a1912b8c 100644 --- a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/delete-mandatory-training-category/delete-mandatory-training-category.component.html +++ b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/delete-mandatory-training-category/delete-mandatory-training-category.component.html @@ -6,18 +6,25 @@

You're about to remove this mandatory training categ
-

- If you do this, '{{ selectedCategory?.category | lowercase }}' will not be mandatory for any of your staff at the - moment. +

+ If you do this, '{{ selectedCategory?.category }}' will no longer be mandatory for any of your staff.

Training category
-
{{ selectedCategory?.category }}
+
{{ selectedCategory?.category }}
Job roles
-
All
+
+
    +
  • {{ job.title }}
  • +
+ All +
@@ -31,7 +38,9 @@

You're about to remove this mandatory training categ or - Cancel diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/delete-mandatory-training-category/delete-mandatory-training-category.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/delete-mandatory-training-category/delete-mandatory-training-category.component.spec.ts index c18e8a6c4f..e73c3bb916 100644 --- a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/delete-mandatory-training-category/delete-mandatory-training-category.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/delete-mandatory-training-category/delete-mandatory-training-category.component.spec.ts @@ -4,45 +4,41 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { AlertService } from '@core/services/alert.service'; import { BackService } from '@core/services/back.service'; -import { EstablishmentService } from '@core/services/establishment.service'; +import { TrainingCategoryService } from '@core/services/training-category.service'; import { TrainingService } from '@core/services/training.service'; import { WindowRef } from '@core/services/window.ref'; -import { MockArticlesService } from '@core/test-utils/MockArticlesService'; -import { MockEstablishmentService } from '@core/test-utils/MockEstablishmentService'; -import { MockPagesService } from '@core/test-utils/MockPagesService'; -import { MockTrainingService } from '@core/test-utils/MockTrainingService'; +import { establishmentBuilder } from '@core/test-utils/MockEstablishmentService'; +import { MockRouter } from '@core/test-utils/MockRouter'; +import { MockTrainingCategoryService } from '@core/test-utils/MockTrainingCategoriesService'; +import { mockMandatoryTraining, MockTrainingService } from '@core/test-utils/MockTrainingService'; import { AddMandatoryTrainingModule } from '@features/training-and-qualifications/add-mandatory-training/add-mandatory-training.module'; import { SharedModule } from '@shared/shared.module'; -import { fireEvent, getByText, render } from '@testing-library/angular'; +import { fireEvent, render } from '@testing-library/angular'; import userEvent from '@testing-library/user-event'; import { DeleteMandatoryTrainingCategoryComponent } from './delete-mandatory-training-category.component'; -import { TrainingCategoryService } from '@core/services/training-category.service'; -import { MockTrainingCategoryService } from '@core/test-utils/MockTrainingCategoriesService'; describe('DeleteMandatoryTrainingCategoryComponent', () => { - const pages = MockPagesService.pagesFactory(); - const articleList = MockArticlesService.articleListFactory(); + async function setup(overrides: any = {}) { + const establishment = establishmentBuilder(); + const routerSpy = jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)); + + const existingMandatoryTraining = mockMandatoryTraining(); + const selectedTraining = existingMandatoryTraining.mandatoryTraining[0]; + const trainingIdInParams = selectedTraining.trainingCategoryId; - async function setup(trainingCategoryId = '1') { - const { fixture, getByText, getAllByText } = await render(DeleteMandatoryTrainingCategoryComponent, { + const setupTools = await render(DeleteMandatoryTrainingCategoryComponent, { imports: [SharedModule, RouterModule, RouterTestingModule, AddMandatoryTrainingModule, HttpClientTestingModule], declarations: [], providers: [ AlertService, BackService, - { - provide: WindowRef, - useClass: WindowRef, - }, - { - provide: EstablishmentService, - useClass: MockEstablishmentService, - }, + WindowRef, { provide: TrainingService, useClass: MockTrainingService, }, + { provide: Router, useFactory: MockRouter.factory({ navigate: routerSpy }) }, { provide: TrainingCategoryService, useClass: MockTrainingCategoryService, @@ -51,13 +47,12 @@ describe('DeleteMandatoryTrainingCategoryComponent', () => { provide: ActivatedRoute, useValue: { snapshot: { - parent: { - url: [{ path: trainingCategoryId }], - data: { - establishment: { - uid: '9', - }, - }, + params: { + trainingCategoryId: overrides.trainingCategoryId ?? trainingIdInParams, + }, + data: { + establishment, + existingMandatoryTraining: overrides.mandatoryTraining ?? existingMandatoryTraining, }, url: [{ path: 'delete-mandatory-training-category' }], }, @@ -67,21 +62,23 @@ describe('DeleteMandatoryTrainingCategoryComponent', () => { }); const injector = getTestBed(); - const router = injector.inject(Router) as Router; + const alertService = injector.inject(AlertService) as AlertService; + const alertSpy = spyOn(alertService, 'addAlert').and.callThrough(); + const trainingService = injector.inject(TrainingService) as TrainingService; - const routerSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); const deleteMandatoryTrainingCategorySpy = spyOn(trainingService, 'deleteCategoryById').and.callThrough(); - const alertSpy = spyOn(alertService, 'addAlert').and.callThrough(); - const component = fixture.componentInstance; + + const component = setupTools.fixture.componentInstance; + return { + ...setupTools, component, - fixture, - getByText, - getAllByText, routerSpy, deleteMandatoryTrainingCategorySpy, alertSpy, + establishment, + selectedTraining, }; } @@ -92,49 +89,90 @@ describe('DeleteMandatoryTrainingCategoryComponent', () => { it('should display the heading', async () => { const { getByText } = await setup(); - expect(getByText(`You're about to remove this mandatory training category`)); + expect(getByText("You're about to remove this mandatory training category")).toBeTruthy(); }); - it('should display the correct training name when navigating to the page with a category ID', async () => { - const { getAllByText } = await setup(); - expect(getAllByText('Activity provision/Well-being', { exact: false }).length).toEqual(2); + it('should display a warning message with training category name', async () => { + const { getByText, selectedTraining } = await setup(); + + const expectedMessage = `If you do this, '${selectedTraining?.category}' will no longer be mandatory for any of your staff.`; + expect(getByText(expectedMessage)).toBeTruthy(); }); - it('Should render Remove categories button and cancel link', async () => { + it('should render Remove categories button and cancel link', async () => { const { getByText } = await setup(); + expect(getByText('Remove category')).toBeTruthy(); expect(getByText('Cancel')).toBeTruthy(); }); - describe('Cancel link', () => { - it('should navigate back to the mandatory details summary page when clicked', async () => { - const { component, getByText, routerSpy } = await setup(); - userEvent.click(getByText('Cancel')); - expect(routerSpy).toHaveBeenCalledWith([ - '/workplace', - component.establishment.uid, - 'add-and-manage-mandatory-training', - ]); + it('should navigate back to add-and-manage-mandatory-training page if training category not found in existing mandatory training', async () => { + const unexpectedTrainingCategoryId = '301'; + + const { routerSpy, establishment } = await setup({ trainingCategoryId: unexpectedTrainingCategoryId }); + + expect(routerSpy).toHaveBeenCalledWith(['/workplace', establishment.uid, 'add-and-manage-mandatory-training']); + }); + + describe('Displaying job roles', () => { + it('should display selected job roles for selected mandatory training when it is not for all job roles', async () => { + const { getByText, selectedTraining } = await setup(); + + selectedTraining.jobs.forEach((job) => { + expect(getByText(job.title)).toBeTruthy(); + }); + }); + + it('should display All when number of job roles for selected training matches allJobRolesCount', async () => { + const mandatoryTraining = mockMandatoryTraining(); + const selectedTraining = mandatoryTraining.mandatoryTraining[1]; + const trainingIdInParams = selectedTraining.trainingCategoryId; + + const { getByText } = await setup({ mandatoryTraining, trainingCategoryId: trainingIdInParams }); + + expect(getByText('All')).toBeTruthy(); }); }); - describe('Remove category button', () => { - it('should call the deleteCategoryById function in the training service when clicked', async () => { - const { getByText, deleteMandatoryTrainingCategorySpy } = await setup(); + describe('On submit', () => { + it('should call deleteCategoryById in the training service', async () => { + const { getByText, deleteMandatoryTrainingCategorySpy, establishment, selectedTraining } = await setup(); + userEvent.click(getByText('Remove category')); - expect(deleteMandatoryTrainingCategorySpy).toHaveBeenCalled(); + expect(deleteMandatoryTrainingCategorySpy).toHaveBeenCalledWith( + establishment.id, + selectedTraining.trainingCategoryId, + ); }); - }); - describe('success alert', async () => { - it('should display a success banner when a category is removed', async () => { - const { alertSpy, getByText } = await setup(); + it("should display a success banner with 'Mandatory training category removed'", async () => { + const { fixture, alertSpy, getByText } = await setup(); fireEvent.click(getByText('Remove category')); + await fixture.whenStable(); + expect(alertSpy).toHaveBeenCalledWith({ type: 'success', message: 'Mandatory training category removed', }); }); + + it('should navigate back to add-and-manage-mandatory-training page', async () => { + const { getByText, routerSpy, establishment } = await setup(); + + userEvent.click(getByText('Remove category')); + + expect(routerSpy).toHaveBeenCalledWith(['/workplace', establishment.uid, 'add-and-manage-mandatory-training']); + }); + }); + + describe('Cancel link', () => { + it('should navigate back to the mandatory details summary page when clicked', async () => { + const { getByText, routerSpy, establishment } = await setup(); + + userEvent.click(getByText('Cancel')); + + expect(routerSpy).toHaveBeenCalledWith(['/workplace', establishment.uid, 'add-and-manage-mandatory-training']); + }); }); }); diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/delete-mandatory-training-category/delete-mandatory-training-category.component.ts b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/delete-mandatory-training-category/delete-mandatory-training-category.component.ts index 8ef26e3f99..69bbb3cbd2 100644 --- a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/delete-mandatory-training-category/delete-mandatory-training-category.component.ts +++ b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/delete-mandatory-training-category/delete-mandatory-training-category.component.ts @@ -1,11 +1,9 @@ -import { Component, OnInit } from '@angular/core'; -import { UntypedFormGroup } from '@angular/forms'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { Establishment } from '@core/model/establishment.model'; -import { TrainingCategory } from '@core/model/training.model'; +import { mandatoryTraining } from '@core/model/training.model'; import { AlertService } from '@core/services/alert.service'; import { BackLinkService } from '@core/services/backLink.service'; -import { EstablishmentService } from '@core/services/establishment.service'; import { TrainingCategoryService } from '@core/services/training-category.service'; import { TrainingService } from '@core/services/training.service'; import { Subscription } from 'rxjs'; @@ -14,12 +12,12 @@ import { Subscription } from 'rxjs'; selector: 'app-delete-mandatory-training-category', templateUrl: './delete-mandatory-training-category.component.html', }) -export class DeleteMandatoryTrainingCategoryComponent implements OnInit { - public categories: TrainingCategory[]; - public selectedCategory: TrainingCategory; - public form: UntypedFormGroup; +export class DeleteMandatoryTrainingCategoryComponent implements OnInit, OnDestroy { + public selectedCategory: mandatoryTraining; public establishment: Establishment; + public allJobRolesCount: number; private subscriptions: Subscription = new Subscription(); + constructor( protected backLinkService: BackLinkService, protected trainingService: TrainingService, @@ -27,31 +25,46 @@ export class DeleteMandatoryTrainingCategoryComponent implements OnInit { protected route: ActivatedRoute, protected router: Router, private alertService: AlertService, - protected establishmentService: EstablishmentService, ) {} ngOnInit(): void { - this.setBackLink(); - const id = parseInt(this.route.snapshot.parent.url[0].path, 10); - this.establishment = this.route.snapshot.parent.data.establishment; - this.trainingCategoryService.getCategories().subscribe((x) => (this.selectedCategory = x.find((y) => y.id === id))); + this.backLinkService.showBackLink(); + + const trainingCategoryIdInParams = parseInt(this.route.snapshot.params?.trainingCategoryId); + this.establishment = this.route.snapshot.data.establishment; + const existingMandatoryTraining = this.route.snapshot.data.existingMandatoryTraining; + + this.allJobRolesCount = existingMandatoryTraining?.allJobRolesCount; + + this.selectedCategory = existingMandatoryTraining?.mandatoryTraining.find( + (category) => category.trainingCategoryId === trainingCategoryIdInParams, + ); + + if (!this.selectedCategory) { + this.navigateBackToMandatoryTrainingHomePage(); + } } public onDelete(): void { - this.trainingService.deleteCategoryById(this.establishment.id, this.selectedCategory.id).subscribe(() => { - this.router.navigate(['/workplace', this.establishment.uid, 'add-and-manage-mandatory-training']); - this.alertService.addAlert({ - type: 'success', - message: 'Mandatory training category removed', - }); - }); + this.subscriptions.add( + this.trainingService + .deleteCategoryById(this.establishment.id, this.selectedCategory.trainingCategoryId) + .subscribe(() => { + this.navigateBackToMandatoryTrainingHomePage().then(() => { + this.alertService.addAlert({ + type: 'success', + message: 'Mandatory training category removed', + }); + }); + }), + ); } - public onCancel(): void { - this.router.navigate(['/workplace', this.establishment.uid, 'add-and-manage-mandatory-training']); + public navigateBackToMandatoryTrainingHomePage(): Promise { + return this.router.navigate(['/workplace', this.establishment.uid, 'add-and-manage-mandatory-training']); } - public setBackLink(): void { - this.backLinkService.showBackLink(); + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); } } diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/delete-mandatory-training/delete-all-mandatory-training.component.html b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/delete-mandatory-training/delete-all-mandatory-training.component.html index c90c3fa22f..c4dac72802 100644 --- a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/delete-mandatory-training/delete-all-mandatory-training.component.html +++ b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/delete-mandatory-training/delete-all-mandatory-training.component.html @@ -1,9 +1,7 @@

You're about to remove all mandatory training categories for your workplace

-

- If you do this, your workplace will not have any training set up as being mandatory for your staff at the moment. -

+

If you do this, your workplace will no longer have any training set up as being mandatory for your staff.

+ Cancel +
+ +
+
diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/select-job-roles-mandatory/select-job-roles-mandatory.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/select-job-roles-mandatory/select-job-roles-mandatory.component.spec.ts new file mode 100644 index 0000000000..39ab305ec4 --- /dev/null +++ b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/select-job-roles-mandatory/select-job-roles-mandatory.component.spec.ts @@ -0,0 +1,417 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { getTestBed } from '@angular/core/testing'; +import { FormBuilder } from '@angular/forms'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Establishment } from '@core/model/establishment.model'; +import { AlertService } from '@core/services/alert.service'; +import { BackLinkService } from '@core/services/backLink.service'; +import { ErrorSummaryService } from '@core/services/error-summary.service'; +import { EstablishmentService } from '@core/services/establishment.service'; +import { MandatoryTrainingService } from '@core/services/training.service'; +import { WindowRef } from '@core/services/window.ref'; +import { establishmentBuilder, MockEstablishmentService } from '@core/test-utils/MockEstablishmentService'; +import { MockRouter } from '@core/test-utils/MockRouter'; +import { MockMandatoryTrainingService } from '@core/test-utils/MockTrainingService'; +import { GroupedRadioButtonAccordionComponent } from '@shared/components/accordions/radio-button-accordion/grouped-radio-button-accordion/grouped-radio-button-accordion.component'; +import { RadioButtonAccordionComponent } from '@shared/components/accordions/radio-button-accordion/radio-button-accordion.component'; +import { SharedModule } from '@shared/shared.module'; +import { render, within } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; +import { throwError } from 'rxjs'; + +import { AddMandatoryTrainingModule } from '../add-mandatory-training.module'; +import { SelectJobRolesMandatoryComponent } from './select-job-roles-mandatory.component'; + +describe('SelectJobRolesMandatoryComponent', () => { + const mockAvailableJobs = [ + { + id: 4, + title: 'Allied health professional (not occupational therapist)', + jobRoleGroup: 'Professional and related roles', + }, + { + id: 10, + title: 'Care worker', + jobRoleGroup: 'Care providing roles', + }, + { + id: 23, + title: 'Registered nurse', + jobRoleGroup: 'Professional and related roles', + }, + { + id: 27, + title: 'Social worker', + jobRoleGroup: 'Professional and related roles', + }, + { + id: 20, + title: 'Other (directly involved in providing care)', + jobRoleGroup: 'Care providing roles', + }, + ]; + + async function setup(overrides: any = {}) { + const establishment = establishmentBuilder() as Establishment; + const routerSpy = jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)); + const selectedTraining = { + trainingCategory: { + category: 'Activity provision, wellbeing', + id: 1, + seq: 0, + trainingCategoryGroup: 'Care skills and knowledge', + }, + }; + + const setupTools = await render(SelectJobRolesMandatoryComponent, { + imports: [HttpClientTestingModule, SharedModule, RouterModule, RouterTestingModule, AddMandatoryTrainingModule], + declarations: [GroupedRadioButtonAccordionComponent, RadioButtonAccordionComponent], + providers: [ + BackLinkService, + ErrorSummaryService, + AlertService, + WindowRef, + FormBuilder, + { provide: Router, useFactory: MockRouter.factory({ navigate: routerSpy }) }, + { provide: EstablishmentService, useClass: MockEstablishmentService }, + { + provide: MandatoryTrainingService, + useFactory: MockMandatoryTrainingService.factory({ selectedTraining, ...overrides }), + }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + data: { + establishment, + jobs: mockAvailableJobs, + }, + }, + }, + }, + ], + }); + + const component = setupTools.fixture.componentInstance; + + const injector = getTestBed(); + + const alertService = injector.inject(AlertService) as AlertService; + const alertSpy = spyOn(alertService, 'addAlert').and.callThrough(); + + const trainingService = injector.inject(MandatoryTrainingService) as MandatoryTrainingService; + const resetStateInTrainingServiceSpy = spyOn(trainingService, 'resetState').and.callThrough(); + + const establishmentService = injector.inject(EstablishmentService) as EstablishmentService; + const createAndUpdateMandatoryTrainingSpy = spyOn( + establishmentService, + 'createAndUpdateMandatoryTraining', + ).and.callThrough(); + + return { + ...setupTools, + component, + routerSpy, + resetStateInTrainingServiceSpy, + alertSpy, + selectedTraining, + createAndUpdateMandatoryTrainingSpy, + establishment, + }; + } + + it('should create', async () => { + const { component } = await setup(); + expect(component).toBeTruthy(); + }); + + it('should show the page caption', async () => { + const { getByText } = await setup(); + + const caption = getByText('Add a mandatory training category'); + + expect(caption).toBeTruthy(); + }); + + it('should show the page heading', async () => { + const { getByText } = await setup(); + + const heading = getByText('Select the job roles that need this training'); + + expect(heading).toBeTruthy(); + }); + + it('should navigate back to the select training category page if no training category set in training service', async () => { + const { component, routerSpy } = await setup({ selectedTraining: null }); + + expect(routerSpy).toHaveBeenCalledWith(['../select-training-category'], { relativeTo: component.route }); + }); + + describe('Accordion', () => { + it('should render an accordion for job role selection', async () => { + const { getByTestId, getByText } = await setup(); + + expect(getByTestId('selectJobRolesAccordion')).toBeTruthy(); + expect(getByText('Show all job roles')).toBeTruthy(); + }); + + it('should render an accordion section for each job role group', async () => { + const { getByText } = await setup(); + + expect(getByText('Care providing roles')).toBeTruthy(); + expect(getByText('Professional and related roles')).toBeTruthy(); + }); + + it('should render a checkbox for each job role', async () => { + const { getByRole } = await setup(); + + mockAvailableJobs.forEach((job) => { + const checkbox = getByRole('checkbox', { name: job.title }); + expect(checkbox).toBeTruthy(); + }); + }); + }); + + describe('On submit', () => { + const selectJobRolesAndSave = (fixture, getByText, jobRoles = [mockAvailableJobs[0]]) => { + userEvent.click(getByText('Show all job roles')); + + jobRoles.forEach((role) => userEvent.click(getByText(role.title))); + fixture.detectChanges(); + + userEvent.click(getByText('Save mandatory training')); + fixture.detectChanges(); + }; + + it('should navigate back to add-and-manage-mandatory-training main page', async () => { + const { fixture, component, getByText, routerSpy } = await setup(); + + selectJobRolesAndSave(fixture, getByText); + + expect(routerSpy).toHaveBeenCalledWith(['../'], { relativeTo: component.route }); + }); + + it("should display 'Mandatory training category added' banner", async () => { + const { fixture, getByText, alertSpy } = await setup(); + + selectJobRolesAndSave(fixture, getByText); + await fixture.whenStable(); + + expect(alertSpy).toHaveBeenCalledWith({ + type: 'success', + message: 'Mandatory training category added', + }); + }); + + it('should clear state in training service', async () => { + const { fixture, getByText, resetStateInTrainingServiceSpy } = await setup(); + + selectJobRolesAndSave(fixture, getByText); + + expect(resetStateInTrainingServiceSpy).toHaveBeenCalled(); + }); + + [ + [mockAvailableJobs[0], mockAvailableJobs[1]], + [mockAvailableJobs[2], mockAvailableJobs[3]], + ].forEach((jobRoleSet) => { + it(`should call createAndUpdateMandatoryTraining with training category in service and selected job roles ('${jobRoleSet[0].title}', '${jobRoleSet[1].title}')`, async () => { + const { fixture, getByText, establishment, selectedTraining, createAndUpdateMandatoryTrainingSpy } = + await setup(); + + selectJobRolesAndSave(fixture, getByText, jobRoleSet); + + expect(createAndUpdateMandatoryTrainingSpy).toHaveBeenCalledWith(establishment.uid, { + trainingCategoryId: selectedTraining.trainingCategory.id, + allJobRoles: false, + jobs: [{ id: jobRoleSet[0].id }, { id: jobRoleSet[1].id }], + }); + }); + }); + + it('should display server error message if call to backend fails', async () => { + const { fixture, getByText, createAndUpdateMandatoryTrainingSpy } = await setup(); + + createAndUpdateMandatoryTrainingSpy.and.returnValue(throwError(() => new Error('Unexpected error'))); + + selectJobRolesAndSave(fixture, getByText); + + const expectedErrorMessage = 'There has been a problem saving your mandatory training. Please try again.'; + + expect(getByText(expectedErrorMessage)).toBeTruthy(); + }); + }); + + describe('Errors', () => { + const expectedErrorMessage = 'Select the job roles that need this training'; + const errorSummaryBoxHeading = 'There is a problem'; + + it('should display an error message on submit if no job roles are selected', async () => { + const { fixture, getByRole, getByText, getByTestId } = await setup(); + + userEvent.click(getByRole('button', { name: 'Save mandatory training' })); + fixture.detectChanges(); + + const accordion = getByTestId('selectJobRolesAccordion'); + expect(within(accordion).getByText(expectedErrorMessage)).toBeTruthy(); + + const thereIsAProblemMessage = getByText(errorSummaryBoxHeading); + + const errorSummaryBox = thereIsAProblemMessage.parentElement; + expect(within(errorSummaryBox).getByText(expectedErrorMessage)).toBeTruthy(); + }); + + it('should expand the whole accordion on error', async () => { + const { fixture, getByRole, getByText } = await setup(); + + userEvent.click(getByRole('button', { name: 'Save mandatory training' })); + fixture.detectChanges(); + + expect(getByText('Hide all job roles')).toBeTruthy(); + }); + + it('should continue to display error messages after empty submit and then user selects job roles', async () => { + const { fixture, getByRole, getByText } = await setup(); + + userEvent.click(getByRole('button', { name: 'Save mandatory training' })); + fixture.detectChanges(); + + const errorSummaryBox = getByText(errorSummaryBoxHeading).parentElement; + + expect(errorSummaryBox).toBeTruthy(); + expect(within(errorSummaryBox).getByText(expectedErrorMessage)).toBeTruthy(); + + userEvent.click(getByText('Care worker')); + userEvent.click(getByText('Registered nurse')); + + fixture.detectChanges(); + + const errorSummaryBoxStillThere = getByText(errorSummaryBoxHeading).parentElement; + + expect(errorSummaryBoxStillThere).toBeTruthy(); + expect(within(errorSummaryBoxStillThere).getByText(expectedErrorMessage)).toBeTruthy(); + }); + }); + + describe('On click of Cancel button', () => { + it('should return to the add-and-manage-mandatory-training page', async () => { + const { component, getByText, routerSpy } = await setup(); + + const cancelButton = getByText('Cancel'); + userEvent.click(cancelButton); + + expect(routerSpy).toHaveBeenCalledWith(['../'], { relativeTo: component.route }); + }); + + it('should clear state in training service', async () => { + const { getByText, resetStateInTrainingServiceSpy } = await setup(); + + const cancelButton = getByText('Cancel'); + userEvent.click(cancelButton); + + expect(resetStateInTrainingServiceSpy).toHaveBeenCalled(); + }); + }); + + describe('Existing mandatory training being edited', () => { + const createMandatoryTrainingBeingEdited = (jobs) => { + return { + category: 'Activity provision/Well-being', + establishmentId: 4090, + jobs, + trainingCategoryId: 1, + }; + }; + + it('should check the currently selected job roles if mandatoryTrainingBeingEdited in training service (when editing existing mandatory training)', async () => { + const jobs = [mockAvailableJobs[0], mockAvailableJobs[1]]; + + const { getByLabelText } = await setup({ + mandatoryTrainingBeingEdited: createMandatoryTrainingBeingEdited(jobs), + allJobRolesCount: 37, + }); + + jobs.forEach((jobRole) => { + const jobRoleCheckbox = getByLabelText(jobRole.title) as HTMLInputElement; + expect(jobRoleCheckbox.checked).toBeTruthy(); + }); + }); + + it('should not check the currently selected job roles if mandatoryTrainingBeingEdited has all job roles (when editing existing mandatory training)', async () => { + const jobs = [mockAvailableJobs[0], mockAvailableJobs[1]]; + + const { getByLabelText } = await setup({ + mandatoryTrainingBeingEdited: createMandatoryTrainingBeingEdited(jobs), + allJobRolesCount: 2, + }); + + jobs.forEach((jobRole) => { + const jobRoleCheckbox = getByLabelText(jobRole.title) as HTMLInputElement; + expect(jobRoleCheckbox.checked).toBeFalsy(); + }); + }); + + it('should expand the accordion for job groups that have job roles selected', async () => { + const jobs = [mockAvailableJobs[0], mockAvailableJobs[1]]; + + const { getByLabelText } = await setup({ + mandatoryTrainingBeingEdited: createMandatoryTrainingBeingEdited(jobs), + }); + + jobs.forEach((jobRole) => { + const jobRoleGroupAccordionSection = getByLabelText(jobRole.jobRoleGroup); + expect(within(jobRoleGroupAccordionSection).getByText('Hide')).toBeTruthy(); // is expanded + }); + }); + + it('should not expand the accordion for job groups that do not have job roles selected', async () => { + const jobs = [mockAvailableJobs[0]]; + + const { getByLabelText } = await setup({ + mandatoryTrainingBeingEdited: createMandatoryTrainingBeingEdited(jobs), + }); + + const jobRoleGroupAccordionSectionWithPreselected = getByLabelText(jobs[0].jobRoleGroup); + expect(within(jobRoleGroupAccordionSectionWithPreselected).getByText('Hide')).toBeTruthy(); // is expanded + + const jobRoleGroupAccordionSectionWithNoneSelected = getByLabelText(mockAvailableJobs[1].jobRoleGroup); + expect(within(jobRoleGroupAccordionSectionWithNoneSelected).getByText('Show')).toBeTruthy(); // not expanded + }); + + it('should call createAndUpdateMandatoryTraining with training category in service and previous training ID', async () => { + const jobs = [mockAvailableJobs[0], mockAvailableJobs[1]]; + const mandatoryTrainingBeingEdited = createMandatoryTrainingBeingEdited(jobs); + + const { getByText, establishment, selectedTraining, createAndUpdateMandatoryTrainingSpy } = await setup({ + mandatoryTrainingBeingEdited, + }); + + userEvent.click(getByText('Save mandatory training')); + + expect(createAndUpdateMandatoryTrainingSpy).toHaveBeenCalledWith(establishment.uid, { + previousTrainingCategoryId: mandatoryTrainingBeingEdited.trainingCategoryId, + trainingCategoryId: selectedTraining.trainingCategory.id, + allJobRoles: false, + jobs: [{ id: jobs[0].id }, { id: jobs[1].id }], + }); + }); + + it("should display 'Mandatory training category updated' banner on submit", async () => { + const jobs = [mockAvailableJobs[0], mockAvailableJobs[1]]; + const mandatoryTrainingBeingEdited = createMandatoryTrainingBeingEdited(jobs); + + const { fixture, getByText, alertSpy } = await setup({ + mandatoryTrainingBeingEdited, + }); + + userEvent.click(getByText('Save mandatory training')); + await fixture.whenStable(); + + expect(alertSpy).toHaveBeenCalledWith({ + type: 'success', + message: 'Mandatory training category updated', + }); + }); + }); +}); diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/select-job-roles-mandatory/select-job-roles-mandatory.component.ts b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/select-job-roles-mandatory/select-job-roles-mandatory.component.ts new file mode 100644 index 0000000000..58e1df6d5b --- /dev/null +++ b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/select-job-roles-mandatory/select-job-roles-mandatory.component.ts @@ -0,0 +1,185 @@ +import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ErrorDetails } from '@core/model/errorSummary.model'; +import { Establishment, mandatoryTraining } from '@core/model/establishment.model'; +import { Job, JobGroup } from '@core/model/job.model'; +import { SelectedTraining } from '@core/model/training.model'; +import { AlertService } from '@core/services/alert.service'; +import { BackLinkService } from '@core/services/backLink.service'; +import { ErrorSummaryService } from '@core/services/error-summary.service'; +import { EstablishmentService } from '@core/services/establishment.service'; +import { JobService } from '@core/services/job.service'; +import { MandatoryTrainingService } from '@core/services/training.service'; +import { AccordionGroupComponent } from '@shared/components/accordions/generic-accordion/accordion-group/accordion-group.component'; +import { CustomValidators } from '@shared/validators/custom-form-validators'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-select-job-roles-mandatory', + templateUrl: './select-job-roles-mandatory.component.html', +}) +export class SelectJobRolesMandatoryComponent implements OnInit, OnDestroy, AfterViewInit { + constructor( + private formBuilder: UntypedFormBuilder, + private trainingService: MandatoryTrainingService, + private router: Router, + private errorSummaryService: ErrorSummaryService, + private backLinkService: BackLinkService, + private alertService: AlertService, + private establishmentService: EstablishmentService, + public route: ActivatedRoute, + ) {} + + @ViewChild('accordion') accordion: AccordionGroupComponent; + @ViewChild('formEl') formEl: ElementRef; + + public form: UntypedFormGroup; + public jobGroups: JobGroup[] = []; + public jobsAvailable: Job[] = []; + public submitted: boolean; + public selectedJobIds: number[] = []; + public errorMessageOnEmptyInput: string = 'Select the job roles that need this training'; + public formErrorsMap: Array = []; + public serverError: string; + public subscriptions: Subscription = new Subscription(); + private establishment: Establishment; + private selectedTrainingCategory: SelectedTraining; + private mandatoryTrainingBeingEdited: mandatoryTraining; + public jobGroupsToOpenAtStart: string[] = []; + + ngOnInit(): void { + this.selectedTrainingCategory = this.trainingService.selectedTraining; + this.establishment = this.route.snapshot.data?.establishment; + + if (!this.selectedTrainingCategory) { + this.router.navigate(['../select-training-category'], { relativeTo: this.route }); + } + + this.backLinkService.showBackLink(); + this.getJobs(); + this.setupForm(); + this.mandatoryTrainingBeingEdited = this.trainingService.mandatoryTrainingBeingEdited; + + if (this.mandatoryTrainingBeingEdited) { + this.prefillForm(); + } + } + + private getJobs(): void { + this.jobsAvailable = this.route.snapshot.data.jobs; + this.jobGroups = JobService.sortJobsByJobGroup(this.jobsAvailable); + } + + private setupForm(): void { + this.form = this.formBuilder.group({ + selectedJobRoles: [[], { validators: CustomValidators.validateArrayNotEmpty(), updateOn: 'submit' }], + }); + + this.formErrorsMap = [ + { + item: 'selectedJobRoles', + type: [ + { + name: 'selectedNone', + message: this.errorMessageOnEmptyInput, + }, + ], + }, + ]; + } + + public onCheckboxClick(target: HTMLInputElement) { + const jobId = Number(target.value); + + if (this.selectedJobIds.includes(jobId)) { + this.selectedJobIds = this.selectedJobIds.filter((id) => id !== jobId); + } else { + this.selectedJobIds = [...this.selectedJobIds, jobId]; + } + } + + public onSubmit(): void { + this.submitted = true; + + this.form.get('selectedJobRoles').setValue(this.selectedJobIds); + + if (this.form.invalid) { + this.accordion.showAll(); + this.errorSummaryService.scrollToErrorSummary(); + return; + } + + this.createMandatoryTraining(); + } + + private createMandatoryTraining(): void { + const props = this.generateUpdateProps(); + + this.subscriptions.add( + this.establishmentService.createAndUpdateMandatoryTraining(this.establishment.uid, props).subscribe( + () => { + this.trainingService.resetState(); + + this.navigateBackToAddMandatoryTrainingPage().then(() => { + this.alertService.addAlert({ + type: 'success', + message: `Mandatory training category ${this.mandatoryTrainingBeingEdited ? 'updated' : 'added'}`, + }); + }); + }, + () => { + this.serverError = 'There has been a problem saving your mandatory training. Please try again.'; + }, + ), + ); + } + + private generateUpdateProps(): mandatoryTraining { + const props: mandatoryTraining = { + trainingCategoryId: this.selectedTrainingCategory.trainingCategory.id, + allJobRoles: false, + jobs: this.selectedJobIds.map((id) => { + return { id }; + }), + }; + + if (this.mandatoryTrainingBeingEdited?.trainingCategoryId) { + props.previousTrainingCategoryId = this.mandatoryTrainingBeingEdited.trainingCategoryId; + } + + return props; + } + + private prefillForm(): void { + if (this.mandatoryTrainingBeingEdited.jobs?.length == this.trainingService.allJobRolesCount) return; + + this.selectedJobIds = this.mandatoryTrainingBeingEdited.jobs.map((job) => Number(job.id)); + this.jobGroupsToOpenAtStart = this.jobGroups + .filter((group) => group.items.some((job) => this.selectedJobIds.includes(job.id))) + .map((group) => group.title); + + this.form.patchValue({ + selectedJobRoles: this.selectedJobIds, + }); + } + + public onCancel(event: Event): void { + event.preventDefault(); + + this.trainingService.resetState(); + this.navigateBackToAddMandatoryTrainingPage(); + } + + private navigateBackToAddMandatoryTrainingPage(): Promise { + return this.router.navigate(['../'], { relativeTo: this.route }); + } + + ngAfterViewInit(): void { + this.errorSummaryService.formEl$.next(this.formEl); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } +} diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/select-training-category-mandatory/select-training-category-mandatory.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/select-training-category-mandatory/select-training-category-mandatory.component.spec.ts new file mode 100644 index 0000000000..a3e3547fd9 --- /dev/null +++ b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/select-training-category-mandatory/select-training-category-mandatory.component.spec.ts @@ -0,0 +1,273 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { getTestBed } from '@angular/core/testing'; +import { FormBuilder } from '@angular/forms'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Establishment } from '@core/model/establishment.model'; +import { BackLinkService } from '@core/services/backLink.service'; +import { ErrorSummaryService } from '@core/services/error-summary.service'; +import { MandatoryTrainingService } from '@core/services/training.service'; +import { WindowRef } from '@core/services/window.ref'; +import { WorkerService } from '@core/services/worker.service'; +import { establishmentBuilder } from '@core/test-utils/MockEstablishmentService'; +import { trainingCategories } from '@core/test-utils/MockTrainingCategoriesService'; +import { + MockMandatoryTrainingService, + MockTrainingServiceWithPreselectedStaff, +} from '@core/test-utils/MockTrainingService'; +import { MockWorkerService } from '@core/test-utils/MockWorkerService'; +import { GroupedRadioButtonAccordionComponent } from '@shared/components/accordions/radio-button-accordion/grouped-radio-button-accordion/grouped-radio-button-accordion.component'; +import { RadioButtonAccordionComponent } from '@shared/components/accordions/radio-button-accordion/radio-button-accordion.component'; +import { SharedModule } from '@shared/shared.module'; +import { fireEvent, render } from '@testing-library/angular'; + +import { AddMandatoryTrainingModule } from '../add-mandatory-training.module'; +import { SelectTrainingCategoryMandatoryComponent } from './select-training-category-mandatory.component'; + +describe('SelectTrainingCategoryMandatoryComponent', () => { + async function setup(overrides: any = {}) { + const establishment = establishmentBuilder() as Establishment; + + const setupTools = await render(SelectTrainingCategoryMandatoryComponent, { + imports: [HttpClientTestingModule, SharedModule, RouterModule, RouterTestingModule, AddMandatoryTrainingModule], + declarations: [GroupedRadioButtonAccordionComponent, RadioButtonAccordionComponent], + providers: [ + BackLinkService, + ErrorSummaryService, + WindowRef, + FormBuilder, + { + provide: WorkerService, + useClass: MockWorkerService, + }, + { + provide: MandatoryTrainingService, + useFactory: overrides.prefill + ? MockTrainingServiceWithPreselectedStaff.factory() + : MockMandatoryTrainingService.factory(overrides.trainingService), + }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + data: { + establishment, + trainingCategories: overrides.trainingCategories ?? trainingCategories, + existingMandatoryTraining: overrides.existingMandatoryTraining ?? {}, + }, + queryParamMap: { + get: () => overrides.selectedTraining ?? null, + }, + }, + }, + }, + ], + }); + + const component = setupTools.fixture.componentInstance; + const injector = getTestBed(); + + const router = injector.inject(Router) as Router; + const trainingService = injector.inject(MandatoryTrainingService) as MandatoryTrainingService; + + const routerSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + return { + ...setupTools, + component, + routerSpy, + trainingService, + establishment, + }; + } + + it('should create', async () => { + const { component } = await setup(); + expect(component).toBeTruthy(); + }); + + it('should show the page caption', async () => { + const { getByText } = await setup(); + + const caption = getByText('Add a mandatory training category'); + + expect(caption).toBeTruthy(); + }); + + it('should show the page heading', async () => { + const { getByText } = await setup(); + + const heading = getByText('Select the training category that you want to make mandatory'); + + expect(heading).toBeTruthy(); + }); + + it('should display the training category groups and descriptions in the accordion', async () => { + const { getByText } = await setup(); + + const trainingCategoryGroups = [ + { name: 'Care skills and knowledge', description: "Training like 'duty of care', 'safeguarding adults'" }, + { name: 'Health and safety in the workplace', description: "Training like 'fire safety', 'first aid'" }, + { + name: 'IT, digital and data in the workplace', + description: "Training like 'online safety and security', 'working with digital technology'", + }, + { + name: 'Specific conditions and disabilities', + description: "Training like 'dementia care', 'Oliver McGowan Mandatory Training'", + }, + { name: 'Staff development', description: "Training like 'communication', 'leadership and management'" }, + ]; + + trainingCategoryGroups.forEach((group) => { + expect(getByText(group.name)).toBeTruthy(); + expect(getByText(group.description)).toBeTruthy(); + }); + }); + + it('should set the selected training category in the training service after selecting category and clicking continue', async () => { + const { getByText, trainingService } = await setup(); + + const setSelectedTrainingCategorySpy = spyOn(trainingService, 'setSelectedTrainingCategory'); + + const openAllLinkLink = getByText('Show all categories'); + fireEvent.click(openAllLinkLink); + + const autismCategory = getByText('Autism'); + fireEvent.click(autismCategory); + + const continueButton = getByText('Continue'); + fireEvent.click(continueButton); + + expect(setSelectedTrainingCategorySpy).toHaveBeenCalledWith({ + id: 2, + seq: 20, + category: 'Autism', + trainingCategoryGroup: 'Specific conditions and disabilities', + }); + }); + + it('should navigate to the all-or-selected-job-roles page after selecting category and clicking continue', async () => { + const { getByText, routerSpy, establishment } = await setup(); + + const openAllLinkLink = getByText('Show all categories'); + fireEvent.click(openAllLinkLink); + + const autismCategory = getByText('Autism'); + fireEvent.click(autismCategory); + + const continueButton = getByText('Continue'); + fireEvent.click(continueButton); + + expect(routerSpy).toHaveBeenCalledWith([ + 'workplace', + establishment.uid, + 'add-and-manage-mandatory-training', + 'all-or-selected-job-roles', + ]); + }); + + it('should navigate back to the add-and-manage-mandatory-training after clicking Cancel', async () => { + const { getByText, fixture, routerSpy, establishment } = await setup(); + + const cancelLink = getByText('Cancel'); + fireEvent.click(cancelLink); + fixture.detectChanges(); + + expect(routerSpy).toHaveBeenCalledWith(['workplace', establishment.uid, 'add-and-manage-mandatory-training']); + }); + + it('should display required error message when no training category selected', async () => { + const { fixture, getByText } = await setup(); + + const continueButton = getByText('Continue'); + fireEvent.click(continueButton); + fixture.detectChanges(); + + const errorMessage = getByText('Select the training category that you want to make mandatory', { + selector: '.govuk-error-message', + }); + + expect(errorMessage).toBeTruthy(); + }); + + it("should not display 'The training is not in any of these categories' checkbox which is option on other 'Select training category' pages", async () => { + const { fixture, queryByText } = await setup(); + + expect(queryByText('The training is not in any of these categories')).toBeFalsy(); + expect(fixture.nativeElement.querySelector('#otherCheckbox')).toBeFalsy(); + }); + + it('should prefill the training category radio when already selected category in training service', async () => { + const { component } = await setup({ prefill: true }); + + expect(component.form.value).toEqual({ category: 1 }); + }); + + describe('Existing mandatory training', () => { + const mockTrainingCategories = [ + { id: 1, seq: 10, category: 'Activity provision/Well-being', trainingCategoryGroup: 'Care skills and knowledge' }, + { id: 2, seq: 20, category: 'Autism', trainingCategoryGroup: 'Specific conditions and disabilities' }, + { id: 3, seq: 20, category: 'Continence care', trainingCategoryGroup: 'Care skills and knowledge' }, + ]; + + const existingMandatoryTraining = { + mandatoryTraining: [ + { + category: mockTrainingCategories[2].category, + establishmentId: 4090, + jobs: [{}, {}], + trainingCategoryId: mockTrainingCategories[2].id, + }, + ], + }; + + it('should not include training categories which already have mandatory training', async () => { + const overrides = { + trainingCategories: mockTrainingCategories, + existingMandatoryTraining, + }; + + const { queryByText } = await setup(overrides); + + expect(queryByText(mockTrainingCategories[0].category)).toBeTruthy(); + expect(queryByText(mockTrainingCategories[1].category)).toBeTruthy(); + expect(queryByText(mockTrainingCategories[2].category)).toBeFalsy(); + }); + + it('should include training category and prefill the radio when existing mandatory training set in service', async () => { + const overrides = { + trainingCategories: mockTrainingCategories, + existingMandatoryTraining, + trainingService: { mandatoryTrainingBeingEdited: existingMandatoryTraining.mandatoryTraining[0] }, + }; + + const { component, queryByText } = await setup(overrides); + + const selectedExistingMandatoryTrainingCategory = queryByText(mockTrainingCategories[2].category); + expect(selectedExistingMandatoryTrainingCategory).toBeTruthy(); + expect(component.form.value).toEqual({ category: mockTrainingCategories[2].id }); + }); + + it('should prefill the category in selectedTraining but still include existing mandatory training category in options when both set in service (user changed category and has gone back to page)', async () => { + const selectedTraining = { + trainingCategory: mockTrainingCategories[0], + }; + + const overrides = { + trainingCategories: mockTrainingCategories, + existingMandatoryTraining, + trainingService: { + mandatoryTrainingBeingEdited: existingMandatoryTraining.mandatoryTraining[0], + _selectedTraining: selectedTraining, + }, + }; + + const { component, queryByText } = await setup(overrides); + + const selectedExistingMandatoryTrainingCategory = queryByText(mockTrainingCategories[2].category); + expect(selectedExistingMandatoryTrainingCategory).toBeTruthy(); + expect(component.form.value).toEqual({ category: selectedTraining.trainingCategory.id }); + }); + }); +}); diff --git a/frontend/src/app/features/training-and-qualifications/add-mandatory-training/select-training-category-mandatory/select-training-category-mandatory.component.ts b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/select-training-category-mandatory/select-training-category-mandatory.component.ts new file mode 100644 index 0000000000..70457f9843 --- /dev/null +++ b/frontend/src/app/features/training-and-qualifications/add-mandatory-training/select-training-category-mandatory/select-training-category-mandatory.component.ts @@ -0,0 +1,101 @@ +import { Component } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BackLinkService } from '@core/services/backLink.service'; +import { ErrorSummaryService } from '@core/services/error-summary.service'; +import { MandatoryTrainingService } from '@core/services/training.service'; +import { WorkerService } from '@core/services/worker.service'; + +import { SelectTrainingCategoryDirective } from '../../../../shared/directives/select-training-category/select-training-category.directive'; + +@Component({ + selector: 'app-select-training-category-mandatory', + templateUrl: '../../../../shared/directives/select-training-category/select-training-category.component.html', +}) +export class SelectTrainingCategoryMandatoryComponent extends SelectTrainingCategoryDirective { + public requiredErrorMessage: string = 'Select the training category that you want to make mandatory'; + public hideOtherCheckbox: boolean = true; + private mandatoryTrainingCategoryIdBeingEdited: number; + + constructor( + protected formBuilder: FormBuilder, + protected trainingService: MandatoryTrainingService, + protected router: Router, + protected backLinkService: BackLinkService, + protected workerService: WorkerService, + protected route: ActivatedRoute, + protected errorSummaryService: ErrorSummaryService, + ) { + super(formBuilder, trainingService, router, backLinkService, workerService, route, errorSummaryService); + } + + init(): void { + this.establishmentUid = this.route.snapshot.data.establishment.uid; + this.mandatoryTrainingCategoryIdBeingEdited = + this.trainingService.mandatoryTrainingBeingEdited?.trainingCategoryId ?? null; + this.getPrefilledId(); + } + + protected getPrefilledId(): void { + const selectedCategory = this.trainingService.selectedTraining?.trainingCategory; + + if (selectedCategory) { + this.preFilledId = selectedCategory?.id; + } else if (this.mandatoryTrainingCategoryIdBeingEdited) { + this.preFilledId = this.trainingService.mandatoryTrainingBeingEdited.trainingCategoryId; + } + } + + protected setSectionHeading(): void { + this.section = 'Add a mandatory training category'; + } + + protected setTitle(): void { + this.title = 'Select the training category that you want to make mandatory'; + } + + protected getCategories(): void { + const allTrainingCategories = this.route.snapshot.data.trainingCategories; + const existingMandatoryTraining = this.route.snapshot.data.existingMandatoryTraining; + + this.trainingService.allJobRolesCount = existingMandatoryTraining.allJobRolesCount; + + const trainingCategoryIdsWithExistingMandatoryTraining = existingMandatoryTraining?.mandatoryTraining?.map( + (existingMandatoryTrainings) => existingMandatoryTrainings.trainingCategoryId, + ); + + if (trainingCategoryIdsWithExistingMandatoryTraining?.length) { + this.categories = allTrainingCategories.filter( + (category) => + !trainingCategoryIdsWithExistingMandatoryTraining.includes(category.id) || + category.id == this.mandatoryTrainingCategoryIdBeingEdited, + ); + } else { + this.categories = allTrainingCategories; + } + + this.sortCategoriesByTrainingGroup(this.categories); + } + + protected prefillForm(): void { + if (this.preFilledId) { + this.form.patchValue({ category: this.preFilledId }); + } + } + + public onCancel(event: Event): void { + event.preventDefault(); + this.trainingService.resetState(); + this.router.navigate(['workplace', this.establishmentUid, 'add-and-manage-mandatory-training']); + } + + protected submit(selectedCategory): void { + this.trainingService.setSelectedTrainingCategory(selectedCategory); + this.router.navigate([ + 'workplace', + this.establishmentUid, + 'add-and-manage-mandatory-training', + 'all-or-selected-job-roles', + ]); + } +} diff --git a/frontend/src/app/features/training-and-qualifications/add-multiple-training/select-training-category-multiple/select-training-category-multiple.component.spec.ts b/frontend/src/app/features/training-and-qualifications/add-multiple-training/select-training-category-multiple/select-training-category-multiple.component.spec.ts index 10d86daaa3..a8527cadd8 100644 --- a/frontend/src/app/features/training-and-qualifications/add-multiple-training/select-training-category-multiple/select-training-category-multiple.component.spec.ts +++ b/frontend/src/app/features/training-and-qualifications/add-multiple-training/select-training-category-multiple/select-training-category-multiple.component.spec.ts @@ -1,30 +1,31 @@ -import { fireEvent, render } from '@testing-library/angular'; -import { SelectTrainingCategoryMultipleComponent } from './select-training-category-multiple.component'; -import { getTestBed } from '@angular/core/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { TrainingService } from '@core/services/training.service'; -import { MockTrainingService, MockTrainingServiceWithPreselectedStaff } from '@core/test-utils/MockTrainingService'; +import { getTestBed } from '@angular/core/testing'; +import { FormBuilder } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Establishment } from '@core/model/establishment.model'; import { BackLinkService } from '@core/services/backLink.service'; import { ErrorSummaryService } from '@core/services/error-summary.service'; -import { GroupedRadioButtonAccordionComponent } from '@shared/components/accordions/radio-button-accordion/grouped-radio-button-accordion/grouped-radio-button-accordion.component'; -import { RadioButtonAccordionComponent } from '@shared/components/accordions/radio-button-accordion/radio-button-accordion.component'; -import { SharedModule } from '@shared/shared.module'; -import { RouterTestingModule } from '@angular/router/testing'; +import { TrainingService } from '@core/services/training.service'; import { WindowRef } from '@core/services/window.ref'; -import { FormBuilder } from '@angular/forms'; import { WorkerService } from '@core/services/worker.service'; -import { MockWorkerService } from '@core/test-utils/MockWorkerService'; -import { AddMultipleTrainingModule } from '../add-multiple-training.module'; import { establishmentBuilder } from '@core/test-utils/MockEstablishmentService'; -import { Establishment } from '@core/model/establishment.model'; import { trainingCategories } from '@core/test-utils/MockTrainingCategoriesService'; +import { MockTrainingService, MockTrainingServiceWithPreselectedStaff } from '@core/test-utils/MockTrainingService'; +import { MockWorkerService } from '@core/test-utils/MockWorkerService'; +import { GroupedRadioButtonAccordionComponent } from '@shared/components/accordions/radio-button-accordion/grouped-radio-button-accordion/grouped-radio-button-accordion.component'; +import { RadioButtonAccordionComponent } from '@shared/components/accordions/radio-button-accordion/radio-button-accordion.component'; +import { SharedModule } from '@shared/shared.module'; +import { fireEvent, render } from '@testing-library/angular'; import sinon from 'sinon'; +import { AddMultipleTrainingModule } from '../add-multiple-training.module'; +import { SelectTrainingCategoryMultipleComponent } from './select-training-category-multiple.component'; + describe('SelectTrainingCategoryMultipleComponent', () => { async function setup(prefill = false, accessedFromSummary = false, qsParamGetMock = sinon.stub()) { const establishment = establishmentBuilder() as Establishment; - const { fixture, getByText, getAllByText, getByTestId } = await render(SelectTrainingCategoryMultipleComponent, { + const setupTools = await render(SelectTrainingCategoryMultipleComponent, { imports: [HttpClientTestingModule, SharedModule, RouterModule, RouterTestingModule, AddMultipleTrainingModule], declarations: [GroupedRadioButtonAccordionComponent, RadioButtonAccordionComponent], providers: [ @@ -59,7 +60,7 @@ describe('SelectTrainingCategoryMultipleComponent', () => { }, ], }); - const component = fixture.componentInstance; + const component = setupTools.fixture.componentInstance; const injector = getTestBed(); const router = injector.inject(Router) as Router; @@ -70,11 +71,8 @@ describe('SelectTrainingCategoryMultipleComponent', () => { const trainingServiceSpy = spyOn(trainingService, 'resetSelectedStaff').and.callThrough(); return { + ...setupTools, component, - fixture, - getByText, - getAllByText, - getByTestId, routerSpy, trainingService, trainingServiceSpy, @@ -147,6 +145,13 @@ describe('SelectTrainingCategoryMultipleComponent', () => { expect(getByTestId('groupedAccordion')).toBeTruthy(); }); + it("should display 'The training is not in any of these categories' checkbox", async () => { + const { fixture, getByText } = await setup(); + + expect(getByText('The training is not in any of these categories')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('#otherCheckbox')).toBeTruthy(); + }); + it('should return to the select staff page if there is no selected staff', async () => { const { component, fixture, routerSpy } = await setup(); diff --git a/frontend/src/app/shared/directives/select-training-category/select-training-category.component.html b/frontend/src/app/shared/directives/select-training-category/select-training-category.component.html index 672d6de479..e0e515eea1 100644 --- a/frontend/src/app/shared/directives/select-training-category/select-training-category.component.html +++ b/frontend/src/app/shared/directives/select-training-category/select-training-category.component.html @@ -19,7 +19,7 @@

{{ title }}

[errorMessage]="formErrorsMap[0].type[0].message" > -
+
{{ title }}

-
+
diff --git a/frontend/src/app/shared/directives/select-training-category/select-training-category.directive.ts b/frontend/src/app/shared/directives/select-training-category/select-training-category.directive.ts index 5084312b65..28dec47bd9 100644 --- a/frontend/src/app/shared/directives/select-training-category/select-training-category.directive.ts +++ b/frontend/src/app/shared/directives/select-training-category/select-training-category.directive.ts @@ -29,6 +29,9 @@ export class SelectTrainingCategoryDirective implements OnInit, AfterViewInit { public previousUrl: string[]; public preFilledId: number; public error = false; + public requiredErrorMessage: string = 'Select the training category'; + public submitButtonText: string = 'Continue'; + public hideOtherCheckbox: boolean = false; private summaryText = { 'Care skills and knowledge': "'duty of care', 'safeguarding adults'", @@ -37,7 +40,6 @@ export class SelectTrainingCategoryDirective implements OnInit, AfterViewInit { 'Specific conditions and disabilities': "'dementia care', 'Oliver McGowan Mandatory Training'", 'Staff development': "'communication', 'leadership and management' ", }; - submitButtonText: string = 'Continue'; constructor( protected formBuilder: FormBuilder, @@ -61,6 +63,9 @@ export class SelectTrainingCategoryDirective implements OnInit, AfterViewInit { } protected init(): void {} + protected submit(selectedCategory: any): void {} + protected setSectionHeading(): void {} + public onCancel(event: Event) {} protected prefillForm(): void { let selectedCategory = this.trainingService.selectedTraining?.trainingCategory; @@ -77,21 +82,17 @@ export class SelectTrainingCategoryDirective implements OnInit, AfterViewInit { this.form.get('category').updateValueAndValidity(); } - protected submit(selectedCategory: any): void {} - protected setTitle(): void { this.title = 'Select the category that best matches the training taken'; } - protected setSectionHeading(): void {} - - private getCategories(): void { + protected getCategories(): void { this.categories = this.route.snapshot.data.trainingCategories; this.sortCategoriesByTrainingGroup(this.categories); this.otherCategory = this.categories.filter((category) => category.trainingCategoryGroup === null)[0]; } - private sortCategoriesByTrainingGroup(trainingCategories) { + protected sortCategoriesByTrainingGroup(trainingCategories) { this.trainingGroups = []; for (const group of Object.keys(this.summaryText)) { let currentTrainingGroup = { @@ -141,8 +142,6 @@ export class SelectTrainingCategoryDirective implements OnInit, AfterViewInit { } } - public onCancel(event: Event) {} - public setBackLink(): void { this.backLinkService.showBackLink(); } @@ -167,7 +166,7 @@ export class SelectTrainingCategoryDirective implements OnInit, AfterViewInit { type: [ { name: 'required', - message: 'Select the training category', + message: this.requiredErrorMessage, }, ], },

Mandatory training categoriesJob rolesMandatory training categoryJob role + Remove all +
- + {{ records.category }} - - {{ 'All' }} + + All - +
{{ job.title }}
+ + Remove + +