diff --git a/webui/src/app/shared-network-tab/shared-network-tab.component.html b/webui/src/app/shared-network-tab/shared-network-tab.component.html index be717fbd..c9987885 100644 --- a/webui/src/app/shared-network-tab/shared-network-tab.component.html +++ b/webui/src/app/shared-network-tab/shared-network-tab.component.html @@ -1,3 +1,4 @@ +
@@ -129,5 +130,14 @@ class="p-button-info ml-2" (click)="onSharedNetworkEditBegin()" > +
diff --git a/webui/src/app/shared-network-tab/shared-network-tab.component.spec.ts b/webui/src/app/shared-network-tab/shared-network-tab.component.spec.ts index 75039813..7c08dd4b 100644 --- a/webui/src/app/shared-network-tab/shared-network-tab.component.spec.ts +++ b/webui/src/app/shared-network-tab/shared-network-tab.component.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing' +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing' import { SharedNetworkTabComponent } from './shared-network-tab.component' import { FieldsetModule } from 'primeng/fieldset' @@ -13,7 +13,6 @@ import { CheckboxModule } from 'primeng/checkbox' import { FormsModule } from '@angular/forms' import { NoopAnimationsModule } from '@angular/platform-browser/animations' import { OverlayPanelModule } from 'primeng/overlaypanel' -import { RouterTestingModule } from '@angular/router/testing' import { TableModule } from 'primeng/table' import { TagModule } from 'primeng/tag' import { TooltipModule } from 'primeng/tooltip' @@ -29,10 +28,20 @@ import { PlaceholderPipe } from '../pipes/placeholder.pipe' import { SubnetBarComponent } from '../subnet-bar/subnet-bar.component' import { IPType } from '../iptype' import { By } from '@angular/platform-browser' +import { ConfirmDialogModule } from 'primeng/confirmdialog' +import { RouterModule } from '@angular/router' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { ConfirmationService, MessageService } from 'primeng/api' +import { of, throwError } from 'rxjs' +import { DHCPService } from '../backend' +import { HttpErrorResponse } from '@angular/common/http' describe('SharedNetworkTabComponent', () => { let component: SharedNetworkTabComponent let fixture: ComponentFixture + let dhcpApi: DHCPService + let msgService: MessageService + let confirmService: ConfirmationService beforeEach(async () => { await TestBed.configureTestingModule({ @@ -56,21 +65,27 @@ describe('SharedNetworkTabComponent', () => { ButtonModule, ChartModule, CheckboxModule, + ConfirmDialogModule, DividerModule, FieldsetModule, FormsModule, + HttpClientTestingModule, NoopAnimationsModule, OverlayPanelModule, - RouterTestingModule, + RouterModule.forRoot([{ path: 'dhcp/shared-networks/:id', component: SharedNetworkTabComponent }]), TableModule, TagModule, TooltipModule, TreeModule, ], + providers: [ConfirmationService, MessageService], }).compileComponents() fixture = TestBed.createComponent(SharedNetworkTabComponent) component = fixture.componentInstance + dhcpApi = fixture.debugElement.injector.get(DHCPService) + confirmService = fixture.debugElement.injector.get(ConfirmationService) + msgService = fixture.debugElement.injector.get(MessageService) fixture.detectChanges() }) @@ -515,4 +530,75 @@ describe('SharedNetworkTabComponent', () => { expect(fieldsets[5].nativeElement.innerText).toContain('DHCP Options') expect(fieldsets[5].nativeElement.innerText).toContain('No options configured.') }) + + it('should display shared network delete button', () => { + component.sharedNetwork = { + name: 'foo', + universe: IPType.IPv6, + addrUtilization: 30, + pdUtilization: 60, + pools: [ + { + pool: '2001:db8:1::2-2001:db8:1::786', + }, + ], + localSharedNetworks: [ + { + appId: 1, + appName: 'foo@192.0.2.1', + keaConfigSharedNetworkParameters: { + sharedNetworkLevelParameters: {}, + }, + }, + ], + } + fixture.detectChanges() + const deleteBtn = fixture.debugElement.query(By.css('[label=Delete]')) + expect(deleteBtn).toBeTruthy() + + // Simulate clicking on the button and make sure that the confirm dialog + // has been displayed. + spyOn(confirmService, 'confirm') + deleteBtn.nativeElement.click() + expect(confirmService.confirm).toHaveBeenCalled() + }) + + it('should emit an event indicating successful shared network deletion', fakeAsync(() => { + const successResp: any = {} + spyOn(dhcpApi, 'deleteSharedNetwork').and.returnValue(of(successResp)) + spyOn(msgService, 'add') + spyOn(component.sharedNetworkDelete, 'emit') + + // Delete the subnet. + component.sharedNetwork = { + id: 1, + } + component.deleteSharedNetwork() + tick() + // Success message should be displayed. + expect(msgService.add).toHaveBeenCalled() + // An event should be called. + expect(component.sharedNetworkDelete.emit).toHaveBeenCalledWith(component.sharedNetwork) + // This flag should be cleared. + expect(component.sharedNetworkDeleting).toBeFalse() + })) + + it('should not emit an event when shared network deletion fails', fakeAsync(() => { + spyOn(dhcpApi, 'deleteSharedNetwork').and.returnValue(throwError(() => new HttpErrorResponse({ status: 404 }))) + spyOn(msgService, 'add') + spyOn(component.sharedNetworkDelete, 'emit') + + // Delete the host and receive an error. + component.sharedNetwork = { + id: 1, + } + component.deleteSharedNetwork() + tick() + // Error message should be displayed. + expect(msgService.add).toHaveBeenCalled() + // The event shouldn't be emitted on error. + expect(component.sharedNetworkDelete.emit).not.toHaveBeenCalledWith(component.sharedNetwork) + // This flag should be cleared. + expect(component.sharedNetworkDeleting).toBeFalse() + })) }) diff --git a/webui/src/app/shared-network-tab/shared-network-tab.component.stories.ts b/webui/src/app/shared-network-tab/shared-network-tab.component.stories.ts index 8093774c..02d0cc13 100644 --- a/webui/src/app/shared-network-tab/shared-network-tab.component.stories.ts +++ b/webui/src/app/shared-network-tab/shared-network-tab.component.stories.ts @@ -10,11 +10,10 @@ import { LocalNumberPipe } from '../pipes/local-number.pipe' import { FieldsetModule } from 'primeng/fieldset' import { DividerModule } from 'primeng/divider' import { TableModule } from 'primeng/table' -import { NoopAnimationsModule } from '@angular/platform-browser/animations' +import { provideNoopAnimations } from '@angular/platform-browser/animations' import { UtilizationStatsChartComponent } from '../utilization-stats-chart/utilization-stats-chart.component' import { EntityLinkComponent } from '../entity-link/entity-link.component' import { AddressPoolBarComponent } from '../address-pool-bar/address-pool-bar.component' -import { RouterTestingModule } from '@angular/router/testing' import { DelegatedPrefixBarComponent } from '../delegated-prefix-bar/delegated-prefix-bar.component' import { UtilizationStatsChartsComponent } from '../utilization-stats-charts/utilization-stats-charts.component' import { CascadedParametersBoardComponent } from '../cascaded-parameters-board/cascaded-parameters-board.component' @@ -27,27 +26,46 @@ import { FormsModule } from '@angular/forms' import { IPType } from '../iptype' import { PlaceholderPipe } from '../pipes/placeholder.pipe' import { SubnetBarComponent } from '../subnet-bar/subnet-bar.component' +import { importProvidersFrom } from '@angular/core' +import { HttpClientModule } from '@angular/common/http' +import { ConfirmationService, MessageService } from 'primeng/api' +import { ConfirmDialogModule } from 'primeng/confirmdialog' +import { toastDecorator } from '../utils-stories' +import { MessageModule } from 'primeng/message' +import { ToastModule } from 'primeng/toast' +import { RouterModule, provideRouter } from '@angular/router' export default { title: 'App/SharedNetworkTab', component: SharedNetworkTabComponent, decorators: [ applicationConfig({ - providers: [], + providers: [ + ConfirmationService, + importProvidersFrom(HttpClientModule), + MessageService, + provideNoopAnimations(), + provideRouter([ + { path: 'dhcp/shared-networks/:id', component: SharedNetworkTabComponent }, + { path: 'iframe.html', component: SharedNetworkTabComponent }, + ]), + ], }), moduleMetadata({ imports: [ ButtonModule, ChartModule, CheckboxModule, + ConfirmDialogModule, DividerModule, FieldsetModule, FormsModule, - NoopAnimationsModule, + MessageModule, OverlayPanelModule, - RouterTestingModule, + RouterModule, TableModule, TagModule, + ToastModule, TooltipModule, TreeModule, ], @@ -67,6 +85,7 @@ export default { UtilizationStatsChartsComponent, ], }), + toastDecorator, ], } as Meta @@ -75,6 +94,7 @@ type Story = StoryObj export const SharedNetwork4: Story = { args: { sharedNetwork: { + id: 1, name: 'foo', addrUtilization: 30, pools: [ @@ -334,6 +354,7 @@ export const SharedNetwork4: Story = { export const SharedNetwork6: Story = { args: { sharedNetwork: { + id: 2, name: 'foo', universe: IPType.IPv6, addrUtilization: 30, diff --git a/webui/src/app/shared-network-tab/shared-network-tab.component.ts b/webui/src/app/shared-network-tab/shared-network-tab.component.ts index 57d1b009..7fad3e90 100644 --- a/webui/src/app/shared-network-tab/shared-network-tab.component.ts +++ b/webui/src/app/shared-network-tab/shared-network-tab.component.ts @@ -1,7 +1,10 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' import { SharedNetworkWithUniquePools, hasDifferentLocalSharedNetworkOptions } from '../subnets' import { NamedCascadedParameters } from '../cascaded-parameters-board/cascaded-parameters-board.component' -import { DHCPOption, KeaConfigSubnetDerivedParameters } from '../backend' +import { DHCPOption, DHCPService, KeaConfigSubnetDerivedParameters, SharedNetwork } from '../backend' +import { ConfirmationService, MessageService } from 'primeng/api' +import { lastValueFrom } from 'rxjs' +import { getErrorMessage } from '../utils' @Component({ selector: 'app-shared-network-tab', @@ -20,6 +23,12 @@ export class SharedNetworkTabComponent implements OnInit { */ @Output() sharedNetworkEditBegin = new EventEmitter() + /** + * An event emitter notifying a parent that user has clicked the + * Delete button to delete the shared network. + */ + @Output() sharedNetworkDelete = new EventEmitter() + /** * DHCP parameters structured for display by the @link CascadedParametersBoard. * @@ -35,6 +44,26 @@ export class SharedNetworkTabComponent implements OnInit { */ dhcpOptions: DHCPOption[][][] = [] + /** + * Disables the button deleting a shared network after clicking this button. + */ + sharedNetworkDeleting = false + + /** + * Component constructor. + * + * @param dhcpApi service used to communicate with the server over REST API. + * @param confirmService confirmation service displaying the confirm dialog when + * attempting to delete the shared network. + * @param msgService service displaying error messages upon a communication + * error with the server. + */ + constructor( + private dhcpApi: DHCPService, + private confirmService: ConfirmationService, + private msgService: MessageService + ) {} + /** * A component lifecycle hook invoked upon the component initialization. * @@ -80,4 +109,51 @@ export class SharedNetworkTabComponent implements OnInit { onSharedNetworkEditBegin(): void { this.sharedNetworkEditBegin.emit(this.sharedNetwork) } + + /** + * Displays a dialog to confirm shared network deletion. + */ + confirmDeleteSharedNetwork() { + this.confirmService.confirm({ + message: 'Are you sure that you want to permanently delete this shared network and its subnets?', + header: 'Delete Shared Network', + icon: 'pi pi-exclamation-triangle', + accept: () => { + this.deleteSharedNetwork() + }, + }) + } + + /** + * Sends a request to the server to delete the shared network. + */ + deleteSharedNetwork() { + // Disable the button for deleting the shared network to prevent pressing the + // button multiple times and sending multiple requests. + this.sharedNetworkDeleting = true + lastValueFrom(this.dhcpApi.deleteSharedNetwork(this.sharedNetwork.id)) + .then((/* data */) => { + this.msgService.add({ + severity: 'success', + summary: `Shared network ${this.sharedNetwork.name} successfully deleted`, + }) + // Notify the parent that the shared network was deleted and the tab can be closed. + this.sharedNetworkDelete.emit(this.sharedNetwork) + }) + .catch((err) => { + // Re-enable the delete button. + // Issues with deleting the host. + const msg = getErrorMessage(err) + this.msgService.add({ + severity: 'error', + summary: 'Cannot delete the shared network', + detail: `Failed to delete the shared network ${this.sharedNetwork.name} : ${msg}`, + life: 10000, + }) + }) + .finally(() => { + // Re-enable the delete button. + this.sharedNetworkDeleting = false + }) + } } diff --git a/webui/src/app/shared-networks-page/shared-networks-page.component.html b/webui/src/app/shared-networks-page/shared-networks-page.component.html index 28fcf9bc..1e677a2c 100644 --- a/webui/src/app/shared-networks-page/shared-networks-page.component.html +++ b/webui/src/app/shared-networks-page/shared-networks-page.component.html @@ -205,6 +205,7 @@ *ngSwitchCase="SharedNetworkTabType.Display" [sharedNetwork]="openedTabs[activeTabIndex].tabSubject" (sharedNetworkEditBegin)="onSharedNetworkEditBegin(openedTabs[activeTabIndex].tabSubject)" + (sharedNetworkDelete)="onSharedNetworkDelete(openedTabs[activeTabIndex].tabSubject)" > { let component: SharedNetworksPageComponent @@ -61,6 +61,7 @@ describe('SharedNetworksPageComponent', () => { ChartModule, CheckboxModule, ChipsModule, + ConfirmDialogModule, DividerModule, DropdownModule, FieldsetModule, @@ -72,7 +73,7 @@ describe('SharedNetworksPageComponent', () => { OverlayPanelModule, ProgressSpinnerModule, ReactiveFormsModule, - RouterTestingModule.withRoutes([ + RouterModule.forRoot([ { path: 'dhcp/shared-networks', pathMatch: 'full', @@ -110,6 +111,7 @@ describe('SharedNetworksPageComponent', () => { UtilizationStatsChartsComponent, ], providers: [ + ConfirmationService, DHCPService, MessageService, { @@ -120,7 +122,6 @@ describe('SharedNetworksPageComponent', () => { paramMap: of(new MockParamMap()), }, }, - RouterTestingModule, ], }) @@ -632,4 +633,26 @@ describe('SharedNetworksPageComponent', () => { expect(dhcpService.updateSharedNetworkDelete).toHaveBeenCalled() })) + + it('should close subnet tab when subnet is deleted', fakeAsync(() => { + component.loadNetworks({}) + tick() + fixture.detectChanges() + + // Open subnet tab. + component.openTabBySharedNetworkId(1) + fixture.detectChanges() + tick() + expect(component.openedTabs.length).toBe(2) + + // Simulate the notification that the shared network has been deleted. + component.onSharedNetworkDelete({ + id: 1, + }) + fixture.detectChanges() + tick() + + // The main shared network tab should only be left. + expect(component.openedTabs.length).toBe(1) + })) }) diff --git a/webui/src/app/shared-networks-page/shared-networks-page.component.ts b/webui/src/app/shared-networks-page/shared-networks-page.component.ts index a92c09a9..322e5b98 100644 --- a/webui/src/app/shared-networks-page/shared-networks-page.component.ts +++ b/webui/src/app/shared-networks-page/shared-networks-page.component.ts @@ -235,7 +235,7 @@ export class SharedNetworksPageComponent implements OnInit, OnDestroy { ) ) .then((data) => { - this.networks = data.items + this.networks = data.items || [] this.totalNetworks = data.total ?? 0 }) .catch((error) => { @@ -624,4 +624,19 @@ export class SharedNetworksPageComponent implements OnInit, OnDestroy { tab.state = event } } + + /** + * Event handler triggered when a shared network was deleted using a delete + * button on one of the tabs. + * + * @param sharedNetwork pointer to the deleted shared network. + */ + onSharedNetworkDelete(sharedNetwork: SharedNetwork): void { + // Try to find a suitable tab by shared network id. + const index = this.openedTabs.findIndex((t) => t.tabSubject && t.tabSubject.id === sharedNetwork.id) + if (index >= 0) { + // Close the tab. + this.closeTabByIndex(index) + } + } } diff --git a/webui/src/app/subnet-tab/subnet-tab.component.spec.ts b/webui/src/app/subnet-tab/subnet-tab.component.spec.ts index 6d16a519..824bec15 100644 --- a/webui/src/app/subnet-tab/subnet-tab.component.spec.ts +++ b/webui/src/app/subnet-tab/subnet-tab.component.spec.ts @@ -14,7 +14,6 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations' import { UtilizationStatsChartComponent } from '../utilization-stats-chart/utilization-stats-chart.component' import { EntityLinkComponent } from '../entity-link/entity-link.component' import { AddressPoolBarComponent } from '../address-pool-bar/address-pool-bar.component' -import { RouterTestingModule } from '@angular/router/testing' import { DelegatedPrefixBarComponent } from '../delegated-prefix-bar/delegated-prefix-bar.component' import { SubnetTabComponent } from './subnet-tab.component' import { By } from '@angular/platform-browser' @@ -33,6 +32,7 @@ import { ConfirmationService, MessageService } from 'primeng/api' import { DHCPService } from '../backend' import { HttpClientTestingModule } from '@angular/common/http/testing' import { of, throwError } from 'rxjs' +import { RouterModule } from '@angular/router' describe('SubnetTabComponent', () => { let component: SubnetTabComponent @@ -55,7 +55,7 @@ describe('SubnetTabComponent', () => { HttpClientTestingModule, NoopAnimationsModule, OverlayPanelModule, - RouterTestingModule, + RouterModule.forRoot([{ path: 'dhcp/subnets/:id', component: SubnetTabComponent }]), TableModule, TagModule, ToastModule, @@ -574,7 +574,7 @@ describe('SubnetTabComponent', () => { expect(component.subnetDeleting).toBeFalse() })) - it('should not emit an event when host deletion fails', fakeAsync(() => { + it('should not emit an event when subnet deletion fails', fakeAsync(() => { spyOn(dhcpApi, 'deleteSubnet').and.returnValue(throwError({ status: 404 })) spyOn(msgService, 'add') spyOn(component.subnetDelete, 'emit') diff --git a/webui/src/app/subnet-tab/subnet-tab.component.stories.ts b/webui/src/app/subnet-tab/subnet-tab.component.stories.ts index 418dbff4..5b8e6f68 100644 --- a/webui/src/app/subnet-tab/subnet-tab.component.stories.ts +++ b/webui/src/app/subnet-tab/subnet-tab.component.stories.ts @@ -10,11 +10,10 @@ import { LocalNumberPipe } from '../pipes/local-number.pipe' import { FieldsetModule } from 'primeng/fieldset' import { DividerModule } from 'primeng/divider' import { TableModule } from 'primeng/table' -import { NoopAnimationsModule } from '@angular/platform-browser/animations' +import { provideNoopAnimations } from '@angular/platform-browser/animations' import { UtilizationStatsChartComponent } from '../utilization-stats-chart/utilization-stats-chart.component' import { EntityLinkComponent } from '../entity-link/entity-link.component' import { AddressPoolBarComponent } from '../address-pool-bar/address-pool-bar.component' -import { RouterTestingModule } from '@angular/router/testing' import { DelegatedPrefixBarComponent } from '../delegated-prefix-bar/delegated-prefix-bar.component' import { UtilizationStatsChartsComponent } from '../utilization-stats-charts/utilization-stats-charts.component' import { CascadedParametersBoardComponent } from '../cascaded-parameters-board/cascaded-parameters-board.component' @@ -32,13 +31,23 @@ import { importProvidersFrom } from '@angular/core' import { HttpClientModule } from '@angular/common/http' import { ToastModule } from 'primeng/toast' import { ConfirmDialogModule } from 'primeng/confirmdialog' +import { RouterModule, provideRouter } from '@angular/router' export default { title: 'App/SubnetTab', component: SubnetTabComponent, decorators: [ applicationConfig({ - providers: [importProvidersFrom(HttpClientModule)], + providers: [ + ConfirmationService, + importProvidersFrom(HttpClientModule), + MessageService, + provideNoopAnimations(), + provideRouter([ + { path: 'dhcp/subnets/:id', component: SubnetTabComponent }, + { path: 'iframe.html', component: SubnetTabComponent }, + ]), + ], }), moduleMetadata({ imports: [ @@ -49,9 +58,8 @@ export default { DividerModule, FieldsetModule, FormsModule, - NoopAnimationsModule, OverlayPanelModule, - RouterTestingModule, + RouterModule, TableModule, TagModule, ToastModule, @@ -72,7 +80,6 @@ export default { UtilizationStatsChartComponent, UtilizationStatsChartsComponent, ], - providers: [ConfirmationService, MessageService], }), toastDecorator, ],