Skip to content

Commit d2ceb2c

Browse files
mmalerbajelbourn
authored andcommitted
fix(datepicker): allow ISO 8601 strings as inputs (#7091)
1 parent 602a861 commit d2ceb2c

14 files changed

+244
-43
lines changed

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,12 +164,18 @@ export abstract class DateAdapter<D> {
164164
abstract addCalendarDays(date: D, days: number): D;
165165

166166
/**
167-
* Gets the RFC 3339 compatible date string (https://tools.ietf.org/html/rfc3339) for the given
168-
* date.
167+
* Gets the RFC 3339 compatible string (https://tools.ietf.org/html/rfc3339) for the given date.
169168
* @param date The date to get the ISO date string for.
170169
* @returns The ISO date string date string.
171170
*/
172-
abstract getISODateString(date: D): string;
171+
abstract toIso8601(date: D): string;
172+
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;
173179

174180
/**
175181
* Checks whether the given object is considered a date instance by this DateAdapter.

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,14 @@ describe('NativeDateAdapter', () => {
331331
let d = '1/1/2017';
332332
expect(adapter.isDateInstance(d)).toBe(false);
333333
});
334+
335+
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('1990-13-31T23:59:00Z')).toBeNull();
340+
expect(adapter.fromIso8601('1/1/2017')).toBeNull();
341+
});
334342
});
335343

336344

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ const DEFAULT_DAY_OF_WEEK_NAMES = {
3838
};
3939

4040

41+
/**
42+
* Matches strings that have the form of a valid RFC 3339 string
43+
* (https://tools.ietf.org/html/rfc3339). Note that the string may not actually be a valid date
44+
* because the regex will match strings an with out of bounds month, date, etc.
45+
*/
46+
const ISO_8601_REGEX =
47+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|(?:(?:\+|-)\d{2}:\d{2}))$/;
48+
49+
4150
/** Creates an array and fills it with values. */
4251
function range<T>(length: number, valueFunction: (index: number) => T): T[] {
4352
const valuesArray = Array(length);
@@ -202,14 +211,26 @@ export class NativeDateAdapter extends DateAdapter<Date> {
202211
this.getYear(date), this.getMonth(date), this.getDate(date) + days);
203212
}
204213

205-
getISODateString(date: Date): string {
214+
toIso8601(date: Date): string {
206215
return [
207216
date.getUTCFullYear(),
208217
this._2digit(date.getUTCMonth() + 1),
209218
this._2digit(date.getUTCDate())
210219
].join('-');
211220
}
212221

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;
229+
}
230+
}
231+
return null;
232+
}
233+
213234
isDateInstance(obj: any) {
214235
return obj instanceof Date;
215236
}

src/lib/datepicker/calendar.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
} from '@angular/material/core';
4141
import {first} from 'rxjs/operator/first';
4242
import {Subscription} from 'rxjs/Subscription';
43+
import {coerceDateProperty} from './coerce-date-property';
4344
import {createMissingDateImplError} from './datepicker-errors';
4445
import {MdDatepickerIntl} from './datepicker-intl';
4546

