]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Pool deletion enhancements 70643/head
authorAfreen Misbah <afreen@ibm.com>
Tue, 28 Jul 2026 20:49:27 +0000 (02:19 +0530)
committerAfreen Misbah <afreen@ibm.com>
Thu, 30 Jul 2026 16:18:01 +0000 (21:48 +0530)
Admin role:
- show confirmation modal that pool deletion is disabled
- allow enabling pool deletion
- fix pool deletion text

Pool mgr role
- show a modal that pool deletion is disabled

Signed-off-by: Afreen Misbah <afreen@ibm.com>
35 files changed:
src/pybind/mgr/dashboard/frontend/cypress/e2e/pools/pools.po.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi-target-list/iscsi-target-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-edit-authentication/nvmeof-edit-authentication.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.html [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.scss [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-list/nvmeof-listeners-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/logs/logs.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/smart-list/smart-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/delete-guard-modal/delete-guard-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/delete-guard-modal/delete-guard-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/delete-guard-modal/delete-guard-modal.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-loading.directive.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/delete-confirmation.model.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/delete-guard.model.ts [new file with mode: 0644]

index cd87b245d993edc3f71f3b08159ba4c20164c798..7c27649b34743afad2e733c821764e3268dad7c0 100644 (file)
@@ -64,6 +64,32 @@ export class PoolPageHelper extends PageHelper {
       .should('have.value', bpsLimit);
   }
 
+  private ensurePoolDeletionEnabled(name: string) {
+    this.clickRowActionButton(name, 'delete');
+    cy.get('cds-modal').then(($modal) => {
+      if ($modal.text().includes("Can't delete")) {
+        cy.get('cds-modal button').contains('Enable').click({ force: true });
+        cy.get('cds-modal').should('not.exist');
+      } else {
+        cy.get('cds-modal .cds--modal-close-button button').click({ force: true });
+        cy.get('cds-modal').should('not.exist');
+      }
+    });
+  }
+
+  delete(
+    name: string,
+    columnIndex?: number,
+    section?: string,
+    cdsModal = true,
+    isMultiselect = false,
+    shouldReload = false,
+    confirmInput = true
+  ) {
+    this.ensurePoolDeletionEnabled(name);
+    super.delete(name, columnIndex, section, cdsModal, isMultiselect, shouldReload, confirmInput);
+  }
+
   private setApplications(apps: string[]) {
     if (!apps || apps.length === 0) {
       return;
index 85048dc8d7efbf129b36bb9cecbeed7699b8fff2..07c3068df4686337ed9fb39aaebc2ab8b3c9fbf8 100644 (file)
@@ -109,7 +109,7 @@ import { NvmeSubsystemViewBreadcrumbResolver } from './nvme-subsystem-view/nvme-
 import { NvmeSubsystemViewComponent } from './nvme-subsystem-view/nvme-subsystem-view.component';
 import { NvmeofSubsystemPerformanceComponent } from './nvmeof-subsystem-performance/nvmeof-subsystem-performance.component';
 import { NvmeofTabsComponent } from './nvmeof-tabs/nvmeof-tabs.component';
-import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component';
+
 import { NvmeofSetupCardsComponent } from './nvmeof-setup-cards/nvmeof-setup-cards.component';
 import { NvmeofGatewayGroupFilterComponent } from './nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component';
 import { NvmeofEditAuthenticationComponent } from './nvmeof-edit-authentication/nvmeof-edit-authentication.component';
@@ -207,7 +207,6 @@ import { NvmeofEditAuthenticationComponent } from './nvmeof-edit-authentication/
     NvmeofSubsystemOverviewComponent,
     NvmeofSubsystemPerformanceComponent,
     NvmeofTabsComponent,
-    NvmeofGatewayGroupDeleteGuardModalComponent,
     NvmeofEditAuthenticationComponent
   ],
 
index 0be274520ec9432159532de50645c62a840bba53..ce8012fac530c2019a180c97840f937a2484a210 100644 (file)
@@ -3,8 +3,8 @@
 <cd-alert-panel
   type="info"
   *ngIf="available === false"
-  title="iSCSI Targets not available"
-  i18n-title
+  alertTitle="iSCSI Targets not available"
+  i18n-alertTitle
 >
   <ng-container i18n
     >Please consult the&nbsp;<cd-doc section="iscsi"></cd-doc>&nbsp;on how to configure and enable
index 791390cd59da5d7779d7e4551fa88cb3e2057554..a6cbce3aaa5576166be21129564dec9899ab4d14 100644 (file)
@@ -13,8 +13,8 @@
     @if (showAuthAlert) {
       <cd-alert-panel
         type="warning"
-        title="Subsystem DHCHAP Key will be deleted permanently"
-        i18n-title
+        alertTitle="Subsystem DHCHAP Key will be deleted permanently"
+        i18n-alertTitle
         class="cd-nvmeof-edit-auth-downgrade-alert"
       >
         <span i18n>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.html
deleted file mode 100644 (file)
index 8315142..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-<cds-modal
-  size="sm"
-  [open]="open"
-  (overlaySelected)="closeModal()"
->
-  <cds-modal-header (closeSelect)="closeModal()">
-    <h2
-      cdsModalHeaderLabel
-      modal-primary-focus
-      i18n
-    >
-      Manage {{ gatewayName }}
-    </h2>
-    <span
-      class="cds--type-heading-03"
-      cdsModalHeaderHeading
-      i18n
-      >Can't delete {{ gatewayName }}</span
-    >
-  </cds-modal-header>
-  <section cdsModalContent>
-    <p
-      class="cds--type-body-01 cds--mb-05"
-      i18n
-    >
-      This resource has connected items that must be deleted first. Delete the connected items, and
-      try again.
-    </p>
-    <p
-      class="cds--type-label-01 cds--mb-04"
-      i18n
-    >
-      View connected items:
-    </p>
-    <div>
-      @for (sub of connectedSubsystems; track sub.nqn) {
-        <div class="cds--mb-04">
-          <a
-            class="cds--link cds--type-body-01"
-            tabindex="0"
-            (click)="navigateToSubsystem(sub.nqn)"
-            (keydown.enter)="navigateToSubsystem(sub.nqn)"
-          >
-            <span class="cds--mr-03">{{ sub.nqn }}</span>
-            <cd-icon type="launch"></cd-icon>
-          </a>
-        </div>
-      }
-    </div>
-  </section>
-</cds-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.scss
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.spec.ts
deleted file mode 100644 (file)
index ff7ab90..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { Router } from '@angular/router';
-import { RouterTestingModule } from '@angular/router/testing';
-import { NO_ERRORS_SCHEMA } from '@angular/core';
-import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group-delete-guard-modal.component';
-
-describe('NvmeofGatewayGroupDeleteGuardModalComponent', () => {
-  let component: NvmeofGatewayGroupDeleteGuardModalComponent;
-  let fixture: ComponentFixture<NvmeofGatewayGroupDeleteGuardModalComponent>;
-  let router: Router;
-
-  beforeEach(async () => {
-    await TestBed.configureTestingModule({
-      imports: [RouterTestingModule],
-      declarations: [NvmeofGatewayGroupDeleteGuardModalComponent],
-      providers: [
-        { provide: 'gatewayName', useValue: 'gateway-dev' },
-        {
-          provide: 'connectedSubsystems',
-          useValue: [{ nqn: 'subsystem-1' }, { nqn: 'subsystem-2' }]
-        }
-      ],
-      schemas: [NO_ERRORS_SCHEMA]
-    }).compileComponents();
-  });
-
-  beforeEach(() => {
-    fixture = TestBed.createComponent(NvmeofGatewayGroupDeleteGuardModalComponent);
-    component = fixture.componentInstance;
-    router = TestBed.inject(Router);
-    jest.spyOn(router, 'navigate').mockImplementation();
-    fixture.detectChanges();
-  });
-
-  it('should create the modal component', () => {
-    expect(component).toBeTruthy();
-  });
-
-  it('should load dynamic inputs', () => {
-    expect(component.gatewayName).toBe('gateway-dev');
-    expect(component.connectedSubsystems).toEqual([{ nqn: 'subsystem-1' }, { nqn: 'subsystem-2' }]);
-  });
-
-  it('should navigate to subsystem detail page and close modal', () => {
-    const closeSpy = jest.spyOn(component, 'closeModal').mockImplementation();
-
-    component.navigateToSubsystem('subsystem-1');
-
-    expect(router.navigate).toHaveBeenCalledWith(
-      ['/block/nvmeof/subsystems', 'subsystem-1', 'overview'],
-      { queryParams: { group: 'gateway-dev' } }
-    );
-    expect(closeSpy).toHaveBeenCalled();
-  });
-});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component.ts
deleted file mode 100644 (file)
index 86cfd47..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Component, Inject, Optional } from '@angular/core';
-import { Router } from '@angular/router';
-import { BaseModal } from 'carbon-components-angular';
-
-interface ConnectedSubsystem {
-  nqn: string;
-}
-
-@Component({
-  selector: 'cd-nvmeof-gateway-group-delete-guard-modal',
-  templateUrl: './nvmeof-gateway-group-delete-guard-modal.component.html',
-  standalone: false
-})
-export class NvmeofGatewayGroupDeleteGuardModalComponent extends BaseModal {
-  constructor(
-    private router: Router,
-    @Optional() @Inject('gatewayName') public gatewayName: string,
-    @Optional() @Inject('connectedSubsystems') public connectedSubsystems: ConnectedSubsystem[] = []
-  ) {
-    super();
-  }
-
-  navigateToSubsystem(nqn: string): void {
-    this.router.navigate(['/block/nvmeof/subsystems', nqn, 'overview'], {
-      queryParams: { group: this.gatewayName }
-    });
-    this.closeModal();
-  }
-}
index 93055deeddfb5149fdc30f792129bb7fbc5b21d8..32e558713423ecad63ec5bd4303115743a522adc 100644 (file)
@@ -8,7 +8,7 @@ import { HttpClientModule } from '@angular/common/http';
 import { SharedModule } from '~/app/shared/shared.module';
 import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
 import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
-import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group-delete-guard-modal.component';
+import { DeleteGuardModalComponent } from '~/app/shared/components/delete-guard-modal/delete-guard-modal.component';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { NvmeofStateService } from '../nvmeof-state.service';
 
@@ -300,9 +300,21 @@ describe('NvmeofGatewayGroupComponent', () => {
       component.deleteGatewayGroupModal();
 
       expect(nvmeofService.listSubsystems).toHaveBeenCalledWith('default');
-      expect(modalService.show).toHaveBeenCalledWith(NvmeofGatewayGroupDeleteGuardModalComponent, {
-        gatewayName: 'default',
-        connectedSubsystems: [{ nqn: 'subsystem-1' }, { nqn: 'subsystem-2' }]
+      expect(modalService.show).toHaveBeenCalledWith(DeleteGuardModalComponent, {
+        resourceName: 'default',
+        resourceType: 'gateway group',
+        connectedItems: [
+          {
+            name: 'subsystem-1',
+            route: ['/block/nvmeof/subsystems', 'subsystem-1', 'overview'],
+            queryParams: { group: 'default' }
+          },
+          {
+            name: 'subsystem-2',
+            route: ['/block/nvmeof/subsystems', 'subsystem-2', 'overview'],
+            queryParams: { group: 'default' }
+          }
+        ]
       });
     });
 
index 0c436fa10d6c27827e4e468f7b2580108ffcadbe..69ad15e67128e889a7c6ef1b580dff364ad4aa46 100644 (file)
@@ -31,7 +31,7 @@ import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impa
 import { NotificationService } from '~/app/shared/services/notification.service';
 import { NotificationType } from '~/app/shared/enum/notification-type.enum';
 import { URLBuilderService } from '~/app/shared/services/url-builder.service';
-import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group-delete-guard-modal.component';
+import { DeleteGuardModalComponent } from '~/app/shared/components/delete-guard-modal/delete-guard-modal.component';
 import { NvmeofStateService } from '../nvmeof-state.service';
 
 const BASE_URL = 'block/nvmeof/gateways';
@@ -242,10 +242,13 @@ export class NvmeofGatewayGroupComponent implements OnInit, OnDestroy {
         }
 
         if (subsList.length > 0) {
-          this.modalService.show(NvmeofGatewayGroupDeleteGuardModalComponent, {
-            gatewayName: group,
-            connectedSubsystems: subsList.map((subsystem: NvmeofSubsystem) => ({
-              nqn: subsystem.nqn
+          this.modalService.show(DeleteGuardModalComponent, {
+            resourceName: group,
+            resourceType: $localize`gateway group`,
+            connectedItems: subsList.map((subsystem: NvmeofSubsystem) => ({
+              name: subsystem.nqn,
+              route: ['/block/nvmeof/subsystems', subsystem.nqn, 'overview'],
+              queryParams: { group }
             }))
           });
         } else {
@@ -261,9 +264,7 @@ export class NvmeofGatewayGroupComponent implements OnInit, OnDestroy {
       itemDescription: $localize`gateway group`,
       bodyTemplate: this.deleteTpl,
       itemNames: [selectedGroup.spec.group],
-      bodyContext: {
-        deletionMessage: $localize`Deleting <strong>${selectedGroup.spec.group}</strong> will remove all associated subsystems and may disrupt traffic routing for services relying on it. This action cannot be undone.`
-      },
+      hasAssociatedResources: true,
       submitActionObservable: () => {
         return this.taskWrapper
           .wrapTaskAroundCall({
index 976c8e3960082683050a5deb8766f6d1326bdb8a..09ca8c4c34efd48758e0a5424f7108d2355f6313 100644 (file)
@@ -456,11 +456,7 @@ describe('NvmeofGatewayNodeComponent', () => {
       actionDescription: 'remove',
       hideDefaultWarning: true,
       impact: DeletionImpact.high,
-
-      bodyContext: {
-        deletionMessage:
-          'Removing <strong>host1</strong> will detach it from the gateway group and stop handling new I/O requests. Active connections may be disrupted.<br><br>You can re-add this node later if required.'
-      },
+      hasAssociatedResources: true,
       submitActionObservable: jasmine.any(Function)
     });
 
index 89fe4865356f6b92c97d84f23dbfe1ced6655fe4..01e4f48c88ee56c75071e8dbe2854754592b5816 100644 (file)
@@ -224,9 +224,7 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy, OnChanges
       actionDescription: $localize`remove`,
       hideDefaultWarning: true,
       impact: DeletionImpact.high,
-      bodyContext: {
-        deletionMessage: $localize`Removing <strong>${hostname}</strong> will detach it from the gateway group and stop handling new I/O requests. Active connections may be disrupted.<br><br>You can re-add this node later if required.`
-      },
+      hasAssociatedResources: true,
       submitActionObservable: () => {
         const updatedSpec = this.buildRemoveGatewaySpecPayload(hostname);
         if (!updatedSpec) {
index 872d004ad738d1e4c7504b410b6ee370af9bde1e..c5771fbf9beffacedca60cbfc8670a3ac140f8b6 100644 (file)
@@ -237,15 +237,12 @@ export class NvmeofInitiatorsListComponent implements OnInit, OnDestroy {
       hostNQNs.splice(allowAllHostIndex, 1);
       itemNames = [...hostNQNs, $localize`Allow any host(*)`];
     }
-    const hostName = itemNames[0];
     const deleteModalRef = this.modalService.show(DeleteConfirmationModalComponent, {
       itemDescription: $localize`host`,
       impact: DeletionImpact.high,
       itemNames,
       actionDescription: 'remove',
-      bodyContext: {
-        deletionMessage: $localize`Removing <strong>${hostName}</strong> will disconnect it and revoke its permissions for the <strong>${this.subsystemNQN}</strong> subsystem.`
-      },
+      hasAssociatedResources: true,
       submitActionObservable: () =>
         this.taskWrapper.wrapTaskAroundCall({
           task: new FinishedTask('nvmeof/initiator/remove', {
index 7e91024675e0bbff486aa5a1990a8be46b9ce4ef..617b7c98ba73b7d44f1201823ff354b582f7da80 100644 (file)
@@ -1,9 +1,9 @@
 <cd-alert-panel
   *ngIf="listeners && listeners.length === 0"
   type="error"
-  title="No listeners exists"
+  alertTitle="No listeners exists"
   spacingClass="cds-mb-3"
-  i18n-title
+  i18n-alertTitle
 >
   <ng-container i18n
     >Currently, there are no listeners available in the NVMe subsystem. Please check your
index 4a8283eed5553e54be6b24df4a85f687d64c5c72..6cc064f5096a1c8bacec30c70466a39de9d95c50 100644 (file)
@@ -264,9 +264,7 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
       bodyTemplate: this.deleteTpl,
       itemNames: [namespace.nsid],
       actionDescription: 'delete',
-      bodyContext: {
-        deletionMessage: $localize`Deleting the namespace <strong>${namespace.nsid}</strong> will permanently remove all resources, services, and configurations within it. This action cannot be undone.`
-      },
+      hasAssociatedResources: true,
       submitActionObservable: () =>
         this.taskWrapper
           .wrapTaskAroundCall({
index 33f58c9905778f11946796a8a78f6609c1f1ad20..066998052c8d95fae5146fb1a60d14eba20630a3 100644 (file)
           >
           @if (formGroup.get('hostType').value === HOST_TYPE.ALL) {
             <cd-alert-panel
-              title="Caution"
+              alertTitle="Caution"
               type="warning"
               class="cds-mb-3"
               i18n
-              i18n-title
+              i18n-alertTitle
             >
               Allowing all hosts grants access to every initiator on the network. Authentication is
               not supported in this mode, which may expose the subsystem to unauthorized access.
index de0e7ebd4ccb76f043fe5d3be15d6b21795fb361..86203156de97af9d9c29b6751f862bb38a53188d 100644 (file)
@@ -196,8 +196,8 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
       bodyTemplate: this.deleteTpl,
       itemNames: [subsystem.nqn],
       actionDescription: 'delete',
+      hasAssociatedResources: true,
       bodyContext: {
-        deletionMessage: $localize`Deleting <strong>${subsystem.nqn}</strong> will remove all associated configurations and resources. Dependent services may stop working. This action cannot be undone.`,
         forceDeleteAcknowledgementMessage: $localize`I understand this may remove resources still attached to this subsystem.`
       },
       submitActionObservable: () =>
index 417ecd4e79f36814e0076307049a32dd4243a744..78e8ae8947141cc6f4ff448c1344842acc630b96 100644 (file)
 <ng-template #daemonLogsTpl>
   <cd-alert-panel
     type="info"
-    title="Loki/Alloy service not running"
-    i18n-title
+    alertTitle="Loki/Alloy service not running"
+    i18n-alertTitle
   >
     <ng-container i18n>Please start the loki and alloy services to see these logs.</ng-container>
   </cd-alert-panel>
index 8a2fda126ca5f33e50289e5b0e5bddd1d4a7b0c6..95c9d163dc9a9bc9a11d2a1e0a23acbd3788a3b6 100644 (file)
     {{ row.pool_name }}
   </a>
 </ng-template>
+
+<ng-template #poolEnableTpl>
+  <p
+    class="cds--type-body-01 cds--mb-05"
+    i18n
+  >
+    To delete this pool, first enable pool deletion.
+  </p>
+</ng-template>
+
+<ng-template #poolNoPermsTpl>
+  <p
+    class="cds--type-body-01 cds--mb-05"
+    i18n
+  >
+    Pool deletion is currently disabled.<br />
+    You do not have permissions to enable. Please contact administrator.
+  </p>
+</ng-template>
index 531dd8c53c4e6e42dd4b767e7c0039d18e08c7eb..5973006b7eefd88273497aa4dde4f40441564d91 100644 (file)
@@ -6,7 +6,7 @@ import { RouterTestingModule } from '@angular/router/testing';
 import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
 import _ from 'lodash';
 
-import { of } from 'rxjs';
+import { of, throwError } from 'rxjs';
 
 import { RbdConfigurationListComponent } from '~/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component';
 import { PgCategoryService } from '~/app/ceph/shared/pg-category.service';
@@ -104,7 +104,7 @@ describe('PoolListComponent', () => {
       spyOn(configurationService, 'get').and.returnValue(of(configOption));
       fixture = TestBed.createComponent(PoolListComponent);
       component = fixture.componentInstance;
-      expect(component.monAllowPoolDelete).toBe(true);
+      expect(component.monAllowPoolDelete$.value).toBe(true);
     });
 
     it('should set value correctly if mon_allow_pool_delete flag is set to false', () => {
@@ -120,7 +120,7 @@ describe('PoolListComponent', () => {
       spyOn(configurationService, 'get').and.returnValue(of(configOption));
       fixture = TestBed.createComponent(PoolListComponent);
       component = fixture.componentInstance;
-      expect(component.monAllowPoolDelete).toBe(false);
+      expect(component.monAllowPoolDelete$.value).toBe(false);
     });
 
     it('should set value correctly if mon_allow_pool_delete flag is not set', () => {
@@ -130,14 +130,14 @@ describe('PoolListComponent', () => {
       spyOn(configurationService, 'get').and.returnValue(of(configOption));
       fixture = TestBed.createComponent(PoolListComponent);
       component = fixture.componentInstance;
-      expect(component.monAllowPoolDelete).toBe(false);
+      expect(component.monAllowPoolDelete$.value).toBe(false);
     });
 
-    it('should set value correctly w/o config-opt read privileges', () => {
+    it('should set value to false w/o config-opt read privileges', () => {
       configOptRead = false;
       fixture = TestBed.createComponent(PoolListComponent);
       component = fixture.componentInstance;
-      expect(component.monAllowPoolDelete).toBe(true);
+      expect(component.monAllowPoolDelete$.value).toBe(false);
     });
   });
 
@@ -480,21 +480,30 @@ describe('PoolListComponent', () => {
     });
   });
 
-  describe('getDisableDesc', () => {
+  describe('enablePoolDeletion', () => {
+    let configurationService: ConfigurationService;
+
     beforeEach(() => {
-      component.selection.selected = [{ pool_name: 'foo' }];
+      configurationService = TestBed.inject(ConfigurationService);
     });
 
-    it('should return message if mon_allow_pool_delete flag is set to false', () => {
-      component.monAllowPoolDelete = false;
-      expect(component.getDisableDesc()).toBe(
-        'Pool deletion is disabled by the mon_allow_pool_delete configuration setting.'
-      );
+    it('should call configurationService.create and update monAllowPoolDelete$', () => {
+      spyOn(configurationService, 'create').and.returnValue(of(null));
+      component.monAllowPoolDelete$.next(false);
+      component.enablePoolDeletion();
+      expect(configurationService.create).toHaveBeenCalledWith({
+        name: 'mon_allow_pool_delete',
+        value: [{ section: 'mon', value: true }],
+        force_update: false
+      });
+      expect(component.monAllowPoolDelete$.value).toBe(true);
     });
 
-    it('should return false if mon_allow_pool_delete flag is set to true', () => {
-      component.monAllowPoolDelete = true;
-      expect(component.getDisableDesc()).toBeFalsy();
+    it('should not update monAllowPoolDelete$ on error', () => {
+      spyOn(configurationService, 'create').and.returnValue(throwError(() => 'fail'));
+      component.monAllowPoolDelete$.next(false);
+      component.enablePoolDeletion();
+      expect(component.monAllowPoolDelete$.value).toBe(false);
     });
   });
 });
index 4b54fccc0a4242a5b5e8453c9836c501b0fb9322..056cf2ea710f4d29d972765e512ed5452e77defc 100644 (file)
@@ -1,6 +1,7 @@
 import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
 
 import _ from 'lodash';
+import { BehaviorSubject } from 'rxjs';
 import { mergeMap } from 'rxjs/operators';
 
 import { PgCategoryService } from '~/app/ceph/shared/pg-category.service';
@@ -9,7 +10,9 @@ import { ErasureCodeProfileService } from '~/app/shared/api/erasure-code-profile
 import { PoolService } from '~/app/shared/api/pool.service';
 import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
 import { TableStatusViewCache } from '~/app/shared/classes/table-status-view-cache';
+import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
 import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
+import { DeleteGuardModalComponent } from '~/app/shared/components/delete-guard-modal/delete-guard-modal.component';
 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
 import { TableComponent } from '~/app/shared/datatable/table/table.component';
 import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
@@ -32,6 +35,7 @@ import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
 import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
 
 const BASE_URL = 'pool';
+const POOL_CONFIG = 'mon_allow_pool_delete';
 
 interface PoolTaskMetadata {
   pool_name: string;
@@ -56,6 +60,12 @@ export class PoolListComponent extends ListWithDetails implements OnInit {
   @ViewChild('poolConfigurationSourceTpl')
   poolConfigurationSourceTpl!: TemplateRef<any>;
 
+  @ViewChild('poolEnableTpl', { static: true })
+  poolEnableTpl!: TemplateRef<any>;
+
+  @ViewChild('poolNoPermsTpl', { static: true })
+  poolNoPermsTpl!: TemplateRef<any>;
+
   pools: Pool[] = [];
   columns: CdTableColumn[] = [];
   selection = new CdTableSelection();
@@ -64,7 +74,7 @@ export class PoolListComponent extends ListWithDetails implements OnInit {
   tableActions: CdTableAction[] = [];
   tableStatus = new TableStatusViewCache();
   cacheTiers: any[] = [];
-  monAllowPoolDelete = false;
+  monAllowPoolDelete$ = new BehaviorSubject<boolean>(false);
   ecProfileList: ErasureCodeProfile[] = [];
   viewUrl = '/pool/view';
 
@@ -100,30 +110,21 @@ export class PoolListComponent extends ListWithDetails implements OnInit {
         permission: 'delete',
         icon: Icons.destroy,
         click: () => this.deletePoolModal(),
-        name: this.actionLabels.DELETE,
-        disable: this.getDisableDesc.bind(this)
+        name: this.actionLabels.DELETE
       }
     ];
 
     // Note, we need read permissions to get the 'mon_allow_pool_delete'
     // configuration option.
     if (this.permissions.configOpt.read) {
-      this.configurationService.get('mon_allow_pool_delete').subscribe((data: any) => {
+      this.configurationService.get(POOL_CONFIG).subscribe((data: any) => {
         if (_.has(data, 'value')) {
           const monSection = _.find(data.value, (v) => {
             return v.section === 'mon';
           }) || { value: false };
-          this.monAllowPoolDelete = monSection.value === 'true' ? true : false;
+          this.monAllowPoolDelete$.next(monSection.value === 'true');
         }
       });
-    } else if (this.permissions.pool?.read) {
-      /*
-     `monAllowPoolDelete` will always be `false`,
-      because no read permissions for reading config settings.
-      Hence enabling by default for pool based roles which allow CRUD.
-      @TODO: Fix once permissions of config-opt are sorted.
-    */
-      this.monAllowPoolDelete = true;
     }
   }
 
@@ -192,9 +193,27 @@ export class PoolListComponent extends ListWithDetails implements OnInit {
 
   deletePoolModal() {
     const name = this.selection.first().pool_name;
+    if (!this.monAllowPoolDelete$.getValue()) {
+      if (this.permissions.configOpt.read && this.permissions.pool.read) {
+        this.modalService.show(ConfirmationModalComponent, {
+          titleText: $localize`Can't delete ${name}`,
+          buttonText: $localize`Enable`,
+          bodyTpl: this.poolEnableTpl,
+          warning: true,
+          onSubmit: () => this.enablePoolDeletion()
+        });
+      } else {
+        this.modalService.show(DeleteGuardModalComponent, {
+          resourceName: name,
+          resourceType: $localize`Pool`,
+          bodyTemplate: this.poolNoPermsTpl
+        });
+      }
+      return;
+    }
     this.modalService.show(DeleteConfirmationModalComponent, {
       impact: DeletionImpact.high,
-      itemDescription: 'Pool',
+      itemDescription: $localize`Pool`,
       itemNames: [name],
       submitActionObservable: () =>
         this.taskWrapper.wrapTaskAroundCall({
@@ -204,6 +223,19 @@ export class PoolListComponent extends ListWithDetails implements OnInit {
     });
   }
 
+  enablePoolDeletion() {
+    this.configurationService
+      .create({
+        name: POOL_CONFIG,
+        value: [{ section: 'mon', value: true }],
+        force_update: false
+      })
+      .subscribe(() => {
+        this.monAllowPoolDelete$.next(true);
+        this.modalService.dismissAll();
+      });
+  }
+
   getPgStatusCellClass(_row: any, _column: any, value: string): object {
     return {
       'text-right': true,
@@ -279,18 +311,6 @@ export class PoolListComponent extends ListWithDetails implements OnInit {
     }
   }
 
-  getDisableDesc(): boolean | string {
-    if (this.selection?.hasSelection) {
-      if (!this.monAllowPoolDelete) {
-        return $localize`Pool deletion is disabled by the mon_allow_pool_delete configuration setting.`;
-      }
-
-      return false;
-    }
-
-    return true;
-  }
-
   setExpandedRow(expandedRow: any) {
     super.setExpandedRow(expandedRow);
     this.getSelectionTiers();
index fd7f13e1295045b88e2ccc65a2c002375d094d47..6c873775c060f90573044f6955de49059534e984 100644 (file)
         type="info"
         *ngIf="bucketForm.getValue('lock_enabled')"
         class="me-1"
-        i18n-title
+        i18n-alertTitle
       >
         Bucket Versioning can't be disabled when Object Locking is enabled.
       </cd-alert-panel>
index e654573ab2e659f43083a570e71cae6db3fb517b..1a07480d85e46bd6476e3446e901282a1e747613 100644 (file)
@@ -47,8 +47,8 @@
                 id="alert-self-test-unknown"
                 size="slim"
                 type="warning"
-                i18n-title
-                title="SMART overall-health self-assessment test result"
+                i18n-alertTitle
+                alertTitle="SMART overall-health self-assessment test result"
                 i18n
                 >unknown</cd-alert-panel
               >
@@ -59,8 +59,8 @@
                     id="alert-self-test-passed"
                     size="slim"
                     type="info"
-                    i18n-title
-                    title="SMART overall-health self-assessment test result"
+                    i18n-alertTitle
+                    alertTitle="SMART overall-health self-assessment test result"
                     i18n
                     >passed</cd-alert-panel
                   >
@@ -70,8 +70,8 @@
                     id="alert-self-test-failed"
                     size="slim"
                     type="warning"
-                    i18n-title
-                    title="SMART overall-health self-assessment test result"
+                    i18n-alertTitle
+                    alertTitle="SMART overall-health self-assessment test result"
                     i18n
                     >failed</cd-alert-panel
                   >
index 4d577d9257a16ee8f8365ec7134d63aa6be9f77e..ba37f3d5f97f3e520bf28ccae1c663b3897a5995 100644 (file)
@@ -89,31 +89,20 @@ describe('OsdSmartListComponent', () => {
     component.ngOnChanges(changes);
   };
 
-  /**
-   * Verify an alert panel and its attributes.
-   *
-   * @param selector The CSS selector for the alert panel.
-   * @param panelTitle The title should be displayed.
-   * @param panelType Alert level of panel. Can be in `warning` or `info`.
-   * @param panelSize Pass `slim` for slim alert panel.
-   */
   const verifyAlertPanel = (
     selector: string,
     panelTitle: string,
-    panelType: 'warning' | 'info',
-    panelSize?: 'slim'
+    panelType: 'warning' | 'info'
   ) => {
     const alertPanel = fixture.debugElement.query(By.css(selector));
     expect(component.incompatible).toBe(false);
     expect(component.loading).toBe(false);
-
     expect(alertPanel.attributes.type).toBe(panelType);
-    if (panelSize === 'slim') {
-      expect(alertPanel.attributes.title).toBe(panelTitle);
-      expect(alertPanel.attributes.size).toBe(panelSize);
+    if (alertPanel.attributes.alertTitle) {
+      expect(alertPanel.attributes.alertTitle).toBe(panelTitle);
     } else {
       const panelText = alertPanel.query(By.css('.cds--actionable-notification__content'));
-      expect(panelText.nativeElement.textContent).toBe(panelTitle);
+      expect(panelText.nativeElement.textContent).toContain(panelTitle);
     }
   };
 
@@ -207,8 +196,7 @@ describe('OsdSmartListComponent', () => {
     verifyAlertPanel(
       'cd-alert-panel#alert-self-test-passed',
       'SMART overall-health self-assessment test result',
-      'info',
-      'slim'
+      'info'
     );
   });
 
@@ -218,8 +206,7 @@ describe('OsdSmartListComponent', () => {
     verifyAlertPanel(
       'cd-alert-panel#alert-self-test-failed',
       'SMART overall-health self-assessment test result',
-      'warning',
-      'slim'
+      'warning'
     );
   });
 
@@ -229,8 +216,7 @@ describe('OsdSmartListComponent', () => {
     verifyAlertPanel(
       'cd-alert-panel#alert-self-test-unknown',
       'SMART overall-health self-assessment test result',
-      'warning',
-      'slim'
+      'warning'
     );
   });
 
index bbc1790da0ce34b25b1061895a55f0b5d45c09e7..1bc504e54f05a7b568e7cfa3cadcf6c49dc8afa9 100644 (file)
@@ -6,6 +6,9 @@
 ></cds-actionable-notification>
 
 <ng-template #content>
+  @if (showTitle && alertTitle) {
+    <span class="cds--actionable-notification__title">{{ alertTitle }}</span>
+  }
   <ng-content></ng-content>
 </ng-template>
 
index 0e342bd1c6bbdb8cb447d97d4386b74835fb22f9..8e95d7cf0b3b5e14d010194fbae4c48024c58e71 100644 (file)
@@ -24,14 +24,12 @@ export class AlertPanelComponent implements OnInit {
   actionTpl: TemplateRef<any>;
 
   @Input()
-  title = '';
+  alertTitle = '';
   @Input()
   type: 'warning' | 'error' | 'info' | 'success' | 'danger';
   @Input()
   showTitle = true;
   @Input()
-  size: 'slim' | 'normal' = 'normal';
-  @Input()
   dismissible = false;
   @Input()
   spacingClass = '';
@@ -64,19 +62,19 @@ export class AlertPanelComponent implements OnInit {
     const type: NotificationType = this.type === 'danger' ? 'error' : this.type;
     switch (this.type) {
       case 'warning':
-        this.title = this.title || $localize`Warning`;
+        this.alertTitle = this.alertTitle || $localize`Warning`;
         break;
       case 'error':
-        this.title = this.title || $localize`Error`;
+        this.alertTitle = this.alertTitle || $localize`Error`;
         break;
       case 'info':
-        this.title = this.title || $localize`Information`;
+        this.alertTitle = this.alertTitle || $localize`Information`;
         break;
       case 'success':
-        this.title = this.title || $localize`Success`;
+        this.alertTitle = this.alertTitle || $localize`Success`;
         break;
       case 'danger':
-        this.title = this.title || $localize`Danger`;
+        this.alertTitle = this.alertTitle || $localize`Danger`;
         break;
     }
 
@@ -85,7 +83,7 @@ export class AlertPanelComponent implements OnInit {
       template: this.alertContent,
       actionsTemplate: this.actionTpl,
       showClose: this.dismissible,
-      title: this.showTitle ? this.title : '',
+      title: this.showTitle ? this.alertTitle : '',
       lowContrast: this.lowContrast,
       variant: this.variant
     };
index 273d5d8724911344600d9a88f6491a92f663f6f6..e0cf05e21f37b9b85787e2b0de9fa91c71ce349d 100644 (file)
@@ -59,6 +59,7 @@ import { ConfigOptionComponent } from './config-option/config-option.component';
 import { ConfirmationModalComponent } from './confirmation-modal/confirmation-modal.component';
 import { Copy2ClipboardButtonComponent } from './copy2clipboard-button/copy2clipboard-button.component';
 import { DeleteConfirmationModalComponent } from './delete-confirmation-modal/delete-confirmation-modal.component';
+import { DeleteGuardModalComponent } from './delete-guard-modal/delete-guard-modal.component';
 import { CustomLoginBannerComponent } from './custom-login-banner/custom-login-banner.component';
 import { DateTimePickerComponent } from './date-time-picker/date-time-picker.component';
 import { DocComponent } from './doc/doc.component';
@@ -200,6 +201,7 @@ import { OverviewComponent } from './resource-overview-card/resource-overview-ca
     LoadingPanelComponent,
     ModalComponent,
     DeleteConfirmationModalComponent,
+    DeleteGuardModalComponent,
     ConfirmationModalComponent,
     LanguageSelectorComponent,
     GrafanaComponent,
index 3787dd2077f240fcaf13b8a6616a90dcc3382d2a..537e567615216bf2edf76c6c0f87c98649ea68c9 100644 (file)
             <p class="cds--type-body-01">
               @if (bodyContext?.deletionMessage) {
                 <span [innerHTML]="bodyContext.deletionMessage"></span>
+              } @else if (hasAssociatedResources) {
+                <ng-container i18n>
+                  Deleting <strong>{{ itemNames[0] }}</strong> will remove all associated
+                  {{ itemDescription }}. This action cannot be undone.
+                </ng-container>
               } @else {
-                <ng-container i18n
-                  >Deleting <strong>{{ itemNames[0] }}</strong> will remove all associated
-                  {{ itemDescription }}.This action cannot be undone.</ng-container
-                >
+                <ng-container i18n>
+                  Deleting <strong>{{ itemNames[0] }}</strong> permanently deletes the pool. This
+                  action cannot be undone.
+                </ng-container>
               }
             </p>
             <ng-template #labelTemplate>
index 658de582ff27f1193f125bf7ffaf4d89e698f29b..0467ca5356aef2b73234432a6ea69481d68e27a8 100644 (file)
@@ -41,6 +41,7 @@ export class DeleteConfirmationModalComponent extends BaseModal implements OnIni
     @Optional()
     @Inject('callBackAtionObservable')
     public callBackAtionObservable?: () => Observable<any>,
+    @Optional() @Inject('hasAssociatedResources') public hasAssociatedResources?: boolean,
     @Optional() @Inject('hideDefaultWarning') public hideDefaultWarning?: boolean,
     @Optional() @Inject('childFormGroup') public childFormGroup?: CdFormGroup,
     @Optional() @Inject('childFormGroupTemplate') public childFormGroupTemplate?: TemplateRef<any>
@@ -87,10 +88,6 @@ export class DeleteConfirmationModalComponent extends BaseModal implements OnIni
     if (!(this.submitAction || this.submitActionObservable)) {
       throw new Error('No submit action defined');
     }
-    if (this.bodyContext?.disableForm) {
-      this.toggleFormControls(this.bodyContext?.disableForm);
-      return;
-    }
 
     if (this.impact === this.impactEnum.high && this.itemNames?.[0]) {
       const target = String(this.itemNames[0]);
@@ -153,15 +150,4 @@ export class DeleteConfirmationModalComponent extends BaseModal implements OnIni
   stopLoadingSpinner() {
     this.deletionForm.setErrors({ cdSubmitButton: true });
   }
-
-  toggleFormControls(disableForm = false) {
-    if (disableForm) {
-      this.deletionForm.disable();
-      this.deletionForm.setErrors({ disabledByContext: true });
-      this.submitDisabled$ = of(true);
-    } else {
-      this.deletionForm.enable();
-      this.deletionForm.setErrors(null);
-    }
-  }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/delete-guard-modal/delete-guard-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/delete-guard-modal/delete-guard-modal.component.html
new file mode 100644 (file)
index 0000000..6e6da66
--- /dev/null
@@ -0,0 +1,60 @@
+<cds-modal
+  size="sm"
+  [open]="open"
+  (overlaySelected)="closeModal()"
+>
+  <cds-modal-header (closeSelect)="closeModal()">
+    <h2
+      cdsModalHeaderLabel
+      modal-primary-focus
+      i18n
+    >
+      Manage {{ resourceType }}
+    </h2>
+    <span
+      class="cds--type-heading-03"
+      cdsModalHeaderHeading
+      i18n
+      >Can't delete {{ resourceName }}</span
+    >
+  </cds-modal-header>
+  <section cdsModalContent>
+    @if (bodyTemplate) {
+      <ng-container *ngTemplateOutlet="bodyTemplate; context: bodyContext"></ng-container>
+    } @else {
+      <p
+        class="cds--type-body-01 cds--mb-05"
+        i18n
+      >
+        {{ message }}
+      </p>
+      @if (connectedItems.length) {
+        <p
+          class="cds--type-label-01 cds--mb-04"
+          i18n
+        >
+          {{ connectedItemsLabel }}
+        </p>
+        <div>
+          @for (item of connectedItems; track item.name) {
+            <div class="cds--mb-04">
+              @if (item.route?.length) {
+                <a
+                  class="cds--link cds--type-body-01"
+                  tabindex="0"
+                  (click)="navigateToItem(item)"
+                  (keydown.enter)="navigateToItem(item)"
+                >
+                  <span class="cds--mr-03">{{ item.name }}</span>
+                  <cd-icon type="launch"></cd-icon>
+                </a>
+              } @else {
+                <span class="cds--type-body-01">{{ item.name }}</span>
+              }
+            </div>
+          }
+        </div>
+      }
+    }
+  </section>
+</cds-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/delete-guard-modal/delete-guard-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/delete-guard-modal/delete-guard-modal.component.spec.ts
new file mode 100644 (file)
index 0000000..22ff2d8
--- /dev/null
@@ -0,0 +1,90 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Router } from '@angular/router';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ModalModule } from 'carbon-components-angular';
+import { DeleteGuardModalComponent } from './delete-guard-modal.component';
+
+describe('DeleteGuardModalComponent', () => {
+  let component: DeleteGuardModalComponent;
+  let fixture: ComponentFixture<DeleteGuardModalComponent>;
+  let router: Router;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [ModalModule],
+      declarations: [DeleteGuardModalComponent],
+      providers: [
+        {
+          provide: Router,
+          useValue: { navigate: jest.fn() }
+        },
+        { provide: 'resourceName', useValue: 'my-pool' },
+        { provide: 'resourceType', useValue: 'Pool' }
+      ],
+      schemas: [NO_ERRORS_SCHEMA]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(DeleteGuardModalComponent);
+    component = fixture.componentInstance;
+    router = TestBed.inject(Router);
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should set default values', () => {
+    expect(component.resourceName).toBe('my-pool');
+    expect(component.resourceType).toBe('Pool');
+    expect(component.connectedItems).toEqual([]);
+    expect(component.message).toContain('connected items');
+    expect(component.connectedItemsLabel).toContain('View connected items');
+  });
+
+  it('should navigate to item route and close modal', () => {
+    const closeSpy = jest.spyOn(component, 'closeModal');
+    const item = {
+      name: 'subsystem-1',
+      route: ['/block/nvmeof/subsystems', 'subsystem-1', 'overview'],
+      queryParams: { group: 'default' }
+    };
+
+    component.navigateToItem(item);
+
+    expect(router.navigate).toHaveBeenCalledWith(
+      ['/block/nvmeof/subsystems', 'subsystem-1', 'overview'],
+      { queryParams: { group: 'default' } }
+    );
+    expect(closeSpy).toHaveBeenCalled();
+  });
+
+  it('should not navigate if item has no route', () => {
+    const closeSpy = jest.spyOn(component, 'closeModal');
+    const item = { name: 'no-route-item' };
+
+    component.navigateToItem(item);
+
+    expect(router.navigate).not.toHaveBeenCalled();
+    expect(closeSpy).not.toHaveBeenCalled();
+  });
+
+  it('should use default resourceType when not provided', async () => {
+    TestBed.resetTestingModule();
+    await TestBed.configureTestingModule({
+      imports: [ModalModule],
+      declarations: [DeleteGuardModalComponent],
+      providers: [
+        { provide: Router, useValue: { navigate: jest.fn() } },
+        { provide: 'resourceName', useValue: 'test-resource' }
+      ],
+      schemas: [NO_ERRORS_SCHEMA]
+    }).compileComponents();
+
+    const defaultFixture = TestBed.createComponent(DeleteGuardModalComponent);
+    const defaultComponent = defaultFixture.componentInstance;
+    defaultFixture.detectChanges();
+
+    expect(defaultComponent.resourceType).toBe('resource');
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/delete-guard-modal/delete-guard-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/delete-guard-modal/delete-guard-modal.component.ts
new file mode 100644 (file)
index 0000000..df218a6
--- /dev/null
@@ -0,0 +1,39 @@
+import { Component, Inject, Optional, TemplateRef } from '@angular/core';
+import { Router } from '@angular/router';
+import { BaseModal } from 'carbon-components-angular';
+import { ConnectedItem } from '../../models/delete-guard.model';
+
+@Component({
+  selector: 'cd-delete-guard-modal',
+  templateUrl: './delete-guard-modal.component.html',
+  standalone: false
+})
+export class DeleteGuardModalComponent extends BaseModal {
+  constructor(
+    private router: Router,
+    @Optional() @Inject('resourceName') public resourceName: string,
+    @Optional() @Inject('resourceType') public resourceType: string,
+    @Optional() @Inject('connectedItems') public connectedItems: ConnectedItem[],
+    @Optional() @Inject('message') public message: string,
+    @Optional() @Inject('connectedItemsLabel') public connectedItemsLabel: string,
+    @Optional() @Inject('bodyTemplate') public bodyTemplate: TemplateRef<any>,
+    @Optional() @Inject('bodyContext') public bodyContext: any
+  ) {
+    super();
+    this.resourceType = this.resourceType || $localize`resource`;
+    this.connectedItems = this.connectedItems || [];
+    this.message =
+      this.message ||
+      $localize`This resource has connected items that must be deleted first. Delete the connected items, and try again.`;
+    this.connectedItemsLabel = this.connectedItemsLabel || $localize`View connected items:`;
+  }
+
+  navigateToItem(item: ConnectedItem): void {
+    if (item.route?.length) {
+      this.router.navigate(item.route, {
+        queryParams: item.queryParams
+      });
+      this.closeModal();
+    }
+  }
+}
index 9ab8d33a51b56a4a4286751a9d39483f7fbd5411..07a355463d7dfde249116641f23eb65f9a73ca7a 100644 (file)
@@ -61,12 +61,13 @@ describe('FormLoadingDirective', () => {
     component.loadingError();
     fixture.detectChanges();
 
-    expectShown(0, 1, 0);
+    expect(fixture.debugElement.queryAll(By.css('cd-alert-panel')).length).toEqual(1);
+    expect(fixture.debugElement.queryAll(By.css('cd-loading-panel')).length).toEqual(0);
 
     const alert = fixture.debugElement.nativeElement.querySelector(
       'cd-alert-panel .cds--actionable-notification__content'
     );
-    expect(alert.textContent).toBe('Form data could not be loaded.');
+    expect(alert.textContent).toContain('Form data could not be loaded.');
   });
 
   it('should show original component when calling loadingReady()', () => {
index 7f4af3123ff9795047c1702ad364ffa79295dd95..9bea38cea2dfefa0ea32fe3a5c104785f83bfbb2 100644 (file)
@@ -1,6 +1,5 @@
 export interface DeleteConfirmationBodyContext {
   warningMessage?: string;
-  disableForm?: boolean;
   inputLabel?: string;
   inputPlaceholder?: string;
   deletionMessage?: string;
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/delete-guard.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/delete-guard.model.ts
new file mode 100644 (file)
index 0000000..2896e92
--- /dev/null
@@ -0,0 +1,5 @@
+export interface ConnectedItem {
+  name: string;
+  route?: string[];
+  queryParams?: Record<string, string>;
+}