From cf41ddfa96ec2202d0044fd6fb3107884dae60da Mon Sep 17 00:00:00 2001 From: Olivier Combe Date: Thu, 20 Jul 2017 11:01:33 +0200 Subject: [PATCH] feat(core): remove dependency to intl API BREAKING CHANGE: all i18n pipes have been updated to not require the intl API anymore. Some results might vary, see the document to update from v4 to v5 for more information. Fixes #10809, #9524, #7008, #9324, #7590, #6724, #3429, #17576, #17478, #17319, #17200, #16838, #16624, #16625, #16591, #14131, #12632, #11376, #11187 --- packages/common/src/common.ts | 7 +- packages/common/src/common_module.ts | 39 +- packages/common/src/directives/ng_plural.ts | 2 +- packages/common/src/i18n/localization.ts | 130 ++++ packages/common/src/localization.ts | 401 ----------- packages/common/src/pipes/date_pipe.ts | 627 ++++++++++++++++-- .../common/src/pipes/deprecated/date_pipe.ts | 179 +++++ packages/common/src/pipes/deprecated/index.ts | 35 + .../common/src/pipes/{ => deprecated}/intl.ts | 7 +- .../src/pipes/deprecated/number_pipe.ts | 179 +++++ packages/common/src/pipes/i18n_plural_pipe.ts | 16 +- packages/common/src/pipes/index.ts | 15 +- packages/common/src/pipes/number_pipe.ts | 459 +++++++++++-- .../directives/ng_component_outlet_spec.ts | 2 +- packages/common/test/localization_spec.ts | 54 +- packages/common/test/pipes/date_pipe_spec.ts | 270 +++++--- .../test/pipes/deprecated/date_pipe_spec.ts | 205 ++++++ .../test/pipes/deprecated/number_pipe_spec.ts | 110 +++ .../common/test/pipes/number_pipe_spec.ts | 58 +- packages/core/src/core.ts | 3 +- packages/core/src/i18n/ng_locale.ts | 155 +++++ packages/core/src/i18n/tokens.ts | 6 + .../examples/common/pipes/ts/date_pipe.ts | 3 +- .../examples/common/pipes/ts/number_pipe.ts | 5 +- .../test/browser_util_spec.ts | 1 - tools/public_api_guard/common/common.d.ts | 57 +- tools/public_api_guard/core/core.d.ts | 23 + 27 files changed, 2368 insertions(+), 680 deletions(-) create mode 100644 packages/common/src/i18n/localization.ts delete mode 100644 packages/common/src/localization.ts create mode 100644 packages/common/src/pipes/deprecated/date_pipe.ts create mode 100644 packages/common/src/pipes/deprecated/index.ts rename packages/common/src/pipes/{ => deprecated}/intl.ts (99%) create mode 100644 packages/common/src/pipes/deprecated/number_pipe.ts create mode 100644 packages/common/test/pipes/deprecated/date_pipe_spec.ts create mode 100644 packages/common/test/pipes/deprecated/number_pipe_spec.ts create mode 100644 packages/core/src/i18n/ng_locale.ts diff --git a/packages/common/src/common.ts b/packages/common/src/common.ts index c4e7d3cb769f5c..7f3dd1c8d4e492 100644 --- a/packages/common/src/common.ts +++ b/packages/common/src/common.ts @@ -12,11 +12,14 @@ * Entry point for all public APIs of the common package. */ export * from './location/index'; -export {NgLocaleLocalization, NgLocalization} from './localization'; +export {NgLocaleLocalization, NgLocalization} from './i18n/localization'; +export {AVAILABLE_LOCALES} from './i18n/available_locales'; +export {CURRENCIES} from './i18n/currencies'; export {parseCookieValue as ɵparseCookieValue} from './cookie'; -export {CommonModule} from './common_module'; +export {CommonModule, DeprecatedCommonModule} from './common_module'; export {NgClass, NgFor, NgForOf, NgForOfContext, NgIf, NgIfContext, NgPlural, NgPluralCase, NgStyle, NgSwitch, NgSwitchCase, NgSwitchDefault, NgTemplateOutlet, NgComponentOutlet} from './directives/index'; export {DOCUMENT} from './dom_tokens'; export {AsyncPipe, DatePipe, I18nPluralPipe, I18nSelectPipe, JsonPipe, LowerCasePipe, CurrencyPipe, DecimalPipe, PercentPipe, SlicePipe, UpperCasePipe, TitleCasePipe} from './pipes/index'; +export {DeprecatedDatePipe, DeprecatedCurrencyPipe, DeprecatedDecimalPipe, DeprecatedPercentPipe} from './pipes/deprecated/index'; export {PLATFORM_BROWSER_ID as ɵPLATFORM_BROWSER_ID, PLATFORM_SERVER_ID as ɵPLATFORM_SERVER_ID, PLATFORM_WORKER_APP_ID as ɵPLATFORM_WORKER_APP_ID, PLATFORM_WORKER_UI_ID as ɵPLATFORM_WORKER_UI_ID, isPlatformBrowser, isPlatformServer, isPlatformWorkerApp, isPlatformWorkerUi} from './platform_id'; export {VERSION} from './version'; diff --git a/packages/common/src/common_module.ts b/packages/common/src/common_module.ts index 7c80454f9337dc..3394bb8188ab9f 100644 --- a/packages/common/src/common_module.ts +++ b/packages/common/src/common_module.ts @@ -8,10 +8,17 @@ import {NgModule} from '@angular/core'; -import {COMMON_DEPRECATED_DIRECTIVES, COMMON_DIRECTIVES} from './directives/index'; -import {NgLocaleLocalization, NgLocalization} from './localization'; -import {COMMON_PIPES} from './pipes/index'; +import {COMMON_DIRECTIVES} from './directives/index'; +import {NgLocaleLocalization, NgLocalization} from './i18n/localization'; +import {COMMON_DEPRECATED_I18N_PIPES} from './pipes/deprecated/index'; +import {COMMON_I18N_PIPES, COMMON_PIPES} from './pipes/index'; +@NgModule({ + declarations: [COMMON_DIRECTIVES, COMMON_PIPES], + exports: [COMMON_DIRECTIVES, COMMON_PIPES] +}) +export class CommonBaseModule { +} // Note: This does not contain the location providers, // as they need some platform specific implementations to work. @@ -21,11 +28,33 @@ import {COMMON_PIPES} from './pipes/index'; * @stable */ @NgModule({ - declarations: [COMMON_DIRECTIVES, COMMON_PIPES], - exports: [COMMON_DIRECTIVES, COMMON_PIPES], + imports: [CommonBaseModule], + declarations: [COMMON_I18N_PIPES], + exports: [COMMON_I18N_PIPES, CommonBaseModule], providers: [ {provide: NgLocalization, useClass: NgLocaleLocalization}, ], }) export class CommonModule { } + +// Note: This does not contain the location providers, +// as they need some platform specific implementations to work. +/** + * The module that includes all the basic Angular directives like {@link NgIf}, {@link NgForOf}, ... + * It includes the deprecated i18n pipes that will be removed in Angular v6 in favor of the new + * i18n pipes that don't use the intl api. + * + * @stable + * @deprecated use CommonModule instead + */ +@NgModule({ + imports: [CommonBaseModule], + declarations: [COMMON_DEPRECATED_I18N_PIPES], + exports: [COMMON_DEPRECATED_I18N_PIPES, CommonBaseModule], + providers: [ + {provide: NgLocalization, useClass: NgLocaleLocalization}, + ], +}) +export class DeprecatedCommonModule { +} diff --git a/packages/common/src/directives/ng_plural.ts b/packages/common/src/directives/ng_plural.ts index e014a5e9d22f81..f82479aa5f4b0c 100644 --- a/packages/common/src/directives/ng_plural.ts +++ b/packages/common/src/directives/ng_plural.ts @@ -8,7 +8,7 @@ import {Attribute, Directive, Host, Input, TemplateRef, ViewContainerRef} from '@angular/core'; -import {NgLocalization, getPluralCategory} from '../localization'; +import {NgLocalization, getPluralCategory} from '../i18n/localization'; import {SwitchView} from './ng_switch'; diff --git a/packages/common/src/i18n/localization.ts b/packages/common/src/i18n/localization.ts new file mode 100644 index 00000000000000..bde1c08a73473f --- /dev/null +++ b/packages/common/src/i18n/localization.ts @@ -0,0 +1,130 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Inject, Injectable, LOCALE_DATA, LOCALE_ID, NgLocale, Optional, Plural} from '@angular/core'; +import {AVAILABLE_LOCALES} from './available_locales'; +import {NgLocaleEn} from './data/locale_en'; + + +/** + * @experimental + */ +export abstract class NgLocalization { + abstract getPluralCategory(value: any, locale?: string): string; +} + + +/** + * Returns the plural category for a given value. + * - "=value" when the case exists, + * - the plural category otherwise + * + * @internal + */ +export function getPluralCategory( + value: number, cases: string[], ngLocalization: NgLocalization, locale?: string): string { + let key = `=${value}`; + + if (cases.indexOf(key) > -1) { + return key; + } + + key = ngLocalization.getPluralCategory(value, locale); + + if (cases.indexOf(key) > -1) { + return key; + } + + if (cases.indexOf('other') > -1) { + return 'other'; + } + + throw new Error(`No plural message found for value "${value}"`); +} + +/** + * Returns the plural case based on the locale + * + * @experimental + */ +@Injectable() +export class NgLocaleLocalization extends NgLocalization { + constructor( + @Inject(LOCALE_ID) protected locale: string, + @Optional() @Inject(LOCALE_DATA) protected localeData: NgLocale[]) { + super(); + } + + getPluralCategory(value: any, locale?: string): string { + const localeDatum = findNgLocale(locale || this.locale, this.localeData); + const plural = localeDatum.getPluralCase(value); + + switch (plural) { + case Plural.Zero: + return 'zero'; + case Plural.One: + return 'one'; + case Plural.Two: + return 'two'; + case Plural.Few: + return 'few'; + case Plural.Many: + return 'many'; + default: + return 'other'; + } + } +} + +/** + * Returns the closest existing locale or null + * ie: "en-US" will return "en", and "fr_ca" will return "fr-CA" + */ +function getNormalizedLocale(locale: string): string { + let normalizedLocale = locale.replace('_', '-'); + const match = + AVAILABLE_LOCALES.find((l: string) => l.toLocaleLowerCase() === locale.toLocaleLowerCase()); + + if (match) { + normalizedLocale = match; + } else { + const parentLocale = normalizedLocale.split('-')[0].toLocaleLowerCase(); + if (AVAILABLE_LOCALES.find((l: string) => l.toLocaleLowerCase() === parentLocale)) { + normalizedLocale = parentLocale; + } else { + throw new Error( + `"${locale}" is not a valid LOCALE_ID value. See https://github.com/unicode-cldr/cldr-core/blob/master/availableLocales.json for a list of valid locales`); + } + } + + return normalizedLocale; +} + +/** + * Finds the matching NgLocale for a locale + * + * @internal + * @experimental i18n support is experimental. + */ +export function findNgLocale(locale: string, localeData: NgLocale[] | null): NgLocale { + const currentLocale = getNormalizedLocale(locale); + + if (localeData) { + const match = + localeData.find((providedLocale: NgLocale) => providedLocale.localeId === currentLocale); + if (match) { + return match; + } + } + + if (currentLocale === 'en') { + return NgLocaleEn; + } else { + throw new Error(`Missing NgLocale data for the locale "${locale}"`); + } +} diff --git a/packages/common/src/localization.ts b/packages/common/src/localization.ts deleted file mode 100644 index c2deb745b524c9..00000000000000 --- a/packages/common/src/localization.ts +++ /dev/null @@ -1,401 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import {Inject, Injectable, LOCALE_ID} from '@angular/core'; - -/** - * @experimental - */ -export abstract class NgLocalization { abstract getPluralCategory(value: any): string; } - - -/** - * Returns the plural category for a given value. - * - "=value" when the case exists, - * - the plural category otherwise - * - * @internal - */ -export function getPluralCategory( - value: number, cases: string[], ngLocalization: NgLocalization): string { - let key = `=${value}`; - - if (cases.indexOf(key) > -1) { - return key; - } - - key = ngLocalization.getPluralCategory(value); - - if (cases.indexOf(key) > -1) { - return key; - } - - if (cases.indexOf('other') > -1) { - return 'other'; - } - - throw new Error(`No plural message found for value "${value}"`); -} - -/** - * Returns the plural case based on the locale - * - * @experimental - */ -@Injectable() -export class NgLocaleLocalization extends NgLocalization { - constructor(@Inject(LOCALE_ID) protected locale: string) { super(); } - - getPluralCategory(value: any): string { - const plural = getPluralCase(this.locale, value); - - switch (plural) { - case Plural.Zero: - return 'zero'; - case Plural.One: - return 'one'; - case Plural.Two: - return 'two'; - case Plural.Few: - return 'few'; - case Plural.Many: - return 'many'; - default: - return 'other'; - } - } -} - -// This is generated code DO NOT MODIFY -// see angular/script/cldr/gen_plural_rules.js - -/** @experimental */ -export enum Plural { - Zero, - One, - Two, - Few, - Many, - Other, -} - -/** - * Returns the plural case based on the locale - * - * @experimental - */ -export function getPluralCase(locale: string, nLike: number | string): Plural { - // TODO(vicb): lazy compute - if (typeof nLike === 'string') { - nLike = parseInt(nLike, 10); - } - const n: number = nLike as number; - const nDecimal = n.toString().replace(/^[^.]*\.?/, ''); - const i = Math.floor(Math.abs(n)); - const v = nDecimal.length; - const f = parseInt(nDecimal, 10); - const t = parseInt(n.toString().replace(/^[^.]*\.?|0+$/g, ''), 10) || 0; - - const lang = locale.split('-')[0].toLowerCase(); - - switch (lang) { - case 'af': - case 'asa': - case 'az': - case 'bem': - case 'bez': - case 'bg': - case 'brx': - case 'ce': - case 'cgg': - case 'chr': - case 'ckb': - case 'ee': - case 'el': - case 'eo': - case 'es': - case 'eu': - case 'fo': - case 'fur': - case 'gsw': - case 'ha': - case 'haw': - case 'hu': - case 'jgo': - case 'jmc': - case 'ka': - case 'kk': - case 'kkj': - case 'kl': - case 'ks': - case 'ksb': - case 'ky': - case 'lb': - case 'lg': - case 'mas': - case 'mgo': - case 'ml': - case 'mn': - case 'nb': - case 'nd': - case 'ne': - case 'nn': - case 'nnh': - case 'nyn': - case 'om': - case 'or': - case 'os': - case 'ps': - case 'rm': - case 'rof': - case 'rwk': - case 'saq': - case 'seh': - case 'sn': - case 'so': - case 'sq': - case 'ta': - case 'te': - case 'teo': - case 'tk': - case 'tr': - case 'ug': - case 'uz': - case 'vo': - case 'vun': - case 'wae': - case 'xog': - if (n === 1) return Plural.One; - return Plural.Other; - case 'ak': - case 'ln': - case 'mg': - case 'pa': - case 'ti': - if (n === Math.floor(n) && n >= 0 && n <= 1) return Plural.One; - return Plural.Other; - case 'am': - case 'as': - case 'bn': - case 'fa': - case 'gu': - case 'hi': - case 'kn': - case 'mr': - case 'zu': - if (i === 0 || n === 1) return Plural.One; - return Plural.Other; - case 'ar': - if (n === 0) return Plural.Zero; - if (n === 1) return Plural.One; - if (n === 2) return Plural.Two; - if (n % 100 === Math.floor(n % 100) && n % 100 >= 3 && n % 100 <= 10) return Plural.Few; - if (n % 100 === Math.floor(n % 100) && n % 100 >= 11 && n % 100 <= 99) return Plural.Many; - return Plural.Other; - case 'ast': - case 'ca': - case 'de': - case 'en': - case 'et': - case 'fi': - case 'fy': - case 'gl': - case 'it': - case 'nl': - case 'sv': - case 'sw': - case 'ur': - case 'yi': - if (i === 1 && v === 0) return Plural.One; - return Plural.Other; - case 'be': - if (n % 10 === 1 && !(n % 100 === 11)) return Plural.One; - if (n % 10 === Math.floor(n % 10) && n % 10 >= 2 && n % 10 <= 4 && - !(n % 100 >= 12 && n % 100 <= 14)) - return Plural.Few; - if (n % 10 === 0 || n % 10 === Math.floor(n % 10) && n % 10 >= 5 && n % 10 <= 9 || - n % 100 === Math.floor(n % 100) && n % 100 >= 11 && n % 100 <= 14) - return Plural.Many; - return Plural.Other; - case 'br': - if (n % 10 === 1 && !(n % 100 === 11 || n % 100 === 71 || n % 100 === 91)) return Plural.One; - if (n % 10 === 2 && !(n % 100 === 12 || n % 100 === 72 || n % 100 === 92)) return Plural.Two; - if (n % 10 === Math.floor(n % 10) && (n % 10 >= 3 && n % 10 <= 4 || n % 10 === 9) && - !(n % 100 >= 10 && n % 100 <= 19 || n % 100 >= 70 && n % 100 <= 79 || - n % 100 >= 90 && n % 100 <= 99)) - return Plural.Few; - if (!(n === 0) && n % 1e6 === 0) return Plural.Many; - return Plural.Other; - case 'bs': - case 'hr': - case 'sr': - if (v === 0 && i % 10 === 1 && !(i % 100 === 11) || f % 10 === 1 && !(f % 100 === 11)) - return Plural.One; - if (v === 0 && i % 10 === Math.floor(i % 10) && i % 10 >= 2 && i % 10 <= 4 && - !(i % 100 >= 12 && i % 100 <= 14) || - f % 10 === Math.floor(f % 10) && f % 10 >= 2 && f % 10 <= 4 && - !(f % 100 >= 12 && f % 100 <= 14)) - return Plural.Few; - return Plural.Other; - case 'cs': - case 'sk': - if (i === 1 && v === 0) return Plural.One; - if (i === Math.floor(i) && i >= 2 && i <= 4 && v === 0) return Plural.Few; - if (!(v === 0)) return Plural.Many; - return Plural.Other; - case 'cy': - if (n === 0) return Plural.Zero; - if (n === 1) return Plural.One; - if (n === 2) return Plural.Two; - if (n === 3) return Plural.Few; - if (n === 6) return Plural.Many; - return Plural.Other; - case 'da': - if (n === 1 || !(t === 0) && (i === 0 || i === 1)) return Plural.One; - return Plural.Other; - case 'dsb': - case 'hsb': - if (v === 0 && i % 100 === 1 || f % 100 === 1) return Plural.One; - if (v === 0 && i % 100 === 2 || f % 100 === 2) return Plural.Two; - if (v === 0 && i % 100 === Math.floor(i % 100) && i % 100 >= 3 && i % 100 <= 4 || - f % 100 === Math.floor(f % 100) && f % 100 >= 3 && f % 100 <= 4) - return Plural.Few; - return Plural.Other; - case 'ff': - case 'fr': - case 'hy': - case 'kab': - if (i === 0 || i === 1) return Plural.One; - return Plural.Other; - case 'fil': - if (v === 0 && (i === 1 || i === 2 || i === 3) || - v === 0 && !(i % 10 === 4 || i % 10 === 6 || i % 10 === 9) || - !(v === 0) && !(f % 10 === 4 || f % 10 === 6 || f % 10 === 9)) - return Plural.One; - return Plural.Other; - case 'ga': - if (n === 1) return Plural.One; - if (n === 2) return Plural.Two; - if (n === Math.floor(n) && n >= 3 && n <= 6) return Plural.Few; - if (n === Math.floor(n) && n >= 7 && n <= 10) return Plural.Many; - return Plural.Other; - case 'gd': - if (n === 1 || n === 11) return Plural.One; - if (n === 2 || n === 12) return Plural.Two; - if (n === Math.floor(n) && (n >= 3 && n <= 10 || n >= 13 && n <= 19)) return Plural.Few; - return Plural.Other; - case 'gv': - if (v === 0 && i % 10 === 1) return Plural.One; - if (v === 0 && i % 10 === 2) return Plural.Two; - if (v === 0 && - (i % 100 === 0 || i % 100 === 20 || i % 100 === 40 || i % 100 === 60 || i % 100 === 80)) - return Plural.Few; - if (!(v === 0)) return Plural.Many; - return Plural.Other; - case 'he': - if (i === 1 && v === 0) return Plural.One; - if (i === 2 && v === 0) return Plural.Two; - if (v === 0 && !(n >= 0 && n <= 10) && n % 10 === 0) return Plural.Many; - return Plural.Other; - case 'is': - if (t === 0 && i % 10 === 1 && !(i % 100 === 11) || !(t === 0)) return Plural.One; - return Plural.Other; - case 'ksh': - if (n === 0) return Plural.Zero; - if (n === 1) return Plural.One; - return Plural.Other; - case 'kw': - case 'naq': - case 'se': - case 'smn': - if (n === 1) return Plural.One; - if (n === 2) return Plural.Two; - return Plural.Other; - case 'lag': - if (n === 0) return Plural.Zero; - if ((i === 0 || i === 1) && !(n === 0)) return Plural.One; - return Plural.Other; - case 'lt': - if (n % 10 === 1 && !(n % 100 >= 11 && n % 100 <= 19)) return Plural.One; - if (n % 10 === Math.floor(n % 10) && n % 10 >= 2 && n % 10 <= 9 && - !(n % 100 >= 11 && n % 100 <= 19)) - return Plural.Few; - if (!(f === 0)) return Plural.Many; - return Plural.Other; - case 'lv': - case 'prg': - if (n % 10 === 0 || n % 100 === Math.floor(n % 100) && n % 100 >= 11 && n % 100 <= 19 || - v === 2 && f % 100 === Math.floor(f % 100) && f % 100 >= 11 && f % 100 <= 19) - return Plural.Zero; - if (n % 10 === 1 && !(n % 100 === 11) || v === 2 && f % 10 === 1 && !(f % 100 === 11) || - !(v === 2) && f % 10 === 1) - return Plural.One; - return Plural.Other; - case 'mk': - if (v === 0 && i % 10 === 1 || f % 10 === 1) return Plural.One; - return Plural.Other; - case 'mt': - if (n === 1) return Plural.One; - if (n === 0 || n % 100 === Math.floor(n % 100) && n % 100 >= 2 && n % 100 <= 10) - return Plural.Few; - if (n % 100 === Math.floor(n % 100) && n % 100 >= 11 && n % 100 <= 19) return Plural.Many; - return Plural.Other; - case 'pl': - if (i === 1 && v === 0) return Plural.One; - if (v === 0 && i % 10 === Math.floor(i % 10) && i % 10 >= 2 && i % 10 <= 4 && - !(i % 100 >= 12 && i % 100 <= 14)) - return Plural.Few; - if (v === 0 && !(i === 1) && i % 10 === Math.floor(i % 10) && i % 10 >= 0 && i % 10 <= 1 || - v === 0 && i % 10 === Math.floor(i % 10) && i % 10 >= 5 && i % 10 <= 9 || - v === 0 && i % 100 === Math.floor(i % 100) && i % 100 >= 12 && i % 100 <= 14) - return Plural.Many; - return Plural.Other; - case 'pt': - if (n === Math.floor(n) && n >= 0 && n <= 2 && !(n === 2)) return Plural.One; - return Plural.Other; - case 'ro': - if (i === 1 && v === 0) return Plural.One; - if (!(v === 0) || n === 0 || - !(n === 1) && n % 100 === Math.floor(n % 100) && n % 100 >= 1 && n % 100 <= 19) - return Plural.Few; - return Plural.Other; - case 'ru': - case 'uk': - if (v === 0 && i % 10 === 1 && !(i % 100 === 11)) return Plural.One; - if (v === 0 && i % 10 === Math.floor(i % 10) && i % 10 >= 2 && i % 10 <= 4 && - !(i % 100 >= 12 && i % 100 <= 14)) - return Plural.Few; - if (v === 0 && i % 10 === 0 || - v === 0 && i % 10 === Math.floor(i % 10) && i % 10 >= 5 && i % 10 <= 9 || - v === 0 && i % 100 === Math.floor(i % 100) && i % 100 >= 11 && i % 100 <= 14) - return Plural.Many; - return Plural.Other; - case 'shi': - if (i === 0 || n === 1) return Plural.One; - if (n === Math.floor(n) && n >= 2 && n <= 10) return Plural.Few; - return Plural.Other; - case 'si': - if (n === 0 || n === 1 || i === 0 && f === 1) return Plural.One; - return Plural.Other; - case 'sl': - if (v === 0 && i % 100 === 1) return Plural.One; - if (v === 0 && i % 100 === 2) return Plural.Two; - if (v === 0 && i % 100 === Math.floor(i % 100) && i % 100 >= 3 && i % 100 <= 4 || !(v === 0)) - return Plural.Few; - return Plural.Other; - case 'tzm': - if (n === Math.floor(n) && n >= 0 && n <= 1 || n === Math.floor(n) && n >= 11 && n <= 99) - return Plural.One; - return Plural.Other; - // When there is no specification, the default is always "other" - // Spec: http://cldr.unicode.org/index/cldr-spec/plural-rules - // > other (required—general plural form — also used if the language only has a single form) - default: - return Plural.Other; - } -} diff --git a/packages/common/src/pipes/date_pipe.ts b/packages/common/src/pipes/date_pipe.ts index 12a5aa7340d589..4ff0d69c130f26 100644 --- a/packages/common/src/pipes/date_pipe.ts +++ b/packages/common/src/pipes/date_pipe.ts @@ -6,11 +6,16 @@ * found in the LICENSE file at https://angular.io/license */ -import {Inject, LOCALE_ID, Pipe, PipeTransform} from '@angular/core'; -import {DateFormatter} from './intl'; +import {Inject, LOCALE_DATA, LOCALE_ID, NgLocale, Optional, Pipe, PipeTransform} from '@angular/core'; + +import {findNgLocale} from '../i18n/localization'; + import {invalidPipeArgumentError} from './invalid_pipe_argument_error'; import {isNumeric} from './number_pipe'; +const ZERO_CHAR = '0'; +const DATE_FORMATS_SPLIT = + /((?:[^GyMLwWdEabBhHmsSzZO']+)|(?:'(?:[^']|'')*')|(?:G{1,5}|y{1,4}|M{1,5}|L{1,5}|w{1,2}|W{1}|d{1,2}|E{1,6}|a{1,5}|b{1,5}|B{1,5}|h{1,2}|H{1,2}|m{1,2}|s{1,2}|S{1,3}|z{1,4}|Z{1,5}|O{1,4}))([\s\S]*)/; const ISO8601_DATE_REGEX = /^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/; // 1 2 3 4 5 6 7 8 9 10 11 @@ -18,44 +23,94 @@ const ISO8601_DATE_REGEX = /** * @ngModule CommonModule * @whatItDoes Formats a date according to locale rules. - * @howToUse `date_expression | date[:format]` + * @howToUse `date_expression | date[:format[:timezone[:locale]]]` * @description * * Where: * - `expression` is a date object or a number (milliseconds since UTC epoch) or an ISO string * (https://www.w3.org/TR/NOTE-datetime). * - `format` indicates which date/time components to include. The format can be predefined as - * shown below or custom as shown in the table. - * - `'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` for `en-US`) - * - `'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`) + * shown below (all examples are given for `en-US`) or custom as shown in the table. + * - `'short'`: equivalent to `'M/d/yy, h:mm a'` (e.g. `9/3/2010, 12:05 PM`) + * - `'medium'`: equivalent to `'MMM d, y, h:mm:ss a'` (e.g. `Sep 3, 2010, 12:05:08 PM`) + * - `'long'`: equivalent to `'MMMM d, y, h:mm:ss a z'` (e.g. `Sep 3, 2010, 12:05:08 PM PDT`) + * - `'full'`: equivalent to `'EEEE, MMMM d, y, h:mm:ss a zzzz'` (e.g. `Sep 3, 2010, 12:05:08 PM + * Pacific Daylight Time`) + * - `'shortDate'`: equivalent to `'M/d/yy'` (e.g. `9/3/2010`) + * - `'mediumDate'`: equivalent to `'MMM d, y'` (e.g. `Sep 3, 2010`) + * - `'longDate'`: equivalent to `'MMMM d, y'` (e.g. `September 3, 2010`) + * - `'fullDate'`: equivalent to `'EEEE, MMMM d, y'` (e.g. `Friday, September 3, 2010`) + * - `'shortTime'`: equivalent to `'h:mm a'` (e.g. `12:05 PM`) + * - `'mediumTime'`: equivalent to `'h:mm:ss a'` (e.g. `12:05:08 PM`) + * - `'longTime'`: equivalent to `'h:mm:ss a z'` (e.g. `12:05:08 PM PDT`) + * - `'fullTime'`: equivalent to `'h:mm:ss a zzzz'` (e.g. `12:05:08 PM Pacific Daylight Time`) + * - `timezone` to be used for formatting. It understands UTC/GMT and the continental US time zone + * abbreviations, but for general use, use a time zone offset, for example, + * `'+0430'` (4 hours, 30 minutes east of the Greenwich meridian) + * If not specified, the timezone of the browser will be used. + * - `locale` is a `string` defining the locale to use (uses the current {@link LOCALE_ID} by + * default) * * - * | Component | Symbol | Narrow | Short Form | Long Form | Numeric | 2-digit | - * |-----------|:------:|--------|--------------|-------------------|-----------|-----------| - * | era | G | G (A) | GGG (AD) | GGGG (Anno Domini)| - | - | - * | year | y | - | - | - | y (2015) | yy (15) | - * | month | M | L (S) | MMM (Sep) | MMMM (September) | M (9) | MM (09) | - * | day | d | - | - | - | d (3) | dd (03) | - * | weekday | E | E (S) | 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) | - | - | - | - * | timezone | a | - | a (PM) | - | - | - | + * | Field Type | Format | Description | Example Value | + * |------------|-------------|---------------|--------| + * | Era | G..GGG | Abbreviated | AD | + * | | GGGG | Wide | Anno Domini | + * | | GGGGG | Narrow | A | + * | Year | y | Numeric: minimum digits | 2, 20, 201, 2017, 20173 | + * | | yy | Numeric: 2 digits + zero padded | 02, 20, 01, 17, 73 | + * | | yyy | Numeric: 3 digits + zero padded | 002, 020, 201, 2017, 20173 | + * | | yyyy | Numeric: 4 digits or more + zero padded | 0002, 0020, 0201, 2017, 20173 | + * | Month | M | Numeric: 1 digit | 9, 12 | + * | | MM | Numeric: 2 digits + zero padded | 09, 12 | + * | | MMM | Abbreviated | Sep | + * | | MMMM | Wide | September | + * | | MMMMM | Narrow | S | + * | Month standalone | L | Numeric: 1 digit | 9, 12 | + * | | LL | Numeric: 2 digits + zero padded | 09, 12 | + * | | LLL | Abbreviated | Sep | + * | | LLLL | Wide | September | + * | | LLLLL | Narrow | S | + * | Week of year | w | Numeric: minimum digits | 1... 53 | + * | | ww | Numeric: 2 digits + zero padded | 01... 53 | + * | Week of month | W | Numeric: 1 digit | 1... 5 | + * | Day of month | d | Numeric: minimum digits | 1 | + * | | dd | Numeric: 2 digits + zero padded | 1 | + * | Week day | E..EEE | Abbreviated | Tue | + * | | EEEE | Wide | Tuesday | + * | | EEEEE | Narrow | T | + * | | EEEEEE | Short | Tu | + * | Period | a..aaa | Abbreviated | am/pm or AM/PM | + * | | aaaa | Wide | ante meridiem/post meridiem | + * | | aaaaa | Narrow | a/p | + * | Period | B..BBB | Abbreviated | mid. | + * | | BBBB | Wide | am, pm, midnight, noon, morning, afternoon, evening, night | + * | | BBBBB | Narrow | md | + * | Period standalone | b..bbb | Abbreviated | mid. | + * | | bbbb | Wide | am, pm, midnight, noon, morning, afternoon, evening, night | + * | | bbbbb | Narrow | md | + * | Hour 1-12 | h | Numeric: minimum digits | 1, 12 | + * | | hh | Numeric: 2 digits + zero padded | 01, 12 | + * | Hour 0-23 | H | Numeric: minimum digits | 0, 23 | + * | | HH | Numeric: 2 digits + zero padded | 00, 23 | + * | Minute | m | Numeric: minimum digits | 8, 59 | + * | | mm | Numeric: 2 digits + zero padded | 08, 59 | + * | Second | s | Numeric: minimum digits | 0... 59 | + * | | ss | Numeric: 2 digits + zero padded | 00... 59 | + * | Fractional seconds | S | Numeric: 1 digit | 0... 9 | + * | | SS | Numeric: 2 digits + zero padded | 00... 99 | + * | | SSS | Numeric: 3 digits + zero padded (= milliseconds) | 000... 999 | + * | Zone | z..zzz | Short specific non location format (fallback to O) | PDT | + * | | zzzz | Long specific non location format (fallback to OOOO) | Pacific Daylight Time | + * | | Z..ZZZ | ISO8601 basic format | -0800 | + * | | ZZZZ | Long localized GMT format | GMT-8:00 | + * | | ZZZZZ | ISO8601 extended format + Z indicator for offset 0 (= XXXXX) | -08:00 | + * | | O..OOO | Short localized GMT format | GMT-8 | + * | | OOOO | Long localized GMT format | GMT-08:00 | * - * In javascript, only the components specified will be respected (not the ordering, - * punctuations, ...) and details of the formatting will be dependent on the locale. * - * Timezone of the formatted text will be the local system timezone of the end-user's machine. + * Timezone of the formatted text will be the local system timezone of the end-user's machine, + * unless specified manually using the third parameter. * * When the expression is a ISO string without time (e.g. 2016-09-19) the time zone offset is not * applied and the formatted text will have the same day, month and year of the expression. @@ -65,8 +120,6 @@ const ISO8601_DATE_REGEX = * Instead users should treat the date as an immutable object and change the reference when the * pipe needs to re-run (this is to avoid reformatting the date on every change detection run * which would be an expensive operation). - * - this pipe uses the Internationalization API. Therefore it is only reliable in Chrome and Opera - * browsers. * * ### Examples * @@ -77,7 +130,7 @@ const ISO8601_DATE_REGEX = * {{ 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' + * {{ dateObj | date:'mm:ss' }} // output is '43:11' * ``` * * {@example common/pipes/ts/date_pipe.ts region='DatePipe'} @@ -86,22 +139,30 @@ const ISO8601_DATE_REGEX = */ @Pipe({name: 'date', pure: true}) export class DatePipe implements PipeTransform { - /** @internal */ - static _ALIASES: {[key: string]: string} = { - 'medium': 'yMMMdjms', - 'short': 'yMdjm', - 'fullDate': 'yMMMMEEEEd', - 'longDate': 'yMMMMd', - 'mediumDate': 'yMMMd', - 'shortDate': 'yMd', - 'mediumTime': 'jms', - 'shortTime': 'jm' - }; - - constructor(@Inject(LOCALE_ID) private _locale: string) {} + constructor( + @Inject(LOCALE_ID) private locale: string, + @Optional() @Inject(LOCALE_DATA) private localeData: NgLocale[]) {} - transform(value: any, pattern: string = 'mediumDate'): string|null { + transform(value: any, pattern: string = 'mediumDate', timezone?: string, locale?: string): string + |null { let date: Date; + const localeDatum = findNgLocale(locale || this.locale, this.localeData); + const specialPatterns: {[key: string]: string} = { + 'short': formatDateTime(localeDatum.dateTimeSettings.formats.dateTime.short, [localeDatum.dateTimeSettings.formats.time.short, localeDatum.dateTimeSettings.formats.date.short]), + 'medium': formatDateTime(localeDatum.dateTimeSettings.formats.dateTime.medium, [localeDatum.dateTimeSettings.formats.time.medium, localeDatum.dateTimeSettings.formats.date.medium]), + 'long': formatDateTime(localeDatum.dateTimeSettings.formats.dateTime.long, [localeDatum.dateTimeSettings.formats.time.long, localeDatum.dateTimeSettings.formats.date.long]), + 'full': formatDateTime(localeDatum.dateTimeSettings.formats.dateTime.full, [localeDatum.dateTimeSettings.formats.time.full, localeDatum.dateTimeSettings.formats.date.full]), + 'shortDate': localeDatum.dateTimeSettings.formats.date.short, + 'mediumDate': localeDatum.dateTimeSettings.formats.date.medium, + 'longDate': localeDatum.dateTimeSettings.formats.date.long, + 'fullDate': localeDatum.dateTimeSettings.formats.date.full, + 'shortTime': localeDatum.dateTimeSettings.formats.time.short, + 'mediumTime': localeDatum.dateTimeSettings.formats.time.medium, + 'longTime': localeDatum.dateTimeSettings.formats.time.long, + 'fullTime': localeDatum.dateTimeSettings.formats.time.full + }; + + pattern = specialPatterns[pattern] || pattern; if (isBlank(value) || value !== value) return null; @@ -138,10 +199,46 @@ export class DatePipe implements PipeTransform { } } - return DateFormatter.format(date, this._locale, DatePipe._ALIASES[pattern] || pattern); + let match; + let parts: string[] = []; + let text = ''; + let format: any = pattern; + while (format) { + match = DATE_FORMATS_SPLIT.exec(format); + if (match) { + parts = parts.concat(match.slice(1)); + format = parts.pop(); + } else { + parts.push(format); + format = null; + } + } + + let dateTimezoneOffset = date.getTimezoneOffset(); + if (timezone) { + dateTimezoneOffset = timezoneToOffset(timezone, dateTimezoneOffset); + date = convertTimezoneToLocal(date, timezone, true); + } + parts.forEach(value => { + const dateFormatter: DateFormatter = DATE_FORMATS[value]; + text += dateFormatter ? + dateFormatter(date, localeDatum, dateTimezoneOffset) : + value === '\'\'' ? '\'' : value.replace(/(^'|'$)/g, '').replace(/''/g, '\''); + }); + + return text; } } +function formatDateTime(str: string, opt_values: string[]) { + if (opt_values) { + str = str.replace(/\{([^}]+)}/g, function(match, key) { + return (opt_values != null && key in opt_values) ? opt_values[key] : match; + }); + } + return str; +} + function isBlank(obj: any): boolean { return obj == null || obj === ''; } @@ -173,3 +270,437 @@ function isoStringToDate(match: RegExpMatchArray): Date { function toInt(str: string): number { return parseInt(str, 10); } + +function padNumber(num: number, digits: number, trim?: boolean, negWrap?: boolean): string { + let neg = ''; + if (num < 0 || (negWrap && num <= 0)) { + if (negWrap) { + num = -num + 1; + } else { + num = -num; + neg = '-'; + } + } + let strNum = '' + num; + while (strNum.length < digits) strNum = ZERO_CHAR + strNum; + if (trim) { + strNum = strNum.substr(strNum.length - digits); + } + return neg + strNum; +} + +type DateType = + 'FullYear' | 'Month' | 'Date' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds' | 'Day'; + +function dateGetter( + name: DateType, size: number, offset: number = 0, trim = false, + negWrap = false): DateFormatter { + return function(date: Date, ngLocale: NgLocale): string { + let value = 0; + switch (name) { + case 'FullYear': + value = date.getFullYear(); + break; + case 'Month': + value = date.getMonth(); + break; + case 'Date': + value = date.getDate(); + break; + case 'Hours': + value = date.getHours(); + break; + case 'Minutes': + value = date.getMinutes(); + break; + case 'Seconds': + value = date.getSeconds(); + break; + case 'Milliseconds': + const div = size === 1 ? 100 : (size === 2 ? 10 : 1); + value = Math.round(date.getMilliseconds() / div); + break; + case 'Day': + value = date.getDay(); + break; + } + if (offset > 0 || value > -offset) { + value += offset; + } + if (value === 0 && offset === -12) { + value = 12; + } + return padNumber(value, size, trim, negWrap); + }; +} + +type DateWidth = 'narrow' | 'short' | 'abbreviated' | 'wide'; + +function extractTime(time: string) { + const [h, m] = time.split(':'); + return { hours: parseInt(h, 10), minutes: parseInt(m, 10), } +} + +function dateStrGetter( + name: 'dayPeriods' | 'days' | 'months' | 'eras', width: DateWidth, + context: 'format' | 'standalone' = 'format', extended = false): DateFormatter { + return function(date: Date, ngLocale: NgLocale): string { + let data: any; + let key: string|number = ''; + switch (name) { + case 'months': + data = ngLocale.dateTimeTranslations.months; + key = date.getMonth(); + break; + case 'days': + data = ngLocale.dateTimeTranslations.days; + key = date.getDay(); + break; + case 'dayPeriods': + data = ngLocale.dateTimeTranslations.dayPeriods; + const currentHours = date.getHours(); + const currentMinutes = date.getMinutes(); + const rules: any = ngLocale.dateTimeSettings.dayPeriodRules; + // if no rules for the day periods, we use am/pm by default + key = date.getHours() < 12 ? 'am' : 'pm'; + if (extended && rules) { + Object.keys(rules).forEach((ruleKey: string) => { + if (typeof rules[ruleKey] === 'string') { // noon or midnight + const {hours, minutes} = extractTime(rules[ruleKey]); + if (hours === currentHours && minutes === currentMinutes) { + key = ruleKey; + } + } else if (rules[ruleKey].from && rules[ruleKey].to) { + // morning, afternoon, evening, night + const {hours: hoursFrom, minutes: minutesFrom} = extractTime(rules[ruleKey].from); + const {hours: hoursTo, minutes: minutesTo} = extractTime(rules[ruleKey].to); + if (currentHours >= hoursFrom && currentMinutes >= minutesFrom && + (currentHours < hoursTo || + (currentHours === hoursTo && currentMinutes < minutesTo))) { + key = ruleKey; + } + } + }); + } + break; + case 'eras': + data = ngLocale.dateTimeTranslations.eras; + key = date.getFullYear() <= 0 ? 0 : 1; + } + + if (name !== 'eras') { + if (context === 'standalone') { + data = data.standalone; + } else { + data = data.format; + } + } + + switch (width) { + case 'narrow': + data = data.narrow; + break; + case 'short': + if (name !== 'days') { + throw new Error(`No short width data for ${name}`); + } + data = data.short; + break; + case 'abbreviated': + data = data.abbreviated; + break; + case 'wide': + data = data.wide; + break; + } + + if (typeof data === 'undefined' || typeof key === 'undefined') { + throw new Error(`Unable to find locale data for ${name}`); + } + + return data[key]; + }; +} + +export type ZoneWidth = 'short' | 'long' | 'extended'; + +function timeZoneGetter(width: ZoneWidth): DateFormatter { + return function(date: Date, ngLocale: NgLocale, offset: number) { + const zone = -1 * offset; + let value = ''; + switch (width) { + case 'short': + value = ((zone >= 0) ? '+' : '') + + padNumber(Math[zone > 0 ? 'floor' : 'ceil'](zone / 60), 2) + + padNumber(Math.abs(zone % 60), 2); + break; + case 'long': + value = 'GMT' + ((zone >= 0) ? '+' : '') + + padNumber(Math[zone > 0 ? 'floor' : 'ceil'](zone / 60), 2) + ':' + + padNumber(Math.abs(zone % 60), 2); + break; + case 'extended': + if (offset === 0) { + value = 'Z'; + } else { + // todo(ocombe): support optional seconds field, if there really is a use case + value = ((zone >= 0) ? '+' : '') + + padNumber(Math[zone > 0 ? 'floor' : 'ceil'](zone / 60), 2) + ':' + + padNumber(Math.abs(zone % 60), 2); + } + break; + } + + return value; + } +} + +function timeZoneFallbackGetter(width: ZoneWidth): DateFormatter { + return function(date: Date, ngLocale: NgLocale, offset: number) { + const zone = -1 * offset; + let value = 'GMT'; + switch (width) { + case 'short': + value += + ((zone >= 0) ? '+' : '') + padNumber(Math[zone > 0 ? 'floor' : 'ceil'](zone / 60), 1); + break; + case 'long': + value += ((zone >= 0) ? '+' : '') + + padNumber(Math[zone > 0 ? 'floor' : 'ceil'](zone / 60), 2) + ':' + + padNumber(Math.abs(zone % 60), 2); + break; + } + + return value; + } +} + +function getFirstThursdayOfYear(year: number) { + // 0 = index of January + const dayOfWeekOnFirst = (new Date(year, 0, 1)).getDay(); + // 4 = index of Thursday (+1 to account for 1st = 5) + // 11 = index of *next* Thursday (+1 account for 1st = 12) + return new Date(year, 0, ((dayOfWeekOnFirst <= 4) ? 5 : 12) - dayOfWeekOnFirst); +} + +function getThursdayThisWeek(datetime: Date) { + return new Date( + datetime.getFullYear(), datetime.getMonth(), + // 4 = index of Thursday + datetime.getDate() + (4 - datetime.getDay())); +} + +function weekGetter(size: number, monthBased = false): DateFormatter { + return function(date: Date, ngLocale: NgLocale) { + let result; + if (monthBased) { + const nbDaysBefore1stDayOfMonth = + new Date(date.getFullYear(), date.getMonth(), 1).getDay() - 1; + const today = date.getDate(); + result = 1 + Math.floor((today + nbDaysBefore1stDayOfMonth) / 7); + } else { + const firstThurs = getFirstThursdayOfYear(date.getFullYear()); + const thisThurs = getThursdayThisWeek(date); + const diff = +thisThurs - +firstThurs; + result = 1 + Math.round(diff / 6.048e8); // 6.048e8 ms per week + } + + return padNumber(result, size); + }; +} + +type DateFormatter = (date: Date, format: NgLocale, offset?: number) => string; +// Following CLDR formats +// http://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table +// See also explanations http://cldr.unicode.org/translation/date-time +const DATE_FORMATS: {[format: string]: DateFormatter} = { + // Era name abbreviated (AD) + G: dateStrGetter('eras', 'abbreviated'), + // equivalent to G + GG: dateStrGetter('eras', 'abbreviated'), + // equivalent to G + GGG: dateStrGetter('eras', 'abbreviated'), + // Era name wide (Anno Domini) + GGGG: dateStrGetter('eras', 'wide'), + // Era name narrow (A) + GGGGG: dateStrGetter('eras', 'narrow'), + + // 1 digit representation of year, e.g. (AD 1 => 1, AD 199 => 199) + y: dateGetter('FullYear', 1, 0, false, true), + // 2 digit representation of year, padded (00-99). (e.g. AD 2001 => 01, AD 2010 => 10) + yy: dateGetter('FullYear', 2, 0, true, true), + // 3 digit representation of year, padded (000-999). (e.g. AD 2001 => 01, AD 2010 => 10) + yyy: dateGetter('FullYear', 3, 0, false, true), + // 4 digit representation of year (e.g. AD 1 => 0001, AD 2010 => 2010) + yyyy: dateGetter('FullYear', 4, 0, false, true), + + // todo Y, U + + // todo Q + + // Month in year (1-12), numeric + M: dateGetter('Month', 1, 1), + // Month in year, padded (01-12) + MM: dateGetter('Month', 2, 1), + // Month in year abbreviated (Jan-Dec) + MMM: dateStrGetter('months', 'abbreviated'), + // Month in year wide (January-December) + MMMM: dateStrGetter('months', 'wide'), + // Month in year narrow (J-D) + MMMMM: dateStrGetter('months', 'narrow'), + + // equivalent to M + L: dateGetter('Month', 1, 1), + // equivalent to MM + LL: dateGetter('Month', 2, 1), + // Standalone month in year abbreviated (Jan-Dec) + LLL: dateStrGetter('months', 'abbreviated', 'standalone'), + // Standalone month in year wide (January-December) + LLLL: dateStrGetter('months', 'wide', 'standalone'), + // Standalone month in year narrow (J-D) + LLLLL: dateStrGetter('months', 'narrow', 'standalone'), + + w: weekGetter(1), + ww: weekGetter(2), + + W: weekGetter(1, true), + + // Day in month (1-31) + d: dateGetter('Date', 1), + // Day in month, padded (01-31) + dd: dateGetter('Date', 2), + + // todo D, F + + // Day in Week abbreviated (Sun-Sat) + E: dateStrGetter('days', 'abbreviated'), + // equivalent to E + EE: dateStrGetter('days', 'abbreviated'), + // equivalent to E + EEE: dateStrGetter('days', 'abbreviated'), + // Day in Week wide (Sunday-Saturday) + EEEE: dateStrGetter('days', 'wide'), + // Day in Week narrow (S-S) + EEEEE: dateStrGetter('days', 'narrow'), + // Day in Week narrow (Su-Sa) + EEEEEE: dateStrGetter('days', 'short'), + + // todo e, c + + // Period of the day abbreviated (am-pm) + a: dateStrGetter('dayPeriods', 'abbreviated'), + // equivalent to a + aa: dateStrGetter('dayPeriods', 'abbreviated'), + // equivalent to a + aaa: dateStrGetter('dayPeriods', 'abbreviated'), + // Period of the day wide (am-pm) + aaaa: dateStrGetter('dayPeriods', 'wide'), + // Period of the day narrow (a-p) + aaaaa: dateStrGetter('dayPeriods', 'narrow'), + + // Standalone period of the day abbreviated (mid., at night, ...) + b: dateStrGetter('dayPeriods', 'abbreviated', 'standalone', true), + // equivalent to b + bb: dateStrGetter('dayPeriods', 'abbreviated', 'standalone', true), + // equivalent to b + bbb: dateStrGetter('dayPeriods', 'abbreviated', 'standalone', true), + // Standalone period of the day wide (midnight, at night, ...) + bbbb: dateStrGetter('dayPeriods', 'wide', 'standalone', true), + // Standalone period of the day narrow (mi, at night, ...) + bbbbb: dateStrGetter('dayPeriods', 'narrow', 'standalone', true), + + // Period of the day abbreviated (mid., ...) + B: dateStrGetter('dayPeriods', 'abbreviated', 'format', true), + // // equivalent to b + BB: dateStrGetter('dayPeriods', 'abbreviated', 'format', true), + // // equivalent to b + BBB: dateStrGetter('dayPeriods', 'abbreviated', 'format', true), + // Period of the day wide (midnight, noon, morning, afternoon, evening, night) + BBBB: dateStrGetter('dayPeriods', 'wide', 'format', true), + // Period of the day narrow (mi, ...) + BBBBB: dateStrGetter('dayPeriods', 'narrow', 'format', true), + + // Hour in AM/PM, (1-12) + h: dateGetter('Hours', 1, -12), + // Hour in AM/PM, padded (01-12) + hh: dateGetter('Hours', 2, -12), + + // Hour in day (0-23) + H: dateGetter('Hours', 1), + // Hour in day, padded (00-23) + HH: dateGetter('Hours', 2), + + // todo j, J, C + + // Minute in hour (0-59) + m: dateGetter('Minutes', 1), + // Minute in hour, padded (00-59) + mm: dateGetter('Minutes', 2), + + // Second in minute (0-59) + s: dateGetter('Seconds', 1), + // Second in minute, padded (00-59) + ss: dateGetter('Seconds', 2), + + // Fractional second padded (0-9) + S: dateGetter('Milliseconds', 1), + // Fractional second padded (00-99) + SS: dateGetter('Milliseconds', 2), + // Fractional second padded (000-999 = millisecond) + SSS: dateGetter('Milliseconds', 3), + + // todo A + + // should be location, but fallback to O instead because we don't have the data + z: timeZoneFallbackGetter('short'), + // equivalent to z + zz: timeZoneFallbackGetter('short'), + // equivalent to z + zzz: timeZoneFallbackGetter('short'), + // should be location, but fallback to OOOO instead because we don't have the data + zzzz: timeZoneFallbackGetter('long'), + + // Timezone ISO8601 short format (-0430) + Z: timeZoneGetter('short'), + // equivalent to Z + ZZ: timeZoneGetter('short'), + // equivalent to Z + ZZZ: timeZoneGetter('short'), + // equivalent to OOOO + ZZZZ: timeZoneGetter('long'), + // Timezone ISO8601 extended format (-04:30) + ZZZZZ: timeZoneGetter('extended'), + + // Timezone GMT short format (GMT+4) + O: timeZoneFallbackGetter('short'), + // equivalent to O + OO: timeZoneFallbackGetter('short'), + // equivalent to O + OOO: timeZoneFallbackGetter('short'), + // Timezone GMT long format (GMT+0430) + OOOO: timeZoneFallbackGetter('long'), + + // todo v, V, X, x +}; + +const ALL_COLONS = /:/g; +function timezoneToOffset(timezone: string, fallback: number): number { + // Support: IE 9-11 only, Edge 13-15+ + // IE/Edge do not "understand" colon (`:`) in timezone + timezone = timezone.replace(ALL_COLONS, ''); + const requestedTimezoneOffset = Date.parse('Jan 01, 1970 00:00:00 ' + timezone) / 60000; + return isNaN(requestedTimezoneOffset) ? fallback : requestedTimezoneOffset; +} + +function addDateMinutes(date: Date, minutes: number) { + date = new Date(date.getTime()); + date.setMinutes(date.getMinutes() + minutes); + return date; +} + +function convertTimezoneToLocal(date: Date, timezone: string, reverse: boolean): Date { + const reverseValue = reverse ? -1 : 1; + const dateTimezoneOffset = date.getTimezoneOffset(); + const timezoneOffset = timezoneToOffset(timezone, dateTimezoneOffset); + return addDateMinutes(date, reverseValue * (timezoneOffset - dateTimezoneOffset)); +} diff --git a/packages/common/src/pipes/deprecated/date_pipe.ts b/packages/common/src/pipes/deprecated/date_pipe.ts new file mode 100644 index 00000000000000..99ef76eca76093 --- /dev/null +++ b/packages/common/src/pipes/deprecated/date_pipe.ts @@ -0,0 +1,179 @@ +/** +* @license +* Copyright Google Inc. All Rights Reserved. +* +* Use of this source code is governed by an MIT-style license that can be +* found in the LICENSE file at https://angular.io/license + */ + +import {Inject, LOCALE_ID, Pipe, PipeTransform} from '@angular/core'; + +import {invalidPipeArgumentError} from '../invalid_pipe_argument_error'; + +import {DateFormatter} from './intl'; +import {isNumeric} from './number_pipe'; + +const ISO8601_DATE_REGEX = + /^(\d{4})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/; +// 1 2 3 4 5 6 7 8 9 10 11 + +/** + * @ngModule CommonModule + * @whatItDoes Formats a date according to locale rules. + * @howToUse `date_expression | date[:format]` + * @description + * + * Where: + * - `expression` is a date object or a number (milliseconds since UTC epoch) or an ISO string + * (https://www.w3.org/TR/NOTE-datetime). + * - `format` indicates which date/time components to include. The format can be predefined as + * shown below or custom as shown in the table. + * - `'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` for `en-US`) + * - `'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`) + * + * + * | Component | Symbol | Narrow | Short Form | Long Form | Numeric | 2-digit | + * |-----------|:------:|--------|--------------|-------------------|-----------|-----------| + * | era | G | G (A) | GGG (AD) | GGGG (Anno Domini)| - | - | + * | year | y | - | - | - | y (2015) | yy (15) | + * | month | M | L (S) | MMM (Sep) | MMMM (September) | M (9) | MM (09) | + * | day | d | - | - | - | d (3) | dd (03) | + * | weekday | E | E (S) | 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) | - | - | - | + * | timezone | a | - | a (PM) | - | - | - | + * + * In javascript, only the components specified will be respected (not the ordering, + * punctuations, ...) and details of the formatting will be dependent on the locale. + * + * Timezone of the formatted text will be the local system timezone of the end-user's machine. + * + * When the expression is a ISO string without time (e.g. 2016-09-19) the time zone offset is not + * applied and the formatted text will have the same day, month and year of the expression. + * + * WARNINGS: + * - this pipe is marked as pure hence it will not be re-evaluated when the input is mutated. + * Instead users should treat the date as an immutable object and change the reference when the + * pipe needs to re-run (this is to avoid reformatting the date on every change detection run + * which would be an expensive operation). + * - this pipe uses the Internationalization API. Therefore it is only reliable in Chrome and Opera + * browsers. + * + * ### 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' + * ``` + * + * {@example common/pipes/ts/date_pipe.ts region='DatePipe'} + * + * @stable + */ +@Pipe({name: 'date', pure: true}) +export class DeprecatedDatePipe implements PipeTransform { + /** @internal */ + static _ALIASES: {[key: string]: string} = { + 'medium': 'yMMMdjms', + 'short': 'yMdjm', + 'fullDate': 'yMMMMEEEEd', + 'longDate': 'yMMMMd', + 'mediumDate': 'yMMMd', + 'shortDate': 'yMd', + 'mediumTime': 'jms', + 'shortTime': 'jm' + }; + + constructor(@Inject(LOCALE_ID) private _locale: string) {} + + transform(value: any, pattern: string = 'mediumDate'): string|null { + let date: Date; + + if (isBlank(value) || value !== value) return null; + + if (typeof value === 'string') { + value = value.trim(); + } + + if (isDate(value)) { + date = value; + } else if (isNumeric(value)) { + date = new Date(parseFloat(value)); + } else if (typeof value === 'string' && /^(\d{4}-\d{1,2}-\d{1,2})$/.test(value)) { + /** + * For ISO Strings without time the day, month and year must be extracted from the ISO String + * before Date creation to avoid time offset and errors in the new Date. + * If we only replace '-' with ',' in the ISO String ("2015,01,01"), and try to create a new + * date, some browsers (e.g. IE 9) will throw an invalid Date error + * If we leave the '-' ("2015-01-01") and try to create a new Date("2015-01-01") the + * timeoffset + * is applied + * Note: ISO months are 0 for January, 1 for February, ... + */ + const [y, m, d] = value.split('-').map((val: string) => parseInt(val, 10)); + date = new Date(y, m - 1, d); + } else { + date = new Date(value); + } + + if (!isDate(date)) { + let match: RegExpMatchArray|null; + if ((typeof value === 'string') && (match = value.match(ISO8601_DATE_REGEX))) { + date = isoStringToDate(match); + } else { + throw invalidPipeArgumentError(DeprecatedDatePipe, value); + } + } + + return DateFormatter.format( + date, this._locale, DeprecatedDatePipe._ALIASES[pattern] || pattern); + } +} + +function isBlank(obj: any): boolean { + return obj == null || obj === ''; +} + +function isDate(obj: any): obj is Date { + return obj instanceof Date && !isNaN(obj.valueOf()); +} + +function isoStringToDate(match: RegExpMatchArray): Date { + const date = new Date(0); + let tzHour = 0; + let tzMin = 0; + const dateSetter = match[8] ? date.setUTCFullYear : date.setFullYear; + const timeSetter = match[8] ? date.setUTCHours : date.setHours; + + if (match[9]) { + tzHour = toInt(match[9] + match[10]); + tzMin = toInt(match[9] + match[11]); + } + dateSetter.call(date, toInt(match[1]), toInt(match[2]) - 1, toInt(match[3])); + const h = toInt(match[4] || '0') - tzHour; + const m = toInt(match[5] || '0') - tzMin; + const s = toInt(match[6] || '0'); + const ms = Math.round(parseFloat('0.' + (match[7] || 0)) * 1000); + timeSetter.call(date, h, m, s, ms); + return date; +} + +function toInt(str: string): number { + return parseInt(str, 10); +} diff --git a/packages/common/src/pipes/deprecated/index.ts b/packages/common/src/pipes/deprecated/index.ts new file mode 100644 index 00000000000000..d67bf2c4cc521f --- /dev/null +++ b/packages/common/src/pipes/deprecated/index.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * @module + * @description + * This module provides a set of deprecated i18n pipes that will be + * removed in Angular v6 in favor of the new i18n pipes that don't use the intl api. + * @deprecated + */ +import {DeprecatedDatePipe} from './date_pipe'; +import {DeprecatedCurrencyPipe, DeprecatedDecimalPipe, DeprecatedPercentPipe} from './number_pipe'; + +export { + DeprecatedCurrencyPipe, + DeprecatedDatePipe, + DeprecatedDecimalPipe, + DeprecatedPercentPipe, +}; + + +/** + * A collection of deprecated i18n pipes that are likely to be used in each and every application. + */ +export const COMMON_DEPRECATED_I18N_PIPES = [ + DeprecatedDecimalPipe, + DeprecatedPercentPipe, + DeprecatedCurrencyPipe, + DeprecatedDatePipe +]; diff --git a/packages/common/src/pipes/intl.ts b/packages/common/src/pipes/deprecated/intl.ts similarity index 99% rename from packages/common/src/pipes/intl.ts rename to packages/common/src/pipes/deprecated/intl.ts index 483e89fd8c84c8..0d26dbcc06b257 100644 --- a/packages/common/src/pipes/intl.ts +++ b/packages/common/src/pipes/deprecated/intl.ts @@ -5,12 +5,7 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ - -export enum NumberFormatStyle { - Decimal, - Percent, - Currency, -} +import {NumberFormatStyle} from '../number_pipe'; export class NumberFormatter { static format(num: number, locale: string, style: NumberFormatStyle, opts: { diff --git a/packages/common/src/pipes/deprecated/number_pipe.ts b/packages/common/src/pipes/deprecated/number_pipe.ts new file mode 100644 index 00000000000000..c86ee42a8ac8a1 --- /dev/null +++ b/packages/common/src/pipes/deprecated/number_pipe.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Inject, LOCALE_ID, Pipe, PipeTransform, Type} from '@angular/core'; + +import {invalidPipeArgumentError} from '../invalid_pipe_argument_error'; +import {NumberFormatStyle} from '../number_pipe'; + +import {NumberFormatter} from './intl'; + +const _NUMBER_FORMAT_REGEXP = /^(\d+)?\.((\d+)(-(\d+))?)?$/; + +function formatNumber( + pipe: Type, locale: string, value: number | string, style: NumberFormatStyle, + digits?: string | null, currency: string | null = null, + currencyAsSymbol: boolean = false): string|null { + if (value == null) return null; + + // Convert strings to numbers + value = typeof value === 'string' && isNumeric(value) ? +value : value; + if (typeof value !== 'number') { + throw invalidPipeArgumentError(pipe, value); + } + + let minInt: number|undefined = undefined; + let minFraction: number|undefined = undefined; + let maxFraction: number|undefined = undefined; + if (style !== NumberFormatStyle.Currency) { + // rely on Intl default for currency + minInt = 1; + minFraction = 0; + maxFraction = 3; + } + + if (digits) { + const parts = digits.match(_NUMBER_FORMAT_REGEXP); + if (parts === null) { + throw new Error(`${digits} is not a valid digit info for number pipes`); + } + if (parts[1] != null) { // min integer digits + minInt = parseIntAutoRadix(parts[1]); + } + if (parts[3] != null) { // min fraction digits + minFraction = parseIntAutoRadix(parts[3]); + } + if (parts[5] != null) { // max fraction digits + maxFraction = parseIntAutoRadix(parts[5]); + } + } + + return NumberFormatter.format(value as number, locale, style, { + minimumIntegerDigits: minInt, + minimumFractionDigits: minFraction, + maximumFractionDigits: maxFraction, + currency: currency, + currencyAsSymbol: currencyAsSymbol, + }); +} + +/** + * @ngModule CommonModule + * @whatItDoes Formats a number according to locale rules. + * @howToUse `number_expression | number[:digitInfo]` + * + * Formats a number as text. Group sizing and separator and other locale-specific + * configurations are based on the active locale. + * + * where `expression` is a number: + * - `digitInfo` is a `string` which has a following format:
+ * {minIntegerDigits}.{minFractionDigits}-{maxFractionDigits} + * - `minIntegerDigits` is the minimum number of integer digits to use. Defaults to `1`. + * - `minFractionDigits` is the minimum number of digits after fraction. Defaults to `0`. + * - `maxFractionDigits` is the maximum number of digits after fraction. Defaults to `3`. + * + * For more information on the acceptable range for each of these numbers and other + * details see your native internationalization library. + * + * WARNING: this pipe uses the Internationalization API which is not yet available in all browsers + * and may require a polyfill. See [Browser Support](guide/browser-support) for details. + * + * ### Example + * + * {@example common/pipes/ts/number_pipe.ts region='NumberPipe'} + * + * @stable + */ +@Pipe({name: 'number'}) +export class DeprecatedDecimalPipe implements PipeTransform { + constructor(@Inject(LOCALE_ID) private _locale: string) {} + + transform(value: any, digits?: string): string|null { + return formatNumber( + DeprecatedDecimalPipe, this._locale, value, NumberFormatStyle.Decimal, digits); + } +} + +/** + * @ngModule CommonModule + * @whatItDoes Formats a number as a percentage according to locale rules. + * @howToUse `number_expression | percent[:digitInfo]` + * + * @description + * + * Formats a number as percentage. + * + * - `digitInfo` See {@link DecimalPipe} for detailed description. + * + * WARNING: this pipe uses the Internationalization API which is not yet available in all browsers + * and may require a polyfill. See [Browser Support](guide/browser-support) for details. + * + * ### Example + * + * {@example common/pipes/ts/number_pipe.ts region='PercentPipe'} + * + * @stable + */ +@Pipe({name: 'percent'}) +export class DeprecatedPercentPipe implements PipeTransform { + constructor(@Inject(LOCALE_ID) private _locale: string) {} + + transform(value: any, digits?: string): string|null { + return formatNumber( + DeprecatedPercentPipe, this._locale, value, NumberFormatStyle.Percent, digits); + } +} + +/** + * @ngModule CommonModule + * @whatItDoes Formats a number as currency using locale rules. + * @howToUse `number_expression | currency[:currencyCode[:symbolDisplay[:digitInfo]]]` + * @description + * + * Use `currency` to format a number as currency. + * + * - `currencyCode` is the [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) currency code, such + * as `USD` for the US dollar and `EUR` for the euro. + * - `symbolDisplay` is a boolean indicating whether to use the currency symbol or code. + * - `true`: use symbol (e.g. `$`). + * - `false`(default): use code (e.g. `USD`). + * - `digitInfo` See {@link DecimalPipe} for detailed description. + * + * WARNING: this pipe uses the Internationalization API which is not yet available in all browsers + * and may require a polyfill. See [Browser Support](guide/browser-support) for details. + * + * ### Example + * + * {@example common/pipes/ts/number_pipe.ts region='CurrencyPipe'} + * + * @stable + */ +@Pipe({name: 'currency'}) +export class DeprecatedCurrencyPipe implements PipeTransform { + constructor(@Inject(LOCALE_ID) private _locale: string) {} + + transform( + value: any, currencyCode: string = 'USD', symbolDisplay: boolean = false, + digits?: string): string|null { + return formatNumber( + DeprecatedCurrencyPipe, this._locale, value, NumberFormatStyle.Currency, digits, + currencyCode, symbolDisplay); + } +} + +function parseIntAutoRadix(text: string): number { + const result: number = parseInt(text); + if (isNaN(result)) { + throw new Error('Invalid integer literal when parsing ' + text); + } + return result; +} + +export function isNumeric(value: any): boolean { + return !isNaN(value - parseFloat(value)); +} diff --git a/packages/common/src/pipes/i18n_plural_pipe.ts b/packages/common/src/pipes/i18n_plural_pipe.ts index dc0909d1a5e16e..d771bd0555f3fc 100644 --- a/packages/common/src/pipes/i18n_plural_pipe.ts +++ b/packages/common/src/pipes/i18n_plural_pipe.ts @@ -6,8 +6,10 @@ * found in the LICENSE file at https://angular.io/license */ -import {Pipe, PipeTransform} from '@angular/core'; -import {NgLocalization, getPluralCategory} from '../localization'; +import {Inject, LOCALE_ID, Pipe, PipeTransform} from '@angular/core'; + +import {NgLocalization, getPluralCategory} from '../i18n/localization'; + import {invalidPipeArgumentError} from './invalid_pipe_argument_error'; const _INTERPOLATION_REGEXP: RegExp = /#/g; @@ -15,13 +17,15 @@ const _INTERPOLATION_REGEXP: RegExp = /#/g; /** * @ngModule CommonModule * @whatItDoes Maps a value to a string that pluralizes the value according to locale rules. - * @howToUse `expression | i18nPlural:mapping` + * @howToUse `expression | i18nPlural:mapping[:locale]` * @description * * Where: * - `expression` is a number. * - `mapping` is an object that mimics the ICU format, see * http://userguide.icu-project.org/formatparse/messages + * - `locale` is a `string` defining the locale to use (uses the current {@link LOCALE_ID} by + * default) * * ## Example * @@ -31,16 +35,16 @@ const _INTERPOLATION_REGEXP: RegExp = /#/g; */ @Pipe({name: 'i18nPlural', pure: true}) export class I18nPluralPipe implements PipeTransform { - constructor(private _localization: NgLocalization) {} + constructor(private localization: NgLocalization) {} - transform(value: number, pluralMap: {[count: string]: string}): string { + transform(value: number, pluralMap: {[count: string]: string}, locale?: string): string { if (value == null) return ''; if (typeof pluralMap !== 'object' || pluralMap === null) { throw invalidPipeArgumentError(I18nPluralPipe, pluralMap); } - const key = getPluralCategory(value, Object.keys(pluralMap), this._localization); + const key = getPluralCategory(value, Object.keys(pluralMap), this.localization, locale); return pluralMap[key].replace(_INTERPOLATION_REGEXP, value.toString()); } diff --git a/packages/common/src/pipes/index.ts b/packages/common/src/pipes/index.ts index 7014c6275debdf..9c50f18237edd5 100644 --- a/packages/common/src/pipes/index.ts +++ b/packages/common/src/pipes/index.ts @@ -35,9 +35,18 @@ export { UpperCasePipe }; +/** + * A collection of i18n pipes that are likely to be used in each and every application. + */ +export const COMMON_I18N_PIPES = [ + DecimalPipe, + PercentPipe, + CurrencyPipe, + DatePipe +]; /** - * A collection of Angular pipes that are likely to be used in each and every application. + * A collection of pipes that are likely to be used in each and every application. */ export const COMMON_PIPES = [ AsyncPipe, @@ -45,11 +54,7 @@ export const COMMON_PIPES = [ LowerCasePipe, JsonPipe, SlicePipe, - DecimalPipe, - PercentPipe, TitleCasePipe, - CurrencyPipe, - DatePipe, I18nPluralPipe, I18nSelectPipe, ]; diff --git a/packages/common/src/pipes/number_pipe.ts b/packages/common/src/pipes/number_pipe.ts index ae170ddc5f6eb7..eaa5cc3f2e7bbe 100644 --- a/packages/common/src/pipes/number_pipe.ts +++ b/packages/common/src/pipes/number_pipe.ts @@ -6,63 +6,365 @@ * found in the LICENSE file at https://angular.io/license */ -import {Inject, LOCALE_ID, Pipe, PipeTransform, Type} from '@angular/core'; -import {NumberFormatStyle, NumberFormatter} from './intl'; +import {Inject, LOCALE_DATA, LOCALE_ID, NgLocale, Optional, Pipe, PipeTransform, Type} from '@angular/core'; + +import {CURRENCIES} from '../i18n/currencies'; +import {findNgLocale} from '../i18n/localization'; + import {invalidPipeArgumentError} from './invalid_pipe_argument_error'; -const _NUMBER_FORMAT_REGEXP = /^(\d+)?\.((\d+)(-(\d+))?)?$/; +const NUMBER_FORMAT_REGEXP = /^(\d+)?\.((\d+)(-(\d+))?)?$/; +const MAX_DIGITS = 22; +const DECIMAL_SEP = '.'; +const ZERO_CHAR = '0'; +const PATTERN_SEP = ';'; +const GROUP_SEP = ','; +const DIGIT_CHAR = '#'; +const CURRENCY_CHAR = '¤'; +const PERCENT_CHAR = '%'; + +// todo (ocombe): add pipe "scientific" to format number +export enum NumberFormatStyle { + Decimal, + Percent, + Currency, + Scientific +} function formatNumber( - pipe: Type, locale: string, value: number | string, style: NumberFormatStyle, - digits?: string | null, currency: string | null = null, - currencyAsSymbol: boolean = false): string|null { + pipe: Type, value: number | string, style: NumberFormatStyle, format: string, + localeDatum: NgLocale, digitsInfo?: string | null, currency: string | null = null): string| + null { if (value == null) return null; + let number: number; // Convert strings to numbers - value = typeof value === 'string' && isNumeric(value) ? +value : value; - if (typeof value !== 'number') { - throw invalidPipeArgumentError(pipe, value); + number = typeof value === 'string' && isNumeric(value) ? +value : value as number; + if (typeof number !== 'number') { + throw invalidPipeArgumentError(pipe, number); } - let minInt: number|undefined = undefined; - let minFraction: number|undefined = undefined; - let maxFraction: number|undefined = undefined; - if (style !== NumberFormatStyle.Currency) { - // rely on Intl default for currency - minInt = 1; - minFraction = 0; - maxFraction = 3; + if (style === NumberFormatStyle.Percent) { + number = number * 100; } - if (digits) { - const parts = digits.match(_NUMBER_FORMAT_REGEXP); - if (parts === null) { - throw new Error(`${digits} is not a valid digit info for number pipes`); + const numStr = Math.abs(number) + ''; + const pattern = parseNumberFormat(format, localeDatum.numberSettings.symbols.minusSign); + let formattedText = ''; + let isZero = false; + + if (!isFinite(number)) { + formattedText = localeDatum.numberSettings.symbols.infinity; + } else { + const parsedNumber = parseNumber(numStr); + + let minInt = pattern.minInt; + let minFraction = pattern.minFrac; + let maxFraction = pattern.maxFrac; + + if (digitsInfo) { + const parts = digitsInfo.match(NUMBER_FORMAT_REGEXP); + if (parts === null) { + throw new Error(`${digitsInfo} is not a valid digit info for number pipes`); + } + if (parts[1] != null) { // min integer digits + minInt = parseIntAutoRadix(parts[1]); + } + if (parts[3] != null) { // min fraction digits + minFraction = parseIntAutoRadix(parts[3]); + } + if (parts[5] != null) { // max fraction digits + maxFraction = parseIntAutoRadix(parts[5]); + } else if (parts[3] != null) { + maxFraction = parseIntAutoRadix(parts[3]); + } + } + + roundNumber(parsedNumber, minFraction, maxFraction); + + let digits = parsedNumber.digits; + let integerLen = parsedNumber.integerLen; + const exponent = parsedNumber.exponent; + let decimals = []; + isZero = digits.reduce(function(isZero, d) { return isZero && !d; }, true); + + // pad zeros for small numbers + while (integerLen < minInt) { + digits.unshift(0); + integerLen++; + } + + // pad zeros for small numbers + while (integerLen < 0) { + digits.unshift(0); + integerLen++; + } + + // extract decimals digits + if (integerLen > 0) { + decimals = digits.splice(integerLen, digits.length); + } else { + decimals = digits; + digits = [0]; + } + + // format the integer digits with grouping separators + const groups = []; + if (digits.length >= pattern.lgSize) { + groups.unshift(digits.splice(-pattern.lgSize, digits.length).join('')); + } + + while (digits.length > pattern.gSize) { + groups.unshift(digits.splice(-pattern.gSize, digits.length).join('')); } - if (parts[1] != null) { // min integer digits - minInt = parseIntAutoRadix(parts[1]); + + if (digits.length) { + groups.unshift(digits.join('')); } - if (parts[3] != null) { // min fraction digits - minFraction = parseIntAutoRadix(parts[3]); + + formattedText = groups.join( + currency && localeDatum.numberSettings.symbols.currencyGroup ? + localeDatum.numberSettings.symbols.currencyGroup : + localeDatum.numberSettings.symbols.group); + + // append the decimal digits + if (decimals.length) { + formattedText += localeDatum.numberSettings.symbols.decimal + decimals.join(''); } - if (parts[5] != null) { // max fraction digits - maxFraction = parseIntAutoRadix(parts[5]); + + if (exponent) { + formattedText += localeDatum.numberSettings.symbols.exponential + '+' + exponent; } } - return NumberFormatter.format(value as number, locale, style, { - minimumIntegerDigits: minInt, - minimumFractionDigits: minFraction, - maximumFractionDigits: maxFraction, - currency: currency, - currencyAsSymbol: currencyAsSymbol, - }); + if (number < 0 && !isZero) { + formattedText = pattern.negPre + formattedText + pattern.negSuf; + } else { + formattedText = pattern.posPre + formattedText + pattern.posSuf; + } + + if (style === NumberFormatStyle.Currency && currency !== null) { + return formattedText.replace(new RegExp(CURRENCY_CHAR, 'g'), currency); + } + + if (style === NumberFormatStyle.Percent) { + return formattedText.replace( + new RegExp(PERCENT_CHAR, 'g'), localeDatum.numberSettings.symbols.percentSign); + } + + return formattedText; +} + +interface ParsedNumberFormat { + minInt: number; + // the minimum number of digits required in the fraction part of the number + minFrac: number; + // the maximum number of digits required in the fraction part of the number + maxFrac: number; + // the string to go in front of a positive number + posPre: string; + // the string to go after a positive number + posSuf: string; + // the string to go in front of a negative number (e.g. `-` or `(`)) + negPre: string; + // the string to go after a negative number (e.g. `)`) + negSuf: string; + // number of digits in each group of separated digits + gSize: number; + // number of digits in the last group of digits before the decimal separator + lgSize: number; +} + +function parseNumberFormat(format: string, minusSign = '-'): ParsedNumberFormat { + const p = { + minInt: 1, + minFrac: 0, + maxFrac: 0, + posPre: '', + posSuf: '', + negPre: '', + negSuf: '', + gSize: 0, + lgSize: 0 + }; + + const patternParts = format.split(PATTERN_SEP), positive = patternParts[0], + negative = patternParts[1]; + + const positiveParts = positive.indexOf(DECIMAL_SEP) !== -1 ? + positive.split(DECIMAL_SEP) : + [ + positive.substring(0, positive.lastIndexOf(ZERO_CHAR) + 1), + positive.substring(positive.lastIndexOf(ZERO_CHAR) + 1) + ], + integer = positiveParts[0], fraction = positiveParts[1] || ''; + + p.posPre = integer.substr(0, integer.indexOf(DIGIT_CHAR)); + + for (let i = 0; i < fraction.length; i++) { + const ch = fraction.charAt(i); + if (ch === ZERO_CHAR) { + p.minFrac = p.maxFrac = i + 1; + } else if (ch === DIGIT_CHAR) { + p.maxFrac = i + 1; + } else { + p.posSuf += ch; + } + } + + const groups = integer.split(GROUP_SEP); + p.gSize = groups[1] ? groups[1].length : 0; + p.lgSize = (groups[2] || groups[1]) ? (groups[2] || groups[1]).length : 0; + + if (negative) { + const trunkLen = positive.length - p.posPre.length - p.posSuf.length, + pos = negative.indexOf(DIGIT_CHAR); + + p.negPre = negative.substr(0, pos).replace(/'/g, ''); + p.negSuf = negative.substr(pos + trunkLen).replace(/'/g, ''); + } else { + p.negPre = minusSign + p.posPre; + p.negSuf = p.posSuf; + } + + return p; +} + +interface ParsedNumber { + digits: number[]; + exponent: number; + integerLen: number; +} + +/** + * Parse a number (as a string) into three components that can be used + * for formatting the number. + * + * (Significant bits of this parse algorithm came from https://github.com/MikeMcl/big.js/) + * + * @param {string} numStr The number to parse + * @return {ParsedNumber} An object describing this number, containing the following keys: + * - digits : an array of digits containing leading zeros as necessary + * - integerLen : the number of the digits in `d` that are to the left of the decimal point + * - exponent : the exponent for numbers that would need more than `MAX_DIGITS` digits in `d` + * + */ +function parseNumber(numStr: string): ParsedNumber { + let exponent = 0, digits, integerLen; + let i, j, zeros; + + // Decimal point? + if ((integerLen = numStr.indexOf(DECIMAL_SEP)) > -1) { + numStr = numStr.replace(DECIMAL_SEP, ''); + } + + // Exponential form? + if ((i = numStr.search(/e/i)) > 0) { + // Work out the exponent. + if (integerLen < 0) integerLen = i; + integerLen += +numStr.slice(i + 1); + numStr = numStr.substring(0, i); + } else if (integerLen < 0) { + // There was no decimal point or exponent so it is an integer. + integerLen = numStr.length; + } + + // Count the number of leading zeros. + for (i = 0; numStr.charAt(i) === ZERO_CHAR; i++) { /* empty */ + } + + if (i === (zeros = numStr.length)) { + // The digits are all zero. + digits = [0]; + integerLen = 1; + } else { + // Count the number of trailing zeros + zeros--; + while (numStr.charAt(zeros) === ZERO_CHAR) zeros--; + + // Trailing zeros are insignificant so ignore them + integerLen -= i; + digits = []; + // Convert string to array of digits without leading/trailing zeros. + for (j = 0; i <= zeros; i++, j++) { + digits[j] = +numStr.charAt(i); + } + } + + // If the number overflows the maximum allowed digits then use an exponent. + if (integerLen > MAX_DIGITS) { + digits = digits.splice(0, MAX_DIGITS - 1); + exponent = integerLen - 1; + integerLen = 1; + } + + return {digits, exponent, integerLen}; +} + +/** + * Round the parsed number to the specified number of decimal places + * This function changes the parsedNumber in-place + */ +function roundNumber(parsedNumber: ParsedNumber, minFrac: number, maxFrac: number) { + let digits = parsedNumber.digits; + let fractionLen = digits.length - parsedNumber.integerLen; + + const fractionSize = Math.min(Math.max(minFrac, fractionLen), maxFrac); + + // The index of the digit to where rounding is to occur + let roundAt = fractionSize + parsedNumber.integerLen; + let digit = digits[roundAt]; + + if (roundAt > 0) { + // Drop fractional digits beyond `roundAt` + digits.splice(Math.max(parsedNumber.integerLen, roundAt)); + + // Set non-fractional digits beyond `roundAt` to 0 + for (let j = roundAt; j < digits.length; j++) { + digits[j] = 0; + } + } else { + // We rounded to zero so reset the parsedNumber + fractionLen = Math.max(0, fractionLen); + parsedNumber.integerLen = 1; + digits.length = Math.max(1, roundAt = fractionSize + 1); + digits[0] = 0; + for (let i = 1; i < roundAt; i++) digits[i] = 0; + } + + if (digit >= 5) { + if (roundAt - 1 < 0) { + for (let k = 0; k > roundAt; k--) { + digits.unshift(0); + parsedNumber.integerLen++; + } + digits.unshift(1); + parsedNumber.integerLen++; + } else { + digits[roundAt - 1]++; + } + } + + // Pad out with zeros to get the required fraction length + for (; fractionLen < Math.max(0, fractionSize); fractionLen++) digits.push(0); + + + // Do any carrying, e.g. a digit was rounded up to 10 + const carry = digits.reduceRight(function(carry, d, i, digits) { + d = d + carry; + digits[i] = d % 10; + return Math.floor(d / 10); + }, 0); + if (carry) { + digits.unshift(carry); + parsedNumber.integerLen++; + } } /** * @ngModule CommonModule * @whatItDoes Formats a number according to locale rules. - * @howToUse `number_expression | number[:digitInfo]` + * @howToUse `number_expression | number[:digitInfo[:locale]]` * * Formats a number as text. Group sizing and separator and other locale-specific * configurations are based on the active locale. @@ -73,13 +375,12 @@ function formatNumber( * - `minIntegerDigits` is the minimum number of integer digits to use. Defaults to `1`. * - `minFractionDigits` is the minimum number of digits after fraction. Defaults to `0`. * - `maxFractionDigits` is the maximum number of digits after fraction. Defaults to `3`. + * - `locale` is a `string` defining the locale to use (uses the current {@link LOCALE_ID} by + * default) * * For more information on the acceptable range for each of these numbers and other * details see your native internationalization library. * - * WARNING: this pipe uses the Internationalization API which is not yet available in all browsers - * and may require a polyfill. See [Browser Support](guide/browser-support) for details. - * * ### Example * * {@example common/pipes/ts/number_pipe.ts region='NumberPipe'} @@ -88,26 +389,30 @@ function formatNumber( */ @Pipe({name: 'number'}) export class DecimalPipe implements PipeTransform { - constructor(@Inject(LOCALE_ID) private _locale: string) {} + constructor( + @Inject(LOCALE_ID) private locale: string, + @Optional() @Inject(LOCALE_DATA) private localeData: NgLocale[]) {} - transform(value: any, digits?: string): string|null { - return formatNumber(DecimalPipe, this._locale, value, NumberFormatStyle.Decimal, digits); + transform(value: any, digits?: string, locale?: string): string|null { + const localeDatum = findNgLocale(locale || this.locale, this.localeData); + return formatNumber( + DecimalPipe, value, NumberFormatStyle.Decimal, localeDatum.numberSettings.formats.decimal, + localeDatum, digits); } } /** * @ngModule CommonModule * @whatItDoes Formats a number as a percentage according to locale rules. - * @howToUse `number_expression | percent[:digitInfo]` + * @howToUse `number_expression | percent[:digitInfo[:locale]]` * * @description * * Formats a number as percentage. * * - `digitInfo` See {@link DecimalPipe} for detailed description. - * - * WARNING: this pipe uses the Internationalization API which is not yet available in all browsers - * and may require a polyfill. See [Browser Support](guide/browser-support) for details. + * - `locale` is a `string` defining the locale to use (uses the current {@link LOCALE_ID} by + * default) * * ### Example * @@ -117,30 +422,37 @@ export class DecimalPipe implements PipeTransform { */ @Pipe({name: 'percent'}) export class PercentPipe implements PipeTransform { - constructor(@Inject(LOCALE_ID) private _locale: string) {} + constructor( + @Inject(LOCALE_ID) private locale: string, + @Optional() @Inject(LOCALE_DATA) private localeData: NgLocale[]) {} - transform(value: any, digits?: string): string|null { - return formatNumber(PercentPipe, this._locale, value, NumberFormatStyle.Percent, digits); + transform(value: any, digits?: string, locale?: string): string|null { + const localeDatum = findNgLocale(locale || this.locale, this.localeData); + return formatNumber( + PercentPipe, value, NumberFormatStyle.Percent, localeDatum.numberSettings.formats.percent, + localeDatum, digits); } } /** * @ngModule CommonModule * @whatItDoes Formats a number as currency using locale rules. - * @howToUse `number_expression | currency[:currencyCode[:symbolDisplay[:digitInfo]]]` + * @howToUse `number_expression | currency[:currencyCode[:symbolDisplay[:digitInfo[:locale]]]]` * @description * * Use `currency` to format a number as currency. * * - `currencyCode` is the [ISO 4217](https://en.wikipedia.org/wiki/ISO_4217) currency code, such * as `USD` for the US dollar and `EUR` for the euro. - * - `symbolDisplay` is a boolean indicating whether to use the currency symbol or code. - * - `true`: use symbol (e.g. `$`). - * - `false`(default): use code (e.g. `USD`). + * - `display` indicates whether to show the currency symbol or the code. + * - `code`(default): use code (e.g. `USD`). + * - `symbol`: use symbol (e.g. `$`). + * - `symbol-narrow`: some countries have two symbols for their currency, one regular and one + * narrow (e.g. the canadian dollar CAD has the symbol `CA$` and the symbol-narrow `$`). + * If there is no narrow symbol for the chosen currency, the regular symbol will be used. * - `digitInfo` See {@link DecimalPipe} for detailed description. - * - * WARNING: this pipe uses the Internationalization API which is not yet available in all browsers - * and may require a polyfill. See [Browser Support](guide/browser-support) for details. + * - `locale` is a `string` defining the locale to use (uses the current {@link LOCALE_ID} by + * default) * * ### Example * @@ -150,14 +462,41 @@ export class PercentPipe implements PipeTransform { */ @Pipe({name: 'currency'}) export class CurrencyPipe implements PipeTransform { - constructor(@Inject(LOCALE_ID) private _locale: string) {} + constructor( + @Inject(LOCALE_ID) private locale: string, + @Optional() @Inject(LOCALE_DATA) private localeData: NgLocale[]) {} transform( - value: any, currencyCode: string = 'USD', symbolDisplay: boolean = false, - digits?: string): string|null { + value: any, currencyCode?: string, symbolDisplay: 'code'|'symbol'|'symbol-narrow' = 'code', + digits?: string, locale?: string): string|null { + const localeDatum = findNgLocale(locale || this.locale, this.localeData); + + if (typeof symbolDisplay === 'boolean') { + if (console && console.warn) { + console.warn( + `Warning: the currency pipe has been changed in Angular v5. The symbolDisplay option (third parameter) is now a string instead of a boolean. The accepted values are "code", "symbol" or "symbol-narrow".`); + } + symbolDisplay = symbolDisplay ? 'symbol' : 'code'; + } + + let currency: string; + if (currencyCode) { + if (symbolDisplay === 'symbol') { + currency = CURRENCIES[currencyCode]['symbol'] || currencyCode; + } else if (symbolDisplay === 'symbol-narrow') { + currency = CURRENCIES[currencyCode]['symbolNarrow'] || CURRENCIES[currencyCode]['symbol'] || + currencyCode; + } else { + currency = currencyCode; + } + } else { + currency = + localeDatum.currencySettings[symbolDisplay === 'code' ? 'name' : 'symbol'] || 'USD'; + } + return formatNumber( - CurrencyPipe, this._locale, value, NumberFormatStyle.Currency, digits, currencyCode, - symbolDisplay); + CurrencyPipe, value, NumberFormatStyle.Currency, + localeDatum.numberSettings.formats.currency, localeDatum, digits, currency); } } diff --git a/packages/common/test/directives/ng_component_outlet_spec.ts b/packages/common/test/directives/ng_component_outlet_spec.ts index 0d897a97c17c0b..5339e7dd4c998f 100644 --- a/packages/common/test/directives/ng_component_outlet_spec.ts +++ b/packages/common/test/directives/ng_component_outlet_spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {CommonModule} from '@angular/common'; +import {CommonModule, DeprecatedCommonModule} from '@angular/common'; import {NgComponentOutlet} from '@angular/common/src/directives/ng_component_outlet'; import {Compiler, Component, ComponentRef, Inject, InjectionToken, Injector, NO_ERRORS_SCHEMA, NgModule, NgModuleFactory, Optional, Provider, QueryList, ReflectiveInjector, TemplateRef, Type, ViewChild, ViewChildren, ViewContainerRef} from '@angular/core'; import {TestBed, async, fakeAsync} from '@angular/core/testing'; diff --git a/packages/common/test/localization_spec.ts b/packages/common/test/localization_spec.ts index 08e7792e8e4777..fb56ee816bbcba 100644 --- a/packages/common/test/localization_spec.ts +++ b/packages/common/test/localization_spec.ts @@ -6,10 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ -import {LOCALE_ID} from '@angular/core'; +import {LOCALE_DATA, LOCALE_ID} from '@angular/core'; import {TestBed, inject} from '@angular/core/testing'; -import {NgLocaleLocalization, NgLocalization, getPluralCategory} from '../src/localization'; +import {NgLocaleEn} from '../src/i18n/data/locale_en'; +import {NgLocaleFr} from '../src/i18n/data/locale_fr'; +import {NgLocaleRo} from '../src/i18n/data/locale_ro'; +import {NgLocaleSr} from '../src/i18n/data/locale_sr'; +import {NgLocaleZgh} from '../src/i18n/data/locale_zgh'; +import {NgLocaleLocalization, NgLocalization, findNgLocale, getPluralCategory} from '../src/i18n/localization'; export function main() { describe('l10n', () => { @@ -18,7 +23,10 @@ export function main() { describe('ro', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [{provide: LOCALE_ID, useValue: 'ro'}], + providers: [ + {provide: LOCALE_ID, useValue: 'ro'}, + {provide: LOCALE_DATA, useValue: NgLocaleRo, multi: true} + ], }); }); @@ -34,7 +42,10 @@ export function main() { describe('sr', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [{provide: LOCALE_ID, useValue: 'sr'}], + providers: [ + {provide: LOCALE_ID, useValue: 'sr'}, + {provide: LOCALE_DATA, useValue: NgLocaleSr, multi: true} + ], }); }); @@ -54,7 +65,7 @@ export function main() { describe('NgLocaleLocalization', () => { it('should return the correct values for the "en" locale', () => { - const l10n = new NgLocaleLocalization('en-US'); + const l10n = new NgLocaleLocalization('en-US', [NgLocaleEn]); expect(l10n.getPluralCategory(0)).toEqual('other'); expect(l10n.getPluralCategory(1)).toEqual('one'); @@ -62,7 +73,7 @@ export function main() { }); it('should return the correct values for the "ro" locale', () => { - const l10n = new NgLocaleLocalization('ro'); + const l10n = new NgLocaleLocalization('ro', [NgLocaleRo]); expect(l10n.getPluralCategory(0)).toEqual('few'); expect(l10n.getPluralCategory(1)).toEqual('one'); @@ -74,7 +85,7 @@ export function main() { }); it('should return the correct values for the "sr" locale', () => { - const l10n = new NgLocaleLocalization('sr'); + const l10n = new NgLocaleLocalization('sr', [NgLocaleSr]); expect(l10n.getPluralCategory(1)).toEqual('one'); expect(l10n.getPluralCategory(31)).toEqual('one'); @@ -121,7 +132,7 @@ export function main() { }); it('should return the default value for a locale with no rule', () => { - const l10n = new NgLocaleLocalization('zgh'); + const l10n = new NgLocaleLocalization('zgh', [NgLocaleZgh]); expect(l10n.getPluralCategory(0)).toEqual('other'); expect(l10n.getPluralCategory(1)).toEqual('other'); @@ -133,7 +144,7 @@ export function main() { describe('getPluralCategory', () => { it('should return plural category', () => { - const l10n = new NgLocaleLocalization('fr'); + const l10n = new NgLocaleLocalization('fr', [NgLocaleFr]); expect(getPluralCategory(0, ['one', 'other'], l10n)).toEqual('one'); expect(getPluralCategory(1, ['one', 'other'], l10n)).toEqual('one'); @@ -141,7 +152,7 @@ export function main() { }); it('should return discrete cases', () => { - const l10n = new NgLocaleLocalization('fr'); + const l10n = new NgLocaleLocalization('fr', [NgLocaleFr]); expect(getPluralCategory(0, ['one', 'other', '=0'], l10n)).toEqual('=0'); expect(getPluralCategory(1, ['one', 'other'], l10n)).toEqual('one'); @@ -150,7 +161,7 @@ export function main() { }); it('should fallback to other when the case is not present', () => { - const l10n = new NgLocaleLocalization('ro'); + const l10n = new NgLocaleLocalization('ro', [NgLocaleRo]); expect(getPluralCategory(1, ['one', 'other'], l10n)).toEqual('one'); // 2 -> 'few' expect(getPluralCategory(2, ['one', 'other'], l10n)).toEqual('other'); @@ -159,12 +170,31 @@ export function main() { describe('errors', () => { it('should report an error when the "other" category is not present', () => { expect(() => { - const l10n = new NgLocaleLocalization('ro'); + const l10n = new NgLocaleLocalization('ro', [NgLocaleRo]); // 2 -> 'few' getPluralCategory(2, ['one'], l10n); }).toThrowError('No plural message found for value "2"'); }); }); }); + + describe('findNgLocale', () => { + it('should throw if the locale provided is not a valid LOCALE_ID', () => { + expect(() => findNgLocale('invalid', null)) + .toThrow(new Error( + `"invalid" is not a valid LOCALE_ID value. See https://github.com/unicode-cldr/cldr-core/blob/master/availableLocales.json for a list of valid locales`)); + }); + + it('should throw if the LOCALE_DATA for the chosen locale if not available', () => { + expect(() => findNgLocale('fr-FR', null)) + .toThrow(new Error(`Missing NgLocale data for the locale "fr-FR"`)); + }); + + it('should return english data if the locale is en-US', + () => { expect(findNgLocale('en-US', null)).toEqual(NgLocaleEn); }); + + it('should return the LOCALE_DATA if it is available', + () => { expect(findNgLocale('fr-FR', [NgLocaleFr])).toEqual(NgLocaleFr); }); + }) }); } diff --git a/packages/common/test/pipes/date_pipe_spec.ts b/packages/common/test/pipes/date_pipe_spec.ts index 07a009d0dc23bf..ed1905333e604f 100644 --- a/packages/common/test/pipes/date_pipe_spec.ts +++ b/packages/common/test/pipes/date_pipe_spec.ts @@ -9,7 +9,12 @@ import {DatePipe} from '@angular/common'; import {JitReflector} from '@angular/compiler'; import {PipeResolver} from '@angular/compiler/src/pipe_resolver'; -import {browserDetection} from '@angular/platform-browser/testing/src/browser_util'; + +import {NgLocaleDe} from '../../src/i18n/data/locale_de'; +import {NgLocaleEn} from '../../src/i18n/data/locale_en'; +import {NgLocaleHu} from '../../src/i18n/data/locale_hu'; +import {NgLocaleSr} from '../../src/i18n/data/locale_sr'; +import {NgLocaleTh} from '../../src/i18n/data/locale_th'; export function main() { describe('DatePipe', () => { @@ -22,17 +27,9 @@ export function main() { expect(pipe.transform(date, pattern)).toEqual(output); } - // TODO: reactivate the disabled expectations once emulators are fixed in SauceLabs - // In some old versions of Chrome in Android emulators, time formatting returns dates in the - // timezone of the VM host, - // instead of the device timezone. Same symptoms as - // https://bugs.chromium.org/p/chromium/issues/detail?id=406382 - // This happens locally and in SauceLabs, so some checks are disabled to avoid failures. - // Tracking issue: https://github.com/angular/angular/issues/11187 - beforeEach(() => { - date = new Date(2015, 5, 15, 9, 3, 1); - pipe = new DatePipe('en-US'); + date = new Date(2015, 5, 15, 9, 3, 1, 550); + pipe = new DatePipe('en-US', [NgLocaleEn]); }); it('should be marked as pure', () => { @@ -67,59 +64,122 @@ export function main() { describe('transform', () => { it('should format each component correctly', () => { const dateFixtures: any = { - 'y': '2015', - 'yy': '15', - 'M': '6', - 'MM': '06', - 'MMM': 'Jun', - 'MMMM': 'June', - 'd': '15', - 'dd': '15', - 'EEE': 'Mon', - 'EEEE': 'Monday' + G: 'AD', + GG: 'AD', + GGG: 'AD', + GGGG: 'Anno Domini', + GGGGG: 'A', + y: '2015', + yy: '15', + yyy: '2015', + yyyy: '2015', + M: '6', + MM: '06', + MMM: 'Jun', + MMMM: 'June', + MMMMM: 'J', + L: '6', + LL: '06', + LLL: 'Jun', + LLLL: 'June', + LLLLL: 'J', + w: '25', + ww: '25', + W: '3', + d: '15', + dd: '15', + E: 'Mon', + EE: 'Mon', + EEE: 'Mon', + EEEE: 'Monday', + EEEEEE: 'Mo', + h: '9', + hh: '09', + H: '9', + HH: '09', + m: '3', + mm: '03', + s: '1', + ss: '01', + S: '6', + SS: '55', + SSS: '550', + a: 'AM', + aa: 'AM', + aaa: 'AM', + aaaa: 'AM', + aaaaa: 'a', + b: 'morning', + bb: 'morning', + bbb: 'morning', + bbbb: 'morning', + bbbbb: 'morning', + B: 'in the morning', + BB: 'in the morning', + BBB: 'in the morning', + BBBB: 'in the morning', + BBBBB: 'in the morning', }; const isoStringWithoutTimeFixtures: any = { - 'y': '2015', - 'yy': '15', - 'M': '1', - 'MM': '01', - 'MMM': 'Jan', - 'MMMM': 'January', - 'd': '1', - 'dd': '01', - 'EEE': 'Thu', - 'EEEE': 'Thursday' + G: 'AD', + GG: 'AD', + GGG: 'AD', + GGGG: 'Anno Domini', + GGGGG: 'A', + y: '2015', + yy: '15', + yyy: '2015', + yyyy: '2015', + M: '1', + MM: '01', + MMM: 'Jan', + MMMM: 'January', + MMMMM: 'J', + L: '1', + LL: '01', + LLL: 'Jan', + LLLL: 'January', + LLLLL: 'J', + w: '1', + ww: '01', + W: '1', + d: '1', + dd: '01', + E: 'Thu', + EE: 'Thu', + EEE: 'Thu', + EEEE: 'Thursday', + EEEEE: 'T', + EEEEEE: 'Th', + h: '12', + hh: '12', + H: '0', + HH: '00', + m: '0', + mm: '00', + s: '0', + ss: '00', + S: '0', + SS: '00', + SSS: '000', + a: 'AM', + aa: 'AM', + aaa: 'AM', + aaaa: 'AM', + aaaaa: 'a', + b: 'midnight', + bb: 'midnight', + bbb: 'midnight', + bbbb: 'midnight', + bbbbb: 'midnight', + B: 'midnight', + BB: 'midnight', + BBB: 'midnight', + BBBB: 'midnight', + BBBBB: 'mi', }; - if (!browserDetection.isOldChrome) { - dateFixtures['h'] = '9'; - dateFixtures['hh'] = '09'; - dateFixtures['j'] = '9 AM'; - isoStringWithoutTimeFixtures['h'] = '12'; - isoStringWithoutTimeFixtures['hh'] = '12'; - isoStringWithoutTimeFixtures['j'] = '12 AM'; - } - - // IE and Edge can't format a date to minutes and seconds without hours - if (!browserDetection.isEdge && !browserDetection.isIE || - !browserDetection.supportsNativeIntlApi) { - if (!browserDetection.isOldChrome) { - dateFixtures['HH'] = '09'; - isoStringWithoutTimeFixtures['HH'] = '00'; - } - dateFixtures['E'] = 'M'; - dateFixtures['L'] = 'J'; - dateFixtures['m'] = '3'; - dateFixtures['s'] = '1'; - dateFixtures['mm'] = '03'; - dateFixtures['ss'] = '01'; - isoStringWithoutTimeFixtures['m'] = '0'; - isoStringWithoutTimeFixtures['s'] = '0'; - isoStringWithoutTimeFixtures['mm'] = '00'; - isoStringWithoutTimeFixtures['ss'] = '00'; - } - Object.keys(dateFixtures).forEach((pattern: string) => { expectDateFormatAs(date, pattern, dateFixtures[pattern]); }); @@ -127,8 +187,26 @@ export function main() { Object.keys(isoStringWithoutTimeFixtures).forEach((pattern: string) => { expectDateFormatAs(isoStringWithoutTime, pattern, isoStringWithoutTimeFixtures[pattern]); }); + }); + + it('should format with timezones', () => { + const dateFixtures: any = { + z: /GMT(\+|-)\d/, + zz: /GMT(\+|-)\d/, + zzz: /GMT(\+|-)\d/, + zzzz: /GMT(\+|-)\d{2}\:30/, + Z: /(\+|-)\d{2}30/, + ZZ: /(\+|-)\d{2}30/, + ZZZ: /(\+|-)\d{2}30/, + ZZZZ: /GMT(\+|-)\d{2}\:30/, + ZZZZZ: /(\+|-)\d{2}\:30/, + O: /GMT(\+|-)\d/, + OOOO: /GMT(\+|-)\d{2}\:30/, + }; - expect(pipe.transform(date, 'Z')).toBeDefined(); + Object.keys(dateFixtures).forEach((pattern: string) => { + expect(pipe.transform(date, pattern, '+0430')).toMatch(dateFixtures[pattern]); + }); }); it('should format common multi component patterns', () => { @@ -141,19 +219,13 @@ export function main() { 'yMEEEd': '20156Mon15', 'MEEEd': '6Mon15', 'MMMd': 'Jun15', - 'yMMMMEEEEd': 'Monday, June 15, 2015' + 'EEEE, MMMM d, y': 'Monday, June 15, 2015', + 'H:mm a': '9:03 AM', + 'ms': '31', + 'MM/dd/yy hh:mm': '06/15/15 09:03', + 'MM/dd/y': '06/15/2015' }; - // IE and Edge can't format a date to minutes and seconds without hours - if (!browserDetection.isEdge && !browserDetection.isIE || - !browserDetection.supportsNativeIntlApi) { - dateFixtures['ms'] = '31'; - } - - if (!browserDetection.isOldChrome) { - dateFixtures['jm'] = '9:03 AM'; - } - Object.keys(dateFixtures).forEach((pattern: string) => { expectDateFormatAs(date, pattern, dateFixtures[pattern]); }); @@ -163,33 +235,23 @@ export function main() { it('should format with pattern aliases', () => { const dateFixtures: any = { 'MM/dd/yyyy': '06/15/2015', - 'fullDate': 'Monday, June 15, 2015', - 'longDate': 'June 15, 2015', - 'mediumDate': 'Jun 15, 2015', - 'shortDate': '6/15/2015' + shortDate: '6/15/15', + mediumDate: 'Jun 15, 2015', + longDate: 'June 15, 2015', + fullDate: 'Monday, June 15, 2015', + short: '6/15/15, 9:03 AM', + medium: 'Jun 15, 2015, 9:03:01 AM', + long: /June 15, 2015 at 9:03:01 AM GMT(\+|-)\d/, + full: /Monday, June 15, 2015 at 9:03:01 AM GMT(\+|-)\d{2}:\d{2}/, + shortTime: '9:03 AM', + mediumTime: '9:03:01 AM', + longTime: /9:03:01 AM GMT(\+|-)\d/, + fullTime: /9:03:01 AM GMT(\+|-)\d{2}:\d{2}/, }; - if (!browserDetection.isOldChrome) { - // IE and Edge do not add a coma after the year in these 2 cases - if ((browserDetection.isEdge || browserDetection.isIE) && - browserDetection.supportsNativeIntlApi) { - dateFixtures['medium'] = 'Jun 15, 2015 9:03:01 AM'; - dateFixtures['short'] = '6/15/2015 9:03 AM'; - } else { - dateFixtures['medium'] = 'Jun 15, 2015, 9:03:01 AM'; - dateFixtures['short'] = '6/15/2015, 9:03 AM'; - } - } - - if (!browserDetection.isOldChrome) { - dateFixtures['mediumTime'] = '9:03:01 AM'; - dateFixtures['shortTime'] = '9:03 AM'; - } - Object.keys(dateFixtures).forEach((pattern: string) => { - expectDateFormatAs(date, pattern, dateFixtures[pattern]); + expect(pipe.transform(date, pattern)).toMatch(dateFixtures[pattern]); }); - }); it('should format invalid in IE ISO date', @@ -198,8 +260,28 @@ export function main() { it('should format invalid in Safari ISO date', () => expect(pipe.transform('2017-01-20T19:00:00+0000')).toEqual('Jan 20, 2017')); + it('should format correctly in IE11', + () => expect(pipe.transform('2017-05-07T22:14:39', 'dd-MM-yyyy HH:mm', '+00:00')) + .toEqual('07-05-2017 22:14')); + + it('should show the correct time in Safari with shortTime and custom format', () => { + expect(pipe.transform('2017-06-13T10:14:39', 'shortTime', '+00:00')).toEqual('10:14 AM'); + expect(pipe.transform('2017-06-13T10:14:39', 'h:mm a', '+00:00')).toEqual('10:14 AM'); + }); + it('should remove bidi control characters', () => expect(pipe.transform(date, 'MM/dd/yyyy') !.length).toEqual(10)); + + it(`should format the date correctly in various locales`, () => { + expect(new DatePipe('de', [NgLocaleDe]).transform(date, 'short')) + .toEqual('15.06.15, 09:03'); + expect(new DatePipe('th', [NgLocaleTh]).transform(date, 'dd-MM-yy')).toEqual('15-06-15'); + expect(new DatePipe('hu', [NgLocaleHu]).transform(date, 'a')).toEqual('de.'); + expect(new DatePipe('sr', [NgLocaleSr]).transform(date, 'a')).toEqual('пре подне'); + + // todo(ocombe): activate this test when we support local numbers + // expect(new DatePipe('mr', [NgLocaleMr]).transform(date, 'hh')).toEqual('०९'); + }); }); }); } diff --git a/packages/common/test/pipes/deprecated/date_pipe_spec.ts b/packages/common/test/pipes/deprecated/date_pipe_spec.ts new file mode 100644 index 00000000000000..83dcf382ed1de4 --- /dev/null +++ b/packages/common/test/pipes/deprecated/date_pipe_spec.ts @@ -0,0 +1,205 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {DeprecatedDatePipe} from '@angular/common'; +import {JitReflector} from '@angular/compiler'; +import {PipeResolver} from '@angular/compiler/src/pipe_resolver'; +import {browserDetection} from '@angular/platform-browser/testing/src/browser_util'; + +export function main() { + describe('DeprecatedDatePipe', () => { + let date: Date; + const isoStringWithoutTime = '2015-01-01'; + let pipe: DeprecatedDatePipe; + + // Check the transformation of a date into a pattern + function expectDateFormatAs(date: Date | string, pattern: any, output: string): void { + expect(pipe.transform(date, pattern)).toEqual(output); + } + + // TODO: reactivate the disabled expectations once emulators are fixed in SauceLabs + // In some old versions of Chrome in Android emulators, time formatting returns dates in the + // timezone of the VM host, + // instead of the device timezone. Same symptoms as + // https://bugs.chromium.org/p/chromium/issues/detail?id=406382 + // This happens locally and in SauceLabs, so some checks are disabled to avoid failures. + // Tracking issue: https://github.com/angular/angular/issues/11187 + + beforeEach(() => { + date = new Date(2015, 5, 15, 9, 3, 1); + pipe = new DeprecatedDatePipe('en-US'); + }); + + it('should be marked as pure', () => { + expect(new PipeResolver(new JitReflector()).resolve(DeprecatedDatePipe) !.pure).toEqual(true); + }); + + describe('supports', () => { + it('should support date', () => { expect(() => pipe.transform(date)).not.toThrow(); }); + + it('should support int', () => { expect(() => pipe.transform(123456789)).not.toThrow(); }); + + it('should support numeric strings', + () => { expect(() => pipe.transform('123456789')).not.toThrow(); }); + + it('should support decimal strings', + () => { expect(() => pipe.transform('123456789.11')).not.toThrow(); }); + + it('should support ISO string', + () => expect(() => pipe.transform('2015-06-15T21:43:11Z')).not.toThrow()); + + it('should return null for empty string', () => expect(pipe.transform('')).toEqual(null)); + + it('should return null for NaN', () => expect(pipe.transform(Number.NaN)).toEqual(null)); + + it('should support ISO string without time', + () => { expect(() => pipe.transform(isoStringWithoutTime)).not.toThrow(); }); + + it('should not support other objects', + () => expect(() => pipe.transform({})).toThrowError(/InvalidPipeArgument/)); + }); + + describe('transform', () => { + it('should format each component correctly', () => { + const dateFixtures: any = { + 'y': '2015', + 'yy': '15', + 'M': '6', + 'MM': '06', + 'MMM': 'Jun', + 'MMMM': 'June', + 'd': '15', + 'dd': '15', + 'EEE': 'Mon', + 'EEEE': 'Monday' + }; + + const isoStringWithoutTimeFixtures: any = { + 'y': '2015', + 'yy': '15', + 'M': '1', + 'MM': '01', + 'MMM': 'Jan', + 'MMMM': 'January', + 'd': '1', + 'dd': '01', + 'EEE': 'Thu', + 'EEEE': 'Thursday' + }; + + if (!browserDetection.isOldChrome) { + dateFixtures['h'] = '9'; + dateFixtures['hh'] = '09'; + dateFixtures['j'] = '9 AM'; + isoStringWithoutTimeFixtures['h'] = '12'; + isoStringWithoutTimeFixtures['hh'] = '12'; + isoStringWithoutTimeFixtures['j'] = '12 AM'; + } + + // IE and Edge can't format a date to minutes and seconds without hours + if (!browserDetection.isEdge && !browserDetection.isIE || + !browserDetection.supportsNativeIntlApi) { + if (!browserDetection.isOldChrome) { + dateFixtures['HH'] = '09'; + isoStringWithoutTimeFixtures['HH'] = '00'; + } + dateFixtures['E'] = 'M'; + dateFixtures['L'] = 'J'; + dateFixtures['m'] = '3'; + dateFixtures['s'] = '1'; + dateFixtures['mm'] = '03'; + dateFixtures['ss'] = '01'; + isoStringWithoutTimeFixtures['m'] = '0'; + isoStringWithoutTimeFixtures['s'] = '0'; + isoStringWithoutTimeFixtures['mm'] = '00'; + isoStringWithoutTimeFixtures['ss'] = '00'; + } + + Object.keys(dateFixtures).forEach((pattern: string) => { + expectDateFormatAs(date, pattern, dateFixtures[pattern]); + }); + + Object.keys(isoStringWithoutTimeFixtures).forEach((pattern: string) => { + expectDateFormatAs(isoStringWithoutTime, pattern, isoStringWithoutTimeFixtures[pattern]); + }); + + expect(pipe.transform(date, 'Z')).toBeDefined(); + }); + + it('should format common multi component patterns', () => { + const dateFixtures: any = { + 'EEE, M/d/y': 'Mon, 6/15/2015', + 'EEE, M/d': 'Mon, 6/15', + 'MMM d': 'Jun 15', + 'dd/MM/yyyy': '15/06/2015', + 'MM/dd/yyyy': '06/15/2015', + 'yMEEEd': '20156Mon15', + 'MEEEd': '6Mon15', + 'MMMd': 'Jun15', + 'yMMMMEEEEd': 'Monday, June 15, 2015' + }; + + // IE and Edge can't format a date to minutes and seconds without hours + if (!browserDetection.isEdge && !browserDetection.isIE || + !browserDetection.supportsNativeIntlApi) { + dateFixtures['ms'] = '31'; + } + + if (!browserDetection.isOldChrome) { + dateFixtures['jm'] = '9:03 AM'; + } + + Object.keys(dateFixtures).forEach((pattern: string) => { + expectDateFormatAs(date, pattern, dateFixtures[pattern]); + }); + + }); + + it('should format with pattern aliases', () => { + const dateFixtures: any = { + 'MM/dd/yyyy': '06/15/2015', + 'fullDate': 'Monday, June 15, 2015', + 'longDate': 'June 15, 2015', + 'mediumDate': 'Jun 15, 2015', + 'shortDate': '6/15/2015' + }; + + if (!browserDetection.isOldChrome) { + // IE and Edge do not add a coma after the year in these 2 cases + if ((browserDetection.isEdge || browserDetection.isIE) && + browserDetection.supportsNativeIntlApi) { + dateFixtures['medium'] = 'Jun 15, 2015 9:03:01 AM'; + dateFixtures['short'] = '6/15/2015 9:03 AM'; + } else { + dateFixtures['medium'] = 'Jun 15, 2015, 9:03:01 AM'; + dateFixtures['short'] = '6/15/2015, 9:03 AM'; + } + } + + if (!browserDetection.isOldChrome) { + dateFixtures['mediumTime'] = '9:03:01 AM'; + dateFixtures['shortTime'] = '9:03 AM'; + } + + Object.keys(dateFixtures).forEach((pattern: string) => { + expectDateFormatAs(date, pattern, dateFixtures[pattern]); + }); + + }); + + it('should format invalid in IE ISO date', + () => expect(pipe.transform('2017-01-11T09:25:14.014-0500')).toEqual('Jan 11, 2017')); + + it('should format invalid in Safari ISO date', + () => expect(pipe.transform('2017-01-20T19:00:00+0000')).toEqual('Jan 20, 2017')); + + it('should remove bidi control characters', + () => expect(pipe.transform(date, 'MM/dd/yyyy') !.length).toEqual(10)); + }); + }); +} diff --git a/packages/common/test/pipes/deprecated/number_pipe_spec.ts b/packages/common/test/pipes/deprecated/number_pipe_spec.ts new file mode 100644 index 00000000000000..f3e493cf54f3b4 --- /dev/null +++ b/packages/common/test/pipes/deprecated/number_pipe_spec.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {DeprecatedCurrencyPipe, DeprecatedDecimalPipe, DeprecatedPercentPipe} from '@angular/common'; +import {isNumeric} from '@angular/common/src/pipes/number_pipe'; +import {beforeEach, describe, expect, it} from '@angular/core/testing/src/testing_internal'; +import {browserDetection} from '@angular/platform-browser/testing/src/browser_util'; + +export function main() { + describe('Number pipes', () => { + describe('DeprecatedDecimalPipe', () => { + let pipe: DeprecatedDecimalPipe; + + beforeEach(() => { pipe = new DeprecatedDecimalPipe('en-US'); }); + + describe('transform', () => { + it('should return correct value for numbers', () => { + expect(pipe.transform(12345)).toEqual('12,345'); + expect(pipe.transform(123, '.2')).toEqual('123.00'); + expect(pipe.transform(1, '3.')).toEqual('001'); + expect(pipe.transform(1.1, '3.4-5')).toEqual('001.1000'); + expect(pipe.transform(1.123456, '3.4-5')).toEqual('001.12346'); + expect(pipe.transform(1.1234)).toEqual('1.123'); + }); + + it('should support strings', () => { + expect(pipe.transform('12345')).toEqual('12,345'); + expect(pipe.transform('123', '.2')).toEqual('123.00'); + expect(pipe.transform('1', '3.')).toEqual('001'); + expect(pipe.transform('1.1', '3.4-5')).toEqual('001.1000'); + expect(pipe.transform('1.123456', '3.4-5')).toEqual('001.12346'); + expect(pipe.transform('1.1234')).toEqual('1.123'); + }); + + it('should not support other objects', () => { + expect(() => pipe.transform(new Object())).toThrowError(); + expect(() => pipe.transform('123abc')).toThrowError(); + }); + }); + }); + + describe('DeprecatedPercentPipe', () => { + let pipe: DeprecatedPercentPipe; + + beforeEach(() => { pipe = new DeprecatedPercentPipe('en-US'); }); + + describe('transform', () => { + it('should return correct value for numbers', () => { + expect(normalize(pipe.transform(1.23) !)).toEqual('123%'); + expect(normalize(pipe.transform(1.2, '.2') !)).toEqual('120.00%'); + }); + + it('should not support other objects', + () => { expect(() => pipe.transform(new Object())).toThrowError(); }); + }); + }); + + describe('DeprecatedCurrencyPipe', () => { + let pipe: DeprecatedCurrencyPipe; + + beforeEach(() => { pipe = new DeprecatedCurrencyPipe('en-US'); }); + + describe('transform', () => { + it('should return correct value for numbers', () => { + // In old Chrome, default formatiing for USD is different + if (browserDetection.isOldChrome) { + expect(normalize(pipe.transform(123) !)).toEqual('USD123'); + } else { + expect(normalize(pipe.transform(123) !)).toEqual('USD123.00'); + } + expect(normalize(pipe.transform(12, 'EUR', false, '.1') !)).toEqual('EUR12.0'); + expect(normalize(pipe.transform(5.1234, 'USD', false, '.0-3') !)).toEqual('USD5.123'); + }); + + it('should not support other objects', + () => { expect(() => pipe.transform(new Object())).toThrowError(); }); + }); + }); + + describe('isNumeric', () => { + it('should return true when passing correct numeric string', + () => { expect(isNumeric('2')).toBe(true); }); + + it('should return true when passing correct double string', + () => { expect(isNumeric('1.123')).toBe(true); }); + + it('should return true when passing correct negative string', + () => { expect(isNumeric('-2')).toBe(true); }); + + it('should return true when passing correct scientific notation string', + () => { expect(isNumeric('1e5')).toBe(true); }); + + it('should return false when passing incorrect numeric', + () => { expect(isNumeric('a')).toBe(false); }); + + it('should return false when passing parseable but non numeric', + () => { expect(isNumeric('2a')).toBe(false); }); + }); + }); +} + +// Between the symbol and the number, Edge adds a no breaking space and IE11 adds a standard space +function normalize(s: string): string { + return s.replace(/\u00A0| /g, ''); +} diff --git a/packages/common/test/pipes/number_pipe_spec.ts b/packages/common/test/pipes/number_pipe_spec.ts index 85cd2515278c23..209a407793dbfd 100644 --- a/packages/common/test/pipes/number_pipe_spec.ts +++ b/packages/common/test/pipes/number_pipe_spec.ts @@ -9,16 +9,16 @@ import {CurrencyPipe, DecimalPipe, PercentPipe} from '@angular/common'; import {isNumeric} from '@angular/common/src/pipes/number_pipe'; import {beforeEach, describe, expect, it} from '@angular/core/testing/src/testing_internal'; -import {browserDetection} from '@angular/platform-browser/testing/src/browser_util'; +import {NgLocaleEn} from '../../src/i18n/data/locale_en'; +import {NgLocaleEsUS} from '../../src/i18n/data/locale_es-US'; export function main() { describe('Number pipes', () => { describe('DecimalPipe', () => { - let pipe: DecimalPipe; - - beforeEach(() => { pipe = new DecimalPipe('en-US'); }); - describe('transform', () => { + let pipe: DecimalPipe; + beforeEach(() => { pipe = new DecimalPipe('en-US', [NgLocaleEn]); }); + it('should return correct value for numbers', () => { expect(pipe.transform(12345)).toEqual('12,345'); expect(pipe.transform(123, '.2')).toEqual('123.00'); @@ -38,47 +38,60 @@ export function main() { }); it('should not support other objects', () => { - expect(() => pipe.transform(new Object())).toThrowError(); + expect(() => pipe.transform({})).toThrowError(); expect(() => pipe.transform('123abc')).toThrowError(); }); }); + + describe('transform with custom locales', () => { + it('should return the correct format for es-US in IE11', () => { + const pipe = new DecimalPipe('es-US', [NgLocaleEsUS]); + expect(pipe.transform('9999999.99', '1.2-2')).toEqual('9,999,999.99'); + }); + }) }); describe('PercentPipe', () => { let pipe: PercentPipe; - beforeEach(() => { pipe = new PercentPipe('en-US'); }); + beforeEach(() => { pipe = new PercentPipe('en-US', [NgLocaleEn]); }); describe('transform', () => { it('should return correct value for numbers', () => { - expect(normalize(pipe.transform(1.23) !)).toEqual('123%'); - expect(normalize(pipe.transform(1.2, '.2') !)).toEqual('120.00%'); + expect(pipe.transform(1.23)).toEqual('123%'); + expect(pipe.transform(1.2, '.2')).toEqual('120.00%'); }); it('should not support other objects', - () => { expect(() => pipe.transform(new Object())).toThrowError(); }); + () => { expect(() => pipe.transform({})).toThrowError(); }); }); }); describe('CurrencyPipe', () => { let pipe: CurrencyPipe; - beforeEach(() => { pipe = new CurrencyPipe('en-US'); }); + beforeEach(() => { pipe = new CurrencyPipe('en-US', [NgLocaleEn]); }); describe('transform', () => { it('should return correct value for numbers', () => { - // In old Chrome, default formatiing for USD is different - if (browserDetection.isOldChrome) { - expect(normalize(pipe.transform(123) !)).toEqual('USD123'); - } else { - expect(normalize(pipe.transform(123) !)).toEqual('USD123.00'); - } - expect(normalize(pipe.transform(12, 'EUR', false, '.1') !)).toEqual('EUR12.0'); - expect(normalize(pipe.transform(5.1234, 'USD', false, '.0-3') !)).toEqual('USD5.123'); + expect(pipe.transform(123)).toEqual('US Dollar123.00'); + expect(pipe.transform(12, 'EUR', 'code', '.1')).toEqual('EUR12.0'); + expect(pipe.transform(5.1234, 'USD', 'code', '.0-3')).toEqual('USD5.123'); + expect(pipe.transform(5.1234, 'USD', 'code')).toEqual('USD5.12'); + expect(pipe.transform(5.1234, 'USD', 'symbol')).toEqual('$5.12'); + expect(pipe.transform(5.1234, 'CAD', 'symbol')).toEqual('CA$5.12'); + expect(pipe.transform(5.1234, 'CAD', 'symbol-narrow')).toEqual('$5.12'); }); it('should not support other objects', - () => { expect(() => pipe.transform(new Object())).toThrowError(); }); + () => { expect(() => pipe.transform({})).toThrowError(); }); + + it('should warn if you are using the v4 signature', () => { + const warnSpy = spyOn(console, 'warn'); + pipe.transform(123, 'USD', true); + expect(warnSpy).toHaveBeenCalledWith( + `Warning: the currency pipe has been changed in Angular v5. The symbolDisplay option (third parameter) is now a string instead of a boolean. The accepted values are "code", "symbol" or "symbol-narrow".`); + }) }); }); @@ -103,8 +116,3 @@ export function main() { }); }); } - -// Between the symbol and the number, Edge adds a no breaking space and IE11 adds a standard space -function normalize(s: string): string { - return s.replace(/\u00A0| /g, ''); -} diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 772da782a8ba0f..614e274a198604 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -25,7 +25,8 @@ export {DebugElement, DebugNode, asNativeElements, getDebugNode, Predicate} from export {GetTestability, Testability, TestabilityRegistry, setTestabilityGetter} from './testability/testability'; export * from './change_detection'; export * from './platform_core_providers'; -export {TRANSLATIONS, TRANSLATIONS_FORMAT, LOCALE_ID, MissingTranslationStrategy} from './i18n/tokens'; +export {TRANSLATIONS, TRANSLATIONS_FORMAT, LOCALE_ID, LOCALE_DATA, MissingTranslationStrategy} from './i18n/tokens'; +export {NgLocale, Plural} from './i18n/ng_locale'; export {ApplicationModule} from './application_module'; export {wtfCreateScope, wtfLeave, wtfStartTimeRange, wtfEndTimeRange, WtfScopeFn} from './profile/profile'; export {Type} from './type'; diff --git a/packages/core/src/i18n/ng_locale.ts b/packages/core/src/i18n/ng_locale.ts new file mode 100644 index 00000000000000..26460ca3573e9a --- /dev/null +++ b/packages/core/src/i18n/ng_locale.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export interface DayPeriods { + am: string; + pm: string; + noon?: string; + midnight?: string; + morning1?: string; + morning2?: string; + afternoon1?: string; + afternoon2?: string; + evening1?: string; + evening2?: string; + night1?: string; + night2?: string; + [key: string]: string|undefined; +} + +export interface DayPeriodRules { + noon?: string; + midnight?: string; + morning1?: DayPartFromTo; + morning2?: DayPartFromTo; + afternoon1?: DayPartFromTo; + afternoon2?: DayPartFromTo; + evening1?: DayPartFromTo; + evening2?: DayPartFromTo; + night1?: DayPartFromTo; + night2?: DayPartFromTo; +} + +export interface DayPartFromTo { + from: string; + to: string; +} + +export interface DayPeriodsWidth { + abbreviated: DayPeriods; + narrow: DayPeriods; + wide: DayPeriods; + [key: string]: DayPeriods; +} + +export interface DaysWidth { + abbreviated: string[]; + narrow: string[]; + wide: string[]; + short: string[]; + [key: string]: string[]; +} + +export interface MonthsWidth { + abbreviated: string[]; + narrow: string[]; + wide: string[]; + [key: string]: string[]; +} + +export interface ErasWidth { + abbreviated: [string, string]; + narrow: [string, string]; + wide: [string, string]; + [key: string]: [string, string]; +} + +// days & months have "format" and "standAlone" styles +// see http://cldr.unicode.org/translation/date-time#TOC-Stand-Alone-vs.-Format-Styles +// Field format standAlone +// Month M L +// Day E c +export interface DateTimeTranslations { + dayPeriods: {format: DayPeriodsWidth, standalone: DayPeriodsWidth}; + days: {format: DaysWidth, standalone: DaysWidth}; + months: {format: MonthsWidth, standalone: MonthsWidth}; + eras: ErasWidth; +} + +export interface DateTimeFormat { + full: string; + long: string; + medium: string; + short: string; +} + +export interface DateTimeFormats { + date: DateTimeFormat; + time: DateTimeFormat; + dateTime: DateTimeFormat; +} + +export interface DateTimeSettings { + firstDayOfWeek: number; + weekendRange: number[]; + formats: DateTimeFormats; + dayPeriodRules?: DayPeriodRules; +} + +export interface NumberFormat { + currency: string; + decimal: string; + percent: string; + scientific: string; +} + +export interface NumberSymbols { + decimal: string; + currencyGroup?: string; + group: string; + list: string; + percentSign: string; + plusSign: string; + minusSign: string; + exponential: string; + superscriptingExponent: string; + perMille: string; + infinity: string; + nan: string; + timeSeparator: string; +} + +export interface NumberSettings { + symbols: NumberSymbols; + formats: NumberFormat; +} + +export interface CurrencySettings { + symbol?: string; + name?: string; +} + +/** @experimental */ +export interface NgLocale { + localeId: string; + dateTimeTranslations: DateTimeTranslations; + dateTimeSettings: DateTimeSettings; + numberSettings: NumberSettings; + currencySettings: CurrencySettings; + getPluralCase: (value: number) => Plural; +} + +/** @experimental */ +export enum Plural { + Zero, + One, + Two, + Few, + Many, + Other, +} diff --git a/packages/core/src/i18n/tokens.ts b/packages/core/src/i18n/tokens.ts index 0dd471bd5caf6d..046d73eb93dfb5 100644 --- a/packages/core/src/i18n/tokens.ts +++ b/packages/core/src/i18n/tokens.ts @@ -7,12 +7,18 @@ */ import {InjectionToken} from '../di/injection_token'; +import {NgLocale} from './ng_locale'; /** * @experimental i18n support is experimental. */ export const LOCALE_ID = new InjectionToken('LocaleId'); +/** + * @experimental i18n support is experimental. + */ +export const LOCALE_DATA = new InjectionToken('LocaleData'); + /** * @experimental i18n support is experimental. */ diff --git a/packages/examples/common/pipes/ts/date_pipe.ts b/packages/examples/common/pipes/ts/date_pipe.ts index 5b518524ce58c8..d88b6da9a1c5a3 100644 --- a/packages/examples/common/pipes/ts/date_pipe.ts +++ b/packages/examples/common/pipes/ts/date_pipe.ts @@ -14,7 +14,8 @@ import {Component} from '@angular/core'; template: `

