]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Converting Create OSDs tearsheet type from Full to Wide
authorDevika Babrekar <devika.babrekar@ibm.com>
Thu, 2 Jul 2026 10:46:46 +0000 (16:16 +0530)
committerDevika Babrekar <devika.babrekar@ibm.com>
Thu, 2 Jul 2026 10:46:46 +0000 (16:16 +0530)
Fixes: https://tracker.ceph.com/issues/77132
Signed-off-by: Devika Babrekar <devika.babrekar@ibm.com>
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-step-2/create-cluster-step-2.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-step-2/create-cluster-step-2.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.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 5a77bb1f1dba452bced0be966576f9fd3586d560..95f1449aa9bc12964c5ab6a478fdf5ff2b5167d3 100644 (file)
@@ -307,13 +307,13 @@ const routes: Routes = [
       },
       {
         path: 'osd',
+        component: OsdListComponent,
         data: { breadcrumbs: 'Cluster/OSDs' },
         children: [
-          { path: '', component: OsdListComponent },
           {
             path: URLVerbs.CREATE,
             component: OsdFormComponent,
-            data: { breadcrumbs: ActionLabels.CREATE }
+            outlet: 'modal'
           }
         ]
       },
index 1523bdb986bd930be286df8300b4a58678c747c0..b95d40920865c1da81158d668a1e22480003b1fa 100644 (file)
                      (emitDriveGroup)="setDriveGroup($event)"
                      (emitDeploymentOption)="setDeploymentOptions($event)"
                      (emitMode)="setDeploymentMode($event)"
-                     (osdCreated)="onOsdCreated()"></cd-osd-form>
+                     (osdCreated)="onOsdCreated()"
+                     (cancelled)="onFormCancelled()"></cd-osd-form>
       } @else {
         <cd-osd-list [showTabs]="false"
+                     [inlineCreate]="true"
                      (createAction)="onCreateAction()"></cd-osd-list>
       }
     </div>
index 655e675f184863e4a911cab003374acbc1a8e30d..e15cffa08a647b600e881f1c5a8e2e761064a146 100644 (file)
@@ -37,6 +37,10 @@ export class CreateClusterStep2Component implements OnInit, TearsheetStep {
     this.formGroup.patchValue({ skipped: true });
   }
 
+  onFormCancelled() {
+    this.showForm = false;
+  }
+
   onCreateAction() {
     this.showForm = true;
   }
index d536d41a9c62c85d7b4d34f795bd486dbd6c9f19..91dc4ca6eccd67a93bc590def3c7036968ad08b1 100644 (file)
@@ -6,10 +6,11 @@
   [steps]="steps"
   [title]="!hideTitle ? (action | titlecase) + ' ' + (resource | upperFirst) : ''"
   (submitRequested)="submit()"
+  (closeRequested)="onCancel()"
   (stepChanged)="populateReviewData()"
   [isSubmitLoading]="isSubmitLoading"
   [submitButtonLabel]="simpleDeployment ? createOsdsLabel : actionLabels.PREVIEW"
-  type="full">
+  type="wide">
 
   <cd-tearsheet-step>
   <div class="osd-tearsheet-content">
@@ -18,7 +19,7 @@
     cdsStack="vertical"
     [gap]="6">
 
