--- /dev/null
+import { Injectable, OnDestroy } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { NvmeofService, GroupsComboboxItem } from '~/app/shared/api/nvmeof.service';
+import { CephServiceSpec } from '~/app/shared/models/service.interface';
+import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+
+const DEFAULT_PLACEHOLDER = $localize`Enter group name`;
+
+@Injectable()
+export class GatewayGroupQueryHandlerService implements OnDestroy {
+ group: string = null;
+ gwGroups: GroupsComboboxItem[] = [];
+ groupSelectionCleared = false;
+ gwGroupsEmpty = false;
+ gwGroupPlaceholder: string = DEFAULT_PLACEHOLDER;
+
+ dataRefresh$ = new Subject<void>();
+ private destroy$ = new Subject<void>();
+
+ constructor(
+ private route: ActivatedRoute,
+ private router: Router,
+ private nvmeofService: NvmeofService
+ ) {}
+
+ init() {
+ this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => {
+ const group = params?.['group']?.trim() || null;
+ const hasGroupParam = Object.prototype.hasOwnProperty.call(params ?? {}, 'group');
+ if (group) {
+ if (this.group === group && !this.groupSelectionCleared) {
+ return;
+ }
+ this.groupSelectionCleared = false;
+ this.onGroupSelection({ content: group }, false);
+ } else if (hasGroupParam) {
+ if (!this.group && this.groupSelectionCleared) {
+ return;
+ }
+ this.groupSelectionCleared = true;
+ if (this.group) {
+ this.onGroupClear(false);
+ } else {
+ this.dataRefresh$.next();
+ }
+ } else {
+ if (!this.group && !this.groupSelectionCleared) {
+ return;
+ }
+ this.groupSelectionCleared = false;
+ this.group = null;
+ }
+ });
+ this.fetchGatewayGroups();
+ }
+
+ onGroupChange(group: string | null): void {
+ if (group) {
+ this.onGroupSelection({ content: group });
+ } else {
+ this.onGroupClear();
+ }
+ }
+
+ onGroupSelection(selected: GroupsComboboxItem, syncQueryParam = true) {
+ selected.selected = true;
+ this.group = selected.content;
+ this.groupSelectionCleared = false;
+ if (syncQueryParam) {
+ this.syncGroupQueryParam(this.group);
+ }
+ this.dataRefresh$.next();
+ }
+
+ onGroupClear(syncQueryParam = true) {
+ this.group = null;
+ this.groupSelectionCleared = true;
+ if (syncQueryParam) {
+ this.syncGroupQueryParam(null);
+ }
+ this.dataRefresh$.next();
+ }
+
+ private syncGroupQueryParam(group: string | null): void {
+ const currentGroup = this.route.snapshot.queryParams['group']?.trim() || null;
+ if (currentGroup === group) {
+ return;
+ }
+ this.router.navigate([], {
+ relativeTo: this.route,
+ queryParams: { group: group || null },
+ queryParamsHandling: 'merge',
+ replaceUrl: true
+ });
+ }
+
+ private fetchGatewayGroups() {
+ this.nvmeofService
+ .listGatewayGroups()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe({
+ next: (response: CephServiceSpec[][]) => this.handleGatewayGroupsSuccess(response),
+ error: (error) => this.handleGatewayGroupsError(error)
+ });
+ }
+
+ private handleGatewayGroupsSuccess(response: CephServiceSpec[][]) {
+ if (response?.[0]?.length) {
+ this.gwGroups = this.nvmeofService.formatGwGroupsList(response);
+ } else {
+ this.gwGroups = [];
+ }
+ this.updateGroupSelectionState();
+ }
+
+ private updateGroupSelectionState() {
+ if (this.gwGroups.length) {
+ if (this.group) {
+ this.gwGroups = this.gwGroups.map((g) => ({
+ ...g,
+ selected: g.content === this.group
+ }));
+ } else if (!this.groupSelectionCleared) {
+ this.onGroupSelection(this.gwGroups[0]);
+ } else {
+ this.gwGroups = this.gwGroups.map((g) => ({
+ ...g,
+ selected: false
+ }));
+ }
+ this.gwGroupsEmpty = false;
+ this.gwGroupPlaceholder = DEFAULT_PLACEHOLDER;
+ } else {
+ this.gwGroupsEmpty = true;
+ this.gwGroupPlaceholder = $localize`No groups available`;
+ }
+ }
+
+ private handleGatewayGroupsError(error: any) {
+ this.gwGroups = [];
+ this.gwGroupsEmpty = true;
+ this.gwGroupPlaceholder = $localize`Unable to fetch Gateway groups`;
+ if (error?.preventDefault) {
+ error?.preventDefault?.();
+ }
+ }
+
+ ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+}
-<div cdsGrid
- [useCssGrid]="true"
- [narrow]="true"
- [fullWidth]="true">
-<div cdsCol
- [columnNumbers]="{sm: 4, md: 8}">
- <div class="form-item cds-mt-5"
- cdsRow>
- <cds-combo-box
- type="single"
- label="Selected Gateway Group"
- i18n-label
- [placeholder]="placeholder"
- [items]="items"
- (selected)="onSelected($event)"
- (clear)="onClear()"
- [disabled]="disabled">
- <cds-dropdown-list></cds-dropdown-list>
- </cds-combo-box>
- </div>
-</div>
+<div class="nvmeof-gateway-group-filter cds-mt-3">
+ <span class="nvmeof-gateway-group-filter__label cds--type-body-compact-01"
+ i18n>Gateway group:</span>
+ <cds-combo-box type="single"
+ size="sm"
+ class="nvmeof-gateway-group-filter__combo"
+ [placeholder]="placeholder"
+ [items]="items"
+ [disabled]="disabled"
+ (selected)="onSelected($event)"
+ (clear)="onClear()">
+ <cds-dropdown-list></cds-dropdown-list>
+ </cds-combo-box>
</div>
+.nvmeof-gateway-group-filter {
+ display: flex;
+ align-items: center;
+ gap: var(--cds-spacing-03);
+ margin-left: var(--cds-spacing-05);
+
+ &__label {
+ white-space: nowrap;
+ color: var(--cds-text-secondary);
+ font-weight: 600;
+ }
+
+ &__combo {
+ min-inline-size: 14rem;
+ max-inline-size: 20rem;
+
+ // Remove bottom border from combobox - target all possible elements
+ ::ng-deep {
+ .cds--combo-box,
+ .cds--text-input,
+ .cds--list-box,
+ .cds--text-input__field-wrapper,
+ input,
+ input[role='combobox'] {
+ border-bottom: none !important;
+ }
+
+ // Also remove from parent containers
+ .cds--form-requirement,
+ .cds--text-input__label,
+ .cds--list-box__wrapper {
+ border-bottom: none !important;
+ }
+
+ // Target the input on all states
+ input:focus,
+ input:hover,
+ input:active {
+ border-bottom: none !important;
+ }
+ }
+ }
+}
expect(component.placeholder).toBe('Unable to fetch Gateway groups');
});
- it('should call preventDefault on the error when the API call fails', () => {
- const err = { preventDefault: jest.fn() };
- (nvmeofService.listGatewayGroups as jest.Mock).mockReturnValue(throwError(err));
+ it('should handle non-Error API failures by disabling the control', () => {
+ const err = { message: 'network error' };
+ (nvmeofService.listGatewayGroups as jest.Mock).mockReturnValue(throwError(() => err));
fixture.detectChanges();
- expect(err.preventDefault).toHaveBeenCalled();
+ expect(component.disabled).toBe(true);
+ expect(component.placeholder).toBe('Unable to fetch Gateway groups');
+ expect(component.items).toEqual([]);
});
it('should render a cds-combo-box', () => {
-import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
-import { ActivatedRoute, Router, RouterModule } from '@angular/router';
+import {
+ Component,
+ EventEmitter,
+ OnDestroy,
+ OnInit,
+ Output,
+ ViewEncapsulation
+} from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
-import { ComboBoxModule, GridModule, LayoutModule } from 'carbon-components-angular';
+import { ComboBoxModule } from 'carbon-components-angular';
import { NvmeofService, GroupsComboboxItem } from '~/app/shared/api/nvmeof.service';
-import { CephServiceSpec } from '~/app/shared/models/service.interface';
-
-const DEFAULT_PLACEHOLDER = $localize`Enter group name`;
@Component({
selector: 'cd-nvmeof-gateway-group-filter',
templateUrl: './nvmeof-gateway-group-filter.component.html',
styleUrls: ['./nvmeof-gateway-group-filter.component.scss'],
standalone: true,
- imports: [ComboBoxModule, GridModule, LayoutModule, RouterModule]
+ imports: [ComboBoxModule],
+ encapsulation: ViewEncapsulation.None
})
export class NvmeofGatewayGroupFilterComponent implements OnInit, OnDestroy {
@Output() groupChange = new EventEmitter<string | null>();
items: GroupsComboboxItem[] = [];
- disabled = false;
- placeholder = DEFAULT_PLACEHOLDER;
+ placeholder: string = '';
+ disabled: boolean = false;
private destroy$ = new Subject<void>();
constructor(
private nvmeofService: NvmeofService,
- private route: ActivatedRoute,
- private router: Router
+ private router: Router,
+ private route: ActivatedRoute
) {}
ngOnInit(): void {
- this.loadGatewayGroups();
- }
-
- ngOnDestroy(): void {
- this.destroy$.next();
- this.destroy$.complete();
- }
-
- onSelected(item: GroupsComboboxItem): void {
- this.syncQueryParam(item.content);
- }
-
- onClear(): void {
- this.syncQueryParam(null);
- }
-
- private loadGatewayGroups(): void {
this.nvmeofService
.listGatewayGroups()
.pipe(takeUntil(this.destroy$))
.subscribe({
- next: (response: CephServiceSpec[][]) => this.onGroupsLoaded(response),
- error: (error: any) => this.onGroupsError(error)
+ next: (response: any) => {
+ if (!response?.[0]?.length) {
+ this.disabled = true;
+ this.placeholder = $localize`No groups available`;
+ this.items = [];
+ return;
+ }
+ const formatted: GroupsComboboxItem[] = this.nvmeofService.formatGwGroupsList(response);
+ const currentGroup: string | undefined = this.route.snapshot.queryParams['group'];
+ if (currentGroup) {
+ this.items = formatted.map((item) => ({
+ ...item,
+ selected: item.content === currentGroup
+ }));
+ } else {
+ this.items = formatted;
+ const first = formatted[0];
+ this.router.navigate([], {
+ relativeTo: this.route,
+ queryParams: { group: first.content },
+ queryParamsHandling: 'merge',
+ replaceUrl: true
+ });
+ }
+ },
+ error: () => {
+ this.disabled = true;
+ this.placeholder = $localize`Unable to fetch Gateway groups`;
+ this.items = [];
+ }
});
}
- private onGroupsLoaded(response: CephServiceSpec[][]): void {
- if (response?.[0]?.length) {
- this.items = this.nvmeofService.formatGwGroupsList(response);
- } else {
- this.items = [];
- }
- this.syncSelectionState();
+ onSelected(item: GroupsComboboxItem): void {
+ this.groupChange.emit(item.content);
}
- private onGroupsError(error: any): void {
- this.items = [];
- this.disabled = true;
- this.placeholder = $localize`Unable to fetch Gateway groups`;
- if (error?.preventDefault) {
- error.preventDefault();
- }
+ onClear(): void {
this.groupChange.emit(null);
}
- private syncSelectionState(): void {
- if (this.items.length) {
- this.disabled = false;
- this.placeholder = DEFAULT_PLACEHOLDER;
- const urlGroup = this.route.snapshot.queryParams['group']?.trim() || null;
- if (!urlGroup) {
- this.syncQueryParam(this.items[0].content);
- } else {
- this.items = this.items.map((g) => ({ ...g, selected: g.content === urlGroup }));
- this.groupChange.emit(urlGroup);
- }
- } else {
- this.disabled = true;
- this.placeholder = $localize`No groups available`;
- this.groupChange.emit(null);
- }
- }
-
- private syncQueryParam(group: string | null): void {
- const currentGroup = this.route.snapshot.queryParams['group']?.trim() || null;
- if (currentGroup === group) {
- // URL already correct — still emit so the parent gets the current value on init
- this.items = this.items.map((g) => ({ ...g, selected: g.content === group }));
- this.groupChange.emit(group);
- return;
- }
-
- this.router.navigate([], {
- relativeTo: this.route,
- queryParams: { group: group || null },
- queryParamsHandling: 'merge',
- replaceUrl: true
- });
- this.items = this.items.map((g) => ({ ...g, selected: g.content === group }));
- this.groupChange.emit(group);
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
}
}
-<ng-container *ngIf="gatewayGroup$ | async as gateways">
- <cd-table
- #table
- [data]="gateways"
- [columns]="columns"
- columnMode="flex"
- selectionType="single"
- identifier="name"
- (updateSelection)="updateSelection($event)"
- (fetchData)="fetchData()"
- emptyStateTitle="No gateway group created"
- i18n-emptyStateTitle
- emptyStateMessage="Set up your first gateway group to start using NVMe over Fabrics. This will allow you to create high-performance block storage with NVMe/TCP protocol."
- i18n-emptyStateMessage>
-
- <div class="table-actions">
- <cd-table-actions [permission]="permission"
+<div class="nvmeof-content-layout">
+ <div class="nvmeof-content-main">
+ <ng-container *ngIf="gatewayGroup$ | async as gateways">
+ <cd-table
+ #table
+ [data]="gateways"
+ [columns]="columns"
+ columnMode="flex"
+ selectionType="single"
+ identifier="name"
+ (updateSelection)="updateSelection($event)"
+ (fetchData)="fetchData()"
+ emptyStateTitle="No gateway group created"
+ i18n-emptyStateTitle
+ emptyStateMessage="Set up your first gateway group to start using NVMe over Fabrics. This will allow you to create high-performance block storage with NVMe/TCP protocol."
+ i18n-emptyStateMessage>
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
[selection]="selection"
- class="btn-group"
[tableActions]="tableActions">
</cd-table-actions>
- </div>
- </cd-table>
-</ng-container>
+ </cd-table>
+ </ng-container>
+ </div>
+</div>
<ng-template #dateTpl
let-created="data.value">
listGatewayGroups: jest.fn().mockReturnValue(of([])),
listSubsystems: jest.fn().mockReturnValue(of([]))
};
-
const nvmeofStateServiceMock = {
refresh$: new Subject<void>(),
requestRefresh: jest.fn()
viewUrl = `/${BASE_URL}/view`;
icons = Icons;
-
iconSize = IconSize;
constructor(
}),
catchError(() => {
return of([]);
- })
+ }),
+ finalize(() => this.setTableLoading(false))
)
),
shareReplay({ bufferSize: 1, refCount: true }),
.subscribe(() => this.fetchData());
}
fetchData(): void {
+ this.setTableLoading(true);
this.subject.next([]);
this.checkNodesAvailability();
}
+ private setTableLoading(loading: boolean): void {
+ if (this.table) {
+ this.table.loadingIndicator = loading;
+ }
+ }
+
updateSelection(selection: CdTableSelection): void {
this.selection = selection;
}
-<cd-nvmeof-gateway-group-filter
- (groupChange)="onGroupChange($event)">
-</cd-nvmeof-gateway-group-filter>
-
+<div class="nvmeof-content-layout">
+ <div class="nvmeof-content-main">
<ng-container *ngIf="namespaces$ | async as namespaces">
<cd-table [data]="namespaces"
+ [compactSearchField]="true"
columnMode="flex"
(fetchData)="fetchData()"
[columns]="namespacesColumns"
emptyStateMessage="Namespaces are storage volumes mapped to subsystems for host access. Create a namespace to start provisioning storage within a subsystem."
i18n-emptyStateMessage>
+ <div class="table-filter">
+ <cd-nvmeof-gateway-group-filter
+ (groupChange)="groupHandler.onGroupChange($event)">
+ </cd-nvmeof-gateway-group-filter>
+ </div>
+
<div class="table-actions">
<cd-table-actions [permission]="permission"
[selection]="selection"
</div>
</cd-table>
</ng-container>
+ </div>
+</div>
<router-outlet name="modal"></router-outlet>
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientModule } from '@angular/common/http';
-import { NO_ERRORS_SCHEMA } from '@angular/core';
-import { of, Subject } from 'rxjs';
-import { take } from 'rxjs/operators';
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { BehaviorSubject, Subject, of } from 'rxjs';
+import { skip, take } from 'rxjs/operators';
import { RouterTestingModule } from '@angular/router/testing';
import { SharedModule } from '~/app/shared/shared.module';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { NvmeofSubsystemsDetailsComponent } from '../nvmeof-subsystems-details/nvmeof-subsystems-details.component';
import { NvmeofNamespacesListComponent } from './nvmeof-namespaces-list.component';
+import { NvmeofGatewayGroupFilterComponent } from '../nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component';
const mockNamespaces = [
{
}
];
+const mockGroups = [
+ [
+ {
+ service_name: 'nvmeof.rbd.g1',
+ service_type: 'nvmeof',
+ unmanaged: false,
+ spec: {
+ group: 'g1'
+ }
+ }
+ ],
+ 1
+];
+
+const mockFormattedGwGroups = [
+ {
+ content: 'g1'
+ }
+];
+
class MockNvmeOfService {
gatewayGroupsResponse: any = [[{ id: 'g1' }]];
namespacesResponse: any = { namespaces: mockNamespaces };
listGatewayGroups() {
- return of(this.gatewayGroupsResponse);
+ return of(mockGroups);
}
formatGwGroupsList(_response: any) {
- return [{ content: 'g1', selected: false }];
+ return mockFormattedGwGroups;
}
listNamespaces(_group?: string) {
describe('NvmeofNamespacesListComponent', () => {
let component: NvmeofNamespacesListComponent;
let fixture: ComponentFixture<NvmeofNamespacesListComponent>;
-
+ let queryParams$: BehaviorSubject<Record<string, string>>;
let modalService: MockModalCdsService;
let nvmeofService: MockNvmeOfService;
+ const activatedRouteMock = {
+ queryParams: null as any,
+ snapshot: { queryParams: {} as Record<string, string> }
+ };
beforeEach(async () => {
- const nvmeofStateServiceMock = {
- refresh$: new Subject<void>(),
- requestRefresh: jest.fn()
- };
+ const refresh$ = new Subject<void>();
+ queryParams$ = new BehaviorSubject<Record<string, string>>({});
+ activatedRouteMock.queryParams = queryParams$.asObservable();
+ activatedRouteMock.snapshot.queryParams = queryParams$.value;
await TestBed.configureTestingModule({
declarations: [NvmeofNamespacesListComponent, NvmeofSubsystemsDetailsComponent],
- imports: [HttpClientModule, RouterTestingModule, SharedModule],
+ imports: [
+ HttpClientModule,
+ RouterTestingModule,
+ SharedModule,
+ NvmeofGatewayGroupFilterComponent
+ ],
providers: [
{ provide: NvmeofService, useClass: MockNvmeOfService },
{ provide: AuthStorageService, useClass: MockAuthStorageService },
{ provide: ModalCdsService, useClass: MockModalCdsService },
{ provide: TaskWrapperService, useClass: MockTaskWrapperService },
- { provide: NvmeofStateService, useValue: nvmeofStateServiceMock }
+ { provide: ActivatedRoute, useValue: activatedRouteMock },
+ {
+ provide: NvmeofStateService,
+ useValue: { refresh$: refresh$.asObservable(), requestRefresh: jest.fn() }
+ }
],
- schemas: [NO_ERRORS_SCHEMA]
+ schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
+ const router = TestBed.inject(Router);
+ jest.spyOn(router, 'navigate').mockImplementation((_commands, extras?) => {
+ const group = extras?.queryParams?.['group'];
+ const params = group ? { group: String(group) } : {};
+ activatedRouteMock.snapshot.queryParams = params;
+ queryParams$.next(params);
+ return Promise.resolve(true);
+ });
+
fixture = TestBed.createComponent(NvmeofNamespacesListComponent);
component = fixture.componentInstance;
- component.ngOnInit();
component.subsystemNQN = 'nqn.2001-07.com.ceph:1721040751436';
+ component.ngOnInit();
fixture.detectChanges();
modalService = TestBed.inject(ModalCdsService) as any;
nvmeofService = TestBed.inject(NvmeofService) as any;
});
it('should retrieve namespaces', (done) => {
- component.group = 'g1';
+ component.groupHandler.group = 'g1';
component.namespaces$.pipe(take(1)).subscribe((namespaces) => {
expect(namespaces).toEqual(
mockNamespaces.map((ns) => ({
});
it('should deduplicate namespaces by nsid and subsystem nqn', (done) => {
- component.group = 'g1';
+ component.groupHandler.group = 'g1';
nvmeofService.namespacesResponse = {
namespaces: [
{ nsid: 1, ns_subsystem_nqn: 'sub1' },
]
};
- component.namespaces$.pipe(take(1)).subscribe((namespaces) => {
+ component.namespaces$.pipe(skip(1), take(1)).subscribe((namespaces) => {
expect(namespaces).toEqual([
{ nsid: 1, ns_subsystem_nqn: 'sub1', unique_id: '1_sub1' },
{ nsid: 1, ns_subsystem_nqn: 'sub2', unique_id: '1_sub2' }
import { Icons } from '~/app/shared/enum/icons.enum';
import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { TableComponent } from '~/app/shared/datatable/table/table.component';
import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
import { FinishedTask } from '~/app/shared/models/finished-task';
import { NvmeofSubsystemNamespace } from '~/app/shared/models/nvmeof';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { NvmeofStateService } from '../nvmeof-state.service';
-import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
-import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators';
+import { GatewayGroupQueryHandlerService } from '../gateway-group-query-handler.service';
+import { BehaviorSubject, Observable, forkJoin, of, Subject } from 'rxjs';
+import { catchError, finalize, map, shareReplay, switchMap, takeUntil, tap } from 'rxjs/operators';
+import { CephServiceSpec } from '~/app/shared/models/service.interface';
@Component({
selector: 'cd-nvmeof-namespaces-list',
templateUrl: './nvmeof-namespaces-list.component.html',
styleUrls: ['./nvmeof-namespaces-list.component.scss'],
- standalone: false
+ standalone: false,
+ providers: [GatewayGroupQueryHandlerService]
})
export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
@Input()
subsystemNQN: string;
- @Input()
- group: string;
@ViewChild('deleteTpl', { static: true })
deleteTpl: TemplateRef<any>;
+ @ViewChild(TableComponent)
+ table: TableComponent;
namespacesColumns: any;
tableActions: CdTableAction[];
selection = new CdTableSelection();
private taskWrapper: TaskWrapperService,
private nvmeofService: NvmeofService,
private dimlessBinaryPipe: DimlessBinaryPipe,
- private nvmeofStateService: NvmeofStateService
+ private nvmeofStateService: NvmeofStateService,
+ public groupHandler: GatewayGroupQueryHandlerService
) {
this.permission = this.authStorageService.getPermissions().nvmeof;
}
ngOnInit() {
+ this.groupHandler.init();
+ this.groupHandler.dataRefresh$
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(() => this.listNamespaces());
this.namespacesColumns = [
{
name: $localize`Namespace ID`,
click: () => {
this.router.navigate(['block/nvmeof/namespaces/create'], {
queryParams: {
- group: this.group,
+ group: this.groupHandler.group,
subsystem_nqn: this.subsystemNQN
}
});
},
canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
- disable: () => !this.group
+ disable: () => !this.groupHandler.group
},
{
name: $localize`Expand`,
],
{
relativeTo: this.route,
- queryParams: { group: this.group },
+ queryParams: { group: this.groupHandler.group },
queryParamsHandling: 'merge'
}
);
this.namespaces$ = this.namespaceSubject.pipe(
switchMap(() => {
- if (!this.group) {
- return of([]);
+ if (!this.groupHandler.group) {
+ if (this.groupHandler.groupSelectionCleared) {
+ return of([]);
+ }
+ return this.fetchAllGroupsNamespaces();
}
- return this.nvmeofService.listNamespaces(this.group).pipe(
- map((res: NvmeofSubsystemNamespace[] | { namespaces: NvmeofSubsystemNamespace[] }) => {
- const namespaces = Array.isArray(res) ? res : res.namespaces || [];
- // Deduplicate by nsid + subsystem NQN (API with wildcard can return duplicates per gateway)
- const seen = new Set<string>();
- return namespaces
- .filter((ns) => {
- const key = `${ns.nsid}_${ns['ns_subsystem_nqn']}`;
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- })
- .map((ns) => ({
- ...ns,
- unique_id: `${ns.nsid}_${ns['ns_subsystem_nqn']}`
- }));
- }),
+ return this.nvmeofService.listNamespaces(this.groupHandler.group).pipe(
+ map((res: NvmeofSubsystemNamespace[] | { namespaces: NvmeofSubsystemNamespace[] }) =>
+ this.normalizeAndDedup(res)
+ ),
catchError(() => of([]))
);
}),
+ tap(() => this.setTableLoading(false)),
+ finalize(() => this.setTableLoading(false)),
+ shareReplay({ bufferSize: 1, refCount: true }),
takeUntil(this.destroy$)
);
this.nvmeofStateService.refresh$
.subscribe(() => this.fetchData());
}
- onGroupChange(group: string | null): void {
- this.group = group;
- this.namespaceSubject.next();
+ private normalizeAndDedup(
+ res: NvmeofSubsystemNamespace[] | { namespaces: NvmeofSubsystemNamespace[] }
+ ): (NvmeofSubsystemNamespace & { unique_id: string })[] {
+ const namespaces = Array.isArray(res) ? res : res.namespaces || [];
+ const seen = new Set<string>();
+ return namespaces
+ .filter((ns) => {
+ const key = `${ns.nsid}_${ns['ns_subsystem_nqn']}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ })
+ .map((ns) => ({ ...ns, unique_id: `${ns.nsid}_${ns['ns_subsystem_nqn']}` }));
+ }
+
+ private fetchAllGroupsNamespaces() {
+ return this.nvmeofService.listGatewayGroups().pipe(
+ map((gatewayGroups: CephServiceSpec[][]) => this.extractValidGroups(gatewayGroups)),
+ switchMap((groups) => this.fetchNamespacesForGroups(groups)),
+ catchError(() => of([]))
+ );
+ }
+
+ private extractValidGroups(gatewayGroups: CephServiceSpec[][]): CephServiceSpec[] {
+ const firstItem = gatewayGroups?.[0];
+ const groups = Array.isArray(firstItem) ? firstItem : [];
+ return groups.filter((g) => g?.spec?.group);
+ }
+
+ private fetchNamespacesForGroups(
+ groups: CephServiceSpec[]
+ ): Observable<(NvmeofSubsystemNamespace & { unique_id: string })[]> {
+ if (groups.length === 0) return of([]);
+ return forkJoin(groups.map((g) => this.fetchNamespacesForGroup(g.spec.group))).pipe(
+ map((results) => this.deduplicateAcrossGroups(results.flat()))
+ );
+ }
+
+ private fetchNamespacesForGroup(
+ groupName: string
+ ): Observable<(NvmeofSubsystemNamespace & { unique_id: string })[]> {
+ return this.nvmeofService.listNamespaces(groupName).pipe(
+ map((res: NvmeofSubsystemNamespace[] | { namespaces: NvmeofSubsystemNamespace[] }) =>
+ this.normalizeAndDedup(res)
+ ),
+ catchError(() => of([]))
+ );
+ }
+
+ private deduplicateAcrossGroups(
+ namespaces: (NvmeofSubsystemNamespace & { unique_id: string })[]
+ ): (NvmeofSubsystemNamespace & { unique_id: string })[] {
+ const seen = new Set<string>();
+ return namespaces.filter((ns) => {
+ if (seen.has(ns.unique_id)) return false;
+ seen.add(ns.unique_id);
+ return true;
+ });
}
updateSelection(selection: CdTableSelection) {
this.selection = selection;
}
- fetchData() {
+ listNamespaces() {
+ this.setTableLoading(true);
this.namespaceSubject.next();
}
+ fetchData() {
+ this.listNamespaces();
+ }
+
+ private setTableLoading(loading: boolean): void {
+ if (this.table) {
+ this.table.loadingIndicator = loading;
+ }
+ }
+
deleteNamespaceModal() {
const namespace = this.selection.first();
const subsystemNqn = namespace.ns_subsystem_nqn;
nqn: subsystemNqn,
nsid: namespace.nsid
}),
- call: this.nvmeofService.deleteNamespace(subsystemNqn, namespace.nsid, this.group)
+ call: this.nvmeofService.deleteNamespace(
+ subsystemNqn,
+ namespace.nsid,
+ this.groupHandler.group
+ )
})
.pipe(tap({ complete: () => this.nvmeofStateService.requestRefresh() }))
});
[description]="cards.subsystem.description"
[isConfigured]="hasSubsystems"
[successMessage]="cards.subsystem.successMessage"
- [infoMessage]="hasGatewayGroups ? cards.subsystem.infoMessage : gatewayPendingMessage">
+ [infoMessage]="cards.subsystem.infoMessage">
</cd-setup-step-card>
</div>
[description]="cards.namespace.description"
[isConfigured]="hasNamespaces"
[successMessage]="cards.namespace.successMessage"
- [infoMessage]="hasGatewayGroups ? cards.namespace.infoMessage : gatewayPendingMessage">
+ [infoMessage]="cards.namespace.infoMessage">
</cd-setup-step-card>
</div>
</div>
i18n>Configuration complete. View status →</a>
</div>
}
-</cd-productive-card>
+</cd-productive-card>
\ No newline at end of file
expect(link).toBeTruthy();
});
+ it('should not emit viewStatus from the disabled completion link', () => {
+ component.isAllConfigured = true;
+ fixture.detectChanges();
+
+ const emitSpy = jest.spyOn(component.viewStatus, 'emit');
+ const link = fixture.nativeElement.querySelector('.nvmeof-setup-cards__completion a');
+ link.click();
+
+ expect(emitSpy).not.toHaveBeenCalled();
+ });
+
describe('setup state', () => {
const getStepCards = () =>
fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card');
});
});
- it('should display "No gateway configured yet." on step 2 and 3 when hasGatewayGroups is false', () => {
+ it('should keep subsystem and namespace info messages when hasGatewayGroups is false', () => {
component.hasGatewayGroups = false;
component.hasSubsystems = false;
component.hasNamespaces = false;
const subsystemCard = cardElements[1].componentInstance;
const namespaceCard = cardElements[2].componentInstance;
- expect(subsystemCard.statusMessage).toBe('No gateway configured yet.');
- expect(namespaceCard.statusMessage).toBe('No gateway configured yet.');
+ expect(subsystemCard.statusMessage).toBe('No subsystem configured for this cluster yet.');
+ expect(namespaceCard.statusMessage).toBe('No namespace allocated or mapped yet.');
});
it('should display original info messages when hasGatewayGroups is true', () => {
expect(getCards()[0].componentInstance.statusMessage).toBe(
'No gateway groups configured for this cluster yet.'
);
- expect(getCards()[1].componentInstance.statusMessage).toBe('No gateway configured yet.');
- expect(getCards()[2].componentInstance.statusMessage).toBe('No gateway configured yet.');
+ expect(getCards()[1].componentInstance.statusMessage).toBe(
+ 'No subsystem configured for this cluster yet.'
+ );
+ expect(getCards()[2].componentInstance.statusMessage).toBe(
+ 'No namespace allocated or mapped yet.'
+ );
});
});
import { CommonModule } from '@angular/common';
-import { Component, Input, ViewEncapsulation } from '@angular/core';
+import { Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core';
import { RouterModule } from '@angular/router';
import { LayoutModule, LayerModule, LinkModule, TilesModule } from 'carbon-components-angular';
import { ProductiveCardComponent } from '~/app/shared/components/productive-card/productive-card.component';
@Input() hasSubsystems = false;
@Input() hasNamespaces = false;
@Input() isAllConfigured = false;
-
- readonly gatewayPendingMessage = $localize`No gateway configured yet.`;
+ @Output() viewStatus = new EventEmitter<void>();
readonly cards = {
gateway: {
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { ActivatedRoute } from '@angular/router';
-import { of, Subject } from 'rxjs';
+import { Subject, of } from 'rxjs';
import { NvmeofSubsystemNamespacesListComponent } from './nvmeof-subsystem-namespaces-list.component';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
}
beforeEach(async () => {
- const nvmeofStateServiceMock = {
- refresh$: new Subject<void>(),
- requestRefresh: jest.fn()
- };
-
+ const refresh$ = new Subject<void>();
await TestBed.configureTestingModule({
declarations: [NvmeofSubsystemNamespacesListComponent],
imports: [HttpClientTestingModule, RouterTestingModule, SharedModule],
}
},
{ provide: AuthStorageService, useClass: MockAuthStorageService },
- { provide: NvmeofStateService, useValue: nvmeofStateServiceMock }
+ {
+ provide: NvmeofStateService,
+ useValue: { refresh$: refresh$.asObservable(), requestRefresh: jest.fn() }
+ }
]
}).compileComponents();
});
-<cd-nvmeof-gateway-group-filter
- (groupChange)="onGroupChange($event)">
-</cd-nvmeof-gateway-group-filter>
-
+<div class="nvmeof-content-layout">
+ <div class="nvmeof-content-main">
<ng-container *ngIf="subsystems$ | async as subsystems">
<cd-table #table
[data]="subsystems"
[columns]="subsystemsColumns"
+ [compactSearchField]="true"
columnMode="flex"
selectionType="single"
(updateSelection)="updateSelection($event)"
emptyStateMessage="Subsystems group NVMe namespaces and manage host access. Create a subsystem to start mapping NVMe volumes to hosts."
i18n-emptyStateMessage>
+ <div class="table-filter">
+ <cd-nvmeof-gateway-group-filter
+ (groupChange)="groupHandler.onGroupChange($event)">
+ </cd-nvmeof-gateway-group-filter>
+ </div>
+
<div class="table-actions">
<cd-table-actions [permission]="permissions.nvmeof"
[selection]="selection"
</cd-nvmeof-subsystems-details>
</cd-table>
</ng-container>
+ </div>
+</div>
<ng-template #customTableItemTemplate
let-value="data.value"
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientModule } from '@angular/common/http';
import { ActivatedRoute, Router } from '@angular/router';
-import { BehaviorSubject, of, Subject } from 'rxjs';
-import { skip, take } from 'rxjs/operators';
+import { BehaviorSubject, Subject, of } from 'rxjs';
+import { take } from 'rxjs/operators';
import { RouterTestingModule } from '@angular/router/testing';
import { SharedModule } from '~/app/shared/shared.module';
import { NvmeofSubsystemsComponent } from './nvmeof-subsystems.component';
import { NvmeofSubsystemsDetailsComponent } from '../nvmeof-subsystems-details/nvmeof-subsystems-details.component';
import { NvmeofGatewayGroupFilterComponent } from '../nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component';
-import { ComboBoxModule, GridModule } from 'carbon-components-angular';
import { NvmeofStateService } from '../nvmeof-state.service';
const mockSubsystems = [
}
];
+const mockGroups = [
+ [
+ {
+ service_name: 'nvmeof.default',
+ service_type: 'nvmeof',
+ service_id: 'default',
+ spec: { group: 'default' }
+ }
+ ]
+];
+
class MockNvmeOfService {
listGatewayGroups() {
return of([
describe('NvmeofSubsystemsComponent', () => {
let component: NvmeofSubsystemsComponent;
let fixture: ComponentFixture<NvmeofSubsystemsComponent>;
- let nvmeofService: MockNvmeOfService;
let queryParams$: BehaviorSubject<Record<string, string>>;
const activatedRouteMock = {
queryParams: null as any,
};
beforeEach(async () => {
+ const refresh$ = new Subject<void>();
queryParams$ = new BehaviorSubject<Record<string, string>>({});
activatedRouteMock.queryParams = queryParams$.asObservable();
activatedRouteMock.snapshot.queryParams = queryParams$.value;
- const nvmeofStateServiceMock = {
- refresh$: new Subject<void>(),
- requestRefresh: jest.fn()
- };
-
await TestBed.configureTestingModule({
declarations: [NvmeofSubsystemsComponent, NvmeofSubsystemsDetailsComponent],
imports: [
HttpClientModule,
RouterTestingModule,
SharedModule,
- ComboBoxModule,
- GridModule,
NvmeofGatewayGroupFilterComponent
],
providers: [
{ provide: ModalCdsService, useClass: MockModalService },
{ provide: TaskWrapperService, useClass: MockTaskWrapperService },
{ provide: ActivatedRoute, useValue: activatedRouteMock },
- { provide: NvmeofStateService, useValue: nvmeofStateServiceMock }
+ {
+ provide: NvmeofStateService,
+ useValue: { refresh$: refresh$.asObservable(), requestRefresh: jest.fn() }
+ }
]
}).compileComponents();
fixture = TestBed.createComponent(NvmeofSubsystemsComponent);
component = fixture.componentInstance;
- nvmeofService = TestBed.inject(NvmeofService) as any;
component.ngOnInit();
fixture.detectChanges();
});
it('should retrieve subsystems', (done) => {
const expected = mockSubsystems.map((s) => ({
...s,
- gw_group: 'default',
+ gw_group: component.groupHandler.group,
auth: 'No authentication',
initiator_count: 0
}));
- component.onGroupChange('default');
- component.subsystems$.pipe(skip(1), take(1)).subscribe((subsystems) => {
+ component.subsystems$.pipe(take(1)).subscribe((subsystems) => {
expect(subsystems).toEqual(expected);
done();
});
- component.fetchData();
+ component.getSubsystems();
});
- it('should not fetch subsystems when group is not selected', (done) => {
- const listSubsystemsSpy = jest.spyOn(nvmeofService, 'listSubsystems');
- component.group = null;
- component.fetchData();
+ it('should set first group as default initially', () => {
+ expect(component.groupHandler.group).toBe(mockGroups[0][0].spec.group);
+ });
+ it('should show subsystems across groups when dropdown selection is cleared', (done) => {
+ component.groupHandler.onGroupClear();
component.subsystems$.pipe(take(1)).subscribe((subsystems) => {
- expect(subsystems).toEqual([]);
- expect(listSubsystemsSpy).not.toHaveBeenCalled();
+ expect(subsystems.length).toBeGreaterThan(0);
done();
});
- });
-
- it('should set first group as default initially', () => {
- expect(component.group).toBe('default');
+ component.getSubsystems();
});
it('should clear selected group and stop fetching subsystems', () => {
- component.group = 'default';
+ component.groupHandler.group = 'default';
- component.onGroupChange(null);
+ component.groupHandler.onGroupChange(null);
- expect(component.group).toBeNull();
+ expect(component.groupHandler.group).toBeNull();
});
});
import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { TableComponent } from '~/app/shared/datatable/table/table.component';
import { Icons } from '~/app/shared/enum/icons.enum';
import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
import { NvmeofStateService } from '../nvmeof-state.service';
+import { GatewayGroupQueryHandlerService } from '../gateway-group-query-handler.service';
import { BehaviorSubject, forkJoin, Observable, of, Subject } from 'rxjs';
-import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators';
+import { catchError, finalize, map, shareReplay, switchMap, takeUntil, tap } from 'rxjs/operators';
import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
+import { CephServiceSpec } from '~/app/shared/models/service.interface';
const BASE_URL = 'block/nvmeof/subsystems';
selector: 'cd-nvmeof-subsystems',
templateUrl: './nvmeof-subsystems.component.html',
styleUrls: ['./nvmeof-subsystems.component.scss'],
- standalone: false
+ standalone: false,
+ providers: [GatewayGroupQueryHandlerService]
})
export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit, OnDestroy {
@ViewChild('authenticationTpl', { static: true })
@ViewChild('customTableItemTemplate', { static: true })
customTableItemTemplate: TemplateRef<any>;
+ @ViewChild(TableComponent)
+ table: TableComponent;
+
subsystems: (NvmeofSubsystem & { gw_group?: string; initiator_count?: number })[] = [];
pendingNqn: string = null;
subsystemsColumns: any;
tableActions: CdTableAction[];
subsystemDetails: any[];
context: CdTableFetchDataContext;
- group: string = null;
authType = NvmeofSubsystemAuthType;
subsystems$: Observable<(NvmeofSubsystem & { gw_group?: string; initiator_count?: number })[]>;
+
private subsystemSubject = new BehaviorSubject<void>(undefined);
private destroy$ = new Subject<void>();
private router: Router,
private modalService: ModalCdsService,
private taskWrapper: TaskWrapperService,
- private nvmeofStateService: NvmeofStateService
+ private nvmeofStateService: NvmeofStateService,
+ public groupHandler: GatewayGroupQueryHandlerService
) {
super();
this.permissions = this.authStorageService.getPermissions();
}
ngOnInit() {
+ this.groupHandler.init();
+ this.groupHandler.dataRefresh$
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(() => this.getSubsystems());
this.subsystemsColumns = [
{
name: $localize`Subsystem NQN`,
icon: Icons.add,
click: () =>
this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.CREATE] } }], {
- queryParams: { group: this.group }
+ queryParams: { group: this.groupHandler.group }
}),
canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
- disable: () => !this.group
+ disable: () => !this.groupHandler.group
},
{
name: this.actionLabels.DELETE,
this.subsystems$ = this.subsystemSubject.pipe(
switchMap(() => {
- if (!this.group) {
- return of([]);
+ if (!this.groupHandler.group) {
+ if (this.groupHandler.groupSelectionCleared) {
+ return of([]);
+ }
+ return this.fetchAllGroupsSubsystems();
}
- return this.nvmeofService.listSubsystems(this.group).pipe(
- switchMap((subsystems: NvmeofSubsystem[] | NvmeofSubsystem) => {
- const subs = Array.isArray(subsystems) ? subsystems : [subsystems];
+ return this.nvmeofService.listSubsystems(this.groupHandler.group).pipe(
+ switchMap((subsystems: any) => {
+ const subs: NvmeofSubsystem[] = Array.isArray(subsystems) ? subsystems : [subsystems];
if (subs.length === 0) return of([]);
- return forkJoin(subs.map((sub) => this.enrichSubsystemWithInitiators(sub)));
+ return forkJoin(
+ subs.map((sub) => this.enrichSubsystemForGroup(sub, this.groupHandler.group))
+ );
}),
catchError((error) => {
this.handleError(error);
}),
tap((subs) => {
this.subsystems = subs;
+ this.setTableLoading(false);
}),
+ finalize(() => this.setTableLoading(false)),
+ shareReplay({ bufferSize: 1, refCount: true }),
takeUntil(this.destroy$)
);
this.nvmeofStateService.refresh$
.subscribe(() => this.fetchData());
}
- onGroupChange(group: string | null): void {
- this.group = group;
+ getSubsystems() {
+ this.setTableLoading(true);
this.subsystemSubject.next();
}
}
fetchData() {
- this.subsystemSubject.next();
+ this.getSubsystems();
+ }
+
+ private setTableLoading(loading: boolean): void {
+ if (this.table) {
+ this.table.loadingIndicator = loading;
+ }
}
deleteSubsystemModal() {
this.taskWrapper
.wrapTaskAroundCall({
task: new FinishedTask('nvmeof/subsystem/delete', { nqn: subsystem.nqn }),
- call: this.nvmeofService.deleteSubsystem(subsystem.nqn, this.group)
+ call: this.nvmeofService.deleteSubsystem(subsystem.nqn, this.groupHandler.group)
})
.pipe(tap({ complete: () => this.nvmeofStateService.requestRefresh() }))
});
this.context?.error?.(error);
}
- private enrichSubsystemWithInitiators(sub: NvmeofSubsystem) {
- return this.nvmeofService.getInitiators(sub.nqn, this.group).pipe(
+ private enrichSubsystemForGroup(sub: NvmeofSubsystem, group: string) {
+ return this.nvmeofService.getInitiators(sub.nqn, group).pipe(
catchError(() => of([])),
map((initiators: NvmeofSubsystemInitiator[] | { hosts?: NvmeofSubsystemInitiator[] }) => {
let count = 0;
else if (initiators?.hosts && Array.isArray(initiators.hosts)) {
count = initiators.hosts.length;
}
-
return {
...sub,
- gw_group: this.group,
+ gw_group: group,
initiator_count: count,
auth: getSubsystemAuthStatus(sub, initiators)
} as NvmeofSubsystem & { initiator_count?: number; auth?: string };
);
}
+ private fetchAllGroupsSubsystems() {
+ return this.nvmeofService.listGatewayGroups().pipe(
+ map((gatewayGroups) => this.extractValidGroups(gatewayGroups)),
+ switchMap((groups) => this.fetchSubsystemsForGroups(groups)),
+ catchError(() => of([]))
+ );
+ }
+
+ private extractValidGroups(gatewayGroups: CephServiceSpec[][]): CephServiceSpec[] {
+ const firstItem = gatewayGroups?.[0];
+ const groups = Array.isArray(firstItem) ? firstItem : [];
+ return groups.filter((g) => g?.spec?.group);
+ }
+
+ private fetchSubsystemsForGroups(groups: CephServiceSpec[]): Observable<NvmeofSubsystem[]> {
+ if (groups.length === 0) return of([]);
+ return forkJoin(groups.map((g) => this.fetchSubsystemsForGroup(g.spec.group))).pipe(
+ map((results) => results.flat())
+ );
+ }
+
+ private fetchSubsystemsForGroup(groupName: string): Observable<NvmeofSubsystem[]> {
+ return this.nvmeofService.listSubsystems(groupName).pipe(
+ switchMap((subsystems) => this.enrichSubsystems(subsystems, groupName)),
+ catchError(() => of([]))
+ );
+ }
+
+ private enrichSubsystems(subsystems: any, groupName: string): Observable<NvmeofSubsystem[]> {
+ const subs = Array.isArray(subsystems) ? subsystems : [subsystems];
+ if (subs.length === 0) return of([]);
+ return forkJoin(subs.map((sub) => this.enrichSubsystemForGroup(sub, groupName)));
+ }
+
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ActivatedRoute, Event as RouterEvent, NavigationEnd, Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
-import { HttpClientTestingModule } from '@angular/common/http/testing';
import { BehaviorSubject, Subject, of } from 'rxjs';
import { TabsModule } from 'carbon-components-angular';
let refresh$: Subject<void>;
let routerEvents$: Subject<RouterEvent>;
let currentSetupState: SetupState;
+ let routerUrl = '/block/nvmeof/gateways';
const setQueryParams = (params: any) => queryParams$.next(params);
const emitRefresh = () => refresh$.next();
+ const setRouterUrl = (url: string) => {
+ routerUrl = url;
+ };
const setSetupState = (state: SetupState) => {
currentSetupState = state;
nvmeofServiceSpy.fetchSetupState.mockReturnValue(of(currentSetupState));
beforeEach(async () => {
queryParams$ = new BehaviorSubject<any>({ group: 'grp1' });
refresh$ = new Subject<void>();
- const nvmeofStateServiceMock = {
- refresh$: refresh$.asObservable()
- };
+ routerUrl = '/block/nvmeof/gateways';
currentSetupState = { hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true };
nvmeofServiceSpy = {
fetchSetupState: jest.fn().mockImplementation(() => of(currentSetupState))
TestBed.configureTestingModule({
declarations: [NvmeofTabsComponent],
imports: [
- RouterTestingModule,
HttpClientTestingModule,
+ RouterTestingModule,
SharedModule,
TabsModule,
NvmeofSetupCardsComponent
{ provide: NvmeofService, useValue: nvmeofServiceSpy },
{ provide: ActivatedRoute, useValue: { queryParams: queryParams$.asObservable() } }
]
- });
- TestBed.overrideComponent(NvmeofTabsComponent, {
- set: { providers: [{ provide: NvmeofStateService, useValue: nvmeofStateServiceMock }] }
- });
- await TestBed.compileComponents();
+ })
+ .overrideComponent(NvmeofTabsComponent, {
+ set: {
+ providers: [
+ {
+ provide: NvmeofStateService,
+ useValue: {
+ refresh$: refresh$.asObservable(),
+ requestRefresh: jest.fn()
+ }
+ }
+ ]
+ }
+ })
+ .compileComponents();
fixture = TestBed.createComponent(NvmeofTabsComponent);
component = fixture.componentInstance;
router = TestBed.inject(Router);
routerEvents$ = new Subject<RouterEvent>();
+ Object.defineProperty(router, 'events', {
+ configurable: true,
+ get: () => routerEvents$.asObservable()
+ });
Object.defineProperty(router, 'url', {
- get: () => '/block/nvmeof/gateways',
- configurable: true
+ configurable: true,
+ get: () => routerUrl
+ });
+ Object.defineProperty(router, 'navigate', {
+ configurable: true,
+ value: jest.fn().mockResolvedValue(true)
});
- jest.spyOn(router, 'events', 'get').mockReturnValue(routerEvents$.asObservable());
});
it('should create', () => {
});
it('should default activeTab to gateways', () => {
- jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/gateways');
+ setRouterUrl('/block/nvmeof/gateways');
component.ngOnInit();
expect(component.activeTab).toBe(component.Tabs.gateways);
});
it('should set activeTab to subsystems when URL contains subsystems', () => {
- jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/subsystems');
+ setRouterUrl('/block/nvmeof/subsystems');
component.ngOnInit();
expect(component.activeTab).toBe(component.Tabs.subsystems);
});
it('should set activeTab to namespaces when URL contains namespaces', () => {
- jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/namespaces');
+ setRouterUrl('/block/nvmeof/namespaces');
component.ngOnInit();
expect(component.activeTab).toBe(component.Tabs.namespaces);
});
it('should fallback to gateways when URL does not match any tab', () => {
- jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/unknown');
+ setRouterUrl('/block/nvmeof/unknown');
component.ngOnInit();
expect(component.activeTab).toBe(component.Tabs.gateways);
});
it('should hide the shell on namespace create routes', () => {
- jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/namespaces/create');
+ setRouterUrl('/block/nvmeof/namespaces/create');
component.ngOnInit();
expect(component.showTabsShell).toBe(false);
});
it('should keep the shell visible on namespace list routes', () => {
- jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/namespaces');
+ setRouterUrl('/block/nvmeof/namespaces');
component.ngOnInit();
expect(component.showTabsShell).toBe(true);
});
it('should keep the shell visible on list routes with a secondary outlet', () => {
- jest
- .spyOn(router, 'url', 'get')
- .mockReturnValue('/block/nvmeof/subsystems(modal:create)?group=default');
+ setRouterUrl('/block/nvmeof/subsystems(modal:create)?group=default');
component.ngOnInit();
expect(component.showTabsShell).toBe(true);
});
it('should hide the shell when primary route is a create page with secondary outlet', () => {
- jest
- .spyOn(router, 'url', 'get')
- .mockReturnValue('/block/nvmeof/subsystems/create(modal:create)?group=default');
+ setRouterUrl('/block/nvmeof/subsystems/create(modal:create)?group=default');
component.ngOnInit();
expect(component.showTabsShell).toBe(false);
});
it('should navigate to correct path on tab selection', () => {
- spyOn(router, 'navigate');
component.onSelected(component.Tabs.subsystems);
expect(component.activeTab).toBe(component.Tabs.subsystems);
expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof', 'subsystems'], {
});
it('should navigate to gateways on selecting gateways tab', () => {
- spyOn(router, 'navigate');
component.onSelected(component.Tabs.gateways);
expect(component.activeTab).toBe(component.Tabs.gateways);
expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof', 'gateways'], {
});
it('should navigate to namespaces on selecting namespaces tab', () => {
- spyOn(router, 'navigate');
component.onSelected(component.Tabs.namespaces);
expect(component.activeTab).toBe(component.Tabs.namespaces);
expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof', 'namespaces'], {
expect(component.hasSubsystems).toBe(true);
expect(component.hasNamespaces).toBe(true);
expect(component.isAllConfigured).toBe(true);
+ expect(component.showSetupCards).toBe(true);
});
it('scenario: no gateway groups — all steps pending', () => {
expect(component.isAllConfigured).toBe(false);
});
+ it('scenario: selected gateway group does not exist — setup still reflects all groups', () => {
+ component.ngOnInit();
+ setQueryParams({ group: 'grp-other' });
+ expect(component.hasGatewayGroups).toBe(true);
+ expect(component.hasSubsystems).toBe(true);
+ expect(component.hasNamespaces).toBe(true);
+ expect(component.isAllConfigured).toBe(true);
+ });
+
it('scenario: no subsystems in object response across all groups — step 1 complete', () => {
setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false });
component.ngOnInit();
});
it('should render correct setup card messages after all gateway groups are removed', () => {
- jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/gateways');
- setSetupState({ hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true });
+ setRouterUrl('/block/nvmeof/gateways');
component.ngOnInit();
setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false });
expect(cardElements[0].componentInstance.statusMessage).toBe(
'No gateway groups configured for this cluster yet.'
);
- expect(cardElements[1].componentInstance.statusMessage).toBe('No gateway configured yet.');
- expect(cardElements[2].componentInstance.statusMessage).toBe('No gateway configured yet.');
+ expect(cardElements[1].componentInstance.statusMessage).toBe(
+ 'No subsystem configured for this cluster yet.'
+ );
+ expect(cardElements[2].componentInstance.statusMessage).toBe(
+ 'No namespace allocated or mapped yet.'
+ );
});
it('should render success setup card messages before gateway groups are removed', () => {
- jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/gateways');
+ setRouterUrl('/block/nvmeof/gateways');
component.ngOnInit();
emitRefresh();
fixture.detectChanges();
this.hasSubsystems = hasSubsystems;
this.hasNamespaces = hasNamespaces;
this.isAllConfigured = hasGatewayGroups && hasSubsystems && hasNamespaces;
- this.showSetupCards = !this.isAllConfigured;
+ this.showSetupCards = true;
});
}
}}
@if (customFilter) {
@for (filter of stagedCustomFilters; track filter.id; let i = $index) {
- @if (typeof customFilter === 'string') {
+ @if (isCustomFilterLabel()) {
<div cdsRow
class="cds-mt-4 cds--type-body-02">
Filter {{ customFilter }}:
@Input()
customFilter: boolean | string = false;
+ isCustomFilterLabel(): boolean {
+ return typeof this.customFilter === 'string';
+ }
+
@Input()
status = new TableStatus();
@Output()
setExpandedRow = new EventEmitter();
+ @Input()
+ compactSearchField? = false;
/**
* This should be defined if you need access to the applied column filters.
.popover {
--bs-popover-zindex: 9999;
}
+
+// Remove bottom border from NVMeoF gateway group filter combobox
+.nvmeof-gateway-group-filter__combo {
+ .cds--combo-box,
+ .cds--text-input,
+ .cds--list-box,
+ input[role='combobox'],
+ input {
+ border-bottom: none !important;
+ }
+
+ .cds--text-input__field-wrapper {
+ border-bottom: none !important;
+ }
+}