8000 fix(forms): provide a method to compare options (#13349) · angular/angular@f89d004 · GitHub
[go: up one dir, main page]

Skip to content

Commit f89d004

Browse files
Dzmitry Shylovichmhevery
authored andcommitted
fix(forms): provide a method to compare options (#13349)
Closes #13268 PR Close #13349
1 parent 6c7300c commit f89d004

File tree

5 files changed

+371
-75
lines changed

5 files changed

+371
-75
lines changed
< 8000 /div>

modules/@angular/forms/src/directives/select_control_value_accessor.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,31 @@ function _extractId(valueString: string): string {
5757
*
5858
* {@example forms/ts/reactiveSelectControl/reactive_select_control_example.ts region='Component'}
5959
*
60+
* ### Caveat: Option selection
61+
*
62+
* Angular uses object identity to select option. It's possible for the identities of items
63+
* to change while the data does not. This can happen, for example, if the items are produced
64+
* from an RPC to the server, and that RPC is re-run. Even if the data hasn't changed, the
65+
* second response will produce objects with different identities.
66+
*
67+
* To customize the default option comparison algorithm, `<select>` supports `compareWith` input.
68+
* `compareWith` takes a **function** which has two arguments: `option1` and `option2`.
69+
* If `compareWith` is given, Angular selects option by the return value of the function.
70+
*
71+
* #### Syntax
72+
*
73+
* ```
74+
* <select [compareWith]="compareFn" [(ngModel)]="selectedCountries">
75+
* <option *ngFor="let country of countries" [ngValue]="country">
76+
* {{country.name}}
77+
* </option>
78+
* </select>
79+
*
80+
* compareFn(c1: Country, c2: Country): boolean {
81+
* return c1 && c2 ? c1.id === c2.id : c1 === c2;
82+
* }
83+
* ```
84+
*
6085
* Note: We listen to the 'change' event because 'input' events aren't fired
6186
* for selects in Firefox and IE:
6287
* https://bugzilla.mozilla.org/show_bug.cgi?id=1024350
@@ -82,6 +107,16 @@ export class SelectControlValueAccessor implements ControlValueAccessor {
82107
onChange = (_: any) => {};
83108
onTouched = () => {};
84109

110+
@Input()
111+
set compareWith(fn: (o1: any, o2: any) => boolean) {
112+
if (typeof fn !== 'function') {
113+
throw new Error(`compareWith must be a function, but received ${JSON.stringify(fn)}`);
114+
}
115+
this._compareWith = fn;
116+
}
117+
118+
private _compareWith: (o1: any, o2: any) => boolean = looseIdentical;
119+
85120
constructor(private _renderer: Renderer, private _elementRef: ElementRef) {}
86121

87122
writeValue(value: any): void {
@@ -112,7 +147,7 @@ export class SelectControlValueAccessor implements ControlValueAccessor {
112147
/** @internal */
113148
_getOptionId(value: any): string {
114149
for (const id of Array.from(this._optionMap.keys())) {
115-
if (looseIdentical(this._optionMap.get(id), value)) return id;
150+
if (this._compareWith(this._optionMap.get(id), value)) return id;
116151
}
117152
return null;
118153
}

modules/@angular/forms/src/directives/select_multiple_control_value_accessor.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,31 @@ abstract class HTMLCollection {
4444
/**
4545
* The accessor for writing a value and listening to changes on a select element.
4646
*
47+
* ### Caveat: Options selection
48+
*
49+
* Angular uses object identity to select options. It's possible for the identities of items
50+
* to change while the data does not. This can happen, for example, if the items are produced
51+
* from an RPC to the server, and that RPC is re-run. Even if the data hasn't changed, the
52+
* second response will produce objects with different identities.
53+
*
54+
* To customize the default option comparison algorithm, `<select multiple>` supports `compareWith`
55+
* input. `compareWith` takes a **function** which has two arguments: `option1` and `option2`.
56+
* If `compareWith` is given, Angular selects options by the return value of the function.
57+
*
58+
* #### Syntax
59+
*
60+
* ```
61+
* <select multiple [compareWith]="compareFn" [(ngModel)]="selectedCountries">
62+
* <option *ngFor="let country of countries" [ngValue]="country">
63+
* {{country.name}}
64+
* </option>
65+
* </select>
66+
*
67+
* compareFn(c1: Country, c2: Country): boolean {
68+
* return c1 && c2 ? c1.id === c2.id : c1 === c2;
69+
* }
70+
* ```
71+
*
4772
* @stable
4873
*/
4974
@Directive({
@@ -62,6 +87,16 @@ export class SelectMultipleControlValueAccessor implements ControlValueAccessor
6287
onChange = (_: any) => {};
6388
onTouched = () => {};
6489

90+
@Input()
91+
set compareWith(fn: (o1: any, o2: any) => boolean) {
92+
if (typeof fn !== 'function') {
93+
throw new Error(`compareWith must be a function, but received ${JSON.stringify(fn)}`);
94+
}
95+
this._compareWith = fn;
96+
}
97+
98+
private _compareWith: (o1: any, o2: any) => boolean = looseIdentical;
99+
65100
constructor(private _renderer: Renderer, private _elementRef: ElementRef) {}
66101

67102
writeValue(value: any): void {
@@ -119,7 +154,7 @@ export class SelectMultipleControlValueAccessor implements ControlValueAccessor
119154
/** @internal */
120155
_getOptionId(value: any): string {
121156
for (const id of Array.from(this._optionMap.keys())) {
122-
if (looseIdentical(this._optionMap.get(id)._value, value)) return id;
157+
if (this._compareWith(this._optionMap.get(id)._value, value)) return id;
123158
}
124159
return null;
125160
}

modules/@angular/forms/test/reactive_integration_spec.ts

Lines changed: 165 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,95 @@ export function main() {
338338

339339
});
340340

341+
describe('select controls', () => {
342+
it(`should support primitive values`, () => {
343+
const fixture = initTest(FormControlNameSelect);
344+
fixture.detectChanges();
345+
346+
// model -> view
347+
const select = fixture.debugElement.query(By.css('select'));
348+
const sfOption = fixture.debugElement.query(By.css('option'));
349+
expect(select.nativeElement.value).toEqual('SF');
350+
expect(sfOption.nativeElement.selected).toBe(true);
351+
352+
select.nativeElement.value = 'NY';
353+
dispatchEvent(select.nativeElement, 'change');
354+
fixture.detectChanges();
355+
356+
// view -> model
357+
expect(sfOption.nativeElement.selected).toBe(false);
358+
expect(fixture.componentInstance.form.value).toEqual({'city': 'NY'});
359+
});
360+
361+
it(`should support objects`, () => {
362+
const fixture = initTest(FormControlSelectNgValue);
363+
fixture.detectChanges();
364+
365+
// model -> view
366+
const select = fixture.debugElement.query(By.css('select'));
367+
const sfOption = fixture.debugElement.query(By.css('option'));
368+
expect(select.nativeElement.value).toEqual('0: Object');
369+
expect(sfOption.nativeElement.selected).toBe(true);
370+
});
371+
372+
it('should throw an error if compareWith is not a function', () => {
373+
const fixture = initTest(FormControlSelectWithCompareFn);
374+
fixture.componentInstance.compareFn = null;
375+
expect(() => fixture.detectChanges())
376+
.toThrowError(/compareWith must be a function, but received null/);
377+
});
378+
379+
it('should compare options using provided compareWith function', () => {
380+
const fixture = initTest(FormControlSelectWithCompareFn);
381+
fixture.detectChanges();
382+
383+
const select = fixture.debugElement.query(By.css('select'));
384+
const sfOption = fixture.debugElement.query(By.css('option'));
385+
expect(select.nativeElement.value).toEqual('0: Object');
386+
expect(sfOption.nativeElement.selected).toBe(true);
387+
});
388+
});
389+
390+
describe('select multiple controls', () => {
391+
it('should support primitive values', () => {
392+
const fixture = initTest(FormControlSelectMultiple);
393+
fixture.detectChanges();
394+
395+
const select = fixture.debugElement.query(By.css('select'));
396+
const sfOption = fixture.debugElement.query(By.css('option'));
397+
expect(select.nativeElement.value).toEqual(`0: 'SF'`);
398+
expect(sfOption.nativeElement.selected).toBe(true);
399+
});
400+
401+
it('should support objects', () => {
402+
const fixture = initTest(FormControlSelectMultipleNgValue);
403+
fixture.detectChanges();
404+
405+
const select = fixture.debugElement.query(By.css('select'));
406+
const sfOption = fixture.debugElement.query(By.css('option'));
407+
expect(select.nativeElement.value).toEqual('0: Object');
408+
expect(sfOption.nativeElement.selected).toBe(true);
409+
});
410+
411+
it('should throw an error when compareWith is not a function', () => {
412+
const fixture = initTest(FormControlSelectMultipleWithCompareFn);
413+
fixture.componentInstance.compareFn = null;
414+
expect(() => fixture.detectChanges())
415+
.toThrowError(/compareWith must be a function, but received null/);
416+
});
417+
418+
it('should compare options using provided compareWith function', fakeAsync(() => {
419+
const fixture = initTest(FormControlSelectMultipleWithCompareFn);
420+
fixture.detectChanges();
421+
tick();
422+
423+
const select = fixture.debugElement.query(By.css('select'));
424+
const sfOption = fixture.debugElement.query(By.css('option'));
425+
expect(select.nativeElement.value).toEqual('0: Object');
426+
expect(sfOption.nativeElement.selected).toBe(true);
427+
}));
428+
});
429+
341430
describe('form arrays', () => {
342431
it('should support form arrays', () => {
343432
const fixture = initTest(FormArrayComp);
@@ -835,25 +924,6 @@ export function main() {
835924
expect(control.value).toBe(false);
836925
});
837926

838-
it('should support <select>', () => {
839-
const fixture = initTest(FormControlNameSelect);
840-
fixture.detectChanges();
841-
842-
// model -> view
843-
const select = fixture.debugElement.query(By.css('select'));
844-
const sfOption = fixture.debugElement.query(By.css('option'));
845-
expect(select.nativeElement.value).toEqual('SF');
846-
expect(sfOption.nativeElement.selected).toBe(true);
847-
848-
select.nativeElement.value = 'NY';
849-
dispatchEvent(select.nativeElement, 'change');
850-
fixture.detectChanges();
851-
852-
// view -> model
853-
expect(sfOption.nativeElement.selected).toBe(false);
854-
expect(fixture.componentInstance.form.value).toEqual({'city': 'NY'});
855-
});
856-
857927
describe('should support <type=number>', () => {
858928
it('with basic use case', () => {
859929
const fixture = initTest(FormControlNumberInput);
@@ -2005,6 +2075,82 @@ class FormControlNameSelect {
20052075
form = new FormGroup({city: new FormControl('SF')});
20062076
}
20072077

2078+
@Component({
2079+
selector: 'form-control-select-ngValue',
2080+
template: `
2081+
<div [formGroup]="form">
2082+
<select formControlName="city">
2083+
<option *ngFor="let c of cities" [ngValue]="c">{{c.name}}</option>
2084+
</select>
2085+
</div>`
2086+
})
2087+
class FormControlSelectNgValue {
2088+
cities = [{id: 1, name: 'SF'}, {id: 2, name: 'NY'}];
2089+
form = new FormGroup({city: new FormControl(this.cities[0])});
2090+
}
2091+
2092+
@Component({
2093+
selector: 'form-control-select-compare-with',
2094+
template: `
2095+
<div [formGroup]="form">
2096+
<select formControlName="city" [compareWith]="compareFn">
2097+
<option *ngFor="let c of cities" [ngValue]="c">{{c.name}}</option>
2098+
</select>
2099+
</div>`
2100+
})
2101+
class FormControlSelectWithCompareFn {
2102+
compareFn: (o1: any, o2: any) => boolean = (o1: any, o2: any) => {
2103+
return o1 && o2 ? o1.id === o2.id : o1 === o2;
2104+
};
2105+
cities = [{id: 1, name: 'SF'}, {id: 2, name: 'NY'}];
2106+
form = new FormGroup({city: new FormControl({id: 1, name: 'SF'})});
2107+
}
2108+
2109+
@Component({
2110+
selector: 'form-control-select-multiple',
2111+
template: `
2112+
<div [formGroup]="form">
2113+
<select multiple formControlName="city">
2114+
<option *ngFor="let c of cities" [value]="c">{{c}}</option>
2115+
</select>
2116+
</div>`
2117+
})
2118+
class FormControlSelectMultiple {
2119+
cities = ['SF', 'NY'];
2120+
form = new FormGroup({city: new FormControl(['SF'])});
2121+
}
2122+
2123+
@Component({
2124+
selector: 'form-control-select-multiple',
2125+
template: `
2126+
<div [formGroup]="form">
2127+
<select multiple formControlName="city">
2128+
<option *ngFor="let c of cities" [ngValue]="c">{{c.name}}</option>
2129+
</select>
2130+
</div>`
2131+
})
2132+
class FormControlSelectMultipleNgValue {
2133+
cities = [{id: 1, name: 'SF'}, {id: 2, name: 'NY'}];
2134+
form = new FormGroup({city: new FormControl([this.cities[0]])});
2135+
}
2136+
2137+
@Component({
2138+
selector: 'form-control-select-multiple-compare-with',
2139+
template: `
2140+
<div [formGroup]="form">
2141+
<select multiple formControlName="city" [compareWith]="compareFn">
2142+
<option *ngFor="let c of cities" [ngValue]="c">{{c.name}}</option>
2143+
</select>
2144+
</div>`
2145+
})
2146+
class FormControlSelectMultipleWithCompareFn {
2147+
compareFn: (o1: any, o2: any) => boolean = (o1: any, o2: any) => {
2148+
return o1 && o2 ? o1.id === o2.id : o1 === o2;
2149+
};
2150+
cities = [{id: 1, name: 'SF'}, {id: 2, name: 'NY'}];
2151+
form = new FormGroup({city: new FormControl([{id: 1, name: 'SF'}])});
2152+
}
2153+
20082154
@Component({
20092155
selector: 'wrapped-value-form',
20102156
template: `

0 commit comments

Comments
 (0)
0