mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[uiActionsEnhanced] reduce bundle size (#112956)
This commit is contained in:
parent
5f264441f3
commit
ac0fd7ced6
13 changed files with 327 additions and 253 deletions
|
@ -73,7 +73,7 @@ pageLoadAssetSize:
|
|||
transform: 41007
|
||||
triggersActionsUi: 100000
|
||||
uiActions: 97717
|
||||
uiActionsEnhanced: 313011
|
||||
uiActionsEnhanced: 32000
|
||||
upgradeAssistant: 81241
|
||||
uptime: 40825
|
||||
urlDrilldown: 70674
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -5,5 +5,4 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export * from './drilldown_manager';
|
||||
export * from './create_public_drilldown_manager';
|
||||
|
|
|
@ -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 };
|
|
@ -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"`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue