diff --git a/backend/migrations/20250103160000-updateTextForWorkerLeaveReasons.js b/backend/migrations/20250103160000-updateTextForWorkerLeaveReasons.js new file mode 100644 index 0000000000..4b2fa990c2 --- /dev/null +++ b/backend/migrations/20250103160000-updateTextForWorkerLeaveReasons.js @@ -0,0 +1,83 @@ +'use strict'; +const models = require('../server/models/index'); + +const allReasons = [ + { + id: 1, + seq: 1, + oldText: 'They moved to another adult social care employer', + newText: 'The worker moved to another adult social care employer', + }, + { + id: 2, + seq: 2, + oldText: 'They moved to a role in the health sector', + newText: 'The worker moved to a role in the health sector', + }, + { + id: 3, + seq: 3, + oldText: 'They moved to a different sector (e.g. retail)', + newText: 'The worker moved to a different sector (for example, retail)', + }, + { + id: 4, + seq: 4, + oldText: 'They moved to another role in this organisation', + newText: 'The worker moved to a different role in this organisation', + }, + { + id: 5, + seq: 5, + oldText: 'The worker chose to leave (destination unknown)', + newText: 'The worker chose to leave (destination not known)', + }, + { id: 6, seq: 6, oldText: 'The worker retired', newText: 'The worker retired' }, + { + id: 7, + seq: 7, + oldText: 'Employer terminated their employment', + newText: 'The worker had their employment terminated', + }, + { id: 8, seq: 8, oldText: 'Other', newText: 'For a reason not listed' }, + { id: 9, seq: 9, oldText: 'Not known', newText: 'Reason not known' }, +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + return queryInterface.sequelize.transaction(async (transaction) => { + for (const reason of allReasons) { + await models.workerLeaveReasons.update( + { + reason: reason.newText, + }, + { + where: { + id: reason.id, + }, + transaction, + }, + ); + } + }); + }, + + async down(queryInterface) { + return queryInterface.sequelize.transaction(async (transaction) => { + for (const reason of allReasons) { + await models.workerLeaveReasons.update( + { + reason: reason.oldText, + }, + { + where: { + id: reason.id, + }, + transaction, + }, + ); + } + }); + }, +}; diff --git a/backend/migrations/20250106091714-addLastViewedVacanciesAndTurnoverMessage.js b/backend/migrations/20250106091714-addLastViewedVacanciesAndTurnoverMessage.js new file mode 100644 index 0000000000..f45dc45e4d --- /dev/null +++ b/backend/migrations/20250106091714-addLastViewedVacanciesAndTurnoverMessage.js @@ -0,0 +1,17 @@ +'use strict'; + +const userTable = { tableName: 'User', schema: 'cqc' }; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + return queryInterface.addColumn(userTable, 'LastViewedVacanciesAndTurnoverMessage', { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }); + }, + + async down(queryInterface) { + return queryInterface.removeColumn(userTable, 'LastViewedVacanciesAndTurnoverMessage'); + }, +}; diff --git a/backend/server/models/user.js b/backend/server/models/user.js index 4b321e1fd4..bc3a734bce 100644 --- a/backend/server/models/user.js +++ b/backend/server/models/user.js @@ -274,6 +274,11 @@ module.exports = function (sequelize, DataTypes) { allowNull: true, field: '"CanManageWdfClaimsChangedBy"', }, + lastViewedVacanciesAndTurnoverMessage: { + type: DataTypes.DATE, + allowNull: true, + field: 'LastViewedVacanciesAndTurnoverMessage', + }, }, { tableName: '"User"', @@ -542,5 +547,16 @@ module.exports = function (sequelize, DataTypes) { return userFound; }; + User.setDateForLastViewedVacanciesAndTurnoverMessage = async function (userUid) { + return await this.update( + { lastViewedVacanciesAndTurnoverMessage: new Date() }, + { + where: { + uid: userUid, + }, + }, + ); + }; + return User; }; diff --git a/backend/server/routes/accounts/user.js b/backend/server/routes/accounts/user.js index df9db3b72d..ba9e807e3a 100644 --- a/backend/server/routes/accounts/user.js +++ b/backend/server/routes/accounts/user.js @@ -1,6 +1,7 @@ // default route for user endpoint const express = require('express'); const router = express.Router(); +const { validate } = require('uuid'); const models = require('../../models'); const Authorization = require('../../utils/security/isAuthenticated'); @@ -809,7 +810,7 @@ router.route('/swap/establishment/notification/:nmsdId').get(async (req, res) => let notificationArr = []; const establishmentNotifications = await notifications.selectNotificationByEstablishment(req.establishmentUid); - if(establishmentNotifications) notificationArr.push(establishmentNotifications); + if (establishmentNotifications) notificationArr.push(establishmentNotifications); return res.status(200).send(notificationArr); } } catch (e) { @@ -911,6 +912,22 @@ const swapEstablishment = async (req, res) => { .json(response); }; +const updateLastViewedVacanciesAndTurnoverMessage = async (req, res) => { + try { + const userUid = req.params?.userUid; + + if (!validate(userUid)) { + return res.status(400).send('User UID invalid'); + } + + await models.user.setDateForLastViewedVacanciesAndTurnoverMessage(userUid); + + return res.status(200).send('Last viewed date updated'); + } catch (error) { + return res.status(500).send('Failed to update last viewed date'); + } +}; + router.route('/').get(return200); router.route('/admin').get(Authorization.isAdmin, listAdminUsers); router.route('/admin/:userId').get(Authorization.isAdmin, getUser); @@ -945,6 +962,9 @@ router.route('/my/establishments').get(Authorization.isAuthorised, listEstablish router.route('/admin/:userId').get(Authorization.isAuthorised, getUser); router.use('/my/notifications', Authorization.isAuthorised); router.route('/my/notifications').get(listNotifications); +router + .route('/update-last-viewed-vacancies-and-turnover-message/:userUid') + .post(Authorization.isAuthorised, updateLastViewedVacanciesAndTurnoverMessage); router.use('/swap/establishment/:id', authLimiter); router.route('/swap/establishment/:id').post(Authorization.isAdmin, swapEstablishment); @@ -955,3 +975,4 @@ module.exports.partAddUser = partAddUser; module.exports.listAdminUsers = listAdminUsers; module.exports.updateUser = updateUser; +module.exports.updateLastViewedVacanciesAndTurnoverMessage = updateLastViewedVacanciesAndTurnoverMessage; diff --git a/backend/server/routes/login.js b/backend/server/routes/login.js index e3029da265..5e7cb37dd0 100644 --- a/backend/server/routes/login.js +++ b/backend/server/routes/login.js @@ -79,6 +79,7 @@ router.post('/', async (req, res) => { 'UserRoleValue', 'registrationSurveyCompleted', 'tribalId', + 'lastViewedVacanciesAndTurnoverMessage', ], include: [ { @@ -95,6 +96,7 @@ router.post('/', async (req, res) => { 'lastBulkUploaded', 'eightWeeksFromFirstLogin', 'EmployerTypeValue', + 'dataOwner', ], include: [ { @@ -284,6 +286,7 @@ router.post('/', async (req, res) => { migratedUser, }, establishmentUser.user.registrationSurveyCompleted, + establishmentUser.user.lastViewedVacanciesAndTurnoverMessage, ); await models.sequelize.transaction(async (t) => { diff --git a/backend/server/test/unit/routes/accounts/user.spec.js b/backend/server/test/unit/routes/accounts/user.spec.js index bea679977e..42a737f932 100644 --- a/backend/server/test/unit/routes/accounts/user.spec.js +++ b/backend/server/test/unit/routes/accounts/user.spec.js @@ -3,8 +3,15 @@ const expect = chai.expect; const sinon = require('sinon'); const httpMocks = require('node-mocks-http'); -const { meetsMaxUserLimit, partAddUser, listAdminUsers, updateUser } = require('../../../../routes/accounts/user'); +const { + meetsMaxUserLimit, + partAddUser, + listAdminUsers, + updateUser, + updateLastViewedVacanciesAndTurnoverMessage, +} = require('../../../../routes/accounts/user'); const User = require('../../../../models/classes/user').User; +const models = require('../../../../models'); describe('user.js', () => { let req; @@ -345,4 +352,44 @@ describe('user.js', () => { }); }); }); + + describe('updateLastViewedVacanciesAndTurnoverMessage', () => { + let req; + let res; + + beforeEach(() => { + req = httpMocks.createRequest(); + res = httpMocks.createResponse(); + }); + + it('should return 200 response if userUid in params is valid and database call successful', async () => { + req.params = { userUid: '6b6885fa-340d-4d59-8720-c03d8845e603' }; + sinon.stub(models.user, 'setDateForLastViewedVacanciesAndTurnoverMessage').returns(null); + + await updateLastViewedVacanciesAndTurnoverMessage(req, res); + + expect(res.statusCode).to.equal(200); + expect(res._getData()).to.deep.equal('Last viewed date updated'); + }); + + it('should return 400 response if userUid in params invalid', async () => { + req.params = { userUid: 'invalid-uid' }; + sinon.stub(models.user, 'setDateForLastViewedVacanciesAndTurnoverMessage').returns(null); + + await updateLastViewedVacanciesAndTurnoverMessage(req, res); + + expect(res.statusCode).to.equal(400); + expect(res._getData()).to.deep.equal('User UID invalid'); + }); + + it('should return 500 response if unexpected error', async () => { + req.params = { userUid: '6b6885fa-340d-4d59-8720-c03d8845e603' }; + sinon.stub(models.user, 'setDateForLastViewedVacanciesAndTurnoverMessage').throws(); + + await updateLastViewedVacanciesAndTurnoverMessage(req, res); + + expect(res.statusCode).to.equal(500); + expect(res._getData()).to.deep.equal('Failed to update last viewed date'); + }); + }); }); diff --git a/backend/server/utils/login/response.js b/backend/server/utils/login/response.js index 75291824ce..6c4b397abe 100644 --- a/backend/server/utils/login/response.js +++ b/backend/server/utils/login/response.js @@ -11,6 +11,7 @@ module.exports = ( agreedUpdatedTerms, migratedUser, registrationSurveyCompleted, + lastViewedVacanciesAndTurnoverMessage, ) => { // note - the mainService can be null return { @@ -35,6 +36,7 @@ module.exports = ( parentName: establishment.parentName ? establishment.parentName : undefined, isFirstBulkUpload: establishment.lastBulkUploaded ? false : true, employerTypeSet: establishment.EmployerTypeValue ? true : false, + dataOwner: establishment.dataOwner, } : null, mainService: establishment @@ -45,5 +47,6 @@ module.exports = ( : null, expiryDate: expiryDate, registrationSurveyCompleted: registrationSurveyCompleted, + lastViewedVacanciesAndTurnoverMessage, }; }; diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index cd991243ca..bd9b73cb30 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -31,10 +31,11 @@ import { FirstLoginPageComponent } from '@features/first-login-page/first-login- import { ForgotYourPasswordComponent } from '@features/forgot-your-username-or-password/forgot-your-password/forgot-your-password.component'; import { ForgotYourUsernameOrPasswordComponent } from '@features/forgot-your-username-or-password/forgot-your-username-or-password.component'; import { ForgotYourUsernameComponent } from '@features/forgot-your-username-or-password/forgot-your-username/forgot-your-username.component'; -import { UsernameFoundComponent } from '@features/forgot-your-username-or-password/username-found/username-found.component'; import { SecurityQuestionAnswerNotMatchComponent } from '@features/forgot-your-username-or-password/forgot-your-username/security-question-answer-not-match/security-question-answer-not-match.component'; import { UserAccountNotFoundComponent } from '@features/forgot-your-username-or-password/forgot-your-username/user-account-not-found/user-account-not-found.component'; +import { UsernameFoundComponent } from '@features/forgot-your-username-or-password/username-found/username-found.component'; import { LoginComponent } from '@features/login/login.component'; +import { VacanciesAndTurnoverLoginMessage } from '@features/login/vacancies-and-turnover-login-message/vacancies-and-turnover-login-message.component'; import { LogoutComponent } from '@features/logout/logout.component'; import { MigratedUserTermsConditionsComponent } from '@features/migrated-user-terms-conditions/migrated-user-terms-conditions.component'; import { BecomeAParentComponent } from '@features/new-dashboard/become-a-parent/become-a-parent.component'; @@ -148,6 +149,11 @@ const routes: Routes = [ component: MigratedUserTermsConditionsComponent, data: { title: 'Migrated User Terms And Conditions' }, }, + { + path: 'update-your-vacancies-and-turnover-data', + component: VacanciesAndTurnoverLoginMessage, + data: { title: 'Update your vacancies and turnover data' }, + }, { path: 'workplace', loadChildren: () => import('@features/workplace/workplace.module').then((m) => m.WorkplaceModule), diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index b9bd20be93..0f877bb15b 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -1,6 +1,3 @@ -import { Angulartics2Module } from 'angulartics2'; -import { HighchartsChartModule } from 'highcharts-angular'; - import { CommonModule } from '@angular/common'; import { HTTP_INTERCEPTORS, HttpClient, HttpClientModule } from '@angular/common/http'; import { ErrorHandler, NgModule } from '@angular/core'; @@ -72,7 +69,9 @@ import { FindUsernameComponent } from '@features/forgot-your-username-or-passwor import { ForgotYourUsernameComponent } from '@features/forgot-your-username-or-password/forgot-your-username/forgot-your-username.component'; import { SecurityQuestionAnswerNotMatchComponent } from '@features/forgot-your-username-or-password/forgot-your-username/security-question-answer-not-match/security-question-answer-not-match.component'; import { UserAccountNotFoundComponent } from '@features/forgot-your-username-or-password/forgot-your-username/user-account-not-found/user-account-not-found.component'; +import { UsernameFoundComponent } from '@features/forgot-your-username-or-password/username-found/username-found.component'; import { LoginComponent } from '@features/login/login.component'; +import { VacanciesAndTurnoverLoginMessage } from '@features/login/vacancies-and-turnover-login-message/vacancies-and-turnover-login-message.component'; import { LogoutComponent } from '@features/logout/logout.component'; import { BecomeAParentComponent } from '@features/new-dashboard/become-a-parent/become-a-parent.component'; import { DashboardWrapperComponent } from '@features/new-dashboard/dashboard-wrapper.component'; @@ -94,6 +93,8 @@ import { BenchmarksModule } from '@shared/components/benchmarks-tab/benchmarks.m import { DataAreaTabModule } from '@shared/components/data-area-tab/data-area-tab.module'; import { FeatureFlagsService } from '@shared/services/feature-flags.service'; import { SharedModule } from '@shared/shared.module'; +import { Angulartics2Module } from 'angulartics2'; +import { HighchartsChartModule } from 'highcharts-angular'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; @@ -101,7 +102,6 @@ import { StaffMismatchBannerComponent } from './features/dashboard/home-tab/staf import { MigratedUserTermsConditionsComponent } from './features/migrated-user-terms-conditions/migrated-user-terms-conditions.component'; import { SatisfactionSurveyComponent } from './features/satisfaction-survey/satisfaction-survey.component'; import { SentryErrorHandler } from './SentryErrorHandler.component'; -import { UsernameFoundComponent } from '@features/forgot-your-username-or-password/username-found/username-found.component'; @NgModule({ declarations: [ @@ -151,6 +151,7 @@ import { UsernameFoundComponent } from '@features/forgot-your-username-or-passwo SelectStarterJobRolesComponent, SecurityQuestionAnswerNotMatchComponent, UserAccountNotFoundComponent, + VacanciesAndTurnoverLoginMessage, ], imports: [ Angulartics2Module.forRoot({ diff --git a/frontend/src/app/core/model/userDetails.model.ts b/frontend/src/app/core/model/userDetails.model.ts index ce5f07518d..f1d11d31bb 100644 --- a/frontend/src/app/core/model/userDetails.model.ts +++ b/frontend/src/app/core/model/userDetails.model.ts @@ -25,6 +25,7 @@ export interface UserDetails { updatedBy?: string; username?: string; canManageWdfClaims?: boolean; + lastViewedVacanciesAndTurnoverMessage?: string; } export enum UserStatus { diff --git a/frontend/src/app/core/services/user.service.ts b/frontend/src/app/core/services/user.service.ts index d9ab108482..f961fa62fa 100644 --- a/frontend/src/app/core/services/user.service.ts +++ b/frontend/src/app/core/services/user.service.ts @@ -49,7 +49,9 @@ export class UserService { } public getLoggedInUser(): Observable { - return this.http.get(`${environment.appRunnerEndpoint}/api/user/me`).pipe(tap((user) => (this.loggedInUser = user))); + return this.http + .get(`${environment.appRunnerEndpoint}/api/user/me`) + .pipe(tap((user) => (this.loggedInUser = user))); } public get returnUrl() { @@ -137,7 +139,10 @@ export class UserService { * PUT /api/user/establishment/:establishmentUID/:userUID */ public updateUserDetails(workplaceUid: string, userUid: string, userDetails: UserDetails): Observable { - return this.http.put(`${environment.appRunnerEndpoint}/api/user/establishment/${workplaceUid}/${userUid}`, userDetails); + return this.http.put( + `${environment.appRunnerEndpoint}/api/user/establishment/${workplaceUid}/${userUid}`, + userDetails, + ); } public updateAdminUserDetails(userUid: string, userDetails: UserDetails): Observable { @@ -165,7 +170,9 @@ export class UserService { */ public getEstablishments(wdf: boolean = false): Observable { const params = wdf ? new HttpParams().set('wdf', `${wdf}`) : null; - return this.http.get(`${environment.appRunnerEndpoint}/api/user/my/establishments`, { params }); + return this.http.get(`${environment.appRunnerEndpoint}/api/user/my/establishments`, { + params, + }); } /* @@ -176,4 +183,11 @@ export class UserService { .get(`${environment.appRunnerEndpoint}/api/user/establishment/${workplaceUid}`) .pipe(map((response) => response.users)); } + + public updateLastViewedVacanciesAndTurnoverMessage() { + return this.http.post( + `${environment.appRunnerEndpoint}/api/user/update-last-viewed-vacancies-and-turnover-message/${this.loggedInUser.uid}`, + {}, + ); + } } diff --git a/frontend/src/app/core/test-utils/MockAuthService.ts b/frontend/src/app/core/test-utils/MockAuthService.ts index d2558a9abe..10e8be61d3 100644 --- a/frontend/src/app/core/test-utils/MockAuthService.ts +++ b/frontend/src/app/core/test-utils/MockAuthService.ts @@ -1,6 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; +import { WorkplaceDataOwner } from '@core/model/my-workplaces.model'; import { AuthService } from '@core/services/auth.service'; import { EstablishmentService } from '@core/services/establishment.service'; import { PermissionsService } from '@core/services/permissions/permissions.service'; @@ -81,15 +82,21 @@ export class MockAuthService extends AuthService { } public authenticate(username: string, password: string): Observable { - return of({ - body: { - role: this._isAdmin ? 'Admin' : 'Edit', - agreedUpdatedTerms: true, - establishment: { - employerTypeSet: this._employerTypeSet, - uid: 'mockuid' - } - }, - }); + return of(mockAuthenticateResponse(this._isAdmin, this._employerTypeSet)); } } + +export const mockAuthenticateResponse = (isAdmin = false, employerTypeSet = true): any => { + return { + body: { + role: isAdmin ? 'Admin' : 'Edit', + agreedUpdatedTerms: true, + lastViewedVacanciesAndTurnoverMessage: new Date().toISOString(), + establishment: { + employerTypeSet, + uid: 'mockuid', + dataOwner: WorkplaceDataOwner.Workplace, + }, + }, + }; +}; diff --git a/frontend/src/app/core/test-utils/MockWorkerService.ts b/frontend/src/app/core/test-utils/MockWorkerService.ts index e506d982ed..7cf08ca001 100644 --- a/frontend/src/app/core/test-utils/MockWorkerService.ts +++ b/frontend/src/app/core/test-utils/MockWorkerService.ts @@ -9,7 +9,7 @@ import { } from '@core/model/training.model'; import { URLStructure } from '@core/model/url.model'; import { Worker, WorkerEditResponse, WorkersResponse } from '@core/model/worker.model'; -import { NewWorkerMandatoryInfo, WorkerService } from '@core/services/worker.service'; +import { NewWorkerMandatoryInfo, Reason, WorkerService } from '@core/services/worker.service'; import { build, fake, oneOf, perBuild, sequence } from '@jackfranklin/test-data-bot'; import { Observable, of } from 'rxjs'; @@ -371,6 +371,18 @@ export const mockAvailableQualifications: AvailableQualificationsResponse[] = [ }, ]; +export const mockLeaveReasons: Reason[] = [ + { id: 1, reason: 'The worker moved to another adult social care employer' }, + { id: 2, reason: 'The worker moved to a role in the health sector' }, + { id: 3, reason: 'The worker moved to a different sector (for example, retail)' }, + { id: 4, reason: 'The worker moved to a different role in this organisation' }, + { id: 5, reason: 'The worker chose to leave (destination unknown)' }, + { id: 6, reason: 'The worker retired' }, + { id: 7, reason: 'The worker had their employment terminated' }, + { id: 8, reason: 'For a reason not listed' }, + { id: 9, reason: 'Reason not known' }, +]; + export const trainingRecord = { id: 10, uid: 'someTrainingUid', @@ -487,6 +499,10 @@ export class MockWorkerService extends WorkerService { ): Observable { return of(mockAvailableQualifications); } + + getLeaveReasons(): Observable { + return of(mockLeaveReasons); + } } @Injectable() diff --git a/frontend/src/app/features/login/login.component.spec.ts b/frontend/src/app/features/login/login.component.spec.ts index ff76efc127..87617b2f2e 100644 --- a/frontend/src/app/features/login/login.component.spec.ts +++ b/frontend/src/app/features/login/login.component.spec.ts @@ -1,27 +1,30 @@ import { HttpErrorResponse } from '@angular/common/http'; import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { getTestBed, TestBed } from '@angular/core/testing'; +import { getTestBed } from '@angular/core/testing'; +import { ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms'; import { Router, RouterModule } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; +import { WorkplaceDataOwner } from '@core/model/my-workplaces.model'; +import { Roles } from '@core/model/roles.enum'; import { AuthService } from '@core/services/auth.service'; import { UserService } from '@core/services/user.service'; -import { MockAuthService } from '@core/test-utils/MockAuthService'; +import { mockAuthenticateResponse, MockAuthService } from '@core/test-utils/MockAuthService'; import { MockUserService } from '@core/test-utils/MockUserService'; -import { FeatureFlagsService } from '@shared/services/feature-flags.service'; import { SharedModule } from '@shared/shared.module'; import { fireEvent, render, within } from '@testing-library/angular'; -import { throwError } from 'rxjs'; +import userEvent from '@testing-library/user-event'; +import { of, throwError } from 'rxjs'; import { LoginComponent } from './login.component'; -import { ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms'; -import userEvent from '@testing-library/user-event'; describe('LoginComponent', () => { - async function setup(isAdmin = false, employerTypeSet = true, isAuthenticated = true) { + async function setup(overrides = {}) { + const isAdmin: boolean = ('isAdmin' in overrides ? overrides.isAdmin : false) as boolean; + const employerTypeSet: boolean = ('employerTypeSet' in overrides ? overrides.employerTypeSet : true) as boolean; + const setupTools = await render(LoginComponent, { imports: [SharedModule, RouterModule, RouterTestingModule, HttpClientTestingModule, ReactiveFormsModule], providers: [ - FeatureFlagsService, UntypedFormBuilder, { provide: AuthService, @@ -37,33 +40,22 @@ describe('LoginComponent', () => { const injector = getTestBed(); const router = injector.inject(Router) as Router; - const spy = spyOn(router, 'navigate'); - spy.and.returnValue(Promise.resolve(true)); + const routerSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + const navigateByUrlSpy = spyOn(router, 'navigateByUrl').and.returnValue(Promise.resolve(true)); const authService = injector.inject(AuthService) as AuthService; - let authSpy: any; - if (isAuthenticated) { - authSpy = spyOn(authService, 'authenticate'); - authSpy.and.callThrough(); - } else { - const mockErrorResponse = new HttpErrorResponse({ - status: 401, - statusText: 'Unauthorized', - error: {}, - }); - - const authService = TestBed.inject(AuthService); - authSpy = spyOn(authService, 'authenticate').and.returnValue(throwError(mockErrorResponse)); - } + const authSpy = spyOn(authService, 'authenticate').and.callThrough(); const fixture = setupTools.fixture; const component = fixture.componentInstance; return { + ...setupTools, component, fixture, - ...setupTools, - spy, + routerSpy, + navigateByUrlSpy, + authService, authSpy, }; } @@ -149,103 +141,218 @@ describe('LoginComponent', () => { expect(createAccountLink.getAttribute('href')).toEqual('/registration/create-account'); }); - it('should send you to dashboard on login as user', async () => { - const { component, fixture, spy, authSpy } = await setup(); + describe('Navigation on successful login', () => { + const signIn = (getByLabelText, getByRole, fixture) => { + userEvent.type(getByLabelText('Username'), '1'); + userEvent.type(getByLabelText('Password'), '1'); - component.form.markAsDirty(); - component.form.get('username').setValue('1'); - component.form.get('username').markAsDirty(); - component.form.get('password').setValue('1'); - component.form.get('password').markAsDirty(); + userEvent.click(getByRole('button', { name: 'Sign in' })); + fixture.detectChanges(); + }; - component.onSubmit(); + it('should navigate to dashboard when non-admin user with no outstanding on login actions', async () => { + const { fixture, routerSpy, authSpy, getByLabelText, getByRole } = await setup(); - fixture.detectChanges(); + signIn(getByLabelText, getByRole, fixture); - expect(component.form.valid).toBeTruthy(); - expect(authSpy).toHaveBeenCalled(); - expect(spy).toHaveBeenCalledWith(['/dashboard']); - }); + expect(authSpy).toHaveBeenCalled(); + expect(routerSpy).toHaveBeenCalledWith(['/dashboard']); + }); - it('should send you to sfcadmin on login as admin', async () => { - const { component, fixture, spy, authSpy } = await setup(true); + it('should navigate to redirectLocation when recently logged out user with data stored in authService', async () => { + const { fixture, navigateByUrlSpy, authService, getByLabelText, getByRole } = await setup(); - component.form.markAsDirty(); - component.form.get('username').setValue('1'); - component.form.get('username').markAsDirty(); - component.form.get('password').setValue('1'); - component.form.get('password').markAsDirty(); + const mockUrlToNavigateTo = '/mockUrl'; - component.onSubmit(); + spyOn(authService, 'isPreviousUser').and.returnValue(true); + spyOnProperty(authService, 'redirectLocation').and.returnValue(mockUrlToNavigateTo); - fixture.detectChanges(); + signIn(getByLabelText, getByRole, fixture); - expect(component.form.valid).toBeTruthy(); - expect(authSpy).toHaveBeenCalled(); - expect(spy).toHaveBeenCalledWith(['/sfcadmin']); - }); + expect(navigateByUrlSpy).toHaveBeenCalledWith(mockUrlToNavigateTo); + }); + + it('should navigate to sfcadmin when user is admin', async () => { + const { fixture, routerSpy, authSpy, getByLabelText, getByRole } = await setup({ isAdmin: true }); + + signIn(getByLabelText, getByRole, fixture); + + expect(authSpy).toHaveBeenCalled(); + expect(routerSpy).toHaveBeenCalledWith(['/sfcadmin']); + }); - it('should send you to type-of-employer on login where employer type not set', async () => { - const { component, fixture, spy, authSpy } = await setup(false, false); + it('should navigate to type-of-employer when employer type not set for non-admin user', async () => { + const { fixture, routerSpy, getByLabelText, getByRole } = await setup({ employerTypeSet: false }); + + signIn(getByLabelText, getByRole, fixture); + + expect(routerSpy).toHaveBeenCalledWith(['workplace', `mockuid`, 'type-of-employer']); + }); + + it('should navigate to migrated-user-terms-and-conditions when auth response has migratedUserFirstLogon as true', async () => { + const { fixture, routerSpy, getByLabelText, getByRole, authSpy } = await setup({ employerTypeSet: false }); + const authenticateResponse = mockAuthenticateResponse(); + authenticateResponse.body.migratedUserFirstLogon = true; + + authSpy.and.returnValue(of(authenticateResponse)); + + signIn(getByLabelText, getByRole, fixture); + + expect(routerSpy).toHaveBeenCalledWith(['/migrated-user-terms-and-conditions']); + }); + + it('should navigate to registration-survey when auth response has registrationSurveyCompleted as false', async () => { + const { fixture, routerSpy, getByLabelText, getByRole, authSpy } = await setup({ employerTypeSet: false }); + const authenticateResponse = mockAuthenticateResponse(); + authenticateResponse.body.registrationSurveyCompleted = false; + + authSpy.and.returnValue(of(authenticateResponse)); + + signIn(getByLabelText, getByRole, fixture); + + expect(routerSpy).toHaveBeenCalledWith(['/registration-survey']); + }); + + describe('update-your-vacancies-and-turnover-data', () => { + it('should navigate to update-your-vacancies-and-turnover-data when lastViewedVacanciesAndTurnoverMessage is null and edit user where workplace is data owner', async () => { + const { fixture, routerSpy, getByLabelText, getByRole, authSpy } = await setup({ employerTypeSet: false }); + const authenticateResponse = mockAuthenticateResponse(); + authenticateResponse.body.lastViewedVacanciesAndTurnoverMessage = null; + + authSpy.and.returnValue(of(authenticateResponse)); + + signIn(getByLabelText, getByRole, fixture); + + expect(routerSpy).toHaveBeenCalledWith(['/update-your-vacancies-and-turnover-data']); + }); - component.form.markAsDirty(); - component.form.get('username').setValue('1'); - component.form.get('username').markAsDirty(); - component.form.get('password').setValue('1'); - component.form.get('password').markAsDirty(); + it('should navigate to update-your-vacancies-and-turnover-data when lastViewedVacanciesAndTurnoverMessage is over six months ago and edit user where workplace is data owner', async () => { + const { fixture, routerSpy, getByLabelText, getByRole, authSpy } = await setup(); + const authenticateResponse = mockAuthenticateResponse(); - component.onSubmit(); + const currentDate = new Date(); + const sevenMonthsAgo = new Date(); + sevenMonthsAgo.setMonth(currentDate.getMonth() - 7); - fixture.detectChanges(); - expect(component.form.valid).toBeTruthy(); - expect(authSpy).toHaveBeenCalled(); - expect(spy).toHaveBeenCalledWith(['workplace', `mockuid`, 'type-of-employer']); + authenticateResponse.body.lastViewedVacanciesAndTurnoverMessage = sevenMonthsAgo.toISOString(); + authenticateResponse.body.role = Roles.Edit; + + authSpy.and.returnValue(of(authenticateResponse)); + + signIn(getByLabelText, getByRole, fixture); + + expect(routerSpy).toHaveBeenCalledWith(['/update-your-vacancies-and-turnover-data']); + }); + + it('should not navigate to update-your-vacancies-and-turnover-data when lastViewedVacanciesAndTurnoverMessage is null but user is read only', async () => { + const { fixture, routerSpy, getByLabelText, getByRole, authSpy } = await setup(); + const authenticateResponse = mockAuthenticateResponse(); + authenticateResponse.body.lastViewedVacanciesAndTurnoverMessage = null; + authenticateResponse.body.role = Roles.Read; + + authSpy.and.returnValue(of(authenticateResponse)); + + signIn(getByLabelText, getByRole, fixture); + + expect(routerSpy).toHaveBeenCalledWith(['/dashboard']); + }); + + it('should not navigate to update-your-vacancies-and-turnover-data when lastViewedVacanciesAndTurnoverMessage is null but workplace is not data owner', async () => { + const { fixture, routerSpy, getByLabelText, getByRole, authSpy } = await setup(); + const authenticateResponse = mockAuthenticateResponse(); + authenticateResponse.body.lastViewedVacanciesAndTurnoverMessage = null; + authenticateResponse.body.establishment.dataOwner = WorkplaceDataOwner.Parent; + + authSpy.and.returnValue(of(authenticateResponse)); + + signIn(getByLabelText, getByRole, fixture); + + expect(routerSpy).toHaveBeenCalledWith(['/dashboard']); + }); + + it('should not navigate to update-your-vacancies-and-turnover-data when lastViewedVacanciesAndTurnoverMessage is under six months ago', async () => { + const { fixture, routerSpy, getByLabelText, getByRole, authSpy } = await setup(); + const authenticateResponse = mockAuthenticateResponse(); + + const currentDate = new Date(); + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(currentDate.getMonth() - 3); + + authenticateResponse.body.lastViewedVacanciesAndTurnoverMessage = threeMonthsAgo; + authenticateResponse.body.role = Roles.Edit; + + authSpy.and.returnValue(of(authenticateResponse)); + + signIn(getByLabelText, getByRole, fixture); + + expect(routerSpy).toHaveBeenCalledWith(['/dashboard']); + }); + + it('should not navigate to update-your-vacancies-and-turnover-data when lastViewedVacanciesAndTurnoverMessage is null but there is login action with higher priority (registration survey)', async () => { + const { fixture, routerSpy, getByLabelText, getByRole, authSpy } = await setup(); + const authenticateResponse = mockAuthenticateResponse(); + + authenticateResponse.body.lastViewedVacanciesAndTurnoverMessage = null; + authenticateResponse.body.role = Roles.Edit; + authenticateResponse.body.registrationSurveyCompleted = false; + + authSpy.and.returnValue(of(authenticateResponse)); + + signIn(getByLabelText, getByRole, fixture); + + expect(routerSpy).not.toHaveBeenCalledWith(['/update-your-vacancies-and-turnover-data']); + expect(routerSpy).toHaveBeenCalledWith(['/registration-survey']); + }); + }); }); describe('validation', async () => { it('should display enter your username message', async () => { - const { component, fixture, getAllByText } = await setup(false, false, false); + const { fixture, getAllByText, getByRole, getByLabelText } = await setup(); - component.form.markAsDirty(); - component.form.get('password').setValue('1'); - component.form.get('password').markAsDirty(); + userEvent.type(getByLabelText('Password'), '1'); - component.onSubmit(); + userEvent.click(getByRole('button', { name: 'Sign in' })); fixture.detectChanges(); expect(getAllByText('Enter your username')).toBeTruthy(); }); it('should display enter your password message', async () => { - const { component, fixture, getAllByText } = await setup(false, false, false); + const { fixture, getAllByText, getByRole, getByLabelText } = await setup(); - component.form.markAsDirty(); - component.form.get('username').setValue('1'); - component.form.get('username').markAsDirty(); + userEvent.type(getByLabelText('Username'), '1'); - component.onSubmit(); + userEvent.click(getByRole('button', { name: 'Sign in' })); fixture.detectChanges(); expect(getAllByText('Enter your password')).toBeTruthy(); }); + const unauthorizedError = new HttpErrorResponse({ + status: 401, + statusText: 'Unauthorized', + error: {}, + }); + it('should display invalid username/password message', async () => { - const { component, fixture, getAllByText } = await setup(false, false, false); + const { fixture, getAllByText, authSpy, getByLabelText, getByRole } = await setup(); - component.form.markAsDirty(); - component.form.get('username').setValue('1'); - component.form.get('username').markAsDirty(); - component.form.get('password').setValue('1'); - component.form.get('password').markAsDirty(); + authSpy.and.returnValue(throwError(unauthorizedError)); - component.onSubmit(); + userEvent.type(getByLabelText('Username'), '1'); + userEvent.type(getByLabelText('Password'), '1'); + + userEvent.click(getByRole('button', { name: 'Sign in' })); fixture.detectChanges(); expect(getAllByText('Your username or your password is incorrect')).toBeTruthy(); }); it('should focus on the first input box when the invalid username/password message is clicked', async () => { - const { component, fixture, getAllByText, getByRole } = await setup(false, false, false); + const { component, fixture, getAllByText, getByRole, authSpy } = await setup(); + + authSpy.and.returnValue(throwError(unauthorizedError)); component.form.setValue({ username: '1', password: '1' }); component.onSubmit(); @@ -261,21 +368,14 @@ describe('LoginComponent', () => { }); it('should not let you sign in with a username with special characters', async () => { - const { component, fixture, getAllByText, getByTestId } = await setup(); - - const signInButton = within(getByTestId('signinButton')).getByText('Sign in'); - const form = component.form; + const { fixture, getAllByText, getByRole, getByLabelText } = await setup(); - component.form.markAsDirty(); - form.controls['username'].setValue('username@123.com'); - form.controls['username'].markAsDirty(); - component.form.get('password').setValue('1'); - component.form.get('password').markAsDirty(); + userEvent.type(getByLabelText('Username'), 'username@123.com'); + userEvent.type(getByLabelText('Password'), '1'); - fireEvent.click(signInButton); + userEvent.click(getByRole('button', { name: 'Sign in' })); fixture.detectChanges(); - expect(form.invalid).toBeTruthy(); expect( getAllByText("You've entered an @ symbol (remember, your username cannot be an email address)").length, ).toBe(2); diff --git a/frontend/src/app/features/login/login.component.ts b/frontend/src/app/features/login/login.component.ts index 1024afe6fa..cf2a8487f7 100644 --- a/frontend/src/app/features/login/login.component.ts +++ b/frontend/src/app/features/login/login.component.ts @@ -10,10 +10,11 @@ import { } from '@angular/forms'; import { Router } from '@angular/router'; import { ErrorDefinition, ErrorDetails } from '@core/model/errorSummary.model'; +import { WorkplaceDataOwner } from '@core/model/my-workplaces.model'; +import { Roles } from '@core/model/roles.enum'; import { AuthService } from '@core/services/auth.service'; import { ErrorSummaryService } from '@core/services/error-summary.service'; import { EstablishmentService } from '@core/services/establishment.service'; -import { IdleService } from '@core/services/idle.service'; import { UserService } from '@core/services/user.service'; import { isAdminRole } from '@core/utils/check-role-util'; import { Subscription } from 'rxjs'; @@ -35,7 +36,6 @@ export class LoginComponent implements OnInit, OnDestroy, AfterViewInit { public showServerErrorAsLink: boolean = true; constructor( - private idleService: IdleService, private authService: AuthService, private userService: UserService, private establishmentService: EstablishmentService, @@ -158,35 +158,47 @@ export class LoginComponent implements OnInit, OnDestroy, AfterViewInit { this.subscriptions.add( this.authService.authenticate(username, password).subscribe( (response) => { - if (response.body.establishment && response.body.establishment.uid) { - // update the establishment service state with the given establishment id + const isPreviousUser = this.authService.isPreviousUser(username); + this.authService.clearPreviousUser(); + + if (response.body.establishment?.uid) { this.establishmentService.establishmentId = response.body.establishment.uid; } + if (isAdminRole(response.body.role)) { this.userService.agreedUpdatedTerms = true; // skip term & condition check for admin user - this.router.navigate(['/sfcadmin']); - } else { - this.userService.agreedUpdatedTerms = response.body.agreedUpdatedTerms; - if (this.authService.isPreviousUser(username) && this.authService.redirectLocation) { - this.router.navigateByUrl(this.authService.redirectLocation); - } else { - if (response.body.establishment.employerTypeSet === false) { - this.establishmentService.setEmployerTypeHasValue(false); - this.router.navigate(['workplace', `${response.body.establishment.uid}`, 'type-of-employer']); - } else { - this.router.navigate(['/dashboard']); - } - } - this.authService.clearPreviousUser(); - - if (response.body.migratedUserFirstLogon || !this.userService.agreedUpdatedTerms) { - this.router.navigate(['/migrated-user-terms-and-conditions']); - } - - if (response.body.registrationSurveyCompleted === false) { - this.router.navigate(['/registration-survey']); - } + return this.router.navigate(['/sfcadmin']); + } + + this.userService.agreedUpdatedTerms = response.body.agreedUpdatedTerms; + + if (response.body.migratedUserFirstLogon || !this.userService.agreedUpdatedTerms) { + return this.router.navigate(['/migrated-user-terms-and-conditions']); + } + + if (response.body.registrationSurveyCompleted === false) { + return this.router.navigate(['/registration-survey']); + } + + if (isPreviousUser && this.authService.redirectLocation) { + return this.router.navigateByUrl(this.authService.redirectLocation); } + + if (response.body.establishment.employerTypeSet === false) { + this.establishmentService.setEmployerTypeHasValue(false); + return this.router.navigate(['workplace', response.body.establishment.uid, 'type-of-employer']); + } + + if ( + (!response.body.lastViewedVacanciesAndTurnoverMessage || + this.isOverSixMonthsAgo(response.body.lastViewedVacanciesAndTurnoverMessage)) && + response.body.role === Roles.Edit && + response.body.establishment?.dataOwner === WorkplaceDataOwner.Workplace + ) { + return this.router.navigate(['/update-your-vacancies-and-turnover-data']); + } + + this.router.navigate(['/dashboard']); }, (error: HttpErrorResponse) => { this.serverError = this.errorSummaryService.getServerErrorMessage(error.status, this.serverErrorsMap); @@ -200,4 +212,13 @@ export class LoginComponent implements OnInit, OnDestroy, AfterViewInit { event.preventDefault(); this.showPassword = !this.showPassword; } + + private isOverSixMonthsAgo(lastViewedVacanciesAndTurnoverMessageDate: string): boolean { + const currentDate = new Date(); + const sixMonthsAgo = new Date(); + sixMonthsAgo.setMonth(currentDate.getMonth() - 6); + + const lastViewedDate = new Date(lastViewedVacanciesAndTurnoverMessageDate); + return lastViewedDate < sixMonthsAgo; + } } diff --git a/frontend/src/app/features/login/vacancies-and-turnover-login-message/vacancies-and-turnover-login-message.component.html b/frontend/src/app/features/login/vacancies-and-turnover-login-message/vacancies-and-turnover-login-message.component.html new file mode 100644 index 0000000000..4a2ae0ed14 --- /dev/null +++ b/frontend/src/app/features/login/vacancies-and-turnover-login-message/vacancies-and-turnover-login-message.component.html @@ -0,0 +1,19 @@ +
+
+

