From d95ab870ae2b143799c8d369e0e898c588d36e13 Mon Sep 17 00:00:00 2001 From: Jane Sandberg Date: Wed, 8 May 2019 15:06:22 -0700 Subject: [PATCH] LP1834662: Add date-related components to Angular client. * Adds a moment.js-based datetime-select widget to Angular (including a read-only version) * Adds a daterange-select widget to Angular Signed-off-by: Jane Sandberg Signed-off-by: Bill Erickson --- Open-ILS/src/eg2/package.json | 2 + Open-ILS/src/eg2/src/app/common.module.ts | 4 +- .../src/eg2/src/app/core/format.service.ts | 126 ++++++++++++-- Open-ILS/src/eg2/src/app/core/format.spec.ts | 26 +++ .../src/app/share/common-widgets.module.ts | 14 +- .../daterange-select.component.css | 12 ++ .../daterange-select.component.html | 21 +++ .../daterange-select.component.spec.ts | 50 ++++++ .../daterange-select.component.ts | 102 +++++++++++ .../datetime-select.component.html | 56 +++++++ .../datetime-select.component.ts | 158 ++++++++++++++++++ .../datetime_validator.directive.ts | 41 +++++ .../src/eg2/src/app/staff/common.module.ts | 9 +- .../app/staff/sandbox/sandbox.component.html | 20 +++ .../app/staff/sandbox/sandbox.component.ts | 27 +++ Open-ILS/src/eg2/src/styles.css | 20 ++- 16 files changed, 668 insertions(+), 20 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.css create mode 100644 Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.html create mode 100644 Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.spec.ts create mode 100644 Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.ts create mode 100644 Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.html create mode 100644 Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.ts create mode 100644 Open-ILS/src/eg2/src/app/share/validators/datetime_validator.directive.ts diff --git a/Open-ILS/src/eg2/package.json b/Open-ILS/src/eg2/package.json index cbc8f27db7..1bafd4d4c2 100644 --- a/Open-ILS/src/eg2/package.json +++ b/Open-ILS/src/eg2/package.json @@ -29,6 +29,8 @@ "core-js": "^2.6.9", "file-saver": "^2.0.2", "material-design-icons": "^3.0.1", + "moment": "2.24.0", + "moment-timezone": "0.5.23", "ngx-cookie": "^4.1.2", "rxjs": "^6.5.2", "zone.js": "^0.8.29" diff --git a/Open-ILS/src/eg2/src/app/common.module.ts b/Open-ILS/src/eg2/src/app/common.module.ts index 76394c3924..ead50f1e54 100644 --- a/Open-ILS/src/eg2/src/app/common.module.ts +++ b/Open-ILS/src/eg2/src/app/common.module.ts @@ -4,7 +4,7 @@ import {CommonModule} from '@angular/common'; import {NgModule, ModuleWithProviders} from '@angular/core'; import {RouterModule} from '@angular/router'; -import {FormsModule} from '@angular/forms'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; import {EgCoreModule} from '@eg/core/core.module'; @@ -41,6 +41,7 @@ import {BoolDisplayComponent} from '@eg/share/util/bool.component'; imports: [ CommonModule, FormsModule, + ReactiveFormsModule, RouterModule, NgbModule, EgCoreModule @@ -51,6 +52,7 @@ import {BoolDisplayComponent} from '@eg/share/util/bool.component'; NgbModule, FormsModule, EgCoreModule, + ReactiveFormsModule, PrintComponent, DialogComponent, AlertDialogComponent, diff --git a/Open-ILS/src/eg2/src/app/core/format.service.ts b/Open-ILS/src/eg2/src/app/core/format.service.ts index 63aeec66f0..20f0fdff18 100644 --- a/Open-ILS/src/eg2/src/app/core/format.service.ts +++ b/Open-ILS/src/eg2/src/app/core/format.service.ts @@ -2,6 +2,7 @@ import {Injectable, Pipe, PipeTransform} from '@angular/core'; import {DatePipe, CurrencyPipe} from '@angular/common'; import {IdlService, IdlObject} from '@eg/core/idl.service'; import {OrgService} from '@eg/core/org.service'; +import * as Moment from 'moment-timezone'; /** * Format IDL vield values for display. @@ -16,6 +17,7 @@ export interface FormatParams { datatype?: string; orgField?: string; // 'shortname' || 'name' datePlusTime?: boolean; + timezoneContextOrg?: number; } @Injectable({providedIn: 'root'}) @@ -107,15 +109,6 @@ export class FormatService { return org ? org[orgField]() : ''; case 'timestamp': - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - console.error('Invalid date in format service', value); - return ''; - } - let fmt = this.dateFormat || 'shortDate'; - if (params.datePlusTime) { - fmt = this.dateTimeFormat || 'short'; - } let tz; if (params.idlField === 'dob') { // special case: since dob is the only date column that the @@ -123,8 +116,19 @@ export class FormatService { // as a UTC value; apply the correct timezone rather than the // local one tz = 'UTC'; + } else { + tz = this.wsOrgTimezone; + } + const date = Moment(value).tz(tz); + if (!date.isValid()) { + console.error('Invalid date in format service', value); + return ''; + } + let fmt = this.dateFormat || 'shortDate'; + if (params.datePlusTime) { + fmt = this.dateTimeFormat || 'short'; } - return this.datePipe.transform(date, fmt, tz); + return this.datePipe.transform(date.toISOString(true), fmt, date.format('ZZ')); case 'money': return this.currencyPipe.transform(value); @@ -142,6 +146,108 @@ export class FormatService { return value + ''; } } + /** + * Create an IDL-friendly display version of a human-readable date + */ + idlFormatDate(date: string, timezone: string): string { return this.momentizeDateString(date, timezone).format('YYYY-MM-DD'); } + + /** + * Create an IDL-friendly display version of a human-readable datetime + */ + idlFormatDatetime(datetime: string, timezone: string): string { return this.momentizeDateTimeString(datetime, timezone).toISOString(); } + + /** + * Turn a date string into a Moment using the date format org setting. + */ + momentizeDateString(date: string, timezone: string, strict = false): Moment { + return this.momentize(date, this.makeFormatParseable(this.dateFormat), timezone, strict); + } + + /** + * Turn a datetime string into a Moment using the datetime format org setting. + */ + momentizeDateTimeString(date: string, timezone: string, strict = false): Moment { + return this.momentize(date, this.makeFormatParseable(this.dateTimeFormat), timezone, strict); + } + + /** + * Turn a string into a Moment using the provided format string. + */ + private momentize(date: string, format: string, timezone: string, strict: boolean): Moment { + if (format.length) { + const result = Moment.tz(date, format, true, timezone); + if (isNaN(result) || 'Invalid date' === result) { + if (strict) { + throw new Error('Error parsing date ' + date); + } + return Moment.tz(date, format, false, timezone); + } + // TODO: The following fallback returns the date at midnight UTC, + // rather than midnight in the local TZ + return Moment.tz(date, timezone); + } + } + + /** + * Takes a dateFormat or dateTimeFormat string (which uses Angular syntax) and transforms + * it into a format string that MomentJs can use to parse input human-readable strings + * (https://momentjs.com/docs/#/parsing/string-format/) + * + * Returns a blank string if it can't do this transformation. + */ + private makeFormatParseable(original: string): string { + if (!original) { return ''; } + switch (original) { + case 'short': { + return 'M/D/YY, h:mm a'; + } + case 'medium': { + return 'MMM D, Y, h:mm:ss a'; + } + case 'long': { + return 'MMMM D, Y, h:mm:ss a [GMT]Z'; + } + case 'full': { + return 'dddd, MMMM D, Y, h:mm:ss a [GMT]Z'; + } + case 'shortDate': { + return 'M/D/YY'; + } + case 'mediumDate': { + return 'MMM D, Y'; + } + case 'longDate': { + return 'MMMM D, Y'; + } + case 'fullDate': { + return 'dddd, MMMM D, Y'; + } + case 'shortTime': { + return 'h:mm a'; + } + case 'mediumTime': { + return 'h:mm:ss a'; + } + case 'longTime': { + return 'h:mm:ss a [GMT]Z'; + } + case 'fullTime': { + return 'h:mm:ss a [GMT]Z'; + } + } + return original + .replace(/a+/g, 'a') // MomentJs can handle all sorts of meridian strings + .replace(/d/g, 'D') // MomentJs capitalizes day of month + .replace(/EEEEEE/g, '') // MomentJs does not handle short day of week + .replace(/EEEEE/g, '') // MomentJs does not handle narrow day of week + .replace(/EEEE/g, 'dddd') // MomentJs has different syntax for long day of week + .replace(/E{1,3}/g, 'ddd') // MomentJs has different syntax for abbreviated day of week + .replace(/L/g, 'M') // MomentJs does not differentiate between month and month standalone + .replace(/W/g, '') // MomentJs uses W for something else + .replace(/y/g, 'Y') // MomentJs capitalizes year + .replace(/ZZZZ|z{1,4}/g, '[GMT]Z') // MomentJs doesn't put "UTC" in front of offset + .replace(/Z{2,3}/g, 'Z'); // MomentJs only uses 1 Z + } } diff --git a/Open-ILS/src/eg2/src/app/core/format.spec.ts b/Open-ILS/src/eg2/src/app/core/format.spec.ts index 05991df68f..81b3201897 100644 --- a/Open-ILS/src/eg2/src/app/core/format.spec.ts +++ b/Open-ILS/src/eg2/src/app/core/format.spec.ts @@ -86,5 +86,31 @@ describe('FormatService', () => { expect(str).toBe('$12.10'); }); + it('should transform M/d/yy, h:mm a Angular format string to a valid MomentJS one', () => { + const momentVersion = service['makeFormatParseable']('M/d/yy, h:mm a'); + expect(momentVersion).toBe('M/D/YY, h:mm a'); + }); + it('should transform MMM d, y, h:mm:ss a Angular format string to a valid MomentJS one', () => { + const momentVersion = service['makeFormatParseable']('MMM d, y, h:mm:ss a'); + expect(momentVersion).toBe('MMM D, Y, h:mm:ss a'); + }); + it('should transform MMMM d, y, h:mm:ss a z Angular format strings to a valid MomentJS one', () => { + const momentVersion = service['makeFormatParseable']('MMMM d, y, h:mm:ss a z'); + expect(momentVersion).toBe('MMMM D, Y, h:mm:ss a [GMT]Z'); + }); + it('should transform full Angular format strings to a valid MomentJS one', () => { + const momentVersion = service['makeFormatParseable']('full'); + expect(momentVersion).toBe('dddd, MMMM D, Y, h:mm:ss a [GMT]Z'); + }); + it('can create a valid Momentjs object given a valid datetime string and correct format', () => { + const moment = service['momentize']('7/3/12, 6:06 PM', 'M/D/YY, h:mm a', 'Africa/Addis_Ababa', false); + expect(moment.isValid()).toBe(true); + }); + it('can create a valid Momentjs object given a valid datetime string and a dateTimeFormat from org settings', () => { + service['dateTimeFormat'] = 'M/D/YY, h:mm a'; + const moment = service.momentizeDateTimeString('7/3/12, 6:06 PM', 'Africa/Addis_Ababa', false); + expect(moment.isValid()).toBe(true); + }); + }); diff --git a/Open-ILS/src/eg2/src/app/share/common-widgets.module.ts b/Open-ILS/src/eg2/src/app/share/common-widgets.module.ts index 71c5d3023c..01b16bd4da 100644 --- a/Open-ILS/src/eg2/src/app/share/common-widgets.module.ts +++ b/Open-ILS/src/eg2/src/app/share/common-widgets.module.ts @@ -5,24 +5,30 @@ */ import {NgModule, ModuleWithProviders} from '@angular/core'; import {CommonModule} from '@angular/common'; -import {FormsModule} from '@angular/forms'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; import {EgCoreModule} from '@eg/core/core.module'; import {ComboboxComponent} from '@eg/share/combobox/combobox.component'; import {ComboboxEntryComponent} from '@eg/share/combobox/combobox-entry.component'; import {DateSelectComponent} from '@eg/share/date-select/date-select.component'; import {OrgSelectComponent} from '@eg/share/org-select/org-select.component'; +import {DateRangeSelectComponent} from '@eg/share/daterange-select/daterange-select.component'; +import {DateTimeSelectComponent} from '@eg/share/datetime-select/datetime-select.component'; + @NgModule({ declarations: [ ComboboxComponent, ComboboxEntryComponent, DateSelectComponent, - OrgSelectComponent + OrgSelectComponent, + DateRangeSelectComponent, + DateTimeSelectComponent, ], imports: [ CommonModule, FormsModule, + ReactiveFormsModule, NgbModule, EgCoreModule ], @@ -34,7 +40,9 @@ import {OrgSelectComponent} from '@eg/share/org-select/org-select.component'; ComboboxComponent, ComboboxEntryComponent, DateSelectComponent, - OrgSelectComponent + OrgSelectComponent, + DateRangeSelectComponent, + DateTimeSelectComponent, ], }) diff --git a/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.css b/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.css new file mode 100644 index 0000000000..4e24456465 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.css @@ -0,0 +1,12 @@ +.daterange-day { + text-align: center; + padding: 0.185rem 0.25rem; + display: inline-block; + height: 2rem; + width: 2rem; +} + +.today { + border: solid 2px #129a78; + border-radius: 5px; +} diff --git a/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.html b/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.html new file mode 100644 index 0000000000..b88d0ef974 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.html @@ -0,0 +1,21 @@ + + + + + + {{ date.day }} + + + diff --git a/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.spec.ts b/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.spec.ts new file mode 100644 index 0000000000..52a0c47e1d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.spec.ts @@ -0,0 +1,50 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {Component, DebugElement, Input, TemplateRef} from '@angular/core'; +import {By} from '@angular/platform-browser'; +import {DateRange, DateRangeSelectComponent} from './daterange-select.component'; +import {ReactiveFormsModule} from '@angular/forms'; +import {NgbDate} from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'ngb-datepicker', + template: '' +}) +class EgMockDateSelectComponent { + @Input() displayMonths: number; + @Input() dayTemplate: TemplateRef; + @Input() outsideDays: string; + @Input() markDisabled: + (date: NgbDate, current: { year: number; month: number; }) => boolean = + (date: NgbDate, current: { year: number; month: number; }) => false +} + +describe('Component: DateRangeSelect', () => { + let component: DateRangeSelectComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ + DateRangeSelectComponent, + EgMockDateSelectComponent, + ]}); + + fixture = TestBed.createComponent(DateRangeSelectComponent); + component = fixture.componentInstance; + component.ngOnInit(); + }); + + + it('creates a range when the user clicks two dates, with the earlier date clicked first', () => { + component.onDateSelection(new NgbDate(2004, 6, 4)); + component.onDateSelection(new NgbDate(2005, 7, 27)); + expect(component.selectedRange.toDate).toBeTruthy(); + }); + + it('creates a range with a null value when the user clicks two dates, with the later date clicked first', () => { + component.onDateSelection(new NgbDate(2011, 1, 27)); + component.onDateSelection(new NgbDate(2006, 11, 16)); + expect(component.selectedRange.toDate).toBeNull(); + }); + +}); diff --git a/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.ts b/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.ts new file mode 100644 index 0000000000..74a1e342a2 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.ts @@ -0,0 +1,102 @@ +import {Component, Input, forwardRef, OnInit} from '@angular/core'; +import {NgbDate, NgbCalendar} from '@ng-bootstrap/ng-bootstrap'; +import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; + +export interface DateRange { + fromDate?: NgbDate; + toDate?: NgbDate; +} + +@Component({ + selector: 'eg-daterange-select', + templateUrl: './daterange-select.component.html', + styleUrls: [ './daterange-select.component.css' ], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DateRangeSelectComponent), + multi: true + }] +}) +export class DateRangeSelectComponent implements ControlValueAccessor, OnInit { + + // Number of days in the initial + // date range shown to user + @Input() initialRangeLength = 10; + + // Start date of the initial + // date range shown to user + @Input() initialRangeStart = new Date(); + + hoveredDate: NgbDate; + + selectedRange: DateRange; + + // Function to disable certain dates + @Input() markDisabled: + (date: NgbDate, current: { year: number; month: number; }) => boolean = + (date: NgbDate, current: { year: number; month: number; }) => false + + onChange = (_: any) => {}; + onTouched = () => {}; + + constructor(private calendar: NgbCalendar) { } + + ngOnInit() { + this.selectedRange = { + fromDate: new NgbDate( + this.initialRangeStart.getFullYear(), + this.initialRangeStart.getMonth() + 1, + this.initialRangeStart.getDate()), + toDate: this.calendar.getNext( + this.calendar.getToday(), + 'd', + this.initialRangeLength) + }; + } + + onDateSelection(date: NgbDate) { + if (!this.selectedRange.fromDate && !this.selectedRange.toDate) { + this.selectedRange.fromDate = date; + } else if (this.selectedRange.fromDate && !this.selectedRange.toDate && date.after(this.selectedRange.fromDate)) { + this.selectedRange.toDate = date; + } else { + this.selectedRange.toDate = null; + this.selectedRange.fromDate = date; + } + this.onChange(this.selectedRange); + } + + isHovered(date: NgbDate) { + return this.selectedRange.fromDate && + !this.selectedRange.toDate && + this.hoveredDate && + date.after(this.selectedRange.fromDate) && + date.before(this.hoveredDate); + } + + isInside(date: NgbDate) { + return date.after(this.selectedRange.fromDate) && date.before(this.selectedRange.toDate); + } + + isRange(date: NgbDate) { + return date.equals(this.selectedRange.fromDate) || + date.equals(this.selectedRange.toDate) || + this.isInside(date) || + this.isHovered(date); + } + + writeValue(value: DateRange) { + if (value) { + this.selectedRange = value; + } + } + registerOnChange(fn: (value: DateRange) => any): void { + this.onChange = fn; + } + registerOnTouched(fn: () => any): void { + this.onTouched = fn; + } + today(): NgbDate { + return this.calendar.getToday(); + } +} diff --git a/Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.html b/Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.html new file mode 100644 index 0000000000..7f13c12444 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.html @@ -0,0 +1,56 @@ +error +
+ +
+ +
+
+
+ Cannot edit this date or time. +
+
+ + + +
+
+ + + + + {{ timezone }} + Timezone not set + + + +
diff --git a/Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.ts b/Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.ts new file mode 100644 index 0000000000..e1874137d3 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.ts @@ -0,0 +1,158 @@ +import {Component, EventEmitter, Input, Output, forwardRef, ViewChild, OnInit, Optional, Self} from '@angular/core'; +import {FormatService} from '@eg/core/format.service'; +import {AbstractControl, ControlValueAccessor, FormControl, FormGroup, NgControl} from '@angular/forms'; +import {NgbDatepicker, NgbTimeStruct, NgbDateStruct} from '@ng-bootstrap/ng-bootstrap'; +import {DatetimeValidator} from '@eg/share/validators/datetime_validator.directive'; +import * as Moment from 'moment-timezone'; + +@Component({ + selector: 'eg-datetime-select', + templateUrl: './datetime-select.component.html', +}) +export class DateTimeSelectComponent implements OnInit, ControlValueAccessor { + @Input() domId = ''; + @Input() fieldName: string; + @Input() initialIso: string; + @Input() required: boolean; + @Input() minuteStep = 15; + @Input() showTZ = true; + @Input() timezone: string = this.format.wsOrgTimezone; + @Input() readOnly = false; + @Output() onChangeAsIso: EventEmitter; + + dateTimeForm: FormGroup; + + @ViewChild('datePicker') datePicker; + + onChange = (_: any) => {}; + onTouched = () => {}; + + constructor( + private format: FormatService, + private dtv: DatetimeValidator, + @Optional() + @Self() + public controlDir: NgControl, // so that the template can access validation state + ) { + controlDir.valueAccessor = this; + this.onChangeAsIso = new EventEmitter(); + const startValue = Moment.tz([], this.timezone); + this.dateTimeForm = new FormGroup({ + 'stringVersion': new FormControl( + this.format.transform({value: startValue, datatype: 'timestamp', datePlusTime: true}), + this.dtv.validate), + 'date': new FormControl({ + year: startValue.year(), + month: startValue.month() + 1, + day: startValue.date() }), + 'time': new FormControl({ + hour: startValue.hour(), + minute: startValue.minute(), + second: 0 }) + }); + } + + ngOnInit() { + if (!this.timezone) { + this.timezone = this.format.wsOrgTimezone; + } + if (this.initialIso) { + this.writeValue(Moment(this.initialIso).tz(this.timezone)); + } + this.dateTimeForm.get('stringVersion').valueChanges.subscribe((value) => { + if ('VALID' === this.dateTimeForm.get('stringVersion').status) { + const model = this.format.momentizeDateTimeString(value, this.timezone, false); + if (model && model.isValid()) { + this.onChange(model); + this.onChangeAsIso.emit(model.toISOString()); + this.dateTimeForm.patchValue({date: { + year: model.year(), + month: model.month() + 1, + day: model.date()}, time: { + hour: model.hour(), + minute: model.minute(), + second: 0 } + }, {emitEvent: false, onlySelf: true}); + this.datePicker.navigateTo({ + year: model.year(), + month: model.month() + 1 + }); + } + } + }); + this.dateTimeForm.get('date').valueChanges.subscribe((date) => { + const newDate = Moment.tz([date.year, (date.month - 1), date.day, + this.time.value.hour, this.time.value.minute, 0], this.timezone); + this.dateTimeForm.patchValue({stringVersion: + this.format.transform({value: newDate, datatype: 'timestamp', datePlusTime: true})}, + {emitEvent: false, onlySelf: true}); + this.onChange(newDate); + this.onChangeAsIso.emit(newDate.toISOString()); + }); + + this.dateTimeForm.get('time').valueChanges.subscribe((time) => { + const newDate = Moment.tz([this.date.value.year, + (this.date.value.month - 1), + this.date.value.day, + time.hour, time.minute, 0], + this.timezone); + this.dateTimeForm.patchValue({stringVersion: + this.format.transform({ + value: newDate, datatype: 'timestamp', datePlusTime: true})}, + {emitEvent: false, onlySelf: true}); + this.onChange(newDate); + this.onChangeAsIso.emit(newDate.toISOString()); + }); + } + + setDatePicker(current: Moment) { + const withTZ = current ? current.tz(this.timezone) : Moment.tz([], this.timezone); + this.dateTimeForm.patchValue({date: { + year: withTZ.year(), + month: withTZ.month() + 1, + day: withTZ.date() }}); + } + + setTimePicker(current: Moment) { + const withTZ = current ? current.tz(this.timezone) : Moment.tz([], this.timezone); + this.dateTimeForm.patchValue({time: { + hour: withTZ.hour(), + minute: withTZ.minute(), + second: 0 }}); + } + + + writeValue(value: Moment) { + if (value !== undefined && value !== null) { + this.dateTimeForm.patchValue({ + stringVersion: this.format.transform({value: value, datatype: 'timestamp', datePlusTime: true})}); + this.setDatePicker(value); + this.setTimePicker(value); + } + } + + registerOnChange(fn: (value: Moment) => any): void { + this.onChange = fn; + } + registerOnTouched(fn: () => any): void { + this.onTouched = fn; + } + + firstError(errors: Object) { + return Object.values(errors)[0]; + } + + get stringVersion(): AbstractControl { + return this.dateTimeForm.get('stringVersion'); + } + + get date(): AbstractControl { + return this.dateTimeForm.get('date'); + } + + get time(): AbstractControl { + return this.dateTimeForm.get('time'); + } + +} + diff --git a/Open-ILS/src/eg2/src/app/share/validators/datetime_validator.directive.ts b/Open-ILS/src/eg2/src/app/share/validators/datetime_validator.directive.ts new file mode 100644 index 0000000000..bed582e426 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/validators/datetime_validator.directive.ts @@ -0,0 +1,41 @@ +import {Directive, forwardRef} from '@angular/core'; +import {NG_VALIDATORS, AbstractControl, FormControl, ValidationErrors, Validator} from '@angular/forms'; +import {FormatService} from '@eg/core/format.service'; +import {EmptyError, Observable, of} from 'rxjs'; +import {single, switchMap, catchError} from 'rxjs/operators'; +import {Injectable} from '@angular/core'; + +@Injectable({providedIn: 'root'}) +export class DatetimeValidator implements Validator { + constructor( + private format: FormatService) { + } + + validate = (control: FormControl) => { + try { + this.format.momentizeDateTimeString(control.value, 'Africa/Addis_Ababa', true); + } catch (err) { + return {datetimeParseError: err.message}; + } + return null; + } +} + +@Directive({ + selector: '[egValidDatetime]', + providers: [{ + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DatetimeValidator), + multi: true + }] +}) +export class DatetimeValidatorDirective { + constructor( + private dtv: DatetimeValidator + ) { } + + validate = (control: FormControl) => { + this.dtv.validate(control); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/common.module.ts b/Open-ILS/src/eg2/src/app/staff/common.module.ts index 66c62c32eb..969ca379aa 100644 --- a/Open-ILS/src/eg2/src/app/staff/common.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/common.module.ts @@ -20,7 +20,7 @@ import {BibSummaryComponent} from '@eg/staff/share/bib-summary/bib-summary.compo import {TranslateComponent} from '@eg/staff/share/translate/translate.component'; import {AdminPageComponent} from '@eg/staff/share/admin-page/admin-page.component'; import {EgHelpPopoverComponent} from '@eg/share/eg-help-popover/eg-help-popover.component'; -import {ReactiveFormsModule} from '@angular/forms'; +import {DatetimeValidatorDirective} from '@eg/share/validators/datetime_validator.directive'; /** * Imports the EG common modules and adds modules common to all staff UI's. @@ -41,11 +41,11 @@ import {ReactiveFormsModule} from '@angular/forms'; BibSummaryComponent, TranslateComponent, AdminPageComponent, - EgHelpPopoverComponent + EgHelpPopoverComponent, + DatetimeValidatorDirective, ], imports: [ EgCommonModule, - ReactiveFormsModule, CommonWidgetsModule, GridModule ], @@ -66,7 +66,8 @@ import {ReactiveFormsModule} from '@angular/forms'; BibSummaryComponent, TranslateComponent, AdminPageComponent, - EgHelpPopoverComponent + EgHelpPopoverComponent, + DatetimeValidatorDirective, ] }) diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html index 2febd8eb3b..b46f85d502 100644 --- a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html +++ b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html @@ -218,6 +218,26 @@ datatype="timestamp" [datePlusTime]="true"> +

+
+
+ + + Your range is: {{myRange.value | json}} +
+
+
+ + + Your datetime is: {{myTimeForm.get('datetime').value | json}} +
+
+


Grid with filtering

diff --git a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts index 740d4d14b8..8ab704a352 100644 --- a/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts @@ -14,11 +14,13 @@ import {DateSelectComponent} from '@eg/share/date-select/date-select.component'; import {PrintService} from '@eg/share/print/print.service'; import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component'; +import {NgbDate} from '@ng-bootstrap/ng-bootstrap'; import {FormGroup, FormControl} from '@angular/forms'; import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component'; import {FormatService} from '@eg/core/format.service'; import {StringComponent} from '@eg/share/string/string.component'; import {GridComponent} from '@eg/share/grid/grid.component'; +import * as Moment from 'moment-timezone'; @Component({ templateUrl: 'sandbox.component.html' @@ -98,6 +100,8 @@ export class SandboxComponent implements OnInit { private sbChannel: any; sbChannelText: string; + myTimeForm: FormGroup; + constructor( private idl: IdlService, private org: OrgService, @@ -251,6 +255,17 @@ export class SandboxComponent implements OnInit { b.cancel_time('2019-03-25T11:07:59-0400'); this.bresvEditor.mode = 'create'; this.bresvEditor.record = b; + + this.myTimeForm = new FormGroup({ + 'datetime': new FormControl(Moment([]), (c: FormControl) => { + // An Angular custom validator + if (c.value.year() < 2019) { + return { tooLongAgo: 'That\'s before 2019' }; + } else { + return null; + } + } ) + }); } sbChannelHandler = msg => { @@ -382,6 +397,18 @@ export class SandboxComponent implements OnInit { ); }); } + + allFutureDates(date: NgbDate, current: { year: number; month: number; }) { + const currentTime = new Date(); + const today = new NgbDate(currentTime.getFullYear(), currentTime.getMonth() + 1, currentTime.getDate()); + return date.after(today); + } + + sevenDaysAgo() { + const d = new Date(); + d.setDate(d.getDate() - 7); + return d; + } } diff --git a/Open-ILS/src/eg2/src/styles.css b/Open-ILS/src/eg2/src/styles.css index 4d28583fde..9573b52e4a 100644 --- a/Open-ILS/src/eg2/src/styles.css +++ b/Open-ILS/src/eg2/src/styles.css @@ -134,10 +134,10 @@ h5 {font-size: .95rem} * Required valid fields are left-border styled in green-ish. * Invalid fields are left-border styled in red-ish. */ -.form-validated .ng-valid[required], .form-validated .ng-valid.required { +.form-validated .ng-valid[required], .form-validated .ng-valid.required, input[formcontrolname].ng-valid { border-left: 5px solid #78FA89; } -.form-validated .ng-invalid:not(form) { +.form-validated .ng-invalid:not(form), input[formcontrolname].ng-invalid { border-left: 5px solid #FA787E; } @@ -195,3 +195,19 @@ h5 {font-size: .95rem} * for the upstream issue that necessitates this. */ body>.dropdown-menu {z-index: 2100;} + +/* Styles for eg-daterange-select that don't work + * in the component's CSS file. + */ +.ngb-dp-day:not(.disabled) .daterange-day.focused { + background-color: #e6e6e6; +} +.ngb-dp-day:not(.disabled) .daterange-day.range, .ngb-dp-day:not(.disabled) .daterange-day:hover { + background-color: #129a78; + color: white; + font-size: 1.4em; +} +.ngb-dp-day:not(.disabled) .daterange-day.faded { + background-color: #c9efe4; + color: black; +} -- 2.43.2