diff --git a/modules/ui/src/app/mocks/profile.mock.ts b/modules/ui/src/app/mocks/profile.mock.ts index d5472a4f6..f5a9120b0 100644 --- a/modules/ui/src/app/mocks/profile.mock.ts +++ b/modules/ui/src/app/mocks/profile.mock.ts @@ -20,3 +20,8 @@ export const PROFILE_MOCK: Profile = { name: 'Profile name', sections: [], }; + +export const PROFILE_MOCK_2: Profile = { + name: 'Second profile name', + sections: [], +}; diff --git a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.html b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.html index a3549c47b..139396c88 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.html +++ b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.html @@ -22,9 +22,10 @@ content_copy diff --git a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.spec.ts b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.spec.ts index 03463c0c7..933ae06e9 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.spec.ts +++ b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.spec.ts @@ -44,4 +44,15 @@ describe('ProfileItemComponent', () => { expect(name?.textContent?.trim()).toEqual('Profile name'); }); + + it('should emit delete event on delete button clicked', () => { + const deleteSpy = spyOn(component.deleteButtonClicked, 'emit'); + const deleteButton = fixture.nativeElement.querySelector( + '.profile-item-button.delete' + ) as HTMLButtonElement; + + deleteButton.click(); + + expect(deleteSpy).toHaveBeenCalledWith('Profile name'); + }); }); diff --git a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.ts b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.ts index 4e34d7b56..e45d36fb6 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.ts +++ b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.ts @@ -13,7 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, +} from '@angular/core'; import { Profile } from '../../../model/profile'; import { MatIcon } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; @@ -29,4 +35,5 @@ import { CommonModule } from '@angular/common'; }) export class ProfileItemComponent { @Input() profile!: Profile; + @Output() deleteButtonClicked = new EventEmitter(); } diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.html b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.html index 9b857cc7d..2cdd350b4 100644 --- a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.html +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.html @@ -32,7 +32,11 @@

Risk assessment

Saved profiles

