From b716046b971aa4e70d9cc1b2b6080478d4aed77b Mon Sep 17 00:00:00 2001 From: Pouria Alimirzaei Date: Sat, 4 Jul 2015 22:34:54 +0430 Subject: [PATCH] feat(pipes): add date pipe Closes #2877 --- modules/angular2/pipes.ts | 1 + .../src/change_detection/change_detection.ts | 13 ++- .../src/change_detection/pipes/date_pipe.ts | 102 ++++++++++++++++++ modules/angular2/src/facade/intl.dart | 18 ++++ modules/angular2/src/facade/intl.ts | 83 ++++++++++++-- modules/angular2/src/facade/lang.dart | 9 +- modules/angular2/src/facade/lang.ts | 14 ++- .../change_detection/pipes/date_pipe_spec.ts | 64 +++++++++++ 8 files changed, 292 insertions(+), 12 deletions(-) create mode 100644 modules/angular2/src/change_detection/pipes/date_pipe.ts create mode 100644 modules/angular2/test/change_detection/pipes/date_pipe_spec.ts diff --git a/modules/angular2/pipes.ts b/modules/angular2/pipes.ts index fd863db924..d7ea745679 100644 --- a/modules/angular2/pipes.ts +++ b/modules/angular2/pipes.ts @@ -12,5 +12,6 @@ export {ObservablePipe} from './src/change_detection/pipes/observable_pipe'; export {JsonPipe} from './src/change_detection/pipes/json_pipe'; export {IterableChanges} from './src/change_detection/pipes/iterable_changes'; export {KeyValueChanges} from './src/change_detection/pipes/keyvalue_changes'; +export {DatePipe} from './src/change_detection/pipes/date_pipe'; export {DecimalPipe, PercentPipe, CurrencyPipe} from './src/change_detection/pipes/number_pipe'; export {LimitToPipe} from './src/change_detection/pipes/limit_to_pipe'; diff --git a/modules/angular2/src/change_detection/change_detection.ts b/modules/angular2/src/change_detection/change_detection.ts index 1692730bc7..015707e869 100644 --- a/modules/angular2/src/change_detection/change_detection.ts +++ b/modules/angular2/src/change_detection/change_detection.ts @@ -11,6 +11,7 @@ import {UpperCaseFactory} from './pipes/uppercase_pipe'; import {LowerCaseFactory} from './pipes/lowercase_pipe'; import {JsonPipe} from './pipes/json_pipe'; import {LimitToPipeFactory} from './pipes/limit_to_pipe'; +import {DatePipe} from './pipes/date_pipe'; import {DecimalPipe, PercentPipe, CurrencyPipe} from './pipes/number_pipe'; import {NullPipeFactory} from './pipes/null_pipe'; import {ChangeDetection, ProtoChangeDetector, ChangeDetectorDefinition} from './interfaces'; @@ -101,6 +102,15 @@ export const percent: List = export const currency: List = CONST_EXPR([CONST_EXPR(new CurrencyPipe()), CONST_EXPR(new NullPipeFactory())]); +/** + * Date/time formatter. + * + * @exportedAs angular2/pipes + */ +export const date: List = + CONST_EXPR([CONST_EXPR(new DatePipe()), CONST_EXPR(new NullPipeFactory())]); + + export const defaultPipes = CONST_EXPR({ "iterableDiff": iterableDiff, "keyValDiff": keyValDiff, @@ -111,7 +121,8 @@ export const defaultPipes = CONST_EXPR({ "limitTo": limitTo, "number": decimal, "percent": percent, - "currency": currency + "currency": currency, + "date": date }); /** diff --git a/modules/angular2/src/change_detection/pipes/date_pipe.ts b/modules/angular2/src/change_detection/pipes/date_pipe.ts new file mode 100644 index 0000000000..9984337a44 --- /dev/null +++ b/modules/angular2/src/change_detection/pipes/date_pipe.ts @@ -0,0 +1,102 @@ +import { + isDate, + isNumber, + isPresent, + Date, + DateWrapper, + CONST, + FunctionWrapper +} from 'angular2/src/facade/lang'; +import {DateFormatter} from 'angular2/src/facade/intl'; +import {StringMapWrapper, ListWrapper} from 'angular2/src/facade/collection'; +import {Pipe, BasePipe, PipeFactory} from './pipe'; +import {ChangeDetectorRef} from '../change_detector_ref'; + +// TODO: move to a global configable location along with other i18n components. +var defaultLocale: string = 'en-US'; + +/** + * Formats a date value to a string based on the requested format. + * + * # Usage + * + * expression | date[:format] + * + * where `expression` is a date object or a number (milliseconds since UTC epoch) and + * `format` indicates which date/time components to include: + * + * | Component | Symbol | Short Form | Long Form | Numeric | 2-digit | + * |-----------|:------:|--------------|-------------------|-----------|-----------| + * | era | G | G (AD) | GGGG (Anno Domini)| - | - | + * | year | y | - | - | y (2015) | yy (15) | + * | month | M | MMM (Sep) | MMMM (September) | M (9) | MM (09) | + * | day | d | - | - | d (3) | dd (03) | + * | weekday | E | EEE (Sun) | EEEE (Sunday) | - | - | + * | hour | j | - | - | j (13) | jj (13) | + * | hour12 | h | - | - | h (1 PM) | hh (01 PM)| + * | hour24 | H | - | - | H (13) | HH (13) | + * | minute | m | - | - | m (5) | mm (05) | + * | second | s | - | - | s (9) | ss (09) | + * | timezone | z | - | z (Pacific Standard Time)| - | - | + * | timezone | Z | Z (GMT-8:00) | - | - | - | + * + * In javascript, only the components specified will be respected (not the ordering, + * punctuations, ...) and details of the the formatting will be dependent on the locale. + * On the other hand in Dart version, you can also include quoted text as well as some extra + * date/time components such as quarter. For more information see: + * https://api.dartlang.org/apidocs/channels/stable/dartdoc-viewer/intl/intl.DateFormat. + * + * `format` can also be one of the following predefined formats: + * + * - `'medium'`: equivalent to `'yMMMdjms'` (e.g. Sep 3, 2010, 12:05:08 PM for en-US) + * - `'short'`: equivalent to `'yMdjm'` (e.g. 9/3/2010, 12:05 PM for en-US) + * - `'fullDate'`: equivalent to `'yMMMMEEEEd'` (e.g. Friday, September 3, 2010 for en-US) + * - `'longDate'`: equivalent to `'yMMMMd'` (e.g. September 3, 2010) + * - `'mediumDate'`: equivalent to `'yMMMd'` (e.g. Sep 3, 2010 for en-US) + * - `'shortDate'`: equivalent to `'yMd'` (e.g. 9/3/2010 for en-US) + * - `'mediumTime'`: equivalent to `'jms'` (e.g. 12:05:08 PM for en-US) + * - `'shortTime'`: equivalent to `'jm'` (e.g. 12:05 PM for en-US) + * + * Timezone of the formatted text will be the local system timezone of the end-users machine. + * + * # Examples + * + * Assuming `dateObj` is (year: 2015, month: 6, day: 15, hour: 21, minute: 43, second: 11) + * in the _local_ time and locale is 'en-US': + * + * {{ dateObj | date }} // output is 'Jun 15, 2015' + * {{ dateObj | date:'medium' }} // output is 'Jun 15, 2015, 9:43:11 PM' + * {{ dateObj | date:'shortTime' }} // output is '9:43 PM' + * {{ dateObj | date:'mmss' }} // output is '43:11' + * + * @exportedAs angular2/pipes + */ +@CONST() +export class DatePipe extends BasePipe implements PipeFactory { + static _ALIASES = { + 'medium': 'yMMMdjms', + 'short': 'yMdjm', + 'fullDate': 'yMMMMEEEEd', + 'longDate': 'yMMMMd', + 'mediumDate': 'yMMMd', + 'shortDate': 'yMd', + 'mediumTime': 'jms', + 'shortTime': 'jm' + }; + + + transform(value, args: List): string { + var pattern: string = isPresent(args) && args.length > 0 ? args[0] : 'mediumDate'; + if (isNumber(value)) { + value = DateWrapper.fromMillis(value); + } + if (StringMapWrapper.contains(DatePipe._ALIASES, pattern)) { + pattern = StringMapWrapper.get(DatePipe._ALIASES, pattern); + } + return DateFormatter.format(value, defaultLocale, pattern); + } + + supports(obj): boolean { return isDate(obj) || isNumber(obj); } + + create(cdRef: ChangeDetectorRef): Pipe { return this } +} diff --git a/modules/angular2/src/facade/intl.dart b/modules/angular2/src/facade/intl.dart index d513569aa0..56b1fa657d 100644 --- a/modules/angular2/src/facade/intl.dart +++ b/modules/angular2/src/facade/intl.dart @@ -40,3 +40,21 @@ class NumberFormatter { return formatter.format(number); } } + +class DateFormatter { + static RegExp _multiPartRegExp = new RegExp(r'^([yMdE]+)([Hjms]+)$'); + + static String format(DateTime date, String locale, String pattern) { + locale = _normalizeLocale(locale); + var formatter = new DateFormat(null, locale); + var matches = _multiPartRegExp.firstMatch(pattern); + if (matches != null) { + // Support for patterns which have known date and time components. + formatter.addPattern(matches[1]); + formatter.addPattern(matches[2], ', '); + } else { + formatter.addPattern(pattern); + } + return formatter.format(date); + } +} diff --git a/modules/angular2/src/facade/intl.ts b/modules/angular2/src/facade/intl.ts index 238327e90d..84d3b4fc36 100644 --- a/modules/angular2/src/facade/intl.ts +++ b/modules/angular2/src/facade/intl.ts @@ -14,9 +14,7 @@ declare module Intl { format(value: number): string; } - var NumberFormat: { - new (locale?: string, options?: NumberFormatOptions): NumberFormat; - } + var NumberFormat: { new (locale?: string, options?: NumberFormatOptions): NumberFormat; } interface DateTimeFormatOptions { localeMatcher?: string; @@ -37,9 +35,7 @@ declare module Intl { format(date?: Date | number): string; } - var DateTimeFormat: { - new (locale?: string, options?: DateTimeFormatOptions): DateTimeFormat; - } + var DateTimeFormat: { new (locale?: string, options?: DateTimeFormatOptions): DateTimeFormat; } } export enum NumberFormatStyle { @@ -71,3 +67,78 @@ export class NumberFormatter { return new Intl.NumberFormat(locale, intlOptions).format(number); } } + +function digitCondition(len: int): string { + return len == 2 ? '2-digit' : 'numeric'; +} +function nameCondition(len: int): string { + return len < 4 ? 'short' : 'long'; +} +function extractComponents(pattern: string): Intl.DateTimeFormatOptions { + var ret: Intl.DateTimeFormatOptions = {}; + var i = 0, j; + while (i < pattern.length) { + j = i; + while (j < pattern.length && pattern[j] == pattern[i]) j++; + let len = j - i; + switch (pattern[i]) { + case 'G': + ret.era = nameCondition(len); + break; + case 'y': + ret.year = digitCondition(len); + break; + case 'M': + if (len >= 3) + ret.month = nameCondition(len); + else + ret.month = digitCondition(len); + break; + case 'd': + ret.day = digitCondition(len); + break; + case 'E': + ret.weekday = nameCondition(len); + break; + case 'j': + ret.hour = digitCondition(len); + break; + case 'h': + ret.hour = digitCondition(len); + ret.hour12 = true; + break; + case 'H': + ret.hour = digitCondition(len); + ret.hour12 = false; + break; + case 'm': + ret.minute = digitCondition(len); + break; + case 's': + ret.second = digitCondition(len); + break; + case 'z': + ret.timeZoneName = 'long'; + break; + case 'Z': + ret.timeZoneName = 'short'; + break; + } + i = j; + } + return ret; +} + +var dateFormatterCache: Map = new Map(); + +export class DateFormatter { + static format(date: Date, locale: string, pattern: string): string { + var key = locale + pattern; + if (dateFormatterCache.has(key)) { + return dateFormatterCache.get(key).format(date); + } + var formatter = new Intl.DateTimeFormat(locale, extractComponents(pattern)); + dateFormatterCache.set(key, formatter); + return formatter.format(date); + } +} diff --git a/modules/angular2/src/facade/lang.dart b/modules/angular2/src/facade/lang.dart index 9f2d7c46d2..9c6f055861 100644 --- a/modules/angular2/src/facade/lang.dart +++ b/modules/angular2/src/facade/lang.dart @@ -33,6 +33,7 @@ bool isStringMap(obj) => obj is Map; bool isArray(obj) => obj is List; bool isPromise(obj) => obj is Future; bool isNumber(obj) => obj is num; +bool isDate(obj) => obj is DateTime; String stringify(obj) => obj.toString(); @@ -232,8 +233,12 @@ class Json { } class DateWrapper { + static DateTime create(int year, [int month = 1, int day = 1, int hour = 0, + int minutes = 0, int seconds = 0, int milliseconds = 0]) { + return new DateTime(year, month, day, hour, minutes, seconds, milliseconds); + } static DateTime fromMillis(int ms) { - return new DateTime.fromMillisecondsSinceEpoch(ms); + return new DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true); } static int toMillis(DateTime date) { return date.millisecondsSinceEpoch; @@ -241,7 +246,7 @@ class DateWrapper { static DateTime now() { return new DateTime.now(); } - static toJson(DateTime date) { + static String toJson(DateTime date) { return date.toUtc().toIso8601String(); } } diff --git a/modules/angular2/src/facade/lang.ts b/modules/angular2/src/facade/lang.ts index 929b453533..1bf4e4dd78 100644 --- a/modules/angular2/src/facade/lang.ts +++ b/modules/angular2/src/facade/lang.ts @@ -93,6 +93,10 @@ export function isNumber(obj): boolean { return typeof obj === 'number'; } +export function isDate(obj): boolean { + return obj instanceof Date && !isNaN(obj.valueOf()); +} + export function stringify(token): string { if (typeof token === 'string') { return token; @@ -282,8 +286,12 @@ export class Json { } export class DateWrapper { - static fromMillis(ms): Date { return new Date(ms); } - static toMillis(date: Date): number { return date.getTime(); } + static create(year: int, month: int = 1, day: int = 1, hour: int = 0, minutes: int = 0, + seconds: int = 0, milliseconds: int = 0): Date { + return new Date(year, month - 1, day, hour, minutes, seconds, milliseconds); + } + static fromMillis(ms: int): Date { return new Date(ms); } + static toMillis(date: Date): int { return date.getTime(); } static now(): Date { return new Date(); } - static toJson(date): string { return date.toJSON(); } + static toJson(date: Date): string { return date.toJSON(); } } diff --git a/modules/angular2/test/change_detection/pipes/date_pipe_spec.ts b/modules/angular2/test/change_detection/pipes/date_pipe_spec.ts new file mode 100644 index 0000000000..a8618f266f --- /dev/null +++ b/modules/angular2/test/change_detection/pipes/date_pipe_spec.ts @@ -0,0 +1,64 @@ +import {ddescribe, describe, it, iit, xit, expect, beforeEach, afterEach} from 'angular2/test_lib'; + +import {DatePipe} from 'angular2/src/change_detection/pipes/date_pipe'; +import {DateWrapper} from 'angular2/src/facade/lang'; + +export function main() { + describe("DatePipe", () => { + var date; + var pipe; + + beforeEach(() => { + date = DateWrapper.create(2015, 6, 15, 21, 43, 11); + pipe = new DatePipe(); + }); + + describe("supports", () => { + it("should support date", () => { expect(pipe.supports(date)).toBe(true); }); + it("should support int", () => { expect(pipe.supports(123456789)).toBe(true); }); + + it("should not support other objects", () => { + expect(pipe.supports(new Object())).toBe(false); + expect(pipe.supports(null)).toBe(false); + }); + }); + + describe("transform", () => { + it('should format each component correctly', () => { + expect(pipe.transform(date, ['y'])).toEqual('2015'); + expect(pipe.transform(date, ['yy'])).toEqual('15'); + expect(pipe.transform(date, ['M'])).toEqual('6'); + expect(pipe.transform(date, ['MM'])).toEqual('06'); + expect(pipe.transform(date, ['MMM'])).toEqual('Jun'); + expect(pipe.transform(date, ['MMMM'])).toEqual('June'); + expect(pipe.transform(date, ['d'])).toEqual('15'); + expect(pipe.transform(date, ['E'])).toEqual('Mon'); + expect(pipe.transform(date, ['EEEE'])).toEqual('Monday'); + expect(pipe.transform(date, ['H'])).toEqual('21'); + expect(pipe.transform(date, ['j'])).toEqual('9 PM'); + expect(pipe.transform(date, ['m'])).toEqual('43'); + expect(pipe.transform(date, ['s'])).toEqual('11'); + }); + + it('should format common multi component patterns', () => { + expect(pipe.transform(date, ['yMEd'])).toEqual('Mon, 6/15/2015'); + expect(pipe.transform(date, ['MEd'])).toEqual('Mon, 6/15'); + expect(pipe.transform(date, ['MMMd'])).toEqual('Jun 15'); + expect(pipe.transform(date, ['yMMMMEEEEd'])).toEqual('Monday, June 15, 2015'); + expect(pipe.transform(date, ['jms'])).toEqual('9:43:11 PM'); + expect(pipe.transform(date, ['ms'])).toEqual('43:11'); + }); + + it('should format with pattern aliases', () => { + expect(pipe.transform(date, ['medium'])).toEqual('Jun 15, 2015, 9:43:11 PM'); + expect(pipe.transform(date, ['short'])).toEqual('6/15/2015, 9:43 PM'); + expect(pipe.transform(date, ['fullDate'])).toEqual('Monday, June 15, 2015'); + expect(pipe.transform(date, ['longDate'])).toEqual('June 15, 2015'); + expect(pipe.transform(date, ['mediumDate'])).toEqual('Jun 15, 2015'); + expect(pipe.transform(date, ['shortDate'])).toEqual('6/15/2015'); + expect(pipe.transform(date, ['mediumTime'])).toEqual('9:43:11 PM'); + expect(pipe.transform(date, ['shortTime'])).toEqual('9:43 PM'); + }); + }); + }); +}