LPXXX Native date select / DateUtil class user/berick/lpxxx-date-time-native-widgets
authorBill Erickson <berickxx@gmail.com>
Mon, 19 Aug 2019 16:06:40 +0000 (12:06 -0400)
committerBill Erickson <berickxx@gmail.com>
Tue, 20 Aug 2019 14:22:37 +0000 (10:22 -0400)
Migrate <eg-date-select> away from ng-bootstrap date select to native
browser <input type="date"/> elements.

Create a DateUtil class to package some common date transforms, etc.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Open-ILS/src/eg2/src/app/core/format.service.ts
Open-ILS/src/eg2/src/app/share/date-select/date-select.component.css
Open-ILS/src/eg2/src/app/share/date-select/date-select.component.html
Open-ILS/src/eg2/src/app/share/date-select/date-select.component.ts
Open-ILS/src/eg2/src/app/share/util/date.spec.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/util/date.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.html
Open-ILS/src/eg2/src/app/staff/sandbox/sandbox.component.ts

index 63aeec6..ebf49b9 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 {DateUtil} from '@eg/share/util/date';
 
 /**
  * Format IDL vield values for display.
@@ -107,24 +108,31 @@ export class FormatService {
                 return org ? org[orgField]() : '';
 
             case 'timestamp':
-                const date = new Date(value);
+                let date;
+
+                if (typeof value === 'string'
+                    && value.match(/^\d{4}-\d{2}-\d{2}$/)) {
+                    // Date values provided as YYYY-MM-DD with no time
+                    // zone (e.g. patron 'dob' field) will be parsed as
+                    // UTC dates by Date(...).  We want local dates,
+                    // though, so let DateUtil handle it.
+                    date = DateUtil.ymdToLocalDate(value);
+
+                } else {
+                    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
-                    // IDL thinks of as a timestamp, the date object comes over
-                    // as a UTC value; apply the correct timezone rather than the
-                    // local one
-                    tz = 'UTC';
-                }
-                return this.datePipe.transform(date, fmt, tz);
+
+                return this.datePipe.transform(date, fmt);
 
             case 'money':
                 return this.currencyPipe.transform(value);
index 80f0188..9401baa 100644 (file)
@@ -1,30 +1,14 @@
-<ng-container *ngIf="readOnly">
-  {{initialDate | formatValue:'timestamp'}}
-</ng-container>
-<ng-container *ngIf="!readOnly">
-  <div class="input-group eg-date-select form-validated">
-    <input 
-      class="form-control"
-      type="text"
-      ngbDatepicker
-      #datePicker="ngbDatepicker"
-      [attr.id]="domId.length ? domId : null"
-      placeholder="yyyy-mm-dd"
-      class="form-control"
-      container="body"
-      name="{{fieldName}}"
-      [disabled]="disabled"
-      [required]="required"
-      (blur)="propagateTouch()"
-      [(ngModel)]="current"
-      (keyup.enter)="onDateEnter()"
-      (dateSelect)="onDateSelect($event)"/>
-    <div class="input-group-append">
-      <button class="btn btn-outline-secondary" [disabled]="disabled"
-        (click)="datePicker.toggle()" type="button">
-        <span title="Select Date" i18n-title
-          class="material-icons mat-icon-in-button">calendar_today</span>
-      </button>
-    </div>
-  </div>
-</ng-container>
+<div class="eg-date-select form-validated">
+  <input 
+    class="form-control"
+    container="body"
+    type="date"
+    [readonly]="readOnly"
+    [attr.id]="domId.length ? domId : null"
+    name="{{fieldName}}"
+    [disabled]="disabled"
+    [required]="required"
+    (blur)="propagateTouch()"
+    [(ngModel)]="current"
+    (change)="onDateSelect($event)"/>
+</div>
index 66d363a..811bfcf 100644 (file)
@@ -1,12 +1,6 @@
 import {Component, OnInit, Input, Output, ViewChild, EventEmitter, forwardRef} from '@angular/core';
-import {NgbDateStruct} from '@ng-bootstrap/ng-bootstrap';
 import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
-
-/**
- * RE: displaying locale dates in the input field:
- * https://github.com/ng-bootstrap/ng-bootstrap/issues/754
- * https://stackoverflow.com/questions/40664523/angular2-ngbdatepicker-how-to-format-date-in-inputfield
- */
+import {DateUtil} from '@eg/share/util/date';
 
 @Component({
   selector: 'eg-date-select',
@@ -29,7 +23,7 @@ export class DateSelectComponent implements OnInit, ControlValueAccessor {
     @Input() disabled: boolean;
     @Input() readOnly: boolean;
 
-    current: NgbDateStruct;
+    current: string; // YYYY-MM-DD
 
     @Output() onChangeAsDate: EventEmitter<Date>;
     @Output() onChangeAsIso: EventEmitter<string>;
@@ -38,24 +32,16 @@ export class DateSelectComponent implements OnInit, ControlValueAccessor {
 
     // convenience methods to access current selected date
     currentAsYmd(): string {
-        if (this.current == null) { return null; }
-        if (!this.isValidDate(this.current)) { return null; }
-        return `${this.current.year}-${String(this.current.month).padStart(2, '0')}-${String(this.current.day).padStart(2, '0')}`;
+        return this.current;
     }
+
+    // Returns current date with time zone
     currentAsIso(): string {
-        if (this.current == null) { return null; }
-        if (!this.isValidDate(this.current)) { return null; }
-        const ymd = `${this.current.year}-${String(this.current.month).padStart(2, '0')}-${String(this.current.day).padStart(2, '0')}`;
-        const date = this.localDateFromYmd(ymd);
-        const iso = date.toISOString();
-        return iso;
+        return DateUtil.ymdToLocalIso(this.current);
     }
+
     currentAsDate(): Date {
-        if (this.current == null) { return null; }
-        if (!this.isValidDate(this.current)) { return null; }
-        const ymd = `${this.current.year}-${String(this.current.month).padStart(2, '0')}-${String(this.current.day).padStart(2, '0')}`;
-        const date = this.localDateFromYmd(ymd);
-        return date;
+        return DateUtil.ymdToLocalDate(this.current);
     }
 
     // Stub functions required by ControlValueAccessor
@@ -71,64 +57,39 @@ export class DateSelectComponent implements OnInit, ControlValueAccessor {
 
     ngOnInit() {
 
-        if (this.initialYmd) {
-            this.initialDate = this.localDateFromYmd(this.initialYmd);
+        if (this.initialDate) {
+            this.current = DateUtil.dateToLocalYmd(this.initialDate);
 
         } else if (this.initialIso) {
-            this.initialDate = new Date(this.initialIso);
-        }
+            this.current = DateUtil.isoToLocalYmd(this.initialIso);
 
-        if (this.initialDate) {
-            this.writeValue(this.initialDate);
+        } else if (this.initialYmd) {
+            this.current = this.initialYmd;
         }
     }
 
-    isValidDate(dt: NgbDateStruct): dt is NgbDateStruct {
-        return (<NgbDateStruct>dt).year !== undefined;
-    }
-
-    onDateEnter() {
-        if (this.current === null) {
+    onDateSelect(evt) {
+        if (this.current === '' || this.current === null) {
             this.onCleared.emit('cleared');
-        } else if (this.isValidDate(this.current)) {
-            this.onDateSelect(this.current);
+            return;
         }
-        // ignoring invalid input for now
-    }
 
-    onDateSelect(evt) {
-        const ymd = `${evt.year}-${String(evt.month).padStart(2, '0')}-${String(evt.day).padStart(2, '0')}`;
-        const date = this.localDateFromYmd(ymd);
+        const date = DateUtil.ymdToLocalDate(this.current);
         const iso = date.toISOString();
+
         this.onChangeAsDate.emit(date);
-        this.onChangeAsYmd.emit(ymd);
+        this.onChangeAsYmd.emit(this.current);
         this.onChangeAsIso.emit(iso);
         this.propagateChange(date);
     }
 
-    // Create a date in the local time zone with selected YMD values.
-    // TODO: Consider moving this to a date service...
-    localDateFromYmd(ymd: string): Date {
-        const parts = ymd.split('-');
-        return new Date(
-            Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
-    }
-
     reset() {
-        this.current = {
-            year: null,
-            month: null,
-            day: null
-        };
+        this.current = ''; // browser uses "" when clearing the value
     }
 
     writeValue(value: Date) {
         if (value) {
-            this.current = {
-                year: value.getFullYear(),
-                month: value.getMonth() + 1,
-                day: value.getDate()
-            };
+            this.current = DateUtil.dateToLocalYmd(value);
         }
     }
 
diff --git a/Open-ILS/src/eg2/src/app/share/util/date.spec.ts b/Open-ILS/src/eg2/src/app/share/util/date.spec.ts
new file mode 100644 (file)
index 0000000..2ef5a42
--- /dev/null
@@ -0,0 +1,38 @@
+import {DateUtil} from './date';
+
+// PhantomJS does not yet support String.padStart()
+// Add polyfil for testing.
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padStart#Polyfill
+if (!String.prototype.padStart) {
+    String.prototype.padStart = function padStart(targetLength, padString) {
+        padString = String(typeof padString !== 'undefined' ? padString : ' ');
+        if (this.length >= targetLength) {
+            return String(this);
+        } else {
+            targetLength = targetLength - this.length;
+            if (targetLength > padString.length) {
+                // append to original to ensure we are longer than needed
+                padString += padString.repeat(targetLength / padString.length);
+            }
+            return padString.slice(0, targetLength) + String(this);
+        }
+    };
+}
+
+describe('DateUtil', () => {
+
+    // date and parts in the local time zone
+    const now = new Date();
+    const year = now.getFullYear();
+    const day = String(now.getDate()).padStart(2, '0');
+    const mon = String(now.getMonth() + 1).padStart(2, '0');
+    const ymd = '2020-02-29'; // leap year
+
+    it('Create YMD from date', () => {
+        expect(DateUtil.dateToLocalYmd(now)).toBe(`${year}-${mon}-${day}`);
+    });
+
+    it('Cross walk YMD and ISO', () => {
+        expect(DateUtil.isoToLocalYmd(DateUtil.ymdToLocalIso(ymd))).toBe(ymd);
+    });
+});
diff --git a/Open-ILS/src/eg2/src/app/share/util/date.ts b/Open-ILS/src/eg2/src/app/share/util/date.ts
new file mode 100644 (file)
index 0000000..6f2516f
--- /dev/null
@@ -0,0 +1,42 @@
+/* Collection of date utility functions */
+
+export class DateUtil {
+
+    // Returns a YYYY-MM-DD string in the local time zone matching
+    // the provided Date object.
+    static dateToLocalYmd(d: Date): string {
+        if (!d) { return null; }
+
+        const day = String(d.getDate()).padStart(2, '0');
+        const mon = String(d.getMonth() + 1).padStart(2, '0');
+
+        return `${d.getFullYear()}-${mon}-${day}`;
+    }
+
+    // Returns a YYYY-MM-DD string in the local time zone matching
+    // the provided ISO string.
+    static isoToLocalYmd(iso: string): string {
+        return DateUtil.dateToLocalYmd(new Date(iso));
+    }
+
+    // Return a date object in the local time zone matching the
+    // provided YYYY-MM-DD
+    static ymdToLocalDate(ymd: string): Date {
+        if (!ymd) { return null; }
+
+        // Date(YYYY-MM-DD)   => UTC date.
+        // Date(YYYY, MM, DD) => local date
+
+        const parts = ymd.split('-');
+        return new Date(
+            Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
+    }
+
+    // Return an ISO string in the local time zone matching the
+    // provided YYYY-MM-DD
+    static ymdToLocalIso(ymd: string): string {
+        const d = DateUtil.ymdToLocalDate(ymd);
+        if (!d) { return null; }
+        return d.toISOString();
+    }
+}
index 2febd8e..adb01d3 100644 (file)
     <button class="btn btn-success" (click)="testStrings()">Test Strings</button>
 </div>
 
-<div class="row">
-  <div class="form-group">
-    <eg-date-select (onChangeAsDate)="changeDate($event)"
-        initialYmd="2017-03-04">
+<div class="row mb-3">
+  <div class="col-lg-3"><!-- verify max-width css -->
+    <eg-date-select (onChangeAsDate)="changeDate($event)" 
+      (onCleared)="testDate = null" [initialDate]="testDate">
     </eg-date-select>
   </div>
-  <div>HERE: {{testDate}}</div>
+  <div class="col-lg-9">
+    <div *ngIf="testDate">Date Set To: {{testDate}}</div>
+    <div *ngIf="!testDate">Date Cleared</div>
+  </div>
 </div>
 
 <!-- printing -->
index 740d4d1..c452e15 100644 (file)
@@ -66,7 +66,7 @@ export class SandboxComponent implements OnInit {
 
     renderLocal = false;
 
-    testDate: any;
+    testDate: Date = new Date();
 
     testStr: string;
     @Input() set testString(str: string) {
@@ -324,7 +324,6 @@ export class SandboxComponent implements OnInit {
     }
 
     changeDate(date) {
-        console.log('HERE WITH ' + date);
         this.testDate = date;
     }