Skip to content

Commit

Permalink
feat: introduce on as a improvement to subscribe to outputs (#465)
Browse files Browse the repository at this point in the history
Closes #462
  • Loading branch information
mumenthalers authored Jul 20, 2024
1 parent 0652a14 commit caad0c2
Show file tree
Hide file tree
Showing 3 changed files with 261 additions and 55 deletions.
38 changes: 32 additions & 6 deletions projects/testing-library/src/lib/models.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { Type, DebugElement } from '@angular/core';
import {ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed} from '@angular/core/testing';
import { Type, DebugElement, OutputRef, EventEmitter } from '@angular/core';
import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing';
import { Routes } from '@angular/router';
import { BoundFunction, Queries, queries, Config as dtlConfig, PrettyDOMOptions } from '@testing-library/dom';

export type OutputRefKeysWithCallback<T> = {
[key in keyof T]?: T[key] extends EventEmitter<infer U>
? (val: U) => void
: T[key] extends OutputRef<infer U>
? (val: U) => void
: never;
};

export type RenderResultQueries<Q extends Queries = typeof queries> = { [P in keyof Q]: BoundFunction<Q[P]> };
export interface RenderResult<ComponentType, WrapperType = ComponentType> extends RenderResultQueries {
/**
Expand Down Expand Up @@ -60,7 +68,7 @@ export interface RenderResult<ComponentType, WrapperType = ComponentType> extend
rerender: (
properties?: Pick<
RenderTemplateOptions<ComponentType>,
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'detectChangesOnRender'
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender'
> & { partialUpdate?: boolean },
) => Promise<void>;
/**
Expand Down Expand Up @@ -205,12 +213,12 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
/**
* @description
* An object to set `@Output` properties of the component
*
* @deprecated use the `on` option instead. When it is necessary to override properties, use the `componentProperties` option.
* @default
* {}
*
* @example
* const sendValue = (value) => { ... }
* const sendValue = new EventEmitter<any>();
* await render(AppComponent, {
* componentOutputs: {
* send: {
Expand All @@ -220,6 +228,24 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
* })
*/
componentOutputs?: Partial<ComponentType>;

/**
* @description
* An object with callbacks to subscribe to EventEmitters/Observables of the component
*
* @default
* {}
*
* @example
* const sendValue = (value) => { ... }
* await render(AppComponent, {
* on: {
* send: (_v:any) => void
* }
* })
*/
on?: OutputRefKeysWithCallback<ComponentType>;

/**
* @description
* A collection of providers to inject dependencies of the component.
Expand Down Expand Up @@ -379,7 +405,7 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
* @description
* Set the defer blocks behavior.
*/
deferBlockBehavior?: DeferBlockBehavior
deferBlockBehavior?: DeferBlockBehavior;
}

export interface ComponentOverride<T> {
Expand Down
146 changes: 99 additions & 47 deletions projects/testing-library/src/lib/testing-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
isStandalone,
NgZone,
OnChanges,
OutputRef,
OutputRefSubscription,
SimpleChange,
SimpleChanges,
Type,
Expand All @@ -25,9 +27,17 @@ import {
waitForOptions as dtlWaitForOptions,
within as dtlWithin,
} from '@testing-library/dom';
import { ComponentOverride, RenderComponentOptions, RenderResult, RenderTemplateOptions } from './models';
import {
ComponentOverride,
RenderComponentOptions,
RenderResult,
RenderTemplateOptions,
OutputRefKeysWithCallback,
} from './models';
import { getConfig } from './config';

type SubscribedOutput<T> = readonly [key: keyof T, callback: (v: any) => void, subscription: OutputRefSubscription];

const mountedFixtures = new Set<ComponentFixture<any>>();
const safeInject = TestBed.inject || TestBed.get;

Expand Down Expand Up @@ -57,6 +67,7 @@ export async function render<SutType, WrapperType = SutType>(
componentProperties = {},
componentInputs = {},
componentOutputs = {},
on = {},
componentProviders = [],
childComponentOverrides = [],
componentImports: componentImports,
Expand Down Expand Up @@ -165,7 +176,55 @@ export async function render<SutType, WrapperType = SutType>(

let detectChanges: () => void;

const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs);
let renderedPropKeys = Object.keys(componentProperties);
let renderedInputKeys = Object.keys(componentInputs);
let renderedOutputKeys = Object.keys(componentOutputs);
let subscribedOutputs: SubscribedOutput<SutType>[] = [];

const renderFixture = async (
properties: Partial<SutType>,
inputs: Partial<SutType>,
outputs: Partial<SutType>,
subscribeTo: OutputRefKeysWithCallback<SutType>,
): Promise<ComponentFixture<SutType>> => {
const createdFixture: ComponentFixture<SutType> = await createComponent(componentContainer);
setComponentProperties(createdFixture, properties);
setComponentInputs(createdFixture, inputs);
setComponentOutputs(createdFixture, outputs);
subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo);

if (removeAngularAttributes) {
createdFixture.nativeElement.removeAttribute('ng-version');
const idAttribute = createdFixture.nativeElement.getAttribute('id');
if (idAttribute && idAttribute.startsWith('root')) {
createdFixture.nativeElement.removeAttribute('id');
}
}

mountedFixtures.add(createdFixture);

let isAlive = true;
createdFixture.componentRef.onDestroy(() => (isAlive = false));

if (hasOnChangesHook(createdFixture.componentInstance) && Object.keys(properties).length > 0) {
const changes = getChangesObj(null, componentProperties);
createdFixture.componentInstance.ngOnChanges(changes);
}

detectChanges = () => {
if (isAlive) {
createdFixture.detectChanges();
}
};

if (detectChangesOnRender) {
detectChanges();
}

return createdFixture;
};

const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs, on);

if (deferBlockStates) {
if (Array.isArray(deferBlockStates)) {
Expand All @@ -177,13 +236,10 @@ export async function render<SutType, WrapperType = SutType>(
}
}

let renderedPropKeys = Object.keys(componentProperties);
let renderedInputKeys = Object.keys(componentInputs);
let renderedOutputKeys = Object.keys(componentOutputs);
const rerender = async (
properties?: Pick<
RenderTemplateOptions<SutType>,
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'detectChangesOnRender'
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender'
> & { partialUpdate?: boolean },
) => {
const newComponentInputs = properties?.componentInputs ?? {};
Expand All @@ -205,6 +261,22 @@ export async function render<SutType, WrapperType = SutType>(
setComponentOutputs(fixture, newComponentOutputs);
renderedOutputKeys = Object.keys(newComponentOutputs);

// first unsubscribe the no longer available or changed callback-fns
const newObservableSubscriptions: OutputRefKeysWithCallback<SutType> = properties?.on ?? {};
for (const [key, cb, subscription] of subscribedOutputs) {
// when no longer provided or when the callback has changed
if (!(key in newObservableSubscriptions) || cb !== (newObservableSubscriptions as any)[key]) {
subscription.unsubscribe();
}
}
// then subscribe the new callback-fns
subscribedOutputs = Object.entries(newObservableSubscriptions).map(([key, cb]) => {
const existing = subscribedOutputs.find(([k]) => k === key);
return existing && existing[1] === cb
? existing // nothing to do
: subscribeToComponentOutput(fixture, key as keyof SutType, cb as (v: any) => void);
});

const newComponentProps = properties?.componentProperties ?? {};
const changesInComponentProps = update(
fixture,
Expand Down Expand Up @@ -249,47 +321,6 @@ export async function render<SutType, WrapperType = SutType>(
: console.log(dtlPrettyDOM(element, maxLength, options)),
...replaceFindWithFindAndDetectChanges(dtlGetQueriesForElement(fixture.nativeElement, queries)),
};

async function renderFixture(
properties: Partial<SutType>,
inputs: Partial<SutType>,
outputs: Partial<SutType>,
): Promise<ComponentFixture<SutType>> {
const createdFixture = await createComponent(componentContainer);
setComponentProperties(createdFixture, properties);
setComponentInputs(createdFixture, inputs);
setComponentOutputs(createdFixture, outputs);

if (removeAngularAttributes) {
createdFixture.nativeElement.removeAttribute('ng-version');
const idAttribute = createdFixture.nativeElement.getAttribute('id');
if (idAttribute && idAttribute.startsWith('root')) {
createdFixture.nativeElement.removeAttribute('id');
}
}

mountedFixtures.add(createdFixture);

let isAlive = true;
createdFixture.componentRef.onDestroy(() => (isAlive = false));

if (hasOnChangesHook(createdFixture.componentInstance) && Object.keys(properties).length > 0) {
const changes = getChangesObj(null, componentProperties);
createdFixture.componentInstance.ngOnChanges(changes);
}

detectChanges = () => {
if (isAlive) {
createdFixture.detectChanges();
}
};

if (detectChangesOnRender) {
detectChanges();
}

return createdFixture;
}
}

async function createComponent<SutType>(component: Type<SutType>): Promise<ComponentFixture<SutType>> {
Expand Down Expand Up @@ -355,6 +386,27 @@ function setComponentInputs<SutType>(
}
}

function subscribeToComponentOutputs<SutType>(
fixture: ComponentFixture<SutType>,
listeners: OutputRefKeysWithCallback<SutType>,
): SubscribedOutput<SutType>[] {
// with Object.entries we lose the type information of the key and callback, therefore we need to cast them
return Object.entries(listeners).map(([key, cb]) =>
subscribeToComponentOutput(fixture, key as keyof SutType, cb as (v: any) => void),
);
}

function subscribeToComponentOutput<SutType>(
fixture: ComponentFixture<SutType>,
key: keyof SutType,
cb: (val: any) => void,
): SubscribedOutput<SutType> {
const eventEmitter = (fixture.componentInstance as any)[key] as OutputRef<any>;
const subscription = eventEmitter.subscribe(cb);
fixture.componentRef.onDestroy(subscription.unsubscribe.bind(subscription));
return [key, cb, subscription];
}

function overrideComponentImports<SutType>(sut: Type<SutType> | string, imports: (Type<any> | any[])[] | undefined) {
if (imports) {
if (typeof sut === 'function' && isStandalone(sut)) {
Expand Down
Loading

0 comments on commit caad0c2

Please sign in to comment.