diff --git a/src/cdk/stepper/stepper.ts b/src/cdk/stepper/stepper.ts index e842ff3a7b02..c346c0ce2c40 100644 --- a/src/cdk/stepper/stepper.ts +++ b/src/cdk/stepper/stepper.ts @@ -152,7 +152,7 @@ export class CdkStepper implements OnDestroy { get selectedIndex() { return this._selectedIndex; } set selectedIndex(index: number) { if (this._steps) { - if (this._anyControlsInvalid(index) || index < this._selectedIndex && + if (this._anyControlsInvalidOrPending(index) || index < this._selectedIndex && !this._steps.toArray()[index].editable) { // remove focus from clicked step header if the step is not able to be selected this._stepHeader.toArray()[index].nativeElement.blur(); @@ -291,13 +291,15 @@ export class CdkStepper implements OnDestroy { this._stepHeader.toArray()[this._focusIndex].nativeElement.focus(); } - private _anyControlsInvalid(index: number): boolean { + private _anyControlsInvalidOrPending(index: number): boolean { const steps = this._steps.toArray(); steps[this._selectedIndex].interacted = true; if (this._linear && index >= 0) { - return steps.slice(0, index).some(step => step.stepControl && step.stepControl.invalid); + return steps.slice(0, index).some(step => + step.stepControl && (step.stepControl.invalid || step.stepControl.pending) + ); } return false; } diff --git a/src/lib/stepper/stepper.spec.ts b/src/lib/stepper/stepper.spec.ts index 5302c249c330..4700d9b9cde5 100644 --- a/src/lib/stepper/stepper.spec.ts +++ b/src/lib/stepper/stepper.spec.ts @@ -3,9 +3,14 @@ import {ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE} from '@angular/cdk/keycodes'; import {dispatchKeyboardEvent} from '@angular/cdk/testing'; import {Component, DebugElement} from '@angular/core'; import {async, ComponentFixture, TestBed, inject} from '@angular/core/testing'; -import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +import {AbstractControl, AsyncValidatorFn, FormControl, FormGroup, ReactiveFormsModule, + ValidationErrors, Validators} from '@angular/forms'; import {By} from '@angular/platform-browser'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {map} from 'rxjs/operators/map'; +import {take} from 'rxjs/operators/take'; +import {Observable} from 'rxjs/Observable'; +import {Subject} from 'rxjs/Subject'; import {MatStepperModule} from './index'; import {MatHorizontalStepper, MatStep, MatStepper, MatVerticalStepper} from './stepper'; import {MatStepperNext, MatStepperPrevious} from './stepper-button'; @@ -156,10 +161,11 @@ describe('MatHorizontalStepper', () => { expect(stepperComponent.linear).toBe(true); }); - it('should not move to next step if current step is not valid', () => { + it('should not move to next step if current step is invalid', () => { expect(testComponent.oneGroup.get('oneCtrl')!.value).toBe(''); expect(testComponent.oneGroup.get('oneCtrl')!.valid).toBe(false); expect(testComponent.oneGroup.valid).toBe(false); + expect(testComponent.oneGroup.invalid).toBe(true); expect(stepperComponent.selectedIndex).toBe(0); let stepHeaderEl = fixture.debugElement @@ -167,6 +173,13 @@ describe('MatHorizontalStepper', () => { assertLinearStepperValidity(stepHeaderEl, testComponent, fixture); }); + it('should not move to next step if current step is pending', () => { + let stepHeaderEl = fixture.debugElement + .queryAll(By.css('.mat-horizontal-stepper-header'))[2].nativeElement; + + assertLinearStepperPending(stepHeaderEl, testComponent, fixture); + }); + it('should not focus step header upon click if it is not able to be selected', () => { assertStepHeaderBlurred(fixture); }); @@ -317,10 +330,11 @@ describe('MatVerticalStepper', () => { expect(stepperComponent.linear).toBe(true); }); - it('should not move to next step if current step is not valid', () => { + it('should not move to next step if current step is invalid', () => { expect(testComponent.oneGroup.get('oneCtrl')!.value).toBe(''); expect(testComponent.oneGroup.get('oneCtrl')!.valid).toBe(false); expect(testComponent.oneGroup.valid).toBe(false); + expect(testComponent.oneGroup.invalid).toBe(true); expect(stepperComponent.selectedIndex).toBe(0); let stepHeaderEl = fixture.debugElement @@ -329,6 +343,13 @@ describe('MatVerticalStepper', () => { assertLinearStepperValidity(stepHeaderEl, testComponent, fixture); }); + it('should not move to next step if current step is pending', () => { + let stepHeaderEl = fixture.debugElement + .queryAll(By.css('.mat-vertical-stepper-header'))[2].nativeElement; + + assertLinearStepperPending(stepHeaderEl, testComponent, fixture); + }); + it('should not focus step header upon click if it is not able to be selected', () => { assertStepHeaderBlurred(fixture); }); @@ -617,6 +638,58 @@ function assertLinearStepperValidity(stepHeaderEl: HTMLElement, expect(stepperComponent.selectedIndex).toBe(1); } +/** Asserts that linear stepper does not allow step selection change if current step is pending. */ +function assertLinearStepperPending(stepHeaderEl: HTMLElement, + testComponent: + LinearMatHorizontalStepperApp | + LinearMatVerticalStepperApp, + fixture: ComponentFixture) { + let stepperComponent = fixture.debugElement.query(By.directive(MatStepper)).componentInstance; + let nextButtonNativeEl = fixture.debugElement + .queryAll(By.directive(MatStepperNext))[1].nativeElement; + + testComponent.oneGroup.get('oneCtrl')!.setValue('input'); + testComponent.twoGroup.get('twoCtrl')!.setValue('input'); + stepperComponent.selectedIndex = 1; + fixture.detectChanges(); + expect(stepperComponent.selectedIndex).toBe(1); + + // Step status = PENDING + // Assert that linear stepper does not allow step selection change + expect(testComponent.twoGroup.pending).toBe(true); + + stepHeaderEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(1); + + nextButtonNativeEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(1); + + // Trigger asynchronous validation + testComponent.validationTrigger.next(); + // Asynchronous validation completed: + // Step status = VALID + expect(testComponent.twoGroup.pending).toBe(false); + expect(testComponent.twoGroup.valid).toBe(true); + + stepHeaderEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(2); + + stepperComponent.selectedIndex = 1; + fixture.detectChanges(); + expect(stepperComponent.selectedIndex).toBe(1); + + nextButtonNativeEl.click(); + fixture.detectChanges(); + + expect(stepperComponent.selectedIndex).toBe(2); +} + /** Asserts that step header focus is blurred if the step cannot be selected upon header click. */ function assertStepHeaderBlurred(fixture: ComponentFixture) { let stepHeaderEl = fixture.debugElement @@ -659,6 +732,7 @@ function assertOptionalStepValidity(testComponent: testComponent.oneGroup.get('oneCtrl')!.setValue('input'); testComponent.twoGroup.get('twoCtrl')!.setValue('input'); + testComponent.validationTrigger.next(); stepperComponent.selectedIndex = 2; fixture.detectChanges(); @@ -706,6 +780,18 @@ function assertCorrectStepIcon(fixture: ComponentFixture, expect(stepperComponent._getIndicatorType(0)).toBe(icon); } +function asyncValidator(minLength: number, validationTrigger: Observable): AsyncValidatorFn { + return (control: AbstractControl): Observable => { + return validationTrigger.pipe( + map(() => { + const success = control.value && control.value.length >= minLength; + return success ? null : { 'asyncValidation': {}}; + }), + take(1) + ); + }; +} + @Component({ template: ` @@ -783,12 +869,14 @@ class LinearMatHorizontalStepperApp { twoGroup: FormGroup; threeGroup: FormGroup; + validationTrigger: Subject = new Subject(); + ngOnInit() { this.oneGroup = new FormGroup({ oneCtrl: new FormControl('', Validators.required) }); this.twoGroup = new FormGroup({ - twoCtrl: new FormControl('', Validators.required) + twoCtrl: new FormControl('', Validators.required, asyncValidator(3, this.validationTrigger)) }); this.threeGroup = new FormGroup({ threeCtrl: new FormControl('', Validators.pattern(VALID_REGEX)) @@ -873,12 +961,14 @@ class LinearMatVerticalStepperApp { twoGroup: FormGroup; threeGroup: FormGroup; + validationTrigger: Subject = new Subject(); + ngOnInit() { this.oneGroup = new FormGroup({ oneCtrl: new FormControl('', Validators.required) }); this.twoGroup = new FormGroup({ - twoCtrl: new FormControl('', Validators.required) + twoCtrl: new FormControl('', Validators.required, asyncValidator(3, this.validationTrigger)) }); this.threeGroup = new FormGroup({ threeCtrl: new FormControl('', Validators.pattern(VALID_REGEX))