Skip to content

Commit c2469bd

Browse files
committed
feat: improve control directive to not set value if not necessary + some first tests
1 parent 4d4d70f commit c2469bd

File tree

2 files changed

+115
-3
lines changed

2 files changed

+115
-3
lines changed

src/control/directive.spec.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { ElementRef } from '@angular/core';
2+
import { ControlValueAccessor } from '@angular/forms';
3+
import { Action, ActionsSubject } from '@ngrx/store';
4+
import { Observable } from 'rxjs/Observable';
5+
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
6+
import 'rxjs/add/operator/first';
7+
import 'rxjs/add/operator/count';
8+
9+
import { SetValueAction } from '../actions';
10+
import { createFormControlState } from '../state';
11+
import { NgrxFormControlDirective } from './directive';
12+
13+
describe(NgrxFormControlDirective.name, () => {
14+
let directive: NgrxFormControlDirective<string>;
15+
let elementRef: ElementRef;
16+
let document: Document;
17+
let actionsSubject: ActionsSubject;
18+
let actions$: Observable<Action>;
19+
let valueAccessor: ControlValueAccessor;
20+
let onChange: (value: string) => void;
21+
let onTouched: () => void;
22+
const FORM_CONTROL_ID = 'test ID';
23+
const INITIAL_FORM_CONTROL_VALUE = 'value';
24+
const INITIAL_STATE = createFormControlState<string>(FORM_CONTROL_ID, INITIAL_FORM_CONTROL_VALUE);
25+
26+
beforeEach(() => {
27+
elementRef = { nativeElement: { focus: () => void 0, blur: () => void 0 } } as any as ElementRef;
28+
document = {} as any as Document;
29+
actionsSubject = new BehaviorSubject<Action>({ type: '' }) as ActionsSubject;
30+
actions$ = actionsSubject as any; // required due to mismatch of lift() function signature
31+
valueAccessor = {
32+
writeValue: () => void 0,
33+
registerOnChange: fn => onChange = fn,
34+
registerOnTouched: fn => onTouched = fn,
35+
setDisabledState: () => void 0,
36+
};
37+
directive = new NgrxFormControlDirective<string>(elementRef, document, actionsSubject, [valueAccessor]);
38+
directive.ngrxFormControlState = INITIAL_STATE;
39+
directive.ngOnInit();
40+
});
41+
42+
afterEach(() => {
43+
directive.ngOnDestroy();
44+
});
45+
46+
it('should write the value when the state changes', () => {
47+
const newValue = 'new value';
48+
const spy = spyOn(valueAccessor, 'writeValue');
49+
directive.ngrxFormControlState = { ...INITIAL_STATE, value: newValue };
50+
expect(spy).toHaveBeenCalledWith(newValue);
51+
});
52+
53+
it('should not write the value when the state value does not change', () => {
54+
const spy = spyOn(valueAccessor, 'writeValue');
55+
directive.ngrxFormControlState = INITIAL_STATE;
56+
expect(spy).not.toHaveBeenCalled();
57+
});
58+
59+
it('should not write the value when the state value is the same as the view value', () => {
60+
const newValue = 'new value';
61+
onChange(newValue);
62+
const spy = spyOn(valueAccessor, 'writeValue');
63+
directive.ngrxFormControlState = { ...INITIAL_STATE, value: newValue };
64+
expect(spy).not.toHaveBeenCalled();
65+
});
66+
67+
it('should dispatch an action if the view value changes', done => {
68+
const newValue = 'new value';
69+
onChange(newValue);
70+
actions$.first().subscribe(a => {
71+
expect(a).toEqual(new SetValueAction(INITIAL_STATE.id, newValue));
72+
done();
73+
});
74+
});
75+
76+
it('should not dispatch an action if the view value is the same as the state', done => {
77+
onChange(INITIAL_STATE.value);
78+
actionsSubject.complete();
79+
actions$.count().subscribe(c => {
80+
expect(c).toEqual(0);
81+
done();
82+
});
83+
});
84+
85+
// TODO: throwing error on undefined state
86+
// TODO: value conversion
87+
// TODO: mark as touched
88+
// TODO: disabling and enabling
89+
// TODO: focus tracking
90+
// TODO: last keydown code tracking
91+
});

