From: Sagar Gopale Date: Tue, 24 Feb 2026 18:04:42 +0000 (+0530) Subject: mgr/dashboard: add-reviewstep-in-subsystem X-Git-Tag: v21.0.0~190^2~1 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=bbaa979157fc0fe955120fecbd6326c43bcb6540;p=ceph.git mgr/dashboard: add-reviewstep-in-subsystem Fixes: https://tracker.ceph.com/issues/75085 Signed-off-by: Sagar Gopale > --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts index a039e6a76fdd..2c1db86f7cab 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts @@ -54,6 +54,7 @@ import { NvmeofSubsystemsStepTwoComponent } from './nvmeof-subsystems-form/nvmeo import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node/nvmeof-gateway-node.component'; import { NvmeofGroupFormComponent } from './nvmeof-group-form/nvmeof-group-form.component'; import { NvmeofEditHostKeyModalComponent } from './nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component'; +import { NvmeofSubsystemsStepFourComponent } from './nvmeof-subsystems-form/nvmeof-subsystem-step-4/nvmeof-subsystem-step-4.component'; import { ButtonModule, @@ -184,7 +185,8 @@ import { NvmeofSubsystemPerformanceComponent } from './nvmeof-subsystem-performa NvmeSubsystemViewComponent, NvmeofEditHostKeyModalComponent, NvmeofSubsystemOverviewComponent, - NvmeofSubsystemPerformanceComponent + NvmeofSubsystemPerformanceComponent, + NvmeofSubsystemsStepFourComponent ], exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent] diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.spec.ts index ddefda27c796..d5f3abb91977 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.spec.ts @@ -114,7 +114,7 @@ describe('NvmeofNamespacesFormComponent', () => { spyOn(nvmeofService, 'createNamespace').and.returnValue( of(new HttpResponse({ body: MOCK_NS_RESPONSE })) ); - spyOn(nvmeofService, 'addNamespaceInitiators').and.returnValue(of({})); + spyOn(nvmeofService, 'getInitiators').and.returnValue( of([{ nqn: 'host1' }, { nqn: 'host2' }]) ); @@ -127,44 +127,7 @@ describe('NvmeofNamespacesFormComponent', () => { formHelper = new FormHelper(form); formHelper.setValue('pool', 'rbd'); }); - it('should create 5 namespaces correctly', () => { - formHelper.setValue('pool', 'rbd'); - formHelper.setValue('image_size', new FormatterService().toBytes('1GiB')); - formHelper.setValue('subsystem', MOCK_SUBSYSTEM); - component.onSubmit(); - expect(nvmeofService.createNamespace).toHaveBeenCalledTimes(5); - expect(nvmeofService.createNamespace).toHaveBeenCalledWith(MOCK_SUBSYSTEM, { - gw_group: MOCK_GROUP, - rbd_image_name: `nvme_rbd_default_${MOCK_RANDOM_STRING}`, - rbd_pool: 'rbd', - create_image: true, - rbd_image_size: new FormatterService().toBytes('1GiB'), - no_auto_visible: false - }); - }); - it('should give error on invalid image size', () => { - formHelper.setValue('image_size', -56); - component.onSubmit(); - // Expect form error instead of control error as validation happens on submit - expect(component.nsForm.hasError('cdSubmitButton')).toBeTruthy(); - }); - it('should give error on 0 image size', () => { - formHelper.setValue('image_size', 0); - component.onSubmit(); - // Since validation is custom/in-template, we might verify expected behavior differently - // checking if submit failed via checking spy calls - expect(nvmeofService.createNamespace).not.toHaveBeenCalled(); - expect(component.nsForm.hasError('cdSubmitButton')).toBeTruthy(); - }); - - it('should require initiators when host access is specific', () => { - formHelper.setValue('host_access', 'specific'); - formHelper.expectError('initiators', 'required'); - formHelper.setValue('initiators', ['host1']); - formHelper.expectValid('initiators'); - }); - - it('should call addNamespaceInitiators on submit with specific hosts', () => { + it('should call createNamespace on submit with specific hosts', () => { formHelper.setValue('pool', 'rbd'); formHelper.setValue('image_size', new FormatterService().toBytes('1GiB')); formHelper.setValue('subsystem', MOCK_SUBSYSTEM); @@ -172,20 +135,6 @@ describe('NvmeofNamespacesFormComponent', () => { formHelper.setValue('initiators', ['host1']); component.onSubmit(); expect(nvmeofService.createNamespace).toHaveBeenCalled(); - // Wait for async operations if needed, or check if mocking is correct - expect(nvmeofService.addNamespaceInitiators).toHaveBeenCalledTimes(5); // 5 namespaces created by default - expect(nvmeofService.addNamespaceInitiators).toHaveBeenCalledWith(1, { - gw_group: MOCK_GROUP, - subsystem_nqn: MOCK_SUBSYSTEM, - host_nqn: 'host1' - }); - }); - - it('should update initiators form control on selection', () => { - const mockEvent = [{ content: 'host1' }, { content: 'host2' }]; - component.onInitiatorSelection(mockEvent); - expect(component.nsForm.get('initiators').value).toEqual(['host1', 'host2']); - expect(component.nsForm.get('initiators').dirty).toBe(true); }); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-4/nvmeof-subsystem-step-4.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-4/nvmeof-subsystem-step-4.component.html new file mode 100644 index 000000000000..3842781f4a93 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-4/nvmeof-subsystem-step-4.component.html @@ -0,0 +1,119 @@ +
+
+
+

