8000 feat(select): implement compareWith for custom comparison (#4540) · angular/components@054ea4d · GitHub
[go: up one dir, main page]

Skip to content

Commit 054ea4d

Browse files
ppham27kara
authored andcommitted
feat(select): implement compareWith for custom comparison (#4540)
Fixes #2250, fixes #2785.
1 parent c86d13c commit 054ea4d

File tree

5 files changed

+222
-14
lines changed

5 files changed

+222
-14
lines changed

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,35 @@
9696
</md-card-content>
9797
</md-card>
9898

99+
100+
<md-card>
101+
<md-card-subtitle>compareWith</md-card-subtitle>
102+
<md-card-content>
103+
<md-select placeholder="Drink" [color]="drinksTheme"
104+
[(ngModel)]="currentDrinkObject"
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)="reassignDrinkByCopy()"
119+
mdTooltip="This action should clear the display value when comparing by reference.">
120+
REASSIGN DRINK BY COPY
121+
</button>
122+
<button md-button (click)="drinkObjectRequired=!drinkObjectRequired">TOGGLE REQUIRED</button>
123+
<button md-button (click)="compareByValue=!compareByValue">TOGGLE COMPARE BY VALUE</button>
124+
<button md-button (click)="drinkObjectControl.reset()">RESET</button>
125+
</md-card-content>
126+
</md-card>
127+
99128
<div *ngIf="showSelect">
100129
<md-card>
101130
<md-card-subtitle>formControl</md-card-subtitle>

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+
reassignDrinkByCopy() {
119+
this.currentDrinkObject = {...this.currentDrinkObject};
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-errors.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
*/
88

99
/**
10-
* Returns an exception to be thrown when attempting to change a s
11-
* elect's `multiple` option after initialization.
10+
* Returns an exception to be thrown when attempting to change a select's `multiple` option
11+
* after initialization.
1212
* @docs-private
1313
*/
1414
export function getMdSelectDynamicMultipleError(): Error {
@@ -24,3 +24,12 @@ export function getMdSelectDynamicMultipleError(): Error {
2424
export function getMdSelectNonArrayValueError(): Error {
2525
return Error('Cannot assign truthy non-array value to select in `multiple` mode.');
2626
}
27+
28+
/**
29+
* Returns an exception to be thrown when assigning a non-function value to the comparator
30+
* used to determine if a value corresponds to an option. Note that whether the function
31+
* actually takes two values and returns a boolean is not checked.
32+
*/
33+
export function getMdSelectNonFunctionValueError(): Error {
34+
return Error('Cannot assign a non-function value to `compareWith`.');
35+
}

src/lib/select/select.spec.ts

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,17 @@ import {Subject} from 'rxjs/Subject';
2828
import {map} from 'rxjs/operator/map';
2929
import {MdSelectModule} from './index';
3030
import {MdSelect} from './select';
31-
import {getMdSelectDynamicMultipleError, getMdSelectNonArrayValueError} from './select-errors';
31+
import {
32+
getMdSelectDynamicMultipleError,
33+
getMdSelectNonArrayValueError,
34+
getMdSelectNonFunctionValueError
35+
} from './select-errors';
3236
import {MdOption} from '../core/option/option';
3337
import {
3438
FloatPlaceholderType,
3539
MD_PLACEHOLDER_GLOBAL_OPTIONS
3640
} from '../core/placeholder/placeholder-options';
41+
import {extendObject} from '../core/util/object-extend';
3742

3843

3944
describe('MdSelect', () => {
@@ -73,7 +78,10 @@ describe('MdSelect', () => {
7378
BasicSelectWithoutFormsPreselected,
7479
BasicSelectWithoutFormsMultiple,
7580
SelectInsideFormGroup,
76-
SelectWithCustomTrigger
81+
SelectWithCustomTrigger,
82+
FalsyValueSelect,
83+
SelectInsideFormGroup,
84+
NgModelCompareWithSelect,
7785
],
7886
providers: [
7987
{provide: OverlayContainer, useFactory: () => {
@@ -2714,8 +2722,78 @@ describe('MdSelect', () => {
27142722

27152723
});
27162724

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

2786+
it('should throw an error', () => {
2787+
expect(() => {
2788+
fixture.detectChanges();
2789+
}).toThrowError(wrappedErrorMessage(getMdSelectNonFunctionValueError()));
2790+
});
2791+
2792+
});
2793+
2794+
});
2795+
2796+
});
27192797

27202798
@Component({
27212799
selector: 'basic-select',
@@ -3250,6 +3328,7 @@ class BasicSelectWithoutFormsMultiple {
32503328
@ViewChild(MdSelect) select: MdSelect;
32513329
}
32523330

3331+
32533332
@Component({
32543333
selector: 'select-with-custom-trigger',
32553334
template: `
@@ -3270,3 +3349,40 @@ class SelectWithCustomTrigger {
32703349
];
32713350
control = new FormControl();
32723351
}
3352+
3353+
3354+
@Component({
3355+
selector: 'ng-model-compare-with',
3356+
template: `
3357+
<md-select [ngModel]="selectedFood" (ngModelChange)="setFoodByCopy($event)"
3358+
[compareWith]="comparator">
3359+
<md-option *ngFor="let food of foods" [value]="food">{{ food.viewValue }}</md-option>
3360+
</md-select>
3361+
`
3362+
})
3363+
class NgModelCompareWithSelect {
3364+
foods: ({value: string, viewValue: string})[] = [
3365+
{ value: 'steak-0', viewValue: 'Steak' },
3366+
{ value: 'pizza-1', viewValue: 'Pizza' },
3367+
{ value: 'tacos-2', viewValue: 'Tacos' },
3368+
];
3369+
selectedFood: {value: string, viewValue: string} = { value: 'pizza-1', viewValue: 'Pizza' };
3370+
comparator: ((f1: any, f2: any) => boolean)|null = this.compareByValue;
3371+
< 10000 /td>
3372+
@ViewChild(MdSelect) select: MdSelect;
3373+
@ViewChildren(MdOption) options: QueryList<MdOption>;
3374+
3375+
useCompareByValue() { this.comparator = this.compareByValue; }
3376+
3377+
useCompareByReference() { this.comparator = this.compareByReference; }
3378+
3379+
useNullComparator() { this.comparator = null; }
3380+
3381+
compareByValue(f1: any, f2: any) { return f1 && f2 && f1.value === f2.value; }
3382+
3383+
compareByReference(f1: any, f2: any) { return f1 === f2; }
3384+
3385+
setFoodByCopy(newValue: {value: string, viewValue: string}) {
3386+
this.selectedFood = extendObject({}, newValue);
3387+
}
3388+
}

src/lib/select/select.ts

Lines changed: 48 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';
@@ -51,7 +52,11 @@ import {Observable} from 'rxjs/Observable';
5152
import {Subscription} from 'rxjs/Subscription';
5253
import {fadeInContent, transformPanel, transformPlaceholder} from './select-animations';
5354
import {SelectionModel} from '../core/selection/selection';
54-
import {getMdSelectDynamicMultipleError, getMdSelectNonArrayValueError} from './select-errors';
55+
import {
56+
getMdSelectDynamicMultipleError,
57+
getMdSelectNonArrayValueError,
58+
getMdSelectNonFunctionValueError
59+
} from './select-errors';
5560
import {CanColor, mixinColor} from '../core/common-behaviors/color';
5661
import {CanDisable, mixinDisabled} from '../core/common-behaviors/disabled';
5762
import {MdOptgroup, MdOption, MdOptionSelectionChange} from '../core/option/index';
@@ -220,6 +225,9 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
220225
/** Whether the component is in multiple selection mode. */
221226
private _multiple: boolean = false;
222227

228+
/** Comparison function to specify which option is displayed. Defaults to object equality. */
229+
private _compareWith = (o1: any, o2: any) => o1 === o2;
230+
223231
/** Deals with the selection logic. */
224232
_selectionModel: SelectionModel<MdOption>;
225233

@@ -337,6 +345,24 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
337345
this._multiple = coerceBooleanProperty(value);
338346
}
339347

348+
/**
349+
* A function to compare the option values with the selected values. The first argument
350+
* is a value from an option. The second is a value from the selection. A boolean
351+
* should be returned.
352+
*/
353+
@Input()
354+
get compareWith() { return this._compareWith; }
355+
set compareWith(fn: (o1: any, o2: any) => boolean) {
356+
if (typeof fn !== 'function') {
357+
throw getMdSelectNonFunctionValueError();
358+
}
359+
this._compareWith = fn;
360+
if (this._selectionModel) {
361+
// A different comparator means the selection could change.
362+
this._initializeSelection();
363+
}
364+
}
365+
340366
/** Whether to float the placeholder text. */
341367
@Input()
342368
get floatPlaceholder(): FloatPlaceholderType { return this._floatPlaceholder; }
@@ -434,12 +460,7 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
434460

435461
this._changeSubscription = startWith.call(this.options.changes, null).subscribe(() => {
436462
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-
});
463+
this._initializeSelection();
443464
});
444465
}
445466

@@ -670,6 +691,14 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
670691
scrollContainer!.scrollTop = this._scrollTop;
671692
}
672693

694+
private _initializeSelection(): void {
695+
// Defer setting the value in order to avoid the "Expression
696+
// has changed after it was checked" errors from Angular.
697+
Promise.resolve().then(() => {
698+
this._setSelectionByValue(this._control ? this._control.value : this._value);
699+
});
700+
}
701+
673702
/**
674703
* Sets the selected option based on a value. If no option can be
675704
* found with the designated value, the select trigger is cleared.
@@ -710,8 +739,17 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
710739
* @returns Option that has the corresponding value.
711740
*/
712741
private _selectValue(value: any, isUserInput = false): MdOption | undefined {
713-
let correspondingOption = this.options.find(option => {
714-
return option.value != null && option.value === value;
742+
const correspondingOption = this.options.find((option: MdOption) => {
743+
try {
744+
// Treat null as a special reset value.
745+
return option.value != null && this._compareWith(option.value, value);
746+
} catch (error) {
747+
if (isDevMode()) {
748+
// Notify developers of errors in their comparator.
749+
console.warn(error);
750+
}
751+
return false;
752+
}
715753
});
716754

717755
if (correspondingOption) {
@@ -722,6 +760,7 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
722760
return correspondingOption;
723761
}
724762

763+
725764
/**
726765
* Clears the select trigger and deselects every option in the list.
727766
* @param skip Option that should not be deselected.

0 commit comments

Comments
 (0)
0