Skip to content

Commit

Permalink
feat(ng-core): Generalize WrappedFormControlSuperclass into `Wrappe…
Browse files Browse the repository at this point in the history
…dControlSuperclass`. It can now wrap a `FormGroup` or `FormArray` for more complex components with multiple inner form components.

BREAKING CHANGE: Rename all references of `WrappedFormControlSuperclass` to `WrappedControlSuperclass`

BREAKING CHANGE: In subclasses of `WrappedControlSuperclass`, rename all references of `formControl` to `control`

BREAKING CHANGE: If you have a subclass of `WrappedControlSuperclass` that implement `ngOnInit()`, you must now call `super.ngOnInit()`
  • Loading branch information
ersimont committed Sep 14, 2021
1 parent 481908d commit fb7cc7e
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 134 deletions.
115 changes: 15 additions & 100 deletions projects/integration/src/app/api-tests/ng-core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
ComponentFixtureAutoDetect,
flushMicrotasks,
} from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { keys } from '@s-libs/micro-dash';
import * as ngCore from '@s-libs/ng-core';
Expand All @@ -22,7 +22,7 @@ import {
InjectableSuperclass,
mixInInjectableSuperclass,
provideValueAccessor,
WrappedFormControlSuperclass,
WrappedControlSuperclass,
} from '@s-libs/ng-core';
import { ComponentContext, expectSingleCallAndReset } from '@s-libs/ng-dev';
import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs';
Expand All @@ -42,8 +42,8 @@ describe('ng-core', () => {
expect(InjectableSuperclass).toBeDefined();
});

