Skip to content

Commit 3025783

Browse files
committed
feat(select): Implement compareWith so custom comparators can be used.
Fixes Issue #2250 and Issue #2785. Users can supply a custom comparator that tests for equality. The comparator can be changed dynamically at which point the selection may change. If the comparator throws an exception, it will log a warning in developer mode but will be swallowed in production mode.
1 parent b5fc68b commit 3025783

File tree

4 files changed

+196
-11
lines changed

4 files changed

+196
-11
lines changed

src/demo-app/select/select-demo.html

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,5 +129,32 @@
129129
</md-card>
130130
</div>
131131

132+
<div *ngIf="showSelect">
133+
<md-card>
134+
<md-card-subtitle>compareWith</md-card-subtitle>
135+
<md-card-content>
136+
<md-select placeholder="Drink" [color]="drinksTheme"
137+
[ngModel]="currentDrinkObject"
138+
(ngModelChange)="setDrinkObjectByCopy($event)"
139+
[required]="drinkObjectRequired"
140+
[compareWith]="compareByValue ? compareDrinkObjectsByValue : compareByReference"
141+
#drinkObjectControl="ngModel">
142+
<md-option *ngFor="let drink of drinks" [value]="drink" [disabled]="drink.disabled">
143+
{{ drink.viewValue }}
144+
</md-option>
145+
</md-select>
146+
<p> Value: {{ currentDrinkObject | json }} </p>
147+
<p> Touched: {{ drinkObjectControl.touched }} </p>
148+
<p> Dirty: {{ drinkObjectControl.dirty }} </p>
149+
<p> Status: {{ drinkObjectControl.control?.status }} </p>
150+
<p> Comparison Mode: {{ compareByValue ? 'VALUE' : 'REFERENCE' }} </p>
151+
152+
<button md-button (click)="drinkObjectRequired=!drinkObjectRequired">TOGGLE REQUIRED</button>
153+
<button md-button (click)="compareByValue=!compareByValue">TOGGLE COMPARE BY VALUE</button>
154+
<button md-button (click)="drinkObjectControl.reset()">RESET</button>
155+
</md-card-content>
156+
</md-card>
157+
</div>
158+
132159
</div>
133160
<div style="height: 500px">This div is for testing scrolled selects.</div>

