Skip to content

Commit

Permalink
feat(datepicker): add validation
Browse files Browse the repository at this point in the history
Closes #1222

BREAKING CHANGE:

Invalid dates entered by a user into datepicker input are
propagated to the model as-is. This is required to properly
support validation and is in-line with behaviour of all the
built-in Angular validators. From now on you need to check
control's validity to determine if the entered date is valid
or not.

Closes #1434
  • Loading branch information
pkozlowski-opensource committed Apr 5, 2017
1 parent 92ae3fd commit 4cbea99
Show file tree
Hide file tree
Showing 3 changed files with 178 additions and 26 deletions.
4 changes: 4 additions & 0 deletions misc/api-doc.js
Expand Up @@ -14,6 +14,10 @@ const ANGULAR_LIFECYCLE_METHODS = [
];

function isInternalMember(member) {
if (!member.symbol) {
return true;
}

const jsDoc = ts.displayPartsToString(member.symbol.getDocumentationComment());
return jsDoc.trim().length === 0 || jsDoc.indexOf('@internal') > -1;
}
Expand Down
142 changes: 126 additions & 16 deletions src/datepicker/datepicker-input.spec.ts
@@ -1,9 +1,9 @@
import {TestBed, ComponentFixture, fakeAsync, tick} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {createGenericTestComponent, isBrowser} from '../test/common';
import {createGenericTestComponent} from '../test/common';

import {Component} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {FormsModule, NgForm} from '@angular/forms';

