Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Drilldows] Url Drilldown basic template helpers #80500

Merged
merged 9 commits into from
Oct 19, 2020
86 changes: 86 additions & 0 deletions docs/user/dashboard/url-drilldown.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -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 ","}}`

|===


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"`);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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));
Expand Down