Source: src/main/javascript/oracle/oj/ojvalidation/IntlNumberConverter.js

Oracle® JavaScript Extension Toolkit (JET)
1.1.2

E65298-01

/**
 * Copyright (c) 2014, Oracle and/or its affiliates.
 * All rights reserved.
 */

/*global OraNumberConverter:true*/
/**
 * @export
 * Placeholder here as closure compiler objects to export annotation outside of top level
 */

/**
 * @constructor
 * @classdesc Constructs an immutable instance and initializes it with the options provided. When initialized 
 * with no options, the default options for the current locale are assumed. The converters by 
 * default use the current page locale (returned by oj.Config.getLocale()). There are several ways 
 * to initialize the converter.
 * <p>
 * <ul>
 * <li>Using options defined by the ECMA 402 Specification, these would be the properties style, 
 * currency, currencyDisplay, minimumIntegerDigits, minimumFractionDigits, maximumFractionDigits, 
 * useGrouping. NOTE: minimumSignificantDigits and maximumSignificantDigits are not supported.</li>
 * <li>Using a custom decimal, currency or percent format pattern. specified using the 'pattern' property</li>
 * <li>Using the decimalFormat option to define a compact pattern, such as "1M" and "1 million".</li>
 * </ul>
 * <p>
 * 
 * The converter provides leniency when parsing user input value to a number in the following ways:<br/>
 * 
 * <ul>
 * <li>Prefix and suffix that do not match the pattern, are removed. E.g., when pattern is 
 * "#,##0.00%" (suffix is the % character), a value of "abc-123.45xyz", will be leniently parsed to 
 * -123.45</li>
 * <li>When a value includes a symbol but the pattern doesn't require it.  E.g., the options are 
 * {pattern: "###", currency: 'USD'}, then values ($123), (123) and -123 will be leniently parsed as 
 * -123.</li>
 * </ul>
 * <p>
 * @property {Object=} options - an object literal used to provide optional information to 
 * initialize the converter.
 * @property {string=} options.style - sets the style of number formatting. Allowed values are "decimal" 
 * (the default), "currency" or "percent". When a number is formatted as a decimal, the decimal 
 * character is replaced with the most appropriate symbol for the locale. In English this is a 
 * decimal point ("."), while in many locales it is a decimal comma (","). If grouping is enabled the 
 * locale dependent grouping separator is also used. These symbols are also used for numbers 
 * formatted as currency or a percentage, where appropriate.
 * @property {string=} options.currency - specifies the currency that will be used when formatting the 
 * number. The value should be a ISO 4217 alphabetic currency code. If the style is set to currency, 
 * it's required that the currency property also be specified. This is because the currency used is 
 * not dependent on the locale. You may be using a Thai locale, but dealing in US Dollars, e.g.
 * @property {string=} options.currencyDisplay - if the number is using currency formatting, specifies 
 * if the currency will be displayed using its "code" (as an ISO 4217 alphabetic currency code), 
 * "symbol" (a localized currency symbol (e.g. $ for US dollars, £ for Great British pounds, and so 
 * on), or "name" (a localized currency name. Allowed values are "code", "symbol" and "name". 
 * The default is "symbol".
 * @property {string=} options.decimalFormat -
 * specifies the decimal format length to use when style is set to "decimal". 
 * Allowed values are : "standard"(default), "short" and "long". 'standard' is equivalent to not 
 * specifying the 'decimalFormat' attribute, in that case the locale’s default decimal pattern 
 * is used for formatting.
 * <p>
 * The user can also specify 'minimumFractionDigits' and  'maximumFractionDigits' to display. 
 * When not present we use the locale's default max and min fraction digits.
 * </p>
 * <p>
 * There is no need to specify the scale; we automatically detect greatest scale that is less or 
 * equal than the input number. For example  1000000 is formatted as "1M" or "1 million" and
 * 1234 is formatted, with zero fractional digits, as "1K" or " 1 thousand" for 
 * short and long formats respectively. The pattern for the short and long number is locale dependent 
 * and uses plural rules for the particular locale.
 * </p>
 * <p>
 * NOTE: Currently this option formats a value (e.g., 2000 -> 2K), but it does not parse a value 
 * (e.g., 2K -> 2000), so it can only be used
 * in a readOnly EditableValue because readOnly EditableValue components do not call
 * the converter's parse function.
 * </p>
 * @property {number=} options.minimumIntegerDigits - sets the minimum number of digits before the 
 * decimal place (known as integer digits). The number is padded with leading zeros if it would not 
 * otherwise have enough digits. The value must be an integer between 1 and 21.
 * @property {number=} options.minimumFractionDigits - similar to 'minimumIntegerDigits', except it 
 * deals with the digits after the decimal place (fractional digits). It must be an integer between 
 * 0 and 20. The fractional digits will be padded with trailing zeros if they are less than the minimum.
 * @property {number=} options.maximumFractionDigits - follows the same rules as 'minimumFractionDigits', 
 * but sets the maximum number of fractional digits that are allowed. The value will be rounded if 
 * there are more digits than the maximum specified.
 * @property {boolean=} options.useGrouping - when the value is truthy, the locale dependent grouping 
 * separator is used when formatting the number. This is often known as the thousands separator, 
 * although it is up to the locale where it is placed. The ‘useGrouping’ is set to true by default.
 * @property {string=} options.pattern an optional localized pattern, where the characters used in 
 * pattern are as defined in the Unicode CLDR for numbers, percent or currency formats. When present 
 * this will override the other "options". <p>
 * 
 *   - When the pattern represents a currency style the 'currency' property is required to 
 * be set, as not setting this will throw an error. The 'currencyDisplay' is optional. <br/>Example: 
 * {pattern: '¤#,##0', currency: 'USD'}. <p>
 * 
 *   - It's not mandatory for the pattern to have the special character '¤' (currency sign) 
 * be present. When not present, values are treated as a currency value, but are not formatted to 
 * show the currency symbol. <br/>Example: {pattern: '#,##0', currency: 'USD'} <p>
 * 
 *   - When the pattern represents a percent style, the percent special character ('%') needs to be 
 * explicitly specified in the pattern, e.g., {pattern: "#,##0%"}. If the pattern does not contain 
 * the percent character it's treated as a decimal pattern, unless the style is set to percent, 
 * in which case the value is treated as a percent value, but not formatted to show the percent symbol. 
 * <br/>Example: {style: 'percent', pattern: "#,##0"}. <p>
 * 
 *   - A decimal pattern or exponent pattern is specified in the pattern using the CLDR 
 * conventions. <br/>Example: {pattern: "#,##0.00"} or {pattern: "0.##E+0"}. <p>
 * 
 * NOTE: 'pattern' is provided for backwards compatibility with existing apps that may want the 
 * convenience of specifying an explicit format mask. Setting a pattern will override the default 
 * locale specific format. <br/>
 * 
 * @example <caption>Create a number converter to parse/format currencies</caption>
 * var converterFactory = oj.Validation.converterFactory("number");
 * var options = {style: "currency", currency: "USD", minimumIntegerDigits: 2};
 * converter = converterFactory.createConverter(options);<br/>
 * 
 * @example <caption>A number converter for percent values using a custom (CLDR) pattern</caption>
 * var converterFactory = oj.Validation.converterFactory("number");
 * var options = {pattern: '#,##0%'};
 * converter = converterFactory.createConverter(options);<br/>
 * 
 * @example <caption>To parse a value as percent but format it without displaying the percent character</caption>
 * var options = {style: 'percent', pattern: '#,##0'};<br/>
 * 
 * @example <caption>To parse a value as currency using a custom (CLDR) pattern</caption>
 * var options = {pattern: '¤#,##0', currency: 'USD'};
 * 
 * @example <caption>The following decimalFormat examples are in en locale.
 * To format a value as short (default for fraction digits is based on the locale)</caption>
 * var options = {style:’decimal’, decimalFormat:’short’};
 * converter = converterFactory.createConverter(options);
 * converter.format(12345);--> 12.354K<br/>
 * 
 * @example <caption>To format a value as long (default for fraction digits is based on the locale):</caption>
 * var options = {style:’decimal’, decimalFormat:’long’};
 * converter = converterFactory.createConverter(options);
 * converter.format(12345);--> 12.345 thousand<br/>
 * 
 * @example <caption>To format a value as short with minimum fraction digits:</caption>
 * options = { style:’decimal’, decimalFormat:’short’, 
 * minimumFractionDigits:4};
 * converter = converterFactory.createConverter(options);
 * converter.format(1234);--> 1.2340K<br/>
 * 
 * @example <caption>To format a value as short with maximum fraction digits:</caption>
 * options = { style:’decimal’, decimalFormat:’short’, 
 * maximumFractionDigits:0};
 * converter = converterFactory.createConverter(options);
 * converter.format(12345);--> 12K<br/>
 * 
 * @example <caption>To format a value as long with minimum and maximum fraction digits:</caption>
 * options = { style:’decimal’, decimalFormat:’long', 
 * minimumFractionDigits:2, maximumFractionDigits:4};
 * converter = converterFactory.createConverter(options);
 * converter.format(12000);--> 12.00 thousand<br/>
 * 
 * @example <caption>To format a value as short with minimum and maximum fraction digits:</caption>
 * options = { style:’decimal’, decimalFormat:’long', 
 * minimumFractionDigits:2, maximumFractionDigits:4};
 * converter = converterFactory.createConverter(options);
 * converter.format(12345678);--> 12.345 million<br/>
 * 
 * @example <caption>decimal style default is standard:</caption>
 * options = { style:’decimal’, decimalFormat:’standard’}; 
 * converter = converterFactory.createConverter(options);
 * converter.format(12345);--> 12,345<br/>

 * @export
 * @augments oj.NumberConverter 
 * @name oj.IntlNumberConverter
 * @since 0.6
 */
