Skip to content

Commit ce2679a

Browse files
author
Fernando Gonzalez Goncharov
committed
enhancement/2 - add number-input component
* add testing utils from @angular/cdk Closes #2
1 parent 3b1d0b3 commit ce2679a

13 files changed

Lines changed: 600 additions & 3 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<input (blur)="onBlur()" #input="ngModel" [min]="min" [max]="max"
2+
[attr.min]="min" [attr.max]="max" (ngModelChange)="onInputChange($event)"
3+
[ngModel]="displayValue" [disabled]="disabled"
4+
[placeholder]="placeholder || ''" placement="top"
5+
triggers="focus:blur"/>
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import {
2+
ComponentFixture,
3+
TestBed,
4+
fakeAsync,
5+
tick
6+
} from '@angular/core/testing';
7+
import { By } from '@angular/platform-browser';
8+
import { Component, Type } from '@angular/core';
9+
import { FormsModule, ReactiveFormsModule, FormControl } from '@angular/forms';
10+
11+
import { typeInElement, dispatchFakeEvent } from '../../../testing';
12+
import { NumberInputComponent } from './number-input.component';
13+
14+
@Component({
15+
template: `<rp-number-input
16+
[formControl]="control"
17+
[placeholder]="'Enter a number'"
18+
[min]="10"
19+
[max]="1000"></rp-number-input>
20+
`
21+
})
22+
export class SimpleNumberInputComponent {
23+
control = new FormControl();
24+
}
25+
26+
describe('[Component]: NumberInputComponent', () => {
27+
// Creates a test component fixture.
28+
function createComponent<T>(component: Type<T>) {
29+
TestBed.configureTestingModule({
30+
imports: [FormsModule, ReactiveFormsModule],
31+
declarations: [NumberInputComponent, component]
32+
});
33+
TestBed.compileComponents();
34+
35+
return TestBed.createComponent<T>(component);
36+
}
37+
38+
describe('forms integration', () => {
39+
let fixture: ComponentFixture<SimpleNumberInputComponent>;
40+
let input: HTMLInputElement;
41+
42+
beforeEach(() => {
43+
fixture = createComponent(SimpleNumberInputComponent);
44+
fixture.detectChanges();
45+
input = fixture.debugElement.query(By.css('input')).nativeElement;
46+
});
47+
48+
it('should update control value as user types with input value', () => {
49+
typeInElement('10', input);
50+
fixture.detectChanges();
51+
52+
expect(fixture.componentInstance.control.value).toEqual(
53+
10,
54+
`Expected control value to be updated as user types.`
55+
);
56+
57+
typeInElement('100', input);
58+
fixture.detectChanges();
59+
60+
expect(fixture.componentInstance.control.value).toEqual(
61+
100,
62+
`Expected control value to be updated as user types.`
63+
);
64+
});
65+
66+
it('should format input value on blur', fakeAsync(() => {
67+
typeInElement('300', input);
68+
dispatchFakeEvent(input, 'blur');
69+
fixture.detectChanges();
70+
tick();
71+
72+
expect(input.value).toEqual(
73+
'300.00',
74+
`Expected input value to be formatted on blur.`
75+
);
76+
77+
typeInElement('3000', input);
78+
dispatchFakeEvent(input, 'blur');
79+
fixture.detectChanges();
80+
tick();
81+
82+
expect(input.value).toEqual(
83+
'3,000.00',
84+
`Expected input value to be formatted on blur.`
85+
);
86+
}));
87+
88+
it('should fill input correctly if control value is set programatically', fakeAsync(() => {
89+
fixture.componentInstance.control.setValue(100);
90+
fixture.detectChanges();
91+
tick();
92+
93+
expect(input.value).toEqual(
94+
'100.00',
95+
`Expected input to fill with control current formatted value.`
96+
);
97+
98+
fixture.componentInstance.control.setValue(1000);
99+
fixture.detectChanges();
100+
tick();
101+
102+
expect(input.value).toEqual(
103+
'1,000.00',
104+
`Expected input to fill with control current formatted value.`
105+
);
106+
}));
107+
108+
it('should clear the input value if control value is reset programatically', fakeAsync(() => {
109+
typeInElement('200', input);
110+
fixture.detectChanges();
111+
tick();
112+
113+
fixture.componentInstance.control.reset();
114+
fixture.detectChanges();
115+
tick();
116+
117+
expect(input.value).toEqual(
118+
'',
119+
`Expected input value to be empty after control reset.`
120+
);
121+
}));
122+
123+
it('should mark the control as dirty as user types', () => {
124+
expect(fixture.componentInstance.control.dirty).toBe(
125+
false,
126+
`Expected control to start out pristine.`
127+
);
128+
129+
typeInElement('20', input);
130+
fixture.detectChanges();
131+
132+
expect(fixture.componentInstance.control.dirty).toBe(
133+
true,
134+
`Expected control to become dirty when the user types into the input.`
135+
);
136+
});
137+
138+
it('should not mark the control dirty when the value is set programmatically', () => {
139+
expect(fixture.componentInstance.control.dirty).toBe(
140+
false,
141+
`Expected control to start out pristine.`
142+
);
143+
144+
fixture.componentInstance.control.setValue('200');
145+
fixture.detectChanges();
146+
147+
expect(fixture.componentInstance.control.dirty).toBe(
148+
false,
149+
`Expected control to stay pristine if value is set programmatically.`
150+
);
151+
});
152+
153+
it('should clear input value on blur if invalid input was provided', fakeAsync(() => {
154+
typeInElement('invalid', input);
155+
fixture.detectChanges();
156+
tick();
157+
158+
dispatchFakeEvent(input, 'blur');
159+
fixture.detectChanges();
160+
tick();
161+
162+
expect(input.value).toEqual(
163+
'',
164+
`Expected input value to be cleared on blur.`
165+
);
166+
}));
167+
});
168+
});
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import {
2+
Component,
3+
EventEmitter,
4+
forwardRef,
5+
HostBinding,
6+
Input,
7+
OnInit,
8+
Output,
9+
Inject,
10+
LOCALE_ID
11+
} from '@angular/core';
12+
import {
13+
AbstractControl,
14+
ControlValueAccessor,
15+
NG_VALIDATORS,
16+
NG_VALUE_ACCESSOR,
17+
ValidationErrors,
18+
Validator
19+
} from '@angular/forms';
20+
import { formatNumber } from '@angular/common';
21+
22+
export const NUMBER_INPUT_VALUE_ACCESSOR: any = {
23+
provide: NG_VALUE_ACCESSOR,
24+
useExisting: forwardRef(() => NumberInputComponent), // tslint:disable-line
25+
multi: true
26+
};
27+
28+
export const NUMBER_INPUT_VALIDATOR: any = {
29+
provide: NG_VALIDATORS,
30+
useExisting: forwardRef(() => NumberInputComponent), // tslint:disable-line
31+
multi: true
32+
};
33+
34+
@Component({
35+
selector: 'rp-number-input',
36+
templateUrl: './number-input.component.html',
37+
styles: [],
38+
providers: [NUMBER_INPUT_VALUE_ACCESSOR, NUMBER_INPUT_VALIDATOR]
39+
})
40+
export class NumberInputComponent
41+
implements OnInit, ControlValueAccessor, Validator {
42+
@HostBinding('class.number-input')
43+
numberInputClass = true;
44+
45+
@Input()
46+
placeholder: string;
47+
48+
@Input()
49+
min: number;
50+
51+
@Input()
52+
max: number;
53+
54+
@Output()
55+
blur: EventEmitter<void> = new EventEmitter();
56+
57+
onChange: () => void;
58+
59+
onTouched: () => void;
60+
61+
value: number | string;
62+
63+
displayValue: string;
64+
65+
disabled: boolean;
66+
67+
constructor(@Inject(LOCALE_ID) private locale: string) {}
68+
69+
ngOnInit() {}
70+
71+
writeValue(value: number): void {
72+
if (this.value !== value) {
73+
this.value = value;
74+
this._updateDisplayValue();
75+
}
76+
}
77+
78+
registerOnChange(fn: any): void {
79+
this.onChange = () => {
80+
if (fn) {
81+
fn(this.value);
82+
}
83+
};
84+
}
85+
86+
registerOnTouched(fn: any): void {
87+
this.onTouched = fn;
88+
}
89+
90+
/**
91+
* Validates the filter control
92+
*/
93+
validate(c: AbstractControl): ValidationErrors | any {
94+
return !isNaN(+this.value) &&
95+
(!this.min || this.value >= this.min) &&
96+
(!this.max || this.value <= this.max)
97+
? null
98+
: {
99+
numberInput: 'Invalid value specified.'
100+
};
101+
}
102+
103+
setDisabledState(isDisabled: boolean) {
104+
this.disabled = isDisabled;
105+
}
106+
107+
/**
108+
* Called when the selection changed
109+
* @param value
110+
*/
111+
onInputChange(value: string) {
112+
this.displayValue = value;
113+
let prepValue = value;
114+
if (/(\d+\.)+\d+,\d+/) {
115+
prepValue = prepValue.replace(/\./g, '');
116+
}
117+
prepValue = prepValue.replace(',', '.');
118+
const newValue = prepValue.match(/^\d+(\.\d+)?$/)
119+
? parseFloat(prepValue)
120+
: value
121+
? value
122+
: null;
123+
if (this.value !== newValue && this.onChange) {
124+
this.value = newValue;
125+
this.onChange();
126+
}
127+
}
128+
129+
/**
130+
* Called when the user removes focus from the field
131+
*/
132+
onBlur() {
133+
this._updateDisplayValue();
134+
}
135+
136+
private _updateDisplayValue() {
137+
const value = parseFloat(`${this.value}`);
138+
139+
this.displayValue = Number.isNaN(value)
140+
? ''
141+
: formatNumber(value, this.locale, '.2-2');
142+
}
143+
}
Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { NgModule } from '@angular/core';
2+
import { CommonModule } from '@angular/common';
3+
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
4+
5+
import { NumberInputComponent } from './components/number-input/number-input.component';
6+
7+
const COMPONENTS = [NumberInputComponent];
28

39
@NgModule({
4-
imports: [],
5-
declarations: [],
6-
exports: []
10+
imports: [CommonModule, FormsModule, ReactiveFormsModule],
11+
declarations: [...COMPONENTS],
12+
exports: [...COMPONENTS]
713
})
814
export class NgRocketPartsModule {}

projects/ng-rocketparts/src/public_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
* Public API Surface of ng-rocketparts
33
*/
44

5+
export * from './lib/components/number-input/number-input.component';
56
export * from './lib/ng-rocketparts.module';

0 commit comments

Comments
 (0)