From 8237c2ef3fbc3863eddbe92ee6059e6eeda6bd88 Mon Sep 17 00:00:00 2001 From: Jane Sandberg Date: Wed, 8 May 2019 15:06:22 -0700 Subject: [PATCH] LP1816475: 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 --- Open-ILS/src/eg2/package.json | 2 + Open-ILS/src/eg2/src/app/core/format.service.ts | 62 +++++++++++++-- .../daterange-select.component.html | 14 ++++ .../daterange-select/daterange-select.component.ts | 64 +++++++++++++++ .../datetime-select/datetime-select.component.html | 54 +++++++++++++ .../datetime-select/datetime-select.component.ts | 90 ++++++++++++++++++++++ Open-ILS/src/eg2/src/app/staff/common.module.ts | 6 ++ 7 files changed, 287 insertions(+), 5 deletions(-) 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.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 diff --git a/Open-ILS/src/eg2/package.json b/Open-ILS/src/eg2/package.json index 98b52939e5..30e1d7a3e2 100644 --- a/Open-ILS/src/eg2/package.json +++ b/Open-ILS/src/eg2/package.json @@ -28,6 +28,8 @@ "bootstrap-css-only": "^4.2.1", "core-js": "^2.6.3", "file-saver": "^2.0.0", + "moment": "2.24.0", + "moment-timezone": "0.5.23", "ngx-cookie": "^4.1.2", "rxjs": "^6.4.0", "zone.js": "^0.8.29" 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 d2b2ce51e2..aa2cde078e 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,12 +109,15 @@ export class FormatService { return org ? org[orgField]() : ''; case 'timestamp': - const date = new Date(value); - let fmt = this.dateFormat || 'shortDate'; - if (params.datePlusTime) { - fmt = this.dateTimeFormat || 'short'; + const date = Moment(value).tz(this.wsOrgTimezone); + if (date) { + let fmt = this.dateFormat || 'shortDate'; + if (params.datePlusTime) { + fmt = this.dateTimeFormat || 'short'; + } + return this.datePipe.transform(date.toISOString(true), fmt, date.format('ZZ')); } - return this.datePipe.transform(date, fmt); + return value; case 'money': return this.currencyPipe.transform(value); @@ -130,6 +135,53 @@ 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(); } + + momentizeDateString(date: string, timezone: string): Moment { + const parseableFormat = this.makeFormatParseable(this.dateFormat); + if (parseableFormat.length) { return Moment.tz(date, parseableFormat, timezone); } + // TODO: The following fallback returns the date at midnight UTC, + // rather than midnight in the local TZ + return Moment.tz(date, timezone); + } + + momentizeDateTimeString(datetime: string, timezone: string): Moment { + const parseableFormat = this.makeFormatParseable(this.dateTimeFormat); + if (parseableFormat.length) { return Moment(datetime, parseableFormat, timezone); } + return Moment(datetime, timezone); + } + + /** + * Takes a dateFormate 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 { + const specialFormats = ['short', 'medium', 'long', 'full', + 'shortDate', 'mediumDate', 'longDate', 'fullDate', + 'shortTime', 'mediumTime', 'longTime', 'fullTime']; + if (!original || specialFormats.includes(original)) { return ''; }; + return original + .replace(/a+/, 'a') // MomentJs can handle all sorts of meridian strings + .replace('d', 'D') // MomentJs capitalizes day of month + .replace('EEEEEE', '') // MomentJs does not handle short day of week + .replace('EEEEE', '') // MomentJs does not handle narrow day of week + .replace('EEEE', 'dddd') // MomentJs has different syntax for long day of week + .replace(/E{1,3}/, 'ddd') // MomentJs has different syntax for abbreviated day of week + .replace('L', 'M') // MomentJs does not differentiate between month and month standalone + .replace('W', '') // MomentJs uses W for something else + .replace('y', 'Y'); // MomentJs capitalizes year + } } 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..66bddcf612 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.html @@ -0,0 +1,14 @@ + + + + + + {{ date.day }} + + + 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..7441d97401 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.ts @@ -0,0 +1,64 @@ +import {Component, Output, EventEmitter} from '@angular/core'; +import {NgbDate, NgbCalendar} from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'eg-daterange-select', + templateUrl: './daterange-select.component.html', + styles: [` + .custom-day { + text-align: center; + padding: 0.185rem 0.25rem; + display: inline-block; + height: 2rem; + width: 2rem; + } + .custom-day.focused { + background-color: #e6e6e6; + } + .custom-day.range, .custom-day:hover { + background-color: rgb(2, 117, 216); + color: white; + } + .custom-day.faded { + background-color: rgba(2, 117, 216, 0.5); + } + `] +}) +export class DateRangeSelectComponent { + + hoveredDate: NgbDate; + + fromDate: NgbDate; + toDate: NgbDate; + + @Output() onChange = new EventEmitter<{start: NgbDate, end: NgbDate}>(); + + constructor(calendar: NgbCalendar) { + this.fromDate = calendar.getToday(); + this.toDate = calendar.getNext(calendar.getToday(), 'd', 10); + } + + onDateSelection(date: NgbDate) { + if (!this.fromDate && !this.toDate) { + this.fromDate = date; + } else if (this.fromDate && !this.toDate && date.after(this.fromDate)) { + this.toDate = date; + } else { + this.toDate = null; + this.fromDate = date; + } + this.onChange.emit({start: this.fromDate, end: this.toDate}); + } + + isHovered(date: NgbDate) { + return this.fromDate && !this.toDate && this.hoveredDate && date.after(this.fromDate) && date.before(this.hoveredDate); + } + + isInside(date: NgbDate) { + return date.after(this.fromDate) && date.before(this.toDate); + } + + isRange(date: NgbDate) { + return date.equals(this.fromDate) || date.equals(this.toDate) || this.isInside(date) || this.isHovered(date); + } +} 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..8a6e8ca01a --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.html @@ -0,0 +1,54 @@ +error +
+ +
+ +
+
+ + +
+ Cannot edit this date or time. +
+
+
+ error + {{validatorError}} +
+ + +
+
+ + + + {{ timezone || 'America/Los_Angeles'}} + + + 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..33da14e2a7 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.ts @@ -0,0 +1,90 @@ +import { Component, Input, Output, EventEmitter, ViewChild, OnInit } from '@angular/core'; +import { ControlValueAccessor } from '@angular/forms'; +import {FormatService} from '@eg/core/format.service'; + +import * as Moment from 'moment-timezone'; + +import { NgbTimeStruct, NgbDateStruct } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'eg-datetime-select', + templateUrl: './datetime-select.component.html' +}) +export class DateTimeSelectComponent implements OnInit { + @Input() domId = ''; + @Input() fieldName: string; + @Input() required: boolean; + @Input() minuteStep = 15; + @Input() showTZ = true; + @Input() timezone: string = this.format.wsOrgTimezone; + @Input() readOnly = false; + @Input() validatorError = ''; + + @Input() initialIso: string; + @Input() initialMoment: Moment; + + @Output() onChangeAsIso = new EventEmitter(); + @Output() onChangeAsMoment = new EventEmitter(); + + stringVersion: any; // Used internally on internal input + timeModel: NgbTimeStruct; + dateModel: NgbDateStruct; + + constructor( + private format: FormatService + ) { + } + + ngOnInit() { + let start = this.initialIso ? Moment.tz(this.initialIso, Moment.ISO_8601, this.timezone) : Moment.tz([], this.timezone); + if (this.initialMoment) { start = this.initialMoment; } + this.stringVersion = this.format.transform({value: start, datatype: 'timestamp', datePlusTime: true}); + this.setDatePicker(start); + this.setTimePicker(start); + + } + + setDatePicker(current: Moment) { + this.dateModel = { + year: current.tz(this.timezone).year(), + month: current.tz(this.timezone).month() + 1, + day: current.tz(this.timezone).date() }; + } + + setTimePicker(current: Moment) { + this.timeModel = { + hour: current.tz(this.timezone).hour(), + minute: current.tz(this.timezone).minute(), + second: 0 }; + } + + handleDateChanged($event) { + const newDate = Moment.tz([$event.year, ($event.month - 1), $event.day, + this.timeModel.hour, this.timeModel.minute, 0], this.timezone); + this.stringVersion = this.format.transform({value: newDate, datatype: 'timestamp', datePlusTime: true}); + this.onChangeAsMoment.emit(newDate); + this.onChangeAsIso.emit(newDate.toISOString()); + } + + handleTimeChanged(event) { + const newDate = Moment.tz([this.dateModel.year, (this.dateModel.month - 1), this.dateModel.day, + this.timeModel.hour, this.timeModel.minute, 0], this.timezone); + this.stringVersion = this.format.transform({value: newDate, datatype: 'timestamp', datePlusTime: true}); + this.onChangeAsMoment.emit(newDate); + this.onChangeAsIso.emit(newDate.toISOString()); + } + + handleInputChanged(event) { + let newDate: Moment; + + if (event) { + newDate = this.format.momentizeDateTimeString(this.stringVersion, this.timezone); + this.setDatePicker(newDate); + this.setTimePicker(newDate); + this.onChangeAsMoment.emit(newDate); + this.onChangeAsIso.emit(newDate.toISOString()); + } + } + +} + 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 9c822a2c6c..65f29eb287 100644 --- a/Open-ILS/src/eg2/src/app/staff/common.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/common.module.ts @@ -16,8 +16,10 @@ import {StringComponent} from '@eg/share/string/string.component'; import {StringService} from '@eg/share/string/string.service'; import {TitleComponent} from '@eg/share/title/title.component'; import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component'; +import {DateRangeSelectComponent} from '@eg/share/daterange-select/daterange-select.component'; import {DateSelectComponent} from '@eg/share/date-select/date-select.component'; import {BucketDialogComponent} from '@eg/staff/share/buckets/bucket-dialog.component'; +import {DateTimeSelectComponent} from '@eg/share/datetime-select/datetime-select.component'; import {BibSummaryComponent} from '@eg/staff/share/bib-summary/bib-summary.component'; import {TranslateComponent} from '@eg/staff/share/translate/translate.component'; import {AdminPageComponent} from '@eg/staff/share/admin-page/admin-page.component'; @@ -39,8 +41,10 @@ import {AdminPageComponent} from '@eg/staff/share/admin-page/admin-page.componen TitleComponent, OpChangeComponent, FmRecordEditorComponent, + DateRangeSelectComponent, DateSelectComponent, BucketDialogComponent, + DateTimeSelectComponent, BibSummaryComponent, TranslateComponent, AdminPageComponent @@ -63,8 +67,10 @@ import {AdminPageComponent} from '@eg/staff/share/admin-page/admin-page.componen TitleComponent, OpChangeComponent, FmRecordEditorComponent, + DateRangeSelectComponent, DateSelectComponent, BucketDialogComponent, + DateTimeSelectComponent, BibSummaryComponent, TranslateComponent, AdminPageComponent -- 2.11.0