Your Workplace vacancies and turnover information

+

+ Please take the time to check and update your vacancies and turnover information under Workplace in ASC-WDS. +

+ +

Current staff vacancies

+

We'll show DHSC and others how shortfalls in staffing affect the sector.

+ +

New starters in the last 12 months

+

We'll learn whether the sector is attracting new staff and whether recruitment plans are working.

+ +

Staff leavers in the last 12 months

+

We'll reveal staff retention issues and help DHSC and the government make policy and funding decisions.

+ + Continue +
+
diff --git a/frontend/src/app/features/login/vacancies-and-turnover-login-message/vacancies-and-turnover-login-message.component.spec.ts b/frontend/src/app/features/login/vacancies-and-turnover-login-message/vacancies-and-turnover-login-message.component.spec.ts new file mode 100644 index 0000000000..a4ece3c636 --- /dev/null +++ b/frontend/src/app/features/login/vacancies-and-turnover-login-message/vacancies-and-turnover-login-message.component.spec.ts @@ -0,0 +1,59 @@ +import { RouterModule } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { UserService } from '@core/services/user.service'; +import { SharedModule } from '@shared/shared.module'; +import { render } from '@testing-library/angular'; +import { of } from 'rxjs'; + +import { VacanciesAndTurnoverLoginMessage } from './vacancies-and-turnover-login-message.component'; + +describe('VacanciesAndTurnoverLoginMessage', () => { + async function setup() { + const updateLastViewedVacanciesAndTurnoverMessageSpy = jasmine + .createSpy('updateLastViewedVacanciesAndTurnoverMessage') + .and.returnValue(of(null)); + + const setupTools = await render(VacanciesAndTurnoverLoginMessage, { + imports: [SharedModule, RouterModule, RouterTestingModule], + providers: [ + { + provide: UserService, + useValue: { + loggedInUser: { + uid: 'ajoij3213213213', + }, + updateLastViewedVacanciesAndTurnoverMessage: updateLastViewedVacanciesAndTurnoverMessageSpy, + }, + }, + ], + }); + + const component = setupTools.fixture.componentInstance; + + return { + ...setupTools, + component, + updateLastViewedVacanciesAndTurnoverMessageSpy, + }; + } + + it('should render a VacanciesAndTurnoverLoginMessage', async () => { + const { component } = await setup(); + + expect(component).toBeTruthy(); + }); + + it('should navigate to dashboard on click of Continue', async () => { + const { getByText } = await setup(); + + const continueButton = getByText('Continue'); + + expect(continueButton.getAttribute('href')).toEqual('/dashboard'); + }); + + it('should call updateLastViewedVacanciesAndTurnoverMessage in UserService on page load', async () => { + const { updateLastViewedVacanciesAndTurnoverMessageSpy } = await setup(); + + expect(updateLastViewedVacanciesAndTurnoverMessageSpy).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/features/login/vacancies-and-turnover-login-message/vacancies-and-turnover-login-message.component.ts b/frontend/src/app/features/login/vacancies-and-turnover-login-message/vacancies-and-turnover-login-message.component.ts new file mode 100644 index 0000000000..cd53ec8f3f --- /dev/null +++ b/frontend/src/app/features/login/vacancies-and-turnover-login-message/vacancies-and-turnover-login-message.component.ts @@ -0,0 +1,21 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { UserService } from '@core/services/user.service'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-vacancies-and-turnover-login-message', + templateUrl: './vacancies-and-turnover-login-message.component.html', +}) +export class VacanciesAndTurnoverLoginMessage implements OnInit, OnDestroy { + private subscriptions: Subscription = new Subscription(); + + constructor(private userService: UserService) {} + + ngOnInit(): void { + this.subscriptions.add(this.userService.updateLastViewedVacanciesAndTurnoverMessage().subscribe(() => {})); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } +} diff --git a/frontend/src/app/features/new-dashboard/workplace-tab/workplace-tab.component.html b/frontend/src/app/features/new-dashboard/workplace-tab/workplace-tab.component.html index a7368b912c..6e0c9b1730 100644 --- a/frontend/src/app/features/new-dashboard/workplace-tab/workplace-tab.component.html +++ b/frontend/src/app/features/new-dashboard/workplace-tab/workplace-tab.component.html @@ -2,6 +2,7 @@ [tab]="'workplace'" [workplace]="workplace" [updatedDate]="workplace.updated" + [return]="summaryReturnUrl" >
diff --git a/frontend/src/app/features/subsidiary/workplace/view-subsidiary-workplace.component.html b/frontend/src/app/features/subsidiary/workplace/view-subsidiary-workplace.component.html index 1f153b993e..77671281c5 100644 --- a/frontend/src/app/features/subsidiary/workplace/view-subsidiary-workplace.component.html +++ b/frontend/src/app/features/subsidiary/workplace/view-subsidiary-workplace.component.html @@ -1,4 +1,10 @@ - +
diff --git a/frontend/src/app/features/subsidiary/workplace/view-subsidiary-workplace.component.spec.ts b/frontend/src/app/features/subsidiary/workplace/view-subsidiary-workplace.component.spec.ts new file mode 100644 index 0000000000..b749933496 --- /dev/null +++ b/frontend/src/app/features/subsidiary/workplace/view-subsidiary-workplace.component.spec.ts @@ -0,0 +1,86 @@ +import { HttpClient } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { BreadcrumbService } from '@core/services/breadcrumb.service'; +import { PermissionsService } from '@core/services/permissions/permissions.service'; +import { UserService } from '@core/services/user.service'; +import { MockBreadcrumbService } from '@core/test-utils/MockBreadcrumbService'; +import { MockPermissionsService } from '@core/test-utils/MockPermissionsService'; +import { SharedModule } from '@shared/shared.module'; +import { render } from '@testing-library/angular'; + +import { Establishment } from '../../../../mockdata/establishment'; +import { ViewSubsidiaryWorkplaceComponent } from './view-subsidiary-workplace.component'; +import { FeatureFlagsService } from '@shared/services/feature-flags.service'; +import { MockFeatureFlagsService } from '@core/test-utils/MockFeatureFlagService'; +import { NewDashboardHeaderComponent } from '@shared/components/new-dashboard-header/dashboard-header.component'; +import { AuthService } from '@core/services/auth.service'; +import { MockAuthService } from '@core/test-utils/MockAuthService'; +import { EstablishmentService } from '@core/services/establishment.service'; +import { AlertService } from '@core/services/alert.service'; +import { WindowRef } from '@core/services/window.ref'; +import { MockUserService } from '@core/test-utils/MockUserService'; +import { Roles } from '@core/model/roles.enum'; + +describe('ViewSubsidiaryWorkplaceComponent', () => { + const setup = async (override: any = { isAdmin: true }) => { + const setupTools = await render(ViewSubsidiaryWorkplaceComponent, { + imports: [SharedModule, RouterModule, RouterTestingModule, HttpClientTestingModule], + providers: [ + AlertService, + WindowRef, + { + provide: PermissionsService, + useFactory: MockPermissionsService.factory(['canViewEstablishment', 'canViewListOfWorkers']), + deps: [HttpClient, Router, UserService], + }, + { + provide: UserService, + useFactory: MockUserService.factory(1, Roles.Admin), + deps: [HttpClient], + }, + + { + provide: BreadcrumbService, + useClass: MockBreadcrumbService, + }, + { + provide: FeatureFlagsService, + useClass: MockFeatureFlagsService, + }, + { + provide: AuthService, + useFactory: MockAuthService.factory(true, override.isAdmin), + deps: [HttpClient, Router, EstablishmentService, UserService, PermissionsService], + }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + data: { + establishment: Establishment, + }, + }, + }, + }, + ], + declarations: [NewDashboardHeaderComponent], + }); + const component = setupTools.fixture.componentInstance; + + return { component, ...setupTools }; + }; + + it('should render a View Subsidiary Workplace Component', async () => { + const { component } = await setup(); + + expect(component).toBeTruthy(); + }); + + it('should show the dashboard header', async () => { + const { getByTestId } = await setup(); + + expect(getByTestId('dashboard-header')).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/features/workers/delete-staff-record/delete-staff-record.component.html b/frontend/src/app/features/workers/delete-staff-record/delete-staff-record.component.html new file mode 100644 index 0000000000..ca9f306008 --- /dev/null +++ b/frontend/src/app/features/workers/delete-staff-record/delete-staff-record.component.html @@ -0,0 +1,113 @@ + + +
+
+ {{ worker.nameOrId }} +

Delete staff record

+ +

Are you sure you want to delete this staff record?

+

+ This action cannot be undone. It will permanently delete this staff record and any related + training and qualification records (and certificates). +

+ +
+
+
+ +

Select why you want to delete this staff record

+
+
+ +
+ + +
+
+
+ + + Error: + {{ getFirstErrorMessage('details') }} + + + +
+
+
+
+
+
+
+ + Error: + {{ getFirstErrorMessage('confirmDelete') }} + +
+ + +
+
+
+ + or + + Cancel + +
+
+
+
diff --git a/frontend/src/app/features/workers/delete-staff-record/delete-staff-record.component.scss b/frontend/src/app/features/workers/delete-staff-record/delete-staff-record.component.scss new file mode 100644 index 0000000000..dff658219a --- /dev/null +++ b/frontend/src/app/features/workers/delete-staff-record/delete-staff-record.component.scss @@ -0,0 +1,32 @@ +@import 'govuk-frontend/govuk/base'; + +.govuk-checkboxes__item-align-middle { + display: flex; + align-items: center; + padding-left: 0; + margin-right: -50px; + + .govuk-checkboxes__input { + display: block; + position: unset; + min-width: 44px; + min-height: 44px; + } + + .govuk-checkboxes__label { + padding: 0; + padding-left: 10px; + display: block; + position: unset; + } + + .govuk-checkboxes__label::before { + top: 5px; + left: 0px; + } + + .govuk-checkboxes__label::after { + top: 5 + 11px; + left: 0 + 9px; + } +} diff --git a/frontend/src/app/features/workers/delete-staff-record/delete-staff-record.component.spec.ts b/frontend/src/app/features/workers/delete-staff-record/delete-staff-record.component.spec.ts new file mode 100644 index 0000000000..3f4fe9aaea --- /dev/null +++ b/frontend/src/app/features/workers/delete-staff-record/delete-staff-record.component.spec.ts @@ -0,0 +1,203 @@ +import { repeat } from 'lodash'; +import { of } from 'rxjs'; + +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { getTestBed } from '@angular/core/testing'; +import { ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms'; +import { Router, RouterModule } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Worker } from '@core/model/worker.model'; +import { AlertService } from '@core/services/alert.service'; +import { EstablishmentService } from '@core/services/establishment.service'; +import { WindowRef } from '@core/services/window.ref'; +import { WorkerService } from '@core/services/worker.service'; +import { MockEstablishmentService } from '@core/test-utils/MockEstablishmentService'; +import { mockLeaveReasons, MockWorkerServiceWithUpdateWorker, workerBuilder } from '@core/test-utils/MockWorkerService'; +import { SharedModule } from '@shared/shared.module'; +import { render, within } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; + +import { DeleteStaffRecordComponent } from './delete-staff-record.component'; + +describe('DeleteStaffRecordComponent', () => { + const mockWorker = workerBuilder() as Worker; + + const setup = async (overrides: any = {}) => { + const setupTools = await render(DeleteStaffRecordComponent, { + imports: [SharedModule, RouterModule, RouterTestingModule, HttpClientTestingModule, ReactiveFormsModule], + providers: [ + UntypedFormBuilder, + { + provide: WorkerService, + useFactory: MockWorkerServiceWithUpdateWorker.factory(mockWorker), + }, + { + provide: EstablishmentService, + useClass: MockEstablishmentService, + }, + AlertService, + WindowRef, + ], + }); + const component = setupTools.fixture.componentInstance; + + const injector = getTestBed(); + const router = injector.inject(Router) as Router; + const routerSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + const workerService = injector.inject(WorkerService) as WorkerService; + const deleteWorkerSpy = spyOn(workerService, 'deleteWorker').and.returnValue(of(null)); + + const alertService = injector.inject(AlertService) as AlertService; + const alertServiceSpy = spyOn(alertService, 'addAlert'); + + return { + ...setupTools, + component, + routerSpy, + workerService, + deleteWorkerSpy, + alertServiceSpy, + }; + }; + + it('should create', async () => { + const { component } = await setup(); + expect(component).toBeTruthy(); + }); + + describe('rendering', () => { + it("should show a page heading and the worker's name as caption", async () => { + const { getByRole, getByTestId } = await setup(); + + expect(getByRole('heading', { name: 'Delete staff record' })).toBeTruthy(); + + const caption = getByTestId('section-heading'); + expect(caption.textContent).toContain(mockWorker.nameOrId); + }); + + it('should show a list of radio buttons for choosing the reason to delete record', async () => { + const { getByRole, getByText } = await setup(); + + expect(getByText('Select why you want to delete this staff record')).toBeTruthy(); + mockLeaveReasons.forEach(({ reason }) => { + expect(getByRole('radio', { name: reason })).toBeTruthy(); + }); + }); + + it('should show a checkbox to confirm deleting record', async () => { + const { getByRole } = await setup(); + + const checkboxText = + 'I know that this action will permanently delete this staff record and any training and qualification records (and certificates) related to it.'; + + expect(getByRole('checkbox', { name: checkboxText })).toBeTruthy(); + }); + + it('should show a red CTA button "Delete this staff record"', async () => { + const { getByRole } = await setup(); + + expect(getByRole('button', { name: 'Delete this staff record' })).toBeTruthy(); + }); + + it('should show a textedit box to enter details when "For a reason not listed" is chosen', async () => { + const { fixture, getByRole, getByTestId } = await setup(); + + userEvent.click(getByRole('radio', { name: 'For a reason not listed' })); + fixture.detectChanges(); + + const detailsInput = getByTestId('other-reason-details'); + expect(within(detailsInput).getByRole('textbox')).toBeTruthy(); + expect(detailsInput.className).not.toContain('govuk-radios__conditional--hidden'); + }); + }); + + describe('form submit and validations', () => { + it('should call workerService deleteWorker on submit', async () => { + const { component, getByRole, deleteWorkerSpy } = await setup(); + + userEvent.click(getByRole('checkbox', { name: /I know that/ })); + userEvent.click(getByRole('radio', { name: mockLeaveReasons[3].reason })); + userEvent.click(getByRole('button', { name: 'Delete this staff record' })); + + const expectedReason = { + reason: { + id: mockLeaveReasons[3].id, + }, + }; + + expect(deleteWorkerSpy).toHaveBeenCalledWith(component.workplace.uid, component.worker.uid, expectedReason); + }); + + it('should call deleteWorker with reason being null if user did not select any reason', async () => { + const { component, getByRole, deleteWorkerSpy } = await setup(); + + userEvent.click(getByRole('checkbox', { name: /I know that/ })); + userEvent.click(getByRole('button', { name: 'Delete this staff record' })); + + expect(deleteWorkerSpy).toHaveBeenCalledWith(component.workplace.uid, component.worker.uid, null); + }); + + it('should call deleteWorker with both reason id and detail if user chose "For a reason not listed" and also input some text', async () => { + const { component, deleteWorkerSpy, fixture, getByRole } = await setup(); + + userEvent.click(getByRole('radio', { name: 'For a reason not listed' })); + userEvent.type(getByRole('textbox'), 'Some very specific reason'); + userEvent.click(getByRole('checkbox', { name: /I know that/ })); + userEvent.click(getByRole('button', { name: 'Delete this staff record' })); + fixture.detectChanges(); + + expect(deleteWorkerSpy).toHaveBeenCalledWith(component.workplace.uid, component.worker.uid, { + reason: { id: 8, other: 'Some very specific reason' }, + }); + }); + + it('should navigate to staff record page and show an alert when worker is successfully deleted', async () => { + const { component, getByRole, routerSpy, alertServiceSpy } = await setup(); + + userEvent.click(getByRole('checkbox', { name: /I know that/ })); + userEvent.click(getByRole('button', { name: 'Delete this staff record' })); + + expect(routerSpy).toHaveBeenCalledWith(['/dashboard'], { fragment: 'staff-records' }); + + await routerSpy.calls.mostRecent().returnValue; + expect(alertServiceSpy).toHaveBeenCalledWith({ + type: 'success', + message: `Staff record deleted (${component.worker.nameOrId})`, + }); + }); + + it('should show an error message if confirmation checkbox is not ticked', async () => { + const { fixture, getByRole, getByText, getAllByText, deleteWorkerSpy } = await setup(); + const expectedErrorMessage = + 'Confirm that you know this action will permanently delete this staff record and any training and qualification records (and certificates) related to it'; + + userEvent.click(getByRole('button', { name: 'Delete this staff record' })); + fixture.detectChanges(); + + expect(getByText('There is a problem')).toBeTruthy(); + expect(getAllByText(expectedErrorMessage)).toHaveSize(2); + + expect(deleteWorkerSpy).not.toHaveBeenCalled(); + }); + + it('should show an error message if the text in provide details textbox exceeds the max length allowed', async () => { + const { fixture, getByRole, getByText, getAllByText, deleteWorkerSpy } = await setup(); + + userEvent.click(getByRole('radio', { name: 'For a reason not listed' })); + fixture.detectChanges(); + + const provideDetailsTextbox = getByRole('textbox', { name: 'Provide details (optional)' }); + userEvent.type(provideDetailsTextbox, repeat('a', 501)); + + userEvent.click(getByRole('checkbox', { name: /I know that/ })); + userEvent.click(getByRole('button', { name: 'Delete this staff record' })); + fixture.detectChanges(); + + expect(getByText('There is a problem')).toBeTruthy(); + expect(getAllByText('Provide details must be 500 characters or fewer')).toHaveSize(2); + + expect(deleteWorkerSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/app/features/workers/delete-staff-record/delete-staff-record.component.ts b/frontend/src/app/features/workers/delete-staff-record/delete-staff-record.component.ts new file mode 100644 index 0000000000..2efb4254bb --- /dev/null +++ b/frontend/src/app/features/workers/delete-staff-record/delete-staff-record.component.ts @@ -0,0 +1,164 @@ +import { Subscription } from 'rxjs'; + +import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { ErrorDetails } from '@core/model/errorSummary.model'; +import { Establishment } from '@core/model/establishment.model'; +import { Worker } from '@core/model/worker.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 { Reason, WorkerService } from '@core/services/worker.service'; + +@Component({ + selector: 'app-delete-staff-record', + templateUrl: './delete-staff-record.component.html', + styleUrls: ['./delete-staff-record.component.scss'], +}) +export class DeleteStaffRecordComponent implements OnInit, AfterViewInit, OnDestroy { + @ViewChild('formEl') formEl: ElementRef; + + public form: UntypedFormGroup; + public submitted: boolean; + public worker: Worker; + public workplace: Establishment; + public reasons: Reason[]; + public formErrorsMap: Array; + public detailsText: string; + + public otherReasonId = 8; + public otherReasonDetailMaxLength = 500; + public confirmationMissingErrorMessage = + 'Confirm that you know this action will permanently delete this staff record and any training and qualification records (and certificates) related to it'; + + private subscriptions: Subscription = new Subscription(); + + constructor( + private alertService: AlertService, + private establishmentService: EstablishmentService, + private router: Router, + private errorSummaryService: ErrorSummaryService, + private workerService: WorkerService, + private formBuilder: UntypedFormBuilder, + private backLinkService: BackLinkService, + ) {} + + ngOnInit(): void { + this.worker = this.workerService.worker; + this.workplace = this.establishmentService.establishment; + + this.getLeaveReasons(); + this.setupForm(); + this.setupFormErrorsMap(); + this.setBackLink(); + } + + ngAfterViewInit() { + this.errorSummaryService.formEl$.next(this.formEl); + } + + private getLeaveReasons(): void { + this.subscriptions.add( + this.workerService.getLeaveReasons().subscribe((reasons) => { + this.reasons = reasons; + }), + ); + } + + private setupForm(): void { + this.form = this.formBuilder.group({ + reason: [null], + details: [null, { validators: [Validators.maxLength(this.otherReasonDetailMaxLength)], updateOn: 'submit' }], + confirmDelete: [null, { validators: [Validators.requiredTrue], updateOn: 'submit' }], + }); + } + + private setBackLink(): void { + this.backLinkService.showBackLink(); + } + + private setupFormErrorsMap(): void { + this.formErrorsMap = [ + { + item: 'confirmDelete', + type: [ + { + name: 'required', + message: this.confirmationMissingErrorMessage, + }, + ], + }, + { + item: 'details', + type: [ + { + name: 'maxlength', + message: `Provide details must be ${this.otherReasonDetailMaxLength} characters or fewer`, + }, + ], + }, + ]; + } + + public getFirstErrorMessage(item: string): string { + const errorType = Object.keys(this.form.get(item).errors)[0]; + return this.errorSummaryService.getFormErrorMessage(item, errorType, this.formErrorsMap); + } + + public get selectedReasonId() { + return this.form.get('reason').value; + } + + public onInputDetails(event: Event) { + event.preventDefault(); + this.detailsText = (event.target as HTMLTextAreaElement).value; + } + + public onSubmit(): void { + this.submitted = true; + + if (this.form.invalid) { + this.errorSummaryService.scrollToErrorSummary(); + return; + } + + const leaveReason = this.buildLeaveReasonProp(); + + this.subscriptions.add( + this.workerService + .deleteWorker(this.workplace.uid, this.worker.uid, leaveReason) + .subscribe(() => this.onSuccess()), + ); + } + + private buildLeaveReasonProp() { + if (!this.selectedReasonId) { + return null; + } + const reasonProp = { id: this.selectedReasonId }; + + if (this.selectedReasonId === this.otherReasonId && this.form.get('details').value) { + reasonProp['other'] = this.form.get('details').value; + } + return { reason: reasonProp }; + } + + private onSuccess(): void { + this.router + .navigate(['/dashboard'], { + fragment: 'staff-records', + }) + .then(() => + this.alertService.addAlert({ + type: 'success', + message: `Staff record deleted (${this.worker.nameOrId})`, + }), + ); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } +} diff --git a/frontend/src/app/features/workers/delete-worker-dialog/delete-worker-dialog.component.html b/frontend/src/app/features/workers/delete-worker-dialog/delete-worker-dialog.component.html deleted file mode 100644 index d5e143e82b..0000000000 --- a/frontend/src/app/features/workers/delete-worker-dialog/delete-worker-dialog.component.html +++ /dev/null @@ -1,40 +0,0 @@ -

You're about to delete this staff record

-

Once deleted you will not be able to access it again.

-
-
- -

Why are you removing this staff record?

-
-
- -
- -
-
- - - Error: - {{ getFormErrorMessage('details', 'maxLength') }} - - - -
-
-
- - Cancel -
diff --git a/frontend/src/app/features/workers/delete-worker-dialog/delete-worker-dialog.component.ts b/frontend/src/app/features/workers/delete-worker-dialog/delete-worker-dialog.component.ts deleted file mode 100644 index 578f142960..0000000000 --- a/frontend/src/app/features/workers/delete-worker-dialog/delete-worker-dialog.component.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; -import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; -import { Router } from '@angular/router'; -import { DialogComponent } from '@core/components/dialog.component'; -import { ErrorDetails } from '@core/model/errorSummary.model'; -import { Establishment } from '@core/model/establishment.model'; -import { Worker } from '@core/model/worker.model'; -import { AlertService } from '@core/services/alert.service'; -import { Dialog, DIALOG_DATA } from '@core/services/dialog.service'; -import { ErrorSummaryService } from '@core/services/error-summary.service'; -import { Reason, WorkerService } from '@core/services/worker.service'; -import { Subscription } from 'rxjs'; - -@Component({ - selector: 'app-delete-worker-dialog', - templateUrl: './delete-worker-dialog.component.html', -}) -export class DeleteWorkerDialogComponent extends DialogComponent implements OnInit, OnDestroy { - public reasons: Reason[]; - public maxLength = 500; - public form: UntypedFormGroup; - public submitted = false; - private formErrorsMap: Array; - private subscriptions: Subscription = new Subscription(); - - constructor( - @Inject(DIALOG_DATA) public data: { worker: Worker; workplace: Establishment; primaryWorkplaceUid: string }, - public dialog: Dialog, - private router: Router, - private formBuilder: UntypedFormBuilder, - private alertService: AlertService, - private errorSummaryService: ErrorSummaryService, - private workerService: WorkerService, - ) { - super(data, dialog); - - this.form = this.formBuilder.group({ - reason: null, - details: [null, [Validators.maxLength(this.maxLength)]], - }); - } - - ngOnInit() { - this.subscriptions.add( - this.workerService.getLeaveReasons().subscribe((reasons) => { - this.reasons = reasons; - }), - ); - - this.setupFormErrors(); - } - - ngOnDestroy() { - this.subscriptions.unsubscribe(); - } - - public getFormErrorMessage(item: string, errorType: string): string { - return this.errorSummaryService.getFormErrorMessage(item, errorType, this.formErrorsMap); - } - - public close(event: Event) { - event.preventDefault(); - this.dialog.close(); - } - - public onSubmit() { - this.submitted = true; - this.errorSummaryService.syncFormErrorsEvent.next(true); - - if (!this.form.valid) { - this.errorSummaryService.scrollToErrorSummary(); - return; - } - - const { reason, details } = this.form.controls; - const deleteReason = reason.value - ? { - reason: { - id: parseInt(reason.value, 10), - ...(details.value && { - other: details.value, - }), - }, - } - : null; - - this.subscriptions.add( - this.workerService.deleteWorker(this.data.workplace.uid, this.data.worker.uid, deleteReason).subscribe( - () => this.onSuccess(), - (error) => this.onError(error), - ), - ); - } - - private onSuccess(): void { - this.router.navigate(['/dashboard'], { - fragment: 'staff-records', - }).then(()=>this.alertService.addAlert({ - type: 'success', - message: `${this.data.worker.nameOrId} has been deleted`, - })); - this.close(event); - } - - private onError(error): void { - console.log(error); - } - - private setupFormErrors(): void { - this.formErrorsMap = [ - { - item: 'details', - type: [ - { - name: 'maxLength', - message: `Details must be ${this.maxLength} characters or less`, - }, - ], - }, - ]; - } -} diff --git a/frontend/src/app/features/workers/staff-record/staff-record.component.html b/frontend/src/app/features/workers/staff-record/staff-record.component.html index 59c67cc843..4aaa72a84b 100644 --- a/frontend/src/app/features/workers/staff-record/staff-record.component.html +++ b/frontend/src/app/features/workers/staff-record/staff-record.component.html @@ -65,8 +65,7 @@

Staff record

*ngIf="canDeleteWorker" draggable="false" role="button" - href="#" - (click)="deleteWorker($event)" + [routerLink]="['/workplace', workplace.uid, 'staff-record', worker.uid, 'delete-staff-record']" > Delete staff record { async function setup(isParent = true, ownWorkplace = true) { const workplace = establishmentBuilder() as Establishment; - const { fixture, getByText, getAllByText, queryByText, getByTestId } = await render(StaffRecordComponent, { + const setupTools = await render(StaffRecordComponent, { imports: [SharedModule, RouterModule, RouterTestingModule, HttpClientTestingModule, WorkersModule], providers: [ AlertService, @@ -69,6 +69,7 @@ describe('StaffRecordComponent', () => { ], }); + const fixture = setupTools.fixture; const component = fixture.componentInstance; const injector = getTestBed(); @@ -94,14 +95,11 @@ describe('StaffRecordComponent', () => { routerSpy, workerService, workerSpy, - getByText, - getAllByText, - getByTestId, - queryByText, workplaceUid, workerUid, alertSpy, parentSubsidiaryViewService, + ...setupTools, }; } @@ -149,7 +147,8 @@ describe('StaffRecordComponent', () => { }); it('should render the delete record link, add training link and flag long term absence link, and not correct text when worker.completed is true', async () => { - const { component, fixture, queryByText, getByText, getByTestId } = await setup(); + const { component, fixture, queryByText, getByText, getByTestId, getByRole, workplaceUid, workerUid } = + await setup(); component.canEditWorker = true; component.canDeleteWorker = true; @@ -161,13 +160,16 @@ describe('StaffRecordComponent', () => { const button = queryByText('Confirm record details'); const text = queryByText(`Check the record details you've added are correct.`); const flagLongTermAbsenceLink = getByText('Flag long-term absence'); - const deleteRecordLink = getByText('Delete staff record'); + const deleteRecordLink = getByRole('button', { name: 'Delete staff record' }); const trainingAndQualsLink = getByTestId('training-and-qualifications-link'); expect(button).toBeFalsy(); expect(text).toBeFalsy(); expect(flagLongTermAbsenceLink).toBeTruthy(); expect(deleteRecordLink).toBeTruthy(); + expect(deleteRecordLink.getAttribute('href')).toEqual( + `/workplace/${workplaceUid}/staff-record/${workerUid}/delete-staff-record`, + ); expect(trainingAndQualsLink).toBeTruthy(); }); diff --git a/frontend/src/app/features/workers/staff-record/staff-record.component.ts b/frontend/src/app/features/workers/staff-record/staff-record.component.ts index b15816e9eb..57d1ddb0d8 100644 --- a/frontend/src/app/features/workers/staff-record/staff-record.component.ts +++ b/frontend/src/app/features/workers/staff-record/staff-record.component.ts @@ -15,7 +15,6 @@ import { ParentSubsidiaryViewService } from '@shared/services/parent-subsidiary- import { Subscription } from 'rxjs'; import { take } from 'rxjs/operators'; -import { DeleteWorkerDialogComponent } from '../delete-worker-dialog/delete-worker-dialog.component'; import { MoveWorkerDialogComponent } from '../move-worker-dialog/move-worker-dialog.component'; @Component({ @@ -75,16 +74,6 @@ export class StaffRecordComponent implements OnInit, OnDestroy { this.canEditWorker = this.permissionsService.can(this.workplace.uid, 'canEditWorker'); } - deleteWorker(event: Event): void { - event.preventDefault(); - this.dialogService.open(DeleteWorkerDialogComponent, { - worker: this.worker, - workplace: this.workplace, - primaryWorkplaceUid: this.route.parent.snapshot.data.primaryWorkplace - ? this.route.parent.snapshot.data.primaryWorkplace.uid - : null, - }); - } public backLinkNavigation(): URLStructure { return this.worker.otherQualification === 'Yes' ? { url: ['/workplace', this.workplace.uid, 'staff-record', this.worker.uid, 'other-qualifications-level'] } diff --git a/frontend/src/app/features/workers/workers-routing.module.ts b/frontend/src/app/features/workers/workers-routing.module.ts index d8773bea47..9100d8d4f9 100644 --- a/frontend/src/app/features/workers/workers-routing.module.ts +++ b/frontend/src/app/features/workers/workers-routing.module.ts @@ -59,6 +59,7 @@ import { TotalStaffChangeComponent } from './total-staff-change/total-staff-chan import { WeeklyContractedHoursComponent } from './weekly-contracted-hours/weekly-contracted-hours.component'; import { YearArrivedUkComponent } from './year-arrived-uk/year-arrived-uk.component'; import { SelectQualificationTypeComponent } from '@features/training-and-qualifications/add-edit-qualification/select-qualification-type/select-qualification-type.component'; +import { DeleteStaffRecordComponent } from './delete-staff-record/delete-staff-record.component'; const routes: Routes = [ { @@ -690,6 +691,14 @@ const routes: Routes = [ resolve: { longTermAbsenceReasons: LongTermAbsenceResolver, worker: WorkerResolver }, data: { title: 'Flag long term absence' }, }, + { + path: 'delete-staff-record', + component: DeleteStaffRecordComponent, + data: { + permissions: ['canDeleteWorker'], + title: 'Delete staff record', + }, + }, ], }, ]; diff --git a/frontend/src/app/features/workers/workers.module.ts b/frontend/src/app/features/workers/workers.module.ts index 428e853e79..3c7e80b754 100644 --- a/frontend/src/app/features/workers/workers.module.ts +++ b/frontend/src/app/features/workers/workers.module.ts @@ -37,7 +37,6 @@ import { DateOfBirthComponent } from './date-of-birth/date-of-birth.component'; import { DaysOfSicknessComponent } from './days-of-sickness/days-of-sickness.component'; import { DeleteQualificationDialogComponent } from './delete-qualification-dialog/delete-qualification-dialog.component'; import { DeleteTrainingDialogComponent } from './delete-training-dialog/delete-training-dialog.component'; -import { DeleteWorkerDialogComponent } from './delete-worker-dialog/delete-worker-dialog.component'; import { DisabilityComponent } from './disability/disability.component'; import { EditWorkerComponent } from './edit-worker/edit-worker.component'; import { EmployedFromOutsideUkComponent } from './employed-from-outside-uk/employed-from-outside-uk.component'; @@ -69,6 +68,7 @@ import { WeeklyContractedHoursComponent } from './weekly-contracted-hours/weekly import { WorkersRoutingModule } from './workers-routing.module'; import { YearArrivedUkComponent } from './year-arrived-uk/year-arrived-uk.component'; import { QualificationCertificateService, TrainingCertificateService } from '@core/services/certificate.service'; +import { DeleteStaffRecordComponent } from './delete-staff-record/delete-staff-record.component'; @NgModule({ imports: [CommonModule, OverlayModule, FormsModule, ReactiveFormsModule, SharedModule, WorkersRoutingModule], @@ -87,7 +87,6 @@ import { QualificationCertificateService, TrainingCertificateService } from '@co DaysOfSicknessComponent, DeleteQualificationDialogComponent, DeleteTrainingDialogComponent, - DeleteWorkerDialogComponent, DeleteRecordComponent, DisabilityComponent, EditWorkerComponent, @@ -125,6 +124,7 @@ import { QualificationCertificateService, TrainingCertificateService } from '@co EmployedFromOutsideUkComponent, Level2AdultSocialCareCertificateComponent, MainJobRoleComponent, + DeleteStaffRecordComponent, ], providers: [ DialogService, diff --git a/frontend/src/app/shared/components/character-count/character-count.component.spec.ts b/frontend/src/app/shared/components/character-count/character-count.component.spec.ts new file mode 100644 index 0000000000..e1737c3281 --- /dev/null +++ b/frontend/src/app/shared/components/character-count/character-count.component.spec.ts @@ -0,0 +1,129 @@ +import { repeat } from 'lodash'; + +import { FormControl, ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms'; +import { SharedModule } from '@shared/shared.module'; +import { render } from '@testing-library/angular'; + +import { CharacterCountComponent } from './character-count.component'; + +describe('CharacterCountComponent', () => { + const setupWithFormControlAsInput = async (override: any = {}) => { + // This is the existing usage of this component which requires a form control object passed in as input. + // Cannot work with form controls with option {updateOn: 'submit'} + const setupConfigs = { words: false, max: 200, ...override }; + + const formBuilder = new UntypedFormBuilder(); + const mockFormGroup = formBuilder.group({ textInput: [''] }); + const textFormControl = mockFormGroup.get('textInput') as FormControl; + + const setupTools = await render(CharacterCountComponent, { + imports: [SharedModule, ReactiveFormsModule], + componentProperties: { + control: textFormControl, + words: setupConfigs.words, + max: setupConfigs.max, + }, + }); + const fixture = setupTools.fixture; + const component = setupTools.fixture.componentInstance; + + const updateTextValue = (newText: string) => { + mockFormGroup.setValue({ textInput: newText }); + }; + + return { ...setupTools, component, fixture, updateTextValue }; + }; + + const setupWithTextAsInput = async (override: any = {}) => { + // A new option to pass in the input text as a plain string instead. This can co-exist with option {updateOn: 'submit'}. + const setupConfigs = { words: false, max: 200, ...override }; + + const templateString = ` + `; + + const setupTools = await render(templateString, { + imports: [SharedModule, ReactiveFormsModule], + declarations: [CharacterCountComponent], + componentProperties: { + textToCount: null, + words: setupConfigs.words, + max: setupConfigs.max, + }, + }); + const fixture = setupTools.fixture; + const component = setupTools.fixture.componentInstance; + + const updateTextValue = (newText: string) => { + component['textToCount'] = newText; + }; + + return { ...setupTools, component, fixture, updateTextValue }; + }; + + const testSuites = [ + { + testSuiteName: 'With form control as input', + setup: setupWithFormControlAsInput, + }, + { + testSuiteName: 'With plain text as input', + setup: setupWithTextAsInput, + }, + ]; + + testSuites.forEach(({ testSuiteName, setup }) => { + describe(testSuiteName, () => { + it('should show a string "You have x character(s) remaining"', async () => { + const { fixture, getByText, updateTextValue } = await setup(); + + expect(getByText('You have 200 characters remaining')).toBeTruthy(); + + updateTextValue('some random text'); + fixture.detectChanges(); + expect(getByText('You have 184 characters remaining')).toBeTruthy(); + + updateTextValue(repeat('a', 199)); + fixture.detectChanges(); + expect(getByText('You have 1 character remaining')).toBeTruthy(); + }); + + it('should show a string "you have x character(s) too many" when input text exceeds the allowed max length', async () => { + const { fixture, getByText, updateTextValue } = await setup(); + + updateTextValue(repeat('a', 201)); + fixture.detectChanges(); + expect(getByText('You have 1 character too many')).toBeTruthy(); + + updateTextValue(repeat('a', 210)); + fixture.detectChanges(); + expect(getByText('You have 10 characters too many')).toBeTruthy(); + }); + + it('should correctly handle the case when user cleared the input text', async () => { + const { fixture, getByText, updateTextValue } = await setup(); + + updateTextValue('some text'); + fixture.detectChanges(); + expect(getByText('You have 191 characters remaining')).toBeTruthy(); + + updateTextValue(null); + fixture.detectChanges(); + expect(getByText('You have 200 characters remaining')).toBeTruthy(); + }); + + it('should count words instead of characters when words = true is given', async () => { + const { fixture, getByText, updateTextValue } = await setup({ words: true, max: 20 }); + + expect(getByText('You have 20 words remaining')).toBeTruthy(); + + updateTextValue('some random text'); + fixture.detectChanges(); + expect(getByText('You have 17 words remaining')).toBeTruthy(); + + updateTextValue(repeat('some random text ', 7)); + fixture.detectChanges(); + expect(getByText('You have 1 word too many')).toBeTruthy(); + }); + }); + }); +}); diff --git a/frontend/src/app/shared/components/character-count/character-count.component.ts b/frontend/src/app/shared/components/character-count/character-count.component.ts index 3aa8d6abd9..b3db3b9fd9 100644 --- a/frontend/src/app/shared/components/character-count/character-count.component.ts +++ b/frontend/src/app/shared/components/character-count/character-count.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; import { UntypedFormControl } from '@angular/forms'; import { Subscription } from 'rxjs'; @@ -6,26 +6,35 @@ import { Subscription } from 'rxjs'; selector: 'app-character-count', templateUrl: './character-count.component.html', }) -export class CharacterCountComponent implements OnInit, OnDestroy { +export class CharacterCountComponent implements OnInit, OnDestroy, OnChanges { public remaining: number; private subscriptions: Subscription = new Subscription(); @Input() control: UntypedFormControl; @Input() max: number; @Input() words = false; + @Input() textToCount: string; ngOnInit() { this.remaining = this.max; - this.subscriptions.add( - this.control.valueChanges.subscribe((value: string) => { - if (value) { - if (this.words) { - this.remaining = this.max - value.match(/\S+/g).length; - } else { - this.remaining = this.max - value.length; - } - } - }), - ); + if (this.control) { + this.subscriptions.add(this.control.valueChanges.subscribe((value) => this.updateRemainingCount(value))); + } + } + + ngOnChanges(changes: SimpleChanges): void { + if ('textToCount' in changes) { + this.updateRemainingCount(changes.textToCount.currentValue); + } + } + + private updateRemainingCount(value: string) { + if (!value) { + this.remaining = this.max; + return; + } + + const countInputSize = this.words ? value.match(/\S+/g).length : value.length; + this.remaining = this.max - countInputSize; } ngOnDestroy() { diff --git a/frontend/src/app/shared/components/new-dashboard-header/dashboard-header.component.html b/frontend/src/app/shared/components/new-dashboard-header/dashboard-header.component.html index 0217ee5ce5..a90662384f 100644 --- a/frontend/src/app/shared/components/new-dashboard-header/dashboard-header.component.html +++ b/frontend/src/app/shared/components/new-dashboard-header/dashboard-header.component.html @@ -1,7 +1,10 @@
-
+
-

+

{{ workplace?.name }} {{ header }}

@@ -77,6 +80,18 @@

Contact Skills for Care

+ + + +
+ + + diff --git a/frontend/src/app/shared/components/new-dashboard-header/dashboard-header.component.scss b/frontend/src/app/shared/components/new-dashboard-header/dashboard-header.component.scss index 8903fb0773..9a8502162f 100644 --- a/frontend/src/app/shared/components/new-dashboard-header/dashboard-header.component.scss +++ b/frontend/src/app/shared/components/new-dashboard-header/dashboard-header.component.scss @@ -11,3 +11,7 @@ height: 215px; } } + +.do-as-parent-link { + margin-bottom: -1px; +} diff --git a/frontend/src/app/shared/components/new-dashboard-header/dashboard-header.component.spec.ts b/frontend/src/app/shared/components/new-dashboard-header/dashboard-header.component.spec.ts index 8601cd47f8..96f5627ae5 100644 --- a/frontend/src/app/shared/components/new-dashboard-header/dashboard-header.component.spec.ts +++ b/frontend/src/app/shared/components/new-dashboard-header/dashboard-header.component.spec.ts @@ -20,7 +20,6 @@ import { MockUserService } from '@core/test-utils/MockUserService'; import { ParentSubsidiaryViewService } from '@shared/services/parent-subsidiary-view.service'; import { SharedModule } from '@shared/shared.module'; import { render, within } from '@testing-library/angular'; -import { of } from 'rxjs'; import { NewDashboardHeaderComponent } from './dashboard-header.component'; @@ -35,20 +34,21 @@ const MockWindow = { describe('NewDashboardHeaderComponent', () => { const establishment = establishmentBuilder() as Establishment; const setup = async ( - tab = 'home', - updateDate = false, - canAddWorker = true, - canEditWorker = false, - hasWorkers = true, - isAdmin = true, - subsidiaries = 0, - viewingSubAsParent = false, - canDeleteEstablishment = true, - workplace = establishment, + override: any = { + tab: 'home', + updateDate: false, + canAddWorker: true, + canEditWorker: false, + hasWorkers: true, + isAdmin: true, + subsidiaries: 0, + viewingSubAsParent: false, + canDeleteEstablishment: true, + }, ) => { - const role = isAdmin ? Roles.Admin : Roles.Edit; - const updatedDate = updateDate ? '01/02/2023' : null; - const { fixture, getByTestId, queryByTestId, getByText, queryByText } = await render(NewDashboardHeaderComponent, { + const role = override.isAdmin ? Roles.Admin : Roles.Edit; + const updatedDate = override.updateDate ? '01/02/2023' : null; + const setupTools = await render(NewDashboardHeaderComponent, { imports: [SharedModule, RouterModule, RouterTestingModule, HttpClientTestingModule, ReactiveFormsModule], providers: [ { @@ -61,38 +61,41 @@ describe('NewDashboardHeaderComponent', () => { }, { provide: PermissionsService, - useFactory: MockPermissionsService.factory(canDeleteEstablishment ? ['canDeleteEstablishment'] : [], isAdmin), + useFactory: MockPermissionsService.factory( + override.canDeleteEstablishment ? ['canDeleteEstablishment'] : [], + override.isAdmin, + ), deps: [HttpClient, Router, UserService], }, { provide: UserService, - useFactory: MockUserService.factory(subsidiaries, role), + useFactory: MockUserService.factory(override.subsidiaries, role), deps: [HttpClient], }, { provide: AuthService, - useFactory: MockAuthService.factory(true, isAdmin), + useFactory: MockAuthService.factory(true, override.isAdmin), deps: [HttpClient, Router, EstablishmentService, UserService, PermissionsService], }, { provide: WindowToken, useValue: MockWindow }, { provide: ParentSubsidiaryViewService, - useFactory: MockParentSubsidiaryViewService.factory(viewingSubAsParent), + useFactory: MockParentSubsidiaryViewService.factory(override.viewingSubAsParent), }, ], componentProperties: { - tab, - canAddWorker, + tab: override.tab, + canAddWorker: override.canAddWorker, updatedDate, tAndQCount: 5, - canEditWorker, - hasWorkers, + canEditWorker: override.canEditWorker, + hasWorkers: override.hasWorkers, isParent: false, - workplace, + workplace: establishment, }, }); - const component = fixture.componentInstance; + const component = setupTools.fixture.componentInstance; const injector = getTestBed(); const establishmentService = injector.inject(EstablishmentService) as EstablishmentService; @@ -101,13 +104,9 @@ describe('NewDashboardHeaderComponent', () => { return { component, - getByTestId, - queryByTestId, - getByText, - queryByText, + ...setupTools, establishmentService, router, - fixture, routerSpy, }; }; @@ -132,8 +131,6 @@ describe('NewDashboardHeaderComponent', () => { it('should not show parent above workplace name if it is not a parent', async () => { const { component, queryByTestId } = await setup(); - component.isParent = false; - expect(queryByTestId('parentLabel')).toBeFalsy(); }); @@ -188,15 +185,26 @@ describe('NewDashboardHeaderComponent', () => { expect(selectedWorkplaceLabel).toBeTruthy(); expect(parentName).toBeFalsy(); }); + + it('should not show the workplace address', async () => { + const override = { tab: 'home' }; + const { queryByTestId } = await setup(override); + + expect(queryByTestId('workplace-address')).toBeFalsy(); + }); }); describe('Workplace tab', () => { it('should display the workplace name, the tab name, the nmdsId number and the last updated date', async () => { - const { component, getByText, getByTestId } = await setup('workplace', true); + const override = { + tab: 'workplace', + updateDate: true, + }; + const { component, getByText, getByTestId } = await setup(override); const workplace = component.workplace; - expect(getByText(workplace.name)).toBeTruthy(); + expect(within(getByTestId('workplaceName')).getByText(workplace.name)).toBeTruthy(); expect(getByText('Workplace')).toBeTruthy(); expect(getByText(`Workplace ID: ${workplace.nmdsId}`)).toBeTruthy(); expect(getByTestId('separator')).toBeTruthy(); @@ -204,13 +212,15 @@ describe('NewDashboardHeaderComponent', () => { }); it('should not display the contact info', async () => { - const { queryByTestId } = await setup('workplace'); + const override = { tab: 'workplace' }; + const { queryByTestId } = await setup(override); expect(queryByTestId('contact-info')).toBeFalsy(); }); it('should render conditional column width classes', async () => { - const { getByTestId } = await setup('workplace'); + const override = { tab: 'workplace' }; + const { getByTestId } = await setup(override); const column1 = getByTestId('column-one'); const column2 = getByTestId('column-two'); @@ -218,11 +228,49 @@ describe('NewDashboardHeaderComponent', () => { expect(column1.getAttribute('class')).toContain('govuk-grid-column-two-thirds'); expect(column2.getAttribute('class')).toContain('govuk-grid-column-one-third'); }); + + it('should show the workplace address', async () => { + const override = { tab: 'workplace' }; + const { getByTestId } = await setup(override); + + expect(getByTestId('workplace-address')).toBeTruthy(); + }); + + describe('what you can do as a parent link', async () => { + it('should show if it is parent', async () => { + const override = { tab: 'workplace' }; + const { component, fixture, getByTestId, getByText } = await setup(override); + + component.isParent = true; + fixture.detectChanges(); + + const doAsParentLink = getByTestId('do-as-parent'); + const doAsParentText = 'What you can do as a parent'; + + expect(doAsParentLink).toBeTruthy(); + expect(within(doAsParentLink).getByText(doAsParentText)).toBeTruthy(); + expect(getByText(doAsParentText).getAttribute('href')).toEqual('/workplace/about-parents'); + }); + + it('should not show if it is not parent', async () => { + const override = { tab: 'workplace' }; + const { component, fixture, queryByTestId } = await setup(override); + + component.isParent = false; + fixture.detectChanges(); + + expect(queryByTestId('do-as-parent')).toBeFalsy(); + }); + }); }); describe('staff records tab', () => { it('should display the workplace name, the tab name, the nmdsId number and the last updated date', async () => { - const { component, getByText, getByTestId } = await setup('staff-records', true); + const override = { + tab: 'staff-records', + updateDate: true, + }; + const { component, getByText, getByTestId } = await setup(override); const workplace = component.workplace; @@ -234,20 +282,31 @@ describe('NewDashboardHeaderComponent', () => { }); it('should not display date if an updated date is not given', async () => { - const { queryByTestId } = await setup('staff-records', false); + const override = { + tab: 'staff-records', + updateDate: false, + }; + const { queryByTestId } = await setup(override); expect(queryByTestId('separator')).toBeFalsy(); expect(queryByTestId('lastUpdatedDate')).toBeFalsy(); }); it('should not display the contact info', async () => { - const { queryByTestId } = await setup('staff-records'); + const override = { + tab: 'staff-records', + }; + const { queryByTestId } = await setup(override); expect(queryByTestId('contact-info')).toBeFalsy(); }); it('should display the add a staff record button if canAddWorker is true with correct href', async () => { - const { component, getByText } = await setup('staff-records'); + const override = { + tab: 'staff-records', + canAddWorker: true, + }; + const { component, getByText } = await setup(override); const workplaceUid = component.workplace.uid; const button = getByText('Add a staff record'); @@ -257,13 +316,21 @@ describe('NewDashboardHeaderComponent', () => { }); it('should not display the add a staff record button if canAddWorker is not true', async () => { - const { queryByText } = await setup('staff-records', true, false); + const override = { + tab: 'staff-records', + updateDate: true, + canAddWorker: false, + }; + const { queryByText } = await setup(override); expect(queryByText('Add a staff record')).toBeFalsy(); }); it('should render conditional column width classes', async () => { - const { getByTestId } = await setup('staff-records'); + const override = { + tab: 'staff-records', + }; + const { getByTestId } = await setup(override); const column1 = getByTestId('column-one'); const column2 = getByTestId('column-two'); @@ -271,11 +338,25 @@ describe('NewDashboardHeaderComponent', () => { expect(column1.getAttribute('class')).toContain('govuk-grid-column-two-thirds'); expect(column2.getAttribute('class')).toContain('govuk-grid-column-one-third'); }); + + it('should not show the workplace address', async () => { + const override = { + tab: 'staff-records', + }; + const { queryByTestId } = await setup(override); + + expect(queryByTestId('workplace-address')).toBeFalsy(); + }); }); describe('training and qualifications tab', () => { it('should display the workplace name, the tab name the number of t and qs, the nmdsId number and the last updated date', async () => { - const { component, getByText, getByTestId } = await setup('training-and-qualifications', true); + const override = { + tab: 'training-and-qualifications', + updateDate: true, + }; + + const { component, getByText, getByTestId } = await setup(override); const workplace = component.workplace; @@ -287,20 +368,34 @@ describe('NewDashboardHeaderComponent', () => { }); it('should not display date if an updated date is not given', async () => { - const { queryByTestId } = await setup('training-and-qualifications', false); + const override = { + tab: 'training-and-qualifications', + updateDate: false, + }; + const { queryByTestId } = await setup(override); expect(queryByTestId('separator')).toBeFalsy(); expect(queryByTestId('lastUpdatedDate')).toBeFalsy(); }); it('should not display the contact info', async () => { - const { queryByTestId } = await setup('training-and-qualifications'); + const override = { + tab: 'training-and-qualifications', + }; + const { queryByTestId } = await setup(override); expect(queryByTestId('contact-info')).toBeFalsy(); }); it('should display the add multiple staff records button if canEditWorker is true with correct href', async () => { - const { component, getByText } = await setup('training-and-qualifications', false, false, true); + const override = { + tab: 'training-and-qualifications', + updateDate: false, + canAddWorker: false, + canEditWorker: true, + hasWorkers: true, + }; + const { component, getByText } = await setup(override); const workplaceUid = component.workplace.uid; const button = getByText('Add multiple training records'); @@ -310,19 +405,32 @@ describe('NewDashboardHeaderComponent', () => { }); it('should not display the add multiple staff records button if canEditWorker is not true', async () => { - const { queryByText } = await setup('training-and-qualifications'); + const override = { + tab: 'training-and-qualifications', + }; + const { queryByText } = await setup(override); expect(queryByText('Add multiple training records')).toBeFalsy(); }); it('should not display the add multiple staff records button if there are no workers', async () => { - const { queryByText } = await setup('training-and-qualifications', false, false, true, false); + const override = { + tab: 'training-and-qualifications', + updateDate: false, + canAddWorker: false, + canEditWorker: true, + hasWorkers: false, + }; + const { queryByText } = await setup(override); expect(queryByText('Add multiple training records')).toBeFalsy(); }); it('should render conditional column width classes', async () => { - const { getByTestId } = await setup('staff-records'); + const override = { + tab: 'training-and-qualifications', + }; + const { getByTestId } = await setup(override); const column1 = getByTestId('column-one'); const column2 = getByTestId('column-two'); @@ -330,41 +438,65 @@ describe('NewDashboardHeaderComponent', () => { expect(column1.getAttribute('class')).toContain('govuk-grid-column-two-thirds'); expect(column2.getAttribute('class')).toContain('govuk-grid-column-one-third'); }); + + it('should not show the workplace address', async () => { + const override = { + tab: 'training-and-qualifications', + }; + const { queryByTestId } = await setup(override); + + expect(queryByTestId('workplace-address')).toBeFalsy(); + }); }); describe('Benchmarks tab', () => { it('should display the workplace name, the tab name and the nmdsId number', async () => { - const { component, getByText } = await setup('benchmarks'); - + const override = { + tab: 'benchmarks', + }; + const { component, getByText } = await setup(override); const workplace = component.workplace; - expect(getByText(workplace.name)).toBeTruthy(); expect(getByText('Benchmarks')).toBeTruthy(); expect(getByText(`Workplace ID: ${workplace.nmdsId}`)).toBeTruthy(); }); it('should not display date if an updated date is not given', async () => { - const { queryByTestId } = await setup('benchmarks', false); - + const override = { + tab: 'benchmarks', + updateDate: false, + }; + const { queryByTestId } = await setup(override); expect(queryByTestId('separator')).toBeFalsy(); expect(queryByTestId('lastUpdatedDate')).toBeFalsy(); }); it('should not display the contact info', async () => { - const { queryByTestId } = await setup('benchmarks'); - + const override = { + tab: 'benchmarks', + }; + const { queryByTestId } = await setup(override); expect(queryByTestId('contact-info')).toBeFalsy(); }); it('should render conditional column width classes', async () => { - const { getByTestId } = await setup('benchmarks'); - + const override = { + tab: 'benchmarks', + }; + const { getByTestId } = await setup(override); const column1 = getByTestId('column-one'); const column2 = getByTestId('column-two'); - expect(column1.getAttribute('class')).toContain('govuk-grid-column-two-thirds'); expect(column2.getAttribute('class')).toContain('govuk-grid-column-one-third'); }); + + it('should not show the workplace address', async () => { + const override = { + tab: 'benchmarks', + }; + const { queryByTestId } = await setup(override); + expect(queryByTestId('workplace-address')).toBeFalsy(); + }); }); describe('Archive Workplace', () => { @@ -375,25 +507,70 @@ describe('NewDashboardHeaderComponent', () => { }); it('should display a Delete workplace link if parent is viewing a subsidiary', async () => { - const { getByText } = await setup('home', false, true, false, false, false, 1, true); + const override = { + tab: 'home', + updateDate: false, + canAddWorker: true, + canEditWorker: false, + hasWorkers: false, + isAdmin: false, + subsidiaries: 1, + viewingSubAsParent: true, + canDeleteEstablishment: true, + }; + const { getByText } = await setup(override); expect(getByText('Delete workplace')).toBeTruthy(); }); it('should not display a Delete workplace link if the workplace has subsidiaries', async () => { - const { queryByText } = await setup('home', false, true, false, true, false, 2); + const override = { + tab: 'home', + updateDate: false, + canAddWorker: true, + canEditWorker: false, + hasWorkers: true, + isAdmin: false, + subsidiaries: 2, + viewingSubAsParent: false, + canDeleteEstablishment: true, + }; + + const { queryByText } = await setup(override); expect(queryByText('Delete workplace')).toBeNull(); }); it('should not display a Delete workplace link if user not an admin and does not have canDeleteEstablishment permission', async () => { - const { queryByText } = await setup('home', false, true, false, true, false, 0, true, false); + const override = { + tab: 'home', + updateDate: false, + canAddWorker: true, + canEditWorker: false, + hasWorkers: true, + isAdmin: false, + subsidiaries: 0, + viewingSubAsParent: true, + canDeleteEstablishment: false, + }; + const { queryByText } = await setup(override); expect(queryByText('Delete workplace')).toBeNull(); }); it('should navigate to the subsidiary delete-workplace page when parent is viewing a subsidiary', async () => { - const { getByText, routerSpy, component } = await setup('home', false, true, false, false, false, 1, true); + const override = { + tab: 'home', + updateDate: false, + canAddWorker: true, + canEditWorker: false, + hasWorkers: false, + isAdmin: false, + subsidiaries: 1, + viewingSubAsParent: true, + canDeleteEstablishment: true, + }; + const { getByText, routerSpy, component } = await setup(override); const deletWorplaceLink = getByText('Delete workplace'); deletWorplaceLink.click(); @@ -402,7 +579,18 @@ describe('NewDashboardHeaderComponent', () => { }); it('should navigate to the standalone delete-workplace page when logged in as admin', async () => { - const { getByText, routerSpy } = await setup('home', false, true, false, false, true, 0, false); + const override = { + tab: 'home', + updateDate: false, + canAddWorker: true, + canEditWorker: false, + hasWorkers: false, + isAdmin: true, + subsidiaries: 0, + viewingSubAsParent: false, + canDeleteEstablishment: true, + }; + const { getByText, routerSpy } = await setup(override); const deletWorplaceLink = getByText('Delete workplace'); deletWorplaceLink.click(); diff --git a/frontend/src/app/shared/components/new-dashboard-header/dashboard-header.component.ts b/frontend/src/app/shared/components/new-dashboard-header/dashboard-header.component.ts index 4cdd428fd5..3aad4e3b3c 100644 --- a/frontend/src/app/shared/components/new-dashboard-header/dashboard-header.component.ts +++ b/frontend/src/app/shared/components/new-dashboard-header/dashboard-header.component.ts @@ -1,7 +1,9 @@ import { Component, Input, OnChanges, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { Establishment } from '@core/model/establishment.model'; +import { URLStructure } from '@core/model/url.model'; import { UserDetails } from '@core/model/userDetails.model'; +import { EstablishmentService } from '@core/services/establishment.service'; import { PermissionsService } from '@core/services/permissions/permissions.service'; import { UserService } from '@core/services/user.service'; import { isAdminRole } from '@core/utils/check-role-util'; @@ -22,8 +24,10 @@ export class NewDashboardHeaderComponent implements OnInit, OnChanges { @Input() canEditWorker = false; @Input() hasWorkers = false; @Input() workplace: Establishment; + @Input() return: URLStructure = null; public canDeleteEstablishment: boolean; + public canEditEstablishment: boolean; public workplaceUid: string; public subsidiaryCount: number; public showLastUpdatedDate: boolean; @@ -44,6 +48,7 @@ export class NewDashboardHeaderComponent implements OnInit, OnChanges { private router: Router, private userService: UserService, private parentSubsidiaryViewService: ParentSubsidiaryViewService, + private establishmentService: EstablishmentService, ) {} ngOnInit(): void { @@ -73,6 +78,7 @@ export class NewDashboardHeaderComponent implements OnInit, OnChanges { private getPermissions(): void { this.user = this.userService.loggedInUser; + this.canEditEstablishment = this.permissionsService.can(this.workplace.uid, 'canEditEstablishment'); if (isAdminRole(this.user?.role)) { this.canDeleteEstablishment = this.permissionsService.can(this.workplace?.uid, 'canDeleteAllEstablishments'); } else { @@ -105,6 +111,10 @@ export class NewDashboardHeaderComponent implements OnInit, OnChanges { } } + public setReturn(): void { + this.establishmentService.setReturnTo(this.return); + } + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } diff --git a/frontend/src/app/shared/components/new-workplace-summary/workplace-summary.component.html b/frontend/src/app/shared/components/new-workplace-summary/workplace-summary.component.html index f223d60d77..06b7598de9 100644 --- a/frontend/src/app/shared/components/new-workplace-summary/workplace-summary.component.html +++ b/frontend/src/app/shared/components/new-workplace-summary/workplace-summary.component.html @@ -1,48 +1,122 @@ - - -
-
-
+
+

-
-
Name
-
Address
-
-
-
- {{ workplace.name }} + Vacancies and turnover +

+ You've not added any vacancy and turnover data +
+
+
+
Current staff vacancies
+
+ + - + + +
    +
  • {{ vacancy | formatSLV }}
  • +
+
+ {{ workplace.vacancies }} +
+
+
+
+ +
-
-
{{ workplace.address1 }}
-
{{ workplace.address2 }}
-
{{ workplace.address3 }}
-
{{ workplace.town }}
-
{{ workplace.county }}
-
{{ workplace.postcode }}
+
+
+ You've not added any staff vacancy data +
+
+
- -
- -
+
-
-
+
+
+
New starters in the last 12 months
+
+ + - + + +
    +
  • {{ starter | formatSLV }}
  • +
+
+ + {{ workplace.starters }} + +
+
+
+
+ +
+
+ +
+
Staff leavers in the last 12 months
+
+ + - + + +
    +
  • {{ leaver | formatSLV }}
  • +
+
+ + {{ workplace.leavers }} + +
+
+
+
+ +
+
+
+
+ +
+

Workplace details

+
+ +
+
Services
-
-

- Vacancies and turnover -

- You've not added any vacancy and turnover data -
-
-
-
Current staff vacancies
-
- - - - - -
    -
  • {{ vacancy | formatSLV }}
  • -
-
- {{ workplace.vacancies }} -
-
-
-
- -
-
-
-
- You've not added any staff vacancy data -
-
-
-
-
-
-
-
-
New starters in the last 12 months
-
- - - - - -
    -
  • {{ starter | formatSLV }}
  • -
-
- - {{ workplace.starters }} - -
-
-
-
- -
-
- -
-
Staff leavers in the last 12 months
-
- - - - - -
    -
  • {{ leaver | formatSLV }}
  • -
-
- - {{ workplace.leavers }} - -
-
-
-
- -
-
-
-
-

Recruitment

{ }); describe('workplace-section', () => { - describe('Workplace name', () => { - it('should render the workplace name and address', async () => { - const { component, fixture, getByText } = await setup(); - - component.workplace.name = 'Care 1'; - component.workplace.address = 'Care Home, Leeds'; - component.workplace.address1 = 'Care Home'; - component.workplace.address2 = 'Care Street'; - component.workplace.address3 = 'Town'; - component.workplace.town = 'Leeds'; - component.workplace.county = 'Yorkshire'; - component.workplace.postcode = 'LS1 1AB'; - - fixture.detectChanges(); - - const workplace = component.workplace; - - expect(getByText('Name')).toBeTruthy(); - expect(getByText('Address')).toBeTruthy(); - expect(getByText(workplace.name)).toBeTruthy(); - expect(getByText(workplace.address1)).toBeTruthy(); - expect(getByText(workplace.address2)).toBeTruthy(); - expect(getByText(workplace.address3)).toBeTruthy(); - expect(getByText(workplace.town)).toBeTruthy(); - expect(getByText(workplace.county)).toBeTruthy(); - expect(getByText(workplace.postcode)).toBeTruthy(); - }); - - it('should render a Change link', async () => { - const { component, fixture, getByTestId } = await setup(); - - component.workplace.name = 'Care Home'; - component.canEditEstablishment = true; - fixture.detectChanges(); - - const workplaceRow = getByTestId('workplace-name-and-address'); - const link = within(workplaceRow).queryByText('Change'); - - expect(link).toBeTruthy(); - expect(link.getAttribute('href')).toEqual(`/workplace/${component.workplace.uid}/update-workplace-details`); - }); - - it('should render a conditional class if the workplace is not regulated and there is a number of staff error', async () => { - const { component, fixture, getByTestId } = await setup(); - - component.canEditEstablishment = true; - component.workplace.isRegulated = false; - component.numberOfStaffError = true; - - fixture.detectChanges(); - - const workplaceRow = getByTestId('workplace-name-and-address'); - - expect(workplaceRow.getAttribute('class')).toContain('govuk-summary-list__row--no-bottom-border'); - }); - - it('should render a conditional class if the workplace is not regulated and there is a number of staff warning', async () => { - const { component, fixture, getByTestId } = await setup(); - - component.canEditEstablishment = true; - component.workplace.isRegulated = false; - component.numberOfStaffWarning = true; - fixture.detectChanges(); - - const workplaceRow = getByTestId('workplace-name-and-address'); - - expect(workplaceRow.getAttribute('class')).toContain('govuk-summary-list__row--no-bottom-border'); - }); - - it('should not render a conditional class if the workplace is regulated and there is a number of staff error', async () => { - const { component, fixture, getByTestId } = await setup(); - - component.canEditEstablishment = true; - component.workplace.isRegulated = true; - component.numberOfStaffError = true; - - fixture.detectChanges(); - - const workplaceRow = getByTestId('workplace-name-and-address'); - - expect(workplaceRow.getAttribute('class')).not.toContain('govuk-summary-list__row--no-bottom-border'); - }); - - it('should not render a conditional class if the workplace is regulated and there is a number of staff warning', async () => { - const { component, fixture, getByTestId } = await setup(); - - component.canEditEstablishment = true; - component.workplace.isRegulated = true; - component.numberOfStaffWarning = true; - - fixture.detectChanges(); - - const workplaceRow = getByTestId('workplace-name-and-address'); - - expect(workplaceRow.getAttribute('class')).not.toContain('govuk-summary-list__row--no-bottom-border'); - }); - }); - describe('CQC Location ID', () => { it('should render the locationID and a Change link if the workplace is regulated', async () => { const { component, fixture, getByTestId } = await setup(); diff --git a/frontend/src/app/shared/components/workplace-name-address/workplace-name-address.component.html b/frontend/src/app/shared/components/workplace-name-address/workplace-name-address.component.html new file mode 100644 index 0000000000..7610c1865b --- /dev/null +++ b/frontend/src/app/shared/components/workplace-name-address/workplace-name-address.component.html @@ -0,0 +1,32 @@ +
+
+
+ + + + + +
+ {{ workplace.name }} + +
+ {{ workplace.address1 }} + , {{ workplace.address2 }} + , {{ workplace.address3 }} + , {{ workplace.town }} +
+
+ {{ workplace.county }}, + {{ workplace.postcode }} +
+
+
+
diff --git a/frontend/src/app/shared/components/workplace-name-address/workplace-name-address.component.scss b/frontend/src/app/shared/components/workplace-name-address/workplace-name-address.component.scss new file mode 100644 index 0000000000..e31fc4aa2a --- /dev/null +++ b/frontend/src/app/shared/components/workplace-name-address/workplace-name-address.component.scss @@ -0,0 +1,8 @@ +.divider { + border-top: 1px solid #e6e5e3; +} + +.change-link { + vertical-align: top; + padding-left: 36px; +} diff --git a/frontend/src/app/shared/components/workplace-name-address/workplace-name-address.component.spec.ts b/frontend/src/app/shared/components/workplace-name-address/workplace-name-address.component.spec.ts new file mode 100644 index 0000000000..bb2940ff98 --- /dev/null +++ b/frontend/src/app/shared/components/workplace-name-address/workplace-name-address.component.spec.ts @@ -0,0 +1,79 @@ +import { render } from '@testing-library/angular'; +import { WorkplaceNameAddress } from './workplace-name-address.component'; +import { SharedModule } from '@shared/shared.module'; +import { establishmentBuilder } from '@core/test-utils/MockEstablishmentService'; +import { Establishment } from '@core/model/establishment.model'; +import { RouterModule } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +describe('WorkplaceNameAddress', () => { + const setup = async (override: any = {}) => { + const establishment = establishmentBuilder() as Establishment; + const setupTools = await render(WorkplaceNameAddress, { + imports: [SharedModule, RouterModule, RouterTestingModule, HttpClientTestingModule], + declarations: [], + providers: [], + componentProperties: { + canEditEstablishment: override.canEditEstablishment, + workplace: override.workplace ? override.workplace : establishment, + }, + }); + + const component = setupTools.fixture.componentInstance; + + return { ...setupTools, component }; + }; + + it('should create WorkplaceNameAddress', async () => { + const { component } = await setup(); + + expect(component).toBeTruthy(); + }); + + it('should render the workplace name and address', async () => { + const workplace = { + name: 'Care 1', + address: 'Care Home, Leeds', + address1: 'Care Home', + address2: 'Care Street', + address3: 'Town', + town: 'Leeds', + county: 'Yorkshire', + postcode: 'LS1 1AB', + }; + + const { getByText } = await setup({ workplace }); + + expect(getByText(workplace.name)).toBeTruthy(); + expect(getByText(workplace.address1)).toBeTruthy(); + expect(getByText(workplace.address2, { exact: false })).toBeTruthy(); + expect(getByText(workplace.address3, { exact: false })).toBeTruthy(); + expect(getByText(workplace.town, { exact: false })).toBeTruthy(); + expect(getByText(workplace.county, { exact: false })).toBeTruthy(); + expect(getByText(workplace.postcode, { exact: false })).toBeTruthy(); + }); + + it('should not render a Change link if permission to edit is false', async () => { + const overrides = { + canEditEstablishment: false, + }; + const { queryByText } = await setup(overrides); + + const changeLink = queryByText('Change'); + + expect(changeLink).toBeFalsy(); + }); + + it('should render a Change link', async () => { + const overrides = { + canEditEstablishment: true, + }; + const { component, getByText } = await setup(overrides); + + const changeLink = getByText('Change'); + + expect(changeLink).toBeTruthy(); + expect(changeLink.getAttribute('href')).toEqual(`/workplace/${component.workplace.uid}/update-workplace-details`); + }); +}); diff --git a/frontend/src/app/shared/components/workplace-name-address/workplace-name-address.component.ts b/frontend/src/app/shared/components/workplace-name-address/workplace-name-address.component.ts new file mode 100644 index 0000000000..feb4b8b674 --- /dev/null +++ b/frontend/src/app/shared/components/workplace-name-address/workplace-name-address.component.ts @@ -0,0 +1,23 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Establishment } from '@core/model/establishment.model'; +import { URLStructure } from '@core/model/url.model'; +import { EstablishmentService } from '@core/services/establishment.service'; + +@Component({ + selector: 'app-workplace-name-address', + templateUrl: './workplace-name-address.component.html', + styleUrls: ['workplace-name-address.component.scss'], +}) +export class WorkplaceNameAddress implements OnInit { + @Input() workplace: Establishment; + @Input() canEditEstablishment: boolean; + @Input() return: URLStructure = null; + + constructor(private establishmentService: EstablishmentService) {} + + ngOnInit(): void {} + + public setReturn(): void { + this.establishmentService.setReturnTo(this.return); + } +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index f7cd9b267d..c3927d9f04 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -132,6 +132,7 @@ import { ServiceNamePipe } from './pipes/service-name.pipe'; import { WorkerDaysPipe } from './pipes/worker-days.pipe'; import { WorkerPayPipe } from './pipes/worker-pay.pipe'; import { WorkplacePermissionsBearerPipe } from './pipes/workplace-permissions-bearer.pipe'; +import { WorkplaceNameAddress } from './components/workplace-name-address/workplace-name-address.component'; @NgModule({ imports: [CommonModule, FormsModule, ReactiveFormsModule, RouterModule, OverlayModule], @@ -265,6 +266,7 @@ import { WorkplacePermissionsBearerPipe } from './pipes/workplace-permissions-be WdfSummaryPanel, FundingRequirementsStateComponent, SelectViewPanelComponent, + WorkplaceNameAddress, ], exports: [ AbsoluteNumberPipe, @@ -393,6 +395,7 @@ import { WorkplacePermissionsBearerPipe } from './pipes/workplace-permissions-be WdfSummaryPanel, FundingRequirementsStateComponent, SelectViewPanelComponent, + WorkplaceNameAddress, ], providers: [DialogService, TotalStaffComponent, ArticleListResolver, PageResolver], })