-    @if (availDevices?.length === 0) {
+    @if (!hasEligibleDevices) {
     <cd-alert-panel
       type="warning"
       class="osd-alert-block"
@@ -50,7 +51,8 @@
           formControlName="deploymentMode"
           orientation="vertical">
 
-          <cds-radio value="automatic">
+          <cds-radio value="automatic"
+                     [disabled]="!hasEligibleDevices ? true : null">
             <div class="osd-radio-label-wrapper">
               <span
               class="cds--type-body-short-01"
@@ -84,7 +86,7 @@
             <cds-radio
               [value]="optionName"
               [class.cds-mb-5]="!isLast"
-              [disabled]="!deploymentOptions?.options[optionName].available ? true : null">
+              [disabled]="!hasEligibleDevices || !deploymentOptions?.options[optionName].available ? true : null">
 
               <div class="osd-radio-label-wrapper">
               <span class="cds--type-body-short-01">
         </div>
         }
 
-          <cds-radio value="manual">
+          <cds-radio value="manual"
+                     [disabled]="!hasEligibleDevices ? true : null">
             <div class="osd-radio-label-wrapper">
               <span
               class="cds--type-body-short-01"
       </div>
     </div>
 
+    <div formGroupName="features">
+      <cds-text-label
+      class="cds--type-heading-compact-02 cds-mb-5"
+      i18n>
+      Features
+      </cds-text-label>
+
+    @for (feature of featureList; track feature.key) {
+      <cds-checkbox
+      [name]="feature.key"
+      formControlName="{{ feature.key }}"
+      (checkedChange)="emitDeploymentSelection()">
+      {{ feature.desc }}
+      </cds-checkbox>
+    }
+    </div>
+
     </form>
   </div>
   </cd-tearsheet-step>
   </cd-tearsheet-step>
   }
 
-  <cd-tearsheet-step>
-  <div class="osd-tearsheet-content">
-    <form
-    [formGroup]="form">
-
-    <div formGroupName="features">
-      <cds-text-label
-      class="cds--type-heading-compact-02 cds-mb-5"
-      i18n>
-      Features
-      </cds-text-label>
-
-    @for (feature of featureList; track feature.key) {
-      <cds-checkbox
-      [name]="feature.key"
-      formControlName="{{ feature.key }}"
-      (checkedChange)="emitDeploymentSelection()">
-      {{ feature.desc }}
-      </cds-checkbox>
-    }
-    </div>
-
-    </form>
-  </div>
-  </cd-tearsheet-step>
 
     <cd-tearsheet-step>
     <div class="osd-tearsheet-content">
index 5f46fc4c677a0855908a3aa6fcae7c2f1b882006..1a19376cf14142fe051bbd96063f2a508acca895 100644 (file)
@@ -215,24 +215,19 @@ describe('OsdFormComponent', () => {
         OsdDeploymentOptions.THROUGHPUT,
         OsdDeploymentOptions.IOPS
       ]);
