Skip to content

Commit

Permalink
fix(ng-core): WrappedControlSuperclass gracefully handles errors th…
Browse files Browse the repository at this point in the history
…rown from `innerToOuter()` and `outerToInner()`
  • Loading branch information
ersimont committed Nov 6, 2021
1 parent 6e07405 commit 45599aa
Show file tree
Hide file tree
Showing 4 changed files with 135 additions and 15 deletions.
98 changes: 96 additions & 2 deletions projects/ng-core/src/lib/wrapped-control-superclass.spec.ts
@@ -1,4 +1,9 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
ErrorHandler,
Input,
} from '@angular/core';
import {
ComponentFixtureAutoDetect,
flushMicrotasks,
Expand All @@ -10,7 +15,7 @@ import {
ReactiveFormsModule,
} from '@angular/forms';
import { By } from '@angular/platform-browser';
import { ComponentContext } from '@s-libs/ng-dev';
import { ComponentContext, expectSingleCallAndReset } from '@s-libs/ng-dev';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { click, find, findButton, setValue } from '../test-helpers';
Expand Down Expand Up @@ -156,6 +161,95 @@ describe('WrappedControlSuperclass', () => {
expect(inputs[1].disabled).toBe(true);
});
});

it('gracefully handles an error in .innerToOuter()', () => {
@Component({
selector: `sl-error-in`,
template: `<input [formControl]="control" />`,
providers: [provideValueAccessor(ErrorInComponent)],
})
class ErrorInComponent extends WrappedControlSuperclass<number> {
control = new FormControl();
override outerToInner = jasmine.createSpy();
}

@Component({
template: `<sl-error-in [(ngModel)]="value"></sl-error-in>`,
})
class WrapperComponent {
@Input() value!: string;
}

const handleError = jasmine.createSpy();
const ctx = new ComponentContext(WrapperComponent, {
declarations: [ErrorInComponent],
imports: [FormsModule, ReactiveFormsModule],
providers: [{ provide: ErrorHandler, useValue: { handleError } }],
});
ctx.run(async () => {
const control: ErrorInComponent = ctx.fixture.debugElement.query(
By.directive(ErrorInComponent),
).componentInstance;
const input: HTMLInputElement = ctx.fixture.debugElement.query(
By.css('input'),
).nativeElement;

const error = new Error();
control.outerToInner.and.throwError(error);
ctx.assignInputs({ value: 'wont show' });
expectSingleCallAndReset(handleError, error);
expect(input.value).toBe('');

control.outerToInner.and.returnValue('restored');
ctx.assignInputs({ value: 'will show' });
expect(input.value).toBe('restored');
});
});

it('gracefully handles an error in .outerToInner()', () => {
@Component({
selector: `sl-error-out`,
template: `<input [formControl]="control" />`,
providers: [provideValueAccessor(ErrorOutComponent)],
})
class ErrorOutComponent extends WrappedControlSuperclass<number> {
control = new FormControl();
override innerToOuter = jasmine.createSpy();
}

@Component({
template: `<sl-error-out [(ngModel)]="value"></sl-error-out>`,
})
class WrapperComponent {
value = 'initial value';
}

const handleError = jasmine.createSpy();
const ctx = new ComponentContext(WrapperComponent, {
declarations: [ErrorOutComponent],
imports: [FormsModule, ReactiveFormsModule],
providers: [{ provide: ErrorHandler, useValue: { handleError } }],
});
ctx.run(async () => {
const wrapper = ctx.getComponentInstance();
const control: ErrorOutComponent = ctx.fixture.debugElement.query(
By.directive(ErrorOutComponent),
).componentInstance;
const input: HTMLInputElement = ctx.fixture.debugElement.query(
By.css('input'),
).nativeElement;

const error = new Error();
control.innerToOuter.and.throwError(error);
setValue(input, 'wont show');
expectSingleCallAndReset(handleError, error);
expect(wrapper.value).toBe('initial value');

control.innerToOuter.and.returnValue('restored');
setValue(input, 'will show');
expect(wrapper.value).toBe('restored');
});
});
});

