Skip to content

Commit caad0c2

Browse files
authored
feat: introduce on as a improvement to subscribe to outputs (#465)
Closes #462
1 parent 0652a14 commit caad0c2

File tree

3 files changed

+261
-55
lines changed

3 files changed

+261
-55
lines changed

projects/testing-library/src/lib/models.ts

+32-6
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
import { Type, DebugElement } from '@angular/core';
2-
import {ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed} from '@angular/core/testing';
1+
import { Type, DebugElement, OutputRef, EventEmitter } from '@angular/core';
2+
import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing';
33
import { Routes } from '@angular/router';
44
import { BoundFunction, Queries, queries, Config as dtlConfig, PrettyDOMOptions } from '@testing-library/dom';
55

6+
export type OutputRefKeysWithCallback<T> = {
7+
[key in keyof T]?: T[key] extends EventEmitter<infer U>
8+
? (val: U) => void
9+
: T[key] extends OutputRef<infer U>
10+
? (val: U) => void
11+
: never;
12+
};
13+
614
export type RenderResultQueries<Q extends Queries = typeof queries> = { [P in keyof Q]: BoundFunction<Q[P]> };
715
export interface RenderResult<ComponentType, WrapperType = ComponentType> extends RenderResultQueries {
816
/**
@@ -60,7 +68,7 @@ export interface RenderResult<ComponentType, WrapperType = ComponentType> extend
6068
rerender: (
6169
properties?: Pick<
6270
RenderTemplateOptions<ComponentType>,
63-
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'detectChangesOnRender'
71+
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender'
6472
> & { partialUpdate?: boolean },
6573
) => Promise<void>;
6674
/**
@@ -205,12 +213,12 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
205213
/**
206214
* @description
207215
* An object to set `@Output` properties of the component
208-
*
216+
* @deprecated use the `on` option instead. When it is necessary to override properties, use the `componentProperties` option.
209217
* @default
210218
* {}
211219
*
212220
* @example
213-
* const sendValue = (value) => { ... }
221+
* const sendValue = new EventEmitter<any>();
214222
* await render(AppComponent, {
215223
* componentOutputs: {
216224
* send: {
@@ -220,6 +228,24 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
220228
* })
221229
*/
222230
componentOutputs?: Partial<ComponentType>;
231+
232+
/**
233+
* @description
234+
* An object with callbacks to subscribe to EventEmitters/Observables of the component
235+
*
236+
* @default
237+
* {}
238+
*
239+
* @example
240+
* const sendValue = (value) => { ... }
241+
* await render(AppComponent, {
242+
* on: {
243+
* send: (_v:any) => void
244+
* }
245+
* })
246+
*/
247+
on?: OutputRefKeysWithCallback<ComponentType>;
248+
223249
/**
224250
* @description
225251
* A collection of providers to inject dependencies of the component.
@@ -379,7 +405,7 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
379405
* @description
380406
* Set the defer blocks behavior.
381407
*/
382-
deferBlockBehavior?: DeferBlockBehavior
408+
deferBlockBehavior?: DeferBlockBehavior;
383409
}
384410

385411
export interface ComponentOverride<T> {

projects/testing-library/src/lib/testing-library.ts

+99-47
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
isStandalone,
66
NgZone,
77
OnChanges,
8+
OutputRef,
9+
OutputRefSubscription,
810
SimpleChange,
911
SimpleChanges,
1012
Type,
@@ -25,9 +27,17 @@ import {
2527
waitForOptions as dtlWaitForOptions,
2628
within as dtlWithin,
2729
} from '@testing-library/dom';
28-
import { ComponentOverride, RenderComponentOptions, RenderResult, RenderTemplateOptions } from './models';
30+
import {
31+
ComponentOverride,
32+
RenderComponentOptions,
33+
RenderResult,
34+
RenderTemplateOptions,
35+
OutputRefKeysWithCallback,
36+
} from './models';
2937
import { getConfig } from './config';
3038

39+
type SubscribedOutput<T> = readonly [key: keyof T, callback: (v: any) => void, subscription: OutputRefSubscription];
40+
3141
const mountedFixtures = new Set<ComponentFixture<any>>();
3242
const safeInject = TestBed.inject || TestBed.get;
3343

@@ -57,6 +67,7 @@ export async function render<SutType, WrapperType = SutType>(
5767
componentProperties = {},
5868
componentInputs = {},
5969
componentOutputs = {},
70+
on = {},
6071
componentProviders = [],
6172
childComponentOverrides = [],
6273
componentImports: componentImports,
@@ -165,7 +176,55 @@ export async function render<SutType, WrapperType = SutType>(
165176

166177
let detectChanges: () => void;
167178

168-
const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs);
179+
let renderedPropKeys = Object.keys(componentProperties);
180+
let renderedInputKeys = Object.keys(componentInputs);
181+
let renderedOutputKeys = Object.keys(componentOutputs);
182+
let subscribedOutputs: SubscribedOutput<SutType>[] = [];
183+
184+
const renderFixture = async (
185+
properties: Partial<SutType>,
186+
inputs: Partial<SutType>,
187+
outputs: Partial<SutType>,
188+
subscribeTo: OutputRefKeysWithCallback<SutType>,
189+
): Promise<ComponentFixture<SutType>> => {
190+
const createdFixture: ComponentFixture<SutType> = await createComponent(componentContainer);
191+
setComponentProperties(createdFixture, properties);
192+
setComponentInputs(createdFixture, inputs);
193+
setComponentOutputs(createdFixture, outputs);
194+
subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo);
195+
196+
if (removeAngularAttributes) {
197+
createdFixture.nativeElement.removeAttribute('ng-version');
198+
const idAttribute = createdFixture.nativeElement.getAttribute('id');
199+
if (idAttribute && idAttribute.startsWith('root')) {
200+
createdFixture.nativeElement.removeAttribute('id');
201+
}
202+
}
203+
204+
mountedFixtures.add(createdFixture);
205+
206+
let isAlive = true;
207+
createdFixture.componentRef.onDestroy(() => (isAlive = false));
208+
209+
if (hasOnChangesHook(createdFixture.componentInstance) && Object.keys(properties).length > 0) {
210+
const changes = getChangesObj(null, componentProperties);
211+
createdFixture.componentInstance.ngOnChanges(changes);
212+
}
213+
214+
detectChanges = () => {
215+
if (isAlive) {
216+
createdFixture.detectChanges();
217+
}
218+
};
219+
220+
if (detectChangesOnRender) {
221+
detectChanges();
222+
}
223+
224+
return createdFixture;
225+
};
226+
227+
const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs, on);
169228

170229
if (deferBlockStates) {
171230
if (Array.isArray(deferBlockStates)) {
@@ -177,13 +236,10 @@ export async function render<SutType, WrapperType = SutType>(
177236
}
178237
}
179238

180-
let renderedPropKeys = Object.keys(componentProperties);
181-
let renderedInputKeys = Object.keys(componentInputs);
182-
let renderedOutputKeys = Object.keys(componentOutputs);
183239
const rerender = async (
184240
properties?: Pick<
185241
RenderTemplateOptions<SutType>,
186-
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'detectChangesOnRender'
242+
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender'
187243
> & { partialUpdate?: boolean },
188244
) => {
189245
const newComponentInputs = properties?.componentInputs ?? {};
@@ -205,6 +261,22 @@ export async function render<SutType, WrapperType = SutType>(
205261
setComponentOutputs(fixture, newComponentOutputs);
206262
renderedOutputKeys = Object.keys(newComponentOutputs);
207263

264+
// first unsubscribe the no longer available or changed callback-fns
265+
const newObservableSubscriptions: OutputRefKeysWithCallback<SutType> = properties?.on ?? {};
266+
for (const [key, cb, subscription] of subscribedOutputs) {
267+
// when no longer provided or when the callback has changed
268+
if (!(key in newObservableSubscriptions) || cb !== (newObservableSubscriptions as any)[key]) {
269+
subscription.unsubscribe();
270+
}
271+
}
272+
// then subscribe the new callback-fns
273+
subscribedOutputs = Object.entries(newObservableSubscriptions).map(([key, cb]) => {
274+
const existing = subscribedOutputs.find(([k]) => k === key);
275+
return existing && existing[1] === cb
276+
? existing // nothing to do
277+
: subscribeToComponentOutput(fixture, key as keyof SutType, cb as (v: any) => void);
278+
});
279+
208280
const newComponentProps = properties?.componentProperties ?? {};
209281
const changesInComponentProps = update(
210282
fixture,
@@ -249,47 +321,6 @@ export async function render<SutType, WrapperType = SutType>(
249321
: console.log(dtlPrettyDOM(element, maxLength, options)),
250322
...replaceFindWithFindAndDetectChanges(dtlGetQueriesForElement(fixture.nativeElement, queries)),
251323
};
252-
253-
async function renderFixture(
254-
properties: Partial<SutType>,
255-
inputs: Partial<SutType>,
256-
outputs: Partial<SutType>,
257-
): Promise<ComponentFixture<SutType>> {
258-
const createdFixture = await createComponent(componentContainer);
259-
setComponentProperties(createdFixture, properties);
260-
setComponentInputs(createdFixture, inputs);
261-
setComponentOutputs(createdFixture, outputs);
262-
263-
if (removeAngularAttributes) {
264-
createdFixture.nativeElement.removeAttribute('ng-version');
265-
const idAttribute = createdFixture.nativeElement.getAttribute('id');
266-
if (idAttribute && idAttribute.startsWith('root')) {
267-
createdFixture.nativeElement.removeAttribute('id');
268-
}
269-
}
270-
271-
mountedFixtures.add(createdFixture);
272-
273-
let isAlive = true;
274-
createdFixture.componentRef.onDestroy(() => (isAlive = false));
275-
276-
if (hasOnChangesHook(createdFixture.componentInstance) && Object.keys(properties).length > 0) {
277-
const changes = getChangesObj(null, componentProperties);
278-
createdFixture.componentInstance.ngOnChanges(changes);
279-
}
280-
281-
detectChanges = () => {
282-
if (isAlive) {
283-
createdFixture.detectChanges();
284-
}
285-
};
286-
287-
if (detectChangesOnRender) {
288-
detectChanges();
289-
}
290-
291-
return createdFixture;
292-
}
293324
}
294325

295326
async function createComponent<SutType>(component: Type<SutType>): Promise<ComponentFixture<SutType>> {
@@ -355,6 +386,27 @@ function setComponentInputs<SutType>(
355386
}
356387
}
357388

389+
function subscribeToComponentOutputs<SutType>(
390+
fixture: ComponentFixture<SutType>,
391+
listeners: OutputRefKeysWithCallback<SutType>,
392+
): SubscribedOutput<SutType>[] {
393+
// with Object.entries we lose the type information of the key and callback, therefore we need to cast them
394+
return Object.entries(listeners).map(([key, cb]) =>
395+
subscribeToComponentOutput(fixture, key as keyof SutType, cb as (v: any) => void),
396+
);
397+
}
398+
399+
function subscribeToComponentOutput<SutType>(
400+
fixture: ComponentFixture<SutType>,
401+
key: keyof SutType,
402+
cb: (val: any) => void,
403+
): SubscribedOutput<SutType> {
404+
const eventEmitter = (fixture.componentInstance as any)[key] as OutputRef<any>;
405+
const subscription = eventEmitter.subscribe(cb);
406+
fixture.componentRef.onDestroy(subscription.unsubscribe.bind(subscription));
407+
return [key, cb, subscription];
408+
}
409+
358410
function overrideComponentImports<SutType>(sut: Type<SutType> | string, imports: (Type<any> | any[])[] | undefined) {
359411
if (imports) {
360412
if (typeof sut === 'function' && isStandalone(sut)) {

0 commit comments

Comments
 (0)