From f7d8a743290e3e29b6a4f30334c485a0e2c7f1d5 Mon Sep 17 00:00:00 2001 From: kurilova Date: Wed, 5 Feb 2025 10:20:59 +0000 Subject: [PATCH] Adds copy/delete functionality for Risk Profiles --- modules/ui/src/app/mocks/profile.mock.ts | 28 ++++ .../profile-form.component.spec.ts | 3 +- .../profile-form/profile-form.component.ts | 6 +- .../profile-form/profile.validators.ts | 16 ++- .../profile-item/profile-item.component.scss | 6 - .../risk-assessment.component.html | 4 +- .../risk-assessment.component.spec.ts | 32 ++++- .../risk-assessment.component.ts | 120 ++++++++++-------- .../risk-assessment.store.spec.ts | 7 +- .../risk-assessment/risk-assessment.store.ts | 19 ++- modules/ui/src/styles.scss | 1 + 11 files changed, 163 insertions(+), 79 deletions(-) diff --git a/modules/ui/src/app/mocks/profile.mock.ts b/modules/ui/src/app/mocks/profile.mock.ts index 3082d39d2..b68d022aa 100644 --- a/modules/ui/src/app/mocks/profile.mock.ts +++ b/modules/ui/src/app/mocks/profile.mock.ts @@ -155,6 +155,34 @@ export const RENAME_PROFILE_MOCK = { export const COPY_PROFILE_MOCK: Profile = { name: 'Copy of Primary profile', status: ProfileStatus.VALID, + created: '2024-05-23 12:38:26', + questions: [ + { + question: 'What is the email of the device owner(s)?', + answer: 'boddey@google.com, cmeredith@google.com', + }, + { + question: 'What type of device do you need reviewed?', + answer: 'IoT Sensor', + }, + { + question: 'Are any of the following statements true about your device?', + answer: 'First', + }, + { + question: 'What features does the device have?', + answer: [0, 1, 2], + }, + { + question: 'Comments', + answer: 'Yes', + }, + ], +}; + +export const DRAFT_COPY_PROFILE_MOCK: Profile = { + name: 'Copy of Primary profile', + status: ProfileStatus.DRAFT, questions: [ { question: 'What is the email of the device owner(s)?', diff --git a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.spec.ts b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.spec.ts index 12ae7457e..48e24a425 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.spec.ts +++ b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.spec.ts @@ -19,6 +19,7 @@ import { ProfileFormComponent } from './profile-form.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { COPY_PROFILE_MOCK, + DRAFT_COPY_PROFILE_MOCK, NEW_PROFILE_MOCK, NEW_PROFILE_MOCK_DRAFT, OUTDATED_DRAFT_PROFILE_MOCK, @@ -312,7 +313,7 @@ describe('ProfileFormComponent', () => { }); it('should have an error when uses the name of copy profile', () => { - component.selectedProfile = COPY_PROFILE_MOCK; + component.selectedProfile = DRAFT_COPY_PROFILE_MOCK; component.profiles = [PROFILE_MOCK, PROFILE_MOCK_2, COPY_PROFILE_MOCK]; expect( diff --git a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.ts b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.ts index 9bea52671..114bd6e0f 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.ts +++ b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.ts @@ -41,7 +41,6 @@ import { ValidatorFn, } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; -import { DeviceValidators } from '../../devices/components/device-form/device.validators'; import { Profile, ProfileFormat, @@ -74,7 +73,6 @@ import { CdkTrapFocus } from '@angular/cdk/a11y'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProfileFormComponent implements OnInit, AfterViewInit { - private deviceValidators = inject(DeviceValidators); private profileValidators = inject(ProfileValidators); private fb = inject(FormBuilder); @@ -99,6 +97,9 @@ export class ProfileFormComponent implements OnInit, AfterViewInit { } @Input() set selectedProfile(profile: Profile | null) { + if (this.isCopyProfile && this.profile) { + this.deleteCopy.emit(this.profile); + } this.profile = profile; if (profile && this.nameControl) { this.updateNameValidator(); @@ -110,6 +111,7 @@ export class ProfileFormComponent implements OnInit, AfterViewInit { } @Output() saveProfile = new EventEmitter(); + @Output() deleteCopy = new EventEmitter(); @Output() discard = new EventEmitter(); ngOnInit() { this.profileForm = this.createProfileForm(); diff --git a/modules/ui/src/app/pages/risk-assessment/profile-form/profile.validators.ts b/modules/ui/src/app/pages/risk-assessment/profile-form/profile.validators.ts index d3847345d..2e742b39d 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-form/profile.validators.ts +++ b/modules/ui/src/app/pages/risk-assessment/profile-form/profile.validators.ts @@ -50,7 +50,11 @@ export class ProfileValidators { !profile.created || (profile.created && profile?.name.toLowerCase() !== value)) ) { - const isSameProfileName = this.hasSameProfileName(value, profiles); + const isSameProfileName = this.hasSameProfileName( + value, + profiles, + profile?.created + ); return isSameProfileName ? { has_same_profile_name: true } : null; } return null; @@ -98,11 +102,15 @@ export class ProfileValidators { private hasSameProfileName( profileName: string, - profiles: Profile[] + profiles: Profile[], + created?: string ): boolean { return ( - profiles.some(profile => profile.name.toLowerCase() === profileName) || - false + profiles.some( + profile => + profile.name.toLowerCase() === profileName && + profile.created !== created + ) || false ); } } diff --git a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.scss b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.scss index 62ff67697..a005792f6 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.scss +++ b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.scss @@ -20,12 +20,6 @@ $profile-draft-icon-size: 22px; $profile-icon-container-size: 24px; $profile-item-container-gap: 8px; -:host.selected { - .profile-item-container { - background-color: colors.$grey-100; - } -} - .profile-item-container { width: 100%; height: 100%; 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 516cdf9b1..1f908bf6b 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 @@ -25,7 +25,8 @@ [entities]="vm.profiles" [isOpenEntityForm]="isOpenProfileForm" [initialEntity]="vm.selectedProfile" - (addEntity)="openForm()"> + (addEntity)="openForm()" + (menuItemClicked)="menuItemClicked($event, vm.profiles)"> @@ -42,6 +43,7 @@ [profiles]="vm.profiles" [profileFormat]="vm.profileFormat" (saveProfile)="saveProfileClicked($event, vm.selectedProfile)" + (deleteCopy)="deleteCopy($event, vm.profiles)" (discard)="discard(vm.selectedProfile)"> 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 b123f1845..286a4e081 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 @@ -27,18 +27,19 @@ import { TestRunService } from '../../services/test-run.service'; import SpyObj = jasmine.SpyObj; import { MatSidenavModule } from '@angular/material/sidenav'; import { - COPY_PROFILE_MOCK, + DRAFT_COPY_PROFILE_MOCK, NEW_PROFILE_MOCK, NEW_PROFILE_MOCK_DRAFT, PROFILE_MOCK, } from '../../mocks/profile.mock'; -import { of } from 'rxjs'; +import { of, Subscription } from 'rxjs'; import { Component, Input } from '@angular/core'; import { Profile, ProfileAction, ProfileFormat } from '../../model/profile'; import { MatDialogRef } from '@angular/material/dialog'; import { SimpleDialogComponent } from '../../components/simple-dialog/simple-dialog.component'; import { RiskAssessmentStore } from './risk-assessment.store'; import { LiveAnnouncer } from '@angular/cdk/a11y'; +import { Observable } from 'rxjs/internal/Observable'; describe('RiskAssessmentComponent', () => { let component: RiskAssessmentComponent; @@ -65,6 +66,7 @@ describe('RiskAssessmentComponent', () => { 'setFocusOnCreateButton', 'setFocusOnSelectedProfile', 'setFocusOnProfileForm', + 'updateProfiles', ]); await TestBed.configureTestingModule({ @@ -187,7 +189,7 @@ describe('RiskAssessmentComponent', () => { } as MatDialogRef); tick(); - component.deleteProfile(PROFILE_MOCK.name, 0, null); + component.deleteProfile(PROFILE_MOCK); tick(); expect(openSpy).toHaveBeenCalledWith(SimpleDialogComponent, { @@ -207,9 +209,22 @@ describe('RiskAssessmentComponent', () => { spyOn(component.dialog, 'open').and.returnValue({ afterClosed: () => of(true), } as MatDialogRef); + + mockRiskAssessmentStore.deleteProfile.and.callFake( + ( + observableOrValue: + | { name: string; onDelete: (idx: number) => void } + | Observable<{ name: string; onDelete: (idx: number) => void }> + ) => { + // @ts-expect-error onDelete exist in object + observableOrValue?.onDelete(1); + return new Subscription(); + } + ); + tick(); - component.deleteProfile(PROFILE_MOCK.name, 0, PROFILE_MOCK); + component.deleteProfile(PROFILE_MOCK); tick(); expect( @@ -243,7 +258,7 @@ describe('RiskAssessmentComponent', () => { describe('#getCopyOfProfile', () => { it('should open the form with copy of profile', () => { const copy = component.getCopyOfProfile(PROFILE_MOCK); - expect(copy).toEqual(COPY_PROFILE_MOCK); + expect(copy).toEqual(DRAFT_COPY_PROFILE_MOCK); }); }); @@ -259,10 +274,13 @@ describe('RiskAssessmentComponent', () => { it('#copyProfileAndOpenForm should call openForm with copy of profile', fakeAsync(() => { spyOn(component, 'openForm'); - component.copyProfileAndOpenForm(PROFILE_MOCK); + component.copyProfileAndOpenForm(PROFILE_MOCK, [ + PROFILE_MOCK, + PROFILE_MOCK, + ]); tick(); - expect(component.openForm).toHaveBeenCalledWith(COPY_PROFILE_MOCK); + expect(component.openForm).toHaveBeenCalledWith(DRAFT_COPY_PROFILE_MOCK); })); describe('#saveProfile', () => { 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 45ca1424a..4eef3dcff 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 @@ -47,7 +47,7 @@ import { EmptyPageComponent } from '../../components/empty-page/empty-page.compo import { ListLayoutComponent } from '../../components/list-layout/list-layout.component'; import { LayoutType } from '../../model/layout-type'; import { NoEntitySelectedComponent } from '../../components/no-entity-selected/no-entity-selected.component'; -import { EntityAction } from '../../model/entity-action'; +import { EntityAction, EntityActionResult } from '../../model/entity-action'; const matFormFieldDefaultOptions: MatFormFieldDefaultOptions = { hideRequiredMarker: true, @@ -84,14 +84,14 @@ export class RiskAssessmentComponent implements OnInit, OnDestroy { readonly LayoutType = LayoutType; readonly ProfileStatus = ProfileStatus; private store = inject(RiskAssessmentStore); - dialog = inject(MatDialog); private liveAnnouncer = inject(LiveAnnouncer); + private destroy$: Subject = new Subject(); + dialog = inject(MatDialog); element = inject(ViewContainerRef); viewModel$ = this.store.viewModel$; isOpenProfileForm = false; isCopyProfile = false; - private destroy$: Subject = new Subject(); ngOnInit() { this.store.getProfilesFormat(); @@ -115,33 +115,24 @@ export class RiskAssessmentComponent implements OnInit, OnDestroy { this.store.setFocusOnProfileForm(); } - async copyProfileAndOpenForm(profile: Profile) { + async copyProfileAndOpenForm(profile: Profile, profiles: Profile[]) { this.isCopyProfile = true; - await this.openForm(this.getCopyOfProfile(profile)); + const copyOfProfile = this.getCopyOfProfile(profile); + this.store.updateProfiles([copyOfProfile, ...profiles]); + await this.openForm(copyOfProfile); } getCopyOfProfile(profile: Profile): Profile { const copyOfProfile = { ...profile }; copyOfProfile.name = this.getCopiedProfileName(profile.name); delete copyOfProfile.created; // new profile is not create yet + delete copyOfProfile.risk; + copyOfProfile.status = ProfileStatus.DRAFT; return copyOfProfile; } - private getCopiedProfileName(name: string): string { - name = `Copy of ${name}`; - if (name.length > DeviceValidators.STRING_FORMAT_MAX_LENGTH) { - name = - name.substring(0, DeviceValidators.STRING_FORMAT_MAX_LENGTH - 3) + - '...'; - } - return name; - } - - deleteProfile( - profileName: string, - index: number, - selectedProfile: Profile | null - ): void { + deleteProfile(profile: Profile): void { + const profileName = profile.name; const dialogRef = this.dialog.open(SimpleDialogComponent, { data: { title: 'Delete risk profile?', @@ -157,9 +148,13 @@ export class RiskAssessmentComponent implements OnInit, OnDestroy { .pipe(takeUntil(this.destroy$)) .subscribe(deleteProfile => { if (deleteProfile) { - this.store.deleteProfile(profileName); - this.closeFormAfterDelete(profileName, selectedProfile); - this.setFocus(index); + this.store.deleteProfile({ + name: profileName, + onDelete: (idx = 0) => { + this.closeFormAfterDelete(profileName, profile); + this.setFocus(idx); + }, + }); } else { this.store.setFocusOnSelectedProfile(); } @@ -189,6 +184,58 @@ export class RiskAssessmentComponent implements OnInit, OnDestroy { } } + discard(selectedProfile: Profile | null) { + this.liveAnnouncer.clear(); + this.isOpenProfileForm = false; + this.isCopyProfile = false; + if (selectedProfile) { + timer(100).subscribe(() => { + this.store.setFocusOnSelectedProfile(); + this.store.updateSelectedProfile(null); + }); + } else { + this.store.setFocusOnCreateButton(); + } + } + + deleteCopy(copyOfProfile: Profile, profiles: Profile[]) { + this.isCopyProfile = false; + this.store.removeProfile(copyOfProfile.name, profiles); + } + + actions(actions: EntityAction[]) { + return (profile: Profile) => { + if (profile.status === ProfileStatus.EXPIRED) { + return [{ action: ProfileAction.Delete, icon: 'delete' }]; + } + return actions; + }; + } + + menuItemClicked( + { action, entity }: EntityActionResult, + profiles: Profile[] + ) { + switch (action) { + case ProfileAction.Copy: + this.copyProfileAndOpenForm(entity, profiles); + break; + case ProfileAction.Delete: + this.deleteProfile(entity); + break; + } + } + + private getCopiedProfileName(name: string): string { + name = `Copy of ${name}`; + if (name.length > DeviceValidators.STRING_FORMAT_MAX_LENGTH) { + name = + name.substring(0, DeviceValidators.STRING_FORMAT_MAX_LENGTH - 3) + + '...'; + } + return name; + } + private compareProfiles(profile1: Profile, profile2: Profile) { if (profile1.name !== profile2.name) { return false; @@ -233,33 +280,6 @@ export class RiskAssessmentComponent implements OnInit, OnDestroy { return true; } - discard(selectedProfile: Profile | null) { - this.liveAnnouncer.clear(); - this.isOpenProfileForm = false; - this.isCopyProfile = false; - if (selectedProfile) { - timer(100).subscribe(() => { - this.store.setFocusOnSelectedProfile(); - this.store.updateSelectedProfile(null); - }); - } else { - this.store.setFocusOnCreateButton(); - } - } - - trackByName = (index: number, item: Profile): string => { - return item.name; - }; - - actions(actions: EntityAction[]) { - return (profile: Profile) => { - if (profile.status === ProfileStatus.EXPIRED) { - return [{ action: ProfileAction.Delete, icon: 'delete' }]; - } - return actions; - }; - } - private closeFormAfterDelete(name: string, selectedProfile: Profile | null) { if (selectedProfile?.name === name) { this.isOpenProfileForm = false; 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 545713064..29e2fddd6 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 @@ -120,7 +120,12 @@ describe('RiskAssessmentStore', () => { it('should dispatch setRiskProfiles', () => { mockService.deleteProfile.and.returnValue(of(true)); - riskAssessmentStore.deleteProfile(PROFILE_MOCK.name); + riskAssessmentStore.deleteProfile({ + name: PROFILE_MOCK.name, + onDelete: (idx: number) => { + return idx; + }, + }); expect(store.dispatch).toHaveBeenCalledWith( setRiskProfiles({ riskProfiles: [PROFILE_MOCK_2] }) 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 bdaf0f01c..f55ed231e 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 @@ -64,14 +64,19 @@ export class RiskAssessmentStore extends ComponentStore { }) ); - deleteProfile = this.effect(trigger$ => { + deleteProfile = this.effect<{ + name: string; + onDelete: (idx: number) => void; + }>(trigger$ => { return trigger$.pipe( - exhaustMap((name: string) => { + exhaustMap(({ name, onDelete }) => { return this.testRunService.deleteProfile(name).pipe( withLatestFrom(this.profiles$), tap(([remove, current]) => { if (remove) { + const idx = current.findIndex(item => name === item.name); this.removeProfile(name, current); + onDelete(idx); } }) ); @@ -171,13 +176,13 @@ export class RiskAssessmentStore extends ComponentStore { ); }); - private removeProfile(name: string, current: Profile[]): void { - const profiles = current.filter(profile => profile.name !== name); - this.updateProfiles(profiles); + updateProfiles(riskProfiles: Profile[]): void { + this.store.dispatch(setRiskProfiles({ riskProfiles })); } - private updateProfiles(riskProfiles: Profile[]): void { - this.store.dispatch(setRiskProfiles({ riskProfiles })); + removeProfile(name: string, current: Profile[]): void { + const profiles = current.filter(profile => profile.name !== name); + this.updateProfiles(profiles); } constructor() { diff --git a/modules/ui/src/styles.scss b/modules/ui/src/styles.scss index a6c6b2ae7..49caf7229 100644 --- a/modules/ui/src/styles.scss +++ b/modules/ui/src/styles.scss @@ -281,6 +281,7 @@ body { max-width: 100%; border-radius: 200px; padding: 0 7px; + white-space: nowrap; &.red { background: colors.$error-container; color: colors.$on-error-container;