src/control/directive.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { ActionsSubject } from '@ngrx/store';
1515
import { Observable } from 'rxjs/Observable';
1616
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
1717
import { Subscription } from 'rxjs/Subscription';
18+
import 'rxjs/add/operator/filter';
1819
import 'rxjs/add/operator/map';
1920
import 'rxjs/add/operator/distinctUntilChanged';
2021

@@ -44,6 +45,7 @@ export class NgrxFormControlDirective<TValue extends FormControlValueTypes> impl
4445
@Input() ngrxEnableFocusTracking = false;
4546
@Input() ngrxEnableLastKeydownCodeTracking = false;
4647

48+
// TODO: move this into a separate directive
4749
// automatically apply the attribute that's used by the CDK to set initial focus
4850
@HostBinding('attr.cdk-focus-region-start') get focusRegionStartAttr() {
4951
return this.state && this.state.isFocused ? '' : null;
@@ -58,6 +60,15 @@ export class NgrxFormControlDirective<TValue extends FormControlValueTypes> impl
5860
// the <any> cast is required due to a mismatch in the typing of lift() between Observable and BehaviorSubject
5961
private get state$(): Observable<FormControlState<TValue>> { return this.stateSubject$ as any; }
6062

63+
// we have to store the last reported value since when the action to update the state
64+
// is dispatched a new state will be received inside the directive, which in turn would
65+
// trigger a view update; however, most input elements move the cursor to the end of the
66+
// input when a new value is written programmatically which means whenever the user
67+
// types something the cursor is forced to the end of the input; to prevent this
68+
// behavior we compare the last reported value with the value to be set and filter out
69+
// those values that are equal to the last reported value
70+
private lastReportedViewValue: TValue;
71+
6172
constructor(
6273
private el: ElementRef,
6374
@Inject(DOCUMENT) private dom: Document,
@@ -71,9 +82,14 @@ export class NgrxFormControlDirective<TValue extends FormControlValueTypes> impl
7182
@Input() convertModelValue: (value: TValue) => any = value => value;
7283

7384
ngOnInit() {
74-
this.valueAccessor.registerOnChange((newValue: any) => {
85+
if (!this.state) {
86+
throw new Error('The form state must not be undefined!');
87+
}
88+
89+
this.valueAccessor.registerOnChange((newValue: TValue) => {
7590
newValue = this.convertViewValue(newValue);
7691
if (newValue !== this.state.value) {
92+
this.lastReportedViewValue = newValue;
7793
this.actionsSubject.next(new SetValueAction(this.state.id, newValue));
7894
}
7995
});
@@ -86,23 +102,28 @@ export class NgrxFormControlDirective<TValue extends FormControlValueTypes> impl
86102

87103
this.subscriptions.push(
88104
this.state$
105+
.map(s => ({ id: s.id, value: this.convertModelValue(s.value) }))
106+
.distinctUntilChanged((l, r) => l.id === r.id && l.value === r.value)
89107
.map(s => s.value)
90-
.map(this.convertModelValue)
108+
.filter(v => v !== this.lastReportedViewValue)
91109
.subscribe(value => this.valueAccessor.writeValue(value))
92110
);
93111

94112
if (this.valueAccessor.setDisabledState) {
95113
this.subscriptions.push(
96114
this.state$
115+
.map(s => ({ id: s.id, isDisabled: s.isDisabled }))
116+
.distinctUntilChanged((l, r) => l.id === r.id && l.isDisabled === r.isDisabled)
97117
.map(s => s.isDisabled)
98118
.subscribe(isDisabled => this.valueAccessor.setDisabledState!(isDisabled))
99119
);
100120
}
101121

102122
this.subscriptions.push(
103123
this.state$
124+
.map(s => ({ id: s.id, isFocused: s.isFocused }))
125+
.distinctUntilChanged((l, r) => l.id === r.id && l.isFocused === r.isFocused)
104126
.map(s => s.isFocused)
105-
.distinctUntilChanged()
106127
.subscribe(isFocused => {
107128
if (isFocused) {
108129
this.el.nativeElement.focus();

0 commit comments

Comments
 (0)