From 418091253887d45f38022fbf7db6dedad2857ac9 Mon Sep 17 00:00:00 2001 From: Olivier Combe Date: Fri, 23 Feb 2018 22:28:07 +0100 Subject: [PATCH] feat(common): export functions to format numbers, percents, currencies & dates (#22423) The utility functions `formatNumber`, `formatPercent`, `formatCurrency`, and `formatDate` used by the number, percent, currency and date pipes are now available for developers who want to use them outside of templates. Fixes #20536 PR Close #22423 --- packages/common/src/common.ts | 4 +- packages/common/src/i18n/format_date.ts | 118 ++++++- packages/common/src/i18n/format_number.ts | 88 +++-- packages/common/src/i18n/locale_data_api.ts | 2 +- packages/common/src/pipes/date_pipe.ts | 105 ++---- .../common/src/pipes/deprecated/date_pipe.ts | 2 +- packages/common/src/pipes/number_pipe.ts | 77 +++-- packages/common/test/i18n/format_date_spec.ts | 315 ++++++++++++++++++ .../common/test/i18n/format_number_spec.ts | 118 +++++++ .../common/test/i18n/locale_data_api_spec.ts | 10 +- packages/common/test/pipes/date_pipe_spec.ts | 269 +-------------- .../common/test/pipes/number_pipe_spec.ts | 63 +--- tools/public_api_guard/common/common.d.ts | 14 +- 13 files changed, 702 insertions(+), 483 deletions(-) create mode 100644 packages/common/test/i18n/format_date_spec.ts create mode 100644 packages/common/test/i18n/format_number_spec.ts diff --git a/packages/common/src/common.ts b/packages/common/src/common.ts index 13d0d61cfa..9db719aba6 100644 --- a/packages/common/src/common.ts +++ b/packages/common/src/common.ts @@ -12,9 +12,11 @@ * Entry point for all public APIs of the common package. */ export * from './location/index'; +export {formatDate} from './i18n/format_date'; +export {formatCurrency, formatNumber, formatPercent} from './i18n/format_number'; export {NgLocaleLocalization, NgLocalization} from './i18n/localization'; export {registerLocaleData} from './i18n/locale_data'; -export {Plural, NumberFormatStyle, FormStyle, Time, TranslationWidth, FormatWidth, NumberSymbol, WeekDay, getNbOfCurrencyDigits, getCurrencySymbol, getLocaleDayPeriods, getLocaleDayNames, getLocaleMonthNames, getLocaleId, getLocaleEraNames, getLocaleWeekEndRange, getLocaleFirstDayOfWeek, getLocaleDateFormat, getLocaleDateTimeFormat, getLocaleExtraDayPeriodRules, getLocaleExtraDayPeriods, getLocalePluralCase, getLocaleTimeFormat, getLocaleNumberSymbol, getLocaleNumberFormat, getLocaleCurrencyName, getLocaleCurrencySymbol} from './i18n/locale_data_api'; +export {Plural, NumberFormatStyle, FormStyle, Time, TranslationWidth, FormatWidth, NumberSymbol, WeekDay, getNumberOfCurrencyDigits, getCurrencySymbol, getLocaleDayPeriods, getLocaleDayNames, getLocaleMonthNames, getLocaleId, getLocaleEraNames, getLocaleWeekEndRange, getLocaleFirstDayOfWeek, getLocaleDateFormat, getLocaleDateTimeFormat, getLocaleExtraDayPeriodRules, getLocaleExtraDayPeriods, getLocalePluralCase, getLocaleTimeFormat, getLocaleNumberSymbol, getLocaleNumberFormat, getLocaleCurrencyName, getLocaleCurrencySymbol} from './i18n/locale_data_api'; export {parseCookieValue as ɵparseCookieValue} from './cookie'; export {CommonModule, DeprecatedI18NPipesModule} from './common_module'; export {NgClass, NgForOf, NgForOfContext, NgIf, NgIfContext, NgPlural, NgPluralCase, NgStyle, NgSwitch, NgSwitchCase, NgSwitchDefault, NgTemplateOutlet, NgComponentOutlet} from './directives/index'; diff --git a/packages/common/src/i18n/format_date.ts b/packages/common/src/i18n/format_date.ts index cfb952c82b..981257ecb7 100644 --- a/packages/common/src/i18n/format_date.ts +++ b/packages/common/src/i18n/format_date.ts @@ -8,6 +8,9 @@ import {FormStyle, FormatWidth, NumberSymbol, Time, TranslationWidth, getLocaleDateFormat, getLocaleDateTimeFormat, getLocaleDayNames, getLocaleDayPeriods, getLocaleEraNames, getLocaleExtraDayPeriodRules, getLocaleExtraDayPeriods, getLocaleId, getLocaleMonthNames, getLocaleNumberSymbol, getLocaleTimeFormat} from './locale_data_api'; +export 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 const NAMED_FORMATS: {[localeId: string]: {[format: string]: string}} = {}; 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]*)/; @@ -38,11 +41,27 @@ enum TranslationType { } /** - * Transforms a date to a locale string based on a pattern and a timezone + * @ngModule CommonModule + * @whatItDoes Formats a date according to locale rules. + * @description * - * @internal + * Where: + * - `value` is a Date, 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. See {@link DatePipe} for more + * details. + * - `locale` is a `string` defining the locale to use. + * - `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 (e.g. `'+0430'`). + * If not specified, host system settings are used. + * + * See {@link DatePipe} for more details. + * + * @stable */ -export function formatDate(date: Date, format: string, locale: string, timezone?: string): string { +export function formatDate( + value: string | number | Date, format: string, locale: string, timezone?: string): string { + let date = toDate(value); const namedFormat = getNamedFormat(locale, format); format = namedFormat || format; @@ -165,8 +184,10 @@ function padNumber( neg = minusSign; } } - let strNum = '' + num; - while (strNum.length < digits) strNum = '0' + strNum; + let strNum = String(num); + while (strNum.length < digits) { + strNum = '0' + strNum; + } if (trim) { strNum = strNum.substr(strNum.length - digits); } @@ -607,3 +628,90 @@ function convertTimezoneToLocal(date: Date, timezone: string, reverse: boolean): const timezoneOffset = timezoneToOffset(timezone, dateTimezoneOffset); return addDateMinutes(date, reverseValue * (timezoneOffset - dateTimezoneOffset)); } + +/** + * Converts a value to date. + * + * Supported input formats: + * - `Date` + * - number: timestamp + * - string: numeric (e.g. "1234"), ISO and date strings in a format supported by + * [Date.parse()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse). + * Note: ISO strings without time return a date without timeoffset. + * + * Throws if unable to convert to a date. + */ +export function toDate(value: string | number | Date): Date { + if (isDate(value)) { + return value; + } + + if (typeof value === 'number' && !isNaN(value)) { + return new Date(value); + } + + if (typeof value === 'string') { + value = value.trim(); + + const parsedNb = parseFloat(value); + + // any string that only contains numbers, like "1234" but not like "1234hello" + if (!isNaN(value as any - parsedNb)) { + return new Date(parsedNb); + } + + if (/^(\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) => +val); + return new Date(y, m - 1, d); + } + + let match: RegExpMatchArray|null; + if (match = value.match(ISO8601_DATE_REGEX)) { + return isoStringToDate(match); + } + } + + const date = new Date(value as any); + if (!isDate(date)) { + throw new Error(`Unable to convert "${value}" into a date`); + } + return date; +} + +/** + * Converts a date in ISO8601 to a Date. + * Used instead of `Date.parse` because of browser discrepancies. + */ +export function isoStringToDate(match: RegExpMatchArray): Date { + const date = new Date(0); + let tzHour = 0; + let tzMin = 0; + + // match[8] means that the string contains "Z" (UTC) or a timezone like "+01:00" or "+0100" + const dateSetter = match[8] ? date.setUTCFullYear : date.setFullYear; + const timeSetter = match[8] ? date.setUTCHours : date.setHours; + + // if there is a timezone defined like "+01:00" or "+0100" + if (match[9]) { + tzHour = Number(match[9] + match[10]); + tzMin = Number(match[9] + match[11]); + } + dateSetter.call(date, Number(match[1]), Number(match[2]) - 1, Number(match[3])); + const h = Number(match[4] || 0) - tzHour; + const m = Number(match[5] || 0) - tzMin; + const s = Number(match[6] || 0); + const ms = Math.round(parseFloat('0.' + (match[7] || 0)) * 1000); + timeSetter.call(date, h, m, s, ms); + return date; +} + +export function isDate(value: any): value is Date { + return value instanceof Date && !isNaN(value.valueOf()); +} diff --git a/packages/common/src/i18n/format_number.ts b/packages/common/src/i18n/format_number.ts index 9033486829..4f31086881 100644 --- a/packages/common/src/i18n/format_number.ts +++ b/packages/common/src/i18n/format_number.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {NumberFormatStyle, NumberSymbol, getLocaleNumberFormat, getLocaleNumberSymbol, getNbOfCurrencyDigits} from './locale_data_api'; +import {NumberFormatStyle, NumberSymbol, getLocaleNumberFormat, getLocaleNumberSymbol, getNumberOfCurrencyDigits} from './locale_data_api'; export const NUMBER_FORMAT_REGEXP = /^(\d+)?\.((\d+)(-(\d+))?)?$/; const MAX_DIGITS = 22; @@ -18,34 +18,19 @@ const DIGIT_CHAR = '#'; const CURRENCY_CHAR = '¤'; const PERCENT_CHAR = '%'; -/** - * Transforms a string into a number (if needed) - */ -function strToNumber(value: number | string): number { - // Convert strings to numbers - if (typeof value === 'string' && !isNaN(+value - parseFloat(value))) { - return +value; - } - if (typeof value !== 'number') { - throw new Error(`${value} is not a number`); - } - return value; -} - /** * Transforms a number to a locale string based on a style and a format */ -function formatNumber( - value: number | string, pattern: ParsedNumberFormat, locale: string, groupSymbol: NumberSymbol, +function formatNumberToLocaleString( + value: number, pattern: ParsedNumberFormat, locale: string, groupSymbol: NumberSymbol, decimalSymbol: NumberSymbol, digitsInfo?: string, isPercent = false): string { let formattedText = ''; let isZero = false; - const num = strToNumber(value); - if (!isFinite(num)) { + if (!isFinite(value)) { formattedText = getLocaleNumberSymbol(locale, NumberSymbol.Infinity); } else { - let parsedNumber = parseNumber(num); + let parsedNumber = parseNumber(value); if (isPercent) { parsedNumber = toPercent(parsedNumber); @@ -128,7 +113,7 @@ function formatNumber( } } - if (num < 0 && !isZero) { + if (value < 0 && !isZero) { formattedText = pattern.negPre + formattedText + pattern.negSuf; } else { formattedText = pattern.posPre + formattedText + pattern.posSuf; @@ -138,20 +123,32 @@ function formatNumber( } /** - * Formats a currency to a locale string + * @ngModule CommonModule + * @whatItDoes Formats a number as currency using locale rules. + * @description * - * @internal + * Use `currency` to format a number as currency. + * + * Where: + * - `value` is a number. + * - `locale` is a `string` defining the locale to use. + * - `currency` is the string that represents the currency, it can be its symbol or its name. + * - `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. + * - `digitInfo` See {@link DecimalPipe} for more details. + * + * @stable */ export function formatCurrency( - value: number | string, locale: string, currency: string, currencyCode?: string, + value: number, locale: string, currency: string, currencyCode?: string, digitsInfo?: string): string { const format = getLocaleNumberFormat(locale, NumberFormatStyle.Currency); const pattern = parseNumberFormat(format, getLocaleNumberSymbol(locale, NumberSymbol.MinusSign)); - pattern.minFrac = getNbOfCurrencyDigits(currencyCode !); + pattern.minFrac = getNumberOfCurrencyDigits(currencyCode !); pattern.maxFrac = pattern.minFrac; - const res = formatNumber( + const res = formatNumberToLocaleString( value, pattern, locale, NumberSymbol.CurrencyGroup, NumberSymbol.CurrencyDecimal, digitsInfo); return res .replace(CURRENCY_CHAR, currency) @@ -160,28 +157,48 @@ export function formatCurrency( } /** - * Formats a percentage to a locale string + * @ngModule CommonModule + * @whatItDoes Formats a number as a percentage according to locale rules. + * @description * - * @internal + * Formats a number as percentage. + * + * Where: + * - `value` is a number. + * - `locale` is a `string` defining the locale to use. + * - `digitInfo` See {@link DecimalPipe} for more details. + * + * @stable */ -export function formatPercent(value: number | string, locale: string, digitsInfo?: string): string { +export function formatPercent(value: number, locale: string, digitsInfo?: string): string { const format = getLocaleNumberFormat(locale, NumberFormatStyle.Percent); const pattern = parseNumberFormat(format, getLocaleNumberSymbol(locale, NumberSymbol.MinusSign)); - const res = formatNumber( + const res = formatNumberToLocaleString( value, pattern, locale, NumberSymbol.Group, NumberSymbol.Decimal, digitsInfo, true); return res.replace( new RegExp(PERCENT_CHAR, 'g'), getLocaleNumberSymbol(locale, NumberSymbol.PercentSign)); } /** - * Formats a number to a locale string + * @ngModule CommonModule + * @whatItDoes Formats a number according to locale rules. + * @description * - * @internal + * Formats a number as text. Group sizing and separator and other locale-specific + * configurations are based on the locale. + * + * Where: + * - `value` is a number. + * - `locale` is a `string` defining the locale to use. + * - `digitInfo` See {@link DecimalPipe} for more details. + * + * @stable */ -export function formatDecimal(value: number | string, locale: string, digitsInfo?: string): string { +export function formatNumber(value: number, locale: string, digitsInfo?: string): string { const format = getLocaleNumberFormat(locale, NumberFormatStyle.Decimal); const pattern = parseNumberFormat(format, getLocaleNumberSymbol(locale, NumberSymbol.MinusSign)); - return formatNumber(value, pattern, locale, NumberSymbol.Group, NumberSymbol.Decimal, digitsInfo); + return formatNumberToLocaleString( + value, pattern, locale, NumberSymbol.Group, NumberSymbol.Decimal, digitsInfo); } interface ParsedNumberFormat { @@ -335,7 +352,7 @@ function parseNumber(num: number): ParsedNumber { digits = []; // Convert string to array of digits without leading/trailing zeros. for (j = 0; i <= zeros; i++, j++) { - digits[j] = +numStr.charAt(i); + digits[j] = Number(numStr.charAt(i)); } } @@ -424,7 +441,6 @@ function roundNumber(parsedNumber: ParsedNumber, minFrac: number, maxFrac: numbe } } -/** @internal */ export function parseIntAutoRadix(text: string): number { const result: number = parseInt(text); if (isNaN(result)) { diff --git a/packages/common/src/i18n/locale_data_api.ts b/packages/common/src/i18n/locale_data_api.ts index b88db6f2bc..eb90cedacc 100644 --- a/packages/common/src/i18n/locale_data_api.ts +++ b/packages/common/src/i18n/locale_data_api.ts @@ -560,7 +560,7 @@ const DEFAULT_NB_OF_CURRENCY_DIGITS = 2; * * @experimental i18n support is experimental. */ -export function getNbOfCurrencyDigits(code: string): number { +export function getNumberOfCurrencyDigits(code: string): number { let digits; const currency = CURRENCIES_EN[code]; if (currency) { diff --git a/packages/common/src/pipes/date_pipe.ts b/packages/common/src/pipes/date_pipe.ts index 2e2c1ededc..da00c9ca9b 100644 --- a/packages/common/src/pipes/date_pipe.ts +++ b/packages/common/src/pipes/date_pipe.ts @@ -10,41 +10,36 @@ import {Inject, LOCALE_ID, Pipe, PipeTransform} from '@angular/core'; import {formatDate} from '../i18n/format_date'; import {invalidPipeArgumentError} from './invalid_pipe_argument_error'; -export 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 - // clang-format off /** * @ngModule CommonModule - * @whatItDoes Formats a date according to locale rules. + * @whatItDoes Uses the function {@link formatDate} to format a date according to locale rules. * @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 + * - `value` 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 (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. `6/15/15, 9:03 AM`) - * - `'medium'`: equivalent to `'MMM d, y, h:mm:ss a'` (e.g. `Jun 15, 2015, 9:03:01 AM`) - * - `'long'`: equivalent to `'MMMM d, y, h:mm:ss a z'` (e.g. `June 15, 2015 at 9:03:01 AM GMT+1`) + * - `'short'`: equivalent to `'M/d/yy, h:mm a'` (e.g. `6/15/15, 9:03 AM`). + * - `'medium'`: equivalent to `'MMM d, y, h:mm:ss a'` (e.g. `Jun 15, 2015, 9:03:01 AM`). + * - `'long'`: equivalent to `'MMMM d, y, h:mm:ss a z'` (e.g. `June 15, 2015 at 9:03:01 AM GMT+1`). * - `'full'`: equivalent to `'EEEE, MMMM d, y, h:mm:ss a zzzz'` (e.g. `Monday, June 15, 2015 at - * 9:03:01 AM GMT+01:00`) - * - `'shortDate'`: equivalent to `'M/d/yy'` (e.g. `6/15/15`) - * - `'mediumDate'`: equivalent to `'MMM d, y'` (e.g. `Jun 15, 2015`) - * - `'longDate'`: equivalent to `'MMMM d, y'` (e.g. `June 15, 2015`) - * - `'fullDate'`: equivalent to `'EEEE, MMMM d, y'` (e.g. `Monday, June 15, 2015`) - * - `'shortTime'`: equivalent to `'h:mm a'` (e.g. `9:03 AM`) - * - `'mediumTime'`: equivalent to `'h:mm:ss a'` (e.g. `9:03:01 AM`) - * - `'longTime'`: equivalent to `'h:mm:ss a z'` (e.g. `9:03:01 AM GMT+1`) - * - `'fullTime'`: equivalent to `'h:mm:ss a zzzz'` (e.g. `9:03:01 AM GMT+01:00`) - * - `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) + * 9:03:01 AM GMT+01:00`). + * - `'shortDate'`: equivalent to `'M/d/yy'` (e.g. `6/15/15`). + * - `'mediumDate'`: equivalent to `'MMM d, y'` (e.g. `Jun 15, 2015`). + * - `'longDate'`: equivalent to `'MMMM d, y'` (e.g. `June 15, 2015`). + * - `'fullDate'`: equivalent to `'EEEE, MMMM d, y'` (e.g. `Monday, June 15, 2015`). + * - `'shortTime'`: equivalent to `'h:mm a'` (e.g. `9:03 AM`). + * - `'mediumTime'`: equivalent to `'h:mm:ss a'` (e.g. `9:03:01 AM`). + * - `'longTime'`: equivalent to `'h:mm:ss a z'` (e.g. `9:03:01 AM GMT+1`). + * - `'fullTime'`: equivalent to `'h:mm:ss a zzzz'` (e.g. `9:03:01 AM GMT+01:00`). + * - `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 (e.g. `'+0430'`). * If not specified, the local system timezone of the end-user's browser will be used. - * - `locale` is a `string` defining the locale to use (uses the current {@link LOCALE_ID} by - * default) + * - `locale` is a `string` defining the locale to use (uses the current {@link LOCALE_ID} by + * default). * * * | Field Type | Format | Description | Example Value | @@ -137,66 +132,10 @@ export class DatePipe implements PipeTransform { transform(value: any, format = 'mediumDate', timezone?: string, locale?: string): string|null { if (value == null || value === '' || value !== value) return null; - if (typeof value === 'string') { - value = value.trim(); + try { + return formatDate(value, format, locale || this.locale, timezone); + } catch (error) { + throw invalidPipeArgumentError(DatePipe, error.message); } - - let date: Date; - let match: RegExpMatchArray|null; - if (isDate(value)) { - date = value; - } else if (!isNaN(value - parseFloat(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) => +val); - date = new Date(y, m - 1, d); - } else if ((typeof value === 'string') && (match = value.match(ISO8601_DATE_REGEX))) { - date = isoStringToDate(match); - } else { - date = new Date(value); - } - - if (!isDate(date)) { - throw invalidPipeArgumentError(DatePipe, value); - } - - return formatDate(date, format, locale || this.locale, timezone); } } - -/** @internal */ -export function isoStringToDate(match: RegExpMatchArray): Date { - const date = new Date(0); - let tzHour = 0; - let tzMin = 0; - - // match[8] means that the string contains "Z" (UTC) or a timezone like "+01:00" or "+0100" - const dateSetter = match[8] ? date.setUTCFullYear : date.setFullYear; - const timeSetter = match[8] ? date.setUTCHours : date.setHours; - - // if there is a timezone defined like "+01:00" or "+0100" - if (match[9]) { - tzHour = +(match[9] + match[10]); - tzMin = +(match[9] + match[11]); - } - dateSetter.call(date, +(match[1]), +(match[2]) - 1, +(match[3])); - const h = +(match[4] || '0') - tzHour; - const m = +(match[5] || '0') - tzMin; - const s = +(match[6] || '0'); - const ms = Math.round(parseFloat('0.' + (match[7] || 0)) * 1000); - timeSetter.call(date, h, m, s, ms); - return date; -} - -function isDate(value: any): value is Date { - return value instanceof Date && !isNaN(value.valueOf()); -} diff --git a/packages/common/src/pipes/deprecated/date_pipe.ts b/packages/common/src/pipes/deprecated/date_pipe.ts index 0ff452e679..7cbd954cc0 100644 --- a/packages/common/src/pipes/deprecated/date_pipe.ts +++ b/packages/common/src/pipes/deprecated/date_pipe.ts @@ -7,7 +7,7 @@ */ import {Inject, LOCALE_ID, Pipe, PipeTransform} from '@angular/core'; -import {ISO8601_DATE_REGEX, isoStringToDate} from '../date_pipe'; +import {ISO8601_DATE_REGEX, isoStringToDate} from '../../i18n/format_date'; import {invalidPipeArgumentError} from '../invalid_pipe_argument_error'; import {DateFormatter} from './intl'; diff --git a/packages/common/src/pipes/number_pipe.ts b/packages/common/src/pipes/number_pipe.ts index 9b49b6018d..824b15f4aa 100644 --- a/packages/common/src/pipes/number_pipe.ts +++ b/packages/common/src/pipes/number_pipe.ts @@ -7,29 +7,28 @@ */ import {Inject, LOCALE_ID, Pipe, PipeTransform} from '@angular/core'; -import {formatCurrency, formatDecimal, formatPercent} from '../i18n/format_number'; +import {formatCurrency, formatNumber, formatPercent} from '../i18n/format_number'; import {getCurrencySymbol} from '../i18n/locale_data_api'; import {invalidPipeArgumentError} from './invalid_pipe_argument_error'; /** * @ngModule CommonModule - * @whatItDoes Formats a number according to locale rules. + * @whatItDoes Uses the function {@link formatNumber} to format a number according to locale rules. * @howToUse `number_expression | number[:digitInfo[:locale]]` + * @description * * Formats a number as text. Group sizing and separator and other locale-specific - * configurations are based on the active locale. + * configurations are based on the locale. * - * where `expression` is a number: - * - `digitInfo` is a `string` which has a following format:
- * {minIntegerDigits}.{minFractionDigits}-{maxFractionDigits} + * Where: + * - `value` 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`. - * - `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. + * - `minFractionDigits` is the minimum number of digits after the decimal point. Defaults to `0`. + * - `maxFractionDigits` is the maximum number of digits after the decimal point. Defaults to `3`. + * - `locale` is a `string` defining the locale to use (uses the current {@link LOCALE_ID} by + * default). * * ### Example * @@ -47,7 +46,8 @@ export class DecimalPipe implements PipeTransform { locale = locale || this._locale; try { - return formatDecimal(value, locale, digitsInfo); + const num = strToNumber(value); + return formatNumber(num, locale, digitsInfo); } catch (error) { throw invalidPipeArgumentError(DecimalPipe, error.message); } @@ -56,16 +56,18 @@ export class DecimalPipe implements PipeTransform { /** * @ngModule CommonModule - * @whatItDoes Formats a number as a percentage according to locale rules. + * @whatItDoes Uses the function {@link formatPercent} to format a number as a percentage according + * to locale rules. * @howToUse `number_expression | percent[:digitInfo[:locale]]` - * * @description * * Formats a number as percentage. * - * - `digitInfo` See {@link DecimalPipe} for a detailed description. - * - `locale` is a `string` defining the locale to use (uses the current {@link LOCALE_ID} by - * default) + * Where: + * - `value` is a number. + * - `digitInfo` See {@link DecimalPipe} for more details. + * - `locale` is a `string` defining the locale to use (uses the current {@link LOCALE_ID} by + * default). * * ### Example * @@ -83,7 +85,8 @@ export class PercentPipe implements PipeTransform { locale = locale || this._locale; try { - return formatPercent(value, locale, digitsInfo); + const num = strToNumber(value); + return formatPercent(num, locale, digitsInfo); } catch (error) { throw invalidPipeArgumentError(PercentPipe, error.message); } @@ -92,25 +95,28 @@ export class PercentPipe implements PipeTransform { /** * @ngModule CommonModule - * @whatItDoes Formats a number as currency using locale rules. + * @whatItDoes Uses the functions {@link getCurrencySymbol} and {@link formatCurrency} to format a + * number as currency using locale rules. * @howToUse `number_expression | currency[:currencyCode[:display[:digitInfo[:locale]]]]` * @description * * Use `currency` to format a number as currency. * + * Where: + * - `value` is a number. * - `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. - * - `display` indicates whether to show the currency symbol, the code or a custom value + * - `display` indicates whether to show the currency symbol, the code or a custom value: * - `code`: use code (e.g. `USD`). * - `symbol`(default): 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 `$`). - * - `string`: use this value instead of a code or a symbol - * - boolean (deprecated from v5): `true` for symbol and false for `code` + * - `string`: use this value instead of a code or a symbol. + * - boolean (deprecated from v5): `true` for symbol and false for `code`. * If there is no narrow symbol for the chosen currency, the regular symbol will be used. - * - `digitInfo` See {@link DecimalPipe} for a detailed description. - * - `locale` is a `string` defining the locale to use (uses the current {@link LOCALE_ID} by - * default) + * - `digitInfo` See {@link DecimalPipe} for more details. + * - `locale` is a `string` defining the locale to use (uses the current {@link LOCALE_ID} by + * default). * * ### Example * @@ -148,7 +154,8 @@ export class CurrencyPipe implements PipeTransform { } try { - return formatCurrency(value, locale, currency, currencyCode, digitsInfo); + const num = strToNumber(value); + return formatCurrency(num, locale, currency, currencyCode, digitsInfo); } catch (error) { throw invalidPipeArgumentError(CurrencyPipe, error.message); } @@ -158,3 +165,17 @@ export class CurrencyPipe implements PipeTransform { function isEmpty(value: any): boolean { return value == null || value === '' || value !== value; } + +/** + * Transforms a string into a number (if needed) + */ +function strToNumber(value: number | string): number { + // Convert strings to numbers + if (typeof value === 'string' && !isNaN(Number(value) - parseFloat(value))) { + return Number(value); + } + if (typeof value !== 'number') { + throw new Error(`${value} is not a number`); + } + return value; +} diff --git a/packages/common/test/i18n/format_date_spec.ts b/packages/common/test/i18n/format_date_spec.ts new file mode 100644 index 0000000000..a57e3929ca --- /dev/null +++ b/packages/common/test/i18n/format_date_spec.ts @@ -0,0 +1,315 @@ +/** + * @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 {registerLocaleData} from '@angular/common'; +import localeAr from '@angular/common/locales/ar'; +import localeDe from '@angular/common/locales/de'; +import localeEn from '@angular/common/locales/en'; +import localeEnExtra from '@angular/common/locales/extra/en'; +import localeHu from '@angular/common/locales/hu'; +import localeSr from '@angular/common/locales/sr'; +import localeTh from '@angular/common/locales/th'; +import {isDate, toDate, formatDate} from '@angular/common/src/i18n/format_date'; + +describe('Format date', () => { + describe('toDate', () => { + it('should support date', () => { expect(isDate(toDate(new Date()))).toBeTruthy(); }); + + it('should support int', () => { expect(isDate(toDate(123456789))).toBeTruthy(); }); + + it('should support numeric strings', + () => { expect(isDate(toDate('123456789'))).toBeTruthy(); }); + + it('should support decimal strings', + () => { expect(isDate(toDate('123456789.11'))).toBeTruthy(); }); + + it('should support ISO string', + () => { expect(isDate(toDate('2015-06-15T21:43:11Z'))).toBeTruthy(); }); + + it('should throw for empty string', () => { expect(() => toDate('')).toThrow(); }); + + it('should throw for alpha numeric strings', + () => { expect(() => toDate('123456789 hello')).toThrow(); }); + + it('should throw for NaN', () => { expect(() => toDate(Number.NaN)).toThrow(); }); + + it('should support ISO string without time', + () => { expect(isDate(toDate('2015-01-01'))).toBeTruthy(); }); + + it('should throw for objects', () => { expect(() => toDate({} as any)).toThrow(); }); + }); + + describe('formatDate', () => { + const isoStringWithoutTime = '2015-01-01'; + const defaultLocale = 'en-US'; + const defaultFormat = 'mediumDate'; + let date: Date; + + // Check the transformation of a date into a pattern + function expectDateFormatAs(date: Date | string, pattern: any, output: string): void { + expect(formatDate(date, pattern, defaultLocale)).toEqual(output); + } + + beforeAll(() => { + registerLocaleData(localeEn, localeEnExtra); + registerLocaleData(localeDe); + registerLocaleData(localeHu); + registerLocaleData(localeSr); + registerLocaleData(localeTh); + registerLocaleData(localeAr); + }); + + beforeEach(() => { date = new Date(2015, 5, 15, 9, 3, 1, 550); }); + + it('should format each component correctly', () => { + const dateFixtures: any = { + 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 = { + 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', + }; + + Object.keys(dateFixtures).forEach((pattern: string) => { + expectDateFormatAs(date, pattern, dateFixtures[pattern]); + }); + + 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/, + }; + + Object.keys(dateFixtures).forEach((pattern: string) => { + expect(formatDate(date, pattern, defaultLocale, '+0430')).toMatch(dateFixtures[pattern]); + }); + }); + + 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', + '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' + }; + + 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', + 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}/, + }; + + Object.keys(dateFixtures).forEach((pattern: string) => { + expect(formatDate(date, pattern, defaultLocale)).toMatch(dateFixtures[pattern]); + }); + }); + + it('should format invalid in IE ISO date', + () => expect(formatDate('2017-01-11T12:00:00.014-0500', defaultFormat, defaultLocale)) + .toEqual('Jan 11, 2017')); + + it('should format invalid in Safari ISO date', + () => expect(formatDate('2017-01-20T12:00:00+0000', defaultFormat, defaultLocale)) + .toEqual('Jan 20, 2017')); + + // test for the following bugs: + // https://github.com/angular/angular/issues/9524 + // https://github.com/angular/angular/issues/9524 + it('should format correctly with iso strings that contain time', + () => expect(formatDate('2017-05-07T22:14:39', 'dd-MM-yyyy HH:mm', defaultLocale)) + .toMatch(/07-05-2017 \d{2}:\d{2}/)); + + // test for issue https://github.com/angular/angular/issues/21491 + it('should not assume UTC for iso strings in Safari if the timezone is not defined', () => { + // this test only works if the timezone is not in UTC + // which is the case for BrowserStack when we test Safari + if (new Date().getTimezoneOffset() !== 0) { + expect(formatDate('2018-01-11T13:00:00', 'HH', defaultLocale)) + .not.toEqual(formatDate('2018-01-11T13:00:00Z', 'HH', defaultLocale)); + } + }); + + // test for the following bugs: + // https://github.com/angular/angular/issues/16624 + // https://github.com/angular/angular/issues/17478 + it('should show the correct time when the timezone is fixed', () => { + expect(formatDate('2017-06-13T10:14:39+0000', 'shortTime', defaultLocale, '+0000')) + .toEqual('10:14 AM'); + expect(formatDate('2017-06-13T10:14:39+0000', 'h:mm a', defaultLocale, '+0000')) + .toEqual('10:14 AM'); + }); + + it('should remove bidi control characters', + () => expect(formatDate(date, 'MM/dd/yyyy', defaultLocale) !.length).toEqual(10)); + + it(`should format the date correctly in various locales`, () => { + expect(formatDate(date, 'short', 'de')).toEqual('15.06.15, 09:03'); + expect(formatDate(date, 'short', 'ar')).toEqual('15‏/6‏/2015 9:03 ص'); + expect(formatDate(date, 'dd-MM-yy', 'th')).toEqual('15-06-15'); + expect(formatDate(date, 'a', 'hu')).toEqual('de.'); + expect(formatDate(date, 'a', 'sr')).toEqual('пре подне'); + + // TODO(ocombe): activate this test when we support local numbers + // expect(formatDate(date, 'hh', 'mr')).toEqual('०९'); + }); + + it('should throw if we use getExtraDayPeriods without loading extra locale data', () => { + expect(() => formatDate(date, 'b', 'de')) + .toThrowError(/Missing extra locale data for the locale "de"/); + }); + }); +}); diff --git a/packages/common/test/i18n/format_number_spec.ts b/packages/common/test/i18n/format_number_spec.ts new file mode 100644 index 0000000000..970b68f5ef --- /dev/null +++ b/packages/common/test/i18n/format_number_spec.ts @@ -0,0 +1,118 @@ +/** + * @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 localeEn from '@angular/common/locales/en'; +import localeEsUS from '@angular/common/locales/es-US'; +import localeFr from '@angular/common/locales/fr'; +import localeAr from '@angular/common/locales/ar'; +import {formatCurrency, formatNumber, formatPercent, registerLocaleData} from '@angular/common'; +import {describe, expect, it} from '@angular/core/testing/src/testing_internal'; + +describe('Format number', () => { + const defaultLocale = 'en-US'; + + beforeAll(() => { + registerLocaleData(localeEn); + registerLocaleData(localeEsUS); + registerLocaleData(localeFr); + registerLocaleData(localeAr); + }); + + describe('Number', () => { + describe('transform', () => { + it('should return correct value for numbers', () => { + expect(formatNumber(12345, defaultLocale)).toEqual('12,345'); + expect(formatNumber(123, defaultLocale, '.2')).toEqual('123.00'); + expect(formatNumber(1, defaultLocale, '3.')).toEqual('001'); + expect(formatNumber(1.1, defaultLocale, '3.4-5')).toEqual('001.1000'); + expect(formatNumber(1.123456, defaultLocale, '3.4-5')).toEqual('001.12346'); + expect(formatNumber(1.1234, defaultLocale)).toEqual('1.123'); + expect(formatNumber(1.123456, defaultLocale, '.2')).toEqual('1.123'); + expect(formatNumber(1.123456, defaultLocale, '.4')).toEqual('1.1235'); + }); + + it('should throw if minFractionDigits is explicitly higher than maxFractionDigits', () => { + expect(() => formatNumber(1.1, defaultLocale, '3.4-2')) + .toThrowError(/is higher than the maximum/); + }); + }); + + describe('transform with custom locales', () => { + it('should return the correct format for es-US', + () => { expect(formatNumber(9999999.99, 'es-US', '1.2-2')).toEqual('9,999,999.99'); }); + }); + }); + + describe('Percent', () => { + describe('transform', () => { + it('should return correct value for numbers', () => { + expect(formatPercent(1.23, defaultLocale)).toEqual('123%'); + expect(formatPercent(1.2, defaultLocale, '.2')).toEqual('120.00%'); + expect(formatPercent(1.2, defaultLocale, '4.2')).toEqual('0,120.00%'); + expect(formatPercent(1.2, 'fr', '4.2')).toEqual('0 120,00 %'); + expect(formatPercent(1.2, 'ar', '4.2')).toEqual('0,120.00‎%‎'); + // see issue #20136 + expect(formatPercent(0.12345674, defaultLocale, '0.0-10')).toEqual('12.345674%'); + expect(formatPercent(0, defaultLocale, '0.0-10')).toEqual('0%'); + expect(formatPercent(0.00, defaultLocale, '0.0-10')).toEqual('0%'); + expect(formatPercent(1, defaultLocale, '0.0-10')).toEqual('100%'); + expect(formatPercent(0.1, defaultLocale, '0.0-10')).toEqual('10%'); + expect(formatPercent(0.12, defaultLocale, '0.0-10')).toEqual('12%'); + expect(formatPercent(0.123, defaultLocale, '0.0-10')).toEqual('12.3%'); + expect(formatPercent(12.3456, defaultLocale, '0.0-10')).toEqual('1,234.56%'); + expect(formatPercent(12.345600, defaultLocale, '0.0-10')).toEqual('1,234.56%'); + expect(formatPercent(12.345699999, defaultLocale, '0.0-6')).toEqual('1,234.57%'); + expect(formatPercent(12.345699999, defaultLocale, '0.4-6')).toEqual('1,234.5700%'); + expect(formatPercent(100, defaultLocale, '0.4-6')).toEqual('10,000.0000%'); + expect(formatPercent(100, defaultLocale, '0.0-10')).toEqual('10,000%'); + expect(formatPercent(1.5e2, defaultLocale)).toEqual('15,000%'); + expect(formatPercent(1e100, defaultLocale)).toEqual('1E+102%'); + }); + }); + }); + + describe('Currency', () => { + const defaultCurrencyCode = 'USD'; + describe('transform', () => { + it('should return correct value for numbers', () => { + expect(formatCurrency(123, defaultLocale, '$')).toEqual('$123.00'); + expect(formatCurrency(12, defaultLocale, 'EUR', 'EUR', '.1')).toEqual('EUR12.0'); + expect( + formatCurrency(5.1234, defaultLocale, defaultCurrencyCode, defaultCurrencyCode, '.0-3')) + .toEqual('USD5.123'); + expect(formatCurrency(5.1234, defaultLocale, defaultCurrencyCode)).toEqual('USD5.12'); + expect(formatCurrency(5.1234, defaultLocale, '$')).toEqual('$5.12'); + expect(formatCurrency(5.1234, defaultLocale, 'CA$')).toEqual('CA$5.12'); + expect(formatCurrency(5.1234, defaultLocale, '$')).toEqual('$5.12'); + expect(formatCurrency(5.1234, defaultLocale, '$', defaultCurrencyCode, '5.2-2')) + .toEqual('$00,005.12'); + expect(formatCurrency(5.1234, 'fr', '$', defaultCurrencyCode, '5.2-2')) + .toEqual('00 005,12 $'); + expect(formatCurrency(5, 'fr', '$US', defaultCurrencyCode)).toEqual('5,00 $US'); + }); + + it('should support any currency code name', () => { + // currency code is unknown, default formatting options will be used + expect(formatCurrency(5.1234, defaultLocale, 'unexisting_ISO_code')) + .toEqual('unexisting_ISO_code5.12'); + // currency code is USD, the pipe will format based on USD but will display "Custom name" + expect(formatCurrency(5.1234, defaultLocale, 'Custom name')).toEqual('Custom name5.12'); + }); + + it('should round to the default number of digits if no digitsInfo', () => { + // IDR has a default number of digits of 0 + expect(formatCurrency(5.1234, defaultLocale, 'IDR', 'IDR')).toEqual('IDR5'); + expect(formatCurrency(5.1234, defaultLocale, 'IDR', 'IDR', '.2')).toEqual('IDR5.12'); + expect(formatCurrency(5.1234, defaultLocale, 'Custom name', 'IDR')).toEqual('Custom name5'); + // BHD has a default number of digits of 3 + expect(formatCurrency(5.1234, defaultLocale, 'BHD', 'BHD')).toEqual('BHD5.123'); + expect(formatCurrency(5.1234, defaultLocale, 'BHD', 'BHD', '.1-2')).toEqual('BHD5.12'); + }); + }); + }); +}); diff --git a/packages/common/test/i18n/locale_data_api_spec.ts b/packages/common/test/i18n/locale_data_api_spec.ts index e44ec258e6..9fee387070 100644 --- a/packages/common/test/i18n/locale_data_api_spec.ts +++ b/packages/common/test/i18n/locale_data_api_spec.ts @@ -13,7 +13,7 @@ import localeZh from '@angular/common/locales/zh'; import localeFrCA from '@angular/common/locales/fr-CA'; import localeEnAU from '@angular/common/locales/en-AU'; import {registerLocaleData} from '../../src/i18n/locale_data'; -import {findLocaleData, getCurrencySymbol, getLocaleDateFormat, FormatWidth, getNbOfCurrencyDigits} from '../../src/i18n/locale_data_api'; +import {findLocaleData, getCurrencySymbol, getLocaleDateFormat, FormatWidth, getNumberOfCurrencyDigits} from '../../src/i18n/locale_data_api'; { describe('locale data api', () => { @@ -76,10 +76,10 @@ import {findLocaleData, getCurrencySymbol, getLocaleDateFormat, FormatWidth, get describe('getNbOfCurrencyDigits', () => { it('should return the correct value', () => { - expect(getNbOfCurrencyDigits('USD')).toEqual(2); - expect(getNbOfCurrencyDigits('IDR')).toEqual(0); - expect(getNbOfCurrencyDigits('BHD')).toEqual(3); - expect(getNbOfCurrencyDigits('unexisting_ISO_code')).toEqual(2); + expect(getNumberOfCurrencyDigits('USD')).toEqual(2); + expect(getNumberOfCurrencyDigits('IDR')).toEqual(0); + expect(getNumberOfCurrencyDigits('BHD')).toEqual(3); + expect(getNumberOfCurrencyDigits('unexisting_ISO_code')).toEqual(2); }); }); diff --git a/packages/common/test/pipes/date_pipe_spec.ts b/packages/common/test/pipes/date_pipe_spec.ts index 5103b4549a..5b84bb45c6 100644 --- a/packages/common/test/pipes/date_pipe_spec.ts +++ b/packages/common/test/pipes/date_pipe_spec.ts @@ -7,15 +7,10 @@ */ import {DatePipe, registerLocaleData} from '@angular/common'; -import {PipeResolver} from '@angular/compiler/src/pipe_resolver'; -import {JitReflector} from '@angular/platform-browser-dynamic/src/compiler_reflector'; import localeEn from '@angular/common/locales/en'; import localeEnExtra from '@angular/common/locales/extra/en'; -import localeDe from '@angular/common/locales/de'; -import localeHu from '@angular/common/locales/hu'; -import localeSr from '@angular/common/locales/sr'; -import localeTh from '@angular/common/locales/th'; -import localeAr from '@angular/common/locales/ar'; +import {PipeResolver} from '@angular/compiler/src/pipe_resolver'; +import {JitReflector} from '@angular/platform-browser-dynamic/src/compiler_reflector'; { let date: Date; @@ -28,14 +23,7 @@ import localeAr from '@angular/common/locales/ar'; expect(pipe.transform(date, pattern)).toEqual(output); } - beforeAll(() => { - registerLocaleData(localeEn, localeEnExtra); - registerLocaleData(localeDe); - registerLocaleData(localeHu); - registerLocaleData(localeSr); - registerLocaleData(localeTh); - registerLocaleData(localeAr); - }); + beforeAll(() => { registerLocaleData(localeEn, localeEnExtra); }); beforeEach(() => { date = new Date(2015, 5, 15, 9, 3, 1, 550); @@ -60,260 +48,21 @@ import localeAr from '@angular/common/locales/ar'; 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 empty string', + () => { expect(pipe.transform('')).toEqual(null); }); - it('should return null for NaN', () => expect(pipe.transform(Number.NaN)).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/)); + () => { expect(() => pipe.transform({})).toThrowError(/InvalidPipeArgument/); }); }); describe('transform', () => { - it('should format each component correctly', () => { - const dateFixtures: any = { - 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 = { - 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', - }; - - Object.keys(dateFixtures).forEach((pattern: string) => { - expectDateFormatAs(date, pattern, dateFixtures[pattern]); - }); - - 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/, - }; - - Object.keys(dateFixtures).forEach((pattern: string) => { - expect(pipe.transform(date, pattern, '+0430')).toMatch(dateFixtures[pattern]); - }); - }); - - 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', - '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' - }; - - 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', - 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}/, - }; - - Object.keys(dateFixtures).forEach((pattern: string) => { - expect(pipe.transform(date, pattern)).toMatch(dateFixtures[pattern]); - }); - }); - - it('should format invalid in IE ISO date', - () => expect(pipe.transform('2017-01-11T12:00:00.014-0500')).toEqual('Jan 11, 2017')); - - it('should format invalid in Safari ISO date', - () => expect(pipe.transform('2017-01-20T12:00:00+0000')).toEqual('Jan 20, 2017')); - - // test for the following bugs: - // https://github.com/angular/angular/issues/9524 - // https://github.com/angular/angular/issues/9524 - it('should format correctly with iso strings that contain time', - () => expect(pipe.transform('2017-05-07T22:14:39', 'dd-MM-yyyy HH:mm')) - .toMatch(/07-05-2017 \d{2}:\d{2}/)); - - // test for issue https://github.com/angular/angular/issues/21491 - it('should not assume UTC for iso strings in Safari if the timezone is not defined', () => { - // this test only works if the timezone is not in UTC - // which is the case for BrowserStack when we test Safari - if (new Date().getTimezoneOffset() !== 0) { - expect(pipe.transform('2018-01-11T13:00:00', 'HH')) - .not.toEqual(pipe.transform('2018-01-11T13:00:00Z', 'HH')); - } - }); - - // test for the following bugs: - // https://github.com/angular/angular/issues/16624 - // https://github.com/angular/angular/issues/17478 - it('should show the correct time when the timezone is fixed', () => { - expect(pipe.transform('2017-06-13T10:14:39+0000', 'shortTime', '+0000')) - .toEqual('10:14 AM'); - expect(pipe.transform('2017-06-13T10:14:39+0000', 'h:mm a', '+0000')).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').transform(date, 'short')).toEqual('15.06.15, 09:03'); - expect(new DatePipe('ar').transform(date, 'short')).toEqual('15‏/6‏/2015 9:03 ص'); - expect(new DatePipe('th').transform(date, 'dd-MM-yy')).toEqual('15-06-15'); - expect(new DatePipe('hu').transform(date, 'a')).toEqual('de.'); - expect(new DatePipe('sr').transform(date, 'a')).toEqual('пре подне'); - - // TODO(ocombe): activate this test when we support local numbers - // expect(new DatePipe('mr', [localeMr]).transform(date, 'hh')).toEqual('०९'); - }); - - it('should throw if we use getExtraDayPeriods without loading extra locale data', () => { - expect(() => new DatePipe('de').transform(date, 'b')) - .toThrowError(/Missing extra locale data for the locale "de"/); - }); + it('should use "mediumDate" as the default format', + () => expect(pipe.transform('2017-01-11T10:14:39+0000')).toEqual('Jan 11, 2017')); }); }); } diff --git a/packages/common/test/pipes/number_pipe_spec.ts b/packages/common/test/pipes/number_pipe_spec.ts index 9e5c40ccf5..adc9676f44 100644 --- a/packages/common/test/pipes/number_pipe_spec.ts +++ b/packages/common/test/pipes/number_pipe_spec.ts @@ -10,7 +10,7 @@ import localeEn from '@angular/common/locales/en'; import localeEsUS from '@angular/common/locales/es-US'; import localeFr from '@angular/common/locales/fr'; import localeAr from '@angular/common/locales/ar'; -import {registerLocaleData, CurrencyPipe, DecimalPipe, PercentPipe} from '@angular/common'; +import {registerLocaleData, CurrencyPipe, DecimalPipe, PercentPipe, formatNumber} from '@angular/common'; import {beforeEach, describe, expect, it} from '@angular/core/testing/src/testing_internal'; { @@ -22,8 +22,6 @@ import {beforeEach, describe, expect, it} from '@angular/core/testing/src/testin registerLocaleData(localeAr); }); - function isNumeric(value: any): boolean { return !isNaN(value - parseFloat(value)); } - describe('DecimalPipe', () => { describe('transform', () => { let pipe: DecimalPipe; @@ -31,13 +29,7 @@ import {beforeEach, describe, expect, it} from '@angular/core/testing/src/testin 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'); - expect(pipe.transform(1.123456, '.2')).toEqual('1.123'); - expect(pipe.transform(1.123456, '.4')).toEqual('1.1235'); }); it('should support strings', () => { @@ -56,10 +48,6 @@ import {beforeEach, describe, expect, it} from '@angular/core/testing/src/testin expect(() => pipe.transform('123abc')) .toThrowError(`InvalidPipeArgument: '123abc is not a number' for pipe 'DecimalPipe'`); }); - - it('should throw if minFractionDigits is explicitly higher than maxFractionDigits', () => { - expect(() => pipe.transform('1.1', '3.4-2')).toThrowError(/is higher than the maximum/); - }); }); describe('transform with custom locales', () => { @@ -78,26 +66,7 @@ import {beforeEach, describe, expect, it} from '@angular/core/testing/src/testin describe('transform', () => { it('should return correct value for numbers', () => { expect(pipe.transform(1.23)).toEqual('123%'); - expect(pipe.transform(1.2, '.2')).toEqual('120.00%'); - expect(pipe.transform(1.2, '4.2')).toEqual('0,120.00%'); - expect(pipe.transform(1.2, '4.2', 'fr')).toEqual('0 120,00 %'); - expect(pipe.transform(1.2, '4.2', 'ar')).toEqual('0,120.00‎%‎'); - // see issue #20136 - expect(pipe.transform(0.12345674, '0.0-10')).toEqual('12.345674%'); - expect(pipe.transform(0, '0.0-10')).toEqual('0%'); - expect(pipe.transform(0.00, '0.0-10')).toEqual('0%'); - expect(pipe.transform(1, '0.0-10')).toEqual('100%'); - expect(pipe.transform(0.1, '0.0-10')).toEqual('10%'); - expect(pipe.transform(0.12, '0.0-10')).toEqual('12%'); - expect(pipe.transform(0.123, '0.0-10')).toEqual('12.3%'); expect(pipe.transform(12.3456, '0.0-10')).toEqual('1,234.56%'); - expect(pipe.transform(12.345600, '0.0-10')).toEqual('1,234.56%'); - expect(pipe.transform(12.345699999, '0.0-6')).toEqual('1,234.57%'); - expect(pipe.transform(12.345699999, '0.4-6')).toEqual('1,234.5700%'); - expect(pipe.transform(100, '0.4-6')).toEqual('10,000.0000%'); - expect(pipe.transform(100, '0.0-10')).toEqual('10,000%'); - expect(pipe.transform(1.5e2)).toEqual('15,000%'); - expect(pipe.transform(1e100)).toEqual('1E+102%'); }); it('should not support other objects', () => { @@ -136,16 +105,6 @@ import {beforeEach, describe, expect, it} from '@angular/core/testing/src/testin expect(pipe.transform(5.1234, 'USD', 'Custom name')).toEqual('Custom name5.12'); }); - it('should round to the default number of digits if no digitsInfo', () => { - // IDR has a default number of digits of 0 - expect(pipe.transform(5.1234, 'IDR')).toEqual('IDR5'); - expect(pipe.transform(5.1234, 'IDR', 'symbol', '.2')).toEqual('IDR5.12'); - expect(pipe.transform(5.1234, 'IDR', 'Custom name')).toEqual('Custom name5'); - // BHD has a default number of digits of 3 - expect(pipe.transform(5.1234, 'BHD')).toEqual('BHD5.123'); - expect(pipe.transform(5.1234, 'BHD', 'symbol', '.1-2')).toEqual('BHD5.12'); - }); - it('should not support other objects', () => { expect(() => pipe.transform({})) .toThrowError( @@ -160,25 +119,5 @@ import {beforeEach, describe, expect, it} from '@angular/core/testing/src/testin }); }); }); - - 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); }); - }); }); } diff --git a/tools/public_api_guard/common/common.d.ts b/tools/public_api_guard/common/common.d.ts index b40100e9df..9d07132c20 100644 --- a/tools/public_api_guard/common/common.d.ts +++ b/tools/public_api_guard/common/common.d.ts @@ -64,6 +64,18 @@ export declare class DeprecatedPercentPipe implements PipeTransform { /** @stable */ export declare const DOCUMENT: InjectionToken; +/** @stable */ +export declare function formatCurrency(value: number, locale: string, currency: string, currencyCode?: string, digitsInfo?: string): string; + +/** @stable */ +export declare function formatDate(value: string | number | Date, format: string, locale: string, timezone?: string): string; + +/** @stable */ +export declare function formatNumber(value: number, locale: string, digitsInfo?: string): string; + +/** @stable */ +export declare function formatPercent(value: number, locale: string, digitsInfo?: string): string; + /** @experimental */ export declare enum FormatWidth { Short = 0, @@ -133,7 +145,7 @@ export declare function getLocaleTimeFormat(locale: string, width: FormatWidth): export declare function getLocaleWeekEndRange(locale: string): [WeekDay, WeekDay]; /** @experimental */ -export declare function getNbOfCurrencyDigits(code: string): number; +export declare function getNumberOfCurrencyDigits(code: string): number; /** @stable */ export declare class HashLocationStrategy extends LocationStrategy {