Today is {{today | date}}

Or if you prefer, {{today | date:'fullDate'}}

-

The time is {{today | date:'jmZ'}}

+

The time is {{today | date:'shortTime'}}

+

The custom date is {{today | date:'yyyy-mm-dd HH:mm'}}

` }) export class DatePipeComponent { diff --git a/packages/examples/common/pipes/ts/number_pipe.ts b/packages/examples/common/pipes/ts/number_pipe.ts index aa9d595db1cb10..96056b9ffdcb19 100644 --- a/packages/examples/common/pipes/ts/number_pipe.ts +++ b/packages/examples/common/pipes/ts/number_pipe.ts @@ -42,8 +42,9 @@ export class PercentPipeComponent { @Component({ selector: 'currency-pipe', template: `
-

A: {{a | currency:'USD':false}}

-

B: {{b | currency:'USD':true:'4.2-2'}}

+

A: {{a | currency:'CAD'}}

+

B: {{b | currency:'CAD':'symbol':'4.2-2'}}

+

B: {{b | currency:'CAD':'symbol-narrow':'4.2-2'}}

` }) export class CurrencyPipeComponent { diff --git a/packages/platform-browser/test/browser_util_spec.ts b/packages/platform-browser/test/browser_util_spec.ts index b62d113cf802df..33dd7b8c444201 100644 --- a/packages/platform-browser/test/browser_util_spec.ts +++ b/packages/platform-browser/test/browser_util_spec.ts @@ -234,7 +234,6 @@ export function main() { expect(bd.isIOS7).toBe(browser['isIOS7']); expect(bd.isSlow).toBe(browser['isSlow']); expect(bd.isChromeDesktop).toBe(browser['isChromeDesktop']); - expect(bd.isOldChrome).toBe(browser['isOldChrome']); }); }); }); diff --git a/tools/public_api_guard/common/common.d.ts b/tools/public_api_guard/common/common.d.ts index 3160d61883a00f..b465f3aec6324e 100644 --- a/tools/public_api_guard/common/common.d.ts +++ b/tools/public_api_guard/common/common.d.ts @@ -11,24 +11,62 @@ export declare class AsyncPipe implements OnDestroy, PipeTransform { transform(obj: null): null; } +/** @experimental */ +export declare const AVAILABLE_LOCALES: string[]; + /** @stable */ export declare class CommonModule { } +/** @experimental */ +export declare const CURRENCIES: { + [code: string]: { + [key: string]: string; + }; +}; + /** @stable */ export declare class CurrencyPipe implements PipeTransform { + constructor(locale: string, localeData: NgLocale[]); + transform(value: any, currencyCode?: string, symbolDisplay?: 'code' | 'symbol' | 'symbol-narrow', digits?: string, locale?: string): string | null; +} + +/** @stable */ +export declare class DatePipe implements PipeTransform { + constructor(locale: string, localeData: NgLocale[]); + transform(value: any, pattern?: string, timezone?: string, locale?: string): string | null; +} + +/** @stable */ +export declare class DecimalPipe implements PipeTransform { + constructor(locale: string, localeData: NgLocale[]); + transform(value: any, digits?: string, locale?: string): string | null; +} + +/** @stable */ +export declare class DeprecatedCommonModule { +} + +/** @stable */ +export declare class DeprecatedCurrencyPipe implements PipeTransform { constructor(_locale: string); transform(value: any, currencyCode?: string, symbolDisplay?: boolean, digits?: string): string | null; } /** @stable */ -export declare class DatePipe implements PipeTransform { +export declare class DeprecatedDatePipe implements PipeTransform { constructor(_locale: string); transform(value: any, pattern?: string): string | null; } /** @stable */ -export declare class DecimalPipe implements PipeTransform { +export declare class DeprecatedDecimalPipe implements PipeTransform { + constructor(_locale: string); + transform(value: any, digits?: string): string | null; +} + +/** @stable */ +export declare class DeprecatedPercentPipe implements PipeTransform { constructor(_locale: string); transform(value: any, digits?: string): string | null; } @@ -51,10 +89,10 @@ export declare class HashLocationStrategy extends LocationStrategy { /** @experimental */ export declare class I18nPluralPipe implements PipeTransform { - constructor(_localization: NgLocalization); + constructor(localization: NgLocalization); transform(value: number, pluralMap: { [count: string]: string; - }): string; + }, locale?: string): string; } /** @experimental */ @@ -192,13 +230,14 @@ export declare class NgIfContext { /** @experimental */ export declare class NgLocaleLocalization extends NgLocalization { protected locale: string; - constructor(locale: string); - getPluralCategory(value: any): string; + protected localeData: NgLocale[]; + constructor(locale: string, localeData: NgLocale[]); + getPluralCategory(value: any, locale?: string): string; } /** @experimental */ export declare abstract class NgLocalization { - abstract getPluralCategory(value: any): string; + abstract getPluralCategory(value: any, locale?: string): string; } /** @experimental */ @@ -264,8 +303,8 @@ export declare class PathLocationStrategy extends LocationStrategy { /** @stable */ export declare class PercentPipe implements PipeTransform { - constructor(_locale: string); - transform(value: any, digits?: string): string | null; + constructor(locale: string, localeData: NgLocale[]); + transform(value: any, digits?: string, locale?: string): string | null; } /** @stable */ diff --git a/tools/public_api_guard/core/core.d.ts b/tools/public_api_guard/core/core.d.ts index f4f31ca1a57fd8..710972c33a77de 100644 --- a/tools/public_api_guard/core/core.d.ts +++ b/tools/public_api_guard/core/core.d.ts @@ -582,6 +582,9 @@ export declare class KeyValueDiffers { static extend(factories: KeyValueDifferFactory[]): Provider; } +/** @experimental */ +export declare const LOCALE_DATA: InjectionToken; + /** @experimental */ export declare const LOCALE_ID: InjectionToken; @@ -608,6 +611,16 @@ export interface ModuleWithProviders { /** @stable */ export declare type NgIterable = Array | Iterable; +/** @experimental */ +export interface NgLocale { + currencySettings: CurrencySettings; + dateTimeSettings: DateTimeSettings; + dateTimeTranslations: DateTimeTranslations; + getPluralCase: (value: number) => Plural; + localeId: string; + numberSettings: NumberSettings; +} + /** @stable */ export declare const NgModule: NgModuleDecorator; @@ -725,6 +738,16 @@ export declare abstract class PlatformRef { abstract onDestroy(callback: () => void): void; } +/** @experimental */ +export declare enum Plural { + Zero = 0, + One = 1, + Two = 2, + Few = 3, + Many = 4, + Other = 5, +} + /** @experimental */ export interface Predicate { (value: T): boolean;