diff --git a/src/datepicker/datepicker-input.spec.ts b/src/datepicker/datepicker-input.spec.ts index 910bd1d55f..3dd103a27b 100644 --- a/src/datepicker/datepicker-input.spec.ts +++ b/src/datepicker/datepicker-input.spec.ts @@ -3,7 +3,7 @@ import {By} from '@angular/platform-browser'; import {createGenericTestComponent} from '../test/common'; import {Component, Injectable} from '@angular/core'; -import {FormsModule, NgForm} from '@angular/forms'; +import {FormsModule} from '@angular/forms'; import {NgbDateAdapter, NgbDatepickerModule} from './datepicker.module'; import {NgbInputDatepicker} from './datepicker-input'; @@ -356,146 +356,6 @@ describe('NgbInputDatepicker', () => { }); - describe('validation', () => { - - describe('values set from model', () => { - - it('should not return errors for valid model', fakeAsync(() => { - const fixture = createTestCmpt( - `
`); - 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(`
`); - 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(`
`); - 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(`
- -
`); - 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(`
- -
`); - 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(`
`); - const form = fixture.debugElement.query(By.directive(NgForm)).injector.get(NgForm); - - fixture.componentRef.instance.date = '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(`
- -
`); - 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(`
- -
`); - 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(`
`); - const inputDebugEl = fixture.debugElement.query(By.css('input')); - const form = fixture.debugElement.query(By.directive(NgForm)).injector.get(NgForm); - - inputDebugEl.triggerEventHandler('input', {target: {value: '2016-09-10'}}); - fixture.detectChanges(); - tick(); - expect(form.control.valid).toBeTruthy(); - - inputDebugEl.triggerEventHandler('input', {target: {value: 'invalid'}}); - fixture.detectChanges(); - tick(); - expect(form.control.invalid).toBeTruthy(); - })); - - it('should consider empty strings as valid', fakeAsync(() => { - const fixture = createTestCmpt(`
`); - 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: ''}}); - fixture.detectChanges(); - tick(); - expect(form.control.valid).toBeTruthy(); - })); - }); - - }); - describe('options', () => { it('should propagate the "dayTemplate" option', () => { diff --git a/src/datepicker/datepicker-input.ts b/src/datepicker/datepicker-input.ts index b88d6ff1d0..cd7a5ec2b4 100644 --- a/src/datepicker/datepicker-input.ts +++ b/src/datepicker/datepicker-input.ts @@ -9,17 +9,14 @@ import { Inject, Input, NgZone, - OnChanges, OnDestroy, Output, Renderer2, - SimpleChanges, TemplateRef, ViewContainerRef } from '@angular/core'; import {DOCUMENT} from '@angular/common'; -import {AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator} from '@angular/forms'; -import {Subject} from 'rxjs'; +import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; import {ngbAutoClose} from '../util/autoclose'; import {ngbFocusTrap} from '../util/focus-trap'; @@ -40,12 +37,6 @@ const NGB_DATEPICKER_VALUE_ACCESSOR = { multi: true }; -const NGB_DATEPICKER_VALIDATOR = { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => NgbInputDatepicker), - multi: true -}; - /** * A directive that allows to stick a datepicker popup to an input field. * @@ -60,10 +51,10 @@ const NGB_DATEPICKER_VALIDATOR = { '(blur)': 'onBlur()', '[disabled]': 'disabled' }, - providers: [NGB_DATEPICKER_VALUE_ACCESSOR, NGB_DATEPICKER_VALIDATOR, NgbDatepickerService] + providers: [NGB_DATEPICKER_VALUE_ACCESSOR, NgbDatepickerService] }) -export class NgbInputDatepicker implements OnChanges, - OnDestroy, ControlValueAccessor, Validator { +export class NgbInputDatepicker implements OnDestroy, + ControlValueAccessor { private _cRef: ComponentRef = null; private _disabled = false; private _model: NgbDate; @@ -247,8 +238,6 @@ export class NgbInputDatepicker implements OnChanges, private _onChange = (_: any) => {}; private _onTouched = () => {}; - private _validatorChange = () => {}; - constructor( private _parserFormatter: NgbDateParserFormatter, private _elRef: ElementRef, @@ -263,32 +252,8 @@ export class NgbInputDatepicker implements OnChanges, registerOnTouched(fn: () => any): void { this._onTouched = fn; } - registerOnValidatorChange(fn: () => void): void { this._validatorChange = fn; } - setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; } - validate(c: AbstractControl): {[key: string]: any} { - const value = c.value; - - if (value === null || value === undefined) { - return null; - } - - const ngbDate = this._fromDateStruct(this._dateAdapter.fromModel(value)); - - if (!this._calendar.isValid(ngbDate)) { - return {'ngbDate': {invalid: c.value}}; - } - - if (this.minDate && ngbDate.before(NgbDate.from(this.minDate))) { - return {'ngbDate': {requiredBefore: this.minDate}}; - } - - if (this.maxDate && ngbDate.after(NgbDate.from(this.maxDate))) { - return {'ngbDate': {requiredAfter: this.maxDate}}; - } - } - writeValue(value) { this._model = this._fromDateStruct(this._dateAdapter.fromModel(value)); this._writeModelValue(this._model); @@ -390,12 +355,6 @@ export class NgbInputDatepicker implements OnChanges, onBlur() { this._onTouched(); } - ngOnChanges(changes: SimpleChanges) { - if (changes['minDate'] || changes['maxDate']) { - this._validatorChange(); - } - } - ngOnDestroy() { this.close(); this._zoneSubscription.unsubscribe(); diff --git a/src/datepicker/datepicker-validators.spec.ts b/src/datepicker/datepicker-validators.spec.ts new file mode 100644 index 0000000000..e08f7bc109 --- /dev/null +++ b/src/datepicker/datepicker-validators.spec.ts @@ -0,0 +1,158 @@ +import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {FormsModule, NgForm} from '@angular/forms'; +import {NgbDatepickerModule, NgbDateStruct} from './datepicker.module'; +import {createGenericTestComponent} from '../test/common'; +import {Component} from '@angular/core'; + +const createTestCmpt = (html: string) => + createGenericTestComponent(html, TestComponent) as ComponentFixture; + +describe('validation', () => { + + beforeEach(() => { + TestBed.configureTestingModule({declarations: [TestComponent], imports: [NgbDatepickerModule, FormsModule]}); + }); + + describe('values set from model', () => { + + it('should not return errors for valid model', fakeAsync(() => { + const fixture = createTestCmpt( + `
`); + 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(`
`); + 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(`
`); + 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(`
+ +
`); + 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(`
+ +
`); + 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(`
`); + const form = fixture.debugElement.query(By.directive(NgForm)).injector.get(NgForm); + + fixture.componentRef.instance.date = '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(`
+ +
`); + 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(`
+ +
`); + 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(`
`); + const inputDebugEl = fixture.debugElement.query(By.css('input')); + const form = fixture.debugElement.query(By.directive(NgForm)).injector.get(NgForm); + + inputDebugEl.triggerEventHandler('input', {target: {value: '2016-09-10'}}); + fixture.detectChanges(); + tick(); + expect(form.control.valid).toBeTruthy(); + + inputDebugEl.triggerEventHandler('input', {target: {value: 'invalid'}}); + fixture.detectChanges(); + tick(); + expect(form.control.invalid).toBeTruthy(); + })); + + it('should consider empty strings as valid', fakeAsync(() => { + const fixture = createTestCmpt(`
`); + 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: ''}}); + fixture.detectChanges(); + tick(); + expect(form.control.valid).toBeTruthy(); + })); + }); + +}); + +@Component({selector: 'test-cmp', template: ''}) +class TestComponent { + date: NgbDateStruct; +} diff --git a/src/datepicker/datepicker-validators.ts b/src/datepicker/datepicker-validators.ts new file mode 100644 index 0000000000..0c4a5940c4 --- /dev/null +++ b/src/datepicker/datepicker-validators.ts @@ -0,0 +1,152 @@ +import {AbstractControl, NG_VALIDATORS, ValidationErrors, Validator, ValidatorFn} from '@angular/forms'; +import {Directive, forwardRef, Input, OnChanges, SimpleChanges, StaticProvider} from '@angular/core'; +import {NgbDateStruct} from './ngb-date-struct'; +import {NgbDate} from './ngb-date'; +import {NgbCalendar} from './ngb-calendar'; + +function isNgbDateStruct(value: any): boolean { + return value && value.day && value.month && value.year; +} + +/** + * A class containing factories for datepicker validator functions: + * * `NgbDateValidators.minDate(minDate: NgbDateStruct)` - checks that the date is after the min date + * * `NgbDateValidators.maxDate(maxDate: NgbDateStruct)` - checks that the date is before the max date + * * `NgbDateValidators.invalidDate(calendar: NgbCalendar)` - checks that the date is valid + */ +// @dynamic +export class NgbDateValidators { + static minDate(minDate: NgbDateStruct): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (!isNgbDateStruct(control.value) || !isNgbDateStruct(minDate)) { + return null; + } + const ngbDate = NgbDate.from(control.value); + return ngbDate.before(NgbDate.from(minDate)) ? {'ngbDate': {requiredBefore: minDate}} : null; + }; + } + static maxDate(maxDate: NgbDateStruct): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (!isNgbDateStruct(control.value) || !isNgbDateStruct(maxDate)) { + return null; + } + const ngbDate = NgbDate.from(control.value); + return ngbDate.after(NgbDate.from(maxDate)) ? {'ngbDate': {requiredAfter: maxDate}} : null; + }; + } + static invalidDate(calendar: NgbCalendar): ValidatorFn { + return (control: AbstractControl): ValidationErrors | + null => { return calendar.isValid(control.value) ? null : {'ngbDate': {invalid: control.value}}; }; + } +} + +/** + * A provider which adds `NgbInvalidDateValidator` to the `NG_VALIDATORS` multi-provider list. + */ +const NGB_INVALID_DATE_VALIDATOR: StaticProvider = { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => NgbInvalidDateValidator), + multi: true +}; + +/** + * A directive that adds date validation to all NgbInputDatepicker controls. The directive is provided with the + * `NG_VALIDATORS` multi-provider list. + */ +@Directive({ + selector: 'input[ngbDatepicker][formControlName],input[ngbDatepicker][formControl],input[ngbDatepicker][ngModel]', + providers: [NGB_INVALID_DATE_VALIDATOR] +}) +export class NgbInvalidDateValidator implements Validator { + private readonly _validator: ValidatorFn; + private _onChange: () => void; + constructor(private _calendar: NgbCalendar) { this._validator = NgbDateValidators.invalidDate(this._calendar); } + + validate(control: AbstractControl): ValidationErrors | null { + return control.value == null ? null : this._validator(control); + } + + registerOnValidatorChange(fn: () => void): void { this._onChange = fn; } +} + +/** + * A provider which adds `NgbMinDateValidator` to the `NG_VALIDATORS` multi-provider list. + */ +const NGB_MIN_DATE_VALIDATOR: StaticProvider = { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => NgbMinDateValidator), + multi: true +}; + +/** + * A directive that adds min date validation to all NgbInputDatepicker controls with the `minDate` attribute. The + * directive is provided with the `NG_VALIDATORS` multi-provider list. + */ +@Directive({ + selector: + 'input[ngbDatepicker][minDate][formControlName],input[ngbDatepicker][minDate][formControl],input[ngbDatepicker][minDate][ngModel]', + providers: [NGB_MIN_DATE_VALIDATOR] +}) +export class NgbMinDateValidator implements Validator, + OnChanges { + private _validator: ValidatorFn; + private _onChange: () => void; + @Input() minDate: NgbDateStruct; + ngOnChanges(changes: SimpleChanges): void { + if ('minDate' in changes) { + this._createValidator(); + if (this._onChange) { + this._onChange(); + } + } + } + + validate(control: AbstractControl): ValidationErrors | null { + return this.minDate == null ? null : this._validator(control); + } + + registerOnValidatorChange(fn: () => void): void { this._onChange = fn; } + + private _createValidator(): void { this._validator = NgbDateValidators.minDate(this.minDate); } +} + +/** + * A provider which adds `NgbMaxDateValidator` to the `NG_VALIDATORS` multi-provider list. + */ +const NGB_MAX_DATE_VALIDATOR: StaticProvider = { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => NgbMaxDateValidator), + multi: true +}; + +/** + * A directive that adds max date validation to all NgbInputDatepicker controls with the `maxDate` attribute. The + * directive is provided with the `NG_VALIDATORS` multi-provider list. + */ +@Directive({ + selector: + 'input[ngbDatepicker][maxDate][formControlName],input[ngbDatepicker][maxDate][formControl],input[ngbDatepicker][maxDate][ngModel]', + providers: [NGB_MAX_DATE_VALIDATOR] +}) +export class NgbMaxDateValidator implements Validator, + OnChanges { + private _validator: ValidatorFn; + private _onChange: () => void; + @Input() maxDate: NgbDateStruct; + ngOnChanges(changes: SimpleChanges): void { + if ('maxDate' in changes) { + this._createValidator(); + if (this._onChange) { + this._onChange(); + } + } + } + + validate(control: AbstractControl): ValidationErrors | null { + return this.maxDate == null ? null : this._validator(control); + } + + registerOnValidatorChange(fn: () => void): void { this._onChange = fn; } + + private _createValidator(): void { this._validator = NgbDateValidators.maxDate(this.maxDate); } +} diff --git a/src/datepicker/datepicker.module.ts b/src/datepicker/datepicker.module.ts index 1af6bf816b..5f85493327 100644 --- a/src/datepicker/datepicker.module.ts +++ b/src/datepicker/datepicker.module.ts @@ -7,6 +7,7 @@ import {NgbDatepickerNavigation} from './datepicker-navigation'; import {NgbInputDatepicker} from './datepicker-input'; import {NgbDatepickerDayView} from './datepicker-day-view'; import {NgbDatepickerNavigationSelect} from './datepicker-navigation-select'; +import {NgbInvalidDateValidator, NgbMaxDateValidator, NgbMinDateValidator} from './datepicker-validators'; export {NgbDatepicker, NgbDatepickerNavigateEvent} from './datepicker'; export {NgbInputDatepicker} from './datepicker-input'; @@ -28,13 +29,19 @@ export {NgbDateAdapter} from './adapters/ngb-date-adapter'; export {NgbDateNativeAdapter} from './adapters/ngb-date-native-adapter'; export {NgbDateNativeUTCAdapter} from './adapters/ngb-date-native-utc-adapter'; export {NgbDateParserFormatter} from './ngb-date-parser-formatter'; +export { + NgbInvalidDateValidator, + NgbMaxDateValidator, + NgbMinDateValidator, + NgbDateValidators +} from './datepicker-validators'; @NgModule({ declarations: [ NgbDatepicker, NgbDatepickerMonthView, NgbDatepickerNavigation, NgbDatepickerNavigationSelect, NgbDatepickerDayView, - NgbInputDatepicker + NgbInputDatepicker, NgbMinDateValidator, NgbMaxDateValidator, NgbInvalidDateValidator ], - exports: [NgbDatepicker, NgbInputDatepicker], + exports: [NgbDatepicker, NgbInputDatepicker, NgbMinDateValidator, NgbMaxDateValidator, NgbInvalidDateValidator], imports: [CommonModule, FormsModule], entryComponents: [NgbDatepicker] }) diff --git a/src/index.ts b/src/index.ts index 447cabba59..e58f529773 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import {NgbTabsetModule} from './tabset/tabset.module'; import {NgbTimepickerModule} from './timepicker/timepicker.module'; import {NgbTooltipModule} from './tooltip/tooltip.module'; import {NgbTypeaheadModule} from './typeahead/typeahead.module'; +import {NgbInvalidDateValidator, NgbMaxDateValidator, NgbMinDateValidator} from './datepicker/datepicker-validators'; export { NgbAccordionModule, @@ -53,7 +54,11 @@ export { NgbDateNativeAdapter, NgbDateNativeUTCAdapter, NgbDatepicker, - NgbInputDatepicker + NgbDateValidators, + NgbInputDatepicker, + NgbMinDateValidator, + NgbMaxDateValidator, + NgbInvalidDateValidator } from './datepicker/datepicker.module'; export { NgbDropdownModule,