Skip to content

Commit 872cf53

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 6121fa1 commit 872cf53

File tree

4 files changed

+186
-8
lines changed

4 files changed

+186
-8
lines changed

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,5 +95,32 @@
9595
</md-card>
9696
</div>
9797

98+
<div *ngIf="showSelect">
99+
<md-card>
100+
<md-card-subtitle>compareWith</md-card-subtitle>
101+
<md-card-content>
102+
<md-select placeholder="Drink" [color]="drinksTheme"
103+
[ngModel]="currentDrinkObject"
104+
(ngModelChange)="setDrinkObjectByCopy($event)"
105+
[required]="drinkObjectRequired"
106+
[compareWith]="compareByValue ? compareDrinkObjectsByValue : compareByReference"
107+
#drinkObjectControl="ngModel">
108+
<md-option *ngFor="let drink of drinks" [value]="drink" [disabled]="drink.disabled">
109+
{{ drink.viewValue }}
110+
</md-option>
111+
</md-select>
112+
<p> Value: {{ currentDrinkObject | json }} </p>
113+
<p> Touched: {{ drinkObjectControl.touched }} </p>
114+
<p> Dirty: {{ drinkObjectControl.dirty }} </p>
115+
<p> Status: {{ drinkObjectControl.control?.status }} </p>
116+
<p> Comparison Mode: {{ compareByValue ? 'VALUE' : 'REFERENCE' }} </p>
117+
118+
<button md-button (click)="drinkObjectRequired=!drinkObjectRequired">TOGGLE REQUIRED</button>
119+
<button md-button (click)="compareByValue=!compareByValue">TOGGLE COMPARE BY VALUE</button>
120+
<button md-button (click)="drinkObjectControl.reset()">RESET</button>
121+
</md-card-content>
122+
</md-card>
123+
</div>
124+
98125
</div>
99126
<div style="height: 500px">This div is for testing scrolled selects.</div>

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,20 @@ 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: {} = {value: 'tea-5', viewValue: 'Tea'};
1820
currentPokemon: string[];
1921
latestChangeEvent: MdSelectChange;
2022
floatPlaceholder: string = 'auto';
2123
foodControl = new FormControl('pizza-1');
2224
drinksTheme = 'primary';
2325
pokemonTheme = 'primary';
26+
compareByValue = true;
2427

