import { NvmeofGatewayGroupComponent } from './nvmeof-gateway-group/nvmeof-gateway-group.component';
import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component';
import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node/nvmeof-gateway-node.component';
-import { NvmeofGroupFormComponent } from './nvmeof-group-form /nvmeof-group-form.component';
+import { NvmeofGroupFormComponent } from './nvmeof-group-form/nvmeof-group-form.component';
import {
ButtonModule,
+++ /dev/null
-<form
- #formDir="ngForm"
- [formGroup]="groupForm"
- novalidate
->
- <div cdsGrid
- [useCssGrid]="true"
- [narrow]="true"
- [fullWidth]="true">
- <div cdsCol
- [columnNumbers]="{sm: 4, md: 8}">
- <div cdsRow
- class="form-heading form-item">
- <h3>{{ action | titlecase }} {{ resource }}</h3>
- <cd-help-text>
- <span i18n>
- A logical group of gateways that hosts will connect to.
- </span>
- </cd-help-text>
- <cd-help-text [formAllFieldsRequired]="true"></cd-help-text>
- </div>
- <div cdsRow
- class="form-item">
- <div cdsCol>
- <cds-text-label
- labelInputID="name"
- i18n
- i18n-helperText
- helperText="A unique name to identify this gateway group."
- cdRequiredField="Gateway group name"
- [invalid]="groupName.isInvalid">
- Gateway group name
- <input
- cdsText
- cdValidate
- type="text"
- id="groupName"
- #groupName="cdValidate"
- autofocus
- formControlName="groupName"
- [invalid]="groupName.isInvalid"
- />
- </cds-text-label>
- <span
- class="invalid-feedback"
- *ngIf="groupForm.showError('groupName', formDir, 'required')"
- i18n>This field is required.</span>
- <span
- class="invalid-feedback"
- *ngIf="groupForm.get('groupName')?.hasError('notUnique') && (groupForm.get('groupName')?.dirty || groupForm.get('groupName')?.touched)"
- i18n>Group name must be unique.</span>
- <span
- class="invalid-feedback"
- *ngIf="groupForm.get('groupName')?.hasError('invalidChars') && (groupForm.get('groupName')?.dirty || groupForm.get('groupName')?.touched)"
- i18n>Special characters are not allowed.</span>
-
- </div>
- </div>
- <!-- Pool -->
- <div cdsRow
- class="form-item">
- <div cdsCol>
- <cds-select
- label="Block pool"
- helperText="An RBD application-enabled pool in which the gateway configuration can be managed."
- labelInputID="pool"
- id="pool"
- formControlName="pool"
- cdRequiredField="Block pool"
- [invalid]="groupForm.controls.pool.invalid && groupForm.controls.pool.dirty"
- [invalidText]="poolError"
- i18n-label
- i18n-helperText
- >
- <option *ngIf="poolsLoading"
- [ngValue]="null"
- i18n>Loading...</option>
- <option *ngIf="!poolsLoading && pools.length === 0"
- [ngValue]="null"
- i18n>-- No block pools available --</option>
- <option *ngFor="let pool of pools"
- [value]="pool.pool_name">{{ pool.pool_name }}</option>
- </cds-select>
- <ng-template #poolError>
- <span class="invalid-feedback"
- *ngIf="groupForm.showError('pool', formDir, 'required')"
- i18n>This field is required.</span>
- </ng-template>
- </div>
- </div>
-
- <!-- Target Nodes Selection -->
- <div
- cdsRow
- class="form-item"
- >
- <div cdsCol>
- <h1 class="cds--type-heading-02">Select target nodes</h1>
- <cd-help-text>
- <span i18n>
- Gateway nodes to run NVMe-oF target pods/services
- </span>
- </cd-help-text>
- </div>
- <div
- cdsCol
- class="cds-pt-3 cds-pb-3"
- >
- <cd-nvmeof-gateway-node
- (hostsLoaded)="onHostsLoaded($event)"
- ></cd-nvmeof-gateway-node>
- </div>
- </div>
-
- <div cdsRow>
- <cd-form-button-panel
- (submitActionEvent)="onSubmit()"
- [form]="groupForm"
- [submitText]="(action | titlecase) + ' ' + (resource)"
- [disabled]="isCreateDisabled"
- wrappingClass="text-right form-button"
- >
- </cd-form-button-panel>
- </div>
- </div>
- </div>
-</form>
-
-
+++ /dev/null
-import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { ReactiveFormsModule } from '@angular/forms';
-import { RouterTestingModule } from '@angular/router/testing';
-import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
-import { Router } from '@angular/router';
-import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
-
-import { ToastrModule } from 'ngx-toastr';
-import { of } from 'rxjs';
-
-import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
-
-import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
-import { SharedModule } from '~/app/shared/shared.module';
-
-import { NvmeofGroupFormComponent } from './nvmeof-group-form.component';
-import { GridModule, InputModule, SelectModule } from 'carbon-components-angular';
-import { PoolService } from '~/app/shared/api/pool.service';
-import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
-import { CephServiceService } from '~/app/shared/api/ceph-service.service';
-import { FormHelper } from '~/testing/unit-test-helper';
-
-describe('NvmeofGroupFormComponent', () => {
- let component: NvmeofGroupFormComponent;
- let fixture: ComponentFixture<NvmeofGroupFormComponent>;
- let form: CdFormGroup;
- let formHelper: FormHelper;
- let poolService: PoolService;
- let taskWrapperService: TaskWrapperService;
- let cephServiceService: CephServiceService;
- let router: Router;
-
- const mockPools = [
- { pool_name: 'rbd', application_metadata: ['rbd'] },
- { pool_name: 'rbd', application_metadata: ['rbd'] },
- { pool_name: 'pool2', application_metadata: ['rgw'] }
- ];
-
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- declarations: [NvmeofGroupFormComponent],
- providers: [NgbActiveModal],
- imports: [
- HttpClientTestingModule,
- NgbTypeaheadModule,
- ReactiveFormsModule,
- RouterTestingModule,
- SharedModule,
- GridModule,
- InputModule,
- SelectModule,
- ToastrModule.forRoot()
- ],
- schemas: [CUSTOM_ELEMENTS_SCHEMA]
- }).compileComponents();
-
- fixture = TestBed.createComponent(NvmeofGroupFormComponent);
- component = fixture.componentInstance;
- poolService = TestBed.inject(PoolService);
- taskWrapperService = TestBed.inject(TaskWrapperService);
- cephServiceService = TestBed.inject(CephServiceService);
- router = TestBed.inject(Router);
-
- spyOn(poolService, 'list').and.returnValue(Promise.resolve(mockPools));
-
- component.ngOnInit();
- form = component.groupForm;
- formHelper = new FormHelper(form);
- fixture.detectChanges();
- });
-
- it('should create', () => {
- expect(component).toBeTruthy();
- });
-
- it('should initialize form with empty fields', () => {
- expect(form.controls.groupName.value).toBeNull();
- expect(form.controls.unmanaged.value).toBe(false);
- });
-
- it('should set action to CREATE on init', () => {
- expect(component.action).toBe('Create');
- });
-
- it('should set resource to gateway group', () => {
- expect(component.resource).toBe('gateway group');
- });
-
- describe('form validation', () => {
- it('should require groupName', () => {
- formHelper.setValue('groupName', '');
- formHelper.expectError('groupName', 'required');
- });
-
- it('should require pool', () => {
- formHelper.setValue('pool', null);
- formHelper.expectError('pool', 'required');
- });
-
- it('should be valid when groupName and pool are set', () => {
- formHelper.setValue('groupName', 'test-group');
- formHelper.setValue('pool', 'rbd');
- expect(form.valid).toBe(true);
- });
- });
-
- describe('loadPools', () => {
- it('should load pools and filter by rbd application metadata', fakeAsync(() => {
- component.loadPools();
- tick();
- expect(component.pools.length).toBe(2);
- expect(component.pools.map((p) => p.pool_name)).toEqual(['rbd', 'rbd']);
- }));
-
- it('should set default pool to rbd if available', fakeAsync(() => {
- component.groupForm.get('pool').setValue(null);
- component.loadPools();
- tick();
- expect(component.groupForm.get('pool').value).toBe('rbd');
- }));
-
- it('should set first pool if rbd is not available', fakeAsync(() => {
- component.groupForm.get('pool').setValue(null);
- const poolsWithoutRbd = [{ pool_name: 'custom-pool', application_metadata: ['rbd'] }];
- (poolService.list as jasmine.Spy).and.returnValue(Promise.resolve(poolsWithoutRbd));
- component.loadPools();
- tick();
- expect(component.groupForm.get('pool').value).toBe('custom-pool');
- }));
-
- it('should handle empty pools', fakeAsync(() => {
- (poolService.list as jasmine.Spy).and.returnValue(Promise.resolve([]));
- component.loadPools();
- tick();
- expect(component.pools.length).toBe(0);
- expect(component.poolsLoading).toBe(false);
- }));
-
- it('should handle pool loading error', fakeAsync(() => {
- (poolService.list as jasmine.Spy).and.returnValue(Promise.reject('error'));
- component.loadPools();
- tick();
- expect(component.pools).toEqual([]);
- expect(component.poolsLoading).toBe(false);
- }));
- });
-
- describe('onSubmit', () => {
- beforeEach(() => {
- spyOn(cephServiceService, 'create').and.returnValue(of({}));
- spyOn(taskWrapperService, 'wrapTaskAroundCall').and.callFake(({ call }) => call);
- spyOn(router, 'navigateByUrl');
- });
-
- it('should not call create if no hosts are selected', () => {
- component.gatewayNodeComponent = {
- getSelectedHosts: (): any[] => [],
- getSelectedHostnames: (): string[] => []
- } as any;
-
- component.groupForm.get('groupName').setValue('test-group');
- component.groupForm.get('pool').setValue('rbd');
- component.onSubmit();
-
- expect(cephServiceService.create).not.toHaveBeenCalled();
- });
-
- it('should create service with correct spec', () => {
- component.gatewayNodeComponent = {
- getSelectedHosts: (): any[] => [{ hostname: 'host1' }, { hostname: 'host2' }],
- getSelectedHostnames: (): string[] => ['host1', 'host2']
- } as any;
-
- component.groupForm.get('groupName').setValue('defalut');
- component.groupForm.get('pool').setValue('rbd');
- component.groupForm.get('unmanaged').setValue(false);
- component.onSubmit();
-
- expect(cephServiceService.create).toHaveBeenCalledWith({
- service_type: 'nvmeof',
- service_id: 'rbd.defalut',
- pool: 'rbd',
- group: 'defalut',
- placement: {
- hosts: ['host1', 'host2']
- },
- unmanaged: false
- });
- });
-
- it('should create service with unmanaged flag set to true', () => {
- component.gatewayNodeComponent = {
- getSelectedHosts: (): any[] => [{ hostname: 'host1' }],
- getSelectedHostnames: (): string[] => ['host1']
- } as any;
-
- component.groupForm.get('groupName').setValue('unmanaged-group');
- component.groupForm.get('pool').setValue('rbd');
- component.groupForm.get('unmanaged').setValue(true);
- component.onSubmit();
-
- expect(cephServiceService.create).toHaveBeenCalledWith(
- jasmine.objectContaining({
- unmanaged: true,
- group: 'unmanaged-group',
- pool: 'rbd'
- })
- );
- });
-
- it('should navigate to list view on success', () => {
- component.gatewayNodeComponent = {
- getSelectedHosts: (): any[] => [{ hostname: 'host1' }],
- getSelectedHostnames: (): string[] => ['host1']
- } as any;
-
- component.groupForm.get('groupName').setValue('test-group');
- component.groupForm.get('pool').setValue('rbd');
- component.onSubmit();
-
- expect(router.navigateByUrl).toHaveBeenCalledWith('/block/nvmeof/gateways');
- });
- });
-});
+++ /dev/null
-import { Component, OnInit, ViewChild } from '@angular/core';
-import { UntypedFormControl, Validators } from '@angular/forms';
-import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
-import { CdForm } from '~/app/shared/forms/cd-form';
-import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
-
-import { Permission } from '~/app/shared/models/permissions';
-import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
-import { PoolService } from '~/app/shared/api/pool.service';
-import { Pool } from '../../pool/pool';
-import { NvmeofGatewayNodeComponent } from '../nvmeof-gateway-node/nvmeof-gateway-node.component';
-import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
-import { CephServiceService } from '~/app/shared/api/ceph-service.service';
-import { FinishedTask } from '~/app/shared/models/finished-task';
-import { Router } from '@angular/router';
-import { CdValidators } from '~/app/shared/forms/cd-validators';
-import { NvmeofService } from '~/app/shared/api/nvmeof.service';
-
-@Component({
- selector: 'cd-nvmeof-group-form',
- templateUrl: './nvmeof-group-form.component.html',
- styleUrls: ['./nvmeof-group-form.component.scss'],
- standalone: false
-})
-export class NvmeofGroupFormComponent extends CdForm implements OnInit {
- @ViewChild(NvmeofGatewayNodeComponent) gatewayNodeComponent: NvmeofGatewayNodeComponent;
-
- permission: Permission;
- groupForm: CdFormGroup;
- action: string;
- resource: string;
- group: string;
- pools: Pool[] = [];
- poolsLoading = false;
- pageURL: string;
- hasAvailableNodes = true;
-
- constructor(
- private authStorageService: AuthStorageService,
- public actionLabels: ActionLabelsI18n,
- private poolService: PoolService,
- private taskWrapperService: TaskWrapperService,
- private cephServiceService: CephServiceService,
- private nvmeofService: NvmeofService,
- private router: Router
- ) {
- super();
- this.permission = this.authStorageService.getPermissions().nvmeof;
- this.resource = $localize`gateway group`;
- }
-
- ngOnInit() {
- this.action = this.actionLabels.CREATE;
- this.createForm();
- this.loadPools();
- }
-
- createForm() {
- this.groupForm = new CdFormGroup({
- groupName: new UntypedFormControl(
- null,
- [
- Validators.required,
- (control) => {
- const value = control.value;
- return value && /[^a-zA-Z0-9_-]/.test(value) ? { invalidChars: true } : null;
- }
- ],
- [CdValidators.unique(this.nvmeofService.exists, this.nvmeofService)]
- ),
- pool: new UntypedFormControl('rbd', {
- validators: [Validators.required]
- }),
- unmanaged: new UntypedFormControl(false)
- });
- }
-
- onHostsLoaded(count: number): void {
- this.hasAvailableNodes = count > 0;
- }
-
- get isCreateDisabled(): boolean {
- if (!this.hasAvailableNodes) {
- return true;
- }
- if (!this.groupForm) {
- return true;
- }
- if (this.groupForm.pending) {
- return true;
- }
- if (this.groupForm.invalid) {
- return true;
- }
- const errors = this.groupForm.errors as { [key: string]: any } | null;
- if (errors && errors.cdSubmitButton) {
- return true;
- }
- if (this.gatewayNodeComponent) {
- const selected = this.gatewayNodeComponent.getSelectedHostnames?.() || [];
- if (selected.length === 0) {
- return true;
- }
- }
-
- return false;
- }
-
- loadPools() {
- this.poolsLoading = true;
- this.poolService.list().then(
- (pools: Pool[]) => {
- this.pools = (pools || []).filter(
- (pool: Pool) => pool.application_metadata && pool.application_metadata.includes('rbd')
- );
- this.poolsLoading = false;
- if (this.pools.length >= 1) {
- const allPoolNames = this.pools.map((pool) => pool.pool_name);
- const poolName = allPoolNames.includes('rbd') ? 'rbd' : this.pools[0].pool_name;
- this.groupForm.patchValue({ pool: poolName });
- }
- },
- () => {
- this.pools = [];
- this.poolsLoading = false;
- }
- );
- }
-
- onSubmit() {
- if (this.groupForm.invalid) {
- return;
- }
-
- if (this.groupForm.pending) {
- this.groupForm.setErrors({ cdSubmitButton: true });
- return;
- }
-
- const formValues = this.groupForm.value;
- const selectedHostnames = this.gatewayNodeComponent?.getSelectedHostnames() || [];
- if (selectedHostnames.length === 0) {
- this.groupForm.setErrors({ cdSubmitButton: true });
- return;
- }
- let taskUrl = `service/${URLVerbs.CREATE}`;
- const serviceName = `${formValues.pool}.${formValues.groupName}`;
-
- const serviceSpec = {
- service_type: 'nvmeof',
- service_id: serviceName,
- pool: formValues.pool,
- group: formValues.groupName,
- placement: {
- hosts: selectedHostnames
- },
- unmanaged: formValues.unmanaged
- };
-
- this.taskWrapperService
- .wrapTaskAroundCall({
- task: new FinishedTask(taskUrl, {
- service_name: `nvmeof.${serviceName}`
- }),
- call: this.cephServiceService.create(serviceSpec)
- })
- .subscribe({
- complete: () => {
- this.goToListView();
- },
- error: () => {
- this.groupForm.setErrors({ cdSubmitButton: true });
- }
- });
- }
-
- private goToListView() {
- this.router.navigateByUrl('/block/nvmeof/gateways');
- }
-}
--- /dev/null
+<form
+ #formDir="ngForm"
+ [formGroup]="groupForm"
+ novalidate
+>
+ <div cdsGrid
+ [useCssGrid]="true"
+ [narrow]="true"
+ [fullWidth]="true">
+ <div cdsCol
+ [columnNumbers]="{sm: 4, md: 8}">
+ <div cdsRow
+ class="form-heading form-item">
+ <h3>{{ action | titlecase }} {{ resource }}</h3>
+ <cd-help-text>
+ <span i18n>
+ A logical group of gateways that hosts will connect to.
+ </span>
+ </cd-help-text>
+ <cd-help-text [formAllFieldsRequired]="true"></cd-help-text>
+ </div>
+ <div cdsRow
+ class="form-item">
+ <div cdsCol>
+ <cds-text-label
+ labelInputID="name"
+ i18n
+ i18n-helperText
+ helperText="A unique name to identify this gateway group."
+ cdRequiredField="Gateway group name"
+ [invalid]="groupName.isInvalid">
+ Gateway group name
+ <input
+ cdsText
+ cdValidate
+ type="text"
+ id="groupName"
+ #groupName="cdValidate"
+ autofocus
+ formControlName="groupName"
+ [invalid]="groupName.isInvalid"
+ />
+ </cds-text-label>
+ <span
+ class="invalid-feedback"
+ *ngIf="groupForm.showError('groupName', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span
+ class="invalid-feedback"
+ *ngIf="groupForm.get('groupName')?.hasError('notUnique') && (groupForm.get('groupName')?.dirty || groupForm.get('groupName')?.touched)"
+ i18n>Group name must be unique.</span>
+ <span
+ class="invalid-feedback"
+ *ngIf="groupForm.get('groupName')?.hasError('invalidChars') && (groupForm.get('groupName')?.dirty || groupForm.get('groupName')?.touched)"
+ i18n>Special characters are not allowed.</span>
+
+ </div>
+ </div>
+ <!-- Pool -->
+ <div cdsRow
+ class="form-item">
+ <div cdsCol>
+ <cds-select
+ label="Block pool"
+ helperText="An RBD application-enabled pool in which the gateway configuration can be managed."
+ labelInputID="pool"
+ id="pool"
+ formControlName="pool"
+ cdRequiredField="Block pool"
+ [invalid]="groupForm.controls.pool.invalid && groupForm.controls.pool.dirty"
+ [invalidText]="poolError"
+ i18n-label
+ i18n-helperText
+ >
+ <option *ngIf="poolsLoading"
+ [ngValue]="null"
+ i18n>Loading...</option>
+ <option *ngIf="!poolsLoading && pools.length === 0"
+ [ngValue]="null"
+ i18n>-- No block pools available --</option>
+ <option *ngFor="let pool of pools"
+ [value]="pool.pool_name">{{ pool.pool_name }}</option>
+ </cds-select>
+ <ng-template #poolError>
+ <span class="invalid-feedback"
+ *ngIf="groupForm.showError('pool', formDir, 'required')"
+ i18n>This field is required.</span>
+ </ng-template>
+ </div>
+ </div>
+
+ <!-- Target Nodes Selection -->
+ <div
+ cdsRow
+ class="form-item"
+ >
+ <div cdsCol>
+ <h1 class="cds--type-heading-02">Select target nodes</h1>
+ <cd-help-text>
+ <span i18n>
+ Gateway nodes to run NVMe-oF target pods/services
+ </span>
+ </cd-help-text>
+ </div>
+ <div
+ cdsCol
+ class="cds-pt-3 cds-pb-3"
+ >
+ <cd-nvmeof-gateway-node
+ (hostsLoaded)="onHostsLoaded($event)"
+ ></cd-nvmeof-gateway-node>
+ </div>
+ </div>
+
+ <div cdsRow>
+ <cd-form-button-panel
+ (submitActionEvent)="onSubmit()"
+ [form]="groupForm"
+ [submitText]="(action | titlecase) + ' ' + (resource)"
+ [disabled]="isCreateDisabled"
+ wrappingClass="text-right form-button"
+ >
+ </cd-form-button-panel>
+ </div>
+ </div>
+ </div>
+</form>
+
+
--- /dev/null
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { Router } from '@angular/router';
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { SharedModule } from '~/app/shared/shared.module';
+
+import { NvmeofGroupFormComponent } from './nvmeof-group-form.component';
+import { GridModule, InputModule, SelectModule } from 'carbon-components-angular';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { FormHelper } from '~/testing/unit-test-helper';
+
+describe('NvmeofGroupFormComponent', () => {
+ let component: NvmeofGroupFormComponent;
+ let fixture: ComponentFixture<NvmeofGroupFormComponent>;
+ let form: CdFormGroup;
+ let formHelper: FormHelper;
+ let poolService: PoolService;
+ let taskWrapperService: TaskWrapperService;
+ let cephServiceService: CephServiceService;
+ let router: Router;
+
+ const mockPools = [
+ { pool_name: 'rbd', application_metadata: ['rbd'] },
+ { pool_name: 'rbd', application_metadata: ['rbd'] },
+ { pool_name: 'pool2', application_metadata: ['rgw'] }
+ ];
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [NvmeofGroupFormComponent],
+ providers: [NgbActiveModal],
+ imports: [
+ HttpClientTestingModule,
+ NgbTypeaheadModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ GridModule,
+ InputModule,
+ SelectModule,
+ ToastrModule.forRoot()
+ ],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NvmeofGroupFormComponent);
+ component = fixture.componentInstance;
+ poolService = TestBed.inject(PoolService);
+ taskWrapperService = TestBed.inject(TaskWrapperService);
+ cephServiceService = TestBed.inject(CephServiceService);
+ router = TestBed.inject(Router);
+
+ spyOn(poolService, 'list').and.returnValue(Promise.resolve(mockPools));
+
+ component.ngOnInit();
+ form = component.groupForm;
+ formHelper = new FormHelper(form);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize form with empty fields', () => {
+ expect(form.controls.groupName.value).toBeNull();
+ expect(form.controls.unmanaged.value).toBe(false);
+ });
+
+ it('should set action to CREATE on init', () => {
+ expect(component.action).toBe('Create');
+ });
+
+ it('should set resource to gateway group', () => {
+ expect(component.resource).toBe('gateway group');
+ });
+
+ describe('form validation', () => {
+ it('should require groupName', () => {
+ formHelper.setValue('groupName', '');
+ formHelper.expectError('groupName', 'required');
+ });
+
+ it('should require pool', () => {
+ formHelper.setValue('pool', null);
+ formHelper.expectError('pool', 'required');
+ });
+
+ it('should be valid when groupName and pool are set', () => {
+ formHelper.setValue('groupName', 'test-group');
+ formHelper.setValue('pool', 'rbd');
+ expect(form.valid).toBe(true);
+ });
+ });
+
+ describe('loadPools', () => {
+ it('should load pools and filter by rbd application metadata', fakeAsync(() => {
+ component.loadPools();
+ tick();
+ expect(component.pools.length).toBe(2);
+ expect(component.pools.map((p) => p.pool_name)).toEqual(['rbd', 'rbd']);
+ }));
+
+ it('should set default pool to rbd if available', fakeAsync(() => {
+ component.groupForm.get('pool').setValue(null);
+ component.loadPools();
+ tick();
+ expect(component.groupForm.get('pool').value).toBe('rbd');
+ }));
+
+ it('should set first pool if rbd is not available', fakeAsync(() => {
+ component.groupForm.get('pool').setValue(null);
+ const poolsWithoutRbd = [{ pool_name: 'custom-pool', application_metadata: ['rbd'] }];
+ (poolService.list as jasmine.Spy).and.returnValue(Promise.resolve(poolsWithoutRbd));
+ component.loadPools();
+ tick();
+ expect(component.groupForm.get('pool').value).toBe('custom-pool');
+ }));
+
+ it('should handle empty pools', fakeAsync(() => {
+ (poolService.list as jasmine.Spy).and.returnValue(Promise.resolve([]));
+ component.loadPools();
+ tick();
+ expect(component.pools.length).toBe(0);
+ expect(component.poolsLoading).toBe(false);
+ }));
+
+ it('should handle pool loading error', fakeAsync(() => {
+ (poolService.list as jasmine.Spy).and.returnValue(Promise.reject('error'));
+ component.loadPools();
+ tick();
+ expect(component.pools).toEqual([]);
+ expect(component.poolsLoading).toBe(false);
+ }));
+ });
+
+ describe('onSubmit', () => {
+ beforeEach(() => {
+ spyOn(cephServiceService, 'create').and.returnValue(of({}));
+ spyOn(taskWrapperService, 'wrapTaskAroundCall').and.callFake(({ call }) => call);
+ spyOn(router, 'navigateByUrl');
+ });
+
+ it('should not call create if no hosts are selected', () => {
+ component.gatewayNodeComponent = {
+ getSelectedHosts: (): any[] => [],
+ getSelectedHostnames: (): string[] => []
+ } as any;
+
+ component.groupForm.get('groupName').setValue('test-group');
+ component.groupForm.get('pool').setValue('rbd');
+ component.onSubmit();
+
+ expect(cephServiceService.create).not.toHaveBeenCalled();
+ });
+
+ it('should create service with correct spec', () => {
+ component.gatewayNodeComponent = {
+ getSelectedHosts: (): any[] => [{ hostname: 'host1' }, { hostname: 'host2' }],
+ getSelectedHostnames: (): string[] => ['host1', 'host2']
+ } as any;
+
+ component.groupForm.get('groupName').setValue('defalut');
+ component.groupForm.get('pool').setValue('rbd');
+ component.groupForm.get('unmanaged').setValue(false);
+ component.onSubmit();
+
+ expect(cephServiceService.create).toHaveBeenCalledWith({
+ service_type: 'nvmeof',
+ service_id: 'rbd.defalut',
+ pool: 'rbd',
+ group: 'defalut',
+ placement: {
+ hosts: ['host1', 'host2']
+ },
+ unmanaged: false
+ });
+ });
+
+ it('should create service with unmanaged flag set to true', () => {
+ component.gatewayNodeComponent = {
+ getSelectedHosts: (): any[] => [{ hostname: 'host1' }],
+ getSelectedHostnames: (): string[] => ['host1']
+ } as any;
+
+ component.groupForm.get('groupName').setValue('unmanaged-group');
+ component.groupForm.get('pool').setValue('rbd');
+ component.groupForm.get('unmanaged').setValue(true);
+ component.onSubmit();
+
+ expect(cephServiceService.create).toHaveBeenCalledWith(
+ jasmine.objectContaining({
+ unmanaged: true,
+ group: 'unmanaged-group',
+ pool: 'rbd'
+ })
+ );
+ });
+
+ it('should navigate to list view on success', () => {
+ component.gatewayNodeComponent = {
+ getSelectedHosts: (): any[] => [{ hostname: 'host1' }],
+ getSelectedHostnames: (): string[] => ['host1']
+ } as any;
+
+ component.groupForm.get('groupName').setValue('test-group');
+ component.groupForm.get('pool').setValue('rbd');
+ component.onSubmit();
+
+ expect(router.navigateByUrl).toHaveBeenCalledWith('/block/nvmeof/gateways');
+ });
+ });
+});
--- /dev/null
+import { Component, OnInit, ViewChild } from '@angular/core';
+import { UntypedFormControl, Validators } from '@angular/forms';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { Pool } from '../../pool/pool';
+import { NvmeofGatewayNodeComponent } from '../nvmeof-gateway-node/nvmeof-gateway-node.component';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { Router } from '@angular/router';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+
+@Component({
+ selector: 'cd-nvmeof-group-form',
+ templateUrl: './nvmeof-group-form.component.html',
+ styleUrls: ['./nvmeof-group-form.component.scss'],
+ standalone: false
+})
+export class NvmeofGroupFormComponent extends CdForm implements OnInit {
+ @ViewChild(NvmeofGatewayNodeComponent) gatewayNodeComponent: NvmeofGatewayNodeComponent;
+
+ permission: Permission;
+ groupForm: CdFormGroup;
+ action: string;
+ resource: string;
+ group: string;
+ pools: Pool[] = [];
+ poolsLoading = false;
+ pageURL: string;
+ hasAvailableNodes = true;
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ public actionLabels: ActionLabelsI18n,
+ private poolService: PoolService,
+ private taskWrapperService: TaskWrapperService,
+ private cephServiceService: CephServiceService,
+ private nvmeofService: NvmeofService,
+ private router: Router
+ ) {
+ super();
+ this.permission = this.authStorageService.getPermissions().nvmeof;
+ this.resource = $localize`gateway group`;
+ }
+
+ ngOnInit() {
+ this.action = this.actionLabels.CREATE;
+ this.createForm();
+ this.loadPools();
+ }
+
+ createForm() {
+ this.groupForm = new CdFormGroup({
+ groupName: new UntypedFormControl(
+ null,
+ [
+ Validators.required,
+ (control) => {
+ const value = control.value;
+ return value && /[^a-zA-Z0-9_-]/.test(value) ? { invalidChars: true } : null;
+ }
+ ],
+ [CdValidators.unique(this.nvmeofService.exists, this.nvmeofService)]
+ ),
+ pool: new UntypedFormControl('rbd', {
+ validators: [Validators.required]
+ }),
+ unmanaged: new UntypedFormControl(false)
+ });
+ }
+
+ onHostsLoaded(count: number): void {
+ this.hasAvailableNodes = count > 0;
+ }
+
+ get isCreateDisabled(): boolean {
+ if (!this.hasAvailableNodes) {
+ return true;
+ }
+ if (!this.groupForm) {
+ return true;
+ }
+ if (this.groupForm.pending) {
+ return true;
+ }
+ if (this.groupForm.invalid) {
+ return true;
+ }
+ const errors = this.groupForm.errors as { [key: string]: any } | null;
+ if (errors && errors.cdSubmitButton) {
+ return true;
+ }
+ if (this.gatewayNodeComponent) {
+ const selected = this.gatewayNodeComponent.getSelectedHostnames?.() || [];
+ if (selected.length === 0) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ loadPools() {
+ this.poolsLoading = true;
+ this.poolService.list().then(
+ (pools: Pool[]) => {
+ this.pools = (pools || []).filter(
+ (pool: Pool) => pool.application_metadata && pool.application_metadata.includes('rbd')
+ );
+ this.poolsLoading = false;
+ if (this.pools.length >= 1) {
+ const allPoolNames = this.pools.map((pool) => pool.pool_name);
+ const poolName = allPoolNames.includes('rbd') ? 'rbd' : this.pools[0].pool_name;
+ this.groupForm.patchValue({ pool: poolName });
+ }
+ },
+ () => {
+ this.pools = [];
+ this.poolsLoading = false;
+ }
+ );
+ }
+
+ onSubmit() {
+ if (this.groupForm.invalid) {
+ return;
+ }
+
+ if (this.groupForm.pending) {
+ this.groupForm.setErrors({ cdSubmitButton: true });
+ return;
+ }
+
+ const formValues = this.groupForm.value;
+ const selectedHostnames = this.gatewayNodeComponent?.getSelectedHostnames() || [];
+ if (selectedHostnames.length === 0) {
+ this.groupForm.setErrors({ cdSubmitButton: true });
+ return;
+ }
+ let taskUrl = `service/${URLVerbs.CREATE}`;
+ const serviceName = `${formValues.pool}.${formValues.groupName}`;
+
+ const serviceSpec = {
+ service_type: 'nvmeof',
+ service_id: serviceName,
+ pool: formValues.pool,
+ group: formValues.groupName,
+ placement: {
+ hosts: selectedHostnames
+ },
+ unmanaged: formValues.unmanaged
+ };
+
+ this.taskWrapperService
+ .wrapTaskAroundCall({
+ task: new FinishedTask(taskUrl, {
+ service_name: `nvmeof.${serviceName}`
+ }),
+ call: this.cephServiceService.create(serviceSpec)
+ })
+ .subscribe({
+ complete: () => {
+ this.goToListView();
+ },
+ error: () => {
+ this.groupForm.setErrors({ cdSubmitButton: true });
+ }
+ });
+ }
+
+ private goToListView() {
+ this.router.navigateByUrl('/block/nvmeof/gateways');
+ }
+}