From 4a37406e0e4a678500251f18980e2f278a341868 Mon Sep 17 00:00:00 2001 From: Dmitriy Shekhovtsov Date: Wed, 19 Jul 2017 14:46:09 +0200 Subject: [PATCH] feat(timepicker): new timepicker implementation (#2058) fixes #2036 fixes #1981 ( + min max demo ) fixes #1973 close #1957 ( + seconds demo ) fixes #1935 fixes #1672 feat #1007 added keyboard and mousewheel support fixes #962 fixes #793 fixes #173 fixes #1271 added custom validation demo fixes #1539 bs4 fixes #1253 if input is invalid * feat(timepicker): new timepicker implementation * feat(timepicker): new timepicker implementation testing * chore(timepicker): removed old timepicker implementation * chore(mini-ngrx): added ngrx licence * fix(timepicker): fix seconds * fix(timepicker): fix custom validation demo * fix(tests): fix tests & aot errors * fix(timepicker): min max restrictions * fix(timepicker): min max checks * feat(timepicker): add inputs validation (#2187) * feat(timepicker): add inputs validation * fix(timepicker): add isPM support * feat(timepicker): add isValid output * feat(timepicker): added test plan (#2127) * fix(timepicker): fix ngModelChange demo * fix(test): fix unit tests * docs(timepicker): fix docs conflict, add isValid description --- demo/src/app/app.module.ts | 2 +- .../+timepicker/demo-timepicker.module.ts | 3 +- .../custom-validation/custom-validation.html | 12 + .../custom-validation/custom-validation.ts | 23 + .../+timepicker/demos/dynamic/dynamic.html | 3 +- .../+timepicker/demos/dynamic/dynamic.ts | 1 + .../app/components/+timepicker/demos/index.ts | 24 +- .../+timepicker/demos/meridian/meridian.html | 2 +- .../+timepicker/demos/meridian/meridian.ts | 7 +- .../+timepicker/demos/min-max/min-max.html | 3 + .../+timepicker/demos/min-max/min-max.ts | 18 + .../mousewheel-arrowkeys.html | 11 + .../mousewheel-arrowkeys.ts | 10 + .../+timepicker/demos/seconds/seconds.html | 3 + .../+timepicker/demos/seconds/seconds.ts | 10 + .../timepicker-section.component.ts | 24 + demo/src/ng-api-doc.ts | 77 +- src/mini-ngrx/LICENCE | 21 + src/mini-ngrx/index.ts | 9 + src/mini-ngrx/state.class.ts | 26 + src/mini-ngrx/store.class.ts | 42 + .../timepicker-controls.util.spec.ts | 175 +++ .../timepicker/timepicker.component.spec.ts | 1010 +++++++++++++++++ src/spec/timepicker/timepicker.utils.spec.ts | 135 +++ src/timepicker/index.ts | 4 +- src/timepicker/reducer/timepicker.actions.ts | 55 + src/timepicker/reducer/timepicker.reducer.ts | 94 ++ src/timepicker/reducer/timepicker.store.ts | 16 + src/timepicker/timepicker-controls.util.ts | 129 +++ src/timepicker/timepicker.component.ts | 582 +++++----- src/timepicker/timepicker.config.ts | 24 +- src/timepicker/timepicker.models.ts | 43 + src/timepicker/timepicker.module.ts | 14 +- src/timepicker/timepicker.utils.ts | 144 +++ 34 files changed, 2422 insertions(+), 334 deletions(-) create mode 100644 demo/src/app/components/+timepicker/demos/custom-validation/custom-validation.html create mode 100644 demo/src/app/components/+timepicker/demos/custom-validation/custom-validation.ts create mode 100644 demo/src/app/components/+timepicker/demos/min-max/min-max.html create mode 100644 demo/src/app/components/+timepicker/demos/min-max/min-max.ts create mode 100644 demo/src/app/components/+timepicker/demos/mousewheel-arrowkeys/mousewheel-arrowkeys.html create mode 100644 demo/src/app/components/+timepicker/demos/mousewheel-arrowkeys/mousewheel-arrowkeys.ts create mode 100644 demo/src/app/components/+timepicker/demos/seconds/seconds.html create mode 100644 demo/src/app/components/+timepicker/demos/seconds/seconds.ts create mode 100644 src/mini-ngrx/LICENCE create mode 100644 src/mini-ngrx/index.ts create mode 100644 src/mini-ngrx/state.class.ts create mode 100644 src/mini-ngrx/store.class.ts create mode 100644 src/spec/timepicker/timepicker-controls.util.spec.ts create mode 100644 src/spec/timepicker/timepicker.component.spec.ts create mode 100644 src/spec/timepicker/timepicker.utils.spec.ts create mode 100644 src/timepicker/reducer/timepicker.actions.ts create mode 100644 src/timepicker/reducer/timepicker.reducer.ts create mode 100644 src/timepicker/reducer/timepicker.store.ts create mode 100644 src/timepicker/timepicker-controls.util.ts create mode 100644 src/timepicker/timepicker.models.ts create mode 100644 src/timepicker/timepicker.utils.ts diff --git a/demo/src/app/app.module.ts b/demo/src/app/app.module.ts index a41fb67474..80dc539422 100644 --- a/demo/src/app/app.module.ts +++ b/demo/src/app/app.module.ts @@ -1,6 +1,6 @@ import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { FormsModule} from '@angular/forms'; import { RouterModule } from '@angular/router'; import { Ng2PageScrollModule } from 'ng2-page-scroll/ng2-page-scroll'; import { AppComponent } from './app.component'; diff --git a/demo/src/app/components/+timepicker/demo-timepicker.module.ts b/demo/src/app/components/+timepicker/demo-timepicker.module.ts index 12104708c3..7d391f4280 100644 --- a/demo/src/app/components/+timepicker/demo-timepicker.module.ts +++ b/demo/src/app/components/+timepicker/demo-timepicker.module.ts @@ -1,6 +1,6 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { TimepickerModule } from 'ngx-bootstrap/timepicker'; @@ -17,6 +17,7 @@ import { routes } from './demo-timepicker.routes'; imports: [ CommonModule, FormsModule, + ReactiveFormsModule, SharedModule, TimepickerModule.forRoot(), RouterModule.forChild(routes) diff --git a/demo/src/app/components/+timepicker/demos/custom-validation/custom-validation.html b/demo/src/app/components/+timepicker/demos/custom-validation/custom-validation.html new file mode 100644 index 0000000000..282e2be738 --- /dev/null +++ b/demo/src/app/components/+timepicker/demos/custom-validation/custom-validation.html @@ -0,0 +1,12 @@ +

Illustrates custom validation, you have to select time between 11:00 and 12:59

+ +
+ +
+ +
+  Time is: {{myTime}}
+
+
Invalid time
diff --git a/demo/src/app/components/+timepicker/demos/custom-validation/custom-validation.ts b/demo/src/app/components/+timepicker/demos/custom-validation/custom-validation.ts new file mode 100644 index 0000000000..474a24c633 --- /dev/null +++ b/demo/src/app/components/+timepicker/demos/custom-validation/custom-validation.ts @@ -0,0 +1,23 @@ +import { Component } from '@angular/core'; +import { FormControl } from '@angular/forms'; + +@Component({ + selector: 'demo-timepicker-custom-validation', + templateUrl: './custom-validation.html' +}) +export class DemoTimepickerCustomValidationComponent { + public myTime: Date; + + public ctrl = new FormControl('', (control: FormControl) => { + const value = control.value; + if (!value) { + return null; + } + const hours = value.getHours(); + if (hours < 11 || hours > 12) { + return {outOfRange: true}; + } + + return null; + }); +} diff --git a/demo/src/app/components/+timepicker/demos/dynamic/dynamic.html b/demo/src/app/components/+timepicker/demos/dynamic/dynamic.html index 7d728855e9..524f285c5f 100644 --- a/demo/src/app/components/+timepicker/demos/dynamic/dynamic.html +++ b/demo/src/app/components/+timepicker/demos/dynamic/dynamic.html @@ -1,6 +1,7 @@ - +
Time is: {{mytime}}
+
Invalid time format
diff --git a/demo/src/app/components/+timepicker/demos/dynamic/dynamic.ts b/demo/src/app/components/+timepicker/demos/dynamic/dynamic.ts index 5802c4246a..f103e6aff2 100644 --- a/demo/src/app/components/+timepicker/demos/dynamic/dynamic.ts +++ b/demo/src/app/components/+timepicker/demos/dynamic/dynamic.ts @@ -7,6 +7,7 @@ import { Component } from '@angular/core'; export class DemoTimepickerDynamicComponent { public mytime: Date = new Date(); + public isValid: boolean; public update(): void { let d = new Date(); diff --git a/demo/src/app/components/+timepicker/demos/index.ts b/demo/src/app/components/+timepicker/demos/index.ts index 3fcb2e56c5..0e6d204e32 100644 --- a/demo/src/app/components/+timepicker/demos/index.ts +++ b/demo/src/app/components/+timepicker/demos/index.ts @@ -4,10 +4,16 @@ import { DemoTimepickerMeridianComponent } from './meridian/meridian'; import { DemoTimepickerDisabledComponent } from './disabled/disabled'; import { DemoTimepickerCustomComponent } from './custom/custom'; import { DemoTimepickerDynamicComponent } from './dynamic/dynamic'; +import { DemoTimepickerMinMaxComponent } from './min-max/min-max'; +import { DemoTimepickerSecondsComponent } from './seconds/seconds'; +import { DemoTimepickerMousewheelArrowkeysComponent } from './mousewheel-arrowkeys/mousewheel-arrowkeys'; +import { DemoTimepickerCustomValidationComponent } from './custom-validation/custom-validation'; export const DEMO_COMPONENTS = [ DemoTimepickerBasicComponent, DemoTimepickerConfigComponent, DemoTimepickerMeridianComponent, - DemoTimepickerDisabledComponent, DemoTimepickerCustomComponent, DemoTimepickerDynamicComponent + DemoTimepickerMinMaxComponent, DemoTimepickerDisabledComponent, DemoTimepickerCustomComponent, + DemoTimepickerDynamicComponent, DemoTimepickerSecondsComponent, DemoTimepickerMousewheelArrowkeysComponent, + DemoTimepickerCustomValidationComponent ]; export const DEMOS = { @@ -19,6 +25,10 @@ export const DEMOS = { component: require('!!raw-loader?lang=typescript!./meridian/meridian'), html: require('!!raw-loader?lang=markup!./meridian/meridian.html') }, + minmax: { + component: require('!!raw-loader?lang=typescript!./min-max/min-max'), + html: require('!!raw-loader?lang=markup!./min-max/min-max.html') + }, disabled: { component: require('!!raw-loader?lang=typescript!./disabled/disabled'), html: require('!!raw-loader?lang=markup!./disabled/disabled.html') @@ -34,5 +44,17 @@ export const DEMOS = { config: { component: require('!!raw-loader?lang=typescript!./config/config'), html: require('!!raw-loader?lang=markup!./config/config.html') + }, + seconds: { + component: require('!!raw-loader?lang=typescript!./seconds/seconds'), + html: require('!!raw-loader?lang=markup!./seconds/seconds.html') + }, + mousewheel: { + component: require('!!raw-loader?lang=typescript!./mousewheel-arrowkeys/mousewheel-arrowkeys'), + html: require('!!raw-loader?lang=markup!./mousewheel-arrowkeys/mousewheel-arrowkeys.html') + }, + customvalidation: { + component: require('!!raw-loader?lang=typescript!./custom-validation/custom-validation'), + html: require('!!raw-loader?lang=markup!./custom-validation/custom-validation.html') } }; diff --git a/demo/src/app/components/+timepicker/demos/meridian/meridian.html b/demo/src/app/components/+timepicker/demos/meridian/meridian.html index ff60681c2c..b12725f611 100644 --- a/demo/src/app/components/+timepicker/demos/meridian/meridian.html +++ b/demo/src/app/components/+timepicker/demos/meridian/meridian.html @@ -2,7 +2,7 @@
Time is: {{mytime}}
-
+
diff --git a/demo/src/app/components/+timepicker/demos/meridian/meridian.ts b/demo/src/app/components/+timepicker/demos/meridian/meridian.ts index 76e7a37165..6ec59f67c4 100644 --- a/demo/src/app/components/+timepicker/demos/meridian/meridian.ts +++ b/demo/src/app/components/+timepicker/demos/meridian/meridian.ts @@ -5,12 +5,11 @@ import { Component } from '@angular/core'; templateUrl: './meridian.html' }) export class DemoTimepickerMeridianComponent { - public ismeridian:boolean = true; + public ismeridian: boolean = true; - public mytime:Date = new Date(); + public mytime: Date = new Date(); - public toggleMode():void { + public toggleMode(): void { this.ismeridian = !this.ismeridian; } - } diff --git a/demo/src/app/components/+timepicker/demos/min-max/min-max.html b/demo/src/app/components/+timepicker/demos/min-max/min-max.html new file mode 100644 index 0000000000..ae707b97c8 --- /dev/null +++ b/demo/src/app/components/+timepicker/demos/min-max/min-max.html @@ -0,0 +1,3 @@ + + +
Time is: {{myTime}}
diff --git a/demo/src/app/components/+timepicker/demos/min-max/min-max.ts b/demo/src/app/components/+timepicker/demos/min-max/min-max.ts new file mode 100644 index 0000000000..60493f10b0 --- /dev/null +++ b/demo/src/app/components/+timepicker/demos/min-max/min-max.ts @@ -0,0 +1,18 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'demo-timepicker-min-max', + templateUrl: './min-max.html' +}) +export class DemoTimepickerMinMaxComponent { + public myTime: Date = new Date(); + public minTime: Date = new Date(); + public maxTime: Date = new Date(); + + constructor() { + this.minTime.setHours(8); + this.minTime.setMinutes(0); + this.maxTime.setHours(17); + this.maxTime.setMinutes(0); + } +} diff --git a/demo/src/app/components/+timepicker/demos/mousewheel-arrowkeys/mousewheel-arrowkeys.html b/demo/src/app/components/+timepicker/demos/mousewheel-arrowkeys/mousewheel-arrowkeys.html new file mode 100644 index 0000000000..6b36e993f0 --- /dev/null +++ b/demo/src/app/components/+timepicker/demos/mousewheel-arrowkeys/mousewheel-arrowkeys.html @@ -0,0 +1,11 @@ +

Mouse wheel disabled

+ + + +
Time is: {{myTime1}}
+ +

Arrow keys disabled

+ + + +
Time is: {{myTime2}}
diff --git a/demo/src/app/components/+timepicker/demos/mousewheel-arrowkeys/mousewheel-arrowkeys.ts b/demo/src/app/components/+timepicker/demos/mousewheel-arrowkeys/mousewheel-arrowkeys.ts new file mode 100644 index 0000000000..acad0f6a12 --- /dev/null +++ b/demo/src/app/components/+timepicker/demos/mousewheel-arrowkeys/mousewheel-arrowkeys.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'demo-timepicker-mousewheel-arrowkeys', + templateUrl: './mousewheel-arrowkeys.html' +}) +export class DemoTimepickerMousewheelArrowkeysComponent { + public myTime1: Date = new Date(); + public myTime2: Date = new Date(); +} diff --git a/demo/src/app/components/+timepicker/demos/seconds/seconds.html b/demo/src/app/components/+timepicker/demos/seconds/seconds.html new file mode 100644 index 0000000000..33e9d95e40 --- /dev/null +++ b/demo/src/app/components/+timepicker/demos/seconds/seconds.html @@ -0,0 +1,3 @@ + + +
Time is: {{myTime}}
diff --git a/demo/src/app/components/+timepicker/demos/seconds/seconds.ts b/demo/src/app/components/+timepicker/demos/seconds/seconds.ts new file mode 100644 index 0000000000..1ffc772eea --- /dev/null +++ b/demo/src/app/components/+timepicker/demos/seconds/seconds.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'demo-timepicker-seconds', + templateUrl: './seconds.html' +}) +export class DemoTimepickerSecondsComponent { + public myTime: Date = new Date(); + public showSec: boolean = true; +} diff --git a/demo/src/app/components/+timepicker/timepicker-section.component.ts b/demo/src/app/components/+timepicker/timepicker-section.component.ts index e40b400339..29dfee9fb7 100644 --- a/demo/src/app/components/+timepicker/timepicker-section.component.ts +++ b/demo/src/app/components/+timepicker/timepicker-section.component.ts @@ -16,10 +16,14 @@ let titleDoc = require('html-loader!markdown-loader!./docs/title.md');
  • API Reference @@ -45,6 +49,16 @@ let titleDoc = require('html-loader!markdown-loader!./docs/title.md'); + +

    Min - Max

    + + + + +

    Show seconds

    + + +

    Disabled

    @@ -54,6 +68,11 @@ let titleDoc = require('html-loader!markdown-loader!./docs/title.md');

    Custom steps

    + + +

    Custom validation

    + +

    Dynamic

    @@ -65,6 +84,11 @@ let titleDoc = require('html-loader!markdown-loader!./docs/title.md'); + +

    Mouse wheel and Arrow keys

    + + +

    API Reference

    diff --git a/demo/src/ng-api-doc.ts b/demo/src/ng-api-doc.ts index a7bf80e87d..726304aac2 100644 --- a/demo/src/ng-api-doc.ts +++ b/demo/src/ng-api-doc.ts @@ -1798,6 +1798,20 @@ export const ngdoc: any = { } ] }, + "TimepickerActions": { + "fileName": "src/timepicker/reducer/timepicker.actions.ts", + "className": "TimepickerActions", + "description": "", + "methods": [], + "properties": [] + }, + "TimepickerStore": { + "fileName": "src/timepicker/reducer/timepicker.store.ts", + "className": "TimepickerStore", + "description": "", + "methods": [], + "properties": [] + }, "TimepickerComponent": { "fileName": "src/timepicker/timepicker.component.ts", "className": "TimepickerComponent", @@ -1844,10 +1858,20 @@ export const ngdoc: any = { "type": "boolean", "description": "

    if true hours and minutes fields will be readonly

    \n" }, + { + "name": "secondsStep", + "type": "number", + "description": "

    seconds change step

    \n" + }, { "name": "showMeridian", "type": "boolean", - "description": "

    if true works in 12H mode and displays AM/PM. If false works in 24H mode and hides AM/PM

    \n" + "description": "" + }, + { + "name": "showSeconds", + "type": "boolean", + "description": "" }, { "name": "showSpinners", @@ -1855,7 +1879,12 @@ export const ngdoc: any = { "description": "

    if true spinner arrows above and below the inputs will be shown

    \n" } ], - "outputs": [], + "outputs": [ + { + "name": "isValid", + "description": "

    emits true if value is a valid date

    \n" + } + ], "properties": [], "methods": [] }, @@ -1879,7 +1908,7 @@ export const ngdoc: any = { }, { "name": "max", - "type": "number", + "type": "Date", "description": "

    maximum time user can select

    \n" }, { @@ -1889,7 +1918,7 @@ export const ngdoc: any = { }, { "name": "min", - "type": "number", + "type": "Date", "description": "

    minimum time user can select

    \n" }, { @@ -1910,12 +1939,24 @@ export const ngdoc: any = { "type": "boolean", "description": "

    if true hours and minutes fields will be readonly

    \n" }, + { + "name": "secondsStep", + "defaultValue": "10", + "type": "number", + "description": "

    seconds changes step

    \n" + }, { "name": "showMeridian", "defaultValue": "true", "type": "boolean", "description": "

    if true works in 12H mode and displays AM/PM. If false works in 24H mode and hides AM/PM

    \n" }, + { + "name": "showSeconds", + "defaultValue": "false", + "type": "boolean", + "description": "

    show seconds in timepicker

    \n" + }, { "name": "showSpinners", "defaultValue": "true", @@ -1924,6 +1965,34 @@ export const ngdoc: any = { } ] }, + "Time": { + "fileName": "src/timepicker/timepicker.models.ts", + "className": "Time", + "description": "", + "methods": [], + "properties": [] + }, + "TimepickerControls": { + "fileName": "src/timepicker/timepicker.models.ts", + "className": "TimepickerControls", + "description": "", + "methods": [], + "properties": [] + }, + "TimepickerComponentState": { + "fileName": "src/timepicker/timepicker.models.ts", + "className": "TimepickerComponentState", + "description": "", + "methods": [], + "properties": [] + }, + "TimeChangeEvent": { + "fileName": "src/timepicker/timepicker.models.ts", + "className": "TimeChangeEvent", + "description": "", + "methods": [], + "properties": [] + }, "TooltipContainerComponent": { "fileName": "src/tooltip/tooltip-container.component.ts", "className": "TooltipContainerComponent", diff --git a/src/mini-ngrx/LICENCE b/src/mini-ngrx/LICENCE new file mode 100644 index 0000000000..9b03581392 --- /dev/null +++ b/src/mini-ngrx/LICENCE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 ngrx + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/mini-ngrx/index.ts b/src/mini-ngrx/index.ts new file mode 100644 index 0000000000..9bcda8e916 --- /dev/null +++ b/src/mini-ngrx/index.ts @@ -0,0 +1,9 @@ +export interface Action { + type: string; + payload?: any; +} + +export type ActionReducer = (state: T, action: Action) => T; + +export { MiniState } from './state.class'; +export { MiniStore } from './store.class'; diff --git a/src/mini-ngrx/state.class.ts b/src/mini-ngrx/state.class.ts new file mode 100644 index 0000000000..4835ab7927 --- /dev/null +++ b/src/mini-ngrx/state.class.ts @@ -0,0 +1,26 @@ +/** + * @copyright ngrx + */ +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; +import { Observable } from 'rxjs/Observable'; +import { Action, ActionReducer } from './index'; +import { observeOn } from 'rxjs/operator/observeOn'; +import { queue } from 'rxjs/scheduler/queue'; +import { scan } from 'rxjs/operator/scan'; + +export class MiniState extends BehaviorSubject { + constructor(_initialState: T, actionsDispatcher$: Observable, reducer: ActionReducer) { + super(_initialState); + + const actionInQueue$ = observeOn.call(actionsDispatcher$, queue); + const state$ = scan.call(actionInQueue$, (state: T, action: Action) => { + if (!action) { + return state; + } + + return reducer(state, action); + }, _initialState); + + state$.subscribe((value: T) => this.next(value)); + } +} diff --git a/src/mini-ngrx/store.class.ts b/src/mini-ngrx/store.class.ts new file mode 100644 index 0000000000..f636999a8b --- /dev/null +++ b/src/mini-ngrx/store.class.ts @@ -0,0 +1,42 @@ +/** + * @copyright ngrx + */ +import { Observable } from 'rxjs/Observable'; +import { Observer } from 'rxjs/Observer'; +import { Operator } from 'rxjs/Operator'; +import { distinctUntilChanged } from 'rxjs/operator/distinctUntilChanged'; + +import { map } from 'rxjs/operator/map'; +import { Action, ActionReducer } from './index'; + +export class MiniStore extends Observable implements Observer { + + constructor(private _dispatcher: Observer, + private _reducer: ActionReducer, + state$: Observable) { + super(); + + this.source = state$; + } + + select(pathOrMapFn: (state: T) => R): Observable { + const mapped$: Observable = map.call(this, pathOrMapFn); + + return distinctUntilChanged.call(mapped$); + } + + lift(operator: Operator): MiniStore { + const store = new MiniStore(this._dispatcher, this._reducer, this); + store.operator = operator; + + return store; + } + + dispatch(action: Action) { this._dispatcher.next(action); } + + next(action: Action) { this._dispatcher.next(action); } + + error(err: any) { this._dispatcher.error(err); } + + complete() {/*noop*/} +} diff --git a/src/spec/timepicker/timepicker-controls.util.spec.ts b/src/spec/timepicker/timepicker-controls.util.spec.ts new file mode 100644 index 0000000000..65eb772ba7 --- /dev/null +++ b/src/spec/timepicker/timepicker-controls.util.spec.ts @@ -0,0 +1,175 @@ +import { + TimeChangeEvent, + TimepickerComponentState, + TimepickerControls +} from '../../timepicker/timepicker.models'; + +import { + canChangeHours, canChangeMinutes, canChangeSeconds, + canChangeValue, getControlsValue, timepickerControls +} from '../../timepicker/timepicker-controls.util'; + +function testTime(hours?: number, minutes?: number, seconds?: number) { + let time = new Date(); + time.setHours(hours || 0); + time.setMinutes(minutes || 0); + time.setSeconds(seconds || 0); + return time; +} + +describe('Runtime coverage. Util: Timepicker-controls', () => { + let state: TimepickerComponentState; + let controls: TimepickerControls; + let event: TimeChangeEvent; + + beforeEach(() => { + state = { + min: null, + max: null, + hourStep: 1, + minuteStep: 5, + secondsStep: 10, + readonlyInput: false, + mousewheel: true, + arrowkeys: true, + showSpinners: true, + showMeridian: true, + showSeconds: false, + meridians: ['AM', 'PM'] + }; + + controls = { + canIncrementHours: false, + canIncrementMinutes: false, + canIncrementSeconds: false, + canDecrementHours: false, + canDecrementMinutes: false, + canDecrementSeconds: false + }; + + event = { + step: 1, + source: '' + }; + }); + + it('should can change value read only', () => { + canChangeValue(state, event); + + state.readonlyInput = true; + canChangeValue(state, event); + }); + + it('should can change value event', () => { + canChangeValue(state); + canChangeValue(state, event); + }); + + it('should can change value event source and wheel', () => { + event.source = 'wheel'; + state.mousewheel = false; + + canChangeValue(state, event); + }); + + it('should can change value event source and key', () => { + event.source = 'key'; + state.arrowkeys = false; + + canChangeValue(state, event); + }); + + it('should change Hours', () => { + canChangeHours(event, controls); + }); + + it('should change Hours no step', () => { + event.step = null; + canChangeHours(event, controls); + }); + + it('should change Hours step is -1', () => { + event.step = -1; + canChangeHours(event, controls); + }); + + it('should change Hours can increment', () => { + controls.canIncrementHours = true; + canChangeHours(event, controls); + }); + + it('should change Minutes', () => { + canChangeMinutes(event, controls); + }); + + it('should change Minutes no step', () => { + event.step = null; + canChangeMinutes(event, controls); + }); + + it('should change Minutes step is -1', () => { + event.step = -1; + canChangeMinutes(event, controls); + }); + + it('should change Minutes can increment', () => { + controls.canIncrementMinutes = true; + canChangeMinutes(event, controls); + }); + + it('should change Seconds', () => { + canChangeSeconds(event, controls); + }); + + it('should change Seconds no step', () => { + event.step = null; + canChangeSeconds(event, controls); + }); + + it('should change Seconds step is -1', () => { + event.step = -1; + canChangeSeconds(event, controls); + }); + + it('should change Seconds can increment', () => { + controls.canIncrementSeconds = true; + canChangeSeconds(event, controls); + }); + + it('should get controls value', () => { + getControlsValue(state); + }); + + it('should set data in timepicker controls', () => { + timepickerControls(new Date(), state); + }); + + it('should set data in timepicker controls without date', () => { + // unreachable code + }); + + it('should set data in timepicker controls without showSeconds', () => { + state.showSeconds = true; + timepickerControls(new Date(), state); + }); + + it('should set data in timepicker controls with max', () => { + state.max = new Date(); + timepickerControls(new Date(), state); + }); + + it('should set data in timepicker controls with max greater to control time', () => { + state.max = testTime(1); + timepickerControls(testTime(), state); + }); + + it('should set data in timepicker controls with min', () => { + state.min = new Date(); + timepickerControls(new Date(), state); + }); + + it('should set data in timepicker controls with min greater to control time', () => { + state.min = testTime(1); + timepickerControls(testTime(), state); + }); +}); diff --git a/src/spec/timepicker/timepicker.component.spec.ts b/src/spec/timepicker/timepicker.component.spec.ts new file mode 100644 index 0000000000..c2a616fa5e --- /dev/null +++ b/src/spec/timepicker/timepicker.component.spec.ts @@ -0,0 +1,1010 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { fireEvent } from '../../../scripts/helpers'; + +import { TimepickerConfig } from '../../timepicker/timepicker.config'; +import { TimepickerActions } from '../../timepicker/reducer/timepicker.actions'; +import { TimepickerModule } from '../../timepicker/timepicker.module'; +import { TimepickerComponent } from '../../timepicker/timepicker.component'; +import { By } from '@angular/platform-browser'; + +function getInputElements(fixture: any) { + return fixture.nativeElement.querySelectorAll('input') as HTMLInputElement; +} + +function getElements(fixture: any, selector: string) { + return fixture.nativeElement.querySelectorAll(selector) as HTMLElement; +} + +function getDebugElements(fixture: any, selector: string) { + return fixture.debugElement.queryAll(By.css(selector)); +} + +function testTime(hours?: number, minutes?: number, seconds?: number) { + let time = new Date(); + time.setHours(hours || 0); + time.setMinutes(minutes || 0); + time.setSeconds(seconds || 0); + return time; +} + +describe('Component: timepicker', () => { + let fixture: ComponentFixture; + let component: TimepickerComponent; + let inputHours: HTMLInputElement; + let inputMinutes: HTMLInputElement; + let inputSeconds: HTMLInputElement; + let inputDebugHours: any; + let inputDebugMinutes: any; + let inputDebugSeconds: any; + let buttonMeridian: HTMLElement; + let buttonDebugMeridian: any; + let buttonChanges: HTMLElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + TimepickerModule.forRoot(), + FormsModule, + ReactiveFormsModule + ], + providers: [ + TimepickerConfig, + TimepickerActions + ] + }); + }); + + describe('default configuration', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TimepickerComponent); + fixture.detectChanges(); + + component = fixture.componentInstance; + inputHours = getInputElements(fixture)[0]; + inputMinutes = getInputElements(fixture)[1]; + buttonChanges = getElements(fixture, 'a.btn'); + buttonMeridian = getElements(fixture, 'button'); + }); + + // поле часы и минуты отображаются + it('should seconds fields is not display', () => { + expect(inputHours).toBeTruthy(); + expect(inputMinutes).toBeTruthy(); + }); + // поле секунды не отображается + it('should seconds fields is not display', () => { + expect(inputSeconds).toBeFalsy(); + }); + // поле часы и минуты должно быть пустым + it('should be empty inputs fields hours and minutes', () => { + expect(inputHours.value).toBeFalsy(); + expect(inputMinutes.value).toBeFalsy(); + }); + // должны отображаться кнопки изменения времени + it('should visible change buttons', () => { + expect(buttonChanges).toBeTruthy(); + }); + // должна отображаться кнопка меридиана + it('should visible meridian button', () => { + expect(buttonMeridian).toBeTruthy(); + }); + }); + + describe('validate input fields with default state', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TimepickerComponent); + fixture.detectChanges(); + + component = fixture.componentInstance; + inputHours = getInputElements(fixture)[0]; + inputMinutes = getInputElements(fixture)[1]; + buttonChanges = getElements(fixture, 'a.btn'); + }); + + // проверить данные в поле минуты корректные данные + it('should validate the data in the minutes input with valid data', () => { + fireEvent(inputMinutes, 'change'); + + component.writeValue(testTime(0,12,0)); + + fixture.detectChanges(); + + expect(inputMinutes.value).toEqual('12'); + }); + // проверить данные в поле минуты корректные данные с неполным значением + it('should validate the data in the minutes input with valid data with half value', () => { + component.writeValue(testTime(0,2,0)); + fixture.detectChanges(); + + expect(inputMinutes.value).toEqual('02'); + }); + // установить время путем нажатия на кнопку изменения времени + it('should set time in a input field after click on input change button', () => { + expect(inputHours.value).toBeFalsy(); + expect(inputMinutes.value).toBeFalsy(); + + fireEvent(buttonChanges[0], 'click'); + + fixture.detectChanges(); + + expect(inputHours.value).toBe('01'); + expect(inputMinutes.value).toBe('00'); + }); + }); + + describe('validate input fields with property of showMeridian switch on', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TimepickerComponent); + fixture.detectChanges(); + + component = fixture.componentInstance; + inputHours = getInputElements(fixture)[0]; + buttonMeridian = getElements(fixture, 'button')[0]; + buttonDebugMeridian = getDebugElements(fixture, 'button')[0]; + }); + + // отобразить кнопку AM/PM при состоянии showMeridian по умолчанию + it('should default state showMeridian display AM/PM button', () => { + expect(buttonMeridian).toBeTruthy(); + }); + // проверить данные в поле ввода Часы при вормате времени 12h + it('should validate the data in the hours input at time format 12h', () => { + fireEvent(inputHours, 'change'); + + component.writeValue(testTime(22)); + + fixture.detectChanges(); + + expect(inputHours.value).toEqual('10'); + }); + // изменить временной период после клика на кнопку AM/PM + it('should change time period after click on AM/PM button', () => { + expect(buttonMeridian.textContent.trim()).toBe(component.meridians[0]); + + buttonDebugMeridian.triggerEventHandler('click', null); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + expect(buttonMeridian.textContent.trim()).toBe(component.meridians[1]); + }); + }); + // изменить временной период после клика на кнопку AM/PM без readonlyInput + it('should change time period after click on AM/PM button without readonlyInput', () => { + component.readonlyInput = false; + component.showMeridian = false; + + fixture.detectChanges(); + + buttonDebugMeridian.triggerEventHandler('click', null); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + expect(buttonMeridian.textContent.trim()).toBe(component.meridians[0]); + }); + }); + // изменить временной период после клика на кнопку AM/PM с readonlyInput + it('should change time period after click on AM/PM button with readonlyInput', () => { + component.readonlyInput = false; + component.showMeridian = true; + + fixture.detectChanges(); + + buttonDebugMeridian.triggerEventHandler('click', null); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + expect(buttonMeridian.textContent.trim()).toBe(component.meridians[1]); + }); + }); + }); + + describe('validate input fields with property of showMeridian switch off', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TimepickerComponent); + fixture.detectChanges(); + + component = fixture.componentInstance; + buttonMeridian = getElements(fixture, 'button')[0]; + inputHours = getInputElements(fixture)[0]; + }); + + // не отобразить кнопку AM/PM если showMeridian выключен + it('should not display AM/PM button if showMeridian switch off', () => { + expect(buttonMeridian).toBeTruthy(); + + component.showMeridian = false; + component.writeValue(testTime()); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + buttonMeridian = getElements(fixture, 'button')[0]; + expect(buttonMeridian).toBeFalsy(); + }); + }); + // проверить данные в поле часы при формате времени 24h + it('should validate the data in the hours input at time format 24h', () => { + component.showMeridian = false; + + component.writeValue(testTime(22)); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + expect(inputHours.value).toBe('22'); + }); + }); + }); + + describe('validate input fields with property of max', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TimepickerComponent); + fixture.detectChanges(); + + component = fixture.componentInstance; + inputHours = getInputElements(fixture)[0]; + inputMinutes = getInputElements(fixture)[1]; + buttonChanges = getElements(fixture, 'a.btn'); + }); + + // заблокировать кнопку увеличения часов + it('should block the hours / minutes increment button if clicking on it will cause exceeding the max value', () => { + component.max = testTime(18); + component.writeValue(testTime(17,50)); + fixture.detectChanges(); + + expect(buttonChanges[0]).toHaveCssClass('disabled'); + expect(buttonChanges[1]).not.toHaveCssClass('disabled'); + + component.writeValue(testTime(17,57)); + fixture.detectChanges(); + + expect(buttonChanges[0]).toHaveCssClass('disabled'); + expect(buttonChanges[1]).toHaveCssClass('disabled'); + }); + }); + + describe('validate input fields with property of min', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TimepickerComponent); + fixture.detectChanges(); + + component = fixture.componentInstance; + inputHours = getInputElements(fixture)[0]; + inputMinutes = getInputElements(fixture)[1]; + buttonChanges = getElements(fixture, 'a.btn'); + }); + + // заблокировать кнопку уменьшения часов + it('should block the hours / minutes decrement button if clicking on it will cause exceeding the min value', () => { + component.min = testTime(13); + component.writeValue(testTime(13,22)); + fixture.detectChanges(); + + expect(buttonChanges[2]).toHaveCssClass('disabled'); + expect(buttonChanges[3]).not.toHaveCssClass('disabled'); + + component.writeValue(testTime(13, 2)); + fixture.detectChanges(); + + expect(buttonChanges[2]).toHaveCssClass('disabled'); + expect(buttonChanges[3]).toHaveCssClass('disabled'); + + }); + }); + + describe('display seconds fields with property of showSeconds', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TimepickerComponent); + fixture.detectChanges(); + + component = fixture.componentInstance; + inputSeconds = getInputElements(fixture)[2]; + }); + + // отображать поле секунды если showSeconds включен + it('should display seconds field if showMeridian switch on', () => { + component.showSeconds = true; + + component.writeValue(testTime()); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + inputSeconds = getInputElements(fixture)[2]; + + fireEvent(inputSeconds, 'change'); + + expect(inputSeconds).toBeTruthy(); + }); + }); + // проверить данные в поле секунды + it('should validate the data in the seconds input', () => { + component.showSeconds = true; + + component.writeValue(testTime(2,6,7)); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + inputSeconds = getInputElements(fixture)[2]; + + fireEvent(inputSeconds, 'change'); + + expect(inputSeconds.value).toBe('07'); + }); + }); + }); + + describe('input fields with property of readonlyInput', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TimepickerComponent); + fixture.detectChanges(); + + component = fixture.componentInstance; + inputHours = getInputElements(fixture)[0]; + inputMinutes = getInputElements(fixture)[1]; + inputSeconds = getInputElements(fixture)[2]; + buttonChanges = getElements(fixture, 'a.btn'); + }); + // должна быть возможность ввода значений + it('should be possible to enter values', () => { + expect(inputHours.getAttribute('readonly')).toBeFalsy(); + expect(inputMinutes.getAttribute('readonly')).toBeFalsy(); + + component.showSeconds = true; + component.writeValue(testTime()); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + fixture.detectChanges(); + return fixture.whenStable(); + }) + .then(() => { + inputSeconds = getInputElements(fixture)[2]; + + expect(inputSeconds.getAttribute('readonly')).toBeFalsy(); + }); + }); + // должна отображать кнопки изменения времени + it('should be display is time change buttons', () => { + expect(buttonChanges).toBeTruthy(); + }); + // не должно быть возможности ввода значений + it('should be impossible to enter values', () => { + component.readonlyInput = true; + component.showSeconds = true; + + component.writeValue(testTime()); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + fixture.detectChanges(); + return fixture.whenStable(); + }) + .then(() => { + inputSeconds = getInputElements(fixture)[2]; + + expect(inputHours.getAttribute('readonly')).toBe(''); + expect(inputMinutes.getAttribute('readonly')).toBe(''); + expect(inputSeconds.getAttribute('readonly')).toBe(''); + }); + }); + // не должны отображаться кнопки изменения времени + it('should not display is time change buttons', () => { + expect(buttonChanges).toBeTruthy(); + + component.readonlyInput = true; + + component.writeValue(testTime()); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + const buttonsHidden = fixture.nativeElement.querySelector('a.btn'); + expect(buttonsHidden.parentElement.parentElement.className).toContain('hidden'); + }); + }); + }); + + describe('input fields hour with property of hourStep', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TimepickerComponent); + fixture.detectChanges(); + + component = fixture.componentInstance; + inputHours = getInputElements(fixture)[0]; + inputMinutes = getInputElements(fixture)[1]; + inputSeconds = getInputElements(fixture)[2]; + buttonChanges = getElements(fixture, 'a.btn'); + }); + // добавить в поле ввода часы значение с учетом hourStep инкримент + it('should add to the hour input field value, hourStep value increment', () => { + component.hourStep = 2; + + component.writeValue(testTime()); + + fireEvent(buttonChanges[0], 'click'); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + expect(inputHours.value).toBe('02'); + }); + }); + // добавить в поле ввода часы значение с учетом hourStep декримент + it('should add to the hour input field value, hourStep value decrement', () => { + component.hourStep = 2; + + component.writeValue(testTime(6)); + + fireEvent(buttonChanges[2], 'click'); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + expect(inputHours.value).toBe('04'); + }); + }); + // вычесть в поле ввода часы значение с учетом minuteStep инкримент + it('should input field value, minuteStep value increment', () => { + component.minuteStep = 12; + + component.writeValue(testTime(6,22)); + + fireEvent(buttonChanges[1], 'click'); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + expect(inputMinutes.value).toBe('34'); + }); + }); + // вычесть в поле ввода часы значение с учетом minuteStep декримент + it('should input field value, minuteStep value decrement', () => { + component.minuteStep = 12; + + component.writeValue(testTime(6,22)); + + fireEvent(buttonChanges[3], 'click'); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + expect(inputMinutes.value).toBe('10'); + }); + }); + // вычесть в поле ввода часы значение с учетом secondsStep инкримент + it('should input field value, secondsStep value increment', () => { + component.showSeconds = true; + component.secondsStep = 10; + + component.writeValue(testTime(6,22, 30)); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + buttonChanges = getElements(fixture, 'a.btn'); + + fireEvent(buttonChanges[2], 'click'); + + fixture.detectChanges(); + return fixture.whenStable(); + }) + .then(() => { + inputSeconds = getInputElements(fixture)[2]; + expect(inputSeconds.value).toBe('40'); + }); + }); + // вычесть в поле ввода часы значение с учетом secondsStep декримент + it('should input field value, secondsStep value decrement', () => { + component.showSeconds = true; + component.secondsStep = 10; + + component.writeValue(testTime(6,22, 30)); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + buttonChanges = getElements(fixture, 'a.btn'); + fireEvent(buttonChanges[5], 'click'); + + fixture.detectChanges(); + return fixture.whenStable(); + }) + .then(() => { + inputSeconds = getInputElements(fixture)[2]; + expect(inputSeconds.value).toBe('20'); + }); + }); + }); + + describe('hide change button', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TimepickerComponent); + fixture.detectChanges(); + + component = fixture.componentInstance; + buttonChanges = getElements(fixture, 'a.btn'); + }); + + // скрыть кнопки изменения времени + it('should hide change button', () => { + component.showSpinners = false; + + component.writeValue(testTime()); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + const buttonsHidden = fixture.nativeElement.querySelector('a.btn'); + expect(buttonsHidden.parentElement.parentElement.className).toContain('hidden'); + }); + }); + }); + + describe('validate mousewheel', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TimepickerComponent); + fixture.detectChanges(); + + component = fixture.componentInstance; + inputHours = getInputElements(fixture)[0]; + inputMinutes = getInputElements(fixture)[1]; + inputSeconds = getInputElements(fixture)[2]; + inputDebugHours = getDebugElements(fixture, 'input')[0]; + inputDebugMinutes = getDebugElements(fixture, 'input')[1]; + inputDebugSeconds = getDebugElements(fixture, 'input')[2]; + }); + + // измененить часы колесом мыши инкремент + it('should can change hours value with the mouse wheel increment', () => { + const methodSpy = spyOn(component, 'changeHours').and.callThrough(); + component.hourStep = 3; + + component.writeValue(testTime(6,30,30)); + fixture.detectChanges(); + + const wheelEvent = new WheelEvent(inputDebugHours, {deltaY: -1}); + + inputDebugHours.triggerEventHandler('wheel', wheelEvent); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(inputHours.value).toEqual('09'); + expect(methodSpy).toHaveBeenCalledWith(component.hourStep * component.wheelSign(wheelEvent), 'wheel'); + }); + }); + // измененить минуты колесом мыши инкремент + it('should can change minutes value with the mouse wheel increment', () => { + const methodSpy = spyOn(component, 'changeMinutes').and.callThrough(); + component.minuteStep = 3; + + component.writeValue(testTime(6,30,30)); + fixture.detectChanges(); + + const wheelEvent = new WheelEvent(inputDebugMinutes, {deltaY: -1}); + + inputDebugMinutes.triggerEventHandler('wheel', wheelEvent); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(inputMinutes.value).toEqual('33'); + expect(methodSpy).toHaveBeenCalledWith(component.minuteStep * component.wheelSign(wheelEvent), 'wheel'); + }); + }); + // измененить секунды колесом мыши инкремент + it('should can change seconds value with the mouse wheel increment', () => { + const methodSpy = spyOn(component, 'changeSeconds').and.callThrough(); + + component.showSeconds = true; + component.secondsStep = 3; + + component.writeValue(testTime(6,30,30)); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + inputDebugSeconds = getDebugElements(fixture, 'input')[2]; + + let wheelEvent = new WheelEvent(inputDebugSeconds, {deltaY: -1}); + inputDebugSeconds.triggerEventHandler('wheel', wheelEvent); + + fixture.detectChanges(); + + inputSeconds = getInputElements(fixture)[2]; + + expect(inputSeconds.value).toEqual('33'); + expect(methodSpy).toHaveBeenCalledWith(component.secondsStep * component.wheelSign(wheelEvent), 'wheel'); + }); + }); + // измененить часы колесом мыши декремент + it('should can change hours value with the mouse wheel decrement', () => { + const methodSpy = spyOn(component, 'changeHours').and.callThrough(); + component.hourStep = 3; + + component.writeValue(testTime(6,30,30)); + fixture.detectChanges(); + + const wheelEvent = new WheelEvent(inputDebugHours, {deltaY: 1}); + + inputDebugHours.triggerEventHandler('wheel', wheelEvent); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(inputHours.value).toEqual('03'); + expect(methodSpy).toHaveBeenCalledWith(component.hourStep * component.wheelSign(wheelEvent), 'wheel'); + }); + }); + // измененить минуты колесом мыши декремент + it('should can change minutes value with the mouse wheel decrement', () => { + const methodSpy = spyOn(component, 'changeMinutes').and.callThrough(); + component.minuteStep = 3; + + component.writeValue(testTime(6,30,30)); + fixture.detectChanges(); + + const wheelEvent = new WheelEvent(inputDebugMinutes, {deltaY: 1}); + + inputDebugMinutes.triggerEventHandler('wheel', wheelEvent); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(inputMinutes.value).toEqual('27'); + expect(methodSpy).toHaveBeenCalledWith(component.minuteStep * component.wheelSign(wheelEvent), 'wheel'); + }); + }); + // измененить секунды колесом мыши декремент + it('should can change seconds value with the mouse wheel decrement', () => { + const methodSpy = spyOn(component, 'changeSeconds').and.callThrough(); + component.secondsStep = 3; + component.showSeconds = true; + + component.writeValue(testTime(6,30,30)); + + fixture.detectChanges(); + fixture.whenStable().then(() => { + inputSeconds = getInputElements(fixture)[2]; + inputDebugSeconds = getDebugElements(fixture, 'input')[2]; + + const wheelEvent = new WheelEvent(inputDebugSeconds, {deltaY: 1}); + + inputDebugSeconds.triggerEventHandler('wheel', wheelEvent); + + fixture.detectChanges(); + + expect(inputSeconds.value).toEqual('27'); + expect(methodSpy).toHaveBeenCalledWith(component.secondsStep * component.wheelSign(wheelEvent), 'wheel'); + }); + }); + // отключить изменение часы колесом мыши + it('should can not change hours value with the mouse wheel', () => { + const methodSpy = spyOn(component, 'changeHours').and.callThrough(); + component.hourStep = 3; + component.mousewheel = false; + + component.writeValue(testTime(6,30,30)); + fixture.detectChanges(); + + const wheelEvent = new WheelEvent(inputDebugHours, {deltaY: 1}); + + inputDebugHours.triggerEventHandler('wheel', wheelEvent); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(inputHours.value).toEqual('06'); + expect(methodSpy).toHaveBeenCalledWith(component.hourStep * component.wheelSign(wheelEvent), 'wheel'); + }); + }); + // отключить изменение минуты колесом мыши + it('should can not change minutes value with the mouse wheel', () => { + const methodSpy = spyOn(component, 'changeMinutes').and.callThrough(); + component.minuteStep = 3; + component.mousewheel = false; + + component.writeValue(testTime(6,30,30)); + fixture.detectChanges(); + + const wheelEvent = new WheelEvent(inputDebugMinutes, {deltaY: 1}); + + inputDebugMinutes.triggerEventHandler('wheel', wheelEvent); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(inputMinutes.value).toEqual('30'); + expect(methodSpy).toHaveBeenCalledWith(component.minuteStep * component.wheelSign(wheelEvent), 'wheel'); + }); + }); + // отключить изменение секунды колесом мыши + it('should can not change seconds value with the mouse wheel', () => { + const methodSpy = spyOn(component, 'changeSeconds').and.callThrough(); + component.showSeconds = true; + component.secondsStep = 3; + component.mousewheel = false; + + component.writeValue(testTime(6,30,30)); + + const wheelEvent = new WheelEvent(inputDebugSeconds, {deltaY: 1}); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + inputSeconds = getInputElements(fixture)[2]; + inputDebugSeconds = getDebugElements(fixture, 'input')[2]; + + inputDebugSeconds.triggerEventHandler('wheel', wheelEvent); + + fixture.detectChanges(); + + expect(inputSeconds.value).toEqual('30'); + expect(methodSpy).toHaveBeenCalledWith(component.secondsStep * component.wheelSign(wheelEvent), 'wheel'); + }); + }); + }); + + describe('validate arrowkeys', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TimepickerComponent); + fixture.detectChanges(); + + component = fixture.componentInstance; + inputHours = getInputElements(fixture)[0]; + inputMinutes = getInputElements(fixture)[1]; + inputSeconds = getInputElements(fixture)[2]; + inputDebugHours = getDebugElements(fixture, 'input')[0]; + inputDebugMinutes = getDebugElements(fixture, 'input')[1]; + inputDebugSeconds = getDebugElements(fixture, 'input')[2]; + }); + + // изменить часы кнопками вверх + it('should can change hours value with the arrow keys up', () => { + const methodSpy = spyOn(component, 'changeHours').and.callThrough(); + component.hourStep = 3; + + component.writeValue(testTime(6,2,3)); + fixture.detectChanges(); + + inputDebugHours.triggerEventHandler('keydown.ArrowUp', null); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(inputHours.value).toEqual('09'); + expect(methodSpy).toHaveBeenCalledWith(component.hourStep, 'key'); + }); + }); + // изменить минуты кнопками вверх + it('should can change minutes value with the arrow keys up', () => { + const methodSpy = spyOn(component, 'changeMinutes').and.callThrough(); + component.minuteStep = 3; + + component.writeValue(testTime(6,2,3)); + fixture.detectChanges(); + + inputDebugMinutes.triggerEventHandler('keydown.ArrowUp', null); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(inputMinutes.value).toEqual('05'); + expect(methodSpy).toHaveBeenCalledWith(component.minuteStep, 'key'); + }); + }); + // изменить секунды кнопками вверх + it('should can change seconds value with the arrow keys up', () => { + const methodSpy = spyOn(component, 'changeSeconds').and.callThrough(); + component.showSeconds = true; + component.secondsStep = 3; + + component.writeValue(testTime(6,2,3)); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + inputDebugSeconds = getDebugElements(fixture, 'input')[2]; + + inputDebugSeconds.triggerEventHandler('keydown.ArrowUp', null); + + fixture.detectChanges(); + return fixture.whenStable(); + }) + .then(() => { + inputSeconds = getInputElements(fixture)[2]; + + expect(inputSeconds.value).toEqual('06'); + expect(methodSpy).toHaveBeenCalledWith(component.secondsStep, 'key'); + }); + }); + // изменить часы кнопками вниз + it('should can not change hours value with the arrow keys down', () => { + const methodSpy = spyOn(component, 'changeHours').and.callThrough(); + component.hourStep = 3; + + component.writeValue(testTime(6,2,3)); + fixture.detectChanges(); + + inputDebugHours.triggerEventHandler('keydown.ArrowDown', null); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(inputHours.value).toEqual('03'); + expect(methodSpy).toHaveBeenCalledWith(-component.hourStep, 'key'); + }); + }); + // изменить минуты кнопками вниз + it('should can not change minutes value with the arrow keys down', () => { + const methodSpy = spyOn(component, 'changeMinutes').and.callThrough(); + component.minuteStep = 3; + + component.writeValue(testTime(6,2,3)); + fixture.detectChanges(); + + inputDebugMinutes.triggerEventHandler('keydown.ArrowDown', null); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(inputMinutes.value).toEqual('59'); + expect(methodSpy).toHaveBeenCalledWith(-component.minuteStep, 'key'); + }); + }); + // изменить секунды кнопками вниз + it('should can not change seconds value with the arrow keys down', () => { + const methodSpy = spyOn(component, 'changeSeconds').and.callThrough(); + + component.showSeconds = true; + component.secondsStep = 3; + + component.writeValue(testTime(6,2,3)); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + inputDebugSeconds = getDebugElements(fixture, 'input')[2]; + + inputDebugSeconds.triggerEventHandler('keydown.ArrowDown', null); + + fixture.detectChanges(); + return fixture.whenStable(); + }) + .then(() => { + inputSeconds = getInputElements(fixture)[2]; + + expect(inputSeconds.value).toEqual('00'); + expect(methodSpy).toHaveBeenCalledWith(-component.secondsStep, 'key'); + }); + }); + // отключить часы времени кнопками + it('should can not change hours value with the arrow keys', () => { + const methodSpy = spyOn(component, 'changeHours').and.callThrough(); + component.hourStep = 3; + component.arrowkeys = false; + + component.writeValue(testTime(6,2,3)); + fixture.detectChanges(); + + inputDebugHours.triggerEventHandler('keydown.ArrowUp', null); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + expect(inputHours.value).toEqual('06'); + expect(methodSpy).toHaveBeenCalledWith(component.hourStep, 'key'); + }); + }); + // отключить минуты времени кнопками + it('should can not change minutes value with the arrow keys', () => { + const methodSpy = spyOn(component, 'changeMinutes').and.callThrough(); + component.minuteStep = 3; + component.arrowkeys = false; + + component.writeValue(testTime(6,2,3)); + fixture.detectChanges(); + + inputDebugMinutes.triggerEventHandler('keydown.ArrowUp', null); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(inputMinutes.value).toEqual('02'); + expect(methodSpy).toHaveBeenCalledWith(component.minuteStep, 'key'); + }); + }); + // отключить секунды времени кнопками + it('should can not change seconds value with the arrow keys', () => { + const methodSpy = spyOn(component, 'changeSeconds').and.callThrough(); + + component.showSeconds = true; + component.secondsStep = 3; + component.arrowkeys = false; + + component.writeValue(testTime(6,2,3)); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + inputDebugSeconds = getDebugElements(fixture, 'input')[2]; + + inputDebugSeconds.triggerEventHandler('keydown.ArrowUp', null); + + fixture.detectChanges(); + return fixture.whenStable(); + }) + .then(() => { + inputSeconds = getInputElements(fixture)[2]; + + expect(inputSeconds.value).toEqual('03'); + expect(methodSpy).toHaveBeenCalledWith(component.secondsStep, 'key'); + }); + }); + }); + + describe('custom validate', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TimepickerComponent); + fixture.detectChanges(); + + component = fixture.componentInstance; + inputHours = getInputElements(fixture)[0]; + inputMinutes = getInputElements(fixture)[1]; + inputSeconds = getInputElements(fixture)[2]; + }); + + // отставить поля не заполненными + it('should leave the input fields not specified', () => { + expect(inputHours.value).toBe(''); + expect(inputMinutes.value).toBe(''); + }); + // не верное значение поля должно сбрасывать время + it('should clear model if values are invalid', () => { + component.showSeconds = true; + component.writeValue(testTime(12,12,12)); + fixture.detectChanges(); + inputSeconds = getInputElements(fixture)[2]; + + expect(inputHours.value).toBe('12'); + expect(inputMinutes.value).toBe('12'); + expect(inputSeconds.value).toBe('12'); + + const methodSpy = spyOn(component, 'onChange').and.callThrough(); + component.hours = '99'; + component.minutes = '99'; + component.seconds = '99'; + component._updateTime(); + fixture.detectChanges(); + + expect(methodSpy).toHaveBeenCalledWith(null); + }); + // верное значение поля + it('should valid value in input fields', () => { + component.showSeconds = true; + component.showMeridian = false; + + component.writeValue(testTime(11,25,45)); + + fixture.detectChanges(); + + expect(inputHours.value).toBeGreaterThan(0); + expect(inputHours.value).toBeLessThan(13); + + expect(inputMinutes.value).toBeGreaterThan(-1); + expect(inputMinutes.value).toBeLessThan(60); + + component.writeValue(testTime(22,25,45)); + + fixture.detectChanges(); + fixture.whenStable() + .then(() => { + expect(inputHours.value).toBeGreaterThan(-1); + expect(inputHours.value).toBeLessThan(24); + + inputSeconds = getInputElements(fixture)[2]; + expect(inputSeconds.value).toBeGreaterThan(-1); + expect(inputSeconds.value).toBeLessThan(60); + }); + }); + }); +}); diff --git a/src/spec/timepicker/timepicker.utils.spec.ts b/src/spec/timepicker/timepicker.utils.spec.ts new file mode 100644 index 0000000000..2e5b00c626 --- /dev/null +++ b/src/spec/timepicker/timepicker.utils.spec.ts @@ -0,0 +1,135 @@ +import { + changeTime, + createDate, + isNumber, + isValidDate, + padNumber, + parseHours, + parseMinutes, + parseSeconds, + parseTime, + setTime, + toNumber +} from '../../timepicker/timepicker.utils'; + +function testTime(hours?: number, minutes?: number, seconds?: number) { + let time = new Date(); + time.setHours(hours || 0); + time.setMinutes(minutes || 0); + time.setSeconds(seconds || 0); + return time; +} + +function modelTime(hours: string | number, minutes: string | number, second: string | number, PM: boolean) { + let time = { + hour: hours || null, + minute: minutes || null, + seconds: second || null, + isPM: PM || null + }; + return time; +} + +describe('Runtime coverage. Utils: Timepicker', () => { + it('should is not empty', () => { + isValidDate(); + }); + + it('should is empty', () => { + isValidDate(testTime()); + }); + + it('should date is interface Data', () => { + let time = new Date(); + time.setHours(NaN); + isValidDate(time); + }); + + it('should date is string', () => { + isValidDate('123'); + }); + + it('should to number', () => { + toNumber(12); + }); + + it('should to string', () => { + toNumber('12'); + }); + + it('should date is string', () => { + isNumber('12'); + }); + + it('should parse hours valid value', () => { + parseHours(12); + }); + + it('should parse hours invalid value', () => { + parseHours('q'); + }); + + it('should parse minutes valid value', () => { + parseMinutes(12); + }); + + it('should parse minutes invalid value', () => { + parseMinutes('q'); + }); + + it('should parse seconds valid value', () => { + parseSeconds(12); + }); + + it('should parse seconds invalid value', () => { + parseSeconds('q'); + }); + + it('should parse time string value', () => { + parseTime('12'); + }); + + it('should parse time date value', () => { + parseTime(testTime()); + }); + + it('should change time valid value', () => { + changeTime(testTime(), modelTime(1, 2, 3, true)); + }); + + it('should change time invalid diff', () => { + changeTime(testTime(), modelTime(-1, 0, 0, false)); + }); + + it('should change time invalid diff hour NaN', () => { + changeTime(testTime(), modelTime(NaN, 0, 0, false)); + }); + + it('should set time opts true', () => { + setTime(testTime(), modelTime(0, 0, 0, true)); + }); + + it('should set time opts false', () => { + setTime(testTime(), modelTime(0, 0, 0, false)); + }); + + it('should set time opts hours NaN', () => { + setTime(testTime(), modelTime(1, 1, 0, false)); + }); + + it('should create date', () => { + createDate(testTime(), 10, 20, 30); + }); + + it('should create date false', () => { + createDate(testTime(), 10, 20, 30); + }); + + it('should pad number', () => { + padNumber(10); + }); + + it('should pad number length', () => { + padNumber(1); + }); +}); diff --git a/src/timepicker/index.ts b/src/timepicker/index.ts index fe8a961676..9a3e8c1661 100644 --- a/src/timepicker/index.ts +++ b/src/timepicker/index.ts @@ -1,3 +1,5 @@ -export { TimepickerConfig } from './timepicker.config'; export { TimepickerComponent } from './timepicker.component'; +export { TimepickerActions } from './reducer/timepicker.actions'; +export { TimepickerStore } from './reducer/timepicker.store'; +export { TimepickerConfig } from './timepicker.config'; export { TimepickerModule } from './timepicker.module'; diff --git a/src/timepicker/reducer/timepicker.actions.ts b/src/timepicker/reducer/timepicker.actions.ts new file mode 100644 index 0000000000..6229eb5115 --- /dev/null +++ b/src/timepicker/reducer/timepicker.actions.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@angular/core'; +import { Action } from '../../mini-ngrx/index'; +import { TimeChangeEvent, TimepickerComponentState, Time } from '../timepicker.models'; + +@Injectable() +export class TimepickerActions { + static readonly WRITE_VALUE = '[timepicker] write value from ng model'; + static readonly CHANGE_HOURS = '[timepicker] change hours'; + static readonly CHANGE_MINUTES = '[timepicker] change minutes'; + static readonly CHANGE_SECONDS = '[timepicker] change seconds'; + static readonly SET_TIME_UNIT = '[timepicker] set time unit'; + static readonly UPDATE_CONTROLS = '[timepicker] update controls'; + + writeValue(value: Date | string) { + return { + type: TimepickerActions.WRITE_VALUE, + payload: value + }; + } + + changeHours(event: TimeChangeEvent) { + return { + type: TimepickerActions.CHANGE_HOURS, + payload: event + }; + } + + changeMinutes(event: TimeChangeEvent) { + return { + type: TimepickerActions.CHANGE_MINUTES, + payload: event + }; + } + + changeSeconds(event: TimeChangeEvent): Action { + return { + type: TimepickerActions.CHANGE_SECONDS, + payload: event + }; + } + + setTime(value: Time): Action { + return { + type: TimepickerActions.SET_TIME_UNIT, + payload: value + }; + } + + updateControls(value: TimepickerComponentState): Action { + return { + type: TimepickerActions.UPDATE_CONTROLS, + payload: value + }; + } +} diff --git a/src/timepicker/reducer/timepicker.reducer.ts b/src/timepicker/reducer/timepicker.reducer.ts new file mode 100644 index 0000000000..93843beba7 --- /dev/null +++ b/src/timepicker/reducer/timepicker.reducer.ts @@ -0,0 +1,94 @@ +import { Action } from '../../mini-ngrx/index'; +import { + canChangeHours, + canChangeMinutes, + canChangeSeconds, + canChangeValue, + timepickerControls +} from '../timepicker-controls.util'; +import { TimepickerConfig } from '../timepicker.config'; +import { TimepickerComponentState, TimepickerControls } from '../timepicker.models'; +import { changeTime, setTime } from '../timepicker.utils'; +import { TimepickerActions } from './timepicker.actions'; + +export class TimepickerState { + value: Date; + config: TimepickerComponentState; + controls: TimepickerControls; +} + +export const initialState = { + config: new TimepickerConfig(), + controls: { + canIncrementHours: true, + canIncrementMinutes: true, + canIncrementSeconds: true, + + canDecrementHours: true, + canDecrementMinutes: true, + canDecrementSeconds: true + } +} as TimepickerState; + +export function timepickerReducer(state = initialState, action: Action) { + switch (action.type) { + case(TimepickerActions.WRITE_VALUE): { + return Object.assign({}, state, {value: action.payload}); + } + + case (TimepickerActions.CHANGE_HOURS): { + if (!canChangeValue(state.config, action.payload) || + !canChangeHours(action.payload, state.controls)) { + return state; + } + + const _newTime = changeTime(state.value, {hour: action.payload.step}); + + return Object.assign({}, state, {value: _newTime}); + } + + case (TimepickerActions.CHANGE_MINUTES): { + if (!canChangeValue(state.config, action.payload) || + !canChangeMinutes(action.payload, state.controls)) { + return state; + } + + const _newTime = changeTime(state.value, {minute: action.payload.step}); + + return Object.assign({}, state, {value: _newTime}); + } + + case (TimepickerActions.CHANGE_SECONDS): { + if (!canChangeValue(state.config, action.payload) || + !canChangeSeconds(action.payload, state.controls)) { + return state; + } + + const _newTime = changeTime(state.value, {seconds: action.payload.step}); + + return Object.assign({}, state, {value: _newTime}); + } + + case (TimepickerActions.SET_TIME_UNIT): { + if (!canChangeValue(state.config)) { + return state; + } + + const _newTime = setTime(state.value, action.payload); + + return Object.assign({}, state, {value: _newTime}); + } + + case (TimepickerActions.UPDATE_CONTROLS): { + const _newControlsState = timepickerControls(state.value, action.payload); + + return Object.assign({}, state, { + config: action.payload, + controls: _newControlsState + }); + } + + default: + return state; + } +} diff --git a/src/timepicker/reducer/timepicker.store.ts b/src/timepicker/reducer/timepicker.store.ts new file mode 100644 index 0000000000..4191ef4b00 --- /dev/null +++ b/src/timepicker/reducer/timepicker.store.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import { timepickerReducer, TimepickerState, initialState } from './timepicker.reducer'; +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; + +import { Action } from '../../mini-ngrx/index'; +import { MiniStore } from '../../mini-ngrx/store.class'; +import { MiniState } from '../../mini-ngrx/state.class'; + +@Injectable() +export class TimepickerStore extends MiniStore { + constructor() { + const _dispatcher = new BehaviorSubject({type: '[mini-ngrx] dispatcher init'}); + const state = new MiniState(initialState, _dispatcher, timepickerReducer); + super(_dispatcher, timepickerReducer, state); + } +} diff --git a/src/timepicker/timepicker-controls.util.ts b/src/timepicker/timepicker-controls.util.ts new file mode 100644 index 0000000000..6cfa039e11 --- /dev/null +++ b/src/timepicker/timepicker-controls.util.ts @@ -0,0 +1,129 @@ +import { changeTime, setTime } from './timepicker.utils'; +import { TimeChangeEvent, TimepickerComponentState, TimepickerControls } from './timepicker.models'; + +export function canChangeValue(state: TimepickerComponentState, event?: TimeChangeEvent): boolean { + if (state.readonlyInput) { + return false; + } + + if (event) { + if (event.source === 'wheel' && !state.mousewheel) { + return false; + } + + if (event.source === 'key' && !state.arrowkeys) { + return false; + } + } + + return true; +} + +export function canChangeHours(event: TimeChangeEvent, controls: TimepickerControls): boolean { + if (!event.step) { + return false; + } + + if (event.step > 0 && !controls.canIncrementHours) { + return false; + } + + if (event.step < 0 && !controls.canDecrementHours) { + return false; + } + + return true; +} + +export function canChangeMinutes(event: TimeChangeEvent, controls: TimepickerControls): boolean { + if (!event.step) { + return false; + } + if (event.step > 0 && !controls.canIncrementMinutes) { + return false; + } + if (event.step < 0 && !controls.canDecrementMinutes) { + return false; + } + + return true; +} + +export function canChangeSeconds(event: TimeChangeEvent, controls: TimepickerControls): boolean { + if (!event.step) { + return false; + } + if (event.step > 0 && !controls.canIncrementSeconds) { + return false; + } + if (event.step < 0 && !controls.canDecrementSeconds) { + return false; + } + + return true; +} + +export function getControlsValue(state: TimepickerComponentState): TimepickerComponentState { + const { + hourStep, minuteStep, secondsStep, + readonlyInput, mousewheel, arrowkeys, + showSpinners, showMeridian, showSeconds, + meridians, min, max + } = state; + return { + hourStep, minuteStep, secondsStep, + readonlyInput, mousewheel, arrowkeys, + showSpinners, showMeridian, showSeconds, + meridians, min, max + }; +} + +export function timepickerControls(value: Date, state: TimepickerComponentState): TimepickerControls { + const {min, max, hourStep, minuteStep, secondsStep, showSeconds} = state; + const res = { + canIncrementHours: true, + canIncrementMinutes: true, + canIncrementSeconds: true, + + canDecrementHours: true, + canDecrementMinutes: true, + canDecrementSeconds: true + } as TimepickerControls; + + if (!value) { + return res; + } + +// compare dates + if (max) { + const _newHour = changeTime(value, { hour: hourStep }); + res.canIncrementHours = max > _newHour; + + if (!res.canIncrementHours) { + const _newMinutes = changeTime(value, { minute: minuteStep }); + res.canIncrementMinutes = showSeconds ? max > _newMinutes : max >= _newMinutes ; + } + + if (!res.canIncrementMinutes) { + const _newSeconds = changeTime(value, { seconds: secondsStep }); + res.canIncrementSeconds = max >= _newSeconds; + } + } + + if (min) { + const _newHour = changeTime(value, { hour: -hourStep }); + res.canDecrementHours = min < _newHour; + + if (!res.canDecrementHours) { + const _newMinutes = changeTime(value, { minute: -minuteStep }); + res.canDecrementMinutes = showSeconds ? min < _newMinutes : min <= _newMinutes; + } + + if (!res.canDecrementMinutes) { + const _newSeconds = changeTime(value, { seconds: -secondsStep }); + res.canDecrementSeconds = min <= _newSeconds; + } + } + + return res; +} diff --git a/src/timepicker/timepicker.component.ts b/src/timepicker/timepicker.component.ts index 2815233587..b41a3fdc0e 100644 --- a/src/timepicker/timepicker.component.ts +++ b/src/timepicker/timepicker.component.ts @@ -1,386 +1,360 @@ -// tslint:disable max-file-line-count -import { Component, Input, OnInit, forwardRef } from '@angular/core'; +/* tslint:disable:no-forward-ref max-file-line-count */ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, EventEmitter, + forwardRef, + Input, + OnChanges, Output, + SimpleChanges +} from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { TimepickerActions } from './reducer/timepicker.actions'; +import { TimepickerStore } from './reducer/timepicker.store'; +import { getControlsValue } from './timepicker-controls.util'; import { TimepickerConfig } from './timepicker.config'; +import { TimeChangeSource, TimepickerComponentState, TimepickerControls } from './timepicker.models'; +import { isValidDate, padNumber, parseTime, isInputValid } from './timepicker.utils'; export const TIMEPICKER_CONTROL_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, + // tslint:disable-next-line useExisting: forwardRef(() => TimepickerComponent), multi: true }; -// todo: refactor directive has to many functions! (extract to stateless helper) -// todo: use moment js? -// todo: implement `time` validator -// todo: replace increment/decrement blockers with getters, or extract -// todo: unify work with selected -function isDefined(value: any): boolean { - return typeof value !== 'undefined'; -} - -function addMinutes(date: any, minutes: number): Date { - let dt = new Date(date.getTime() + minutes * 60000); - let newDate = new Date(date); - newDate.setHours(dt.getHours(), dt.getMinutes()); - return newDate; -} - @Component({ selector: 'timepicker', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [TIMEPICKER_CONTROL_VALUE_ACCESSOR, TimepickerStore], template: ` - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
     
    - - : - -
     
    + + + +     + + + +   + + + +    
    +  :  + +  :  + +     + +
    + + + +     + + + +   + + + +    
    - `, - providers: [TIMEPICKER_CONTROL_VALUE_ACCESSOR] + ` }) -export class TimepickerComponent implements ControlValueAccessor, OnInit { +export class TimepickerComponent implements ControlValueAccessor, TimepickerComponentState, TimepickerControls, OnChanges { /** hours change step */ - @Input() public hourStep: number; + @Input() hourStep: number; /** hours change step */ - @Input() public minuteStep: number; + @Input() minuteStep: number; + /** seconds change step */ + @Input() secondsStep: number; /** if true hours and minutes fields will be readonly */ - @Input() public readonlyInput: boolean; + @Input() readonlyInput: boolean; /** if true scroll inside hours and minutes inputs will change time */ - @Input() public mousewheel: boolean; + @Input() mousewheel: boolean; /** if true up/down arrowkeys inside hours and minutes inputs will change time */ - @Input() public arrowkeys: boolean; + @Input() arrowkeys: boolean; /** if true spinner arrows above and below the inputs will be shown */ - @Input() public showSpinners: boolean; + @Input() showSpinners: boolean; + @Input() showMeridian: boolean; + @Input() showSeconds: boolean; + + /** meridian labels based on locale */ + @Input() meridians: string[]; + /** minimum time user can select */ - @Input() public min: Date; + @Input() min: Date; /** maximum time user can select */ - @Input() public max: Date; - /** meridian labels based on locale */ - @Input() public meridians: string[]; + @Input() max: Date; - /** if true works in 12H mode and displays AM/PM. If false works in 24H mode and hides AM/PM */ - @Input() - public get showMeridian(): boolean { - return this._showMeridian; - } + /** emits true if value is a valid date */ + @Output() isValid: EventEmitter = new EventEmitter(); - public set showMeridian(value: boolean) { - this._showMeridian = value; - // || !this.$error.time - // if (true) { - this.updateTemplate(); - return; - // } - // Evaluate from template - /*let hours = this.getHoursFromTemplate(); - let minutes = this.getMinutesFromTemplate(); - if (isDefined(hours) && isDefined(minutes)) { - this.selected.setHours(hours); - this.refresh(); - }*/ - } + // ui variables + hours: string; + minutes: string; + seconds: string; + meridian: string; - public onChange: any = Function.prototype; - public onTouched: any = Function.prototype; + get isSpinnersVisible(): boolean { + return this.showSpinners && !this.readonlyInput; + } - // input values - public hours: string; - public minutes: string; + // min\max validation for input fields + invalidHours = false; + invalidMinutes = false; + invalidSeconds = false; - // validation - public invalidHours: any; - public invalidMinutes: any; + // time picker controls state + canIncrementHours: boolean; + canIncrementMinutes: boolean; + canIncrementSeconds: boolean; - public meridian: any; // ?? + canDecrementHours: boolean; + canDecrementMinutes: boolean; + canDecrementSeconds: boolean; - // result value - protected _selected: Date = new Date(); - protected _showMeridian: boolean; + // control value accessor methods + onChange: any = Function.prototype; + onTouched: any = Function.prototype; - protected get selected(): Date { - return this._selected; + constructor(_config: TimepickerConfig, + private _cd: ChangeDetectorRef, + private _store: TimepickerStore, + private _timepickerActions: TimepickerActions) { + Object.assign(this, _config); + // todo: add unsubscribe + _store + .select((state) => state.value) + .subscribe((value) => { + // update UI values if date changed + this._renderTime(value); + this.onChange(value); + + this._store.dispatch(this._timepickerActions.updateControls(getControlsValue(this))); + }); + + _store + .select((state) => state.controls) + .subscribe((controlsState) => { + this.isValid.emit(isInputValid(this.hours, this.minutes, this.seconds, this.isPM())); + Object.assign(this, controlsState); + _cd.markForCheck(); + }); } - protected set selected(v: Date) { - if (v) { - this._selected = v; - this.updateTemplate(); - this.onChange(this.selected); - } + isPM(): boolean { + return this.showMeridian && this.meridian === this.meridians[1]; } - protected config: TimepickerConfig; - - public constructor(_config: TimepickerConfig) { - this.config = _config; - Object.assign(this, _config); + prevDef($event: any) { + $event.preventDefault(); } - // todo: add formatter value to Date object - public ngOnInit(): void { - // todo: take in account $locale.DATETIME_FORMATS.AMPMS; - if (this.mousewheel) { - // this.setupMousewheelEvents(); - } - - if (this.arrowkeys) { - // this.setupArrowkeyEvents(); - } - - // this.setupInputEvents(); + wheelSign($event: any): number { + return Math.sign($event.deltaY as number) * -1; } - public writeValue(v: any): void { - if (v === this.selected) { - return; - } - if (v && v instanceof Date) { - this.selected = v; - return; - } - this.selected = v ? new Date(v) : void 0; + ngOnChanges(changes: SimpleChanges): void { + this._store.dispatch(this._timepickerActions.updateControls(getControlsValue(this))); } - public registerOnChange(fn: (_: any) => {}): void { - this.onChange = fn; + changeHours(step: number, source: TimeChangeSource = ''): void { + this._store.dispatch(this._timepickerActions.changeHours({step, source})); } - public registerOnTouched(fn: () => {}): void { - this.onTouched = fn; + changeMinutes(step: number, source: TimeChangeSource = ''): void { + this._store.dispatch(this._timepickerActions.changeMinutes({step, source})); } - public setDisabledState(isDisabled: boolean): void { - this.readonlyInput = isDisabled; + changeSeconds(step: number, source: TimeChangeSource = ''): void { + this._store.dispatch(this._timepickerActions.changeSeconds({step, source})); } - public updateHours(): void { - if (this.readonlyInput) { - return; - } - - let hours = this.getHoursFromTemplate(); - let minutes = this.getMinutesFromTemplate(); - this.invalidHours = !isDefined(hours); - this.invalidMinutes = !isDefined(minutes); - - if (this.invalidHours || this.invalidMinutes) { - // TODO: needed a validation functionality. - return; - // todo: validation? - // invalidate(true); - } - - this.selected.setHours(hours); - this.invalidHours = (this.selected < this.min || this.selected > this.max); - if (this.invalidHours) { - // todo: validation? - // invalidate(true); - return; - } else { - this.refresh(/*'h'*/); - } + updateHours(hours: string): void { + this.hours = hours; + this._updateTime(); } - public hoursOnBlur(): void { - if (this.readonlyInput) { - return; - } - - // todo: binded with validation - if (!this.invalidHours && parseInt(this.hours, 10) < 10) { - this.hours = this.pad(this.hours); - } + updateMinutes(minutes: string) { + this.minutes = minutes; + this._updateTime(); } - public updateMinutes(): void { - if (this.readonlyInput) { - return; - } - - let minutes = this.getMinutesFromTemplate(); - let hours = this.getHoursFromTemplate(); - this.invalidMinutes = !isDefined(minutes); - this.invalidHours = !isDefined(hours); - - if (this.invalidMinutes || this.invalidHours) { - // TODO: needed a validation functionality. - return; - // todo: validation - // invalidate(undefined, true); - } - - this.selected.setMinutes(minutes); - this.invalidMinutes = (this.selected < this.min || this.selected > this.max); - if (this.invalidMinutes) { - // todo: validation - // invalidate(undefined, true); - return; - } else { - this.refresh(/*'m'*/); - } + updateSeconds(seconds: string) { + this.seconds = seconds; + this._updateTime(); } - public minutesOnBlur(): void { - if (this.readonlyInput) { + _updateTime() { + if (!isInputValid(this.hours, this.minutes, this.seconds, this.isPM())) { + this.onChange(null); return; } - - if (!this.invalidMinutes && parseInt(this.minutes, 10) < 10) { - this.minutes = this.pad(this.minutes); - } + this._store.dispatch(this._timepickerActions + .setTime({ + hour: this.hours, + minute: this.minutes, + seconds: this.seconds, + isPM: this.isPM() + })); } - public incrementHours(): void { - if (!this.noIncrementHours()) { - this.addMinutesToSelected(this.hourStep * 60); - } - } - - public decrementHours(): void { - if (!this.noDecrementHours()) { - this.addMinutesToSelected(-this.hourStep * 60); + toggleMeridian(): void { + if (!this.showMeridian || this.readonlyInput) { + return; } - } - public incrementMinutes(): void { - if (!this.noIncrementMinutes()) { - this.addMinutesToSelected(this.minuteStep); - } + const _hoursPerDayHalf = 12; + this._store.dispatch(this._timepickerActions.changeHours({step: _hoursPerDayHalf, source: ''})); } - public decrementMinutes(): void { - if (!this.noDecrementMinutes()) { - this.addMinutesToSelected(-this.minuteStep); + /** + * Write a new value to the element. + */ + writeValue(obj: any): void { + if (isValidDate(obj)) { + this._store.dispatch(this._timepickerActions.writeValue(parseTime(obj))); } } - public noIncrementHours(): boolean { - let incrementedSelected = addMinutes(this.selected, this.hourStep * 60); - return incrementedSelected > this.max || - (incrementedSelected < this.selected && incrementedSelected < this.min); - } - - public noDecrementHours(): boolean { - let decrementedSelected = addMinutes(this.selected, -this.hourStep * 60); - return decrementedSelected < this.min || - (decrementedSelected > this.selected && decrementedSelected > this.max); - } - - public noIncrementMinutes(): boolean { - let incrementedSelected = addMinutes(this.selected, this.minuteStep); - return incrementedSelected > this.max || - (incrementedSelected < this.selected && incrementedSelected < this.min); - } - - public noDecrementMinutes(): boolean { - let decrementedSelected = addMinutes(this.selected, -this.minuteStep); - return decrementedSelected < this.min || - (decrementedSelected > this.selected && decrementedSelected > this.max); - - } - - public toggleMeridian(): void { - if (!this.noToggleMeridian()) { - let sign = this.selected.getHours() < 12 ? 1 : -1; - this.addMinutesToSelected(12 * 60 * sign); - } + /** + * Set the function to be called when the control receives a change event. + */ + registerOnChange(fn: (_: any) => {}): void { + this.onChange = fn; } - public noToggleMeridian(): boolean { - if (this.readonlyInput) { - return true; - } - - if (this.selected.getHours() < 13) { - return addMinutes(this.selected, 12 * 60) > this.max; - } else { - return addMinutes(this.selected, -12 * 60) < this.min; - } + /** + * Set the function to be called when the control receives a touch event. + */ + registerOnTouched(fn: () => {}): void { + this.onTouched = fn; } - protected refresh(/*type?:string*/): void { - // this.makeValid(); - this.updateTemplate(); - this.onChange(this.selected); + /** + * This function is called when the control status changes to or from "DISABLED". + * Depending on the value, it will enable or disable the appropriate DOM element. + * + * @param isDisabled + */ + setDisabledState(isDisabled: boolean): void { + this.readonlyInput = isDisabled; } - protected updateTemplate(/*keyboardChange?:any*/): void { - let hours = this.selected.getHours(); - let minutes = this.selected.getMinutes(); - - if (this.showMeridian) { - // Convert 24 to 12 hour system - hours = (hours === 0 || hours === 12) ? 12 : hours % 12; - } - - // this.hours = keyboardChange === 'h' ? hours : this.pad(hours); - // if (keyboardChange !== 'm') { - // this.minutes = this.pad(minutes); - // } - this.hours = this.pad(hours); - this.minutes = this.pad(minutes); + private _renderTime(value: string | Date): void { + if (!isValidDate(value)) { + this.hours = ''; + this.minutes = ''; + this.seconds = ''; + this.meridian = this.meridians[0]; - if (!this.meridians) { - this.meridians = this.config.meridians; + return; } - this.meridian = this.selected.getHours() < 12 - ? this.meridians[0] - : this.meridians[1]; - } - - protected getHoursFromTemplate(): number { - let hours = parseInt(this.hours, 10); - let valid = this.showMeridian - ? (hours > 0 && hours < 13) - : (hours >= 0 && hours < 24); - if (!valid) { - return void 0; - } + const _value = parseTime(value); + const _hoursPerDayHalf = 12; + let _hours = _value.getHours(); if (this.showMeridian) { - if (hours === 12) { - hours = 0; - } - if (this.meridian === this.meridians[1]) { - hours = hours + 12; + this.meridian = this.meridians[_hours >= _hoursPerDayHalf ? 1 : 0]; + _hours = _hours % _hoursPerDayHalf; + // should be 12 PM, not 00 PM + if (_hours === 0) { + _hours = _hoursPerDayHalf; } } - return hours; - } - - protected getMinutesFromTemplate(): number { - let minutes = parseInt(this.minutes, 10); - return (minutes >= 0 && minutes < 60) ? minutes : undefined; - } - - protected pad(value: string|number): string { - return (isDefined(value) && value.toString().length < 2) - ? '0' + value - : value.toString(); - } - protected addMinutesToSelected(minutes: any): void { - this.selected = addMinutes(this.selected, minutes); - this.refresh(); + this.hours = padNumber(_hours); + this.minutes = padNumber(_value.getMinutes()); + this.seconds = padNumber(_value.getUTCSeconds()); } } diff --git a/src/timepicker/timepicker.config.ts b/src/timepicker/timepicker.config.ts index d976e4a588..b782d77111 100644 --- a/src/timepicker/timepicker.config.ts +++ b/src/timepicker/timepicker.config.ts @@ -4,23 +4,27 @@ import { Injectable } from '@angular/core'; @Injectable() export class TimepickerConfig { /** hours change step */ - public hourStep: number = 1; + hourStep = 1; /** hours change step */ - public minuteStep: number = 5; + minuteStep = 5; + /** seconds changes step */ + secondsStep = 10; /** if true works in 12H mode and displays AM/PM. If false works in 24H mode and hides AM/PM */ - public showMeridian: boolean = true; + showMeridian = true; /** meridian labels based on locale */ - public meridians:string[] = ['AM', 'PM']; + meridians = ['AM', 'PM']; /** if true hours and minutes fields will be readonly */ - public readonlyInput: boolean = false; + readonlyInput = false; /** if true scroll inside hours and minutes inputs will change time */ - public mousewheel: boolean = true; + mousewheel = true; /** if true up/down arrowkeys inside hours and minutes inputs will change time */ - public arrowkeys: boolean = true; + arrowkeys = true; /** if true spinner arrows above and below the inputs will be shown */ - public showSpinners: boolean = true; + showSpinners = true; + /** show seconds in timepicker */ + showSeconds = false; /** minimum time user can select */ - public min: number = void 0; + min: Date; /** maximum time user can select */ - public max: number = void 0; + max: Date; } diff --git a/src/timepicker/timepicker.models.ts b/src/timepicker/timepicker.models.ts new file mode 100644 index 0000000000..69f8ba5a20 --- /dev/null +++ b/src/timepicker/timepicker.models.ts @@ -0,0 +1,43 @@ +export interface Time { + hour?: string | number; + minute?: string | number; + seconds?: string | number; + isPM?: boolean; +} + +export interface TimepickerControls { + canIncrementHours: boolean; + canIncrementMinutes: boolean; + canIncrementSeconds: boolean; + + canDecrementHours: boolean; + canDecrementMinutes: boolean; + canDecrementSeconds: boolean; +} + +export interface TimepickerComponentState { + min: Date; + max: Date; + + hourStep: number; + minuteStep: number; + secondsStep: number; + + readonlyInput: boolean; + + mousewheel: boolean; + arrowkeys: boolean; + + showSpinners: boolean; + showMeridian: boolean; + showSeconds: boolean; + + meridians: string[]; +} + +export type TimeChangeSource = 'wheel' | 'key' | ''; + +export interface TimeChangeEvent { + step: number; + source: TimeChangeSource; +} diff --git a/src/timepicker/timepicker.module.ts b/src/timepicker/timepicker.module.ts index 3c39a0b4cc..f0fb71564c 100644 --- a/src/timepicker/timepicker.module.ts +++ b/src/timepicker/timepicker.module.ts @@ -1,19 +1,21 @@ +import { ModuleWithProviders, NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { NgModule, ModuleWithProviders } from '@angular/core'; + import { TimepickerComponent } from './timepicker.component'; +import { TimepickerActions } from './reducer/timepicker.actions'; import { TimepickerConfig } from './timepicker.config'; +import { TimepickerStore } from './reducer/timepicker.store'; @NgModule({ - imports: [CommonModule, FormsModule], + imports: [CommonModule], declarations: [TimepickerComponent], - exports: [TimepickerComponent, FormsModule] + exports: [TimepickerComponent] }) export class TimepickerModule { - public static forRoot(): ModuleWithProviders { + static forRoot(): ModuleWithProviders { return { ngModule: TimepickerModule, - providers: [TimepickerConfig] + providers: [TimepickerConfig, TimepickerActions, TimepickerStore] }; } } diff --git a/src/timepicker/timepicker.utils.ts b/src/timepicker/timepicker.utils.ts new file mode 100644 index 0000000000..fe61cdc9b9 --- /dev/null +++ b/src/timepicker/timepicker.utils.ts @@ -0,0 +1,144 @@ +import { Time } from './timepicker.models'; + +const dex = 10; +const hoursPerDay = 24; +const hoursPerDayHalf = 12; +const minutesPerHour = 60; +const secondsPerMinute = 60; + +export function isValidDate(value?: string | Date): boolean { + if (!value) { + return false; + } + + if (value instanceof Date && isNaN(value.getHours())) { + return false; + } + + if (typeof value === 'string') { + return isValidDate(new Date(value)); + } + + return true; +} + +export function toNumber(value: string | number): number { + if (typeof value === 'number') { + return value; + } + + return parseInt(value, dex); +} + +export function isNumber(value: string): boolean { + return !isNaN(toNumber(value)); +} + +export function parseHours(value: string | number, isPM: boolean = false): number { + const hour = toNumber(value); + if (isNaN(hour) || hour < 0 || hour > (isPM ? hoursPerDayHalf : hoursPerDay)) { + return NaN; + } + + return hour; +} + +export function parseMinutes(value: string | number): number { + const minute = toNumber(value); + if (isNaN(minute) || minute < 0 || minute > minutesPerHour) { + return NaN; + } + + return minute; +} + +export function parseSeconds(value: string | number): number { + const seconds = toNumber(value); + if (isNaN(seconds) || seconds < 0 || seconds > secondsPerMinute) { + return NaN; + } + + return seconds; +} + +export function parseTime(value: string | Date): Date { + if (typeof value === 'string') { + return new Date(value); + } + + return value; +} + +export function changeTime(value: Date, diff: Time): Date { + if (!value) { + return changeTime(createDate(new Date(),0,0, 0), diff); + } + + let hour = value.getHours(); + let minutes = value.getMinutes(); + let seconds = value.getSeconds(); + + if (diff.hour) { + hour = (hour + toNumber(diff.hour)) % hoursPerDay; + if (hour < 0) { + hour += hoursPerDay; + } + } + + if (diff.minute) { + minutes = (minutes + toNumber(diff.minute)); + } + + if (diff.seconds) { + seconds = (seconds + toNumber(diff.seconds)); + } + + return createDate(value, hour, minutes, seconds); +} + +export function setTime(value: Date, opts: Time): Date { + let hour = parseHours(opts.hour); + const minute = parseMinutes(opts.minute); + const seconds = parseSeconds(opts.seconds) || 0; + + if (opts.isPM) { + hour += hoursPerDayHalf; + } + + // fixme: unreachable code, value is mandatory + if (!value) { + if (!isNaN(hour) && !isNaN(minute)) { + return createDate(new Date(), hour, minute, seconds); + } + + return value; + } + + if (isNaN(hour) || isNaN(minute)) { + return value; + } + + return createDate(value, hour, minute, seconds); +} + +export function createDate(value: Date, hours: number, minutes: number, seconds: number): Date { + + // fixme: unreachable code, value is mandatory + const _value = value || new Date(); + return new Date(_value.getFullYear(), _value.getMonth(), _value.getDate(), + hours, minutes, seconds, _value.getMilliseconds()); +} + +export function padNumber(value: number): string { + const _value = value.toString(); + if (_value.length > 1) { return _value; } + + return `0${_value}`; +} + +export function isInputValid(hours: string, minutes: string, seconds: string = '0', isPM: boolean): boolean { + if (isNaN(parseHours(hours, isPM)) || isNaN(parseMinutes(minutes)) || isNaN(parseSeconds(seconds))) { + return false; + } + return true; +}