]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Fix routing issue when clicking Next button in create subsystem wizard 70702/head
authorpujashahu <pshahu@redhat.com>
Fri, 31 Jul 2026 08:24:39 +0000 (13:54 +0530)
committerpujashahu <pshahu@redhat.com>
Mon, 3 Aug 2026 07:58:04 +0000 (13:28 +0530)
Fixes: https://tracker.ceph.com/issues/78875
Signed-off-by: pujaoshahu <pshahu@redhat.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.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.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts

index 56d887983b84066ea955ac3e6a68111e3d5798af..0d9713d97d663b2d4ba57e6a9c7809359b2f2a08 100644 (file)
@@ -79,5 +79,29 @@ describe('NvmeofSubsystemsStepOneComponent', () => {
 
       expect(form.get('subnetMask')?.hasError('required')).toBeTruthy();
     });
+
+    it('should not require subnet mask when add manually is selected', () => {
+      formHelper.setValue('listenerMode', component.LISTENER_MODE.MANUAL);
+      formHelper.setValue('subnetMask', '');
+      form.get('subnetMask')?.updateValueAndValidity();
+
+      expect(form.get('subnetMask')?.hasError('required')).toBeFalsy();
+    });
+
+    it('should require listeners when add manually is selected and none are chosen', () => {
+      formHelper.setValue('listenerMode', component.LISTENER_MODE.MANUAL);
+      formHelper.setValue('listeners', []);
+      form.get('listeners')?.updateValueAndValidity();
+
+      expect(form.get('listeners')?.hasError('required')).toBeTruthy();
+    });
+
+    it('should not require listeners when auto-fetch is selected', () => {
+      formHelper.setValue('listenerMode', component.LISTENER_MODE.AUTO_FETCH);
+      formHelper.setValue('listeners', []);
+      form.get('listeners')?.updateValueAndValidity();
+
+      expect(form.get('listeners')?.hasError('required')).toBeFalsy();
+    });
   });
 });
