mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
fix(datepicker): validate that input actually parses (#5711)
* fix(datepicker): throw when value is set to invalid type BREAKING CHANGE: You must now use an actual Date object rather than a string when setting the value of the datepicker programmatically (through value, ngModel, or formControl). * addressed comments * validate that datepicker input parses * tweak handling of invalid dates. * fix tests * address feedback * make sure datepicker selected date is valid * rework validation again * addressed comments * fix lint
This commit is contained in:
@@ -40,6 +40,9 @@
|
||||
placeholder="Pick a date"
|
||||
(dateInput)="onDateInput($event)"
|
||||
(dateChange)="onDateChange($event)">
|
||||
<md-error *ngIf="resultPickerModel.hasError('mdDatepickerParse')">
|
||||
"{{resultPickerModel.getError('mdDatepickerParse').text}}" is not a valid date!
|
||||
</md-error>
|
||||
<md-error *ngIf="resultPickerModel.hasError('mdDatepickerMin')">Too early!</md-error>
|
||||
<md-error *ngIf="resultPickerModel.hasError('mdDatepickerMax')">Too late!</md-error>
|
||||
<md-error *ngIf="resultPickerModel.hasError('mdDatepickerFilter')">Date unavailable!</md-error>
|
||||
|
||||
@@ -107,15 +107,15 @@ export abstract class DateAdapter<D> {
|
||||
* @param value The value to parse.
|
||||
* @param parseFormat The expected format of the value being parsed
|
||||
* (type is implementation-dependent).
|
||||
* @returns The parsed date, or null if date could not be parsed.
|
||||
* @returns The parsed date.
|
||||
*/
|
||||
abstract parse(value: any, parseFormat: any): D | null;
|
||||
|
||||
/**
|
||||
* Formats a date as a string.
|
||||
* @param date The value to parse.
|
||||
* @param date The value to format.
|
||||
* @param displayFormat The format to use to display the date as a string.
|
||||
* @returns The parsed date, or null if date could not be parsed.
|
||||
* @returns The formatted date string.
|
||||
*/
|
||||
abstract format(date: D, displayFormat: any): string;
|
||||
|
||||
@@ -156,6 +156,20 @@ export abstract class DateAdapter<D> {
|
||||
*/
|
||||
abstract getISODateString(date: D): string;
|
||||
|
||||
/**
|
||||
* Checks whether the given object is considered a date instance by this DateAdapter.
|
||||
* @param obj The object to check
|
||||
* @returns Whether the object is a date instance.
|
||||
*/
|
||||
abstract isDateInstance(obj: any): boolean;
|
||||
|
||||
/**
|
||||
* Checks whether the given date is valid.
|
||||
* @param date The date to check.
|
||||
* @returns Whether the date is valid.
|
||||
*/
|
||||
abstract isValid(date: D): boolean;
|
||||
|
||||
/**
|
||||
* Sets the locale used for all dates.
|
||||
* @param locale The new locale.
|
||||
|
||||
@@ -196,8 +196,13 @@ describe('NativeDateAdapter', () => {
|
||||
expect(adapter.parse(date)).not.toBe(date);
|
||||
});
|
||||
|
||||
it('should parse invalid value as null', () => {
|
||||
expect(adapter.parse('hello')).toBeNull();
|
||||
it('should parse invalid value as invalid', () => {
|
||||
let d = adapter.parse('hello');
|
||||
expect(d).not.toBeNull();
|
||||
expect(adapter.isDateInstance(d))
|
||||
.toBe(true, 'Expected string to have been fed through Date.parse');
|
||||
expect(adapter.isValid(d as Date))
|
||||
.toBe(false, 'Expected to parse as "invalid date" object');
|
||||
});
|
||||
|
||||
it('should format', () => {
|
||||
@@ -238,6 +243,11 @@ describe('NativeDateAdapter', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw when attempting to format invalid date', () => {
|
||||
expect(() => adapter.format(new Date(NaN), {}))
|
||||
.toThrowError(/NativeDateAdapter: Cannot format invalid date\./);
|
||||
});
|
||||
|
||||
it('should add years', () => {
|
||||
expect(adapter.addCalendarYears(new Date(2017, JAN, 1), 1)).toEqual(new Date(2018, JAN, 1));
|
||||
expect(adapter.addCalendarYears(new Date(2017, JAN, 1), -1)).toEqual(new Date(2016, JAN, 1));
|
||||
@@ -304,6 +314,23 @@ describe('NativeDateAdapter', () => {
|
||||
expect(adapter.format(new Date(1800, 7, 14), {day: 'numeric'})).toBe('Thu Aug 14 1800');
|
||||
}
|
||||
});
|
||||
|
||||
it('should count today as a valid date instance', () => {
|
||||
let d = new Date();
|
||||
expect(adapter.isValid(d)).toBe(true);
|
||||
expect(adapter.isDateInstance(d)).toBe(true);
|
||||
});
|
||||
|
||||
it('should count an invalid date as an invalid date instance', () => {
|
||||
let d = new Date(NaN);
|
||||
expect(adapter.isValid(d)).toBe(false);
|
||||
expect(adapter.isDateInstance(d)).toBe(true);
|
||||
});
|
||||
|
||||
it('should count a string as not a date instance', () => {
|
||||
let d = '1/1/2017';
|
||||
expect(adapter.isDateInstance(d)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -157,11 +157,16 @@ export class NativeDateAdapter extends DateAdapter<Date> {
|
||||
parse(value: any): Date | null {
|
||||
// We have no way using the native JS Date to set the parse format or locale, so we ignore these
|
||||
// parameters.
|
||||
let timestamp = typeof value == 'number' ? value : Date.parse(value);
|
||||
return isNaN(timestamp) ? null : new Date(timestamp);
|
||||
if (typeof value == 'number') {
|
||||
return new Date(value);
|
||||
}
|
||||
return value ? new Date(Date.parse(value)) : null;
|
||||
}
|
||||
|
||||
format(date: Date, displayFormat: Object): string {
|
||||
if (!this.isValid(date)) {
|
||||
throw Error('NativeDateAdapter: Cannot format invalid date.');
|
||||
}
|
||||
if (SUPPORTS_INTL_API) {
|
||||
if (this.useUtcForDisplay) {
|
||||
date = new Date(Date.UTC(
|
||||
@@ -207,6 +212,14 @@ export class NativeDateAdapter extends DateAdapter<Date> {
|
||||
].join('-');
|
||||
}
|
||||
|
||||
isDateInstance(obj: any) {
|
||||
return obj instanceof Date;
|
||||
}
|
||||
|
||||
isValid(date: Date) {
|
||||
return !isNaN(date.getTime());
|
||||
}
|
||||
|
||||
/** Creates a date but allows the month and date to overflow. */
|
||||
private _createDateWithOverflow(year: number, month: number, date: number) {
|
||||
let result = new Date(year, month, date);
|
||||
|
||||
@@ -66,13 +66,13 @@ export class MdCalendar<D> implements AfterContentInit, OnDestroy {
|
||||
@Input() startView: 'month' | 'year' = 'month';
|
||||
|
||||
/** The currently selected date. */
|
||||
@Input() selected: D;
|
||||
@Input() selected: D | null;
|
||||
|
||||
/** The minimum selectable date. */
|
||||
@Input() minDate: D;
|
||||
@Input() minDate: D | null;
|
||||
|
||||
/** The maximum selectable date. */
|
||||
@Input() maxDate: D;
|
||||
@Input() maxDate: D | null;
|
||||
|
||||
/** A function used to filter which dates are selectable. */
|
||||
@Input() dateFilter: (date: D) => boolean;
|
||||
|
||||
@@ -112,36 +112,41 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
|
||||
/** The value of the input. */
|
||||
@Input()
|
||||
get value(): D | null {
|
||||
return this._dateAdapter.parse(this._elementRef.nativeElement.value,
|
||||
this._dateFormats.parse.dateInput);
|
||||
return this._getValidDateOrNull(this._dateAdapter.parse(
|
||||
this._elementRef.nativeElement.value, this._dateFormats.parse.dateInput));
|
||||
}
|
||||
set value(value: D | null) {
|
||||
let date = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput);
|
||||
if (value != null && !this._dateAdapter.isDateInstance(value)) {
|
||||
throw Error('Datepicker: value not recognized as a date object by DateAdapter.');
|
||||
}
|
||||
this._lastValueValid = !value || this._dateAdapter.isValid(value);
|
||||
value = this._getValidDateOrNull(value);
|
||||
|
||||
let oldDate = this.value;
|
||||
this._renderer.setProperty(this._elementRef.nativeElement, 'value',
|
||||
date ? this._dateAdapter.format(date, this._dateFormats.display.dateInput) : '');
|
||||
if (!this._dateAdapter.sameDate(oldDate, date)) {
|
||||
this._valueChange.emit(date);
|
||||
value ? this._dateAdapter.format(value, this._dateFormats.display.dateInput) : '');
|
||||
if (!this._dateAdapter.sameDate(oldDate, value)) {
|
||||
this._valueChange.emit(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** The minimum valid date. */
|
||||
@Input()
|
||||
get min(): D { return this._min; }
|
||||
set min(value: D) {
|
||||
get min(): D | null { return this._min; }
|
||||
set min(value: D | null) {
|
||||
this._min = value;
|
||||
this._validatorOnChange();
|
||||
}
|
||||
private _min: D;
|
||||
private _min: D | null;
|
||||
|
||||
/** The maximum valid date. */
|
||||
@Input()
|
||||
get max(): D { return this._max; }
|
||||
set max(value: D) {
|
||||
get max(): D | null { return this._max; }
|
||||
set max(value: D | null) {
|
||||
this._max = value;
|
||||
this._validatorOnChange();
|
||||
}
|
||||
private _max: D;
|
||||
private _max: D | null;
|
||||
|
||||
/** Whether the datepicker-input is disabled. */
|
||||
@Input()
|
||||
@@ -168,6 +173,12 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
|
||||
|
||||
private _datepickerSubscription: Subscription;
|
||||
|
||||
/** The form control validator for whether the input parses. */
|
||||
private _parseValidator: ValidatorFn = (): ValidationErrors | null => {
|
||||
return this._lastValueValid ?
|
||||
null : {'mdDatepickerParse': {'text': this._elementRef.nativeElement.value}};
|
||||
}
|
||||
|
||||
/** The form control validator for the min date. */
|
||||
private _minValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
|
||||
return (!this.min || !control.value ||
|
||||
@@ -190,7 +201,11 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
|
||||
|
||||
/** The combined form control validator for this input. */
|
||||
private _validator: ValidatorFn | null =
|
||||
Validators.compose([this._minValidator, this._maxValidator, this._filterValidator]);
|
||||
Validators.compose(
|
||||
[this._parseValidator, this._minValidator, this._maxValidator, this._filterValidator]);
|
||||
|
||||
/** Whether the last value set on the input was valid. */
|
||||
private _lastValueValid = false;
|
||||
|
||||
constructor(
|
||||
private _elementRef: ElementRef,
|
||||
@@ -270,6 +285,8 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
|
||||
|
||||
_onInput(value: string) {
|
||||
let date = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput);
|
||||
this._lastValueValid = !date || this._dateAdapter.isValid(date);
|
||||
date = this._getValidDateOrNull(date);
|
||||
this._cvaOnChange(date);
|
||||
this._valueChange.emit(date);
|
||||
this.dateInput.emit(new MdDatepickerInputEvent(this, this._elementRef.nativeElement));
|
||||
@@ -278,4 +295,12 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
|
||||
_onChange() {
|
||||
this.dateChange.emit(new MdDatepickerInputEvent(this, this._elementRef.nativeElement));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param obj The object to check.
|
||||
* @returns The given object if it is both a date instance and valid, otherwise null.
|
||||
*/
|
||||
private _getValidDateOrNull(obj: any): D | null {
|
||||
return (this._dateAdapter.isDateInstance(obj) && this._dateAdapter.isValid(obj)) ? obj : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +223,15 @@ describe('MdDatepicker', () => {
|
||||
expect(ownedElement).not.toBeNull();
|
||||
expect((ownedElement as Element).tagName.toLowerCase()).toBe('md-calendar');
|
||||
});
|
||||
|
||||
it('should throw when given wrong data type', () => {
|
||||
testComponent.date = '1/1/2017' as any;
|
||||
|
||||
expect(() => fixture.detectChanges())
|
||||
.toThrowError(/Datepicker: value not recognized as a date object by DateAdapter\./);
|
||||
|
||||
testComponent.date = null;
|
||||
});
|
||||
});
|
||||
|
||||
describe('datepicker with too many inputs', () => {
|
||||
@@ -902,7 +911,7 @@ describe('MdDatepicker', () => {
|
||||
class StandardDatepicker {
|
||||
touch = false;
|
||||
disabled = false;
|
||||
date = new Date(2020, JAN, 1);
|
||||
date: Date | null = new Date(2020, JAN, 1);
|
||||
@ViewChild('d') datepicker: MdDatepicker<Date>;
|
||||
@ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput<Date>;
|
||||
}
|
||||
@@ -1008,7 +1017,7 @@ class InputContainerDatepicker {
|
||||
})
|
||||
class DatepickerWithMinAndMaxValidation {
|
||||
@ViewChild('d') datepicker: MdDatepicker<Date>;
|
||||
date: Date;
|
||||
date: Date | null;
|
||||
minDate = new Date(2010, JAN, 1);
|
||||
maxDate = new Date(2020, JAN, 1);
|
||||
}
|
||||
|
||||
@@ -124,13 +124,13 @@ export class MdDatepickerContent<D> implements AfterContentInit {
|
||||
export class MdDatepicker<D> implements OnDestroy {
|
||||
/** The date to open the calendar to initially. */
|
||||
@Input()
|
||||
get startAt(): D {
|
||||
get startAt(): D | null {
|
||||
// If an explicit startAt is set we start there, otherwise we start at whatever the currently
|
||||
// selected value is.
|
||||
return this._startAt || (this._datepickerInput ? this._datepickerInput.value : null);
|
||||
}
|
||||
set startAt(date: D) { this._startAt = date; }
|
||||
private _startAt: D;
|
||||
set startAt(date: D | null) { this._startAt = date; }
|
||||
private _startAt: D | null;
|
||||
|
||||
/** The view that the calendar should start in. */
|
||||
@Input() startView: 'month' | 'year' = 'month';
|
||||
@@ -164,15 +164,17 @@ export class MdDatepicker<D> implements OnDestroy {
|
||||
id = `md-datepicker-${datepickerUid++}`;
|
||||
|
||||
/** The currently selected date. */
|
||||
_selected: D | null = null;
|
||||
get _selected(): D | null { return this._validSelected; }
|
||||
set _selected(value: D | null) { this._validSelected = value; }
|
||||
private _validSelected: D | null = null;
|
||||
|
||||
/** The minimum selectable date. */
|
||||
get _minDate(): D {
|
||||
get _minDate(): D | null {
|
||||
return this._datepickerInput && this._datepickerInput.min;
|
||||
}
|
||||
|
||||
/** The maximum selectable date. */
|
||||
get _maxDate(): D {
|
||||
get _maxDate(): D | null {
|
||||
return this._datepickerInput && this._datepickerInput.max;
|
||||
}
|
||||
|
||||
@@ -240,7 +242,7 @@ export class MdDatepicker<D> implements OnDestroy {
|
||||
}
|
||||
this._datepickerInput = input;
|
||||
this._inputSubscription =
|
||||
this._datepickerInput._valueChange.subscribe((value: D) => this._selected = value);
|
||||
this._datepickerInput._valueChange.subscribe((value: D | null) => this._selected = value);
|
||||
}
|
||||
|
||||
/** Open the calendar. */
|
||||
|
||||
Reference in New Issue
Block a user