]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add generic wizard component 66752/head
authorAfreen Misbah <afreen@ibm.com>
Mon, 29 Dec 2025 04:51:36 +0000 (10:21 +0530)
committerAfreen Misbah <afreen@ibm.com>
Tue, 6 Jan 2026 14:04:42 +0000 (19:34 +0530)
Fixes https://tracker.ceph.com/issues/74291

- made on top of carbon modal
- carbon design system used - wide tearsheet
- added a step component as well to support navigation code
- added unit tests

Signed-off-by: Afreen Misbah <afreen@ibm.com>
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.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts [new file with mode: 0644]

index 4bb762e9ea5f15c015948350bab073eb608e8027..ad17ad83deb803e1294963bfc80dec2fbfb6850c 100644 (file)
@@ -1,71 +1,64 @@
-<cd-modal [pageURL]="pageURL"
-          [modalRef]="activeModal">
-  <span class="modal-title"
-        i18n>{{ action | titlecase }} {{ resource | upperFirst }}</span>
-  <ng-container class="modal-content">
+<cd-tearsheet
+ [steps]="steps"
+ [title]="title"
+ [description]="description"
+ (submitRequested)="onSubmit()"
+ >
+  <cd-tearsheet-step>
     <form name="subsystemForm"
           #formDir="ngForm"
           [formGroup]="subsystemForm"
           novalidate>
