[uiActionsEnhanced] reduce bundle size (#112956)

This commit is contained in:
Anton Dosov 2021-09-27 14:00:48 +02:00 committed by GitHub
parent 5f264441f3
commit ac0fd7ced6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 327 additions and 253 deletions

View file

@ -73,7 +73,7 @@ pageLoadAssetSize:
transform: 41007
triggersActionsUi: 100000
uiActions: 97717
uiActionsEnhanced: 313011
uiActionsEnhanced: 32000
upgradeAssistant: 81241
uptime: 40825
urlDrilldown: 70674

View file

@ -129,7 +129,7 @@ export class UrlDrilldown implements Drilldown<Config, ActionContext, ActionFact
public readonly isCompatible = async (config: Config, context: ActionContext) => {
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<Config, ActionContext, ActionFact
return false;
}
const url = this.buildUrl(config, context);
const url = await this.buildUrl(config, context);
const validUrl = this.deps.externalUrl.validateUrl(url);
if (!validUrl) {
return false;
@ -148,9 +148,9 @@ export class UrlDrilldown implements Drilldown<Config, ActionContext, ActionFact
return true;
};
private buildUrl(config: Config, context: ActionContext): string {
private async buildUrl(config: Config, context: ActionContext): Promise<string> {
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<Config, ActionContext, ActionFact
}
public readonly getHref = async (config: Config, context: ActionContext): Promise<string> => {
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(

View file

@ -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<TimeRangeActionContext> {
// Only here for typescript
if (hasTimeRange(embeddable)) {
const CustomizeTimeRangeModal = await import('./customize_time_range_modal').then(
(m) => m.CustomizeTimeRangeModal
);
const modalSession = this.openModal(
<CustomizeTimeRangeModal
onClose={() => modalSession.close()}

View file

@ -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<TimeBadgeActionContext> {
// Only here for typescript
if (hasTimeRange(embeddable)) {
const CustomizeTimeRangeModal = await import('./customize_time_range_modal').then(
(m) => m.CustomizeTimeRangeModal
);
const modalSession = this.openModal(
<CustomizeTimeRangeModal
onClose={() => modalSession.close()}

View file

@ -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<PublicDrilldownManagerProps>;
const LazyDrilldownManager = React.lazy(() =>
import('./drilldown_manager_with_provider').then((m) => ({
default: m.DrilldownManagerWithProvider,
}))
);
/**
* This HOC creates a "public" `<DrilldownManager>` 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 (
<DrilldownManagerProvider {...dependencies} {...drilldownManagerProps}>
<DrilldownManager />
</DrilldownManagerProvider>
<React.Suspense fallback={null}>
<LazyDrilldownManager {...dependencies} {...drilldownManagerProps} />
</React.Suspense>
);
};

View file

@ -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<DrilldownManagerProviderProps> = (props) => {
return (
<DrilldownManagerProvider {...props}>
<DrilldownManager />
</DrilldownManagerProvider>
);
};

View file

@ -5,5 +5,4 @@
* 2.0.
*/
export * from './drilldown_manager';
export * from './create_public_drilldown_manager';

View file

@ -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 };

View file

@ -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"`
);
});
});

View file

@ -5,130 +5,19 @@
* 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`);
export async function compile(
urlTemplate: string,
context: object,
doEncode: boolean = true
): Promise<string> {
const handlebarsTemplate = (await import('./handlebars').then((m) => m.handlebars)).compile(
urlTemplate,
{
strict: true,
noEscape: true,
}
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 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) {

View file

@ -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);
});
});

View file

@ -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 {

View file

@ -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';
@ -21,7 +18,6 @@ export {
StartContract as AdvancedUiActionsStart,
} from './plugin';
export { ActionWizard } from './components';
export {
ActionFactoryDefinition as UiActionsEnhancedActionFactoryDefinition,
ActionFactory as UiActionsEnhancedActionFactory,
@ -42,4 +38,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';