mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
Improve URL drilldown authoring experience (#197454)
## Summary close https://github.com/elastic/kibana/issues/163642 close https://github.com/elastic/kibana/issues/163641 As part of fix-it-week I picked up some of existing URL drilldown authoring issues hoping to improve it a bit with a low effort (we don't want to spend to much time on it). Current URL drilldown authoring experience is terrible mainly because there is no proper validation while creating the drilldown as we don't have the needed runtime context. In the initial version we had a preview but it was very limited and used "dummy" context and in some cases got in the way by blocking the "save" button for URLs that would have been valid in runtime. We simply removed the preview and validaiton on some point later, so you can create an URL drilldown only by trial and error. This is still the case in this PR, but it slightly improve the experience: Firstly, **ONLY IN EDIT MODE** instead of hidding "invalid" drilldowns, we're showing them now with an error. This helps to find broken drilldowns and address issues. fixes https://github.com/elastic/kibana/issues/163641  This is far from ideal, but this is better from what we have now. As for the error UI I wanted to use EuiIconTip, but it doesn't work well when inside that context menu because tooltip is shown below the menu. I didn't want to change zIndex as it might cause regressions in other places, so I went for this inline error truncated after 3 lines and the whole error is shown in native browser tooltip when hovered. The error is also printed in console In addition to that I've slightly improved the form editor experience - Show simple non-blocking validation error (if URL is missing or the pattern doesn't look like an URL) - Add a help text about how to test and properly validate <img width="576" alt="Screenshot 2024-10-24 at 15 35 01" src="https://github.com/user-attachments/assets/248b5c03-d445-4b30-97a2-a0a9d6a055a4"> <img width="547" alt="Screenshot 2024-10-24 at 15 35 08" src="https://github.com/user-attachments/assets/c265ee1c-94ec-4238-85fb-fc90f069f3b4"> This is again, not ideal, but slighltly better then before
This commit is contained in:
parent
0600309378
commit
014b956002
12 changed files with 299 additions and 134 deletions
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"rules": {
|
||||
"@typescript-eslint/consistent-type-definitions": 0
|
||||
}
|
||||
}
|
|
@ -11,6 +11,7 @@ import type { SerializableRecord } from '@kbn/utility-types';
|
|||
|
||||
export type BaseActionConfig = SerializableRecord;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
export type SerializedAction<Config extends BaseActionConfig = BaseActionConfig> = {
|
||||
readonly factoryId: string;
|
||||
readonly name: string;
|
||||
|
@ -20,12 +21,14 @@ export type SerializedAction<Config extends BaseActionConfig = BaseActionConfig>
|
|||
/**
|
||||
* Serialized representation of a triggers-action pair, used to persist in storage.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
export type SerializedEvent = {
|
||||
eventId: string;
|
||||
triggers: string[];
|
||||
action: SerializedAction;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
export type DynamicActionsState = {
|
||||
events: SerializedEvent[];
|
||||
};
|
||||
|
|
|
@ -25,6 +25,7 @@ export const dashboards = [
|
|||
{ id: 'dashboard2', title: 'Dashboard 2' },
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
type DashboardDrilldownConfig = {
|
||||
dashboardId?: string;
|
||||
useCurrentFilters: boolean;
|
||||
|
@ -119,6 +120,7 @@ export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactor
|
|||
getFeatureUsageStart: () => licensingMock.createStart().featureUsage,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
type UrlDrilldownConfig = {
|
||||
url: string;
|
||||
openInNewTab: boolean;
|
||||
|
|
|
@ -9,23 +9,6 @@
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const txtUrlTemplatePlaceholder = i18n.translate(
|
||||
'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplatePlaceholderText',
|
||||
{
|
||||
defaultMessage: 'Example: {exampleUrl}',
|
||||
values: {
|
||||
exampleUrl: 'https://www.my-url.com/?{{event.key}}={{event.value}}',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export const txtUrlPreviewHelpText = i18n.translate(
|
||||
'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewHelpText',
|
||||
{
|
||||
defaultMessage: `Please note that in preview '{{event.*}}' variables are substituted with dummy values.`,
|
||||
}
|
||||
);
|
||||
|
||||
export const txtUrlTemplateLabel = i18n.translate(
|
||||
'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateLabel',
|
||||
{
|
||||
|
@ -33,6 +16,39 @@ export const txtUrlTemplateLabel = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const txtEmptyErrorMessage = i18n.translate(
|
||||
'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateEmptyErrorMessage',
|
||||
{
|
||||
defaultMessage: 'URL template is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const txtInvalidFormatErrorMessage = ({
|
||||
error,
|
||||
example,
|
||||
}: {
|
||||
error: string;
|
||||
example: string;
|
||||
}) =>
|
||||
i18n.translate(
|
||||
'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateInvalidFormatErrorMessage',
|
||||
{
|
||||
defaultMessage: '{error} Example: {example}',
|
||||
values: {
|
||||
error,
|
||||
example,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export const txtUrlTemplateSyntaxTestingHelpText = i18n.translate(
|
||||
'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateSyntaxTestingHelpText',
|
||||
{
|
||||
defaultMessage:
|
||||
'To validate and test the URL template, save the configuration and use this drilldown from the panel.',
|
||||
}
|
||||
);
|
||||
|
||||
export const txtUrlTemplateSyntaxHelpLinkText = i18n.translate(
|
||||
'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateSyntaxHelpLinkText',
|
||||
{
|
||||
|
@ -40,20 +56,6 @@ export const txtUrlTemplateSyntaxHelpLinkText = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const txtUrlTemplatePreviewLabel = i18n.translate(
|
||||
'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLabel',
|
||||
{
|
||||
defaultMessage: 'URL preview:',
|
||||
}
|
||||
);
|
||||
|
||||
export const txtUrlTemplatePreviewLinkText = i18n.translate(
|
||||
'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLinkText',
|
||||
{
|
||||
defaultMessage: 'Preview',
|
||||
}
|
||||
);
|
||||
|
||||
export const txtUrlTemplateOpenInNewTab = i18n.translate(
|
||||
'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.openInNewTabLabel',
|
||||
{
|
||||
|
|
|
@ -17,10 +17,14 @@ import {
|
|||
txtUrlTemplateSyntaxHelpLinkText,
|
||||
txtUrlTemplateLabel,
|
||||
txtUrlTemplateAdditionalOptions,
|
||||
txtEmptyErrorMessage,
|
||||
txtInvalidFormatErrorMessage,
|
||||
txtUrlTemplateSyntaxTestingHelpText,
|
||||
} from './i18n';
|
||||
import { VariablePopover } from '../variable_popover';
|
||||
import { UrlDrilldownOptionsComponent } from './lazy';
|
||||
import { DEFAULT_URL_DRILLDOWN_OPTIONS } from '../../constants';
|
||||
import { validateUrl } from '../../url_validation';
|
||||
|
||||
export interface UrlDrilldownCollectConfigProps {
|
||||
config: UrlDrilldownConfig;
|
||||
|
@ -69,7 +73,16 @@ export const UrlDrilldownCollectConfig: React.FC<UrlDrilldownCollectConfigProps>
|
|||
}
|
||||
}
|
||||
const isEmpty = !urlTemplate;
|
||||
const isInvalid = !isPristine && isEmpty;
|
||||
|
||||
const isValidUrlFormat = validateUrl(urlTemplate);
|
||||
const isInvalid = !isPristine && (isEmpty || !isValidUrlFormat.isValid);
|
||||
|
||||
const invalidErrorMessage = isInvalid
|
||||
? isEmpty
|
||||
? txtEmptyErrorMessage
|
||||
: txtInvalidFormatErrorMessage({ error: isValidUrlFormat.error!, example: exampleUrl })
|
||||
: undefined;
|
||||
|
||||
const variablesDropdown = (
|
||||
<VariablePopover
|
||||
variables={variables}
|
||||
|
@ -91,14 +104,18 @@ export const UrlDrilldownCollectConfig: React.FC<UrlDrilldownCollectConfigProps>
|
|||
<EuiFormRow
|
||||
fullWidth
|
||||
isInvalid={isInvalid}
|
||||
error={invalidErrorMessage}
|
||||
className={'uaeUrlDrilldownCollectConfig__urlTemplateFormRow'}
|
||||
label={txtUrlTemplateLabel}
|
||||
helpText={
|
||||
syntaxHelpDocsLink && (
|
||||
<EuiLink external target={'_blank'} href={syntaxHelpDocsLink}>
|
||||
{txtUrlTemplateSyntaxHelpLinkText}
|
||||
</EuiLink>
|
||||
)
|
||||
<>
|
||||
{txtUrlTemplateSyntaxTestingHelpText}{' '}
|
||||
{syntaxHelpDocsLink ? (
|
||||
<EuiLink external target={'_blank'} href={syntaxHelpDocsLink}>
|
||||
{txtUrlTemplateSyntaxHelpLinkText}
|
||||
</EuiLink>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
labelAppend={variablesDropdown}
|
||||
>
|
||||
|
|
|
@ -14,6 +14,7 @@ export type UrlDrilldownConfig = {
|
|||
/**
|
||||
* User-configurable options for URL drilldowns
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
export type UrlDrilldownOptions = {
|
||||
openInNewTab: boolean;
|
||||
encodeUrl: boolean;
|
||||
|
|
|
@ -14,23 +14,24 @@ import { compile } from './url_template';
|
|||
const generalFormatError = i18n.translate(
|
||||
'uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatGeneralErrorMessage',
|
||||
{
|
||||
defaultMessage: 'Invalid format. Example: {exampleUrl}',
|
||||
values: {
|
||||
exampleUrl: 'https://www.my-url.com/?{{event.key}}={{event.value}}',
|
||||
},
|
||||
defaultMessage: 'Invalid URL format.',
|
||||
}
|
||||
);
|
||||
|
||||
const formatError = (message: string) =>
|
||||
i18n.translate('uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatErrorMessage', {
|
||||
defaultMessage: 'Invalid format: {message}',
|
||||
const compileError = (message: string) =>
|
||||
i18n.translate('uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlCompileErrorMessage', {
|
||||
defaultMessage: 'The URL template is not valid in the given context. {message}.',
|
||||
values: {
|
||||
message,
|
||||
message: message.replaceAll('[object Object]', 'context'),
|
||||
},
|
||||
});
|
||||
|
||||
const SAFE_URL_PATTERN = /^(?:(?:https?|mailto):|[^&:/?#]*(?:[/?#]|$))/gi;
|
||||
export function validateUrl(url: string): { isValid: boolean; error?: string } {
|
||||
export function validateUrl(url: string): {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
invalidUrl?: string;
|
||||
} {
|
||||
if (!url)
|
||||
return {
|
||||
isValid: false,
|
||||
|
@ -45,6 +46,7 @@ export function validateUrl(url: string): { isValid: boolean; error?: string } {
|
|||
return {
|
||||
isValid: false,
|
||||
error: generalFormatError,
|
||||
invalidUrl: url,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -52,20 +54,32 @@ export function validateUrl(url: string): { isValid: boolean; error?: string } {
|
|||
export async function validateUrlTemplate(
|
||||
urlTemplate: UrlDrilldownConfig['url'],
|
||||
scope: UrlDrilldownScope
|
||||
): Promise<{ isValid: boolean; error?: string }> {
|
||||
): Promise<{ isValid: boolean; error?: string; invalidUrl?: string }> {
|
||||
if (!urlTemplate.template)
|
||||
return {
|
||||
isValid: false,
|
||||
error: generalFormatError,
|
||||
};
|
||||
|
||||
let compiledUrl: string;
|
||||
|
||||
try {
|
||||
compiledUrl = await compile(urlTemplate.template, scope);
|
||||
} catch (e) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: compileError(e.message),
|
||||
invalidUrl: urlTemplate.template,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const compiledUrl = await compile(urlTemplate.template, scope);
|
||||
return validateUrl(compiledUrl);
|
||||
} catch (e) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: formatError(e.message),
|
||||
error: generalFormatError + ` ${e.message}.`,
|
||||
invalidUrl: compiledUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,19 +7,20 @@
|
|||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { IExternalUrl } from '@kbn/core/public';
|
||||
import { UrlDrilldown, Config } from './url_drilldown';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { Config, UrlDrilldown } from './url_drilldown';
|
||||
import {
|
||||
ValueClickContext,
|
||||
VALUE_CLICK_TRIGGER,
|
||||
SELECT_RANGE_TRIGGER,
|
||||
CONTEXT_MENU_TRIGGER,
|
||||
SELECT_RANGE_TRIGGER,
|
||||
VALUE_CLICK_TRIGGER,
|
||||
ValueClickContext,
|
||||
} from '@kbn/embeddable-plugin/public';
|
||||
import { DatatableColumnType } from '@kbn/expressions-plugin/common';
|
||||
import { of } from '@kbn/kibana-utils-plugin/common';
|
||||
import { createPoint, rowClickData } from './test/data';
|
||||
import { ROW_CLICK_TRIGGER } from '@kbn/ui-actions-plugin/public';
|
||||
import { settingsServiceMock } from '@kbn/core-ui-settings-browser-mocks';
|
||||
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
|
||||
import React from 'react';
|
||||
|
||||
const mockDataPoints = [
|
||||
{
|
||||
|
@ -61,6 +62,7 @@ const mockEmbeddableApi = {
|
|||
filters$: new BehaviorSubject([]),
|
||||
query$: new BehaviorSubject({ query: 'test', language: 'kuery' }),
|
||||
timeRange$: new BehaviorSubject({ from: 'now-15m', to: 'now' }),
|
||||
viewMode: new BehaviorSubject('edit'),
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -93,6 +95,20 @@ const createDrilldown = (isExternalUrlValid: boolean = true) => {
|
|||
return drilldown;
|
||||
};
|
||||
|
||||
const renderActionMenuItem = async (
|
||||
drilldown: UrlDrilldown,
|
||||
config: Config,
|
||||
context: ValueClickContext
|
||||
) => {
|
||||
const { getByTestId } = render(
|
||||
<drilldown.actionMenuItem config={{ name: 'test', config }} context={context} />
|
||||
);
|
||||
await waitFor(() => null); // wait for effects to complete
|
||||
return {
|
||||
getError: () => getByTestId('urlDrilldown-error'),
|
||||
};
|
||||
};
|
||||
|
||||
describe('UrlDrilldown', () => {
|
||||
const urlDrilldown = createDrilldown();
|
||||
|
||||
|
@ -119,7 +135,7 @@ describe('UrlDrilldown', () => {
|
|||
await expect(urlDrilldown.isCompatible(config, context)).rejects.toThrowError();
|
||||
});
|
||||
|
||||
test('compatible if url is valid', async () => {
|
||||
test('compatible in edit mode if url is valid', async () => {
|
||||
const config: Config = {
|
||||
url: {
|
||||
template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`,
|
||||
|
@ -139,7 +155,74 @@ describe('UrlDrilldown', () => {
|
|||
await expect(result).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('not compatible if url is invalid', async () => {
|
||||
test('compatible in edit mode if url is invalid', async () => {
|
||||
const config: Config = {
|
||||
url: {
|
||||
template: `https://elasti.co/?{{event.value}}&{{rison context.panel.somethingFake}}`,
|
||||
},
|
||||
openInNewTab: false,
|
||||
encodeUrl: true,
|
||||
};
|
||||
|
||||
const context: ValueClickContext = {
|
||||
data: {
|
||||
data: mockDataPoints,
|
||||
},
|
||||
embeddable: mockEmbeddableApi,
|
||||
};
|
||||
|
||||
await expect(urlDrilldown.isCompatible(config, context)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('compatible in edit mode if external URL is denied', async () => {
|
||||
const drilldown1 = createDrilldown(true);
|
||||
const drilldown2 = createDrilldown(false);
|
||||
const config: Config = {
|
||||
url: {
|
||||
template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`,
|
||||
},
|
||||
openInNewTab: false,
|
||||
encodeUrl: true,
|
||||
};
|
||||
|
||||
const context: ValueClickContext = {
|
||||
data: {
|
||||
data: mockDataPoints,
|
||||
},
|
||||
embeddable: mockEmbeddableApi,
|
||||
};
|
||||
|
||||
const result1 = await drilldown1.isCompatible(config, context);
|
||||
const result2 = await drilldown2.isCompatible(config, context);
|
||||
|
||||
expect(result1).toBe(true);
|
||||
expect(result2).toBe(true);
|
||||
});
|
||||
|
||||
test('compatible in view mode if url is valid', async () => {
|
||||
mockEmbeddableApi.parentApi.viewMode.next('view');
|
||||
|
||||
const config: Config = {
|
||||
url: {
|
||||
template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`,
|
||||
},
|
||||
openInNewTab: false,
|
||||
encodeUrl: true,
|
||||
};
|
||||
|
||||
const context: ValueClickContext = {
|
||||
data: {
|
||||
data: mockDataPoints,
|
||||
},
|
||||
embeddable: mockEmbeddableApi,
|
||||
};
|
||||
|
||||
const result = urlDrilldown.isCompatible(config, context);
|
||||
await expect(result).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('not compatible in view mode if url is invalid', async () => {
|
||||
mockEmbeddableApi.parentApi.viewMode.next('view');
|
||||
const config: Config = {
|
||||
url: {
|
||||
template: `https://elasti.co/?{{event.value}}&{{rison context.panel.somethingFake}}`,
|
||||
|
@ -158,7 +241,8 @@ describe('UrlDrilldown', () => {
|
|||
await expect(urlDrilldown.isCompatible(config, context)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('not compatible if external URL is denied', async () => {
|
||||
test('not compatible in view mode if external URL is denied', async () => {
|
||||
mockEmbeddableApi.parentApi.viewMode.next('view');
|
||||
const drilldown1 = createDrilldown(true);
|
||||
const drilldown2 = createDrilldown(false);
|
||||
const config: Config = {
|
||||
|
@ -184,7 +268,7 @@ describe('UrlDrilldown', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getHref & execute', () => {
|
||||
describe('getHref & execute & title', () => {
|
||||
beforeEach(() => {
|
||||
mockNavigateToUrl.mockReset();
|
||||
});
|
||||
|
@ -210,6 +294,9 @@ describe('UrlDrilldown', () => {
|
|||
|
||||
await urlDrilldown.execute(config, context);
|
||||
expect(mockNavigateToUrl).toBeCalledWith(url);
|
||||
|
||||
const { getError } = await renderActionMenuItem(urlDrilldown, config, context);
|
||||
expect(() => getError()).toThrow();
|
||||
});
|
||||
|
||||
test('invalid url', async () => {
|
||||
|
@ -228,12 +315,17 @@ describe('UrlDrilldown', () => {
|
|||
embeddable: mockEmbeddableApi,
|
||||
};
|
||||
|
||||
await expect(urlDrilldown.getHref(config, context)).rejects.toThrowError();
|
||||
await expect(urlDrilldown.execute(config, context)).rejects.toThrowError();
|
||||
await expect(urlDrilldown.getHref(config, context)).resolves.toBeUndefined();
|
||||
await expect(urlDrilldown.execute(config, context)).resolves.toBeUndefined();
|
||||
expect(mockNavigateToUrl).not.toBeCalled();
|
||||
|
||||
const { getError } = await renderActionMenuItem(urlDrilldown, config, context);
|
||||
expect(getError()).toHaveTextContent(
|
||||
`Error building URL: The URL template is not valid in the given context.`
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw on denied external URL', async () => {
|
||||
test('should not throw on denied external URL', async () => {
|
||||
const drilldown1 = createDrilldown(true);
|
||||
const drilldown2 = createDrilldown(false);
|
||||
const config: Config = {
|
||||
|
@ -257,17 +349,11 @@ describe('UrlDrilldown', () => {
|
|||
expect(url).toMatchInlineSnapshot(`"https://elasti.co/?test&(language:kuery,query:test)"`);
|
||||
expect(mockNavigateToUrl).toBeCalledWith(url);
|
||||
|
||||
const [, error1] = await of(drilldown2.getHref(config, context));
|
||||
const [, error2] = await of(drilldown2.execute(config, context));
|
||||
await expect(drilldown2.getHref(config, context)).resolves.toBeUndefined();
|
||||
await expect(drilldown2.execute(config, context)).resolves.toBeUndefined();
|
||||
|
||||
expect(error1).toBeInstanceOf(Error);
|
||||
expect(error1.message).toMatchInlineSnapshot(
|
||||
`"External URL [https://elasti.co/?test&(language:kuery,query:test)] was denied by ExternalUrl service. You can configure external URL policies using \\"externalUrl.policy\\" setting in kibana.yml."`
|
||||
);
|
||||
expect(error2).toBeInstanceOf(Error);
|
||||
expect(error2.message).toMatchInlineSnapshot(
|
||||
`"External URL [https://elasti.co/?test&(language:kuery,query:test)] was denied by ExternalUrl service. You can configure external URL policies using \\"externalUrl.policy\\" setting in kibana.yml."`
|
||||
);
|
||||
const { getError } = await renderActionMenuItem(drilldown2, config, context);
|
||||
expect(getError()).toHaveTextContent(`Error building URL: external URL was denied.`);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,7 +7,11 @@
|
|||
|
||||
import React from 'react';
|
||||
import { IExternalUrl, ThemeServiceStart } from '@kbn/core/public';
|
||||
import type { EmbeddableApiContext } from '@kbn/presentation-publishing';
|
||||
import {
|
||||
type EmbeddableApiContext,
|
||||
getInheritedViewMode,
|
||||
apiCanAccessViewMode,
|
||||
} from '@kbn/presentation-publishing';
|
||||
import {
|
||||
ChartActionContext,
|
||||
CONTEXT_MENU_TRIGGER,
|
||||
|
@ -17,21 +21,23 @@ import {
|
|||
import { IMAGE_CLICK_TRIGGER } from '@kbn/image-embeddable-plugin/public';
|
||||
import { ActionExecutionContext, ROW_CLICK_TRIGGER } from '@kbn/ui-actions-plugin/public';
|
||||
import type { CollectConfigProps as CollectConfigPropsBase } from '@kbn/kibana-utils-plugin/public';
|
||||
import { UrlTemplateEditorVariable, KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { KibanaContextProvider, UrlTemplateEditorVariable } from '@kbn/kibana-react-plugin/public';
|
||||
import {
|
||||
UiActionsEnhancedDrilldownDefinition as Drilldown,
|
||||
UrlDrilldownGlobalScope,
|
||||
UrlDrilldownConfig,
|
||||
UrlDrilldownCollectConfig,
|
||||
urlDrilldownValidateUrlTemplate,
|
||||
urlDrilldownCompileUrl,
|
||||
UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext,
|
||||
UiActionsEnhancedDrilldownDefinition as Drilldown,
|
||||
UrlDrilldownCollectConfig,
|
||||
urlDrilldownCompileUrl,
|
||||
UrlDrilldownConfig,
|
||||
UrlDrilldownGlobalScope,
|
||||
urlDrilldownValidateUrlTemplate,
|
||||
} from '@kbn/ui-actions-enhanced-plugin/public';
|
||||
import type { SerializedAction } from '@kbn/ui-actions-enhanced-plugin/common/types';
|
||||
import type { SettingsStart } from '@kbn/core-ui-settings-browser';
|
||||
import { EuiText, EuiTextBlockTruncate } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { txtUrlDrilldownDisplayName } from './i18n';
|
||||
import { getEventVariableList, getEventScopeValues } from './variables/event_variables';
|
||||
import { getContextVariableList, getContextScopeValues } from './variables/context_variables';
|
||||
import { getEventScopeValues, getEventVariableList } from './variables/event_variables';
|
||||
import { getContextScopeValues, getContextVariableList } from './variables/context_variables';
|
||||
import { getGlobalVariableList } from './variables/global_variables';
|
||||
|
||||
interface UrlDrilldownDeps {
|
||||
|
@ -58,6 +64,13 @@ export type CollectConfigProps = CollectConfigPropsBase<Config, ActionFactoryCon
|
|||
|
||||
const URL_DRILLDOWN = 'URL_DRILLDOWN';
|
||||
|
||||
const getViewMode = (context: ChartActionContext) => {
|
||||
if (apiCanAccessViewMode(context.embeddable)) {
|
||||
return getInheritedViewMode(context.embeddable);
|
||||
}
|
||||
throw new Error('Cannot access view mode');
|
||||
};
|
||||
|
||||
export class UrlDrilldown implements Drilldown<Config, ChartActionContext, ActionFactoryContext> {
|
||||
public readonly id = URL_DRILLDOWN;
|
||||
|
||||
|
@ -75,20 +88,39 @@ export class UrlDrilldown implements Drilldown<Config, ChartActionContext, Actio
|
|||
context: ChartActionContext | ActionExecutionContext<ChartActionContext>;
|
||||
}> = ({ config, context }) => {
|
||||
const [title, setTitle] = React.useState(config.name);
|
||||
const [error, setError] = React.useState<string | undefined>();
|
||||
React.useEffect(() => {
|
||||
let unmounted = false;
|
||||
const variables = this.getRuntimeVariables(context);
|
||||
urlDrilldownCompileUrl(title, variables, false)
|
||||
.then((result) => {
|
||||
if (unmounted) return;
|
||||
if (title !== result) setTitle(result);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
unmounted = true;
|
||||
};
|
||||
});
|
||||
return <>{title}</>;
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
this.buildUrl(config.config, context).catch((e) => {
|
||||
setError(e.message);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
/* title is used as a tooltip, EuiToolTip doesn't work in this context menu due to hacky zIndex */
|
||||
<span title={error}>
|
||||
{title}
|
||||
{/* note: ideally we'd use EuiIconTip for the error, but it doesn't play well with this context menu*/}
|
||||
{error ? (
|
||||
<EuiText color={'danger'} size={'xs'}>
|
||||
<EuiTextBlockTruncate lines={3} data-test-subj={'urlDrilldown-error'}>
|
||||
{error}
|
||||
</EuiTextBlockTruncate>
|
||||
</EuiText>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
public readonly euiIcon = 'link';
|
||||
|
@ -140,53 +172,81 @@ export class UrlDrilldown implements Drilldown<Config, ChartActionContext, Actio
|
|||
};
|
||||
|
||||
public readonly isCompatible = async (config: Config, context: ChartActionContext) => {
|
||||
const scope = this.getRuntimeVariables(context);
|
||||
const { isValid, error } = await urlDrilldownValidateUrlTemplate(config.url, scope);
|
||||
const viewMode = getViewMode(context);
|
||||
|
||||
if (!isValid) {
|
||||
if (viewMode === 'edit') {
|
||||
// check if context is compatible by building the scope
|
||||
const scope = this.getRuntimeVariables(context);
|
||||
return !!scope;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.buildUrl(config, context);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`UrlDrilldown [${config.url.template}] is not valid. Error [${error}]. Skipping execution.`
|
||||
);
|
||||
console.warn(e);
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = await this.buildUrl(config, context);
|
||||
const validUrl = this.deps.externalUrl.validateUrl(url);
|
||||
if (!validUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
private async buildUrl(config: Config, context: ChartActionContext): Promise<string> {
|
||||
const scope = this.getRuntimeVariables(context);
|
||||
const { isValid, error, invalidUrl } = await urlDrilldownValidateUrlTemplate(config.url, scope);
|
||||
|
||||
if (!isValid) {
|
||||
const errorMessage = i18n.translate('xpack.urlDrilldown.invalidUrlErrorMessage', {
|
||||
defaultMessage:
|
||||
'Error building URL: {error} Use drilldown editor to check your URL template. Invalid URL: {invalidUrl}',
|
||||
values: {
|
||||
error,
|
||||
invalidUrl,
|
||||
},
|
||||
});
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const doEncode = config.encodeUrl ?? true;
|
||||
|
||||
const url = await urlDrilldownCompileUrl(
|
||||
config.url.template,
|
||||
this.getRuntimeVariables(context),
|
||||
doEncode
|
||||
);
|
||||
|
||||
const validUrl = this.deps.externalUrl.validateUrl(url);
|
||||
if (!validUrl) {
|
||||
const errorMessage = i18n.translate('xpack.urlDrilldown.invalidUrlErrorMessage', {
|
||||
defaultMessage:
|
||||
'Error building URL: external URL was denied. Administrator can configure external URL policies using "externalUrl.policy" setting in kibana.yml. Invalid URL: {invalidUrl}',
|
||||
values: {
|
||||
invalidUrl: url,
|
||||
},
|
||||
});
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
public readonly getHref = async (
|
||||
config: Config,
|
||||
context: ChartActionContext
|
||||
): Promise<string> => {
|
||||
const url = await this.buildUrl(config, context);
|
||||
const validUrl = this.deps.externalUrl.validateUrl(url);
|
||||
if (!validUrl) {
|
||||
throw new Error(
|
||||
`External URL [${url}] was denied by ExternalUrl service. ` +
|
||||
`You can configure external URL policies using "externalUrl.policy" setting in kibana.yml.`
|
||||
);
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const url = await this.buildUrl(config, context);
|
||||
return url;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(e);
|
||||
return undefined;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
public readonly execute = async (config: Config, context: ChartActionContext) => {
|
||||
const url = await this.getHref(config, context);
|
||||
if (!url) return;
|
||||
|
||||
if (config.openInNewTab) {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
} else {
|
||||
|
|
|
@ -7510,16 +7510,11 @@
|
|||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.encodeDescription": "Si elle est activée, l'URL sera précédée de l’encodage-pourcent comme caractère d'échappement",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.encodeUrl": "Encoder l'URL",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.openInNewTabLabel": "Ouvrir l'URL dans un nouvel onglet",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewHelpText": "Veuillez noter que dans l'aperçu, les variables '{{event.*}}' sont remplacées par des valeurs factices.",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLabel": "Aperçu de l'URL :",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLinkText": "Aperçu",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateLabel": "Entrer l'URL",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplatePlaceholderText": "Exemple : {exampleUrl}",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateSyntaxHelpLinkText": "Aide pour la syntaxe",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesFilterPlaceholderText": "Variables de filtre",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesHelpLinkText": "Aide",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatErrorMessage": "Format non valide : {message}",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatGeneralErrorMessage": "Format non valide. Exemple : {exampleUrl}",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatGeneralErrorMessage": "Format non valide.",
|
||||
"unifiedDataTable.advancedDiffModesTooltip": "Les modes avancés offrent des capacités de diffraction améliorées, mais ils fonctionnent sur des documents bruts et ne prennent donc pas en charge le formatage des champs.",
|
||||
"unifiedDataTable.clearSelection": "Effacer la sélection",
|
||||
"unifiedDataTable.compareSelectedRowsButtonLabel": "Comparer",
|
||||
|
|
|
@ -7264,16 +7264,11 @@
|
|||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.encodeDescription": "有効な場合、URLはパーセントエンコーディングを使用してエスケープされます",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.encodeUrl": "URLのエンコード",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.openInNewTabLabel": "URLを新しいタブで開く",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewHelpText": "プレビュー'{{event.*}}'では、変数にダミー値が代入されます。",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLabel": "URLプレビュー:",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLinkText": "プレビュー",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateLabel": "URLを入力",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplatePlaceholderText": "例:{exampleUrl}",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateSyntaxHelpLinkText": "構文ヘルプ",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesFilterPlaceholderText": "変数をフィルター",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesHelpLinkText": "ヘルプ",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatErrorMessage": "無効な形式:{message}",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatGeneralErrorMessage": "無効なフォーマット。例:{exampleUrl}",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatGeneralErrorMessage": "無効なフォーマット。",
|
||||
"unifiedDataTable.advancedDiffModesTooltip": "高度なモードでは、拡張差異機能を利用できますが、未加工ドキュメントで動作するため、フィールド書式設定はサポートされません。",
|
||||
"unifiedDataTable.clearSelection": "選択した項目をクリア",
|
||||
"unifiedDataTable.compareSelectedRowsButtonLabel": "比較",
|
||||
|
|
|
@ -7280,16 +7280,11 @@
|
|||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.encodeDescription": "如果启用,将使用百分比编码转义 URL",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.encodeUrl": "编码 URL",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.openInNewTabLabel": "在新选项卡中打开 URL",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewHelpText": "请注意,在预览模式下,'{{event.*}}' 变量将替换为虚拟值。",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLabel": "URL 预览:",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLinkText": "预览",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateLabel": "输入 URL",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplatePlaceholderText": "例如:{exampleUrl}",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateSyntaxHelpLinkText": "语法帮助",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesFilterPlaceholderText": "筛选变量",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesHelpLinkText": "帮助",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatErrorMessage": "格式无效:{message}",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatGeneralErrorMessage": "格式无效。例如:{exampleUrl}",
|
||||
"uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatGeneralErrorMessage": "格式无效。",
|
||||
"unifiedDataTable.advancedDiffModesTooltip": "高级模式提供了增强型差异功能,但在原始文档上运行,因此不支持字段格式化。",
|
||||
"unifiedDataTable.clearSelection": "清除所选内容",
|
||||
"unifiedDataTable.compareSelectedRowsButtonLabel": "比较",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue