]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add step 1 for subsystem form 67106/head
authorAfreen Misbah <afreen@ibm.com>
Wed, 28 Jan 2026 13:18:52 +0000 (18:48 +0530)
committerAfreen Misbah <afreen@ibm.com>
Mon, 2 Feb 2026 11:02:09 +0000 (16:32 +0530)
Fixes https://tracker.ceph.com/issues/74093
Fixes https://tracker.ceph.com/issues/74094

- updates tearsheet component css to match with carbon component
- adds laoding state to submit button
- adds support for step validation when angualr component are use for steps rather than plain html templates
- adds step one of nvmeof

Signed-off-by: Afreen Misbah <afreen@ibm.com>
14 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/tearsheet-step.ts [new file with mode: 0644]

index 9725b2ab6bd4cf50772a1434f621e8771f797ed1..f09a5fbf1a1248a338c6725aba2839063306c269 100644 (file)
@@ -47,6 +47,10 @@ import { NvmeofNamespacesListComponent } from './nvmeof-namespaces-list/nvmeof-n
 import { NvmeofNamespacesFormComponent } from './nvmeof-namespaces-form/nvmeof-namespaces-form.component';
 import { NvmeofInitiatorsListComponent } from './nvmeof-initiators-list/nvmeof-initiators-list.component';
 import { NvmeofInitiatorsFormComponent } from './nvmeof-initiators-form/nvmeof-initiators-form.component';
+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 {
   ButtonModule,
@@ -75,9 +79,6 @@ import SubtractFilled from '@carbon/icons/es/subtract--filled/32';
 import Reset from '@carbon/icons/es/reset/32';
 import SubtractAlt from '@carbon/icons/es/subtract--alt/20';
 import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32';
-import { NvmeofGatewayGroupComponent } from './nvmeof-gateway-group/nvmeof-gateway-group.component';
-import { NvmeofGroupFormComponent } from './nvmeof-group-form /nvmeof-group-form.component';
-import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node/nvmeof-gateway-node.component';
 
 @NgModule({
   imports: [
@@ -145,7 +146,8 @@ import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node/nvmeof-gateway
     NvmeofInitiatorsListComponent,
     NvmeofInitiatorsFormComponent,
     NvmeofGatewayNodeComponent,
-    NvmeofGroupFormComponent
+    NvmeofGroupFormComponent,
+    NvmeofSubsystemsStepOneComponent
   ],
   exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
 })
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.html
new file mode 100644 (file)
index 0000000..a8c179e
--- /dev/null
@@ -0,0 +1,58 @@
+
+<form [formGroup]="formGroup"
+      novalidate>
+  <div cdsGrid
+       [useCssGrid]="true"
+       [narrow]="true"
+       [fullWidth]="true">
+    <div cdsCol
+         [columnNumbers]="{sm: 4, md: 8}">
+      <div cdsRow
+           class="form-heading">
+        <h3 class="cds--type-heading-03"
+            i18n>Subsystem details</h3>
+        <p
+          class="cds--type-label-02"
+          i18n>Enter identifying and network details for this subsystem.</p>
+      </div>
+      <div cdsRow
+           class="form-item">
+        <cds-text-label
+          i18n
+          [invalid]="nqnRef.isInvalid"
+          [invalidText]="nqnInvalidTemplate"
+          helperText="A unique identifier for the subsystem."
+          i18n-helperText>
+          Subsystem NQN (NVMe Qualified Name)
+          <input cdsText
+                 cdValidate
+                 #nqnRef="cdValidate"
+                 type="text"
+                 formControlName="nqn"
+                 [invalid]="nqnRef.isInvalid">
+        </cds-text-label>
+      </div>
+      <div cdsRow
+           class="form-item">
+        <cds-text-label
+          i18n
+          helperText="Gateway group routes traffic for this subsystem."
+          i18n-helperText
+          [disabled]="true">
+          Gateway group
+          <input cdsText
+                 type="text"
+                 disabled
+                 [value]="group"
+                 [skeleton]="!group">
+        </cds-text-label>
+      </div>
+    </div>
+  </div>
+</form>
+
+<ng-template #nqnInvalidTemplate>
+@for (err of formGroup.get('nqn').errors | keyvalue; track err.key) {
+<span class="invalid-feedback">{{ INVALID_TEXTS[err.key] }}</span>
+}
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts
new file mode 100644 (file)
index 0000000..5e4c784
--- /dev/null
@@ -0,0 +1,65 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+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 { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystem-step-1.component';
+import { FormHelper } from '~/testing/unit-test-helper';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { GridModule, InputModule } from 'carbon-components-angular';
+
+describe('NvmeofSubsystemsStepOneComponent', () => {
+  let component: NvmeofSubsystemsStepOneComponent;
+  let fixture: ComponentFixture<NvmeofSubsystemsStepOneComponent>;
+  let nvmeofService: NvmeofService;
+  let form: CdFormGroup;
+  let formHelper: FormHelper;
+  const mockGroupName = 'default';
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [NvmeofSubsystemsStepOneComponent],
+      providers: [NgbActiveModal],
+      imports: [
+        HttpClientTestingModule,
+        NgbTypeaheadModule,
+        ReactiveFormsModule,
+        RouterTestingModule,
+        SharedModule,
+        InputModule,
+        GridModule,
+        ToastrModule.forRoot()
+      ]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(NvmeofSubsystemsStepOneComponent);
+    component = fixture.componentInstance;
+    component.ngOnInit();
+    form = component.formGroup;
+    formHelper = new FormHelper(form);
+    fixture.detectChanges();
+    component.group = mockGroupName;
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  describe('should test form', () => {
+    beforeEach(() => {
+      nvmeofService = TestBed.inject(NvmeofService);
+      spyOn(nvmeofService, 'createSubsystem').and.stub();
+    });
+
+    it('should give error on invalid nqn', () => {
+      formHelper.setValue('nqn', 'nqn:2001-07.com.ceph:');
+      formHelper.expectError('nqn', 'nqnPattern');
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.ts
new file mode 100644 (file)
index 0000000..94cd444
--- /dev/null
@@ -0,0 +1,73 @@
+import { Component, Input, OnInit } from '@angular/core';
+import { UntypedFormControl, Validators } from '@angular/forms';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { TearsheetStep } from '~/app/shared/models/tearsheet-step';
+
+@Component({
+  selector: 'cd-nvmeof-subsystem-step-one',
+  templateUrl: './nvmeof-subsystem-step-1.component.html',
+  styleUrls: ['./nvmeof-subsystem-step-1.component.scss'],
+  standalone: false
+})
+export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep {
+  @Input() group!: string;
+  formGroup: CdFormGroup;
+  action: string;
+  pageURL: string;
+  INVALID_TEXTS = {
+    required: $localize`This field is required`,
+    nqnPattern: $localize`Expected NQN format is "nqn.$year-$month.$reverseDomainName:$utf8-string" or "nqn.2014-08.org.nvmexpress:uuid:$UUID-string"`,
+    notUnique: $localize`This NQN is already in use`,
+    maxLength: $localize`An NQN may not be more than 223 bytes in length.`
+  };
+
+  constructor(
+    public actionLabels: ActionLabelsI18n,
+    public activeModal: NgbActiveModal,
+    private nvmeofService: NvmeofService
+  ) {}
+
+  DEFAULT_NQN = 'nqn.2001-07.com.ceph:' + Date.now();
+  NQN_REGEX = /^nqn\.(19|20)\d\d-(0[1-9]|1[0-2])\.\D{2,3}(\.[A-Za-z0-9-]+)+(:[A-Za-z0-9-\.]+(:[A-Za-z0-9-\.]+)*)$/;
+  NQN_REGEX_UUID = /^nqn\.2014-08\.org\.nvmexpress:uuid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
+
+  customNQNValidator = CdValidators.custom(
+    'nqnPattern',
+    (nqnInput: string) =>
+      !!nqnInput && !(this.NQN_REGEX.test(nqnInput) || this.NQN_REGEX_UUID.test(nqnInput))
+  );
+
+  ngOnInit() {
+    this.createForm();
+  }
+
+  createForm() {
+    this.formGroup = new CdFormGroup({
+      nqn: new UntypedFormControl(this.DEFAULT_NQN, {
+        validators: [
+          this.customNQNValidator,
+          Validators.required,
+          CdValidators.custom(
+            'maxLength',
+            (nqnInput: string) => new TextEncoder().encode(nqnInput).length > 223
+          )
+        ],
+        asyncValidators: [
+          CdValidators.unique(
+            this.nvmeofService.isSubsystemPresent,
+            this.nvmeofService,
+            null,
+            null,
+            this.group
+          )
+        ]
+      })
+    });
+  }
+}
index ad17ad83deb803e1294963bfc80dec2fbfb6850c..04e7e38bbf36982ffd1490dd7050ab225dd08078 100644 (file)
@@ -2,63 +2,12 @@
  [steps]="steps"
  [title]="title"
  [description]="description"
- (submitRequested)="onSubmit()"
+ [isSubmitLoading]="isSubmitLoading"
+ (submitRequested)="onSubmit($event)"
  >
   <cd-tearsheet-step>
-    <form name="subsystemForm"
-          #formDir="ngForm"
-          [formGroup]="subsystemForm"
-          novalidate>
-      <!-- NQN -->
-      <div class="form-group row">
-        <label class="cd-col-form-label"
-               for="nqn">
-          <span class="required"
-                i18n>NQN</span>
-        </label>
-        <div class="cd-col-form-input">
-          <input name="nqn"
-                 class="form-control"
-                 type="text"
-                 formControlName="nqn">
-            <cd-help-text>
-              A unique and permanent name for the lifetime of the subsystem.
-            </cd-help-text>
-          <span class="invalid-feedback"
-                *ngIf="subsystemForm.showError('nqn', formDir, 'required')"
-                i18n>This field is required.</span>
-          <span class="invalid-feedback"
-                *ngIf="subsystemForm.showError('nqn', formDir, 'unique')"
-                i18n>This NQN is already in use.</span>
-          <span class="invalid-feedback"
-                *ngIf="subsystemForm.showError('nqn', formDir, 'pattern')"
-                i18n>Expected NQN format<br/>&lt;<code>nqn.$year-$month.$reverseDomainName:$utf8-string</code>".&gt; or <br/>&lt;<code>nqn.2014-08.org.nvmexpress:uuid:$UUID-string</code>".&gt;</span>
-          <span class="invalid-feedback"
-                *ngIf="subsystemForm.showError('nqn', formDir, 'maxLength')"
-                i18n>An NQN may not be more than 223 bytes in length.</span>
-        </div>
-      </div>
-      <!-- Maximum Namespaces -->
-      <div class="form-group row">
-        <label class="cd-col-form-label"
-               for="max_namespaces">
-          <span i18n>Maximum Namespaces</span>
-        </label>
-        <div class="cd-col-form-input">
-          <input id="max_namespaces"
-                 class="form-control"
-                 type="text"
-                 name="max_namespaces"
-                 formControlName="max_namespaces">
-          <cd-help-text i18n>The maximum namespaces per subsystem. Default is {{defaultMaxNamespace}}</cd-help-text>
-          <span class="invalid-feedback"
-                *ngIf="subsystemForm.showError('max_namespaces', formDir, 'min')"
-                i18n>The value must be at least 1.</span>
-          <span class="invalid-feedback"
-                *ngIf="subsystemForm.showError('max_namespaces', formDir, 'pattern')"
-                i18n>The value must be a positive integer.</span>
-        </div>
-      </div>
-    </form>
+    <cd-nvmeof-subsystem-step-one
+     #tearsheetStep
+     [group]="group"></cd-nvmeof-subsystem-step-one>
   </cd-tearsheet-step>
 </cd-tearsheet>
index 45e5a59b9d7001359b41a99a75961dfee9cd7327..79cc234c4d4f9d505a24c867e0fe2958c1871ec2 100644 (file)
@@ -7,28 +7,30 @@ import { ToastrModule } from 'ngx-toastr';
 
 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 { NvmeofSubsystemsFormComponent } from './nvmeof-subsystems-form.component';
-import { FormHelper } from '~/testing/unit-test-helper';
 import {
-  DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM,
-  NvmeofService
-} from '~/app/shared/api/nvmeof.service';
+  NvmeofSubsystemsFormComponent,
+  SubsystemPayload
+} from './nvmeof-subsystems-form.component';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component';
+import { GridModule, InputModule } from 'carbon-components-angular';
 
 describe('NvmeofSubsystemsFormComponent', () => {
   let component: NvmeofSubsystemsFormComponent;
   let fixture: ComponentFixture<NvmeofSubsystemsFormComponent>;
   let nvmeofService: NvmeofService;
-  let form: CdFormGroup;
-  let formHelper: FormHelper;
   const mockTimestamp = 1720693470789;
   const mockGroupName = 'default';
+  const mockPayload: SubsystemPayload = {
+    nqn: '',
+    gw_group: mockGroupName
+  };
 
   beforeEach(async () => {
     spyOn(Date, 'now').and.returnValue(mockTimestamp);
     await TestBed.configureTestingModule({
-      declarations: [NvmeofSubsystemsFormComponent],
+      declarations: [NvmeofSubsystemsFormComponent, NvmeofSubsystemsStepOneComponent],
       providers: [NgbActiveModal],
       imports: [
         HttpClientTestingModule,
@@ -36,6 +38,8 @@ describe('NvmeofSubsystemsFormComponent', () => {
         ReactiveFormsModule,
         RouterTestingModule,
         SharedModule,
+        InputModule,
+        GridModule,
         ToastrModule.forRoot()
       ]
     }).compileComponents();
@@ -43,8 +47,6 @@ describe('NvmeofSubsystemsFormComponent', () => {
     fixture = TestBed.createComponent(NvmeofSubsystemsFormComponent);
     component = fixture.componentInstance;
     component.ngOnInit();
-    form = component.subsystemForm;
-    formHelper = new FormHelper(form);
     fixture.detectChanges();
     component.group = mockGroupName;
   });
@@ -61,43 +63,13 @@ describe('NvmeofSubsystemsFormComponent', () => {
 
     it('should be creating request correctly', () => {
       const expectedNqn = 'nqn.2001-07.com.ceph:' + mockTimestamp;
-      component.onSubmit();
+      mockPayload['nqn'] = expectedNqn;
+      component.onSubmit(mockPayload);
       expect(nvmeofService.createSubsystem).toHaveBeenCalledWith({
         nqn: expectedNqn,
-        max_namespaces: DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM,
-        enable_ha: true,
-        gw_group: mockGroupName
+        gw_group: mockGroupName,
+        enable_ha: true
       });
     });
-
-    it('should give error on invalid nqn', () => {
-      formHelper.setValue('nqn', 'nqn:2001-07.com.ceph:');
-      component.onSubmit();
-      formHelper.expectError('nqn', 'pattern');
-    });
-
-    it('should give error on invalid max_namespaces', () => {
-      formHelper.setValue('max_namespaces', -56);
-      component.onSubmit();
-      formHelper.expectError('max_namespaces', 'pattern');
-    });
-
-    it(`should not give error on max_namespaces greater than ${DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM}`, () => {
-      const expectedNqn = 'nqn.2001-07.com.ceph:' + mockTimestamp;
-      formHelper.setValue('max_namespaces', 600);
-      component.onSubmit();
-      expect(nvmeofService.createSubsystem).toHaveBeenCalledWith({
-        nqn: expectedNqn,
-        max_namespaces: DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM,
-        enable_ha: true,
-        gw_group: mockGroupName
-      });
-    });
-
-    it('should give error on max_namespaces lesser than 1', () => {
-      formHelper.setValue('max_namespaces', 0);
-      component.onSubmit();
-      formHelper.expectError('max_namespaces', 'min');
-    });
   });
 });
index b89e823b32e415993d6ef777734c7c352dc95191..7f17d7da1c4e5d4f74260be349578c80b0b1a967 100644 (file)
@@ -1,22 +1,20 @@
-import { Component, DestroyRef, OnInit } from '@angular/core';
-import { FormControlStatus, UntypedFormControl, Validators } from '@angular/forms';
+import { Component, DestroyRef, OnInit, ViewChild } from '@angular/core';
 import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 
 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
-import { CdValidators } from '~/app/shared/forms/cd-validators';
-import { Permission } from '~/app/shared/models/permissions';
-import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
-import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
-import { FinishedTask } from '~/app/shared/models/finished-task';
 import { ActivatedRoute, Router } from '@angular/router';
-import {
-  DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM,
-  NvmeofService
-} from '~/app/shared/api/nvmeof.service';
 import { Step } from 'carbon-components-angular';
-import { startWith } from 'rxjs/operators';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component';
+
+export type SubsystemPayload = {
+  nqn: string;
+  gw_group: string;
+};
 
 @Component({
   selector: 'cd-nvmeof-subsystems-form',
@@ -25,16 +23,12 @@ import { startWith } from 'rxjs/operators';
   standalone: false
 })
 export class NvmeofSubsystemsFormComponent implements OnInit {
-  permission: Permission;
   subsystemForm: CdFormGroup;
   action: string;
-  resource: string;
-  pageURL: string;
-  defaultMaxNamespace: number = DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM;
   group: string;
   steps: Step[] = [
     {
-      label: $localize`Subsystem Details`,
+      label: $localize`Subsystem details`,
       complete: false,
       invalid: false
     },
@@ -47,114 +41,52 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
       complete: false
     },
     {
-      label: $localize`Advanced Options`,
+      label: $localize`Advanced options`,
       complete: false,
       secondaryLabel: $localize`Advanced`
     }
   ];
   title: string = $localize`Create Subsystem`;
   description: string = $localize`Subsytems define how hosts connect to NVMe namespaces and ensure secure access to storage.`;
+  isSubmitLoading: boolean = false;
+
+  @ViewChild(TearsheetComponent) tearsheet!: TearsheetComponent;
 
   constructor(
-    private authStorageService: AuthStorageService,
     public actionLabels: ActionLabelsI18n,
     public activeModal: NgbActiveModal,
+    private route: ActivatedRoute,
+    private destroyRef: DestroyRef,
     private nvmeofService: NvmeofService,
     private taskWrapperService: TaskWrapperService,
-    private router: Router,
-    private route: ActivatedRoute,
-    private destroyRef: DestroyRef
-  ) {
-    this.permission = this.authStorageService.getPermissions().nvmeof;
-    this.resource = $localize`Subsystem`;
-    this.pageURL = 'block/nvmeof/subsystems';
-  }
-
-  DEFAULT_NQN = 'nqn.2001-07.com.ceph:' + Date.now();
-  NQN_REGEX = /^nqn\.(19|20)\d\d-(0[1-9]|1[0-2])\.\D{2,3}(\.[A-Za-z0-9-]+)+(:[A-Za-z0-9-\.]+(:[A-Za-z0-9-\.]+)*)$/;
-  NQN_REGEX_UUID = /^nqn\.2014-08\.org\.nvmexpress:uuid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
-
-  customNQNValidator = CdValidators.custom(
-    'pattern',
-    (nqnInput: string) =>
-      !!nqnInput && !(this.NQN_REGEX.test(nqnInput) || this.NQN_REGEX_UUID.test(nqnInput))
-  );
+    private router: Router
+  ) {}
 
   ngOnInit() {
     this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
       this.group = params?.['group'];
     });
-
-    this.createForm();
-    this.action = this.actionLabels.CREATE;
-
-    this.subsystemForm.statusChanges
-      .pipe(startWith(this.subsystemForm.status), takeUntilDestroyed(this.destroyRef))
-      .subscribe((status: FormControlStatus) => {
-        const step = this.steps[0];
-        step.invalid = status === 'INVALID';
-      });
-  }
-
-  createForm() {
-    this.subsystemForm = new CdFormGroup({
-      nqn: new UntypedFormControl(this.DEFAULT_NQN, {
-        validators: [
-          this.customNQNValidator,
-          Validators.required,
-          this.customNQNValidator,
-          CdValidators.custom(
-            'maxLength',
-            (nqnInput: string) => new TextEncoder().encode(nqnInput).length > 223
-          )
-        ],
-        asyncValidators: [
-          CdValidators.unique(
-            this.nvmeofService.isSubsystemPresent,
-            this.nvmeofService,
-            null,
-            null,
-            this.group
-          )
-        ]
-      }),
-      max_namespaces: new UntypedFormControl(this.defaultMaxNamespace, {
-        validators: [CdValidators.number(false), Validators.min(1)]
-      })
-    });
   }
 
-  onSubmit() {
+  onSubmit(payload: SubsystemPayload) {
     const component = this;
-    const nqn: string = this.subsystemForm.getValue('nqn');
-    const max_namespaces: number = Number(this.subsystemForm.getValue('max_namespaces'));
+    const pageURL = 'block/nvmeof/subsystems';
     let taskUrl = `nvmeof/subsystem/${URLVerbs.CREATE}`;
-
-    const request = {
-      nqn,
-      enable_ha: true,
-      gw_group: this.group,
-      max_namespaces
-    };
-
-    if (!max_namespaces) {
-      delete request.max_namespaces;
-    }
+    this.isSubmitLoading = true;
     this.taskWrapperService
       .wrapTaskAroundCall({
         task: new FinishedTask(taskUrl, {
-          nqn: nqn
+          nqn: payload.nqn
         }),
-        call: this.nvmeofService.createSubsystem(request)
+        call: this.nvmeofService.createSubsystem({ ...payload, enable_ha: true })
       })
       .subscribe({
         error() {
-          // instead have error message set, not setting form status INVALID
-          // which will show input as false
-          component.subsystemForm.setErrors({ cdSubmitButton: true });
+          component.isSubmitLoading = false;
         },
         complete: () => {
-          this.router.navigate([this.pageURL, { outlets: { modal: null } }]);
+          component.isSubmitLoading = false;
+          this.router.navigate([pageURL, { outlets: { modal: null } }]);
         }
       });
   }
index e8721188dabe28001d819177a518f7cd4b408cfe..cac534f427544de41cc3f2c60abda8f32eed5061 100644 (file)
@@ -91,12 +91,7 @@ export class NvmeofService {
     return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}?gw_group=${group}`);
   }
 
-  createSubsystem(request: {
-    nqn: string;
-    enable_ha: boolean;
-    gw_group: string;
-    max_namespaces?: number;
-  }) {
+  createSubsystem(request: { nqn: string; enable_ha: boolean; gw_group: string }) {
     return this.http.post(`${API_PATH}/subsystem`, request, { observe: 'response' });
   }
 
index 243ba899463f91f78048522f528d9ca3270c1ae2..946040a30569d82e4e5a1e91fc23e926503ec270 100644 (file)
@@ -1,4 +1,5 @@
-import { Component, TemplateRef, ViewChild } from '@angular/core';
+import { Component, ContentChild, TemplateRef, ViewChild } from '@angular/core';
+import { TearsheetStep } from '../../models/tearsheet-step';
 
 @Component({
   selector: 'cd-tearsheet-step',
@@ -9,4 +10,7 @@ import { Component, TemplateRef, ViewChild } from '@angular/core';
 export class TearsheetStepComponent {
   @ViewChild(TemplateRef, { static: true })
   template!: TemplateRef<any>;
+
+  @ContentChild('tearsheetStep')
+  stepComponent!: TearsheetStep;
 }
index 4a917b3229cc1038c04d9f2ccbb8bd87f54e3c15..19bff6c52f2702ee1f62af82d171596f982c4dfd 100644 (file)
         @if (currentStep === lastStep) {
         <button cdsButton="primary"
                 size="xl"
-                (click)="onSubmit()"
-                i18n>{{submitButtonLabel}}</button>
+                class="tearsheet-footer-submit"
+                [disabled]="isSubmitLoading"
+                (click)="handleSubmit()"
+                i18n>
+          @if (isSubmitLoading) {
+          <cds-loading
+            [isActive]="isSubmitLoading"
+            [overlay]="false"
+            size="sm">
+          </cds-loading>
+           {{submitButtonLoadingLabel}}...
+          }
+          @else {
+            {{submitButtonLabel}}
+          }
+        </button>
         }
         @else {
         <button cdsButton="primary"
index 605f7133cefb67264e1ec2277039ddffaa2a3adc..405e440cefd5f6dce5575af7c25fb969f68ea823 100644 (file)
@@ -1,11 +1,23 @@
 // WIDE TEARSHEET
 // No css variable to apply css to modal div hence using css ngdeep
 // This is needed to set the width of tearsheet as per carbon standards.
-:host ::ng-deep .cds--modal-container {
-  block-size: 100%;
-  inset-block-start: auto;
-  inline-size: calc(100% - 8rem);
-  max-block-size: calc(100% - 5.5rem);
+:host ::ng-deep .cds--modal-container.cds--modal-container--lg {
+  position: fixed;
+
+  // 88px from top
+  inset-block-start: 88px;
+
+  // Fill remaining viewport height
+  block-size: calc(100vh - 88px);
+  max-block-size: calc(100vh - 88px);
+
+  // 64px margins left & right
+  inset-inline-start: 64px;
+  inset-inline-end: 64px;
+  inline-size: auto;
+  max-inline-size: none;
+  display: flex;
+  flex-direction: column;
 }
 
 // FULL TEARSHEET
     margin-left: var(--cds-spacing-05);
   }
 
+  &-submit {
+    display: flex;
+    justify-content: start;
+  }
+
   &--full {
     position: sticky;
     bottom: 0;
   }
 }
+
+cds-loading {
+  margin-right: var(--cds-spacing-05);
+}
index a4651564054a49b2336ccc1ed2e12a042f9f99ef..c2ac38a35ccca324cab64aab2677c0d86898f0a6 100644 (file)
@@ -7,7 +7,9 @@ import {
   OnInit,
   Output,
   QueryList,
-  AfterViewChecked
+  AfterViewInit,
+  DestroyRef,
+  OnDestroy
 } from '@angular/core';
 import { FormBuilder } from '@angular/forms';
 import { Step } from 'carbon-components-angular';
@@ -16,21 +18,52 @@ import { ModalCdsService } from '../../services/modal-cds.service';
 import { ActivatedRoute } from '@angular/router';
 import { Location } from '@angular/common';
 import { ConfirmationModalComponent } from '../confirmation-modal/confirmation-modal.component';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { Subject } from 'rxjs';
 
+/**
+<cd-tearsheet
+    [steps]="steps"
+    [title]="title"
+    [isSubmitLoading]="isSubmitLoading"
+    [description]="description"
+    (submitRequested)="onSubmit()">
+  <cd-tearsheet-step>
+      <cd-step #tearsheetStep>
+      </cds-step>
+  </cd-tearsheet-step>
+   <cd-tearsheet-step>
+      step 2 form
+  <cd-tearsheet-step>
+</cd-tearsheet>
+
+-----------------
+
+@Component({
+  selector: 'cd-step',
+  template: `<form></form>,
+  standalone: false
+})
+export class StepComponent implements TearsheetStep {
+formgroup: CdFormGroup;
+}
+**/
 @Component({
   selector: 'cd-tearsheet',
   standalone: false,
   templateUrl: './tearsheet.component.html',
   styleUrls: ['./tearsheet.component.scss']
 })
-export class TearsheetComponent implements OnInit, AfterViewChecked {
+export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy {
   @Input() title!: string;
   @Input() steps!: Array<Step>;
   @Input() description!: string;
-  @Input() submitButtonLabel: string = $localize`Create`;
   @Input() type: 'full' | 'wide' = 'wide';
+  @Input() submitButtonLabel: string = $localize`Create`;
+  @Input() submitButtonLoadingLabel: string = $localize`Creating`;
+  @Input() isSubmitLoading: boolean = true;
 
-  @Output() submitRequested = new EventEmitter<void>();
+  @Output() submitRequested = new EventEmitter<any[]>();
 
   @ContentChildren(TearsheetStepComponent)
   stepContents!: QueryList<TearsheetStepComponent>;
@@ -43,13 +76,15 @@ export class TearsheetComponent implements OnInit, AfterViewChecked {
   lastStep: number = null;
   isOpen: boolean = true;
   hasModalOutlet: boolean = false;
+  private destroy$ = new Subject<void>();
 
   constructor(
     protected formBuilder: FormBuilder,
     private changeDetectorRef: ChangeDetectorRef,
     private cdsModalService: ModalCdsService,
     private route: ActivatedRoute,
-    private location: Location
+    private location: Location,
+    private destroyRef: DestroyRef
   ) {}
 
   ngOnInit() {
@@ -90,10 +125,19 @@ export class TearsheetComponent implements OnInit, AfterViewChecked {
     }
   }
 
-  onSubmit() {
-    if (!this.steps[this.currentStep].invalid) {
-      this.submitRequested.emit();
-    }
+  getMergedPayload(): any {
+    return this.stepContents.toArray().reduce((acc, wrapper) => {
+      const stepFormValue = wrapper.stepComponent.formGroup.value;
+      return { ...acc, ...stepFormValue };
+    }, {});
+  }
+
+  handleSubmit() {
+    if (this.steps[this.currentStep].invalid) return;
+
+    const mergedPayloads = this.getMergedPayload();
+
+    this.submitRequested.emit(mergedPayloads);
   }
 
   closeFullTearsheet() {
@@ -112,7 +156,26 @@ export class TearsheetComponent implements OnInit, AfterViewChecked {
     });
   }
 
-  ngAfterViewChecked() {
-    this.changeDetectorRef.detectChanges();
+  ngAfterViewInit() {
+    // Checking Step validity, subscries ot all steps
+    if (!this.stepContents || !this.steps) return;
+
+    this.stepContents.forEach((wrapper, index) => {
+      const form = wrapper.stepComponent?.formGroup;
+      if (!form) return;
+
+      // Initialize invalid flag if missing
+      this.steps[index].invalid = form.invalid;
+
+      form.statusChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
+        this.steps[index].invalid = form.invalid;
+        this.changeDetectorRef.markForCheck();
+      });
+    });
+  }
+
+  ngOnDestroy() {
+    this.destroy$.next();
+    this.destroy$.complete();
   }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/tearsheet-step.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/tearsheet-step.ts
new file mode 100644 (file)
index 0000000..556bb83
--- /dev/null
@@ -0,0 +1,5 @@
+import { FormGroup } from '@angular/forms';
+
+export interface TearsheetStep {
+  formGroup: FormGroup;
+}