Skip to content

Commit

Permalink
feat(common): export functions to format numbers, percents, currenci…
Browse files Browse the repository at this point in the history
…es & dates

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 angular#20536
  • Loading branch information
ocombe committed Feb 23, 2018
1 parent aad4316 commit dc77b4e
Show file tree
Hide file tree
Showing 11 changed files with 692 additions and 473 deletions.
2 changes: 2 additions & 0 deletions packages/common/src/common.ts
Expand Up @@ -12,6 +12,8 @@
* 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';
Expand Down
118 changes: 113 additions & 5 deletions packages/common/src/i18n/format_date.ts
Expand Up @@ -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]*)/;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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());
}
84 changes: 50 additions & 34 deletions packages/common/src/i18n/format_number.ts
Expand Up @@ -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 formatNbToLocaleString(
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);
Expand Down Expand Up @@ -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;
Expand All @@ -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.maxFrac = pattern.minFrac;

const res = formatNumber(
const res = formatNbToLocaleString(
value, pattern, locale, NumberSymbol.CurrencyGroup, NumberSymbol.CurrencyDecimal, digitsInfo);
return res
.replace(CURRENCY_CHAR, currency)
Expand All @@ -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
*
* Formats a number as percentage.
*
* @internal
* 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 = formatNbToLocaleString(
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
*
* 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.
*
* @internal
* @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 formatNbToLocaleString(
value, pattern, locale, NumberSymbol.Group, NumberSymbol.Decimal, digitsInfo);
}

interface ParsedNumberFormat {
Expand Down Expand Up @@ -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));
}
}

Expand Down Expand Up @@ -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)) {
Expand Down

0 comments on commit dc77b4e

Please sign in to comment.