2528
foods = [
2629
{value: 'steak-0', viewValue: 'Steak'},
@@ -63,4 +66,20 @@ export class SelectDemo {
6366
setPokemonValue() {
6467
this.currentPokemon = ['eevee-4', 'psyduck-6'];
6568
}
69+
70+
setDrinkObjectByCopy(selectedDrink: {}) {
71+
if (selectedDrink) {
72+
this.currentDrinkObject = Object.assign({}, selectedDrink);
73+
} else {
74+
this.currentDrinkObject = undefined;
75+
}
76+
}
77+
78+
compareDrinkObjectsByValue(d1: {value: string}, d2: {value: string}) {
79+
return d1 && d2 && d1.value === d2.value;
80+
}
81+
82+
compareByReference(o1: any, o2: any) {
83+
return o1 === o2;
84+
}
6685
}

src/lib/select/select.spec.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ describe('MdSelect', () => {
5656
SelectEarlyAccessSibling,
5757
BasicSelectInitiallyHidden,
5858
BasicSelectNoPlaceholder,
59-
BasicSelectWithTheming
59+
BasicSelectWithTheming,
60+
NgModelCompareWithSelect,
6061
],
6162
providers: [
6263
{provide: OverlayContainer, useFactory: () => {
@@ -1995,6 +1996,74 @@ describe('MdSelect', () => {
19951996

19961997
});
19971998

1999+
describe('compareWith behavior', () => {
2000+
let fixture: ComponentFixture<NgModelCompareWithSelect>;
2001+
let instance: NgModelCompareWithSelect;
2002+
2003+
beforeEach(async(() => {
2004+
fixture = TestBed.createComponent(NgModelCompareWithSelect);
2005+
instance = fixture.componentInstance;
2006+
spyOn(instance, 'compareByReference').and.callThrough();
2007+
fixture.detectChanges();
2008+
}));
2009+
2010+
const testCompareByReferenceBehavior = () => {
2011+
it('should initialize with no selection despite having a value', () => {
2012+
expect(instance.selectedFood.value).toBe('pizza-1');
2013+
expect(instance.select.selected).toBeUndefined();
2014+
});
2015+
2016+
it('should not update the selection when changing the value', async(() => {
2017+
instance.options.first._selectViaInteraction();
2018+
fixture.detectChanges();
2019+
fixture.whenStable().then(() => {
2020+
expect(instance.selectedFood.value).toEqual('steak-0');
2021+
expect(instance.select.selected).toBeUndefined();
2022+
});
2023+
}));
2024+
};
2025+
2026+
it('should not use the comparator', () => {
2027+
expect(instance.compareByReference).not.toHaveBeenCalled();
2028+
});
2029+
2030+
testCompareByReferenceBehavior();
2031+
2032+
describe('when comparing by reference', () => {
2033+
beforeEach(async(() => {
2034+
instance.useCompareByReference();
2035+
fixture.detectChanges();
2036+
}));
2037+
2038+
it('should use the comparator', () => {
2039+
expect(instance.compareByReference).toHaveBeenCalled();
2040+
});
2041+
2042+
testCompareByReferenceBehavior();
2043+
});
2044+
2045+
describe('when comparing by value', () => {
2046+
beforeEach(async(() => {
2047+
instance.useCompareByValue();
2048+
fixture.detectChanges();
2049+
}));
2050+
2051+
it('should have a selection', () => {
2052+
const selectedOption = instance.select.selected as MdOption;
2053+
expect(selectedOption.value.value).toEqual('pizza-1');
2054+
});
2055+
2056+
it('should update when making a new selection', async(() => {
2057+
instance.options.last._selectViaInteraction();
2058+
fixture.detectChanges();
2059+
fixture.whenStable().then(() => {
2060+
const selectedOption = instance.select.selected as MdOption;
2061+
expect(instance.selectedFood.value).toEqual('tacos-2');
2062+
expect(selectedOption.value.value).toEqual('tacos-2');
2063+
});
2064+
}));
2065+
});
2066+
});
19982067
});
19992068

20002069

@@ -2339,3 +2408,37 @@ class BasicSelectWithTheming {
23392408
@ViewChild(MdSelect) select: MdSelect;
23402409
theme: string;
23412410
}
2411+
2412+
@Component({
2413+
selector: 'ng-model-compare-with',
2414+
template: `
2415+
<md-select [ngModel]="selectedFood" (ngModelChange)="setFoodByCopy($event)"
2416+
[compareWith]="comparator">
2417+
<md-option *ngFor="let food of foods" [value]="food">{{ food.viewValue }}</md-option>
2418+
</md-select>
2419+
`
2420+
})
2421+
class NgModelCompareWithSelect {
2422+
foods: ({value: string, viewValue: string})[] = [
2423+
{ value: 'steak-0', viewValue: 'Steak' },
2424+
{ value: 'pizza-1', viewValue: 'Pizza' },
2425+
{ value: 'tacos-2', viewValue: 'Tacos' },
2426+
];
2427+
selectedFood: {value: string, viewValue: string} = { value: 'pizza-1', viewValue: 'Pizza' };
2428+
comparator: (f1: any, f2: any) => boolean;
2429+
2430+
@ViewChild(MdSelect) select: MdSelect;
2431+
@ViewChildren(MdOption) options: QueryList<MdOption>;
2432+
2433+
useCompareByValue() { this.comparator = this.compareByValue; }
2434+
2435+
useCompareByReference() { this.comparator = this.compareByReference; }
2436+
2437+
compareByValue(f1: any, f2: any) { return f1 && f2 && f1.value === f2.value; }
2438+
2439+
compareByReference(f1: any, f2: any) { return f1 === f2; }
2440+
2441+
setFoodByCopy(newValue: {value: string, viewValue: string}) {
2442+
this.selectedFood = Object.assign({}, newValue);
2443+
}
2444+
}

src/lib/select/select.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
ChangeDetectorRef,
1717
Attribute,
1818
OnInit,
19+
isDevMode,
1920
} from '@angular/core';
2021
import {MdOption, MdOptionSelectionChange} from '../core/option/option';
2122
import {ENTER, SPACE, UP_ARROW, DOWN_ARROW} from '../core/keyboard/keycodes';
@@ -150,6 +151,9 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
150151
/** Whether the component is in multiple selection mode. */
151152
private _multiple: boolean = false;
152153

154+
/** Comparison function to specify which option is displayed. Defaults to object equality. */
155+
private _compareWith: ((o1: any, o2: any) => boolean) | null = null;
156+
153157
/** Deals with the selection logic. */
154158
_selectionModel: SelectionModel<MdOption>;
155159

@@ -262,6 +266,16 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
262266
this._multiple = coerceBooleanProperty(value);
263267
}
264268

269+
@Input()
270+
get compareWith() { return this._compareWith; }
271+
set compareWith(fn: (o1: any, o2: any) => boolean) {
272+
if (this._selectionModel) {
273+
// A different comparator means the selection could change.
274+
this._initializeSelection();
275+
}
276+
this._compareWith = fn;
277+
}
278+
265279
/** Whether to float the placeholder text. */
266280
@Input()
267281
get floatPlaceholder(): MdSelectFloatPlaceholderType { return this._floatPlaceholder; }
@@ -333,11 +347,7 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
333347
this._changeSubscription = this.options.changes.startWith(null).subscribe(() => {
334348
this._resetOptions();
335349

336-
if (this._control) {
337-
// Defer setting the value in order to avoid the "Expression
338-
// has changed after it was checked" errors from Angular.
339-
Promise.resolve(null).then(() => this._setSelectionByValue(this._control.value));
340-
}
350+
this._initializeSelection();
341351
});
342352
}
343353

@@ -533,6 +543,14 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
533543
scrollContainer.scrollTop = this._scrollTop;
534544
}
535545

546+
private _initializeSelection(): void {
547+
if (this._control) {
548+
// Defer setting the value in order to avoid the "Expression
549+
// has changed after it was checked" errors from Angular.
550+
Promise.resolve(null).then(() => this._setSelectionByValue(this._control.value));
551+
}
552+
}
553+
536554
/**
537555
* Sets the selected option based on a value. If no option can be
538556
* found with the designated value, the select trigger is cleared.
@@ -566,8 +584,19 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
566584
* @returns Option that has the corresponding value.
567585
*/
568586
private _selectValue(value: any): MdOption {
569-
let optionsArray = this.options.toArray();
570-
let correspondingOption = optionsArray.find(option => option.value === value);
587+
const optionsArray = this.options.toArray();
588+
const correspondingOption = this._compareWith ?
589+
optionsArray.find((option: MdOption) => {
590+
try {
591+
return this._compareWith(option.value, value);
592+
} catch (error) {
593+
if (isDevMode()) {
594+
// Notify developers of errors in their comparator.
595+
console.warn(error);
596+
}
597+
return false;
598+
}
599+
}) : optionsArray.find(option => option.value === value);
571600

572601
if (correspondingOption) {
573602
correspondingOption.select();

0 commit comments

Comments
 (0)