Merge pull request #70643 from rhcs-dashboard/fix-pool-deletion

mgr/dashboard: Pool deletion enhancements

Reviewed-by: Afreen Misbah <afreen@ibm.com>
Reviewed-by: Naman Munet <nmunet@redhat.com>
This commit is contained in:
Afreen Misbah 2026-07-31 01:10:41 +05:30 committed by GitHub
commit 1066090245
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 390 additions and 276 deletions

View 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;

View 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
],

View 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

View 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>

View File

@ -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>

View File

@ -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();
});
});

View File

@ -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();
}
}

View 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' }
}
]
});
});

View 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({

View 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)
});

View 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) {

View 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', {

View 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

View 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({

View File

@ -52,11 +52,11 @@
>
@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.

View 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: () =>

View File

@ -291,8 +291,8 @@
<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>

View File

@ -84,3 +84,22 @@
{{ 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>

View 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);
});
});
});

View 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();

View File

@ -435,7 +435,7 @@
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>

View 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
>

View 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'
);
});

View 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>

View 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
};

View 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,

View File

@ -75,11 +75,16 @@
<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>

View 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);
}
}
}

View File

@ -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>

View File

@ -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');
});
});

View File

@ -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();
}
}
}

View 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()', () => {

View File

@ -1,6 +1,5 @@
export interface DeleteConfirmationBodyContext {
warningMessage?: string;
disableForm?: boolean;
inputLabel?: string;
inputPlaceholder?: string;
deletionMessage?: string;

View File

@ -0,0 +1,5 @@
export interface ConnectedItem {
name: string;
route?: string[];
queryParams?: Record<string, string>;
}