@@ -64,19 +65,31 @@ export class MdCalendar<D> implements AfterContentInit, OnDestroy {
6465
private _intlChanges: Subscription;
6566

6667
/** A date representing the period (month or year) to start the calendar in. */
67-
@Input() startAt: D;
68+
@Input()
69+
get startAt(): D | null { return this._startAt; }
70+
set startAt(value: D | null) { this._startAt = coerceDateProperty(this._dateAdapter, value); }
71+
private _startAt: D | null;
6872

6973
/** Whether the calendar should be started in month or year view. */
7074
@Input() startView: 'month' | 'year' = 'month';
7175

7276
/** The currently selected date. */
73-
@Input() selected: D | null;
77+
@Input()
78+
get selected(): D | null { return this._selected; }
79+
set selected(value: D | null) { this._selected = coerceDateProperty(this._dateAdapter, value); }
80+
private _selected: D | null;
7481

7582
/** The minimum selectable date. */
76-
@Input() minDate: D | null;
83+
@Input()
84+
get minDate(): D | null { return this._minDate; }
85+
set minDate(value: D | null) { this._minDate = coerceDateProperty(this._dateAdapter, value); }
86+
private _minDate: D | null;
7787

7888
/** The maximum selectable date. */
79-
@Input() maxDate: D | null;
89+
@Input()
90+
get maxDate(): D | null { return this._maxDate; }
91+
set maxDate(value: D | null) { this._maxDate = coerceDateProperty(this._dateAdapter, value); }
92+
private _maxDate: D | null;
8093

8194
/** A function used to filter which dates are selectable. */
8295
@Input() dateFilter: (date: D) => boolean;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {async, inject, TestBed} from '@angular/core/testing';
2+
import {DateAdapter, JAN, MdNativeDateModule} 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: [MdNativeDateModule],
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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. 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: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
import {DateAdapter, MD_DATE_FORMATS, MdDateFormats} from '@angular/material/core';
3535
import {MdFormField} from '@angular/material/form-field';
3636
import {Subscription} from 'rxjs/Subscription';
37+
import {coerceDateProperty} from './coerce-date-property';
3738
import {MdDatepicker} from './datepicker';
3839
import {createMissingDateImplError} from './datepicker-errors';
3940

@@ -74,8 +75,8 @@ export class MdDatepickerInputEvent<D> {
7475
host: {
7576
'[attr.aria-haspopup]': 'true',
7677
'[attr.aria-owns]': '(_datepicker?.opened && _datepicker.id) || null',
77-
'[attr.min]': 'min ? _dateAdapter.getISODateString(min) : null',
78-
'[attr.max]': 'max ? _dateAdapter.getISODateString(max) : null',
78+
'[attr.min]': 'min ? _dateAdapter.toIso8601(min) : null',
79+
'[attr.max]': 'max ? _dateAdapter.toIso8601(max) : null',
7980
'[disabled]': 'disabled',
8081
'(input)': '_onInput($event.target.value)',
8182
'(change)': '_onChange()',
@@ -122,9 +123,7 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
122123
return this._value;
123124
}
124125
set value(value: D | null) {
125-
if (value != null && !this._dateAdapter.isDateInstance(value)) {
126-
throw Error('Datepicker: value not recognized as a date object by DateAdapter.');
127-
}
126+
value = coerceDateProperty(this._dateAdapter, value);
128127
this._lastValueValid = !value || this._dateAdapter.isValid(value);
129128
value = this._getValidDateOrNull(value);
130129

@@ -142,7 +141,7 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
142141
@Input()
143142
get min(): D | null { return this._min; }
144143
set min(value: D | null) {
145-
this._min = value;
144+
this._min = coerceDateProperty(this._dateAdapter, value);
146145
this._validatorOnChange();
147146
}
148147
private _min: D | null;
@@ -151,7 +150,7 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
151150
@Input()
152151
get max(): D | null { return this._max; }
153152
set max(value: D | null) {
154-
this._max = value;
153+
this._max = coerceDateProperty(this._dateAdapter, value);
155154
this._validatorOnChange();
156155
}
157156
private _max: D | null;
@@ -199,21 +198,24 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
199198

200199
/** The form control validator for the min date. */
201200
private _minValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
202-
return (!this.min || !control.value ||
203-
this._dateAdapter.compareDate(this.min, control.value) <= 0) ?
204-
null : {'mdDatepickerMin': {'min': this.min, 'actual': control.value}};
201+
const controlValue = coerceDateProperty(this._dateAdapter, control.value);
202+
return (!this.min || !controlValue ||
203+
this._dateAdapter.compareDate(this.min, controlValue) <= 0) ?
204+
null : {'mdDatepickerMin': {'min': this.min, 'actual': controlValue}};
205205
}
206206

207207
/** The form control validator for the max date. */
208208
private _maxValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
209-
return (!this.max || !control.value ||
210-
this._dateAdapter.compareDate(this.max, control.value) >= 0) ?
211-
null : {'mdDatepickerMax': {'max': this.max, 'actual': control.value}};
209+
const controlValue = coerceDateProperty(this._dateAdapter, control.value);
210+
return (!this.max || !controlValue ||
211+
this._dateAdapter.compareDate(this.max, controlValue) >= 0) ?
212+
null : {'mdDatepickerMax': {'max': this.max, 'actual': controlValue}};
212213
}
213214

214215
/** The form control validator for the date filter. */
215216
private _filterValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
216-
return !this._dateFilter || !control.value || this._dateFilter(control.value) ?
217+
const controlValue = coerceDateProperty(this._dateAdapter, control.value);
218+
return !this._dateFilter || !controlValue || this._dateFilter(controlValue) ?
217219
null : {'mdDatepickerFilter': true};
218220
}
219221

