From a00a9afa279a8e97302fdb251f3e65c784993fc0 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Mon, 4 Nov 2024 15:16:11 +0100 Subject: [PATCH] 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 ![Screenshot 2024-10-24 at 12 02 25](https://github.com/user-attachments/assets/2e33ad91-2425-417d-b44f-faff74fccbab) 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 Screenshot 2024-10-24 at 15 35 01 Screenshot 2024-10-24 at 15 35 08 This is again, not ideal, but slighltly better then before (cherry picked from commit 014b956002fab12064e2ac729c09b1713ef8deac) --- .../ui_actions_enhanced/.eslintrc.json | 5 - .../ui_actions_enhanced/common/types.ts | 3 + .../components/action_wizard/test_data.tsx | 2 + .../url_drilldown_collect_config/i18n.ts | 54 +++---- .../url_drilldown_collect_config.tsx | 29 +++- .../public/drilldowns/url_drilldown/types.ts | 1 + .../url_drilldown/url_validation.ts | 38 +++-- ...illdown.test.ts => url_drilldown.test.tsx} | 130 ++++++++++++++--- .../public/lib/url_drilldown.tsx | 134 +++++++++++++----- .../translations/translations/fr-FR.json | 7 +- .../translations/translations/ja-JP.json | 7 +- .../translations/translations/zh-CN.json | 7 +- 12 files changed, 291 insertions(+), 126 deletions(-) delete mode 100644 src/plugins/ui_actions_enhanced/.eslintrc.json rename x-pack/plugins/drilldowns/url_drilldown/public/lib/{url_drilldown.test.ts => url_drilldown.test.tsx} (80%) diff --git a/src/plugins/ui_actions_enhanced/.eslintrc.json b/src/plugins/ui_actions_enhanced/.eslintrc.json deleted file mode 100644 index 2aab6c2d9093b..0000000000000 --- a/src/plugins/ui_actions_enhanced/.eslintrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "rules": { - "@typescript-eslint/consistent-type-definitions": 0 - } -} diff --git a/src/plugins/ui_actions_enhanced/common/types.ts b/src/plugins/ui_actions_enhanced/common/types.ts index 5086d0e541e97..ff60a9370c576 100644 --- a/src/plugins/ui_actions_enhanced/common/types.ts +++ b/src/plugins/ui_actions_enhanced/common/types.ts @@ -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 = { readonly factoryId: string; readonly name: string; @@ -20,12 +21,14 @@ export type SerializedAction /** * 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[]; }; diff --git a/src/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx b/src/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx index 2e4fd27948b8e..cfe7784ec99fd 100644 --- a/src/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx +++ b/src/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx @@ -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; diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts index 2c49b497e0c75..d357897c32395 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts @@ -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,24 +16,43 @@ export const txtUrlTemplateLabel = i18n.translate( } ); -export const txtUrlTemplateSyntaxHelpLinkText = i18n.translate( - 'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateSyntaxHelpLinkText', +export const txtEmptyErrorMessage = i18n.translate( + 'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateEmptyErrorMessage', { - defaultMessage: 'Syntax help', + defaultMessage: 'URL template is required.', } ); -export const txtUrlTemplatePreviewLabel = i18n.translate( - 'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLabel', +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: 'URL preview:', + defaultMessage: + 'To validate and test the URL template, save the configuration and use this drilldown from the panel.', } ); -export const txtUrlTemplatePreviewLinkText = i18n.translate( - 'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLinkText', +export const txtUrlTemplateSyntaxHelpLinkText = i18n.translate( + 'uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateSyntaxHelpLinkText', { - defaultMessage: 'Preview', + defaultMessage: 'Syntax help', } ); diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx index 60b8cc33c178f..fd9e78c37d981 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx @@ -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 } } 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 = ( - {txtUrlTemplateSyntaxHelpLinkText} - - ) + <> + {txtUrlTemplateSyntaxTestingHelpText}{' '} + {syntaxHelpDocsLink ? ( + + {txtUrlTemplateSyntaxHelpLinkText} + + ) : null} + } labelAppend={variablesDropdown} > diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts index 1deafe53db379..973fcb1c8ebbf 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts @@ -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; diff --git a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts index e95c32df56595..d3c3db4772bec 100644 --- a/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts +++ b/src/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts @@ -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, }; } } diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.tsx similarity index 80% rename from x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts rename to x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.tsx index f4631ea96b937..8eefae138b6c3 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.tsx @@ -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( + + ); + await waitFor(() => null); // wait for effects to complete + return { + getError: () => getByTestId('urlDrilldown-error'), + }; +}; + describe('UrlDrilldown', () => { const urlDrilldown = createDrilldown(); @@ -119,7 +135,73 @@ 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}}`, + }, + 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('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}}`, @@ -139,7 +221,8 @@ describe('UrlDrilldown', () => { await expect(result).resolves.toBe(true); }); - test('not compatible if url is invalid', async () => { + 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.`); }); }); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx index 1dd9c94ef329f..fed0542883611 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx @@ -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 { + UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext, UiActionsEnhancedDrilldownDefinition as Drilldown, - UrlDrilldownGlobalScope, - UrlDrilldownConfig, UrlDrilldownCollectConfig, - urlDrilldownValidateUrlTemplate, urlDrilldownCompileUrl, - UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext, + 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 { + if (apiCanAccessViewMode(context.embeddable)) { + return getInheritedViewMode(context.embeddable); + } + throw new Error('Cannot access view mode'); +}; + export class UrlDrilldown implements Drilldown { public readonly id = URL_DRILLDOWN; @@ -75,20 +88,39 @@ export class UrlDrilldown implements Drilldown; }> = ({ config, context }) => { const [title, setTitle] = React.useState(config.name); + const [error, setError] = React.useState(); 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 */ + + {title} + {/* note: ideally we'd use EuiIconTip for the error, but it doesn't play well with this context menu*/} + {error ? ( + + + {error} + + + ) : null} + + ); }; public readonly euiIcon = 'link'; @@ -140,53 +172,81 @@ export class UrlDrilldown implements Drilldown { - const scope = this.getRuntimeVariables(context); - const { isValid, error } = await urlDrilldownValidateUrlTemplate(config.url, scope); + const viewMode = getViewMode(context); - if (!isValid) { - // eslint-disable-next-line no-console - console.warn( - `UrlDrilldown [${config.url.template}] is not valid. Error [${error}]. Skipping execution.` - ); - return false; + if (viewMode === 'edit') { + // check if context is compatible by building the scope + const scope = this.getRuntimeVariables(context); + return !!scope; } - const url = await this.buildUrl(config, context); - const validUrl = this.deps.externalUrl.validateUrl(url); - if (!validUrl) { + try { + await this.buildUrl(config, context); + return true; + } catch (e) { + // eslint-disable-next-line no-console + console.warn(e); return false; } - - return true; }; private async buildUrl(config: Config, context: ChartActionContext): Promise { + 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 => { - 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 => { + 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 { diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index d18ad3d8a423a..06fcf655c5fbc 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -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", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index df62ec7f26d8c..00fc1cf2e1ed6 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -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": "比較", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 38a162c3af486..f5be9db3603e2 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -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": "比较",