Skip to content

Commit

Permalink
feat: add strongly typed inputs (#473)
Browse files Browse the repository at this point in the history
Closes #464
Closes #474
  • Loading branch information
andreialecu authored Aug 3, 2024
1 parent 40fe4ea commit 3095737
Show file tree
Hide file tree
Showing 8 changed files with 212 additions and 44 deletions.
33 changes: 21 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,44 +100,53 @@ counter.component.ts
@Component({
selector: 'app-counter',
template: `
<span>{{ hello() }}</span>
<button (click)="decrement()">-</button>
<span>Current Count: {{ counter }}</span>
<span>Current Count: {{ counter() }}</span>
<button (click)="increment()">+</button>
`,
})
export class CounterComponent {
@Input() counter = 0;
counter = model(0);
hello = input('Hi', { alias: 'greeting' });

increment() {
this.counter += 1;
this.counter.set(this.counter() + 1);
}

decrement() {
this.counter -= 1;
this.counter.set(this.counter() - 1);
}
}
```

counter.component.spec.ts

```typescript
import { render, screen, fireEvent } from '@testing-library/angular';
import { render, screen, fireEvent, aliasedInput } from '@testing-library/angular';
import { CounterComponent } from './counter.component';

describe('Counter', () => {
test('should render counter', async () => {
await render(CounterComponent, { componentProperties: { counter: 5 } });

expect(screen.getByText('Current Count: 5'));
it('should render counter', async () => {
await render(CounterComponent, {
inputs: {
counter: 5,
// aliases need to be specified this way
...aliasedInput('greeting', 'Hello Alias!'),
},
});

expect(screen.getByText('Current Count: 5')).toBeVisible();
expect(screen.getByText('Hello Alias!')).toBeVisible();
});

test('should increment the counter on click', async () => {
await render(CounterComponent, { componentProperties: { counter: 5 } });
it('should increment the counter on click', async () => {
await render(CounterComponent, { inputs: { counter: 5 } });

const incrementButton = screen.getByRole('button', { name: '+' });
fireEvent.click(incrementButton);

expect(screen.getByText('Current Count: 6'));
expect(screen.getByText('Current Count: 6')).toBeVisible();
});
});
```
Expand Down
4 changes: 2 additions & 2 deletions apps/example-app/src/app/examples/02-input-output.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ test('is possible to set input and listen for output', async () => {
const sendValue = jest.fn();

await render(InputOutputComponent, {
componentInputs: {
inputs: {
value: 47,
},
on: {
Expand Down Expand Up @@ -64,7 +64,7 @@ test('is possible to set input and listen for output (deprecated)', async () =>
const sendValue = jest.fn();

await render(InputOutputComponent, {
componentInputs: {
inputs: {
value: 47,
},
componentOutputs: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { render, screen, within } from '@testing-library/angular';
import { aliasedInput, render, screen, within } from '@testing-library/angular';
import { SignalInputComponent } from './22-signal-inputs.component';
import userEvent from '@testing-library/user-event';

test('works with signal inputs', async () => {
await render(SignalInputComponent, {
componentInputs: {
greeting: 'Hello',
inputs: {
...aliasedInput('greeting', 'Hello'),
name: 'world',
},
});
Expand All @@ -16,8 +16,8 @@ test('works with signal inputs', async () => {

test('works with computed', async () => {
await render(SignalInputComponent, {
componentInputs: {
greeting: 'Hello',
inputs: {
...aliasedInput('greeting', 'Hello'),
name: 'world',
},
});
Expand All @@ -28,8 +28,8 @@ test('works with computed', async () => {

test('can update signal inputs', async () => {
const { fixture } = await render(SignalInputComponent, {
componentInputs: {
greeting: 'Hello',
inputs: {
...aliasedInput('greeting', 'Hello'),
name: 'world',
},
});
Expand All @@ -51,8 +51,8 @@ test('can update signal inputs', async () => {
test('output emits a value', async () => {
const submitFn = jest.fn();
await render(SignalInputComponent, {
componentInputs: {
greeting: 'Hello',
inputs: {
...aliasedInput('greeting', 'Hello'),
name: 'world',
},
on: {
Expand All @@ -67,8 +67,8 @@ test('output emits a value', async () => {

test('model update also updates the template', async () => {
const { fixture } = await render(SignalInputComponent, {
componentInputs: {
greeting: 'Hello',
inputs: {
...aliasedInput('greeting', 'Hello'),
name: 'initial',
},
});
Expand Down Expand Up @@ -97,8 +97,8 @@ test('model update also updates the template', async () => {

test('works with signal inputs, computed values, and rerenders', async () => {
const view = await render(SignalInputComponent, {
componentInputs: {
greeting: 'Hello',
inputs: {
...aliasedInput('greeting', 'Hello'),
name: 'world',
},
});
Expand All @@ -110,8 +110,8 @@ test('works with signal inputs, computed values, and rerenders', async () => {
expect(computedValue.getByText(/hello world/i)).toBeInTheDocument();

await view.rerender({
componentInputs: {
greeting: 'bye',
inputs: {
...aliasedInput('greeting', 'bye'),
name: 'test',
},
});
Expand Down
44 changes: 42 additions & 2 deletions projects/testing-library/src/lib/models.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Type, DebugElement, OutputRef, EventEmitter } from '@angular/core';
import { Type, DebugElement, OutputRef, EventEmitter, Signal } 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';
Expand Down Expand Up @@ -68,7 +68,7 @@ export interface RenderResult<ComponentType, WrapperType = ComponentType> extend
rerender: (
properties?: Pick<
RenderTemplateOptions<ComponentType>,
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender'
'componentProperties' | 'componentInputs' | 'inputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender'
> & { partialUpdate?: boolean },
) => Promise<void>;
/**
Expand All @@ -78,6 +78,27 @@ export interface RenderResult<ComponentType, WrapperType = ComponentType> extend
renderDeferBlock: (deferBlockState: DeferBlockState, deferBlockIndex?: number) => Promise<void>;
}

declare const ALIASED_INPUT_BRAND: unique symbol;
export type AliasedInput<T> = T & {
[ALIASED_INPUT_BRAND]: T;
};
export type AliasedInputs = Record<string, AliasedInput<unknown>>;

export type ComponentInput<T> =
| {
[P in keyof T]?: T[P] extends Signal<infer U> ? U : T[P];
}
| AliasedInputs;

/**
* @description
* Creates an aliased input branded type with a value
*
*/
export function aliasedInput<TAlias extends string, T>(alias: TAlias, value: T): Record<TAlias, AliasedInput<T>> {
return { [alias]: value } as Record<TAlias, AliasedInput<T>>;
}

export interface RenderComponentOptions<ComponentType, Q extends Queries = typeof queries> {
/**
* @description
Expand Down Expand Up @@ -199,6 +220,7 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
* @description
* An object to set `@Input` properties of the component
*
* @deprecated use the `inputs` option instead. When you need to use aliases, use the `aliasedInput(...)` helper function.
* @default
* {}
*
Expand All @@ -210,6 +232,24 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
* })
*/
componentInputs?: Partial<ComponentType> | { [alias: string]: unknown };

/**
* @description
* An object to set `@Input` or `input()` properties of the component
*
* @default
* {}
*
* @example
* await render(AppComponent, {
* inputs: {
* counterValue: 10,
* // explicitly define aliases this way:
* ...aliasedInput('someAlias', 'someValue')
* })
*/
inputs?: ComponentInput<ComponentType>;

/**
* @description
* An object to set `@Output` properties of the component
Expand Down
11 changes: 7 additions & 4 deletions projects/testing-library/src/lib/testing-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export async function render<SutType, WrapperType = SutType>(
componentProperties = {},
componentInputs = {},
componentOutputs = {},
inputs: newInputs = {},
on = {},
componentProviders = [],
childComponentOverrides = [],
Expand Down Expand Up @@ -176,8 +177,10 @@ export async function render<SutType, WrapperType = SutType>(

let detectChanges: () => void;

const allInputs = { ...componentInputs, ...newInputs };

let renderedPropKeys = Object.keys(componentProperties);
let renderedInputKeys = Object.keys(componentInputs);
let renderedInputKeys = Object.keys(allInputs);
let renderedOutputKeys = Object.keys(componentOutputs);
let subscribedOutputs: SubscribedOutput<SutType>[] = [];

Expand Down Expand Up @@ -224,7 +227,7 @@ export async function render<SutType, WrapperType = SutType>(
return createdFixture;
};

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

if (deferBlockStates) {
if (Array.isArray(deferBlockStates)) {
Expand All @@ -239,10 +242,10 @@ export async function render<SutType, WrapperType = SutType>(
const rerender = async (
properties?: Pick<
RenderTemplateOptions<SutType>,
'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender'
'componentProperties' | 'componentInputs' | 'inputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender'
> & { partialUpdate?: boolean },
) => {
const newComponentInputs = properties?.componentInputs ?? {};
const newComponentInputs = { ...properties?.componentInputs, ...properties?.inputs };
const changesInComponentInput = update(
fixture,
renderedInputKeys,
Expand Down
4 changes: 2 additions & 2 deletions projects/testing-library/tests/integrations/ng-mocks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { NgIf } from '@angular/common';
test('sends the correct value to the child input', async () => {
const utils = await render(TargetComponent, {
imports: [MockComponent(ChildComponent)],
componentInputs: { value: 'foo' },
inputs: { value: 'foo' },
});

const children = utils.fixture.debugElement.queryAll(By.directive(ChildComponent));
Expand All @@ -21,7 +21,7 @@ test('sends the correct value to the child input', async () => {
test('sends the correct value to the child input 2', async () => {
const utils = await render(TargetComponent, {
imports: [MockComponent(ChildComponent)],
componentInputs: { value: 'bar' },
inputs: { value: 'bar' },
});

const children = utils.fixture.debugElement.queryAll(By.directive(ChildComponent));
Expand Down
Loading

0 comments on commit 3095737

Please sign in to comment.