From 11f749e0c073f7785933e8a03625dcfcf0cbc9a2 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 23 Sep 2021 13:31:27 +0200 Subject: [PATCH 1/4] lazy load handlebars --- .../public/lib/url_drilldown.tsx | 10 +- .../url_drilldown/url_template.test.ts | 211 ++++++++-------- .../drilldowns/url_drilldown/url_template.ts | 237 ++++++++++-------- .../url_drilldown/url_validation.test.ts | 32 ++- .../url_drilldown/url_validation.ts | 6 +- .../ui_actions_enhanced/public/index.ts | 14 +- 6 files changed, 279 insertions(+), 231 deletions(-) diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx index 617f5a25ebbc5..4c46b84008766 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx @@ -129,7 +129,7 @@ export class UrlDrilldown implements Drilldown { const scope = this.getRuntimeVariables(context); - const { isValid, error } = urlDrilldownValidateUrlTemplate(config.url, scope); + const { isValid, error } = await urlDrilldownValidateUrlTemplate(config.url, scope); if (!isValid) { // eslint-disable-next-line no-console @@ -139,7 +139,7 @@ export class UrlDrilldown implements Drilldown { const doEncode = config.encodeUrl ?? true; - const url = urlDrilldownCompileUrl( + const url = await urlDrilldownCompileUrl( config.url.template, this.getRuntimeVariables(context), doEncode @@ -159,7 +159,7 @@ export class UrlDrilldown implements Drilldown => { - const url = this.buildUrl(config, context); + const url = await this.buildUrl(config, context); const validUrl = this.deps.externalUrl.validateUrl(url); if (!validUrl) { throw new Error( 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 e2a287f700947..032a54364b940 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 @@ -8,83 +8,83 @@ import { compile } from './url_template'; import moment from 'moment-timezone'; -test('should compile url without variables', () => { +test('should compile url without variables', async () => { const url = 'https://elastic.co'; - expect(compile(url, {})).toBe(url); + expect(await compile(url, {})).toBe(url); }); -test('by default, encodes URI', () => { +test('by default, encodes URI', async () => { const url = 'https://elastic.co?foo=head%26shoulders'; - expect(compile(url, {})).not.toBe(url); - expect(compile(url, {})).toBe('https://elastic.co?foo=head%2526shoulders'); + expect(await compile(url, {})).not.toBe(url); + expect(await compile(url, {})).toBe('https://elastic.co?foo=head%2526shoulders'); }); -test('when URI encoding is disabled, should not encode URI', () => { +test('when URI encoding is disabled, should not encode URI', async () => { const url = 'https://xxxxx.service-now.com/nav_to.do?uri=incident.do%3Fsys_id%3D-1%26sysparm_query%3Dshort_description%3DHello'; - expect(compile(url, {}, false)).toBe(url); + expect(await compile(url, {}, false)).toBe(url); }); -test('should fail on unknown syntax', () => { +test('should fail on unknown syntax', async () => { const url = 'https://elastic.co/{{}'; - expect(() => compile(url, {})).toThrowError(); + await expect(() => compile(url, {})).rejects; }); -test('should fail on not existing variable', () => { +test('should fail on not existing variable', async () => { const url = 'https://elastic.co/{{fake}}'; - expect(() => compile(url, {})).toThrowError(); + await expect(() => compile(url, {})).rejects; }); -test('should fail on not existing nested variable', () => { +test('should fail on not existing nested variable', async () => { const url = 'https://elastic.co/{{fake.fake}}'; - expect(() => compile(url, { fake: {} })).toThrowError(); + await expect(() => compile(url, { fake: {} })).rejects; }); -test('should replace existing variable', () => { +test('should replace existing variable', async () => { const url = 'https://elastic.co/{{foo}}'; - expect(compile(url, { foo: 'bar' })).toMatchInlineSnapshot(`"https://elastic.co/bar"`); + expect(await compile(url, { foo: 'bar' })).toMatchInlineSnapshot(`"https://elastic.co/bar"`); }); -test('should fail on unknown helper', () => { +test('should fail on unknown helper', async () => { const url = 'https://elastic.co/{{fake foo}}'; - expect(() => compile(url, { foo: 'bar' })).toThrowError(); + await expect(() => compile(url, { foo: 'bar' })).rejects; }); describe('json helper', () => { - test('should replace with json', () => { + test('should replace with json', async () => { const url = 'https://elastic.co/{{json foo bar}}'; - expect(compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot( + expect(await compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot( `"https://elastic.co/%5B%7B%22foo%22:%22bar%22%7D,%7B%22bar%22:%22foo%22%7D%5D"` ); }); - test('should replace with json and skip encoding', () => { + test('should replace with json and skip encoding', async () => { const url = 'https://elastic.co/{{{json foo bar}}}'; - expect(compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot( + expect(await compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot( `"https://elastic.co/%5B%7B%22foo%22:%22bar%22%7D,%7B%22bar%22:%22foo%22%7D%5D"` ); }); - test('should throw on unknown key', () => { + test('should throw on unknown key', async () => { const url = 'https://elastic.co/{{{json fake}}}'; - expect(() => compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toThrowError(); + await expect(() => compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).rejects; }); }); describe('rison helper', () => { - test('should replace with rison', () => { + test('should replace with rison', async () => { const url = 'https://elastic.co/{{rison foo bar}}'; - expect(compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot( + expect(await compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot( `"https://elastic.co/!((foo:bar),(bar:foo))"` ); }); - test('should replace with rison and skip encoding', () => { + test('should replace with rison and skip encoding', async () => { const url = 'https://elastic.co/{{{rison foo bar}}}'; - expect(compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot( + expect(await compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot( `"https://elastic.co/!((foo:bar),(bar:foo))"` ); }); - test('should throw on unknown key', () => { + test('should throw on unknown key', async () => { const url = 'https://elastic.co/{{{rison fake}}}'; - expect(() => compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toThrowError(); + await expect(() => compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).rejects; }); }); @@ -100,204 +100,217 @@ describe('date helper', () => { moment.tz.setDefault('Browser'); }); - test('uses datemath', () => { + test('uses datemath', async () => { const url = 'https://elastic.co/{{date time}}'; - expect(compile(url, { time: 'now' })).toMatchInlineSnapshot( + expect(await compile(url, { time: 'now' })).toMatchInlineSnapshot( `"https://elastic.co/2020-08-18T14:45:00.000Z"` ); }); - test('can use format', () => { + test('can use format', async () => { const url = 'https://elastic.co/{{date time "dddd, MMMM Do YYYY, h:mm:ss a"}}'; - expect(compile(url, { time: 'now' })).toMatchInlineSnapshot( + expect(await compile(url, { time: 'now' })).toMatchInlineSnapshot( `"https://elastic.co/Tuesday,%20August%2018th%202020,%202:45:00%20pm"` ); }); - test('throws if missing variable', () => { + test('throws if missing variable', async () => { const url = 'https://elastic.co/{{date time}}'; - expect(() => compile(url, {})).toThrowError(); + await expect(() => compile(url, {})).rejects; }); - test("doesn't throw if non valid date", () => { + test("doesn't throw if non valid date", async () => { const url = 'https://elastic.co/{{date time}}'; - expect(compile(url, { time: 'fake' })).toMatchInlineSnapshot(`"https://elastic.co/fake"`); + expect(await compile(url, { time: 'fake' })).toMatchInlineSnapshot(`"https://elastic.co/fake"`); }); - test("doesn't throw on boolean or number", () => { + test("doesn't throw on boolean or number", async () => { const url = 'https://elastic.co/{{date time}}'; - expect(compile(url, { time: false })).toMatchInlineSnapshot(`"https://elastic.co/false"`); - expect(compile(url, { time: 24 })).toMatchInlineSnapshot( + expect(await compile(url, { time: false })).toMatchInlineSnapshot(`"https://elastic.co/false"`); + expect(await compile(url, { time: 24 })).toMatchInlineSnapshot( `"https://elastic.co/1970-01-01T00:00:00.024Z"` ); }); - test('works with ISO string', () => { + test('works with ISO string', async () => { const url = 'https://elastic.co/{{date time}}'; - expect(compile(url, { time: date.toISOString() })).toMatchInlineSnapshot( + expect(await compile(url, { time: date.toISOString() })).toMatchInlineSnapshot( `"https://elastic.co/2020-08-18T14:45:00.000Z"` ); }); - test('works with ts', () => { + test('works with ts', async () => { const url = 'https://elastic.co/{{date time}}'; - expect(compile(url, { time: date.valueOf() })).toMatchInlineSnapshot( + expect(await compile(url, { time: date.valueOf() })).toMatchInlineSnapshot( `"https://elastic.co/2020-08-18T14:45:00.000Z"` ); }); - test('works with ts string', () => { + test('works with ts string', async () => { const url = 'https://elastic.co/{{date time}}'; - expect(compile(url, { time: String(date.valueOf()) })).toMatchInlineSnapshot( + expect(await compile(url, { time: String(date.valueOf()) })).toMatchInlineSnapshot( `"https://elastic.co/2020-08-18T14:45:00.000Z"` ); }); }); describe('formatNumber helper', () => { - test('formats string numbers', () => { + test('formats string numbers', async () => { 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"`); + expect(await compile(url, { value: '32.9999' })).toMatchInlineSnapshot( + `"https://elastic.co/33.0"` + ); + expect(await compile(url, { value: '32.555' })).toMatchInlineSnapshot( + `"https://elastic.co/32.6"` + ); }); - test('formats numbers', () => { + test('formats numbers', async () => { 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"`); + expect(await compile(url, { value: 32.9999 })).toMatchInlineSnapshot( + `"https://elastic.co/33.0"` + ); + expect(await compile(url, { value: 32.555 })).toMatchInlineSnapshot( + `"https://elastic.co/32.6"` + ); }); - test("doesn't fail on Nan", () => { + test("doesn't fail on Nan", async () => { 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( + expect(await compile(url, { value: null })).toMatchInlineSnapshot(`"https://elastic.co/"`); + expect(await compile(url, { value: undefined })).toMatchInlineSnapshot(`"https://elastic.co/"`); + expect(await compile(url, { value: 'not a number' })).toMatchInlineSnapshot( `"https://elastic.co/not%20a%20number"` ); }); - test('fails on missing format string', () => { + test('fails on missing format string', async () => { const url = 'https://elastic.co/{{formatNumber value}}'; - expect(() => compile(url, { value: 12 })).toThrowError(); + await expect(() => compile(url, { value: 12 })).rejects; }); // this doesn't work and doesn't seem // possible to validate with our version of numeral - test.skip('fails on malformed format string', () => { + test.skip('fails on malformed format string', async () => { const url = 'https://elastic.co/{{formatNumber value "not a real format string"}}'; - expect(() => compile(url, { value: 12 })).toThrowError(); + await expect(() => compile(url, { value: 12 })).rejects; }); }); describe('replace helper', () => { - test('replaces all occurrences', () => { + test('replaces all occurrences', async () => { const url = 'https://elastic.co/{{replace value "replace-me" "with-me"}}'; - expect(compile(url, { value: 'replace-me test replace-me' })).toMatchInlineSnapshot( + expect(await 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', () => { + test('can be used to remove a substring', async () => { const url = 'https://elastic.co/{{replace value "Label:" ""}}'; - expect(compile(url, { value: 'Label:Feature:Something' })).toMatchInlineSnapshot( + expect(await compile(url, { value: 'Label:Feature:Something' })).toMatchInlineSnapshot( `"https://elastic.co/Feature:Something"` ); }); - test('works if no matches', () => { + test('works if no matches', async () => { const url = 'https://elastic.co/{{replace value "Label:" ""}}'; - expect(compile(url, { value: 'No matches' })).toMatchInlineSnapshot( + expect(await compile(url, { value: 'No matches' })).toMatchInlineSnapshot( `"https://elastic.co/No%20matches"` ); }); - test('throws on incorrect args', () => { - expect(() => + test('throws on incorrect args', async () => { + await expect(() => compile('https://elastic.co/{{replace value "Label:"}}', { value: 'No matches' }) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `"[replace]: \\"searchString\\" and \\"valueString\\" parameters expected to be strings, but not a string or missing"` ); - expect(() => + await expect(() => compile('https://elastic.co/{{replace value "Label:" 4}}', { value: 'No matches' }) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `"[replace]: \\"searchString\\" and \\"valueString\\" parameters expected to be strings, but not a string or missing"` ); - expect(() => + await expect(() => compile('https://elastic.co/{{replace value 4 ""}}', { value: 'No matches' }) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `"[replace]: \\"searchString\\" and \\"valueString\\" parameters expected to be strings, but not a string or missing"` ); - expect(() => + await expect(() => compile('https://elastic.co/{{replace value}}', { value: 'No matches' }) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `"[replace]: \\"searchString\\" and \\"valueString\\" parameters expected to be strings, but not a string or missing"` ); }); }); describe('basic string formatting helpers', () => { - test('lowercase', () => { + test('lowercase', async () => { const compileUrl = (value: unknown) => compile('https://elastic.co/{{lowercase value}}', { value }); - expect(compileUrl('Some String Value')).toMatchInlineSnapshot( + expect(await 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"`); + expect(await compileUrl(4)).toMatchInlineSnapshot(`"https://elastic.co/4"`); + expect(await compileUrl(null)).toMatchInlineSnapshot(`"https://elastic.co/null"`); }); - test('uppercase', () => { + test('uppercase', async () => { const compileUrl = (value: unknown) => compile('https://elastic.co/{{uppercase value}}', { value }); - expect(compileUrl('Some String Value')).toMatchInlineSnapshot( + expect(await 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"`); + expect(await compileUrl(4)).toMatchInlineSnapshot(`"https://elastic.co/4"`); + expect(await compileUrl(null)).toMatchInlineSnapshot(`"https://elastic.co/NULL"`); }); - test('trim', () => { + test('trim', async () => { 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( + expect(await compileUrl('trim', ' trim-me ')).toMatchInlineSnapshot( + `"https://elastic.co/trim-me"` + ); + expect(await compileUrl('trimRight', ' trim-me ')).toMatchInlineSnapshot( `"https://elastic.co/%20%20trim-me"` ); - expect(compileUrl('trimLeft', ' trim-me ')).toMatchInlineSnapshot( + expect(await compileUrl('trimLeft', ' trim-me ')).toMatchInlineSnapshot( `"https://elastic.co/trim-me%20%20"` ); }); - test('left,right,mid', () => { + test('left,right,mid', async () => { const compileExpression = (expression: string, value: unknown) => compile(`https://elastic.co/${expression}`, { value }); - expect(compileExpression('{{left value 3}}', '12345')).toMatchInlineSnapshot( + expect(await compileExpression('{{left value 3}}', '12345')).toMatchInlineSnapshot( `"https://elastic.co/123"` ); - expect(compileExpression('{{right value 3}}', '12345')).toMatchInlineSnapshot( + expect(await compileExpression('{{right value 3}}', '12345')).toMatchInlineSnapshot( `"https://elastic.co/345"` ); - expect(compileExpression('{{mid value 1 3}}', '12345')).toMatchInlineSnapshot( + expect(await compileExpression('{{mid value 1 3}}', '12345')).toMatchInlineSnapshot( `"https://elastic.co/234"` ); }); - test('concat', () => { + test('concat', async () => { expect( - compile(`https://elastic.co/{{concat value1 "," value2}}`, { value1: 'v1', value2: 'v2' }) + await 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'] }) + await compile(`https://elastic.co/{{concat valueArray}}`, { valueArray: ['1', '2', '3'] }) ).toMatchInlineSnapshot(`"https://elastic.co/1,2,3"`); }); - test('split', () => { + test('split', async () => { expect( - compile( + await compile( `https://elastic.co/{{lookup (split value ",") 0 }}&{{lookup (split value ",") 1 }}`, { value: '47.766201,-122.257057', @@ -305,8 +318,10 @@ describe('basic string formatting helpers', () => { ) ).toMatchInlineSnapshot(`"https://elastic.co/47.766201&-122.257057"`); - expect(() => + await expect(() => compile(`https://elastic.co/{{split value}}`, { value: '47.766201,-122.257057' }) - ).toThrowErrorMatchingInlineSnapshot(`"[split] \\"splitter\\" expected to be a string"`); + ).rejects.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 b724435a7d992..bc90b048e97e9 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 @@ -5,130 +5,147 @@ * 2.0. */ -import { create as createHandlebars, HelperDelegate, HelperOptions } from 'handlebars'; +import type { HelperDelegate, HelperOptions } from 'handlebars'; +import type * as Handlebars from 'handlebars'; import { encode, RisonValue } from 'rison-node'; import dateMath from '@elastic/datemath'; import moment, { Moment } from 'moment'; import numeral from '@elastic/numeral'; import { url } from '../../../../../../src/plugins/kibana_utils/public'; -const handlebars = createHandlebars(); - -function createSerializationHelper( - fnName: string, - serializeFn: (value: unknown) => string -): HelperDelegate { - return (...args) => { - const { hash } = args.slice(-1)[0] as HelperOptions; - const hasHash = Object.keys(hash).length > 0; - const hasValues = args.length > 1; - if (hasHash && hasValues) { - throw new Error(`[${fnName}]: both value list and hash are not supported`); - } - if (hasHash) { - if (Object.values(hash).some((v) => typeof v === 'undefined')) - throw new Error(`[${fnName}]: unknown variable`); - return serializeFn(hash); - } else { - const values = args.slice(0, -1) as unknown[]; - if (values.some((value) => typeof value === 'undefined')) - throw new Error(`[${fnName}]: unknown variable`); - if (values.length === 0) throw new Error(`[${fnName}]: unknown variable`); - if (values.length === 1) return serializeFn(values[0]); - return serializeFn(values); - } - }; -} +let handlebarsPromise: Promise; +async function getHandlebars(): Promise { + if (handlebarsPromise) return handlebarsPromise; + return (handlebarsPromise = import('handlebars').then((handlebarsLib) => { + const handlebars = handlebarsLib.create(); + handlebars.registerHelper('json', createSerializationHelper('json', JSON.stringify)); + handlebars.registerHelper( + 'rison', + createSerializationHelper('rison', (v) => encode(v as RisonValue)) + ); -handlebars.registerHelper('json', createSerializationHelper('json', JSON.stringify)); -handlebars.registerHelper( - 'rison', - createSerializationHelper('rison', (v) => encode(v as RisonValue)) -); + handlebars.registerHelper('date', (...args) => { + const values = args.slice(0, -1) as [string | Date, string | undefined]; + // eslint-disable-next-line prefer-const + let [date, format] = values; + if (typeof date === 'undefined') throw new Error(`[date]: unknown variable`); + let momentDate: Moment | undefined; + if (typeof date === 'string') { + momentDate = dateMath.parse(date); + if (!momentDate || !momentDate.isValid()) { + const ts = Number(date); + if (!Number.isNaN(ts)) { + momentDate = moment(ts); + } + } + } else { + momentDate = moment(date); + } -handlebars.registerHelper('date', (...args) => { - const values = args.slice(0, -1) as [string | Date, string | undefined]; - // eslint-disable-next-line prefer-const - let [date, format] = values; - if (typeof date === 'undefined') throw new Error(`[date]: unknown variable`); - let momentDate: Moment | undefined; - if (typeof date === 'string') { - momentDate = dateMath.parse(date); - if (!momentDate || !momentDate.isValid()) { - const ts = Number(date); - if (!Number.isNaN(ts)) { - momentDate = moment(ts); + if (!momentDate || !momentDate.isValid()) { + // do not throw error here, because it could be that in preview `__testValue__` is not parsable, + // but in runtime it is + return date; } - } - } else { - momentDate = moment(date); - } + return format ? momentDate.format(format) : momentDate.toISOString(); + }); - if (!momentDate || !momentDate.isValid()) { - // do not throw error here, because it could be that in preview `__testValue__` is not parsable, - // but in runtime it is - return date; - } - 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('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); + }); -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); -}); + handlebars.registerHelper('encodeURIComponent', (component: unknown) => { + const str = String(component); + return encodeURIComponent(str); + }); + handlebars.registerHelper('encodeURIQuery', (component: unknown) => { + const str = String(component); + return url.encodeUriQuery(str); + }); + + return handlebars; + })); + + function createSerializationHelper( + fnName: string, + serializeFn: (value: unknown) => string + ): HelperDelegate { + return (...args) => { + const { hash } = args.slice(-1)[0] as HelperOptions; + const hasHash = Object.keys(hash).length > 0; + const hasValues = args.length > 1; + if (hasHash && hasValues) { + throw new Error(`[${fnName}]: both value list and hash are not supported`); + } + if (hasHash) { + if (Object.values(hash).some((v) => typeof v === 'undefined')) + throw new Error(`[${fnName}]: unknown variable`); + return serializeFn(hash); + } else { + const values = args.slice(0, -1) as unknown[]; + if (values.some((value) => typeof value === 'undefined')) + throw new Error(`[${fnName}]: unknown variable`); + if (values.length === 0) throw new Error(`[${fnName}]: unknown variable`); + if (values.length === 1) return serializeFn(values[0]); + return serializeFn(values); + } + }; + } +} -handlebars.registerHelper('encodeURIComponent', (component: unknown) => { - const str = String(component); - return encodeURIComponent(str); -}); -handlebars.registerHelper('encodeURIQuery', (component: unknown) => { - const str = String(component); - return url.encodeUriQuery(str); -}); +export async function compile( + urlTemplate: string, + context: object, + doEncode: boolean = true +): Promise { + const handlebarsTemplate = (await getHandlebars()).compile(urlTemplate, { + strict: true, + noEscape: true, + }); -export function compile(urlTemplate: string, context: object, doEncode: boolean = true): string { - const handlebarsTemplate = handlebars.compile(urlTemplate, { strict: true, noEscape: true }); let processedUrl: string = handlebarsTemplate(context); if (doEncode) { diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.test.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.test.ts index c348d763e4174..78379b3495919 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.test.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.test.ts @@ -62,27 +62,37 @@ describe('validateUrl', () => { }); describe('validateUrlTemplate', () => { - test('domain in variable is allowed', () => { + test('domain in variable is allowed', async () => { expect( - validateUrlTemplate( - { template: '{{kibanaUrl}}/test' }, - { kibanaUrl: 'http://localhost:5601/app' } + ( + await validateUrlTemplate( + { template: '{{kibanaUrl}}/test' }, + { kibanaUrl: 'http://localhost:5601/app' } + ) ).isValid ).toBe(true); }); - test('unsafe domain in variable is not allowed', () => { + test('unsafe domain in variable is not allowed', async () => { expect( - // eslint-disable-next-line no-script-url - validateUrlTemplate({ template: '{{kibanaUrl}}/test' }, { kibanaUrl: 'javascript:evil()' }) - .isValid + ( + await validateUrlTemplate( + { template: '{{kibanaUrl}}/test' }, + // eslint-disable-next-line no-script-url + { kibanaUrl: 'javascript:evil()' } + ) + ).isValid ).toBe(false); }); - test('if missing variable then invalid', () => { + test('if missing variable then invalid', async () => { expect( - validateUrlTemplate({ template: '{{url}}/test' }, { kibanaUrl: 'http://localhost:5601/app' }) - .isValid + ( + await validateUrlTemplate( + { template: '{{url}}/test' }, + { kibanaUrl: 'http://localhost:5601/app' } + ) + ).isValid ).toBe(false); }); }); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts index 8e2b39862f26a..860e6f96cc782 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts @@ -50,10 +50,10 @@ export function validateUrl(url: string): { isValid: boolean; error?: string } { } } -export function validateUrlTemplate( +export async function validateUrlTemplate( urlTemplate: UrlDrilldownConfig['url'], scope: UrlDrilldownScope -): { isValid: boolean; error?: string } { +): Promise<{ isValid: boolean; error?: string }> { if (!urlTemplate.template) return { isValid: false, @@ -61,7 +61,7 @@ export function validateUrlTemplate( }; try { - const compiledUrl = compile(urlTemplate.template, scope); + const compiledUrl = await compile(urlTemplate.template, scope); return validateUrl(compiledUrl); } catch (e) { return { diff --git a/x-pack/plugins/ui_actions_enhanced/public/index.ts b/x-pack/plugins/ui_actions_enhanced/public/index.ts index 8b4b43d54db89..303734c747289 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/index.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/index.ts @@ -5,9 +5,6 @@ * 2.0. */ -// TODO: https://github.com/elastic/kibana/issues/109891 -/* eslint-disable @kbn/eslint/no_export_all */ - import { PluginInitializerContext } from '../../../../src/core/public'; import { AdvancedUiActionsPublicPlugin } from './plugin'; @@ -42,4 +39,13 @@ export { DrilldownDefinition as UiActionsEnhancedDrilldownDefinition, DrilldownTemplate as UiActionsEnhancedDrilldownTemplate, } from './drilldowns'; -export * from './drilldowns/url_drilldown'; +export { + urlDrilldownCompileUrl, + UrlDrilldownCollectConfig, + UrlDrilldownConfig, + UrlDrilldownGlobalScope, + urlDrilldownGlobalScopeProvider, + UrlDrilldownScope, + urlDrilldownValidateUrl, + urlDrilldownValidateUrlTemplate, +} from './drilldowns/url_drilldown'; From 42a0172f7476c6ca7b52e36355c80e5581df2cdc Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 23 Sep 2021 15:18:02 +0200 Subject: [PATCH 2/4] lazy load components --- packages/kbn-optimizer/limits.yml | 2 +- .../public/custom_time_range_action.tsx | 4 +++- .../public/custom_time_range_badge.tsx | 4 +++- .../create_public_drilldown_manager.tsx | 14 +++++++++----- .../drilldown_manager_with_provider.tsx | 18 ++++++++++++++++++ .../containers/drilldown_manager/index.ts | 1 - .../ui_actions_enhanced/public/index.ts | 1 - 7 files changed, 34 insertions(+), 10 deletions(-) create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/drilldown_manager_with_provider.tsx diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 69cfffe1f08f0..7b4dd60190865 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -73,7 +73,7 @@ pageLoadAssetSize: transform: 41007 triggersActionsUi: 100000 uiActions: 97717 - uiActionsEnhanced: 313011 + uiActionsEnhanced: 60000 upgradeAssistant: 81241 uptime: 40825 urlDrilldown: 70674 diff --git a/x-pack/plugins/ui_actions_enhanced/public/custom_time_range_action.tsx b/x-pack/plugins/ui_actions_enhanced/public/custom_time_range_action.tsx index ee2587f61fbc8..f0ec601a0cc2f 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/custom_time_range_action.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/custom_time_range_action.tsx @@ -15,7 +15,6 @@ import { } from 'src/plugins/embeddable/public'; import { Action, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public'; import { TimeRange } from '../../../../src/plugins/data/public'; -import { CustomizeTimeRangeModal } from './customize_time_range_modal'; import { OpenModal, CommonlyUsedRange } from './types'; export const CUSTOM_TIME_RANGE = 'CUSTOM_TIME_RANGE'; @@ -97,6 +96,9 @@ export class CustomTimeRangeAction implements Action { // Only here for typescript if (hasTimeRange(embeddable)) { + const CustomizeTimeRangeModal = await import('./customize_time_range_modal').then( + (m) => m.CustomizeTimeRangeModal + ); const modalSession = this.openModal( modalSession.close()} diff --git a/x-pack/plugins/ui_actions_enhanced/public/custom_time_range_badge.tsx b/x-pack/plugins/ui_actions_enhanced/public/custom_time_range_badge.tsx index 441381cd76fe1..28d936475f6b1 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/custom_time_range_badge.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/custom_time_range_badge.tsx @@ -10,7 +10,6 @@ import { prettyDuration, commonDurationRanges } from '@elastic/eui'; import { IEmbeddable, Embeddable, EmbeddableInput } from 'src/plugins/embeddable/public'; import { Action, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public'; import { TimeRange } from '../../../../src/plugins/data/public'; -import { CustomizeTimeRangeModal } from './customize_time_range_modal'; import { doesInheritTimeRange } from './does_inherit_time_range'; import { OpenModal, CommonlyUsedRange } from './types'; @@ -77,6 +76,9 @@ export class CustomTimeRangeBadge implements Action { // Only here for typescript if (hasTimeRange(embeddable)) { + const CustomizeTimeRangeModal = await import('./customize_time_range_modal').then( + (m) => m.CustomizeTimeRangeModal + ); const modalSession = this.openModal( modalSession.close()} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/create_public_drilldown_manager.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/create_public_drilldown_manager.tsx index 6b7d8a7a19360..17251958abde0 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/create_public_drilldown_manager.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/create_public_drilldown_manager.tsx @@ -7,11 +7,15 @@ import * as React from 'react'; import { DrilldownManagerDependencies, PublicDrilldownManagerProps } from '../../types'; -import { DrilldownManagerProvider } from '../context'; -import { DrilldownManager } from './drilldown_manager'; export type PublicDrilldownManagerComponent = React.FC; +const LazyDrilldownManager = React.lazy(() => + import('./drilldown_manager_with_provider').then((m) => ({ + default: m.DrilldownManagerWithProvider, + })) +); + /** * This HOC creates a "public" `` component `PublicDrilldownManagerComponent`, * which can be exported from plugin contract for other plugins to consume. @@ -21,9 +25,9 @@ export const createPublicDrilldownManager = ( ): PublicDrilldownManagerComponent => { const PublicDrilldownManager: PublicDrilldownManagerComponent = (drilldownManagerProps) => { return ( - - - + + + ); }; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/drilldown_manager_with_provider.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/drilldown_manager_with_provider.tsx new file mode 100644 index 0000000000000..6f67a91f3feaa --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/drilldown_manager_with_provider.tsx @@ -0,0 +1,18 @@ +/* + * 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 React from 'react'; +import { DrilldownManagerProvider, DrilldownManagerProviderProps } from '../context'; +import { DrilldownManager } from './drilldown_manager'; + +export const DrilldownManagerWithProvider: React.FC = (props) => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/index.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/index.ts index fd2b7adf3e4bc..82cb861d496b9 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/index.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/drilldown_manager/index.ts @@ -5,5 +5,4 @@ * 2.0. */ -export * from './drilldown_manager'; export * from './create_public_drilldown_manager'; diff --git a/x-pack/plugins/ui_actions_enhanced/public/index.ts b/x-pack/plugins/ui_actions_enhanced/public/index.ts index 303734c747289..3135cf44a7aa9 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/index.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/index.ts @@ -18,7 +18,6 @@ export { StartContract as AdvancedUiActionsStart, } from './plugin'; -export { ActionWizard } from './components'; export { ActionFactoryDefinition as UiActionsEnhancedActionFactoryDefinition, ActionFactory as UiActionsEnhancedActionFactory, From 0f9813431d25992f83de55640ed1c4ce1e917104 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 23 Sep 2021 17:23:30 +0200 Subject: [PATCH 3/4] reduce limits --- packages/kbn-optimizer/limits.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 7b4dd60190865..d4e1a2b36be74 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -73,7 +73,7 @@ pageLoadAssetSize: transform: 41007 triggersActionsUi: 100000 uiActions: 97717 - uiActionsEnhanced: 60000 + uiActionsEnhanced: 32000 upgradeAssistant: 81241 uptime: 40825 urlDrilldown: 70674 From 96b9151b326ed6ecd93f93f1f7885e625ce28d4d Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Mon, 27 Sep 2021 11:38:30 +0200 Subject: [PATCH 4/4] make all hb related code lazy --- .../drilldowns/url_drilldown/handlebars.ts | 130 ++++++++++++++++ .../drilldowns/url_drilldown/url_template.ts | 142 +----------------- 2 files changed, 137 insertions(+), 135 deletions(-) create mode 100644 x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/handlebars.ts diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/handlebars.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/handlebars.ts new file mode 100644 index 0000000000000..3f831bc5c9057 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/handlebars.ts @@ -0,0 +1,130 @@ +/* + * 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 { create as createHandlebars, HelperDelegate, HelperOptions } from 'handlebars'; +import { encode, RisonValue } from 'rison-node'; +import dateMath from '@elastic/datemath'; +import moment, { Moment } from 'moment'; +import numeral from '@elastic/numeral'; +import { url } from '../../../../../../src/plugins/kibana_utils/public'; + +const handlebars = createHandlebars(); + +function createSerializationHelper( + fnName: string, + serializeFn: (value: unknown) => string +): HelperDelegate { + return (...args) => { + const { hash } = args.slice(-1)[0] as HelperOptions; + const hasHash = Object.keys(hash).length > 0; + const hasValues = args.length > 1; + if (hasHash && hasValues) { + throw new Error(`[${fnName}]: both value list and hash are not supported`); + } + if (hasHash) { + if (Object.values(hash).some((v) => typeof v === 'undefined')) + throw new Error(`[${fnName}]: unknown variable`); + return serializeFn(hash); + } else { + const values = args.slice(0, -1) as unknown[]; + if (values.some((value) => typeof value === 'undefined')) + throw new Error(`[${fnName}]: unknown variable`); + if (values.length === 0) throw new Error(`[${fnName}]: unknown variable`); + if (values.length === 1) return serializeFn(values[0]); + return serializeFn(values); + } + }; +} + +handlebars.registerHelper('json', createSerializationHelper('json', JSON.stringify)); +handlebars.registerHelper( + 'rison', + createSerializationHelper('rison', (v) => encode(v as RisonValue)) +); + +handlebars.registerHelper('date', (...args) => { + const values = args.slice(0, -1) as [string | Date, string | undefined]; + // eslint-disable-next-line prefer-const + let [date, format] = values; + if (typeof date === 'undefined') throw new Error(`[date]: unknown variable`); + let momentDate: Moment | undefined; + if (typeof date === 'string') { + momentDate = dateMath.parse(date); + if (!momentDate || !momentDate.isValid()) { + const ts = Number(date); + if (!Number.isNaN(ts)) { + momentDate = moment(ts); + } + } + } else { + momentDate = moment(date); + } + + if (!momentDate || !momentDate.isValid()) { + // do not throw error here, because it could be that in preview `__testValue__` is not parsable, + // but in runtime it is + return date; + } + 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); +}); + +handlebars.registerHelper('encodeURIComponent', (component: unknown) => { + const str = String(component); + return encodeURIComponent(str); +}); +handlebars.registerHelper('encodeURIQuery', (component: unknown) => { + const str = String(component); + return url.encodeUriQuery(str); +}); + +export { handlebars }; 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 bc90b048e97e9..cccc7ac8b68dd 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 @@ -5,146 +5,18 @@ * 2.0. */ -import type { HelperDelegate, HelperOptions } from 'handlebars'; -import type * as Handlebars from 'handlebars'; -import { encode, RisonValue } from 'rison-node'; -import dateMath from '@elastic/datemath'; -import moment, { Moment } from 'moment'; -import numeral from '@elastic/numeral'; -import { url } from '../../../../../../src/plugins/kibana_utils/public'; - -let handlebarsPromise: Promise; -async function getHandlebars(): Promise { - if (handlebarsPromise) return handlebarsPromise; - return (handlebarsPromise = import('handlebars').then((handlebarsLib) => { - const handlebars = handlebarsLib.create(); - handlebars.registerHelper('json', createSerializationHelper('json', JSON.stringify)); - handlebars.registerHelper( - 'rison', - createSerializationHelper('rison', (v) => encode(v as RisonValue)) - ); - - handlebars.registerHelper('date', (...args) => { - const values = args.slice(0, -1) as [string | Date, string | undefined]; - // eslint-disable-next-line prefer-const - let [date, format] = values; - if (typeof date === 'undefined') throw new Error(`[date]: unknown variable`); - let momentDate: Moment | undefined; - if (typeof date === 'string') { - momentDate = dateMath.parse(date); - if (!momentDate || !momentDate.isValid()) { - const ts = Number(date); - if (!Number.isNaN(ts)) { - momentDate = moment(ts); - } - } - } else { - momentDate = moment(date); - } - - if (!momentDate || !momentDate.isValid()) { - // do not throw error here, because it could be that in preview `__testValue__` is not parsable, - // but in runtime it is - return date; - } - 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); - }); - - handlebars.registerHelper('encodeURIComponent', (component: unknown) => { - const str = String(component); - return encodeURIComponent(str); - }); - handlebars.registerHelper('encodeURIQuery', (component: unknown) => { - const str = String(component); - return url.encodeUriQuery(str); - }); - - return handlebars; - })); - - function createSerializationHelper( - fnName: string, - serializeFn: (value: unknown) => string - ): HelperDelegate { - return (...args) => { - const { hash } = args.slice(-1)[0] as HelperOptions; - const hasHash = Object.keys(hash).length > 0; - const hasValues = args.length > 1; - if (hasHash && hasValues) { - throw new Error(`[${fnName}]: both value list and hash are not supported`); - } - if (hasHash) { - if (Object.values(hash).some((v) => typeof v === 'undefined')) - throw new Error(`[${fnName}]: unknown variable`); - return serializeFn(hash); - } else { - const values = args.slice(0, -1) as unknown[]; - if (values.some((value) => typeof value === 'undefined')) - throw new Error(`[${fnName}]: unknown variable`); - if (values.length === 0) throw new Error(`[${fnName}]: unknown variable`); - if (values.length === 1) return serializeFn(values[0]); - return serializeFn(values); - } - }; - } -} - export async function compile( urlTemplate: string, context: object, doEncode: boolean = true ): Promise { - const handlebarsTemplate = (await getHandlebars()).compile(urlTemplate, { - strict: true, - noEscape: true, - }); + const handlebarsTemplate = (await import('./handlebars').then((m) => m.handlebars)).compile( + urlTemplate, + { + strict: true, + noEscape: true, + } + ); let processedUrl: string = handlebarsTemplate(context);