import {NgbDatepickerModule} from './datepicker.module';
import {NgbInputDatepicker} from './datepicker-input';
Expand Down Expand Up @@ -125,20 +125,6 @@ describe('NgbInputDatepicker', () => {
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();

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

it('should propagate disabled state', fakeAsync(() => {
const fixture = createTestCmpt(`
<input ngbDatepicker [(ngModel)]="date" #d="ngbDatepicker" [disabled]="isDisabled">
Expand Down Expand Up @@ -200,6 +186,130 @@ describe('NgbInputDatepicker', () => {
}));
});

describe('validation', () => {

describe('values set from model', () => {

it('should not return errors for valid model', fakeAsync(() => {
const fixture = createTestCmpt(
`<form><input ngbDatepicker [ngModel]="{year: 2017, month: 04, day: 04}" name="dp"></form>`);
const form = fixture.debugElement.query(By.directive(NgForm)).injector.get(NgForm);

fixture.detectChanges();
tick();
expect(form.control.valid).toBeTruthy();
expect(form.control.hasError('ngbDate', ['dp'])).toBeFalsy();
}));

it('should not return errors for empty model', fakeAsync(() => {
const fixture = createTestCmpt(`<form><input ngbDatepicker [ngModel]="date" name="dp"></form>`);
const form = fixture.debugElement.query(By.directive(NgForm)).injector.get(NgForm);

fixture.detectChanges();
tick();
expect(form.control.valid).toBeTruthy();
}));

it('should return "invalid" errors for invalid model', fakeAsync(() => {
const fixture = createTestCmpt(`<form><input ngbDatepicker [ngModel]="5" name="dp"></form>`);
const form = fixture.debugElement.query(By.directive(NgForm)).injector.get(NgForm);

fixture.detectChanges();
tick();
expect(form.control.invalid).toBeTruthy();
expect(form.control.getError('ngbDate', ['dp']).invalid).toBe(5);
}));

it('should return "requiredBefore" errors for dates before minimal date', fakeAsync(() => {
const fixture = createTestCmpt(`<form>
<input ngbDatepicker [ngModel]="{year: 2017, month: 04, day: 04}" [minDate]="{year: 2017, month: 6, day: 4}" name="dp">
</form>`);
const form = fixture.debugElement.query(By.directive(NgForm)).injector.get(NgForm);

fixture.detectChanges();
tick();
expect(form.control.invalid).toBeTruthy();
expect(form.control.getError('ngbDate', ['dp']).requiredBefore).toEqual({year: 2017, month: 6, day: 4});
}));

it('should return "requiredAfter" errors for dates after maximal date', fakeAsync(() => {
const fixture = createTestCmpt(`<form>
<input ngbDatepicker [ngModel]="{year: 2017, month: 04, day: 04}" [maxDate]="{year: 2017, month: 2, day: 4}" name="dp">
</form>`);
const form = fixture.debugElement.query(By.directive(NgForm)).injector.get(NgForm);

fixture.detectChanges();
tick();
expect(form.control.invalid).toBeTruthy();
expect(form.control.getError('ngbDate', ['dp']).requiredAfter).toEqual({year: 2017, month: 2, day: 4});
}));

it('should update validity status when model changes', fakeAsync(() => {
const fixture = createTestCmpt(`<form><input ngbDatepicker [ngModel]="date" name="dp"></form>`);
const form = fixture.debugElement.query(By.directive(NgForm)).injector.get(NgForm);

fixture.componentRef.instance.date = <any>'invalid';
fixture.detectChanges();
tick();
expect(form.control.invalid).toBeTruthy();

fixture.componentRef.instance.date = {year: 2015, month: 7, day: 3};
fixture.detectChanges();
tick();
expect(form.control.valid).toBeTruthy();
}));

it('should update validity status when minDate changes', fakeAsync(() => {
const fixture = createTestCmpt(`<form>
<input ngbDatepicker [ngModel]="{year: 2017, month: 2, day: 4}" [minDate]="date" name="dp">
</form>`);
const form = fixture.debugElement.query(By.directive(NgForm)).injector.get(NgForm);

fixture.detectChanges();
tick();
expect(form.control.valid).toBeTruthy();

fixture.componentRef.instance.date = {year: 2018, month: 7, day: 3};
fixture.detectChanges();
tick();
expect(form.control.invalid).toBeTruthy();
}));

it('should update validity status when maxDate changes', fakeAsync(() => {
const fixture = createTestCmpt(`<form>
<input ngbDatepicker [ngModel]="{year: 2017, month: 2, day: 4}" [maxDate]="date" name="dp">
</form>`);
const form = fixture.debugElement.query(By.directive(NgForm)).injector.get(NgForm);

fixture.detectChanges();
tick();
expect(form.control.valid).toBeTruthy();

fixture.componentRef.instance.date = {year: 2015, month: 7, day: 3};
fixture.detectChanges();
tick();
expect(form.control.invalid).toBeTruthy();
}));

it('should update validity for manually entered dates', fakeAsync(() => {
const fixture = createTestCmpt(`<form><input ngbDatepicker [(ngModel)]="date" name="dp"></form>`);
const inputDebugEl = fixture.debugElement.query(By.css('input'));
const form = fixture.debugElement.query(By.directive(NgForm)).injector.get(NgForm);

inputDebugEl.triggerEventHandler('change', {target: {value: '2016-09-10'}});
fixture.detectChanges();
tick();
expect(form.control.valid).toBeTruthy();

inputDebugEl.triggerEventHandler('change', {target: {value: 'invalid'}});
fixture.detectChanges();
tick();
expect(form.control.invalid).toBeTruthy();
}));
});

});

describe('options', () => {

it('should propagate the "dayTemplate" option', () => {
Expand Down
58 changes: 48 additions & 10 deletions src/datepicker/datepicker-input.ts
Expand Up @@ -10,9 +10,11 @@ import {
TemplateRef,
forwardRef,
EventEmitter,
Output
Output,
OnChanges,
SimpleChanges
} from '@angular/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
import {AbstractControl, ControlValueAccessor, Validator, NG_VALUE_ACCESSOR, NG_VALIDATORS} from '@angular/forms';

import {NgbDate} from './ngb-date';
import {NgbDatepicker, NgbDatepickerNavigateEvent} from './datepicker';
Expand All @@ -30,6 +32,12 @@ const NGB_DATEPICKER_VALUE_ACCESSOR = {
multi: true
};

const NGB_DATEPICKER_VALIDATOR = {
provide: NG_VALIDATORS,
useExisting: forwardRef(() => NgbInputDatepicker),
multi: true
};

/**
* A directive that makes it possible to have datepickers on input fields.
* Manages integration with the input field itself (data entry) and ngModel (validation etc.).
Expand All @@ -38,9 +46,10 @@ const NGB_DATEPICKER_VALUE_ACCESSOR = {
selector: 'input[ngbDatepicker]',
exportAs: 'ngbDatepicker',
host: {'(change)': 'manualDateChange($event.target.value)', '(keyup.esc)': 'close()', '(blur)': 'onBlur()'},
providers: [NGB_DATEPICKER_VALUE_ACCESSOR, NgbDatepickerService]
providers: [NGB_DATEPICKER_VALUE_ACCESSOR, NGB_DATEPICKER_VALIDATOR, NgbDatepickerService]
})
export class NgbInputDatepicker implements ControlValueAccessor {
export class NgbInputDatepicker implements OnChanges,
ControlValueAccessor, Validator {
private _cRef: ComponentRef<NgbDatepicker> = null;
private _model: NgbDate;
private _zoneSubscription: any;
Expand Down Expand Up @@ -114,6 +123,7 @@ export class NgbInputDatepicker implements ControlValueAccessor {

private _onChange = (_: any) => {};
private _onTouched = () => {};
private _validatorChange = () => {};


constructor(
Expand All @@ -131,11 +141,7 @@ export class NgbInputDatepicker implements ControlValueAccessor {

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

writeValue(value) {
const ngbDate = value ? new NgbDate(value.year, value.month, value.day) : null;
this._model = this._calendar.isValid(value) ? ngbDate : null;
this._writeModelValue(this._model);
}
registerOnValidatorChange(fn: () => void): void { this._validatorChange = fn; };

setDisabledState(isDisabled: boolean): void {
this._renderer.setElementProperty(this._elRef.nativeElement, 'disabled', isDisabled);
Expand All @@ -144,9 +150,35 @@ export class NgbInputDatepicker implements ControlValueAccessor {
}
}

validate(c: AbstractControl): {[key: string]: any} {
const value = c.value;

if (value === null || value === undefined) {
return null;
}

if (!this._calendar.isValid(value)) {
return {'ngbDate': {invalid: c.value}};
}

if (this.minDate && NgbDate.from(value).before(NgbDate.from(this.minDate))) {
return {'ngbDate': {requiredBefore: this.minDate}};
}

if (this.maxDate && NgbDate.from(value).after(NgbDate.from(this.maxDate))) {
return {'ngbDate': {requiredAfter: this.maxDate}};
}
}

writeValue(value) {
const ngbDate = value ? new NgbDate(value.year, value.month, value.day) : null;
this._model = this._calendar.isValid(value) ? ngbDate : null;
this._writeModelValue(this._model);
}

manualDateChange(value: string) {
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._onChange(this._model ? {year: this._model.year, month: this._model.month, day: this._model.day} : value);
this._writeModelValue(this._model);
}

Expand Down Expand Up @@ -210,6 +242,12 @@ export class NgbInputDatepicker implements ControlValueAccessor {

onBlur() { this._onTouched(); }

ngOnChanges(changes: SimpleChanges) {
if (changes['minDate'] || changes['maxDate']) {
this._validatorChange();
}
}

private _applyDatepickerInputs(datepickerInstance: NgbDatepicker): void {
['dayTemplate', 'displayMonths', 'firstDayOfWeek', 'markDisabled', 'minDate', 'maxDate', 'navigation',
'outsideDays', 'showNavigation', 'showWeekdays', 'showWeekNumbers']
Expand Down

0 comments on commit 4cbea99

Please sign in to comment.