diff --git a/docs/user/dashboard/url-drilldown.asciidoc b/docs/user/dashboard/url-drilldown.asciidoc index b71dfb016c76..cdb17e9daa5e 100644 --- a/docs/user/dashboard/url-drilldown.asciidoc +++ b/docs/user/dashboard/url-drilldown.asciidoc @@ -135,6 +135,92 @@ Example: `{{ date event.from “YYYY MM DD”}}` + `{{date “now-15”}}` + +|formatNumber +a|Format numbers. Numbers can be formatted to look like currency, percentages, times or numbers with decimal places, thousands, and abbreviations. +Refer to the http://numeraljs.com/#format[numeral.js] for different formatting options. + +Example: + +`{{formatNumber event.value "0.0"}}` + +|lowercase +a|Converts a string to lower case. + +Example: + +`{{lowercase event.value}}` + +|uppercase +a|Converts a string to upper case. + +Example: + +`{{uppercase event.value}}` + +|trim +a|Removes leading and trailing spaces from a string. + +Example: + +`{{trim event.value}}` + +|trimLeft +a|Removes leading spaces from a string. + +Example: + +`{{trimLeft event.value}}` + +|trimRight +a|Removes trailing spaces from a string. + +Example: + +`{{trimRight event.value}}` + +|mid +a|Extracts a substring from a string by start position and number of characters to extract. + +Example: + +`{{mid event.value 3 5}}` - extracts five characters starting from a third character. + +|left +a|Extracts a number of characters from a string (starting from left). + +Example: + +`{{left event.value 3}}` + +|right +a|Extracts a number of characters from a string (starting from right). + +Example: + +`{{right event.value 3}}` + +|concat +a|Concatenates two or more strings. + +Example: + +`{{concat event.value "," event.key}}` + +|replace +a|Replaces all substrings within a string. + +Example: + +`{{replace event.value "stringToReplace" "stringToReplaceWith"}}` + +|split +a|Splits a string using a provided splitter. + +Example: + +`{{split event.value ","}}` + |=== diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.test.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.test.ts index 64b8cc49292b..68a9654316d4 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.test.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.test.ts @@ -139,3 +139,161 @@ describe('date helper', () => { ); }); }); + +describe('formatNumber helper', () => { + test('formats string numbers', () => { + const url = 'https://elastic.co/{{formatNumber value "0.0"}}'; + expect(compile(url, { value: '32.9999' })).toMatchInlineSnapshot(`"https://elastic.co/33.0"`); + expect(compile(url, { value: '32.555' })).toMatchInlineSnapshot(`"https://elastic.co/32.6"`); + }); + + test('formats numbers', () => { + const url = 'https://elastic.co/{{formatNumber value "0.0"}}'; + expect(compile(url, { value: 32.9999 })).toMatchInlineSnapshot(`"https://elastic.co/33.0"`); + expect(compile(url, { value: 32.555 })).toMatchInlineSnapshot(`"https://elastic.co/32.6"`); + }); + + test("doesn't fail on Nan", () => { + const url = 'https://elastic.co/{{formatNumber value "0.0"}}'; + expect(compile(url, { value: null })).toMatchInlineSnapshot(`"https://elastic.co/"`); + expect(compile(url, { value: undefined })).toMatchInlineSnapshot(`"https://elastic.co/"`); + expect(compile(url, { value: 'not a number' })).toMatchInlineSnapshot( + `"https://elastic.co/not%20a%20number"` + ); + }); + + test('fails on missing format string', () => { + const url = 'https://elastic.co/{{formatNumber value}}'; + expect(() => compile(url, { value: 12 })).toThrowError(); + }); + + // this doesn't work and doesn't seem + // possible to validate with our version of numeral + test.skip('fails on malformed format string', () => { + const url = 'https://elastic.co/{{formatNumber value "not a real format string"}}'; + expect(() => compile(url, { value: 12 })).toThrowError(); + }); +}); + +describe('replace helper', () => { + test('replaces all occurrences', () => { + const url = 'https://elastic.co/{{replace value "replace-me" "with-me"}}'; + + expect(compile(url, { value: 'replace-me test replace-me' })).toMatchInlineSnapshot( + `"https://elastic.co/with-me%20test%20with-me"` + ); + }); + + test('can be used to remove a substring', () => { + const url = 'https://elastic.co/{{replace value "Label:" ""}}'; + + expect(compile(url, { value: 'Label:Feature:Something' })).toMatchInlineSnapshot( + `"https://elastic.co/Feature:Something"` + ); + }); + + test('works if no matches', () => { + const url = 'https://elastic.co/{{replace value "Label:" ""}}'; + + expect(compile(url, { value: 'No matches' })).toMatchInlineSnapshot( + `"https://elastic.co/No%20matches"` + ); + }); + + test('throws on incorrect args', () => { + expect(() => + compile('https://elastic.co/{{replace value "Label:"}}', { value: 'No matches' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[replace]: \\"searchString\\" and \\"valueString\\" parameters expected to be strings, but not a string or missing"` + ); + expect(() => + compile('https://elastic.co/{{replace value "Label:" 4}}', { value: 'No matches' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[replace]: \\"searchString\\" and \\"valueString\\" parameters expected to be strings, but not a string or missing"` + ); + expect(() => + compile('https://elastic.co/{{replace value 4 ""}}', { value: 'No matches' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[replace]: \\"searchString\\" and \\"valueString\\" parameters expected to be strings, but not a string or missing"` + ); + expect(() => + compile('https://elastic.co/{{replace value}}', { value: 'No matches' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[replace]: \\"searchString\\" and \\"valueString\\" parameters expected to be strings, but not a string or missing"` + ); + }); +}); + +describe('basic string formatting helpers', () => { + test('lowercase', () => { + const compileUrl = (value: unknown) => + compile('https://elastic.co/{{lowercase value}}', { value }); + + expect(compileUrl('Some String Value')).toMatchInlineSnapshot( + `"https://elastic.co/some%20string%20value"` + ); + expect(compileUrl(4)).toMatchInlineSnapshot(`"https://elastic.co/4"`); + expect(compileUrl(null)).toMatchInlineSnapshot(`"https://elastic.co/null"`); + }); + test('uppercase', () => { + const compileUrl = (value: unknown) => + compile('https://elastic.co/{{uppercase value}}', { value }); + + expect(compileUrl('Some String Value')).toMatchInlineSnapshot( + `"https://elastic.co/SOME%20STRING%20VALUE"` + ); + expect(compileUrl(4)).toMatchInlineSnapshot(`"https://elastic.co/4"`); + expect(compileUrl(null)).toMatchInlineSnapshot(`"https://elastic.co/NULL"`); + }); + test('trim', () => { + const compileUrl = (fn: 'trim' | 'trimLeft' | 'trimRight', value: unknown) => + compile(`https://elastic.co/{{${fn} value}}`, { value }); + + expect(compileUrl('trim', ' trim-me ')).toMatchInlineSnapshot(`"https://elastic.co/trim-me"`); + expect(compileUrl('trimRight', ' trim-me ')).toMatchInlineSnapshot( + `"https://elastic.co/%20%20trim-me"` + ); + expect(compileUrl('trimLeft', ' trim-me ')).toMatchInlineSnapshot( + `"https://elastic.co/trim-me%20%20"` + ); + }); + test('left,right,mid', () => { + const compileExpression = (expression: string, value: unknown) => + compile(`https://elastic.co/${expression}`, { value }); + + expect(compileExpression('{{left value 3}}', '12345')).toMatchInlineSnapshot( + `"https://elastic.co/123"` + ); + expect(compileExpression('{{right value 3}}', '12345')).toMatchInlineSnapshot( + `"https://elastic.co/345"` + ); + expect(compileExpression('{{mid value 1 3}}', '12345')).toMatchInlineSnapshot( + `"https://elastic.co/234"` + ); + }); + + test('concat', () => { + expect( + compile(`https://elastic.co/{{concat value1 "," value2}}`, { value1: 'v1', value2: 'v2' }) + ).toMatchInlineSnapshot(`"https://elastic.co/v1,v2"`); + + expect( + compile(`https://elastic.co/{{concat valueArray}}`, { valueArray: ['1', '2', '3'] }) + ).toMatchInlineSnapshot(`"https://elastic.co/1,2,3"`); + }); + + test('split', () => { + expect( + compile( + `https://elastic.co/{{lookup (split value ",") 0 }}&{{lookup (split value ",") 1 }}`, + { + value: '47.766201,-122.257057', + } + ) + ).toMatchInlineSnapshot(`"https://elastic.co/47.766201&-122.257057"`); + + expect(() => + compile(`https://elastic.co/{{split value}}`, { value: '47.766201,-122.257057' }) + ).toThrowErrorMatchingInlineSnapshot(`"[split] \\"splitter\\" expected to be a string"`); + }); +}); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.ts index 2c3537636b9d..f4a1acff8762 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.ts @@ -8,6 +8,7 @@ import { create as createHandlebars, HelperDelegate, HelperOptions } from 'handl import { encode, RisonValue } from 'rison-node'; import dateMath from '@elastic/datemath'; import moment, { Moment } from 'moment'; +import numeral from '@elastic/numeral'; const handlebars = createHandlebars(); @@ -69,6 +70,52 @@ handlebars.registerHelper('date', (...args) => { return format ? momentDate.format(format) : momentDate.toISOString(); }); +handlebars.registerHelper('formatNumber', (rawValue: unknown, pattern: string) => { + if (!pattern || typeof pattern !== 'string') + throw new Error(`[formatNumber]: pattern string is required`); + const value = Number(rawValue); + if (rawValue == null || Number.isNaN(value)) return rawValue; + return numeral(value).format(pattern); +}); + +handlebars.registerHelper('lowercase', (rawValue: unknown) => String(rawValue).toLowerCase()); +handlebars.registerHelper('uppercase', (rawValue: unknown) => String(rawValue).toUpperCase()); +handlebars.registerHelper('trim', (rawValue: unknown) => String(rawValue).trim()); +handlebars.registerHelper('trimLeft', (rawValue: unknown) => String(rawValue).trimLeft()); +handlebars.registerHelper('trimRight', (rawValue: unknown) => String(rawValue).trimRight()); +handlebars.registerHelper('left', (rawValue: unknown, numberOfChars: number) => { + if (typeof numberOfChars !== 'number') + throw new Error('[left]: expected "number of characters to extract" to be a number'); + return String(rawValue).slice(0, numberOfChars); +}); +handlebars.registerHelper('right', (rawValue: unknown, numberOfChars: number) => { + if (typeof numberOfChars !== 'number') + throw new Error('[left]: expected "number of characters to extract" to be a number'); + return String(rawValue).slice(-numberOfChars); +}); +handlebars.registerHelper('mid', (rawValue: unknown, start: number, length: number) => { + if (typeof start !== 'number') throw new Error('[left]: expected "start" to be a number'); + if (typeof length !== 'number') throw new Error('[left]: expected "length" to be a number'); + return String(rawValue).substr(start, length); +}); +handlebars.registerHelper('concat', (...args) => { + const values = args.slice(0, -1) as unknown[]; + return values.join(''); +}); +handlebars.registerHelper('split', (...args) => { + const [str, splitter] = args.slice(0, -1) as [string, string]; + if (typeof splitter !== 'string') throw new Error('[split] "splitter" expected to be a string'); + return String(str).split(splitter); +}); +handlebars.registerHelper('replace', (...args) => { + const [str, searchString, valueString] = args.slice(0, -1) as [string, string, string]; + if (typeof searchString !== 'string' || typeof valueString !== 'string') + throw new Error( + '[replace]: "searchString" and "valueString" parameters expected to be strings, but not a string or missing' + ); + return String(str).split(searchString).join(valueString); +}); + export function compile(url: string, context: object): string { const template = handlebars.compile(url, { strict: true, noEscape: true }); return encodeURI(template(context));