LP1816475: Add date-related components to Angular client.
authorJane Sandberg <sandbej@linnbenton.edu>
Wed, 8 May 2019 22:06:22 +0000 (15:06 -0700)
committerJane Sandberg <sandbej@linnbenton.edu>
Wed, 19 Jun 2019 21:59:14 +0000 (14:59 -0700)
* 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 <sandbej@linnbenton.edu>
Open-ILS/src/eg2/package.json
Open-ILS/src/eg2/src/app/core/format.service.ts
Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/datetime-select/datetime-select.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/common.module.ts

index 98b5293..30e1d7a 100644 (file)
@@ -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"
index d2b2ce5..aa2cde0 100644 (file)
@@ -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 (file)
index 0000000..66bddcf
--- /dev/null
@@ -0,0 +1,14 @@
+<ngb-datepicker #dp (select)="onDateSelection($event)" [displayMonths]="2" [dayTemplate]="t" outsideDays="hidden">
+</ngb-datepicker>
+
+<ng-template #t let-date let-focused="focused">
+  <span class="custom-day"
+        [class.focused]="focused"
+        [class.range]="isRange(date)"
+        [class.faded]="isHovered(date) || isInside(date)"
+        (mouseenter)="hoveredDate = date"
+        (mouseleave)="hoveredDate = null">
+    {{ date.day }}
+  </span>
+</ng-template>
+
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 (file)
index 0000000..7441d97
--- /dev/null
@@ -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 (file)
index 0000000..8a6e8ca
--- /dev/null
@@ -0,0 +1,54 @@
+<span class="material-icons" *ngIf="validatorError">error</span>
+<div class="input-group">
+  <input type="datetime"
+    [attr.id]="domId.length ? domId : null" 
+    name="{{fieldName}}"
+    class="form-control"
+    [placeholder]="initialIso"
+    [(ngModel)]="stringVersion"
+    (blur)="handleInputChanged($event)"
+    (change)="handleInputChanged($event)"
+    #dtPicker="ngbPopover"
+    [ngbPopover]="dt"
+    placement="bottom"
+    [attr.disabled]="readOnly ? true : null"
+    [autoClose]="'outside'"
+    popoverTitle="Select date and time"
+    i18n-popoverTitle
+    [required]="required">
+  <div class="input-group-btn">
+    <button class="btn btn-primary" (click)="dtPicker.toggle()">
+      <span title="Select Date" i18n-title
+        class="material-icons mat-icon-in-button">calendar_today</span>
+    </button>
+  </div>
+</div>
+
+<ng-template #dt>
+  <div i18n *ngIf="readOnly">
+    Cannot edit this date or time.
+  </div>
+  <div *ngIf="!readOnly">
+    <div *ngIf="validatorError" class="alert alert-danger">
+      <span class="material-icons">error</span>
+      {{validatorError}}
+    </div>
+    <ngb-datepicker
+      [(ngModel)]="dateModel"
+      (select)="handleDateChanged($event)"
+      [footerTemplate]="time">
+    </ngb-datepicker>
+  </div>
+</ng-template>
+<ng-template #time>
+  <ngb-timepicker name="time"
+    [(ngModel)]="timeModel" [meridian]="true"
+    (ngModelChange)="handleTimeChanged()"
+    [spinners]="true"
+    [hourStep]="1"
+    [minuteStep]="minuteStep || 30" >
+  </ngb-timepicker>
+  <span *ngIf="showTZ" class="badge badge-info">{{ timezone || 'America/Los_Angeles'}}</span>
+  <button i18n class="btn btn-success" (click)="dtPicker.close()">Choose time</button>
+</ng-template>
+
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 (file)
index 0000000..33da14e
--- /dev/null
@@ -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<string>();
+    @Output() onChangeAsMoment = new EventEmitter<Moment>();
+
+    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());
+        }
+    }
+
+}
+
index 9c822a2..65f29eb 100644 (file)
@@ -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