diff --git a/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.html b/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.html new file mode 100644 index 0000000..3560aaa --- /dev/null +++ b/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.html @@ -0,0 +1,5 @@ + diff --git a/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.spec.ts b/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.spec.ts new file mode 100644 index 0000000..67ee40f --- /dev/null +++ b/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.spec.ts @@ -0,0 +1,168 @@ +import { + ComponentFixture, + TestBed, + fakeAsync, + tick +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Component, Type } from '@angular/core'; +import { FormsModule, ReactiveFormsModule, FormControl } from '@angular/forms'; + +import { typeInElement, dispatchFakeEvent } from '../../../testing'; +import { NumberInputComponent } from './number-input.component'; + +@Component({ + template: ` + ` +}) +export class SimpleNumberInputComponent { + control = new FormControl(); +} + +describe('[Component]: NumberInputComponent', () => { + // Creates a test component fixture. + function createComponent(component: Type) { + TestBed.configureTestingModule({ + imports: [FormsModule, ReactiveFormsModule], + declarations: [NumberInputComponent, component] + }); + TestBed.compileComponents(); + + return TestBed.createComponent(component); + } + + describe('forms integration', () => { + let fixture: ComponentFixture; + let input: HTMLInputElement; + + beforeEach(() => { + fixture = createComponent(SimpleNumberInputComponent); + fixture.detectChanges(); + input = fixture.debugElement.query(By.css('input')).nativeElement; + }); + + it('should update control value as user types with input value', () => { + typeInElement('10', input); + fixture.detectChanges(); + + expect(fixture.componentInstance.control.value).toEqual( + 10, + `Expected control value to be updated as user types.` + ); + + typeInElement('100', input); + fixture.detectChanges(); + + expect(fixture.componentInstance.control.value).toEqual( + 100, + `Expected control value to be updated as user types.` + ); + }); + + it('should format input value on blur', fakeAsync(() => { + typeInElement('300', input); + dispatchFakeEvent(input, 'blur'); + fixture.detectChanges(); + tick(); + + expect(input.value).toEqual( + '300.00', + `Expected input value to be formatted on blur.` + ); + + typeInElement('3000', input); + dispatchFakeEvent(input, 'blur'); + fixture.detectChanges(); + tick(); + + expect(input.value).toEqual( + '3,000.00', + `Expected input value to be formatted on blur.` + ); + })); + + it('should fill input correctly if control value is set programatically', fakeAsync(() => { + fixture.componentInstance.control.setValue(100); + fixture.detectChanges(); + tick(); + + expect(input.value).toEqual( + '100.00', + `Expected input to fill with control current formatted value.` + ); + + fixture.componentInstance.control.setValue(1000); + fixture.detectChanges(); + tick(); + + expect(input.value).toEqual( + '1,000.00', + `Expected input to fill with control current formatted value.` + ); + })); + + it('should clear the input value if control value is reset programatically', fakeAsync(() => { + typeInElement('200', input); + fixture.detectChanges(); + tick(); + + fixture.componentInstance.control.reset(); + fixture.detectChanges(); + tick(); + + expect(input.value).toEqual( + '', + `Expected input value to be empty after control reset.` + ); + })); + + it('should mark the control as dirty as user types', () => { + expect(fixture.componentInstance.control.dirty).toBe( + false, + `Expected control to start out pristine.` + ); + + typeInElement('20', input); + fixture.detectChanges(); + + expect(fixture.componentInstance.control.dirty).toBe( + true, + `Expected control to become dirty when the user types into the input.` + ); + }); + + it('should not mark the control dirty when the value is set programmatically', () => { + expect(fixture.componentInstance.control.dirty).toBe( + false, + `Expected control to start out pristine.` + ); + + fixture.componentInstance.control.setValue('200'); + fixture.detectChanges(); + + expect(fixture.componentInstance.control.dirty).toBe( + false, + `Expected control to stay pristine if value is set programmatically.` + ); + }); + + it('should clear input value on blur if invalid input was provided', fakeAsync(() => { + typeInElement('invalid', input); + fixture.detectChanges(); + tick(); + + dispatchFakeEvent(input, 'blur'); + fixture.detectChanges(); + tick(); + + expect(input.value).toEqual( + '', + `Expected input value to be cleared on blur.` + ); + })); + }); +}); diff --git a/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.ts b/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.ts new file mode 100644 index 0000000..967f03a --- /dev/null +++ b/projects/ng-rocketparts/src/lib/components/number-input/number-input.component.ts @@ -0,0 +1,143 @@ +import { + Component, + EventEmitter, + forwardRef, + HostBinding, + Input, + OnInit, + Output, + Inject, + LOCALE_ID +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator +} from '@angular/forms'; +import { formatNumber } from '@angular/common'; + +export const NUMBER_INPUT_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => NumberInputComponent), // tslint:disable-line + multi: true +}; + +export const NUMBER_INPUT_VALIDATOR: any = { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => NumberInputComponent), // tslint:disable-line + multi: true +}; + +@Component({ + selector: 'ngr-number-input', + templateUrl: './number-input.component.html', + styles: [], + providers: [NUMBER_INPUT_VALUE_ACCESSOR, NUMBER_INPUT_VALIDATOR] +}) +export class NumberInputComponent + implements OnInit, ControlValueAccessor, Validator { + @HostBinding('class.ngr-number-input') + numberInputClass = true; + + @Input() + placeholder: string; + + @Input() + min: number; + + @Input() + max: number; + + @Output() + blur: EventEmitter = new EventEmitter(); + + onChange: () => void; + + onTouched: () => void; + + value: number | string; + + displayValue: string; + + disabled: boolean; + + constructor(@Inject(LOCALE_ID) private locale: string) {} + + ngOnInit() {} + + writeValue(value: number): void { + if (this.value !== value) { + this.value = value; + this._updateDisplayValue(); + } + } + + registerOnChange(fn: any): void { + this.onChange = () => { + if (fn) { + fn(this.value); + } + }; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + /** + * Validates the filter control + */ + validate(c: AbstractControl): ValidationErrors | any { + return !isNaN(+this.value) && + (!this.min || this.value >= this.min) && + (!this.max || this.value <= this.max) + ? null + : { + numberInput: 'Invalid value specified.' + }; + } + + setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + } + + /** + * Called when the selection changed + * @param value + */ + onInputChange(value: string) { + this.displayValue = value; + let prepValue = value; + + prepValue = prepValue.replace(',', '.'); + + const newValue = prepValue.match(/^\d+(\.\d+)?$/) + ? parseFloat(prepValue) + : value + ? value + : null; + + if (this.value !== newValue && this.onChange) { + this.value = newValue; + this.onChange(); + } + } + + /** + * Called when the user removes focus from the field + */ + onBlur() { + this._updateDisplayValue(); + } + + private _updateDisplayValue() { + const value = parseFloat(`${this.value}`); + + this.displayValue = Number.isNaN(value) + ? '' + : formatNumber(value, this.locale, '.2-2'); + } +} diff --git a/projects/ng-rocketparts/src/lib/components/number-input/number-input.module.spec.ts b/projects/ng-rocketparts/src/lib/components/number-input/number-input.module.spec.ts new file mode 100644 index 0000000..76ed2a5 --- /dev/null +++ b/projects/ng-rocketparts/src/lib/components/number-input/number-input.module.spec.ts @@ -0,0 +1,13 @@ +import { NumberInputModule } from './number-input.module'; + +describe('NumberInputModule', () => { + let numberInputModule: NumberInputModule; + + beforeEach(() => { + numberInputModule = new NumberInputModule(); + }); + + it('should create an instance', () => { + expect(numberInputModule).toBeTruthy(); + }); +}); diff --git a/projects/ng-rocketparts/src/lib/components/number-input/number-input.module.ts b/projects/ng-rocketparts/src/lib/components/number-input/number-input.module.ts new file mode 100644 index 0000000..c58dbd2 --- /dev/null +++ b/projects/ng-rocketparts/src/lib/components/number-input/number-input.module.ts @@ -0,0 +1,12 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { NumberInputComponent } from './number-input.component'; + +@NgModule({ + imports: [CommonModule, FormsModule, ReactiveFormsModule], + declarations: [NumberInputComponent], + exports: [NumberInputComponent] +}) +export class NumberInputModule {} diff --git a/projects/ng-rocketparts/src/lib/ng-rocketparts.module.ts b/projects/ng-rocketparts/src/lib/ng-rocketparts.module.ts index 7944e05..191e416 100644 --- a/projects/ng-rocketparts/src/lib/ng-rocketparts.module.ts +++ b/projects/ng-rocketparts/src/lib/ng-rocketparts.module.ts @@ -1,9 +1,15 @@ import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { NumberInputModule } from './components/number-input/number-input.module'; import { MaybeAsyncPipe } from './pipes/maybe-async.pipe'; +const MODULES = [NumberInputModule]; + @NgModule({ - imports: [], + imports: [CommonModule, FormsModule, ReactiveFormsModule], declarations: [MaybeAsyncPipe], - exports: [MaybeAsyncPipe] + exports: [...MODULES, MaybeAsyncPipe] }) export class NgRocketPartsModule {} diff --git a/projects/ng-rocketparts/src/public_api.ts b/projects/ng-rocketparts/src/public_api.ts index fc30cbe..7dfe2e2 100644 --- a/projects/ng-rocketparts/src/public_api.ts +++ b/projects/ng-rocketparts/src/public_api.ts @@ -2,5 +2,6 @@ * Public API Surface of ng-rocketparts */ +export * from './lib/components/number-input/number-input.module'; export * from './lib/pipes/maybe-async.pipe'; export * from './lib/ng-rocketparts.module'; diff --git a/projects/ng-rocketparts/src/testing/dispatch-events.ts b/projects/ng-rocketparts/src/testing/dispatch-events.ts new file mode 100755 index 0000000..589ce82 --- /dev/null +++ b/projects/ng-rocketparts/src/testing/dispatch-events.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + createFakeEvent, + createKeyboardEvent, + createMouseEvent, + createTouchEvent +} from './event-objects'; + +/** Utility to dispatch any event on a Node. */ +export function dispatchEvent(node: Node | Window, event: Event): Event { + node.dispatchEvent(event); + return event; +} + +/** Shorthand to dispatch a fake event on a specified node. */ +export function dispatchFakeEvent( + node: Node | Window, + type: string, + canBubble?: boolean +): Event { + return dispatchEvent(node, createFakeEvent(type, canBubble)); +} + +/** Shorthand to dispatch a keyboard event with a specified key code. */ +export function dispatchKeyboardEvent( + node: Node, + type: string, + keyCode: number, + target?: Element +): KeyboardEvent { + return dispatchEvent( + node, + createKeyboardEvent(type, keyCode, target) + ) as KeyboardEvent; +} + +interface DispatchMouseEventConfig { + node: Node; + type: string; + x: number; + y: number; + event: MouseEvent; +} +/** Shorthand to dispatch a mouse event on the specified coordinates. */ +export function dispatchMouseEvent({ + node, + type, + x = 0, + y = 0, + event = createMouseEvent(type, x, y) +}: DispatchMouseEventConfig): MouseEvent { + return dispatchEvent(node, event) as MouseEvent; +} + +/** Shorthand to dispatch a touch event on the specified coordinates. */ +export function dispatchTouchEvent(node: Node, type: string, x = 0, y = 0) { + return dispatchEvent(node, createTouchEvent(type, x, y)); +} diff --git a/projects/ng-rocketparts/src/testing/element-focus.ts b/projects/ng-rocketparts/src/testing/element-focus.ts new file mode 100755 index 0000000..76270a5 --- /dev/null +++ b/projects/ng-rocketparts/src/testing/element-focus.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { dispatchFakeEvent } from './dispatch-events'; + +/** + * Patches an elements focus and blur methods to emit events consistently and predictably. + * This is necessary, because some browsers, like IE11, will call the focus handlers asynchronously, + * while others won't fire them at all if the browser window is not focused. + */ +export function patchElementFocus(element: HTMLElement) { + element.focus = () => dispatchFakeEvent(element, 'focus'); + element.blur = () => dispatchFakeEvent(element, 'blur'); +} diff --git a/projects/ng-rocketparts/src/testing/event-objects.ts b/projects/ng-rocketparts/src/testing/event-objects.ts new file mode 100755 index 0000000..f88b527 --- /dev/null +++ b/projects/ng-rocketparts/src/testing/event-objects.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** Creates a browser MouseEvent with the specified options. */ +export function createMouseEvent(type: string, x = 0, y = 0, button = 0) { + const event = document.createEvent('MouseEvent'); + + event.initMouseEvent( + type, + true /* canBubble */, + false /* cancelable */, + window /* view */, + 0 /* detail */, + x /* screenX */, + y /* screenY */, + x /* clientX */, + y /* clientY */, + false /* ctrlKey */, + false /* altKey */, + false /* shiftKey */, + false /* metaKey */, + button /* button */, + null /* relatedTarget */ + ); + + // `initMouseEvent` doesn't allow us to pass the `buttons` and + // defaults it to 0 which looks like a fake event. + Object.defineProperty(event, 'buttons', { get: () => 1 }); + + return event; +} + +/** Creates a browser TouchEvent with the specified pointer coordinates. */ +export function createTouchEvent(type: string, pageX = 0, pageY = 0) { + // In favor of creating events that work for most of the browsers, the event is created + // as a basic UI Event. The necessary details for the event will be set manually. + const event = document.createEvent('UIEvent'); + const touchDetails = { pageX, pageY }; + + event.initUIEvent(type, true, true, window, 0); + + // Most of the browsers don't have a "initTouchEvent" method that can be used to define + // the touch details. + Object.defineProperties(event, { + touches: { value: [touchDetails] }, + targetTouches: { value: [touchDetails] }, + changedTouches: { value: [touchDetails] } + }); + + return event; +} + +/** Dispatches a keydown event from an element. */ +export function createKeyboardEvent( + type: string, + keyCode: number, + target?: Element, + key?: string +) { + const event = document.createEvent('KeyboardEvent') as any; + // Firefox does not support `initKeyboardEvent`, but supports `initKeyEvent`. + const initEventFn = (event.initKeyEvent || event.initKeyboardEvent).bind( + event + ); + const originalPreventDefault = event.preventDefault; + + initEventFn(type, true, true, window, 0, 0, 0, 0, 0, keyCode); + + // Webkit Browsers don't set the keyCode when calling the init function. + // See related bug https://bugs.webkit.org/show_bug.cgi?id=16735 + Object.defineProperties(event, { + keyCode: { get: () => keyCode }, + key: { get: () => key }, + target: { get: () => target } + }); + + // IE won't set `defaultPrevented` on synthetic events so we need to do it manually. + event.preventDefault = function() { + Object.defineProperty(event, 'defaultPrevented', { get: () => true }); + return originalPreventDefault.apply(this, arguments); + }; + + return event; +} + +/** Creates a fake event object with any desired event type. */ +export function createFakeEvent( + type: string, + canBubble = false, + cancelable = true +) { + const event = document.createEvent('Event'); + event.initEvent(type, canBubble, cancelable); + return event; +} diff --git a/projects/ng-rocketparts/src/testing/index.ts b/projects/ng-rocketparts/src/testing/index.ts new file mode 100755 index 0000000..676ca90 --- /dev/null +++ b/projects/ng-rocketparts/src/testing/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './public-api'; diff --git a/projects/ng-rocketparts/src/testing/mock-ng-zone.ts b/projects/ng-rocketparts/src/testing/mock-ng-zone.ts new file mode 100755 index 0000000..82348aa --- /dev/null +++ b/projects/ng-rocketparts/src/testing/mock-ng-zone.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { EventEmitter, Injectable, NgZone } from '@angular/core'; + +/** + * Mock synchronous NgZone implementation that can be used + * to flush out `onStable` subscriptions in tests. + * + * via: https://github.com/angular/angular/blob/master/packages/core/testing/src/ng_zone_mock.ts + * @docs-private + */ +@Injectable() +export class MockNgZone extends NgZone { + onStable: EventEmitter = new EventEmitter(false); + + constructor() { + super({ enableLongStackTrace: false }); + } + + run(fn: Function): any { + return fn(); + } + + runOutsideAngular(fn: Function): any { + return fn(); + } + + simulateZoneExit(): void { + this.onStable.emit(null); + } +} diff --git a/projects/ng-rocketparts/src/testing/public-api.ts b/projects/ng-rocketparts/src/testing/public-api.ts new file mode 100755 index 0000000..8197910 --- /dev/null +++ b/projects/ng-rocketparts/src/testing/public-api.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './dispatch-events'; +export * from './event-objects'; +export * from './type-in-element'; +export * from './wrapped-error-message'; +export * from './mock-ng-zone'; +export * from './element-focus'; diff --git a/projects/ng-rocketparts/src/testing/type-in-element.ts b/projects/ng-rocketparts/src/testing/type-in-element.ts new file mode 100755 index 0000000..a6448f0 --- /dev/null +++ b/projects/ng-rocketparts/src/testing/type-in-element.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { dispatchFakeEvent } from './dispatch-events'; + +/** + * Focuses an input, sets its value and dispatches + * the `input` event, simulating the user typing. + * @param value Value to be set on the input. + * @param element Element onto which to set the value. + */ +export function typeInElement(value: string, element: HTMLInputElement) { + element.focus(); + element.value = value; + dispatchFakeEvent(element, 'input'); +} diff --git a/projects/ng-rocketparts/src/testing/wrapped-error-message.ts b/projects/ng-rocketparts/src/testing/wrapped-error-message.ts new file mode 100755 index 0000000..d930513 --- /dev/null +++ b/projects/ng-rocketparts/src/testing/wrapped-error-message.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * Gets a RegExp used to detect an angular wrapped error message. + * See https://github.com/angular/angular/issues/8348 + */ +export function wrappedErrorMessage(e: Error) { + const escapedMessage = e.message.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); + return new RegExp(escapedMessage); +}