LP1879517: Surveys shouldn't end before they begin
[evergreen-equinox.git] / Open-ILS / src / eg2 / src / app / core / format.service.ts
1 import {Injectable, Pipe, PipeTransform} from '@angular/core';
2 import {DatePipe, DecimalPipe, getLocaleDateFormat, getLocaleTimeFormat, getLocaleDateTimeFormat, FormatWidth} from '@angular/common';
3 import {IdlService, IdlObject} from '@eg/core/idl.service';
4 import {OrgService} from '@eg/core/org.service';
5 import {AuthService} from '@eg/core/auth.service';
6 import {LocaleService} from '@eg/core/locale.service';
7 import * as moment from 'moment-timezone';
8 import {DateUtil} from '@eg/share/util/date';
9
10 /**
11  * Format IDL vield values for display.
12  */
13
14 declare var OpenSRF;
15
16 export interface FormatParams {
17     value: any;
18     idlClass?: string;
19     idlField?: string;
20     datatype?: string;
21     orgField?: string; // 'shortname' || 'name'
22     datePlusTime?: boolean;
23     timezoneContextOrg?: number;
24     dateOnlyInterval?: string;
25 }
26
27 @Injectable({providedIn: 'root'})
28 export class FormatService {
29
30     dateFormat = 'shortDate';
31     dateTimeFormat = 'short';
32     wsOrgTimezone: string = OpenSRF.tz;
33     tzCache: {[orgId: number]: string} = {};
34
35     constructor(
36         private datePipe: DatePipe,
37         private decimalPipe: DecimalPipe,
38         private idl: IdlService,
39         private org: OrgService,
40         private auth: AuthService,
41         private locale: LocaleService
42     ) {
43
44         // Create an inilne polyfill for Number.isNaN, which is
45         // not available in PhantomJS for unit testing.
46         // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isNaN
47         if (!Number.isNaN) {
48             // "The following works because NaN is the only value
49             // in javascript which is not equal to itself."
50             Number.isNaN = (value: any) => {
51                 return value !== value;
52             };
53         }
54     }
55
56     /**
57      * Create a human-friendly display version of any field type.
58      */
59     transform(params: FormatParams): string {
60         const value = params.value;
61
62         if (   value === undefined
63             || value === null
64             || value === ''
65             || Number.isNaN(value)) {
66             return '';
67         }
68
69         let datatype = params.datatype;
70
71         if (!datatype) {
72             if (params.idlClass && params.idlField) {
73                 datatype = this.idl.classes[params.idlClass]
74                     .field_map[params.idlField].datatype;
75             } else {
76                 // Assume it's a primitive value
77                 return value + '';
78             }
79         }
80
81         switch (datatype) {
82
83             case 'link':
84                 if (typeof value !== 'object') {
85                     return value + ''; // no fleshed value here
86                 }
87
88                 if (!params.idlClass || !params.idlField) {
89                     // Without a full accounting of the field data,
90                     // we can't determine the linked selector field.
91                     return value + '';
92                 }
93
94                 const selector =
95                     this.idl.getLinkSelector(params.idlClass, params.idlField);
96
97                 if (selector && typeof value[selector] === 'function') {
98                     const val = value[selector]();
99
100                     if (Array.isArray(val)) {
101                         // Typically has_many links will not be fleshed,
102                         // but in the off-chance the are, avoid displaying
103                         // an array reference value.
104                         return '';
105                     } else {
106                         return val + '';
107                     }
108
109                 } else {
110
111                     // We have an object with no display selector
112                     // Display its pkey instead to avoid showing [object Object]
113
114                     const pkey = this.idl.classes[params.idlClass].pkey;
115                     if (pkey && typeof value[pkey] === 'function') {
116                         return value[pkey]();
117                     }
118
119                     return '';
120                 }
121
122             case 'org_unit':
123                 const orgField = params.orgField || 'shortname';
124                 const org = this.org.get(value);
125                 return org ? org[orgField]() : '';
126
127             case 'timestamp':
128                 let tz;
129                 if (params.idlField === 'dob') {
130                     // special case: since dob is the only date column that the
131                     // IDL thinks of as a timestamp, the date object comes over
132                     // as a UTC value; apply the correct timezone rather than the
133                     // local one
134                     tz = 'UTC';
135                 } else {
136                     if (params.timezoneContextOrg) {
137                         tz = this.getOrgTz( // support ID or object
138                             this.org.get(params.timezoneContextOrg).id());
139                     } else {
140                         tz = this.wsOrgTimezone;
141                     }
142                 }
143
144                 const date = moment(value).tz(tz);
145                 if (!date || !date.isValid()) {
146                     console.error(
147                         'Invalid date in format service; date=', value, 'tz=', tz);
148                     return '';
149                 }
150
151                 let fmt = this.dateFormat || 'shortDate';
152
153                 if (params.datePlusTime) {
154                     // Time component directly requested
155                     fmt = this.dateTimeFormat || 'short';
156
157                 } else if (params.dateOnlyInterval) {
158                     // Time component displays for non-day-granular intervals.
159                     const secs = DateUtil.intervalToSeconds(params.dateOnlyInterval);
160                     if (secs !== null && secs % 86400 !== 0) {
161                         fmt = this.dateTimeFormat || 'short';
162                     }
163                 }
164
165                 return this.datePipe.transform(date.toISOString(true), fmt, date.format('ZZ'));
166
167             case 'money':
168                 // TODO: this used to use CurrencyPipe, but that injected
169                 // an assumption that the default currency is always going to be
170                 // USD. Since CurrencyPipe doesn't have an apparent way to specify
171                 // that that currency symbol shouldn't be displayed at all, it
172                 // was switched to DecimalPipe
173                 return this.decimalPipe.transform(value, '1.2-2');
174
175             case 'bool':
176                 // Slightly better than a bare 't' or 'f'.
177                 // Note the caller is better off using an <eg-bool/> for
178                 // boolean display.
179                 return Boolean(
180                     value === 't' || value === 1 ||
181                     value === '1' || value === true
182                 ).toString();
183
184             default:
185                 return value + '';
186         }
187     }
188
189     /**
190     Fetch the org timezone from cache when available.  Otherwise,
191     get the timezone from the org unit setting.  The first time
192     this call is made, it may return the incorrect value since
193     it's not a promise-returning method (because format() is not a
194     promise-returning method).  Future calls will return the correct
195     value since it's locally cached.  Since most format() calls are
196     repeated many times for Angular digestion, the end result is that
197     the correct value will be used in the end.
198     */
199     getOrgTz(orgId: number): string {
200
201         if (this.tzCache[orgId] === null) {
202             // We are still waiting for the value to be returned
203             // from the server.
204             return this.wsOrgTimezone;
205         }
206
207         if (this.tzCache[orgId] !== undefined) {
208             // We have a cached value.
209             return this.tzCache[orgId];
210         }
211
212         // Avoid duplicate parallel lookups by indicating we
213         // are loading the value from the server.
214         this.tzCache[orgId] = null;
215
216         this.org.settings(['lib.timezone'], orgId)
217         .then(sets => this.tzCache[orgId] = sets['lib.timezone']);
218
219         // Use the local timezone while we wait for the real value
220         // to load from the server.
221         return this.wsOrgTimezone;
222     }
223
224     /**
225      * Create an IDL-friendly display version of a human-readable date
226      */
227     idlFormatDate(date: string, timezone: string): string { return this.momentizeDateString(date, timezone).format('YYYY-MM-DD'); }
228
229     /**
230      * Create an IDL-friendly display version of a human-readable datetime
231      */
232     idlFormatDatetime(datetime: string, timezone: string): string { return this.momentizeDateTimeString(datetime, timezone).toISOString(); }
233
234     /**
235      * Create a Moment from an ISO string
236      */
237     momentizeIsoString(isoString: string, timezone: string): moment.Moment {
238         return (isoString?.length) ? moment(isoString).tz(timezone) : moment();
239     }
240
241     /**
242      * Turn a date string into a Moment using the date format org setting.
243      */
244     momentizeDateString(date: string, timezone: string, strict?, locale?): moment.Moment {
245         return this.momentize(date, this.makeFormatParseable(this.dateFormat, locale), timezone, strict);
246     }
247
248     /**
249      * Turn a datetime string into a Moment using the datetime format org setting.
250      */
251     momentizeDateTimeString(date: string, timezone: string, strict?, locale?): moment.Moment {
252         return this.momentize(date, this.makeFormatParseable(this.dateTimeFormat, locale), timezone, strict);
253     }
254
255     /**
256      * Turn a string into a Moment using the provided format string.
257      */
258     private momentize(date: string, format: string, timezone: string, strict: boolean): moment.Moment {
259         if (format.length) {
260             const result = moment.tz(date, format, true, timezone);
261             if (!result.isValid()) {
262                 if (strict) {
263                     throw new Error('Error parsing date ' + date);
264                 }
265                 return moment.tz(date, format, false, timezone);
266             }
267         return moment(new Date(date), timezone);
268         }
269     }
270
271     /**
272      * Takes a dateFormat or dateTimeFormat string (which uses Angular syntax) and transforms
273      * it into a format string that MomentJs can use to parse input human-readable strings
274      * (https://momentjs.com/docs/#/parsing/string-format/)
275      *
276      * Returns a blank string if it can't do this transformation.
277      */
278     private makeFormatParseable(original: string, locale?: string): string {
279         if (!original) { return ''; }
280         if (!locale) { locale = this.locale.currentLocaleCode(); }
281         switch (original) {
282             case 'short': {
283                 const template = getLocaleDateTimeFormat(locale, FormatWidth.Short);
284                 const date = getLocaleDateFormat(locale, FormatWidth.Short);
285                 const time = getLocaleTimeFormat(locale, FormatWidth.Short);
286                 original = template
287                     .replace('{1}', date)
288                     .replace('{0}', time)
289                     .replace(/\'(\w+)\'/, '[$1]');
290                 break;
291             }
292             case 'medium': {
293                 const template = getLocaleDateTimeFormat(locale, FormatWidth.Medium);
294                 const date = getLocaleDateFormat(locale, FormatWidth.Medium);
295                 const time = getLocaleTimeFormat(locale, FormatWidth.Medium);
296                 original = template
297                     .replace('{1}', date)
298                     .replace('{0}', time)
299                     .replace(/\'(\w+)\'/, '[$1]');
300                 break;
301             }
302             case 'long': {
303                 const template = getLocaleDateTimeFormat(locale, FormatWidth.Long);
304                 const date = getLocaleDateFormat(locale, FormatWidth.Long);
305                 const time = getLocaleTimeFormat(locale, FormatWidth.Long);
306                 original = template
307                     .replace('{1}', date)
308                     .replace('{0}', time)
309                     .replace(/\'(\w+)\'/, '[$1]');
310                 break;
311             }
312             case 'full': {
313                 const template = getLocaleDateTimeFormat(locale, FormatWidth.Full);
314                 const date = getLocaleDateFormat(locale, FormatWidth.Full);
315                 const time = getLocaleTimeFormat(locale, FormatWidth.Full);
316                 original = template
317                     .replace('{1}', date)
318                     .replace('{0}', time)
319                     .replace(/\'(\w+)\'/, '[$1]');
320                 break;
321             }
322             case 'shortDate': {
323                 original = getLocaleDateFormat(locale, FormatWidth.Short);
324                 break;
325             }
326             case 'mediumDate': {
327                 original = getLocaleDateFormat(locale, FormatWidth.Medium);
328                 break;
329             }
330             case 'longDate': {
331                 original = getLocaleDateFormat(locale, FormatWidth.Long);
332                 break;
333             }
334             case 'fullDate': {
335                 original = getLocaleDateFormat(locale, FormatWidth.Full);
336                 break;
337             }
338             case 'shortTime': {
339                 original = getLocaleTimeFormat(locale, FormatWidth.Short);
340                 break;
341             }
342             case 'mediumTime': {
343                 original = getLocaleTimeFormat(locale, FormatWidth.Medium);
344                 break;
345             }
346             case 'longTime': {
347                 original = getLocaleTimeFormat(locale, FormatWidth.Long);
348                 break;
349             }
350             case 'fullTime': {
351                 original = getLocaleTimeFormat(locale, FormatWidth.Full);
352                 break;
353             }
354         }
355         return original
356             .replace(/a+/g, 'a') // MomentJs can handle all sorts of meridian strings
357             .replace(/d/g, 'D') // MomentJs capitalizes day of month
358             .replace(/EEEEEE/g, '') // MomentJs does not handle short day of week
359             .replace(/EEEEE/g, '') // MomentJs does not handle narrow day of week
360             .replace(/EEEE/g, 'dddd') // MomentJs has different syntax for long day of week
361             .replace(/E{1,3}/g, 'ddd') // MomentJs has different syntax for abbreviated day of week
362             .replace(/L/g, 'M') // MomentJs does not differentiate between month and month standalone
363             .replace(/W/g, '') // MomentJs uses W for something else
364             .replace(/y/g, 'Y') // MomentJs capitalizes year
365             .replace(/ZZZZ|z{1,4}/g, '[GMT]Z') // MomentJs doesn't put "UTC" in front of offset
366             .replace(/Z{2,3}/g, 'Z'); // MomentJs only uses 1 Z
367     }
368 }
369
370
371 // Pipe-ify the above formating logic for use in templates
372 @Pipe({name: 'formatValue'})
373 export class FormatValuePipe implements PipeTransform {
374     constructor(private formatter: FormatService) {}
375     // Add other filter params as needed to fill in the FormatParams
376     transform(value: string, datatype: string): string {
377         return this.formatter.transform({value: value, datatype: datatype});
378     }
379 }
380
381 @Pipe({name: 'egOrgDateInContext'})
382 export class OrgDateInContextPipe implements PipeTransform {
383     constructor(private formatter: FormatService) {}
384
385     transform(value: string, orgId?: number, interval?: string ): string {
386         return this.formatter.transform({
387             value: value,
388             datatype: 'timestamp',
389             timezoneContextOrg: orgId,
390             dateOnlyInterval: interval
391         });
392     }
393 }
394
395 @Pipe({name: 'egDueDate'})
396 export class DueDatePipe implements PipeTransform {
397     constructor(private formatter: FormatService) {}
398
399     transform(circ: IdlObject): string {
400         return this.formatter.transform({
401             value: circ.due_date(),
402             datatype: 'timestamp',
403             timezoneContextOrg: circ.circ_lib(),
404             dateOnlyInterval: circ.duration()
405         });
406     }
407 }
408