diff --git a/addons/web/static/src/core/l10n/numbers.js b/addons/web/static/src/core/l10n/numbers.js index dfcfb5ec2bb..55e803b4230 100644 --- a/addons/web/static/src/core/l10n/numbers.js +++ b/addons/web/static/src/core/l10n/numbers.js @@ -1,9 +1,11 @@ /** @odoo-module **/ -import { escapeRegExp, intersperse, sprintf } from "../utils/strings"; +import { escapeRegExp, intersperse } from "../utils/strings"; import { localization } from "./localization"; import { _lt } from "./translation"; +class InvalidNumberError extends Error {} + // ----------------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------------- @@ -32,7 +34,7 @@ function insertThousandsSep(num, thousandsSep = ",", grouping = [3, 0]) { * @param {Object} options - additional options * @param {string|RegExp} [options.thousandsSep] - the thousands separator used in the value * @param {string|RegExp} [options.decimalPoint] - the decimal point used in the value - * @returns number + * @returns {number} */ function parseNumber(value, options = {}) { // a number can have the thousand separator multiple times. ex: 1,000,000.00 @@ -57,7 +59,7 @@ function parseNumber(value, options = {}) { * @param {number[]} [options.grouping] * array of relative offsets at which to insert `thousandsSep`. * See `numbers.insertThousandsSep` method. - * @returns string + * @returns {string} */ export function formatFloat(value, options = {}) { if (value === false) { @@ -72,11 +74,10 @@ export function formatFloat(value, options = {}) { } /** - * Parse a more readable formated string representing a float to a float + * Try to extract a float from a string. The localization is considered in the process. * - * @param {string} value the formatted number - * @returns float - * @throws Error if the value can't be parsed properly + * @param {string} value + * @returns {number} a float */ export function parseFloat(value) { let thousandsSepRegex = new RegExp(escapeRegExp(localization.thousandsSep), "g"); @@ -86,11 +87,104 @@ export function parseFloat(value) { decimalPoint: decimalPointRegex, }); if (isNaN(parsed)) { - throw new Error(sprintf(_lt("'%s' is not a correct float").toString(), value)); + throw new InvalidNumberError(`"${value}" is not a correct number`); } return parsed; } +/** + * Try to extract a float time from a string. The localization is considered in the process. + * The float time can have two formats: float or integer:integer. + * + * @param {string} value + * @returns {number} a float + */ +export function parseFloatTime(value) { + let sign = 1; + if (value[0] === "-") { + value = value.slice(1); + sign = -1; + } + const values = value.split(":"); + if (values.length > 2) { + throw new InvalidNumberError(`"${value}" is not a correct number`); + } + if (values.length === 1) { + return sign * parseFloat(value); + } + const hours = parseInteger(values[0]); + const minutes = parseInteger(values[1]); + return sign * (hours + minutes / 60); +} + +/** + * Try to extract an integer from a string. The localization is considered in the process. + * + * @param {string} value + * @returns {number} an integer + */ +export function parseInteger(value) { + let thousandsSepRegex = new RegExp(escapeRegExp(localization.thousandsSep), "g"); + let decimalPointRegex = new RegExp(escapeRegExp(localization.decimalPoint), "g"); + const parsed = parseNumber(value, { + thousandsSep: thousandsSepRegex, + decimalPoint: decimalPointRegex, + }); + if (!Number.isInteger(parsed)) { + throw new InvalidNumberError(`"${value}" is not a correct number`); + } + return parsed; +} + +/** + * Try to extract a monetary value from a string. The localization is considered in the process. + * The monetary value can have the formats sym$ float, float$ sym or float + * where $  is a non breaking space and sym is a currency symbol. + * If a symbol is found it must correspond to the default currency symbol or to the + * symbol of the currency whose id is passed in options. + * + * @param {string} value + * @param {Object} [options={}] + * @param {number} [options.currencyId] + * @returns {number} float + */ +export function parseMonetary(value, options = {}) { + let values = value.split(" "); + if (values.length === 1) { + return parseFloat(value); + } + let currency; + if (options.currencyId) { + currency = odoo.session_info.currencies[options.currencyId]; + } else { + currency = Object.values(odoo.session_info.currencies)[0]; + } + const symbolIndex = values.findIndex((v) => v === currency.symbol); + if (symbolIndex === -1) { + throw new InvalidNumberError(`"${value}" is not a correct number`); + } + values.splice(symbolIndex, 1); + if (values.length !== 1) { + throw new InvalidNumberError(`"${value}" is not a correct number`); + } + return parseFloat(values[0]); +} + +/** + * Try to extract a float from a string and unconvert it with a conversion factor of 100. + * The localization is considered in the process. + * The percentage can have two formats: float or float%. + * + * @param {string} value + * @returns {number} float + */ +export function parsePercentage(value) { + if (value[value.length - 1] === "%") { + value = value.slice(0, value.length - 1); + } + return parseFloat(value) / 100; +} + /** * Format a number to a human readable format. * By example, 3000 could become 3k. Or massive number can use the scientific exponential notation. @@ -102,7 +196,7 @@ export function parseFloat(value) { * the minimum number of digits to preserve when switching to another * level of thousands (e.g. with a value of '2', 4321 will still be * represented as 4321 otherwise it will be down to one digit (4k)) - * @returns string + * @returns {string} */ export function humanNumber(number, options = { decimals: 0, minDigits: 1 }) { number = Math.round(number); diff --git a/addons/web/static/tests/core/l10n/numbers_tests.js b/addons/web/static/tests/core/l10n/numbers_tests.js index a7eefbae049..04de9983050 100644 --- a/addons/web/static/tests/core/l10n/numbers_tests.js +++ b/addons/web/static/tests/core/l10n/numbers_tests.js @@ -1,12 +1,28 @@ /** @odoo-module **/ -import { defaultLocalization } from "@web/../tests/helpers/mock_services"; -import { formatFloat, humanNumber, parseFloat } from "@web/core/l10n/numbers"; +import { defaultLocalization } from "../../helpers/mock_services"; +import { + formatFloat, + humanNumber, + parseFloat, + parseFloatTime, + parseInteger, + parseMonetary, + parsePercentage, +} from "@web/core/l10n/numbers"; import { localization } from "@web/core/l10n/localization"; import { patchWithCleanup } from "@web/../tests/helpers/utils"; +function expectInvalidNumberError(assert, func, value, options) { + let message = `${func.name} fails on value: "${value}"`; + if (options) { + message += ` with options: ${JSON.stringify(options)}`; + } + assert.throws(() => func(value, options), message); +} + QUnit.module("utils", (hooks) => { - hooks.beforeEach(async () => { + hooks.beforeEach(() => { patchWithCleanup(localization, defaultLocalization); }); @@ -69,16 +85,104 @@ QUnit.module("utils", (hooks) => { assert.strictEqual(parseFloat("1,000.00"), 1000); assert.strictEqual(parseFloat("1,000,000.00"), 1000000); assert.strictEqual(parseFloat("1,234.567"), 1234.567); - assert.throws( - () => parseFloat("1.000.000"), - "Throw an exception if it's not a valid number" - ); + expectInvalidNumberError(assert, parseFloat, "1.000.000"); patchWithCleanup(localization, { decimalPoint: ",", thousandsSep: "." }); assert.strictEqual(parseFloat("1.234,567"), 1234.567); - assert.throws( - () => parseFloat("1,000,000"), - "Throw an exception if it's not a valid number" - ); + expectInvalidNumberError(assert, parseFloat, "1,000,000"); + }); + + QUnit.test("parseFloatTime", function (assert) { + assert.expect(12); + + assert.strictEqual(parseFloatTime("0"), 0); + assert.strictEqual(parseFloatTime("100"), 100); + assert.strictEqual(parseFloatTime("100.00"), 100); + assert.strictEqual(parseFloatTime("7:15"), 7.25); + assert.strictEqual(parseFloatTime("-4:30"), -4.5); + assert.strictEqual(parseFloatTime(":"), 0); + assert.strictEqual(parseFloatTime("1:"), 1); + assert.strictEqual(parseFloatTime(":12"), 0.2); + + expectInvalidNumberError(assert, parseFloatTime, "a:1"); + expectInvalidNumberError(assert, parseFloatTime, "1:a"); + expectInvalidNumberError(assert, parseFloatTime, "1:1:"); + expectInvalidNumberError(assert, parseFloatTime, ":1:1"); + }); + + QUnit.test("parseInteger", function (assert) { + assert.expect(11); + + assert.strictEqual(parseInteger(""), 0); + assert.strictEqual(parseInteger("0"), 0); + assert.strictEqual(parseInteger("100"), 100); + assert.strictEqual(parseInteger("-100"), -100); + assert.strictEqual(parseInteger("1,000"), 1000); + assert.strictEqual(parseInteger("1,000,000"), 1000000); + expectInvalidNumberError(assert, parseInteger, "1.000.000"); + expectInvalidNumberError(assert, parseInteger, "1,234.567"); + + patchWithCleanup(localization, { decimalPoint: ",", thousandsSep: "." }); + + assert.strictEqual(parseInteger("1.000.000"), 1000000); + expectInvalidNumberError(assert, parseInteger, "1,000,000"); + expectInvalidNumberError(assert, parseInteger, "1.234,567"); + }); + + QUnit.test("parseMonetary", function (assert) { + assert.expect(17); + + patchWithCleanup(odoo.session_info, { + currencies: { + 1: { + digits: [69, 2], + position: "after", + symbol: "€", + }, + 3: { + digits: [69, 2], + position: "before", + symbol: "$", + }, + }, + }); + + assert.strictEqual(parseMonetary(""), 0); + assert.strictEqual(parseMonetary("0"), 0); + assert.strictEqual(parseMonetary("100.00 €"), 100); + assert.strictEqual(parseMonetary("-100.00"), -100); + assert.strictEqual(parseMonetary("1,000.00"), 1000); + assert.strictEqual(parseMonetary("1,000,000.00"), 1000000); + assert.strictEqual(parseMonetary("$ 125.00", { currencyId: 3 }), 125); + assert.strictEqual(parseMonetary("1,000.00 €", { currencyId: 1 }), 1000); + + expectInvalidNumberError(assert, parseMonetary, " ", { currencyId: 3 }); + expectInvalidNumberError(assert, parseMonetary, "1 ", { currencyId: 3 }); + expectInvalidNumberError(assert, parseMonetary, " 1", { currencyId: 3 }); + + expectInvalidNumberError(assert, parseMonetary, "12.00 €"); + expectInvalidNumberError(assert, parseMonetary, "$ 12.00", { currencyId: 3 }); + expectInvalidNumberError(assert, parseMonetary, "1 $", { currencyId: 1 }); + expectInvalidNumberError(assert, parseMonetary, "$ 1"); // "€" is the default currency here + + expectInvalidNumberError(assert, parseMonetary, "1$ 1", { currencyId: 1 }); + expectInvalidNumberError(assert, parseMonetary, "$ 12.00 34", { currencyId: 3 }); + }); + + QUnit.test("parsePercentage", function (assert) { + assert.expect(9); + + assert.strictEqual(parsePercentage(""), 0); + assert.strictEqual(parsePercentage("0"), 0); + assert.strictEqual(parsePercentage("0.5"), 0.005); + assert.strictEqual(parsePercentage("1"), 0.01); + assert.strictEqual(parsePercentage("100"), 1); + assert.strictEqual(parsePercentage("50%"), 0.5); + expectInvalidNumberError(assert, parsePercentage, "50%40"); + + patchWithCleanup(localization, { decimalPoint: ",", thousandsSep: "." }); + + assert.strictEqual(parsePercentage("1.234,56"), 12.3456); + assert.strictEqual(parsePercentage("6,02"), 0.0602); }); });