Drilldowns (#61219)

* Add drilldown wizard components

* Dynamic actions (#58216)

* feat: 🎸 add DynamicAction and FactoryAction types

* feat: 🎸 add Mutable<T> type to @kbn/utility-types

* feat: 🎸 add ActionInternal and ActionContract

* chore: 🤖 remove unused file

* feat: 🎸 improve action interfaces

* docs: ✏️ add JSDocs

* feat: 🎸 simplify ui_actions interfaces

* fix: 🐛 fix TypeScript types

* feat: 🎸 add AbstractPresentable interface

* feat: 🎸 add AbstractConfigurable interface

* feat: 🎸 use AbstractPresentable in ActionInternal

* test: 💍 fix ui_actions Jest tests

* feat: 🎸 add state container to action

* perf: ️ convert MenuItem to React component on Action instance

* refactor: 💡 rename AbsractPresentable -> Presentable

* refactor: 💡 rename AbstractConfigurable -> Configurable

* feat: 🎸 add Storybook to ui_actions

* feat: 🎸 add <ErrorConfigureAction> component

* feat: 🎸 improve <ConfigureAction> component

* chore: 🤖 use .story file extension prefix for Storybook

* feat: 🎸 improve <ErrorConfigureAction> component

* feat: 🎸 show error if dynamic action has CollectConfig missing

* feat: 🎸 render sample action configuration component

* feat: 🎸 connect action config to <ConfigureAction>

* feat: 🎸 improve <ConfigureAction> stories

* test: 💍 add ActionInternal serialize/deserialize tests

* feat: 🎸 add ActionContract

* feat: 🎸 split action Context into Execution and Presentation

* fix: 🐛 fix TypeScript error

* refactor: 💡 extract state container hooks to module scope

* docs: ✏️ fix typos

* chore: 🤖 remove Mutable<t> type

* test: 💍 don't cast to any getActions() function

* style: 💄 avoid using unnecessary types

* chore: 🤖 address PR review comments

* chore: 🤖 rename ActionContext generic

* chore: 🤖 remove order from state container

* chore: 🤖 remove deprecation notice on getHref

* test: 💍 fix tests after order field change

* remove comments

Co-authored-by: Matt Kime <matt@mattki.me>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>

* Drilldown context menu (#59638)

* fix: 🐛 fix TypeScript error

* feat: 🎸 add CONTEXT_MENU_DRILLDOWNS_TRIGGER trigger

* fix: 🐛 correctly order context menu items

* fix: 🐛 set correct order on drilldown flyout actions

* fix: 🐛 clean up context menu building functions

* feat: 🎸 add context menu separator action

* Add basic ActionFactoryService. Pass data from it into components instead of mocks

* Dashboard x pack (#59653)

* feat: 🎸 add dashboard_enhanced plugin to x-pack

* feat: 🎸 improve context menu separator

* feat: 🎸 move drilldown flyout actions to dashboard_enhanced

* fix: 🐛 fix exports from ui_actions plugin

* feat: 🎸 "implement" registerDrilldown() method

* fix ConfigurableBaseConfig type

* Implement connected flyout_manage_drilldowns component

* Simplify connected flyout manage drilldowns component. Remove intermediate component

* clean up data-testid workaround in new components

* Connect welcome message to storage

Not sure, but use LocalStorage. Didn’t find a way to persist user settings. looks like uiSettings are not user scoped.

* require `context` in Presentable. drill context down through wizard components

* Drilldown factory (#59823)

* refactor: 💡 import storage interface from ui_actions plugin

* refactor: 💡 make actions not-dynamic

* feat: 🎸 fix TypeScript errors, reshuffle types and code

* fix: 🐛 fix more TypeScript errors

* fix: 🐛 fix TypeScript import error

* Drilldown registration (#59834)

* feat: 🎸 improve drilldown registration method

* fix: 🐛 set up translations for dashboard_enhanced plugin

* Drilldown events 3 (#59854)

* feat: 🎸 add serialize/unserialize to action

* feat: 🎸 pass in uiActions service into Embeddable

* feat: 🎸 merge ui_actions oss and basic plugins

* refactor: 💡 move action factory registry to OSS

* fix: 🐛 fix TypeScript errors

* Drilldown events 4 (#59876)

* feat: 🎸 mock sample drilldown execute methods

* feat: 🎸 add .dynamicActions manager to Embeddable

* feat: 🎸 add first version of dynamic action manager

* Drilldown events 5 (#59885)

* feat: 🎸 display drilldowns in context menu only on one embed

* feat: 🎸 clear dynamic actions from registry when embed unloads

* fix: 🐛 fix OSS TypeScript errors

* basic integration of components with dynamicActionManager

* fix: 🐛 don't overwrite explicitInput with combined input (#59938)

* display drilldown count in embeddable edit mode

* display drilldown count in embeddable edit mode

* improve wizard components. more tests.

* partial progress, dashboard drilldowns (#59977)

* partial progress, dashboard drilldowns

* partial progress, dashboard drilldowns

* feat: 🎸 improve dashboard drilldown setup

* feat: 🎸 wire in services into dashboard drilldown

* chore: 🤖 add Storybook to dashboard_enhanced

* feat: 🎸 create presentational <DashboardDrilldownConfig>

* test: 💍 add <DashboardDrilldownConfig> stories

* test: 💍 use presentation dashboar config component

* feat: 🎸 wire in services into React component

* docs: ✏️ add README to /components folder

* feat: 🎸 increase importance of Dashboard drilldown

* feat: 🎸 improve icon definition in drilldowns

* chore: 🤖 remove unnecessary comment

* chore: 🤖 add todos

Co-authored-by: streamich <streamich@gmail.com>

* Manage drilldowns toasts. Add basic error handling.

* support order in action factory selector

* fix column order in manage drilldowns list

* remove accidental debug info

* bunch of nit ui fixes

* Drilldowns reactive action manager (#60099)

* feat: 🎸 improve isConfigValid return type

* feat: 🎸 make DynamicActionManager reactive

* docs: ✏️ add JSDocs to public mehtods of DynamicActionManager

* feat: 🎸 make panel top-right corner number badge reactive

* fix: 🐛 correctly await for .deleteEvents()

* Drilldowns various 2 (#60103)

* chore: 🤖 address review comments

* test: 💍 fix embeddable_panel.test.tsx tests

* chore: 🤖 clean up ActionInternal

* chore: 🤖 make isConfigValid a simple predicate

* chore: 🤖 fix TypeScript type errors

* test: 💍 stub DynamicActionManager tests (#60104)

* Drilldowns review 1 (#60139)

* refactor: 💡 improve generic types

* fix: 🐛 don't overwrite icon

* fix: 🐛 fix x-pack TypeScript errors

* fix: 🐛 fix TypeScript error

* fix: 🐛 correct merge

* Drilldowns various 4 (#60264)

* feat: 🎸 hide "Create drilldown" from context menu when needed

* style: 💄 remove AnyDrilldown type

* feat: 🎸 add drilldown factory context

* chore: 🤖 remove sample drilldown

* fix: 🐛 increase spacing between action factory picker

* workaround issue with closing flyout when navigating away

Adds overlay just like other flyouts which makes this defect harder to bump in

* fix react key issue in action_wizard

* don’t open 2 flyouts

* fix action order

https://github.com/elastic/kibana/issues/60138

* Drilldowns reload stored (#60336)

* style: 💄 don't use double equals __

* feat: 🎸 add reload$ to ActionStorage interface

* feat: 🎸 add reload$ to embeddable event storage

* feat: 🎸 add storage syncing to DynamicActionManager

* refactor: 💡 use state from DynamicActionManager in React

* fix: 🐛 add check for manager being stopped

* Drilldowns triggers (#60339)

* feat: 🎸 make use of supportedTriggers()

* feat: 🎸 pass in context to configuration component

* feat: 🎸 augment factory context

* fix: 🐛 stop infinite re-rendering

* Drilldowns multitrigger (#60357)

* feat: 🎸 add support for multiple triggers

* feat: 🎸 enable Drilldowns for TSVB

Although TSVB brushing event is now broken on master, KibanaApp plans to
fix it in 7.7

* "Create drilldown" flyout - design cleanup (#60309)

* create drilldown flyout cleanup

* remove border from selectedActionFactoryContainer

* adjust callout in DrilldownHello

* update form labels

* remove unused file

* fix type error

Co-authored-by: Anton Dosov <anton.dosov@elastic.co>

* basic unit tests for flyout_create_drildown action

* Drilldowns finalize (#60371)

* fix: 🐛 align flyout content to left side

* fix: 🐛 move context menu item number 1px lower

* fix: 🐛 move flyout back nav chevron up

* fix: 🐛 fix type check after refactor

* basic unit tests for drilldown actions

* Drilldowns finalize 2 (#60510)

* test: 💍 fix test mock

* chore: 🤖 remove unused UiActionsService methods

* refactor: 💡 cleanup UiActionsService action registration

* fix: 🐛 add missing functionality after refactor

* test: 💍 add action factory tests

* test: 💍 add DynamicActionManager tests

* feat: 🎸 capture error if it happens during initial load

* fix: 🐛 register correctly CSV action

* feat: 🎸 don't show "OPTIONS" title on drilldown context menus

* feat: 🎸 add server-side for x-pack dashboard plugin

* feat: 🎸 disable Drilldowns for TSVB

* feat: 🎸 enable drilldowns on kibana.yml feature flag

* feat: 🎸 add feature flag comment to kibana.yml

* feat: 🎸 remove places from drilldown interface

* refactor: 💡 remove place in factory context

* chore: 🤖 remove doExecute

* remove not needed now error_configure_action component

* remove workaround for storybook

* feat: 🎸 improve DrilldownDefinition interface

* style: 💄 replace any by unknown

* chore: 🤖 remove any

* chore: 🤖 make isConfigValid return type a boolean

* refactor: 💡 move getDisplayName to factory, remove deprecated

* style: 💄 remove any

* feat: 🎸 improve ActionFactoryDefinition

* refactor: 💡 change visualize_embeddable params

* feat: 🎸 add dashboard dependency to dashboard_enhanced

* style: 💄 rename drilldown plugin life-cycle contracts

* refactor: 💡 do naming adjustments for dashboard drilldown

* fix: 🐛 fix Type error

* fix: 🐛 fix TypeScript type errors

* test: 💍 fix test after refactor

* refactor: 💡 rename context -> placeContext in React component

* chore: 🤖 remove setting from kibana.yml

* refactor: 💡 change return type of getAction as per review

* remove custom css per review

* refactor: 💡 rename drilldownCount to eventCount

* style: 💄 remove any

* refactor: 💡 change how uiActions are passed to vis embeddable

* style: 💄 remove unused import

* fix: 🐛 pass in uiActions to visualize_embeddable

* fix: 🐛 correctly register action

* fix: 🐛 fix type error

* chore: 🤖 remove unused translations

* Dynamic actions to xpack (#62647)

* feat: 🎸 set up sample action factory provider

* feat: 🎸 create dashboard_enhanced plugin

* feat: 🎸 add EnhancedEmbeddable interface

* refactor: 💡 move DynamicActionManager to x-pack

* feat: 🎸 connect dynamic action manager to embeddable life-cycle

* test: 💍 fix Jest tests after refactor

* fix: 🐛 fix type error

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>

* refactor: 💡 move action factories to x-pack (#63190)

* refactor: 💡 move action factories to x-pack

* fix: 🐛 use correct plugin embeddable deps

* test: 💍 fix Jest test after refactor

* chore: 🤖 remove kibana.yml flag (#62441)

* Panel top right (#63466)

* feat: 🎸 add PANEL_NOTIFICATION_TRIGGER

* feat: 🎸 add PanelNotificationsAction action

* test: 💍 add PanelNotificationsAction unit tests

* refactor: 💡 revert addTriggerAction() change

* style: 💄 remove unused import

* fix: 🐛 fix typecheck errors after merge

* support getHref in drilldowns (#63727)

* chore: 🤖 remove ui_actions storybook config

* update docs

* fix ts

* fix: 🐛 fix broken merge

* [Drilldowns] Dashboard to dashboard drilldown (#63108)

* partial progress on async loading / searching of dashboard titles

* feat: 🎸 make combobox full width

* filtering combobox polish

* storybook fix

* implement navigating to dashboard, seems like a type problem

* try navToApp

* filter out current dashboard

* rough draft linking to a dashboard

* remove note

* typefix

* fix navigation from dashboard to dashboard

except for back button - that would be addressed separatly

* partial progress getting filters from action data

* fix issue with getIndexPatterns undefined

we can’t import those functions as static functions, instead we have to expose them on plugin contract because they are statefull

* fix filter / time passing into url

* typefix

* dashboard to dashboard drilldown functional test and back button fix

* documentation update

* chore clean-ups

fix type

* basic unit test for dashboard drilldown

* remove test todos

decided to skip those tests because not clear how to test due to EuiCombobox is using react-virtualized and options list is not rendered in jsdom env

* remove config

* improve back button with filter comparison tweak

* dashboard filters/date option off by default

* revert change to config/kibana.yml

* remove unneeded comments

* use default time range as appropriate

* fix type, add filter icon, add text

* fix test

* change how time range is restored and improve back button for drilldowns

* resolve conflicts

* fix async compile issue

* remove redundant test

* wip

* wip

* fix

* temp skip tests

* fix

* handle missing dashboard edge case

* fix api

* refactor action filter creation utils

* updating

* updating docs

* improve

* fix storybook

* post merge fixes

* fix payload emitted in brush event

* properly export createRange action

* improve tests

* add test

* post merge fixes

* improve

* fix

* improve

* fix build

* wip getHref support

* implement getHref()

* give proper name to a story

* use sync start services

* update text

* fix types

* fix ts

* fix docs

* move clone below drilldowns (near replace)

* remove redundant comments

* refactor action filter creation utils

* updating

* updating docs

* fix payload emitted in brush event

* properly export createRange action

* some more updates

* fixing types

* ...

* inline EventData

* fix typescript in lens and update docs

* improve filters types

* docs

* merge

* @mdefazio review

* adjust actions order

* docs

* @stacey-gammon review

Co-authored-by: Matt Kime <matt@mattki.me>
Co-authored-by: streamich <streamich@gmail.com>
Co-authored-by: ppisljar <peter.pisljar@gmail.com>

* fix docs

* nit fixes

* chore: 🤖 remove uiActions from Embeddable dependencies

* chore: 🤖 don't export ActionInternal from ui_actions

* test: 💍 remove uiActions deps in x-pack test mocks

* chore: 🤖 cleanup ui_actions types

* docs: ✏️ add JSDoc comment to addTriggerAction()

* docs: ✏️ regenerate docs

* Drilldown demo 2 (#64300)

* chore: 🤖 add example of Discover drilldown to sample plugin

* fix: 🐛 show drilldowns with higher "order" first

* feat: 🎸 add createStartServicesGetter() to /public  kibana_util

* feat: 🎸 load index patterns in Discover drilldown

* feat: 🎸 add toggle for index pattern selection

* feat: 🎸 add spacer to separate unrelated config fields

* fix: 🐛 correctly configre setup core

* feat: 🎸 navigate to correct index pattern

* chore: 🤖 fix type check errors

* fix: 🐛 make index pattern select full width

* fix: 🐛 add getHref support

* feat: 🎸 add example plugin ability to X-Pack

* refactor: 💡 move Discover drilldown example to X-Pack

* feat: 🎸 add dashboard-to-url drilldown example

* feat: 🎸 add new tab support for URL drilldown

* feat: 🎸 add "hello world" drilldown example

* docs: ✏️ add README

* feat: 🎸 add getHref support

* chore: 🤖 cleanup after moving examples to X-Pack

* docs: ✏️ add to README.md info on how to find drilldowns

* feat: 🎸 store events in .enhancements field

* docs: ✏️ add comment to range trigger title

* refactor: 💡 move Configurable interface into kibana_utils

* chore: 🤖 simplify internal component types

* refactor: 💡 move registerDrilldwon() to advanced_ui_actions

* test: 💍 update functional test data

* merge

* docs: ✏️ make drilldown enhancement comment more general

* fix: 🐛 return public type from registerAction() call

* docs: ✏️ add comment to value click trigger title field

* docs: ✏️ improve comment

* fix: 🐛 use second argument of CollectConfigProps interface

* fix: 🐛 add workaround for Firefox rendering issue

See:
https://github.com/elastic/kibana/pull/61219/#pullrequestreview-402903330

* chore: 🤖 delete unused file

* fix: 🐛 import type from new location

* style: 💄 make generic type variable name sconsistent

* fix: 🐛 show "Create drilldown" only on dashboard

* test: 💍 add extra unit test for root embeddable type

* docs: ✏️ update generated docs

* chore: 🤖 add example warnings to sample drilldowns

* docs: ✏️ add links to example warnings

* feat: 🎸 add URL drilldown validation and https:// prefixing

* fix: 🐛 disable drilldowns for lens

* refactor: 💡 remove PlaceContext from DrilldownDefinition

* fix: 🐛 fix type check error

* feat: 🎸 show warning message if embeddable not provided

Co-authored-by: Anton Dosov <anton.dosov@elastic.co>
Co-authored-by: Matt Kime <matt@mattki.me>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Andrea Del Rio <delrio.andre@gmail.com>
Co-authored-by: ppisljar <peter.pisljar@gmail.com>
This commit is contained in:
Vadim Dalecky 2020-05-04 16:11:20 +02:00 committed by GitHub
parent cb00e5e7bb
commit 360b9c1200
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
213 changed files with 7816 additions and 1189 deletions

1
.github/CODEOWNERS vendored
View file

@ -3,6 +3,7 @@
# For more info, see https://help.github.com/articles/about-codeowners/
# App
/x-pack/plugins/dashboard_enhanced/ @elastic/kibana-app
/x-pack/plugins/lens/ @elastic/kibana-app
/x-pack/plugins/graph/ @elastic/kibana-app
/src/legacy/server/url_shortening/ @elastic/kibana-app

View file

@ -8,6 +8,7 @@
"data": "src/plugins/data",
"embeddableApi": "src/plugins/embeddable",
"embeddableExamples": "examples/embeddable_examples",
"uiActionsExamples": "examples/ui_action_examples",
"share": "src/plugins/share",
"home": "src/plugins/home",
"charts": "src/plugins/charts",

View file

@ -49,6 +49,7 @@ esFilters: {
generateFilters: typeof generateFilters;
onlyDisabledFiltersChanged: (newFilters?: import("../common").Filter[] | undefined, oldFilters?: import("../common").Filter[] | undefined) => boolean;
changeTimeFilter: typeof changeTimeFilter;
convertRangeFilterToTimeRangeString: typeof convertRangeFilterToTimeRangeString;
mapAndFlattenFilters: (filters: import("../common").Filter[]) => import("../common").Filter[];
extractTimeFilter: typeof extractTimeFilter;
}

View file

@ -18,9 +18,8 @@
*/
import { UiActionExamplesPlugin } from './plugin';
import { PluginInitializer } from '../../../src/core/public';
export const plugin: PluginInitializer<void, void> = () => new UiActionExamplesPlugin();
export const plugin = () => new UiActionExamplesPlugin();
export { HELLO_WORLD_TRIGGER_ID } from './hello_world_trigger';
export { ACTION_HELLO_WORLD } from './hello_world_action';

View file

@ -17,15 +17,19 @@
* under the License.
*/
import { Plugin, CoreSetup } from '../../../src/core/public';
import { UiActionsSetup } from '../../../src/plugins/ui_actions/public';
import { Plugin, CoreSetup, CoreStart } from '../../../src/core/public';
import { UiActionsSetup, UiActionsStart } from '../../../src/plugins/ui_actions/public';
import { createHelloWorldAction, ACTION_HELLO_WORLD } from './hello_world_action';
import { helloWorldTrigger, HELLO_WORLD_TRIGGER_ID } from './hello_world_trigger';
interface UiActionExamplesSetupDependencies {
export interface UiActionExamplesSetupDependencies {
uiActions: UiActionsSetup;
}
export interface UiActionExamplesStartDependencies {
uiActions: UiActionsStart;
}
declare module '../../../src/plugins/ui_actions/public' {
export interface TriggerContextMapping {
[HELLO_WORLD_TRIGGER_ID]: {};
@ -37,8 +41,12 @@ declare module '../../../src/plugins/ui_actions/public' {
}
export class UiActionExamplesPlugin
implements Plugin<void, void, UiActionExamplesSetupDependencies> {
public setup(core: CoreSetup, { uiActions }: UiActionExamplesSetupDependencies) {
implements
Plugin<void, void, UiActionExamplesSetupDependencies, UiActionExamplesStartDependencies> {
public setup(
core: CoreSetup<UiActionExamplesStartDependencies>,
{ uiActions }: UiActionExamplesSetupDependencies
) {
uiActions.registerTrigger(helloWorldTrigger);
const helloWorldAction = createHelloWorldAction(async () => ({
@ -46,9 +54,10 @@ export class UiActionExamplesPlugin
}));
uiActions.registerAction(helloWorldAction);
uiActions.attachAction(helloWorldTrigger.id, helloWorldAction);
uiActions.addTriggerAction(helloWorldTrigger.id, helloWorldAction);
}
public start() {}
public start(core: CoreStart, plugins: UiActionExamplesStartDependencies) {}
public stop() {}
}

View file

@ -95,8 +95,7 @@ const ActionsExplorer = ({ uiActionsApi, openModal }: Props) => {
);
},
});
uiActionsApi.registerAction(dynamicAction);
uiActionsApi.attachAction(HELLO_WORLD_TRIGGER_ID, dynamicAction);
uiActionsApi.addTriggerAction(HELLO_WORLD_TRIGGER_ID, dynamicAction);
setConfirmationText(
`You've successfully added a new action: ${dynamicAction.getDisplayName(
{}

View file

@ -79,21 +79,21 @@ export class UiActionsExplorerPlugin implements Plugin<void, void, {}, StartDeps
const startServices = core.getStartServices();
deps.uiActions.attachAction(
deps.uiActions.addTriggerAction(
USER_TRIGGER,
createPhoneUserAction(async () => (await startServices)[1].uiActions)
);
deps.uiActions.attachAction(
deps.uiActions.addTriggerAction(
USER_TRIGGER,
createEditUserAction(async () => (await startServices)[0].overlays.openModal)
);
deps.uiActions.attachAction(COUNTRY_TRIGGER, viewInMapsAction);
deps.uiActions.attachAction(COUNTRY_TRIGGER, lookUpWeatherAction);
deps.uiActions.attachAction(COUNTRY_TRIGGER, showcasePluggability);
deps.uiActions.attachAction(PHONE_TRIGGER, makePhoneCallAction);
deps.uiActions.attachAction(PHONE_TRIGGER, showcasePluggability);
deps.uiActions.attachAction(USER_TRIGGER, showcasePluggability);
deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, viewInMapsAction);
deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, lookUpWeatherAction);
deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, showcasePluggability);
deps.uiActions.addTriggerAction(PHONE_TRIGGER, makePhoneCallAction);
deps.uiActions.addTriggerAction(PHONE_TRIGGER, showcasePluggability);
deps.uiActions.addTriggerAction(USER_TRIGGER, showcasePluggability);
core.application.register({
id: 'uiActionsExplorer',

View file

@ -91,6 +91,7 @@ export interface OverlayFlyoutStart {
export interface OverlayFlyoutOpenOptions {
className?: string;
closeButtonAriaLabel?: string;
ownFocus?: boolean;
'data-test-subj'?: string;
}

View file

@ -18,12 +18,13 @@
*/
export const storybookAliases = {
advanced_ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js',
apm: 'x-pack/plugins/apm/scripts/storybook.js',
canvas: 'x-pack/legacy/plugins/canvas/scripts/storybook_new.js',
codeeditor: 'src/plugins/kibana_react/public/code_editor/scripts/storybook.ts',
dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/scripts/storybook.js',
drilldowns: 'x-pack/plugins/drilldowns/scripts/storybook.js',
embeddable: 'src/plugins/embeddable/scripts/storybook.js',
infra: 'x-pack/legacy/plugins/infra/scripts/storybook.js',
siem: 'x-pack/plugins/siem/scripts/storybook.js',
ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js',
};

View file

@ -39,7 +39,7 @@ export interface ClonePanelActionContext {
export class ClonePanelAction implements ActionByType<typeof ACTION_CLONE_PANEL> {
public readonly type = ACTION_CLONE_PANEL;
public readonly id = ACTION_CLONE_PANEL;
public order = 11;
public order = 45;
constructor(private core: CoreStart) {}

View file

@ -37,7 +37,7 @@ export interface ReplacePanelActionContext {
export class ReplacePanelAction implements ActionByType<typeof ACTION_REPLACE_PANEL> {
public readonly type = ACTION_REPLACE_PANEL;
public readonly id = ACTION_REPLACE_PANEL;
public order = 11;
public order = 3;
constructor(
private core: CoreStart,

View file

@ -46,7 +46,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => {
const editModeAction = createEditModeAction();
uiActionsSetup.registerAction(editModeAction);
uiActionsSetup.attachAction(CONTEXT_MENU_TRIGGER, editModeAction);
uiActionsSetup.addTriggerAction(CONTEXT_MENU_TRIGGER, editModeAction);
setup.registerEmbeddableFactory(
CONTACT_CARD_EMBEDDABLE,
new ContactCardEmbeddableFactory((() => null) as any, {} as any)

View file

@ -136,7 +136,7 @@ export class DashboardPlugin
): Setup {
const expandPanelAction = new ExpandPanelAction();
uiActions.registerAction(expandPanelAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id);
const startServices = core.getStartServices();
if (share) {
@ -310,11 +310,11 @@ export class DashboardPlugin
plugins.embeddable.getEmbeddableFactories
);
uiActions.registerAction(changeViewAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, changeViewAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, changeViewAction.id);
const clonePanelAction = new ClonePanelAction(core);
uiActions.registerAction(clonePanelAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, clonePanelAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, clonePanelAction.id);
const savedDashboardLoader = createSavedDashboardLoader({
savedObjectsClient: core.savedObjects.client,

View file

@ -42,6 +42,7 @@ export function createFilterAction(
return createAction<typeof ACTION_GLOBAL_APPLY_FILTER>({
type: ACTION_GLOBAL_APPLY_FILTER,
id: ACTION_GLOBAL_APPLY_FILTER,
getIconType: () => 'filter',
getDisplayName: () => {
return i18n.translate('data.filter.applyFilterActionTitle', {
defaultMessage: 'Apply filter to current view',

View file

@ -59,6 +59,7 @@ import {
changeTimeFilter,
mapAndFlattenFilters,
extractTimeFilter,
convertRangeFilterToTimeRangeString,
} from './query';
// Filter helpers namespace:
@ -96,6 +97,7 @@ export const esFilters = {
onlyDisabledFiltersChanged,
changeTimeFilter,
convertRangeFilterToTimeRangeString,
mapAndFlattenFilters,
extractTimeFilter,
};

View file

@ -37,10 +37,14 @@ const indexPatternCache = createIndexPatternCache();
type IndexPatternCachedFieldType = 'id' | 'title';
export interface IndexPatternSavedObjectAttrs {
title: string;
}
export class IndexPatternsService {
private config: IUiSettingsClient;
private savedObjectsClient: SavedObjectsClientContract;
private savedObjectsCache?: Array<SimpleSavedObject<Record<string, any>>> | null;
private savedObjectsCache?: Array<SimpleSavedObject<IndexPatternSavedObjectAttrs>> | null;
private apiClient: IndexPatternsApiClient;
ensureDefaultIndexPattern: EnsureDefaultIndexPattern;
@ -53,7 +57,7 @@ export class IndexPatternsService {
private async refreshSavedObjectsCache() {
this.savedObjectsCache = (
await this.savedObjectsClient.find<Record<string, any>>({
await this.savedObjectsClient.find<IndexPatternSavedObjectAttrs>({
type: 'index-pattern',
fields: ['title'],
perPage: 10000,

View file

@ -126,12 +126,12 @@ export class DataPublicPlugin implements Plugin<DataPublicPluginSetup, DataPubli
createFilterAction(queryService.filterManager, queryService.timefilter.timefilter)
);
uiActions.attachAction(
uiActions.addTriggerAction(
SELECT_RANGE_TRIGGER,
selectRangeAction(queryService.filterManager, queryService.timefilter.timefilter)
);
uiActions.attachAction(
uiActions.addTriggerAction(
VALUE_CLICK_TRIGGER,
valueClickAction(queryService.filterManager, queryService.timefilter.timefilter)
);
@ -172,7 +172,10 @@ export class DataPublicPlugin implements Plugin<DataPublicPluginSetup, DataPubli
});
setSearchService(search);
uiActions.attachAction(APPLY_FILTER_TRIGGER, uiActions.getAction(ACTION_GLOBAL_APPLY_FILTER));
uiActions.addTriggerAction(
APPLY_FILTER_TRIGGER,
uiActions.getAction(ACTION_GLOBAL_APPLY_FILTER)
);
const dataServices = {
actions: {

View file

@ -361,6 +361,7 @@ export const esFilters: {
generateFilters: typeof generateFilters;
onlyDisabledFiltersChanged: (newFilters?: import("../common").Filter[] | undefined, oldFilters?: import("../common").Filter[] | undefined) => boolean;
changeTimeFilter: typeof changeTimeFilter;
convertRangeFilterToTimeRangeString: typeof convertRangeFilterToTimeRangeString;
mapAndFlattenFilters: (filters: import("../common").Filter[]) => import("../common").Filter[];
extractTimeFilter: typeof extractTimeFilter;
};
@ -1783,52 +1784,53 @@ export type TSearchStrategyProvider<T extends TStrategyTypes> = (context: ISearc
// src/plugins/data/common/es_query/filters/match_all_filter.ts:28:3 - (ae-forgotten-export) The symbol "MatchAllFilterMeta" needs to be exported by the entry point index.d.ts
// src/plugins/data/common/es_query/filters/phrase_filter.ts:33:3 - (ae-forgotten-export) The symbol "PhraseFilterMeta" needs to be exported by the entry point index.d.ts
// src/plugins/data/common/es_query/filters/phrases_filter.ts:31:3 - (ae-forgotten-export) The symbol "PhrasesFilterMeta" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "FilterLabel" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "FILTERS" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "getDisplayValueFromFilter" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "generateFilters" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "changeTimeFilter" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "extractTimeFilter" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:135:21 - (ae-forgotten-export) The symbol "buildEsQuery" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:135:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:135:21 - (ae-forgotten-export) The symbol "luceneStringToDsl" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:135:21 - (ae-forgotten-export) The symbol "decorateQuery" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "getRoutes" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:377:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:378:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:387:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:393:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:394:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:397:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:398:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "FilterLabel" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "FILTERS" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "getDisplayValueFromFilter" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "generateFilters" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "changeTimeFilter" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "convertRangeFilterToTimeRangeString" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "extractTimeFilter" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:137:21 - (ae-forgotten-export) The symbol "buildEsQuery" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:137:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:137:21 - (ae-forgotten-export) The symbol "luceneStringToDsl" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:137:21 - (ae-forgotten-export) The symbol "decorateQuery" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "getRoutes" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:379:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:380:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:391:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:395:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/index.ts:403:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/query/state_sync/connect_to_query_state.ts:33:33 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/query/state_sync/connect_to_query_state.ts:37:1 - (ae-forgotten-export) The symbol "QueryStateChange" needs to be exported by the entry point index.d.ts
// src/plugins/data/public/types.ts:52:5 - (ae-forgotten-export) The symbol "createFiltersFromValueClickAction" needs to be exported by the entry point index.d.ts

View file

@ -23,5 +23,5 @@ export * from './types';
export { Timefilter, TimefilterContract } from './timefilter';
export { TimeHistory, TimeHistoryContract } from './time_history';
export { getTime, calculateBounds } from './get_time';
export { changeTimeFilter } from './lib/change_time_filter';
export { changeTimeFilter, convertRangeFilterToTimeRangeString } from './lib/change_time_filter';
export { extractTimeFilter } from './lib/extract_time_filter';

View file

@ -20,7 +20,7 @@
import moment from 'moment';
import { keys } from 'lodash';
import { TimefilterContract } from '../../timefilter';
import { RangeFilter } from '../../../../common';
import { RangeFilter, TimeRange } from '../../../../common';
export function convertRangeFilterToTimeRange(filter: RangeFilter) {
const key = keys(filter.range)[0];
@ -32,6 +32,14 @@ export function convertRangeFilterToTimeRange(filter: RangeFilter) {
};
}
export function convertRangeFilterToTimeRangeString(filter: RangeFilter): TimeRange {
const { from, to } = convertRangeFilterToTimeRange(filter);
return {
from: from?.toISOString(),
to: to?.toISOString(),
};
}
export function changeTimeFilter(timeFilter: TimefilterContract, filter: RangeFilter) {
timeFilter.setTime(convertRangeFilterToTimeRange(filter));
}

View file

@ -31,12 +31,15 @@ import {
ACTION_EDIT_PANEL,
FilterActionContext,
ACTION_APPLY_FILTER,
panelNotificationTrigger,
PANEL_NOTIFICATION_TRIGGER,
} from './lib';
declare module '../../ui_actions/public' {
export interface TriggerContextMapping {
[CONTEXT_MENU_TRIGGER]: EmbeddableContext;
[PANEL_BADGE_TRIGGER]: EmbeddableContext;
[PANEL_NOTIFICATION_TRIGGER]: EmbeddableContext;
}
export interface ActionContextMapping {
@ -56,6 +59,7 @@ declare module '../../ui_actions/public' {
export const bootstrap = (uiActions: UiActionsSetup) => {
uiActions.registerTrigger(contextMenuTrigger);
uiActions.registerTrigger(panelBadgeTrigger);
uiActions.registerTrigger(panelNotificationTrigger);
const actionApplyFilter = createFilterAction();

View file

@ -23,23 +23,24 @@ import { PluginInitializerContext } from 'src/core/public';
import { EmbeddablePublicPlugin } from './plugin';
export {
Adapters,
ACTION_ADD_PANEL,
AddPanelAction,
ACTION_APPLY_FILTER,
ACTION_EDIT_PANEL,
Adapters,
AddPanelAction,
Container,
ContainerInput,
ContainerOutput,
CONTEXT_MENU_TRIGGER,
contextMenuTrigger,
ACTION_EDIT_PANEL,
defaultEmbeddableFactoryProvider,
EditPanelAction,
Embeddable,
EmbeddableChildPanel,
EmbeddableChildPanelProps,
EmbeddableContext,
EmbeddableFactoryDefinition,
EmbeddableFactory,
EmbeddableFactoryDefinition,
EmbeddableFactoryNotFoundError,
EmbeddableFactoryRenderer,
EmbeddableInput,
@ -57,6 +58,8 @@ export {
OutputSpec,
PANEL_BADGE_TRIGGER,
panelBadgeTrigger,
PANEL_NOTIFICATION_TRIGGER,
panelNotificationTrigger,
PanelNotFoundError,
PanelState,
PropertySpec,
@ -64,10 +67,17 @@ export {
withEmbeddableSubscription,
SavedObjectEmbeddableInput,
isSavedObjectEmbeddableInput,
isRangeSelectTriggerContext,
isValueClickTriggerContext,
} from './lib';
export function plugin(initializerContext: PluginInitializerContext) {
return new EmbeddablePublicPlugin(initializerContext);
}
export { EmbeddableSetup, EmbeddableStart } from './plugin';
export {
EmbeddableSetup,
EmbeddableStart,
EmbeddableSetupDependencies,
EmbeddableStartDependencies,
} from './plugin';

View file

@ -34,7 +34,7 @@ interface ActionContext {
export class EditPanelAction implements Action<ActionContext> {
public readonly type = ACTION_EDIT_PANEL;
public readonly id = ACTION_EDIT_PANEL;
public order = 15;
public order = 50;
constructor(
private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'],

View file

@ -16,14 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { isEqual, cloneDeep } from 'lodash';
import { cloneDeep, isEqual } from 'lodash';
import * as Rx from 'rxjs';
import { Adapters } from '../types';
import { Adapters, ViewMode } from '../types';
import { IContainer } from '../containers';
import { IEmbeddable, EmbeddableInput, EmbeddableOutput } from './i_embeddable';
import { ViewMode } from '../types';
import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable';
import { TriggerContextMapping } from '../ui_actions';
import { EmbeddableActionStorage } from './embeddable_action_storage';
function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) {
return input.hidePanelTitles ? '' : input.title === undefined ? output.defaultTitle : input.title;
@ -33,6 +32,10 @@ export abstract class Embeddable<
TEmbeddableInput extends EmbeddableInput = EmbeddableInput,
TEmbeddableOutput extends EmbeddableOutput = EmbeddableOutput
> implements IEmbeddable<TEmbeddableInput, TEmbeddableOutput> {
static runtimeId: number = 0;
public readonly runtimeId = Embeddable.runtimeId++;
public readonly parent?: IContainer;
public readonly isContainer: boolean = false;
public abstract readonly type: string;
@ -51,11 +54,6 @@ export abstract class Embeddable<
// TODO: Rename to destroyed.
private destoyed: boolean = false;
private __actionStorage?: EmbeddableActionStorage;
public get actionStorage(): EmbeddableActionStorage {
return this.__actionStorage || (this.__actionStorage = new EmbeddableActionStorage(this));
}
constructor(input: TEmbeddableInput, output: TEmbeddableOutput, parent?: IContainer) {
this.id = input.id;
this.output = {
@ -158,8 +156,10 @@ export abstract class Embeddable<
*/
public destroy(): void {
this.destoyed = true;
this.input$.complete();
this.output$.complete();
if (this.parentSubscription) {
this.parentSubscription.unsubscribe();
}

View file

@ -1,126 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Embeddable } from '..';
/**
* Below two interfaces are here temporarily, they will move to `ui_actions`
* plugin once #58216 is merged.
*/
export interface SerializedEvent {
eventId: string;
triggerId: string;
action: unknown;
}
export interface ActionStorage {
create(event: SerializedEvent): Promise<void>;
update(event: SerializedEvent): Promise<void>;
remove(eventId: string): Promise<void>;
read(eventId: string): Promise<SerializedEvent>;
count(): Promise<number>;
list(): Promise<SerializedEvent[]>;
}
export class EmbeddableActionStorage implements ActionStorage {
constructor(private readonly embbeddable: Embeddable<any, any>) {}
async create(event: SerializedEvent) {
const input = this.embbeddable.getInput();
const events = (input.events || []) as SerializedEvent[];
const exists = !!events.find(({ eventId }) => eventId === event.eventId);
if (exists) {
throw new Error(
`[EEXIST]: Event with [eventId = ${event.eventId}] already exists on ` +
`[embeddable.id = ${input.id}, embeddable.title = ${input.title}].`
);
}
this.embbeddable.updateInput({
...input,
events: [...events, event],
});
}
async update(event: SerializedEvent) {
const input = this.embbeddable.getInput();
const events = (input.events || []) as SerializedEvent[];
const index = events.findIndex(({ eventId }) => eventId === event.eventId);
if (index === -1) {
throw new Error(
`[ENOENT]: Event with [eventId = ${event.eventId}] could not be ` +
`updated as it does not exist in ` +
`[embeddable.id = ${input.id}, embeddable.title = ${input.title}].`
);
}
this.embbeddable.updateInput({
...input,
events: [...events.slice(0, index), event, ...events.slice(index + 1)],
});
}
async remove(eventId: string) {
const input = this.embbeddable.getInput();
const events = (input.events || []) as SerializedEvent[];
const index = events.findIndex(event => eventId === event.eventId);
if (index === -1) {
throw new Error(
`[ENOENT]: Event with [eventId = ${eventId}] could not be ` +
`removed as it does not exist in ` +
`[embeddable.id = ${input.id}, embeddable.title = ${input.title}].`
);
}
this.embbeddable.updateInput({
...input,
events: [...events.slice(0, index), ...events.slice(index + 1)],
});
}
async read(eventId: string): Promise<SerializedEvent> {
const input = this.embbeddable.getInput();
const events = (input.events || []) as SerializedEvent[];
const event = events.find(ev => eventId === ev.eventId);
if (!event) {
throw new Error(
`[ENOENT]: Event with [eventId = ${eventId}] could not be found in ` +
`[embeddable.id = ${input.id}, embeddable.title = ${input.title}].`
);
}
return event;
}
private __list() {
const input = this.embbeddable.getInput();
return (input.events || []) as SerializedEvent[];
}
async count(): Promise<number> {
return this.__list().length;
}
async list(): Promise<SerializedEvent[]> {
return this.__list();
}
}

View file

@ -36,9 +36,9 @@ export interface EmbeddableInput {
hidePanelTitles?: boolean;
/**
* Reserved key for `ui_actions` events.
* Reserved key for enhancements added by other plugins.
*/
events?: unknown;
enhancements?: unknown;
/**
* List of action IDs that this embeddable should not render.
@ -91,6 +91,19 @@ export interface IEmbeddable<
**/
readonly id: string;
/**
* Unique ID an embeddable is assigned each time it is initialized. This ID
* is different for different instances of the same embeddable. For example,
* if the same dashboard is rendered twice on the screen, all embeddable
* instances will have a unique `runtimeId`.
*/
readonly runtimeId?: number;
/**
* Extra abilities added to Embeddable by `*_enhanced` plugins.
*/
enhancements?: object;
/**
* A functional representation of the isContainer variable, but helpful for typescript to
* know the shape if this returns true

View file

@ -45,7 +45,7 @@ import { inspectorPluginMock } from '../../../../inspector/public/mocks';
import { EuiBadge } from '@elastic/eui';
import { embeddablePluginMock } from '../../mocks';
const actionRegistry = new Map<string, Action<object | undefined | string | number>>();
const actionRegistry = new Map<string, Action>();
const triggerRegistry = new Map<string, Trigger>();
const { setup, doStart } = embeddablePluginMock.createInstance();
@ -214,13 +214,17 @@ const renderInEditModeAndOpenContextMenu = async (
};
test('HelloWorldContainer in edit mode hides disabledActions', async () => {
const action: Action = {
const action = {
id: 'FOO',
type: 'FOO' as ActionType,
getIconType: () => undefined,
getDisplayName: () => 'foo',
isCompatible: async () => true,
execute: async () => {},
order: 10,
getHref: () => {
return Promise.resolve(undefined);
},
};
const getActions = () => Promise.resolve([action]);
@ -246,13 +250,17 @@ test('HelloWorldContainer in edit mode hides disabledActions', async () => {
});
test('HelloWorldContainer hides disabled badges', async () => {
const action: Action = {
const action = {
id: 'BAR',
type: 'BAR' as ActionType,
getIconType: () => undefined,
getDisplayName: () => 'bar',
isCompatible: async () => true,
execute: async () => {},
order: 10,
getHref: () => {
return Promise.resolve(undefined);
},
};
const getActions = () => Promise.resolve([action]);

View file

@ -25,7 +25,12 @@ import { CoreStart, OverlayStart } from '../../../../../core/public';
import { toMountPoint } from '../../../../kibana_react/public';
import { Start as InspectorStartContract } from '../inspector';
import { CONTEXT_MENU_TRIGGER, PANEL_BADGE_TRIGGER, EmbeddableContext } from '../triggers';
import {
CONTEXT_MENU_TRIGGER,
PANEL_BADGE_TRIGGER,
PANEL_NOTIFICATION_TRIGGER,
EmbeddableContext,
} from '../triggers';
import { IEmbeddable } from '../embeddables/i_embeddable';
import { ViewMode } from '../types';
@ -38,6 +43,14 @@ import { EditPanelAction } from '../actions';
import { CustomizePanelModal } from './panel_header/panel_actions/customize_title/customize_panel_modal';
import { EmbeddableStart } from '../../plugin';
const sortByOrderField = (
{ order: orderA }: { order?: number },
{ order: orderB }: { order?: number }
) => (orderB || 0) - (orderA || 0);
const removeById = (disabledActions: string[]) => ({ id }: { id: string }) =>
disabledActions.indexOf(id) === -1;
interface Props {
embeddable: IEmbeddable<any, any>;
getActions: UiActionsService['getTriggerCompatibleActions'];
@ -58,6 +71,7 @@ interface State {
hidePanelTitles: boolean;
closeContextMenu: boolean;
badges: Array<Action<EmbeddableContext>>;
notifications: Array<Action<EmbeddableContext>>;
}
export class EmbeddablePanel extends React.Component<Props, State> {
@ -83,6 +97,7 @@ export class EmbeddablePanel extends React.Component<Props, State> {
hidePanelTitles,
closeContextMenu: false,
badges: [],
notifications: [],
};
this.embeddableRoot = React.createRef();
@ -104,6 +119,22 @@ export class EmbeddablePanel extends React.Component<Props, State> {
});
}
private async refreshNotifications() {
let notifications = await this.props.getActions(PANEL_NOTIFICATION_TRIGGER, {
embeddable: this.props.embeddable,
});
if (!this.mounted) return;
const { disabledActions } = this.props.embeddable.getInput();
if (disabledActions) {
notifications = notifications.filter(badge => disabledActions.indexOf(badge.id) === -1);
}
this.setState({
notifications,
});
}
public UNSAFE_componentWillMount() {
this.mounted = true;
const { embeddable } = this.props;
@ -116,6 +147,7 @@ export class EmbeddablePanel extends React.Component<Props, State> {
});
this.refreshBadges();
this.refreshNotifications();
}
});
@ -127,6 +159,7 @@ export class EmbeddablePanel extends React.Component<Props, State> {
});
this.refreshBadges();
this.refreshNotifications();
}
});
}
@ -176,6 +209,7 @@ export class EmbeddablePanel extends React.Component<Props, State> {
closeContextMenu={this.state.closeContextMenu}
title={title}
badges={this.state.badges}
notifications={this.state.notifications}
embeddable={this.props.embeddable}
headerId={headerId}
/>
@ -202,13 +236,14 @@ export class EmbeddablePanel extends React.Component<Props, State> {
};
private getActionContextMenuPanel = async () => {
let actions = await this.props.getActions(CONTEXT_MENU_TRIGGER, {
let regularActions = await this.props.getActions(CONTEXT_MENU_TRIGGER, {
embeddable: this.props.embeddable,
});
const { disabledActions } = this.props.embeddable.getInput();
if (disabledActions) {
actions = actions.filter(action => disabledActions.indexOf(action.id) === -1);
const removeDisabledActions = removeById(disabledActions);
regularActions = regularActions.filter(removeDisabledActions);
}
const createGetUserData = (overlays: OverlayStart) =>
@ -247,16 +282,10 @@ export class EmbeddablePanel extends React.Component<Props, State> {
new EditPanelAction(this.props.getEmbeddableFactory, this.props.application),
];
const sorted = actions
.concat(extraActions)
.sort((a: Action<EmbeddableContext>, b: Action<EmbeddableContext>) => {
const bOrder = b.order || 0;
const aOrder = a.order || 0;
return bOrder - aOrder;
});
const sortedActions = [...regularActions, ...extraActions].sort(sortByOrderField);
return await buildContextMenuForActions({
actions: sorted,
actions: sortedActions,
actionContext: { embeddable: this.props.embeddable },
closeMenu: this.closeMyContextMenuPanel,
});

View file

@ -33,15 +33,13 @@ interface ActionContext {
export class CustomizePanelTitleAction implements Action<ActionContext> {
public readonly type = ACTION_CUSTOMIZE_PANEL;
public id = ACTION_CUSTOMIZE_PANEL;
public order = 10;
public order = 40;
constructor(private readonly getDataFromUser: GetUserData) {
this.order = 10;
}
constructor(private readonly getDataFromUser: GetUserData) {}
public getDisplayName() {
return i18n.translate('embeddableApi.customizePanel.action.displayName', {
defaultMessage: 'Customize panel',
defaultMessage: 'Edit panel title',
});
}

View file

@ -31,7 +31,7 @@ interface ActionContext {
export class InspectPanelAction implements Action<ActionContext> {
public readonly type = ACTION_INSPECT_PANEL;
public readonly id = ACTION_INSPECT_PANEL;
public order = 10;
public order = 20;
constructor(private readonly inspector: InspectorStartContract) {}

View file

@ -41,7 +41,7 @@ function hasExpandedPanelInput(
export class RemovePanelAction implements Action<ActionContext> {
public readonly type = REMOVE_PANEL_ACTION;
public readonly id = REMOVE_PANEL_ACTION;
public order = 5;
public order = 1;
constructor() {}

View file

@ -23,6 +23,7 @@ import {
EuiIcon,
EuiToolTip,
EuiScreenReaderOnly,
EuiNotificationBadge,
} from '@elastic/eui';
import classNames from 'classnames';
import React from 'react';
@ -38,6 +39,7 @@ export interface PanelHeaderProps {
getActionContextMenuPanel: () => Promise<EuiContextMenuPanelDescriptor>;
closeContextMenu: boolean;
badges: Array<Action<EmbeddableContext>>;
notifications: Array<Action<EmbeddableContext>>;
embeddable: IEmbeddable;
headerId?: string;
}
@ -56,6 +58,22 @@ function renderBadges(badges: Array<Action<EmbeddableContext>>, embeddable: IEmb
));
}
function renderNotifications(
notifications: Array<Action<EmbeddableContext>>,
embeddable: IEmbeddable
) {
return notifications.map(notification => (
<EuiNotificationBadge
data-test-subj={`embeddablePanelNotification-${notification.id}`}
key={notification.id}
style={{ marginTop: '4px', marginRight: '4px' }}
onClick={() => notification.execute({ embeddable })}
>
{notification.getDisplayName({ embeddable })}
</EuiNotificationBadge>
));
}
function renderTooltip(description: string) {
return (
description !== '' && (
@ -88,6 +106,7 @@ export function PanelHeader({
getActionContextMenuPanel,
closeContextMenu,
badges,
notifications,
embeddable,
headerId,
}: PanelHeaderProps) {
@ -147,7 +166,7 @@ export function PanelHeader({
)}
{renderBadges(badges, embeddable)}
</h2>
{renderNotifications(notifications, embeddable)}
<PanelOptionsMenu
isViewMode={isViewMode}
getActionContextMenuPanel={getActionContextMenuPanel}

View file

@ -17,16 +17,16 @@
* under the License.
*/
import { Trigger } from '../../../../ui_actions/public';
import { KibanaDatatable } from '../../../../expressions';
import { Trigger } from '../../../../ui_actions/public';
import { IEmbeddable } from '..';
export interface EmbeddableContext {
embeddable: IEmbeddable;
}
export interface ValueClickTriggerContext {
embeddable?: IEmbeddable;
export interface ValueClickTriggerContext<T extends IEmbeddable = IEmbeddable> {
embeddable?: T;
timeFieldName?: string;
data: {
data: Array<{
@ -39,8 +39,12 @@ export interface ValueClickTriggerContext {
};
}
export interface RangeSelectTriggerContext {
embeddable?: IEmbeddable;
export const isValueClickTriggerContext = (
context: ValueClickTriggerContext | RangeSelectTriggerContext
): context is ValueClickTriggerContext => context.data && 'data' in context.data;
export interface RangeSelectTriggerContext<T extends IEmbeddable = IEmbeddable> {
embeddable?: T;
timeFieldName?: string;
data: {
table: KibanaDatatable;
@ -49,6 +53,10 @@ export interface RangeSelectTriggerContext {
};
}
export const isRangeSelectTriggerContext = (
context: ValueClickTriggerContext | RangeSelectTriggerContext
): context is RangeSelectTriggerContext => context.data && 'range' in context.data;
export const CONTEXT_MENU_TRIGGER = 'CONTEXT_MENU_TRIGGER';
export const contextMenuTrigger: Trigger<'CONTEXT_MENU_TRIGGER'> = {
id: CONTEXT_MENU_TRIGGER,
@ -60,5 +68,12 @@ export const PANEL_BADGE_TRIGGER = 'PANEL_BADGE_TRIGGER';
export const panelBadgeTrigger: Trigger<'PANEL_BADGE_TRIGGER'> = {
id: PANEL_BADGE_TRIGGER,
title: 'Panel badges',
description: 'Actions appear in title bar when an embeddable loads in a panel',
description: 'Actions appear in title bar when an embeddable loads in a panel.',
};
export const PANEL_NOTIFICATION_TRIGGER = 'PANEL_NOTIFICATION_TRIGGER';
export const panelNotificationTrigger: Trigger<'PANEL_NOTIFICATION_TRIGGER'> = {
id: PANEL_NOTIFICATION_TRIGGER,
title: 'Panel notifications',
description: 'Actions appear in top-right corner of a panel.',
};

View file

@ -16,7 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { EmbeddableStart, EmbeddableSetup } from '.';
import {
EmbeddableStart,
EmbeddableSetup,
EmbeddableSetupDependencies,
EmbeddableStartDependencies,
} from '.';
import { EmbeddablePublicPlugin } from './plugin';
import { coreMock } from '../../../core/public/mocks';
@ -45,14 +50,14 @@ const createStartContract = (): Start => {
return startContract;
};
const createInstance = () => {
const createInstance = (setupPlugins: Partial<EmbeddableSetupDependencies> = {}) => {
const plugin = new EmbeddablePublicPlugin({} as any);
const setup = plugin.setup(coreMock.createSetup(), {
uiActions: uiActionsPluginMock.createSetupContract(),
uiActions: setupPlugins.uiActions || uiActionsPluginMock.createSetupContract(),
});
const doStart = () =>
const doStart = (startPlugins: Partial<EmbeddableStartDependencies> = {}) =>
plugin.start(coreMock.createStart(), {
uiActions: uiActionsPluginMock.createStartContract(),
uiActions: startPlugins.uiActions || uiActionsPluginMock.createStartContract(),
inspector: inspectorPluginMock.createStartContract(),
});
return {

View file

@ -24,15 +24,58 @@ import { Comparator, Connect, StateContainer, UnboxState } from './types';
const { useContext, useLayoutEffect, useRef, createElement: h } = React;
/**
* Returns the latest state of a state container.
*
* @param container State container which state to track.
*/
export const useContainerState = <Container extends StateContainer<any, any>>(
container: Container
): UnboxState<Container> => useObservable(container.state$, container.get());
/**
* Apply selector to state container to extract only needed information. Will
* re-render your component only when the section changes.
*
* @param container State container which state to track.
* @param selector Function used to pick parts of state.
* @param comparator Comparator function used to memoize previous result, to not
* re-render React component if state did not change. By default uses
* `fast-deep-equal` package.
*/
export const useContainerSelector = <Container extends StateContainer<any, any>, Result>(
container: Container,
selector: (state: UnboxState<Container>) => Result,
comparator: Comparator<Result> = defaultComparator
): Result => {
const { state$, get } = container;
const lastValueRef = useRef<Result>(get());
const [value, setValue] = React.useState<Result>(() => {
const newValue = selector(get());
lastValueRef.current = newValue;
return newValue;
});
useLayoutEffect(() => {
const subscription = state$.subscribe((currentState: UnboxState<Container>) => {
const newValue = selector(currentState);
if (!comparator(lastValueRef.current, newValue)) {
lastValueRef.current = newValue;
setValue(newValue);
}
});
return () => subscription.unsubscribe();
}, [state$, comparator]);
return value;
};
export const createStateContainerReactHelpers = <Container extends StateContainer<any, any>>() => {
const context = React.createContext<Container>(null as any);
const useContainer = (): Container => useContext(context);
const useState = (): UnboxState<Container> => {
const { state$, get } = useContainer();
const value = useObservable(state$, get());
return value;
const container = useContainer();
return useContainerState(container);
};
const useTransitions: () => Container['transitions'] = () => useContainer().transitions;
@ -41,24 +84,8 @@ export const createStateContainerReactHelpers = <Container extends StateContaine
selector: (state: UnboxState<Container>) => Result,
comparator: Comparator<Result> = defaultComparator
): Result => {
const { state$, get } = useContainer();
const lastValueRef = useRef<Result>(get());
const [value, setValue] = React.useState<Result>(() => {
const newValue = selector(get());
lastValueRef.current = newValue;
return newValue;
});
useLayoutEffect(() => {
const subscription = state$.subscribe((currentState: UnboxState<Container>) => {
const newValue = selector(currentState);
if (!comparator(lastValueRef.current, newValue)) {
lastValueRef.current = newValue;
setValue(newValue);
}
});
return () => subscription.unsubscribe();
}, [state$, comparator]);
return value;
const container = useContainer();
return useContainerSelector<Container, Result>(container, selector, comparator);
};
const connect: Connect<UnboxState<Container>> = mapStateToProp => component => props =>

View file

@ -43,7 +43,7 @@ export interface BaseStateContainer<State extends BaseState> {
export interface StateContainer<
State extends BaseState,
PureTransitions extends object,
PureTransitions extends object = object,
PureSelectors extends object = {}
> extends BaseStateContainer<State> {
transitions: Readonly<PureTransitionsToTransitions<PureTransitions>>;

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { createStateContainer, StateContainer, of } from './common';

View file

@ -74,8 +74,10 @@ export {
StartSyncStateFnType,
StopSyncStateFnType,
} from './state_sync';
export { Configurable, CollectConfigProps } from './ui';
export { removeQueryParam, redirectWhenMissing } from './history';
export { applyDiff } from './state_management/utils/diff_object';
export { createStartServicesGetter, StartServicesGetter } from './core/create_start_service_getter';
/** dummy plugin, we just want kibanaUtils to have its own bundle */
export function plugin() {

View file

@ -0,0 +1,60 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { UiComponent } from '../../common/ui/ui_component';
/**
* Represents something that can be configured by user using UI.
*/
export interface Configurable<Config extends object = object, Context = object> {
/**
* Create default config for this item, used when item is created for the first time.
*/
readonly createConfig: () => Config;
/**
* Is this config valid. Used to validate user's input before saving.
*/
readonly isConfigValid: (config: Config) => boolean;
/**
* `UiComponent` to be rendered when collecting configuration for this item.
*/
readonly CollectConfig: UiComponent<CollectConfigProps<Config, Context>>;
}
/**
* Props provided to `CollectConfig` component on every re-render.
*/
export interface CollectConfigProps<Config extends object = object, Context = object> {
/**
* Current (latest) config of the item.
*/
config: Config;
/**
* Callback called when user updates the config in UI.
*/
onConfig: (config: Config) => void;
/**
* Context information about where component is being rendered.
*/
context: Context;
}

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export * from './configurable';

View file

@ -19,10 +19,12 @@
import { UiComponent } from 'src/plugins/kibana_utils/public';
import { ActionType, ActionContextMapping } from '../types';
import { Presentable } from '../util/presentable';
export type ActionByType<T extends ActionType> = Action<ActionContextMapping[T], T>;
export interface Action<Context = {}, T = ActionType> {
export interface Action<Context extends {} = {}, T = ActionType>
extends Partial<Presentable<Context>> {
/**
* Determined the order when there is more than one action matched to a trigger.
* Higher numbers are displayed first.
@ -63,14 +65,30 @@ export interface Action<Context = {}, T = ActionType> {
isCompatible(context: Context): Promise<boolean>;
/**
* If this returns something truthy, this will be used as [href] attribute on a link if possible (e.g. in context menu item)
* to support right click -> open in a new tab behavior.
* For regular click navigation is prevented and `execute()` takes control.
* Executes the action.
*/
getHref?(context: Context): Promise<string | undefined>;
execute(context: Context): Promise<void>;
}
/**
* A convenience interface used to register an action.
*/
export interface ActionDefinition<Context extends object = object>
extends Partial<Presentable<Context>> {
/**
* ID of the action that uniquely identifies this action in the actions registry.
*/
readonly id: string;
/**
* ID of the factory for this action. Used to construct dynamic actions.
*/
readonly type?: ActionType;
/**
* Executes the action.
*/
execute(context: Context): Promise<void>;
}
export type ActionContext<A> = A extends ActionDefinition<infer Context> ? Context : never;

View file

@ -1,72 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { UiComponent } from 'src/plugins/kibana_utils/public';
import { ActionType, ActionContextMapping } from '../types';
export interface ActionDefinition<T extends ActionType> {
/**
* Determined the order when there is more than one action matched to a trigger.
* Higher numbers are displayed first.
*/
order?: number;
/**
* A unique identifier for this action instance.
*/
id?: string;
/**
* The action type is what determines the context shape.
*/
readonly type: T;
/**
* Optional EUI icon type that can be displayed along with the title.
*/
getIconType?(context: ActionContextMapping[T]): string;
/**
* Returns a title to be displayed to the user.
* @param context
*/
getDisplayName?(context: ActionContextMapping[T]): string;
/**
* `UiComponent` to render when displaying this action as a context menu item.
* If not provided, `getDisplayName` will be used instead.
*/
MenuItem?: UiComponent<{ context: ActionContextMapping[T] }>;
/**
* Returns a promise that resolves to true if this action is compatible given the context,
* otherwise resolves to false.
*/
isCompatible?(context: ActionContextMapping[T]): Promise<boolean>;
/**
* If this returns something truthy, this is used in addition to the `execute` method when clicked.
*/
getHref?(context: ActionContextMapping[T]): Promise<string | undefined>;
/**
* Executes the action.
*/
execute(context: ActionContextMapping[T]): Promise<void>;
}

View file

@ -0,0 +1,33 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ActionDefinition } from './action';
import { ActionInternal } from './action_internal';
const defaultActionDef: ActionDefinition = {
id: 'test-action',
execute: jest.fn(),
};
describe('ActionInternal', () => {
test('can instantiate from action definition', () => {
const action = new ActionInternal(defaultActionDef);
expect(action.id).toBe('test-action');
});
});

View file

@ -0,0 +1,58 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Action, ActionContext as Context, ActionDefinition } from './action';
import { Presentable } from '../util/presentable';
import { uiToReactComponent } from '../../../kibana_react/public';
import { ActionType } from '../types';
export class ActionInternal<A extends ActionDefinition = ActionDefinition>
implements Action<Context<A>>, Presentable<Context<A>> {
constructor(public readonly definition: A) {}
public readonly id: string = this.definition.id;
public readonly type: ActionType = this.definition.type || '';
public readonly order: number = this.definition.order || 0;
public readonly MenuItem? = this.definition.MenuItem;
public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined;
public execute(context: Context<A>) {
return this.definition.execute(context);
}
public getIconType(context: Context<A>): string | undefined {
if (!this.definition.getIconType) return undefined;
return this.definition.getIconType(context);
}
public getDisplayName(context: Context<A>): string {
if (!this.definition.getDisplayName) return `Action: ${this.id}`;
return this.definition.getDisplayName(context);
}
public async isCompatible(context: Context<A>): Promise<boolean> {
if (!this.definition.isCompatible) return true;
return await this.definition.isCompatible(context);
}
public async getHref(context: Context<A>): Promise<string | undefined> {
if (!this.definition.getHref) return undefined;
return await this.definition.getHref(context);
}
}

View file

@ -17,11 +17,19 @@
* under the License.
*/
import { ActionContextMapping } from '../types';
import { ActionByType } from './action';
import { ActionType } from '../types';
import { ActionDefinition } from './action_definition';
import { ActionDefinition } from './action';
export function createAction<T extends ActionType>(action: ActionDefinition<T>): ActionByType<T> {
interface ActionDefinitionByType<T extends ActionType>
extends Omit<ActionDefinition<ActionContextMapping[T]>, 'id'> {
id?: string;
}
export function createAction<T extends ActionType>(
action: ActionDefinitionByType<T>
): ActionByType<T> {
return {
getIconType: () => undefined,
order: 0,
@ -29,5 +37,5 @@ export function createAction<T extends ActionType>(action: ActionDefinition<T>):
isCompatible: () => Promise.resolve(true),
getDisplayName: () => '',
...action,
};
} as ActionByType<T>;
}

View file

@ -18,5 +18,6 @@
*/
export * from './action';
export * from './action_internal';
export * from './create_action';
export * from './incompatible_action_error';

View file

@ -24,19 +24,25 @@ import { i18n } from '@kbn/i18n';
import { uiToReactComponent } from '../../../kibana_react/public';
import { Action } from '../actions';
export const defaultTitle = i18n.translate('uiActions.actionPanel.title', {
defaultMessage: 'Options',
});
/**
* Transforms an array of Actions to the shape EuiContextMenuPanel expects.
*/
export async function buildContextMenuForActions<A>({
export async function buildContextMenuForActions<Context extends object>({
actions,
actionContext,
title = defaultTitle,
closeMenu,
}: {
actions: Array<Action<A>>;
actionContext: A;
actions: Array<Action<Context>>;
actionContext: Context;
title?: string;
closeMenu: () => void;
}): Promise<EuiContextMenuPanelDescriptor> {
const menuItems = await buildEuiContextMenuPanelItems<A>({
const menuItems = await buildEuiContextMenuPanelItems<Context>({
actions,
actionContext,
closeMenu,
@ -44,9 +50,7 @@ export async function buildContextMenuForActions<A>({
return {
id: 'mainMenu',
title: i18n.translate('uiActions.actionPanel.title', {
defaultMessage: 'Options',
}),
title,
items: menuItems,
};
}
@ -54,49 +58,41 @@ export async function buildContextMenuForActions<A>({
/**
* Transform an array of Actions into the shape needed to build an EUIContextMenu
*/
async function buildEuiContextMenuPanelItems<A>({
async function buildEuiContextMenuPanelItems<Context extends object>({
actions,
actionContext,
closeMenu,
}: {
actions: Array<Action<A>>;
actionContext: A;
actions: Array<Action<Context>>;
actionContext: Context;
closeMenu: () => void;
}) {
const items: EuiContextMenuPanelItemDescriptor[] = [];
const promises = actions.map(async action => {
const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length);
const promises = actions.map(async (action, index) => {
const isCompatible = await action.isCompatible(actionContext);
if (!isCompatible) {
return;
}
items.push(
await convertPanelActionToContextMenuItem({
action,
actionContext,
closeMenu,
})
);
items[index] = await convertPanelActionToContextMenuItem({
action,
actionContext,
closeMenu,
});
});
await Promise.all(promises);
return items;
return items.filter(Boolean);
}
/**
*
* @param {ContextMenuAction} action
* @param {Embeddable} embeddable
* @return {Promise<EuiContextMenuPanelItemDescriptor>}
*/
async function convertPanelActionToContextMenuItem<A>({
async function convertPanelActionToContextMenuItem<Context extends object>({
action,
actionContext,
closeMenu,
}: {
action: Action<A>;
actionContext: A;
action: Action<Context>;
actionContext: Context;
closeMenu: () => void;
}): Promise<EuiContextMenuPanelItemDescriptor> {
const menuPanelItem: EuiContextMenuPanelItemDescriptor = {

View file

@ -149,7 +149,11 @@ export function openContextMenu(
anchorPosition="downRight"
withTitle
>
<EuiContextMenu initialPanelId="mainMenu" panels={panels} />
<EuiContextMenu
initialPanelId="mainMenu"
panels={panels}
data-test-subj={props['data-test-subj']}
/>
</EuiPopover>,
container
);

View file

@ -26,8 +26,14 @@ export function plugin(initializerContext: PluginInitializerContext) {
export { UiActionsSetup, UiActionsStart } from './plugin';
export { UiActionsServiceParams, UiActionsService } from './service';
export { Action, createAction, IncompatibleActionError } from './actions';
export {
Action,
ActionDefinition as UiActionsActionDefinition,
createAction,
IncompatibleActionError,
} from './actions';
export { buildContextMenuForActions } from './context_menu';
export { Presentable as UiActionsPresentable } from './util';
export {
Trigger,
TriggerContext,

View file

@ -28,10 +28,12 @@ export type Start = jest.Mocked<UiActionsStart>;
const createSetupContract = (): Setup => {
const setupContract: Setup = {
addTriggerAction: jest.fn(),
attachAction: jest.fn(),
detachAction: jest.fn(),
registerAction: jest.fn(),
registerTrigger: jest.fn(),
unregisterAction: jest.fn(),
};
return setupContract;
};
@ -39,16 +41,18 @@ const createSetupContract = (): Setup => {
const createStartContract = (): Start => {
const startContract: Start = {
attachAction: jest.fn(),
registerAction: jest.fn(),
registerTrigger: jest.fn(),
getAction: jest.fn(),
unregisterAction: jest.fn(),
addTriggerAction: jest.fn(),
clear: jest.fn(),
detachAction: jest.fn(),
executeTriggerActions: jest.fn(),
fork: jest.fn(),
getAction: jest.fn(),
getTrigger: jest.fn(),
getTriggerActions: jest.fn((id: TriggerId) => []),
getTriggerCompatibleActions: jest.fn(),
clear: jest.fn(),
fork: jest.fn(),
registerAction: jest.fn(),
registerTrigger: jest.fn(),
};
return startContract;

View file

@ -23,7 +23,12 @@ import { selectRangeTrigger, valueClickTrigger, applyFilterTrigger } from './tri
export type UiActionsSetup = Pick<
UiActionsService,
'attachAction' | 'detachAction' | 'registerAction' | 'registerTrigger'
| 'addTriggerAction'
| 'attachAction'
| 'detachAction'
| 'registerAction'
| 'registerTrigger'
| 'unregisterAction'
>;
export type UiActionsStart = PublicMethodsOf<UiActionsService>;

View file

@ -18,7 +18,7 @@
*/
import { UiActionsService } from './ui_actions_service';
import { Action, createAction } from '../actions';
import { Action, ActionInternal, createAction } from '../actions';
import { createHelloWorldAction } from '../tests/test_samples';
import { ActionRegistry, TriggerRegistry, TriggerId, ActionType } from '../types';
import { Trigger } from '../triggers';
@ -102,6 +102,21 @@ describe('UiActionsService', () => {
type: 'test' as ActionType,
});
});
test('return action instance', () => {
const service = new UiActionsService();
const action = service.registerAction({
id: 'test',
execute: async () => {},
getDisplayName: () => 'test',
getIconType: () => '',
isCompatible: async () => true,
type: 'test' as ActionType,
});
expect(action).toBeInstanceOf(ActionInternal);
expect(action.id).toBe('test');
});
});
describe('.getTriggerActions()', () => {
@ -139,13 +154,14 @@ describe('UiActionsService', () => {
expect(list0).toHaveLength(0);
service.attachAction(FOO_TRIGGER, action1);
service.addTriggerAction(FOO_TRIGGER, action1);
const list1 = service.getTriggerActions(FOO_TRIGGER);
expect(list1).toHaveLength(1);
expect(list1).toEqual([action1]);
expect(list1[0]).toBeInstanceOf(ActionInternal);
expect(list1[0].id).toBe(action1.id);
service.attachAction(FOO_TRIGGER, action2);
service.addTriggerAction(FOO_TRIGGER, action2);
const list2 = service.getTriggerActions(FOO_TRIGGER);
expect(list2).toHaveLength(2);
@ -164,7 +180,7 @@ describe('UiActionsService', () => {
service.registerAction(helloWorldAction);
expect(actions.size - length).toBe(1);
expect(actions.get(helloWorldAction.id)).toBe(helloWorldAction);
expect(actions.get(helloWorldAction.id)!.id).toBe(helloWorldAction.id);
});
test('getTriggerCompatibleActions returns attached actions', async () => {
@ -178,7 +194,7 @@ describe('UiActionsService', () => {
title: 'My trigger',
};
service.registerTrigger(testTrigger);
service.attachAction(MY_TRIGGER, helloWorldAction);
service.addTriggerAction(MY_TRIGGER, helloWorldAction);
const compatibleActions = await service.getTriggerCompatibleActions(MY_TRIGGER, {
hi: 'there',
@ -204,7 +220,7 @@ describe('UiActionsService', () => {
};
service.registerTrigger(testTrigger);
service.attachAction(testTrigger.id, action);
service.addTriggerAction(testTrigger.id, action);
const compatibleActions1 = await service.getTriggerCompatibleActions(testTrigger.id, {
accept: true,
@ -288,7 +304,7 @@ describe('UiActionsService', () => {
id: FOO_TRIGGER,
});
service1.registerAction(testAction1);
service1.attachAction(FOO_TRIGGER, testAction1);
service1.addTriggerAction(FOO_TRIGGER, testAction1);
const service2 = service1.fork();
@ -309,14 +325,14 @@ describe('UiActionsService', () => {
});
service1.registerAction(testAction1);
service1.registerAction(testAction2);
service1.attachAction(FOO_TRIGGER, testAction1);
service1.addTriggerAction(FOO_TRIGGER, testAction1);
const service2 = service1.fork();
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
service2.attachAction(FOO_TRIGGER, testAction2);
service2.addTriggerAction(FOO_TRIGGER, testAction2);
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(2);
@ -330,14 +346,14 @@ describe('UiActionsService', () => {
});
service1.registerAction(testAction1);
service1.registerAction(testAction2);
service1.attachAction(FOO_TRIGGER, testAction1);
service1.addTriggerAction(FOO_TRIGGER, testAction1);
const service2 = service1.fork();
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
service1.attachAction(FOO_TRIGGER, testAction2);
service1.addTriggerAction(FOO_TRIGGER, testAction2);
expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(2);
expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1);
@ -392,7 +408,7 @@ describe('UiActionsService', () => {
} as any;
service.registerTrigger(trigger);
service.attachAction(MY_TRIGGER, action);
service.addTriggerAction(MY_TRIGGER, action);
const actions = service.getTriggerActions(trigger.id);
@ -400,7 +416,7 @@ describe('UiActionsService', () => {
expect(actions[0].id).toBe(ACTION_HELLO_WORLD);
});
test('can detach an action to a trigger', () => {
test('can detach an action from a trigger', () => {
const service = new UiActionsService();
const trigger: Trigger = {
@ -413,7 +429,7 @@ describe('UiActionsService', () => {
service.registerTrigger(trigger);
service.registerAction(action);
service.attachAction(trigger.id, action);
service.addTriggerAction(trigger.id, action);
service.detachAction(trigger.id, action.id);
const actions2 = service.getTriggerActions(trigger.id);
@ -445,7 +461,7 @@ describe('UiActionsService', () => {
} as any;
service.registerAction(action);
expect(() => service.attachAction('i do not exist' as TriggerId, action)).toThrowError(
expect(() => service.addTriggerAction('i do not exist' as TriggerId, action)).toThrowError(
'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = ACTION_HELLO_WORLD].'
);
});

View file

@ -23,9 +23,8 @@ import {
TriggerToActionsRegistry,
TriggerId,
TriggerContextMapping,
ActionType,
} from '../types';
import { Action, ActionByType } from '../actions';
import { ActionInternal, Action, ActionDefinition, ActionContext } from '../actions';
import { Trigger, TriggerContext } from '../triggers/trigger';
import { TriggerInternal } from '../triggers/trigger_internal';
import { TriggerContract } from '../triggers/trigger_contract';
@ -76,49 +75,41 @@ export class UiActionsService {
return trigger.contract;
};
public readonly registerAction = <T extends ActionType>(action: ActionByType<T>) => {
if (this.actions.has(action.id)) {
throw new Error(`Action [action.id = ${action.id}] already registered.`);
public readonly registerAction = <A extends ActionDefinition>(
definition: A
): Action<ActionContext<A>> => {
if (this.actions.has(definition.id)) {
throw new Error(`Action [action.id = ${definition.id}] already registered.`);
}
const action = new ActionInternal(definition);
this.actions.set(action.id, action);
return action;
};
public readonly getAction = <T extends ActionType>(id: string): ActionByType<T> => {
if (!this.actions.has(id)) {
throw new Error(`Action [action.id = ${id}] not registered.`);
public readonly unregisterAction = (actionId: string): void => {
if (!this.actions.has(actionId)) {
throw new Error(`Action [action.id = ${actionId}] is not registered.`);
}
return this.actions.get(id) as ActionByType<T>;
this.actions.delete(actionId);
};
public readonly attachAction = <TType extends TriggerId, AType extends ActionType>(
triggerId: TType,
// The action can accept partial or no context, but if it needs context not provided
// by this type of trigger, typescript will complain. yay!
action: ActionByType<AType> & Action<TriggerContextMapping[TType]>
): void => {
if (!this.actions.has(action.id)) {
this.registerAction(action);
} else {
const registeredAction = this.actions.get(action.id);
if (registeredAction !== action) {
throw new Error(`A different action instance with this id is already registered.`);
}
}
public readonly attachAction = <T extends TriggerId>(triggerId: T, actionId: string): void => {
const trigger = this.triggers.get(triggerId);
if (!trigger) {
throw new Error(
`No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${action.id}].`
`No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${actionId}].`
);
}
const actionIds = this.triggerToActions.get(triggerId);
if (!actionIds!.find(id => id === action.id)) {
this.triggerToActions.set(triggerId, [...actionIds!, action.id]);
if (!actionIds!.find(id => id === actionId)) {
this.triggerToActions.set(triggerId, [...actionIds!, actionId]);
}
};
@ -139,6 +130,32 @@ 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.
*
* `addTriggerAction` also infers better typing of the `action` argument.
*/
public readonly addTriggerAction = <T extends TriggerId>(
triggerId: T,
// The action can accept partial or no context, but if it needs context not provided
// by this type of trigger, typescript will complain. yay!
action: Action<TriggerContextMapping[T]>
): void => {
if (!this.actions.has(action.id)) this.registerAction(action);
this.attachAction(triggerId, action.id);
};
public readonly getAction = <T extends ActionDefinition>(
id: string
): Action<ActionContext<T>> => {
if (!this.actions.has(id)) {
throw new Error(`Action [action.id = ${id}] not registered.`);
}
return this.actions.get(id) as ActionInternal<T>;
};
public readonly getTriggerActions = <T extends TriggerId>(
triggerId: T
): Array<Action<TriggerContextMapping[T]>> => {
@ -147,9 +164,9 @@ export class UiActionsService {
const actionIds = this.triggerToActions.get(triggerId);
const actions = actionIds!.map(actionId => this.actions.get(actionId)).filter(Boolean) as Array<
Action<TriggerContextMapping[T]>
>;
const actions = actionIds!
.map(actionId => this.actions.get(actionId) as ActionInternal)
.filter(Boolean);
return actions as Array<Action<TriggerContext<T>>>;
};

View file

@ -69,7 +69,7 @@ test('executes a single action mapped to a trigger', async () => {
const action = createTestAction('test1', () => true);
setup.registerTrigger(trigger);
setup.attachAction(trigger.id, action);
setup.addTriggerAction(trigger.id, action);
const context = {};
const start = doStart();
@ -109,7 +109,7 @@ test('does not execute an incompatible action', async () => {
);
setup.registerTrigger(trigger);
setup.attachAction(trigger.id, action);
setup.addTriggerAction(trigger.id, action);
const start = doStart();
const context = {
@ -130,8 +130,8 @@ test('shows a context menu when more than one action is mapped to a trigger', as
const action2 = createTestAction('test2', () => true);
setup.registerTrigger(trigger);
setup.attachAction(trigger.id, action1);
setup.attachAction(trigger.id, action2);
setup.addTriggerAction(trigger.id, action1);
setup.addTriggerAction(trigger.id, action2);
expect(openContextMenu).toHaveBeenCalledTimes(0);
@ -155,7 +155,7 @@ test('passes whole action context to isCompatible()', async () => {
});
setup.registerTrigger(trigger);
setup.attachAction(trigger.id, action);
setup.addTriggerAction(trigger.id, action);
const start = doStart();

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { Action } from '../actions';
import { ActionInternal, Action } from '../actions';
import { uiActionsPluginMock } from '../mocks';
import { TriggerId, ActionType } from '../types';
@ -47,13 +47,14 @@ test('returns actions set on trigger', () => {
expect(list0).toHaveLength(0);
setup.attachAction('trigger' as TriggerId, action1);
setup.addTriggerAction('trigger' as TriggerId, action1);
const list1 = start.getTriggerActions('trigger' as TriggerId);
expect(list1).toHaveLength(1);
expect(list1).toEqual([action1]);
expect(list1[0]).toBeInstanceOf(ActionInternal);
expect(list1[0].id).toBe(action1.id);
setup.attachAction('trigger' as TriggerId, action2);
setup.addTriggerAction('trigger' as TriggerId, action2);
const list2 = start.getTriggerActions('trigger' as TriggerId);
expect(list2).toHaveLength(2);

View file

@ -37,7 +37,7 @@ beforeEach(() => {
id: 'trigger' as TriggerId,
title: 'trigger',
});
uiActions.setup.attachAction('trigger' as TriggerId, action);
uiActions.setup.addTriggerAction('trigger' as TriggerId, action);
});
test('can register action', async () => {
@ -58,7 +58,7 @@ test('getTriggerCompatibleActions returns attached actions', async () => {
title: 'My trigger',
};
setup.registerTrigger(testTrigger);
setup.attachAction('MY-TRIGGER' as TriggerId, helloWorldAction);
setup.addTriggerAction('MY-TRIGGER' as TriggerId, helloWorldAction);
const start = doStart();
const actions = await start.getTriggerCompatibleActions('MY-TRIGGER' as TriggerId, {});
@ -84,7 +84,7 @@ test('filters out actions not applicable based on the context', async () => {
setup.registerTrigger(testTrigger);
setup.registerAction(action1);
setup.attachAction(testTrigger.id, action1);
setup.addTriggerAction(testTrigger.id, action1);
const start = doStart();
let actions = await start.getTriggerCompatibleActions(testTrigger.id, { accept: true });

View file

@ -16,4 +16,5 @@
* specific language governing permissions and limitations
* under the License.
*/
export { createHelloWorldAction } from './hello_world_action';

View file

@ -22,6 +22,8 @@ import { Trigger } from '.';
export const SELECT_RANGE_TRIGGER = 'SELECT_RANGE_TRIGGER';
export const selectRangeTrigger: Trigger<'SELECT_RANGE_TRIGGER'> = {
id: SELECT_RANGE_TRIGGER,
title: 'Select range',
// This is empty string to hide title of ui_actions context menu that appears
// when this trigger is executed.
title: '',
description: 'Applies a range filter',
};

View file

@ -65,8 +65,11 @@ export class TriggerInternal<T extends TriggerId> {
const panel = await buildContextMenuForActions({
actions,
actionContext: context,
title: this.trigger.title,
closeMenu: () => session.close(),
});
const session = openContextMenu([panel]);
const session = openContextMenu([panel], {
'data-test-subj': 'multipleActionsContextMenu',
});
}
}

View file

@ -22,6 +22,8 @@ import { Trigger } from '.';
export const VALUE_CLICK_TRIGGER = 'VALUE_CLICK_TRIGGER';
export const valueClickTrigger: Trigger<'VALUE_CLICK_TRIGGER'> = {
id: VALUE_CLICK_TRIGGER,
title: 'Value clicked',
// This is empty string to hide title of ui_actions context menu that appears
// when this trigger is executed.
title: '',
description: 'Value was clicked',
};

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { ActionByType } from './actions/action';
import { ActionInternal } from './actions/action_internal';
import { TriggerInternal } from './triggers/trigger_internal';
import { Filter } from '../../data/public';
import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers';
@ -25,7 +25,7 @@ import { IEmbeddable } from '../../embeddable/public';
import { RangeSelectTriggerContext, ValueClickTriggerContext } from '../../embeddable/public';
export type TriggerRegistry = Map<TriggerId, TriggerInternal<any>>;
export type ActionRegistry = Map<string, ActionByType<any>>;
export type ActionRegistry = Map<string, ActionInternal>;
export type TriggerToActionsRegistry = Map<TriggerId, string[]>;
const DEFAULT_TRIGGER = '';

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export * from './presentable';

View file

@ -0,0 +1,65 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { UiComponent } from 'src/plugins/kibana_utils/public';
/**
* Represents something that can be displayed to user in UI.
*/
export interface Presentable<Context extends object = object> {
/**
* ID that uniquely identifies this object.
*/
readonly id: string;
/**
* Determines the display order in relation to other items. Higher numbers are
* displayed first.
*/
readonly order: number;
/**
* `UiComponent` to render when displaying this entity as a context menu item.
* If not provided, `getDisplayName` will be used instead.
*/
readonly MenuItem?: UiComponent<{ context: Context }>;
/**
* Optional EUI icon type that can be displayed along with the title.
*/
getIconType(context: Context): string | undefined;
/**
* Returns a title to be displayed to the user.
*/
getDisplayName(context: Context): string;
/**
* This method should return a link if this item can be clicked on. The link
* is used to navigate user if user middle-clicks it or Ctrl + clicks or
* right-clicks and selects "Open in new tab".
*/
getHref?(context: Context): Promise<string | undefined>;
/**
* Returns a promise that resolves to true if this item is compatible given
* the context and should be displayed to user, otherwise resolves to false.
*/
isCompatible(context: Context): Promise<boolean>;
}

View file

@ -265,6 +265,7 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut
timeFieldName: this.vis.data.indexPattern!.timeFieldName!,
data: event.data,
};
getUiActions()
.getTrigger(triggerId)
.exec(context);

View file

@ -104,16 +104,21 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide
public async getDashboardIdFromCurrentUrl() {
const currentUrl = await browser.getCurrentUrl();
const urlSubstring = 'kibana#/dashboard/';
const startOfIdIndex = currentUrl.indexOf(urlSubstring) + urlSubstring.length;
const endIndex = currentUrl.indexOf('?');
const id = currentUrl.substring(startOfIdIndex, endIndex < 0 ? currentUrl.length : endIndex);
const id = this.getDashboardIdFromUrl(currentUrl);
log.debug(`Dashboard id extracted from ${currentUrl} is ${id}`);
return id;
}
public getDashboardIdFromUrl(url: string) {
const urlSubstring = 'kibana#/dashboard/';
const startOfIdIndex = url.indexOf(urlSubstring) + urlSubstring.length;
const endIndex = url.indexOf('?');
const id = url.substring(startOfIdIndex, endIndex < 0 ? url.length : endIndex);
return id;
}
/**
* Returns true if already on the dashboard landing page (that page doesn't have a link to itself).
* @returns {Promise<boolean>}
@ -512,6 +517,20 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide
return checkList.filter(viz => viz.isPresent === false).map(viz => viz.name);
}
public async getPanelDrilldownCount(panelIndex = 0): Promise<number> {
log.debug('getPanelDrilldownCount');
const panel = (await this.getDashboardPanels())[panelIndex];
try {
const count = await panel.findByTestSubject(
'embeddablePanelNotification-ACTION_PANEL_NOTIFICATIONS'
);
return Number.parseInt(await count.getVisibleText(), 10);
} catch (e) {
// if not found then this is 0 (we don't show badge with 0)
return 0;
}
}
}
return new DashboardPage();

View file

@ -27,14 +27,10 @@ export class SampelPanelActionTestPlugin
implements Plugin<SampelPanelActionTestPluginSetup, SampelPanelActionTestPluginStart> {
public setup(core: CoreSetup, { uiActions }: { uiActions: UiActionsSetup }) {
const samplePanelAction = createSamplePanelAction(core.getStartServices);
uiActions.registerAction(samplePanelAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, samplePanelAction);
const samplePanelLink = createSamplePanelLink();
uiActions.registerAction(samplePanelLink);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, samplePanelLink);
uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, samplePanelAction);
uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, samplePanelLink);
return {};
}

View file

@ -69,11 +69,10 @@ export class EmbeddableExplorerPublicPlugin
const sayHelloAction = new SayHelloAction(alert);
const sendMessageAction = createSendMessageAction(core.overlays);
plugins.uiActions.registerAction(helloWorldAction);
plugins.uiActions.registerAction(sayHelloAction);
plugins.uiActions.registerAction(sendMessageAction);
plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, helloWorldAction);
plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, helloWorldAction);
plugins.__LEGACY.onRenderComplete(() => {
const root = document.getElementById(REACT_ROOT_ID);

View file

@ -3,11 +3,13 @@
"paths": {
"xpack.actions": "plugins/actions",
"xpack.advancedUiActions": "plugins/advanced_ui_actions",
"xpack.uiActionsEnhanced": "examples/ui_actions_enhanced_examples",
"xpack.alerting": "plugins/alerting",
"xpack.alertingBuiltins": "plugins/alerting_builtins",
"xpack.apm": ["legacy/plugins/apm", "plugins/apm"],
"xpack.beatsManagement": "legacy/plugins/beats_management",
"xpack.canvas": "legacy/plugins/canvas",
"xpack.dashboard": "plugins/dashboard_enhanced",
"xpack.crossClusterReplication": "plugins/cross_cluster_replication",
"xpack.dashboardMode": "legacy/plugins/dashboard_mode",
"xpack.data": "plugins/data_enhanced",

View file

@ -1,3 +1,36 @@
## Ui actions enhanced examples
# Ui actions enhanced examples
To run this example, use the command `yarn start --run-examples`.
To run this example plugin, use the command `yarn start --run-examples`.
## Drilldown examples
This plugin holds few examples on how to add drilldown types to dashboard.
To play with drilldowns, open any dashboard, click "Edit" to put it in *edit mode*.
Now when opening context menu of dashboard panels you should see "Create drilldown" option.
![image](https://user-images.githubusercontent.com/9773803/80460907-c2ef7880-8934-11ea-8400-533bb9d57e36.png)
Once you click "Create drilldown" you should be able to see drilldowns added by
this sample plugin.
![image](https://user-images.githubusercontent.com/9773803/80460408-131a0b00-8934-11ea-81e4-137e9e33f34b.png)
### `dashboard_hello_world_drilldown`
`dashboard_hello_world_drilldown` is the most basic "hello world" example showing
how a drilldown can be built, all in one file.
### `dashboard_to_url_drilldown`
`dashboard_to_url_drilldown` is a good starting point for build a drilldown
that navigates somewhere externally.
One can see how middle-click or Ctrl + click behavior could be supported using
`getHref` field.
### `dashboard_to_discover_drilldown`
`dashboard_to_discover_drilldown` shows how a real-world drilldown could look like.

View file

@ -5,6 +5,6 @@
"configPath": ["ui_actions_enhanced_examples"],
"server": false,
"ui": true,
"requiredPlugins": ["uiActions", "data"],
"requiredPlugins": ["advancedUiActions", "data"],
"optionalPlugins": []
}

View file

@ -0,0 +1 @@
This folder contains a one-file example of the most basic drilldown implementation.

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiFormRow, EuiFieldText } from '@elastic/eui';
import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public';
import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/advanced_ui_actions/public';
import {
RangeSelectTriggerContext,
ValueClickTriggerContext,
} from '../../../../../src/plugins/embeddable/public';
import { CollectConfigProps } from '../../../../../src/plugins/kibana_utils/public';
export type ActionContext = RangeSelectTriggerContext | ValueClickTriggerContext;
export interface Config {
name: string;
}
const SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN = 'SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN';
export class DashboardHelloWorldDrilldown implements Drilldown<Config, ActionContext> {
public readonly id = SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN;
public readonly order = 6;
public readonly getDisplayName = () => 'Say hello drilldown';
public readonly euiIcon = 'cheer';
private readonly ReactCollectConfig: React.FC<CollectConfigProps<Config>> = ({
config,
onConfig,
}) => (
<EuiFormRow label="Enter your name" fullWidth>
<EuiFieldText
fullWidth
value={config.name}
onChange={event => onConfig({ ...config, name: event.target.value })}
/>
</EuiFormRow>
);
public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig);
public readonly createConfig = () => ({
name: '',
});
public readonly isConfigValid = (config: Config): config is Config => {
return !!config.name;
};
public readonly execute = async (config: Config, context: ActionContext) => {
alert(`Hello, ${config.name}`);
};
}

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, useEffect } from 'react';
import useMountedState from 'react-use/lib/useMountedState';
import { CollectConfigProps } from './types';
import { DiscoverDrilldownConfig, IndexPatternItem } from './components/discover_drilldown_config';
import { Params } from './drilldown';
export interface CollectConfigContainerProps extends CollectConfigProps {
params: Params;
}
export const CollectConfigContainer: React.FC<CollectConfigContainerProps> = ({
config,
onConfig,
params: { start },
}) => {
const isMounted = useMountedState();
const [indexPatterns, setIndexPatterns] = useState<IndexPatternItem[]>([]);
useEffect(() => {
(async () => {
const indexPatternSavedObjects = await start().plugins.data.indexPatterns.getCache();
if (!isMounted()) return;
setIndexPatterns(
indexPatternSavedObjects
? indexPatternSavedObjects.map(indexPattern => ({
id: indexPattern.id,
title: indexPattern.attributes.title,
}))
: []
);
})();
}, [isMounted, start]);
return (
<DiscoverDrilldownConfig
activeIndexPatternId={config.indexPatternId}
indexPatterns={indexPatterns}
onIndexPatternSelect={indexPatternId => {
onConfig({ ...config, indexPatternId });
}}
customIndexPattern={config.customIndexPattern}
onCustomIndexPatternToggle={() =>
onConfig({
...config,
customIndexPattern: !config.customIndexPattern,
indexPatternId: undefined,
})
}
carryFiltersAndQuery={config.carryFiltersAndQuery}
onCarryFiltersAndQueryToggle={() =>
onConfig({
...config,
carryFiltersAndQuery: !config.carryFiltersAndQuery,
})
}
carryTimeRange={config.carryTimeRange}
onCarryTimeRangeToggle={() =>
onConfig({
...config,
carryTimeRange: !config.carryTimeRange,
})
}
/>
);
};

View file

@ -0,0 +1,104 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiFormRow, EuiSelect, EuiSwitch, EuiSpacer, EuiCallOut } from '@elastic/eui';
import { txtChooseDestinationIndexPattern } from './i18n';
export interface IndexPatternItem {
id: string;
title: string;
}
export interface DiscoverDrilldownConfigProps {
activeIndexPatternId?: string;
indexPatterns: IndexPatternItem[];
onIndexPatternSelect: (indexPatternId: string) => void;
customIndexPattern?: boolean;
onCustomIndexPatternToggle?: () => void;
carryFiltersAndQuery?: boolean;
onCarryFiltersAndQueryToggle?: () => void;
carryTimeRange?: boolean;
onCarryTimeRangeToggle?: () => void;
}
export const DiscoverDrilldownConfig: React.FC<DiscoverDrilldownConfigProps> = ({
activeIndexPatternId,
indexPatterns,
onIndexPatternSelect,
customIndexPattern,
onCustomIndexPatternToggle,
carryFiltersAndQuery,
onCarryFiltersAndQueryToggle,
carryTimeRange,
onCarryTimeRangeToggle,
}) => {
return (
<>
<EuiCallOut title="Example warning!" color="warning" iconType="help">
<p>
This is an example drilldown. It is meant as a starting point for developers, so they can
grab this code and get started. It does not provide a complete working functionality but
serves as a getting started example.
</p>
<p>
Implementation of the actual <em>Go to Discover</em> drilldown is tracked in{' '}
<a href="https://github.com/elastic/kibana/issues/60227">#60227</a>
</p>
</EuiCallOut>
<EuiSpacer size="xl" />
{!!onCustomIndexPatternToggle && (
<>
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="customIndexPattern"
label="Use custom index pattern"
checked={!!customIndexPattern}
onChange={onCustomIndexPatternToggle}
/>
</EuiFormRow>
{!!customIndexPattern && (
<EuiFormRow fullWidth label={txtChooseDestinationIndexPattern}>
<EuiSelect
name="selectDashboard"
hasNoInitialSelection={true}
fullWidth
options={[
{ id: '', text: 'Pick one...' },
...indexPatterns.map(({ id, title }) => ({ value: id, text: title })),
]}
value={activeIndexPatternId || ''}
onChange={e => onIndexPatternSelect(e.target.value)}
/>
</EuiFormRow>
)}
<EuiSpacer size="xl" />
</>
)}
{!!onCarryFiltersAndQueryToggle && (
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="carryFiltersAndQuery"
label="Carry over filters and query"
checked={!!carryFiltersAndQuery}
onChange={onCarryFiltersAndQueryToggle}
/>
</EuiFormRow>
)}
{!!onCarryTimeRangeToggle && (
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="carryTimeRange"
label="Carry over time range"
checked={!!carryTimeRange}
onChange={onCarryTimeRangeToggle}
/>
</EuiFormRow>
)}
</>
);
};

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const txtChooseDestinationIndexPattern = i18n.translate(
'xpack.uiActionsEnhanced.components.DiscoverDrilldownConfig.chooseIndexPattern',
{
defaultMessage: 'Choose destination index pattern',
}
);

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './discover_drilldown_config';

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './discover_drilldown_config';

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN = 'SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN';

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { StartDependencies as Start } from '../plugin';
import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public';
import { StartServicesGetter } from '../../../../../src/plugins/kibana_utils/public';
import { ActionContext, Config, CollectConfigProps } from './types';
import { CollectConfigContainer } from './collect_config_container';
import { SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN } from './constants';
import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/advanced_ui_actions/public';
import { txtGoToDiscover } from './i18n';
const isOutputWithIndexPatterns = (
output: unknown
): output is { indexPatterns: Array<{ id: string }> } => {
if (!output || typeof output !== 'object') return false;
return Array.isArray((output as any).indexPatterns);
};
export interface Params {
start: StartServicesGetter<Pick<Start, 'data'>>;
}
export class DashboardToDiscoverDrilldown implements Drilldown<Config, ActionContext> {
constructor(protected readonly params: Params) {}
public readonly id = SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN;
public readonly order = 10;
public readonly getDisplayName = () => txtGoToDiscover;
public readonly euiIcon = 'discoverApp';
private readonly ReactCollectConfig: React.FC<CollectConfigProps> = props => (
<CollectConfigContainer {...props} params={this.params} />
);
public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig);
public readonly createConfig = () => ({
customIndexPattern: false,
carryFiltersAndQuery: true,
carryTimeRange: true,
});
public readonly isConfigValid = (config: Config): config is Config => {
if (config.customIndexPattern && !config.indexPatternId) return false;
return true;
};
private readonly getPath = async (config: Config, context: ActionContext): Promise<string> => {
let indexPatternId =
!!config.customIndexPattern && !!config.indexPatternId ? config.indexPatternId : '';
if (!indexPatternId && !!context.embeddable) {
const output = context.embeddable!.getOutput();
if (isOutputWithIndexPatterns(output) && output.indexPatterns.length > 0) {
indexPatternId = output.indexPatterns[0].id;
}
}
const index = indexPatternId ? `,index:'${indexPatternId}'` : '';
return `#/discover?_g=(filters:!(),refreshInterval:(pause:!f,value:900000),time:(from:now-7d,to:now))&_a=(columns:!(_source),filters:!()${index},interval:auto,query:(language:kuery,query:''),sort:!())`;
};
public readonly getHref = async (config: Config, context: ActionContext): Promise<string> => {
return `kibana${await this.getPath(config, context)}`;
};
public readonly execute = async (config: Config, context: ActionContext) => {
const path = await this.getPath(config, context);
await this.params.start().core.application.navigateToApp('kibana', {
path,
});
};
}

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const txtGoToDiscover = i18n.translate('xpack.uiActionsEnhanced.drilldown.goToDiscover', {
defaultMessage: 'Go to Discover (example)',
});

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN } from './constants';
export {
DashboardToDiscoverDrilldown,
Params as DashboardToDiscoverDrilldownParams,
} from './drilldown';
export {
ActionContext as DashboardToDiscoverActionContext,
Config as DashboardToDiscoverConfig,
} from './types';

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
RangeSelectTriggerContext,
ValueClickTriggerContext,
} from '../../../../../src/plugins/embeddable/public';
import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public';
export type ActionContext = RangeSelectTriggerContext | ValueClickTriggerContext;
export interface Config {
/**
* Whether to use a user selected index pattern, stored in `indexPatternId` field.
*/
customIndexPattern: boolean;
/**
* ID of index pattern picked by user in UI. If not set, drilldown will use
* the index pattern of the visualization.
*/
indexPatternId?: string;
/**
* Whether to carry over source dashboard filters and query.
*/
carryFiltersAndQuery: boolean;
/**
* Whether to carry over source dashboard time range.
*/
carryTimeRange: boolean;
}
export type CollectConfigProps = CollectConfigPropsBase<Config>;

View file

@ -0,0 +1,114 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiFormRow, EuiSwitch, EuiFieldText, EuiCallOut, EuiSpacer } from '@elastic/eui';
import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public';
import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/advanced_ui_actions/public';
import {
RangeSelectTriggerContext,
ValueClickTriggerContext,
} from '../../../../../src/plugins/embeddable/public';
import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public';
function isValidUrl(url: string) {
try {
new URL(url);
return true;
} catch {
return false;
}
}
export type ActionContext = RangeSelectTriggerContext | ValueClickTriggerContext;
export interface Config {
url: string;
openInNewTab: boolean;
}
export type CollectConfigProps = CollectConfigPropsBase<Config>;
const SAMPLE_DASHBOARD_TO_URL_DRILLDOWN = 'SAMPLE_DASHBOARD_TO_URL_DRILLDOWN';
export class DashboardToUrlDrilldown implements Drilldown<Config, ActionContext> {
public readonly id = SAMPLE_DASHBOARD_TO_URL_DRILLDOWN;
public readonly order = 8;
public readonly getDisplayName = () => 'Go to URL (example)';
public readonly euiIcon = 'link';
private readonly ReactCollectConfig: React.FC<CollectConfigProps> = ({ config, onConfig }) => (
<>
<EuiCallOut title="Example warning!" color="warning" iconType="help">
<p>
This is an example drilldown. It is meant as a starting point for developers, so they can
grab this code and get started. It does not provide a complete working functionality but
serves as a getting started example.
</p>
<p>
Implementation of the actual <em>Go to URL</em> drilldown is tracked in{' '}
<a href="https://github.com/elastic/kibana/issues/55324">#55324</a>
</p>
</EuiCallOut>
<EuiSpacer size="xl" />
<EuiFormRow label="Enter target URL" fullWidth>
<EuiFieldText
fullWidth
name="url"
placeholder="Enter URL"
value={config.url}
onChange={event => onConfig({ ...config, url: event.target.value })}
onBlur={() => {
if (!config.url) return;
if (/https?:\/\//.test(config.url)) return;
onConfig({ ...config, url: 'https://' + config.url });
}}
/>
</EuiFormRow>
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="openInNewTab"
label="Open in new tab?"
checked={config.openInNewTab}
onChange={() => onConfig({ ...config, openInNewTab: !config.openInNewTab })}
/>
</EuiFormRow>
</>
);
public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig);
public readonly createConfig = () => ({
url: '',
openInNewTab: false,
});
public readonly isConfigValid = (config: Config): config is Config => {
if (!config.url) return false;
return isValidUrl(config.url);
};
/**
* `getHref` is need to support mouse middle-click and Cmd + Click behavior
* to open a link in new tab.
*/
public readonly getHref = async (config: Config, context: ActionContext) => {
return config.url;
};
public readonly execute = async (config: Config, context: ActionContext) => {
const url = await this.getHref(config, context);
if (config.openInNewTab) {
window.open(url, '_blank');
} else {
window.location.href = url;
}
};
}

View file

@ -5,24 +5,37 @@
*/
import { Plugin, CoreSetup, CoreStart } from '../../../../src/core/public';
import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public';
import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public';
import {
AdvancedUiActionsSetup,
AdvancedUiActionsStart,
} from '../../../../x-pack/plugins/advanced_ui_actions/public';
import { DashboardHelloWorldDrilldown } from './dashboard_hello_world_drilldown';
import { DashboardToUrlDrilldown } from './dashboard_to_url_drilldown';
import { DashboardToDiscoverDrilldown } from './dashboard_to_discover_drilldown';
import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public';
export interface SetupDependencies {
data: DataPublicPluginSetup;
uiActions: UiActionsSetup;
advancedUiActions: AdvancedUiActionsSetup;
}
export interface StartDependencies {
data: DataPublicPluginStart;
uiActions: UiActionsStart;
advancedUiActions: AdvancedUiActionsStart;
}
export class UiActionsEnhancedExamplesPlugin
implements Plugin<void, void, SetupDependencies, StartDependencies> {
public setup(core: CoreSetup<StartDependencies>, plugins: SetupDependencies) {
// eslint-disable-next-line
console.log('ui_actions_enhanced_examples');
public setup(
core: CoreSetup<StartDependencies>,
{ advancedUiActions: uiActions }: SetupDependencies
) {
const start = createStartServicesGetter(core.getStartServices);
uiActions.registerDrilldown(new DashboardHelloWorldDrilldown());
uiActions.registerDrilldown(new DashboardToUrlDrilldown());
uiActions.registerDrilldown(new DashboardToDiscoverDrilldown({ start }));
}
public start(core: CoreStart, plugins: StartDependencies) {}

View file

@ -130,7 +130,7 @@ export const initializeCanvas = async (
restoreAction = action;
startPlugins.uiActions.detachAction(VALUE_CLICK_TRIGGER, action.id);
startPlugins.uiActions.attachAction(VALUE_CLICK_TRIGGER, emptyAction);
startPlugins.uiActions.addTriggerAction(VALUE_CLICK_TRIGGER, emptyAction);
}
if (setupPlugins.usageCollection) {
@ -147,7 +147,7 @@ export const teardownCanvas = (coreStart: CoreStart, startPlugins: CanvasStartDe
startPlugins.uiActions.detachAction(VALUE_CLICK_TRIGGER, emptyAction.id);
if (restoreAction) {
startPlugins.uiActions.attachAction(VALUE_CLICK_TRIGGER, restoreAction);
startPlugins.uiActions.addTriggerAction(VALUE_CLICK_TRIGGER, restoreAction);
restoreAction = undefined;
}

View file

@ -1,8 +1,3 @@
.auaActionWizard__selectedActionFactoryContainer {
background-color: $euiColorLightestShade;
padding: $euiSize;
}
.auaActionWizard__actionFactoryItem {
.euiKeyPadMenuItem__label {
height: #{$euiSizeXL};

View file

@ -6,28 +6,26 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { dashboardDrilldownActionFactory, Demo, urlDrilldownActionFactory } from './test_data';
import { Demo, dashboardFactory, urlFactory } from './test_data';
storiesOf('components/ActionWizard', module)
.add('default', () => (
<Demo actionFactories={[dashboardDrilldownActionFactory, urlDrilldownActionFactory]} />
))
.add('default', () => <Demo actionFactories={[dashboardFactory, urlFactory]} />)
.add('Only one factory is available', () => (
// to make sure layout doesn't break
<Demo actionFactories={[dashboardDrilldownActionFactory]} />
<Demo actionFactories={[dashboardFactory]} />
))
.add('Long list of action factories', () => (
// to make sure layout doesn't break
<Demo
actionFactories={[
dashboardDrilldownActionFactory,
urlDrilldownActionFactory,
dashboardDrilldownActionFactory,
urlDrilldownActionFactory,
dashboardDrilldownActionFactory,
urlDrilldownActionFactory,
dashboardDrilldownActionFactory,
urlDrilldownActionFactory,
dashboardFactory,
urlFactory,
dashboardFactory,
urlFactory,
dashboardFactory,
urlFactory,
dashboardFactory,
urlFactory,
]}
/>
));

View file

@ -8,24 +8,17 @@ import React from 'react';
import { cleanup, fireEvent, render } from '@testing-library/react/pure';
import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global
import { TEST_SUBJ_ACTION_FACTORY_ITEM, TEST_SUBJ_SELECTED_ACTION_FACTORY } from './action_wizard';
import {
dashboardDrilldownActionFactory,
dashboards,
Demo,
urlDrilldownActionFactory,
} from './test_data';
import { dashboardFactory, dashboards, Demo, urlFactory } from './test_data';
// TODO: afterEach is not available for it globally during setup
// https://github.com/elastic/kibana/issues/59469
afterEach(cleanup);
test('Pick and configure action', () => {
const screen = render(
<Demo actionFactories={[dashboardDrilldownActionFactory, urlDrilldownActionFactory]} />
);
const screen = render(<Demo actionFactories={[dashboardFactory, urlFactory]} />);
// check that all factories are displayed to pick
expect(screen.getAllByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).toHaveLength(2);
expect(screen.getAllByTestId(new RegExp(TEST_SUBJ_ACTION_FACTORY_ITEM))).toHaveLength(2);
// select URL one
fireEvent.click(screen.getByText(/Go to URL/i));
@ -47,11 +40,11 @@ test('Pick and configure action', () => {
});
test('If only one actions factory is available then actionFactory selection is emitted without user input', () => {
const screen = render(<Demo actionFactories={[urlDrilldownActionFactory]} />);
const screen = render(<Demo actionFactories={[urlFactory]} />);
// check that no factories are displayed to pick from
expect(screen.queryByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).not.toBeInTheDocument();
expect(screen.queryByTestId(TEST_SUBJ_SELECTED_ACTION_FACTORY)).toBeInTheDocument();
expect(screen.queryByTestId(new RegExp(TEST_SUBJ_ACTION_FACTORY_ITEM))).not.toBeInTheDocument();
expect(screen.queryByTestId(new RegExp(TEST_SUBJ_SELECTED_ACTION_FACTORY))).toBeInTheDocument();
// Input url
const URL = 'https://elastic.co';

View file

@ -16,40 +16,20 @@ import {
} from '@elastic/eui';
import { txtChangeButton } from './i18n';
import './action_wizard.scss';
// TODO: this interface is temporary for just moving forward with the component
// and it will be imported from the ../ui_actions when implemented properly
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type ActionBaseConfig = {};
export interface ActionFactory<Config extends ActionBaseConfig = ActionBaseConfig> {
type: string; // TODO: type should be tied to Action and ActionByType
displayName: string;
iconType?: string;
wizard: React.FC<ActionFactoryWizardProps<Config>>;
createConfig: () => Config;
isValid: (config: Config) => boolean;
}
export interface ActionFactoryWizardProps<Config extends ActionBaseConfig> {
config?: Config;
/**
* Callback called when user updates the config in UI.
*/
onConfig: (config: Config) => void;
}
import { ActionFactory } from '../../dynamic_actions';
export interface ActionWizardProps {
/**
* List of available action factories
*/
actionFactories: Array<ActionFactory<any>>; // any here to be able to pass array of ActionFactory<Config> with different configs
actionFactories: ActionFactory[];
/**
* Currently selected action factory
* undefined - is allowed and means that non is selected
* undefined - is allowed and means that none is selected
*/
currentActionFactory?: ActionFactory;
/**
* Action factory selected changed
* null - means user click "change" and removed action factory selection
@ -59,12 +39,17 @@ export interface ActionWizardProps {
/**
* current config for currently selected action factory
*/
config?: ActionBaseConfig;
config?: object;
/**
* config changed
*/
onConfigChange: (config: ActionBaseConfig) => void;
onConfigChange: (config: object) => void;
/**
* Context will be passed into ActionFactory's methods
*/
context: object;
}
export const ActionWizard: React.FC<ActionWizardProps> = ({
@ -73,6 +58,7 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
onActionFactoryChange,
onConfigChange,
config,
context,
}) => {
// auto pick action factory if there is only 1 available
if (!currentActionFactory && actionFactories.length === 1) {
@ -87,6 +73,7 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
onDeselect={() => {
onActionFactoryChange(null);
}}
context={context}
config={config}
onConfigChange={newConfig => {
onConfigChange(newConfig);
@ -97,6 +84,7 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
return (
<ActionFactorySelector
context={context}
actionFactories={actionFactories}
onActionFactorySelected={actionFactory => {
onActionFactoryChange(actionFactory);
@ -105,15 +93,16 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
);
};
interface SelectedActionFactoryProps<Config extends ActionBaseConfig = ActionBaseConfig> {
actionFactory: ActionFactory<Config>;
config: Config;
onConfigChange: (config: Config) => void;
interface SelectedActionFactoryProps {
actionFactory: ActionFactory;
config: object;
context: object;
onConfigChange: (config: object) => void;
showDeselect: boolean;
onDeselect: () => void;
}
export const TEST_SUBJ_SELECTED_ACTION_FACTORY = 'selected-action-factory';
export const TEST_SUBJ_SELECTED_ACTION_FACTORY = 'selectedActionFactory';
const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({
actionFactory,
@ -121,28 +110,28 @@ const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({
showDeselect,
onConfigChange,
config,
context,
}) => {
return (
<div
className="auaActionWizard__selectedActionFactoryContainer"
data-test-subj={TEST_SUBJ_SELECTED_ACTION_FACTORY}
data-testid={TEST_SUBJ_SELECTED_ACTION_FACTORY}
data-test-subj={`${TEST_SUBJ_SELECTED_ACTION_FACTORY}-${actionFactory.id}`}
>
<header>
<EuiFlexGroup alignItems="center" gutterSize="s">
{actionFactory.iconType && (
{actionFactory.getIconType(context) && (
<EuiFlexItem grow={false}>
<EuiIcon type={actionFactory.iconType} size="m" />
<EuiIcon type={actionFactory.getIconType(context)!} size="m" />
</EuiFlexItem>
)}
<EuiFlexItem grow={true}>
<EuiText>
<h4>{actionFactory.displayName}</h4>
<h4>{actionFactory.getDisplayName(context)}</h4>
</EuiText>
</EuiFlexItem>
{showDeselect && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty size="s" onClick={() => onDeselect()}>
<EuiButtonEmpty size="xs" onClick={() => onDeselect()}>
{txtChangeButton}
</EuiButtonEmpty>
</EuiFlexItem>
@ -151,10 +140,11 @@ const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({
</header>
<EuiSpacer size="m" />
<div>
{actionFactory.wizard({
config,
onConfig: onConfigChange,
})}
<actionFactory.ReactCollectConfig
config={config}
onConfig={onConfigChange}
context={context}
/>
</div>
</div>
);
@ -162,14 +152,16 @@ const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({
interface ActionFactorySelectorProps {
actionFactories: ActionFactory[];
context: object;
onActionFactorySelected: (actionFactory: ActionFactory) => void;
}
export const TEST_SUBJ_ACTION_FACTORY_ITEM = 'action-factory-item';
export const TEST_SUBJ_ACTION_FACTORY_ITEM = 'actionFactoryItem';
const ActionFactorySelector: React.FC<ActionFactorySelectorProps> = ({
actionFactories,
onActionFactorySelected,
context,
}) => {
if (actionFactories.length === 0) {
// this is not user facing, as it would be impossible to get into this state
@ -177,20 +169,30 @@ const ActionFactorySelector: React.FC<ActionFactorySelectorProps> = ({
return <div>No action factories to pick from</div>;
}
// The below style is applied to fix Firefox rendering bug.
// See: https://github.com/elastic/kibana/pull/61219/#pullrequestreview-402903330
const firefoxBugFix = {
willChange: 'opacity',
};
return (
<EuiFlexGroup wrap>
{actionFactories.map(actionFactory => (
<EuiKeyPadMenuItem
className="auaActionWizard__actionFactoryItem"
key={actionFactory.type}
label={actionFactory.displayName}
data-testid={TEST_SUBJ_ACTION_FACTORY_ITEM}
data-test-subj={TEST_SUBJ_ACTION_FACTORY_ITEM}
onClick={() => onActionFactorySelected(actionFactory)}
>
{actionFactory.iconType && <EuiIcon type={actionFactory.iconType} size="m" />}
</EuiKeyPadMenuItem>
))}
<EuiFlexGroup gutterSize="m" wrap={true} style={firefoxBugFix}>
{[...actionFactories]
.sort((f1, f2) => f2.order - f1.order)
.map(actionFactory => (
<EuiFlexItem grow={false} key={actionFactory.id}>
<EuiKeyPadMenuItem
className="auaActionWizard__actionFactoryItem"
label={actionFactory.getDisplayName(context)}
data-test-subj={`${TEST_SUBJ_ACTION_FACTORY_ITEM}-${actionFactory.id}`}
onClick={() => onActionFactorySelected(actionFactory)}
>
{actionFactory.getIconType(context) && (
<EuiIcon type={actionFactory.getIconType(context)!} size="m" />
)}
</EuiKeyPadMenuItem>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
};

View file

@ -9,6 +9,6 @@ import { i18n } from '@kbn/i18n';
export const txtChangeButton = i18n.translate(
'xpack.advancedUiActions.components.actionWizard.changeButton',
{
defaultMessage: 'change',
defaultMessage: 'Change',
}
);

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { ActionFactory, ActionWizard } from './action_wizard';
export { ActionWizard } from './action_wizard';

View file

@ -6,124 +6,161 @@
import React, { useState } from 'react';
import { EuiFieldText, EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui';
import { ActionFactory, ActionBaseConfig, ActionWizard } from './action_wizard';
import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public';
import { ActionWizard } from './action_wizard';
import { ActionFactoryDefinition, ActionFactory } from '../../dynamic_actions';
import { CollectConfigProps } from '../../../../../../src/plugins/kibana_utils/public';
type ActionBaseConfig = object;
export const dashboards = [
{ id: 'dashboard1', title: 'Dashboard 1' },
{ id: 'dashboard2', title: 'Dashboard 2' },
];
export const dashboardDrilldownActionFactory: ActionFactory<{
interface DashboardDrilldownConfig {
dashboardId?: string;
useCurrentDashboardFilters: boolean;
useCurrentDashboardDataRange: boolean;
}> = {
type: 'Dashboard',
displayName: 'Go to Dashboard',
iconType: 'dashboardApp',
useCurrentFilters: boolean;
useCurrentDateRange: boolean;
}
function DashboardDrilldownCollectConfig(props: CollectConfigProps<DashboardDrilldownConfig>) {
const config = props.config ?? {
dashboardId: undefined,
useCurrentFilters: true,
useCurrentDateRange: true,
};
return (
<>
<EuiFormRow label="Choose destination dashboard:">
<EuiSelect
name="selectDashboard"
hasNoInitialSelection={true}
options={dashboards.map(({ id, title }) => ({ value: id, text: title }))}
value={config.dashboardId}
onChange={e => {
props.onConfig({ ...config, dashboardId: e.target.value });
}}
/>
</EuiFormRow>
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="useCurrentFilters"
label="Use current dashboard's filters"
checked={config.useCurrentFilters}
onChange={() =>
props.onConfig({
...config,
useCurrentFilters: !config.useCurrentFilters,
})
}
/>
</EuiFormRow>
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="useCurrentDateRange"
label="Use current dashboard's date range"
checked={config.useCurrentDateRange}
onChange={() =>
props.onConfig({
...config,
useCurrentDateRange: !config.useCurrentDateRange,
})
}
/>
</EuiFormRow>
</>
);
}
export const dashboardDrilldownActionFactory: ActionFactoryDefinition<
DashboardDrilldownConfig,
any,
any
> = {
id: 'Dashboard',
getDisplayName: () => 'Go to Dashboard',
getIconType: () => 'dashboardApp',
createConfig: () => {
return {
dashboardId: undefined,
useCurrentDashboardDataRange: true,
useCurrentDashboardFilters: true,
useCurrentFilters: true,
useCurrentDateRange: true,
};
},
isValid: config => {
isConfigValid: (config: DashboardDrilldownConfig): config is DashboardDrilldownConfig => {
if (!config.dashboardId) return false;
return true;
},
wizard: props => {
const config = props.config ?? {
dashboardId: undefined,
useCurrentDashboardDataRange: true,
useCurrentDashboardFilters: true,
};
return (
<>
<EuiFormRow label="Choose destination dashboard:">
<EuiSelect
name="selectDashboard"
hasNoInitialSelection={true}
options={dashboards.map(({ id, title }) => ({ value: id, text: title }))}
value={config.dashboardId}
onChange={e => {
props.onConfig({ ...config, dashboardId: e.target.value });
}}
/>
</EuiFormRow>
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="useCurrentFilters"
label="Use current dashboard's filters"
checked={config.useCurrentDashboardFilters}
onChange={() =>
props.onConfig({
...config,
useCurrentDashboardFilters: !config.useCurrentDashboardFilters,
})
}
/>
</EuiFormRow>
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="useCurrentDateRange"
label="Use current dashboard's date range"
checked={config.useCurrentDashboardDataRange}
onChange={() =>
props.onConfig({
...config,
useCurrentDashboardDataRange: !config.useCurrentDashboardDataRange,
})
}
/>
</EuiFormRow>
</>
);
CollectConfig: reactToUiComponent(DashboardDrilldownCollectConfig),
isCompatible(context?: object): Promise<boolean> {
return Promise.resolve(true);
},
order: 0,
create: () => ({
id: 'test',
execute: async () => alert('Navigate to dashboard!'),
}),
};
export const urlDrilldownActionFactory: ActionFactory<{ url: string; openInNewTab: boolean }> = {
type: 'Url',
displayName: 'Go to URL',
iconType: 'link',
export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory);
interface UrlDrilldownConfig {
url: string;
openInNewTab: boolean;
}
function UrlDrilldownCollectConfig(props: CollectConfigProps<UrlDrilldownConfig>) {
const config = props.config ?? {
url: '',
openInNewTab: false,
};
return (
<>
<EuiFormRow label="Enter target URL">
<EuiFieldText
placeholder="Enter URL"
name="url"
value={config.url}
onChange={event => props.onConfig({ ...config, url: event.target.value })}
/>
</EuiFormRow>
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="openInNewTab"
label="Open in new tab?"
checked={config.openInNewTab}
onChange={() => props.onConfig({ ...config, openInNewTab: !config.openInNewTab })}
/>
</EuiFormRow>
</>
);
}
export const urlDrilldownActionFactory: ActionFactoryDefinition<UrlDrilldownConfig> = {
id: 'Url',
getDisplayName: () => 'Go to URL',
getIconType: () => 'link',
createConfig: () => {
return {
url: '',
openInNewTab: false,
};
},
isValid: config => {
isConfigValid: (config: UrlDrilldownConfig): config is UrlDrilldownConfig => {
if (!config.url) return false;
return true;
},
wizard: props => {
const config = props.config ?? {
url: '',
openInNewTab: false,
};
return (
<>
<EuiFormRow label="Enter target URL">
<EuiFieldText
placeholder="Enter URL"
name="url"
value={config.url}
onChange={event => props.onConfig({ ...config, url: event.target.value })}
/>
</EuiFormRow>
<EuiFormRow hasChildLabel={false}>
<EuiSwitch
name="openInNewTab"
label="Open in new tab?"
checked={config.openInNewTab}
onChange={() => props.onConfig({ ...config, openInNewTab: !config.openInNewTab })}
/>
</EuiFormRow>
</>
);
CollectConfig: reactToUiComponent(UrlDrilldownCollectConfig),
order: 10,
isCompatible(context?: object): Promise<boolean> {
return Promise.resolve(true);
},
create: () => null as any,
};
export const urlFactory = new ActionFactory(urlDrilldownActionFactory);
export function Demo({ actionFactories }: { actionFactories: Array<ActionFactory<any>> }) {
const [state, setState] = useState<{
currentActionFactory?: ActionFactory;
@ -157,14 +194,15 @@ export function Demo({ actionFactories }: { actionFactories: Array<ActionFactory
changeActionFactory(newActionFactory);
}}
currentActionFactory={state.currentActionFactory}
context={{}}
/>
<div style={{ marginTop: '44px' }} />
<hr />
<div>Action Factory Type: {state.currentActionFactory?.type}</div>
<div>Action Factory Id: {state.currentActionFactory?.id}</div>
<div>Action Factory Config: {JSON.stringify(state.config)}</div>
<div>
Is config valid:{' '}
{JSON.stringify(state.currentActionFactory?.isValid(state.config!) ?? false)}
{JSON.stringify(state.currentActionFactory?.isConfigValid(state.config!) ?? false)}
</div>
</>
);

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './drilldown_picker';
export * from './action_wizard';

View file

@ -44,7 +44,7 @@ export class CustomTimeRangeAction implements ActionByType<typeof CUSTOM_TIME_RA
private dateFormat?: string;
private commonlyUsedRanges: CommonlyUsedRange[];
public readonly id = CUSTOM_TIME_RANGE;
public order = 7;
public order = 30;
constructor({
openModal,

View file

@ -0,0 +1,100 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ActionFactoryDefinition } from '../dynamic_actions';
/**
* This is a convenience interface to register a drilldown. Drilldown has
* ability to collect configuration from user. Once drilldown is executed it
* receives the collected information together with the context of the
* user's interaction.
*
* `Config` is a serializable object containing the configuration that the
* drilldown is able to collect using UI.
*
* `PlaceContext` is an object that the app that opens drilldown management
* flyout provides to the React component, specifying the contextual information
* about that app. For example, on Dashboard app this context contains
* information about the current embeddable and dashboard.
*
* `ExecutionContext` is an object created in response to user's interaction
* and provided to the `execute` function of the drilldown. This object contains
* information about the action user performed.
*/
export interface DrilldownDefinition<
Config extends object = object,
ExecutionContext extends object = object
> {
/**
* Globally unique identifier for this drilldown.
*/
id: string;
/**
* Determines the display order of the drilldowns in the flyout picker.
* Higher numbers are displayed first.
*/
order?: number;
/**
* Function that returns default config for this drilldown.
*/
createConfig: ActionFactoryDefinition<Config, object, ExecutionContext>['createConfig'];
/**
* `UiComponent` that collections config for this drilldown. You can create
* a React component and transform it `UiComponent` using `uiToReactComponent`
* helper from `kibana_utils` plugin.
*
* ```tsx
* import React from 'react';
* import { uiToReactComponent } from 'src/plugins/kibana_utils';
* import { CollectConfigProps } from 'src/plugins/kibana_utils/public';
*
* type Props = CollectConfigProps<Config>;
*
* const ReactCollectConfig: React.FC<Props> = () => {
* return <div>Collecting config...'</div>;
* };
*
* export const CollectConfig = uiToReactComponent(ReactCollectConfig);
* ```
*/
CollectConfig: ActionFactoryDefinition<Config, object, ExecutionContext>['CollectConfig'];
/**
* A validator function for the config object. Should always return a boolean
* given any input.
*/
isConfigValid: ActionFactoryDefinition<Config, object, ExecutionContext>['isConfigValid'];
/**
* Name of EUI icon to display when showing this drilldown to user.
*/
euiIcon?: string;
/**
* Should return an internationalized name of the drilldown, which will be
* displayed to the user.
*/
getDisplayName: () => string;
/**
* Implements the "navigation" action of the drilldown. This happens when
* user clicks something in the UI that executes a trigger to which this
* drilldown was attached.
*
* @param config Config object that user configured this drilldown with.
* @param context Object that represents context in which the underlying
* `UIAction` of this drilldown is being executed in.
*/
execute(config: Config, context: ExecutionContext): void;
/**
* A link where drilldown should navigate on middle click or Ctrl + click.
*/
getHref?(config: Config, context: ExecutionContext): Promise<string | undefined>;
}

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './flyout_create_drilldown';
export * from './drilldown_definition';

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { uiToReactComponent } from '../../../../../src/plugins/kibana_react/public';
import {
UiActionsActionDefinition as ActionDefinition,
UiActionsPresentable as Presentable,
} from '../../../../../src/plugins/ui_actions/public';
import { ActionFactoryDefinition } from './action_factory_definition';
import { Configurable } from '../../../../../src/plugins/kibana_utils/public';
import { SerializedAction } from './types';
export class ActionFactory<
Config extends object = object,
FactoryContext extends object = object,
ActionContext extends object = object
> implements Omit<Presentable<FactoryContext>, 'getHref'>, Configurable<Config, FactoryContext> {
constructor(
protected readonly def: ActionFactoryDefinition<Config, FactoryContext, ActionContext>
) {}
public readonly id = this.def.id;
public readonly order = this.def.order || 0;
public readonly MenuItem? = this.def.MenuItem;
public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined;
public readonly CollectConfig = this.def.CollectConfig;
public readonly ReactCollectConfig = uiToReactComponent(this.CollectConfig);
public readonly createConfig = this.def.createConfig;
public readonly isConfigValid = this.def.isConfigValid;
public getIconType(context: FactoryContext): string | undefined {
if (!this.def.getIconType) return undefined;
return this.def.getIconType(context);
}
public getDisplayName(context: FactoryContext): string {
if (!this.def.getDisplayName) return '';
return this.def.getDisplayName(context);
}
public async isCompatible(context: FactoryContext): Promise<boolean> {
if (!this.def.isCompatible) return true;
return await this.def.isCompatible(context);
}
public create(
serializedAction: Omit<SerializedAction<Config>, 'factoryId'>
): ActionDefinition<ActionContext> {
return this.def.create(serializedAction);
}
}

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
UiActionsActionDefinition as ActionDefinition,
UiActionsPresentable as Presentable,
} from '../../../../../src/plugins/ui_actions/public';
import { Configurable } from '../../../../../src/plugins/kibana_utils/public';
import { SerializedAction } from './types';
/**
* This is a convenience interface for registering new action factories.
*/
export interface ActionFactoryDefinition<
Config extends object = object,
FactoryContext extends object = object,
ActionContext extends object = object
>
extends Partial<Omit<Presentable<FactoryContext>, 'getHref'>>,
Configurable<Config, FactoryContext> {
/**
* Unique ID of the action factory. This ID is used to identify this action
* factory in the registry as well as to construct actions of this type and
* identify this action factory when presenting it to the user in UI.
*/
id: string;
/**
* This method should return a definition of a new action, normally used to
* register it in `ui_actions` registry.
*/
create(
serializedAction: Omit<SerializedAction<Config>, 'factoryId'>
): ActionDefinition<ActionContext>;
}

View file

@ -0,0 +1,635 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { DynamicActionManager } from './dynamic_action_manager';
import { ActionStorage, MemoryActionStorage } from './dynamic_action_storage';
import { UiActionsService } from '../../../../../src/plugins/ui_actions/public';
import { ActionInternal } from '../../../../../src/plugins/ui_actions/public/actions';
import { of } from '../../../../../src/plugins/kibana_utils';
import { UiActionsServiceEnhancements } from '../services';
import { ActionFactoryDefinition } from './action_factory_definition';
import { SerializedAction, SerializedEvent } from './types';
const actionFactoryDefinition1: ActionFactoryDefinition = {
id: 'ACTION_FACTORY_1',
CollectConfig: {} as any,
createConfig: () => ({}),
isConfigValid: (() => true) as any,
create: ({ name }) => ({
id: '',
execute: async () => {},
getDisplayName: () => name,
}),
};
const actionFactoryDefinition2: ActionFactoryDefinition = {
id: 'ACTION_FACTORY_2',
CollectConfig: {} as any,
createConfig: () => ({}),
isConfigValid: (() => true) as any,
create: ({ name }) => ({
id: '',
execute: async () => {},
getDisplayName: () => name,
}),
};
const event1: SerializedEvent = {
eventId: 'EVENT_ID_1',
triggers: ['VALUE_CLICK_TRIGGER'],
action: {
factoryId: actionFactoryDefinition1.id,
name: 'Action 1',
config: {},
},
};
const event2: SerializedEvent = {
eventId: 'EVENT_ID_2',
triggers: ['VALUE_CLICK_TRIGGER'],
action: {
factoryId: actionFactoryDefinition1.id,
name: 'Action 2',
config: {},
},
};
const event3: SerializedEvent = {
eventId: 'EVENT_ID_3',
triggers: ['VALUE_CLICK_TRIGGER'],
action: {
factoryId: actionFactoryDefinition2.id,
name: 'Action 3',
config: {},
},
};
const setup = (events: readonly SerializedEvent[] = []) => {
const isCompatible = async () => true;
const storage: ActionStorage = new MemoryActionStorage(events);
const actions = new Map<string, ActionInternal>();
const uiActions = new UiActionsService({
actions,
});
const uiActionsEnhancements = new UiActionsServiceEnhancements();
const manager = new DynamicActionManager({
isCompatible,
storage,
uiActions: { ...uiActions, ...uiActionsEnhancements },
});
uiActions.registerTrigger({
id: 'VALUE_CLICK_TRIGGER',
});
return {
isCompatible,
actions,
storage,
uiActions: { ...uiActions, ...uiActionsEnhancements },
manager,
};
};
describe('DynamicActionManager', () => {
test('can instantiate', () => {
const { manager } = setup([event1]);
expect(manager).toBeInstanceOf(DynamicActionManager);
});
describe('.start()', () => {
test('instantiates stored events', async () => {
const { manager, actions, uiActions } = setup([event1]);
const create1 = jest.fn();
const create2 = jest.fn();
uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 });
uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 });
expect(create1).toHaveBeenCalledTimes(0);
expect(create2).toHaveBeenCalledTimes(0);
expect(actions.size).toBe(0);
await manager.start();
expect(create1).toHaveBeenCalledTimes(1);
expect(create2).toHaveBeenCalledTimes(0);
expect(actions.size).toBe(1);
});
test('does nothing when no events stored', async () => {
const { manager, actions, uiActions } = setup();
const create1 = jest.fn();
const create2 = jest.fn();
uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 });
uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 });
expect(create1).toHaveBeenCalledTimes(0);
expect(create2).toHaveBeenCalledTimes(0);
expect(actions.size).toBe(0);
await manager.start();
expect(create1).toHaveBeenCalledTimes(0);
expect(create2).toHaveBeenCalledTimes(0);
expect(actions.size).toBe(0);
});
test('UI state is empty before manager starts', async () => {
const { manager } = setup([event1]);
expect(manager.state.get()).toMatchObject({
events: [],
isFetchingEvents: false,
fetchCount: 0,
});
});
test('loads events into UI state', async () => {
const { manager, uiActions } = setup([event1, event2, event3]);
uiActions.registerActionFactory(actionFactoryDefinition1);
uiActions.registerActionFactory(actionFactoryDefinition2);
await manager.start();
expect(manager.state.get()).toMatchObject({
events: [event1, event2, event3],
isFetchingEvents: false,
fetchCount: 1,
});
});
test('sets isFetchingEvents to true while fetching events', async () => {
const { manager, uiActions } = setup([event1, event2, event3]);
uiActions.registerActionFactory(actionFactoryDefinition1);
uiActions.registerActionFactory(actionFactoryDefinition2);
const promise = manager.start().catch(() => {});
expect(manager.state.get().isFetchingEvents).toBe(true);
await promise;
expect(manager.state.get().isFetchingEvents).toBe(false);
});
test('throws if storage threw', async () => {
const { manager, storage } = setup([event1]);
storage.list = async () => {
throw new Error('baz');
};
const [, error] = await of(manager.start());
expect(error).toEqual(new Error('baz'));
});
test('sets UI state error if error happened during initial fetch', async () => {
const { manager, storage } = setup([event1]);
storage.list = async () => {
throw new Error('baz');
};
await of(manager.start());
expect(manager.state.get().fetchError!.message).toBe('baz');
});
});
describe('.stop()', () => {
test('removes events from UI actions registry', async () => {
const { manager, actions, uiActions } = setup([event1, event2]);
const create1 = jest.fn();
const create2 = jest.fn();
uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 });
uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 });
expect(actions.size).toBe(0);
await manager.start();
expect(actions.size).toBe(2);
await manager.stop();
expect(actions.size).toBe(0);
});
});
describe('.createEvent()', () => {
describe('when storage succeeds', () => {
test('stores new event in storage', async () => {
const { manager, storage, uiActions } = setup([]);
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
};
expect(await storage.count()).toBe(0);
await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']);
expect(await storage.count()).toBe(1);
const [event] = await storage.list();
expect(event).toMatchObject({
eventId: expect.any(String),
triggers: ['VALUE_CLICK_TRIGGER'],
action: {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
},
});
});
test('adds event to UI state', async () => {
const { manager, uiActions } = setup([]);
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
};
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
expect(manager.state.get().events.length).toBe(0);
await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']);
expect(manager.state.get().events.length).toBe(1);
});
test('optimistically adds event to UI state', async () => {
const { manager, uiActions } = setup([]);
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
};
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
expect(manager.state.get().events.length).toBe(0);
const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e);
expect(manager.state.get().events.length).toBe(1);
await promise;
expect(manager.state.get().events.length).toBe(1);
});
test('instantiates event in actions service', async () => {
const { manager, uiActions, actions } = setup([]);
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
};
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
expect(actions.size).toBe(0);
await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']);
expect(actions.size).toBe(1);
});
});
describe('when storage fails', () => {
test('throws an error', async () => {
const { manager, storage, uiActions } = setup([]);
storage.create = async () => {
throw new Error('foo');
};
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
};
const [, error] = await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER']));
expect(error).toEqual(new Error('foo'));
});
test('does not add even to UI state', async () => {
const { manager, storage, uiActions } = setup([]);
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
};
storage.create = async () => {
throw new Error('foo');
};
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER']));
expect(manager.state.get().events.length).toBe(0);
});
test('optimistically adds event to UI state and then removes it', async () => {
const { manager, storage, uiActions } = setup([]);
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
};
storage.create = async () => {
throw new Error('foo');
};
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
expect(manager.state.get().events.length).toBe(0);
const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e);
expect(manager.state.get().events.length).toBe(1);
await promise;
expect(manager.state.get().events.length).toBe(0);
});
test('does not instantiate event in actions service', async () => {
const { manager, storage, uiActions, actions } = setup([]);
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition1.id,
name: 'foo',
config: {},
};
storage.create = async () => {
throw new Error('foo');
};
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
expect(actions.size).toBe(0);
await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER']));
expect(actions.size).toBe(0);
});
});
});
describe('.updateEvent()', () => {
describe('when storage succeeds', () => {
test('un-registers old event from ui actions service and registers the new one', async () => {
const { manager, actions, uiActions } = setup([event3]);
uiActions.registerActionFactory(actionFactoryDefinition2);
await manager.start();
expect(actions.size).toBe(1);
const registeredAction1 = actions.values().next().value;
expect(registeredAction1.getDisplayName()).toBe('Action 3');
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition2.id,
name: 'foo',
config: {},
};
await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']);
expect(actions.size).toBe(1);
const registeredAction2 = actions.values().next().value;
expect(registeredAction2.getDisplayName()).toBe('foo');
});
test('updates event in storage', async () => {
const { manager, storage, uiActions } = setup([event3]);
const storageUpdateSpy = jest.spyOn(storage, 'update');
uiActions.registerActionFactory(actionFactoryDefinition2);
await manager.start();
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition2.id,
name: 'foo',
config: {},
};
expect(storageUpdateSpy).toHaveBeenCalledTimes(0);
await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']);
expect(storageUpdateSpy).toHaveBeenCalledTimes(1);
expect(storageUpdateSpy.mock.calls[0][0]).toMatchObject({
eventId: expect.any(String),
triggers: ['VALUE_CLICK_TRIGGER'],
action: {
factoryId: actionFactoryDefinition2.id,
},
});
});
test('updates event in UI state', async () => {
const { manager, uiActions } = setup([event3]);
uiActions.registerActionFactory(actionFactoryDefinition2);
await manager.start();
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition2.id,
name: 'foo',
config: {},
};
expect(manager.state.get().events[0].action.name).toBe('Action 3');
await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']);
expect(manager.state.get().events[0].action.name).toBe('foo');
});
test('optimistically updates event in UI state', async () => {
const { manager, uiActions } = setup([event3]);
uiActions.registerActionFactory(actionFactoryDefinition2);
await manager.start();
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition2.id,
name: 'foo',
config: {},
};
expect(manager.state.get().events[0].action.name).toBe('Action 3');
const promise = manager
.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])
.catch(e => e);
expect(manager.state.get().events[0].action.name).toBe('foo');
await promise;
});
});
describe('when storage fails', () => {
test('throws error', async () => {
const { manager, storage, uiActions } = setup([event3]);
storage.update = () => {
throw new Error('bar');
};
uiActions.registerActionFactory(actionFactoryDefinition2);
await manager.start();
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition2.id,
name: 'foo',
config: {},
};
const [, error] = await of(
manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])
);
expect(error).toEqual(new Error('bar'));
});
test('keeps the old action in actions registry', async () => {
const { manager, storage, actions, uiActions } = setup([event3]);
storage.update = () => {
throw new Error('bar');
};
uiActions.registerActionFactory(actionFactoryDefinition2);
await manager.start();
expect(actions.size).toBe(1);
const registeredAction1 = actions.values().next().value;
expect(registeredAction1.getDisplayName()).toBe('Action 3');
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition2.id,
name: 'foo',
config: {},
};
await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']));
expect(actions.size).toBe(1);
const registeredAction2 = actions.values().next().value;
expect(registeredAction2.getDisplayName()).toBe('Action 3');
});
test('keeps old event in UI state', async () => {
const { manager, storage, uiActions } = setup([event3]);
storage.update = () => {
throw new Error('bar');
};
uiActions.registerActionFactory(actionFactoryDefinition2);
await manager.start();
const action: SerializedAction<unknown> = {
factoryId: actionFactoryDefinition2.id,
name: 'foo',
config: {},
};
expect(manager.state.get().events[0].action.name).toBe('Action 3');
await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']));
expect(manager.state.get().events[0].action.name).toBe('Action 3');
});
});
});
describe('.deleteEvents()', () => {
describe('when storage succeeds', () => {
test('removes all actions from uiActions service', async () => {
const { manager, actions, uiActions } = setup([event2, event1]);
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
expect(actions.size).toBe(2);
await manager.deleteEvents([event1.eventId, event2.eventId]);
expect(actions.size).toBe(0);
});
test('removes all events from storage', async () => {
const { manager, uiActions, storage } = setup([event2, event1]);
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
expect(await storage.list()).toEqual([event2, event1]);
await manager.deleteEvents([event1.eventId, event2.eventId]);
expect(await storage.list()).toEqual([]);
});
test('removes all events from UI state', async () => {
const { manager, uiActions } = setup([event2, event1]);
uiActions.registerActionFactory(actionFactoryDefinition1);
await manager.start();
expect(manager.state.get().events).toEqual([event2, event1]);
await manager.deleteEvents([event1.eventId, event2.eventId]);
expect(manager.state.get().events).toEqual([]);
});
});
});
});

View file

@ -0,0 +1,273 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { v4 as uuidv4 } from 'uuid';
import { Subscription } from 'rxjs';
import { ActionStorage } from './dynamic_action_storage';
import {
TriggerContextMapping,
UiActionsActionDefinition as ActionDefinition,
} from '../../../../../src/plugins/ui_actions/public';
import { defaultState, transitions, selectors, State } from './dynamic_action_manager_state';
import { StateContainer, createStateContainer } from '../../../../../src/plugins/kibana_utils';
import { StartContract } from '../plugin';
import { SerializedAction, SerializedEvent } from './types';
const compareEvents = (
a: ReadonlyArray<{ eventId: string }>,
b: ReadonlyArray<{ eventId: string }>
) => {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i].eventId !== b[i].eventId) return false;
return true;
};
export type DynamicActionManagerState = State;
export interface DynamicActionManagerParams {
storage: ActionStorage;
uiActions: Pick<
StartContract,
'registerAction' | 'attachAction' | 'unregisterAction' | 'detachAction' | 'getActionFactory'
>;
isCompatible: <C = unknown>(context: C) => Promise<boolean>;
}
export class DynamicActionManager {
static idPrefixCounter = 0;
private readonly idPrefix = `D_ACTION_${DynamicActionManager.idPrefixCounter++}_`;
private stopped: boolean = false;
private reloadSubscription?: Subscription;
/**
* UI State of the dynamic action manager.
*/
protected readonly ui = createStateContainer(defaultState, transitions, selectors);
constructor(protected readonly params: DynamicActionManagerParams) {}
protected getEvent(eventId: string): SerializedEvent {
const oldEvent = this.ui.selectors.getEvent(eventId);
if (!oldEvent) throw new Error(`Could not find event [eventId = ${eventId}].`);
return oldEvent;
}
/**
* We prefix action IDs with a unique `.idPrefix`, so we can render the
* same dashboard twice on the screen.
*/
protected generateActionId(eventId: string): string {
return this.idPrefix + eventId;
}
protected reviveAction(event: SerializedEvent) {
const { eventId, triggers, action } = event;
const { uiActions, isCompatible } = this.params;
const actionId = this.generateActionId(eventId);
const factory = uiActions.getActionFactory(event.action.factoryId);
const actionDefinition: ActionDefinition = {
...factory.create(action as SerializedAction<object>),
id: actionId,
isCompatible,
};
uiActions.registerAction(actionDefinition);
for (const trigger of triggers) uiActions.attachAction(trigger as any, actionId);
}
protected killAction({ eventId, triggers }: SerializedEvent) {
const { uiActions } = this.params;
const actionId = this.generateActionId(eventId);
for (const trigger of triggers) uiActions.detachAction(trigger as any, actionId);
uiActions.unregisterAction(actionId);
}
private syncId = 0;
/**
* This function is called every time stored events might have changed not by
* us. For example, when in edit mode on dashboard user presses "back" button
* in the browser, then contents of storage changes.
*/
private onSync = () => {
if (this.stopped) return;
(async () => {
const syncId = ++this.syncId;
const events = await this.params.storage.list();
if (this.stopped) return;
if (syncId !== this.syncId) return;
if (compareEvents(events, this.ui.get().events)) return;
for (const event of this.ui.get().events) this.killAction(event);
for (const event of events) this.reviveAction(event);
this.ui.transitions.finishFetching(events);
})().catch(error => {
/* eslint-disable */
console.log('Dynamic action manager storage reload failed.');
console.error(error);
/* eslint-enable */
});
};
// Public API: ---------------------------------------------------------------
/**
* Read-only state container of dynamic action manager. Use it to perform all
* *read* operations.
*/
public readonly state: StateContainer<State> = this.ui;
/**
* 1. Loads all events from @type {DynamicActionStorage} storage.
* 2. Creates actions for each event in `ui_actions` registry.
* 3. Adds events to UI state.
* 4. Does nothing if dynamic action manager was stopped or if event fetching
* is already taking place.
*/
public async start() {
if (this.stopped) return;
if (this.ui.get().isFetchingEvents) return;
this.ui.transitions.startFetching();
try {
const events = await this.params.storage.list();
for (const event of events) this.reviveAction(event);
this.ui.transitions.finishFetching(events);
} catch (error) {
this.ui.transitions.failFetching(error instanceof Error ? error : { message: String(error) });
throw error;
}
if (this.params.storage.reload$) {
this.reloadSubscription = this.params.storage.reload$.subscribe(this.onSync);
}
}
/**
* 1. Removes all events from `ui_actions` registry.
* 2. Puts dynamic action manager is stopped state.
*/
public async stop() {
this.stopped = true;
const events = await this.params.storage.list();
for (const event of events) {
this.killAction(event);
}
if (this.reloadSubscription) {
this.reloadSubscription.unsubscribe();
}
}
/**
* Creates a new event.
*
* 1. Stores event in @type {DynamicActionStorage} storage.
* 2. Optimistically adds it to UI state, and rolls back on failure.
* 3. Adds action to `ui_actions` registry.
*
* @param action Dynamic action for which to create an event.
* @param triggers List of triggers to which action should react.
*/
public async createEvent(
action: SerializedAction<unknown>,
triggers: Array<keyof TriggerContextMapping>
) {
const event: SerializedEvent = {
eventId: uuidv4(),
triggers,
action,
};
this.ui.transitions.addEvent(event);
try {
await this.params.storage.create(event);
this.reviveAction(event);
} catch (error) {
this.ui.transitions.removeEvent(event.eventId);
throw error;
}
}
/**
* Updates an existing event. Fails if event with given `eventId` does not
* exit.
*
* 1. Updates the event in @type {DynamicActionStorage} storage.
* 2. Optimistically replaces the old event by the new one in UI state, and
* rolls back on failure.
* 3. Replaces action in `ui_actions` registry with the new event.
*
*
* @param eventId ID of the event to replace.
* @param action New action for which to create the event.
* @param triggers List of triggers to which action should react.
*/
public async updateEvent(
eventId: string,
action: SerializedAction<unknown>,
triggers: Array<keyof TriggerContextMapping>
) {
const event: SerializedEvent = {
eventId,
triggers,
action,
};
const oldEvent = this.getEvent(eventId);
this.killAction(oldEvent);
this.reviveAction(event);
this.ui.transitions.replaceEvent(event);
try {
await this.params.storage.update(event);
} catch (error) {
this.killAction(event);
this.reviveAction(oldEvent);
this.ui.transitions.replaceEvent(oldEvent);
throw error;
}
}
/**
* Removes existing event. Throws if event does not exist.
*
* 1. Removes the event from @type {DynamicActionStorage} storage.
* 2. Optimistically removes event from UI state, and puts it back on failure.
* 3. Removes associated action from `ui_actions` registry.
*
* @param eventId ID of the event to remove.
*/
public async deleteEvent(eventId: string) {
const event = this.getEvent(eventId);
this.killAction(event);
this.ui.transitions.removeEvent(eventId);
try {
await this.params.storage.remove(eventId);
} catch (error) {
this.reviveAction(event);
this.ui.transitions.addEvent(event);
throw error;
}
}
/**
* Deletes multiple events at once.
*
* @param eventIds List of event IDs.
*/
public async deleteEvents(eventIds: string[]) {
await Promise.all(eventIds.map(this.deleteEvent.bind(this)));
}
}

Some files were not shown because too many files have changed in this diff Show more