oj.IntlNumberConverter = function(options)
{
  this.Init(options);
};

oj.Object.createSubclass(oj.IntlNumberConverter, oj.NumberConverter, "oj.IntlNumberConverter");

/**
 * Initializes the number converter instance with the set options.
 * @param {Object=} options an object literal used to provide an optional information to 
 * initialize the converter.<p>
 * @export
 */
oj.IntlNumberConverter.prototype.Init = function(options) 
{
  oj.IntlNumberConverter.superclass.Init.call(this, options);
};


// Returns the wrapped number converter implementation object.
oj.IntlNumberConverter.prototype._getWrapped = function ()
{
  if (!this._wrapped)
  {
    this._wrapped = OraNumberConverter.getInstance();
  }
  
  return this._wrapped;
};

/**
 * Formats a Number and returns the formatted string, using the options this converter was 
 * initialized with.
 * 
 * @param {Number|number} value to be formatted for display
 * @return {string} the localized and formatted value suitable for display. When the value is 
 * formatted as a percent it's multiplied by 100.
 * 
 * @throws {Error} a ConverterError both when formatting fails, or if the options provided during 
 * initialization cannot be resolved correctly. 
 * 
 * @export
 */
oj.IntlNumberConverter.prototype.format = function (value) 
{
  // undefined, null and empty string values all return null. If value is NaN then return "".
  if (value == null || 
      (typeof value === "string" && (oj.StringUtils.trim("" + value)).length === 0) ||
      (typeof value === "number" && isNaN(value))) 
  {
    return oj.IntlConverterUtils.__getNullFormattedValue();
  }
  
  // TODO: Is this correct?
  var localeElements = oj.LocaleData.__getBundle(), locale = oj.Config.getLocale(), 
          resolvedOptions = this.resolvedOptions(), converterError;
  
  try
  {
    return this._getWrapped().format(value, localeElements, resolvedOptions, locale);
  }
  catch (e)
  {
    converterError = this._processConverterError(e, value);
    throw converterError;
  }
};