src/demo-app/select/select-demo.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import {MdSelectChange} from '@angular/material';
1010
})
1111
export class SelectDemo {
1212
drinksRequired = false;
13+
drinkObjectRequired = false;
1314
pokemonRequired = false;
1415
drinksDisabled = false;
1516
pokemonDisabled = false;
1617
showSelect = false;
1718
currentDrink: string;
19+
currentDrinkObject: {}|undefined = {value: 'tea-5', viewValue: 'Tea'};
1820
currentPokemon: string[];
1921
currentPokemonFromGroup: string;
2022
currentDigimon: string;
@@ -24,6 +26,7 @@ export class SelectDemo {
2426
topHeightCtrl = new FormControl(0);
2527
drinksTheme = 'primary';
2628
pokemonTheme = 'primary';
29+
compareByValue = true;
2730

2831
foods = [
2932
{value: null, viewValue: 'None'},
@@ -111,4 +114,16 @@ export class SelectDemo {
111114
setPokemonValue() {
112115
this.currentPokemon = ['eevee-4', 'psyduck-6'];
113116
}
117+
118+
setDrinkObjectByCopy(selectedDrink: {}) {
119+
this.currentDrinkObject = selectedDrink ? {...selectedDrink} : undefined;
120+
}
121+
122+
compareDrinkObjectsByValue(d1: {value: string}, d2: {value: string}) {
123+
return d1 && d2 && d1.value === d2.value;
124+
}
125+
126+
compareByReference(o1: any, o2: any) {
127+
return o1 === o2;
128+
}
114129
}

src/lib/select/select.spec.ts

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,10 @@ describe('MdSelect', () => {
7373
BasicSelectWithoutFormsPreselected,
7474
BasicSelectWithoutFormsMultiple,
7575
SelectInsideFormGroup,
76-
SelectWithCustomTrigger
76+
SelectWithCustomTrigger,
77+
FalsyValueSelect,
78+
SelectInsideFormGroup,
79+
NgModelCompareWithSelect,
7780
],
7881
providers: [
7982
{provide: OverlayContainer, useFactory: () => {
@@ -2715,8 +2718,74 @@ describe('MdSelect', () => {
27152718

27162719
});
27172720

2718-
});
2721+
describe('compareWith behavior', () => {
2722+
let fixture: ComponentFixture<NgModelCompareWithSelect>;
2723+
let instance: NgModelCompareWithSelect;
2724+
2725+
beforeEach(async(() => {
2726+
fixture = TestBed.createComponent(NgModelCompareWithSelect);
2727+
instance = fixture.componentInstance;
2728+
fixture.detectChanges();
2729+
}));
2730+
2731+
it('should have a selection', () => {
2732+
const selectedOption = instance.select.selected as MdOption;
2733+
expect(selectedOption.value.value).toEqual('pizza-1');
2734+
});
2735+
2736+
it('should update when making a new selection', async(() => {
2737+
instance.options.last._selectViaInteraction();
2738+
fixture.detectChanges();
2739+
fixture.whenStable().then(() => {
2740+
const selectedOption = instance.select.selected as MdOption;
2741+
expect(instance.selectedFood.value).toEqual('tacos-2');
2742+
expect(selectedOption.value.value).toEqual('tacos-2');
2743+
});
2744+
}));
2745+
2746+
describe('when comparing by reference', () => {
2747+
beforeEach(async(() => {
2748+
spyOn(instance, 'compareByReference').and.callThrough();
2749+
instance.useCompareByReference();
2750+
fixture.detectChanges();
2751+
}));
2752+
2753+
it('should use the comparator', () => {
2754+
expect(instance.compareByReference).toHaveBeenCalled();
2755+
});
2756+
2757+
it('should initialize with no selection despite having a value', () => {
2758+
expect(instance.selectedFood.value).toBe('pizza-1');
2759+
expect(instance.select.selected).toBeUndefined();
2760+
});
2761+
2762+
it('should not update the selection when changing the value', async(() => {
2763+
instance.options.first._selectViaInteraction();
2764+
fixture.detectChanges();
2765+
fixture.whenStable().then(() => {
2766+
expect(instance.selectedFood.value).toEqual('steak-0');
2767+
expect(instance.select.selected).toBeUndefined();
2768+
});
2769+
}));
2770+
2771+
});
2772+
2773+
describe('when using a non-function comparator', () => {
2774+
beforeEach(() => {
2775+
instance.useNullComparator();
2776+
});
27192777

2778+
it('should throw an error', () => {
2779+
expect(() => {
2780+
fixture.detectChanges();
2781+
}).toThrowError('compareWith must be a function, but received null');
2782+
});
2783+
2784+
});
2785+
2786+
});
2787+
2788+
});
27202789

27212790
@Component({
27222791
selector: 'basic-select',
@@ -3251,6 +3320,7 @@ class BasicSelectWithoutFormsMultiple {
32513320
@ViewChild(MdSelect) select: MdSelect;
32523321
}
32533322

3323+
32543324
@Component({
32553325
selector: 'select-with-custom-trigger',
32563326
template: `
@@ -3271,3 +3341,40 @@ class SelectWithCustomTrigger {
32713341
];
32723342
control = new FormControl();
32733343
}
3344+
3345+
3346+
@Component({
3347+
selector: 'ng-model-compare-with',
3348+
template: `
3349+
<md-select [ngModel]="selectedFood" (ngModelChange)="setFoodByCopy($event)"
3350+
[compareWith]="comparator">
3351+
<md-option *ngFor="let food of foods" [value]="food">{{ food.viewValue }}</md-option>
3352+
</md-select>
3353+
`
3354+
})
3355+
class NgModelCompareWithSelect {
3356+
foods: ({value: string, viewValue: string})[] = [
3357+
{ value: 'steak-0', viewValue: 'Steak' },
3358+
{ value: 'pizza-1', viewValue: 'Pizza' },
3359+
{ value: 'tacos-2', viewValue: 'Tacos' },
3360+
];
3361+
selectedFood: {value: string, viewValue: string} = { value: 'pizza-1', viewValue: 'Pizza' };
3362+
comparator: ((f1: any, f2: any) => boolean)|null = this.compareByValue;
3363+
3364+
@ViewChild(MdSelect) select: MdSelect;
3365+
@ViewChildren(MdOption) options: QueryList<MdOption>;
3366+
3367+
useCompareByValue() { this.comparator = this.compareByValue; }
3368+
3369+
useCompareByReference() { this.comparator = this.compareByReference; }
3370+
3371+
useNullComparator() { this.comparator = null; }
3372+
3373+
compareByValue(f1: any, f2: any) { return f1 && f2 && f1.value === f2.value; }
3374+
3375+
compareByReference(f1: any, f2: any) { return f1 === f2; }
3376+
3377+
setFoodByCopy(newValue: {value: string, viewValue: string}) {
3378+
this.selectedFood = Object.assign({}, newValue);
3379+
}
3380+
}

src/lib/select/select.ts

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
ViewChild,
3030
ViewEncapsulation,
3131
Directive,
32+
isDevMode,
3233
} from '@angular/core';
3334
import {ControlValueAccessor, FormGroupDirective, NgControl, NgForm} from '@angular/forms';
3435
import {DOWN_ARROW, END, ENTER, HOME, SPACE, UP_ARROW} from '@angular/cdk/keycodes';
@@ -220,6 +221,9 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
220221
/** Whether the component is in multiple selection mode. */
221222
private _multiple: boolean = false;
222223

224+
/** Comparison function to specify which option is displayed. Defaults to object equality. */
225+
private _compareWith = (o1: any, o2: any) => o1 === o2;
226+
223227
/** Deals with the selection logic. */
224228
_selectionModel: SelectionModel<MdOption>;
225229

@@ -337,6 +341,25 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
337341
this._multiple = coerceBooleanProperty(value);
338342
}
339343

344+
/**
345+
* A function to compare the option values with the selected values. The first argument
346+
* is a value from an option. The second is a value from the selection. A boolean
347+
* should be returned.
348+
*/
349+
@Input()
350+
get compareWith() { return this._compareWith; }
351+
set compareWith(fn: (o1: any, o2: any) => boolean) {
352+
if (typeof fn !== 'function') {
353+
throw new TypeError(
354+
`compareWith must be a function, but received ${JSON.stringify(fn)}`);
355+
}
356+
this._compareWith = fn;
357+
if (this._selectionModel) {
358+
// A different comparator means the selection could change.
359+
this._initializeSelection();
360+
}
361+
}
362+
340363
/** Whether to float the placeholder text. */
341364
@Input()
342365
get floatPlaceholder(): FloatPlaceholderType { return this._floatPlaceholder; }
@@ -434,12 +457,7 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
434457

435458
this._changeSubscription = startWith.call(this.options.changes, null).subscribe(() => {
436459
this._resetOptions();
437-
438-
// Defer setting the value in order to avoid the "Expression
439-
// has changed after it was checked" errors from Angular.
440-
Promise.resolve().then(() => {
441-
this._setSelectionByValue(this._control ? this._control.value : this._value);
442-
});
460+
this._initializeSelection();
443461
});
444462
}
445463

@@ -670,6 +688,14 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
670688
scrollContainer!.scrollTop = this._scrollTop;
671689
}
672690

691+
private _initializeSelection(): void {
692+
// Defer setting the value in order to avoid the "Expression
693+
// has changed after it was checked" errors from Angular.
694+
Promise.resolve().then(() => {
695+
this._setSelectionByValue(this._control ? this._control.value : this._value);
696+
});
697+
}
698+
673699
/**
674700
* Sets the selected option based on a value. If no option can be
675701
* found with the designated value, the select trigger is cleared.
@@ -710,18 +736,28 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
710736
* @returns Option that has the corresponding value.
711737
*/
712738
private _selectValue(value: any, isUserInput = false): MdOption | undefined {
713-
let correspondingOption = this.options.find(option => {
714-
return option.value != null && option.value === value;
739+
const correspondingOption = this.options.find((option: MdOption) => {
740+
try {
741+
// Treat null as a special reset value.
742+
return option.value != null && this._compareWith(option.value, value);
743+
} catch (error) {
744+
if (isDevMode()) {
745+
// Notify developers of errors in their comparator.
746+
console.warn(error);
747+
}
748+
return false;
749+
}
715750
});
716751

717752
if (correspondingOption) {
718753
isUserInput ? correspondingOption._selectViaInteraction() : correspondingOption.select();
719754
this._selectionModel.select(correspondingOption);
720755
}
721-
756+
722757
return correspondingOption;
723758
}
724759

760+
725761
/**
726762
* Clears the select trigger and deselects every option in the list.
727763
* @param skip Option that should not be deselected.

0 commit comments

Comments
 (0)