-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { Component, ViewChild } from '@angular/core';
import { By } from '@angular/platform-browser';
-import { FormControl, FormGroup, Validators } from '@angular/forms';
+import { AbstractControl, FormControl, FormGroup, Validators } from '@angular/forms';
import { SharedModule } from '../../shared.module';
import { TearsheetStepComponent } from '../tearsheet-step/tearsheet-step.component';
import { TearsheetComponent, TearsheetOverflowScroll } from './tearsheet.component';
import { ActivatedRoute } from '@angular/router';
+import { Observable, of } from 'rxjs';
+import { delay } from 'rxjs/operators';
// Mock Component that uses tearsheet
@Component({
});
}
+@Component({
+ selector: 'cd-mock-async-form-step',
+ template: '',
+ standalone: false
+})
+class MockAsyncFormStepComponent {
+ formGroup = new FormGroup({
+ name: new FormControl('default-name', {
+ validators: [Validators.required],
+ asyncValidators: [
+ (_control: AbstractControl): Observable<{ notUnique: boolean } | null> =>
+ of(null).pipe(delay(10))
+ ]
+ }),
+ requiredField: new FormControl('', Validators.required)
+ });
+}
+
+@Component({
+ template: `
+ <cd-tearsheet
+ [steps]="steps"
+ [title]="title"
+ [description]="description"
+ >
+ <cd-tearsheet-step>
+ <cd-mock-async-form-step #tearsheetStep></cd-mock-async-form-step>
+ </cd-tearsheet-step>
+ <cd-tearsheet-step>
+ <div>Step 2</div>
+ </cd-tearsheet-step>
+ </cd-tearsheet>
+ `,
+ standalone: false
+})
+class MockAsyncFormHostComponent {
+ steps = [
+ { label: 'Step 1', complete: false },
+ { label: 'Step 2', complete: false }
+ ];
+ title = 'Async Form Host';
+ description = 'Async Form Host Description';
+
+ @ViewChild(TearsheetComponent)
+ tearsheet!: TearsheetComponent;
+
+ @ViewChild(MockAsyncFormStepComponent)
+ formStep!: MockAsyncFormStepComponent;
+}
+
@Component({
template: `
<cd-tearsheet
TearsheetStepComponent,
MockHostComponent,
MockFormStepComponent,
- MockFormHostComponent
+ MockFormHostComponent,
+ MockAsyncFormStepComponent,
+ MockAsyncFormHostComponent
],
imports: [SharedModule],
providers: [
expect(tearsheetComponent.currentStep).toBe(0);
});
- it('should disable next button when current step is invalid', () => {
+ it('should keep Next enabled when current step is invalid so users can retry', () => {
hostComponent.steps = hostComponent.steps.map((step, i) =>
i === 0 ? { ...step, invalid: true } : step
);
);
const nextBtn = buttons.find((btn) => btn.nativeElement.textContent.trim() === 'Next');
expect(nextBtn).toBeTruthy();
- expect(nextBtn?.nativeElement.disabled).toBe(true);
+ expect(nextBtn?.nativeElement.disabled).toBe(false);
});
});
describe('nested form validation on next', () => {
- it('should mark nested controls dirty and touched on next', () => {
+ it('should mark nested controls touched on next without dirtying them', () => {
const formHostFixture = TestBed.createComponent(MockFormHostComponent);
formHostFixture.detectChanges();
const formHost = formHostFixture.componentInstance;
formHost.tearsheet.onNext();
- expect(childControl?.dirty).toBe(true);
+ // Touch shows errors; do not mark dirty (that re-triggers async validators).
+ expect(childControl?.dirty).toBe(false);
expect(childControl?.touched).toBe(true);
expect(childControl?.hasError('required')).toBe(true);
+ expect(formHost.tearsheet.currentStep).toBe(0);
});
+
+ it('should advance after async validators complete when required fields are filled', fakeAsync(() => {
+ const formHostFixture = TestBed.createComponent(MockAsyncFormHostComponent);
+ formHostFixture.detectChanges();
+ const formHost = formHostFixture.componentInstance;
+
+ formHost.formStep.formGroup.get('requiredField')?.setValue('filled');
+ formHostFixture.detectChanges();
+
+ formHost.tearsheet.onNext();
+ expect(formHost.tearsheet.currentStep).toBe(0);
+
+ tick(10);
+ formHostFixture.detectChanges();
+
+ expect(formHost.tearsheet.currentStep).toBe(1);
+ expect(formHost.tearsheet.steps[0].invalid).toBe(false);
+ }));
+
+ it('should stay on step when invalid then advance after the field is fixed', fakeAsync(() => {
+ const formHostFixture = TestBed.createComponent(MockAsyncFormHostComponent);
+ formHostFixture.detectChanges();
+ const formHost = formHostFixture.componentInstance;
+
+ // First Next with empty required field: stay on step; Next remains usable.
+ formHost.tearsheet.onNext();
+ tick(10);
+ formHostFixture.detectChanges();
+ expect(formHost.tearsheet.currentStep).toBe(0);
+
+ formHost.formStep.formGroup.get('requiredField')?.setValue('filled');
+ tick(10);
+ formHostFixture.detectChanges();
+
+ formHost.tearsheet.onNext();
+ tick(10);
+ formHostFixture.detectChanges();
+ expect(formHost.tearsheet.currentStep).toBe(1);
+ }));
+
+ it('should submit after validity refresh settles async validators', fakeAsync(() => {
+ const formHostFixture = TestBed.createComponent(MockAsyncFormHostComponent);
+ formHostFixture.detectChanges();
+ const formHost = formHostFixture.componentInstance;
+ const submitSpy = jest.spyOn(formHost.tearsheet.submitRequested, 'emit');
+
+ formHost.formStep.formGroup.get('requiredField')?.setValue('filled');
+ // Settle the initial async validator from control creation.
+ tick(10);
+ formHostFixture.detectChanges();
+
+ formHost.tearsheet.currentStep = 1;
+ formHostFixture.detectChanges();
+
+ formHost.tearsheet.onSubmit();
+ // refreshControlValidity re-runs async validators; wait for them to settle.
+ expect(submitSpy).not.toHaveBeenCalled();
+ tick(10);
+ formHostFixture.detectChanges();
+
+ expect(submitSpy).toHaveBeenCalledTimes(1);
+ expect(submitSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'default-name', requiredField: 'filled' })
+ );
+ }));
+
+ it('should navigate to the first invalid step instead of silently blocking Create', fakeAsync(() => {
+ const formHostFixture = TestBed.createComponent(MockAsyncFormHostComponent);
+ formHostFixture.detectChanges();
+ const formHost = formHostFixture.componentInstance;
+ const submitSpy = jest.spyOn(formHost.tearsheet.submitRequested, 'emit');
+
+ tick(10);
+ formHost.tearsheet.currentStep = 1;
+ formHostFixture.detectChanges();
+
+ formHost.tearsheet.onSubmit();
+ tick(10);
+ formHostFixture.detectChanges();
+
+ expect(submitSpy).not.toHaveBeenCalled();
+ expect(formHost.tearsheet.currentStep).toBe(0);
+ expect(formHost.tearsheet.steps[0].invalid).toBe(true);
+ }));
+
+ it('should not advance if the user navigated away before the async validator settled', fakeAsync(() => {
+ const formHostFixture = TestBed.createComponent(MockAsyncFormHostComponent);
+ formHostFixture.detectChanges();
+ const formHost = formHostFixture.componentInstance;
+
+ // Fill the required field so the form would be valid once async settles.
+ formHost.formStep.formGroup.get('requiredField')?.setValue('filled');
+ formHostFixture.detectChanges();
+
+ // Trigger Next — async validator is in-flight, step stays at 0.
+ formHost.tearsheet.onNext();
+ expect(formHost.tearsheet.currentStep).toBe(0);
+
+ // User navigates back to step 0 then clicks step 1 directly (or onStepSelect),
+ // simulating navigation away while the validator is still pending.
+ formHost.tearsheet.currentStep = 1;
+ formHostFixture.detectChanges();
+
+ // Async validator settles — callback must not advance further (step should stay at 1).
+ tick(10);
+ formHostFixture.detectChanges();
+
+ expect(formHost.tearsheet.currentStep).toBe(1);
+ }));
});
describe('[stepValid] seeding and live sync', () => {
import { Location } from '@angular/common';
import { ConfirmationModalComponent } from '../confirmation-modal/confirmation-modal.component';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
-import { Subject } from 'rxjs';
-import { takeUntil } from 'rxjs/operators';
+import { forkJoin, Subject } from 'rxjs';
+import { filter, finalize, startWith, take, takeUntil } from 'rxjs/operators';
export type TearsheetOverflowScroll = 'auto' | 'hidden' | 'visible' | 'scroll';
@Input() successIcon: boolean = false;
@Input() headerTestId?: string;
- @Output() submitRequested = new EventEmitter<void>();
+ /** Merged step form values for consumers that bind `(submitRequested)="onSubmit($event)"`. */
+ @Output() submitRequested = new EventEmitter<Record<string, unknown>>();
@Output() closeRequested = new EventEmitter<void>();
@Output() stepChanged = new EventEmitter<{ current: number }>();
@Output() validateStep = new EventEmitter<{ step: number }>();
@ContentChildren(TearsheetStepComponent)
stepContents!: QueryList<TearsheetStepComponent>;
+ private advancingDueToAsync = false;
+ private submittingDueToAsync = false;
+ /** Snapshot of each step's form value taken when leaving the step. */
+ private stepValueCache = new WeakMap<TearsheetStepComponent, Record<string, unknown>>();
+
get activeStepTemplate() {
return this.stepContents?.toArray()[this.currentStep]?.template;
}
private _updateStepInvalid(index: number, invalid: boolean) {
this.steps = this.steps.map((step, i) => (i === index ? { ...step, invalid } : step));
+ // statusChanges / async validators run outside the OnPush event path;
+ // mark dirty so Next button disabled state re-renders.
+ this.cdr.markForCheck();
}
onStepSelect(event: { step: Step; index: number }) {
this.validateStep.emit({ step: this.currentStep });
const wrapper = this.stepContents?.toArray()?.[this.currentStep];
const currentForm = wrapper?.resolvedFormGroup;
+ // Touch for error display, then refresh each control so cdValidate /
+ // Carbon invalid bindings update. Do NOT markAsDirty — that re-triggers
+ // pristine-skipping async validators (e.g. NQN unique).
currentForm?.markAllAsTouched();
- this.markControlsDirtyAndValidate(currentForm);
- if (currentForm) {
- this._updateStepInvalid(this.currentStep, currentForm.invalid);
+ this.refreshControlValidity(currentForm);
+
+ // If an async validator is already in-flight (user edited NQN), wait for it.
+ if (currentForm?.pending) {
+ if (this.advancingDueToAsync) {
+ return;
+ }
+ this.advancingDueToAsync = true;
+ // Snapshot the step index now; the user may navigate away before the
+ // validator settles, so we must re-check both the index and the active
+ // wrapper on arrival and skip the advance if either has changed.
+ const stepBeingValidated = this.currentStep;
+ currentForm.statusChanges
+ .pipe(
+ startWith(currentForm.status),
+ filter((status) => status !== 'PENDING'),
+ take(1),
+ takeUntil(this.setupTeardown$),
+ finalize(() => {
+ this.advancingDueToAsync = false;
+ })
+ )
+ .subscribe(() => {
+ const activeWrapper = this.stepContents?.toArray()?.[stepBeingValidated];
+ if (this.currentStep === stepBeingValidated && activeWrapper === wrapper) {
+ this.advanceFromCurrentStep(wrapper);
+ }
+ });
+ return;
+ }
+
+ this.advanceFromCurrentStep(wrapper);
+ }
+
+ /**
+ * Re-run validators and emit statusChanges on every control without marking
+ * them dirty. Needed so cdValidate picks up touched+invalid after Next.
+ */
+ private refreshControlValidity(control: AbstractControl | null) {
+ if (!control) {
+ return;
}
+ if (control instanceof FormGroup || control instanceof FormArray) {
+ Object.values(control.controls).forEach((child) => this.refreshControlValidity(child));
+ }
+ control.updateValueAndValidity({ onlySelf: true, emitEvent: true });
+ }
+ private advanceFromCurrentStep(wrapper: TearsheetStepComponent | undefined) {
+ // canProceed uses form.valid, so PENDING/INVALID both block advance.
+ // Next stays enabled; we only show field errors and refuse to leave the step.
const canAdvance = wrapper ? wrapper.canProceed : true;
- this._updateStepInvalid(this.currentStep, !canAdvance);
if (this.currentStep !== this.lastStep && canAdvance) {
+ this._updateStepInvalid(this.currentStep, false);
+ if (wrapper) {
+ this.cacheStepValue(wrapper);
+ }
this.currentStep = this.currentStep + 1;
this.stepChanged.emit({ current: this.currentStep });
- this.cdr.markForCheck();
- } else if (!canAdvance) {
- this.cdr.markForCheck();
+ }
+ }
+
+ private cacheStepValue(wrapper: TearsheetStepComponent) {
+ const value = wrapper.stepComponent?.formGroup?.value as Record<string, unknown> | null;
+ if (value) {
+ this.stepValueCache.set(wrapper, { ...value });
}
}
getMergedPayload(): any {
return this.stepContents.toArray().reduce((acc, wrapper) => {
- const stepFormValue = wrapper.stepComponent?.formGroup?.value;
- return { ...acc, ...stepFormValue };
+ const liveValue = wrapper.stepComponent?.formGroup?.value;
+ const cachedValue = this.stepValueCache.get(wrapper);
+ return { ...acc, ...(liveValue ?? cachedValue ?? {}) };
}, {});
}
onSubmit() {
- this.stepContents?.forEach((wrapper, index) => {
+ if (this.submittingDueToAsync) {
+ return;
+ }
+
+ // Cache whatever is still mounted before validating/submitting.
+ this.stepContents?.forEach((wrapper) => this.cacheStepValue(wrapper));
+
+ const wrappers = this.stepContents?.toArray() ?? [];
+ wrappers.forEach((wrapper) => {
const form = wrapper.resolvedFormGroup;
if (!form) return;
form.markAllAsTouched();
- this.markControlsDirtyAndValidate(form);
- this._updateStepInvalid(index, form.invalid);
+ this.refreshControlValidity(form);
});
- const wrappers = this.stepContents?.toArray() ?? [];
- const anyStepInvalid = this.steps.some(
- (step, index) => step?.invalid || (wrappers[index] ? !wrappers[index].canProceed : false)
- );
- if (anyStepInvalid) return;
+ this.finishSubmit();
+ }
- const mergedPayloads = this.getMergedPayload();
- this.submitRequested.emit(mergedPayloads);
+ private waitForFormsToSettle(forms: FormGroup[], onSettled: () => void) {
+ if (!forms.length) {
+ onSettled();
+ return;
+ }
+ this.submittingDueToAsync = true;
+ forkJoin(
+ forms.map((form) =>
+ form.statusChanges.pipe(
+ startWith(form.status),
+ filter((status) => status !== 'PENDING'),
+ take(1)
+ )
+ )
+ )
+ .pipe(
+ takeUntil(this.setupTeardown$),
+ finalize(() => {
+ this.submittingDueToAsync = false;
+ })
+ )
+ .subscribe(() => onSettled());
}
- private markControlsDirtyAndValidate(control: AbstractControl | null) {
- if (!control) {
+ private finishSubmit() {
+ const wrappers = this.stepContents?.toArray() ?? [];
+ const forms = wrappers
+ .map((wrapper) => wrapper.resolvedFormGroup)
+ .filter((form): form is FormGroup => !!form);
+
+ const pendingForms = forms.filter((form) => form.pending);
+ if (pendingForms.length) {
+ this.waitForFormsToSettle(pendingForms, () => this.finishSubmit());
return;
}
- if (control instanceof FormGroup || control instanceof FormArray) {
- Object.values(control.controls).forEach((child) => this.markControlsDirtyAndValidate(child));
+
+ let firstInvalid = -1;
+ wrappers.forEach((wrapper, index) => {
+ const form = wrapper.resolvedFormGroup;
+ if (form) {
+ this._updateStepInvalid(index, form.invalid);
+ if (form.invalid && firstInvalid < 0) {
+ firstInvalid = index;
+ }
+ } else if (wrapper.stepValid !== null && !wrapper.canProceed) {
+ this._updateStepInvalid(index, true);
+ if (firstInvalid < 0) {
+ firstInvalid = index;
+ }
+ } else {
+ // Form not currently resolvable (step content unmounted). Trust cache /
+ // earlier navigation — do not block Create on a stale steps[].invalid flag.
+ this._updateStepInvalid(index, false);
+ }
+ });
+
+ if (firstInvalid >= 0) {
+ this.currentStep = firstInvalid;
+ this.stepChanged.emit({ current: this.currentStep });
+ this.cdr.markForCheck();
+ return;
}
- control.markAsDirty({ onlySelf: true });
- control.updateValueAndValidity({ onlySelf: true, emitEvent: true });
+
+ this.submitRequested.emit(this.getMergedPayload());
}
closeFullTearsheet() {
// with Next enabled so the user can navigate freely before touching fields.
const form = wrapper.resolvedFormGroup;
if (form) {
- form.statusChanges
- .pipe(takeUntil(this.setupTeardown$))
- .subscribe(() => this._updateStepInvalid(index, form.invalid));
+ // Do not seed or sync form.invalid onto the Next button — Next stays
+ // enabled so users can click it, see field errors (e.g. subnet-mask),
+ // fix them, and click Next again. Advance is still gated in onNext().
+ form.statusChanges.pipe(takeUntil(this.setupTeardown$)).subscribe(() => {
+ if (form.pending) {
+ return;
+ }
+ // Clear step invalid once the form becomes valid again after a
+ // failed Next attempt (field-level errors are handled by cdValidate).
+ if (form.valid) {
+ this._updateStepInvalid(index, false);
+ }
+ });
}
// Path 2: step uses [stepValid] input binding (no formGroup reference).