-      expect(component.steps).toHaveLength(3);
-      expect(component.steps.map((step) => step.label)).toEqual([
-        'Deployment Options',
-        'Features',
-        'Review'
-      ]);
+      expect(component.steps).toHaveLength(2);
+      expect(component.steps.map((step) => step.label)).toEqual(['Deployment Options', 'Review']);
     });
 
     it('should expand and collapse steps when deployment mode changes', () => {
       component.form.get('deploymentMode').setValue('manual');
       fixture.detectChanges();
 
-      expect(component.steps).toHaveLength(5);
+      expect(component.steps).toHaveLength(4);
       expect(component.steps.map((step) => step.label)).toEqual([
         'Deployment Options',
         'Select data devices',
         'Select DB/WAL devices (optional)',
-        'Features',
         'Review'
       ]);
       expect(fixture.nativeElement.textContent).toContain('Select data devices');
@@ -241,12 +236,8 @@ describe('OsdFormComponent', () => {
       component.form.get('deploymentMode').setValue('automatic');
       fixture.detectChanges();
 
-      expect(component.steps).toHaveLength(3);
-      expect(component.steps.map((step) => step.label)).toEqual([
-        'Deployment Options',
-        'Features',
-        'Review'
-      ]);
+      expect(component.steps).toHaveLength(2);
+      expect(component.steps.map((step) => step.label)).toEqual(['Deployment Options', 'Review']);
       expect(fixture.nativeElement.textContent).not.toContain('Select data devices');
       expect(fixture.nativeElement.textContent).not.toContain('Select DB/WAL devices (optional)');
     });
@@ -271,6 +262,13 @@ describe('OsdFormComponent', () => {
       expect(text).toContain('IOPS-optimized');
     });
 
+    it('should mark the first step invalid when no eligible devices are available', () => {
+      expect(component.hasEligibleDevices).toBe(false);
+      expect(component.steps[0].invalid).toBe(true);
+      expect(component.form.get('deploymentMode').disabled).toBe(true);
+      expect(component.form.get('deploymentOption').disabled).toBe(true);
+    });
+
     it('should only disable the options that are not available', () => {
       expect(deploymentOptions.options['throughput_optimized'].available).toBeFalsy();
       expect(deploymentOptions.options['iops_optimized'].available).toBeFalsy();
index ded0a1be5c9003d60ee34a69063f09616ee4eeb1..6860134b3b916d300862c33f3451967a0061a956 100644 (file)
@@ -8,6 +8,7 @@ import {
   ViewChild
 } from '@angular/core';
 import { UntypedFormControl } from '@angular/forms';
+import { Location } from '@angular/common';
 import { Router } from '@angular/router';
 
 import _ from 'lodash';
@@ -94,6 +95,8 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy {
 
   @Output() osdCreated: EventEmitter<void> = new EventEmitter();
 
+  @Output() cancelled: EventEmitter<void> = new EventEmitter();
+
   icons = Icons;
 
   form!: CdFormGroup;
@@ -140,6 +143,7 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy {
     private orchService: OrchestratorService,
     private hostService: HostService,
     private router: Router,
+    private location: Location,
     private formatterService: FormatterService,
     private modalService: ModalService,
     private osdService: OsdService,
@@ -158,22 +162,56 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy {
     this.createForm();
   }
 
+  get hasEligibleDevices(): boolean {
+    return this.allDevices.length > 0;
+  }
+
   private getStepsForMode(mode: string): Array<Step> {
+    const firstStepInvalid = !this.hasEligibleDevices;
+    const subsequentStepDisabled = !this.hasEligibleDevices;
     return mode !== 'manual'
       ? [
-          { label: STEP_LABELS.DEPLOYMENT, invalid: false },
-          { label: STEP_LABELS.FEATURES, invalid: false },
-          { label: STEP_LABELS.REVIEW, invalid: false }
+          { label: STEP_LABELS.DEPLOYMENT, invalid: firstStepInvalid },
+          { label: STEP_LABELS.REVIEW, invalid: false, disabled: subsequentStepDisabled }
         ]
       : [
-          { label: STEP_LABELS.DEPLOYMENT, invalid: false },
-          { label: STEP_LABELS.DATA, invalid: false },
-          { label: STEP_LABELS.DB_WAL, invalid: false },
-          { label: STEP_LABELS.FEATURES, invalid: false },
-          { label: STEP_LABELS.REVIEW, invalid: false }
+          { label: STEP_LABELS.DEPLOYMENT, invalid: firstStepInvalid },
+          { label: STEP_LABELS.DATA, invalid: false, disabled: subsequentStepDisabled },
+          { label: STEP_LABELS.DB_WAL, invalid: false, disabled: subsequentStepDisabled },
+          { label: STEP_LABELS.REVIEW, invalid: false, disabled: subsequentStepDisabled }
         ];
   }
 
+  private updateFirstStepInvalid() {
+    const invalid = !this.hasEligibleDevices;
+    const subsequentStepDisabled = !this.hasEligibleDevices;
+    const unchanged =
+      this.steps[0]?.invalid === invalid &&
+      this.steps.slice(1).every((step) => step.disabled === subsequentStepDisabled);
+    if (unchanged) {
+      return;
+    }
+    this.steps = this.steps.map((step, i) => {
+      if (i === 0) {
+        return { ...step, invalid };
+      }
+      return { ...step, disabled: subsequentStepDisabled };
+    });
+  }
+
+  private updateDeploymentControlsAvailability() {
+    const deploymentMode = this.form.get('deploymentMode');
+    const deploymentOption = this.form.get('deploymentOption');
+    if (!this.hasEligibleDevices) {
+      deploymentMode?.disable({ emitEvent: false });
+      deploymentOption?.disable({ emitEvent: false });
+    } else {
+      deploymentMode?.enable({ emitEvent: false });
+      deploymentOption?.enable({ emitEvent: false });
+    }
+    this.updateFirstStepInvalid();
+  }
+
   private createEmptyReviewDeviceSelection(): ReviewDeviceSelection {
     return {
       count: 0,
@@ -232,6 +270,7 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy {
   private updateSteps() {
     const mode = this.form?.get('deploymentMode')?.value ?? 'automatic';
     this.steps = this.getStepsForMode(mode);
+    this.updateFirstStepInvalid();
   }
 
   ngOnInit() {
@@ -311,11 +350,13 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy {
       (devices: InventoryDevice[]) => {
         this.allDevices = _.filter(devices, 'available');
         this.availDevices = [...this.allDevices];
+        this.updateDeploymentControlsAvailability();
         this.loadingReady();
       },
       () => {
         this.allDevices = [];
         this.availDevices = [];
+        this.updateDeploymentControlsAvailability();
         this.loadingError();
       }
     );
@@ -456,6 +497,14 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy {
     );
   }
 
+  onCancel() {
+    if (this.hideTitle) {
+      this.cancelled.emit();
+      return;
+    }
+    this.location.back();
+  }
+
   private navigateAfterCreate() {
     const returnUrl = window.history.state?.returnUrl;
 
index bac3ee08d6e3515caf224305e16146edff3a77d3..2a7e150f992a894fd096bf9fe2bcfe66ad40cbd5 100644 (file)
@@ -1,11 +1,11 @@
 import {
   Component,
-  EventEmitter,
   Input,
   OnInit,
-  Output,
   TemplateRef,
-  ViewChild
+  ViewChild,
+  Output,
+  EventEmitter
 } from '@angular/core';
 import { UntypedFormControl } from '@angular/forms';
 import { Router } from '@angular/router';
@@ -39,7 +39,6 @@ import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
 import { ModalService } from '~/app/shared/services/modal.service';
 import { NotificationService } from '~/app/shared/services/notification.service';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
-import { URLBuilderService } from '~/app/shared/services/url-builder.service';
 import { OsdFlagsIndivModalComponent } from '../osd-flags-indiv-modal/osd-flags-indiv-modal.component';
 import { OsdFlagsModalComponent } from '../osd-flags-modal/osd-flags-modal.component';
 import { OsdPgScrubModalComponent } from '../osd-pg-scrub-modal/osd-pg-scrub-modal.component';
@@ -57,11 +56,11 @@ const BASE_URL = 'osd';
   selector: 'cd-osd-list',
   templateUrl: './osd-list.component.html',
   styleUrls: ['./osd-list.component.scss'],
-  providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }],
   standalone: false
 })
 export class OsdListComponent extends ListWithDetails implements OnInit {
   @Input() showTabs = true;
+  @Input() inlineCreate = false;
   @Output() createAction = new EventEmitter<void>();
 
   @ViewChild('osdUsageTpl', { static: true })
@@ -121,7 +120,6 @@ export class OsdListComponent extends ListWithDetails implements OnInit {
     private osdService: OsdService,
     private dimlessBinaryPipe: DimlessBinaryPipe,
     private modalService: ModalService,
-    private urlBuilder: URLBuilderService,
     private router: Router,
     private taskWrapper: TaskWrapperService,
     public actionLabels: ActionLabelsI18n,
@@ -137,14 +135,10 @@ export class OsdListComponent extends ListWithDetails implements OnInit {
         permission: 'create',
         icon: Icons.add,
         click: () => {
-          if (this.createAction.observers.length > 0) {
+          if (this.inlineCreate) {
             this.createAction.emit();
           } else {
-            this.router.navigate([this.urlBuilder.getCreate()], {
-              state: {
-                returnUrl: this.router.url
-              }
-            });
+            this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.CREATE] } }]);
           }
         },
         disable: (selection: CdTableSelection) => this.getDisable('create', selection),
index a5b181968b2308da019aca60927f607f82c1a78f..c1bb0ac8c7ad9b272bde8c0d1e4e174ca9dc07ad 100644 (file)
@@ -81,6 +81,7 @@
           @else {
           <button cdsButton="primary"
                   size="xl"
+                  [disabled]="steps[currentStep]?.invalid"
                   (click)="onNext()"
                   i18n>Next</button>
           }
         @else {
         <button cdsButton="primary"
                 size="xl"
+                [disabled]="steps[currentStep]?.invalid"
                 (click)="onNext()"
                 i18n>Next</button>
         }
index c886b41bc1cd453393cb33392778df2e32200d23..791575ec8f782d557f877d72ace097285c5b9858 100644 (file)
@@ -172,5 +172,18 @@ describe('TearsheetComponent', () => {
       tearsheetComponent.onNext();
       expect(tearsheetComponent.currentStep).toBe(0);
     });
+
+    it('should disable next button when current step is invalid', () => {
+      hostComponent.steps = hostComponent.steps.map((step, i) =>
+        i === 0 ? { ...step, invalid: true } : step
+      );
+      hostFixture.detectChanges();
+      const buttons = hostFixture.debugElement.queryAll(
+        By.css('.tearsheet-footer button[cdsButton="primary"]')
+      );
+      const nextBtn = buttons.find((btn) => btn.nativeElement.textContent.trim() === 'Next');
+      expect(nextBtn).toBeTruthy();
+      expect(nextBtn?.nativeElement.disabled).toBe(true);
+    });
   });
 });
index dd91a045c951e6c7b343e171b40684cb769ef64c..2be126ef6897dc6a31f16f1d129108481b3e8f29 100644 (file)
@@ -10,6 +10,8 @@ import {
   AfterViewInit,
   DestroyRef,
   OnDestroy,
+  OnChanges,
+  SimpleChanges,
   ChangeDetectionStrategy,
   TemplateRef,
   ViewEncapsulation
@@ -61,7 +63,7 @@ formgroup: CdFormGroup;
   changeDetection: ChangeDetectionStrategy.OnPush,
   encapsulation: ViewEncapsulation.None
 })