it('has WrappedFormControlSuperclass', () => {
expect(WrappedFormControlSuperclass).toBeDefined();
it('has WrappedControlSuperclass', () => {
expect(WrappedControlSuperclass).toBeDefined();
});

it('has mixInInjectableSuperclass', () => {
Expand Down Expand Up @@ -192,111 +192,26 @@ describe('ng-core', () => {
});

it('knows where to find @angular/forms', () => {
// WrappedFormControlSuperclass uses @angular/forms. This is one of its tests
// WrappedControlSuperclass uses @angular/forms. This is one of its tests

@Component({
template: `
<s-string-component
[(ngModel)]="string"
(ngModelChange)="emissions = emissions + 1"
#stringControl="ngModel"
[disabled]="shouldDisable"
></s-string-component>
<div *ngIf="stringControl.touched">Touched!</div>
<button (click)="shouldDisable = !shouldDisable">
Toggle Disabled
</button>
<hr />
<s-date-component [(ngModel)]="date"></s-date-component>
`,
})
class TestComponent {
@Input() string = '';
emissions = 0;
date = new Date();
shouldDisable = false;
}
@Component({ template: `<input [formControl]="control" />` })
class NgTouchedComponent extends WrappedControlSuperclass<string> {
control = new FormControl();

@Component({
selector: `s-string-component`,
template: ` <input [formControl]="formControl" /> `,
providers: [provideValueAccessor(StringComponent)],
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StringComponent extends WrappedFormControlSuperclass<string> {
constructor(injector: Injector) {
super(injector);
}
}

@Component({
selector: `s-date-component`,
template: `
<input type="datetime-local" [formControl]="formControl" />
`,
providers: [provideValueAccessor(DateComponent)],
changeDetection: ChangeDetectionStrategy.OnPush,
})
class DateComponent extends WrappedFormControlSuperclass<Date, string> {
constructor(injector: Injector) {
super(injector);
}

protected innerToOuter(value: string): Date {
return new Date(value + 'Z');
}

protected outerToInner(value: Date): string {
if (value === null) {
return ''; // happens during initialization
}
return value.toISOString().substr(0, 16);
}
}

class TestComponentContext extends ComponentContext<TestComponent> {
constructor() {
super(TestComponent, {
imports: [FormsModule, ReactiveFormsModule],
declarations: [DateComponent, StringComponent],
// this can go away with component harnesses eventually
providers: [
{ provide: ComponentFixtureAutoDetect, useValue: true },
],
});
}
}
const ctx = new TestComponentContext();

function stringInput(): HTMLInputElement {
return find<HTMLInputElement>(ctx.fixture, 's-string-component input');
}

function find<T extends Element>(
fixture: ComponentFixture<any>,
cssSelector: string,
): T {
const found = fixture.nativeElement.querySelector(cssSelector) as T;
if (found) {
return found;
} else {
throw new Error('could not find ' + cssSelector);
}
}

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();
}

ctx.assignInputs({ string: 'initial value' });
const ctx = new ComponentContext(NgTouchedComponent, {
imports: [ReactiveFormsModule],
});
ctx.run(() => {
expect(stringInput().value).toBe('initial value');
const debugElement = ctx.fixture.debugElement.query(By.css('input'));
debugElement.triggerEventHandler('blur', {});
ctx.tick();

setValue(stringInput(), 'edited value');
expect(ctx.getComponentInstance().string).toBe('edited value');
expect(debugElement.classes['ng-touched']).toBe(true);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import {
ComponentFixtureAutoDetect,
flushMicrotasks,
} from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
} from '@angular/forms';
import { By } from '@angular/platform-browser';
import { ComponentContext } from '@s-libs/ng-dev';
import { Observable } from 'rxjs';
Expand All @@ -20,16 +25,16 @@ import {
provideValueAccessor,
} from './form-component-superclass';
import { InjectableSuperclass } from './injectable-superclass';
import { WrappedFormControlSuperclass } from './wrapped-form-control-superclass';
import { WrappedControlSuperclass } from './wrapped-control-superclass';

describe('WrappedFormControlSuperclass', () => {
describe('WrappedControlSuperclass', () => {
it('allows setting up an observable to translate between inner and outer values', () => {
@Component({
selector: 's-observable-translation',
template: `<input [formControl]="formControl" />`,
template: `<input [formControl]="control" />`,
providers: [provideValueAccessor(ObservableTranslationComponent)],
})
class ObservableTranslationComponent extends WrappedFormControlSuperclass<
class ObservableTranslationComponent extends WrappedControlSuperclass<
number,
string
> {
Expand Down Expand Up @@ -86,8 +91,8 @@ describe('WrappedFormControlSuperclass', () => {
});

it('adds ng-touched to the inner form control at the right time', () => {
@Component({ template: `<input [formControl]="formControl" />` })
class NgTouchedComponent extends WrappedFormControlSuperclass<string> {
@Component({ template: `<input [formControl]="control" />` })
class NgTouchedComponent extends WrappedControlSuperclass<string> {
constructor(injector: Injector) {
super(injector);
}
Expand All @@ -104,9 +109,70 @@ describe('WrappedFormControlSuperclass', () => {
expect(debugElement.classes['ng-touched']).toBe(true);
});
});

// https://github.com/simontonsoftware/s-libs/pull/52
it('can wrap a form group', () => {
class FullName {
firstName = '';
lastName = '';
}

@Component({
selector: 's-full-name',
template: `
<div [formGroup]="control">
<input id="first" formControlName="firstName" />
<input id="last" formControlName="lastName" />
</div>
`,
providers: [provideValueAccessor(FullNameComponent)],
})
class FullNameComponent extends WrappedControlSuperclass<FullName> {
control = new FormGroup({
firstName: new FormControl(),
lastName: new FormControl(),
});

// This looks unnecessary, but is required for Angular to provide `Injector`
constructor(injector: Injector) {
super(injector);
}

protected outerToInner(outer: FullName | null): FullName {
// `outer` can come in as `null` during initialization when the user binds with `ngModel`
return outer || new FullName();
}
}

@Component({
template: `
<s-full-name [ngModel]="fullName" [disabled]="disabled"></s-full-name>
`,
})
class FormComponent {
@Input() disabled = false;
fullName = { firstName: 'Krick', lastName: 'Ray' };
}

const ctx = new ComponentContext(FormComponent, {
imports: [FormsModule, ReactiveFormsModule],
declarations: [FullNameComponent],
});
ctx.run(async () => {
const inputs = document.querySelectorAll('input');
expect(inputs[0].value).toBe('Krick');
expect(inputs[1].value).toBe('Ray');

expect(inputs[0].disabled).toBe(false);
expect(inputs[1].disabled).toBe(false);
ctx.assignInputs({ disabled: true });
expect(inputs[0].disabled).toBe(true);
expect(inputs[1].disabled).toBe(true);
});
});
});

describe('WrappedFormControlSuperclass tests using an old style fixture', () => {
describe('WrappedControlSuperclass tests using an old style fixture', () => {
@Component({
template: `
<s-string-component
Expand All @@ -130,23 +196,23 @@ describe('WrappedFormControlSuperclass tests using an old style fixture', () =>

@Component({
selector: `s-string-component`,
template: ` <input [formControl]="formControl" /> `,
template: ` <input [formControl]="control" /> `,
providers: [provideValueAccessor(StringComponent)],
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StringComponent extends WrappedFormControlSuperclass<string> {
class StringComponent extends WrappedControlSuperclass<string> {
constructor(injector: Injector) {
super(injector);
}
}

@Component({
selector: `s-date-component`,
template: ` <input type="datetime-local" [formControl]="formControl" /> `,
template: ` <input type="datetime-local" [formControl]="control" /> `,
providers: [provideValueAccessor(DateComponent)],
changeDetection: ChangeDetectionStrategy.OnPush,
})
class DateComponent extends WrappedFormControlSuperclass<Date, string> {
class DateComponent extends WrappedControlSuperclass<Date, string> {
constructor(injector: Injector) {
super(injector);
}
Expand Down
Loading

0 comments on commit fb7cc7e

Please sign in to comment.