/**
 * Retrieves a hint String describing the format the value is expected to be in.
 * 
 * @return {String} a hint describing the format the value is expected to be in.
 * @export
 */
oj.IntlNumberConverter.prototype.getHint = function ()
{
  // UX does not want any hint for numbers. 
  // return oj.Translations.getTranslatedString("oj-converter.hint.summary",
  //        {'exampleValue': this._getHintValue()}); 
  return oj.IntlNumberConverter.superclass.getHint.call(this);
};

/**
 * Returns the options called with converter initialization.
 * @return {Object} an object of options.
 * @export
 */
oj.IntlNumberConverter.prototype.getOptions = function () 
{
  return oj.IntlNumberConverter.superclass.getOptions.call(this);
};

/**
 * Parses a string value to return a Number, using the options this converter was initialized with. 
 * 
 * @param {String|string} value to parse
 * @return {number|null} the parsed number or null if the value was null or an empty string. When 
 * the value is parsed as a percent its 1/100th part is returned.
 * 
 * @throws {Error} a ConverterError both when parsing fails, or if the options provided during 
 * initialization cannot be resolved correctly. 
 *  
 * @export
 */
oj.IntlNumberConverter.prototype.parse = function (value) 
{
  var localeElements = oj.LocaleData.__getBundle(), locale = oj.Config.getLocale(), 
          resolvedOptions = this.resolvedOptions(), converterError;

  // null and empty string values are ignored and not parsed. It
  // undefined.
  if (value == null || value === "") // check for undefined, null and ""
  {
    return null;
  }
  
  try
  {
    // we want to trim the value for leading spaces before and after
    return this._getWrapped().parse(oj.StringUtils.trim(value), 
                                    localeElements, 
                                    resolvedOptions, 
                                    locale);
  }
  catch (e)
  {
    converterError = this._processConverterError(e, value);
    throw converterError;
  }
  
};