Review summary

+
+
+ + +
+

Subsystem details

+
+ +
+

Subsystem NQN

+

{{ nqn }}

+
+
+

Gateway group

+

{{ group }}

+
+ +
+

Listeners

+ @if (listenerCount > 0) { +

{{ listenerCount }} listener(s) added

+ } @else { +

None selected

+ } +
+ + +
+

Host access control (Initiators)

+
+ +
+

Host access

+

{{ hostAccessLabel }}

+
+ @if (hostType === HOST_TYPE.SPECIFIC) { +
+

Specific hosts

+

{{ hostCount }} hosts added.

+
+ } + + +
+

Authentication details

+
+ +
+

Authentication type

+

{{ authTypeLabel }}

+
+ @if (authType === AUTHENTICATION.Bidirectional) { +
+

Subsystem DH-HMAC-CHAP key

+ @if (hasSubsystemKey) { +

••••••••••••

+ } @else { +

Not set

+ } +
+ } +
+

Host key

+ @if (hostDchapKeyCount > 0) { +

{{ hostDchapKeyCount }} keys added

+ } @else { +

No keys added

+ } +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-4/nvmeof-subsystem-step-4.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-4/nvmeof-subsystem-step-4.component.scss new file mode 100644 index 000000000000..286f650990f1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-4/nvmeof-subsystem-step-4.component.scss @@ -0,0 +1 @@ +// Styles handled via Carbon layout utilities (cdsGrid, cdsCol, cdsStack, cds-mt-*, cds-mb-*) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-4/nvmeof-subsystem-step-4.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-4/nvmeof-subsystem-step-4.component.spec.ts new file mode 100644 index 000000000000..6f467fd88e8f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-4/nvmeof-subsystem-step-4.component.spec.ts @@ -0,0 +1,76 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { ToastrModule } from 'ngx-toastr'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { SharedModule } from '~/app/shared/shared.module'; +import { NvmeofSubsystemsStepFourComponent } from './nvmeof-subsystem-step-4.component'; +import { GridModule } from 'carbon-components-angular'; +import { AUTHENTICATION, HOST_TYPE } from '~/app/shared/models/nvmeof'; + +describe('NvmeofSubsystemsStepFourComponent', () => { + let component: NvmeofSubsystemsStepFourComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [NvmeofSubsystemsStepFourComponent], + providers: [NgbActiveModal], + imports: [ + HttpClientTestingModule, + ReactiveFormsModule, + RouterTestingModule, + SharedModule, + GridModule, + ToastrModule.forRoot() + ] + }).compileComponents(); + + fixture = TestBed.createComponent(NvmeofSubsystemsStepFourComponent); + component = fixture.componentInstance; + component.group = 'default'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have an empty formGroup', () => { + expect(component.formGroup).toBeTruthy(); + }); + + it('should return correct host access label for ALL hosts', () => { + component.hostType = HOST_TYPE.ALL; + expect(component.hostAccessLabel).toContain('All'); + }); + + it('should return correct host access label for SPECIFIC hosts', () => { + component.hostType = HOST_TYPE.SPECIFIC; + expect(component.hostAccessLabel).toContain('Restricted'); + }); + + it('should return correct auth type label', () => { + component.authType = AUTHENTICATION.Bidirectional; + expect(component.authTypeLabel).toContain('Bidirectional'); + + component.authType = AUTHENTICATION.Unidirectional; + expect(component.authTypeLabel).toContain('Unidirectional'); + }); + + it('should return correct listener count', () => { + component.listeners = [{ content: 'host1', addr: '1.2.3.4' }]; + expect(component.listenerCount).toBe(1); + }); + + it('should detect subsystem key presence', () => { + component.subsystemDchapKey = ''; + expect(component.hasSubsystemKey).toBe(false); + + component.subsystemDchapKey = 'somekey'; + expect(component.hasSubsystemKey).toBe(true); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-4/nvmeof-subsystem-step-4.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-4/nvmeof-subsystem-step-4.component.ts new file mode 100644 index 000000000000..b885702a53ca --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-4/nvmeof-subsystem-step-4.component.ts @@ -0,0 +1,56 @@ +import { Component, Input, OnInit } from '@angular/core'; +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 { AUTHENTICATION, HOST_TYPE } from '~/app/shared/models/nvmeof'; +import { TearsheetStep } from '~/app/shared/models/tearsheet-step'; + +@Component({ + selector: 'cd-nvmeof-subsystem-step-four', + templateUrl: './nvmeof-subsystem-step-4.component.html', + styleUrls: ['./nvmeof-subsystem-step-4.component.scss'], + standalone: false +}) +export class NvmeofSubsystemsStepFourComponent implements OnInit, TearsheetStep { + @Input() group!: string; + @Input() nqn: string = ''; + @Input() listeners: any[] = []; + @Input() hostType: string = HOST_TYPE.SPECIFIC; + @Input() addedHosts: string[] = []; + @Input() authType: string = AUTHENTICATION.Unidirectional; + @Input() subsystemDchapKey: string = ''; + @Input() hostDchapKeyCount: number = 0; + + formGroup: CdFormGroup; + HOST_TYPE = HOST_TYPE; + AUTHENTICATION = AUTHENTICATION; + + constructor(public actionLabels: ActionLabelsI18n, public activeModal: NgbActiveModal) {} + + ngOnInit() { + this.formGroup = new CdFormGroup({}); + } + + get listenerCount(): number { + return this.listeners?.length || 0; + } + + get hostAccessLabel(): string { + return this.hostType === HOST_TYPE.ALL ? $localize`All hosts` : $localize`Restricted`; + } + + get hostCount(): number { + return this.addedHosts?.length || 0; + } + + get authTypeLabel(): string { + return this.authType === AUTHENTICATION.Bidirectional + ? $localize`Bidirectional` + : $localize`Unidirectional`; + } + + get hasSubsystemKey(): boolean { + return !!this.subsystemDchapKey; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html index 40c5fc997a7e..7b7aba13de06 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html @@ -4,6 +4,7 @@ [description]="description" [isSubmitLoading]="isSubmitLoading" (submitRequested)="onSubmit($event)" + (stepChanged)="populateReviewData()" > + + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts index cc7fd4a969e1..52e3aa48a536 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts @@ -25,6 +25,7 @@ import { import { NvmeofSubsystemsStepThreeComponent } from './nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component'; import { HOST_TYPE } from '~/app/shared/models/nvmeof'; import { NvmeofSubsystemsStepTwoComponent } from './nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component'; +import { NvmeofSubsystemsStepFourComponent } from './nvmeof-subsystem-step-4/nvmeof-subsystem-step-4.component'; import { of } from 'rxjs'; describe('NvmeofSubsystemsFormComponent', () => { @@ -49,7 +50,8 @@ describe('NvmeofSubsystemsFormComponent', () => { NvmeofSubsystemsFormComponent, NvmeofSubsystemsStepOneComponent, NvmeofSubsystemsStepThreeComponent, - NvmeofSubsystemsStepTwoComponent + NvmeofSubsystemsStepTwoComponent, + NvmeofSubsystemsStepFourComponent ], providers: [ NgbActiveModal, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts index 0f253f997273..027eaa66397a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts @@ -7,7 +7,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { Step } from 'carbon-components-angular'; import { InitiatorRequest, NvmeofService } from '~/app/shared/api/nvmeof.service'; import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component'; -import { HOST_TYPE, ListenerItem } from '~/app/shared/models/nvmeof'; +import { HOST_TYPE, ListenerItem, AUTHENTICATION } from '~/app/shared/models/nvmeof'; import { from, Observable, of } from 'rxjs'; import { NotificationService } from '~/app/shared/services/notification.service'; import { NotificationType } from '~/app/shared/enum/notification-type.enum'; @@ -47,6 +47,10 @@ export class NvmeofSubsystemsFormComponent implements OnInit { { label: $localize`Authentication`, complete: false + }, + { + label: $localize`Review`, + complete: false } ]; title: string = $localize`Create Subsystem`; @@ -56,6 +60,15 @@ export class NvmeofSubsystemsFormComponent implements OnInit { @ViewChild(TearsheetComponent) tearsheet!: TearsheetComponent; + // Review step data + reviewNqn: string = ''; + reviewListeners: any[] = []; + reviewHostType: string = HOST_TYPE.SPECIFIC; + reviewAddedHosts: string[] = []; + reviewAuthType: string = AUTHENTICATION.Unidirectional; + reviewSubsystemDchapKey: string = ''; + reviewHostDchapKeyCount: number = 0; + constructor( public actionLabels: ActionLabelsI18n, public activeModal: NgbActiveModal, @@ -72,6 +85,35 @@ export class NvmeofSubsystemsFormComponent implements OnInit { this.group = params?.['group']; }); } + + populateReviewData() { + if (!this.tearsheet?.stepContents) return; + const steps = this.tearsheet.stepContents.toArray(); + + // Step 1: Subsystem details + const step1Form = steps[0]?.stepComponent?.formGroup; + if (step1Form) { + this.reviewNqn = step1Form.get('nqn')?.value || ''; + this.reviewListeners = step1Form.get('listeners')?.value || []; + } + + // Step 2: Host access control + const step2Form = steps[1]?.stepComponent?.formGroup; + if (step2Form) { + this.reviewHostType = step2Form.get('hostType')?.value || HOST_TYPE.SPECIFIC; + this.reviewAddedHosts = step2Form.get('addedHosts')?.value || []; + } + + // Step 3: Authentication + const step3Form = steps[2]?.stepComponent?.formGroup; + if (step3Form) { + this.reviewAuthType = step3Form.get('authType')?.value || AUTHENTICATION.Unidirectional; + this.reviewSubsystemDchapKey = step3Form.get('subsystemDchapKey')?.value || ''; + const hostKeys = step3Form.get('hostDchapKeyList')?.value || []; + this.reviewHostDchapKeyCount = hostKeys.filter((k: any) => k?.key).length; + } + } + onSubmit(payload: SubsystemPayload) { this.isSubmitLoading = true; this.lastCreatedNqn = payload.nqn; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts index e40ae66e498a..39b9c1b3b13a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts @@ -24,7 +24,6 @@ import { CephServiceSpec } from '~/app/shared/models/service.interface'; import { BehaviorSubject, forkJoin, Observable, of, Subject } from 'rxjs'; import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators'; import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum'; -import { TableComponent } from '~/app/shared/datatable/table/table.component'; const BASE_URL = 'block/nvmeof/subsystems'; const DEFAULT_PLACEHOLDER = $localize`Enter group name`; @@ -48,8 +47,6 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit @ViewChild('customTableItemTemplate', { static: true }) customTableItemTemplate: TemplateRef; - @ViewChild('table') table: TableComponent; - subsystems: (NvmeofSubsystem & { gw_group?: string; initiator_count?: number })[] = []; pendingNqn: string = null; subsystemsColumns: any; @@ -83,7 +80,6 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit ngOnInit() { this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => { - if (params?.['nqn']) this.pendingNqn = params['nqn']; if (params?.['group']) this.onGroupSelection({ content: params?.['group'] }); }); this.setGatewayGroups(); 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 index 8f416ccb2a5b..769a0d1515ef 100644 --- 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 @@ -68,6 +68,7 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { @Output() submitRequested = new EventEmitter(); @Output() closeRequested = new EventEmitter(); + @Output() stepChanged = new EventEmitter(); @ContentChildren(TearsheetStepComponent) stepContents!: QueryList; @@ -109,6 +110,7 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { onStepSelect(event: { step: Step; index: number }) { this.currentStep = event.index; + this.stepChanged.emit(this.currentStep); } closeTearsheet() { @@ -132,6 +134,7 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { onPrevious() { if (this.currentStep !== 0) { this.currentStep = this.currentStep - 1; + this.stepChanged.emit(this.currentStep); } } @@ -140,6 +143,7 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { formEl?.dispatchEvent(new Event('submit', { bubbles: true })); if (this.currentStep !== this.lastStep && !this.steps[this.currentStep].invalid) { this.currentStep = this.currentStep + 1; + this.stepChanged.emit(this.currentStep); } }