From f4160d59074cc3980770e4b299e856153b03d16f Mon Sep 17 00:00:00 2001 From: Mathieu Duckerts-Antoine Date: Wed, 26 May 2021 11:07:58 +0000 Subject: [PATCH] [IMP] web: allow to parse more numbers The present commit reintroduces several number parsers that were previously defined in web.field_utils: parseFloatTime, parseInteger, parseMonetary, and parsePercentage. Some minor simplifications in their API have been brought. Notably, the "field" parameter has been removed. Indeed, we expect those parsers to be used in more specific contexts than before so that the advantage of having a common API (value, field, options) has become less important. --- addons/web/static/src/core/l10n/numbers.js | 112 ++++++++++++++-- .../static/tests/core/l10n/numbers_tests.js | 126 ++++++++++++++++-- 2 files changed, 218 insertions(+), 20 deletions(-) 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); }); });