Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions modules/ui/src/app/mocks/profile.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,8 @@ export const PROFILE_MOCK: Profile = {
name: 'Profile name',
sections: [],
};

export const PROFILE_MOCK_2: Profile = {
name: 'Second profile name',
sections: [],
};
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@
<mat-icon fontSet="material-symbols-outlined"> content_copy </mat-icon>
</button>
<button
class="profile-item-button"
class="profile-item-button delete"
mat-icon-button
aria-label="Delete profile">
aria-label="Delete profile"
(click)="deleteButtonClicked.emit(profile.name)">
<mat-icon fontSet="material-symbols-outlined"> delete </mat-icon>
</button>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -29,4 +35,5 @@ import { CommonModule } from '@angular/common';
})
export class ProfileItemComponent {
@Input() profile!: Profile;
@Output() deleteButtonClicked = new EventEmitter<string>();
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ <h2 class="title">Risk assessment</h2>
<h2 class="profiles-drawer-header-title">Saved profiles</h2>
</div>
<div class="profiles-drawer-content">
<app-profile-item *ngFor="let profile of vm.profiles" [profile]="profile">
<app-profile-item
*ngFor="let profile of vm.profiles; let i = index"
[profile]="profile"
class="profile-item-{{ i }}"
(deleteButtonClicked)="deleteProfile($event, i)">
</app-profile-item>
</div>
</mat-drawer>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<RiskAssessmentComponent>;
let mockService: SpyObj<TestRunService>;
let mockFocusManagerService: SpyObj<FocusManagerService>;
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);
Expand Down Expand Up @@ -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<typeof DeleteFormComponent>);
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();
}));
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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<boolean> = new Subject<boolean>();
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 });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,27 @@ 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<TestRunService>;
let mockFocusManagerService: SpyObj<FocusManagerService>;

beforeEach(() => {
mockService = jasmine.createSpyObj(['fetchProfiles']);
mockService = jasmine.createSpyObj(['fetchProfiles', 'deleteProfile']);
mockFocusManagerService = jasmine.createSpyObj([
'focusFirstElementInContainer',
]);

TestBed.configureTestingModule({
imports: [NoopAnimationsModule],
providers: [
RiskAssessmentStore,
provideMockStore({}),
{ provide: TestRunService, useValue: mockService },
{ provide: FocusManagerService, useValue: mockFocusManagerService },
],
});

Expand Down Expand Up @@ -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();
});
});
});
});
46 changes: 44 additions & 2 deletions modules/ui/src/app/pages/risk-assessment/risk-assessment.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -48,7 +49,48 @@ export class RiskAssessmentStore extends ComponentStore<AppComponentState> {
})
);
});
constructor(private testRunService: TestRunService) {

deleteProfile = this.effect<string>(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: [],
});
Expand Down
Loading