Skip to content

Commit

Permalink
feat: add DirectiveSuperclass
Browse files Browse the repository at this point in the history
  • Loading branch information
ersimont committed Dec 15, 2018
1 parent 3428b0d commit b2d0213
Show file tree
Hide file tree
Showing 9 changed files with 304 additions and 13 deletions.
4 changes: 2 additions & 2 deletions .idea/codeStyles/Project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions .idea/encodings.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion .idea/runConfigurations/E2E_Tests.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions .idea/watcherTasks.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions projects/s-ng-utils/src/lib/auto-destroyable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { SubscriptionManager } from "s-rxjs-utils";
*
* ```ts
* @Injectable()
* // or @Component()
* // or @Directive()
* // or @Component() (also consider DirectiveSuperclass)
* // or @Directive() (also consider DirectiveSuperclass)
* // or @Pipe()
* class MyThing extends AutoDestroyable {
* constructor(somethingObservable: Observable) {
Expand Down
203 changes: 203 additions & 0 deletions projects/s-ng-utils/src/lib/directive-superclass.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import {
ChangeDetectionStrategy,
Component,
Inject,
Injector,
Input,
} from "@angular/core";
import {
ComponentFixture,
ComponentFixtureAutoDetect,
fakeAsync,
flushMicrotasks,
TestBed,
} from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { BehaviorSubject, combineLatest, Observable } from "rxjs";
import { map } from "rxjs/operators";
import { click, find, findButton } from "../test-helpers";
import { DirectiveSuperclass } from "./directive-superclass";

describe("DirectiveSuperclass", () => {
let fixture: ComponentFixture<TestComponent>;
let el: HTMLElement;
let color$: BehaviorSubject<string>;
let colorTextComponent: ColorTextComponent;

function init(initialAttrs?: Partial<TestComponent>) {
color$ = new BehaviorSubject("Grey");
TestBed.configureTestingModule({
declarations: [ColorTextComponent, TestComponent],
providers: [
{ provide: "color$", useValue: color$ },
{ provide: ComponentFixtureAutoDetect, useValue: true },
],
});
fixture = TestBed.createComponent(TestComponent);
Object.assign(fixture.componentInstance, initialAttrs);
fixture.detectChanges();
flushMicrotasks();
el = fixture.nativeElement;
colorTextComponent = fixture.debugElement.query(
By.directive(ColorTextComponent),
).componentInstance;
}

function darkButton() {
return findButton(fixture, "Dark");
}

function slateButton() {
return findButton(fixture, "Slate");
}

function bothButton() {
return findButton(fixture, "Both");
}

function hideButton() {
return findButton(fixture, "Hide");
}

function colorSpan() {
return find<HTMLSpanElement>(fixture, "s-color-text span");
}

/////////

describe(".changedKeys$", () => {
it("emits the keys that change", fakeAsync(() => {
init();
const stub = jasmine.createSpy();
colorTextComponent.changedKeys$.subscribe(stub);

click(darkButton());
expect(stub).toHaveBeenCalledTimes(1);
expect(stub).toHaveBeenCalledWith(new Set(["prefix"]));

click(slateButton());
expect(stub).toHaveBeenCalledTimes(2);
expect(stub).toHaveBeenCalledWith(new Set(["prefix2"]));

click(bothButton());
expect(stub).toHaveBeenCalledTimes(3);
expect(stub).toHaveBeenCalledWith(new Set(["prefix", "prefix2"]));
}));
});

describe(".getInput$()", () => {
it("emits the value of an input when it changes", fakeAsync(() => {
init();
const stub = jasmine.createSpy();
colorTextComponent.getInput$("prefix2").subscribe(stub);

click(darkButton());
expect(stub).not.toHaveBeenCalled();

click(slateButton());
expect(stub).toHaveBeenCalledTimes(1);
expect(stub).toHaveBeenCalledWith("Slate");

click(bothButton());
expect(stub).toHaveBeenCalledTimes(2);
expect(stub).toHaveBeenCalledWith(undefined);
}));
});

describe(".bindToInstance()", () => {
it("sets the local value", fakeAsync(() => {
init();
expect(colorSpan().innerText).toBe("Grey");
expect(colorSpan().style.backgroundColor).toBe("grey");

click(darkButton());
expect(colorSpan().innerText).toBe("DarkGrey");
expect(colorSpan().style.backgroundColor).toBe("darkgrey");

click(slateButton());
expect(colorSpan().innerText).toBe("DarkSlateGrey");
expect(colorSpan().style.backgroundColor).toBe("darkslategrey");

click(bothButton());
expect(colorSpan().innerText).toBe("Grey");
expect(colorSpan().style.backgroundColor).toBe("grey");
}));

it("triggers change detection", fakeAsync(() => {
init();

color$.next("Blue");
fixture.detectChanges();
expect(colorSpan().innerText).toBe("Blue");
expect(colorSpan().style.backgroundColor).toBe("blue");

click(bothButton());
expect(colorSpan().innerText).toBe("DarkSlateBlue");
expect(colorSpan().style.backgroundColor).toBe("darkslateblue");
}));
});

describe(".subscribeTo()", () => {
it("cleans up subscriptions", fakeAsync(() => {
init();
expect(color$.observers.length).toBe(1);

click(hideButton());
expect(color$.observers.length).toBe(0);
}));
});
});

@Component({
template: `
<button (click)="toggle('prefix', 'Dark')">Dark</button>
<button (click)="toggle('prefix2', 'Slate')">Slate</button>
<button (click)="toggle('prefix', 'Dark'); toggle('prefix2', 'Slate')">
Both
</button>
<button (click)="hide = !hide">Hide</button>
<s-color-text
*ngIf="!hide"
[prefix]="prefix"
[prefix2]="prefix2"
></s-color-text>
`,
})
class TestComponent {
color$ = new BehaviorSubject<string>("Green");
prefix?: string;
prefix2?: string;
hide = false;

toggle(key: "prefix" | "prefix2", value: string) {
this[key] = this[key] ? undefined : value;
}
}

@Component({
selector: "s-color-text",
template: `
<span [style.background]="color">{{ color }}</span>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class ColorTextComponent extends DirectiveSuperclass {
@Input() prefix?: string;
@Input() prefix2?: string;
color!: string;

constructor(
@Inject("color$") color$: Observable<string>,
injector: Injector,
) {
super(injector);
this.bindToInstance(
"color",
combineLatest(
this.getInput$("prefix"),
this.getInput$("prefix2"),
color$,
).pipe(map((parts) => parts.filter((p) => p).join(""))),
);
}
}
81 changes: 81 additions & 0 deletions projects/s-ng-utils/src/lib/directive-superclass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
OnChanges,
SimpleChanges,
Injector,
ChangeDetectorRef,
} from "@angular/core";
import { Observable, Subject } from "rxjs";
import { filter, map } from "rxjs/operators";
import { AutoDestroyable } from "./auto-destroyable";