describe('WrappedControlSuperclass tests using an old style fixture', () => {
Expand Down
43 changes: 34 additions & 9 deletions projects/ng-core/src/lib/wrapped-control-superclass.ts
@@ -1,7 +1,14 @@
import { Directive, Injector, OnInit } from '@angular/core';
import { Directive, ErrorHandler, Injector, OnInit } from '@angular/core';
import { AbstractControl } from '@angular/forms';
import { wrapMethod } from '@s-libs/js-core';
import { Observable, Subject } from 'rxjs';
import { bindKey, flow } from '@s-libs/micro-dash';
import {
MonoTypeOperatorFunction,
Observable,
retry,
Subject,
tap,
} from 'rxjs';
import { map } from 'rxjs/operators';
import { FormComponentSuperclass } from './form-component-superclass';

Expand Down Expand Up @@ -75,13 +82,18 @@ export abstract class WrappedControlSuperclass<OuterType, InnerType = OuterType>
/** Bind this to your inner form control to make all the magic happen. */
abstract control: AbstractControl;

private incomingValues$ = new Subject<OuterType>();
#incomingValues$ = new Subject<OuterType>();
#errorHandler: ErrorHandler;

constructor(injector: Injector) {
super(injector);
this.subscribeTo(this.setUpOuterToInner$(this.incomingValues$), (inner) => {
this.control.setValue(inner, { emitEvent: false });
});
this.#errorHandler = injector.get(ErrorHandler);
this.subscribeTo(
this.setUpOuterToInner$(this.#incomingValues$),
(inner) => {
this.control.setValue(inner, { emitEvent: false });
},
);
}

ngOnInit(): void {
Expand All @@ -100,7 +112,7 @@ export abstract class WrappedControlSuperclass<OuterType, InnerType = OuterType>

/** Called as angular propagates values changes to this `ControlValueAccessor`. You normally do not need to use it. */
handleIncomingValue(outer: OuterType): void {
this.incomingValues$.next(outer);
this.#incomingValues$.next(outer);
}

/** Called as angular propagates disabled changes to this `ControlValueAccessor`. You normally do not need to use it. */
Expand Down Expand Up @@ -140,7 +152,10 @@ export abstract class WrappedControlSuperclass<OuterType, InnerType = OuterType>
protected setUpOuterToInner$(
outer$: Observable<OuterType>,
): Observable<InnerType> {
return outer$.pipe(map((outer) => this.outerToInner(outer)));
return outer$.pipe(
map((outer) => this.outerToInner(outer)),
this.#handleError(),
);
}

/**
Expand Down Expand Up @@ -170,6 +185,16 @@ export abstract class WrappedControlSuperclass<OuterType, InnerType = OuterType>
protected setUpInnerToOuter$(
inner$: Observable<InnerType>,
): Observable<OuterType> {
return inner$.pipe(map((inner) => this.innerToOuter(inner)));
return inner$.pipe(
map((inner) => this.innerToOuter(inner)),
this.#handleError(),
);
}

#handleError<T>(): MonoTypeOperatorFunction<T> {
return flow(
tap<T>({ error: bindKey(this.#errorHandler, 'handleError') }),
retry(),
);
}
}
Expand Up @@ -6,7 +6,7 @@ import { WrappedFormControlSuperclass } from './wrapped-form-control-superclass'

describe('WrappedFormControlSuperclass', () => {
it('provides a FormControl', () => {
@Component({ template: `<input [formControl]="control" />` })
@Component({ template: `` })
class SimpleControl extends WrappedFormControlSuperclass<string> {}

const ctx = new ComponentContext(SimpleControl, {
Expand Down
7 changes: 4 additions & 3 deletions projects/ng-core/src/test-helpers.ts
@@ -1,4 +1,5 @@
import { ComponentFixture, flushMicrotasks } from '@angular/core/testing';
import { ComponentFixture } from '@angular/core/testing';
import { AngularContext } from '@s-libs/ng-dev';

export function findButton(
fixture: ComponentFixture<any>,
Expand Down Expand Up @@ -30,12 +31,12 @@ export function find<T extends Element>(

export function click(element: Element): void {
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
flushMicrotasks();
AngularContext.getCurrent()!.tick();
}

export function setValue(input: HTMLInputElement, value: string): void {
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
flushMicrotasks();
AngularContext.getCurrent()!.tick();
}

0 comments on commit 45599aa

Please sign in to comment.