- +
diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.spec.ts b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.spec.ts index e1a15a375..2667388bc 100644 --- a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.spec.ts +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.spec.ts @@ -13,7 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing'; import { RiskAssessmentComponent } from './risk-assessment.component'; import { MatToolbarModule } from '@angular/material/toolbar'; @@ -25,20 +30,32 @@ import { PROFILE_MOCK } from '../../mocks/profile.mock'; import { of } from 'rxjs'; import { Component, Input } from '@angular/core'; import { Profile } from '../../model/profile'; +import { MatDialogRef } from '@angular/material/dialog'; +import { DeleteFormComponent } from '../../components/delete-form/delete-form.component'; +import { FocusManagerService } from '../../services/focus-manager.service'; describe('RiskAssessmentComponent', () => { let component: RiskAssessmentComponent; let fixture: ComponentFixture; let mockService: SpyObj; + let mockFocusManagerService: SpyObj; let compiled: HTMLElement; beforeEach(async () => { - mockService = jasmine.createSpyObj(['fetchProfiles']); + mockService = jasmine.createSpyObj(['fetchProfiles', 'deleteProfile']); + mockService.deleteProfile.and.returnValue(of(true)); + + mockFocusManagerService = jasmine.createSpyObj([ + 'focusFirstElementInContainer', + ]); await TestBed.configureTestingModule({ declarations: [RiskAssessmentComponent, FakeProfileItemComponent], imports: [MatToolbarModule, MatSidenavModule, BrowserAnimationsModule], - providers: [{ provide: TestRunService, useValue: mockService }], + providers: [ + { provide: TestRunService, useValue: mockService }, + { provide: FocusManagerService, useValue: mockFocusManagerService }, + ], }).compileComponents(); fixture = TestBed.createComponent(RiskAssessmentComponent); @@ -91,6 +108,32 @@ describe('RiskAssessmentComponent', () => { expect(profileItems.length).toEqual(2); }); + + describe('#deleteProfile', () => { + it('should open delete profile modal', fakeAsync(() => { + const openSpy = spyOn(component.dialog, 'open').and.returnValue({ + afterClosed: () => of(true), + } as MatDialogRef); + tick(); + + component.deleteProfile(PROFILE_MOCK.name, 0); + tick(); + + expect(openSpy).toHaveBeenCalledWith(DeleteFormComponent, { + ariaLabel: 'Delete risk profile', + data: { + title: 'Delete risk profile', + content: `You are about to delete ${PROFILE_MOCK.name}. Are you sure?`, + }, + autoFocus: true, + hasBackdrop: true, + disableClose: true, + panelClass: 'delete-form-dialog', + }); + + openSpy.calls.reset(); + })); + }); }); }); diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.ts b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.ts index b9c805a65..6e9bfa54c 100644 --- a/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.ts +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment.component.ts @@ -13,8 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnDestroy } from '@angular/core'; import { RiskAssessmentStore } from './risk-assessment.store'; +import { DeleteFormComponent } from '../../components/delete-form/delete-form.component'; +import { Subject, takeUntil } from 'rxjs'; +import { MatDialog } from '@angular/material/dialog'; @Component({ selector: 'app-risk-assessment', @@ -23,9 +26,53 @@ import { RiskAssessmentStore } from './risk-assessment.store'; providers: [RiskAssessmentStore], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class RiskAssessmentComponent { +export class RiskAssessmentComponent implements OnDestroy { viewModel$ = this.store.viewModel$; - constructor(private store: RiskAssessmentStore) { + private destroy$: Subject = new Subject(); + constructor( + private store: RiskAssessmentStore, + public dialog: MatDialog + ) { this.store.getProfiles(); } + + ngOnDestroy() { + this.destroy$.next(true); + this.destroy$.unsubscribe(); + } + + deleteProfile(profileName: string, index: number): void { + const dialogRef = this.dialog.open(DeleteFormComponent, { + ariaLabel: 'Delete risk profile', + data: { + title: 'Delete risk profile', + content: `You are about to delete ${profileName}. Are you sure?`, + }, + autoFocus: true, + hasBackdrop: true, + disableClose: true, + panelClass: 'delete-form-dialog', + }); + + dialogRef + ?.afterClosed() + .pipe(takeUntil(this.destroy$)) + .subscribe(deleteProfile => { + if (deleteProfile) { + this.store.deleteProfile(profileName); + this.setFocus(index); + } + }); + } + + private setFocus(index: number): void { + const nextItem = window.document.querySelector( + `.profile-item-${index + 1}` + ) as HTMLElement; + const firstItem = window.document.querySelector( + `.profile-item-0` + ) as HTMLElement; + + this.store.setFocus({ nextItem, firstItem }); + } } diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment.store.spec.ts b/modules/ui/src/app/pages/risk-assessment/risk-assessment.store.spec.ts index bd1df47d0..7b45ca9a1 100644 --- a/modules/ui/src/app/pages/risk-assessment/risk-assessment.store.spec.ts +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment.store.spec.ts @@ -20,14 +20,19 @@ import { TestRunService } from '../../services/test-run.service'; import SpyObj = jasmine.SpyObj; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { RiskAssessmentStore } from './risk-assessment.store'; -import { PROFILE_MOCK } from '../../mocks/profile.mock'; +import { PROFILE_MOCK, PROFILE_MOCK_2 } from '../../mocks/profile.mock'; +import { FocusManagerService } from '../../services/focus-manager.service'; describe('RiskAssessmentStore', () => { let riskAssessmentStore: RiskAssessmentStore; let mockService: SpyObj; + let mockFocusManagerService: SpyObj; beforeEach(() => { - mockService = jasmine.createSpyObj(['fetchProfiles']); + mockService = jasmine.createSpyObj(['fetchProfiles', 'deleteProfile']); + mockFocusManagerService = jasmine.createSpyObj([ + 'focusFirstElementInContainer', + ]); TestBed.configureTestingModule({ imports: [NoopAnimationsModule], @@ -35,6 +40,7 @@ describe('RiskAssessmentStore', () => { RiskAssessmentStore, provideMockStore({}), { provide: TestRunService, useValue: mockService }, + { provide: FocusManagerService, useValue: mockFocusManagerService }, ], }); @@ -84,5 +90,70 @@ describe('RiskAssessmentStore', () => { riskAssessmentStore.getProfiles(); }); }); + + describe('deleteProfile', () => { + it('should update store profiles', done => { + mockService.deleteProfile.and.returnValue(of(true)); + + riskAssessmentStore.updateProfiles([PROFILE_MOCK, PROFILE_MOCK_2]); + + riskAssessmentStore.viewModel$ + .pipe(skip(1), take(1)) + .subscribe(store => { + expect(store.profiles).toEqual([PROFILE_MOCK_2]); + done(); + }); + + riskAssessmentStore.deleteProfile(PROFILE_MOCK.name); + }); + }); + + describe('setFocus', () => { + const mockNextItem = document.createElement('div') as HTMLElement; + const mockFirstItem = document.createElement('section') as HTMLElement; + const mockNullEL = window.document.querySelector(`.mock`) as HTMLElement; + + it('should set focus to the next profile item when available', () => { + const mockData = { + nextItem: mockNextItem, + firstItem: mockFirstItem, + }; + riskAssessmentStore.updateProfiles([PROFILE_MOCK, PROFILE_MOCK_2]); + + riskAssessmentStore.setFocus(mockData); + + expect( + mockFocusManagerService.focusFirstElementInContainer + ).toHaveBeenCalledWith(mockNextItem); + }); + + it('should set focus to the first profile item when available and no next item', () => { + const mockData = { + nextItem: mockNullEL, + firstItem: mockFirstItem, + }; + riskAssessmentStore.updateProfiles([PROFILE_MOCK, PROFILE_MOCK_2]); + + riskAssessmentStore.setFocus(mockData); + + expect( + mockFocusManagerService.focusFirstElementInContainer + ).toHaveBeenCalledWith(mockFirstItem); + }); + + it('should set focus to the first element in the main when no items', () => { + const mockData = { + nextItem: mockNullEL, + firstItem: mockFirstItem, + }; + riskAssessmentStore.updateProfiles([PROFILE_MOCK]); + + riskAssessmentStore.setFocus(mockData); + + expect( + mockFocusManagerService.focusFirstElementInContainer + ).toHaveBeenCalledWith(); + }); + }); }); }); diff --git a/modules/ui/src/app/pages/risk-assessment/risk-assessment.store.ts b/modules/ui/src/app/pages/risk-assessment/risk-assessment.store.ts index 0f358d9c8..6b0a348c8 100644 --- a/modules/ui/src/app/pages/risk-assessment/risk-assessment.store.ts +++ b/modules/ui/src/app/pages/risk-assessment/risk-assessment.store.ts @@ -16,10 +16,11 @@ import { Injectable } from '@angular/core'; import { ComponentStore } from '@ngrx/component-store'; -import { tap } from 'rxjs/operators'; +import { tap, withLatestFrom } from 'rxjs/operators'; import { exhaustMap } from 'rxjs'; import { TestRunService } from '../../services/test-run.service'; import { Profile } from '../../model/profile'; +import { FocusManagerService } from '../../services/focus-manager.service'; export interface AppComponentState { profiles: Profile[]; @@ -48,7 +49,48 @@ export class RiskAssessmentStore extends ComponentStore { }) ); }); - constructor(private testRunService: TestRunService) { + + deleteProfile = this.effect(trigger$ => { + return trigger$.pipe( + exhaustMap((name: string) => { + return this.testRunService.deleteProfile(name).pipe( + withLatestFrom(this.profiles$), + tap(([remove, current]) => { + if (remove) { + this.removeProfile(name, current); + } + }) + ); + }) + ); + }); + + setFocus = this.effect<{ nextItem: HTMLElement; firstItem: HTMLElement }>( + trigger$ => { + return trigger$.pipe( + withLatestFrom(this.profiles$), + tap(([{ nextItem, firstItem }, profiles]) => { + if (nextItem) { + this.focusManagerService.focusFirstElementInContainer(nextItem); + } else if (profiles.length > 1) { + this.focusManagerService.focusFirstElementInContainer(firstItem); + } else { + this.focusManagerService.focusFirstElementInContainer(); + } + }) + ); + } + ); + + private removeProfile(name: string, current: Profile[]): void { + const profiles = current.filter(profile => profile.name !== name); + this.updateProfiles(profiles); + } + + constructor( + private testRunService: TestRunService, + private focusManagerService: FocusManagerService + ) { super({ profiles: [], }); diff --git a/modules/ui/src/app/services/test-run.service.spec.ts b/modules/ui/src/app/services/test-run.service.spec.ts index 3062e081c..3a8a8c1f8 100644 --- a/modules/ui/src/app/services/test-run.service.spec.ts +++ b/modules/ui/src/app/services/test-run.service.spec.ts @@ -493,6 +493,34 @@ describe('TestRunService', () => { req.flush([PROFILE_MOCK]); }); + it('deleteProfile should delete profile', () => { + service.deleteProfile('test').subscribe(res => { + expect(res).toEqual(true); + }); + + const req = httpTestingController.expectOne( + 'http://localhost:8000/profiles' + ); + + expect(req.request.method).toBe('DELETE'); + + req.flush(true); + }); + + it('deleteProfile should return false when error happens', () => { + service.deleteProfile('test').subscribe(res => { + expect(res).toEqual(false); + }); + + const req = httpTestingController.expectOne( + 'http://localhost:8000/profiles' + ); + + expect(req.request.method).toBe('DELETE'); + + req.error(new ErrorEvent('')); + }); + it('fetchCertificates should return certificates', () => { const certificates = [certificate] as Certificate[]; diff --git a/modules/ui/src/app/services/test-run.service.ts b/modules/ui/src/app/services/test-run.service.ts index 1e82612e1..375677e82 100644 --- a/modules/ui/src/app/services/test-run.service.ts +++ b/modules/ui/src/app/services/test-run.service.ts @@ -222,6 +222,17 @@ export class TestRunService { return this.http.get(`${API_URL}/profiles`); } + deleteProfile(name: string): Observable { + return this.http + .delete(`${API_URL}/profiles`, { + body: JSON.stringify({ name }), + }) + .pipe( + catchError(() => of(false)), + map(res => !!res) + ); + } + fetchCertificates(): Observable { return this.http.get(`${API_URL}/system/config/certs`); }