index 09ae0a6473e003a10f4098771be9248086868053..6be42a9855cac99c3ed092917822f655f328e064 100644 (file)
@@ -86,8 +86,14 @@ export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep {
     const subnetMaskValidators = [
       CdValidators.composeIf({ listenerMode: this.LISTENER_MODE.AUTO_FETCH }, [Validators.required])
     ];
+    // Empty array is a valid value for Validators.required in some paths; require
+    // at least one selected listener when Add manually is active.
+    const requireListeners = CdValidators.custom(
+      'required',
+      (value: ListenerItem[] | null | undefined) => !value || value.length === 0
+    );
     const listenersValidators = [
-      CdValidators.composeIf({ listenerMode: this.LISTENER_MODE.MANUAL }, [Validators.required])
+      CdValidators.composeIf({ listenerMode: this.LISTENER_MODE.MANUAL }, [requireListeners])
     ];
 
     if (this.listenersOnly) {
index 2fcfc1a6369d98879d96d694dea273c544f61d3b..bda8066220d2c6ccff9ee2ec3572f61f08645003 100644 (file)
                 type="button"
                 cdsButton="primary"
                 size="xl"
-                [disabled]="steps[currentStep]?.invalid"
                 (click)="onNext()"
                 i18n
               >
               type="button"
               cdsButton="primary"
               size="xl"
-              [disabled]="steps[currentStep]?.invalid"
               (click)="onNext()"
               i18n
             >
index 7405093f55e03d7c441a1dea60f3034609212fea..960e6f93a3104d9ba447a23678474cd2bbdf65e1 100644 (file)
@@ -1,11 +1,13 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
 import { Component, ViewChild } from '@angular/core';
 import { By } from '@angular/platform-browser';
-import { FormControl, FormGroup, Validators } from '@angular/forms';
+import { AbstractControl, FormControl, FormGroup, Validators } from '@angular/forms';
 import { SharedModule } from '../../shared.module';
 import { TearsheetStepComponent } from '../tearsheet-step/tearsheet-step.component';
 import { TearsheetComponent, TearsheetOverflowScroll } from './tearsheet.component';
 import { ActivatedRoute } from '@angular/router';
+import { Observable, of } from 'rxjs';
+import { delay } from 'rxjs/operators';
 
 // Mock Component that uses tearsheet
 @Component({
@@ -61,6 +63,56 @@ class MockFormStepComponent {
   });
 }
 
+@Component({
+  selector: 'cd-mock-async-form-step',
+  template: '',
+  standalone: false
+})
+class MockAsyncFormStepComponent {
+  formGroup = new FormGroup({
+    name: new FormControl('default-name', {
+      validators: [Validators.required],
+      asyncValidators: [
+        (_control: AbstractControl): Observable<{ notUnique: boolean } | null> =>
+          of(null).pipe(delay(10))
+      ]
+    }),
+    requiredField: new FormControl('', Validators.required)
+  });
+}
+
+@Component({
+  template: `
+    <cd-tearsheet
+      [steps]="steps"
+      [title]="title"
+      [description]="description"
+    >
+      <cd-tearsheet-step>
+        <cd-mock-async-form-step #tearsheetStep></cd-mock-async-form-step>
+      </cd-tearsheet-step>
+      <cd-tearsheet-step>
+        <div>Step 2</div>
+      </cd-tearsheet-step>
+    </cd-tearsheet>
+  `,
+  standalone: false
+})
+class MockAsyncFormHostComponent {
+  steps = [
+    { label: 'Step 1', complete: false },
+    { label: 'Step 2', complete: false }
+  ];
+  title = 'Async Form Host';
+  description = 'Async Form Host Description';
+
+  @ViewChild(TearsheetComponent)
+  tearsheet!: TearsheetComponent;
+
+  @ViewChild(MockAsyncFormStepComponent)
+  formStep!: MockAsyncFormStepComponent;
+}
+
 @Component({
   template: `
     <cd-tearsheet
@@ -105,7 +157,9 @@ describe('TearsheetComponent', () => {
         TearsheetStepComponent,
         MockHostComponent,
         MockFormStepComponent,
-        MockFormHostComponent
+        MockFormHostComponent,
+        MockAsyncFormStepComponent,
+        MockAsyncFormHostComponent
       ],
       imports: [SharedModule],
       providers: [
@@ -218,7 +272,7 @@ describe('TearsheetComponent', () => {
       expect(tearsheetComponent.currentStep).toBe(0);
     });
 
-    it('should disable next button when current step is invalid', () => {
+    it('should keep Next enabled when current step is invalid so users can retry', () => {
       hostComponent.steps = hostComponent.steps.map((step, i) =>
         i === 0 ? { ...step, invalid: true } : step
       );
@@ -228,12 +282,12 @@ describe('TearsheetComponent', () => {
       );
       const nextBtn = buttons.find((btn) => btn.nativeElement.textContent.trim() === 'Next');
       expect(nextBtn).toBeTruthy();
-      expect(nextBtn?.nativeElement.disabled).toBe(true);
+      expect(nextBtn?.nativeElement.disabled).toBe(false);
     });
   });
 
   describe('nested form validation on next', () => {
-    it('should mark nested controls dirty and touched on next', () => {
+    it('should mark nested controls touched on next without dirtying them', () => {
       const formHostFixture = TestBed.createComponent(MockFormHostComponent);
       formHostFixture.detectChanges();
       const formHost = formHostFixture.componentInstance;
@@ -245,10 +299,121 @@ describe('TearsheetComponent', () => {
 
       formHost.tearsheet.onNext();
 
-      expect(childControl?.dirty).toBe(true);
+      // Touch shows errors; do not mark dirty (that re-triggers async validators).
+      expect(childControl?.dirty).toBe(false);
       expect(childControl?.touched).toBe(true);
       expect(childControl?.hasError('required')).toBe(true);
+      expect(formHost.tearsheet.currentStep).toBe(0);
     });
+
+    it('should advance after async validators complete when required fields are filled', fakeAsync(() => {
+      const formHostFixture = TestBed.createComponent(MockAsyncFormHostComponent);
+      formHostFixture.detectChanges();
+      const formHost = formHostFixture.componentInstance;
+
+      formHost.formStep.formGroup.get('requiredField')?.setValue('filled');
+      formHostFixture.detectChanges();
+
+      formHost.tearsheet.onNext();
+      expect(formHost.tearsheet.currentStep).toBe(0);
+
+      tick(10);
+      formHostFixture.detectChanges();
+
+      expect(formHost.tearsheet.currentStep).toBe(1);
+      expect(formHost.tearsheet.steps[0].invalid).toBe(false);
+    }));
+
+    it('should stay on step when invalid then advance after the field is fixed', fakeAsync(() => {
+      const formHostFixture = TestBed.createComponent(MockAsyncFormHostComponent);
+      formHostFixture.detectChanges();
+      const formHost = formHostFixture.componentInstance;
+
+      // First Next with empty required field: stay on step; Next remains usable.
+      formHost.tearsheet.onNext();
+      tick(10);
+      formHostFixture.detectChanges();
+      expect(formHost.tearsheet.currentStep).toBe(0);
+
+      formHost.formStep.formGroup.get('requiredField')?.setValue('filled');
+      tick(10);
+      formHostFixture.detectChanges();
+
+      formHost.tearsheet.onNext();
+      tick(10);
+      formHostFixture.detectChanges();
+      expect(formHost.tearsheet.currentStep).toBe(1);
+    }));
+
+    it('should submit after validity refresh settles async validators', fakeAsync(() => {
+      const formHostFixture = TestBed.createComponent(MockAsyncFormHostComponent);
+      formHostFixture.detectChanges();
+      const formHost = formHostFixture.componentInstance;
+      const submitSpy = jest.spyOn(formHost.tearsheet.submitRequested, 'emit');
+
+      formHost.formStep.formGroup.get('requiredField')?.setValue('filled');
+      // Settle the initial async validator from control creation.
+      tick(10);
+      formHostFixture.detectChanges();
+
+      formHost.tearsheet.currentStep = 1;
+      formHostFixture.detectChanges();
+
+      formHost.tearsheet.onSubmit();
+      // refreshControlValidity re-runs async validators; wait for them to settle.
+      expect(submitSpy).not.toHaveBeenCalled();
+      tick(10);
+      formHostFixture.detectChanges();
+
+      expect(submitSpy).toHaveBeenCalledTimes(1);
+      expect(submitSpy).toHaveBeenCalledWith(
+        expect.objectContaining({ name: 'default-name', requiredField: 'filled' })
+      );
+    }));
+
+    it('should navigate to the first invalid step instead of silently blocking Create', fakeAsync(() => {
+      const formHostFixture = TestBed.createComponent(MockAsyncFormHostComponent);
+      formHostFixture.detectChanges();
+      const formHost = formHostFixture.componentInstance;
+      const submitSpy = jest.spyOn(formHost.tearsheet.submitRequested, 'emit');
+
+      tick(10);
+      formHost.tearsheet.currentStep = 1;
+      formHostFixture.detectChanges();
+
+      formHost.tearsheet.onSubmit();
+      tick(10);
+      formHostFixture.detectChanges();
+
+      expect(submitSpy).not.toHaveBeenCalled();
+      expect(formHost.tearsheet.currentStep).toBe(0);
+      expect(formHost.tearsheet.steps[0].invalid).toBe(true);
+    }));
+
+    it('should not advance if the user navigated away before the async validator settled', fakeAsync(() => {
+      const formHostFixture = TestBed.createComponent(MockAsyncFormHostComponent);
+      formHostFixture.detectChanges();
+      const formHost = formHostFixture.componentInstance;
+
+      // Fill the required field so the form would be valid once async settles.
+      formHost.formStep.formGroup.get('requiredField')?.setValue('filled');
+      formHostFixture.detectChanges();
+
+      // Trigger Next — async validator is in-flight, step stays at 0.
+      formHost.tearsheet.onNext();
+      expect(formHost.tearsheet.currentStep).toBe(0);
+
+      // User navigates back to step 0 then clicks step 1 directly (or onStepSelect),
+      // simulating navigation away while the validator is still pending.
+      formHost.tearsheet.currentStep = 1;
+      formHostFixture.detectChanges();
+
+      // Async validator settles — callback must not advance further (step should stay at 1).
+      tick(10);
+      formHostFixture.detectChanges();
+
+      expect(formHost.tearsheet.currentStep).toBe(1);
+    }));
   });
 
   describe('[stepValid] seeding and live sync', () => {
index 2bd40d44a42f1c39dd0be091c17c483c95667910..5fc75c0fae9e9971b18f98cf5468cf5003238a58 100644 (file)
@@ -24,8 +24,8 @@ 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';
-import { takeUntil } from 'rxjs/operators';
+import { forkJoin, Subject } from 'rxjs';
+import { filter, finalize, startWith, take, takeUntil } from 'rxjs/operators';
 
 export type TearsheetOverflowScroll = 'auto' | 'hidden' | 'visible' | 'scroll';
 
@@ -79,7 +79,8 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy, OnC
   @Input() successIcon: boolean = false;
   @Input() headerTestId?: string;
 
-  @Output() submitRequested = new EventEmitter<void>();
+  /** Merged step form values for consumers that bind `(submitRequested)="onSubmit($event)"`. */
+  @Output() submitRequested = new EventEmitter<Record<string, unknown>>();
   @Output() closeRequested = new EventEmitter<void>();
   @Output() stepChanged = new EventEmitter<{ current: number }>();
   @Output() validateStep = new EventEmitter<{ step: number }>();
@@ -87,6 +88,11 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy, OnC
   @ContentChildren(TearsheetStepComponent)
   stepContents!: QueryList<TearsheetStepComponent>;
 
+  private advancingDueToAsync = false;
+  private submittingDueToAsync = false;
+  /** Snapshot of each step's form value taken when leaving the step. */
+  private stepValueCache = new WeakMap<TearsheetStepComponent, Record<string, unknown>>();
+
   get activeStepTemplate() {
     return this.stepContents?.toArray()[this.currentStep]?.template;
   }
@@ -154,6 +160,9 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy, OnC
 
   private _updateStepInvalid(index: number, invalid: boolean) {
     this.steps = this.steps.map((step, i) => (i === index ? { ...step, invalid } : step));
+    // statusChanges / async validators run outside the OnPush event path;
+    // mark dirty so Next button disabled state re-renders.
+    this.cdr.markForCheck();
   }
 
   onStepSelect(event: { step: Step; index: number }) {
@@ -213,58 +222,170 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy, OnC
     this.validateStep.emit({ step: this.currentStep });
     const wrapper = this.stepContents?.toArray()?.[this.currentStep];
     const currentForm = wrapper?.resolvedFormGroup;
+    // Touch for error display, then refresh each control so cdValidate /
+    // Carbon invalid bindings update. Do NOT markAsDirty — that re-triggers
+    // pristine-skipping async validators (e.g. NQN unique).
     currentForm?.markAllAsTouched();
-    this.markControlsDirtyAndValidate(currentForm);
-    if (currentForm) {
-      this._updateStepInvalid(this.currentStep, currentForm.invalid);
+    this.refreshControlValidity(currentForm);
+
+    // If an async validator is already in-flight (user edited NQN), wait for it.
+    if (currentForm?.pending) {
+      if (this.advancingDueToAsync) {
+        return;
+      }
+      this.advancingDueToAsync = true;
+      // Snapshot the step index now; the user may navigate away before the
+      // validator settles, so we must re-check both the index and the active
+      // wrapper on arrival and skip the advance if either has changed.
+      const stepBeingValidated = this.currentStep;
+      currentForm.statusChanges
+        .pipe(
+          startWith(currentForm.status),
+          filter((status) => status !== 'PENDING'),
+          take(1),
+          takeUntil(this.setupTeardown$),
+          finalize(() => {
+            this.advancingDueToAsync = false;
+          })
+        )
+        .subscribe(() => {
+          const activeWrapper = this.stepContents?.toArray()?.[stepBeingValidated];
+          if (this.currentStep === stepBeingValidated && activeWrapper === wrapper) {
+            this.advanceFromCurrentStep(wrapper);
+          }
+        });
+      return;
+    }
+
+    this.advanceFromCurrentStep(wrapper);
+  }
+
+  /**
+   * Re-run validators and emit statusChanges on every control without marking
+   * them dirty. Needed so cdValidate picks up touched+invalid after Next.
+   */
+  private refreshControlValidity(control: AbstractControl | null) {
+    if (!control) {
+      return;
     }
+    if (control instanceof FormGroup || control instanceof FormArray) {
+      Object.values(control.controls).forEach((child) => this.refreshControlValidity(child));
+    }
+    control.updateValueAndValidity({ onlySelf: true, emitEvent: true });
+  }
 
+  private advanceFromCurrentStep(wrapper: TearsheetStepComponent | undefined) {
+    // canProceed uses form.valid, so PENDING/INVALID both block advance.
+    // Next stays enabled; we only show field errors and refuse to leave the step.
     const canAdvance = wrapper ? wrapper.canProceed : true;
-    this._updateStepInvalid(this.currentStep, !canAdvance);
     if (this.currentStep !== this.lastStep && canAdvance) {
+      this._updateStepInvalid(this.currentStep, false);
+      if (wrapper) {
+        this.cacheStepValue(wrapper);
+      }
       this.currentStep = this.currentStep + 1;
       this.stepChanged.emit({ current: this.currentStep });
-      this.cdr.markForCheck();
-    } else if (!canAdvance) {
-      this.cdr.markForCheck();
+    }
+  }
+
+  private cacheStepValue(wrapper: TearsheetStepComponent) {
+    const value = wrapper.stepComponent?.formGroup?.value as Record<string, unknown> | null;
+    if (value) {
+      this.stepValueCache.set(wrapper, { ...value });
     }
   }
 
   getMergedPayload(): any {
     return this.stepContents.toArray().reduce((acc, wrapper) => {
-      const stepFormValue = wrapper.stepComponent?.formGroup?.value;
-      return { ...acc, ...stepFormValue };
+      const liveValue = wrapper.stepComponent?.formGroup?.value;
+      const cachedValue = this.stepValueCache.get(wrapper);
+      return { ...acc, ...(liveValue ?? cachedValue ?? {}) };
     }, {});
   }
 
   onSubmit() {
-    this.stepContents?.forEach((wrapper, index) => {
+    if (this.submittingDueToAsync) {
+      return;
+    }
+
+    // Cache whatever is still mounted before validating/submitting.
+    this.stepContents?.forEach((wrapper) => this.cacheStepValue(wrapper));
+
+    const wrappers = this.stepContents?.toArray() ?? [];
+    wrappers.forEach((wrapper) => {
       const form = wrapper.resolvedFormGroup;
       if (!form) return;
       form.markAllAsTouched();
-      this.markControlsDirtyAndValidate(form);
-      this._updateStepInvalid(index, form.invalid);
+      this.refreshControlValidity(form);
     });
 
-    const wrappers = this.stepContents?.toArray() ?? [];
-    const anyStepInvalid = this.steps.some(
-      (step, index) => step?.invalid || (wrappers[index] ? !wrappers[index].canProceed : false)
-    );
-    if (anyStepInvalid) return;
+    this.finishSubmit();
+  }
 
-    const mergedPayloads = this.getMergedPayload();
-    this.submitRequested.emit(mergedPayloads);
+  private waitForFormsToSettle(forms: FormGroup[], onSettled: () => void) {
+    if (!forms.length) {
+      onSettled();
+      return;
+    }
+    this.submittingDueToAsync = true;
+    forkJoin(
+      forms.map((form) =>
+        form.statusChanges.pipe(
+          startWith(form.status),
+          filter((status) => status !== 'PENDING'),
+          take(1)
+        )
+      )
+    )
+      .pipe(
+        takeUntil(this.setupTeardown$),
+        finalize(() => {
+          this.submittingDueToAsync = false;
+        })
+      )
+      .subscribe(() => onSettled());
   }
 
-  private markControlsDirtyAndValidate(control: AbstractControl | null) {
-    if (!control) {
+  private finishSubmit() {
+    const wrappers = this.stepContents?.toArray() ?? [];
+    const forms = wrappers
+      .map((wrapper) => wrapper.resolvedFormGroup)
+      .filter((form): form is FormGroup => !!form);
+
+    const pendingForms = forms.filter((form) => form.pending);
+    if (pendingForms.length) {
+      this.waitForFormsToSettle(pendingForms, () => this.finishSubmit());
       return;
     }
-    if (control instanceof FormGroup || control instanceof FormArray) {
-      Object.values(control.controls).forEach((child) => this.markControlsDirtyAndValidate(child));
+
+    let firstInvalid = -1;
+    wrappers.forEach((wrapper, index) => {
+      const form = wrapper.resolvedFormGroup;
+      if (form) {
+        this._updateStepInvalid(index, form.invalid);
+        if (form.invalid && firstInvalid < 0) {
+          firstInvalid = index;
+        }
+      } else if (wrapper.stepValid !== null && !wrapper.canProceed) {
+        this._updateStepInvalid(index, true);
+        if (firstInvalid < 0) {
+          firstInvalid = index;
+        }
+      } else {
+        // Form not currently resolvable (step content unmounted). Trust cache /
+        // earlier navigation — do not block Create on a stale steps[].invalid flag.
+        this._updateStepInvalid(index, false);
+      }
+    });
+
+    if (firstInvalid >= 0) {
+      this.currentStep = firstInvalid;
+      this.stepChanged.emit({ current: this.currentStep });
+      this.cdr.markForCheck();
+      return;
     }
-    control.markAsDirty({ onlySelf: true });
-    control.updateValueAndValidity({ onlySelf: true, emitEvent: true });
+
+    this.submitRequested.emit(this.getMergedPayload());
   }
 
   closeFullTearsheet() {
@@ -302,9 +423,19 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy, OnC
         // with Next enabled so the user can navigate freely before touching fields.
         const form = wrapper.resolvedFormGroup;
         if (form) {
-          form.statusChanges
-            .pipe(takeUntil(this.setupTeardown$))
-            .subscribe(() => this._updateStepInvalid(index, form.invalid));
+          // Do not seed or sync form.invalid onto the Next button — Next stays
+          // enabled so users can click it, see field errors (e.g. subnet-mask),
+          // fix them, and click Next again. Advance is still gated in onNext().
+          form.statusChanges.pipe(takeUntil(this.setupTeardown$)).subscribe(() => {
+            if (form.pending) {
+              return;
+            }
+            // Clear step invalid once the form becomes valid again after a
+            // failed Next attempt (field-level errors are handled by cdValidate).
+            if (form.valid) {
+              this._updateStepInvalid(index, false);
+            }
+          });
         }
 
         // Path 2: step uses [stepValid] input binding (no formGroup reference).