Skip to content

Commit 9fa075e

Browse files
authored
fix(datepicker): allow DateAdapter authors to have more control ove… (#7346)
* fix(datepicker): allow `DateAdapter` authors to have more control over what can/can't be coerced to a date BREAKING CHANGES: - `fromIso8601` method on `DateAdapter` removed in favor of `coerceToDate` * address comments * more comments addressed * return invalid date instead of throwing
1 parent 00de3f6 commit 9fa075e

14 files changed

+202
-164
lines changed

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

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export abstract class DateAdapter<D> {
118118
abstract today(): D;
119119

120120
/**
121-
* Parses a date from a value.
121+
* Parses a date from a user-provided value.
122122
* @param value The value to parse.
123123
* @param parseFormat The expected format of the value being parsed
124124
* (type is implementation-dependent).
@@ -127,7 +127,7 @@ export abstract class DateAdapter<D> {
127127
abstract parse(value: any, parseFormat: any): D | null;
128128

129129
/**
130-
* Formats a date as a string.
130+
* Formats a date as a string according to the given format.
131131
* @param date The value to format.
132132
* @param displayFormat The format to use to display the date as a string.
133133
* @returns The formatted date string.
@@ -165,18 +165,13 @@ export abstract class DateAdapter<D> {
165165

166166
/**
167167
* Gets the RFC 3339 compatible string (https://tools.ietf.org/html/rfc3339) for the given date.
168+
* This method is used to generate date strings that are compatible with native HTML attributes
169+
* such as the `min` or `max` attribute of an `<input>`.
168170
* @param date The date to get the ISO date string for.
169171
* @returns The ISO date string date string.
170172
*/
171173
abstract toIso8601(date: D): string;
172174

173-
/**
174-
* Creates a date from an RFC 3339 compatible string (https://tools.ietf.org/html/rfc3339).
175-
* @param iso8601String The ISO date string to create a date from
176-
* @returns The date created from the ISO date string.
177-
*/
178-
abstract fromIso8601(iso8601String: string): D | null;
179-
180175
/**
181176
* Checks whether the given object is considered a date instance by this DateAdapter.
182177
* @param obj The object to check
@@ -191,6 +186,31 @@ export abstract class DateAdapter<D> {
191186
*/
192187
abstract isValid(date: D): boolean;
193188

189+
/**
190+
* Gets date instance that is not valid.
191+
* @returns An invalid date.
192+
*/
193+
abstract invalid(): D;
194+
195+
/**
196+
* Attempts to deserialize a value to a valid date object. This is different from parsing in that
197+
* deserialize should only accept non-ambiguous, locale-independent formats (e.g. a ISO 8601
198+
* string). The default implementation does not allow any deserialization, it simply checks that
199+
* the given value is already a valid date object or null. The `<mat-datepicker>` will call this
200+
* method on all of it's `@Input()` properties that accept dates. It is therefore possible to
201+
* support passing values from your backend directly to these properties by overriding this method
202+
* to also deserialize the format used by your backend.
203+
* @param value The value to be deserialized into a date object.
204+
* @returns The deserialized date object, either a valid date, null if the value can be
205+
* deserialized into a null date (e.g. the empty string), or an invalid date.
206+
*/
207+
deserialize(value: any): D | null {
208+
if (value == null || this.isDateInstance(value) && this.isValid(value)) {
209+
return value;
210+
}
211+
return this.invalid();
212+
}
213+
194214
/**
195215
* Sets the locale used for all dates.
196216
* @param locale The new locale.
@@ -221,7 +241,15 @@ export abstract class DateAdapter<D> {
221241
* Null dates are considered equal to other null dates.
222242
*/
223243
sameDate(first: D | null, second: D | null): boolean {
224-
return first && second ? !this.compareDate(first, second) : first == second;
244+
if (first && second) {
245+
let firstValid = this.isValid(first);
246+
let secondValid = this.isValid(second);
247+
if (firstValid && secondValid) {
248+
return !this.compareDate(first, second)
249+
}
250+
return firstValid == secondValid;
251+
}
252+
return first == second;
225253
}
226254

227255
/**

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

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import {DateAdapter, MAT_DATE_LOCALE, NativeDateAdapter, NativeDateModule} from
66

77
const SUPPORTS_INTL = typeof Intl != 'undefined';
88

9+
910
describe('NativeDateAdapter', () => {
1011
const platform = new Platform();
1112
let adapter: NativeDateAdapter;
13+
let assertValidDate: (d: Date | null, valid: boolean) => void;
1214

1315
beforeEach(async(() => {
1416
TestBed.configureTestingModule({
@@ -18,6 +20,13 @@ describe('NativeDateAdapter', () => {
1820

1921
beforeEach(inject([DateAdapter], (d: NativeDateAdapter) => {
2022
adapter = d;
23+
24+
assertValidDate = (d: Date | null, valid: boolean) => {
25+
expect(adapter.isDateInstance(d)).not.toBeNull(`Expected ${d} to be a date instance`);
26+
expect(adapter.isValid(d!)).toBe(valid,
27+
`Expected ${d} to be ${valid ? 'valid' : 'invalid'},` +
28+
` but was ${valid ? 'invalid' : 'valid'}`);
29+
}
2130
}));
2231

2332
it('should get year', () => {
@@ -333,14 +342,22 @@ describe('NativeDateAdapter', () => {
333342
});
334343

335344
it('should create dates from valid ISO strings', () => {
336-
expect(adapter.fromIso8601('1985-04-12T23:20:50.52Z')).not.toBeNull();
337-
expect(adapter.fromIso8601('1996-12-19T16:39:57-08:00')).not.toBeNull();
338-
expect(adapter.fromIso8601('1937-01-01T12:00:27.87+00:20')).not.toBeNull();
339-
expect(adapter.fromIso8601('2017-01-01')).not.toBeNull();
340-
expect(adapter.fromIso8601('2017-01-01T00:00:00')).not.toBeNull();
341-
expect(adapter.fromIso8601('1990-13-31T23:59:00Z')).toBeNull();
342-
expect(adapter.fromIso8601('1/1/2017')).toBeNull();
343-
expect(adapter.fromIso8601('2017-01-01T')).toBeNull();
345+
assertValidDate(adapter.deserialize('1985-04-12T23:20:50.52Z'), true);
346+
assertValidDate(adapter.deserialize('1996-12-19T16:39:57-08:00'), true);
347+
assertValidDate(adapter.deserialize('1937-01-01T12:00:27.87+00:20'), true);
348+
assertValidDate(adapter.deserialize('2017-01-01'), true);
349+
assertValidDate(adapter.deserialize('2017-01-01T00:00:00'), true);
350+
assertValidDate(adapter.deserialize('1990-13-31T23:59:00Z'), false);
351+
assertValidDate(adapter.deserialize('1/1/2017'), false);
352+
assertValidDate(adapter.deserialize('2017-01-01T'), false);
353+
expect(adapter.deserialize('')).toBeNull();
354+
expect(adapter.deserialize(null)).toBeNull();
355+
assertValidDate(adapter.deserialize(new Date()), true);
356+
assertValidDate(adapter.deserialize(new Date(NaN)), false);
357+
});
358+
359+
it('should create an invalid date', () => {
360+
assertValidDate(adapter.invalid(), false);
344361
});
345362
});
346363

@@ -390,5 +407,4 @@ describe('NativeDateAdapter with LOCALE_ID override', () => {
390407

391408
expect(adapter.getDayOfWeekNames('long')).toEqual(expectedValue);
392409
});
393-
394410
});

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

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

99
import {Inject, Injectable, Optional} from '@angular/core';
10-
import {DateAdapter, MAT_DATE_LOCALE} from './date-adapter';
1110
import {extendObject} from '../util/object-extend';
11+
import {DateAdapter, MAT_DATE_LOCALE} from './date-adapter';
12+
1213

1314
// TODO(mmalerba): Remove when we no longer support safari 9.
1415
/** Whether the browser supports the Intl API. */
@@ -219,16 +220,26 @@ export class NativeDateAdapter extends DateAdapter<Date> {
219220
].join('-');
220221
}
221222

222-
fromIso8601(iso8601String: string): Date | null {
223-
// The `Date` constructor accepts formats other than ISO 8601, so we need to make sure the
224-
// string is the right format first.
225-
if (ISO_8601_REGEX.test(iso8601String)) {
226-
let d = new Date(iso8601String);
227-
if (this.isValid(d)) {
228-
return d;
223+
/**
224+
* Returns the given value if given a valid Date or null. Deserializes valid ISO 8601 strings
225+
* (https://www.ietf.org/rfc/rfc3339.txt) into valid Dates and empty string into null. Returns an
226+
* invalid date for all other values.
227+
*/
228+
deserialize(value: any): Date | null {
229+
if (typeof value === 'string') {
230+
if (!value) {
231+
return null;
232+
}
233+
// The `Date` constructor accepts formats other than ISO 8601, so we need to make sure the
234+
// string is the right format first.
235+
if (ISO_8601_REGEX.test(value)) {
236+
let date = new Date(value);
237+
if (this.isValid(date)) {
238+
return date;
239+
}
229240
}
230241
}
231-
return null;
242+
return super.deserialize(value);
232243
}
233244

234245
isDateInstance(obj: any) {
@@ -239,6 +250,10 @@ export class NativeDateAdapter extends DateAdapter<Date> {
239250
return !isNaN(date.getTime());
240251
}
241252

253+
invalid(): Date {
254+
return new Date(NaN);
255+
}
256+
242257
/** Creates a date but allows the month and date to overflow. */
243258
private _createDateWithOverflow(year: number, month: number, date: number) {
244259
let result = new Date(year, month, date);

src/lib/datepicker/calendar.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ import {
3838
import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats} from '@angular/material/core';
3939
import {first} from 'rxjs/operators';
4040
import {Subscription} from 'rxjs/Subscription';
41-
import {coerceDateProperty} from './coerce-date-property';
4241
import {createMissingDateImplError} from './datepicker-errors';
4342
import {MatDatepickerIntl} from './datepicker-intl';
4443
import {MatMonthView} from './month-view';
@@ -67,7 +66,9 @@ export class MatCalendar<D> implements AfterContentInit, OnDestroy, OnChanges {
6766
/** A date representing the period (month or year) to start the calendar in. */
6867
@Input()
6968
get startAt(): D | null { return this._startAt; }
70-
set startAt(value: D | null) { this._startAt = coerceDateProperty(this._dateAdapter, value); }
69+
set startAt(value: D | null) {
70+
this._startAt = this._getValidDateOrNull(this._dateAdapter.deserialize(value));
71+
}
7172
private _startAt: D | null;
7273

7374
/** Whether the calendar should be started in month or year view. */
@@ -76,19 +77,25 @@ export class MatCalendar<D> implements AfterContentInit, OnDestroy, OnChanges {
7677
/** The currently selected date. */
7778
@Input()
7879
get selected(): D | null { return this._selected; }
79-
set selected(value: D | null) { this._selected = coerceDateProperty(this._dateAdapter, value); }
80+
set selected(value: D | null) {
81+
this._selected = this._getValidDateOrNull(this._dateAdapter.deserialize(value));
82+
}
8083
private _selected: D | null;
8184

8285
/** The minimum selectable date. */
8386
@Input()
8487
get minDate(): D | null { return this._minDate; }
85-
set minDate(value: D | null) { this._minDate = coerceDateProperty(this._dateAdapter, value); }
88+
set minDate(value: D | null) {
89+
this._minDate = this._getValidDateOrNull(this._dateAdapter.deserialize(value));
90+
}
8691
private _minDate: D | null;
8792

8893
/** The maximum selectable date. */
8994
@Input()
9095
get maxDate(): D | null { return this._maxDate; }
91-
set maxDate(value: D | null) { this._maxDate = coerceDateProperty(this._dateAdapter, value); }
96+
set maxDate(value: D | null) {
97+
this._maxDate = this._getValidDateOrNull(this._dateAdapter.deserialize(value));
98+
}
9299
private _maxDate: D | null;
93100

94101
/** A function used to filter which dates are selectable. */
@@ -385,4 +392,12 @@ export class MatCalendar<D> implements AfterContentInit, OnDestroy, OnChanges {
385392
(this._dateAdapter.getMonth(date) >= 7 ? 5 : 12);
386393
return this._dateAdapter.addCalendarMonths(date, increment);
387394
}
395+
396+
/**
397+
* @param obj The object to check.
398+
* @returns The given object if it is both a date instance and valid, otherwise null.
399+
*/
400+
private _getValidDateOrNull(obj: any): D | null {
401+
return (this._dateAdapter.isDateInstance(obj) && this._dateAdapter.isValid(obj)) ? obj : null;
402+
}
388403
}

src/lib/datepicker/coerce-date-property.spec.ts

Lines changed: 0 additions & 54 deletions
This file was deleted.

src/lib/datepicker/coerce-date-property.ts

Lines changed: 0 additions & 35 deletions
This file was deleted.

0 commit comments

Comments
 (0)