Skip to content

Commit 8bb54ca

Browse files
mmalerbatinayuangao
authored andcommitted
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
1 parent 0d80a77 commit 8bb54ca

File tree

8 files changed

+125
-32
lines changed

8 files changed

+125
-32
lines changed

src/demo-app/datepicker/datepicker-demo.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ <h2>Result</h2>
4040
placeholder="Pick a date"
4141
(dateInput)="onDateInput($event)"
4242
(dateChange)="onDateChange($event)">
43+
<md-error *ngIf="resultPickerModel.hasError('mdDatepickerParse')">
44+
"{{resultPickerModel.getError('mdDatepickerParse').text}}" is not a valid date!
45+
</md-error>
4346
<md-error *ngIf="resultPickerModel.hasError('mdDatepickerMin')">Too early!</md-error>
4447
<md-error *ngIf="resultPickerModel.hasError('mdDatepickerMax')">Too late!</md-error>
4548
<md-error *ngIf="resultPickerModel.hasError('mdDatepickerFilter')">Date unavailable!</md-error>

src/lib/core/datetime/date-adapter.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,15 +107,15 @@ export abstract class DateAdapter<D> {
107107
* @param value The value to parse.
108108
* @param parseFormat The expected format of the value being parsed
109109
* (type is implementation-dependent).
110-
* @returns The parsed date, or null if date could not be parsed.
110+
* @returns The parsed date.
111111
*/
112112
abstract parse(value: any, parseFormat: any): D | null;
113113

114114
/**
115115
* Formats a date as a string.
116-
* @param date The value to parse.
116+
* @param date The value to format.
117117
* @param displayFormat The format to use to display the date as a string.
118-
* @returns The parsed date, or null if date could not be parsed.
118+
* @returns The formatted date string.
119119
*/
120120
abstract format(date: D, displayFormat: any): string;
121121

@@ -156,6 +156,20 @@ export abstract class DateAdapter<D> {
156156
*/
157157
abstract getISODateString(date: D): string;
158158

159+
/**
160+
* Checks whether the given object is considered a date instance by this DateAdapter.
161+
* @param obj The object to check
162+
* @returns Whether the object is a date instance.
163+
*/
164+
abstract isDateInstance(obj: any): boolean;
165+
166+
/**
167+
* Checks whether the given date is valid.
168+
* @param date The date to check.
169+
* @returns Whether the date is valid.
170+
*/
171+
abstract isValid(date: D): boolean;
172+
159173
/**
160174
* Sets the locale used for all dates.
161175
* @param locale The new locale.

src/lib/core/datetime/native-date-adapter.spec.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,13 @@ describe('NativeDateAdapter', () => {
196196
expect(adapter.parse(date)).not.toBe(date);
197197
});
198198

199-
it('should parse invalid value as null', () => {
200-
expect(adapter.parse('hello')).toBeNull();
199+
it('should parse invalid value as invalid', () => {
200+
let d = adapter.parse('hello');
201+
expect(d).not.toBeNull();
202+
expect(adapter.isDateInstance(d))
203+
.toBe(true, 'Expected string to have been fed through Date.parse');
204+
expect(adapter.isValid(d as Date))
205+
.toBe(false, 'Expected to parse as "invalid date" object');
201206
});
202207

203208
it('should format', () => {
@@ -238,6 +243,11 @@ describe('NativeDateAdapter', () => {
238243
}
239244
});
240245

246+
it('should throw when attempting to format invalid date', () => {
247+
expect(() => adapter.format(new Date(NaN), {}))
248+
.toThrowError(/NativeDateAdapter: Cannot format invalid date\./);
249+
});
250+
241251
it('should add years', () => {
242252
expect(adapter.addCalendarYears(new Date(2017, JAN, 1), 1)).toEqual(new Date(2018, JAN, 1));
243253
expect(adapter.addCalendarYears(new Date(2017, JAN, 1), -1)).toEqual(new Date(2016, JAN, 1));
@@ -304,6 +314,23 @@ describe('NativeDateAdapter', () => {
304314
expect(adapter.format(new Date(1800, 7, 14), {day: 'numeric'})).toBe('Thu Aug 14 1800');
305315
}
306316
});
317+
318+
it('should count today as a valid date instance', () => {
319+
let d = new Date();
320+
expect(adapter.isValid(d)).toBe(true);
321+
expect(adapter.isDateInstance(d)).toBe(true);
322+
});
323+
324+
it('should count an invalid date as an invalid date instance', () => {
325+
let d = new Date(NaN);
326+
expect(adapter.isValid(d)).toBe(false);
327+
expect(adapter.isDateInstance(d)).toBe(true);
328+
});
329+
330+
it('should count a string as not a date instance', () => {
331+
let d = '1/1/2017';
332+
expect(adapter.isDateInstance(d)).toBe(false);
333+
});
307334
});
308335

309336

src/lib/core/datetime/native-date-adapter.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,16 @@ export class NativeDateAdapter extends DateAdapter<Date> {
157157
parse(value: any): Date | null {
158158
// We have no way using the native JS Date to set the parse format or locale, so we ignore these
159159
// parameters.
160-
let timestamp = typeof value == 'number' ? value : Date.parse(value);
161-
return isNaN(timestamp) ? null : new Date(timestamp);
160+
if (typeof value == 'number') {
161+
return new Date(value);
162+
}
163+
return value ? new Date(Date.parse(value)) : null;
162164
}
163165

164166
format(date: Date, displayFormat: Object): string {
167+
if (!this.isValid(date)) {
168+
throw Error('NativeDateAdapter: Cannot format invalid date.');
169+
}
165170
if (SUPPORTS_INTL_API) {
166171
if (this.useUtcForDisplay) {
167172
date = new Date(Date.UTC(
@@ -207,6 +212,14 @@ export class NativeDateAdapter extends DateAdapter<Date> {
207212
].join('-');
208213
}
209214

215+
isDateInstance(obj: any) {
216+
return obj instanceof Date;
217+
}
218+
219+
isValid(date: Date) {
220+
return !isNaN(date.getTime());
221+
}
222+
210223
/** Creates a date but allows the month and date to overflow. */
211224
private _createDateWithOverflow(year: number, month: number, date: number) {
212225
let result = new Date(year, month, date);

src/lib/datepicker/calendar.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,13 @@ export class MdCalendar<D> implements AfterContentInit, OnDestroy {
6666
@Input() startView: 'month' | 'year' = 'month';
6767

6868
/** The currently selected date. */
69-
@Input() selected: D;
69+
@Input() selected: D | null;
7070

7171
/** The minimum selectable date. */
72-
@Input() minDate: D;
72+
@Input() minDate: D | null;
7373

7474
/** The maximum selectable date. */
75-
@Input() maxDate: D;
75+
@Input() maxDate: D | null;
7676

7777
/** A function used to filter which dates are selectable. */
7878
@Input() dateFilter: (date: D) => boolean;

src/lib/datepicker/datepicker-input.ts

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -112,36 +112,41 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
112112
/** The value of the input. */
113113
@Input()
114114
get value(): D | null {
115-
return this._dateAdapter.parse(this._elementRef.nativeElement.value,
116-
this._dateFormats.parse.dateInput);
115+
return this._getValidDateOrNull(this._dateAdapter.parse(
116+
this._elementRef.nativeElement.value, this._dateFormats.parse.dateInput));
117117
}
118118
set value(value: D | null) {
119-
let date = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput);
119+
if (value != null && !this._dateAdapter.isDateInstance(value)) {
120+
throw Error('Datepicker: value not recognized as a date object by DateAdapter.');
121+
}
122+
this._lastValueValid = !value || this._dateAdapter.isValid(value);
123+
value = this._getValidDateOrNull(value);
124+
120125
let oldDate = this.value;
121126
this._renderer.setProperty(this._elementRef.nativeElement, 'value',
122-
date ? this._dateAdapter.format(date, this._dateFormats.display.dateInput) : '');
123-
if (!this._dateAdapter.sameDate(oldDate, date)) {
124-
this._valueChange.emit(date);
127+
value ? this._dateAdapter.format(value, this._dateFormats.display.dateInput) : '');
128+
if (!this._dateAdapter.sameDate(oldDate, value)) {
129+
this._valueChange.emit(value);
125130
}
126131
}
127132

128133
/** The minimum valid date. */
129134
@Input()
130-
get min(): D { return this._min; }
131-
set min(value: D) {
135+
get min(): D | null { return this._min; }
136+
set min(value: D | null) {
132137
this._min = value;
133138
this._validatorOnChange();
134139
}
135-
private _min: D;
140+
private _min: D | null;
136141

137142
/** The maximum valid date. */
138143
@Input()
139-
get max(): D { return this._max; }
140-
set max(value: D) {
144+
get max(): D | null { return this._max; }
145+
set max(value: D | null) {
141146
this._max = value;
142147
this._validatorOnChange();
143148
}
144-
private _max: D;
149+
private _max: D | null;
145150

146151
/** Whether the datepicker-input is disabled. */
147152
@Input()
@@ -168,6 +173,12 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
168173

169174
private _datepickerSubscription: Subscription;
170175

176+
/** The form control validator for whether the input parses. */
177+
private _parseValidator: ValidatorFn = (): ValidationErrors | null => {
178+
return this._lastValueValid ?
179+
null : {'mdDatepickerParse': {'text': this._elementRef.nativeElement.value}};
180+
}
181+
171182
/** The form control validator for the min date. */
172183
private _minValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
173184
return (!this.min || !control.value ||
@@ -190,7 +201,11 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
190201

191202
/** The combined form control validator for this input. */
192203
private _validator: ValidatorFn | null =
193-
Validators.compose([this._minValidator, this._maxValidator, this._filterValidator]);
204+
Validators.compose(
205+
[this._parseValidator, this._minValidator, this._maxValidator, this._filterValidator]);
206+
207+
/** Whether the last value set on the input was valid. */
208+
private _lastValueValid = false;
194209

195210
constructor(
196211
private _elementRef: ElementRef,
@@ -270,6 +285,8 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
270285

271286
_onInput(value: string) {
272287
let date = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput);
288+
this._lastValueValid = !date || this._dateAdapter.isValid(date);
289+
date = this._getValidDateOrNull(date);
273290
this._cvaOnChange(date);
274291
this._valueChange.emit(date);
275292
this.dateInput.emit(new MdDatepickerInputEvent(this, this._elementRef.nativeElement));
@@ -278,4 +295,12 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
278295
_onChange() {
279296
this.dateChange.emit(new MdDatepickerInputEvent(this, this._elementRef.nativeElement));
280297
}
298+
299+
/**
300+
* @param obj The object to check.
301+
* @returns The given object if it is both a date instance and valid, otherwise null.
302+
*/
303+
private _getValidDateOrNull(obj: any): D | null {
304+
return (this._dateAdapter.isDateInstance(obj) && this._dateAdapter.isValid(obj)) ? obj : null;
305+
}
281306
}

src/lib/datepicker/datepicker.spec.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,15 @@ describe('MdDatepicker', () => {
223223
expect(ownedElement).not.toBeNull();
224224
expect((ownedElement as Element).tagName.toLowerCase()).toBe('md-calendar');
225225
});
226+
227+
it('should throw when given wrong data type', () => {
228+
testComponent.date = '1/1/2017' as any;
229+
230+
expect(() => fixture.detectChanges())
231+
.toThrowError(/Datepicker: value not recognized as a date object by DateAdapter\./);
232+
233+
testComponent.date = null;
234+
});
226235
});
227236

228237
describe('datepicker with too many inputs', () => {
@@ -902,7 +911,7 @@ describe('MdDatepicker', () => {
902911
class StandardDatepicker {
903912
touch = false;
904913
disabled = false;
905-
date = new Date(2020, JAN, 1);
914+
date: Date | null = new Date(2020, JAN, 1);
906915
@ViewChild('d') datepicker: MdDatepicker<Date>;
907916
@ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput<Date>;
908917
}
@@ -1008,7 +1017,7 @@ class InputContainerDatepicker {
10081017
})
10091018
class DatepickerWithMinAndMaxValidation {
10101019
@ViewChild('d') datepicker: MdDatepicker<Date>;
1011-
date: Date;
1020+
date: Date | null;
10121021
minDate = new Date(2010, JAN, 1);
10131022
maxDate = new Date(2020, JAN, 1);
10141023
}

src/lib/datepicker/datepicker.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,13 @@ export class MdDatepickerContent<D> implements AfterContentInit {
124124
export class MdDatepicker<D> implements OnDestroy {
125125
/** The date to open the calendar to initially. */
126126
@Input()
127-
get startAt(): D {
127+
get startAt(): D | null {
128128
// If an explicit startAt is set we start there, otherwise we start at whatever the currently
129129
// selected value is.
130130
return this._startAt || (this._datepickerInput ? this._datepickerInput.value : null);
131131
}
132-
set startAt(date: D) { this._startAt = date; }
133-
private _startAt: D;
132+
set startAt(date: D | null) { this._startAt = date; }
133+
private _startAt: D | null;
134134

135135
/** The view that the calendar should start in. */
136136
@Input() startView: 'month' | 'year' = 'month';
@@ -164,15 +164,17 @@ export class MdDatepicker<D> implements OnDestroy {
164164
id = `md-datepicker-${datepickerUid++}`;
165165

166166
/** The currently selected date. */
167-
_selected: D | null = null;
167+
get _selected(): D | null { return this._validSelected; }
168+
set _selected(value: D | null) { this._validSelected = value; }
169+
private _validSelected: D | null = null;
168170

169171
/** The minimum selectable date. */
170-
get _minDate(): D {
172+
get _minDate(): D | null {
171173
return this._datepickerInput && this._datepickerInput.min;
172174
}
173175

174176
/** The maximum selectable date. */
175-
get _maxDate(): D {
177+
get _maxDate(): D | null {
176178
return this._datepickerInput && this._datepickerInput.max;
177179
}
178180

@@ -240,7 +242,7 @@ export class MdDatepicker<D> implements OnDestroy {
240242
}
241243
this._datepickerInput = input;
242244
this._inputSubscription =
243-
this._datepickerInput._valueChange.subscribe((value: D) => this._selected = value);
245+
this._datepickerInput._valueChange.subscribe((value: D | null) => this._selected = value);
244246
}
245247

246248
/** Open the calendar. */

0 commit comments

Comments
 (0)