src/lib/datepicker/datepicker.spec.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
1212
import {
1313
DEC,
1414
JAN,
15+
JUL,
16+
JUN,
1517
MAT_DATE_LOCALE,
1618
MdNativeDateModule,
1719
NativeDateModule,
@@ -47,6 +49,7 @@ describe('MdDatepicker', () => {
4749
DatepickerWithChangeAndInputEvents,
4850
DatepickerWithFilterAndValidation,
4951
DatepickerWithFormControl,
52+
DatepickerWithISOStrings,
5053
DatepickerWithMinAndMaxValidation,
5154
DatepickerWithNgModel,
5255
DatepickerWithStartAt,
@@ -270,8 +273,9 @@ describe('MdDatepicker', () => {
270273
it('should throw when given wrong data type', () => {
271274
testComponent.date = '1/1/2017' as any;
272275

273-
expect(() => fixture.detectChanges())
274-
.toThrowError(/Datepicker: value not recognized as a date object by DateAdapter\./);
276+
expect(() => fixture.detectChanges()).toThrowError(
277+
'Datepicker: Value must be either a date object recognized by the DateAdapter or an ' +
278+
'ISO 8601 string. Instead got: 1/1/2017');
275279

276280
testComponent.date = null;
277281
});
@@ -865,6 +869,32 @@ describe('MdDatepicker', () => {
865869
expect(testComponent.onDateInput).toHaveBeenCalled();
866870
});
867871
});
872+
873+
describe('with ISO 8601 strings as input', () => {
874+
let fixture: ComponentFixture<DatepickerWithISOStrings>;
875+
let testComponent: DatepickerWithISOStrings;
876+
877+
beforeEach(async(() => {
878+
fixture = TestBed.createComponent(DatepickerWithISOStrings);
879+
testComponent = fixture.componentInstance;
880+
}));
881+
882+
afterEach(async(() => {
883+
testComponent.datepicker.close();
884+
fixture.detectChanges();
885+
}));
886+
887+
it('should coerce ISO strings', async(() => {
888+
expect(() => fixture.detectChanges()).not.toThrow();
889+
fixture.whenStable().then(() => {
890+
fixture.detectChanges();
891+
expect(testComponent.datepicker.startAt).toEqual(new Date(2017, JUL, 1));
892+
expect(testComponent.datepickerInput.value).toEqual(new Date(2017, JUN, 1));
893+
expect(testComponent.datepickerInput.min).toEqual(new Date(2017, JAN, 1));
894+
expect(testComponent.datepickerInput.max).toEqual(new Date(2017, DEC, 31));
895+
});
896+
}));
897+
});
868898
});
869899

870900
describe('with missing DateAdapter and MD_DATE_FORMATS', () => {
@@ -1179,3 +1209,18 @@ class DatepickerWithi18n {
11791209
@ViewChild('d') datepicker: MdDatepicker<Date>;
11801210
@ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput<Date>;
11811211
}
1212+
1213+
@Component({
1214+
template: `
1215+
<input [mdDatepicker]="d" [(ngModel)]="value" [min]="min" [max]="max">
1216+
<md-datepicker #d [startAt]="startAt"></md-datepicker>
1217+
`
1218+
})
1219+
class DatepickerWithISOStrings {
1220+
value = new Date(2017, JUN, 1).toISOString();
1221+
min = new Date(2017, JAN, 1).toISOString();
1222+
max = new Date (2017, DEC, 31).toISOString();
1223+
startAt = new Date(2017, JUL, 1).toISOString();
1224+
@ViewChild('d') datepicker: MdDatepicker<Date>;
1225+
@ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput<Date>;
1226+
}

src/lib/datepicker/datepicker.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {DOCUMENT} from '@angular/platform-browser';
4242
import {Subject} from 'rxjs/Subject';
4343
import {Subscription} from 'rxjs/Subscription';
4444
import {MdCalendar} from './calendar';
45+
import {coerceDateProperty} from './coerce-date-property';
4546
import {createMissingDateImplError} from './datepicker-errors';
4647
import {MdDatepickerInput} from './datepicker-input';
4748

@@ -131,7 +132,7 @@ export class MdDatepicker<D> implements OnDestroy {
131132
// selected value is.
132133
return this._startAt || (this._datepickerInput ? this._datepickerInput.value : null);
133134
}
134-
set startAt(date: D | null) { this._startAt = date; }
135+
set startAt(date: D | null) { this._startAt = coerceDateProperty(this._dateAdapter, date); }
135136
private _startAt: D | null;
136137

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

0 commit comments

Comments
 (0)