Skip to content

Commit 0e919a9

Browse files
committed
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`
1 parent 291a87c commit 0e919a9

14 files changed

+105
-157
lines changed

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

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -170,13 +170,6 @@ export abstract class DateAdapter<D> {
170170
*/
171171
abstract toIso8601(date: D): string;
172172

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-
180173
/**
181174
* Checks whether the given object is considered a date instance by this DateAdapter.
182175
* @param obj The object to check
@@ -191,6 +184,23 @@ export abstract class DateAdapter<D> {
191184
*/
192185
abstract isValid(date: D): boolean;
193186

187+
/**
188+
* Attempts to coerce a value to a valid date object. This is different from parsing in that it
189+
* should only coerce non-ambiguous, locale-independent values (e.g. a ISO 8601 string).
190+
* The default implementation does not allow any coercion, it simply checks that the given value
191+
* is already a valid date object or null.
192+
* @param value The value to be coerced to a date object.
193+
* @returns The coerced date object, either a valid date, null if the value can be coerced to a
194+
* null date (e.g. the empty string).
195+
* @throws If the given value cannot be coerced to a valid date or null.
196+
*/
197+
coerceToDate(value: any): D | null {
198+
if (value == null || this.isDateInstance(value) && this.isValid(value)) {
199+
return value;
200+
}
201+
throw Error(`Could not coerce "${value}" to a valid date object.`);
202+
}
203+
194204
/**
195205
* Sets the locale used for all dates.
196206
* @param locale The new locale.

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ 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;
@@ -333,14 +334,18 @@ describe('NativeDateAdapter', () => {
333334
});
334335

335336
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();
337+
expect(adapter.coerceToDate('1985-04-12T23:20:50.52Z')).not.toBeNull();
338+
expect(adapter.coerceToDate('1996-12-19T16:39:57-08:00')).not.toBeNull();
339+
expect(adapter.coerceToDate('1937-01-01T12:00:27.87+00:20')).not.toBeNull();
340+
expect(adapter.coerceToDate('2017-01-01')).not.toBeNull();
341+
expect(adapter.coerceToDate('2017-01-01T00:00:00')).not.toBeNull();
342+
expect(() => adapter.coerceToDate('1990-13-31T23:59:00Z')).toThrow();
343+
expect(() => adapter.coerceToDate('1/1/2017')).toThrow();
344+
expect(() => adapter.coerceToDate('2017-01-01T')).toThrow();
345+
expect(adapter.coerceToDate('')).toBeNull();
346+
expect(adapter.coerceToDate(null)).toBeNull();
347+
expect(adapter.coerceToDate(new Date())).not.toBeNull();
348+
expect(() => adapter.coerceToDate(new Date(NaN))).toThrow();
344349
});
345350
});
346351

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

391396
expect(adapter.getDayOfWeekNames('long')).toEqual(expectedValue);
392397
});
393-
394398
});

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

Lines changed: 20 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. Coerces valid ISO 8601 strings
225+
* (https://www.ietf.org/rfc/rfc3339.txt) to valid Dates and empty string to null. Throws on all
226+
* other values.
227+
*/
228+
coerceToDate(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.coerceToDate(value);
232243
}
233244

234245
isDateInstance(obj: any) {

src/lib/datepicker/calendar.ts

Lines changed: 4 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/operator/first';
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,7 @@ 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) { this._startAt = this._dateAdapter.coerceToDate(value); }
7170
private _startAt: D | null;
7271

7372
/** Whether the calendar should be started in month or year view. */
@@ -76,19 +75,19 @@ export class MatCalendar<D> implements AfterContentInit, OnDestroy, OnChanges {
7675
/** The currently selected date. */
7776
@Input()
7877
get selected(): D | null { return this._selected; }
79-
set selected(value: D | null) { this._selected = coerceDateProperty(this._dateAdapter, value); }
78+
set selected(value: D | null) { this._selected = this._dateAdapter.coerceToDate(value); }
8079
private _selected: D | null;
8180

8281
/** The minimum selectable date. */
8382
@Input()
8483
get minDate(): D | null { return this._minDate; }
85-
set minDate(value: D | null) { this._minDate = coerceDateProperty(this._dateAdapter, value); }
84+
set minDate(value: D | null) { this._minDate = this._dateAdapter.coerceToDate(value); }
8685
private _minDate: D | null;
8786

8887
/** The maximum selectable date. */
8988
@Input()
9089
get maxDate(): D | null { return this._maxDate; }
91-
set maxDate(value: D | null) { this._maxDate = coerceDateProperty(this._dateAdapter, value); }
90+
set maxDate(value: D | null) { this._maxDate = this._dateAdapter.coerceToDate(value); }
9291
private _maxDate: D | null;
9392

9493
/** A function used to filter which dates are selectable. */
Lines changed: 0 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +0,0 @@
1-
import {async, inject, TestBed} from '@angular/core/testing';
2-
import {DateAdapter, JAN, MatNativeDateModule} from '@angular/material/core';
3-
import {coerceDateProperty} from './index';
4-
5-
6-
describe('coerceDateProperty', () => {
7-
let adapter: DateAdapter<Date>;
8-
9-
beforeEach(async(() => {
10-
TestBed.configureTestingModule({
11-
imports: [MatNativeDateModule],
12-
});
13-
14-
TestBed.compileComponents();
15-
}));
16-
17-
beforeEach(inject([DateAdapter], (dateAdapter: DateAdapter<Date>) => {
18-
adapter = dateAdapter;
19-
}));
20-
21-
it('should pass through existing date', () => {
22-
const d = new Date(2017, JAN, 1);
23-
expect(coerceDateProperty(adapter, d)).toBe(d);
24-
});
25-
26-
it('should pass through invalid date', () => {
27-
const d = new Date(NaN);
28-
expect(coerceDateProperty(adapter, d)).toBe(d);
29-
});
30-
31-
it('should pass through null and undefined', () => {
32-
expect(coerceDateProperty(adapter, null)).toBeNull();
33-
expect(coerceDateProperty(adapter, undefined)).toBeUndefined();
34-
});
35-
36-
it('should coerce empty string to null', () => {
37-
expect(coerceDateProperty(adapter, '')).toBe(null);
38-
});
39-
40-
it('should coerce ISO 8601 string to date', () => {
41-
let isoString = '2017-01-01T00:00:00Z';
42-
expect(coerceDateProperty(adapter, isoString)).toEqual(new Date(isoString));
43-
});
44-
45-
it('should throw when given a number', () => {
46-
expect(() => coerceDateProperty(adapter, 5)).toThrow();
47-
expect(() => coerceDateProperty(adapter, 0)).toThrow();
48-
});
49-
50-
it('should throw when given a string with incorrect format', () => {
51-
expect(() => coerceDateProperty(adapter, '1/1/2017')).toThrow();
52-
expect(() => coerceDateProperty(adapter, 'hello')).toThrow();
53-
});
54-
});
Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +0,0 @@
1-
/**
2-
* @license
3-
* Copyright Google LLC All Rights Reserved.
4-
*
5-
* Use of this source code is governed by an MIT-style license that can be
6-
* found in the LICENSE file at https://angular.io/license
7-
*/
8-
9-
import {DateAdapter} from '@angular/material/core';
10-
11-
12-
/**
13-
* Function that attempts to coerce a value to a date using a DateAdapter. Date instances, null,
14-
* and undefined will be passed through. Empty strings will be coerced to null. Valid ISO 8601
15-
* strings (https://www.ietf.org/rfc/rfc3339.txt) will be coerced to dates. All other values will
16-
* result in an error being thrown.
17-
* @param adapter The date adapter to use for coercion
18-
* @param value The value to coerce.
19-
* @return A date object coerced from the value.
20-
* @throws Throws when the value cannot be coerced.
21-
*/
22-
export function coerceDateProperty<D>(adapter: DateAdapter<D>, value: any): D | null {
23-
if (typeof value === 'string') {
24-
if (value == '') {
25-
value = null;
26-
} else {
27-
value = adapter.fromIso8601(value) || value;
28-
}
29-
}
30-
if (value == null || adapter.isDateInstance(value)) {
31-
return value;
32-
}
33-
throw Error(`Datepicker: Value must be either a date object recognized by the DateAdapter or ` +
34-
`an ISO 8601 string. Instead got: ${value}`);
35-
}

src/lib/datepicker/datepicker-input.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ import {
3434
import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats} from '@angular/material/core';
3535
import {MatFormField} from '@angular/material/form-field';
3636
import {Subscription} from 'rxjs/Subscription';
37-
import {coerceDateProperty} from './coerce-date-property';
3837
import {MatDatepicker} from './datepicker';
3938
import {createMissingDateImplError} from './datepicker-errors';
4039

@@ -113,7 +112,7 @@ export class MatDatepickerInput<D> implements AfterContentInit, ControlValueAcce
113112
return this._value;
114113
}
115114
set value(value: D | null) {
116-
value = coerceDateProperty(this._dateAdapter, value);
115+
value = this._dateAdapter.coerceToDate(value);
117116
this._lastValueValid = !value || this._dateAdapter.isValid(value);
118117
value = this._getValidDateOrNull(value);
119118

@@ -131,7 +130,7 @@ export class MatDatepickerInput<D> implements AfterContentInit, ControlValueAcce
131130
@Input()
132131
get min(): D | null { return this._min; }
133132
set min(value: D | null) {
134-
this._min = coerceDateProperty(this._dateAdapter, value);
133+
this._min = this._dateAdapter.coerceToDate(value);
135134
this._validatorOnChange();
136135
}
137136
private _min: D | null;
@@ -140,7 +139,7 @@ export class MatDatepickerInput<D> implements AfterContentInit, ControlValueAcce
140139
@Input()
141140
get max(): D | null { return this._max; }
142141
set max(value: D | null) {
143-
this._max = coerceDateProperty(this._dateAdapter, value);
142+
this._max = this._dateAdapter.coerceToDate(value);
144143
this._validatorOnChange();
145144
}
146145
private _max: D | null;
@@ -188,23 +187,23 @@ export class MatDatepickerInput<D> implements AfterContentInit, ControlValueAcce
188187

189188
/** The form control validator for the min date. */
190189
private _minValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
191-
const controlValue = coerceDateProperty(this._dateAdapter, control.value);
190+
const controlValue = this._dateAdapter.coerceToDate(control.value);
192191
return (!this.min || !controlValue ||
193192
this._dateAdapter.compareDate(this.min, controlValue) <= 0) ?
194193
null : {'matDatepickerMin': {'min': this.min, 'actual': controlValue}};
195194
}
196195

197196
/** The form control validator for the max date. */
198197
private _maxValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
199-
const controlValue = coerceDateProperty(this._dateAdapter, control.value);
198+
const controlValue = this._dateAdapter.coerceToDate(control.value);
200199
return (!this.max || !controlValue ||
201200
this._dateAdapter.compareDate(this.max, controlValue) >= 0) ?
202201
null : {'matDatepickerMax': {'max': this.max, 'actual': controlValue}};
203202
}
204203

205204
/** The form control validator for the date filter. */
206205
private _filterValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
207-
const controlValue = coerceDateProperty(this._dateAdapter, control.value);
206+
const controlValue = this._dateAdapter.coerceToDate(control.value);
208207
return !this._dateFilter || !controlValue || this._dateFilter(controlValue) ?
209208
null : {'matDatepickerFilter': true};
210209
}

src/lib/datepicker/datepicker.spec.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -275,9 +275,8 @@ describe('MatDatepicker', () => {
275275
it('should throw when given wrong data type', () => {
276276
testComponent.date = '1/1/2017' as any;
277277

278-
expect(() => fixture.detectChanges()).toThrowError(
279-
'Datepicker: Value must be either a date object recognized by the DateAdapter or an ' +
280-
'ISO 8601 string. Instead got: 1/1/2017');
278+
expect(() => fixture.detectChanges())
279+
.toThrowError('Could not coerce "1/1/2017" to a valid date object.');
281280

282281
testComponent.date = null;
283282
});

src/lib/datepicker/datepicker.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ import {DOCUMENT} from '@angular/platform-browser';
4242
import {Subject} from 'rxjs/Subject';
4343
import {Subscription} from 'rxjs/Subscription';
4444
import {MatCalendar} from './calendar';
45-
import {coerceDateProperty} from './coerce-date-property';
4645
import {createMissingDateImplError} from './datepicker-errors';
4746
import {MatDatepickerInput} from './datepicker-input';
4847

@@ -133,7 +132,7 @@ export class MatDatepicker<D> implements OnDestroy {
133132
// selected value is.
134133
return this._startAt || (this._datepickerInput ? this._datepickerInput.value : null);
135134
}
136-
set startAt(date: D | null) { this._startAt = coerceDateProperty(this._dateAdapter, date); }
135+
set startAt(date: D | null) { this._startAt = this._dateAdapter.coerceToDate(date); }
137136
private _startAt: D | null;
138137

139138
/** The view that the calendar should start in. */

src/lib/datepicker/month-view.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {
2020
} from '@angular/core';
2121
import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats} from '@angular/material/core';
2222
import {MatCalendarCell} from './calendar-body';
23-
import {coerceDateProperty} from './coerce-date-property';
2423
import {createMissingDateImplError} from './datepicker-errors';
2524

2625

@@ -47,7 +46,7 @@ export class MatMonthView<D> implements AfterContentInit {
4746
get activeDate(): D { return this._activeDate; }
4847
set activeDate(value: D) {
4948
let oldActiveDate = this._activeDate;
50-
this._activeDate = coerceDateProperty(this._dateAdapter, value) || this._dateAdapter.today();
49+
this._activeDate = this._dateAdapter.coerceToDate(value) || this._dateAdapter.today();
5150
if (!this._hasSameMonthAndYear(oldActiveDate, this._activeDate)) {
5251
this._init();
5352
}
@@ -58,7 +57,7 @@ export class MatMonthView<D> implements AfterContentInit {
5857
@Input()
5958
get selected(): D | null { return this._selected; }
6059
set selected(value: D | null) {
61-
this._selected = coerceDateProperty(this._dateAdapter, value);
60+
this._selected = this._dateAdapter.coerceToDate(value);
6261
this._selectedDate = this._getDateInCurrentMonth(this._selected);
6362
}
6463
private _selected: D | null;

src/lib/datepicker/public-api.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
export * from './datepicker-module';
1010
export * from './calendar';
1111
export * from './calendar-body';
12-
export * from './coerce-date-property';
1312
export * from './datepicker';
1413
export * from './datepicker-input';
1514
export * from './datepicker-intl';

0 commit comments

Comments
 (0)