-<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/><<code>nqn.$year-$month.$reverseDomainName:$utf8-string</code>".> or <br/><<code>nqn.2014-08.org.nvmexpress:uuid:$UUID-string</code>".></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/><<code>nqn.$year-$month.$reverseDomainName:$utf8-string</code>".> or <br/><<code>nqn.2014-08.org.nvmexpress:uuid:$UUID-string</code>".></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>
-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';
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',
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,
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`;
);
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() {
})
.subscribe({
error() {
+ // instead have error message set, not setting form status INVALID
+ // which will show input as false
component.subsystemForm.setErrors({ cdSubmitButton: true });
},
complete: () => {
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';
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';
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: [
TilesModule,
PopoverModule,
InlineLoadingModule,
- TagModule
+ TagModule,
+ LinkModule
],
declarations: [
SparklineComponent,
IconComponent,
InlineMessageComponent,
DetailsCardComponent,
- ToastComponent
+ ToastComponent,
+ TearsheetComponent,
+ TearsheetStepComponent
],
providers: [provideCharts(withDefaultRegisterables())],
exports: [
IconComponent,
InlineMessageComponent,
DetailsCardComponent,
- ToastComponent
+ ToastComponent,
+ TearsheetComponent,
+ TearsheetStepComponent
]
})
export class ComponentsModule {
--- /dev/null
+<ng-template>
+ <ng-content></ng-content>
+</ng-template>
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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>;
+}
--- /dev/null
+<!-- 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>
--- /dev/null
+// 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);
+ }
+}
--- /dev/null
+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);
+ });
+ });
+});
--- /dev/null
+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();
+ }
+}