Skip to content

Commit

Permalink
fix(datepicker): protect against invalid min, max and start dates
Browse files Browse the repository at this point in the history
Fixes #1062 
Closes #1092
  • Loading branch information
maxokorokov committed Dec 15, 2016
1 parent 247661c commit 186c0e1
Show file tree
Hide file tree
Showing 8 changed files with 246 additions and 16 deletions.
35 changes: 34 additions & 1 deletion src/datepicker/datepicker-input.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {TestBed, ComponentFixture, fakeAsync, tick} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {createGenericTestComponent} from '../test/common';
import {createGenericTestComponent, isBrowser} from '../test/common';

import {Component} from '@angular/core';
import {FormsModule} from '@angular/forms';
Expand Down Expand Up @@ -75,12 +75,45 @@ describe('NgbInputDatepicker', () => {
expect(fixture.componentInstance.date).toEqual({year: 2016, month: 9, day: 10});
});

it('should set only valid dates', fakeAsync(() => {
const fixture = createTestCmpt(`<input ngbDatepicker [ngModel]="date">`);
const input = fixture.nativeElement.querySelector('input');

fixture.componentInstance.date = <any>{};
fixture.detectChanges();
tick();
expect(input.value).toBe('');

fixture.componentInstance.date = null;
fixture.detectChanges();
tick();
expect(input.value).toBe('');

fixture.componentInstance.date = <any>new Date();
fixture.detectChanges();
tick();
expect(input.value).toBe('');

fixture.componentInstance.date = undefined;
fixture.detectChanges();
tick();
expect(input.value).toBe('');

fixture.componentInstance.date = new NgbDate(300000, 1, 1);
fixture.detectChanges();
tick();
expect(input.value).toBe('');
}));

it('should propagate null to model when a user enters invalid date', () => {
const fixture = createTestCmpt(`<input ngbDatepicker [(ngModel)]="date">`);
const inputDebugEl = fixture.debugElement.query(By.css('input'));

inputDebugEl.triggerEventHandler('change', {target: {value: 'aaa'}});
expect(fixture.componentInstance.date).toBeNull();

inputDebugEl.triggerEventHandler('change', {target: {value: '300000-1-1'}});
expect(fixture.componentInstance.date).toBeNull();
});