/**
* Extend this when creating a directive (including a component, which is a kind of directive) to gain access to the helpers demonstrated below.
*
* ```ts
* @Component({
* selector: "s-color-text",
* template: `
* <span [style.background]="color">{{ color }}</span>
* `,
* // note that `bindToInstance()` works even with OnPush change detection
* changeDetection: ChangeDetectionStrategy.OnPush,
* })
* class ColorTextComponent extends DirectiveSuperclass {
* @Input() prefix?: string;
* @Input() prefix2?: string;
* color!: string;
*
* constructor(
* @Inject("color$") color$: Observable<string>,
* injector: Injector,
* ) {
* super(injector);
*
* // combine everything to calculate `color` and keep it up to date
* this.bindToInstance(
* "color",
* combineLatest(
* this.getInput$("prefix"),
* this.getInput$("prefix2"),
* color$,
* ).pipe(map((parts) => parts.filter((p) => p).join(""))),
* );
* }
* }
* ```
*/
export abstract class DirectiveSuperclass extends AutoDestroyable
implements OnChanges {
/** Emits the set of `@Input()` property names that change during each call to `ngOnChanges()`. */
changedKeys$ = new Subject<Set<keyof this>>();

protected changeDetectorRef: ChangeDetectorRef;

constructor(injector: Injector) {
super();
this.changeDetectorRef = injector.get(ChangeDetectorRef);
}

ngOnChanges(changes: SimpleChanges) {
this.changedKeys$.next(
new Set(Object.getOwnPropertyNames(changes) as Array<keyof this>),
);
}

/** @return an observable of the values for one of this directive's `@Input()` properties */
getInput$<K extends keyof this>(key: K): Observable<this[K]> {
return this.changedKeys$.pipe(
filter((keys) => keys.has(key)),
map(() => this[key]),
);
}

/**
* Binds an observable to one of this directive's instance variables. When the observable emits the instance variable will be updated, and change detection will be triggered to propagate any changes. Use this an an alternative to repeating `| async` multiple times in your template. */
bindToInstance<K extends keyof this>(key: K, value$: Observable<this[K]>) {
this.subscribeTo(value$, (value) => {
this[key] = value;
this.changeDetectorRef.markForCheck();
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe("WrappedFormControlSuperclass", () => {
></s-string-component>
<div *ngIf="stringControl.touched">Touched!</div>
<button (click)="shouldDisable = !shouldDisable">Toggle Disabled</button>
<hr>
<hr />
<s-date-component [(ngModel)]="date"></s-date-component>
`,
})
Expand Down
Loading

0 comments on commit b2d0213

Please sign in to comment.