fix(datepicker): allow ISO 8601 strings as inputs (#7091)

This commit is contained in:
mmalerba
2017-09-20 16:06:40 -07:00
committed by Jeremy Elbourn
parent 602a861621
commit d2ceb2c755
14 changed files with 244 additions and 43 deletions
+9 -3
View File
@@ -164,12 +164,18 @@ export abstract class DateAdapter<D> {
abstract addCalendarDays(date: D, days: number): D;
/**
* Gets the RFC 3339 compatible date string (https://tools.ietf.org/html/rfc3339) for the given
* date.
* Gets the RFC 3339 compatible string (https://tools.ietf.org/html/rfc3339) for the given date.
* @param date The date to get the ISO date string for.
* @returns The ISO date string date string.
*/
abstract getISODateString(date: D): string;
abstract toIso8601(date: D): string;
/**
* Creates a date from an RFC 3339 compatible string (https://tools.ietf.org/html/rfc3339).
* @param iso8601String The ISO date string to create a date from
* @returns The date created from the ISO date string.
*/
abstract fromIso8601(iso8601String: string): D | null;
/**
* Checks whether the given object is considered a date instance by this DateAdapter.
@@ -331,6 +331,14 @@ describe('NativeDateAdapter', () => {
let d = '1/1/2017';
expect(adapter.isDateInstance(d)).toBe(false);
});
it('should create dates from valid ISO strings', () => {
expect(adapter.fromIso8601('1985-04-12T23:20:50.52Z')).not.toBeNull();
expect(adapter.fromIso8601('1996-12-19T16:39:57-08:00')).not.toBeNull();
expect(adapter.fromIso8601('1937-01-01T12:00:27.87+00:20')).not.toBeNull();
expect(adapter.fromIso8601('1990-13-31T23:59:00Z')).toBeNull();
expect(adapter.fromIso8601('1/1/2017')).toBeNull();
});
});
+22 -1
View File
@@ -38,6 +38,15 @@ const DEFAULT_DAY_OF_WEEK_NAMES = {
};
/**
* Matches strings that have the form of a valid RFC 3339 string
* (https://tools.ietf.org/html/rfc3339). Note that the string may not actually be a valid date
* because the regex will match strings an with out of bounds month, date, etc.
*/
const ISO_8601_REGEX =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|(?:(?:\+|-)\d{2}:\d{2}))$/;
/** Creates an array and fills it with values. */
function range<T>(length: number, valueFunction: (index: number) => T): T[] {
const valuesArray = Array(length);
@@ -202,7 +211,7 @@ export class NativeDateAdapter extends DateAdapter<Date> {
this.getYear(date), this.getMonth(date), this.getDate(date) + days);
}
getISODateString(date: Date): string {
toIso8601(date: Date): string {
return [
date.getUTCFullYear(),
this._2digit(date.getUTCMonth() + 1),
@@ -210,6 +219,18 @@ export class NativeDateAdapter extends DateAdapter<Date> {
].join('-');
}
fromIso8601(iso8601String: string): Date | null {
// The `Date` constructor accepts formats other than ISO 8601, so we need to make sure the
// string is the right format first.
if (ISO_8601_REGEX.test(iso8601String)) {
let d = new Date(iso8601String);
if (this.isValid(d)) {
return d;
}
}
return null;
}
isDateInstance(obj: any) {
return obj instanceof Date;
}
+17 -4
View File
@@ -40,6 +40,7 @@ import {
} from '@angular/material/core';
import {first} from 'rxjs/operator/first';
import {Subscription} from 'rxjs/Subscription';
import {coerceDateProperty} from './coerce-date-property';
import {createMissingDateImplError} from './datepicker-errors';
import {MdDatepickerIntl} from './datepicker-intl';
@@ -64,19 +65,31 @@ export class MdCalendar<D> implements AfterContentInit, OnDestroy {
private _intlChanges: Subscription;
/** A date representing the period (month or year) to start the calendar in. */
@Input() startAt: D;
@Input()
get startAt(): D | null { return this._startAt; }
set startAt(value: D | null) { this._startAt = coerceDateProperty(this._dateAdapter, value); }
private _startAt: D | null;
/** Whether the calendar should be started in month or year view. */
@Input() startView: 'month' | 'year' = 'month';
/** The currently selected date. */
@Input() selected: D | null;
@Input()
get selected(): D | null { return this._selected; }
set selected(value: D | null) { this._selected = coerceDateProperty(this._dateAdapter, value); }
private _selected: D | null;
/** The minimum selectable date. */
@Input() minDate: D | null;
@Input()
get minDate(): D | null { return this._minDate; }
set minDate(value: D | null) { this._minDate = coerceDateProperty(this._dateAdapter, value); }
private _minDate: D | null;
/** The maximum selectable date. */
@Input() maxDate: D | null;
@Input()
get maxDate(): D | null { return this._maxDate; }
set maxDate(value: D | null) { this._maxDate = coerceDateProperty(this._dateAdapter, value); }
private _maxDate: D | null;
/** A function used to filter which dates are selectable. */
@Input() dateFilter: (date: D) => boolean;
@@ -0,0 +1,54 @@
import {async, inject, TestBed} from '@angular/core/testing';
import {DateAdapter, JAN, MdNativeDateModule} from '@angular/material/core';
import {coerceDateProperty} from './index';
describe('coerceDateProperty', () => {
let adapter: DateAdapter<Date>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [MdNativeDateModule],
});
TestBed.compileComponents();
}));
beforeEach(inject([DateAdapter], (dateAdapter: DateAdapter<Date>) => {
adapter = dateAdapter;
}));
it('should pass through existing date', () => {
const d = new Date(2017, JAN, 1);
expect(coerceDateProperty(adapter, d)).toBe(d);
});
it('should pass through invalid date', () => {
const d = new Date(NaN);
expect(coerceDateProperty(adapter, d)).toBe(d);
});
it('should pass through null and undefined', () => {
expect(coerceDateProperty(adapter, null)).toBeNull();
expect(coerceDateProperty(adapter, undefined)).toBeUndefined();
});
it('should coerce empty string to null', () => {
expect(coerceDateProperty(adapter, '')).toBe(null);
});
it('should coerce ISO 8601 string to date', () => {
let isoString = '2017-01-01T00:00:00Z';
expect(coerceDateProperty(adapter, isoString)).toEqual(new Date(isoString));
});
it('should throw when given a number', () => {
expect(() => coerceDateProperty(adapter, 5)).toThrow();
expect(() => coerceDateProperty(adapter, 0)).toThrow();
});
it('should throw when given a string with incorrect format', () => {
expect(() => coerceDateProperty(adapter, '1/1/2017')).toThrow();
expect(() => coerceDateProperty(adapter, 'hello')).toThrow();
});
});
@@ -0,0 +1,35 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {DateAdapter} from '@angular/material/core';
/**
* Function that attempts to coerce a value to a date using a DateAdapter. Date instances, null,
* and undefined will be passed through. Empty strings will be coerced to null. Valid ISO 8601
* strings (https://www.ietf.org/rfc/rfc3339.txt) will be coerced to dates. All other values will
* result in an error being thrown.
* @param adapter The date adapter to use for coercion
* @param value The value to coerce.
* @return A date object coerced from the value.
* @throws Throws when the value cannot be coerced.
*/
export function coerceDateProperty<D>(adapter: DateAdapter<D>, value: any): D | null {
if (typeof value === 'string') {
if (value == '') {
value = null;
} else {
value = adapter.fromIso8601(value) || value;
}
}
if (value == null || adapter.isDateInstance(value)) {
return value;
}
throw Error(`Datepicker: Value must be either a date object recognized by the DateAdapter or ` +
`an ISO 8601 string. Instead got: ${value}`);
}
+16 -14
View File
@@ -34,6 +34,7 @@ import {
import {DateAdapter, MD_DATE_FORMATS, MdDateFormats} from '@angular/material/core';
import {MdFormField} from '@angular/material/form-field';
import {Subscription} from 'rxjs/Subscription';
import {coerceDateProperty} from './coerce-date-property';
import {MdDatepicker} from './datepicker';
import {createMissingDateImplError} from './datepicker-errors';
@@ -74,8 +75,8 @@ export class MdDatepickerInputEvent<D> {
host: {
'[attr.aria-haspopup]': 'true',
'[attr.aria-owns]': '(_datepicker?.opened && _datepicker.id) || null',
'[attr.min]': 'min ? _dateAdapter.getISODateString(min) : null',
'[attr.max]': 'max ? _dateAdapter.getISODateString(max) : null',
'[attr.min]': 'min ? _dateAdapter.toIso8601(min) : null',
'[attr.max]': 'max ? _dateAdapter.toIso8601(max) : null',
'[disabled]': 'disabled',
'(input)': '_onInput($event.target.value)',
'(change)': '_onChange()',
@@ -122,9 +123,7 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
return this._value;
}
set value(value: D | null) {
if (value != null && !this._dateAdapter.isDateInstance(value)) {
throw Error('Datepicker: value not recognized as a date object by DateAdapter.');
}
value = coerceDateProperty(this._dateAdapter, value);
this._lastValueValid = !value || this._dateAdapter.isValid(value);
value = this._getValidDateOrNull(value);
@@ -142,7 +141,7 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
@Input()
get min(): D | null { return this._min; }
set min(value: D | null) {
this._min = value;
this._min = coerceDateProperty(this._dateAdapter, value);
this._validatorOnChange();
}
private _min: D | null;
@@ -151,7 +150,7 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
@Input()
get max(): D | null { return this._max; }
set max(value: D | null) {
this._max = value;
this._max = coerceDateProperty(this._dateAdapter, value);
this._validatorOnChange();
}
private _max: D | null;
@@ -199,21 +198,24 @@ export class MdDatepickerInput<D> implements AfterContentInit, ControlValueAcces
/** The form control validator for the min date. */
private _minValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
return (!this.min || !control.value ||
this._dateAdapter.compareDate(this.min, control.value) <= 0) ?
null : {'mdDatepickerMin': {'min': this.min, 'actual': control.value}};
const controlValue = coerceDateProperty(this._dateAdapter, control.value);
return (!this.min || !controlValue ||
this._dateAdapter.compareDate(this.min, controlValue) <= 0) ?
null : {'mdDatepickerMin': {'min': this.min, 'actual': controlValue}};
}
/** The form control validator for the max date. */
private _maxValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
return (!this.max || !control.value ||
this._dateAdapter.compareDate(this.max, control.value) >= 0) ?
null : {'mdDatepickerMax': {'max': this.max, 'actual': control.value}};
const controlValue = coerceDateProperty(this._dateAdapter, control.value);
return (!this.max || !controlValue ||
this._dateAdapter.compareDate(this.max, controlValue) >= 0) ?
null : {'mdDatepickerMax': {'max': this.max, 'actual': controlValue}};
}
/** The form control validator for the date filter. */
private _filterValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
return !this._dateFilter || !control.value || this._dateFilter(control.value) ?
const controlValue = coerceDateProperty(this._dateAdapter, control.value);
return !this._dateFilter || !controlValue || this._dateFilter(controlValue) ?
null : {'mdDatepickerFilter': true};
}
+47 -2
View File
@@ -12,6 +12,8 @@ import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {
DEC,
JAN,
JUL,
JUN,
MAT_DATE_LOCALE,
MdNativeDateModule,
NativeDateModule,
@@ -47,6 +49,7 @@ describe('MdDatepicker', () => {
DatepickerWithChangeAndInputEvents,
DatepickerWithFilterAndValidation,
DatepickerWithFormControl,
DatepickerWithISOStrings,
DatepickerWithMinAndMaxValidation,
DatepickerWithNgModel,
DatepickerWithStartAt,
@@ -270,8 +273,9 @@ describe('MdDatepicker', () => {
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\./);
expect(() => fixture.detectChanges()).toThrowError(
'Datepicker: Value must be either a date object recognized by the DateAdapter or an ' +
'ISO 8601 string. Instead got: 1/1/2017');
testComponent.date = null;
});
@@ -865,6 +869,32 @@ describe('MdDatepicker', () => {
expect(testComponent.onDateInput).toHaveBeenCalled();
});
});
describe('with ISO 8601 strings as input', () => {
let fixture: ComponentFixture<DatepickerWithISOStrings>;
let testComponent: DatepickerWithISOStrings;
beforeEach(async(() => {
fixture = TestBed.createComponent(DatepickerWithISOStrings);
testComponent = fixture.componentInstance;
}));
afterEach(async(() => {
testComponent.datepicker.close();
fixture.detectChanges();
}));
it('should coerce ISO strings', async(() => {
expect(() => fixture.detectChanges()).not.toThrow();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(testComponent.datepicker.startAt).toEqual(new Date(2017, JUL, 1));
expect(testComponent.datepickerInput.value).toEqual(new Date(2017, JUN, 1));
expect(testComponent.datepickerInput.min).toEqual(new Date(2017, JAN, 1));
expect(testComponent.datepickerInput.max).toEqual(new Date(2017, DEC, 31));
});
}));
});
});
describe('with missing DateAdapter and MD_DATE_FORMATS', () => {
@@ -1179,3 +1209,18 @@ class DatepickerWithi18n {
@ViewChild('d') datepicker: MdDatepicker<Date>;
@ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput<Date>;
}
@Component({
template: `
<input [mdDatepicker]="d" [(ngModel)]="value" [min]="min" [max]="max">
<md-datepicker #d [startAt]="startAt"></md-datepicker>
`
})
class DatepickerWithISOStrings {
value = new Date(2017, JUN, 1).toISOString();
min = new Date(2017, JAN, 1).toISOString();
max = new Date (2017, DEC, 31).toISOString();
startAt = new Date(2017, JUL, 1).toISOString();
@ViewChild('d') datepicker: MdDatepicker<Date>;
@ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput<Date>;
}
+2 -1
View File
@@ -42,6 +42,7 @@ import {DOCUMENT} from '@angular/platform-browser';
import {Subject} from 'rxjs/Subject';
import {Subscription} from 'rxjs/Subscription';
import {MdCalendar} from './calendar';
import {coerceDateProperty} from './coerce-date-property';
import {createMissingDateImplError} from './datepicker-errors';
import {MdDatepickerInput} from './datepicker-input';
@@ -131,7 +132,7 @@ export class MdDatepicker<D> implements OnDestroy {
// selected value is.
return this._startAt || (this._datepickerInput ? this._datepickerInput.value : null);
}
set startAt(date: D | null) { this._startAt = date; }
set startAt(date: D | null) { this._startAt = coerceDateProperty(this._dateAdapter, date); }
private _startAt: D | null;
/** The view that the calendar should start in. */
+10 -9
View File
@@ -19,6 +19,7 @@ import {
} from '@angular/core';
import {DateAdapter, MD_DATE_FORMATS, MdDateFormats} from '@angular/material/core';
import {MdCalendarCell} from './calendar-body';
import {coerceDateProperty} from './coerce-date-property';
import {createMissingDateImplError} from './datepicker-errors';
@@ -45,7 +46,7 @@ export class MdMonthView<D> implements AfterContentInit {
get activeDate(): D { return this._activeDate; }
set activeDate(value: D) {
let oldActiveDate = this._activeDate;
this._activeDate = value || this._dateAdapter.today();
this._activeDate = coerceDateProperty(this._dateAdapter, value) || this._dateAdapter.today();
if (!this._hasSameMonthAndYear(oldActiveDate, this._activeDate)) {
this._init();
}
@@ -54,12 +55,12 @@ export class MdMonthView<D> implements AfterContentInit {
/** The currently selected date. */
@Input()
get selected(): D { return this._selected; }
set selected(value: D) {
this._selected = value;
this._selectedDate = this._getDateInCurrentMonth(this.selected);
get selected(): D | null { return this._selected; }
set selected(value: D | null) {
this._selected = coerceDateProperty(this._dateAdapter, value);
this._selectedDate = this._getDateInCurrentMonth(this._selected);
}
private _selected: D;
private _selected: D | null;
/** A function used to filter which dates are selectable. */
@Input() dateFilter: (date: D) => boolean;
@@ -172,13 +173,13 @@ export class MdMonthView<D> implements AfterContentInit {
* Gets the date in this month that the given Date falls on.
* Returns null if the given Date is in another month.
*/
private _getDateInCurrentMonth(date: D): number | null {
return this._hasSameMonthAndYear(date, this.activeDate) ?
private _getDateInCurrentMonth(date: D | null): number | null {
return date && this._hasSameMonthAndYear(date, this.activeDate) ?
this._dateAdapter.getDate(date) : null;
}
/** Checks whether the 2 dates are non-null and fall within the same month of the same year. */
private _hasSameMonthAndYear(d1: D, d2: D): boolean {
private _hasSameMonthAndYear(d1: D | null, d2: D | null): boolean {
return !!(d1 && d2 && this._dateAdapter.getMonth(d1) == this._dateAdapter.getMonth(d2) &&
this._dateAdapter.getYear(d1) == this._dateAdapter.getYear(d2));
}
+1
View File
@@ -9,6 +9,7 @@
export * from './datepicker-module';
export * from './calendar';
export * from './calendar-body';
export * from './coerce-date-property';
export * from './datepicker';
export * from './datepicker-input';
export * from './datepicker-intl';
+8 -7
View File
@@ -19,6 +19,7 @@ import {
} from '@angular/core';
import {DateAdapter, MD_DATE_FORMATS, MdDateFormats} from '@angular/material/core';
import {MdCalendarCell} from './calendar-body';
import {coerceDateProperty} from './coerce-date-property';
import {createMissingDateImplError} from './datepicker-errors';
@@ -40,7 +41,7 @@ export class MdYearView<D> implements AfterContentInit {
get activeDate(): D { return this._activeDate; }
set activeDate(value: D) {
let oldActiveDate = this._activeDate;
this._activeDate = value || this._dateAdapter.today();
this._activeDate = coerceDateProperty(this._dateAdapter, value) || this._dateAdapter.today();
if (this._dateAdapter.getYear(oldActiveDate) != this._dateAdapter.getYear(this._activeDate)) {
this._init();
}
@@ -49,12 +50,12 @@ export class MdYearView<D> implements AfterContentInit {
/** The currently selected date. */
@Input()
get selected(): D { return this._selected; }
set selected(value: D) {
this._selected = value;
this._selectedMonth = this._getMonthInCurrentYear(this.selected);
get selected(): D | null { return this._selected; }
set selected(value: D | null) {
this._selected = coerceDateProperty(this._dateAdapter, value);
this._selectedMonth = this._getMonthInCurrentYear(this._selected);
}
private _selected: D;
private _selected: D | null;
/** A function used to filter which dates are selectable. */
@Input() dateFilter: (date: D) => boolean;
@@ -118,7 +119,7 @@ export class MdYearView<D> implements AfterContentInit {
* Gets the month in this year that the given Date falls on.
* Returns null if the given Date is in another year.
*/
private _getMonthInCurrentYear(date: D) {
private _getMonthInCurrentYear(date: D | null) {
return date && this._dateAdapter.getYear(date) == this._dateAdapter.getYear(this.activeDate) ?
this._dateAdapter.getMonth(date) : null;
}
@@ -309,6 +309,14 @@ describe('MomentDateAdapter', () => {
expect(adapter.isDateInstance(d)).toBe(false);
});
it('should create dates from valid ISO strings', () => {
expect(adapter.fromIso8601('1985-04-12T23:20:50.52Z')).not.toBeNull();
expect(adapter.fromIso8601('1996-12-19T16:39:57-08:00')).not.toBeNull();
expect(adapter.fromIso8601('1937-01-01T12:00:27.87+00:20')).not.toBeNull();
expect(adapter.fromIso8601('1990-13-31T23:59:00Z')).toBeNull();
expect(adapter.fromIso8601('1/1/2017')).toBeNull();
});
it('setLocale should not modify global moment locale', () => {
expect(moment.locale()).toBe('en');
adapter.setLocale('ja-JP');
@@ -342,7 +350,7 @@ describe('MomentDateAdapter', () => {
adapter.addCalendarDays(date, 1);
adapter.addCalendarMonths(date, 1);
adapter.addCalendarYears(date, 1);
adapter.getISODateString(date);
adapter.toIso8601(date);
adapter.isDateInstance(date);
adapter.isValid(date);
expect(date.locale()).toBe('en');
@@ -170,10 +170,15 @@ export class MomentDateAdapter extends DateAdapter<Moment> {
return this.clone(date).add({days});
}
getISODateString(date: Moment): string {
toIso8601(date: Moment): string {
return this.clone(date).format();
}
fromIso8601(iso8601String: string): Moment | null {
let d = moment(iso8601String, moment.ISO_8601).locale(this.locale);
return this.isValid(d) ? d : null;
}
isDateInstance(obj: any): boolean {
return moment.isMoment(obj);
}