Skip to content

Commit

Permalink
[ResponseOps] adds FormatNumber mustache lambda (#159644)
Browse files Browse the repository at this point in the history
resolves #155869

Adds mustache lambda `{{#FormatNumber}}`, which uses
[Intl.NumberFormat][] to format the number.

[Intl.NumberFormat]:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat
  • Loading branch information
pmuellr authored Jun 20, 2023
1 parent e7c7445 commit d9ca8aa
Show file tree
Hide file tree
Showing 6 changed files with 354 additions and 1 deletion.
74 changes: 74 additions & 0 deletions docs/user/alerting/action-variables.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,80 @@ For example, the date format `"YYYY-MM-DD hh:mma"` will render in the following

The date value itself should usually be referenced with triple braces since some characters in date strings may contain values that are escaped, which would prevent them from being parsed as dates.

[discrete]
===== FormatNumber

The FormatNumber lambda provides number formatting capabilities using the https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat[`Intl.NumberFormat` object].

Numbers can be formatted with the following `Intl.NumberFormat` options:

- `compactDisplay`
- `currencyDisplay`
- `currencySign`
- `notation`
- `signDisplay`
- `unitDisplay`
- `unit`
- `useGrouping` - but only values true and false
- `minimumIntegerDigits`
- `minimumFractionDigits`
- `maximumFractionDigits`
- `minimumSignificantDigits`
- `maximumSignificantDigits`

To use the lambda, surround the number and formatting options with `{{#FormatNumber}}...{{/FormatNumber}}`.

The format of the text passed to the lambda is: `<number>; <locales>; <options>`, where semicolons (`;`) separate each parameter.
The `<number>` parameter is required. It is the value to be formatted. The `<locales>` and `<options>` parameters are optional, but the semicolons must be provided - the values may be empty strings.
The `<locales>` parameter is a list of locales, separated by commas (`,`).
The `<options>` parameter is a list of key value pairs, separated by commas (`,`). The key value pairs are strings separated by colons (`:`) where the key is the name of the option and the value is the value of the option.
The default locale is `en-US` and no options are set by default.

For more information on locale strings, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#locales_argument[the `locales` argument documentation from the `Intl` reference].

The options and values that can be used with them are listed under `options` in the https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat[Intl.NumberFormat() constructor documentation].

For example, the following template, given the value of context variable
`context.value.condition0` is 628.4, will render as shown below.

[source]
----
original value: {{{context.value.condition0}}}
formatted value: {{#FormatNumber}}
{{{context.value.condition0}}} ; de-DE ; style: currency, currency: EUR
{{/FormatNumber}}
----

Rendered text:

[source]
----
original value: 628.4
formatted value: 628,40 €
----

The `{{FormatNumber}}` and `{{EvalMath}}` lambdas can be used together to perform calculations on numbers and then format them.

For example, the following template, given the value of context variable
`context.value.condition0` is 628.4 will render as shown below.

[source]
----
original value: {{{context.value.condition0}}}
formatted value: {{#FormatNumber}}
{{#EvalMath}} {{context.value.condition0}} * 0.1 {{/EvalMath}}
; de-DE ; style: currency, currency: EUR
{{/FormatNumber}}
----

Rendered text:

[source]
----
original value: 628.4
formatted value: 62,84 €
----

[discrete]
[[mustache-examples]]
=== Mustache examples
Expand Down
20 changes: 20 additions & 0 deletions x-pack/plugins/actions/server/lib/mustache_lambdas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,24 @@ describe('mustache lambdas', () => {
expect(result).toMatch(/^error rendering mustache template .*/);
});
});

describe('FormatNumber', () => {
it('valid format string is successful', () => {
const num = '42.0';
const template = dedent`
{{#FormatNumber}} {{num}}; en-US; style: currency, currency: EUR {{/FormatNumber}}
`.trim();

expect(renderMustacheString(template, { num }, 'none')).toEqual('€42.00');
});

it('renders an error message on errors', () => {
const num = 'nope;;';
const template = dedent`
{{#FormatNumber}} {{num}} {{/FormatNumber}}
`.trim();

expect(renderMustacheString(template, { num }, 'none')).toEqual(`invalid number: 'nope'`);
});
});
});
8 changes: 7 additions & 1 deletion x-pack/plugins/actions/server/lib/mustache_lambdas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@

import * as tinymath from '@kbn/tinymath';
import { parse as hjsonParse } from 'hjson';

import moment from 'moment-timezone';

import { formatNumber } from './number_formatter';

type Variables = Record<string, unknown>;

const DefaultDateTimeZone = 'UTC';
Expand Down Expand Up @@ -39,6 +40,11 @@ function getLambdas() {
const dateString = render(text.trim()).trim();
return formatDate(dateString);
},
FormatNumber: () =>
function (text: string, render: RenderFn) {
const numberString = render(text.trim()).trim();
return formatNumber(numberString);
},
};
}

Expand Down
124 changes: 124 additions & 0 deletions x-pack/plugins/actions/server/lib/number_formatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { formatNumber } from './number_formatter';

describe('formatNumber()', () => {
it('using defaults is successful', () => {
expect(formatNumber('1;;')).toMatchInlineSnapshot(`"1"`);
});

it('error cases handled', () => {
expect(formatNumber('1')).toMatchInlineSnapshot(`"invalid format, missing semicolons: '1'"`);
expect(formatNumber('nope;;')).toMatchInlineSnapshot(`"invalid number: 'nope'"`);
expect(formatNumber('1;; nah')).toMatchInlineSnapshot(
`"invalid options: missing colon in option: 'nah'"`
);
expect(formatNumber('1;; minimumIntegerDigits: N.O.')).toMatchInlineSnapshot(
`"error formatting number: minimumIntegerDigits value is out of range."`
);
expect(formatNumber('1;; compactDisplay: uhuh')).toMatchInlineSnapshot(
`"error formatting number: Value uhuh out of range for Intl.NumberFormat options property compactDisplay"`
);
});

it('using locales is successful', () => {
expect(formatNumber('1000; de-DE;')).toMatchInlineSnapshot(`"1.000"`);
});

it('option compactDisplay is successful', () => {
expect(
formatNumber(' 1000;; notation: compact, compactDisplay: short, ')
).toMatchInlineSnapshot(`"1K"`);
});

it('option currency is successful', () => {
expect(formatNumber('1000;; currency: EUR, style: currency')).toMatchInlineSnapshot(
`"€1,000.00"`
);
});

it('option currencyDisplay is successful', () => {
expect(
formatNumber('1000;; currency: EUR, style: currency, currencyDisplay: name')
).toMatchInlineSnapshot(`"1,000.00 euros"`);
});

it('option currencySign is successful', () => {
expect(
formatNumber('-1;; currency: EUR, style: currency, currencySign: accounting')
).toMatchInlineSnapshot(`"(€1.00)"`);
});

// not sure how to test this, and probably doesn't matter
// because we default to en-US, and generally don't have
// control over the server's locale
it.skip('option localeMatcher is successful', () => {});

it('option notation is successful', () => {
expect(formatNumber('1000;; notation: engineering')).toMatchInlineSnapshot(`"1E3"`);
});

it('option numberingSystem is successful', () => {
expect(formatNumber('1;; numberingSystem: fullwide')).toMatchInlineSnapshot(`"1"`);
});

it('option signDisplay is successful', () => {
expect(formatNumber('1;; signDisplay: always')).toMatchInlineSnapshot(`"+1"`);
});

it('option style is successful', () => {
expect(formatNumber('1;; style: percent')).toMatchInlineSnapshot(`"100%"`);
});

it('option unit is successful', () => {
expect(formatNumber('1;; style: unit, unit: acre-per-liter')).toMatchInlineSnapshot(`"1 ac/L"`);
});

it('option unitDisplay is successful', () => {
expect(
formatNumber('1;; style: unit, unit: petabyte, unitDisplay: narrow')
).toMatchInlineSnapshot(`"1PB"`);
});

it('option useGrouping is successful', () => {
expect(formatNumber('1000;; useGrouping: true ')).toMatchInlineSnapshot(`"1,000"`);
expect(formatNumber('1000;; useGrouping: false')).toMatchInlineSnapshot(`"1000"`);
});

// not yet supported in node.js
it.skip('option roundingMode is successful', () => {});

// not yet supported in node.js
it.skip('option roundingPriority is successful', () => {});

// not yet supported in node.js
it.skip('option roundingIncrement is successful', () => {});

// not yet supported in node.js
it.skip('option trailingZeroDisplay is successful', () => {});

it('option minimumIntegerDigits is successful', () => {
expect(formatNumber('1;; minimumIntegerDigits: 7')).toMatchInlineSnapshot(`"0,000,001"`);
});

it('option minimumFractionDigits is successful', () => {
expect(formatNumber('1;; minimumFractionDigits: 3')).toMatchInlineSnapshot(`"1.000"`);
});

it('option maximumFractionDigits is successful', () => {
expect(formatNumber('1.234;; maximumFractionDigits: 2')).toMatchInlineSnapshot(`"1.23"`);
});

it('option minimumSignificantDigits is successful', () => {
expect(formatNumber('1;; minimumSignificantDigits: 3')).toMatchInlineSnapshot(`"1.00"`);
});

it('option maximumSignificantDigits is successful', () => {
expect(formatNumber('123456;; maximumSignificantDigits: 4')).toMatchInlineSnapshot(`"123,500"`);
});
});
112 changes: 112 additions & 0 deletions x-pack/plugins/actions/server/lib/number_formatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

const DEFAULT_LOCALES = ['en-US'];

/**
* Takes a string which contains a number and formatting options,
* and returns that string formatted according to the options.
* Intl.FormatNumber is used for formatting.
*
* The format is 'number; locales; options', where
* - `number` is the number to format
* - `locales` is a comma-separated list of locales
* - `options` is a comma-separated list of Intl.NumberFormat options
*
* Both semicolons are required , but the `locales` and `options` can
* be empty. If `locales` is empty, `en-US` is used, for consistency.
*
* Examples:
* `1234.567; en-US; style: currency, currency: USD`
* `1234.567;; style: currency, currency: USD`
* `1234.567;;`
*
* see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat
*
* @param numberAndFormat string containing a number and formatting options
* @returns number formatted according to the options
*/
export function formatNumber(numberLocalesOptions: string): string {
const [numString, localesString, optionsString] = splitNumberLocalesOptions(numberLocalesOptions);
if (localesString === undefined || optionsString === undefined) {
return `invalid format, missing semicolons: '${numberLocalesOptions}'`;
}

const num = parseFloat(numString);
if (isNaN(num)) return `invalid number: '${numString}'`;

const locales = getLocales(localesString);

const [options, optionsError] = getOptions(optionsString);
if (optionsError) return `invalid options: ${optionsError}`;

try {
return new Intl.NumberFormat(locales, options).format(num);
} catch (err) {
return `error formatting number: ${err.message}`;
}
}

function getLocales(localesString: string): string[] {
const locales = splitCommas(localesString)
.map((s) => s.trim())
.filter((s) => s.length > 0);

if (locales.length > 0) return locales;

return DEFAULT_LOCALES;
}

type IntlNumberOptions = Record<string, string | number | boolean>;

function getOptions(optionsString: string): [IntlNumberOptions, string?] {
const options: IntlNumberOptions = {};

const keyVals = splitCommas(optionsString);

for (const keyVal of keyVals) {
if (keyVal === '') continue;

const [key, valString] = splitKeyVal(keyVal);
if (valString === undefined) {
return [{}, `missing colon in option: '${keyVal}'`];
}

options[key] = getVal(valString);
}

return [options];
}

// Intl.NumberFormat options can be a string, number, or boolean
// There don't seem to be cases of needing to send a string version
// of a boolean or number.
function getVal(valString: string): string | number | boolean {
const valAsNum = parseFloat(valString);
if (!isNaN(valAsNum)) return valAsNum;

if (valString === 'true') return true;
if (valString === 'false') return false;

return valString;
}

function splitCommas(str: string): string[] {
return str.split(',').map((s) => s.trim());
}

function splitKeyVal(s: string): [string, string | undefined] {
const [key, val] = s.split(':', 2);
return [key.trim(), val?.trim()];
}

function splitNumberLocalesOptions(
numberLocalesOptions: string
): [string, string | undefined, string | undefined] {
const [num, locales, options] = numberLocalesOptions.split(';', 3);
return [num.trim(), locales?.trim(), options?.trim()];
}
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,23 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
});
});

it('should handle FormatNumber', async () => {
// from x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts,
// const DeepContextVariables
const template = `{{#context.deep}}{{#FormatNumber}}
{{{arrayI.1}}}; en-US; style: currency, currency: EUR
{{/FormatNumber}}{{/context.deep}}`;
const rule = await createRule({
id: slackConnector.id,
group: 'default',
params: {
message: `message {{alertId}} - ${template}`,
},
});
const body = await retry.try(async () => waitForActionBody(slackSimulatorURL, rule.id));
expect(body.trim()).to.be('€45.00');
});

async function createRule(action: any) {
const ruleResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
Expand Down

0 comments on commit d9ca8aa

Please sign in to comment.