it('should propagate disabled state', fakeAsync(() => {
Expand Down
13 changes: 8 additions & 5 deletions src/datepicker/datepicker-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {NgbDateParserFormatter} from './ngb-date-parser-formatter';

import {positionElements} from '../util/positioning';
import {NgbDateStruct} from './ngb-date-struct';
import {NgbDatepickerService} from './datepicker-service';

const NGB_DATEPICKER_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
Expand Down Expand Up @@ -99,7 +100,7 @@ export class NgbInputDatepicker implements ControlValueAccessor {
/**
* Date to open calendar with.
* With default calendar we use ISO 8601: 'month' is 1=Jan ... 12=Dec.
* If nothing provided, calendar will open with current month.
* If nothing or invalid date provided, calendar will open with current month.
* Use 'navigateTo(date)' as an alternative
*/
@Input() startDate: {year: number, month: number};
Expand All @@ -116,7 +117,8 @@ export class NgbInputDatepicker implements ControlValueAccessor {

constructor(
private _parserFormatter: NgbDateParserFormatter, private _elRef: ElementRef, private _vcRef: ViewContainerRef,
private _renderer: Renderer, private _cfr: ComponentFactoryResolver, ngZone: NgZone) {
private _renderer: Renderer, private _cfr: ComponentFactoryResolver, ngZone: NgZone,
private _service: NgbDatepickerService) {
this._zoneSubscription = ngZone.onStable.subscribe(() => {
if (this._cRef) {
positionElements(this._elRef.nativeElement, this._cRef.location.nativeElement, 'bottom-left');
Expand All @@ -129,7 +131,8 @@ export class NgbInputDatepicker implements ControlValueAccessor {
registerOnTouched(fn: () => any): void { this._onTouched = fn; }

writeValue(value) {
this._model = value ? new NgbDate(value.year, value.month, value.day) : null;
this._model =
value ? this._service.toValidDate({year: value.year, month: value.month, day: value.day}, null) : null;
this._writeModelValue(this._model);
}

Expand All @@ -141,7 +144,7 @@ export class NgbInputDatepicker implements ControlValueAccessor {
}

manualDateChange(value: string) {
this._model = NgbDate.from(this._parserFormatter.parse(value));
this._model = this._service.toValidDate(this._parserFormatter.parse(value), null);
this._onChange(this._model ? {year: this._model.year, month: this._model.month, day: this._model.day} : null);
this._writeModelValue(this._model);
}
Expand Down Expand Up @@ -195,7 +198,7 @@ export class NgbInputDatepicker implements ControlValueAccessor {
/**
* Navigates current view to provided date.
* With default calendar we use ISO 8601: 'month' is 1=Jan ... 12=Dec.
* If nothing provided calendar will open current month.
* If nothing or invalid date provided calendar will open current month.
* Use 'startDate' input as an alternative
*/
navigateTo(date?: {year: number, month: number}) {
Expand Down
36 changes: 36 additions & 0 deletions src/datepicker/datepicker-service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ class MockCalendar extends NgbCalendarGregorian {
getPrev(date: NgbDate, period = 'd'): NgbDate { return new NgbDate(2000, 2, 1); }

getToday(): NgbDate { return new NgbDate(2000, 1, 1); }

isValid(date: NgbDate): boolean { return true; }
}

describe('ngb-datepicker-service', () => {
Expand Down Expand Up @@ -104,4 +106,38 @@ describe('ngb-datepicker-service', () => {
expect(result).toEqual({month: 10, year: 2016});
}));

describe('toValidDate() for Gregorian Calendar', () => {

beforeEach(() => {
TestBed.configureTestingModule({
providers: [NgbDatepickerI18n, NgbDatepickerService, {provide: NgbCalendar, useClass: NgbCalendarGregorian}]
});
});

it('should convert a valid NgbDate', inject([NgbDatepickerService], (service) => {
expect(service.toValidDate(new NgbDate(2016, 10, 5))).toEqual(new NgbDate(2016, 10, 5));
expect(service.toValidDate({year: 2016, month: 10, day: 5})).toEqual(new NgbDate(2016, 10, 5));
expect(service.toValidDate(new NgbDate(999, 999, 999))).toEqual(new NgbDate(999, 999, 999));
}));

it('should return today for an invalid NgbDate',
inject([NgbDatepickerService, NgbCalendar], (service, calendar) => {
const today = calendar.getToday();
expect(service.toValidDate(null)).toEqual(today);
expect(service.toValidDate({})).toEqual(today);
expect(service.toValidDate(undefined)).toEqual(today);
expect(service.toValidDate(new Date())).toEqual(today);
}));

it('should return today if default value is undefined',
inject([NgbDatepickerService, NgbCalendar], (service, calendar) => {
expect(service.toValidDate(null, undefined)).toEqual(calendar.getToday());
}));

it('should return default value for an invalid NgbDate if provided', inject([NgbDatepickerService], (service) => {
expect(service.toValidDate(null, new NgbDate(1066, 6, 6))).toEqual(new NgbDate(1066, 6, 6));
expect(service.toValidDate(null, null)).toEqual(null);
}));
});

});
8 changes: 8 additions & 0 deletions src/datepicker/datepicker-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ export class NgbDatepickerService {
return month;
}

toValidDate(date: {year: number, month: number, day?: number}, defaultValue?: NgbDate): NgbDate {
const ngbDate = NgbDate.from(date);
if (defaultValue === undefined) {
defaultValue = this._calendar.getToday();
}
return this._calendar.isValid(ngbDate) ? ngbDate : defaultValue;
}

private _getFirstViewDate(date: NgbDate, firstDayOfWeek: number): NgbDate {
const currentMonth = date.month;
let today = new NgbDate(date.year, date.month, date.day);
Expand Down
111 changes: 111 additions & 0 deletions src/datepicker/datepicker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,117 @@ describe('ngb-datepicker', () => {
}).toThrowError();
});

it('should handle incorrect startDate values', () => {
const fixture = createTestComponent(`<ngb-datepicker [startDate]="date"></ngb-datepicker>`);
const today = new Date();
const currentMonth = `${today.getMonth() + 1}`;
const currentYear = `${today.getFullYear()}`;

expect(getMonthSelect(fixture.nativeElement).value).toBe('8');
expect(getYearSelect(fixture.nativeElement).value).toBe('2016');

fixture.componentInstance.date = null;
fixture.detectChanges();
expect(getMonthSelect(fixture.nativeElement).value).toBe(currentMonth);
expect(getYearSelect(fixture.nativeElement).value).toBe(currentYear);

fixture.componentInstance.date = undefined;
fixture.detectChanges();
expect(getMonthSelect(fixture.nativeElement).value).toBe(currentMonth);
expect(getYearSelect(fixture.nativeElement).value).toBe(currentYear);

fixture.componentInstance.date = <any>{};
fixture.detectChanges();
expect(getMonthSelect(fixture.nativeElement).value).toBe(currentMonth);
expect(getYearSelect(fixture.nativeElement).value).toBe(currentYear);

fixture.componentInstance.date = <any>new Date();
fixture.detectChanges();
expect(getMonthSelect(fixture.nativeElement).value).toBe(currentMonth);
expect(getYearSelect(fixture.nativeElement).value).toBe(currentYear);

fixture.componentInstance.date = new NgbDate(3000000, 1, 1);
fixture.detectChanges();
expect(getMonthSelect(fixture.nativeElement).value).toBe(currentMonth);
expect(getYearSelect(fixture.nativeElement).value).toBe(currentYear);
});

it('should handle incorrect minDate values', () => {
const fixture = createTestComponent(
`<ngb-datepicker [minDate]="minDate" [maxDate]="maxDate" [startDate]="date"></ngb-datepicker>`);

function expectMinDate(year: number, month: number) {
fixture.componentInstance.date = {year: 0, month: 1};
fixture.detectChanges();
expect(getMonthSelect(fixture.nativeElement).value).toBe(`${month}`);
expect(getYearSelect(fixture.nativeElement).value).toBe(`${year}`);

// resetting
fixture.componentInstance.date = {year: 1000, month: 1};
fixture.detectChanges();
}

expectMinDate(2010, 1);

fixture.componentInstance.minDate = null;
fixture.detectChanges();
expectMinDate(990, 1);

fixture.componentInstance.minDate = undefined;
fixture.detectChanges();
expectMinDate(990, 1);

fixture.componentInstance.minDate = <any>{};
fixture.detectChanges();
expectMinDate(990, 1);

fixture.componentInstance.minDate = <any>new Date();
fixture.detectChanges();
expectMinDate(990, 1);

fixture.componentInstance.minDate = new NgbDate(3000000, 1, 1);
fixture.detectChanges();
expectMinDate(990, 1);
});

it('should handle incorrect maxDate values', () => {
const fixture = createTestComponent(
`<ngb-datepicker [minDate]="minDate" [maxDate]="maxDate" [startDate]="date"></ngb-datepicker>`);

function expectMaxDate(year: number, month: number) {
fixture.componentInstance.date = {year: 10000, month: 1};
fixture.detectChanges();
expect(getMonthSelect(fixture.nativeElement).value).toBe(`${month}`);
expect(getYearSelect(fixture.nativeElement).value).toBe(`${year}`);

// resetting
fixture.componentInstance.date = {year: 3000, month: 1};
fixture.detectChanges();
}

expectMaxDate(2020, 12);

fixture.componentInstance.maxDate = null;
fixture.detectChanges();
expectMaxDate(3010, 12);

fixture.componentInstance.maxDate = undefined;
fixture.detectChanges();
expectMaxDate(3010, 12);

fixture.componentInstance.maxDate = <any>{};
fixture.detectChanges();
expectMaxDate(3010, 12);

fixture.componentInstance.maxDate = <any>new Date();
fixture.detectChanges();
expectMaxDate(3010, 12);

fixture.componentInstance.maxDate = new NgbDate(3000000, 1, 1);
fixture.detectChanges();
expectMaxDate(3010, 12);
});

it('should support disabling dates via callback', () => {
const fixture = createTestComponent(
`<ngb-datepicker [startDate]="date" [minDate]="minDate" [maxDate]="maxDate" [markDisabled]="markDisabled"></ngb-datepicker>`);
Expand Down
20 changes: 11 additions & 9 deletions src/datepicker/datepicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export class NgbDatepicker implements OnChanges,
/**
* Date to open calendar with.
* With default calendar we use ISO 8601: 'month' is 1=Jan ... 12=Dec.
* If nothing provided, calendar will open with current month.
* If nothing or invalid date provided, calendar will open with current month.
* Use 'navigateTo(date)' as an alternative
*/
@Input() startDate: {year: number, month: number};
Expand Down Expand Up @@ -193,22 +193,22 @@ export class NgbDatepicker implements OnChanges,
/**
* Navigates current view to provided date.
* With default calendar we use ISO 8601: 'month' is 1=Jan ... 12=Dec.
* If nothing provided calendar will open current month.
* If nothing or invalid date provided calendar will open current month.
* Use 'startDate' input as an alternative
*/
navigateTo(date?: {year: number, month: number}) {
this._setViewWithinLimits(date ? NgbDate.from(date) : this._calendar.getToday());
this._setViewWithinLimits(this._service.toValidDate(date));
this._updateData();
}

ngOnInit() {
this._setDates();
this.navigateTo(this.startDate);
this.navigateTo(this._date);
}

ngOnChanges(changes: SimpleChanges) {
this._setDates();
this._setViewWithinLimits(this.startDate ? NgbDate.from(this.startDate) : this._calendar.getToday());
this._setViewWithinLimits(this._date);

if (changes['displayMonths']) {
this.displayMonths = toInteger(this.displayMonths);
Expand Down Expand Up @@ -256,22 +256,24 @@ export class NgbDatepicker implements OnChanges,

registerOnTouched(fn: () => any): void { this.onTouched = fn; }

writeValue(value) { this.model = value ? new NgbDate(value.year, value.month, value.day) : null; }
writeValue(value) { this.model = this._service.toValidDate(value, null); }

setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; }

private _setDates() {
this._maxDate = NgbDate.from(this.maxDate);
this._minDate = NgbDate.from(this.minDate);
this._date = this.startDate ? NgbDate.from(this.startDate) : this._calendar.getToday();
this._date = this._service.toValidDate(this.startDate);

if (!this._minDate) {
if (!this._calendar.isValid(this._minDate)) {
this._minDate = this._calendar.getPrev(this._date, 'y', 10);
this.minDate = {year: this._minDate.year, month: this._minDate.month, day: this._minDate.day};
}

if (!this._maxDate) {
if (!this._calendar.isValid(this._maxDate)) {
this._maxDate = this._calendar.getNext(this._date, 'y', 11);
this._maxDate = this._calendar.getPrev(this._maxDate);
this.maxDate = {year: this._maxDate.year, month: this._maxDate.month, day: this._maxDate.day};
}

if (this._minDate && this._maxDate && this._maxDate.before(this._minDate)) {
Expand Down
24 changes: 24 additions & 0 deletions src/datepicker/ngb-calendar.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {NgbCalendarGregorian} from './ngb-calendar';
import {NgbDate} from './ngb-date';
import {isBrowser} from '../test/common';

describe('ngb-calendar-gregorian', () => {

Expand Down Expand Up @@ -63,4 +64,27 @@ describe('ngb-calendar-gregorian', () => {
expect(calendar.getPrev(new NgbDate(2016, 12, 22), 'y')).toEqual(new NgbDate(2015, 1, 1));
expect(calendar.getPrev(new NgbDate(2017, 1, 22), 'y')).toEqual(new NgbDate(2016, 1, 1));
});

it('should check that NgbDate is a valid javascript date', () => {

// invalid values
expect(calendar.isValid(null)).toBeFalsy();
expect(calendar.isValid(undefined)).toBeFalsy();
expect(calendar.isValid(<any>NaN)).toBeFalsy();
expect(calendar.isValid(<any>new Date())).toBeFalsy();
expect(calendar.isValid(new NgbDate(null, null, null))).toBeFalsy();
expect(calendar.isValid(new NgbDate(undefined, undefined, undefined))).toBeFalsy();
expect(calendar.isValid(new NgbDate(NaN, NaN, NaN))).toBeFalsy();

// min/max dates
expect(calendar.isValid(new NgbDate(275760, 9, 12))).toBeTruthy();
expect(calendar.isValid(new NgbDate(275760, 9, 14))).toBeFalsy();
expect(calendar.isValid(new NgbDate(-271821, 4, 19))).toBeFalsy();
expect(calendar.isValid(new NgbDate(-271821, 4, 21))).toBeTruthy();

// valid dates
expect(calendar.isValid(new NgbDate(2016, 8, 8))).toBeTruthy();
expect(calendar.isValid(new NgbDate(0, 0, 0))).toBeTruthy();
expect(calendar.isValid(new NgbDate(-1, -1, -1))).toBeTruthy();
});
});

0 comments on commit 186c0e1

Please sign in to comment.