Skip to content

Commit bc3010f

Browse files
jebnerJasmin Ebner
andauthored
Improve signal handling (#262)
* feat: support signals with transform * feat: provide helper method to set a single input value * feat: support output signals * refactor: prettify files * refactor: cleanup after review --------- Co-authored-by: Jasmin Ebner <jasmin.ebner@kwsoft.ch>
1 parent 87011e4 commit bc3010f

File tree

8 files changed

+79
-32
lines changed

8 files changed

+79
-32
lines changed

lib/examples/component-with-bindings.spec.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component, EventEmitter, input, Input, NgModule, OnChanges, Output } from '@angular/core';
1+
import { Component, EventEmitter, input, Input, NgModule, OnChanges, output, Output } from '@angular/core';
22
import { Shallow } from '../shallow';
33

44
////// Module Setup //////
@@ -12,19 +12,26 @@ interface Person {
1212
standalone: false,
1313
selector: 'born-in',
1414
template: `
15-
<label id="personLabel" (click)="selected.emit(person)">
15+
<label id="personLabel" (click)="selectPerson.emit(person)">
1616
{{ person.firstName }} {{ person.lastName }} was born in {{ person.birthDate.getFullYear() }}
1717
</label>
18-
<label id="partnerLabel" (click)="selected.emit(partner())">
18+
<label id="partnerLabel" (click)="selectPartner.emit(partner())">
1919
{{ partner().firstName }} {{ partner().lastName }} was born in {{ partner().birthDate.getFullYear() }}
2020
</label>
21+
<div id="personAge">
22+
{{ age() }}
23+
</div>
2124
<label id="ngOnChangesCount">{{ ngOnChangesCount }}</label>
2225
`,
2326
})
2427
class BornInComponent implements OnChanges {
2528
@Input({ required: true }) person!: Person;
2629
partner = input.required<Person>();
27-
@Output() selected = new EventEmitter<Person>();
30+
age = input<string, number>('Age not provided', {
31+
transform: (value: number) => `${value} years old`,
32+
});
33+
@Output() selectPerson = new EventEmitter<Person>();
34+
selectPartner = output<Person>();
2835

2936
public ngOnChangesCount = 0;
3037

@@ -59,24 +66,39 @@ describe('component with bindings', () => {
5966
};
6067

6168
it('displays the name and year the person was born', async () => {
62-
const { find } = await shallow.render({ bind: { person, partner } });
69+
const { find } = await shallow.render({ bind: { person, partner, age: 17 } });
6370

6471
expect(find('#personLabel').nativeElement.textContent).toContain('Brandon Domingue was born in 1982');
6572
expect(find('#partnerLabel').nativeElement.textContent).toContain('John Doe was born in 1990');
73+
expect(find('#personAge').nativeElement.textContent).toContain('17 years old');
6674
});
6775

6876
it('emits the person when clicked', async () => {
6977
const { find, outputs } = await shallow.render({ bind: { person, partner } });
7078
find('#personLabel').nativeElement.click();
7179

72-
expect(outputs.selected.emit).toHaveBeenCalledWith(person);
80+
expect(outputs.selectPerson.emit).toHaveBeenCalledWith(person);
7381
});
7482

7583
it('emits the partner when clicked', async () => {
7684
const { find, outputs } = await shallow.render({ bind: { person, partner } });
7785
find('#partnerLabel').nativeElement.click();
7886

79-
expect(outputs.selected.emit).toHaveBeenCalledWith(partner);
87+
expect(outputs.selectPartner.emit).toHaveBeenCalledWith(partner);
88+
});
89+
90+
it('updates the age considering the transform function', async () => {
91+
const { find, fixture, bindings } = await shallow.render({ bind: { person: person, partner: partner, age: 7 } });
92+
93+
// way 1: Update using the bindings
94+
bindings.age = 8;
95+
fixture.detectChanges();
96+
expect(find('#personAge').nativeElement.textContent).toContain('8 years old');
97+
98+
// way 2: Update using `updateBinding` function, similar as it's done with `ComponentRef.updateBinding`
99+
fixture.componentInstance.updateBinding('age', 9);
100+
fixture.detectChanges();
101+
expect(find('#personAge').nativeElement.textContent).toContain('9 years old');
80102
});
81103

82104
it('displays the number of times the person was updated', async () => {

lib/models/recursive-partial.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
* Utility type that converts an object to recursively have optional properties.
33
* This includes items an arrays and return values for functions.
44
*/
5-
import { InputSignal } from '@angular/core';
5+
import { InputSignal, InputSignalWithTransform } from '@angular/core';
66

77
export type RecursivePartial<T> = Partial<{
8-
[key in keyof T]: T[key] extends InputSignal<infer U> // Handle signals like input() and input.required()
8+
[key in keyof T]: T[key] extends InputSignal<infer U> | InputSignalWithTransform<any, infer U> // Handle signals like input() and input.required()
99
? RecursivePartial<U> // Extract the type and recursively apply RecursivePartial
1010
: T[key] extends (...a: Array<infer U>) => any // Function-based properties (like methods or other signals)
1111
? (...a: Array<U>) => RecursivePartial<ReturnType<T[key]>> | ReturnType<T[key]>

lib/models/renderer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Directive, EventEmitter, Type } from '@angular/core';
1+
import { Directive, EventEmitter, OutputEmitterRef, Type } from '@angular/core';
22
import { ComponentFixture, TestBed } from '@angular/core/testing';
33
import { By } from '@angular/platform-browser';
44
import { testFramework } from '../test-frameworks/test-framework';
@@ -145,7 +145,7 @@ export class Renderer<TComponent extends object> {
145145
const outputs = reflect.getInputsAndOutputs(this._setup.testComponentOrService).outputs;
146146
outputs.forEach(({ propertyName }) => {
147147
const value = (instance as any)[propertyName];
148-
if (value && value instanceof EventEmitter) {
148+
if (value && (value instanceof EventEmitter || value instanceof OutputEmitterRef)) {
149149
testFramework.spyOn(value, 'emit');
150150
}
151151
});

lib/models/rendering.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { DebugElement, EventEmitter, Type, InjectionToken, AbstractType } from '@angular/core';
1+
import { DebugElement, Type, InjectionToken, AbstractType } from '@angular/core';
22
import { ComponentFixture, TestBed } from '@angular/core/testing';
33
import { By } from '@angular/platform-browser';
4-
import { outputProxy, PickByType } from '../tools/output-proxy';
4+
import { outputProxy, OutputTypes, PickByType } from '../tools/output-proxy';
55
import { createQueryMatch, QueryMatch } from './query-match';
66
import { TestSetup } from './test-setup';
77
import { MockDirective } from '../tools/mock-directive';
@@ -44,7 +44,7 @@ export class Rendering<TComponent extends object, TBindings> {
4444
private readonly _setup: TestSetup<TComponent>,
4545
) {}
4646

47-
readonly outputs: PickByType<TComponent, EventEmitter<any>> = outputProxy(this.instance);
47+
readonly outputs: PickByType<TComponent, OutputTypes> = outputProxy(this.instance);
4848

4949
/////////////////////////////////////////////////////////////////////////////
5050
// The following methods MUST be arrow functions so they can be deconstructured

lib/tools/create-container.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,14 @@ describe('createContainerComponent', () => {
2424
container.foo();
2525
expect(container.foo).toHaveBeenCalled();
2626
});
27+
28+
it('provides method to set the input', () => {
29+
const bindings: any = { foo: 'foo', bar: 'bar' };
30+
const Container = createContainer('', bindings);
31+
const container: any = new Container();
32+
33+
container.updateBinding('foo', 'fooo');
34+
35+
expect(container.foo).toBe('fooo');
36+
});
2737
});

lib/tools/create-container.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,21 @@ export function createContainer(
1212
// eslint-disable-next-line @angular-eslint/prefer-standalone
1313
@Component({ template, imports, standalone })
1414
class ProxyShallowContainerComponent extends ShallowRenderContainer {
15+
private bindings: any;
1516
constructor() {
1617
super();
17-
const spies = spyOnBindings(bindings);
18+
this.bindings = spyOnBindings(bindings);
1819
Object.defineProperties(
1920
ProxyShallowContainerComponent.prototype,
20-
Object.keys(spies).reduce((acc, key) => {
21-
return { ...acc, [key]: { get: () => spies[key] } };
21+
Object.keys(this.bindings).reduce((acc, key) => {
22+
return { ...acc, [key]: { get: () => this.bindings[key] } };
2223
}, {}),
2324
);
2425
}
26+
27+
updateBinding(name: string, value: any): void {
28+
this.bindings[name] = value;
29+
}
2530
}
2631

2732
return ProxyShallowContainerComponent;

lib/tools/output-proxy.spec.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,34 @@
1-
import { Component, EventEmitter, Output } from '@angular/core';
1+
import { Component, EventEmitter, output, Output } from '@angular/core';
22
import {
33
outputProxy,
4+
OutputTypes,
45
PickByType,
5-
PropertyNotAnEventEmitterError,
6+
PropertyNotAnEventEmitterOrSignalOutputError,
67
PropertyNotMarkedAsOutputError,
78
} from './output-proxy';
9+
import { TestBed } from '@angular/core/testing';
810

911
describe('outputProxy', () => {
1012
@Component({
1113
standalone: false,
1214
selector: 'Foo',
13-
template: '<h1/>',
15+
template: '<h1>Foo</h1>',
1416
})
1517
class FooComponent {
1618
@Output() normalOutput = new EventEmitter<string>();
1719
@Output() notAnEventEmitter = 'foo';
1820
@Output('renamed') renamedOutput = new EventEmitter<string>();
21+
signalOutput = output<string>();
1922
notMarkedAsOutput = new EventEmitter<string>();
2023
}
2124
let component: FooComponent;
22-
let outputs: PickByType<FooComponent, EventEmitter<any>>;
25+
let outputs: PickByType<FooComponent, OutputTypes>;
2326

24-
beforeEach(() => {
25-
component = new FooComponent();
27+
beforeEach(async () => {
28+
await TestBed.configureTestingModule({}).compileComponents();
29+
30+
const fixture = TestBed.createComponent(FooComponent);
31+
component = fixture.componentInstance;
2632
outputs = outputProxy(component);
2733
});
2834

@@ -34,12 +40,16 @@ describe('outputProxy', () => {
3440
expect(outputs.renamedOutput).toBe(component.renamedOutput);
3541
});
3642

43+
it('works with signal outputs', () => {
44+
expect(outputs.signalOutput).toBe(component.signalOutput);
45+
});
46+
3747
it('throws an error if the property is not an EventEmitter', () => {
3848
try {
3949
String((outputs as any).notAnEventEmitter);
4050
fail('should have thrown an error');
4151
} catch (e) {
42-
expect(e).toBeInstanceOf(PropertyNotAnEventEmitterError);
52+
expect(e).toBeInstanceOf(PropertyNotAnEventEmitterOrSignalOutputError);
4353
}
4454
});
4555

lib/tools/output-proxy.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { EventEmitter } from '@angular/core';
1+
import { EventEmitter, OutputEmitterRef } from '@angular/core';
22
import { CustomError } from '../models/custom-error';
33
import { reflect } from './reflect';
44

@@ -10,6 +10,8 @@ export type KeysOfType<TObject, TPropertyType> = {
1010

1111
export type PickByType<TObject, TPropertyType> = Pick<TObject, KeysOfType<TObject, TPropertyType>>;
1212

13+
export type OutputTypes = EventEmitter<any> | OutputEmitterRef<any>;
14+
1315
export class PropertyNotMarkedAsOutputError extends CustomError {
1416
constructor(key: string | symbol | number, component: any) {
1517
super(
@@ -19,19 +21,17 @@ export class PropertyNotMarkedAsOutputError extends CustomError {
1921
}
2022
}
2123

22-
export class PropertyNotAnEventEmitterError extends CustomError {
24+
export class PropertyNotAnEventEmitterOrSignalOutputError extends CustomError {
2325
constructor(key: string | symbol | number, component: any) {
2426
super(
25-
`${String(key)} is not an instance of an EventEmitter. ` +
27+
`${String(key)} is not an instance of an EventEmitter or a Signal Output. ` +
2628
`Check that it is properly defined and set on the ${className(component)} class`,
2729
);
2830
}
2931
}
3032

3133
// eslint-disable-next-line @typescript-eslint/ban-types
32-
export const outputProxy = <TComponent extends Object>(
33-
component: TComponent,
34-
): PickByType<TComponent, EventEmitter<any>> => {
34+
export const outputProxy = <TComponent extends Object>(component: TComponent): PickByType<TComponent, OutputTypes> => {
3535
const outputs = reflect.getInputsAndOutputs(component.constructor).outputs.map(o => o.propertyName);
3636

3737
return new Proxy(
@@ -42,8 +42,8 @@ export const outputProxy = <TComponent extends Object>(
4242
throw new PropertyNotMarkedAsOutputError(key, component);
4343
}
4444
const maybeOutput = (component as any)[key];
45-
if (!(maybeOutput instanceof EventEmitter)) {
46-
throw new PropertyNotAnEventEmitterError(key, component);
45+
if (!(maybeOutput instanceof EventEmitter) && !(maybeOutput instanceof OutputEmitterRef)) {
46+
throw new PropertyNotAnEventEmitterOrSignalOutputError(key, component);
4747
}
4848

4949
return maybeOutput;

0 commit comments

Comments
 (0)