Skip to content
7 changes: 7 additions & 0 deletions src/main/frontend/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@

@for (detail of vulnerabilityDetails; track detail.vulnerability.cveId) {
<app-cve-overview-item
[id]="'cve-item-' + detail.vulnerability.cveId"
[detail]="detail"
[isSelected]="detail.vulnerability.cveId === selectedId"
[branchMaintainedMap]="branchMaintainedMap"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
<a
class="manage-impact-btn"
title="Edit the Frank!Framework impact for this CVE"
[routerLink]="['/vulnerabilities/manage']"
[queryParams]="{ cve: vulnerabilityDetail.vulnerability.cveId }"
[routerLink]="['/vulnerabilities/manage', vulnerabilityDetail.vulnerability.cveId]"
>
<span class="manage-icon" aria-hidden="true"></span>
<span>Manage Impact</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<void>();
private readonly searchSubject = new Subject<string>();
private readonly branchStartDates = signal<Map<string, Date>>(new Map());

private isBulkLoading = false;
private pendingCveId: string | null = null;

private static emptyPage(page: number): VulnerabilityPage {
return {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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);
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -68,7 +67,7 @@ describe('BusinessValueManageComponent', () => {
let mockBusinessValueService: jasmine.SpyObj<BusinessValueService>;
let mockIssueService: jasmine.SpyObj<IssueService>;
let mockReleaseService: jasmine.SpyObj<ReleaseService>;
let mockLocation: jasmine.SpyObj<Location>;
let mockRouter: jasmine.SpyObj<Router>;

beforeEach(async () => {
mockBusinessValueService = jasmine.createSpyObj('BusinessValueService', [
Expand All @@ -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));
Expand All @@ -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(),
Expand Down Expand Up @@ -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']);
});
});
});
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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',
Expand Down Expand Up @@ -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<Set<string>>(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 {
Expand Down Expand Up @@ -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();
Expand All @@ -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));
Expand Down Expand Up @@ -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 ?? []);
Expand All @@ -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));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ <h3>General Management</h3>
<p class="section-group-subtitle">Applies to all releases</p>
</div>
<div class="management-sections">
<a class="section-card vulnerabilities-card" [routerLink]="vulnerabilitiesLink()">
<a
class="section-card vulnerabilities-card"
[routerLink]="vulnerabilitiesLink()"
[queryParams]="vulnerabilitiesLinkQueryParams()"
>
<div class="card-icon-wrapper">
<svg
class="card-icon"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { BusinessValue, BusinessValueService } from '../../services/business-val
styleUrl: './release-manage.component.scss',
})
export class ReleaseManageComponent implements OnInit {
public static readonly releaseManagePath = '/release-manage';

public authService = inject(AuthService);
public release = signal<Release | null>(null);
public releaseIssues = signal<Issue[] | null>([]);
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ describe('VulnerabilityImpactManageComponent', () => {
'deleteVulnerabilityImpact'
]);
const releaseServiceSpy = jasmine.createSpyObj('ReleaseService', ['getAllReleases']);
const locationSpy = jasmine.createSpyObj('Location', ['back']);
const locationSpy = jasmine.createSpyObj('Location', ['back', 'go']);
queryParameterGet = jasmine.createSpy('get').and.returnValue(null);
const activatedRouteStub = { snapshot: { queryParamMap: { get: queryParameterGet } } };
const activatedRouteStub = { snapshot: { paramMap: { get: queryParameterGet } } };

await TestBed.configureTestingModule({
imports: [VulnerabilityImpactManageComponent],
Expand Down Expand Up @@ -207,24 +207,24 @@ 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();

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();
Expand Down
Loading
Loading