diff --git a/src/main/frontend/src/app/app.routes.ts b/src/main/frontend/src/app/app.routes.ts index 3813e21f..3dcf6b75 100644 --- a/src/main/frontend/src/app/app.routes.ts +++ b/src/main/frontend/src/app/app.routes.ts @@ -20,17 +20,24 @@ export const routes: Routes = [ }, { path: 'roadmap', component: ReleaseRoadmapComponent }, { path: 'cve-overview', component: CveOverviewComponent }, + { path: 'cve-overview/:cveId', component: CveOverviewComponent }, { path: 'vulnerabilities/manage', component: VulnerabilityImpactManageComponent, canActivate: [FrankFrameworkMemberGuard], }, + { + path: 'vulnerabilities/manage/:cveId', + component: VulnerabilityImpactManageComponent, + canActivate: [FrankFrameworkMemberGuard], + }, { path: 'release-manage/:id', canActivate: [FrankFrameworkMemberGuard], children: [ { path: '', component: ReleaseManageComponent }, { path: 'business-values', component: BusinessValueManageComponent }, + { path: 'business-values/:businessValueId', component: BusinessValueManageComponent }, ], }, { path: 'not-found', component: NotFoundComponent }, diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview-left-panel/cve-overview-left-panel.component.html b/src/main/frontend/src/app/pages/cve-overview/cve-overview-left-panel/cve-overview-left-panel.component.html index 7195fdd8..2403210f 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview-left-panel/cve-overview-left-panel.component.html +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview-left-panel/cve-overview-left-panel.component.html @@ -56,6 +56,7 @@ @for (detail of vulnerabilityDetails; track detail.vulnerability.cveId) { Manage Impact diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.spec.ts b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.spec.ts index 0bf30f66..946aa80d 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.spec.ts +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview-right-panel/cve-overview-right-panel.component.spec.ts @@ -107,7 +107,7 @@ describe('CveOverviewRightPanelComponent', () => { expect(manageButton).toBeTruthy(); expect(manageButton.getAttribute('href')).toContain('/vulnerabilities/manage'); - expect(manageButton.getAttribute('href')).toContain('cve=CVE-2024-0001'); + expect(manageButton.getAttribute('href')).toContain('/CVE-2024-0001'); }); }); diff --git a/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.ts b/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.ts index a03fd757..1f0e4458 100644 --- a/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.ts +++ b/src/main/frontend/src/app/pages/cve-overview/cve-overview.component.ts @@ -1,5 +1,6 @@ import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { CommonModule, Location } from '@angular/common'; +import { ActivatedRoute } from '@angular/router'; import { Observable, Subject, catchError, debounceTime, distinctUntilChanged, finalize, of, takeUntil } from 'rxjs'; import { Vulnerability, @@ -94,11 +95,14 @@ export class CveOverviewComponent implements OnInit, OnDestroy { private readonly releaseService = inject(ReleaseService); private readonly authService = inject(AuthService); private readonly impactLabelPipe = inject(ImpactLabelPipe); + private readonly location = inject(Location); + private readonly route = inject(ActivatedRoute); private readonly destroy$ = new Subject(); private readonly searchSubject = new Subject(); private readonly branchStartDates = signal>(new Map()); private isBulkLoading = false; + private pendingCveId: string | null = null; private static emptyPage(page: number): VulnerabilityPage { return { @@ -175,8 +179,11 @@ export class CveOverviewComponent implements OnInit, OnDestroy { this.resetAndFetch(); }); + this.pendingCveId = this.route.snapshot.paramMap.get('cveId'); this.loadBranchStartDates(); - this.fetchPage(0); + this.fetchPage(0, () => { + if (this.pendingCveId) this.findAndSelectCve(this.pendingCveId); + }); } ngOnDestroy(): void { @@ -187,6 +194,7 @@ export class CveOverviewComponent implements OnInit, OnDestroy { public onSelectCve(vulnerability: Vulnerability): void { this.selectedCveId.set(vulnerability.cveId); this.showFilterPanel.set(false); + this.location.go(`/cve-overview/${vulnerability.cveId}`); } public onSearch(query: string): void { @@ -208,11 +216,16 @@ export class CveOverviewComponent implements OnInit, OnDestroy { this.showFilterPanel.update((isVisible) => !isVisible); } - public fetchPage(page: number): void { + public fetchPage(page: number, onComplete?: () => void): void { if (this.isLoading()) return; this.isLoading.set(true); this.requestPage(page) - .pipe(finalize(() => this.isLoading.set(false))) + .pipe( + finalize(() => { + this.isLoading.set(false); + onComplete?.(); + }), + ) .subscribe((data) => { this.allDetails.update((existing) => (page === 0 ? data.content : [...existing, ...data.content])); this.currentPage.set(data.number); @@ -234,6 +247,23 @@ export class CveOverviewComponent implements OnInit, OnDestroy { this.fetchPage(0); } + private findAndSelectCve(cveId: string): void { + const match = this.allDetails().find((detail) => detail.vulnerability.cveId === cveId); + + if (match) { + this.selectedCveId.set(cveId); + this.pendingCveId = null; + setTimeout(() => { + document.querySelector(`#cve-item-${cveId}`)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 100); + return; + } + + if (this.isLastPage()) return; + + this.fetchPage(this.currentPage() + 1, () => this.findAndSelectCve(cveId)); + } + private loadBranchStartDates(): void { this.releaseService .getAllReleases() diff --git a/src/main/frontend/src/app/pages/release-manage/business-value-manage/business-value-manage.component.spec.ts b/src/main/frontend/src/app/pages/release-manage/business-value-manage/business-value-manage.component.spec.ts index eb9abb20..1428bfd2 100644 --- a/src/main/frontend/src/app/pages/release-manage/business-value-manage/business-value-manage.component.spec.ts +++ b/src/main/frontend/src/app/pages/release-manage/business-value-manage/business-value-manage.component.spec.ts @@ -1,6 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Location } from '@angular/common'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { of, throwError } from 'rxjs'; import { Component, Input, Output, EventEmitter } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; @@ -68,7 +67,7 @@ describe('BusinessValueManageComponent', () => { let mockBusinessValueService: jasmine.SpyObj; let mockIssueService: jasmine.SpyObj; let mockReleaseService: jasmine.SpyObj; - let mockLocation: jasmine.SpyObj; + let mockRouter: jasmine.SpyObj; beforeEach(async () => { mockBusinessValueService = jasmine.createSpyObj('BusinessValueService', [ @@ -79,7 +78,7 @@ describe('BusinessValueManageComponent', () => { ]); mockIssueService = jasmine.createSpyObj('IssueService', ['getIssuesByReleaseId']); mockReleaseService = jasmine.createSpyObj('ReleaseService', ['getReleaseById', 'getAllReleases']); - mockLocation = jasmine.createSpyObj('Location', ['back']); + mockRouter = jasmine.createSpyObj('Router', ['navigate']); mockBusinessValueService.getBusinessValuesByReleaseId.and.returnValue(of(mockBusinessValues)); mockIssueService.getIssuesByReleaseId.and.returnValue(of(mockIssues)); @@ -92,11 +91,11 @@ describe('BusinessValueManageComponent', () => { { provide: BusinessValueService, useValue: mockBusinessValueService }, { provide: IssueService, useValue: mockIssueService }, { provide: ReleaseService, useValue: mockReleaseService }, - { provide: Location, useValue: mockLocation }, + { provide: Router, useValue: mockRouter }, { provide: ActivatedRoute, useValue: { - snapshot: { paramMap: { get: () => 'release-123' } }, + snapshot: { paramMap: { get: (key: string) => key === 'id' ? 'release-123' : null } }, }, }, provideHttpClient(), @@ -304,10 +303,10 @@ describe('BusinessValueManageComponent', () => { expect(component.showCreateForm()).toBeTrue(); }); - it('should go back using location service', () => { + it('should go back to parent location with router', () => { component.goBack(); - expect(mockLocation.back).toHaveBeenCalledWith(); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/release-manage', 'release-123']); }); }); }); diff --git a/src/main/frontend/src/app/pages/release-manage/business-value-manage/business-value-manage.component.ts b/src/main/frontend/src/app/pages/release-manage/business-value-manage/business-value-manage.component.ts index 83fe984a..2b824d21 100644 --- a/src/main/frontend/src/app/pages/release-manage/business-value-manage/business-value-manage.component.ts +++ b/src/main/frontend/src/app/pages/release-manage/business-value-manage/business-value-manage.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit, inject, signal, computed } from '@angular/core'; -import { CommonModule, Location } from '@angular/common'; -import { ActivatedRoute } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; import { BusinessValue, BusinessValueService } from '../../../services/business-value.service'; import { Issue, IssueService } from '../../../services/issue.service'; import { Release, ReleaseService } from '../../../services/release.service'; @@ -15,6 +15,7 @@ import { IssueWithSelection, } from './business-value-issue-panel/business-value-issue-panel.component'; import { LoaderComponent } from '../../../components/loader/loader.component'; +import { ReleaseManageComponent } from '../release-manage.component'; @Component({ selector: 'app-business-value-manage', @@ -56,23 +57,26 @@ export class BusinessValueManageComponent implements OnInit { public hasChanges = computed(() => this.hasIssueChanges()); private route = inject(ActivatedRoute); - private location = inject(Location); private businessValueService = inject(BusinessValueService); private issueService = inject(IssueService); private releaseService = inject(ReleaseService); + private router = inject(Router); private originalSelectedIssueIds = signal>(new Set()); + private pendingBusinessValueId: string | null = null; + private readonly businessValuesPath = 'business-values'; ngOnInit(): void { const releaseId = this.route.snapshot.paramMap.get('id'); if (releaseId) { this.releaseId.set(releaseId); + this.pendingBusinessValueId = this.route.snapshot.paramMap.get('businessValueId'); this.fetchData(releaseId); } } public goBack(): void { - this.location.back(); + this.router.navigate([ReleaseManageComponent.releaseManagePath, this.releaseId()]); } public toggleCreateForm(): void { @@ -126,6 +130,7 @@ export class BusinessValueManageComponent implements OnInit { if (this.selectedBusinessValue()?.id === deletedId) { this.selectedBusinessValue.set(null); this.resetIssueSelection(); + this.router.navigate([ReleaseManageComponent.releaseManagePath, this.releaseId(), this.businessValuesPath]); } this.closeDeleteModal(); @@ -149,8 +154,16 @@ export class BusinessValueManageComponent implements OnInit { if (this.selectedBusinessValue()?.id === businessValue.id) { this.selectedBusinessValue.set(null); this.resetIssueSelection(); + this.router.navigate([ReleaseManageComponent.releaseManagePath, this.releaseId(), this.businessValuesPath]); } else { this.selectedBusinessValue.set(businessValue); + this.router.navigate([ + ReleaseManageComponent.releaseManagePath, + this.releaseId(), + this.businessValuesPath, + businessValue.id, + ]); + this.businessValueService.getBusinessValueById(businessValue.id).subscribe({ next: (detailedBV) => { const updatedList = this.businessValues().map((bv) => (bv.id === detailedBV.id ? detailedBV : bv)); @@ -239,7 +252,12 @@ export class BusinessValueManageComponent implements OnInit { release: this.releaseService.getReleaseById(releaseId).pipe(catchError(() => of(null))), allReleases: this.releaseService.getAllReleases().pipe(catchError(() => of([]))), }) - .pipe(finalize(() => this.isLoading.set(false))) + .pipe( + finalize(() => { + this.isLoading.set(false); + this.applyDeepLinkedBusinessValue(); + }), + ) .subscribe(({ businessValues, issues, release, allReleases }) => { this.businessValues.set(businessValues); this.allIssues.set(issues ?? []); @@ -265,6 +283,17 @@ export class BusinessValueManageComponent implements OnInit { }); } + private applyDeepLinkedBusinessValue(): void { + if (!this.pendingBusinessValueId) return; + + const match = this.businessValues().find((businessValue) => businessValue.id === this.pendingBusinessValueId); + + if (match) { + this.pendingBusinessValueId = null; + this.selectBusinessValue(match); + } + } + private updateIssueSelection(currentBusinessValue: BusinessValue): void { const currentConnectedIds = new Set(currentBusinessValue.issues?.map((issue) => issue.id)); diff --git a/src/main/frontend/src/app/pages/release-manage/release-manage.component.html b/src/main/frontend/src/app/pages/release-manage/release-manage.component.html index a227eebc..3e49b8cb 100644 --- a/src/main/frontend/src/app/pages/release-manage/release-manage.component.html +++ b/src/main/frontend/src/app/pages/release-manage/release-manage.component.html @@ -80,7 +80,11 @@

General Management

Applies to all releases

- +
(null); public releaseIssues = signal([]); @@ -61,6 +63,10 @@ export class ReleaseManageComponent implements OnInit { return '/vulnerabilities/manage'; } + public vulnerabilitiesLinkQueryParams(): { releaseId: string | null } { + return { releaseId: this.release()?.id ?? null }; + } + public closeSection(): void { this.activeSection.set(null); } diff --git a/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-manage.component.spec.ts b/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-manage.component.spec.ts index 5f66c0bf..c0cc1694 100644 --- a/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-manage.component.spec.ts +++ b/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-manage.component.spec.ts @@ -2,7 +2,8 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testin import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { Location } from '@angular/common'; -import { ActivatedRoute } from '@angular/router'; +import { provideLocationMocks } from '@angular/common/testing'; +import { ActivatedRoute, provideRouter } from '@angular/router'; import { of, throwError } from 'rxjs'; import { VulnerabilityImpactManageComponent } from './vulnerability-impact-manage.component'; import { VulnerabilityService, Vulnerability, VulnerabilitySeverities, VulnerabilityPage } from '../../../services/vulnerability.service'; @@ -12,7 +13,7 @@ describe('VulnerabilityImpactManageComponent', () => { let component: VulnerabilityImpactManageComponent; let fixture: ComponentFixture; let vulnerabilityService: jasmine.SpyObj; - let location: jasmine.SpyObj; + let location: Location; let queryParameterGet: jasmine.Spy; const mockVulnerabilities: Vulnerability[] = [ @@ -60,16 +61,16 @@ describe('VulnerabilityImpactManageComponent', () => { 'deleteVulnerabilityImpact' ]); const releaseServiceSpy = jasmine.createSpyObj('ReleaseService', ['getAllReleases']); - const locationSpy = jasmine.createSpyObj('Location', ['back']); queryParameterGet = jasmine.createSpy('get').and.returnValue(null); - const activatedRouteStub = { snapshot: { queryParamMap: { get: queryParameterGet } } }; + const activatedRouteStub = { snapshot: { paramMap: { get: queryParameterGet } } }; await TestBed.configureTestingModule({ imports: [VulnerabilityImpactManageComponent], providers: [ + provideRouter([{ path: '**', component: VulnerabilityImpactManageComponent }]), + provideLocationMocks(), { provide: VulnerabilityService, useValue: vulnerabilityServiceSpy }, { provide: ReleaseService, useValue: releaseServiceSpy }, - { provide: Location, useValue: locationSpy }, { provide: ActivatedRoute, useValue: activatedRouteStub }, provideHttpClient(), provideHttpClientTesting() @@ -77,7 +78,9 @@ describe('VulnerabilityImpactManageComponent', () => { }).compileComponents(); vulnerabilityService = TestBed.inject(VulnerabilityService) as jasmine.SpyObj; - location = TestBed.inject(Location) as jasmine.SpyObj; + location = TestBed.inject(Location); + spyOn(location, 'back'); + const releaseService = TestBed.inject(ReleaseService) as jasmine.SpyObj; vulnerabilityService.getAllVulnerabilitiesPaginated.and.returnValue(of(mockPage)); @@ -207,16 +210,16 @@ describe('VulnerabilityImpactManageComponent', () => { expect(location.back).toHaveBeenCalledWith(); }); - it('should pre-select a vulnerability from the cve query parameter', () => { + it('should pre-select a vulnerability from the cve path parameter', () => { queryParameterGet.and.returnValue('CVE-2024-0002'); fixture.detectChanges(); - expect(queryParameterGet).toHaveBeenCalledWith('cve'); + expect(queryParameterGet).toHaveBeenCalledWith('cveId'); expect(component.selectedVulnerability()?.cveId).toBe('CVE-2024-0002'); }); - it('should ignore an unknown cve query parameter', () => { + it('should ignore an unknown cve path parameter', () => { queryParameterGet.and.returnValue('CVE-9999-9999'); fixture.detectChanges(); @@ -224,7 +227,7 @@ describe('VulnerabilityImpactManageComponent', () => { expect(component.selectedVulnerability()).toBeNull(); }); - it('should not pre-select a vulnerability when there is no cve query parameter', () => { + it('should not pre-select a vulnerability when there is no cve path parameter', () => { fixture.detectChanges(); expect(component.selectedVulnerability()).toBeNull(); @@ -246,4 +249,3 @@ describe('VulnerabilityImpactManageComponent', () => { expect(vulnerabilityService.deleteVulnerabilityImpact).not.toHaveBeenCalled(); }); }); - diff --git a/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-manage.component.ts b/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-manage.component.ts index 045fbb0f..6d37357c 100644 --- a/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-manage.component.ts +++ b/src/main/frontend/src/app/pages/release-manage/vulnerability-impact-manage/vulnerability-impact-manage.component.ts @@ -1,6 +1,6 @@ -import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core'; +import { Component, OnInit, OnDestroy, inject, input, signal, computed, InputSignal } from '@angular/core'; import { CommonModule, Location } from '@angular/common'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { Vulnerability, VulnerabilityDetail, @@ -16,6 +16,7 @@ import { ImpactLabelPipe } from '../../../pipes/impact-label.pipe'; import { sortVersionsAsc } from '../../../pipes/version-compare'; import { isBranchMaintained } from '../../../pipes/branch-lifecycle'; import { Release, ReleaseService } from '../../../services/release.service'; +import { ReleaseManageComponent } from '../release-manage.component'; @Component({ selector: 'app-vulnerability-impact-manage', @@ -28,8 +29,8 @@ import { Release, ReleaseService } from '../../../services/release.service'; export class VulnerabilityImpactManageComponent implements OnInit, OnDestroy { private static readonly PAGE_SIZE = 20; private static readonly SEARCH_DEBOUNCE_MS = 350; - private static readonly KEEP_LATEST_LTS_COUNT = 3; + private static readonly VULNERABILITY_MANAGE_PATH = '/vulnerabilities/manage'; public allDetails = signal([]); public selectedVulnerability = signal(null); @@ -41,6 +42,7 @@ export class VulnerabilityImpactManageComponent implements OnInit, OnDestroy { public vulnerabilitySearchQuery = signal(''); public showFilterPanel = signal(false); public filters = signal({ ...DEFAULT_CVE_FILTERS }); + public releaseId: InputSignal = input(null); public readonly branchMaintainedMap = computed>(() => { const startDates = this.branchStartDates(); @@ -133,6 +135,8 @@ export class VulnerabilityImpactManageComponent implements OnInit, OnDestroy { private location = inject(Location); private route = inject(ActivatedRoute); private impactLabelPipe = inject(ImpactLabelPipe); + private router = inject(Router); + private readonly destroy$ = new Subject(); private readonly searchSubject = new Subject(); private isLoadingAll = false; @@ -187,7 +191,12 @@ export class VulnerabilityImpactManageComponent implements OnInit, OnDestroy { } public goBack(): void { - this.location.back(); + const releaseId = this.releaseId(); + if (releaseId) { + this.router.navigate([ReleaseManageComponent.releaseManagePath, releaseId]); + } else { + this.location.back(); + } } public onSearchQueryChanged(query: string): void { @@ -218,8 +227,10 @@ export class VulnerabilityImpactManageComponent implements OnInit, OnDestroy { public onVulnerabilitySelected(vulnerability: Vulnerability): void { if (this.selectedVulnerability()?.cveId === vulnerability.cveId) { this.selectedVulnerability.set(null); + this.router.navigate([VulnerabilityImpactManageComponent.VULNERABILITY_MANAGE_PATH]); } else { this.selectedVulnerability.set(vulnerability); + this.router.navigate([VulnerabilityImpactManageComponent.VULNERABILITY_MANAGE_PATH, vulnerability.cveId]); } } @@ -362,7 +373,7 @@ export class VulnerabilityImpactManageComponent implements OnInit, OnDestroy { } private applyDeepLinkedCve(): void { - const cveId = this.route.snapshot.queryParamMap.get('cve'); + const cveId = this.route.snapshot.paramMap.get('cveId'); if (!cveId) return; this.findAndScrollToCve(cveId); }