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
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`);
}