/**
 * Returns an object literal with properties reflecting the number formatting options computed based 
 * on the options parameter. If options (or pattern) is not provided, the properties will be derived 
 * from the locale defaults.
 * 
 * @return {Object} An object literal containing the resolved values for the following options. Some 
 * of these properties may not be present, indicating that the corresponding components will not be 
 * represented in the formatted output.
 * <ul>
 * <li><b>locale</b>: a String value with the language tag of the locale whose localization is used 
 * for formatting.</li>
 * <li><b>style</b>: a String value. One of the allowed values - "decimal", "currency" or "percent".</li>
 * <li><b>currency</b>: a String value.  an ISO 4217 alphabetic currency code. May be present only 
 *  when style is currency.</li>
 * <li><b>currencyDisplay</b>: a String value. One of the allowed values - "code", "symbol", or 
 *  "name".</li>
 * <li><b>numberingSystem</b>: a String value of the numbering system used. E.g. latn</li>
 * <li><b>minimumIntegerDigits</b>: a non-negative integer Number value indicating the minimum 
 *  integer digits to be used.</li>
 * <li><b>minimumFractionDigits</b>: a non-negative integer Number value indicating the minimum 
 *  fraction digits to be used.</li>
 * <li><b>maximumFractionDigits</b>: a non-negative integer Number value indicating the maximum 
 *  fraction digits to be used.</li>
 * <li><b>useGrouping</b>: a Boolean value indicating whether a grouping separator is used.</li>
 * 
 * @throws a oj.ConverterError when the options that the converter was initialized with are invalid. 
 * @export
 */
oj.IntlNumberConverter.prototype.resolvedOptions = function()
{
  var localeElements, locale = oj.Config.getLocale(), converterError;
  // options are resolved and cached for the current locale. when locale changes resolvedOptions 
  // is reevaluated as it contains locale specific info.
  if ((locale !== this._locale) || !this._resolvedOptions)
  {
    localeElements = oj.LocaleData.__getBundle();
    try
    {
      if (!localeElements)
      {
        oj.Logger.error("locale bundle for the current locale %s is unavailable", locale);
        return {};
      }
      
      // cache if successfully resolved
      this._resolvedOptions = this._getWrapped().resolvedOptions(localeElements, 
                                                                 this.getOptions(), 
                                                                 locale);
      this._locale = locale;
    }
    catch (e)
    {
      converterError = this._processConverterError(e);
      throw converterError;
    }
  }
  
  return this._resolvedOptions;
};

