Skip to content

feat: add component.type function #37

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 24, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion projects/testing-library/src/lib/models.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Type } from '@angular/core';
import { ComponentFixture } from '@angular/core/testing';
import { FireObject, Queries, queries, BoundFunction } from '@testing-library/dom';
import { UserEvents } from './user-events';

export type RenderResultQueries<Q extends Queries = typeof queries> = { [P in keyof Q]: BoundFunction<Q[P]> };

export interface RenderResult extends RenderResultQueries, FireObject {
export interface RenderResult extends RenderResultQueries, FireObject, UserEvents {
container: HTMLElement;
debug: (element?: HTMLElement) => void;
fixture: ComponentFixture<any>;
Expand Down
2 changes: 2 additions & 0 deletions projects/testing-library/src/lib/testing-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { By } from '@angular/platform-browser';
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { getQueriesForElement, prettyDOM, fireEvent, FireObject, FireFunction } from '@testing-library/dom';
import { RenderResult, RenderOptions } from './models';
import { createType } from './user-events';

@Component({ selector: 'wrapper-component', template: '' })
class WrapperComponent implements OnInit {
Expand Down Expand Up @@ -84,6 +85,7 @@ export async function render<T>(
debug: (element = fixture.nativeElement) => console.log(prettyDOM(element)),
...getQueriesForElement(fixture.nativeElement, queries),
...eventsWithDetectChanges,
type: createType(eventsWithDetectChanges),
} as any;
}

Expand Down
10 changes: 10 additions & 0 deletions projects/testing-library/src/lib/user-events/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { fireEvent } from '@testing-library/dom';
import { createType } from './type';

export interface UserEvents {
type: ReturnType<typeof createType>;
}

const type = createType(fireEvent);

export { createType, type };
73 changes: 73 additions & 0 deletions projects/testing-library/src/lib/user-events/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { FireFunction, FireObject } from '@testing-library/dom';

function wait(time) {
return new Promise(function(resolve) {
setTimeout(() => resolve(), time);
});
}

// implementation from https://github.com/testing-library/user-event
export function createType(fireEvent: FireFunction & FireObject) {
function createFireChangeEvent(value: string) {
return function fireChangeEvent(event) {
if (value !== event.target.value) {
fireEvent.change(event.target);
}
event.target.removeEventListener('blur', fireChangeEvent);
};
}

return async function type(element: HTMLElement, value: string, { allAtOnce = false, delay = 0 } = {}) {
const initialValue = (element as HTMLInputElement).value;

if (allAtOnce) {
fireEvent.input(element, { target: { value } });
element.addEventListener('blur', createFireChangeEvent(initialValue));
return;
}

let actuallyTyped = '';
for (let index = 0; index < value.length; index++) {
const char = value[index];
const key = char;
const keyCode = char.charCodeAt(0);

if (delay > 0) {
await wait(delay);
}

const downEvent = fireEvent.keyDown(element, {
key: key,
keyCode: keyCode,
which: keyCode,
});

if (downEvent) {
const pressEvent = fireEvent.keyPress(element, {
key: key,
keyCode,
charCode: keyCode,
});

if (pressEvent) {
actuallyTyped += key;
fireEvent.input(element, {
target: {
value: actuallyTyped,
},
bubbles: true,
cancelable: true,
});
}
}

fireEvent.keyUp(element, {
key: key,
keyCode: keyCode,
which: keyCode,
});
}

element.addEventListener('blur', createFireChangeEvent(initialValue));
};
}
1 change: 1 addition & 0 deletions projects/testing-library/src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@

export * from './lib/models';
export * from './lib/testing-library';
export * from './lib/user-events';
export * from '@testing-library/dom';
221 changes: 221 additions & 0 deletions projects/testing-library/tests/user-events/type.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { ReactiveFormsModule, FormsModule, FormControl } from '@angular/forms';
import { render, RenderResult } from '../../src/public_api';
import { Component, ViewChild, Input } from '@angular/core';
import { fakeAsync, tick } from '@angular/core/testing';

describe('updates the value', () => {
test('with a template-driven form', async () => {
@Component({
selector: 'fixture',
template: `
<input type="text" [(ngModel)]="value" data-testid="input" />
<p data-testid="text">{{ value }}</p>
`,
})
class FixtureComponent {
value: string;
}

const component = await render(FixtureComponent, {
imports: [FormsModule],
});

assertType(component, () => component.fixture.componentInstance.value);
});

test('with a reactive form', async () => {
@Component({
selector: 'fixture',
template: `
<input type="text" [formControl]="value" data-testid="input" />
<p data-testid="text">{{ value.value }}</p>
`,
})
class FixtureComponent {
value = new FormControl('');
}

const component = await render(FixtureComponent, {
imports: [ReactiveFormsModule],
});

assertType(component, () => component.fixture.componentInstance.value.value);
});

test('with events', async () => {
@Component({
selector: 'fixture',
template: `
<input type="text" (input)="onInput($event)" data-testid="input" />
<p data-testid="text">{{ value }}</p>
`,
})
class FixtureComponent {
value = '';

onInput(event: KeyboardEvent) {
this.value = (<HTMLInputElement>event.target).value;
}
}

const component = await render(FixtureComponent);

assertType(component, () => component.fixture.componentInstance.value);
});

test('by reference', async () => {
@Component({
selector: 'fixture',
template: `
<input type="text" data-testid="input" #input />
<p data-testid="text">{{ input.value }}</p>
`,
})
class FixtureComponent {
@ViewChild('input', { static: false }) value;
}

const component = await render(FixtureComponent);

assertType(component, () => component.fixture.componentInstance.value.nativeElement.value);
});

function assertType(component: RenderResult, value: () => string) {
const input = '@testing-library/angular';
const inputControl = component.getByTestId('input') as HTMLInputElement;
component.type(inputControl, input);

expect(value()).toBe(input);
expect(component.getByTestId('text').textContent).toBe(input);
expect(inputControl.value).toBe(input);
expect(inputControl).toHaveProperty('value', input);
}
});

describe('options', () => {
@Component({
selector: 'fixture',
template: `
<input
type="text"
data-testid="input"
(input)="onInput($event)"
(change)="onChange($event)"
(keydown)="onKeyDown($event)"
(keypress)="onKeyPress($event)"
(keyup)="onKeyUp($event)"
/>
`,
})
class FixtureComponent {
onInput($event) {}
onChange($event) {}
onKeyDown($event) {}
onKeyPress($event) {}
onKeyUp($event) {}
}

async function setup() {
const componentProperties = {
onInput: jest.fn(),
onChange: jest.fn(),
onKeyDown: jest.fn(),
onKeyPress: jest.fn(),
onKeyUp: jest.fn(),
};
const component = await render(FixtureComponent, { componentProperties });

return { component, ...componentProperties };
}

describe('allAtOnce', () => {
test('false: updates the value one char at a time', async () => {
const { component, onInput, onChange, onKeyDown, onKeyPress, onKeyUp } = await setup();

const inputControl = component.getByTestId('input') as HTMLInputElement;
const inputValue = 'foobar';
component.type(inputControl, inputValue);

expect(onInput).toBeCalledTimes(inputValue.length);
expect(onKeyDown).toBeCalledTimes(inputValue.length);
expect(onKeyPress).toBeCalledTimes(inputValue.length);
expect(onKeyUp).toBeCalledTimes(inputValue.length);

component.blur(inputControl);
expect(onChange).toBeCalledTimes(1);
});

test('true: updates the value in one time and does not trigger other events', async () => {
const { component, onInput, onChange, onKeyDown, onKeyPress, onKeyUp } = await setup();

const inputControl = component.getByTestId('input') as HTMLInputElement;
const inputValue = 'foobar';
component.type(inputControl, inputValue, { allAtOnce: true });

expect(onInput).toBeCalledTimes(1);
expect(onKeyDown).toBeCalledTimes(0);
expect(onKeyPress).toBeCalledTimes(0);
expect(onKeyUp).toBeCalledTimes(0);

component.blur(inputControl);
expect(onChange).toBeCalledTimes(1);
});
});

describe('delay', () => {
test('delays the input', fakeAsync(async () => {
const { component } = await setup();

const inputControl = component.getByTestId('input') as HTMLInputElement;
const inputValue = 'foobar';
component.type(inputControl, inputValue, { delay: 25 });

[...inputValue].forEach((_, i) => {
expect(inputControl.value).toBe(inputValue.substr(0, i));
tick(25);
});
}));
});
});

test('should not type when event.preventDefault() is called', async () => {
@Component({
selector: 'fixture',
template: `
<input
type="text"
data-testid="input"
(input)="onInput($event)"
(change)="onChange($event)"
(keydown)="onKeyDown($event)"
(keypress)="onKeyPress($event)"
(keyup)="onKeyUp($event)"
/>
`,
})
class FixtureComponent {
onInput($event) {}
onChange($event) {}
onKeyDown($event) {}
onKeyPress($event) {}
onKeyUp($event) {}
}

const componentProperties = {
onChange: jest.fn(),
onKeyDown: jest.fn().mockImplementation(event => event.preventDefault()),
};

const component = await render(FixtureComponent, { componentProperties });

const inputControl = component.getByTestId('input') as HTMLInputElement;
const inputValue = 'foobar';
component.type(inputControl, inputValue);

expect(componentProperties.onKeyDown).toHaveBeenCalledTimes(inputValue.length);

component.blur(inputControl);
expect(componentProperties.onChange).toBeCalledTimes(0);

expect(inputControl.value).toBe('');
});
24 changes: 24 additions & 0 deletions src/app/__snapshots__/app.component.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,30 @@ exports[`matches snapshot 1`] = `
<button>
Greet
</button>
<form
class="ng-untouched ng-pristine ng-invalid"
ng-reflect-form="[object Object]"
novalidate=""
>
<label>
Name:
<input
class="ng-untouched ng-pristine ng-invalid"
formcontrolname="name"
ng-reflect-name="name"
type="text"
/>
</label>
<label>
Age:
<input
class="ng-untouched ng-pristine ng-valid"
formcontrolname="age"
ng-reflect-name="age"
type="number"
/>
</label>
</form>
</app-root>
</div>
`;
12 changes: 12 additions & 0 deletions src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,15 @@ <h2><a target="_blank" rel="noopener" href="https://blog.angular.io/">Angular bl
</ul>

<button (click)="greet()">Greet</button>

<form [formGroup]="form" (ngSubmit)="onSubmit()">
<label>
Name:
<input type="text" formControlName="name" />
</label>

<label>
Age:
<input type="number" formControlName="age" />
</label>
</form>
Loading