Skip to content

Commit

Permalink
feat(select): Implement compareWith so custom comparators can be used.
Browse files Browse the repository at this point in the history
Fixes Issue angular#2250 and Issue angular#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.
  • Loading branch information
ppham27 committed Aug 14, 2017
1 parent 85a6fff commit 99543dc
Show file tree
Hide file tree
Showing 4 changed files with 194 additions and 15 deletions.
27 changes: 27 additions & 0 deletions src/demo-app/select/select-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -129,5 +129,32 @@
</md-card>
</div>

<div *ngIf="showSelect">
<md-card>
<md-card-subtitle>compareWith</md-card-subtitle>
<md-card-content>
<md-select placeholder="Drink" [color]="drinksTheme"
[ngModel]="currentDrinkObject"
(ngModelChange)="setDrinkObjectByCopy($event)"
[required]="drinkObjectRequired"
[compareWith]="compareByValue ? compareDrinkObjectsByValue : compareByReference"
#drinkObjectControl="ngModel">
<md-option *ngFor="let drink of drinks" [value]="drink" [disabled]="drink.disabled">
{{ drink.viewValue }}
</md-option>
</md-select>
<p> Value: {{ currentDrinkObject | json }} </p>
<p> Touched: {{ drinkObjectControl.touched }} </p>
<p> Dirty: {{ drinkObjectControl.dirty }} </p>
<p> Status: {{ drinkObjectControl.control?.status }} </p>
<p> Comparison Mode: {{ compareByValue ? 'VALUE' : 'REFERENCE' }} </p>

<button md-button (click)="drinkObjectRequired=!drinkObjectRequired">TOGGLE REQUIRED</button>
<button md-button (click)="compareByValue=!compareByValue">TOGGLE COMPARE BY VALUE</button>
<button md-button (click)="drinkObjectControl.reset()">RESET</button>
</md-card-content>
</md-card>
</div>

</div>
<div style="height: 500px">This div is for testing scrolled selects.</div>
19 changes: 19 additions & 0 deletions src/demo-app/select/select-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +26,7 @@ export class SelectDemo {
topHeightCtrl = new FormControl(0);
drinksTheme = 'primary';
pokemonTheme = 'primary';
compareByValue = true;

foods = [
{value: null, viewValue: 'None'},
Expand Down Expand Up @@ -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;
}
}
112 changes: 109 additions & 3 deletions src/lib/select/select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ describe('MdSelect', () => {
BasicSelectWithoutFormsMultiple,
SelectInsideFormGroup,
SelectWithCustomTrigger
FalsyValueSelect,
NgModelCompareWithSelect,
],
providers: [
{provide: OverlayContainer, useFactory: () => {
Expand Down Expand Up @@ -2690,8 +2692,78 @@ describe('MdSelect', () => {

});

});
describe('compareWith behavior', () => {
let fixture: ComponentFixture<NgModelCompareWithSelect>;
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',
Expand Down Expand Up @@ -3139,15 +3211,13 @@ class SelectWithGroups {
@ViewChildren(MdOption) options: QueryList<MdOption>;
}


@Component({
template: `<form><md-select [(ngModel)]="value"></md-select></form>`
})
class InvalidSelectInForm {
value: any;
}


@Component({
template: `
<form [formGroup]="formGroup">
Expand Down Expand Up @@ -3226,6 +3296,7 @@ class BasicSelectWithoutFormsMultiple {
@ViewChild(MdSelect) select: MdSelect;
}


@Component({
selector: 'select-with-custom-trigger',
template: `
Expand All @@ -3246,3 +3317,38 @@ class SelectWithCustomTrigger {
];
control = new FormControl();
}


@Component({
selector: 'ng-model-compare-with',
template: `
<md-select [ngModel]="selectedFood" (ngModelChange)="setFoodByCopy($event)"
[compareWith]="comparator">
<md-option *ngFor="let food of foods" [value]="food">{{ food.viewValue }}</md-option>
</md-select>
`
})
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<MdOption>;

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);
}
}
51 changes: 39 additions & 12 deletions src/lib/select/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
ViewChild,
ViewEncapsulation,
Directive,
isDevMode,
} from '@angular/core';
import {ControlValueAccessor, FormGroupDirective, NgControl, NgForm} from '@angular/forms';
import {DOWN_ARROW, END, ENTER, HOME, SPACE, UP_ARROW} from '@angular/cdk/keycodes';
Expand Down Expand Up @@ -220,6 +221,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<MdOption>;

Expand Down Expand Up @@ -337,6 +341,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; }
Expand Down Expand Up @@ -433,13 +447,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();
});
}

Expand Down Expand Up @@ -670,6 +679,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.
Expand Down Expand Up @@ -703,11 +720,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();
Expand Down

0 comments on commit 99543dc

Please sign in to comment.