From 09a034c4029ef19bfaa271a92d0dce74ea103179 Mon Sep 17 00:00:00 2001 From: Philip Pham Date: Sat, 13 May 2017 17:32:59 -0700 Subject: [PATCH] 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. --- src/demo-app/select/select-demo.html | 27 +++++++ src/demo-app/select/select-demo.ts | 19 +++++ src/lib/select/select.spec.ts | 108 ++++++++++++++++++++++++++- src/lib/select/select.ts | 48 +++++++++--- 4 files changed, 189 insertions(+), 13 deletions(-) diff --git a/src/demo-app/select/select-demo.html b/src/demo-app/select/select-demo.html index fbb317a33f89..5e6a983b1b02 100644 --- a/src/demo-app/select/select-demo.html +++ b/src/demo-app/select/select-demo.html @@ -113,5 +113,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 6318b2a6272c..c8b4c1c9e05e 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; latestChangeEvent: MdSelectChange; @@ -23,6 +25,7 @@ export class SelectDemo { topHeightCtrl = new FormControl(0); drinksTheme = 'primary'; pokemonTheme = 'primary'; + compareByValue = true; foods = [ {value: null, viewValue: 'None'}, @@ -101,4 +104,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 01c09e97ca10..28ef56019bf0 100644 --- a/src/lib/select/select.spec.ts +++ b/src/lib/select/select.spec.ts @@ -63,7 +63,9 @@ describe('MdSelect', () => { ResetValuesSelect, FalsyValueSelect, SelectWithGroups, - InvalidSelectInForm + InvalidSelectInForm, + FalsyValueSelect, + NgModelCompareWithSelect, ], providers: [ {provide: OverlayContainer, useFactory: () => { @@ -2361,7 +2363,6 @@ describe('MdSelect', () => { }); - describe('reset values', () => { let fixture: ComponentFixture; let trigger: HTMLElement; @@ -2437,6 +2438,74 @@ 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'); + }); + })); + }); + }); }); @@ -2885,10 +2954,43 @@ class SelectWithGroups { @ViewChildren(MdOption) options: QueryList; } - @Component({ template: `
` }) class InvalidSelectInForm { value: any; } + +@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 0ee23309ac06..28079db0562a 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) { + if (this._selectionModel) { + // A different comparator means the selection could change. + this._initializeSelection(); + } + this._compareWith = fn; + } + /** Whether to float the placeholder text. */ @Input() get floatPlaceholder(): FloatPlaceholderType { return this._floatPlaceholder; } @@ -377,11 +391,7 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On this._changeSubscription = startWith.call(this.options.changes, null).subscribe(() => { this._resetOptions(); - if (this._control) { - // Defer setting the value in order to avoid the "Expression - // has changed after it was checked" errors from Angular. - Promise.resolve(null).then(() => this._setSelectionByValue(this._control.value)); - } + this._initializeSelection(); }); } @@ -600,6 +610,14 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On scrollContainer!.scrollTop = this._scrollTop; } + private _initializeSelection(): void { + if (this._control) { + // Defer setting the value in order to avoid the "Expression + // has changed after it was checked" errors from Angular. + Promise.resolve(null).then(() => this._setSelectionByValue(this._control.value)); + } + } + /** * Sets the selected option based on a value. If no option can be * found with the designated value, the select trigger is cleared. @@ -633,11 +651,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();