Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Sep 20, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/lib/core/datetime/date-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions src/lib/core/datetime/native-date-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});


Expand Down
23 changes: 22 additions & 1 deletion src/lib/core/datetime/native-date-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -202,14 +211,26 @@ 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),
this._2digit(date.getUTCDate())
].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;
}
Expand Down
21 changes: 17 additions & 4 deletions src/lib/datepicker/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -63,19 +64,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;
Expand Down
54 changes: 54 additions & 0 deletions src/lib/datepicker/coerce-date-property.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
35 changes: 35 additions & 0 deletions src/lib/datepicker/coerce-date-property.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
30 changes: 16 additions & 14 deletions src/lib/datepicker/datepicker-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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()',
Expand Down Expand Up @@ -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);

Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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};
}

Expand Down
49 changes: 47 additions & 2 deletions src/lib/datepicker/datepicker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {
DEC,
JAN,
JUL,
JUN,
MAT_DATE_LOCALE,
MdNativeDateModule,
NativeDateModule,
Expand Down Expand Up @@ -47,6 +49,7 @@ describe('MdDatepicker', () => {
DatepickerWithChangeAndInputEvents,
DatepickerWithFilterAndValidation,
DatepickerWithFormControl,
DatepickerWithISOStrings,
DatepickerWithMinAndMaxValidation,
DatepickerWithNgModel,
DatepickerWithStartAt,
Expand Down Expand Up @@ -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;
});
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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>;
}
3 changes: 2 additions & 1 deletion src/lib/datepicker/datepicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -129,7 +130,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. */
Expand Down
Loading