-      <div class="modal-body">
-        <!-- 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>
+      <!-- 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>
-      <div class="modal-footer">
-        <div class="text-right">
-          <cd-form-button-panel (submitActionEvent)="onSubmit()"
-                                [form]="subsystemForm"
-                                [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
+      <!-- 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>
-  </ng-container>
-</cd-modal>
+  </cd-tearsheet-step>
+</cd-tearsheet>
index bb20cfce61beb6c44bba0df7d58c557572a1ca5c..b89e823b32e415993d6ef777734c7c352dc95191 100644 (file)
@@ -1,5 +1,6 @@
-import { Component, OnInit } from '@angular/core';
-import { UntypedFormControl, Validators } from '@angular/forms';
+import { Component, DestroyRef, OnInit } from '@angular/core';
+import { FormControlStatus, UntypedFormControl, Validators } from '@angular/forms';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 
 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
@@ -14,6 +15,8 @@ import {
   DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM,
   NvmeofService
 } from '~/app/shared/api/nvmeof.service';
+import { Step } from 'carbon-components-angular';
+import { startWith } from 'rxjs/operators';
 
 @Component({
   selector: 'cd-nvmeof-subsystems-form',
@@ -29,6 +32,28 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
   pageURL: string;
   defaultMaxNamespace: number = DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM;
   group: string;
+  steps: Step[] = [
+    {
+      label: $localize`Subsystem Details`,
+      complete: false,
+      invalid: false
+    },
+    {
+      label: $localize`Host access control`,
+      complete: false
+    },
+    {
+      label: $localize`Authentication`,
+      complete: false
+    },
+    {
+      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.`;
 
   constructor(
     private authStorageService: AuthStorageService,
@@ -37,7 +62,8 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
     private nvmeofService: NvmeofService,
     private taskWrapperService: TaskWrapperService,
     private router: Router,
-    private route: ActivatedRoute
+    private route: ActivatedRoute,
+    private destroyRef: DestroyRef
   ) {
     this.permission = this.authStorageService.getPermissions().nvmeof;
     this.resource = $localize`Subsystem`;
@@ -55,11 +81,19 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
   );
 
   ngOnInit() {
-    this.route.queryParams.subscribe((params) => {
+    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() {
@@ -115,6 +149,8 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
       })
       .subscribe({
         error() {
+          // instead have error message set, not setting form status INVALID
+          // which will show input as false
           component.subsystemForm.setErrors({ cdSubmitButton: true });
         },
         complete: () => {
index a01f806999d56554be6482cbcac6f92e81fae409..12d70e741f9696ccd75ebff8d4f36dec27454437 100644 (file)
@@ -41,7 +41,8 @@ import {
   TilesModule,
   PopoverModule,
   InlineLoadingModule,
-  TagModule
+  TagModule,
+  LinkModule
 } from 'carbon-components-angular';
 import EditIcon from '@carbon/icons/es/edit/20';
 import CodeIcon from '@carbon/icons/es/code/16';
@@ -92,6 +93,7 @@ import { InlineMessageComponent } from './inline-message/inline-message.componen
 import { IconComponent } from './icon/icon.component';
 import { DetailsCardComponent } from './details-card/details-card.component';
 import { ToastComponent } from './notification-toast/notification-toast.component';
+import { TearsheetComponent } from './tearsheet/tearsheet.component';
 
 // Icons
 import InfoIcon from '@carbon/icons/es/information/16';
@@ -99,6 +101,7 @@ import CopyIcon from '@carbon/icons/es/copy/32';
 import downloadIcon from '@carbon/icons/es/download/16';
 import IdeaIcon from '@carbon/icons/es/idea/20';
 import CloseIcon from '@carbon/icons/es/close/16';
+import { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.component';
 
 @NgModule({
   imports: [
@@ -144,7 +147,8 @@ import CloseIcon from '@carbon/icons/es/close/16';
     TilesModule,
     PopoverModule,
     InlineLoadingModule,
-    TagModule
+    TagModule,
+    LinkModule
   ],
   declarations: [
     SparklineComponent,
@@ -190,7 +194,9 @@ import CloseIcon from '@carbon/icons/es/close/16';
     IconComponent,
     InlineMessageComponent,
     DetailsCardComponent,
-    ToastComponent
+    ToastComponent,
+    TearsheetComponent,
+    TearsheetStepComponent
   ],
   providers: [provideCharts(withDefaultRegisterables())],
   exports: [
@@ -233,7 +239,9 @@ import CloseIcon from '@carbon/icons/es/close/16';
     IconComponent,
     InlineMessageComponent,
     DetailsCardComponent,
-    ToastComponent
+    ToastComponent,
+    TearsheetComponent,
+    TearsheetStepComponent
   ]
 })
 export class ComponentsModule {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.html
new file mode 100644 (file)
index 0000000..a4bd3d8
--- /dev/null
@@ -0,0 +1,3 @@
+<ng-template>
+  <ng-content></ng-content>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.spec.ts
new file mode 100644 (file)
index 0000000..775f20e
--- /dev/null
@@ -0,0 +1,24 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { TearsheetStepComponent } from './tearsheet-step.component';
+
+describe('WizardComponent', () => {
+  let component: TearsheetStepComponent;
+  let fixture: ComponentFixture<TearsheetStepComponent>;
+
+  configureTestBed({
+    imports: [SharedModule]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(TearsheetStepComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.ts
new file mode 100644 (file)
index 0000000..243ba89
--- /dev/null
@@ -0,0 +1,12 @@
+import { Component, TemplateRef, ViewChild } from '@angular/core';
+
+@Component({
+  selector: 'cd-tearsheet-step',
+  standalone: false,
+  templateUrl: './tearsheet-step.component.html',
+  styleUrls: ['./tearsheet-step.component.scss']
+})
+export class TearsheetStepComponent {
+  @ViewChild(TemplateRef, { static: true })
+  template!: TemplateRef<any>;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html
new file mode 100644 (file)
index 0000000..9508faf
--- /dev/null
@@ -0,0 +1,69 @@
+<!-- Wide Tearsheet -->
+<cds-modal
+  size="lg"
+  [open]="isOpen"
+  (overlaySelected)="closeWizard()">
+  <!-- Tearsheet Header -->
+  <header
+    class="tearsheet-header">
+    <h4 cdsModalHeaderHeading
+        class="cds--type-heading-04 tearsheet-title">
+        {{title}}
+    </h4>
+    <p class="cds--type-body-02 tearsheet-description">
+      {{description}}
+    </p>
+  </header>
+  <section cdsGrid
+           class="tearsheet-body"
+           [useCssGrid]="true"
+           [fullWidth]="true">
+    <!-- Tearsheet Influencer-->
+    <div cdsCol
+         [columnNumbers]="{'lg': 3, 'md': 3, 'sm': 3}"
+         class="tearsheet-influencer">
+      <cds-progress-indicator
+        orientation="vertical"
+        [steps]="steps"
+        [current]="currentStep"
+        spacing="equal"
+        (stepSelected)="onStepSelect($event)">
+      </cds-progress-indicator>
+    </div>
+    <div cdsCol
+         [columnNumbers]="{'lg': 13, 'md': 13, 'sm': 13}"
+         class="tearsheet-main">
+      <!-- Tearsheet Content Area -->
+      <div class="tearsheet-content">
+      <ng-container
+        *ngTemplateOutlet="activeStepTemplate">
+      </ng-container>
+      </div>
+      <!-- Tearsheet Footer -->
+      <cds-modal-footer class="tearsheet-footer">
+        <button cdsButton="ghost"
+                class="tearsheet-footer-cancel"
+                (click)="closeWizard()"
+                size="xl"
+                i18n>Cancel</button>
+        <button cdsButton="secondary"
+                size="xl"
+                [disabled]="currentStep === 0"
+                (click)="onPrevious()"
+                i18n>Previous</button>
+        @if (currentStep === lastStep) {
+        <button cdsButton="primary"
+                size="xl"
+                (click)="onSubmit()"
+                i18n>{{submitButtonLabel}}</button>
+        }
+        @else {
+        <button cdsButton="primary"
+                size="xl"
+                (click)="onNext()"
+                i18n>Next</button>
+        }
+      </cds-modal-footer>
+    </div>
+  </section>
+</cds-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.scss
new file mode 100644 (file)
index 0000000..c72bcd6
--- /dev/null
@@ -0,0 +1,65 @@
+// 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);
+}
+
+.tearsheet-header {
+  fill: var(--cds-icon-primary);
+  background-color: var(--cds-layer-01);
+  padding: var(--cds-spacing-06) var(--cds-spacing-07);
+  border-block-end: 1px solid var(--cds-border-subtle-01);
+
+  .tearsheet-title {
+    color: var(--cds-text-primary);
+  }
+
+  .tearsheet-description {
+    margin-top: var(--cds-spacing-04);
+    color: var(--cds-text-secondary);
+  }
+}
+
+.tearsheet-body {
+  padding-block: 0;
+  padding-inline: 0;
+  padding: 0;
+  margin: 0;
+  height: 100%;
+
+  .tearsheet-influencer {
+    background-color: var(--cds-layer-01);
+    padding: var(--cds-spacing-06) var(--cds-spacing-07);
+    overflow-block: auto;
+    overflow-y: auto;
+    border-inline-end: 1px solid var(--cds-border-subtle-01);
+    margin: 0;
+  }
+
+  .tearsheet-main {
+    margin: 0;
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+
+    .tearsheet-content {
+      background-color: var(--cds-background);
+      margin: 0;
+      padding: var(--cds-spacing-06) var(--cds-spacing-07);
+      flex: 1;
+      overflow-y: auto;
+    }
+  }
+}
+
+.tearsheet-footer {
+  border-top: 1px solid var(--cds-border-subtle);
+  background: var(--cds-background);
+
+  .tearsheet-footer-cancel {
+    margin-left: var(--cds-spacing-05);
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.spec.ts
new file mode 100644 (file)
index 0000000..4a8e979
--- /dev/null
@@ -0,0 +1,153 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Component, ViewChild } from '@angular/core';
+import { By } from '@angular/platform-browser';
+import { SharedModule } from '../../shared.module';
+import { TearsheetStepComponent } from '../tearsheet-step/tearsheet-step.component';
+import { TearsheetComponent } from './tearsheet.component';
+import { ActivatedRoute } from '@angular/router';
+
+// Mock Component that uses tearsheet
+@Component({
+  template: `
+    <cd-tearsheet
+      [steps]="steps"
+      [title]="title"
+      [description]="description"
+      (submitRequested)="onSubmit()"
+    >
+      <cd-tearsheet-step>
+        <div class="step-1-content">Step 1 Content</div>
+      </cd-tearsheet-step>
+      <cd-tearsheet-step>
+        <div class="step-2-content">Step 2 Content</div>
+      </cd-tearsheet-step>
+      <cd-tearsheet-step>
+        <div class="step-3-content">Step 3 Content</div>
+      </cd-tearsheet-step>
+    </cd-tearsheet>
+  `,
+  standalone: false
+})
+class MockHostComponent {
+  steps = [
+    {
+      label: 'Step 1',
+      complete: false,
+      invalid: false
+    },
+    {
+      label: 'Step 2',
+      complete: false
+    },
+    {
+      label: 'Step 3',
+      complete: false
+    }
+  ];
+  title = 'Test Title';
+  description = 'Test Description';
+
+  onSubmit() {}
+
+  @ViewChild(TearsheetComponent)
+  tearsheet!: TearsheetComponent;
+}
+
+describe('TearsheetComponent', () => {
+  let hostFixture: ComponentFixture<MockHostComponent>;
+  let hostComponent: MockHostComponent;
+  let tearsheetComponent: TearsheetComponent;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [TearsheetComponent, TearsheetStepComponent, MockHostComponent],
+      imports: [SharedModule],
+      providers: [
+        {
+          provide: ActivatedRoute,
+          useValue: { outlet: 'modal' }
+        }
+      ]
+    }).compileComponents();
+  });
+
+  beforeEach(() => {
+    hostFixture = TestBed.createComponent(MockHostComponent);
+    hostComponent = hostFixture.componentInstance;
+    hostFixture.detectChanges();
+    tearsheetComponent = hostComponent.tearsheet;
+  });
+
+  it('should create component', () => {
+    expect(tearsheetComponent).toBeTruthy();
+  });
+
+  it('should have 3 steps from input', () => {
+    expect(tearsheetComponent.steps.length).toBe(3);
+  });
+
+  it('should have title from input', () => {
+    expect(tearsheetComponent.title).toBe('Test Title');
+  });
+
+  it('should have description from input', () => {
+    expect(tearsheetComponent.description).toBe('Test Description');
+  });
+
+  it('should detect 3 step children via ContentChildren', () => {
+    expect(tearsheetComponent.stepContents).toBeDefined();
+    expect(tearsheetComponent.stepContents.length).toBe(3);
+  });
+
+  it('should have first step selected by default', () => {
+    expect(tearsheetComponent.currentStep).toBe(0);
+    const firstStep = tearsheetComponent.stepContents.first;
+    expect(firstStep).toBeDefined();
+  });
+
+  it('should render step content', () => {
+    const step1Content = hostFixture.debugElement.query(By.css('.step-1-content'));
+    expect(step1Content).toBeTruthy();
+    expect(step1Content.nativeElement.textContent).toContain('Step 1 Content');
+  });
+
+  it('should emit submitRequested event', () => {
+    spyOn(hostComponent, 'onSubmit');
+
+    tearsheetComponent.submitRequested.emit();
+
+    expect(hostComponent.onSubmit).toHaveBeenCalled();
+  });
+
+  describe('step navigation', () => {
+    it('should go to next step', () => {
+      tearsheetComponent.onNext();
+      expect(tearsheetComponent.currentStep).toBe(1);
+    });
+
+    it('should go to previous step', () => {
+      tearsheetComponent.currentStep = 2;
+      tearsheetComponent.onPrevious();
+      expect(tearsheetComponent.currentStep).toBe(1);
+    });
+
+    it('should not go beyond last step', () => {
+      tearsheetComponent.currentStep = 2;
+      tearsheetComponent.onNext();
+      expect(tearsheetComponent.currentStep).toBe(2);
+    });
+
+    it('should not go before first step', () => {
+      tearsheetComponent.currentStep = 0;
+      tearsheetComponent.onPrevious();
+      expect(tearsheetComponent.currentStep).toBe(0);
+    });
+
+    it('should not go to next step on invalid', () => {
+      tearsheetComponent.currentStep = 0;
+      hostComponent.steps[0].invalid = true;
+      tearsheetComponent.onNext();
+      expect(tearsheetComponent.currentStep).toBe(0);
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts
new file mode 100644 (file)
index 0000000..01e56d2
--- /dev/null
@@ -0,0 +1,91 @@
+import {
+  ChangeDetectorRef,
+  Component,
+  ContentChildren,
+  EventEmitter,
+  Input,
+  OnInit,
+  Output,
+  QueryList,
+  AfterViewChecked
+} from '@angular/core';
+import { FormBuilder } from '@angular/forms';
+import { Step } from 'carbon-components-angular';
+import { TearsheetStepComponent } from '../tearsheet-step/tearsheet-step.component';
+import { ModalCdsService } from '../../services/modal-cds.service';
+import { ActivatedRoute } from '@angular/router';
+import { Location } from '@angular/common';
+
+@Component({
+  selector: 'cd-tearsheet',
+  standalone: false,
+  templateUrl: './tearsheet.component.html',
+  styleUrls: ['./tearsheet.component.scss']
+})
+export class TearsheetComponent implements OnInit, AfterViewChecked {
+  @Input() title!: string;
+  @Input() steps!: Array<Step>;
+  @Input() description!: string;
+
+  @Output() submitRequested = new EventEmitter<void>();
+
+  @ContentChildren(TearsheetStepComponent)
+  stepContents!: QueryList<TearsheetStepComponent>;
+
+  get activeStepTemplate() {
+    return this.stepContents?.toArray()[this.currentStep]?.template;
+  }
+
+  currentStep: number = 0;
+  lastStep: number = null;
+  isOpen: boolean = true;
+  hasModalOutlet: boolean = false;
+
+  constructor(
+    protected formBuilder: FormBuilder,
+    private changeDetectorRef: ChangeDetectorRef,
+    private cdsModalService: ModalCdsService,
+    private route: ActivatedRoute,
+    private location: Location
+  ) {}
+
+  ngOnInit() {
+    this.lastStep = this.steps.length - 1;
+    this.hasModalOutlet = this.route.outlet === 'modal';
+  }
+
+  onStepSelect(event: { step: Step; index: number }) {
+    this.currentStep = event.index;
+  }
+
+  closeWizard() {
+    this.isOpen = false;
+    if (this.hasModalOutlet) {
+      this.location.back();
+    } else {
+      this.cdsModalService.dismissAll();
+    }
+  }
+
+  onPrevious() {
+    if (this.currentStep !== 0) {
+      this.currentStep = this.currentStep - 1;
+    }
+  }
+
+  onNext() {
+    if (this.currentStep !== this.lastStep && !this.steps[this.currentStep].invalid) {
+      this.currentStep = this.currentStep + 1;
+    }
+  }
+
+  onSubmit() {
+    if (!this.steps[this.currentStep].invalid) {
+      this.submitRequested.emit();
+    }
+  }
+
+  ngAfterViewChecked() {
+    this.changeDetectorRef.detectChanges();
+  }
+}