diff --git a/src/demo-app/select/select-demo.html b/src/demo-app/select/select-demo.html index e5444b1b66e7..280494cc935f 100644 --- a/src/demo-app/select/select-demo.html +++ b/src/demo-app/select/select-demo.html @@ -129,5 +129,32 @@ +
+ + compareWith + + + + {{ drink.viewValue }} + + +

Value: {{ currentDrinkObject | json }}

+

Touched: {{ drinkObjectControl.touched }}

+

Dirty: {{ drinkObjectControl.dirty }}

+

Status: {{ drinkObjectControl.control?.status }}

+

Comparison Mode: {{ compareByValue ? 'VALUE' : 'REFERENCE' }}

+ + + + +
+
+
+
This div is for testing scrolled selects.
diff --git a/src/demo-app/select/select-demo.ts b/src/demo-app/select/select-demo.ts index 9a35b612e1c2..fe91f63bd961 100644 --- a/src/demo-app/select/select-demo.ts +++ b/src/demo-app/select/select-demo.ts @@ -10,11 +10,13 @@ import {MdSelectChange} from '@angular/material'; }) export class SelectDemo { drinksRequired = false; + drinkObjectRequired = false; pokemonRequired = false; drinksDisabled = false; pokemonDisabled = false; showSelect = false; currentDrink: string; + currentDrinkObject: {} = {value: 'tea-5', viewValue: 'Tea'}; currentPokemon: string[]; currentPokemonFromGroup: string; currentDigimon: string; @@ -24,6 +26,7 @@ export class SelectDemo { topHeightCtrl = new FormControl(0); drinksTheme = 'primary'; pokemonTheme = 'primary'; + compareByValue = true; foods = [ {value: null, viewValue: 'None'}, @@ -111,4 +114,20 @@ export class SelectDemo { setPokemonValue() { this.currentPokemon = ['eevee-4', 'psyduck-6']; } + + setDrinkObjectByCopy(selectedDrink: {}) { + if (selectedDrink) { + this.currentDrinkObject = Object.assign({}, selectedDrink); + } else { + this.currentDrinkObject = undefined; + } + } + + compareDrinkObjectsByValue(d1: {value: string}, d2: {value: string}) { + return d1 && d2 && d1.value === d2.value; + } + + compareByReference(o1: any, o2: any) { + return o1 === o2; + } } diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts index ef8bd567ad59..0824f26650c5 100644 --- a/src/lib/select/select.spec.ts +++ b/src/lib/select/select.spec.ts @@ -66,7 +66,9 @@ describe('MdSelect', () => { InvalidSelectInForm, BasicSelectWithoutForms, BasicSelectWithoutFormsPreselected, - BasicSelectWithoutFormsMultiple + BasicSelectWithoutFormsMultiple, + FalsyValueSelect, + NgModelCompareWithSelect, ], providers: [ {provide: OverlayContainer, useFactory: () => { @@ -2571,8 +2573,75 @@ describe('MdSelect', () => { }); -}); + describe('compareWith behavior', () => { + let fixture: ComponentFixture; + let instance: NgModelCompareWithSelect; + + beforeEach(async(() => { + fixture = TestBed.createComponent(NgModelCompareWithSelect); + instance = fixture.componentInstance; + spyOn(instance, 'compareByReference').and.callThrough(); + fixture.detectChanges(); + })); + + const testCompareByReferenceBehavior = () => { + it('should initialize with no selection despite having a value', () => { + expect(instance.selectedFood.value).toBe('pizza-1'); + expect(instance.select.selected).toBeUndefined(); + }); + + it('should not update the selection when changing the value', async(() => { + instance.options.first._selectViaInteraction(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(instance.selectedFood.value).toEqual('steak-0'); + expect(instance.select.selected).toBeUndefined(); + }); + })); + }; + + it('should not use the comparator', () => { + expect(instance.compareByReference).not.toHaveBeenCalled(); + }); + testCompareByReferenceBehavior(); + + describe('when comparing by reference', () => { + beforeEach(async(() => { + instance.useCompareByReference(); + fixture.detectChanges(); + })); + + it('should use the comparator', () => { + expect(instance.compareByReference).toHaveBeenCalled(); + }); + + testCompareByReferenceBehavior(); + }); + + describe('when comparing by value', () => { + beforeEach(async(() => { + instance.useCompareByValue(); + fixture.detectChanges(); + })); + + it('should have a selection', () => { + const selectedOption = instance.select.selected as MdOption; + expect(selectedOption.value.value).toEqual('pizza-1'); + }); + + it('should update when making a new selection', async(() => { + instance.options.last._selectViaInteraction(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + const selectedOption = instance.select.selected as MdOption; + expect(instance.selectedFood.value).toEqual('tacos-2'); + expect(selectedOption.value.value).toEqual('tacos-2'); + }); + })); + }); + }); +}); @Component({ selector: 'basic-select', @@ -3019,7 +3088,6 @@ class SelectWithGroups { @ViewChildren(MdOption) options: QueryList; } - @Component({ template: `
` }) @@ -3027,7 +3095,6 @@ class InvalidSelectInForm { value: any; } - @Component({ template: ` @@ -3086,3 +3153,37 @@ class BasicSelectWithoutFormsMultiple { @ViewChild(MdSelect) select: MdSelect; } + +@Component({ + selector: 'ng-model-compare-with', + template: ` + + {{ food.viewValue }} + + ` +}) +class NgModelCompareWithSelect { + foods: ({value: string, viewValue: string})[] = [ + { value: 'steak-0', viewValue: 'Steak' }, + { value: 'pizza-1', viewValue: 'Pizza' }, + { value: 'tacos-2', viewValue: 'Tacos' }, + ]; + selectedFood: {value: string, viewValue: string} = { value: 'pizza-1', viewValue: 'Pizza' }; + comparator: (f1: any, f2: any) => boolean; + + @ViewChild(MdSelect) select: MdSelect; + @ViewChildren(MdOption) options: QueryList; + + useCompareByValue() { this.comparator = this.compareByValue; } + + useCompareByReference() { this.comparator = this.compareByReference; } + + compareByValue(f1: any, f2: any) { return f1 && f2 && f1.value === f2.value; } + + compareByReference(f1: any, f2: any) { return f1 === f2; } + + setFoodByCopy(newValue: {value: string, viewValue: string}) { + this.selectedFood = Object.assign({}, newValue); + } +} diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts index 9bd7ff685fed..c3e858bd8300 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -27,6 +27,7 @@ import { Inject, ChangeDetectionStrategy, InjectionToken, + isDevMode, } from '@angular/core'; import {MdOption, MdOptionSelectionChange, MdOptgroup} from '../core/option/index'; import {ENTER, SPACE, UP_ARROW, DOWN_ARROW, HOME, END} from '../core/keyboard/keycodes'; @@ -194,6 +195,9 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On /** Whether the component is in multiple selection mode. */ private _multiple: boolean = false; + /** Comparison function to specify which option is displayed. Defaults to object equality. */ + private _compareWith = (o1: any, o2: any) => o1 === o2; + /** Deals with the selection logic. */ _selectionModel: SelectionModel; @@ -308,6 +312,16 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On this._multiple = coerceBooleanProperty(value); } + @Input() + get compareWith() { return this._compareWith; } + set compareWith(fn: (o1: any, o2: any) => boolean) { + this._compareWith = fn; + if (this._selectionModel) { + // A different comparator means the selection could change. + this._initializeSelection(); + } + } + /** Whether to float the placeholder text. */ @Input() get floatPlaceholder(): FloatPlaceholderType { return this._floatPlaceholder; } @@ -391,13 +405,8 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On this._initKeyManager(); this._changeSubscription = startWith.call(this.options.changes, null).subscribe(() => { - this._resetOptions(); - - // Defer setting the value in order to avoid the "Expression - // has changed after it was checked" errors from Angular. - Promise.resolve().then(() => { - this._setSelectionByValue(this._control ? this._control.value : this._value); - }); + this._resetOptions(); + this._initializeSelection(); }); } @@ -616,6 +625,14 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On scrollContainer!.scrollTop = this._scrollTop; } + private _initializeSelection(): void { + // Defer setting the value in order to avoid the "Expression + // has changed after it was checked" errors from Angular. + Promise.resolve().then(() => { + this._setSelectionByValue(this._control ? this._control.value : this._value); + }); + } + /** * Sets the selected option based on a value. If no option can be * found with the designated value, the select trigger is cleared. @@ -649,11 +666,21 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On * Finds and selects and option based on its value. * @returns Option that has the corresponding value. */ - private _selectValue(value: any, isUserInput = false): MdOption | undefined { - let optionsArray = this.options.toArray(); - let correspondingOption = optionsArray.find(option => { - return option.value != null && option.value === value; - }); + private _selectValue(value: any, isUserInput = false): MdOption|undefined { + const optionsArray = this.options.toArray(); + + const findOption = (option: MdOption) => { + try { + return this._compareWith(option.value, value); + } catch (error) { + if (isDevMode()) { + // Notify developers of errors in their comparator. + console.warn(error); + } + return false; + } + }; + const correspondingOption = optionsArray.find(findOption); if (correspondingOption) { isUserInput ? correspondingOption._selectViaInteraction() : correspondingOption.select();