[uiActions] make trigger action registry async (#205512)

Closes https://github.com/elastic/kibana/issues/191642, part of
https://github.com/elastic/kibana/pull/205512

PR makes the following changes to uiActions API.
* Deprecates `registerAction` and `addTriggerAction` and replaces them
with `registerActionAsync` and `addTriggerActionAsync`
* Makes all registry consumption methods async, such as `getAction`,
`getTriggerActions` and `getFrequentlyChangingActionsForTrigger`

PR updates presentation_panel plugin to use `registerActionAsync`. With
actions behind async import, page load bundle size has been reduced by
21.1KB.

<img width="500" alt="Screenshot 2025-01-08 at 2 14 23 PM"
src="https://github.com/user-attachments/assets/34a2cae9-dc5e-429b-bbdb-ffd9dfe1cce3"
/>

Also, async exports are [contained in a single file
`panel_module`](https://github.com/elastic/kibana/issues/206117). This
results in dashboard only loading one `presentationPanel.chunk`.

<img width="500" alt="Screenshot 2025-01-08 at 2 15 02 PM"
src="https://github.com/user-attachments/assets/e083b852-b50d-4fa7-8ebd-e2f56f85e998"
/>

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2025-01-13 09:57:34 -07:00 committed by GitHub
parent fe0bc34069
commit 1bd8c97982
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 528 additions and 321 deletions

View file

@ -24,36 +24,24 @@ export function AddButton({ pageApi, uiActions }: { pageApi: unknown; uiActions:
id: ADD_PANEL_TRIGGER,
},
};
const actionsPromises = uiActions.getTriggerActions(ADD_PANEL_TRIGGER).map(async (action) => {
return {
isCompatible: await action.isCompatible(actionContext),
action,
};
});
Promise.all(actionsPromises).then((actions) => {
if (cancelled) {
return;
}
uiActions.getTriggerCompatibleActions(ADD_PANEL_TRIGGER, actionContext).then((actions) => {
if (cancelled) return;
const nextItems = actions
.filter(
({ action, isCompatible }) => isCompatible && action.id !== 'ACTION_CREATE_ESQL_CHART'
)
.map(({ action }) => {
return (
<EuiContextMenuItem
key={action.id}
icon="share"
onClick={() => {
action.execute(actionContext);
setIsPopoverOpen(false);
}}
>
{action.getDisplayName(actionContext)}
</EuiContextMenuItem>
);
});
const nextItems = actions.map((action) => {
return (
<EuiContextMenuItem
key={action.id}
icon="share"
onClick={() => {
action.execute(actionContext);
setIsPopoverOpen(false);
}}
>
{action.getDisplayName(actionContext)}
</EuiContextMenuItem>
);
});
setItems(nextItems);
});

View file

@ -13,7 +13,8 @@ export function plugin() {
return new PresentationPanelPlugin();
}
export { getEditPanelAction, ACTION_CUSTOMIZE_PANEL } from './panel_actions';
export { ACTION_CUSTOMIZE_PANEL } from './panel_actions/customize_panel_action/constants';
export { ACTION_EDIT_PANEL } from './panel_actions/edit_panel_action/constants';
export { PresentationPanel } from './panel_component';
export type { PresentationPanelProps } from './panel_component/types';
export { PresentationPanelError } from './panel_component/presentation_panel_error';

View file

@ -9,8 +9,8 @@
import { BehaviorSubject } from 'rxjs';
import { CoreStart } from '@kbn/core/public';
import { PresentationPanelStartDependencies } from './plugin';
import type { CoreStart } from '@kbn/core/public';
import type { PresentationPanelStartDependencies } from './plugin';
export let core: CoreStart;
export let uiActions: PresentationPanelStartDependencies['uiActions'];

View file

@ -0,0 +1,11 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export const ACTION_CUSTOMIZE_PANEL = 'ACTION_CUSTOMIZE_PANEL';
export const CUSTOM_TIME_RANGE_BADGE = 'CUSTOM_TIME_RANGE_BADGE';

View file

@ -10,6 +10,7 @@
import { PrettyDuration } from '@elastic/eui';
import {
Action,
ActionExecutionMeta,
FrequentCompatibilityChangeAction,
IncompatibleActionError,
} from '@kbn/ui-actions-plugin/public';
@ -17,10 +18,8 @@ import React from 'react';
import { UI_SETTINGS } from '@kbn/data-plugin/common';
import { apiPublishesTimeRange, EmbeddableApiContext } from '@kbn/presentation-publishing';
import { core } from '../../kibana_services';
import { customizePanelAction } from '../panel_actions';
export const CUSTOM_TIME_RANGE_BADGE = 'CUSTOM_TIME_RANGE_BADGE';
import { ACTION_CUSTOMIZE_PANEL, CUSTOM_TIME_RANGE_BADGE } from './constants';
import { core, uiActions } from '../../kibana_services';
export class CustomTimeRangeBadge
implements Action<EmbeddableApiContext>, FrequentCompatibilityChangeAction<EmbeddableApiContext>
@ -69,8 +68,9 @@ export class CustomTimeRangeBadge
});
}
public async execute({ embeddable }: EmbeddableApiContext) {
customizePanelAction.execute({ embeddable });
public async execute(context: ActionExecutionMeta & EmbeddableApiContext) {
const action = await uiActions.getAction(ACTION_CUSTOMIZE_PANEL);
action.execute(context);
}
public getIconType() {

View file

@ -26,8 +26,7 @@ import {
} from '@kbn/presentation-publishing';
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { openCustomizePanelFlyout } from './open_customize_panel';
export const ACTION_CUSTOMIZE_PANEL = 'ACTION_CUSTOMIZE_PANEL';
import { ACTION_CUSTOMIZE_PANEL } from './constants';
export type CustomizePanelActionApi = CanAccessViewMode &
Partial<

View file

@ -16,8 +16,8 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { hasEditCapabilities } from '@kbn/presentation-publishing';
import { FilterItems } from '@kbn/unified-search-plugin/public';
import { editPanelAction } from '../panel_actions';
import { CustomizePanelActionApi } from './customize_panel_action';
import { executeEditPanelAction } from '../edit_panel_action/execute_edit_action';
export const filterDetailsActionStrings = {
getQueryTitle: () =>
@ -75,7 +75,7 @@ export function FiltersDetails({ editMode, api }: FiltersDetailsProps) {
<EuiButtonEmpty
size="xs"
data-test-subj="customizePanelEditQueryButton"
onClick={() => editPanelAction.execute({ embeddable: api })}
onClick={() => executeEditPanelAction(api)}
aria-label={i18n.translate(
'presentationPanel.action.customizePanel.flyout.optionsMenuForm.editQueryButtonAriaLabel',
{
@ -112,7 +112,7 @@ export function FiltersDetails({ editMode, api }: FiltersDetailsProps) {
<EuiButtonEmpty
size="xs"
data-test-subj="customizePanelEditFiltersButton"
onClick={() => editPanelAction.execute({ embeddable: api })}
onClick={() => executeEditPanelAction(api)}
aria-label={i18n.translate(
'presentationPanel.action.customizePanel.flyout.optionsMenuForm.editFiltersButtonAriaLabel',
{

View file

@ -0,0 +1,10 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export const ACTION_EDIT_PANEL = 'editPanel';

View file

@ -23,8 +23,7 @@ import {
FrequentCompatibilityChangeAction,
IncompatibleActionError,
} from '@kbn/ui-actions-plugin/public';
export const ACTION_EDIT_PANEL = 'editPanel';
import { ACTION_EDIT_PANEL } from './constants';
export type EditPanelActionApi = CanAccessViewMode & HasEditCapabilities;

View file

@ -0,0 +1,27 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { ActionExecutionMeta } from '@kbn/ui-actions-plugin/public';
import type { EmbeddableApiContext } from '@kbn/presentation-publishing';
import { CONTEXT_MENU_TRIGGER } from '../triggers';
import { ACTION_EDIT_PANEL } from './constants';
import { uiActions } from '../../kibana_services';
export async function executeEditPanelAction(api: unknown) {
try {
const action = await uiActions.getAction(ACTION_EDIT_PANEL);
action.execute({
embeddable: api,
trigger: { id: CONTEXT_MENU_TRIGGER },
} as EmbeddableApiContext & ActionExecutionMeta);
} catch (error) {
// eslint-disable-next-line no-console
console.warn('Unable to execute edit action, Error: ', error.message);
}
}

View file

@ -7,15 +7,6 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export {
ACTION_CUSTOMIZE_PANEL,
CustomizePanelAction,
CustomTimeRangeBadge,
} from './customize_panel_action';
export { EditPanelAction } from './edit_panel_action/edit_panel_action';
export { InspectPanelAction } from './inspect_panel_action/inspect_panel_action';
export { getEditPanelAction } from './panel_actions';
export { RemovePanelAction } from './remove_panel_action/remove_panel_action';
export {
contextMenuTrigger,
CONTEXT_MENU_TRIGGER,

View file

@ -0,0 +1,10 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export const ACTION_INSPECT_PANEL = 'openInspector';

View file

@ -17,10 +17,9 @@ import {
HasParentApi,
} from '@kbn/presentation-publishing';
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { ACTION_INSPECT_PANEL } from './constants';
import { inspector } from '../../kibana_services';
export const ACTION_INSPECT_PANEL = 'openInspector';
export type InspectPanelActionApi = HasInspectorAdapters &
Partial<PublishesPanelTitle & HasParentApi>;
const isApiCompatible = (api: unknown | null): api is InspectPanelActionApi => {

View file

@ -1,45 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { uiActions } from '../kibana_services';
import { CustomizePanelAction, CustomTimeRangeBadge } from './customize_panel_action';
import { EditPanelAction } from './edit_panel_action/edit_panel_action';
import { InspectPanelAction } from './inspect_panel_action/inspect_panel_action';
import { RemovePanelAction } from './remove_panel_action/remove_panel_action';
import { CONTEXT_MENU_TRIGGER, PANEL_BADGE_TRIGGER } from './triggers';
// export these actions to make them accessible in this plugin.
export let customizePanelAction: CustomizePanelAction;
export let editPanelAction: EditPanelAction;
export const getEditPanelAction = () => editPanelAction;
export const registerActions = () => {
editPanelAction = new EditPanelAction();
customizePanelAction = new CustomizePanelAction();
const removePanel = new RemovePanelAction();
const inspectPanel = new InspectPanelAction();
const timeRangeBadge = new CustomTimeRangeBadge();
uiActions.registerAction(removePanel);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, removePanel.id);
uiActions.registerAction(timeRangeBadge);
uiActions.attachAction(PANEL_BADGE_TRIGGER, timeRangeBadge.id);
uiActions.registerAction(inspectPanel);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, inspectPanel.id);
uiActions.registerAction(editPanelAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, editPanelAction.id);
uiActions.registerAction(customizePanelAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, customizePanelAction.id);
};

View file

@ -0,0 +1,50 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { uiActions } from '../kibana_services';
import { ACTION_EDIT_PANEL } from './edit_panel_action/constants';
import { ACTION_INSPECT_PANEL } from './inspect_panel_action/constants';
import { ACTION_REMOVE_PANEL } from './remove_panel_action/constants';
import {
ACTION_CUSTOMIZE_PANEL,
CUSTOM_TIME_RANGE_BADGE,
} from './customize_panel_action/constants';
import { CONTEXT_MENU_TRIGGER, PANEL_BADGE_TRIGGER } from './triggers';
export const registerActions = () => {
uiActions.registerActionAsync(ACTION_REMOVE_PANEL, async () => {
const { RemovePanelAction } = await import('../panel_component/panel_module');
return new RemovePanelAction();
});
uiActions.attachAction(CONTEXT_MENU_TRIGGER, ACTION_REMOVE_PANEL);
uiActions.registerActionAsync(CUSTOM_TIME_RANGE_BADGE, async () => {
const { CustomTimeRangeBadge } = await import('../panel_component/panel_module');
return new CustomTimeRangeBadge();
});
uiActions.attachAction(PANEL_BADGE_TRIGGER, CUSTOM_TIME_RANGE_BADGE);
uiActions.registerActionAsync(ACTION_INSPECT_PANEL, async () => {
const { InspectPanelAction } = await import('../panel_component/panel_module');
return new InspectPanelAction();
});
uiActions.attachAction(CONTEXT_MENU_TRIGGER, ACTION_INSPECT_PANEL);
uiActions.registerActionAsync(ACTION_EDIT_PANEL, async () => {
const { EditPanelAction } = await import('../panel_component/panel_module');
return new EditPanelAction();
});
uiActions.attachAction(CONTEXT_MENU_TRIGGER, ACTION_EDIT_PANEL);
uiActions.registerActionAsync(ACTION_CUSTOMIZE_PANEL, async () => {
const { CustomizePanelAction } = await import('../panel_component/panel_module');
return new CustomizePanelAction();
});
uiActions.attachAction(CONTEXT_MENU_TRIGGER, ACTION_CUSTOMIZE_PANEL);
};

View file

@ -0,0 +1,10 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export const ACTION_REMOVE_PANEL = 'deletePanel';

View file

@ -18,10 +18,8 @@ import {
PublishesViewMode,
} from '@kbn/presentation-publishing';
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { getContainerParentFromAPI, PresentationContainer } from '@kbn/presentation-containers';
export const ACTION_REMOVE_PANEL = 'deletePanel';
import { ACTION_REMOVE_PANEL } from './constants';
export type RemovePanelActionApi = PublishesViewMode &
HasUniqueId &

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
import type { EmbeddableApiContext } from '@kbn/presentation-publishing';
import { Action } from '@kbn/ui-actions-plugin/public';
export type AnyApiAction = Action<EmbeddableApiContext>;

View file

@ -243,10 +243,11 @@ export const PresentationPanelHoverActions = ({
(async () => {
// subscribe to any frequently changing context menu actions
const frequentlyChangingActions = uiActions.getFrequentlyChangingActionsForTrigger(
const frequentlyChangingActions = await uiActions.getFrequentlyChangingActionsForTrigger(
CONTEXT_MENU_TRIGGER,
apiContext
);
if (canceled) return;
for (const frequentlyChangingAction of frequentlyChangingActions) {
if ((quickActionIds as readonly string[]).includes(frequentlyChangingAction.id)) {
@ -265,10 +266,12 @@ export const PresentationPanelHoverActions = ({
}
// subscribe to any frequently changing notification actions
const frequentlyChangingNotifications = uiActions.getFrequentlyChangingActionsForTrigger(
PANEL_NOTIFICATION_TRIGGER,
apiContext
);
const frequentlyChangingNotifications =
await uiActions.getFrequentlyChangingActionsForTrigger(
PANEL_NOTIFICATION_TRIGGER,
apiContext
);
if (canceled) return;
for (const frequentlyChangingNotification of frequentlyChangingNotifications) {
if (

View file

@ -80,10 +80,11 @@ export const usePresentationPanelHeaderActions = <
const apiContext = { embeddable: api };
// subscribe to any frequently changing badge actions
const frequentlyChangingBadges = uiActions.getFrequentlyChangingActionsForTrigger(
const frequentlyChangingBadges = await uiActions.getFrequentlyChangingActionsForTrigger(
PANEL_BADGE_TRIGGER,
apiContext
);
if (canceled) return;
for (const badge of frequentlyChangingBadges) {
subscriptions.add(
badge.subscribeToCompatibilityChanges(apiContext, (isCompatible, action) =>
@ -93,10 +94,12 @@ export const usePresentationPanelHeaderActions = <
}
// subscribe to any frequently changing notification actions
const frequentlyChangingNotifications = uiActions.getFrequentlyChangingActionsForTrigger(
PANEL_NOTIFICATION_TRIGGER,
apiContext
);
const frequentlyChangingNotifications =
await uiActions.getFrequentlyChangingActionsForTrigger(
PANEL_NOTIFICATION_TRIGGER,
apiContext
);
if (canceled) return;
for (const notification of frequentlyChangingNotifications) {
if (!disabledNotifications.includes(notification.id))
subscriptions.add(

View file

@ -0,0 +1,16 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { PresentationPanelInternal } from './presentation_panel_internal';
export { PresentationPanelErrorInternal } from './presentation_panel_error_internal';
export { RemovePanelAction } from '../panel_actions/remove_panel_action/remove_panel_action';
export { CustomTimeRangeBadge } from '../panel_actions/customize_panel_action';
export { CustomizePanelAction } from '../panel_actions/customize_panel_action';
export { EditPanelAction } from '../panel_actions/edit_panel_action/edit_panel_action';
export { InspectPanelAction } from '../panel_actions/inspect_panel_action/inspect_panel_action';

View file

@ -16,8 +16,7 @@ import React from 'react';
import useAsync from 'react-use/lib/useAsync';
import { css } from '@emotion/react';
import { untilPluginStartServicesReady } from '../kibana_services';
import { PresentationPanelError } from './presentation_panel_error';
import { DefaultPresentationPanelApi, PresentationPanelProps } from './types';
import type { DefaultPresentationPanelApi, PresentationPanelProps } from './types';
import { getErrorLoadingPanel } from './presentation_panel_strings';
export const PresentationPanel = <
@ -30,7 +29,7 @@ export const PresentationPanel = <
) => {
const { Component, hidePanelChrome, ...passThroughProps } = props;
const { euiTheme } = useEuiTheme();
const { loading, value, error } = useAsync(async () => {
const { loading, value } = useAsync(async () => {
if (hidePanelChrome) {
return {
unwrappedComponent: isPromise(Component) ? await Component : Component,
@ -38,15 +37,31 @@ export const PresentationPanel = <
}
const startServicesPromise = untilPluginStartServicesReady();
const modulePromise = await import('./presentation_panel_internal');
const componentPromise = isPromise(Component) ? Component : Promise.resolve(Component);
const [, unwrappedComponent, panelModule] = await Promise.all([
const results = await Promise.allSettled([
startServicesPromise,
componentPromise,
modulePromise,
import('./panel_module'),
]);
const Panel = panelModule.PresentationPanelInternal;
return { Panel, unwrappedComponent };
let loadErrorReason: string | undefined;
for (const result of results) {
if (result.status === 'rejected') {
loadErrorReason = result.reason;
break;
}
}
return {
loadErrorReason,
Panel:
results[2].status === 'fulfilled' ? results[2].value?.PresentationPanelInternal : undefined,
PanelError:
results[2].status === 'fulfilled'
? results[2].value?.PresentationPanelErrorInternal
: undefined,
unwrappedComponent: results[1].status === 'fulfilled' ? results[1].value : undefined,
};
// Ancestry chain is expected to use 'key' attribute to reset DOM and state
// when unwrappedComponent needs to be re-loaded
@ -66,9 +81,10 @@ export const PresentationPanel = <
);
const Panel = value?.Panel;
const PanelError = value?.PanelError;
const UnwrappedComponent = value?.unwrappedComponent;
const shouldHavePanel = !hidePanelChrome;
if (error || (shouldHavePanel && !Panel) || !UnwrappedComponent) {
if (value?.loadErrorReason || (shouldHavePanel && !Panel) || !UnwrappedComponent) {
return (
<EuiFlexGroup
alignItems="center"
@ -76,7 +92,11 @@ export const PresentationPanel = <
data-test-subj="embeddableError"
justifyContent="center"
>
<PresentationPanelError error={error ?? new Error(getErrorLoadingPanel())} />
{PanelError ? (
<PanelError error={new Error(value?.loadErrorReason ?? getErrorLoadingPanel())} />
) : (
value?.loadErrorReason ?? getErrorLoadingPanel()
)}
</EuiFlexGroup>
);
}

View file

@ -7,100 +7,21 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EuiButtonEmpty, EuiEmptyPrompt, EuiText } from '@elastic/eui';
import React, { useEffect, useMemo, useState } from 'react';
import React from 'react';
import { dynamic } from '@kbn/shared-ux-utility';
import { PanelLoader } from '@kbn/panel-loader';
import type { PresentationPanelErrorProps } from './presentation_panel_error_internal';
import { ErrorLike } from '@kbn/expressions-plugin/common';
import { useStateFromPublishingSubject } from '@kbn/presentation-publishing';
import { renderSearchError } from '@kbn/search-errors';
import { Markdown } from '@kbn/shared-ux-markdown';
import { Subscription } from 'rxjs';
import { i18n } from '@kbn/i18n';
import { useErrorTextStyle } from '@kbn/react-hooks';
import { editPanelAction } from '../panel_actions/panel_actions';
import { getErrorCallToAction } from './presentation_panel_strings';
import { DefaultPresentationPanelApi } from './types';
export const PresentationPanelError = ({
api,
error,
}: {
error: ErrorLike;
api?: DefaultPresentationPanelApi;
}) => {
const errorTextStyle = useErrorTextStyle();
const [isEditable, setIsEditable] = useState(false);
const handleErrorClick = useMemo(
() => (isEditable ? () => editPanelAction?.execute({ embeddable: api }) : undefined),
[api, isEditable]
);
const label = useMemo(
() => (isEditable ? editPanelAction?.getDisplayName({ embeddable: api }) : ''),
[api, isEditable]
);
const panelTitle = useStateFromPublishingSubject(api?.panelTitle);
const ariaLabel = useMemo(
() => (panelTitle ? getErrorCallToAction(panelTitle) : label),
[label, panelTitle]
);
// Get initial editable state from action and subscribe to changes.
useEffect(() => {
if (!editPanelAction?.couldBecomeCompatible({ embeddable: api })) return;
let canceled = false;
const subscription = new Subscription();
(async () => {
const initiallyCompatible = await editPanelAction?.isCompatible({ embeddable: api });
if (canceled) return;
setIsEditable(initiallyCompatible);
subscription.add(
editPanelAction?.subscribeToCompatibilityChanges({ embeddable: api }, (isCompatible) => {
if (!canceled) setIsEditable(isCompatible);
})
);
})();
return () => {
canceled = true;
subscription.unsubscribe();
const Component = dynamic(
async () => {
const { PresentationPanelErrorInternal } = await import('./panel_module');
return {
default: PresentationPanelErrorInternal,
};
}, [api]);
},
{ fallback: <PanelLoader /> }
);
const searchErrorDisplay = renderSearchError(error);
const actions = searchErrorDisplay?.actions ?? [];
if (isEditable) {
actions.push(
<EuiButtonEmpty aria-label={ariaLabel} onClick={handleErrorClick} size="s">
{label}
</EuiButtonEmpty>
);
}
return (
<EuiEmptyPrompt
body={
searchErrorDisplay?.body ?? (
<EuiText size="s" css={errorTextStyle}>
<Markdown data-test-subj="errorMessageMarkdown" readOnly>
{error.message?.length
? error.message
: i18n.translate('presentationPanel.emptyErrorMessage', {
defaultMessage: 'Error',
})}
</Markdown>
</EuiText>
)
}
data-test-subj="embeddableStackError"
iconType="warning"
iconColor="danger"
layout="vertical"
actions={actions}
/>
);
};
export function PresentationPanelError(props: PresentationPanelErrorProps) {
return <Component {...props} />;
}

View file

@ -9,16 +9,16 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { PresentationPanelError } from './presentation_panel_error';
import { PresentationPanelErrorInternal } from './presentation_panel_error_internal';
describe('PresentationPanelError', () => {
describe('PresentationPanelErrorInternal', () => {
test('should display error', async () => {
render(<PresentationPanelError error={new Error('Simulated error')} />);
render(<PresentationPanelErrorInternal error={new Error('Simulated error')} />);
await waitFor(() => screen.getByTestId('errorMessageMarkdown'));
});
test('should display error with empty message', async () => {
render(<PresentationPanelError error={new Error('')} />);
render(<PresentationPanelErrorInternal error={new Error('')} />);
await waitFor(() => screen.getByTestId('errorMessageMarkdown'));
});
});

View file

@ -0,0 +1,133 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EuiButtonEmpty, EuiEmptyPrompt, EuiText } from '@elastic/eui';
import React, { useEffect, useMemo, useState } from 'react';
import { ErrorLike } from '@kbn/expressions-plugin/common';
import { EmbeddableApiContext, useStateFromPublishingSubject } from '@kbn/presentation-publishing';
import { renderSearchError } from '@kbn/search-errors';
import { Markdown } from '@kbn/shared-ux-markdown';
import { Subscription } from 'rxjs';
import { i18n } from '@kbn/i18n';
import { useErrorTextStyle } from '@kbn/react-hooks';
import { ActionExecutionMeta } from '@kbn/ui-actions-plugin/public';
import { getErrorCallToAction } from './presentation_panel_strings';
import { DefaultPresentationPanelApi } from './types';
import { uiActions } from '../kibana_services';
import { executeEditPanelAction } from '../panel_actions/edit_panel_action/execute_edit_action';
import { ACTION_EDIT_PANEL } from '../panel_actions/edit_panel_action/constants';
import { CONTEXT_MENU_TRIGGER } from '../panel_actions';
export interface PresentationPanelErrorProps {
error: ErrorLike;
api?: DefaultPresentationPanelApi;
}
export const PresentationPanelErrorInternal = ({ api, error }: PresentationPanelErrorProps) => {
const errorTextStyle = useErrorTextStyle();
const [isEditable, setIsEditable] = useState(false);
const handleErrorClick = useMemo(
() => (isEditable ? () => executeEditPanelAction(api) : undefined),
[api, isEditable]
);
const [label, setLabel] = useState('');
useEffect(() => {
if (!isEditable) {
setLabel('');
return;
}
const canceled = false;
uiActions
.getAction(ACTION_EDIT_PANEL)
.then((action) => {
if (canceled) return;
setLabel(
action?.getDisplayName({
embeddable: api,
trigger: { id: CONTEXT_MENU_TRIGGER },
} as EmbeddableApiContext & ActionExecutionMeta)
);
})
.catch(() => {
// ignore action not found
});
}, [api, isEditable]);
const panelTitle = useStateFromPublishingSubject(api?.panelTitle);
const ariaLabel = useMemo(
() => (panelTitle ? getErrorCallToAction(panelTitle) : label),
[label, panelTitle]
);
// Get initial editable state from action and subscribe to changes.
useEffect(() => {
let canceled = false;
const subscription = new Subscription();
(async () => {
const editPanelAction = await uiActions.getAction(ACTION_EDIT_PANEL);
if (canceled || !editPanelAction?.couldBecomeCompatible?.({ embeddable: api })) return;
const initiallyCompatible = await editPanelAction?.isCompatible({
embeddable: api,
trigger: { id: CONTEXT_MENU_TRIGGER },
} as EmbeddableApiContext & ActionExecutionMeta);
if (canceled) return;
setIsEditable(initiallyCompatible);
subscription.add(
editPanelAction?.subscribeToCompatibilityChanges?.({ embeddable: api }, (isCompatible) => {
if (!canceled) setIsEditable(isCompatible);
})
);
})();
return () => {
canceled = true;
subscription.unsubscribe();
};
}, [api]);
const searchErrorDisplay = renderSearchError(error);
const actions = searchErrorDisplay?.actions ?? [];
if (isEditable) {
actions.push(
<EuiButtonEmpty aria-label={ariaLabel} onClick={handleErrorClick} size="s">
{label}
</EuiButtonEmpty>
);
}
return (
<EuiEmptyPrompt
body={
searchErrorDisplay?.body ?? (
<EuiText size="s" css={errorTextStyle}>
<Markdown data-test-subj="errorMessageMarkdown" readOnly>
{error.message?.length
? error.message
: i18n.translate('presentationPanel.emptyErrorMessage', {
defaultMessage: 'Error',
})}
</Markdown>
</EuiText>
)
}
data-test-subj="embeddableStackError"
iconType="warning"
iconColor="danger"
layout="vertical"
actions={actions}
/>
);
};

View file

@ -18,7 +18,7 @@ import classNames from 'classnames';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { PresentationPanelHeader } from './panel_header/presentation_panel_header';
import { PresentationPanelHoverActions } from './panel_header/presentation_panel_hover_actions';
import { PresentationPanelError } from './presentation_panel_error';
import { PresentationPanelErrorInternal } from './presentation_panel_error_internal';
import { DefaultPresentationPanelApi, PresentationPanelInternalProps } from './types';
export const PresentationPanelInternal = <
@ -147,7 +147,7 @@ export const PresentationPanelInternal = <
data-test-subj="embeddableError"
justifyContent="center"
>
<PresentationPanelError api={api} error={blockingError} />
<PresentationPanelErrorInternal api={api} error={blockingError} />
</EuiFlexGroup>
)}
{!initialLoadComplete && <PanelLoader />}

View file

@ -15,7 +15,7 @@ import { SavedObjectTaggingOssPluginStart } from '@kbn/saved-objects-tagging-oss
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import { setKibanaServices } from './kibana_services';
import { registerActions } from './panel_actions/panel_actions';
import { registerActions } from './panel_actions/register_actions';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface PresentationPanelSetup {}

View file

@ -28,7 +28,8 @@
"@kbn/panel-loader",
"@kbn/search-errors",
"@kbn/shared-ux-markdown",
"@kbn/react-hooks"
"@kbn/react-hooks",
"@kbn/shared-ux-utility"
],
"exclude": ["target/**/*"]
}

View file

@ -10,7 +10,7 @@
import { AggregateQuery, Filter, FilterStateStore, Query } from '@kbn/es-query';
import { I18nProvider } from '@kbn/i18n-react';
import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl';
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { BehaviorSubject } from 'rxjs';
@ -44,8 +44,10 @@ const mockedEditPanelAction = {
execute: jest.fn(),
isCompatible: jest.fn().mockResolvedValue(true),
};
jest.mock('@kbn/presentation-panel-plugin/public', () => ({
getEditPanelAction: () => mockedEditPanelAction,
jest.mock('../services/kibana_services', () => ({
uiActionsService: {
getAction: async () => mockedEditPanelAction,
},
}));
describe('filters notification popover', () => {
@ -133,6 +135,8 @@ describe('filters notification popover', () => {
await renderAndOpenPopover();
const editButton = await screen.findByTestId('filtersNotificationModal__editButton');
await userEvent.click(editButton);
expect(mockedEditPanelAction.execute).toHaveBeenCalled();
await waitFor(() => {
expect(mockedEditPanelAction.execute).toHaveBeenCalled();
});
});
});

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import {
EuiButton,
@ -23,13 +23,17 @@ import {
import { css } from '@emotion/react';
import { AggregateQuery, getAggregateQueryMode, isOfQueryType } from '@kbn/es-query';
import { getEditPanelAction } from '@kbn/presentation-panel-plugin/public';
import { ACTION_EDIT_PANEL } from '@kbn/presentation-panel-plugin/public';
import { FilterItems } from '@kbn/unified-search-plugin/public';
import {
EmbeddableApiContext,
apiCanLockHoverActions,
getViewModeSubject,
useBatchedOptionalPublishingSubjects,
} from '@kbn/presentation-publishing';
import { ActionExecutionMeta } from '@kbn/ui-actions-plugin/public';
import { CONTEXT_MENU_TRIGGER } from '@kbn/embeddable-plugin/public';
import { uiActionsService } from '../services/kibana_services';
import { dashboardFilterNotificationActionStrings } from './_dashboard_actions_strings';
import { FiltersNotificationActionApi } from './filters_notification_action';
@ -37,12 +41,23 @@ export function FiltersNotificationPopover({ api }: { api: FiltersNotificationAc
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [disableEditbutton, setDisableEditButton] = useState(false);
const editPanelAction = getEditPanelAction();
const filters = useMemo(() => api.filters$?.value, [api]);
const displayName = dashboardFilterNotificationActionStrings.getDisplayName();
const canEditUnifiedSearch = api.canEditUnifiedSearch?.() ?? true;
const executeEditAction = useCallback(async () => {
try {
const action = await uiActionsService.getAction(ACTION_EDIT_PANEL);
action.execute({
embeddable: api,
trigger: { id: CONTEXT_MENU_TRIGGER },
} as EmbeddableApiContext & ActionExecutionMeta);
} catch (error) {
// eslint-disable-next-line no-console
console.warn('Unable to execute edit action, Error: ', error.message);
}
}, [api]);
const { queryString, queryLanguage } = useMemo(() => {
const query = api.query$?.value;
if (!query) return {};
@ -141,7 +156,7 @@ export function FiltersNotificationPopover({ api }: { api: FiltersNotificationAc
data-test-subj={'filtersNotificationModal__editButton'}
size="s"
fill
onClick={() => editPanelAction.execute({ embeddable: api })}
onClick={executeEditAction}
>
{dashboardFilterNotificationActionStrings.getEditButtonTitle()}
</EuiButton>

View file

@ -335,7 +335,7 @@ export function getDiscoverStateContainer({
services.dataViews.clearInstanceCache(prevDataView.id);
updateFiltersReferences({
await updateFiltersReferences({
prevDataView,
nextDataView,
services,

View file

@ -15,7 +15,7 @@ import { ActionExecutionContext } from '@kbn/ui-actions-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { DiscoverServices } from '../../../../build_services';
export const updateFiltersReferences = ({
export const updateFiltersReferences = async ({
prevDataView,
nextDataView,
services: { uiActions },
@ -25,7 +25,7 @@ export const updateFiltersReferences = ({
services: DiscoverServices;
}) => {
const trigger = uiActions.getTrigger(UPDATE_FILTER_REFERENCES_TRIGGER);
const action = uiActions.getAction(UPDATE_FILTER_REFERENCES_ACTION);
const action = await uiActions.getAction(UPDATE_FILTER_REFERENCES_ACTION);
action?.execute({
trigger,
fromDataView: prevDataView.id,

View file

@ -50,7 +50,7 @@ export const FloatingActions: FC<FloatingActionsProps> = ({
useEffect(() => {
if (!api) return;
let mounted = true;
let canceled = false;
const context = {
embeddable: api,
trigger: panelHoverTrigger,
@ -74,7 +74,7 @@ export const FloatingActions: FC<FloatingActionsProps> = ({
const subscriptions = new Subscription();
const handleActionCompatibilityChange = (isCompatible: boolean, action: Action) => {
if (!mounted) return;
if (canceled) return;
setFloatingActions((currentActions) => {
const newActions: FloatingActionItem[] = currentActions
?.filter((current) => current.id !== action.id)
@ -88,13 +88,12 @@ export const FloatingActions: FC<FloatingActionsProps> = ({
(async () => {
const actions = await getActions();
if (!mounted) return;
if (canceled) return;
setFloatingActions(actions);
const frequentlyChangingActions = uiActionsService.getFrequentlyChangingActionsForTrigger(
PANEL_HOVER_TRIGGER,
context
);
const frequentlyChangingActions =
await uiActionsService.getFrequentlyChangingActionsForTrigger(PANEL_HOVER_TRIGGER, context);
if (canceled) return;
for (const action of frequentlyChangingActions) {
subscriptions.add(
@ -104,7 +103,7 @@ export const FloatingActions: FC<FloatingActionsProps> = ({
})();
return () => {
mounted = false;
canceled = true;
subscriptions.unsubscribe();
};
}, [api, viewMode, disabledActions]);

View file

@ -33,6 +33,7 @@ const createStartContract = (): Start => {
attachAction: jest.fn(),
unregisterAction: jest.fn(),
addTriggerAction: jest.fn(),
addTriggerActionAsync: jest.fn(),
clear: jest.fn(),
detachAction: jest.fn(),
executeTriggerActions: jest.fn(),
@ -41,14 +42,16 @@ const createStartContract = (): Start => {
hasAction: jest.fn(),
getTrigger: jest.fn(),
hasTrigger: jest.fn(),
getTriggerActions: jest.fn((id: string) => []),
getTriggerActions: jest.fn(async (id: string) => []),
getTriggerCompatibleActions: jest.fn((triggerId: string, context: object) =>
Promise.resolve([] as Array<Action<object>>)
),
getFrequentlyChangingActionsForTrigger: jest.fn(
(triggerId: string, context: object) => [] as Array<FrequentCompatibilityChangeAction<object>>
async (triggerId: string, context: object) =>
[] as Array<FrequentCompatibilityChangeAction<object>>
),
registerAction: jest.fn(),
registerActionAsync: jest.fn(),
registerTrigger: jest.fn(),
};

View file

@ -128,7 +128,7 @@ describe('UiActionsService', () => {
isCompatible: async () => true,
};
test('returns actions set on trigger', () => {
test('returns actions set on trigger', async () => {
const service = new UiActionsService();
service.registerAction(action1);
@ -139,19 +139,19 @@ describe('UiActionsService', () => {
title: 'baz',
});
const list0 = service.getTriggerActions(FOO_TRIGGER);
const list0 = await service.getTriggerActions(FOO_TRIGGER);
expect(list0).toHaveLength(0);
service.addTriggerAction(FOO_TRIGGER, action1);
const list1 = service.getTriggerActions(FOO_TRIGGER);
const list1 = await service.getTriggerActions(FOO_TRIGGER);
expect(list1).toHaveLength(1);
expect(list1[0]).toBeInstanceOf(ActionInternal);
expect(list1[0].id).toBe(action1.id);
service.addTriggerAction(FOO_TRIGGER, action2);
const list2 = service.getTriggerActions(FOO_TRIGGER);
const list2 = await service.getTriggerActions(FOO_TRIGGER);
expect(list2).toHaveLength(2);
expect(!!list2.find(({ id }: { id: string }) => id === 'action1')).toBe(true);
@ -171,7 +171,8 @@ describe('UiActionsService', () => {
service.registerAction(helloWorldAction);
expect(actions.size - length).toBe(1);
expect(actions.get(helloWorldAction.id)!.id).toBe(helloWorldAction.id);
const action = await actions.get(helloWorldAction.id)?.();
expect(action?.id).toBe(helloWorldAction.id);
});
test('getTriggerCompatibleActions returns attached actions', async () => {
@ -288,7 +289,7 @@ describe('UiActionsService', () => {
expect(trigger2.id).toBe(FOO_TRIGGER);
});
test('forked service preserves trigger-to-actions mapping', () => {
test('forked service preserves trigger-to-actions mapping', async () => {
const service1 = new UiActionsService();
service1.registerTrigger({
@ -299,8 +300,8 @@ describe('UiActionsService', () => {
const service2 = service1.fork();
const actions1 = service1.getTriggerActions(FOO_TRIGGER);
const actions2 = service2.getTriggerActions(FOO_TRIGGER);
const actions1 = await service1.getTriggerActions(FOO_TRIGGER);
const actions2 = await service2.getTriggerActions(FOO_TRIGGER);
expect(actions1).toHaveLength(1);
expect(actions2).toHaveLength(1);
@ -308,7 +309,7 @@ describe('UiActionsService', () => {
expect(actions2[0].id).toBe(testAction1.id);
});
test('new attachments in fork do not appear in original service', () => {
test('new attachments in fork do not appear in original service', async () => {
const service1 = new UiActionsService();
service1.registerTrigger({
@ -320,16 +321,16 @@ describe('UiActionsService', () => {
const service2 = service1.fork();
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(await service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(await service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
service2.addTriggerAction(FOO_TRIGGER, testAction2);
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(2);
expect(await service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(await service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(2);
});
test('new attachments in original service do not appear in fork', () => {
test('new attachments in original service do not appear in fork', async () => {
const service1 = new UiActionsService();
service1.registerTrigger({
@ -341,13 +342,13 @@ describe('UiActionsService', () => {
const service2 = service1.fork();
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(await service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(await service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
service1.addTriggerAction(FOO_TRIGGER, testAction2);
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(2);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(await service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(2);
expect(await service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
});
});
@ -372,7 +373,7 @@ describe('UiActionsService', () => {
});
});
test('can register action', () => {
test('can register action', async () => {
const actions: ActionRegistry = new Map();
const service = new UiActionsService({ actions });
@ -381,13 +382,13 @@ describe('UiActionsService', () => {
order: 13,
} as unknown as ActionDefinition);
expect(actions.get(ACTION_HELLO_WORLD)).toMatchObject({
expect(await actions.get(ACTION_HELLO_WORLD)?.()).toMatchObject({
id: ACTION_HELLO_WORLD,
order: 13,
});
});
test('can attach an action to a trigger', () => {
test('can attach an action to a trigger', async () => {
const service = new UiActionsService();
const trigger: Trigger = {
@ -401,13 +402,13 @@ describe('UiActionsService', () => {
service.registerTrigger(trigger);
service.addTriggerAction(MY_TRIGGER, action);
const actions = service.getTriggerActions(trigger.id);
const actions = await service.getTriggerActions(trigger.id);
expect(actions.length).toBe(1);
expect(actions[0].id).toBe(ACTION_HELLO_WORLD);
});
test('can detach an action from a trigger', () => {
test('can detach an action from a trigger', async () => {
const service = new UiActionsService();
const trigger: Trigger = {
@ -423,7 +424,7 @@ describe('UiActionsService', () => {
service.addTriggerAction(trigger.id, action);
service.detachAction(trigger.id, action.id);
const actions2 = service.getTriggerActions(trigger.id);
const actions2 = await service.getTriggerActions(trigger.id);
expect(actions2).toEqual([]);
});

View file

@ -8,6 +8,7 @@
*/
import type { Trigger } from '@kbn/ui-actions-browser/src/triggers';
import { asyncMap } from '@kbn/std';
import { TriggerRegistry, ActionRegistry, TriggerToActionsRegistry } from '../types';
import {
ActionInternal,
@ -70,6 +71,11 @@ export class UiActionsService {
return trigger.contract;
};
/**
* @deprecated
*
* Use `plugins.uiActions.registerActionAsync` instead.
*/
public readonly registerAction = <Context extends object>(
definition: ActionDefinition<Context>
): Action<Context> => {
@ -79,11 +85,25 @@ export class UiActionsService {
const action = new ActionInternal(definition);
this.actions.set(action.id, action as unknown as ActionInternal<object>);
this.actions.set(action.id, async () => action as unknown as ActionInternal<object>);
return action;
};
public readonly registerActionAsync = <Context extends object>(
id: string,
getDefinition: () => Promise<ActionDefinition<Context>>
) => {
if (this.actions.has(id)) {
throw new Error(`Action [action.id = ${id}] already registered.`);
}
this.actions.set(id, async () => {
const action = new ActionInternal(await getDefinition());
return action as unknown as ActionInternal<object>;
});
};
public readonly unregisterAction = (actionId: string): void => {
if (!this.actions.has(actionId)) {
throw new Error(`Action [action.id = ${actionId}] is not registered.`);
@ -130,40 +150,56 @@ export class UiActionsService {
};
/**
* `addTriggerAction` is similar to `attachAction` as it attaches action to a
* trigger, but it also registers the action, if it has not been registered, yet.
* @deprecated
*
* Use `plugins.uiActions.addTriggerActionAsync` instead.
*/
public readonly addTriggerAction = (triggerId: string, action: ActionDefinition<any>): void => {
if (!this.actions.has(action.id)) this.registerAction(action);
this.attachAction(triggerId, action.id);
};
public readonly getAction = (id: string): Action => {
if (!this.actions.has(id)) {
/**
* `addTriggerAction` is similar to `attachAction` as it attaches action to a
* trigger, but it also registers the action, if it has not been registered, yet.
*/
public readonly addTriggerActionAsync = (
triggerId: string,
actionId: string,
getDefinition: () => Promise<ActionDefinition<any>>
): void => {
if (!this.actions.has(actionId)) this.registerActionAsync(actionId, getDefinition);
this.attachAction(triggerId, actionId);
};
public readonly getAction = async (id: string): Promise<Action> => {
const getAction = this.actions.get(id);
if (!getAction) {
throw new Error(`Action [action.id = ${id}] not registered.`);
}
return this.actions.get(id)! as Action;
return (await getAction()) as Action;
};
public readonly getTriggerActions = (triggerId: string): Action[] => {
public readonly getTriggerActions = async (triggerId: string): Promise<Action[]> => {
// This line checks if trigger exists, otherwise throws.
this.getTrigger!(triggerId);
const actionIds = this.triggerToActions.get(triggerId);
const actionIds = this.triggerToActions.get(triggerId) ?? [];
const actions = actionIds!
.map((actionId) => this.actions.get(actionId) as ActionInternal)
.filter(Boolean);
const actions = await asyncMap(
actionIds,
async (actionId) => (await this.actions.get(actionId)?.()) as ActionInternal
);
return actions as Action[];
return actions.filter(Boolean);
};
public readonly getTriggerCompatibleActions = async (
triggerId: string,
context: object
): Promise<Action[]> => {
const actions = this.getTriggerActions!(triggerId);
const actions = await this.getTriggerActions(triggerId);
const isCompatibles = await Promise.all(
actions.map((action) =>
action.isCompatible({
@ -180,11 +216,11 @@ export class UiActionsService {
}, []);
};
public readonly getFrequentlyChangingActionsForTrigger = (
public readonly getFrequentlyChangingActionsForTrigger = async (
triggerId: string,
context: object
): FrequentCompatibilityChangeAction[] => {
return this.getTriggerActions!(triggerId).filter((action) => {
): Promise<FrequentCompatibilityChangeAction[]> => {
return (await this.getTriggerActions(triggerId)).filter((action) => {
return (
Boolean(action.subscribeToCompatibilityChanges) &&
action.couldBecomeCompatible?.({

View file

@ -24,7 +24,7 @@ const action2: ActionDefinition = {
execute: async () => {},
};
test('returns actions set on trigger', () => {
test('returns actions set on trigger', async () => {
const { setup, doStart } = uiActionsPluginMock.createPlugin();
setup.registerAction(action1);
setup.registerAction(action2);
@ -35,19 +35,19 @@ test('returns actions set on trigger', () => {
});
const start = doStart();
const list0 = start.getTriggerActions('trigger');
const list0 = await start.getTriggerActions('trigger');
expect(list0).toHaveLength(0);
setup.addTriggerAction('trigger', action1);
const list1 = start.getTriggerActions('trigger');
const list1 = await start.getTriggerActions('trigger');
expect(list1).toHaveLength(1);
expect(list1[0]).toBeInstanceOf(ActionInternal);
expect(list1[0].id).toBe(action1.id);
setup.addTriggerAction('trigger', action2);
const list2 = start.getTriggerActions('trigger');
const list2 = await start.getTriggerActions('trigger');
expect(list2).toHaveLength(2);
expect(!!list2.find(({ id }: { id: string }) => id === 'action1')).toBe(true);

View file

@ -14,7 +14,7 @@ import { ActionInternal } from './actions/action_internal';
import { TriggerInternal } from './triggers/trigger_internal';
export type TriggerRegistry = Map<string, TriggerInternal<object>>;
export type ActionRegistry = Map<string, ActionInternal>;
export type ActionRegistry = Map<string, () => Promise<ActionInternal>>;
export type TriggerToActionsRegistry = Map<string, string[]>;
export interface VisualizeFieldContext {

View file

@ -15,6 +15,7 @@
"@kbn/expressions-plugin",
"@kbn/react-kibana-context-render",
"@kbn/react-kibana-mount",
"@kbn/std",
],
"exclude": [
"target/**/*",

View file

@ -314,7 +314,7 @@ describe('DynamicActionManager', () => {
await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']);
const createdAction = actions.values().next().value;
const createdAction = await actions.values().next().value();
expect(createdAction.grouping).toBe(dynamicActionGrouping);
});
@ -477,7 +477,7 @@ describe('DynamicActionManager', () => {
expect(actions.size).toBe(1);
const registeredAction1 = actions.values().next().value;
const registeredAction1 = await actions.values().next().value();
expect(registeredAction1.getDisplayName()).toBe('Action 3');
@ -491,7 +491,7 @@ describe('DynamicActionManager', () => {
expect(actions.size).toBe(1);
const registeredAction2 = actions.values().next().value;
const registeredAction2 = await actions.values().next().value();
expect(registeredAction2.getDisplayName()).toBe('foo');
});
@ -600,7 +600,7 @@ describe('DynamicActionManager', () => {
expect(actions.size).toBe(1);
const registeredAction1 = actions.values().next().value;
const registeredAction1 = await actions.values().next().value();
expect(registeredAction1.getDisplayName()).toBe('Action 3');
@ -614,7 +614,7 @@ describe('DynamicActionManager', () => {
expect(actions.size).toBe(1);
const registeredAction2 = actions.values().next().value;
const registeredAction2 = await actions.values().next().value();
expect(registeredAction2.getDisplayName()).toBe('Action 3');
});
@ -733,10 +733,10 @@ describe('DynamicActionManager', () => {
await manager.start();
expect(uiActions.getTriggerActions('VALUE_CLICK_TRIGGER')).toHaveLength(2);
expect(await uiActions.getTriggerActions('VALUE_CLICK_TRIGGER')).toHaveLength(2);
expect(await storage.list()).toEqual([event1, event3, event2]);
await manager.stop();
expect(uiActions.getTriggerActions('VALUE_CLICK_TRIGGER')).toHaveLength(0);
expect(await uiActions.getTriggerActions('VALUE_CLICK_TRIGGER')).toHaveLength(0);
});
});

View file

@ -278,7 +278,10 @@ export const getFieldStatsChartEmbeddableFactory = (
}
};
const addFilters = (filters: Filter[], actionId: string = ACTION_GLOBAL_APPLY_FILTER) => {
const addFilters = async (
filters: Filter[],
actionId: string = ACTION_GLOBAL_APPLY_FILTER
) => {
if (!pluginStart.uiActions) {
toasts.addWarning(ERROR_MSG.APPLY_FILTER_ERR);
return;
@ -298,7 +301,7 @@ export const getFieldStatsChartEmbeddableFactory = (
filters,
};
try {
const action = pluginStart.uiActions.getAction(actionId);
const action = await pluginStart.uiActions.getAction(actionId);
action.execute(executeContext);
} catch (error) {
toasts.addWarning(ERROR_MSG.APPLY_FILTER_ERR);

View file

@ -115,7 +115,7 @@ export const createIndexPatternService = ({
applyImmediately: true,
});
const trigger = uiActions.getTrigger(UPDATE_FILTER_REFERENCES_TRIGGER);
const action = uiActions.getAction(UPDATE_FILTER_REFERENCES_ACTION);
const action = await uiActions.getAction(UPDATE_FILTER_REFERENCES_ACTION);
action?.execute({
trigger,

View file

@ -227,7 +227,7 @@ export function renameIndexPattern({
};
}
export function triggerActionOnIndexPatternChange({
export async function triggerActionOnIndexPatternChange({
state,
layerId,
uiActions,
@ -243,7 +243,7 @@ export function triggerActionOnIndexPatternChange({
const toDataView = indexPatternId;
const trigger = uiActions.getTrigger(UPDATE_FILTER_REFERENCES_TRIGGER);
const action = uiActions.getAction(UPDATE_FILTER_REFERENCES_ACTION);
const action = await uiActions.getAction(UPDATE_FILTER_REFERENCES_ACTION);
action?.execute({
trigger,

View file

@ -165,7 +165,7 @@ export function LayerPanels(
);
const onRemoveLayer = useCallback(
(layerToRemoveId: string) => {
async (layerToRemoveId: string) => {
const datasourcePublicAPI = props.framePublicAPI.datasourceLayers?.[layerToRemoveId];
const datasourceId = datasourcePublicAPI?.datasourceId;
@ -173,7 +173,7 @@ export function LayerPanels(
const layerDatasource = datasourceMap[datasourceId];
const layerDatasourceState = datasourceStates?.[datasourceId]?.state;
const trigger = props.uiActions.getTrigger(UPDATE_FILTER_REFERENCES_TRIGGER);
const action = props.uiActions.getAction(UPDATE_FILTER_REFERENCES_ACTION);
const action = await props.uiActions.getAction(UPDATE_FILTER_REFERENCES_ACTION);
action?.execute({
trigger,

View file

@ -60,7 +60,7 @@ export interface RenderTooltipContentParams {
layerId: string;
featureId?: string | number;
}) => Geometry | null;
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => void;
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => Promise<void>;
}
export type RenderToolTipContent = (params: RenderTooltipContentParams) => JSX.Element;

View file

@ -35,7 +35,7 @@ export interface Props {
addFilters: ((filters: Filter[], actionId: string) => Promise<void>) | null;
getFilterActions?: () => Promise<Action[]>;
getActionContext?: () => ActionExecutionContext;
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => void;
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => Promise<void>;
isMapLoading: boolean;
cancelAllInFlightRequests: () => void;
exitFullScreen: () => void;

View file

@ -73,7 +73,7 @@ export interface Props {
addFilters: ((filters: Filter[], actionId: string) => Promise<void>) | null;
getFilterActions?: () => Promise<Action[]>;
getActionContext?: () => ActionExecutionContext;
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => void;
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => Promise<void>;
renderTooltipContent?: RenderToolTipContent;
timeslice?: Timeslice;
featureModeActive: boolean;

View file

@ -40,7 +40,7 @@ interface Props {
addFilters: ((filters: Filter[], actionId: string) => Promise<void>) | null;
getFilterActions?: () => Promise<Action[]>;
getActionContext?: () => ActionExecutionContext;
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => void;
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => Promise<void>;
showFilterActions: (view: ReactNode) => void;
}

View file

@ -28,7 +28,7 @@ interface Props {
addFilters: ((filters: Filter[], actionId: string) => Promise<void>) | null;
getFilterActions?: () => Promise<Action[]>;
getActionContext?: () => ActionExecutionContext;
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => void;
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => Promise<void>;
closeTooltip: () => void;
features: TooltipFeature[];
isLocked: boolean;

View file

@ -62,7 +62,7 @@ export interface Props {
mbMap: MbMap;
openOnClickTooltip: (tooltipState: TooltipState) => void;
openOnHoverTooltip: (tooltipState: TooltipState) => void;
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => void;
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => Promise<void>;
openTooltips: TooltipState[];
renderTooltipContent?: RenderToolTipContent;
updateOpenTooltips: (openTooltips: TooltipState[]) => void;

View file

@ -38,7 +38,7 @@ interface Props {
}) => Geometry | null;
location: [number, number];
mbMap: MbMap;
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => void;
onSingleValueTrigger?: (actionId: string, key: string, value: RawValue) => Promise<void>;
renderTooltipContent?: RenderToolTipContent;
executionContext: KibanaExecutionContext;
}

View file

@ -33,7 +33,7 @@ export function initializeActionHandlers(getApi: () => MapApi | undefined) {
...getActionContext(),
filters,
};
const action = getUiActions().getAction(actionId);
const action = await getUiActions().getAction(actionId);
if (!action) {
throw new Error('Unable to apply filter, could not locate action');
}
@ -60,8 +60,8 @@ export function initializeActionHandlers(getApi: () => MapApi | undefined) {
);
return [...filterActions, ...valueClickActions.filter(isUrlDrilldown)];
},
onSingleValueTrigger: (actionId: string, key: string, value: RawValue) => {
const action = getUiActions().getAction(actionId);
onSingleValueTrigger: async (actionId: string, key: string, value: RawValue) => {
const action = await getUiActions().getAction(actionId);
if (!action) {
throw new Error('Unable to apply action, could not locate action');
}

View file

@ -104,7 +104,7 @@ export function initializeCrossPanelActions({
}
// debounce to fix timing issue for dashboard with multiple maps with synchronized movement and filter by map extent enabled
const setMapExtentFilter = _.debounce(() => {
const setMapExtentFilter = _.debounce(async () => {
const mapExtent = getMapExtent(savedMap.getStore().getState());
const geoFieldNames = mapEmbeddablesSingleton.getGeoFieldNames();
@ -126,21 +126,21 @@ export function initializeCrossPanelActions({
filters: [mapExtentFilter],
controlledBy,
};
const action = getUiActions().getAction(ACTION_GLOBAL_APPLY_FILTER);
const action = await getUiActions().getAction(ACTION_GLOBAL_APPLY_FILTER);
if (!action) {
throw new Error('Unable to apply map extent filter, could not locate action');
}
action.execute(executeContext);
}, 100);
function clearMapExtentFilter() {
async function clearMapExtentFilter() {
prevMapExtent = undefined;
const executeContext = {
...getActionContext(),
filters: [],
controlledBy,
};
const action = getUiActions().getAction(ACTION_GLOBAL_APPLY_FILTER);
const action = await getUiActions().getAction(ACTION_GLOBAL_APPLY_FILTER);
if (!action) {
throw new Error('Unable to apply map extent filter, could not locate action');
}