-export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy {
+export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy, OnChanges {
   @Input() title!: string;
   @Input() steps!: Array<Step>;
   @Input() description!: string;
@@ -134,13 +136,42 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy {
     this.hasModalOutlet = this.route.outlet === 'modal';
   }
 
+  ngOnChanges(changes: SimpleChanges) {
+    if (changes['steps']) {
+      this.lastStep = this.steps.length - 1;
+      if (this.currentStep > this.lastStep) {
+        this.currentStep = this.lastStep;
+      }
+      this.cdr.markForCheck();
+    }
+  }
+
   private _updateStepInvalid(index: number, invalid: boolean) {
     this.steps = this.steps.map((step, i) => (i === index ? { ...step, invalid } : step));
   }
 
   onStepSelect(event: { step: Step; index: number }) {
+    if (this.isStepNavBlocked(event.index)) {
+      return;
+    }
     this.currentStep = event.index;
     this.stepChanged.emit({ current: this.currentStep });
+    this.cdr.markForCheck();
+  }
+
+  private isStepNavBlocked(index: number): boolean {
+    if (this.steps[index]?.disabled) {
+      return true;
+    }
+    if (index > this.currentStep && this.steps[this.currentStep]?.invalid) {
+      return true;
+    }
+    for (let i = 0; i < index; i++) {
+      if (this.steps[i]?.invalid || this.steps[i]?.disabled) {
+        return true;
+      }
+    }
+    return false;
   }
 
   closeTearsheet() {
@@ -154,6 +185,9 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy {
   closeWideTearsheet() {
     this.closeRequested.emit();
     this.isOpen = false;
+    if (this.closeRequested.observers.length > 0) {
+      return;
+    }
     if (this.hasModalOutlet) {
       this.location.back();
     } else {