From 6075e6a6e79fab98c75a9da2a52fc408ad10cd8f Mon Sep 17 00:00:00 2001 From: Carlo Nomes Date: Thu, 25 Apr 2019 14:51:53 +0200 Subject: [PATCH] feat(stark-ui): implement `stark-date-time-picker` module/component CLOSES ISSUE: #587 --- packages/stark-ui/assets/stark-ui-bundle.scss | 49 +- packages/stark-ui/src/modules.ts | 1 + .../stark-ui/src/modules/date-time-picker.ts | 2 + .../assets/translations/en.ts | 10 + .../assets/translations/fr.ts | 10 + .../assets/translations/nl.ts | 10 + .../modules/date-time-picker/components.ts | 1 + .../date-time-picker.component.html | 39 + .../date-time-picker.component.scss | 51 ++ .../date-time-picker.component.spec.ts | 820 ++++++++++++++++++ .../components/date-time-picker.component.ts | 735 ++++++++++++++++ .../date-time-picker.module.ts | 47 + packages/stark-ui/testing/tsconfig-build.json | 10 +- packages/stark-ui/tsconfig.spec.json | 9 +- packages/tsconfig.json | 7 +- showcase/src/app/app-menu.config.ts | 7 + showcase/src/app/demo-ui/demo-ui.module.ts | 6 +- .../demo-date-time-picker-page.component.scss | 5 + .../demo-date-time-picker-page.component.ts | 99 +++ .../demo-date-time-picker-page.html | 71 ++ .../demo-ui/pages/date-time-picker/index.ts | 1 + showcase/src/app/demo-ui/pages/index.ts | 1 + showcase/src/app/demo-ui/routes.ts | 11 +- .../examples/date-time-picker/ng-model.html | 23 + .../examples/date-time-picker/ng-model.scss | 5 + .../examples/date-time-picker/ng-model.ts | 64 ++ .../date-time-picker/reactive-form.html | 21 + .../date-time-picker/reactive-form.scss | 5 + .../date-time-picker/reactive-form.ts | 81 ++ showcase/src/assets/translations/en.json | 13 + showcase/src/assets/translations/fr.json | 13 + showcase/src/assets/translations/nl.json | 13 + 32 files changed, 2202 insertions(+), 38 deletions(-) create mode 100644 packages/stark-ui/src/modules/date-time-picker.ts create mode 100644 packages/stark-ui/src/modules/date-time-picker/assets/translations/en.ts create mode 100644 packages/stark-ui/src/modules/date-time-picker/assets/translations/fr.ts create mode 100644 packages/stark-ui/src/modules/date-time-picker/assets/translations/nl.ts create mode 100644 packages/stark-ui/src/modules/date-time-picker/components.ts create mode 100644 packages/stark-ui/src/modules/date-time-picker/components/date-time-picker.component.html create mode 100644 packages/stark-ui/src/modules/date-time-picker/components/date-time-picker.component.scss create mode 100644 packages/stark-ui/src/modules/date-time-picker/components/date-time-picker.component.spec.ts create mode 100644 packages/stark-ui/src/modules/date-time-picker/components/date-time-picker.component.ts create mode 100644 packages/stark-ui/src/modules/date-time-picker/date-time-picker.module.ts create mode 100644 showcase/src/app/demo-ui/pages/date-time-picker/demo-date-time-picker-page.component.scss create mode 100644 showcase/src/app/demo-ui/pages/date-time-picker/demo-date-time-picker-page.component.ts create mode 100644 showcase/src/app/demo-ui/pages/date-time-picker/demo-date-time-picker-page.html create mode 100644 showcase/src/app/demo-ui/pages/date-time-picker/index.ts create mode 100644 showcase/src/assets/examples/date-time-picker/ng-model.html create mode 100644 showcase/src/assets/examples/date-time-picker/ng-model.scss create mode 100644 showcase/src/assets/examples/date-time-picker/ng-model.ts create mode 100644 showcase/src/assets/examples/date-time-picker/reactive-form.html create mode 100644 showcase/src/assets/examples/date-time-picker/reactive-form.scss create mode 100644 showcase/src/assets/examples/date-time-picker/reactive-form.ts diff --git a/packages/stark-ui/assets/stark-ui-bundle.scss b/packages/stark-ui/assets/stark-ui-bundle.scss index 64f126a806..fcae14dc53 100644 --- a/packages/stark-ui/assets/stark-ui-bundle.scss +++ b/packages/stark-ui/assets/stark-ui-bundle.scss @@ -1,55 +1,56 @@ /* Stark styles */ -@import "../assets/styles/base"; +@import "../assets/styles/base"; /* contains basic style corrections needed by the app to render correctly, so it should be loaded first */ @import "../assets/theming/base-theme"; @import "../assets/styles/components/button"; @import "../assets/styles/components/button-theme"; -@import "../assets/styles/components/icon"; @import "../assets/styles/components/card"; @import "../assets/styles/components/card-theme"; @import "../assets/styles/components/header"; @import "../assets/styles/components/header-theme"; +@import "../assets/styles/components/icon"; /* Stark components */ -@import "../src/modules/app-logo/components/app-logo.component"; -@import "../src/modules/app-logo/components/app-logo-theme"; -@import "../src/modules/app-data/components/app-data.component"; +@import "../src/modules/action-bar/components/action-bar-theme"; +@import "../src/modules/action-bar/components/action-bar.component"; @import "../src/modules/app-data/components/app-data-theme"; -@import "../src/modules/app-footer/components/app-footer.component"; +@import "../src/modules/app-data/components/app-data.component"; @import "../src/modules/app-footer/components/app-footer-theme"; -@import "../src/modules/app-menu/components/app-menu.component"; +@import "../src/modules/app-footer/components/app-footer.component"; +@import "../src/modules/app-logo/components/app-logo-theme"; +@import "../src/modules/app-logo/components/app-logo.component"; @import "../src/modules/app-menu/components/app-menu-theme"; -@import "../src/modules/action-bar/components/action-bar.component"; -@import "../src/modules/action-bar/components/action-bar-theme"; -@import "../src/modules/app-sidebar/components/app-sidebar.component"; +@import "../src/modules/app-menu/components/app-menu.component"; @import "../src/modules/app-sidebar/components/app-sidebar-theme"; +@import "../src/modules/app-sidebar/components/app-sidebar.component"; @import "../src/modules/breadcrumb/components/breadcrumb.component"; -@import "../src/modules/collapsible/components/collapsible.component"; @import "../src/modules/collapsible/components/collapsible-theme"; +@import "../src/modules/collapsible/components/collapsible.component"; @import "../src/modules/date-range-picker/components/date-range-picker.component"; +@import "../src/modules/date-time-picker/components/date-time-picker.component"; @import "../src/modules/dialogs/components/alert-dialog-theme"; @import "../src/modules/dialogs/components/alert-dialog.component"; @import "../src/modules/dialogs/components/confirm-dialog-theme"; -@import "../src/modules/dialogs/components/prompt-dialog.component"; @import "../src/modules/dialogs/components/prompt-dialog-theme"; +@import "../src/modules/dialogs/components/prompt-dialog.component"; +@import "../src/modules/dropdown/components/dropdown-theme"; +@import "../src/modules/dropdown/components/dropdown.component"; @import "../src/modules/generic-search/components/generic-search/generic-search.component"; @import "../src/modules/language-selector/components/language-selector.component"; -@import "../src/modules/message-pane/components/message-pane.component"; @import "../src/modules/message-pane/components/message-pane-theme"; -@import "../src/modules/minimap/components/minimap.component"; +@import "../src/modules/message-pane/components/message-pane.component"; @import "../src/modules/minimap/components/minimap-theme"; -@import "../src/modules/slider/components/slider-theme"; -@import "../src/modules/pagination/components/pagination.component"; +@import "../src/modules/minimap/components/minimap.component"; @import "../src/modules/pagination/components/pagination-theme"; +@import "../src/modules/pagination/components/pagination.component"; @import "../src/modules/pretty-print/components/pretty-print.component"; -@import "../src/modules/table/components/table.component"; -@import "../src/modules/table/components/table-theme"; -@import "../src/modules/table/components/dialogs/multisort.component"; -@import "../src/modules/dropdown/components/dropdown-theme"; -@import "../src/modules/route-search/components/route-search.component"; @import "../src/modules/route-search/components/route-search-theme"; -@import "../src/modules/toast-notification/components/toast-notification.component"; -@import "../src/modules/toast-notification/components/toast-notification-theme"; -@import "../src/modules/dropdown/components/dropdown.component"; +@import "../src/modules/route-search/components/route-search.component"; @import "../src/modules/session-ui/components/session-card/session-card.component"; +@import "../src/modules/slider/components/slider-theme"; +@import "../src/modules/table/components/dialogs/multisort.component"; +@import "../src/modules/table/components/table-theme"; +@import "../src/modules/table/components/table.component"; +@import "../src/modules/toast-notification/components/toast-notification-theme"; +@import "../src/modules/toast-notification/components/toast-notification.component"; /* Stark session-ui pages */ @import "../src/modules/session-ui/pages/session-ui-pages"; @import "../src/modules/session-ui/pages/login/login-page.component"; diff --git a/packages/stark-ui/src/modules.ts b/packages/stark-ui/src/modules.ts index 065a783091..8e4a1f2dd4 100644 --- a/packages/stark-ui/src/modules.ts +++ b/packages/stark-ui/src/modules.ts @@ -9,6 +9,7 @@ export * from "./modules/breadcrumb"; export * from "./modules/collapsible"; export * from "./modules/date-picker"; export * from "./modules/date-range-picker"; +export * from "./modules/date-time-picker"; export * from "./modules/dialogs"; export * from "./modules/dropdown"; export * from "./modules/generic-search"; diff --git a/packages/stark-ui/src/modules/date-time-picker.ts b/packages/stark-ui/src/modules/date-time-picker.ts new file mode 100644 index 0000000000..f348fbe54d --- /dev/null +++ b/packages/stark-ui/src/modules/date-time-picker.ts @@ -0,0 +1,2 @@ +export * from "./date-time-picker/date-time-picker.module"; +export * from "./date-time-picker/components"; diff --git a/packages/stark-ui/src/modules/date-time-picker/assets/translations/en.ts b/packages/stark-ui/src/modules/date-time-picker/assets/translations/en.ts new file mode 100644 index 0000000000..14322355e0 --- /dev/null +++ b/packages/stark-ui/src/modules/date-time-picker/assets/translations/en.ts @@ -0,0 +1,10 @@ +/** + * @ignore + */ +export const translationsEn: object = { + STARK: { + DATE_TIME_PICKER: { + CLEAR_DATETIME: "Clear datetime" + } + } +}; diff --git a/packages/stark-ui/src/modules/date-time-picker/assets/translations/fr.ts b/packages/stark-ui/src/modules/date-time-picker/assets/translations/fr.ts new file mode 100644 index 0000000000..75dc163fde --- /dev/null +++ b/packages/stark-ui/src/modules/date-time-picker/assets/translations/fr.ts @@ -0,0 +1,10 @@ +/** + * @ignore + */ +export const translationsFr: object = { + STARK: { + DATE_TIME_PICKER: { + CLEAR_DATETIME: "Effacer la date et l'heure" + } + } +}; diff --git a/packages/stark-ui/src/modules/date-time-picker/assets/translations/nl.ts b/packages/stark-ui/src/modules/date-time-picker/assets/translations/nl.ts new file mode 100644 index 0000000000..67654846d3 --- /dev/null +++ b/packages/stark-ui/src/modules/date-time-picker/assets/translations/nl.ts @@ -0,0 +1,10 @@ +/** + * @ignore + */ +export const translationsNl: object = { + STARK: { + DATE_TIME_PICKER: { + CLEAR_DATETIME: "Verwijder datetime" + } + } +}; diff --git a/packages/stark-ui/src/modules/date-time-picker/components.ts b/packages/stark-ui/src/modules/date-time-picker/components.ts new file mode 100644 index 0000000000..8306fe4ef5 --- /dev/null +++ b/packages/stark-ui/src/modules/date-time-picker/components.ts @@ -0,0 +1 @@ +export * from "./components/date-time-picker.component"; diff --git a/packages/stark-ui/src/modules/date-time-picker/components/date-time-picker.component.html b/packages/stark-ui/src/modules/date-time-picker/components/date-time-picker.component.html new file mode 100644 index 0000000000..0d64a6d370 --- /dev/null +++ b/packages/stark-ui/src/modules/date-time-picker/components/date-time-picker.component.html @@ -0,0 +1,39 @@ +
+ + + +
+ +
+
+ + diff --git a/packages/stark-ui/src/modules/date-time-picker/components/date-time-picker.component.scss b/packages/stark-ui/src/modules/date-time-picker/components/date-time-picker.component.scss new file mode 100644 index 0000000000..fb1b581f63 --- /dev/null +++ b/packages/stark-ui/src/modules/date-time-picker/components/date-time-picker.component.scss @@ -0,0 +1,51 @@ +/* The 'stark-date-time-picker-form-field-wrapper' is added dynamically by the component to the surrounding form field wrapper*/ +.mat-form-field .mat-form-field-wrapper.stark-date-time-picker-form-field-wrapper { + width: 280px; +} + +.stark-date-time-picker { + display: flex; + + .date-time-wrapper { + width: 100%; + display: flex; + + input { + text-align: left; + } + + /* Date */ + > .stark-date-picker { + flex: 2; + max-width: 200px; + } + + /* Time */ + > .time-input { + flex: 1; + max-width: 60px; + } + } + + .mat-datepicker-toggle { + transition: color 500ms; + } + + /* + &:not(.floating) .mat-datepicker-toggle { + color: rgba(0, 0, 0, 0); + } + */ + + .clear-date-time-button { + height: 20px; + width: 20px; + line-height: 20px; + position: absolute; + margin-left: 280px; /* should be the same width of the 'stark-date-time-picker-form-field-wrapper' (see above) */ + + &.hidden { + display: none; + } + } +} diff --git a/packages/stark-ui/src/modules/date-time-picker/components/date-time-picker.component.spec.ts b/packages/stark-ui/src/modules/date-time-picker/components/date-time-picker.component.spec.ts new file mode 100644 index 0000000000..62fc781f00 --- /dev/null +++ b/packages/stark-ui/src/modules/date-time-picker/components/date-time-picker.component.spec.ts @@ -0,0 +1,820 @@ +/* tslint:disable:completed-docs max-inline-declarations no-big-function */ +import { Component, NO_ERRORS_SCHEMA, ViewChild } from "@angular/core"; +import { async, ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { FormControl, FormsModule, ReactiveFormsModule, Validators } from "@angular/forms"; +import { By, HAMMER_LOADER } from "@angular/platform-browser"; +import { MatFormField, MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { DateAdapter, MAT_DATE_FORMATS, MAT_DATE_LOCALE } from "@angular/material/core"; +import { MatDatepickerModule } from "@angular/material/datepicker"; +import { MatTooltipModule } from "@angular/material/tooltip"; +import { MatMomentDateModule, MomentDateAdapter } from "@angular/material-moment-adapter"; +import { TranslateModule } from "@ngx-translate/core"; +import { MockStarkLoggingService } from "@nationalbankbelgium/stark-core/testing"; +import { STARK_LOGGING_SERVICE } from "@nationalbankbelgium/stark-core"; +import { Observer, Subject } from "rxjs"; +import moment from "moment"; +import { + STARK_DATE_FORMATS, + StarkDatePickerComponent, + StarkDatePickerFilter, + StarkDatePickerMaskConfig +} from "../../date-picker/components"; +import { StarkTimestampMaskConfig, StarkTimestampMaskDirective } from "../../input-mask-directives/directives"; +import { DEFAULT_TIME_MASK_CONFIG, StarkDateTimePickerComponent } from "./date-time-picker.component"; +import createSpyObj = jasmine.createSpyObj; +import Spy = jasmine.Spy; +import SpyObj = jasmine.SpyObj; + +@Component({ + selector: "host-component", + template: ` + + + + ` +}) +class TestHostComponent { + @ViewChild(StarkDateTimePickerComponent) + public dateTimePickerComponent!: StarkDateTimePickerComponent; + + public value?: Date; + public pickerId?: string; + public pickerName?: string; + public placeholder?: string; + public isDisabled?: boolean; + public required?: boolean; + public dateMask?: StarkDatePickerMaskConfig; + public timeMask?: StarkTimestampMaskConfig; + public dateFilter?: StarkDatePickerFilter; + public minDate?: Date; + public maxDate?: Date; + + public onValueChange(value: Date): void { + this.value = value; + } +} + +@Component({ + selector: "host-form-control-component", + template: ` + + + + ` +}) +class TestHostFormControlComponent { + @ViewChild(StarkDateTimePickerComponent) + public dateTimePickerComponent!: StarkDateTimePickerComponent; + + public formControl = new FormControl(); + public pickerId?: string; + public pickerName?: string; + public placeholder?: string; + public isDisabled?: boolean; + public required?: boolean; + public dateMask?: StarkDatePickerMaskConfig; + public timeMask?: StarkTimestampMaskConfig; + public dateFilter?: StarkDatePickerFilter; + public minDate?: Date; + public maxDate?: Date; +} + +describe("DateTimePickerComponent", () => { + let component: StarkDateTimePickerComponent; + const timeInputSelector = ".time-input"; + + beforeEach(async(() => { + return TestBed.configureTestingModule({ + declarations: [ + StarkTimestampMaskDirective, + StarkDatePickerComponent, + StarkDateTimePickerComponent, + TestHostComponent, + TestHostFormControlComponent + ], + imports: [ + NoopAnimationsModule, + MatDatepickerModule, + MatTooltipModule, + MatFormFieldModule, + MatInputModule, + MatMomentDateModule, + FormsModule, + ReactiveFormsModule, + TranslateModule.forRoot() + ], + providers: [ + { provide: STARK_LOGGING_SERVICE, useValue: new MockStarkLoggingService() }, + { provide: MAT_DATE_FORMATS, useValue: STARK_DATE_FORMATS }, + { provide: MAT_DATE_LOCALE, useValue: "en-us" }, + { provide: DateAdapter, useClass: MomentDateAdapter, deps: [MAT_DATE_LOCALE] }, + { + // See https://github.com/NationalBankBelgium/stark/issues/1088 + provide: HAMMER_LOADER, + useValue: (): Promise => new Subject().toPromise() + } + ], + schemas: [NO_ERRORS_SCHEMA] // to avoid errors due to "mat-icon" directive not known (which we don't want to add in these tests) + }).compileComponents(); + })); + + describe("MatFormFieldControl", () => { + let hostComponent: TestHostFormControlComponent; + let hostFixture: ComponentFixture; + const formFieldInvalidClass = "mat-form-field-invalid"; + + beforeEach(() => { + hostFixture = TestBed.createComponent(TestHostFormControlComponent); + hostComponent = hostFixture.componentInstance; + hostFixture.detectChanges(); // trigger initial data binding + }); + + it("if date is initially invalid, the date time picker should not be displayed as invalid until the user interacts with the date or time picker", () => { + // re-create component with a form control with "required" validator + hostFixture = TestBed.createComponent(TestHostFormControlComponent); + hostComponent = hostFixture.componentInstance; + hostComponent.formControl = new FormControl(undefined, Validators.required); // initially invalid + hostFixture.detectChanges(); // trigger initial data binding + + let formFieldDebugElement = hostFixture.debugElement.query(By.directive(MatFormField)); + expect(formFieldDebugElement.classes[formFieldInvalidClass]).toBe(false); + + const datePickerInputDebugElement = hostFixture.debugElement.query(By.css("stark-date-picker > input")); + expect(datePickerInputDebugElement).toBeTruthy(); + // more verbose way to create and trigger an event (the only way it works in IE) + // https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Creating_and_triggering_events + let blurEvent = document.createEvent("Event"); + blurEvent.initEvent("blur", true, true); + datePickerInputDebugElement.nativeElement.dispatchEvent(blurEvent); // simulate that the user has touched the input + hostFixture.detectChanges(); + + expect(formFieldDebugElement.classes[formFieldInvalidClass]).toBe(true); + + // re-create component with a form control with "required" validator + hostFixture = TestBed.createComponent(TestHostFormControlComponent); + hostComponent = hostFixture.componentInstance; + hostComponent.formControl = new FormControl(undefined, Validators.required); // initially invalid + hostFixture.detectChanges(); // trigger initial data binding + + formFieldDebugElement = hostFixture.debugElement.query(By.directive(MatFormField)); + expect(formFieldDebugElement.classes[formFieldInvalidClass]).toBe(false); + + const timeInputDebugElement = hostFixture.debugElement.query(By.css(".date-time-wrapper > input")); + expect(timeInputDebugElement).toBeTruthy(); + // more verbose way to create and trigger an event (the only way it works in IE) + // https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Creating_and_triggering_events + blurEvent = document.createEvent("Event"); + blurEvent.initEvent("blur", true, true); + timeInputDebugElement.nativeElement.dispatchEvent(blurEvent); // simulate that the user has touched the input + hostFixture.detectChanges(); + + expect(formFieldDebugElement.classes[formFieldInvalidClass]).toBe(true); + }); + + it("if date time is initially invalid, the date time picker should not be displayed as invalid until the form control is marked as 'touched'", () => { + // re-create component with a form control with "required" validator + hostFixture = TestBed.createComponent(TestHostFormControlComponent); + hostComponent = hostFixture.componentInstance; + hostComponent.formControl = new FormControl(undefined, Validators.required); // initially invalid + hostFixture.detectChanges(); // trigger initial data binding + + const formFieldDebugElement = hostFixture.debugElement.query(By.directive(MatFormField)); + expect(formFieldDebugElement.classes[formFieldInvalidClass]).toBe(false); + + hostComponent.formControl.markAsTouched(); + hostFixture.detectChanges(); + + expect(formFieldDebugElement.classes[formFieldInvalidClass]).toBe(true); + }); + + it("if date time is initially invalid, the date time picker should not be displayed as invalid until the form control is marked as 'dirty'", () => { + // re-create component with a form control with "required" validator + hostFixture = TestBed.createComponent(TestHostFormControlComponent); + hostComponent = hostFixture.componentInstance; + hostComponent.formControl = new FormControl(undefined, Validators.required); // initially invalid + hostFixture.detectChanges(); // trigger initial data binding + + const formFieldDebugElement = hostFixture.debugElement.query(By.directive(MatFormField)); + expect(formFieldDebugElement.classes[formFieldInvalidClass]).toBe(false); + + hostComponent.formControl.markAsDirty(); + hostFixture.detectChanges(); + + expect(formFieldDebugElement.classes[formFieldInvalidClass]).toBe(true); + }); + + it("if marked as required, an asterisk should be appended to the label", () => { + const formFieldLabelSelector = ".mat-form-field-label"; + const formFieldRequiredMarkerSelector = ".mat-form-field-required-marker"; + + hostComponent.placeholder = "this is a placeholder"; + hostFixture.detectChanges(); + + expect(hostFixture.debugElement.query(By.css(formFieldLabelSelector))).toBeTruthy(); + expect(hostFixture.debugElement.query(By.css(formFieldRequiredMarkerSelector))).toBeFalsy(); + + hostComponent.required = ""; // coerced to true + hostFixture.detectChanges(); + + expect(hostFixture.debugElement.query(By.css(formFieldLabelSelector))).toBeTruthy(); + expect(hostFixture.debugElement.query(By.css(formFieldRequiredMarkerSelector))).toBeTruthy(); + + hostComponent.required = false; + hostFixture.detectChanges(); + + expect(hostFixture.debugElement.query(By.css(formFieldLabelSelector))).toBeTruthy(); + expect(hostFixture.debugElement.query(By.css(formFieldRequiredMarkerSelector))).toBeFalsy(); + + hostComponent.required = true; + hostFixture.detectChanges(); + + expect(hostFixture.debugElement.query(By.css(formFieldLabelSelector))).toBeTruthy(); + expect(hostFixture.debugElement.query(By.css(formFieldRequiredMarkerSelector))).toBeTruthy(); + }); + }); + + describe("using formControl", () => { + let hostComponent: TestHostFormControlComponent; + let hostFixture: ComponentFixture; + + beforeEach(() => { + hostFixture = TestBed.createComponent(TestHostFormControlComponent); + hostComponent = hostFixture.componentInstance; + hostFixture.detectChanges(); // trigger initial data binding + + component = hostComponent.dateTimePickerComponent; + }); + + describe("on initialization", () => { + it("should set internal component properties", () => { + expect(hostFixture).toBeDefined(); + expect(component).toBeDefined(); + expect(component.logger).toBeTruthy(); + }); + + it("should NOT have any inputs set", () => { + expect(component.value).toBeNull(); + expect(component.dateFilter).toBeUndefined(); + expect(component.disabled).toBe(false); + expect(component.required).toBe(false); + expect(component.max).toBeUndefined(); + expect(component.min).toBeUndefined(); + expect(component.pickerId).toBeUndefined(); + expect(component.pickerName).toBeUndefined(); + expect(component.placeholder).toEqual(""); + expect(component.dateMask).toBeUndefined(); + expect(component.timeMask).toBe(DEFAULT_TIME_MASK_CONFIG); + expect(component.dateTimeChange).toBeDefined(); + }); + + it("should NOT have any validation errors if the model value is empty", () => { + expect(hostComponent.formControl.value).toBeNull(); + expect(hostComponent.formControl.errors).toBeNull(); + expect(component.dateTimeFormGroup.errors).toBeNull(); + expect(component.dateTimeFormGroup.controls["date"].errors).toBeNull(); + expect(component.dateTimeFormGroup.controls["time"].errors).toBeNull(); + }); + }); + + describe("datepicker properties", () => { + let mockObserver: SpyObj>; + + beforeEach(() => { + mockObserver = createSpyObj>("observerSpy", ["next", "error", "complete"]); + }); + + it("should be set correctly according to the given inputs and WITHOUT triggering a 'valueChange' event", () => { + hostComponent.formControl.valueChanges.subscribe(mockObserver); + + hostComponent.pickerId = "test-id"; + hostComponent.pickerName = "test-name"; + const minDate = new Date(2018, 6, 1); + hostComponent.minDate = minDate; + const maxDate = new Date(2018, 6, 2); + hostComponent.maxDate = maxDate; + /// hostComponent.required = true; // IMPORTANT: toggling the 'required' property triggers a 'valueChange' event fired by the Angular 'required' validator (see Validators.required) + hostFixture.detectChanges(); + + expect(hostFixture.nativeElement.querySelector("mat-datepicker#test-id")).toBeTruthy(); + expect(hostFixture.nativeElement.querySelector("input#test-id-input")).toBeTruthy(); // the "-input" suffix is appended to the pickerId + expect(hostFixture.nativeElement.querySelector("input[name='test-name']")).toBeTruthy(); + /// expect(hostFixture.nativeElement.querySelector("input#test-id-time-input[required]")).toBeTruthy(); // see comment above about Angular 'required' validator + expect(component.datePicker.min).not.toBeNull(); + expect(component.datePicker.min).toEqual(minDate); + expect(component.datePicker.max).not.toBeNull(); + expect(component.datePicker.max).toEqual(maxDate); + + expect(mockObserver.next).not.toHaveBeenCalled(); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + }); + + it("the datepicker should be disabled when the form control is disabled AND it should trigger a 'valueChange' event ONLY IF the 'emitEvent' option is enabled", () => { + hostComponent.formControl.valueChanges.subscribe(mockObserver); + + hostComponent.formControl.disable({ emitEvent: false }); + hostFixture.detectChanges(); + + expect(component.datePicker.disabled).toBe(true); + + hostComponent.formControl.enable({ emitEvent: false }); + hostFixture.detectChanges(); + + expect(component.datePicker.disabled).toBe(false); + expect(mockObserver.next).not.toHaveBeenCalled(); // because the 'emitEvent' is false + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + + hostComponent.formControl.disable(); // 'emitEvent' true by default + hostFixture.detectChanges(); + + expect(component.datePicker.disabled).toBe(true); + expect(mockObserver.next).toHaveBeenCalledTimes(1); + mockObserver.next.calls.reset(); + + hostComponent.formControl.enable(); // 'emitEvent' true by default + hostFixture.detectChanges(); + + expect(component.datePicker.disabled).toBe(false); + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + }); + + it("the datepicker value should be the same as the date part of the form control's value", () => { + const date = new Date(2018, 6, 3, 10, 15, 20); + hostComponent.formControl.setValue(date); + hostFixture.detectChanges(); + expect(component.datePicker.value).not.toBeNull(); + expect(component.datePicker.value).toEqual(new Date(date.getFullYear(), date.getMonth(), date.getDate())); + }); + }); + + describe("date mask", () => { + it("the dateMask should be passed 'as is' to the internal datepicker", () => { + hostComponent.dateMask = true; + hostFixture.detectChanges(); + expect(component.dateMask).toBe(true); + + hostComponent.dateMask = ""; + hostFixture.detectChanges(); + expect(component.dateMask).toBe(""); + + hostComponent.dateMask = false; + hostFixture.detectChanges(); + expect(component.dateMask).toBe(false); + + const dateMask: StarkDatePickerMaskConfig = { format: "DD-MM-YYYY" }; + hostComponent.dateMask = dateMask; + hostFixture.detectChanges(); + expect(component.dateMask).toBe(dateMask); + }); + }); + + describe("date filter", () => { + it("the dateFilter should be passed 'as is' to the internal datepicker", () => { + expect(component.dateFilter).toBeUndefined(); + + const filterFn: any = (date: Date): boolean => { + const day: number = date.getDay(); + return day === 3; + }; + hostComponent.dateFilter = filterFn; + hostFixture.detectChanges(); + expect(component.dateFilter).toBe(filterFn); + + hostComponent.dateFilter = "OnlyWeekdays"; + hostFixture.detectChanges(); + expect(component.dateFilter).toBe("OnlyWeekdays"); + + hostComponent.dateFilter = "OnlyWeekends"; + hostFixture.detectChanges(); + expect(component.dateFilter).toBe("OnlyWeekends"); + }); + }); + + describe("time input properties", () => { + let mockObserver: SpyObj>; + + beforeEach(() => { + mockObserver = createSpyObj>("observerSpy", ["next", "error", "complete"]); + }); + + it("should be set correctly according to the specified inputs and WITHOUT triggering a 'valueChange' event", () => { + hostComponent.formControl.valueChanges.subscribe(mockObserver); + + hostComponent.pickerId = "test-id"; + hostComponent.pickerName = "test-name"; + /// hostComponent.required = true; // IMPORTANT: toggling the 'required' property triggers a 'valueChange' event fired by the Angular 'required' validator (see Validators.required) + hostFixture.detectChanges(); + + expect(hostFixture.debugElement.query(By.css(timeInputSelector))).toBeTruthy(); + expect(hostFixture.nativeElement.querySelector("input#test-id-time-input")).toBeTruthy(); // the "-time-input" suffix is appended to the pickerId + expect(hostFixture.nativeElement.querySelector("input[name='test-name-time-input']")).toBeTruthy(); + /// expect(hostFixture.nativeElement.querySelector("input#test-id-time-input[required]")).toBeTruthy(); // see comment above about Angular 'required' validator + + expect(mockObserver.next).not.toHaveBeenCalled(); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + }); + + it("the time input should be disabled when the form control is disabled AND it should trigger a 'valueChange' event ONLY IF the 'emitEvent' option is enabled", () => { + const timeInputDebugElement = hostFixture.debugElement.query(By.css(timeInputSelector)); + expect(timeInputDebugElement).toBeTruthy(); + hostComponent.formControl.valueChanges.subscribe(mockObserver); + + hostComponent.formControl.disable({ emitEvent: false }); + hostFixture.detectChanges(); + + expect(timeInputDebugElement.properties["disabled"]).toBe(true); + expect(component.timeInput.nativeElement.disabled).toBe(true); + // + hostComponent.formControl.enable({ emitEvent: false }); + hostFixture.detectChanges(); + + expect(timeInputDebugElement.properties["disabled"]).toBe(false); + expect(component.timeInput.nativeElement.disabled).toBe(false); + expect(mockObserver.next).not.toHaveBeenCalled(); // because the 'emitEvent' is false + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + + hostComponent.formControl.disable(); // 'emitEvent' true by default + hostFixture.detectChanges(); + + expect(timeInputDebugElement.properties["disabled"]).toBe(true); + expect(component.timeInput.nativeElement.disabled).toBe(true); + expect(mockObserver.next).toHaveBeenCalledTimes(1); + mockObserver.next.calls.reset(); + // + hostComponent.formControl.enable(); // 'emitEvent' true by default + hostFixture.detectChanges(); + + expect(timeInputDebugElement.properties["disabled"]).toBe(false); + expect(component.timeInput.nativeElement.disabled).toBe(false); + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + }); + + it("the time input value should be the same as the time part of the form control's value", () => { + const date = new Date(2018, 6, 3, 10, 15, 20); + hostComponent.formControl.setValue(date); + hostFixture.detectChanges(); + expect(component.timeInput.nativeElement.value).not.toBeNull(); + expect(component.timeInput.nativeElement.value).toEqual(`${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`); + }); + }); + + describe("time mask", () => { + it("the timeMask should be passed 'as is' to the internal time input only if it is a valid mask config or use the DEFAULT_TIME_MASK otherwise", () => { + hostComponent.timeMask = true; // invalid mask + hostFixture.detectChanges(); + expect(component.timeMask).toBe(DEFAULT_TIME_MASK_CONFIG); + + hostComponent.timeMask = ""; // invalid mask + hostFixture.detectChanges(); + expect(component.timeMask).toBe(DEFAULT_TIME_MASK_CONFIG); + + hostComponent.timeMask = false; // invalid mask + hostFixture.detectChanges(); + expect(component.timeMask).toBe(DEFAULT_TIME_MASK_CONFIG); + + const timeMask: StarkTimestampMaskConfig = { format: "HH:mm" }; + hostComponent.timeMask = timeMask; + hostFixture.detectChanges(); + expect(component.timeMask).toBe(timeMask); + }); + }); + + describe("date time selection", () => { + let mockObserver: SpyObj>; + + beforeEach(() => { + mockObserver = createSpyObj>("observerSpy", ["next", "error", "complete"]); + }); + + it("the date time should be correctly set and emit the new value in the form control's 'valueChange' observable", () => { + hostComponent.formControl.valueChanges.subscribe(mockObserver); + + const date = new Date(2018, 6, 7); + component.datePicker.picker.select(moment(date)); // select a date in the internal date picker + hostFixture.detectChanges(); + + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.next).toHaveBeenCalledWith(date); + mockObserver.next.calls.reset(); + + const dateTime = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 15, 30, 45); + // type the time in the time input + component.timeInput.nativeElement.value = `${dateTime.getHours()}:${dateTime.getMinutes()}:${dateTime.getSeconds()}`; + // more verbose way to create and trigger an event (the only way it works in IE) + // https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Creating_and_triggering_events + const inputEvent: Event = document.createEvent("Event"); + inputEvent.initEvent("input", true, true); + component.timeInput.nativeElement.dispatchEvent(inputEvent); + const changeEvent = document.createEvent("Event"); + changeEvent.initEvent("change", true, true); + component.timeInput.nativeElement.dispatchEvent(changeEvent); + hostFixture.detectChanges(); + + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.next).toHaveBeenCalledWith(dateTime); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + }); + + it("the date part should be set to the default date if it is not defined and emit the new value in the form control's 'valueChange' observable", () => { + hostComponent.formControl.valueChanges.subscribe(mockObserver); + + const time = [15, 15, 15]; // later converted to "XX:XX:XX" (the default format is HH:mm:ss) + const expectedDateTime = new Date( + component.defaultDate.getFullYear(), // default date + component.defaultDate.getMonth(), + component.defaultDate.getDate(), + ...time // + given time + ); + // type the time in the time input + component.timeInput.nativeElement.value = time.join(":"); // to have the time string in the format "XX:XX:XX" + // more verbose way to create and trigger an event (the only way it works in IE) + // https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Creating_and_triggering_events + const inputEvent: Event = document.createEvent("Event"); + inputEvent.initEvent("input", true, true); + component.timeInput.nativeElement.dispatchEvent(inputEvent); + const changeEvent = document.createEvent("Event"); + changeEvent.initEvent("change", true, true); + component.timeInput.nativeElement.dispatchEvent(changeEvent); + hostFixture.detectChanges(); + + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.next).not.toHaveBeenCalledWith(component.defaultDate); + expect(mockObserver.next).toHaveBeenCalledWith(expectedDateTime); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + }); + + it("the time part should be set to the default time if it is not defined and emit the new value in the form control's 'valueChange' observable", () => { + hostComponent.formControl.valueChanges.subscribe(mockObserver); + + const date = new Date(2018, 6, 7, 15, 15, 15, 155); + const expectedDateTime = new Date( + date.getFullYear(), // given date + date.getMonth(), + date.getDate(), + component.defaultTime.getHours(), // + default time + component.defaultTime.getMinutes(), + component.defaultTime.getSeconds(), + component.defaultTime.getMilliseconds() + ); + component.datePicker.picker.select(moment(date)); // select a date in the internal date picker + hostFixture.detectChanges(); + + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.next).not.toHaveBeenCalledWith(date); + expect(mockObserver.next).toHaveBeenCalledWith(expectedDateTime); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + }); + }); + }); + + describe("NOT using formControl", () => { + let hostComponent: TestHostComponent; + let hostFixture: ComponentFixture; + + beforeEach(() => { + hostFixture = TestBed.createComponent(TestHostComponent); + hostComponent = hostFixture.componentInstance; + hostFixture.detectChanges(); // trigger initial data binding + + component = hostComponent.dateTimePickerComponent; + }); + + describe("on initialization", () => { + /* tslint:disable-next-line:no-identical-functions */ + it("should set internal component properties", () => { + expect(hostFixture).toBeDefined(); + expect(component).toBeDefined(); + expect(component.logger).toBeTruthy(); + }); + + it("should NOT have any inputs set", () => { + expect(component.value).toBeUndefined(); + expect(component.dateFilter).toBeUndefined(); + expect(component.disabled).toBe(false); + expect(component.required).toBe(false); + expect(component.max).toBeUndefined(); + expect(component.min).toBeUndefined(); + expect(component.pickerId).toBeUndefined(); + expect(component.pickerName).toBeUndefined(); + expect(component.placeholder).toEqual(""); + expect(component.dateMask).toBeUndefined(); + expect(component.timeMask).toBe(DEFAULT_TIME_MASK_CONFIG); + expect(component.dateTimeChange).toBeDefined(); + }); + }); + + describe("datepicker properties", () => { + let mockObserver: SpyObj>; + + beforeEach(() => { + mockObserver = createSpyObj>("observerSpy", ["next", "error", "complete"]); + }); + + it("should be set correctly according to the given inputs and WITHOUT emitting a 'dateTimeChange' event", () => { + spyOn(hostComponent, "onValueChange"); + component.dateTimeChange.subscribe(mockObserver); + + hostComponent.pickerId = "test-id"; + hostComponent.pickerName = "test-name"; + const minDate = new Date(2018, 6, 1); + hostComponent.minDate = minDate; + const maxDate = new Date(2018, 6, 2); + hostComponent.maxDate = maxDate; + hostComponent.required = true; + hostFixture.detectChanges(); + + expect(hostFixture.nativeElement.querySelector("mat-datepicker#test-id")).toBeTruthy(); + expect(hostFixture.nativeElement.querySelector("input#test-id-input")).toBeTruthy(); // the "-input" suffix is appended to the pickerId + expect(hostFixture.nativeElement.querySelector("input[name='test-name']")).toBeTruthy(); + expect(hostFixture.nativeElement.querySelector("input#test-id-time-input[required]")).toBeTruthy(); + expect(component.datePicker.min).not.toBeNull(); + expect(component.datePicker.min).toEqual(minDate); + expect(component.datePicker.max).not.toBeNull(); + expect(component.datePicker.max).toEqual(maxDate); + + expect(hostComponent.onValueChange).not.toHaveBeenCalled(); + expect(mockObserver.next).not.toHaveBeenCalled(); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + }); + + it("the datepicker should be disabled when 'disabled' is true and it should NOT emit a 'dateTimeChange' event", () => { + spyOn(hostComponent, "onValueChange"); + component.dateTimeChange.subscribe(mockObserver); + + hostComponent.isDisabled = true; + hostFixture.detectChanges(); + expect(component.datePicker.disabled).toBe(true); + + hostComponent.isDisabled = false; + hostFixture.detectChanges(); + expect(component.datePicker.disabled).toBe(false); + + expect(hostComponent.onValueChange).not.toHaveBeenCalled(); + expect(mockObserver.next).not.toHaveBeenCalled(); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + }); + + it("the datepicker value should be the same as the date part of the 'value' input and it should not emit a 'dateChange' event", () => { + spyOn(hostComponent, "onValueChange"); + component.dateTimeChange.subscribe(mockObserver); + + const date = new Date(2018, 6, 3, 10, 15, 20); + hostComponent.value = date; + hostFixture.detectChanges(); + expect(component.datePicker.value).not.toBeNull(); + expect(component.datePicker.value).toEqual(new Date(date.getFullYear(), date.getMonth(), date.getDate())); + + expect(hostComponent.onValueChange).not.toHaveBeenCalled(); + expect(mockObserver.next).not.toHaveBeenCalled(); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + }); + }); + + describe("time input properties", () => { + let mockObserver: SpyObj>; + + beforeEach(() => { + mockObserver = createSpyObj>("observerSpy", ["next", "error", "complete"]); + }); + + it("should be set correctly according to the specified inputs and WITHOUT emitting a 'dateTimeChange' event", () => { + spyOn(hostComponent, "onValueChange"); + component.dateTimeChange.subscribe(mockObserver); + + hostComponent.pickerId = "test-id"; + hostComponent.pickerName = "test-name"; + hostFixture.detectChanges(); + + expect(hostFixture.debugElement.query(By.css(timeInputSelector))).toBeTruthy(); + expect(hostFixture.nativeElement.querySelector("input#test-id-time-input")).toBeTruthy(); // the "-time-input" suffix is appended to the pickerId + expect(hostFixture.nativeElement.querySelector("input[name='test-name-time-input']")).toBeTruthy(); + + expect(hostComponent.onValueChange).not.toHaveBeenCalled(); + expect(mockObserver.next).not.toHaveBeenCalled(); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + }); + + it("the time input should be disabled when 'disabled' is true and it should NOT emit a 'dateTimeChange' event", () => { + spyOn(hostComponent, "onValueChange"); + component.dateTimeChange.subscribe(mockObserver); + + const timeInputDebugElement = hostFixture.debugElement.query(By.css(timeInputSelector)); + expect(timeInputDebugElement).toBeTruthy(); + + hostComponent.isDisabled = true; + hostFixture.detectChanges(); + expect(timeInputDebugElement.properties["disabled"]).toBe(true); + expect(component.timeInput.nativeElement.disabled).toBe(true); + // + hostComponent.isDisabled = false; + hostFixture.detectChanges(); + expect(timeInputDebugElement.properties["disabled"]).toBe(false); + expect(component.timeInput.nativeElement.disabled).toBe(false); + + expect(hostComponent.onValueChange).not.toHaveBeenCalled(); + expect(mockObserver.next).not.toHaveBeenCalled(); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + }); + + it("the time input value should be the same as the time part of 'value' and it should not emit a 'dateChange' event", () => { + spyOn(hostComponent, "onValueChange"); + component.dateTimeChange.subscribe(mockObserver); + + const date = new Date(2018, 6, 3, 10, 15, 20); + hostComponent.value = date; + hostFixture.detectChanges(); + expect(component.timeInput.nativeElement.value).not.toBeNull(); + expect(component.timeInput.nativeElement.value).toEqual(`${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`); + + expect(hostComponent.onValueChange).not.toHaveBeenCalled(); + expect(mockObserver.next).not.toHaveBeenCalled(); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + }); + }); + + describe("date changes", () => { + let mockObserver: SpyObj>; + + beforeEach(() => { + mockObserver = createSpyObj>("observerSpy", ["next", "error", "complete"]); + }); + + it("should emit the new value in the 'dateChange' output", () => { + spyOn(hostComponent, "onValueChange").and.callThrough(); + component.dateTimeChange.subscribe(mockObserver); + + const date = new Date(2018, 6, 3); + component.datePicker.picker.select(moment(date)); // select a date in the internal date picker + hostFixture.detectChanges(); + + expect(hostComponent.onValueChange).toHaveBeenCalledTimes(1); + expect(hostComponent.onValueChange).toHaveBeenCalledWith(date); + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.next).toHaveBeenCalledWith(date); + (hostComponent.onValueChange).calls.reset(); + mockObserver.next.calls.reset(); + + const dateTime = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 15, 30, 45); + component.timeInput.nativeElement.value = `${dateTime.getHours()}:${dateTime.getMinutes()}:${dateTime.getSeconds()}`; + // more verbose way to create and trigger an event (the only way it works in IE) + // https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Creating_and_triggering_events + const inputEvent: Event = document.createEvent("Event"); + inputEvent.initEvent("input", true, true); + component.timeInput.nativeElement.dispatchEvent(inputEvent); + const changeEvent = document.createEvent("Event"); + changeEvent.initEvent("change", true, true); + component.timeInput.nativeElement.dispatchEvent(changeEvent); + hostFixture.detectChanges(); + + expect(hostComponent.onValueChange).toHaveBeenCalledTimes(1); + expect(hostComponent.onValueChange).toHaveBeenCalledWith(dateTime); + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.next).toHaveBeenCalledWith(dateTime); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/stark-ui/src/modules/date-time-picker/components/date-time-picker.component.ts b/packages/stark-ui/src/modules/date-time-picker/components/date-time-picker.component.ts new file mode 100644 index 0000000000..801ea4602f --- /dev/null +++ b/packages/stark-ui/src/modules/date-time-picker/components/date-time-picker.component.ts @@ -0,0 +1,735 @@ +/* tslint:disable:no-null-keyword */ +import { + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + HostBinding, + Inject, + Injector, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + Renderer2, + SimpleChanges, + Type, + ViewChild +} from "@angular/core"; +import { + AbstractControl, + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + NgControl, + ValidationErrors, + Validator, + ValidatorFn, + Validators +} from "@angular/forms"; +import { FocusMonitor, FocusOrigin } from "@angular/cdk/a11y"; +import { coerceBooleanProperty } from "@angular/cdk/coercion"; +import { MatFormField, MatFormFieldControl } from "@angular/material/form-field"; +import moment from "moment"; +import { Subject, Subscription } from "rxjs"; +import { TranslateService } from "@ngx-translate/core"; +import { Validator as ClassValidator } from "class-validator"; +import { STARK_LOGGING_SERVICE, StarkLoggingService } from "@nationalbankbelgium/stark-core"; +import { StarkTimestampMaskConfig } from "../../input-mask-directives/directives/timestamp-mask-config.intf"; +import { + StarkDatePickerComponent, + StarkDatePickerFilter, + StarkDatePickerMaskConfig +} from "../../date-picker/components/date-picker.component"; +import { AbstractStarkUiComponent } from "../../../common/classes/abstract-component"; + +/** + * Default TimeMask configuration + */ +export const DEFAULT_TIME_MASK_CONFIG: StarkTimestampMaskConfig = { format: "HH:mm:ss" }; + +/** + * Name of the component + */ +const componentName = "stark-date-time-picker"; + +/** + * Component to select a date and a time together + */ +@Component({ + selector: "stark-date-time-picker", + templateUrl: "./date-time-picker.component.html", + providers: [ + { + provide: NG_VALIDATORS, + useExisting: StarkDateTimePickerComponent, + multi: true + }, + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: StarkDateTimePickerComponent + }, + { + // This implementation has been made thanks to the official documentation. + // See: https://material.angular.io/guide/creating-a-custom-form-field-control + provide: MatFormFieldControl, + useExisting: StarkDateTimePickerComponent + } + ], + // We need to use host instead of @HostBinding: https://github.com/NationalBankBelgium/stark/issues/664 + host: { + class: componentName + } +}) +export class StarkDateTimePickerComponent extends AbstractStarkUiComponent + implements MatFormFieldControl, ControlValueAccessor, Validator, OnInit, OnChanges, OnDestroy { + /** + * Part of {@link MatFormFieldControl} API + * @ignore + * @internal + */ + private static nextId = 0; + + /** + * Part of {@link MatFormFieldControl} API + * @ignore + */ + @HostBinding() + public id = `stark-date-time-picker${StarkDateTimePickerComponent.nextId++}`; + + /** + * Part of {@link MatFormFieldControl} API + * @ignore + */ + @HostBinding("attr.aria-describedby") + public describedBy = ""; + + /** + * Part of {@link MatFormFieldControl} API + * @ignore + */ + @HostBinding("class.floating") + public get shouldLabelFloat(): boolean { + return this.focused || !this.empty; + } + + /** + * Source date to bound to the DateTimePicker + */ + @Input() + public get value(): Date | null { + return this._value; + } + + public set value(value: Date | null) { + if (value) { + this.dateTimeFormGroup.setValue({ + date: new Date(value.getFullYear(), value.getMonth(), value.getDate()), + time: this.constructTimeStringFromDate(value) + }); + } + this._value = value; + this.cdRef.detectChanges(); // to refresh all the validations in the internal date picker + this.stateChanges.next(); + } + + /** + * @ignore + * @internal + */ + private _value: Date | null = null; + + /** + * Placeholder / label for DateTimePicker + */ + @Input() + public get placeholder(): string { + return this._placeholder; + } + + public set placeholder(value: string) { + this.originalPlaceholder = value || ""; + // Handle translation internally because mat-form-field uses the value of `@Input public placeholder` to display the label / placeholder + this._placeholder = this.originalPlaceholder ? this.translateService.instant(this.originalPlaceholder) : this.originalPlaceholder; + this.stateChanges.next(); + } + + /** + * @ignore + * @internal + */ + private _placeholder = ""; + + /** + * Determines if DateTimePicker is required + */ + @Input() + public get required(): boolean { + return this._required; + } + + public set required(isRequired: boolean) { + this._required = coerceBooleanProperty(isRequired); + if (this._required) { + this.dateTimeFormGroup.controls["date"].setValidators([Validators.required]); + this.dateTimeFormGroup.controls["time"].setValidators([Validators.required]); + } else { + this.dateTimeFormGroup.controls["date"].clearValidators(); + this.dateTimeFormGroup.controls["time"].clearValidators(); + } + } + + /** + * @ignore + * @internal + */ + private _required = false; + + /** + * Determines if DateTimePicker is disabled + */ + @Input() + public get disabled(): boolean { + return this._disabled; + } + + public set disabled(isDisabled: boolean) { + this._disabled = coerceBooleanProperty(isDisabled); + + if (isDisabled) { + this.dateTimeFormGroup.disable(); + } else { + this.dateTimeFormGroup.enable(); + } + } + + /** + * @ignore + * @internal + */ + private _disabled = false; + + /** + * Mask for the time + */ + @Input() + public get timeMask(): StarkTimestampMaskConfig { + return this._timeMask; + } + + public set timeMask(value: StarkTimestampMaskConfig) { + // only valid mask configs are accepted, otherwise the default mask is used + this._timeMask = value && value.hasOwnProperty("format") ? value : DEFAULT_TIME_MASK_CONFIG; + } + + /** + * @ignore + * @internal + */ + private _timeMask: StarkTimestampMaskConfig = DEFAULT_TIME_MASK_CONFIG; + + /** + * Input for {@link StarkDatePickerComponent} + */ + @Input() + public pickerId = ""; + + /** + * Input for {@link StarkDatePickerComponent} + */ + @Input() + public pickerName = ""; + + /** + * Input for {@link StarkDatePickerComponent} + */ + @Input() + public dateFilter?: StarkDatePickerFilter; + + /** + * Input for {@link StarkDatePickerComponent} + */ + @Input() + public dateMask?: StarkDatePickerMaskConfig; + + /** + * Input for {@link StarkDatePickerComponent} + */ + @Input() + public max?: Date; + + /** + * Input for {@link StarkDatePickerComponent} + */ + @Input() + public min?: Date; + + /** + * Output that will emit a specific date whenever the selection has changed + */ + @Output() + public readonly dateTimeChange = new EventEmitter(); + + /** + * Reference to the time input embedded in this component + */ + @ViewChild("timeInput") + public timeInput!: ElementRef; + + /** + * Reference to the Stark date picker embedded in this component + */ + @ViewChild(StarkDatePickerComponent) + public datePicker!: StarkDatePickerComponent; + + /** + * @ignore + * @internal + * The registered callback function called when an input event occurs on the input element. + */ + private _onChange: (_: Date | null) => void = (_: Date | null) => { + /*noop*/ + }; + + /** + * @ignore + * @internal + * The registered callback function called when a blur event occurs on the input element. + */ + private _onTouched: () => void = () => { + /*noop*/ + }; + + /** + * Part of {@link MatFormFieldControl} API + * @ignore + * @internal + */ + public ngControl: NgControl | null = null; + + /** + * Part of {@link MatFormFieldControl} API + * @ignore + * @internal + */ + public controlType = componentName; + + /** + * Part of {@link MatFormFieldControl} API + * @ignore + * @internal + */ + public stateChanges = new Subject(); + + /** + * Part of {@link MatFormFieldControl} API + * @ignore + */ + public focused = false; + + /** + * Part of {@link MatFormFieldControl} API + * @ignore + * @internal + */ + public get empty(): boolean { + // IMPORTANT: we need to get the 'raw value' because we also need the values from the disabled controls! + return !this.dateTimeFormGroup.getRawValue().date && !this.dateTimeFormGroup.getRawValue().time; + } + + /** + * Part of {@link MatFormFieldControl} API + * @ignore + * @internal + */ + public get errorState(): boolean { + // the control can be in an error state as long as one of these conditions is met: + // 1) the user has interacted with either the datepicker or the time input + // 2) the control is programmatically marked as 'touched' or 'dirty' + const newErrorState = + this.ngControl !== null && + this.ngControl.control !== null && + (this.dateTimeFormGroup.controls["date"].touched || + this.dateTimeFormGroup.controls["time"].touched || + !!this.ngControl.touched || + !!this.ngControl.dirty) && + (!!this.ngControl.invalid || !!this.datePicker.validate(this.ngControl.control)); + + // IMPORTANT: emit a state change when the errorState changes + // This is needed to force the MatFormFieldControl to refresh and render the MatError's + if (this._errorState !== newErrorState) { + this._errorState = newErrorState; + this.stateChanges.next(); + } + + return this._errorState; + } + + /** + * The current error state + * @ignore + * @internal + */ + public _errorState = false; + + /** + * @ignore + * @internal + * Original placeholder translation key to keep in memory to translate again when language changes. + */ + private originalPlaceholder = ""; + + /** + * @ignore + * @internal + */ + private translateOnLangChangeSubscription!: Subscription; + + /** + * @ignore + */ + public dateTimeFormGroup: FormGroup; + + /** + * Angular validator to check whether the component's date is earlier than the given minDate + * @ignore + * @internal + * @param minDate - Minimum date to validate the component's current date + */ + private _starkMinDateValidator(minDate: Date): ValidatorFn { + // for the minDate, we should discard the time (we set it to the minimum possible value: 00:00:00:000) + // const normalizedMinDate = new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate(), 0, 0, 0, 0); + const validator = new ClassValidator(); + + return (_control: AbstractControl): ValidationErrors | null => { + const controlValue = this.constructDateTime(); + + return controlValue && !validator.minDate(controlValue, minDate) + ? { + starkMinDateTime: { + min: minDate.toISOString(), + actual: controlValue.toISOString() + } + } + : null; + }; + } + + /** + * Angular validator to check whether the component's date is earlier than the given minDate + * @ignore + * @internal + * @param maxDate - Maximum date to validate the component's current date + */ + private _starkMaxDateValidator(maxDate: Date): ValidatorFn { + // for the maxDate, we should discard the time (we set it to the maximum possible value: 23:59:59:999) + // const normalizedMaxDate = new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate(), 23, 59, 59, 999); + const validator = new ClassValidator(); + + return (_control: AbstractControl): ValidationErrors | null => { + const controlValue = this.constructDateTime(); + + return controlValue && !validator.maxDate(controlValue, maxDate) + ? { + starkMaxDateTime: { + max: maxDate.toISOString(), + actual: controlValue.toISOString() + } + } + : null; + }; + } + + /** + * Default date in case no date is defined + */ + public defaultDate = new Date(0); + + /** + * Default time (in a Date object form) in case no time is defined. + * + * IMPORTANT: Although it is a Date object, only the time part will be used. + */ + public defaultTime = new Date(2019, 0, 1, 0, 0, 0, 0); + + /** + * Class constructor + * @param logger - The logger of the application + * @param _fb - The Angular Form builder + * @param _fm - The Angular Material Focus Monitor service + * @param elementRef - Reference to the DOM element where this directive is applied to. + * @param renderer - Angular Renderer wrapper for DOM manipulations. + * @param matFormField - The parent MatFormField directive surrounding this component + * @param injector - The Injector of the application + * @param cdRef - Reference to the change detector attached to this component + * @param translateService - The Translate Service of the application + */ + public constructor( + @Inject(STARK_LOGGING_SERVICE) public logger: StarkLoggingService, + private _fb: FormBuilder, + private _fm: FocusMonitor, + elementRef: ElementRef, + renderer: Renderer2, + private matFormField: MatFormField, + private injector: Injector, + private cdRef: ChangeDetectorRef, + private translateService: TranslateService + ) { + super(renderer, elementRef); + + this.dateTimeFormGroup = this._fb.group({ + date: new FormControl(undefined), + time: new FormControl(undefined) + }); + + this._fm.monitor(elementRef, true).subscribe((origin: FocusOrigin) => { + // when the element is blurred, the emitted 'origin' is null + if (origin === null) { + this.dateTimeFormGroup.controls["date"].markAsTouched(); + this.dateTimeFormGroup.controls["time"].markAsTouched(); + } + + this.focused = !!origin && !this.disabled; + this.stateChanges.next(); + }); + } + + /** + * Component lifecycle hook + */ + public ngOnInit(): void { + this.ngControl = this.injector.get(>NgControl, null); + + if (this.ngControl !== null) { + this.ngControl.valueAccessor = this; + } + + // the parent node of the _connectionContainerRef is the 'div.form-field-wrapper' which defines the final width of the form field + const parentNode: HTMLElement = this.renderer.parentNode(this.matFormField._connectionContainerRef.nativeElement); + this.renderer.addClass(parentNode, `${componentName}-form-field-wrapper`); + + this.translateOnLangChangeSubscription = this.translateService.onLangChange.subscribe(() => { + // re-assign the placeholder to refresh the translation (see 'placeholder' setter) + this.placeholder = this.originalPlaceholder; + }); + + super.ngOnInit(); + this.logger.debug(componentName + ": component initialized"); + } + + /** + * Component lifecycle hook + */ + public ngOnChanges(changes: SimpleChanges): void { + if (changes["min"] || changes["max"]) { + const validators: ValidatorFn[] = []; + if (this.min) { + validators.push(this._starkMinDateValidator(this.min)); + } + if (this.max) { + validators.push(this._starkMaxDateValidator(this.max)); + } + this.dateTimeFormGroup.setValidators(validators); + } + + if (changes["max"] || changes["min"] || changes["required"]) { + this.cdRef.detectChanges(); + // IMPORTANT: the '_onValidatorChange()' callback from Validator API should not be called here to update the validity of the control because it triggers a valueChange event! + // therefore we call 'updateValueAndValidity()' manually on the control instead and without emitting the valueChanges event :) + if (this.ngControl && this.ngControl.control) { + this.ngControl.control.updateValueAndValidity({ emitEvent: false }); + } + this.stateChanges.next(); + } + } + + /** + * Component lifecycle hook + */ + public ngOnDestroy(): void { + this.stateChanges.complete(); + this._fm.stopMonitoring(this.elementRef.nativeElement); + + if (this.translateOnLangChangeSubscription) { + this.translateOnLangChangeSubscription.unsubscribe(); + } + } + + /** + * Part of {@link MatFormFieldControl} API + * @ignore + * @internal + */ + public setDescribedByIds(ids: string[]): void { + this.describedBy = ids.join(" "); + } + + /** + * Part of {@link MatFormFieldControl} API + * @ignore + * @internal + */ + public onContainerClick(event: MouseEvent): void { + if ((event.target as Element).tagName.toLowerCase() !== "input") { + this.elementRef.nativeElement.querySelector("input").focus(); + } + } + + /** + * Part of {@link ControlValueAccessor} API + * Sets the "value" property on the input element. + * @ignore + * @internal + */ + public writeValue(value: Date): void { + this.value = value; + } + + /** + * Part of {@link ControlValueAccessor} API + * Registers a function to be called when the control value changes. + * @ignore + * @internal + */ + public registerOnChange(fn: (val: Date | null) => void): void { + this._onChange = fn; + } + + /** + * Part of {@link ControlValueAccessor} API + * Registers a function to be called when the control is touched. + * @ignore + * @internal + */ + public registerOnTouched(fn: () => void): void { + this._onTouched = fn; + } + + /** + * Part of {@link ControlValueAccessor} API + * Sets the "disabled" property on the input element. + * @ignore + * @internal + */ + public setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + this.stateChanges.next(); + } + + /** + * Part of {@link Validator} API + * @ignore + * @internal + */ + public registerOnValidatorChange(_fn: () => void): void { + // we don't need to keep a reference to the callback function (i.e. in a '_onValidatorChange' property) + // because such callback, when it is called to update the validity of the control, it triggers a valueChange event too! + } + + /** + * Part of {@link Validator} API + * @ignore + * @internal + */ + public validate(control: AbstractControl): ValidationErrors | null { + return { ...this.datePicker.validate(control), ...this.dateTimeFormGroup.errors }; + } + + /** + * @ignore + */ + public onDateTimeChange(): void { + const dateTime = this.constructDateTime(); + + this._onTouched(); + this._onChange(dateTime); + this.dateTimeChange.emit(dateTime); + } + + /** + * Construct the date time model based on the internal form controls for date and time + */ + public constructDateTime(): Date | null { + // IMPORTANT: we need to get the 'raw value' because we also need the values from the disabled controls! + let date: Date = this.dateTimeFormGroup.getRawValue().date; + const time = this.parseTime(this.dateTimeFormGroup.getRawValue().time); + let dateTime: Date | null; + + if (!date && !time) { + dateTime = null; + } else { + if (!date) { + date = this.defaultDate; // default date in case no date is defined + this.dateTimeFormGroup.controls["date"].setValue(date); + } + + let timeValues: number[]; + if (!time) { + timeValues = this.getTimeNumericValuesFromDate(this.defaultTime); // default time values in case no time is defined + // IMPORTANT: when the user is manually clearing the time field, we just keep the default value internally and DON'T set the control value + // otherwise the user will not be able to manually clear entirely the time and type another one because the field would always be reset to '00:00:00' + if (document.activeElement !== this.timeInput.nativeElement) { + this.dateTimeFormGroup.controls["time"].setValue(this.constructTimeStringFromDate(this.defaultTime)); + } + } else { + timeValues = this.getTimeNumericValuesFromDate(time); + } + + dateTime = new Date(date.getFullYear(), date.getMonth(), date.getDate(), ...timeValues); + } + + return dateTime; + } + + /** + * Construct a time string (with the format "HH:mm:ss:SSS") from the given date + */ + public constructTimeStringFromDate(dateTime: Date): string { + return [`0${dateTime.getHours()}`, `0${dateTime.getMinutes()}`, `0${dateTime.getSeconds()}`, `00${dateTime.getMilliseconds()}`] + .map((timePart: string, index: number) => { + const numberOfChars = index === 3 ? 3 : 2; // for milliseconds 3 chars should be taken + return timePart.substring(timePart.length - numberOfChars); + }) + .join(":"); + } + + /** + * Return an array with the numeric values of the time part of the given date + */ + public getTimeNumericValuesFromDate(dateTime: Date): number[] { + return [dateTime.getHours(), dateTime.getMinutes(), dateTime.getSeconds(), dateTime.getMilliseconds()]; + } + + /** + * @ignore + */ + private parseTime(timeValue: string = ""): Date | null { + const time = moment(timeValue, this.timeMask.format); + return time.isValid() ? time.toDate() : null; + } + + /** + * Focus the time input field + */ + public focusTimeInput(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + + this.timeInput.nativeElement.focus(); + } + + /** + * Clears the values of the internal form controls for date and time + */ + public clearDateTime(): void { + this.dateTimeFormGroup.reset(); + this._onTouched(); + this._onChange(null); + this.dateTimeChange.emit(null); + } +} diff --git a/packages/stark-ui/src/modules/date-time-picker/date-time-picker.module.ts b/packages/stark-ui/src/modules/date-time-picker/date-time-picker.module.ts new file mode 100644 index 0000000000..1f0970353b --- /dev/null +++ b/packages/stark-ui/src/modules/date-time-picker/date-time-picker.module.ts @@ -0,0 +1,47 @@ +import { NgModule } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { MatButtonModule } from "@angular/material/button"; +import { MatTooltipModule } from "@angular/material/tooltip"; +import { MatIconModule } from "@angular/material/icon"; +import { TranslateModule, TranslateService } from "@ngx-translate/core"; +import { StarkLocale } from "@nationalbankbelgium/stark-core"; +import { StarkDatePickerModule } from "../date-picker"; +import { StarkInputMaskDirectivesModule } from "../input-mask-directives"; +import { StarkDateTimePickerComponent } from "./components/date-time-picker.component"; +import { translationsEn } from "./assets/translations/en"; +import { translationsFr } from "./assets/translations/fr"; +import { translationsNl } from "./assets/translations/nl"; +import { mergeUiTranslations } from "../../common/translations/merge-translations"; + +@NgModule({ + imports: [ + CommonModule, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatTooltipModule, + FormsModule, + ReactiveFormsModule, + StarkDatePickerModule, + StarkInputMaskDirectivesModule, + TranslateModule + ], + declarations: [StarkDateTimePickerComponent], + exports: [StarkDateTimePickerComponent] +}) +export class StarkDateTimePickerModule { + /** + * Class constructor + * @param translateService - the translation service of the application + */ + public constructor(translateService: TranslateService) { + const english: StarkLocale = { languageCode: "en", translations: translationsEn }; + const french: StarkLocale = { languageCode: "fr", translations: translationsFr }; + const dutch: StarkLocale = { languageCode: "nl", translations: translationsNl }; + mergeUiTranslations(translateService, english, french, dutch); + } +} diff --git a/packages/stark-ui/testing/tsconfig-build.json b/packages/stark-ui/testing/tsconfig-build.json index aa33dfaf13..fbb5effe93 100644 --- a/packages/stark-ui/testing/tsconfig-build.json +++ b/packages/stark-ui/testing/tsconfig-build.json @@ -10,22 +10,22 @@ "../typings" ], "paths": { - "cerialize": ["../../stark-core/node_modules/cerialize"], + "@nationalbankbelgium/stark-core": ["../../../dist/packages/stark-core"], + "@nationalbankbelgium/stark-ui": ["../"], "@ng-idle/*": ["../../stark-core/node_modules/@ng-idle/*"], "@ngrx/*": ["../../stark-core/node_modules/@ngrx/*"], "@ngx-translate/*": ["../../stark-core/node_modules/@ngx-translate/*"], "@uirouter/*": ["../../stark-core/node_modules/@uirouter/*"], + "cerialize": ["../../stark-core/node_modules/cerialize"], + "class-validator": ["../../stark-core/node_modules/class-validator"], "environments/environment": ["../../../dist/packages/stark-core/src/common/environment"], - "moment": ["../../stark-core/node_modules/moment"], - "@nationalbankbelgium/stark-core": ["../../../dist/packages/stark-core"], - "@nationalbankbelgium/stark-ui": ["../"] + "moment": ["../../stark-core/node_modules/moment"] }, "outDir": "../../../dist/packages/stark-ui" }, "files": ["public_api.ts"], - "angularCompilerOptions": { "generateCodeForLibraries": true, "skipMetadataEmit": false, diff --git a/packages/stark-ui/tsconfig.spec.json b/packages/stark-ui/tsconfig.spec.json index aad0078f40..d7e9986bc5 100644 --- a/packages/stark-ui/tsconfig.spec.json +++ b/packages/stark-ui/tsconfig.spec.json @@ -3,15 +3,16 @@ "compilerOptions": { "module": "commonjs", "paths": { + "@nationalbankbelgium/stark-core/testing": ["../../dist/packages/stark-core/testing"], + "@nationalbankbelgium/stark-core": ["../../dist/packages/stark-core"], + "@nationalbankbelgium/stark-ui": ["."], "@ngrx/*": ["../stark-core/node_modules/@ngrx/*"], "@ngx-translate/*": ["../stark-core/node_modules/@ngx-translate/*"], "@uirouter/*": ["../stark-core/node_modules/@uirouter/*"], - "moment": ["../stark-core/node_modules/moment"], + "class-validator": ["../stark-core/node_modules/class-validator"], "lodash-es": ["../stark-core/node_modules/lodash-es"], "lodash-es/*": ["../stark-core/node_modules/lodash-es/*"], - "@nationalbankbelgium/stark-core/testing": ["../../dist/packages/stark-core/testing"], - "@nationalbankbelgium/stark-core": ["../../dist/packages/stark-core"], - "@nationalbankbelgium/stark-ui": ["."] + "moment": ["../stark-core/node_modules/moment"] } }, "files": null, diff --git a/packages/tsconfig.json b/packages/tsconfig.json index b24c0fbb82..ae76f43d73 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -8,14 +8,15 @@ "module": "commonjs", "importHelpers": false, "paths": { - "rxjs/*": ["../node_modules/rxjs/*"], "@angular/*": ["../node_modules/@angular/*"], + "@nationalbankbelgium/stark-*": ["./stark-*"], "@ngrx/*": ["./stark-core/node_modules/@ngrx/*"], "@ngx-translate/*": ["./stark-core/node_modules/@ngx-translate/*"], "@uirouter/*": ["./stark-core/node_modules/@uirouter/*"], - "moment": ["./stark-core/node_modules/moment"], + "class-validator": ["./stark-core/node_modules/class-validator"], "environments/environment": ["./stark-core/src/common/environment"], - "@nationalbankbelgium/stark-*": ["./stark-*"] + "moment": ["./stark-core/node_modules/moment"], + "rxjs/*": ["../node_modules/rxjs/*"] }, "skipDefaultLibCheck": true, "inlineSourceMap": true, diff --git a/showcase/src/app/app-menu.config.ts b/showcase/src/app/app-menu.config.ts index 0b40499262..d827e2ec4c 100644 --- a/showcase/src/app/app-menu.config.ts +++ b/showcase/src/app/app-menu.config.ts @@ -108,6 +108,13 @@ export const APP_MENU_CONFIG: StarkMenuConfig = { isEnabled: true, targetState: "demo-ui.date-range-picker" }, + { + id: "menu-stark-ui-components-date-time-picker", + label: "SHOWCASE.DEMO.DATE_TIME_PICKER.TITLE", + isVisible: true, + isEnabled: true, + targetState: "demo-ui.date-time-picker" + }, { id: "menu-stark-ui-components-dialogs", label: "SHOWCASE.DEMO.DIALOGS.TITLE", diff --git a/showcase/src/app/demo-ui/demo-ui.module.ts b/showcase/src/app/demo-ui/demo-ui.module.ts index 8dec3f5f85..cd1242cad1 100644 --- a/showcase/src/app/demo-ui/demo-ui.module.ts +++ b/showcase/src/app/demo-ui/demo-ui.module.ts @@ -28,6 +28,7 @@ import { StarkCollapsibleModule, StarkDatePickerModule, StarkDateRangePickerModule, + StarkDateTimePickerModule, StarkDialogsModule, StarkDropdownModule, StarkGenericSearchModule, @@ -72,7 +73,8 @@ import { DemoSliderPageComponent, DemoTablePageComponent, DemoToastPageComponent, - DemoTransformInputDirectivePageComponent + DemoTransformInputDirectivePageComponent, + DemoDateTimePickerPageComponent } from "./pages"; import { SharedModule } from "../shared/shared.module"; import { DEMO_STATES } from "./routes"; @@ -119,6 +121,7 @@ import { StarkCollapsibleModule, StarkDatePickerModule, StarkDateRangePickerModule, + StarkDateTimePickerModule, StarkDialogsModule, StarkDropdownModule, StarkGenericSearchModule, @@ -142,6 +145,7 @@ import { DemoCollapsiblePageComponent, DemoDatePickerPageComponent, DemoDateRangePickerPageComponent, + DemoDateTimePickerPageComponent, DemoDialogsPageComponent, DemoDropdownPageComponent, DemoFooterPageComponent, diff --git a/showcase/src/app/demo-ui/pages/date-time-picker/demo-date-time-picker-page.component.scss b/showcase/src/app/demo-ui/pages/date-time-picker/demo-date-time-picker-page.component.scss new file mode 100644 index 0000000000..0a14108675 --- /dev/null +++ b/showcase/src/app/demo-ui/pages/date-time-picker/demo-date-time-picker-page.component.scss @@ -0,0 +1,5 @@ +:host ::ng-deep { + .mat-form-field.expanded .mat-form-field-wrapper.stark-date-time-picker-form-field-wrapper { + margin-bottom: 25px; + } +} diff --git a/showcase/src/app/demo-ui/pages/date-time-picker/demo-date-time-picker-page.component.ts b/showcase/src/app/demo-ui/pages/date-time-picker/demo-date-time-picker-page.component.ts new file mode 100644 index 0000000000..4b8d0f4825 --- /dev/null +++ b/showcase/src/app/demo-ui/pages/date-time-picker/demo-date-time-picker-page.component.ts @@ -0,0 +1,99 @@ +import { Component, Inject, OnDestroy } from "@angular/core"; +import { AbstractControl, FormControl, Validators } from "@angular/forms"; +import { STARK_LOGGING_SERVICE, StarkLoggingService } from "@nationalbankbelgium/stark-core"; +import { StarkTimestampMaskConfig } from "@nationalbankbelgium/stark-ui"; +import { Subscription } from "rxjs"; +import { ReferenceLink } from "../../../shared/components/reference-block"; + +const HOUR_IN_MILLISECONDS = 3600000; +const DAY_IN_MILLISECONDS = 86400000; + +@Component({ + selector: "demo-date-time-picker", + templateUrl: "./demo-date-time-picker-page.html", + styleUrls: ["./demo-date-time-picker-page.component.scss"] +}) +export class DemoDateTimePickerPageComponent implements OnDestroy { + public dateTimeModel?: Date; + public formControl = new FormControl(new Date(), Validators.required); + public isDisabled = false; + + public dateMask = { format: "DD/MM/YYYY" }; + public timeMask: StarkTimestampMaskConfig = { format: "HH:mm" }; + + public minDate = new Date(Date.now() - HOUR_IN_MILLISECONDS); // minDate = one hour earlier + public maxDate = new Date(Date.now() + 30 * DAY_IN_MILLISECONDS); + + public subscription: Subscription; + + public referenceList: ReferenceLink[] = [ + { + label: "Stark Date Time Picker component", + url: "https://stark.nbb.be/api-docs/stark-ui/latest/components/StarkDateTimePickerComponent.html" + } + ]; + + public constructor(@Inject(STARK_LOGGING_SERVICE) private _logger: StarkLoggingService) { + this.subscription = this.formControl.valueChanges.subscribe((value: any) => { + this._logger.debug("formControl: ", value); + }); + } + + public ngOnDestroy(): void { + this.subscription.unsubscribe(); + } + + public onDateTimeChanged(date: Date): void { + this._logger.debug("Date time changed:", date); + } + + public onDateTimeNgModelChanged(): void { + this._logger.debug("ngModel: ", this.dateTimeModel); + } + + public toggleFormControlState(formControl: FormControl): void { + // enable/disable the control without emitting a change event since the value did not change (to avoid unnecessary extra calls!) + if (formControl.disabled) { + formControl.enable({ emitEvent: false }); + } else { + formControl.disable({ emitEvent: false }); + } + } + + public getErrorMessages(control?: AbstractControl): string[] { + const errors: string[] = []; + + if (control && control.errors) { + for (const key of Object.keys(control.errors)) { + switch (key) { + case "required": + errors.push("STARK.VALIDATION.REQUIRED"); + break; + case "matDatepickerFilter": + errors.push("SHOWCASE.DEMO.DATE_TIME_PICKER.ERROR_MESSAGES.INVALID_DATE"); + break; + case "matDatepickerMin": + errors.push("SHOWCASE.DEMO.DATE_TIME_PICKER.ERROR_MESSAGES.MIN_DATE"); + break; + case "matDatepickerMax": + errors.push("SHOWCASE.DEMO.DATE_TIME_PICKER.ERROR_MESSAGES.MAX_DATE"); + break; + case "starkMinDateTime": + errors.push("SHOWCASE.DEMO.DATE_TIME_PICKER.ERROR_MESSAGES.MIN_DATE_TIME"); + break; + case "starkMaxDateTime": + errors.push("SHOWCASE.DEMO.DATE_TIME_PICKER.ERROR_MESSAGES.MAX_DATE_TIME"); + break; + default: + errors.push(key); + } + } + } + + return errors; + } + + public trackItemFn(item: string): string { + return item; + } +} diff --git a/showcase/src/app/demo-ui/pages/date-time-picker/demo-date-time-picker-page.html b/showcase/src/app/demo-ui/pages/date-time-picker/demo-date-time-picker-page.html new file mode 100644 index 0000000000..0a275da240 --- /dev/null +++ b/showcase/src/app/demo-ui/pages/date-time-picker/demo-date-time-picker-page.html @@ -0,0 +1,71 @@ +

SHOWCASE.DEMO.DATE_TIME_PICKER.TITLE

+ +
+

SHOWCASE.DEMO.SHARED.EXAMPLE_VIEWER_LIST

+ + + + + +
+ {{ errorMessage | translate }} +
+
+
+
+ + {{ "SHOWCASE.COMMON.DISABLED" | translate }} + +
+
+ + + + + +
+ {{ errorMessage | translate }} +
+
+
+
+ + {{ "SHOWCASE.COMMON.DISABLED" | translate }} + +
+
+
+ + diff --git a/showcase/src/app/demo-ui/pages/date-time-picker/index.ts b/showcase/src/app/demo-ui/pages/date-time-picker/index.ts new file mode 100644 index 0000000000..1e3cf122fe --- /dev/null +++ b/showcase/src/app/demo-ui/pages/date-time-picker/index.ts @@ -0,0 +1 @@ +export * from "./demo-date-time-picker-page.component"; diff --git a/showcase/src/app/demo-ui/pages/index.ts b/showcase/src/app/demo-ui/pages/index.ts index 5fdda270d0..18eea17c3f 100644 --- a/showcase/src/app/demo-ui/pages/index.ts +++ b/showcase/src/app/demo-ui/pages/index.ts @@ -4,6 +4,7 @@ export * from "./breadcrumb"; export * from "./collapsible"; export * from "./date-picker"; export * from "./date-range-picker"; +export * from "./date-time-picker"; export * from "./dialogs"; export * from "./dropdown"; export * from "./footer"; diff --git a/showcase/src/app/demo-ui/routes.ts b/showcase/src/app/demo-ui/routes.ts index b82bbfe7c8..3798ab71e0 100644 --- a/showcase/src/app/demo-ui/routes.ts +++ b/showcase/src/app/demo-ui/routes.ts @@ -25,7 +25,8 @@ import { DemoSliderPageComponent, DemoTablePageComponent, DemoToastPageComponent, - DemoTransformInputDirectivePageComponent + DemoTransformInputDirectivePageComponent, + DemoDateTimePickerPageComponent } from "./pages"; export const DEMO_STATES: Ng2StateDeclaration[] = [ @@ -78,6 +79,14 @@ export const DEMO_STATES: Ng2StateDeclaration[] = [ }, views: { "@": { component: DemoDateRangePickerPageComponent } } }, + { + name: "demo-ui.date-time-picker", + url: "/date-time-picker", + data: { + translationKey: "SHOWCASE.DEMO.DATE_TIME_PICKER.TITLE" + }, + views: { "@": { component: DemoDateTimePickerPageComponent } } + }, { name: "demo-ui.dialogs", url: "/dialogs", diff --git a/showcase/src/assets/examples/date-time-picker/ng-model.html b/showcase/src/assets/examples/date-time-picker/ng-model.html new file mode 100644 index 0000000000..edc437b886 --- /dev/null +++ b/showcase/src/assets/examples/date-time-picker/ng-model.html @@ -0,0 +1,23 @@ + + + +
+ {{ errorMessage | translate }} +
+
+
+
+ Disabled +
diff --git a/showcase/src/assets/examples/date-time-picker/ng-model.scss b/showcase/src/assets/examples/date-time-picker/ng-model.scss new file mode 100644 index 0000000000..0a14108675 --- /dev/null +++ b/showcase/src/assets/examples/date-time-picker/ng-model.scss @@ -0,0 +1,5 @@ +:host ::ng-deep { + .mat-form-field.expanded .mat-form-field-wrapper.stark-date-time-picker-form-field-wrapper { + margin-bottom: 25px; + } +} diff --git a/showcase/src/assets/examples/date-time-picker/ng-model.ts b/showcase/src/assets/examples/date-time-picker/ng-model.ts new file mode 100644 index 0000000000..341067ab15 --- /dev/null +++ b/showcase/src/assets/examples/date-time-picker/ng-model.ts @@ -0,0 +1,64 @@ +import { Component, Inject } from "@angular/core"; +import { AbstractControl } from "@angular/forms"; +import { STARK_LOGGING_SERVICE, StarkLoggingService } from "@nationalbankbelgium/stark-core"; + +const HOUR_IN_MILLISECONDS = 3600000; +const DAY_IN_MILLISECONDS = 86400000; + +@Component({ + selector: "demo-date-time-picker", + templateUrl: "./demo-date-time-picker.component.html", + styleUrls: ["./demo-date-time-picker.component.scss"] +}) +export class DemoDateTimePickerComponent { + public dateTimeModel?: Date; + public isDisabled = false; + + public dateMask = { format: "DD/MM/YYYY" }; + + public minDate = new Date(Date.now() - HOUR_IN_MILLISECONDS); // minDate = one hour earlier + public maxDate = new Date(Date.now() + 30 * DAY_IN_MILLISECONDS); + + public constructor(@Inject(STARK_LOGGING_SERVICE) private _logger: StarkLoggingService) {} + + public onDateTimeNgModelChanged(): void { + this._logger.debug("ngModel: ", this.dateTimeModel); + } + + public getErrorMessages(control?: AbstractControl): string[] { + const errors: string[] = []; + + if (control && control.errors) { + for (const key of Object.keys(control.errors)) { + switch (key) { + case "required": + errors.push("This field is required"); + break; + case "matDatepickerFilter": + errors.push("Invalid date"); + break; + case "matDatepickerMin": + errors.push("Date cannot be before the minimum date allowed"); + break; + case "matDatepickerMax": + errors.push("Date cannot be after the maximum date allowed"); + break; + case "starkMinDateTime": + errors.push("Datetime cannot be before the minimum datetime allowed"); + break; + case "starkMaxDateTime": + errors.push("Datetime cannot be after the maximum datetime allowed"); + break; + default: + errors.push(key); + } + } + } + + return errors; + } + + public trackItemFn(item: string): string { + return item; + } +} diff --git a/showcase/src/assets/examples/date-time-picker/reactive-form.html b/showcase/src/assets/examples/date-time-picker/reactive-form.html new file mode 100644 index 0000000000..3a3e6c2d36 --- /dev/null +++ b/showcase/src/assets/examples/date-time-picker/reactive-form.html @@ -0,0 +1,21 @@ + + + +
+ {{ errorMessage | translate }} +
+
+
+
+ Disabled +
diff --git a/showcase/src/assets/examples/date-time-picker/reactive-form.scss b/showcase/src/assets/examples/date-time-picker/reactive-form.scss new file mode 100644 index 0000000000..0a14108675 --- /dev/null +++ b/showcase/src/assets/examples/date-time-picker/reactive-form.scss @@ -0,0 +1,5 @@ +:host ::ng-deep { + .mat-form-field.expanded .mat-form-field-wrapper.stark-date-time-picker-form-field-wrapper { + margin-bottom: 25px; + } +} diff --git a/showcase/src/assets/examples/date-time-picker/reactive-form.ts b/showcase/src/assets/examples/date-time-picker/reactive-form.ts new file mode 100644 index 0000000000..d3cb7e169b --- /dev/null +++ b/showcase/src/assets/examples/date-time-picker/reactive-form.ts @@ -0,0 +1,81 @@ +import { Component, Inject, OnDestroy } from "@angular/core"; +import { AbstractControl, FormControl, Validators } from "@angular/forms"; +import { STARK_LOGGING_SERVICE, StarkLoggingService } from "@nationalbankbelgium/stark-core"; +import { StarkTimestampMaskConfig } from "@nationalbankbelgium/stark-ui"; +import { Subscription } from "rxjs"; + +const HOUR_IN_MILLISECONDS = 3600000; +const DAY_IN_MILLISECONDS = 86400000; + +@Component({ + selector: "demo-date-time-picker", + templateUrl: "./demo-date-time-picker.component.html", + styleUrls: ["./demo-date-time-picker.component.scss"] +}) +export class DemoDateTimePickerComponent implements OnDestroy { + public formControl = new FormControl(new Date(), Validators.required); + + public dateMask = { format: "DD/MM/YYYY" }; + public timeMask: StarkTimestampMaskConfig = { format: "HH:mm" }; + + public minDate = new Date(Date.now() - HOUR_IN_MILLISECONDS); // minDate = one hour earlier + public maxDate = new Date(Date.now() + 30 * DAY_IN_MILLISECONDS); + + public subscription: Subscription; + + public constructor(@Inject(STARK_LOGGING_SERVICE) private _logger: StarkLoggingService) { + this.subscription = this.formControl.valueChanges.subscribe((value: any) => { + this._logger.debug("formControl: ", value); + }); + } + + public ngOnDestroy(): void { + this.subscription.unsubscribe(); + } + + public toggleFormControlState(formControl: FormControl): void { + // enable/disable the control without emitting a change event since the value did not change (to avoid unnecessary extra calls!) + if (formControl.disabled) { + formControl.enable({ emitEvent: false }); + } else { + formControl.disable({ emitEvent: false }); + } + } + + public getErrorMessages(control?: AbstractControl): string[] { + const errors: string[] = []; + + if (control && control.errors) { + for (const key of Object.keys(control.errors)) { + switch (key) { + case "required": + errors.push("This field is required"); + break; + case "matDatepickerFilter": + errors.push("Invalid date"); + break; + case "matDatepickerMin": + errors.push("Date cannot be before the minimum date allowed"); + break; + case "matDatepickerMax": + errors.push("Date cannot be after the maximum date allowed"); + break; + case "starkMinDateTime": + errors.push("Datetime cannot be before the minimum datetime allowed"); + break; + case "starkMaxDateTime": + errors.push("Datetime cannot be after the maximum datetime allowed"); + break; + default: + errors.push(key); + } + } + } + + return errors; + } + + public trackItemFn(item: string): string { + return item; + } +} diff --git a/showcase/src/assets/translations/en.json b/showcase/src/assets/translations/en.json index 27ad49b24a..74cea1db49 100644 --- a/showcase/src/assets/translations/en.json +++ b/showcase/src/assets/translations/en.json @@ -67,6 +67,19 @@ "REQUIRED": "Date is required" } }, + "DATE_TIME_PICKER": { + "TITLE": "Date time picker", + "NG_MODEL": "Using ngModel", + "PLACEHOLDER": "Date and time", + "REACTIVE_FORM": "Using Reactive form", + "ERROR_MESSAGES": { + "MIN_DATE": "Date cannot be before the minimum date allowed", + "MAX_DATE": "Date cannot be after the maximum date allowed", + "MIN_DATE_TIME": "Datetime cannot be before the minimum datetime allowed", + "MAX_DATE_TIME": "Datetime cannot be after the maximum datetime allowed", + "INVALID_DATE": "Invalid date" + } + }, "DIALOGS": { "ALERT": { "TITLE": "This is an alert title", diff --git a/showcase/src/assets/translations/fr.json b/showcase/src/assets/translations/fr.json index 454466b63a..561998cb6f 100644 --- a/showcase/src/assets/translations/fr.json +++ b/showcase/src/assets/translations/fr.json @@ -67,6 +67,19 @@ "REQUIRED": "La date est requise" } }, + "DATE_TIME_PICKER": { + "TITLE": "Date time picker", + "NG_MODEL": "Utilisant ngModel", + "PLACEHOLDER": "Date et l'heure", + "REACTIVE_FORM": "Utilisant Reactive form", + "ERROR_MESSAGES": { + "MIN_DATE": "La date ne peut pas être antérieure à la date minimale autorisée", + "MAX_DATE": "La date ne peut pas être ultérieure à la date maximum autorisée", + "MIN_DATE_TIME": "La date-heure ne peut pas être antérieure à la date-heure minimale autorisée", + "MAX_DATE_TIME": "La date-heure ne peut pas être ultérieure à la date-heure maximum autorisée", + "INVALID_DATE": "Date invalide" + } + }, "DIALOGS": { "ALERT": { "TITLE": "Ceci est un titre d'alerte", diff --git a/showcase/src/assets/translations/nl.json b/showcase/src/assets/translations/nl.json index b249d4a60f..36f7a29c65 100644 --- a/showcase/src/assets/translations/nl.json +++ b/showcase/src/assets/translations/nl.json @@ -67,6 +67,19 @@ "REQUIRED": "Datum is verplicht" } }, + "DATE_TIME_PICKER": { + "TITLE": "Date time picker", + "NG_MODEL": "Met ngModel", + "PLACEHOLDER": "Datum en tijd", + "REACTIVE_FORM": "Met Reactive form", + "ERROR_MESSAGES": { + "MIN_DATE": "Datum mag niet vóór de toegestane minimale datum zijn", + "MAX_DATE": "Datum mag niet na de toegestane maximale datum zijn", + "MIN_DATE_TIME": "Datetime mag niet vóór de toegestane minimale datetime zijn", + "MAX_DATE_TIME": "Datetime mag niet na de toegestane maximale datetime zijn", + "INVALID_DATE": "Ongeldige datum" + } + }, "DIALOGS": { "ALERT": { "TITLE": "Dit is een waarschuwings titel",