LP1834662: Add date-related components to Angular client.
authorJane Sandberg <sandbej@linnbenton.edu>
Wed, 8 May 2019 22:06:22 +0000 (15:06 -0700)
committerBill Erickson <berickxx@gmail.com>
Mon, 26 Aug 2019 15:47:28 +0000 (11:47 -0400)
* 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>
Signed-off-by: Bill Erickson <berickxx@gmail.com>
16 files changed:
Open-ILS/src/eg2/package.json
Open-ILS/src/eg2/src/app/common.module.ts
Open-ILS/src/eg2/src/app/core/format.service.ts
Open-ILS/src/eg2/src/app/core/format.spec.ts
Open-ILS/src/eg2/src/app/share/common-widgets.module.ts
Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.css [new file with mode: 0644]
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.spec.ts [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/share/validators/datetime_validator.directive.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/common.module.ts
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts
Open-ILS/src/eg2/src/styles.css

index cbc8f27..1bafd4d 100644 (file)
@@ -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"
index 76394c3..ead50f1 100644 (file)
@@ -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,
index 63aeec6..20f0fdf 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,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
+    }
 }
 
 
index 05991df..81b3201 100644 (file)
@@ -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);
+    });
+
 });
 
index 71c5d30..01b16bd 100644 (file)
@@ -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 (file)
index 0000000..4e24456
--- /dev/null
@@ -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 (file)
index 0000000..b88d0ef
--- /dev/null
@@ -0,0 +1,21 @@
+<ngb-datepicker #dp
+  (select)="onDateSelection($event)"
+  [displayMonths]="2"
+  [dayTemplate]="t"
+  [outsideDays]="'hidden'"
+  [markDisabled]="markDisabled">
+</ngb-datepicker>
+
+<ng-template #t let-date let-focused="focused">
+  <span class="daterange-day"
+    [class.focused]="focused"
+    [class.range]="isRange(date)"
+    [class.faded]="isHovered(date) || isInside(date)"
+    [class.today]="date.equals(today())"
+    (touch)="onTouched()"
+    (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.spec.ts b/Open-ILS/src/eg2/src/app/share/daterange-select/daterange-select.component.spec.ts
new file mode 100644 (file)
index 0000000..52a0c47
--- /dev/null
@@ -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<any>;
+    @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<DateRangeSelectComponent>;
+
+    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 (file)
index 0000000..74a1e34
--- /dev/null
@@ -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 (file)
index 0000000..7f13c12
--- /dev/null
@@ -0,0 +1,56 @@
+<span class="material-icons" *ngIf="controlDir && !controlDir.control.valid">error</span>
+<form
+  [formGroup]="dateTimeForm"
+  class="input-group"
+  ngbDropdown
+  [autoClose]="false"
+  #dt="ngbDropdown">
+  <input type="datetime"
+    [attr.id]="domId.length ? domId : null" 
+    name="{{fieldName}}"
+    class="form-control datetime-input"
+    formControlName="stringVersion"
+    (focus)="dt.open()"
+    [attr.disabled]="readOnly ? true : null"
+    [required]="required"
+    (touch)="onTouched()">
+  <div class="input-group-btn">
+    <button class="btn btn-primary" ngbDropdownToggle
+      aria-label="Select date and time" i18n-aria-label>
+      <span class="material-icons mat-icon-in-button">calendar_today</span>
+    </button>
+  </div>
+  <div ngbDropdownMenu>
+    <div i18n *ngIf="readOnly">
+      Cannot edit this date or time.
+    </div>
+    <div *ngIf="!readOnly">
+      <div *ngIf="controlDir && controlDir.control.errors"
+        role="alert"
+        class="alert alert-danger">
+        <span class="material-icons">error</span>
+       {{firstError(controlDir.control.errors)}}
+      </div>
+      <ngb-datepicker #datePicker
+        formControlName="date"
+        [footerTemplate]="time"
+        (touch)="onTouched()">
+      </ngb-datepicker>
+    </div>
+  </div>
+
+  <ng-template #time>
+    <ngb-timepicker name="time"
+      [meridian]="true"
+      formControlName="time"
+      [spinners]="true"
+      [hourStep]="1"
+      [minuteStep]="minuteStep || 30"
+      (touch)="onTouched()">
+    </ngb-timepicker>
+    <span *ngIf="showTZ && timezone" class="badge badge-info">{{ timezone }}</span>
+    <span *ngIf="showTZ && !timezone" class="badge badge-warning" i18n>Timezone not set</span>
+    <button i18n class="btn btn-success" (click)="dt.close()">Choose time</button>
+  </ng-template>
+
+</form>
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..e187413
--- /dev/null
@@ -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<string>;
+
+    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<string>();
+        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 (file)
index 0000000..bed582e
--- /dev/null
@@ -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);
+    }
+}
+
index 66c62c3..969ca37 100644 (file)
@@ -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,
   ]
 })
 
index 2febd8e..b46f85d 100644 (file)
 </eg-grid>
 
 <br/><br/>
+<div class="row">
+  <div class="col">
+    <eg-daterange-select
+      ngModel #myRange="ngModel"
+      [initialRangeStart]="sevenDaysAgo()"
+      [initialRangeLength]="5"
+      [markDisabled]="allFutureDates">
+    </eg-daterange-select>
+    Your range is: {{myRange.value | json}}
+  </div>
+  <div class="col">
+    <form [formGroup]="myTimeForm">
+      <eg-datetime-select
+        formControlName="datetime">
+      </eg-datetime-select>
+      Your datetime is: {{myTimeForm.get('datetime').value | json}}
+    </form>
+  </div>
+</div>
+<br/><br/>
 
 <h4>Grid with filtering</h4>
 <eg-grid #acpGrid idlClass="acp"
index 740d4d1..8ab704a 100644 (file)
@@ -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;
+    }
 }
 
 
index 4d28583..9573b52 100644 (file)
@@ -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;
+}