/**
 * Processes the error returned by the converter implementation and throws a oj.ConverterError 
 * instance.
 * 
 * @param {Error} e
 * @param {String|string|Number|number|Object=} value
 * @throws an instance of oj.ConverterError
 * @private
 */
oj.IntlNumberConverter.prototype._processConverterError = function (e, value)
{
  var errorInfo = e['errorInfo'], summary, detail, errorCode, parameterMap, converterError, 
          propName, resourceKey;
  if (errorInfo)
  {
    errorCode = errorInfo['errorCode'];
    parameterMap = errorInfo['parameterMap'];
    oj.Assert.assertObject(parameterMap);
    propName = parameterMap['propertyName'];
    
    if (e instanceof TypeError)
    {
      if (errorCode === "optionTypesMismatch" || errorCode === "optionTypeInvalid")
      {
        converterError = oj.IntlConverterUtils.__getConverterOptionError(errorCode, parameterMap);
      }
    }
    else if (e instanceof RangeError)
    {
      if (errorCode === "optionOutOfRange")
      {
        converterError = oj.IntlConverterUtils.__getConverterOptionError(errorCode, parameterMap);
      }
    }
    else if (e instanceof SyntaxError)
    {
      if (errorCode === "optionValueInvalid")
      {
        converterError = oj.IntlConverterUtils.__getConverterOptionError(errorCode, parameterMap);
      }
    }
    else if (e instanceof Error)
    {
      if (errorCode === "decimalFormatMismatch")
      {
        // The '{value}' does not match the expected decimal format
        resourceKey = "oj-converter.number.decimalFormatMismatch.summary";
      }
      else if (errorCode === "currencyFormatMismatch")
      {
        // The {value} does not match the expected currency format
        resourceKey = "oj-converter.number.currencyFormatMismatch.summary";
      }
      else if (errorCode === "percentFormatMismatch")
      {
        resourceKey = "oj-converter.number.percentFormatMismatch.summary";
      }
      // TODO: We'll be able to remove this exception when this bug is fixed post V1.1:
      // Bug 20815368 - implement parse() for short number converter
      //  
      else if (errorCode === "unsupportedParseFormat")
      {
        summary =  oj.Translations.getTranslatedString(
          "oj-converter.number.decimalFormatUnsupportedParse.summary");
        detail = oj.Translations.getTranslatedString(
          "oj-converter.number.decimalFormatUnsupportedParse.detail");
        converterError = new oj.ConverterError(summary, detail);
      }
      
      if (resourceKey)
      {
        summary = oj.Translations.getTranslatedString(resourceKey, 
          {'value': value || parameterMap['value'],
           'format': parameterMap['format']});
        
        // _getHintValue is smart. It uses the converter's 'format' function
        //  to get the example format to show the end user.
        detail = oj.Translations.getTranslatedString("oj-converter.hint.detail",
          {'exampleValue': this._getHintValue()}); 
          
        converterError = new oj.ConverterError(summary, detail);
      }
    }
  }
  
  if (!converterError)
  {
    // An error we are unfamiliar with. Get the message and set as detail
    summary = e.message; // TODO: What should the summary be when it's missing??
    detail = e.message;
    converterError = new oj.ConverterError(summary, detail);
  }
  
  return converterError;
};

// Returns the hint value. It uses the converter's format function to return a formatted
// example. For example, if the converter's style is decimal and decimalFormat is short,
// this.format(12345.98765) returns 12K, and we show 12K in the error message as an example
// of what they should type in.
oj.IntlNumberConverter.prototype._getHintValue = function()
{
  var value = "";
  try
  {
    // use .format to get a real example to show the user what format they can type in to the field.
    value =  this.format(12345.98765);
  }
  catch (e)
  {
    if (e instanceof oj.ConverterError)
    {
      // Something went wrong and we don't have a way to retrieve a valid value.
      // TODO: Log an error
      value = "";
    }
  }
  finally
  {
    // returns the formatted value of 12345.98765
    return value;
  }
};