Skip to content

Commit bfaa388

Browse files
committed
fix: ensure the value attribute of option elements is properly set via [value] bindings when there is no associated form view adapter
1 parent c12264b commit bfaa388

File tree

10 files changed

+144
-6
lines changed

10 files changed

+144
-6
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
## ngrx-forms Changelog
22

3+
<a name="2.3.3"></a>
4+
### 2.3.3
5+
6+
#### Bugfixes
7+
8+
* ensure the `value` attribute of `option` elements is properly set via `[value]` bindings when there is no associated form view adapter (closes [#67](https://github.com/MrWolfZ/ngrx-forms/issues/67), thanks @kmiskiewicz for helping me find this)
9+
310
<a name="2.3.2"></a>
411
### 2.3.2
512

src/control/e2e.spec/select.spec.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ export class SelectComponent {
2323
options = SELECT_OPTIONS;
2424
}
2525

26+
@Component({
27+
// tslint:disable-next-line:component-selector
28+
selector: 'select-test-fallback',
29+
template: '<select><option *ngFor="let o of options" [value]="o">{{o}} Label</option></select>',
30+
})
31+
export class SelectFallbackComponent {
32+
options = SELECT_OPTIONS;
33+
}
34+
2635
describe(SelectComponent.name, () => {
2736
let component: SelectComponent;
2837
let fixture: ComponentFixture<SelectComponent>;
@@ -43,7 +52,7 @@ describe(SelectComponent.name, () => {
4352
beforeEach(async(() => {
4453
TestBed.configureTestingModule({
4554
imports: [NgrxFormsModule],
46-
declarations: [SelectComponent],
55+
declarations: [SelectComponent, SelectFallbackComponent],
4756
providers: [{ provide: ActionsSubject, useValue: actionsSubject }],
4857
}).compileComponents();
4958
}));
@@ -83,6 +92,17 @@ describe(SelectComponent.name, () => {
8392
element.selectedIndex = 0;
8493
element.dispatchEvent(new Event('change'));
8594
});
95+
96+
it('should set the value attribute for options without associated form state', () => {
97+
const fallbackFixture = TestBed.createComponent(SelectFallbackComponent);
98+
fallbackFixture.detectChanges();
99+
const nativeElement = fallbackFixture.nativeElement as HTMLElement;
100+
element = nativeElement.querySelector('select') as HTMLSelectElement;
101+
option1 = nativeElement.querySelectorAll('option')[0] as HTMLOptionElement;
102+
option2 = nativeElement.querySelectorAll('option')[1] as HTMLOptionElement;
103+
expect(option1.value).toBe(SELECT_OPTIONS[0]);
104+
expect(option2.value).toBe(SELECT_OPTIONS[1]);
105+
});
86106
});
87107

88108
const SELECT_NUMBER_OPTIONS = [1, 2];

src/module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { NgrxFormDirective } from './group/directive';
55
import { NgrxCheckboxViewAdapter } from './view-adapter/checkbox';
66
import { NgrxDefaultViewAdapter } from './view-adapter/default';
77
import { NgrxNumberViewAdapter } from './view-adapter/number';
8+
import { NgrxFallbackSelectOption } from './view-adapter/option';
89
import { NgrxRadioViewAdapter } from './view-adapter/radio';
910
import { NgrxRangeViewAdapter } from './view-adapter/range';
1011
import { NgrxSelectOption, NgrxSelectViewAdapter } from './view-adapter/select';
@@ -23,6 +24,7 @@ const exportsAndDeclarations = [
2324
NgrxSelectMultipleViewAdapter,
2425
NgrxSelectOption,
2526
NgrxSelectViewAdapter,
27+
NgrxFallbackSelectOption,
2628
NgrxStatusCssClassesDirective,
2729
];
2830

src/ngrx-forms.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
NgrxSelectMultipleViewAdapter,
3333
NgrxSelectOption,
3434
NgrxSelectViewAdapter,
35+
NgrxFallbackSelectOption,
3536
NgrxStatusCssClassesDirective,
3637
NgrxValueConverters,
3738
removeArrayControl,
@@ -92,6 +93,7 @@ describe('ngrx-forms', () => {
9293
it(`should export ${NgrxRadioViewAdapter.name}`, () => expect(NgrxRadioViewAdapter).toBeDefined());
9394
it(`should export ${NgrxSelectOption.name}`, () => expect(NgrxSelectOption).toBeDefined());
9495
it(`should export ${NgrxSelectMultipleOption.name}`, () => expect(NgrxSelectMultipleOption).toBeDefined());
96+
it(`should export ${NgrxFallbackSelectOption.name}`, () => expect(NgrxFallbackSelectOption).toBeDefined());
9597
it(`should export ${NgrxStatusCssClassesDirective.name}`, () => expect(NgrxStatusCssClassesDirective).toBeDefined());
9698
it(`should export ${NgrxFormsModule.name}`, () => expect(NgrxFormsModule).toBeDefined());
9799
});

src/ngrx-forms.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export { FormViewAdapter, NGRX_FORM_VIEW_ADAPTER } from './view-adapter/view-ada
5050
export { NgrxCheckboxViewAdapter } from './view-adapter/checkbox';
5151
export { NgrxDefaultViewAdapter } from './view-adapter/default';
5252
export { NgrxNumberViewAdapter } from './view-adapter/number';
53+
export { NgrxFallbackSelectOption } from './view-adapter/option';
5354
export { NgrxRadioViewAdapter } from './view-adapter/radio';
5455
export { NgrxRangeViewAdapter } from './view-adapter/range';
5556
export { NgrxSelectViewAdapter, NgrxSelectOption } from './view-adapter/select';

src/view-adapter/option.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Renderer2 } from '@angular/core';
2+
3+
import { NgrxFallbackSelectOption } from './option';
4+
import { NgrxSelectViewAdapter } from './select';
5+
import { NgrxSelectMultipleViewAdapter } from './select-multiple';
6+
7+
describe(NgrxFallbackSelectOption.name, () => {
8+
let viewAdapter: NgrxSelectViewAdapter;
9+
let multipleViewAdapter: NgrxSelectMultipleViewAdapter;
10+
let option: NgrxFallbackSelectOption;
11+
let renderer: Renderer2;
12+
13+
beforeEach(() => {
14+
renderer = jasmine.createSpyObj('renderer2', ['setProperty']);
15+
viewAdapter = new NgrxSelectViewAdapter(renderer, {} as any);
16+
multipleViewAdapter = new NgrxSelectMultipleViewAdapter(renderer, {} as any);
17+
});
18+
19+
it('should set the value attribute if no view adapter is provided', () => {
20+
option = new NgrxFallbackSelectOption({} as any, renderer, null as any, null as any);
21+
option.value = 'value';
22+
expect(renderer.setProperty).not.toHaveBeenCalledWith('value');
23+
});
24+
25+
it('should not set the value attribute if a view adapter is provided', () => {
26+
option = new NgrxFallbackSelectOption({} as any, renderer, viewAdapter, null as any);
27+
option.value = 'value';
28+
expect(renderer.setProperty).not.toHaveBeenCalled();
29+
});
30+
31+
it('should not set the value attribute if a multiple view adapter is provided', () => {
32+
option = new NgrxFallbackSelectOption({} as any, renderer, null as any, multipleViewAdapter);
33+
option.value = 'value';
34+
expect(renderer.setProperty).not.toHaveBeenCalled();
35+
});
36+
});

src/view-adapter/option.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {
2+
Directive,
3+
ElementRef,
4+
Host,
5+
Input,
6+
Optional,
7+
Renderer2,
8+
} from '@angular/core';
9+
10+
import { NgrxSelectViewAdapter } from './select';
11+
import { NgrxSelectMultipleViewAdapter } from './select-multiple';
12+
13+
// tslint:disable:directive-class-suffix
14+
15+
const NULL_RENDERER: Renderer2 = {
16+
setProperty: () => void 0,
17+
} as any;
18+
19+
/**
20+
* This directive is necessary to restore the default behaviour of Angular
21+
* when an `option` is used without an **ngrx-forms** form state. Since it
22+
* is not possible to select an element with a selector that considers its
23+
* parent the `option` directives for `select` and `select[multiple]` will
24+
* always be applied and therefore overriding the `[value]` binding which
25+
* disabled Angular's normal behaviour. This directive restores this
26+
* behaviour if no `select` or `select[multiple]` view adapter is found.
27+
* This is not a perfect solution since it may interfere with other
28+
* directives that try to set the `[value]` but that is very unlikely.
29+
*/
30+
@Directive({
31+
// tslint:disable-next-line:directive-selector
32+
selector: 'option',
33+
})
34+
export class NgrxFallbackSelectOption {
35+
constructor(
36+
private element: ElementRef,
37+
private renderer: Renderer2,
38+
@Host() @Optional() viewAdapter: NgrxSelectViewAdapter,
39+
@Host() @Optional() multipleViewAdapter: NgrxSelectMultipleViewAdapter,
40+
) {
41+
this.renderer = viewAdapter || multipleViewAdapter ? NULL_RENDERER : renderer;
42+
}
43+
44+
@Input('value')
45+
set value(value: any) {
46+
this.renderer.setProperty(this.element.nativeElement, 'value', value);
47+
}
48+
}

src/view-adapter/select-multiple.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,12 @@ describe(NgrxSelectMultipleOption.name, () => {
364364
expect(new NgrxSelectMultipleOption({} as any, {} as any, null as any)).toBeDefined();
365365
});
366366

367-
it('ngOnInit should not change the element if no matching view adapter is injected', () => {
367+
it('should set the value to the id of the element', () => {
368+
option.ngOnInit();
369+
expect(renderer.setProperty).not.toHaveBeenCalledWith(0);
370+
});
371+
372+
it('should not set the value to the id if no view adapter is provided', () => {
368373
option = new NgrxSelectMultipleOption({} as any, renderer, null as any);
369374
option.ngOnInit();
370375
expect(renderer.setProperty).not.toHaveBeenCalled();

src/view-adapter/select.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,4 +363,15 @@ describe(NgrxSelectOption.name, () => {
363363
it('should work if option is created without view adapter', () => {
364364
expect(new NgrxSelectOption({} as any, {} as any, null as any)).toBeDefined();
365365
});
366+
367+
it('should set the value to the id of the element', () => {
368+
option.value = 'value';
369+
expect(renderer.setProperty).not.toHaveBeenCalledWith(0);
370+
});
371+
372+
it('should not set the value to the id if no view adapter is provided', () => {
373+
option = new NgrxSelectOption({} as any, {} as any, null as any);
374+
option.value = 'value';
375+
expect(renderer.setProperty).not.toHaveBeenCalled();
376+
});
366377
});

src/view-adapter/select.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ const NULL_VIEW_ADAPTER: NgrxSelectViewAdapter = {
113113
updateOptionValue: () => void 0,
114114
} as any;
115115

116+
const NULL_RENDERER: Renderer2 = {
117+
setProperty: () => void 0,
118+
} as any;
119+
116120
@Directive({
117121
// tslint:disable-next-line:directive-selector
118122
selector: 'option',
@@ -126,18 +130,20 @@ export class NgrxSelectOption implements OnDestroy {
126130
private renderer: Renderer2,
127131
@Host() @Optional() private viewAdapter: NgrxSelectViewAdapter,
128132
) {
133+
this.renderer = viewAdapter ? renderer : NULL_RENDERER;
129134
this.viewAdapter = viewAdapter || NULL_VIEW_ADAPTER;
130135
this.id = this.viewAdapter.createOptionId();
131136
}
132137

133138
@Input('value')
134139
set value(value: any) {
140+
// this cannot be done inside ngOnInit since the value property
141+
// must be already set when the option value is updated in the view
142+
// adapter and the initial binding of 'value' happens before
143+
// ngOnInit runs
135144
if (!this.isInitialized) {
136145
this.isInitialized = true;
137-
138-
if (this.id) {
139-
this.renderer.setProperty(this.element.nativeElement, 'value', this.id);
140-
}
146+
this.renderer.setProperty(this.element.nativeElement, 'value', this.id);
141147
}
142148

143149
this.viewAdapter.updateOptionValue(this.id, value);

0 commit comments

Comments
 (0)