mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(select): implement compareWith for custom comparison (#4540)
Fixes #2250, fixes #2785.
This commit is contained in:
@@ -96,6 +96,35 @@ Space above cards: <input type="number" [formControl]="topHeightCtrl">
|
||||
</md-card-content>
|
||||
</md-card>
|
||||
|
||||
|
||||
<md-card>
|
||||
<md-card-subtitle>compareWith</md-card-subtitle>
|
||||
<md-card-content>
|
||||
<md-select placeholder="Drink" [color]="drinksTheme"
|
||||
[(ngModel)]="currentDrinkObject"
|
||||
[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)="reassignDrinkByCopy()"
|
||||
mdTooltip="This action should clear the display value when comparing by reference.">
|
||||
REASSIGN DRINK BY COPY
|
||||
</button>
|
||||
<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 *ngIf="showSelect">
|
||||
<md-card>
|
||||
<md-card-subtitle>formControl</md-card-subtitle>
|
||||
|
||||
@@ -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: {}|undefined = {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,16 @@ export class SelectDemo {
|
||||
setPokemonValue() {
|
||||
this.currentPokemon = ['eevee-4', 'psyduck-6'];
|
||||
}
|
||||
|
||||
reassignDrinkByCopy() {
|
||||
this.currentDrinkObject = {...this.currentDrinkObject};
|
||||
}
|
||||
|
||||
compareDrinkObjectsByValue(d1: {value: string}, d2: {value: string}) {
|
||||
return d1 && d2 && d1.value === d2.value;
|
||||
}
|
||||
|
||||
compareByReference(o1: any, o2: any) {
|
||||
return o1 === o2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns an exception to be thrown when attempting to change a s
|
||||
* elect's `multiple` option after initialization.
|
||||
* Returns an exception to be thrown when attempting to change a select's `multiple` option
|
||||
* after initialization.
|
||||
* @docs-private
|
||||
*/
|
||||
export function getMdSelectDynamicMultipleError(): Error {
|
||||
@@ -24,3 +24,12 @@ export function getMdSelectDynamicMultipleError(): Error {
|
||||
export function getMdSelectNonArrayValueError(): Error {
|
||||
return Error('Cannot assign truthy non-array value to select in `multiple` mode.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an exception to be thrown when assigning a non-function value to the comparator
|
||||
* used to determine if a value corresponds to an option. Note that whether the function
|
||||
* actually takes two values and returns a boolean is not checked.
|
||||
*/
|
||||
export function getMdSelectNonFunctionValueError(): Error {
|
||||
return Error('Cannot assign a non-function value to `compareWith`.');
|
||||
}
|
||||
|
||||
@@ -28,12 +28,17 @@ import {Subject} from 'rxjs/Subject';
|
||||
import {map} from 'rxjs/operator/map';
|
||||
import {MdSelectModule} from './index';
|
||||
import {MdSelect} from './select';
|
||||
import {getMdSelectDynamicMultipleError, getMdSelectNonArrayValueError} from './select-errors';
|
||||
import {
|
||||
getMdSelectDynamicMultipleError,
|
||||
getMdSelectNonArrayValueError,
|
||||
getMdSelectNonFunctionValueError
|
||||
} from './select-errors';
|
||||
import {MdOption} from '../core/option/option';
|
||||
import {
|
||||
FloatPlaceholderType,
|
||||
MD_PLACEHOLDER_GLOBAL_OPTIONS
|
||||
} from '../core/placeholder/placeholder-options';
|
||||
import {extendObject} from '../core/util/object-extend';
|
||||
|
||||
|
||||
describe('MdSelect', () => {
|
||||
@@ -73,7 +78,10 @@ describe('MdSelect', () => {
|
||||
BasicSelectWithoutFormsPreselected,
|
||||
BasicSelectWithoutFormsMultiple,
|
||||
SelectInsideFormGroup,
|
||||
SelectWithCustomTrigger
|
||||
SelectWithCustomTrigger,
|
||||
FalsyValueSelect,
|
||||
SelectInsideFormGroup,
|
||||
NgModelCompareWithSelect,
|
||||
],
|
||||
providers: [
|
||||
{provide: OverlayContainer, useFactory: () => {
|
||||
@@ -2714,8 +2722,78 @@ describe('MdSelect', () => {
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
describe('compareWith behavior', () => {
|
||||
let fixture: ComponentFixture<NgModelCompareWithSelect>;
|
||||
let instance: NgModelCompareWithSelect;
|
||||
|
||||
beforeEach(async(() => {
|
||||
fixture = TestBed.createComponent(NgModelCompareWithSelect);
|
||||
instance = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
describe('when comparing by value', () => {
|
||||
|
||||
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');
|
||||
});
|
||||
}));
|
||||
|
||||
});
|
||||
|
||||
describe('when comparing by reference', () => {
|
||||
beforeEach(async(() => {
|
||||
spyOn(instance, 'compareByReference').and.callThrough();
|
||||
instance.useCompareByReference();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should use the comparator', () => {
|
||||
expect(instance.compareByReference).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 if value is copied on change', async(() => {
|
||||
instance.options.first._selectViaInteraction();
|
||||
fixture.detectChanges();
|
||||
fixture.whenStable().then(() => {
|
||||
expect(instance.selectedFood.value).toEqual('steak-0');
|
||||
expect(instance.select.selected).toBeUndefined();
|
||||
});
|
||||
}));
|
||||
|
||||
});
|
||||
|
||||
describe('when using a non-function comparator', () => {
|
||||
beforeEach(() => {
|
||||
instance.useNullComparator();
|
||||
});
|
||||
|
||||
it('should throw an error', () => {
|
||||
expect(() => {
|
||||
fixture.detectChanges();
|
||||
}).toThrowError(wrappedErrorMessage(getMdSelectNonFunctionValueError()));
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@Component({
|
||||
selector: 'basic-select',
|
||||
@@ -3250,6 +3328,7 @@ class BasicSelectWithoutFormsMultiple {
|
||||
@ViewChild(MdSelect) select: MdSelect;
|
||||
}
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'select-with-custom-trigger',
|
||||
template: `
|
||||
@@ -3270,3 +3349,40 @@ 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)|null = this.compareByValue;
|
||||
|
||||
@ViewChild(MdSelect) select: MdSelect;
|
||||
@ViewChildren(MdOption) options: QueryList<MdOption>;
|
||||
|
||||
useCompareByValue() { this.comparator = this.compareByValue; }
|
||||
|
||||
useCompareByReference() { this.comparator = this.compareByReference; }
|
||||
|
||||
useNullComparator() { this.comparator = null; }
|
||||
|
||||
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 = extendObject({}, newValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -51,7 +52,11 @@ import {Observable} from 'rxjs/Observable';
|
||||
import {Subscription} from 'rxjs/Subscription';
|
||||
import {fadeInContent, transformPanel, transformPlaceholder} from './select-animations';
|
||||
import {SelectionModel} from '../core/selection/selection';
|
||||
import {getMdSelectDynamicMultipleError, getMdSelectNonArrayValueError} from './select-errors';
|
||||
import {
|
||||
getMdSelectDynamicMultipleError,
|
||||
getMdSelectNonArrayValueError,
|
||||
getMdSelectNonFunctionValueError
|
||||
} from './select-errors';
|
||||
import {CanColor, mixinColor} from '../core/common-behaviors/color';
|
||||
import {CanDisable, mixinDisabled} from '../core/common-behaviors/disabled';
|
||||
import {MdOptgroup, MdOption, MdOptionSelectionChange} from '../core/option/index';
|
||||
@@ -220,6 +225,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>;
|
||||
|
||||
@@ -337,6 +345,24 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
|
||||
this._multiple = coerceBooleanProperty(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* A function to compare the option values with the selected values. The first argument
|
||||
* is a value from an option. The second is a value from the selection. A boolean
|
||||
* should be returned.
|
||||
*/
|
||||
@Input()
|
||||
get compareWith() { return this._compareWith; }
|
||||
set compareWith(fn: (o1: any, o2: any) => boolean) {
|
||||
if (typeof fn !== 'function') {
|
||||
throw getMdSelectNonFunctionValueError();
|
||||
}
|
||||
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; }
|
||||
@@ -434,12 +460,7 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
|
||||
|
||||
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._initializeSelection();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -670,6 +691,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.
|
||||
@@ -710,8 +739,17 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
|
||||
* @returns Option that has the corresponding value.
|
||||
*/
|
||||
private _selectValue(value: any, isUserInput = false): MdOption | undefined {
|
||||
let correspondingOption = this.options.find(option => {
|
||||
return option.value != null && option.value === value;
|
||||
const correspondingOption = this.options.find((option: MdOption) => {
|
||||
try {
|
||||
// Treat null as a special reset value.
|
||||
return option.value != null && this._compareWith(option.value, value);
|
||||
} catch (error) {
|
||||
if (isDevMode()) {
|
||||
// Notify developers of errors in their comparator.
|
||||
console.warn(error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (correspondingOption) {
|
||||
@@ -722,6 +760,7 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
|
||||
return correspondingOption;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clears the select trigger and deselects every option in the list.
|
||||
* @param skip Option that should not be deselected.
|
||||
|
||||
Reference in New Issue
Block a user