From cb00e5e7bb76611c628f850539fc70060e95360e Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Mon, 4 May 2020 08:48:57 -0400 Subject: [PATCH 001/153] [Alerting] fix labels and links in PagerDuty action ui and docs (#64032) resolves #63222, resolves #63768, resolves #63223 ui changes: - adds an "(optional)" label after the API URL label - changes help link to go to alerting docs and not watcher docs - changes the label "Routing key" to "Integration key" to match other docs - changes the order of the severity options to match other docs doc changes: - changes the reference of "Routing key" to "Integration key" to match other docs - makes clearer that the API URL is optional --- .../alerting/action-types/pagerduty.asciidoc | 4 ++-- .../builtin_action_types/pagerduty.tsx | 24 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/user/alerting/action-types/pagerduty.asciidoc b/docs/user/alerting/action-types/pagerduty.asciidoc index abdcc7d1ba524..673b4f6263e18 100644 --- a/docs/user/alerting/action-types/pagerduty.asciidoc +++ b/docs/user/alerting/action-types/pagerduty.asciidoc @@ -92,7 +92,7 @@ section of the alert configuration and selecting *Add new*. * Alternatively, create a connector by navigating to *Management* from the {kib} navbar and selecting *Alerts and Actions*. Then, select the *Connectors* tab, click the *Create connector* button, and select the PagerDuty option. -. Configure the connector by giving it a name and optionally entering the API URL and Routing Key, or using the defaults. +. Configure the connector by giving it a name and entering the Integration Key, optionally entering a custom API URL. + See <> for how to obtain the endpoint and key information from PagerDuty and <> for more details. @@ -133,7 +133,7 @@ PagerDuty connectors have the following configuration properties: Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. API URL:: An optional PagerDuty event URL. Defaults to `https://events.pagerduty.com/v2/enqueue`. If you are using the <> setting, make sure the hostname is whitelisted. -Routing Key:: A 32 character PagerDuty Integration Key for an integration on a service or on a global ruleset. +Integration Key:: A 32 character PagerDuty Integration Key for an integration on a service, also referred to as the routing key. [float] [[pagerduty-action-configuration]] diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx index 15f91ae1d4609..e1c30ee1e8146 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx @@ -115,7 +115,7 @@ const PagerDutyActionConnectorFields: React.FunctionComponent @@ -139,7 +139,7 @@ const PagerDutyActionConnectorFields: React.FunctionComponent @@ -196,20 +196,20 @@ const PagerDutyParamsFields: React.FunctionComponent Date: Mon, 4 May 2020 16:11:20 +0200 Subject: [PATCH 002/153] Drilldowns (#61219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add drilldown wizard components * Dynamic actions (#58216) * feat: 🎸 add DynamicAction and FactoryAction types * feat: 🎸 add Mutable 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 component * feat: 🎸 improve component * chore: 🤖 use .story file extension prefix for Storybook * feat: 🎸 improve component * feat: 🎸 show error if dynamic action has CollectConfig missing * feat: 🎸 render sample action configuration component * feat: 🎸 connect action config to * feat: 🎸 improve 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 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 Co-authored-by: Elastic Machine * 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 * test: 💍 add 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 * 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 * 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 * 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 Co-authored-by: streamich Co-authored-by: ppisljar * 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 Co-authored-by: Matt Kime Co-authored-by: Elastic Machine Co-authored-by: Andrea Del Rio Co-authored-by: ppisljar --- .github/CODEOWNERS | 1 + .i18nrc.json | 1 + ...na-plugin-plugins-data-public.esfilters.md | 1 + examples/ui_action_examples/public/index.ts | 3 +- examples/ui_action_examples/public/plugin.ts | 23 +- examples/ui_actions_explorer/public/app.tsx | 3 +- .../ui_actions_explorer/public/plugin.tsx | 16 +- .../public/overlays/flyout/flyout_service.tsx | 1 + src/dev/storybook/aliases.ts | 3 +- .../actions/clone_panel_action.tsx | 2 +- .../actions/replace_panel_action.tsx | 2 +- .../tests/dashboard_container.test.tsx | 2 +- src/plugins/dashboard/public/plugin.tsx | 6 +- .../public/actions/apply_filter_action.ts | 1 + src/plugins/data/public/index.ts | 2 + .../index_patterns/index_patterns.ts | 8 +- src/plugins/data/public/plugin.ts | 9 +- src/plugins/data/public/public.api.md | 94 +-- .../data/public/query/timefilter/index.ts | 2 +- .../timefilter/lib/change_time_filter.ts | 10 +- src/plugins/embeddable/public/bootstrap.ts | 4 + src/plugins/embeddable/public/index.ts | 20 +- .../public/lib/actions/edit_panel_action.ts | 2 +- .../public/lib/embeddables/embeddable.tsx | 20 +- .../embeddables/embeddable_action_storage.ts | 126 ---- .../public/lib/embeddables/i_embeddable.ts | 17 +- .../lib/panel/embeddable_panel.test.tsx | 14 +- .../public/lib/panel/embeddable_panel.tsx | 51 +- .../customize_title/customize_panel_action.ts | 8 +- .../panel_actions/inspect_panel_action.ts | 2 +- .../panel_actions/remove_panel_action.ts | 2 +- .../lib/panel/panel_header/panel_header.tsx | 21 +- .../public/lib/triggers/triggers.ts | 27 +- src/plugins/embeddable/public/mocks.ts | 15 +- .../create_state_container_react_helpers.ts | 69 +- .../common/state_containers/types.ts | 2 +- src/plugins/kibana_utils/index.ts | 20 + src/plugins/kibana_utils/public/index.ts | 2 + .../kibana_utils/public/ui/configurable.ts | 60 ++ src/plugins/kibana_utils/public/ui/index.ts | 20 + .../ui_actions/public/actions/action.ts | 28 +- .../public/actions/action_definition.ts | 72 -- .../public/actions/action_internal.test.ts | 33 + .../public/actions/action_internal.ts | 58 ++ .../public/actions/create_action.ts | 14 +- .../ui_actions/public/actions/index.ts | 1 + .../build_eui_context_menu_panels.tsx | 54 +- .../public/context_menu/open_context_menu.tsx | 6 +- src/plugins/ui_actions/public/index.ts | 8 +- src/plugins/ui_actions/public/mocks.ts | 14 +- src/plugins/ui_actions/public/plugin.ts | 7 +- .../public/service/ui_actions_service.test.ts | 48 +- .../public/service/ui_actions_service.ts | 77 ++- .../tests/execute_trigger_actions.test.ts | 10 +- .../public/tests/get_trigger_actions.test.ts | 9 +- .../get_trigger_compatible_actions.test.ts | 6 +- .../public/tests/test_samples/index.ts | 1 + .../public/triggers/select_range_trigger.ts | 4 +- .../public/triggers/trigger_internal.ts | 5 +- .../public/triggers/value_click_trigger.ts | 4 +- src/plugins/ui_actions/public/types.ts | 4 +- src/plugins/ui_actions/public/util/index.ts | 20 + .../ui_actions/public/util/presentable.ts | 65 ++ .../public/embeddable/visualize_embeddable.ts | 1 + .../functional/page_objects/dashboard_page.ts | 27 +- .../kbn_sample_panel_action/public/plugin.ts | 8 +- .../public/np_ready/public/plugin.tsx | 3 +- x-pack/.i18nrc.json | 2 + .../ui_actions_enhanced_examples/README.md | 37 +- .../ui_actions_enhanced_examples/kibana.json | 2 +- .../dashboard_hello_world_drilldown/README.md | 1 + .../dashboard_hello_world_drilldown/index.tsx | 60 ++ .../collect_config_container.tsx | 71 ++ .../discover_drilldown_config.tsx | 104 +++ .../discover_drilldown_config/i18n.ts | 14 + .../discover_drilldown_config/index.ts | 7 + .../components/index.ts | 7 + .../constants.ts | 7 + .../drilldown.tsx | 82 +++ .../dashboard_to_discover_drilldown/i18n.ts | 11 + .../dashboard_to_discover_drilldown/index.ts | 15 + .../dashboard_to_discover_drilldown/types.ts | 38 ++ .../dashboard_to_url_drilldown/index.tsx | 114 ++++ .../public/plugin.ts | 25 +- .../plugins/canvas/public/application.tsx | 4 +- .../action_wizard/action_wizard.scss | 5 - .../action_wizard/action_wizard.story.tsx | 24 +- .../action_wizard/action_wizard.test.tsx | 19 +- .../action_wizard/action_wizard.tsx | 112 +-- .../public/components/action_wizard/i18n.ts | 2 +- .../public/components/action_wizard/index.ts | 2 +- .../components/action_wizard/test_data.tsx | 218 +++--- .../public/components/index.ts} | 2 +- .../public/custom_time_range_action.tsx | 2 +- .../public/drilldowns/drilldown_definition.ts | 100 +++ .../public/drilldowns}/index.ts | 2 +- .../public/dynamic_actions/action_factory.ts | 55 ++ .../action_factory_definition.ts | 38 ++ .../dynamic_action_manager.test.ts | 635 ++++++++++++++++++ .../dynamic_actions/dynamic_action_manager.ts | 273 ++++++++ .../dynamic_action_manager_state.ts | 98 +++ .../dynamic_actions/dynamic_action_storage.ts | 80 +++ .../public/dynamic_actions/index.ts | 12 + .../public/dynamic_actions/types.ts | 20 + .../advanced_ui_actions/public/index.ts | 19 + .../advanced_ui_actions/public/mocks.ts | 73 ++ .../advanced_ui_actions/public/plugin.ts | 36 +- .../public/services/index.ts | 7 + .../ui_actions_service_enhancements.test.ts | 70 ++ .../ui_actions_service_enhancements.ts | 97 +++ .../advanced_ui_actions/public/types.ts | 3 + x-pack/plugins/dashboard_enhanced/README.md | 1 + x-pack/plugins/dashboard_enhanced/kibana.json | 8 + .../dashboard_enhanced/public/index.ts | 19 + .../dashboard_enhanced/public/mocks.ts | 27 + .../dashboard_enhanced/public/plugin.ts | 55 ++ .../flyout_create_drilldown.test.tsx | 144 ++++ .../flyout_create_drilldown.tsx | 86 +++ .../actions/flyout_create_drilldown/index.ts | 11 + .../flyout_edit_drilldown.test.tsx | 148 ++++ .../flyout_edit_drilldown.tsx | 74 ++ .../actions/flyout_edit_drilldown}/i18n.ts | 6 +- .../actions/flyout_edit_drilldown/index.tsx | 11 + .../flyout_edit_drilldown/menu_item.test.tsx | 39 ++ .../flyout_edit_drilldown/menu_item.tsx | 27 + .../services/drilldowns}/actions/index.ts | 0 .../drilldowns/actions/test_helpers.ts | 52 ++ .../dashboard_drilldowns_services.ts | 57 ++ .../components/collect_config_container.tsx | 164 +++++ .../dashboard_drilldown_config.story.tsx | 63 ++ .../dashboard_drilldown_config.test.tsx | 10 + .../dashboard_drilldown_config.tsx | 82 +++ .../dashboard_drilldown_config/i18n.ts | 28 + .../dashboard_drilldown_config/index.ts | 7 + .../components/i18n.ts | 16 + .../components/index.ts | 7 + .../constants.ts | 7 + .../drilldown.test.tsx | 363 ++++++++++ .../drilldown.tsx | 154 +++++ .../dashboard_to_dashboard_drilldown/i18n.ts | 11 + .../dashboard_to_dashboard_drilldown/index.ts | 15 + .../dashboard_to_dashboard_drilldown/types.ts | 21 + .../public/services/drilldowns/index.ts | 7 + .../public/services}/index.ts | 2 +- .../dashboard_enhanced/scripts/storybook.js | 13 + x-pack/plugins/drilldowns/kibana.json | 7 +- .../actions/flyout_create_drilldown/index.tsx | 52 -- .../actions/flyout_edit_drilldown/index.tsx | 72 -- ...nnected_flyout_manage_drilldowns.story.tsx | 43 ++ ...onnected_flyout_manage_drilldowns.test.tsx | 221 ++++++ .../connected_flyout_manage_drilldowns.tsx | 331 +++++++++ .../i18n.ts | 88 +++ .../index.ts | 7 + .../test_data.ts | 89 +++ .../drilldown_hello_bar.story.tsx | 16 +- .../drilldown_hello_bar.tsx | 58 +- .../components/drilldown_hello_bar/i18n.ts | 29 + .../drilldown_picker/drilldown_picker.tsx | 21 - .../flyout_create_drilldown.story.tsx | 24 - .../flyout_create_drilldown.tsx | 34 - .../flyout_drilldown_wizard.story.tsx | 70 ++ .../flyout_drilldown_wizard.tsx | 140 ++++ .../flyout_drilldown_wizard/i18n.ts | 42 ++ .../flyout_drilldown_wizard/index.ts | 7 + .../flyout_frame/flyout_frame.story.tsx | 7 + .../flyout_frame/flyout_frame.test.tsx | 4 +- .../components/flyout_frame/flyout_frame.tsx | 31 +- .../public/components/flyout_frame/i18n.ts | 6 +- .../flyout_list_manage_drilldowns.story.tsx | 22 + .../flyout_list_manage_drilldowns.tsx | 46 ++ .../i18n.ts} | 13 +- .../flyout_list_manage_drilldowns/index.ts | 7 + .../form_create_drilldown.story.tsx | 34 - .../form_create_drilldown.tsx | 52 -- .../form_drilldown_wizard.story.tsx | 29 + .../form_drilldown_wizard.test.tsx} | 28 +- .../form_drilldown_wizard.tsx | 79 +++ .../i18n.ts | 4 +- .../index.tsx | 2 +- .../components/list_manage_drilldowns/i18n.ts | 36 + .../list_manage_drilldowns/index.tsx | 7 + .../list_manage_drilldowns.story.tsx | 19 + .../list_manage_drilldowns.test.tsx | 70 ++ .../list_manage_drilldowns.tsx | 122 ++++ x-pack/plugins/drilldowns/public/index.ts | 8 +- x-pack/plugins/drilldowns/public/mocks.ts | 12 +- x-pack/plugins/drilldowns/public/plugin.ts | 55 +- .../public/service/drilldown_service.ts | 32 - x-pack/plugins/embeddable_enhanced/README.md | 1 + .../plugins/embeddable_enhanced/kibana.json | 7 + .../public/actions/index.ts | 7 + .../panel_notifications_action.test.ts | 75 +++ .../actions/panel_notifications_action.ts | 34 + .../embeddable_action_storage.test.ts | 186 ++--- .../embeddables/embeddable_action_storage.ts | 114 ++++ .../public/embeddables/index.ts | 8 + .../embeddables/is_enhanced_embeddable.ts | 14 + .../embeddable_enhanced/public/index.ts | 22 + .../embeddable_enhanced/public/mocks.ts | 27 + .../embeddable_enhanced/public/plugin.ts | 160 +++++ .../embeddable_enhanced/public/types.ts | 21 + x-pack/plugins/reporting/public/plugin.tsx | 3 +- .../translations/translations/ja-JP.json | 5 - .../translations/translations/zh-CN.json | 5 - .../drilldowns/dashboard_drilldowns.ts | 176 +++++ .../apps/dashboard/drilldowns/index.ts | 13 + .../test/functional/apps/dashboard/index.ts | 1 + .../dashboard/drilldowns/data.json.gz | Bin 0 -> 2662 bytes .../dashboard/drilldowns/mappings.json | 244 +++++++ .../services/dashboard/drilldowns_manage.ts | 95 +++ .../functional/services/dashboard/index.ts | 8 + .../dashboard/panel_drilldown_actions.ts | 80 +++ x-pack/test/functional/services/index.ts | 6 + 213 files changed, 7817 insertions(+), 1190 deletions(-) delete mode 100644 src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts create mode 100644 src/plugins/kibana_utils/index.ts create mode 100644 src/plugins/kibana_utils/public/ui/configurable.ts create mode 100644 src/plugins/kibana_utils/public/ui/index.ts delete mode 100644 src/plugins/ui_actions/public/actions/action_definition.ts create mode 100644 src/plugins/ui_actions/public/actions/action_internal.test.ts create mode 100644 src/plugins/ui_actions/public/actions/action_internal.ts create mode 100644 src/plugins/ui_actions/public/util/index.ts create mode 100644 src/plugins/ui_actions/public/util/presentable.ts create mode 100644 x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/README.md create mode 100644 x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx create mode 100644 x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/collect_config_container.tsx create mode 100644 x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/discover_drilldown_config.tsx create mode 100644 x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/i18n.ts create mode 100644 x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/index.ts create mode 100644 x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/index.ts create mode 100644 x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/constants.ts create mode 100644 x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/drilldown.tsx create mode 100644 x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/i18n.ts create mode 100644 x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/index.ts create mode 100644 x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts create mode 100644 x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx rename x-pack/plugins/{drilldowns/public/components/drilldown_picker/index.tsx => advanced_ui_actions/public/components/index.ts} (87%) create mode 100644 x-pack/plugins/advanced_ui_actions/public/drilldowns/drilldown_definition.ts rename x-pack/plugins/{drilldowns/public/components/flyout_create_drilldown => advanced_ui_actions/public/drilldowns}/index.ts (84%) create mode 100644 x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory.ts create mode 100644 x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory_definition.ts create mode 100644 x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager.test.ts create mode 100644 x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager.ts create mode 100644 x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager_state.ts create mode 100644 x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_storage.ts create mode 100644 x-pack/plugins/advanced_ui_actions/public/dynamic_actions/index.ts create mode 100644 x-pack/plugins/advanced_ui_actions/public/dynamic_actions/types.ts create mode 100644 x-pack/plugins/advanced_ui_actions/public/mocks.ts create mode 100644 x-pack/plugins/advanced_ui_actions/public/services/index.ts create mode 100644 x-pack/plugins/advanced_ui_actions/public/services/ui_actions_service_enhancements.test.ts create mode 100644 x-pack/plugins/advanced_ui_actions/public/services/ui_actions_service_enhancements.ts create mode 100644 x-pack/plugins/dashboard_enhanced/README.md create mode 100644 x-pack/plugins/dashboard_enhanced/kibana.json create mode 100644 x-pack/plugins/dashboard_enhanced/public/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/mocks.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/plugin.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx rename x-pack/plugins/{drilldowns/public/components/flyout_create_drilldown => dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown}/i18n.ts (64%) create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx rename x-pack/plugins/{drilldowns/public => dashboard_enhanced/public/services/drilldowns}/actions/index.ts (100%) create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/collect_config_container.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/i18n.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts rename x-pack/plugins/{drilldowns/public/service => dashboard_enhanced/public/services}/index.ts (86%) create mode 100644 x-pack/plugins/dashboard_enhanced/scripts/storybook.js delete mode 100644 x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx delete mode 100644 x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts create mode 100644 x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts delete mode 100644 x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx rename x-pack/plugins/drilldowns/public/components/{drilldown_picker/drilldown_picker.story.tsx => flyout_list_manage_drilldowns/i18n.ts} (52%) create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts delete mode 100644 x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx rename x-pack/plugins/drilldowns/public/components/{form_create_drilldown/form_create_drilldown.test.tsx => form_drilldown_wizard/form_drilldown_wizard.test.tsx} (60%) create mode 100644 x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx rename x-pack/plugins/drilldowns/public/components/{form_create_drilldown => form_drilldown_wizard}/i18n.ts (89%) rename x-pack/plugins/drilldowns/public/components/{form_create_drilldown => form_drilldown_wizard}/index.tsx (85%) create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx delete mode 100644 x-pack/plugins/drilldowns/public/service/drilldown_service.ts create mode 100644 x-pack/plugins/embeddable_enhanced/README.md create mode 100644 x-pack/plugins/embeddable_enhanced/kibana.json create mode 100644 x-pack/plugins/embeddable_enhanced/public/actions/index.ts create mode 100644 x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.test.ts create mode 100644 x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.ts rename {src/plugins/embeddable/public/lib => x-pack/plugins/embeddable_enhanced/public}/embeddables/embeddable_action_storage.test.ts (72%) create mode 100644 x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.ts create mode 100644 x-pack/plugins/embeddable_enhanced/public/embeddables/index.ts create mode 100644 x-pack/plugins/embeddable_enhanced/public/embeddables/is_enhanced_embeddable.ts create mode 100644 x-pack/plugins/embeddable_enhanced/public/index.ts create mode 100644 x-pack/plugins/embeddable_enhanced/public/mocks.ts create mode 100644 x-pack/plugins/embeddable_enhanced/public/plugin.ts create mode 100644 x-pack/plugins/embeddable_enhanced/public/types.ts create mode 100644 x-pack/test/functional/apps/dashboard/drilldowns/dashboard_drilldowns.ts create mode 100644 x-pack/test/functional/apps/dashboard/drilldowns/index.ts create mode 100644 x-pack/test/functional/es_archives/dashboard/drilldowns/data.json.gz create mode 100644 x-pack/test/functional/es_archives/dashboard/drilldowns/mappings.json create mode 100644 x-pack/test/functional/services/dashboard/drilldowns_manage.ts create mode 100644 x-pack/test/functional/services/dashboard/index.ts create mode 100644 x-pack/test/functional/services/dashboard/panel_drilldown_actions.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b4692a4ddb3b7..a008fa7ea9239 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/.i18nrc.json b/.i18nrc.json index b04c02f6b2265..be3c043b6e52f 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -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", diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md index 7fd65e5db35f3..37142cf1794c3 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md @@ -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; } diff --git a/examples/ui_action_examples/public/index.ts b/examples/ui_action_examples/public/index.ts index 88a36d278e256..5b08192a1196e 100644 --- a/examples/ui_action_examples/public/index.ts +++ b/examples/ui_action_examples/public/index.ts @@ -18,9 +18,8 @@ */ import { UiActionExamplesPlugin } from './plugin'; -import { PluginInitializer } from '../../../src/core/public'; -export const plugin: PluginInitializer = () => new UiActionExamplesPlugin(); +export const plugin = () => new UiActionExamplesPlugin(); export { HELLO_WORLD_TRIGGER_ID } from './hello_world_trigger'; export { ACTION_HELLO_WORLD } from './hello_world_action'; diff --git a/examples/ui_action_examples/public/plugin.ts b/examples/ui_action_examples/public/plugin.ts index c47746d4b3fd6..3a9f673261e33 100644 --- a/examples/ui_action_examples/public/plugin.ts +++ b/examples/ui_action_examples/public/plugin.ts @@ -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 { - public setup(core: CoreSetup, { uiActions }: UiActionExamplesSetupDependencies) { + implements + Plugin { + public setup( + core: CoreSetup, + { 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() {} } diff --git a/examples/ui_actions_explorer/public/app.tsx b/examples/ui_actions_explorer/public/app.tsx index 462f5c3bf88ba..f08b8bb29bdd3 100644 --- a/examples/ui_actions_explorer/public/app.tsx +++ b/examples/ui_actions_explorer/public/app.tsx @@ -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( {} diff --git a/examples/ui_actions_explorer/public/plugin.tsx b/examples/ui_actions_explorer/public/plugin.tsx index f1895905a45e1..de86b51aee3a8 100644 --- a/examples/ui_actions_explorer/public/plugin.tsx +++ b/examples/ui_actions_explorer/public/plugin.tsx @@ -79,21 +79,21 @@ export class UiActionsExplorerPlugin implements Plugin (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', diff --git a/src/core/public/overlays/flyout/flyout_service.tsx b/src/core/public/overlays/flyout/flyout_service.tsx index b609b2ce1d741..444430175d4f2 100644 --- a/src/core/public/overlays/flyout/flyout_service.tsx +++ b/src/core/public/overlays/flyout/flyout_service.tsx @@ -91,6 +91,7 @@ export interface OverlayFlyoutStart { export interface OverlayFlyoutOpenOptions { className?: string; closeButtonAriaLabel?: string; + ownFocus?: boolean; 'data-test-subj'?: string; } diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 4dc930dae3e25..0e91f0a214a45 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -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', }; diff --git a/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx b/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx index 4d15e7e899fa8..ff4e50ba8c327 100644 --- a/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx +++ b/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx @@ -39,7 +39,7 @@ export interface ClonePanelActionContext { export class ClonePanelAction implements ActionByType { public readonly type = ACTION_CLONE_PANEL; public readonly id = ACTION_CLONE_PANEL; - public order = 11; + public order = 45; constructor(private core: CoreStart) {} diff --git a/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx b/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx index ddc255295e89b..5526af2f83850 100644 --- a/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx +++ b/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx @@ -37,7 +37,7 @@ export interface ReplacePanelActionContext { export class ReplacePanelAction implements ActionByType { public readonly type = ACTION_REPLACE_PANEL; public readonly id = ACTION_REPLACE_PANEL; - public order = 11; + public order = 3; constructor( private core: CoreStart, diff --git a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx b/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx index 5dab21ff671b4..40231de7597f1 100644 --- a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx @@ -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) diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 7de054f2eaa9c..b28822120b31e 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -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, diff --git a/src/plugins/data/public/actions/apply_filter_action.ts b/src/plugins/data/public/actions/apply_filter_action.ts index bd20c6f632a3a..ebaac6b745bec 100644 --- a/src/plugins/data/public/actions/apply_filter_action.ts +++ b/src/plugins/data/public/actions/apply_filter_action.ts @@ -42,6 +42,7 @@ export function createFilterAction( return createAction({ 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', diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 75deff23ce20d..ebc794ed7e595 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -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, }; diff --git a/src/plugins/data/public/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/public/index_patterns/index_patterns/index_patterns.ts index b5d66a6aab60a..73d5aeaf30710 100644 --- a/src/plugins/data/public/index_patterns/index_patterns/index_patterns.ts +++ b/src/plugins/data/public/index_patterns/index_patterns/index_patterns.ts @@ -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>> | null; + private savedObjectsCache?: Array> | null; private apiClient: IndexPatternsApiClient; ensureDefaultIndexPattern: EnsureDefaultIndexPattern; @@ -53,7 +57,7 @@ export class IndexPatternsService { private async refreshSavedObjectsCache() { this.savedObjectsCache = ( - await this.savedObjectsClient.find>({ + await this.savedObjectsClient.find({ type: 'index-pattern', fields: ['title'], perPage: 10000, diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index f3a88287313a0..d822e96d0a129 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -126,12 +126,12 @@ export class DataPublicPlugin implements Plugin 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 = (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 diff --git a/src/plugins/data/public/query/timefilter/index.ts b/src/plugins/data/public/query/timefilter/index.ts index 034af03842ab8..a5885a59f60ed 100644 --- a/src/plugins/data/public/query/timefilter/index.ts +++ b/src/plugins/data/public/query/timefilter/index.ts @@ -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'; diff --git a/src/plugins/data/public/query/timefilter/lib/change_time_filter.ts b/src/plugins/data/public/query/timefilter/lib/change_time_filter.ts index 8da83580ef5d6..cbbf2f2754312 100644 --- a/src/plugins/data/public/query/timefilter/lib/change_time_filter.ts +++ b/src/plugins/data/public/query/timefilter/lib/change_time_filter.ts @@ -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)); } diff --git a/src/plugins/embeddable/public/bootstrap.ts b/src/plugins/embeddable/public/bootstrap.ts index c8c4f0b95c458..33cf210763b10 100644 --- a/src/plugins/embeddable/public/bootstrap.ts +++ b/src/plugins/embeddable/public/bootstrap.ts @@ -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(); diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 5ee66f9d19ac0..e61ad2a6eefed 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -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'; diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts index 0abbc25ff49a6..d57867900c24b 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts @@ -34,7 +34,7 @@ interface ActionContext { export class EditPanelAction implements Action { public readonly type = ACTION_EDIT_PANEL; public readonly id = ACTION_EDIT_PANEL; - public order = 15; + public order = 50; constructor( private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'], diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index a135484ff61be..9c544e86e189a 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -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 { + 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(); } diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts deleted file mode 100644 index 520f92840c5f9..0000000000000 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts +++ /dev/null @@ -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; - update(event: SerializedEvent): Promise; - remove(eventId: string): Promise; - read(eventId: string): Promise; - count(): Promise; - list(): Promise; -} - -export class EmbeddableActionStorage implements ActionStorage { - constructor(private readonly embbeddable: Embeddable) {} - - 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 { - 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 { - return this.__list().length; - } - - async list(): Promise { - return this.__list(); - } -} diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index 9a3e49e497962..c16698a5f8637 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -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 diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 49b6d7803a200..9dd4c74c624d9 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -45,7 +45,7 @@ import { inspectorPluginMock } from '../../../../inspector/public/mocks'; import { EuiBadge } from '@elastic/eui'; import { embeddablePluginMock } from '../../mocks'; -const actionRegistry = new Map>(); +const actionRegistry = new Map(); const triggerRegistry = new Map(); 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]); diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index c43359382a33d..36ddfb49b0312 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -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; getActions: UiActionsService['getTriggerCompatibleActions']; @@ -58,6 +71,7 @@ interface State { hidePanelTitles: boolean; closeContextMenu: boolean; badges: Array>; + notifications: Array>; } export class EmbeddablePanel extends React.Component { @@ -83,6 +97,7 @@ export class EmbeddablePanel extends React.Component { hidePanelTitles, closeContextMenu: false, badges: [], + notifications: [], }; this.embeddableRoot = React.createRef(); @@ -104,6 +119,22 @@ export class EmbeddablePanel extends React.Component { }); } + 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 { }); this.refreshBadges(); + this.refreshNotifications(); } }); @@ -127,6 +159,7 @@ export class EmbeddablePanel extends React.Component { }); this.refreshBadges(); + this.refreshNotifications(); } }); } @@ -176,6 +209,7 @@ export class EmbeddablePanel extends React.Component { 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 { }; 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 { new EditPanelAction(this.props.getEmbeddableFactory, this.props.application), ]; - const sorted = actions - .concat(extraActions) - .sort((a: Action, b: Action) => { - 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, }); diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts index c0e43c0538833..36957c3b79491 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts @@ -33,15 +33,13 @@ interface ActionContext { export class CustomizePanelTitleAction implements Action { 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', }); } diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts index d04f35715537c..ae9645767b267 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts @@ -31,7 +31,7 @@ interface ActionContext { export class InspectPanelAction implements Action { public readonly type = ACTION_INSPECT_PANEL; public readonly id = ACTION_INSPECT_PANEL; - public order = 10; + public order = 20; constructor(private readonly inspector: InspectorStartContract) {} diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts index ee7948f3d6a4a..a6d4128f3f106 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts @@ -41,7 +41,7 @@ function hasExpandedPanelInput( export class RemovePanelAction implements Action { public readonly type = REMOVE_PANEL_ACTION; public readonly id = REMOVE_PANEL_ACTION; - public order = 5; + public order = 1; constructor() {} diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index 99516a1d21d6f..35a10ed848e83 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -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; closeContextMenu: boolean; badges: Array>; + notifications: Array>; embeddable: IEmbeddable; headerId?: string; } @@ -56,6 +58,22 @@ function renderBadges(badges: Array>, embeddable: IEmb )); } +function renderNotifications( + notifications: Array>, + embeddable: IEmbeddable +) { + return notifications.map(notification => ( + notification.execute({ embeddable })} + > + {notification.getDisplayName({ embeddable })} + + )); +} + 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)} - + {renderNotifications(notifications, embeddable)} { + 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 { + 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.', }; diff --git a/src/plugins/embeddable/public/mocks.ts b/src/plugins/embeddable/public/mocks.ts index 65b15f3a7614f..f5487c381cfcb 100644 --- a/src/plugins/embeddable/public/mocks.ts +++ b/src/plugins/embeddable/public/mocks.ts @@ -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 = {}) => { 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 = {}) => plugin.start(coreMock.createStart(), { - uiActions: uiActionsPluginMock.createStartContract(), + uiActions: startPlugins.uiActions || uiActionsPluginMock.createStartContract(), inspector: inspectorPluginMock.createStartContract(), }); return { diff --git a/src/plugins/kibana_utils/common/state_containers/create_state_container_react_helpers.ts b/src/plugins/kibana_utils/common/state_containers/create_state_container_react_helpers.ts index 36903f2d7c90f..90823359359a1 100644 --- a/src/plugins/kibana_utils/common/state_containers/create_state_container_react_helpers.ts +++ b/src/plugins/kibana_utils/common/state_containers/create_state_container_react_helpers.ts @@ -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: Container +): UnboxState => 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 = , Result>( + container: Container, + selector: (state: UnboxState) => Result, + comparator: Comparator = defaultComparator +): Result => { + const { state$, get } = container; + const lastValueRef = useRef(get()); + const [value, setValue] = React.useState(() => { + const newValue = selector(get()); + lastValueRef.current = newValue; + return newValue; + }); + useLayoutEffect(() => { + const subscription = state$.subscribe((currentState: UnboxState) => { + const newValue = selector(currentState); + if (!comparator(lastValueRef.current, newValue)) { + lastValueRef.current = newValue; + setValue(newValue); + } + }); + return () => subscription.unsubscribe(); + }, [state$, comparator]); + return value; +}; + export const createStateContainerReactHelpers = >() => { const context = React.createContext(null as any); const useContainer = (): Container => useContext(context); const useState = (): UnboxState => { - 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 = ) => Result, comparator: Comparator = defaultComparator ): Result => { - const { state$, get } = useContainer(); - const lastValueRef = useRef(get()); - const [value, setValue] = React.useState(() => { - const newValue = selector(get()); - lastValueRef.current = newValue; - return newValue; - }); - useLayoutEffect(() => { - const subscription = state$.subscribe((currentState: UnboxState) => { - 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, selector, comparator); }; const connect: Connect> = mapStateToProp => component => props => diff --git a/src/plugins/kibana_utils/common/state_containers/types.ts b/src/plugins/kibana_utils/common/state_containers/types.ts index 26a29bc470e8a..29ffa4cd486b5 100644 --- a/src/plugins/kibana_utils/common/state_containers/types.ts +++ b/src/plugins/kibana_utils/common/state_containers/types.ts @@ -43,7 +43,7 @@ export interface BaseStateContainer { export interface StateContainer< State extends BaseState, - PureTransitions extends object, + PureTransitions extends object = object, PureSelectors extends object = {} > extends BaseStateContainer { transitions: Readonly>; diff --git a/src/plugins/kibana_utils/index.ts b/src/plugins/kibana_utils/index.ts new file mode 100644 index 0000000000000..14d6e52dc0465 --- /dev/null +++ b/src/plugins/kibana_utils/index.ts @@ -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'; diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts index c634322b23d0b..3d8a4414de70c 100644 --- a/src/plugins/kibana_utils/public/index.ts +++ b/src/plugins/kibana_utils/public/index.ts @@ -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() { diff --git a/src/plugins/kibana_utils/public/ui/configurable.ts b/src/plugins/kibana_utils/public/ui/configurable.ts new file mode 100644 index 0000000000000..a4a9f09c1c0e0 --- /dev/null +++ b/src/plugins/kibana_utils/public/ui/configurable.ts @@ -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 { + /** + * 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>; +} + +/** + * Props provided to `CollectConfig` component on every re-render. + */ +export interface CollectConfigProps { + /** + * 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; +} diff --git a/src/plugins/kibana_utils/public/ui/index.ts b/src/plugins/kibana_utils/public/ui/index.ts new file mode 100644 index 0000000000000..54d47ac7e980f --- /dev/null +++ b/src/plugins/kibana_utils/public/ui/index.ts @@ -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'; diff --git a/src/plugins/ui_actions/public/actions/action.ts b/src/plugins/ui_actions/public/actions/action.ts index feaa1f6a60e2f..f5dbbc9f923ac 100644 --- a/src/plugins/ui_actions/public/actions/action.ts +++ b/src/plugins/ui_actions/public/actions/action.ts @@ -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 = Action; -export interface Action { +export interface Action + extends Partial> { /** * 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 { isCompatible(context: Context): Promise; /** - * 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; + execute(context: Context): Promise; +} + +/** + * A convenience interface used to register an action. + */ +export interface ActionDefinition + extends Partial> { + /** + * 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; } + +export type ActionContext = A extends ActionDefinition ? Context : never; diff --git a/src/plugins/ui_actions/public/actions/action_definition.ts b/src/plugins/ui_actions/public/actions/action_definition.ts deleted file mode 100644 index 79fda78401abd..0000000000000 --- a/src/plugins/ui_actions/public/actions/action_definition.ts +++ /dev/null @@ -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 { - /** - * 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; - - /** - * If this returns something truthy, this is used in addition to the `execute` method when clicked. - */ - getHref?(context: ActionContextMapping[T]): Promise; - - /** - * Executes the action. - */ - execute(context: ActionContextMapping[T]): Promise; -} diff --git a/src/plugins/ui_actions/public/actions/action_internal.test.ts b/src/plugins/ui_actions/public/actions/action_internal.test.ts new file mode 100644 index 0000000000000..b14346180c274 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/action_internal.test.ts @@ -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'); + }); +}); diff --git a/src/plugins/ui_actions/public/actions/action_internal.ts b/src/plugins/ui_actions/public/actions/action_internal.ts new file mode 100644 index 0000000000000..4cbc4dd2a053c --- /dev/null +++ b/src/plugins/ui_actions/public/actions/action_internal.ts @@ -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 + implements Action>, Presentable> { + 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) { + return this.definition.execute(context); + } + + public getIconType(context: Context): string | undefined { + if (!this.definition.getIconType) return undefined; + return this.definition.getIconType(context); + } + + public getDisplayName(context: Context): string { + if (!this.definition.getDisplayName) return `Action: ${this.id}`; + return this.definition.getDisplayName(context); + } + + public async isCompatible(context: Context): Promise { + if (!this.definition.isCompatible) return true; + return await this.definition.isCompatible(context); + } + + public async getHref(context: Context): Promise { + if (!this.definition.getHref) return undefined; + return await this.definition.getHref(context); + } +} diff --git a/src/plugins/ui_actions/public/actions/create_action.ts b/src/plugins/ui_actions/public/actions/create_action.ts index cc66f221e4082..dea21678eccea 100644 --- a/src/plugins/ui_actions/public/actions/create_action.ts +++ b/src/plugins/ui_actions/public/actions/create_action.ts @@ -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(action: ActionDefinition): ActionByType { +interface ActionDefinitionByType + extends Omit, 'id'> { + id?: string; +} + +export function createAction( + action: ActionDefinitionByType +): ActionByType { return { getIconType: () => undefined, order: 0, @@ -29,5 +37,5 @@ export function createAction(action: ActionDefinition): isCompatible: () => Promise.resolve(true), getDisplayName: () => '', ...action, - }; + } as ActionByType; } diff --git a/src/plugins/ui_actions/public/actions/index.ts b/src/plugins/ui_actions/public/actions/index.ts index 64bfd368e3dfa..88e42ff2ec113 100644 --- a/src/plugins/ui_actions/public/actions/index.ts +++ b/src/plugins/ui_actions/public/actions/index.ts @@ -18,5 +18,6 @@ */ export * from './action'; +export * from './action_internal'; export * from './create_action'; export * from './incompatible_action_error'; diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index d26740ffdf033..0c19d20ed1bda 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -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({ +export async function buildContextMenuForActions({ actions, actionContext, + title = defaultTitle, closeMenu, }: { - actions: Array>; - actionContext: A; + actions: Array>; + actionContext: Context; + title?: string; closeMenu: () => void; }): Promise { - const menuItems = await buildEuiContextMenuPanelItems({ + const menuItems = await buildEuiContextMenuPanelItems({ actions, actionContext, closeMenu, @@ -44,9 +50,7 @@ export async function buildContextMenuForActions({ return { id: 'mainMenu', - title: i18n.translate('uiActions.actionPanel.title', { - defaultMessage: 'Options', - }), + title, items: menuItems, }; } @@ -54,49 +58,41 @@ export async function buildContextMenuForActions({ /** * Transform an array of Actions into the shape needed to build an EUIContextMenu */ -async function buildEuiContextMenuPanelItems({ +async function buildEuiContextMenuPanelItems({ actions, actionContext, closeMenu, }: { - actions: Array>; - actionContext: A; + actions: Array>; + 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} - */ -async function convertPanelActionToContextMenuItem({ +async function convertPanelActionToContextMenuItem({ action, actionContext, closeMenu, }: { - action: Action; - actionContext: A; + action: Action; + actionContext: Context; closeMenu: () => void; }): Promise { const menuPanelItem: EuiContextMenuPanelItemDescriptor = { diff --git a/src/plugins/ui_actions/public/context_menu/open_context_menu.tsx b/src/plugins/ui_actions/public/context_menu/open_context_menu.tsx index 4d794618e85ab..c723388c021e9 100644 --- a/src/plugins/ui_actions/public/context_menu/open_context_menu.tsx +++ b/src/plugins/ui_actions/public/context_menu/open_context_menu.tsx @@ -149,7 +149,11 @@ export function openContextMenu( anchorPosition="downRight" withTitle > - + , container ); diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index 49b6bd5e17699..a9b413fb36542 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -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, diff --git a/src/plugins/ui_actions/public/mocks.ts b/src/plugins/ui_actions/public/mocks.ts index c1be6b2626525..3522ac4941ba0 100644 --- a/src/plugins/ui_actions/public/mocks.ts +++ b/src/plugins/ui_actions/public/mocks.ts @@ -28,10 +28,12 @@ export type Start = jest.Mocked; 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; diff --git a/src/plugins/ui_actions/public/plugin.ts b/src/plugins/ui_actions/public/plugin.ts index 928e57937a9b5..71148656cbb16 100644 --- a/src/plugins/ui_actions/public/plugin.ts +++ b/src/plugins/ui_actions/public/plugin.ts @@ -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; diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts index bdf71a25e6dbc..45a1bdffa52ad 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts @@ -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].' ); }); diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.ts b/src/plugins/ui_actions/public/service/ui_actions_service.ts index f7718e63773f5..9a08aeabb00f3 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.ts @@ -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 = (action: ActionByType) => { - if (this.actions.has(action.id)) { - throw new Error(`Action [action.id = ${action.id}] already registered.`); + public readonly registerAction = ( + definition: A + ): Action> => { + 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 = (id: string): ActionByType => { - 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; + this.actions.delete(actionId); }; - public readonly attachAction = ( - 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 & Action - ): 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 = (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 = ( + 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 + ): void => { + if (!this.actions.has(action.id)) this.registerAction(action); + this.attachAction(triggerId, action.id); + }; + + public readonly getAction = ( + id: string + ): Action> => { + if (!this.actions.has(id)) { + throw new Error(`Action [action.id = ${id}] not registered.`); + } + + return this.actions.get(id) as ActionInternal; + }; + public readonly getTriggerActions = ( triggerId: T ): Array> => { @@ -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 - >; + const actions = actionIds! + .map(actionId => this.actions.get(actionId) as ActionInternal) + .filter(Boolean); return actions as Array>>; }; diff --git a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts index 5b427f918c173..ade21ee4b7d91 100644 --- a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts @@ -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(); diff --git a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts index f5a6a96fb41a4..55ccac42ff255 100644 --- a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts @@ -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); diff --git a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts index c5e68e5d5ca5a..21dd17ed82e3f 100644 --- a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts @@ -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 }); diff --git a/src/plugins/ui_actions/public/tests/test_samples/index.ts b/src/plugins/ui_actions/public/tests/test_samples/index.ts index 7d63b1b6d5669..dfa71cec89595 100644 --- a/src/plugins/ui_actions/public/tests/test_samples/index.ts +++ b/src/plugins/ui_actions/public/tests/test_samples/index.ts @@ -16,4 +16,5 @@ * specific language governing permissions and limitations * under the License. */ + export { createHelloWorldAction } from './hello_world_action'; diff --git a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts index c638db0ce9dab..c7c998907381a 100644 --- a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts @@ -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', }; diff --git a/src/plugins/ui_actions/public/triggers/trigger_internal.ts b/src/plugins/ui_actions/public/triggers/trigger_internal.ts index 1fc92d7c0cb1b..e499c404ae745 100644 --- a/src/plugins/ui_actions/public/triggers/trigger_internal.ts +++ b/src/plugins/ui_actions/public/triggers/trigger_internal.ts @@ -65,8 +65,11 @@ export class TriggerInternal { 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', + }); } } diff --git a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts index ad32bdc1b564e..5fe060f55dc77 100644 --- a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts @@ -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', }; diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index e6247a8bafff7..85c87306cc4f9 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -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>; -export type ActionRegistry = Map>; +export type ActionRegistry = Map; export type TriggerToActionsRegistry = Map; const DEFAULT_TRIGGER = ''; diff --git a/src/plugins/ui_actions/public/util/index.ts b/src/plugins/ui_actions/public/util/index.ts new file mode 100644 index 0000000000000..a6943e54f016c --- /dev/null +++ b/src/plugins/ui_actions/public/util/index.ts @@ -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'; diff --git a/src/plugins/ui_actions/public/util/presentable.ts b/src/plugins/ui_actions/public/util/presentable.ts new file mode 100644 index 0000000000000..f43b776e74658 --- /dev/null +++ b/src/plugins/ui_actions/public/util/presentable.ts @@ -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 { + /** + * 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; + + /** + * 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; +} diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index 1c545bb36cff0..71b31b7f74168 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -265,6 +265,7 @@ export class VisualizeEmbeddable extends Embeddable} @@ -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 { + 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(); diff --git a/test/plugin_functional/plugins/kbn_sample_panel_action/public/plugin.ts b/test/plugin_functional/plugins/kbn_sample_panel_action/public/plugin.ts index 8ea8d2ff49e3b..9ae1021227315 100644 --- a/test/plugin_functional/plugins/kbn_sample_panel_action/public/plugin.ts +++ b/test/plugin_functional/plugins/kbn_sample_panel_action/public/plugin.ts @@ -27,14 +27,10 @@ export class SampelPanelActionTestPlugin implements Plugin { 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 {}; } diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx index e5f5faa6ac361..b47e84216dd16 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx @@ -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); diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 9f43bf8da0601..ccf8739dd9730 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -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", diff --git a/x-pack/examples/ui_actions_enhanced_examples/README.md b/x-pack/examples/ui_actions_enhanced_examples/README.md index c9f53137d8687..ec049bbd33dec 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/README.md +++ b/x-pack/examples/ui_actions_enhanced_examples/README.md @@ -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. diff --git a/x-pack/examples/ui_actions_enhanced_examples/kibana.json b/x-pack/examples/ui_actions_enhanced_examples/kibana.json index f75852edced5c..e220cdd5cd297 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/kibana.json +++ b/x-pack/examples/ui_actions_enhanced_examples/kibana.json @@ -5,6 +5,6 @@ "configPath": ["ui_actions_enhanced_examples"], "server": false, "ui": true, - "requiredPlugins": ["uiActions", "data"], + "requiredPlugins": ["advancedUiActions", "data"], "optionalPlugins": [] } diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/README.md b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/README.md new file mode 100644 index 0000000000000..47a3429b16d7a --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/README.md @@ -0,0 +1 @@ +This folder contains a one-file example of the most basic drilldown implementation. diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx new file mode 100644 index 0000000000000..b1e1040daee6e --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx @@ -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 { + 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> = ({ + config, + onConfig, + }) => ( + + onConfig({ ...config, name: event.target.value })} + /> + + ); + + 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}`); + }; +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/collect_config_container.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/collect_config_container.tsx new file mode 100644 index 0000000000000..69cf260a20a81 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/collect_config_container.tsx @@ -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 = ({ + config, + onConfig, + params: { start }, +}) => { + const isMounted = useMountedState(); + const [indexPatterns, setIndexPatterns] = useState([]); + + 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 ( + { + 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, + }) + } + /> + ); +}; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/discover_drilldown_config.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/discover_drilldown_config.tsx new file mode 100644 index 0000000000000..cf379b29a0039 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/discover_drilldown_config.tsx @@ -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 = ({ + activeIndexPatternId, + indexPatterns, + onIndexPatternSelect, + customIndexPattern, + onCustomIndexPatternToggle, + carryFiltersAndQuery, + onCarryFiltersAndQueryToggle, + carryTimeRange, + onCarryTimeRangeToggle, +}) => { + return ( + <> + +

+ 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. +

+

+ Implementation of the actual Go to Discover drilldown is tracked in{' '} + #60227 +

+ + + {!!onCustomIndexPatternToggle && ( + <> + + + + {!!customIndexPattern && ( + + ({ value: id, text: title })), + ]} + value={activeIndexPatternId || ''} + onChange={e => onIndexPatternSelect(e.target.value)} + /> + + )} + + + )} + + {!!onCarryFiltersAndQueryToggle && ( + + + + )} + {!!onCarryTimeRangeToggle && ( + + + + )} + + ); +}; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/i18n.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/i18n.ts new file mode 100644 index 0000000000000..ccd75e7dcc3e3 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/i18n.ts @@ -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', + } +); diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/index.ts new file mode 100644 index 0000000000000..b975a73e55621 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/index.ts @@ -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'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/index.ts new file mode 100644 index 0000000000000..b975a73e55621 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/index.ts @@ -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'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/constants.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/constants.ts new file mode 100644 index 0000000000000..518642866c2b5 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/constants.ts @@ -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'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/drilldown.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/drilldown.tsx new file mode 100644 index 0000000000000..1213ec2f35995 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/drilldown.tsx @@ -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>; +} + +export class DashboardToDiscoverDrilldown implements Drilldown { + 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 = props => ( + + ); + + 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 => { + 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 => { + 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, + }); + }; +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/i18n.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/i18n.ts new file mode 100644 index 0000000000000..3e92a9f3f1fe4 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/i18n.ts @@ -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)', +}); diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/index.ts new file mode 100644 index 0000000000000..e824c49a6f1fa --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/index.ts @@ -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'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts new file mode 100644 index 0000000000000..5dfc250a56d28 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts @@ -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; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx new file mode 100644 index 0000000000000..cc38386b26385 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx @@ -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; + +const SAMPLE_DASHBOARD_TO_URL_DRILLDOWN = 'SAMPLE_DASHBOARD_TO_URL_DRILLDOWN'; + +export class DashboardToUrlDrilldown implements Drilldown { + 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 = ({ config, onConfig }) => ( + <> + +

+ 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. +

+

+ Implementation of the actual Go to URL drilldown is tracked in{' '} + #55324 +

+
+ + + onConfig({ ...config, url: event.target.value })} + onBlur={() => { + if (!config.url) return; + if (/https?:\/\//.test(config.url)) return; + onConfig({ ...config, url: 'https://' + config.url }); + }} + /> + + + onConfig({ ...config, openInNewTab: !config.openInNewTab })} + /> + + + ); + + 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; + } + }; +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts b/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts index a4c43753c8247..0d4f274caf57f 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts @@ -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 { - public setup(core: CoreSetup, plugins: SetupDependencies) { - // eslint-disable-next-line - console.log('ui_actions_enhanced_examples'); + public setup( + core: CoreSetup, + { 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) {} diff --git a/x-pack/legacy/plugins/canvas/public/application.tsx b/x-pack/legacy/plugins/canvas/public/application.tsx index f746a24e9b261..f71123cd28b90 100644 --- a/x-pack/legacy/plugins/canvas/public/application.tsx +++ b/x-pack/legacy/plugins/canvas/public/application.tsx @@ -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; } diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss index 2ba6f9baca90d..87ec3f8fc7ec1 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss @@ -1,8 +1,3 @@ -.auaActionWizard__selectedActionFactoryContainer { - background-color: $euiColorLightestShade; - padding: $euiSize; -} - .auaActionWizard__actionFactoryItem { .euiKeyPadMenuItem__label { height: #{$euiSizeXL}; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx index 62f16890cade2..9c73f07289dc9 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx @@ -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', () => ( - - )) + .add('default', () => ) .add('Only one factory is available', () => ( // to make sure layout doesn't break - + )) .add('Long list of action factories', () => ( // to make sure layout doesn't break )); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx index aea47be693b8f..f43d832b1edae 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx @@ -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( - - ); + const screen = render(); // 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(); + const screen = render(); // 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'; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx index ef4a0f76de9ed..867ead688d23d 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx @@ -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 { - type: string; // TODO: type should be tied to Action and ActionByType - displayName: string; - iconType?: string; - wizard: React.FC>; - createConfig: () => Config; - isValid: (config: Config) => boolean; -} - -export interface ActionFactoryWizardProps { - 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>; // any here to be able to pass array of ActionFactory 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 = ({ @@ -73,6 +58,7 @@ export const ActionWizard: React.FC = ({ 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 = ({ onDeselect={() => { onActionFactoryChange(null); }} + context={context} config={config} onConfigChange={newConfig => { onConfigChange(newConfig); @@ -97,6 +84,7 @@ export const ActionWizard: React.FC = ({ return ( { onActionFactoryChange(actionFactory); @@ -105,15 +93,16 @@ export const ActionWizard: React.FC = ({ ); }; -interface SelectedActionFactoryProps { - actionFactory: ActionFactory; - 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 = ({ actionFactory, @@ -121,28 +110,28 @@ const SelectedActionFactory: React.FC = ({ showDeselect, onConfigChange, config, + context, }) => { return (
- {actionFactory.iconType && ( + {actionFactory.getIconType(context) && ( - + )} -

{actionFactory.displayName}

+

{actionFactory.getDisplayName(context)}

{showDeselect && ( - onDeselect()}> + onDeselect()}> {txtChangeButton} @@ -151,10 +140,11 @@ const SelectedActionFactory: React.FC = ({
- {actionFactory.wizard({ - config, - onConfig: onConfigChange, - })} +
); @@ -162,14 +152,16 @@ const SelectedActionFactory: React.FC = ({ 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 = ({ 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 = ({ return
No action factories to pick from
; } + // 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 ( - - {actionFactories.map(actionFactory => ( - onActionFactorySelected(actionFactory)} - > - {actionFactory.iconType && } - - ))} + + {[...actionFactories] + .sort((f1, f2) => f2.order - f1.order) + .map(actionFactory => ( + + onActionFactorySelected(actionFactory)} + > + {actionFactory.getIconType(context) && ( + + )} + + + ))} ); }; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts index 641f25176264a..a315184bf68ef 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts @@ -9,6 +9,6 @@ import { i18n } from '@kbn/i18n'; export const txtChangeButton = i18n.translate( 'xpack.advancedUiActions.components.actionWizard.changeButton', { - defaultMessage: 'change', + defaultMessage: 'Change', } ); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts index ed224248ec4cd..a189afbf956ee 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts @@ -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'; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx index 8ecdde681069e..c3e749f163c94 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx @@ -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) { + const config = props.config ?? { + dashboardId: undefined, + useCurrentFilters: true, + useCurrentDateRange: true, + }; + return ( + <> + + ({ value: id, text: title }))} + value={config.dashboardId} + onChange={e => { + props.onConfig({ ...config, dashboardId: e.target.value }); + }} + /> + + + + props.onConfig({ + ...config, + useCurrentFilters: !config.useCurrentFilters, + }) + } + /> + + + + props.onConfig({ + ...config, + useCurrentDateRange: !config.useCurrentDateRange, + }) + } + /> + + + ); +} + +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 ( - <> - - ({ value: id, text: title }))} - value={config.dashboardId} - onChange={e => { - props.onConfig({ ...config, dashboardId: e.target.value }); - }} - /> - - - - props.onConfig({ - ...config, - useCurrentDashboardFilters: !config.useCurrentDashboardFilters, - }) - } - /> - - - - props.onConfig({ - ...config, - useCurrentDashboardDataRange: !config.useCurrentDashboardDataRange, - }) - } - /> - - - ); + CollectConfig: reactToUiComponent(DashboardDrilldownCollectConfig), + + isCompatible(context?: object): Promise { + 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) { + const config = props.config ?? { + url: '', + openInNewTab: false, + }; + return ( + <> + + props.onConfig({ ...config, url: event.target.value })} + /> + + + props.onConfig({ ...config, openInNewTab: !config.openInNewTab })} + /> + + + ); +} +export const urlDrilldownActionFactory: ActionFactoryDefinition = { + 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 ( - <> - - props.onConfig({ ...config, url: event.target.value })} - /> - - - props.onConfig({ ...config, openInNewTab: !config.openInNewTab })} - /> - - - ); + CollectConfig: reactToUiComponent(UrlDrilldownCollectConfig), + + order: 10, + isCompatible(context?: object): Promise { + return Promise.resolve(true); }, + create: () => null as any, }; +export const urlFactory = new ActionFactory(urlDrilldownActionFactory); + export function Demo({ actionFactories }: { actionFactories: Array> }) { const [state, setState] = useState<{ currentActionFactory?: ActionFactory; @@ -157,14 +194,15 @@ export function Demo({ actionFactories }: { actionFactories: Array

-
Action Factory Type: {state.currentActionFactory?.type}
+
Action Factory Id: {state.currentActionFactory?.id}
Action Factory Config: {JSON.stringify(state.config)}
Is config valid:{' '} - {JSON.stringify(state.currentActionFactory?.isValid(state.config!) ?? false)} + {JSON.stringify(state.currentActionFactory?.isConfigValid(state.config!) ?? false)}
); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx b/x-pack/plugins/advanced_ui_actions/public/components/index.ts similarity index 87% rename from x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx rename to x-pack/plugins/advanced_ui_actions/public/components/index.ts index 3be289fe6d46e..236b1a6ec4611 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/index.ts @@ -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'; diff --git a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx index 325a5ddc10179..c0cd8d5540db2 100644 --- a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx @@ -44,7 +44,7 @@ export class CustomTimeRangeAction implements ActionByType { + /** + * 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['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; + * + * const ReactCollectConfig: React.FC = () => { + * return
Collecting config...'
; + * }; + * + * export const CollectConfig = uiToReactComponent(ReactCollectConfig); + * ``` + */ + CollectConfig: ActionFactoryDefinition['CollectConfig']; + + /** + * A validator function for the config object. Should always return a boolean + * given any input. + */ + isConfigValid: ActionFactoryDefinition['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; +} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts b/x-pack/plugins/advanced_ui_actions/public/drilldowns/index.ts similarity index 84% rename from x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts rename to x-pack/plugins/advanced_ui_actions/public/drilldowns/index.ts index ce235043b4ef6..7f81a68c803eb 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts +++ b/x-pack/plugins/advanced_ui_actions/public/drilldowns/index.ts @@ -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'; diff --git a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory.ts b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory.ts new file mode 100644 index 0000000000000..f1aef5deff49e --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory.ts @@ -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, 'getHref'>, Configurable { + constructor( + protected readonly def: ActionFactoryDefinition + ) {} + + 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 { + if (!this.def.isCompatible) return true; + return await this.def.isCompatible(context); + } + + public create( + serializedAction: Omit, 'factoryId'> + ): ActionDefinition { + return this.def.create(serializedAction); + } +} diff --git a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory_definition.ts b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory_definition.ts new file mode 100644 index 0000000000000..d3751fe811665 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory_definition.ts @@ -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, 'getHref'>>, + Configurable { + /** + * 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, 'factoryId'> + ): ActionDefinition; +} diff --git a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager.test.ts b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager.test.ts new file mode 100644 index 0000000000000..b7f1b36f8f358 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager.test.ts @@ -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(); + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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([]); + }); + }); + }); +}); diff --git a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager.ts b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager.ts new file mode 100644 index 0000000000000..df214bfe80cc7 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager.ts @@ -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: (context: C) => Promise; +} + +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), + 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 = 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, + triggers: Array + ) { + 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, + triggers: Array + ) { + 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))); + } +} diff --git a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager_state.ts b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager_state.ts new file mode 100644 index 0000000000000..61e8604baa913 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager_state.ts @@ -0,0 +1,98 @@ +/* + * 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 { SerializedEvent } from './types'; + +/** + * This interface represents the state of @type {DynamicActionManager} at any + * point in time. + */ +export interface State { + /** + * Whether dynamic action manager is currently in process of fetching events + * from storage. + */ + readonly isFetchingEvents: boolean; + + /** + * Number of times event fetching has been completed. + */ + readonly fetchCount: number; + + /** + * Error received last time when fetching events. + */ + readonly fetchError?: { + message: string; + }; + + /** + * List of all fetched events. + */ + readonly events: readonly SerializedEvent[]; +} + +export interface Transitions { + startFetching: (state: State) => () => State; + finishFetching: (state: State) => (events: SerializedEvent[]) => State; + failFetching: (state: State) => (error: { message: string }) => State; + addEvent: (state: State) => (event: SerializedEvent) => State; + removeEvent: (state: State) => (eventId: string) => State; + replaceEvent: (state: State) => (event: SerializedEvent) => State; +} + +export interface Selectors { + getEvent: (state: State) => (eventId: string) => SerializedEvent | null; +} + +export const defaultState: State = { + isFetchingEvents: false, + fetchCount: 0, + events: [], +}; + +export const transitions: Transitions = { + startFetching: state => () => ({ ...state, isFetchingEvents: true }), + + finishFetching: state => events => ({ + ...state, + isFetchingEvents: false, + fetchCount: state.fetchCount + 1, + fetchError: undefined, + events, + }), + + failFetching: state => ({ message }) => ({ + ...state, + isFetchingEvents: false, + fetchCount: state.fetchCount + 1, + fetchError: { message }, + }), + + addEvent: state => (event: SerializedEvent) => ({ + ...state, + events: [...state.events, event], + }), + + removeEvent: state => (eventId: string) => ({ + ...state, + events: state.events ? state.events.filter(event => event.eventId !== eventId) : state.events, + }), + + replaceEvent: state => event => { + const index = state.events.findIndex(({ eventId }) => eventId === event.eventId); + if (index === -1) return state; + + return { + ...state, + events: [...state.events.slice(0, index), event, ...state.events.slice(index + 1)], + }; + }, +}; + +export const selectors: Selectors = { + getEvent: state => eventId => state.events.find(event => event.eventId === eventId) || null, +}; diff --git a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_storage.ts b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_storage.ts new file mode 100644 index 0000000000000..e40441e67f033 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_storage.ts @@ -0,0 +1,80 @@ +/* + * 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. + */ + +/* eslint-disable max-classes-per-file */ + +import { Observable, Subject } from 'rxjs'; +import { SerializedEvent } from './types'; + +/** + * This CRUD interface needs to be implemented by dynamic action users if they + * want to persist the dynamic actions. It has a default implementation in + * Embeddables, however one can use the dynamic actions without Embeddables, + * in that case they have to implement this interface. + */ +export interface ActionStorage { + create(event: SerializedEvent): Promise; + update(event: SerializedEvent): Promise; + remove(eventId: string): Promise; + read(eventId: string): Promise; + count(): Promise; + list(): Promise; + + /** + * Triggered every time events changed in storage and should be re-loaded. + */ + readonly reload$?: Observable; +} + +export abstract class AbstractActionStorage implements ActionStorage { + public readonly reload$: Observable & Pick, 'next'> = new Subject(); + + public async count(): Promise { + return (await this.list()).length; + } + + public async read(eventId: string): Promise { + const events = await this.list(); + const event = events.find(ev => ev.eventId === eventId); + if (!event) throw new Error(`Event [eventId = ${eventId}] not found.`); + return event; + } + + abstract create(event: SerializedEvent): Promise; + abstract update(event: SerializedEvent): Promise; + abstract remove(eventId: string): Promise; + abstract list(): Promise; +} + +/** + * This is an in-memory implementation of ActionStorage. It is used in testing, + * but can also be used production code to store events in memory. + */ +export class MemoryActionStorage extends AbstractActionStorage { + constructor(public events: readonly SerializedEvent[] = []) { + super(); + } + + public async list() { + return this.events.map(event => ({ ...event })); + } + + public async create(event: SerializedEvent) { + this.events = [...this.events, { ...event }]; + } + + public async update(event: SerializedEvent) { + const index = this.events.findIndex(({ eventId }) => eventId === event.eventId); + if (index < 0) throw new Error(`Event [eventId = ${event.eventId}] not found`); + this.events = [...this.events.slice(0, index), { ...event }, ...this.events.slice(index + 1)]; + } + + public async remove(eventId: string) { + const index = this.events.findIndex(ev => eventId === ev.eventId); + if (index < 0) throw new Error(`Event [eventId = ${eventId}] not found`); + this.events = [...this.events.slice(0, index), ...this.events.slice(index + 1)]; + } +} diff --git a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/index.ts b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/index.ts new file mode 100644 index 0000000000000..bb37cf5e69535 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/index.ts @@ -0,0 +1,12 @@ +/* + * 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 './types'; +export * from './action_factory'; +export * from './action_factory_definition'; +export * from './dynamic_action_storage'; +export * from './dynamic_action_manager_state'; +export * from './dynamic_action_manager'; diff --git a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/types.ts b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/types.ts new file mode 100644 index 0000000000000..9148d1ec7055a --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/types.ts @@ -0,0 +1,20 @@ +/* + * 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 interface SerializedAction { + readonly factoryId: string; + readonly name: string; + readonly config: Config; +} + +/** + * Serialized representation of a triggers-action pair, used to persist in storage. + */ +export interface SerializedEvent { + eventId: string; + triggers: string[]; + action: SerializedAction; +} diff --git a/x-pack/plugins/advanced_ui_actions/public/index.ts b/x-pack/plugins/advanced_ui_actions/public/index.ts index c11c1119a9b13..024cfe5530b97 100644 --- a/x-pack/plugins/advanced_ui_actions/public/index.ts +++ b/x-pack/plugins/advanced_ui_actions/public/index.ts @@ -12,3 +12,22 @@ export function plugin(initializerContext: PluginInitializerContext) { } export { AdvancedUiActionsPublicPlugin as Plugin }; +export { + SetupContract as AdvancedUiActionsSetup, + StartContract as AdvancedUiActionsStart, +} from './plugin'; + +export { ActionWizard } from './components'; +export { + ActionFactoryDefinition as AdvancedUiActionsActionFactoryDefinition, + ActionFactory as AdvancedUiActionsActionFactory, + SerializedAction as UiActionsEnhancedSerializedAction, + SerializedEvent as UiActionsEnhancedSerializedEvent, + AbstractActionStorage as UiActionsEnhancedAbstractActionStorage, + DynamicActionManager as UiActionsEnhancedDynamicActionManager, + DynamicActionManagerParams as UiActionsEnhancedDynamicActionManagerParams, + DynamicActionManagerState as UiActionsEnhancedDynamicActionManagerState, + MemoryActionStorage as UiActionsEnhancedMemoryActionStorage, +} from './dynamic_actions'; + +export { DrilldownDefinition as UiActionsEnhancedDrilldownDefinition } from './drilldowns'; diff --git a/x-pack/plugins/advanced_ui_actions/public/mocks.ts b/x-pack/plugins/advanced_ui_actions/public/mocks.ts new file mode 100644 index 0000000000000..65fde12755beb --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/mocks.ts @@ -0,0 +1,73 @@ +/* + * 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 { CoreSetup, CoreStart } from '../../../../src/core/public'; +import { coreMock } from '../../../../src/core/public/mocks'; +import { uiActionsPluginMock } from '../../../../src/plugins/ui_actions/public/mocks'; +import { embeddablePluginMock } from '../../../../src/plugins/embeddable/public/mocks'; +import { AdvancedUiActionsSetup, AdvancedUiActionsStart } from '.'; +import { plugin as pluginInitializer } from '.'; + +export type Setup = jest.Mocked; +export type Start = jest.Mocked; + +const createSetupContract = (): Setup => { + const setupContract: Setup = { + ...uiActionsPluginMock.createSetupContract(), + registerDrilldown: jest.fn(), + }; + return setupContract; +}; + +const createStartContract = (): Start => { + const startContract: Start = { + ...uiActionsPluginMock.createStartContract(), + getActionFactories: jest.fn(), + getActionFactory: jest.fn(), + }; + + return startContract; +}; + +const createPlugin = ( + coreSetup: CoreSetup = coreMock.createSetup(), + coreStart: CoreStart = coreMock.createStart() +) => { + const pluginInitializerContext = coreMock.createPluginInitializerContext(); + const uiActions = uiActionsPluginMock.createPlugin(); + const embeddable = embeddablePluginMock.createInstance({ + uiActions: uiActions.setup, + }); + const plugin = pluginInitializer(pluginInitializerContext); + const setup = plugin.setup(coreSetup, { + uiActions: uiActions.setup, + embeddable: embeddable.setup, + }); + + return { + pluginInitializerContext, + coreSetup, + coreStart, + plugin, + setup, + doStart: (anotherCoreStart: CoreStart = coreStart) => { + const uiActionsStart = uiActions.doStart(); + const embeddableStart = embeddable.doStart({ + uiActions: uiActionsStart, + }); + return plugin.start(anotherCoreStart, { + uiActions: uiActionsStart, + embeddable: embeddableStart, + }); + }, + }; +}; + +export const uiActionsEnhancedPluginMock = { + createSetupContract, + createStartContract, + createPlugin, +}; diff --git a/x-pack/plugins/advanced_ui_actions/public/plugin.ts b/x-pack/plugins/advanced_ui_actions/public/plugin.ts index b9f0ce43d3cdc..f042130158aec 100644 --- a/x-pack/plugins/advanced_ui_actions/public/plugin.ts +++ b/x-pack/plugins/advanced_ui_actions/public/plugin.ts @@ -11,7 +11,7 @@ import { Plugin, } from '../../../../src/core/public'; import { createReactOverlays } from '../../../../src/plugins/kibana_react/public'; -import { UiActionsStart, UiActionsSetup } from '../../../../src/plugins/ui_actions/public'; +import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; import { CONTEXT_MENU_TRIGGER, PANEL_BADGE_TRIGGER, @@ -30,6 +30,7 @@ import { TimeBadgeActionContext, } from './custom_time_range_badge'; import { CommonlyUsedRange } from './types'; +import { UiActionsServiceEnhancements } from './services'; interface SetupDependencies { embeddable: EmbeddableSetup; // Embeddable are needed because they register basic triggers/actions. @@ -41,8 +42,13 @@ interface StartDependencies { uiActions: UiActionsStart; } -export type Setup = void; -export type Start = void; +export interface SetupContract + extends UiActionsSetup, + Pick {} + +export interface StartContract + extends UiActionsStart, + Pick {} declare module '../../../../src/plugins/ui_actions/public' { export interface ActionContextMapping { @@ -52,12 +58,19 @@ declare module '../../../../src/plugins/ui_actions/public' { } export class AdvancedUiActionsPublicPlugin - implements Plugin { + implements Plugin { + private readonly enhancements = new UiActionsServiceEnhancements(); + constructor(initializerContext: PluginInitializerContext) {} - public setup(core: CoreSetup, { uiActions }: SetupDependencies): Setup {} + public setup(core: CoreSetup, { uiActions }: SetupDependencies): SetupContract { + return { + ...uiActions, + ...this.enhancements, + }; + } - public start(core: CoreStart, { uiActions }: StartDependencies): Start { + public start(core: CoreStart, { uiActions }: StartDependencies): StartContract { const dateFormat = core.uiSettings.get('dateFormat') as string; const commonlyUsedRanges = core.uiSettings.get('timepicker:quickRanges') as CommonlyUsedRange[]; const { openModal } = createReactOverlays(core); @@ -66,16 +79,19 @@ export class AdvancedUiActionsPublicPlugin dateFormat, commonlyUsedRanges, }); - uiActions.registerAction(timeRangeAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, timeRangeAction); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, timeRangeAction); const timeRangeBadge = new CustomTimeRangeBadge({ openModal, dateFormat, commonlyUsedRanges, }); - uiActions.registerAction(timeRangeBadge); - uiActions.attachAction(PANEL_BADGE_TRIGGER, timeRangeBadge); + uiActions.addTriggerAction(PANEL_BADGE_TRIGGER, timeRangeBadge); + + return { + ...uiActions, + ...this.enhancements, + }; } public stop() {} diff --git a/x-pack/plugins/advanced_ui_actions/public/services/index.ts b/x-pack/plugins/advanced_ui_actions/public/services/index.ts new file mode 100644 index 0000000000000..71a3429800c43 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/services/index.ts @@ -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 './ui_actions_service_enhancements'; diff --git a/x-pack/plugins/advanced_ui_actions/public/services/ui_actions_service_enhancements.test.ts b/x-pack/plugins/advanced_ui_actions/public/services/ui_actions_service_enhancements.test.ts new file mode 100644 index 0000000000000..3137e35a2fe47 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/services/ui_actions_service_enhancements.test.ts @@ -0,0 +1,70 @@ +/* + * 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 { UiActionsServiceEnhancements } from './ui_actions_service_enhancements'; +import { ActionFactoryDefinition, ActionFactory } from '../dynamic_actions'; + +describe('UiActionsService', () => { + describe('action factories', () => { + const factoryDefinition1: ActionFactoryDefinition = { + id: 'test-factory-1', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: () => true, + create: () => ({} as any), + }; + const factoryDefinition2: ActionFactoryDefinition = { + id: 'test-factory-2', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: () => true, + create: () => ({} as any), + }; + + test('.getActionFactories() returns empty array if no action factories registered', () => { + const service = new UiActionsServiceEnhancements(); + + const factories = service.getActionFactories(); + + expect(factories).toEqual([]); + }); + + test('can register and retrieve an action factory', () => { + const service = new UiActionsServiceEnhancements(); + + service.registerActionFactory(factoryDefinition1); + + const factory = service.getActionFactory(factoryDefinition1.id); + + expect(factory).toBeInstanceOf(ActionFactory); + expect(factory.id).toBe(factoryDefinition1.id); + }); + + test('can retrieve all action factories', () => { + const service = new UiActionsServiceEnhancements(); + + service.registerActionFactory(factoryDefinition1); + service.registerActionFactory(factoryDefinition2); + + const factories = service.getActionFactories(); + const factoriesSorted = [...factories].sort((f1, f2) => (f1.id > f2.id ? 1 : -1)); + + expect(factoriesSorted.length).toBe(2); + expect(factoriesSorted[0].id).toBe(factoryDefinition1.id); + expect(factoriesSorted[1].id).toBe(factoryDefinition2.id); + }); + + test('throws when retrieving action factory that does not exist', () => { + const service = new UiActionsServiceEnhancements(); + + service.registerActionFactory(factoryDefinition1); + + expect(() => service.getActionFactory('UNKNOWN_ID')).toThrowError( + 'Action factory [actionFactoryId = UNKNOWN_ID] does not exist.' + ); + }); + }); +}); diff --git a/x-pack/plugins/advanced_ui_actions/public/services/ui_actions_service_enhancements.ts b/x-pack/plugins/advanced_ui_actions/public/services/ui_actions_service_enhancements.ts new file mode 100644 index 0000000000000..8befbf43d3c6a --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/services/ui_actions_service_enhancements.ts @@ -0,0 +1,97 @@ +/* + * 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 { ActionFactoryRegistry } from '../types'; +import { ActionFactory, ActionFactoryDefinition } from '../dynamic_actions'; +import { DrilldownDefinition } from '../drilldowns'; + +export interface UiActionsServiceEnhancementsParams { + readonly actionFactories?: ActionFactoryRegistry; +} + +export class UiActionsServiceEnhancements { + protected readonly actionFactories: ActionFactoryRegistry; + + constructor({ actionFactories = new Map() }: UiActionsServiceEnhancementsParams = {}) { + this.actionFactories = actionFactories; + } + + /** + * Register an action factory. Action factories are used to configure and + * serialize/deserialize dynamic actions. + */ + public readonly registerActionFactory = < + Config extends object = object, + FactoryContext extends object = object, + ActionContext extends object = object + >( + definition: ActionFactoryDefinition + ) => { + if (this.actionFactories.has(definition.id)) { + throw new Error(`ActionFactory [actionFactory.id = ${definition.id}] already registered.`); + } + + const actionFactory = new ActionFactory(definition); + + this.actionFactories.set(actionFactory.id, actionFactory as ActionFactory); + }; + + public readonly getActionFactory = (actionFactoryId: string): ActionFactory => { + const actionFactory = this.actionFactories.get(actionFactoryId); + + if (!actionFactory) { + throw new Error(`Action factory [actionFactoryId = ${actionFactoryId}] does not exist.`); + } + + return actionFactory; + }; + + /** + * Returns an array of all action factories. + */ + public readonly getActionFactories = (): ActionFactory[] => { + return [...this.actionFactories.values()]; + }; + + /** + * Convenience method to register a {@link DrilldownDefinition | drilldown}. + */ + public readonly registerDrilldown = < + Config extends object = object, + ExecutionContext extends object = object + >({ + id: factoryId, + order, + CollectConfig, + createConfig, + isConfigValid, + getDisplayName, + euiIcon, + execute, + getHref, + }: DrilldownDefinition): void => { + const actionFactory: ActionFactoryDefinition = { + id: factoryId, + order, + CollectConfig, + createConfig, + isConfigValid, + getDisplayName, + getIconType: () => euiIcon, + isCompatible: async () => true, + create: serializedAction => ({ + id: '', + type: factoryId, + getIconType: () => euiIcon, + getDisplayName: () => serializedAction.name, + execute: async context => await execute(serializedAction.config, context), + getHref: getHref ? async context => getHref(serializedAction.config, context) : undefined, + }), + } as ActionFactoryDefinition; + + this.registerActionFactory(actionFactory); + }; +} diff --git a/x-pack/plugins/advanced_ui_actions/public/types.ts b/x-pack/plugins/advanced_ui_actions/public/types.ts index 313b09535b196..5c960192dcaff 100644 --- a/x-pack/plugins/advanced_ui_actions/public/types.ts +++ b/x-pack/plugins/advanced_ui_actions/public/types.ts @@ -5,6 +5,7 @@ */ import { KibanaReactOverlays } from '../../../../src/plugins/kibana_react/public'; +import { ActionFactory } from './dynamic_actions'; export interface CommonlyUsedRange { from: string; @@ -13,3 +14,5 @@ export interface CommonlyUsedRange { } export type OpenModal = KibanaReactOverlays['openModal']; + +export type ActionFactoryRegistry = Map; diff --git a/x-pack/plugins/dashboard_enhanced/README.md b/x-pack/plugins/dashboard_enhanced/README.md new file mode 100644 index 0000000000000..d9296ae158621 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/README.md @@ -0,0 +1 @@ +# X-Pack part of Dashboard app diff --git a/x-pack/plugins/dashboard_enhanced/kibana.json b/x-pack/plugins/dashboard_enhanced/kibana.json new file mode 100644 index 0000000000000..f416ca97f7110 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "dashboardEnhanced", + "version": "kibana", + "server": false, + "ui": true, + "requiredPlugins": ["data", "advancedUiActions", "drilldowns", "embeddable", "dashboard", "share"], + "configPath": ["xpack", "dashboardEnhanced"] +} diff --git a/x-pack/plugins/dashboard_enhanced/public/index.ts b/x-pack/plugins/dashboard_enhanced/public/index.ts new file mode 100644 index 0000000000000..53540a4a1ad2e --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/index.ts @@ -0,0 +1,19 @@ +/* + * 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 { PluginInitializerContext } from 'src/core/public'; +import { DashboardEnhancedPlugin } from './plugin'; + +export { + SetupContract as DashboardEnhancedSetupContract, + SetupDependencies as DashboardEnhancedSetupDependencies, + StartContract as DashboardEnhancedStartContract, + StartDependencies as DashboardEnhancedStartDependencies, +} from './plugin'; + +export function plugin(context: PluginInitializerContext) { + return new DashboardEnhancedPlugin(context); +} diff --git a/x-pack/plugins/dashboard_enhanced/public/mocks.ts b/x-pack/plugins/dashboard_enhanced/public/mocks.ts new file mode 100644 index 0000000000000..67dc1fd97d521 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/mocks.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DashboardEnhancedSetupContract, DashboardEnhancedStartContract } from '.'; + +export type Setup = jest.Mocked; +export type Start = jest.Mocked; + +const createSetupContract = (): Setup => { + const setupContract: Setup = {}; + + return setupContract; +}; + +const createStartContract = (): Start => { + const startContract: Start = {}; + + return startContract; +}; + +export const dashboardEnhancedPluginMock = { + createSetupContract, + createStartContract, +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/plugin.ts b/x-pack/plugins/dashboard_enhanced/public/plugin.ts new file mode 100644 index 0000000000000..772e032289bce --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/plugin.ts @@ -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 { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; +import { SharePluginStart, SharePluginSetup } from '../../../../src/plugins/share/public'; +import { EmbeddableSetup, EmbeddableStart } from '../../../../src/plugins/embeddable/public'; +import { DashboardDrilldownsService } from './services'; +import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { AdvancedUiActionsSetup, AdvancedUiActionsStart } from '../../advanced_ui_actions/public'; +import { DrilldownsSetup, DrilldownsStart } from '../../drilldowns/public'; + +export interface SetupDependencies { + advancedUiActions: AdvancedUiActionsSetup; + drilldowns: DrilldownsSetup; + embeddable: EmbeddableSetup; + share: SharePluginSetup; +} + +export interface StartDependencies { + advancedUiActions: AdvancedUiActionsStart; + data: DataPublicPluginStart; + drilldowns: DrilldownsStart; + embeddable: EmbeddableStart; + share: SharePluginStart; +} + +// eslint-disable-next-line +export interface SetupContract {} + +// eslint-disable-next-line +export interface StartContract {} + +export class DashboardEnhancedPlugin + implements Plugin { + public readonly drilldowns = new DashboardDrilldownsService(); + + constructor(protected readonly context: PluginInitializerContext) {} + + public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { + this.drilldowns.bootstrap(core, plugins, { + enableDrilldowns: true, + }); + + return {}; + } + + public start(core: CoreStart, plugins: StartDependencies): StartContract { + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx new file mode 100644 index 0000000000000..5ec1b881317d6 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx @@ -0,0 +1,144 @@ +/* + * 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 { + FlyoutCreateDrilldownAction, + OpenFlyoutAddDrilldownParams, +} from './flyout_create_drilldown'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks'; +import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; +import { TriggerContextMapping } from '../../../../../../../../src/plugins/ui_actions/public'; +import { MockEmbeddable, enhanceEmbeddable } from '../test_helpers'; + +const overlays = coreMock.createStart().overlays; +const drilldowns = drilldownsPluginMock.createStartContract(); + +const actionParams: OpenFlyoutAddDrilldownParams = { + start: () => ({ + core: { + overlays, + } as any, + plugins: { + drilldowns, + }, + self: {}, + }), +}; + +test('should create', () => { + expect(() => new FlyoutCreateDrilldownAction(actionParams)).not.toThrow(); +}); + +test('title is a string', () => { + expect(typeof new FlyoutCreateDrilldownAction(actionParams).getDisplayName() === 'string').toBe( + true + ); +}); + +test('icon exists', () => { + expect(typeof new FlyoutCreateDrilldownAction(actionParams).getIconType() === 'string').toBe( + true + ); +}); + +interface CompatibilityParams { + isEdit?: boolean; + isValueClickTriggerSupported?: boolean; + isEmbeddableEnhanced?: boolean; + rootType?: string; +} + +describe('isCompatible', () => { + const drilldownAction = new FlyoutCreateDrilldownAction(actionParams); + + async function assertCompatibility( + { + isEdit = true, + isValueClickTriggerSupported = true, + isEmbeddableEnhanced = true, + rootType = 'dashboard', + }: CompatibilityParams, + expectedResult: boolean = true + ): Promise { + let embeddable = new MockEmbeddable( + { id: '', viewMode: isEdit ? ViewMode.EDIT : ViewMode.VIEW }, + { + supportedTriggers: (isValueClickTriggerSupported ? ['VALUE_CLICK_TRIGGER'] : []) as Array< + keyof TriggerContextMapping + >, + } + ); + + embeddable.rootType = rootType; + + if (isEmbeddableEnhanced) { + embeddable = enhanceEmbeddable(embeddable); + } + + const result = await drilldownAction.isCompatible({ + embeddable, + }); + + expect(result).toBe(expectedResult); + } + + const assertNonCompatibility = (params: CompatibilityParams) => + assertCompatibility(params, false); + + test("compatible if dynamicUiActions enabled, 'VALUE_CLICK_TRIGGER' is supported, in edit mode", async () => { + await assertCompatibility({}); + }); + + test('not compatible if embeddable is not enhanced', async () => { + await assertNonCompatibility({ + isEmbeddableEnhanced: false, + }); + }); + + test("not compatible if 'VALUE_CLICK_TRIGGER' is not supported", async () => { + await assertNonCompatibility({ + isValueClickTriggerSupported: false, + }); + }); + + test('not compatible if in view mode', async () => { + await assertNonCompatibility({ + isEdit: false, + }); + }); + + test('not compatible if root embeddable is not "dashboard"', async () => { + await assertNonCompatibility({ + rootType: 'visualization', + }); + }); +}); + +describe('execute', () => { + const drilldownAction = new FlyoutCreateDrilldownAction(actionParams); + + test('throws error if no dynamicUiActions', async () => { + await expect( + drilldownAction.execute({ + embeddable: new MockEmbeddable({ id: '' }, {}), + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Need embeddable to be EnhancedEmbeddable to execute FlyoutCreateDrilldownAction."` + ); + }); + + test('should open flyout', async () => { + const spy = jest.spyOn(overlays, 'openFlyout'); + const embeddable = enhanceEmbeddable(new MockEmbeddable({ id: '' }, {})); + + await drilldownAction.execute({ + embeddable, + }); + + expect(spy).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx new file mode 100644 index 0000000000000..81f88e563a258 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx @@ -0,0 +1,86 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public'; +import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; +import { isEnhancedEmbeddable } from '../../../../../../embeddable_enhanced/public'; +import { EmbeddableContext } from '../../../../../../../../src/plugins/embeddable/public'; +import { StartDependencies } from '../../../../plugin'; +import { StartServicesGetter } from '../../../../../../../../src/plugins/kibana_utils/public'; + +export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; + +export interface OpenFlyoutAddDrilldownParams { + start: StartServicesGetter>; +} + +export class FlyoutCreateDrilldownAction implements ActionByType { + public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN; + public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN; + public order = 12; + + constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {} + + public getDisplayName() { + return i18n.translate('xpack.dashboard.FlyoutCreateDrilldownAction.displayName', { + defaultMessage: 'Create drilldown', + }); + } + + public getIconType() { + return 'plusInCircle'; + } + + private isEmbeddableCompatible(context: EmbeddableContext) { + if (!isEnhancedEmbeddable(context.embeddable)) return false; + const supportedTriggers = context.embeddable.supportedTriggers(); + if (!supportedTriggers || !supportedTriggers.length) return false; + if (context.embeddable.getRoot().type !== 'dashboard') return false; + + /** + * Temporarily disable drilldowns for Lens as Lens embeddable does not have + * `.embeddable` field on VALUE_CLICK_TRIGGER context. + * + * @todo Remove this condition once Lens adds `.embeddable` to field to context. + */ + if (context.embeddable.type === 'lens') return false; + + return supportedTriggers.indexOf('VALUE_CLICK_TRIGGER') > -1; + } + + public async isCompatible(context: EmbeddableContext) { + const isEditMode = context.embeddable.getInput().viewMode === 'edit'; + return isEditMode && this.isEmbeddableCompatible(context); + } + + public async execute(context: EmbeddableContext) { + const { core, plugins } = this.params.start(); + const { embeddable } = context; + + if (!isEnhancedEmbeddable(embeddable)) { + throw new Error( + 'Need embeddable to be EnhancedEmbeddable to execute FlyoutCreateDrilldownAction.' + ); + } + + const handle = core.overlays.openFlyout( + toMountPoint( + handle.close()} + placeContext={context} + viewMode={'create'} + dynamicActionManager={embeddable.enhancements.dynamicActions} + /> + ), + { + ownFocus: true, + 'data-test-subj': 'createDrilldownFlyout', + } + ); + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts new file mode 100644 index 0000000000000..4d2db209fc961 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts @@ -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. + */ + +export { + FlyoutCreateDrilldownAction, + OpenFlyoutAddDrilldownParams, + OPEN_FLYOUT_ADD_DRILLDOWN, +} from './flyout_create_drilldown'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx new file mode 100644 index 0000000000000..555acf1fca5ff --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx @@ -0,0 +1,148 @@ +/* + * 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 { FlyoutEditDrilldownAction, FlyoutEditDrilldownParams } from './flyout_edit_drilldown'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks'; +import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; +import { uiActionsEnhancedPluginMock } from '../../../../../../advanced_ui_actions/public/mocks'; +import { EnhancedEmbeddable } from '../../../../../../embeddable_enhanced/public'; +import { MockEmbeddable, enhanceEmbeddable } from '../test_helpers'; + +const overlays = coreMock.createStart().overlays; +const drilldowns = drilldownsPluginMock.createStartContract(); +const uiActionsPlugin = uiActionsEnhancedPluginMock.createPlugin(); +const uiActions = uiActionsPlugin.doStart(); + +uiActionsPlugin.setup.registerDrilldown({ + id: 'foo', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: () => true, + execute: async () => {}, + getDisplayName: () => 'test', +}); + +const actionParams: FlyoutEditDrilldownParams = { + start: () => ({ + core: { + overlays, + } as any, + plugins: { + drilldowns, + }, + self: {}, + }), +}; + +test('should create', () => { + expect(() => new FlyoutEditDrilldownAction(actionParams)).not.toThrow(); +}); + +test('title is a string', () => { + expect(typeof new FlyoutEditDrilldownAction(actionParams).getDisplayName() === 'string').toBe( + true + ); +}); + +test('icon exists', () => { + expect(typeof new FlyoutEditDrilldownAction(actionParams).getIconType() === 'string').toBe(true); +}); + +test('MenuItem exists', () => { + expect(new FlyoutEditDrilldownAction(actionParams).MenuItem).toBeDefined(); +}); + +describe('isCompatible', () => { + function setupIsCompatible({ + isEdit = true, + isEmbeddableEnhanced = true, + }: { + isEdit?: boolean; + isEmbeddableEnhanced?: boolean; + } = {}) { + const action = new FlyoutEditDrilldownAction(actionParams); + const input = { + id: '', + viewMode: isEdit ? ViewMode.EDIT : ViewMode.VIEW, + }; + const embeddable = new MockEmbeddable(input, {}); + const context = { + embeddable: (isEmbeddableEnhanced + ? enhanceEmbeddable(embeddable, uiActions) + : embeddable) as EnhancedEmbeddable, + }; + + return { + action, + context, + }; + } + + test('not compatible if no drilldowns', async () => { + const { action, context } = setupIsCompatible(); + expect(await action.isCompatible(context)).toBe(false); + }); + + test('not compatible if embeddable is not enhanced', async () => { + const { action, context } = setupIsCompatible({ isEmbeddableEnhanced: false }); + expect(await action.isCompatible(context)).toBe(false); + }); + + describe('when has at least one drilldown', () => { + test('is compatible in edit mode', async () => { + const { action, context } = setupIsCompatible(); + + await context.embeddable.enhancements.dynamicActions.createEvent( + { + config: {}, + factoryId: 'foo', + name: '', + }, + ['VALUE_CLICK_TRIGGER'] + ); + + expect(await action.isCompatible(context)).toBe(true); + }); + + test('not compatible in view mode', async () => { + const { action, context } = setupIsCompatible({ isEdit: false }); + + await context.embeddable.enhancements.dynamicActions.createEvent( + { + config: {}, + factoryId: 'foo', + name: '', + }, + ['VALUE_CLICK_TRIGGER'] + ); + + expect(await action.isCompatible(context)).toBe(false); + }); + }); +}); + +describe('execute', () => { + const drilldownAction = new FlyoutEditDrilldownAction(actionParams); + + test('throws error if no dynamicUiActions', async () => { + await expect( + drilldownAction.execute({ + embeddable: new MockEmbeddable({ id: '' }, {}), + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Need embeddable to be EnhancedEmbeddable to execute FlyoutEditDrilldownAction."` + ); + }); + + test('should open flyout', async () => { + const spy = jest.spyOn(overlays, 'openFlyout'); + await drilldownAction.execute({ + embeddable: enhanceEmbeddable(new MockEmbeddable({ id: '' }, {})), + }); + expect(spy).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx new file mode 100644 index 0000000000000..a4499ba4d757d --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx @@ -0,0 +1,74 @@ +/* + * 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 { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public'; +import { + reactToUiComponent, + toMountPoint, +} from '../../../../../../../../src/plugins/kibana_react/public'; +import { EmbeddableContext, ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; +import { txtDisplayName } from './i18n'; +import { MenuItem } from './menu_item'; +import { isEnhancedEmbeddable } from '../../../../../../embeddable_enhanced/public'; +import { StartDependencies } from '../../../../plugin'; +import { StartServicesGetter } from '../../../../../../../../src/plugins/kibana_utils/public'; + +export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; + +export interface FlyoutEditDrilldownParams { + start: StartServicesGetter>; +} + +export class FlyoutEditDrilldownAction implements ActionByType { + public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN; + public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN; + public order = 10; + + constructor(protected readonly params: FlyoutEditDrilldownParams) {} + + public getDisplayName() { + return txtDisplayName; + } + + public getIconType() { + return 'list'; + } + + MenuItem = reactToUiComponent(MenuItem); + + public async isCompatible({ embeddable }: EmbeddableContext) { + if (embeddable.getInput().viewMode !== ViewMode.EDIT) return false; + if (!isEnhancedEmbeddable(embeddable)) return false; + return embeddable.enhancements.dynamicActions.state.get().events.length > 0; + } + + public async execute(context: EmbeddableContext) { + const { core, plugins } = this.params.start(); + const { embeddable } = context; + + if (!isEnhancedEmbeddable(embeddable)) { + throw new Error( + 'Need embeddable to be EnhancedEmbeddable to execute FlyoutEditDrilldownAction.' + ); + } + + const handle = core.overlays.openFlyout( + toMountPoint( + handle.close()} + placeContext={context} + viewMode={'manage'} + dynamicActionManager={embeddable.enhancements.dynamicActions} + /> + ), + { + ownFocus: true, + 'data-test-subj': 'editDrilldownFlyout', + } + ); + } +} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts similarity index 64% rename from x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts index ceabc6d3a9aa5..4e2e5eb7092e4 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts @@ -6,9 +6,9 @@ import { i18n } from '@kbn/i18n'; -export const txtCreateDrilldown = i18n.translate( - 'xpack.drilldowns.components.FlyoutCreateDrilldown.CreateDrilldown', +export const txtDisplayName = i18n.translate( + 'xpack.dashboard.panel.openFlyoutEditDrilldown.displayName', { - defaultMessage: 'Create drilldown', + defaultMessage: 'Manage drilldowns', } ); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx new file mode 100644 index 0000000000000..3e1b37f270708 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx @@ -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. + */ + +export { + FlyoutEditDrilldownAction, + FlyoutEditDrilldownParams, + OPEN_FLYOUT_EDIT_DRILLDOWN, +} from './flyout_edit_drilldown'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx new file mode 100644 index 0000000000000..ec3a78e97eae4 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx @@ -0,0 +1,39 @@ +/* + * 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 { render, cleanup, act } from '@testing-library/react/pure'; +import { MenuItem } from './menu_item'; +import { createStateContainer } from '../../../../../../../../src/plugins/kibana_utils/public'; +import { UiActionsEnhancedDynamicActionManager as DynamicActionManager } from '../../../../../../advanced_ui_actions/public'; +import { EnhancedEmbeddable } from '../../../../../../embeddable_enhanced/public'; +import '@testing-library/jest-dom'; + +afterEach(cleanup); + +test('', () => { + const state = createStateContainer<{ events: object[] }>({ events: [] }); + const { getByText, queryByText } = render( + + ); + + expect(getByText(/manage drilldowns/i)).toBeInTheDocument(); + expect(queryByText('0')).not.toBeInTheDocument(); + + act(() => { + state.set({ events: [{}] }); + }); + + expect(queryByText('1')).toBeInTheDocument(); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx new file mode 100644 index 0000000000000..5a04e03e03457 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiNotificationBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useContainerState } from '../../../../../../../../src/plugins/kibana_utils/public'; +import { EnhancedEmbeddableContext } from '../../../../../../embeddable_enhanced/public'; +import { txtDisplayName } from './i18n'; + +export const MenuItem: React.FC<{ context: EnhancedEmbeddableContext }> = ({ context }) => { + const { events } = useContainerState(context.embeddable.enhancements.dynamicActions.state); + const count = events.length; + + return ( + + {txtDisplayName} + {count > 0 && ( + + {count} + + )} + + ); +}; diff --git a/x-pack/plugins/drilldowns/public/actions/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/index.ts similarity index 100% rename from x-pack/plugins/drilldowns/public/actions/index.ts rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/index.ts diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts new file mode 100644 index 0000000000000..cccacf701a9ad --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts @@ -0,0 +1,52 @@ +/* + * 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 { Embeddable, EmbeddableInput } from '../../../../../../../src/plugins/embeddable/public'; +import { EnhancedEmbeddable } from '../../../../../embeddable_enhanced/public'; +import { + UiActionsEnhancedMemoryActionStorage as MemoryActionStorage, + UiActionsEnhancedDynamicActionManager as DynamicActionManager, + AdvancedUiActionsStart, +} from '../../../../../advanced_ui_actions/public'; +import { TriggerContextMapping } from '../../../../../../../src/plugins/ui_actions/public'; +import { uiActionsEnhancedPluginMock } from '../../../../../advanced_ui_actions/public/mocks'; + +export class MockEmbeddable extends Embeddable { + public rootType = 'dashboard'; + public readonly type = 'mock'; + private readonly triggers: Array = []; + constructor( + initialInput: EmbeddableInput, + params: { supportedTriggers?: Array } + ) { + super(initialInput, {}, undefined); + this.triggers = params.supportedTriggers ?? []; + } + public render(node: HTMLElement) {} + public reload() {} + public supportedTriggers(): Array { + return this.triggers; + } + public getRoot() { + return { + type: this.rootType, + } as Embeddable; + } +} + +export const enhanceEmbeddable = ( + embeddable: E, + uiActions: AdvancedUiActionsStart = uiActionsEnhancedPluginMock.createStartContract() +): EnhancedEmbeddable => { + (embeddable as EnhancedEmbeddable).enhancements = { + dynamicActions: new DynamicActionManager({ + storage: new MemoryActionStorage(), + isCompatible: async () => true, + uiActions, + }), + }; + return embeddable as EnhancedEmbeddable; +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts new file mode 100644 index 0000000000000..0161836b2c5b9 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts @@ -0,0 +1,57 @@ +/* + * 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 { CoreSetup } from 'src/core/public'; +import { SetupDependencies, StartDependencies } from '../../plugin'; +import { CONTEXT_MENU_TRIGGER } from '../../../../../../src/plugins/embeddable/public'; +import { EnhancedEmbeddableContext } from '../../../../embeddable_enhanced/public'; +import { + FlyoutCreateDrilldownAction, + FlyoutEditDrilldownAction, + OPEN_FLYOUT_ADD_DRILLDOWN, + OPEN_FLYOUT_EDIT_DRILLDOWN, +} from './actions'; +import { DashboardToDashboardDrilldown } from './dashboard_to_dashboard_drilldown'; +import { createStartServicesGetter } from '../../../../../../src/plugins/kibana_utils/public'; + +declare module '../../../../../../src/plugins/ui_actions/public' { + export interface ActionContextMapping { + [OPEN_FLYOUT_ADD_DRILLDOWN]: EnhancedEmbeddableContext; + [OPEN_FLYOUT_EDIT_DRILLDOWN]: EnhancedEmbeddableContext; + } +} + +interface BootstrapParams { + enableDrilldowns: boolean; +} + +export class DashboardDrilldownsService { + bootstrap( + core: CoreSetup, + plugins: SetupDependencies, + { enableDrilldowns }: BootstrapParams + ) { + if (enableDrilldowns) { + this.setupDrilldowns(core, plugins); + } + } + + setupDrilldowns( + core: CoreSetup, + { advancedUiActions: uiActions }: SetupDependencies + ) { + const start = createStartServicesGetter(core.getStartServices); + + const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ start }); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown); + + const actionFlyoutEditDrilldown = new FlyoutEditDrilldownAction({ start }); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutEditDrilldown); + + const dashboardToDashboardDrilldown = new DashboardToDashboardDrilldown({ start }); + uiActions.registerDrilldown(dashboardToDashboardDrilldown); + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/collect_config_container.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/collect_config_container.tsx new file mode 100644 index 0000000000000..dc19fccf5c92f --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/collect_config_container.tsx @@ -0,0 +1,164 @@ +/* + * 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 { EuiComboBoxOptionOption } from '@elastic/eui'; +import { debounce, findIndex } from 'lodash'; +import { SimpleSavedObject } from '../../../../../../../../src/core/public'; +import { DashboardDrilldownConfig } from './dashboard_drilldown_config'; +import { txtDestinationDashboardNotFound } from './i18n'; +import { CollectConfigProps } from '../../../../../../../../src/plugins/kibana_utils/public'; +import { Config } from '../types'; +import { Params } from '../drilldown'; + +const mergeDashboards = ( + dashboards: Array>, + selectedDashboard?: EuiComboBoxOptionOption +) => { + // if we have a selected dashboard and its not in the list, append it + if (selectedDashboard && findIndex(dashboards, { value: selectedDashboard.value }) === -1) { + return [selectedDashboard, ...dashboards]; + } + return dashboards; +}; + +const dashboardSavedObjectToMenuItem = ( + savedObject: SimpleSavedObject<{ + title: string; + }> +) => ({ + value: savedObject.id, + label: savedObject.attributes.title, +}); + +interface DashboardDrilldownCollectConfigProps extends CollectConfigProps { + params: Params; +} + +interface CollectConfigContainerState { + dashboards: Array>; + searchString?: string; + isLoading: boolean; + selectedDashboard?: EuiComboBoxOptionOption; + error?: string; +} + +export class CollectConfigContainer extends React.Component< + DashboardDrilldownCollectConfigProps, + CollectConfigContainerState +> { + private isMounted = true; + state = { + dashboards: [], + isLoading: false, + searchString: undefined, + selectedDashboard: undefined, + error: undefined, + }; + + constructor(props: DashboardDrilldownCollectConfigProps) { + super(props); + this.debouncedLoadDashboards = debounce(this.loadDashboards.bind(this), 500); + } + + componentDidMount() { + this.loadSelectedDashboard(); + this.loadDashboards(); + } + + componentWillUnmount() { + this.isMounted = false; + } + + render() { + const { config, onConfig } = this.props; + const { dashboards, selectedDashboard, isLoading, error } = this.state; + + return ( + { + onConfig({ ...config, dashboardId }); + if (this.state.error) { + this.setState({ error: undefined }); + } + }} + onSearchChange={this.debouncedLoadDashboards} + onCurrentFiltersToggle={() => + onConfig({ + ...config, + useCurrentFilters: !config.useCurrentFilters, + }) + } + onKeepRangeToggle={() => + onConfig({ + ...config, + useCurrentDateRange: !config.useCurrentDateRange, + }) + } + /> + ); + } + + private async loadSelectedDashboard() { + const { + config, + params: { start }, + } = this.props; + if (!config.dashboardId) return; + const savedObject = await start().core.savedObjects.client.get<{ title: string }>( + 'dashboard', + config.dashboardId + ); + + if (!this.isMounted) return; + + // handle case when destination dashboard no longer exists + if (savedObject.error?.statusCode === 404) { + this.setState({ + error: txtDestinationDashboardNotFound(config.dashboardId), + }); + this.props.onConfig({ ...config, dashboardId: undefined }); + return; + } + + if (savedObject.error) { + this.setState({ + error: savedObject.error.message, + }); + this.props.onConfig({ ...config, dashboardId: undefined }); + return; + } + + this.setState({ selectedDashboard: dashboardSavedObjectToMenuItem(savedObject) }); + } + + private readonly debouncedLoadDashboards: (searchString?: string) => void; + private async loadDashboards(searchString?: string) { + this.setState({ searchString, isLoading: true }); + const savedObjectsClient = this.props.params.start().core.savedObjects.client; + const { savedObjects } = await savedObjectsClient.find<{ title: string }>({ + type: 'dashboard', + search: searchString ? `${searchString}*` : undefined, + searchFields: ['title^3', 'description'], + defaultSearchOperator: 'AND', + perPage: 100, + }); + + // bail out if this response is no longer needed + if (!this.isMounted) return; + if (searchString !== this.state.searchString) return; + + const dashboardList = savedObjects.map(dashboardSavedObjectToMenuItem); + + this.setState({ dashboards: dashboardList, isLoading: false }); + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx new file mode 100644 index 0000000000000..f3a966a73509c --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx @@ -0,0 +1,63 @@ +/* + * 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. + */ + +/* eslint-disable no-console */ + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { DashboardDrilldownConfig } from './dashboard_drilldown_config'; + +export const dashboards = [ + { value: 'dashboard1', label: 'Dashboard 1' }, + { value: 'dashboard2', label: 'Dashboard 2' }, + { value: 'dashboard3', label: 'Dashboard 3' }, +]; + +const InteractiveDemo: React.FC = () => { + const [activeDashboardId, setActiveDashboardId] = React.useState('dashboard1'); + const [currentFilters, setCurrentFilters] = React.useState(false); + const [keepRange, setKeepRange] = React.useState(false); + + return ( + setActiveDashboardId(id)} + onCurrentFiltersToggle={() => setCurrentFilters(old => !old)} + onKeepRangeToggle={() => setKeepRange(old => !old)} + onSearchChange={() => {}} + isLoading={false} + /> + ); +}; + +storiesOf( + 'services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config', + module +) + .add('default', () => ( + console.log('onDashboardSelect', e)} + onSearchChange={() => {}} + isLoading={false} + /> + )) + .add('with switches', () => ( + console.log('onDashboardSelect', e)} + onCurrentFiltersToggle={() => console.log('onCurrentFiltersToggle')} + onKeepRangeToggle={() => console.log('onKeepRangeToggle')} + onSearchChange={() => {}} + isLoading={false} + /> + )) + .add('interactive demo', () => ); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx new file mode 100644 index 0000000000000..edeb7de48d9ac --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// Need to wait for https://github.com/elastic/eui/pull/3173/ +// to unit test this component +// basic interaction is covered in end-to-end tests +test.todo(''); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx new file mode 100644 index 0000000000000..a41a5fb718219 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx @@ -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 { EuiFormRow, EuiSwitch, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { + txtChooseDestinationDashboard, + txtUseCurrentFilters, + txtUseCurrentDateRange, +} from './i18n'; + +export interface DashboardDrilldownConfigProps { + activeDashboardId?: string; + dashboards: Array>; + currentFilters?: boolean; + keepRange?: boolean; + onDashboardSelect: (dashboardId: string) => void; + onCurrentFiltersToggle?: () => void; + onKeepRangeToggle?: () => void; + onSearchChange: (searchString: string) => void; + isLoading: boolean; + error?: string; +} + +export const DashboardDrilldownConfig: React.FC = ({ + activeDashboardId, + dashboards, + currentFilters, + keepRange, + onDashboardSelect, + onCurrentFiltersToggle, + onKeepRangeToggle, + onSearchChange, + isLoading, + error, +}) => { + const selectedTitle = dashboards.find(item => item.value === activeDashboardId)?.label || ''; + + return ( + <> + + + async + selectedOptions={ + activeDashboardId ? [{ label: selectedTitle, value: activeDashboardId }] : [] + } + options={dashboards} + onChange={([{ value = '' } = { value: '' }]) => onDashboardSelect(value)} + onSearchChange={onSearchChange} + isLoading={isLoading} + singleSelection={{ asPlainText: true }} + fullWidth + data-test-subj={'dashboardDrilldownSelectDashboard'} + isInvalid={!!error} + /> + + {!!onCurrentFiltersToggle && ( + + + + )} + {!!onKeepRangeToggle && ( + + + + )} + + ); +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts new file mode 100644 index 0000000000000..a37f2bfa01bd4 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts @@ -0,0 +1,28 @@ +/* + * 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 txtChooseDestinationDashboard = i18n.translate( + 'xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard', + { + defaultMessage: 'Choose destination dashboard', + } +); + +export const txtUseCurrentFilters = i18n.translate( + 'xpack.dashboard.components.DashboardDrilldownConfig.useCurrentFilters', + { + defaultMessage: 'Use filters and query from origin dashboard', + } +); + +export const txtUseCurrentDateRange = i18n.translate( + 'xpack.dashboard.components.DashboardDrilldownConfig.useCurrentDateRange', + { + defaultMessage: 'Use date range from origin dashboard', + } +); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/index.ts new file mode 100644 index 0000000000000..b9a64a3cc17e6 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/index.ts @@ -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 './dashboard_drilldown_config'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/i18n.ts new file mode 100644 index 0000000000000..6f6f7412f6b53 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/i18n.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtDestinationDashboardNotFound = (dashboardId?: string) => + i18n.translate('xpack.dashboard.drilldown.errorDestinationDashboardIsMissing', { + defaultMessage: + "Destination dashboard ('{dashboardId}') no longer exists. Choose another dashboard.", + values: { + dashboardId, + }, + }); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/index.ts new file mode 100644 index 0000000000000..c34290528d914 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/index.ts @@ -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 { CollectConfigContainer } from './collect_config_container'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts new file mode 100644 index 0000000000000..e2a530b156da5 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts @@ -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 DASHBOARD_TO_DASHBOARD_DRILLDOWN = 'DASHBOARD_TO_DASHBOARD_DRILLDOWN'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx new file mode 100644 index 0000000000000..18ee95cb57b3b --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx @@ -0,0 +1,363 @@ +/* + * 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 { DashboardToDashboardDrilldown } from './drilldown'; +import { UrlGeneratorContract } from '../../../../../../../src/plugins/share/public'; +import { savedObjectsServiceMock } from '../../../../../../../src/core/public/mocks'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { ActionContext, Config } from './types'; +import { + Filter, + FilterStateStore, + Query, + RangeFilter, + TimeRange, +} from '../../../../../../../src/plugins/data/common'; +import { esFilters } from '../../../../../../../src/plugins/data/public'; + +// convenient to use real implementation here. +import { createDirectAccessDashboardLinkGenerator } from '../../../../../../../src/plugins/dashboard/public/url_generator'; +import { VisualizeEmbeddableContract } from '../../../../../../../src/plugins/visualizations/public'; +import { + RangeSelectTriggerContext, + ValueClickTriggerContext, +} from '../../../../../../../src/plugins/embeddable/public'; +import { SavedObjectLoader } from '../../../../../../../src/plugins/saved_objects/public'; +import { StartServicesGetter } from '../../../../../../../src/plugins/kibana_utils/public/core'; +import { StartDependencies } from '../../../plugin'; + +describe('.isConfigValid()', () => { + const drilldown = new DashboardToDashboardDrilldown({} as any); + + test('returns false for invalid config with missing dashboard id', () => { + expect( + drilldown.isConfigValid({ + dashboardId: '', + useCurrentDateRange: false, + useCurrentFilters: false, + }) + ).toBe(false); + }); + + test('returns true for valid config', () => { + expect( + drilldown.isConfigValid({ + dashboardId: 'id', + useCurrentDateRange: false, + useCurrentFilters: false, + }) + ).toBe(true); + }); +}); + +test('config component exist', () => { + const drilldown = new DashboardToDashboardDrilldown({} as any); + expect(drilldown.CollectConfig).toEqual(expect.any(Function)); +}); + +test('initial config: switches are ON', () => { + const drilldown = new DashboardToDashboardDrilldown({} as any); + const { useCurrentDateRange, useCurrentFilters } = drilldown.createConfig(); + expect(useCurrentDateRange).toBe(true); + expect(useCurrentFilters).toBe(true); +}); + +test('getHref is defined', () => { + const drilldown = new DashboardToDashboardDrilldown({} as any); + expect(drilldown.getHref).toBeDefined(); +}); + +describe('.execute() & getHref', () => { + /** + * A convenience test setup helper + * Beware: `dataPluginMock.createStartContract().actions` and extracting filters from event is mocked! + * The url generation is not mocked and uses real implementation + * So this tests are mostly focused on making sure the filters returned from `dataPluginMock.createStartContract().actions` helpers + * end up in resulting navigation path + */ + async function setupTestBed( + config: Partial, + embeddableInput: { filters?: Filter[]; timeRange?: TimeRange; query?: Query }, + filtersFromEvent: Filter[], + useRangeEvent = false + ) { + const navigateToApp = jest.fn(); + const getUrlForApp = jest.fn((app, opt) => `${app}/${opt.path}`); + const dataPluginActions = dataPluginMock.createStartContract().actions; + const savedObjectsClient = savedObjectsServiceMock.createStartContract().client; + + const drilldown = new DashboardToDashboardDrilldown({ + start: ((() => ({ + core: { + application: { + navigateToApp, + getUrlForApp, + }, + savedObjects: { + client: savedObjectsClient, + }, + }, + plugins: { + advancedUiActions: {}, + data: { + actions: dataPluginActions, + }, + share: { + urlGenerators: { + getUrlGenerator: () => + createDirectAccessDashboardLinkGenerator(() => + Promise.resolve({ + appBasePath: 'test', + useHashedUrl: false, + savedDashboardLoader: ({} as unknown) as SavedObjectLoader, + }) + ) as UrlGeneratorContract, + }, + }, + }, + self: {}, + })) as unknown) as StartServicesGetter< + Pick + >, + }); + const selectRangeFiltersSpy = jest + .spyOn(dataPluginActions, 'createFiltersFromRangeSelectAction') + .mockImplementation(() => Promise.resolve(filtersFromEvent)); + const valueClickFiltersSpy = jest + .spyOn(dataPluginActions, 'createFiltersFromValueClickAction') + .mockImplementation(() => Promise.resolve(filtersFromEvent)); + + const completeConfig: Config = { + dashboardId: 'id', + useCurrentFilters: false, + useCurrentDateRange: false, + ...config, + }; + + const context = ({ + data: useRangeEvent + ? ({ range: {} } as RangeSelectTriggerContext['data']) + : ({ data: [] } as ValueClickTriggerContext['data']), + timeFieldName: 'order_date', + embeddable: { + getInput: () => ({ + filters: [], + timeRange: { from: 'now-15m', to: 'now' }, + query: { query: 'test', language: 'kuery' }, + ...embeddableInput, + }), + }, + } as unknown) as ActionContext; + + await drilldown.execute(completeConfig, context); + + if (useRangeEvent) { + expect(selectRangeFiltersSpy).toBeCalledTimes(1); + expect(valueClickFiltersSpy).toBeCalledTimes(0); + } else { + expect(selectRangeFiltersSpy).toBeCalledTimes(0); + expect(valueClickFiltersSpy).toBeCalledTimes(1); + } + + expect(navigateToApp).toBeCalledTimes(1); + expect(navigateToApp.mock.calls[0][0]).toBe('kibana'); + + const executeNavigatedPath = navigateToApp.mock.calls[0][1]?.path; + const href = await drilldown.getHref(completeConfig, context); + + expect(href.includes(executeNavigatedPath)).toBe(true); + + return { + href, + }; + } + + test('navigates to correct dashboard', async () => { + const testDashboardId = 'dashboardId'; + const { href } = await setupTestBed( + { + dashboardId: testDashboardId, + }, + {}, + [], + false + ); + + expect(href).toEqual(expect.stringContaining(`dashboard/${testDashboardId}`)); + }); + + test('query is removed if filters are disabled', async () => { + const queryString = 'querystring'; + const queryLanguage = 'kuery'; + const { href } = await setupTestBed( + { + useCurrentFilters: false, + }, + { + query: { query: queryString, language: queryLanguage }, + }, + [] + ); + + expect(href).toEqual(expect.not.stringContaining(queryString)); + expect(href).toEqual(expect.not.stringContaining(queryLanguage)); + }); + + test('navigates with query if filters are enabled', async () => { + const queryString = 'querystring'; + const queryLanguage = 'kuery'; + const { href } = await setupTestBed( + { + useCurrentFilters: true, + }, + { + query: { query: queryString, language: queryLanguage }, + }, + [] + ); + + expect(href).toEqual(expect.stringContaining(queryString)); + expect(href).toEqual(expect.stringContaining(queryLanguage)); + }); + + test('when user chooses to keep current filters, current filters are set on destination dashboard', async () => { + const existingAppFilterKey = 'appExistingFilter'; + const existingGlobalFilterKey = 'existingGlobalFilter'; + const newAppliedFilterKey = 'newAppliedFilter'; + + const { href } = await setupTestBed( + { + useCurrentFilters: true, + }, + { + filters: [getFilter(false, existingAppFilterKey), getFilter(true, existingGlobalFilterKey)], + }, + [getFilter(false, newAppliedFilterKey)] + ); + + expect(href).toEqual(expect.stringContaining(existingAppFilterKey)); + expect(href).toEqual(expect.stringContaining(existingGlobalFilterKey)); + expect(href).toEqual(expect.stringContaining(newAppliedFilterKey)); + }); + + test('when user chooses to remove current filters, current app filters are remove on destination dashboard', async () => { + const existingAppFilterKey = 'appExistingFilter'; + const existingGlobalFilterKey = 'existingGlobalFilter'; + const newAppliedFilterKey = 'newAppliedFilter'; + + const { href } = await setupTestBed( + { + useCurrentFilters: false, + }, + { + filters: [getFilter(false, existingAppFilterKey), getFilter(true, existingGlobalFilterKey)], + }, + [getFilter(false, newAppliedFilterKey)] + ); + + expect(href).not.toEqual(expect.stringContaining(existingAppFilterKey)); + expect(href).toEqual(expect.stringContaining(existingGlobalFilterKey)); + expect(href).toEqual(expect.stringContaining(newAppliedFilterKey)); + }); + + test('when user chooses to keep current time range, current time range is passed in url', async () => { + const { href } = await setupTestBed( + { + useCurrentDateRange: true, + }, + { + timeRange: { + from: 'now-300m', + to: 'now', + }, + }, + [] + ); + + expect(href).toEqual(expect.stringContaining('now-300m')); + }); + + test('when user chooses to not keep current time range, no current time range is passed in url', async () => { + const { href } = await setupTestBed( + { + useCurrentDateRange: false, + }, + { + timeRange: { + from: 'now-300m', + to: 'now', + }, + }, + [], + false + ); + + expect(href).not.toEqual(expect.stringContaining('now-300m')); + }); + + test('if range filter contains date, then it is passed as time', async () => { + const { href } = await setupTestBed( + { + useCurrentDateRange: true, + }, + { + timeRange: { + from: 'now-300m', + to: 'now', + }, + }, + [getMockTimeRangeFilter()], + true + ); + + expect(href).not.toEqual(expect.stringContaining('now-300m')); + expect(href).toEqual(expect.stringContaining('2020-03-23')); + }); +}); + +function getFilter(isPinned: boolean, queryKey: string): Filter { + return { + $state: { + store: isPinned ? esFilters.FilterStateStore.GLOBAL_STATE : FilterStateStore.APP_STATE, + }, + meta: { + index: 'logstash-*', + disabled: false, + negate: false, + alias: null, + }, + query: { + match: { + [queryKey]: 'any', + }, + }, + }; +} + +function getMockTimeRangeFilter(): RangeFilter { + return { + meta: { + index: 'logstash-*', + params: { + gte: '2020-03-23T13:10:29.665Z', + lt: '2020-03-23T13:10:36.736Z', + format: 'strict_date_optional_time', + }, + type: 'range', + key: 'order_date', + disabled: false, + negate: false, + alias: null, + }, + range: { + order_date: { + gte: '2020-03-23T13:10:29.665Z', + lt: '2020-03-23T13:10:36.736Z', + format: 'strict_date_optional_time', + }, + }, + }; +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx new file mode 100644 index 0000000000000..848e77384f7f0 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx @@ -0,0 +1,154 @@ +/* + * 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 { reactToUiComponent } from '../../../../../../../src/plugins/kibana_react/public'; +import { DASHBOARD_APP_URL_GENERATOR } from '../../../../../../../src/plugins/dashboard/public'; +import { ActionContext, Config } from './types'; +import { CollectConfigContainer } from './components'; +import { DASHBOARD_TO_DASHBOARD_DRILLDOWN } from './constants'; +import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../../advanced_ui_actions/public'; +import { txtGoToDashboard } from './i18n'; +import { esFilters } from '../../../../../../../src/plugins/data/public'; +import { VisualizeEmbeddableContract } from '../../../../../../../src/plugins/visualizations/public'; +import { + isRangeSelectTriggerContext, + isValueClickTriggerContext, +} from '../../../../../../../src/plugins/embeddable/public'; +import { StartServicesGetter } from '../../../../../../../src/plugins/kibana_utils/public'; +import { StartDependencies } from '../../../plugin'; + +export interface Params { + start: StartServicesGetter>; +} + +export class DashboardToDashboardDrilldown + implements Drilldown> { + constructor(protected readonly params: Params) {} + + public readonly id = DASHBOARD_TO_DASHBOARD_DRILLDOWN; + + public readonly order = 100; + + public readonly getDisplayName = () => txtGoToDashboard; + + public readonly euiIcon = 'dashboardApp'; + + private readonly ReactCollectConfig: React.FC = props => ( + + ); + + public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); + + public readonly createConfig = () => ({ + dashboardId: '', + useCurrentFilters: true, + useCurrentDateRange: true, + }); + + public readonly isConfigValid = (config: Config): config is Config => { + if (!config.dashboardId) return false; + return true; + }; + + public readonly getHref = async ( + config: Config, + context: ActionContext + ): Promise => { + const dashboardPath = await this.getDestinationUrl(config, context); + const dashboardHash = dashboardPath.split('#')[1]; + + return this.params.start().core.application.getUrlForApp('kibana', { + path: `#${dashboardHash}`, + }); + }; + + public readonly execute = async ( + config: Config, + context: ActionContext + ) => { + const dashboardPath = await this.getDestinationUrl(config, context); + const dashboardHash = dashboardPath.split('#')[1]; + + await this.params.start().core.application.navigateToApp('kibana', { + path: `#${dashboardHash}`, + }); + }; + + private getDestinationUrl = async ( + config: Config, + context: ActionContext + ): Promise => { + const { + createFiltersFromRangeSelectAction, + createFiltersFromValueClickAction, + } = this.params.start().plugins.data.actions; + const { + timeRange: currentTimeRange, + query, + filters: currentFilters, + } = context.embeddable!.getInput(); + + // if useCurrentDashboardFilters enabled, then preserve all the filters (pinned and unpinned) + // otherwise preserve only pinned + const existingFilters = + (config.useCurrentFilters + ? currentFilters + : currentFilters?.filter(f => esFilters.isFilterPinned(f))) ?? []; + + // if useCurrentDashboardDataRange is enabled, then preserve current time range + // if undefined is passed, then destination dashboard will figure out time range itself + // for brush event this time range would be overwritten + let timeRange = config.useCurrentDateRange ? currentTimeRange : undefined; + let filtersFromEvent = await (async () => { + try { + if (isRangeSelectTriggerContext(context)) + return await createFiltersFromRangeSelectAction(context.data); + if (isValueClickTriggerContext(context)) + return await createFiltersFromValueClickAction(context.data); + + // eslint-disable-next-line no-console + console.warn( + ` + DashboardToDashboard drilldown: can't extract filters from action. + Is it not supported action?`, + context + ); + + return []; + } catch (e) { + // eslint-disable-next-line no-console + console.warn( + ` + DashboardToDashboard drilldown: error extracting filters from action. + Continuing without applying filters from event`, + e + ); + return []; + } + })(); + + if (context.timeFieldName) { + const { timeRangeFilter, restOfFilters } = esFilters.extractTimeFilter( + context.timeFieldName, + filtersFromEvent + ); + filtersFromEvent = restOfFilters; + if (timeRangeFilter) { + timeRange = esFilters.convertRangeFilterToTimeRangeString(timeRangeFilter); + } + } + + const { plugins } = this.params.start(); + + return plugins.share.urlGenerators.getUrlGenerator(DASHBOARD_APP_URL_GENERATOR).createUrl({ + dashboardId: config.dashboardId, + query: config.useCurrentFilters ? query : undefined, + timeRange, + filters: [...existingFilters, ...filtersFromEvent], + }); + }; +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts new file mode 100644 index 0000000000000..98b746bafd24a --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts @@ -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 txtGoToDashboard = i18n.translate('xpack.dashboard.drilldown.goToDashboard', { + defaultMessage: 'Go to Dashboard', +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts new file mode 100644 index 0000000000000..914f34980a272 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts @@ -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 { DASHBOARD_TO_DASHBOARD_DRILLDOWN } from './constants'; +export { + DashboardToDashboardDrilldown, + Params as DashboardToDashboardDrilldownParams, +} from './drilldown'; +export { + ActionContext as DashboardToDashboardActionContext, + Config as DashboardToDashboardConfig, +} from './types'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts new file mode 100644 index 0000000000000..1fbff0a7269e2 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts @@ -0,0 +1,21 @@ +/* + * 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 { + ValueClickTriggerContext, + RangeSelectTriggerContext, + IEmbeddable, +} from '../../../../../../../src/plugins/embeddable/public'; + +export type ActionContext = + | ValueClickTriggerContext + | RangeSelectTriggerContext; + +export interface Config { + dashboardId?: string; + useCurrentFilters: boolean; + useCurrentDateRange: boolean; +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts new file mode 100644 index 0000000000000..7be8f1c65da12 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts @@ -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 './dashboard_drilldowns_services'; diff --git a/x-pack/plugins/drilldowns/public/service/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/index.ts similarity index 86% rename from x-pack/plugins/drilldowns/public/service/index.ts rename to x-pack/plugins/dashboard_enhanced/public/services/index.ts index 44472b18a5317..8cc3e12906531 100644 --- a/x-pack/plugins/drilldowns/public/service/index.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './drilldown_service'; +export * from './drilldowns'; diff --git a/x-pack/plugins/dashboard_enhanced/scripts/storybook.js b/x-pack/plugins/dashboard_enhanced/scripts/storybook.js new file mode 100644 index 0000000000000..5d95c56c31e3b --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/scripts/storybook.js @@ -0,0 +1,13 @@ +/* + * 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 { join } from 'path'; + +// eslint-disable-next-line +require('@kbn/storybook').runStorybookCli({ + name: 'dashboard_enhanced', + storyGlobs: [join(__dirname, '..', 'public', '**', '*.story.tsx')], +}); diff --git a/x-pack/plugins/drilldowns/kibana.json b/x-pack/plugins/drilldowns/kibana.json index 4dba07b5a7be3..678c054aa322c 100644 --- a/x-pack/plugins/drilldowns/kibana.json +++ b/x-pack/plugins/drilldowns/kibana.json @@ -3,9 +3,6 @@ "version": "kibana", "server": false, "ui": true, - "configPath": ["xpack", "drilldowns"], - "requiredPlugins": [ - "uiActions", - "embeddable" - ] + "requiredPlugins": ["uiActions", "embeddable", "advancedUiActions"], + "configPath": ["xpack", "drilldowns"] } diff --git a/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx deleted file mode 100644 index 4834cc8081374..0000000000000 --- a/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { CoreStart } from 'src/core/public'; -import { ActionByType } from '../../../../../../src/plugins/ui_actions/public'; -import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; -import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; -import { FlyoutCreateDrilldown } from '../../components/flyout_create_drilldown'; - -export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; - -export interface FlyoutCreateDrilldownActionContext { - embeddable: IEmbeddable; -} - -export interface OpenFlyoutAddDrilldownParams { - overlays: () => Promise; -} - -export class FlyoutCreateDrilldownAction implements ActionByType { - public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN; - public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN; - public order = 100; - - constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {} - - public getDisplayName() { - return i18n.translate('xpack.drilldowns.FlyoutCreateDrilldownAction.displayName', { - defaultMessage: 'Create drilldown', - }); - } - - public getIconType() { - return 'plusInCircle'; - } - - public async isCompatible({ embeddable }: FlyoutCreateDrilldownActionContext) { - return embeddable.getInput().viewMode === 'edit'; - } - - public async execute(context: FlyoutCreateDrilldownActionContext) { - const overlays = await this.params.overlays(); - const handle = overlays.openFlyout( - toMountPoint( handle.close()} />) - ); - } -} diff --git a/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx deleted file mode 100644 index f109da94fcaca..0000000000000 --- a/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { CoreStart } from 'src/core/public'; -import { EuiNotificationBadge } from '@elastic/eui'; -import { ActionByType } from '../../../../../../src/plugins/ui_actions/public'; -import { - toMountPoint, - reactToUiComponent, -} from '../../../../../../src/plugins/kibana_react/public'; -import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; -import { FormCreateDrilldown } from '../../components/form_create_drilldown'; - -export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; - -export interface FlyoutEditDrilldownActionContext { - embeddable: IEmbeddable; -} - -export interface FlyoutEditDrilldownParams { - overlays: () => Promise; -} - -const displayName = i18n.translate('xpack.drilldowns.panel.openFlyoutEditDrilldown.displayName', { - defaultMessage: 'Manage drilldowns', -}); - -// mocked data -const drilldrownCount = 2; - -export class FlyoutEditDrilldownAction implements ActionByType { - public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN; - public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN; - public order = 100; - - constructor(protected readonly params: FlyoutEditDrilldownParams) {} - - public getDisplayName() { - return displayName; - } - - public getIconType() { - return 'list'; - } - - private ReactComp: React.FC<{ context: FlyoutEditDrilldownActionContext }> = () => { - return ( - <> - {displayName}{' '} - - {drilldrownCount} - - - ); - }; - - MenuItem = reactToUiComponent(this.ReactComp); - - public async isCompatible({ embeddable }: FlyoutEditDrilldownActionContext) { - return embeddable.getInput().viewMode === 'edit' && drilldrownCount > 0; - } - - public async execute({ embeddable }: FlyoutEditDrilldownActionContext) { - const overlays = await this.params.overlays(); - overlays.openFlyout(toMountPoint()); - } -} diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx new file mode 100644 index 0000000000000..16b4d3a25d9e5 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx @@ -0,0 +1,43 @@ +/* + * 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 * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; +import { + dashboardFactory, + urlFactory, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; +import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { StubBrowserStorage } from '../../../../../../src/test_utils/public/stub_browser_storage'; +import { mockDynamicActionManager } from './test_data'; + +const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ + advancedUiActions: { + getActionFactories() { + return [dashboardFactory, urlFactory]; + }, + } as any, + storage: new Storage(new StubBrowserStorage()), + notifications: { + toasts: { + addError: (...args: any[]) => { + alert(JSON.stringify(args)); + }, + addSuccess: (...args: any[]) => { + alert(JSON.stringify(args)); + }, + } as any, + }, +}); + +storiesOf('components/FlyoutManageDrilldowns', module).add('default', () => ( + {}}> + + +)); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx new file mode 100644 index 0000000000000..6749b41e81fc7 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx @@ -0,0 +1,221 @@ +/* + * 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 { cleanup, fireEvent, render, wait } from '@testing-library/react/pure'; +import '@testing-library/jest-dom/extend-expect'; +import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; +import { + dashboardFactory, + urlFactory, +} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; +import { StubBrowserStorage } from '../../../../../../src/test_utils/public/stub_browser_storage'; +import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { mockDynamicActionManager } from './test_data'; +import { TEST_SUBJ_DRILLDOWN_ITEM } from '../list_manage_drilldowns'; +import { WELCOME_MESSAGE_TEST_SUBJ } from '../drilldown_hello_bar'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { NotificationsStart } from 'kibana/public'; +import { toastDrilldownsCRUDError } from './i18n'; + +const storage = new Storage(new StubBrowserStorage()); +const notifications = coreMock.createStart().notifications; +const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ + advancedUiActions: { + getActionFactories() { + return [dashboardFactory, urlFactory]; + }, + } as any, + storage, + notifications, +}); + +// https://github.com/elastic/kibana/issues/59469 +afterEach(cleanup); + +beforeEach(() => { + storage.clear(); + (notifications.toasts as jest.Mocked).addSuccess.mockClear(); + (notifications.toasts as jest.Mocked).addError.mockClear(); +}); + +test('Allows to manage drilldowns', async () => { + const screen = render( + + ); + + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + + // no drilldowns in the list + expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0); + + fireEvent.click(screen.getByText(/Create new/i)); + + let [createHeading, createButton] = screen.getAllByText(/Create Drilldown/i); + expect(createHeading).toBeVisible(); + expect(screen.getByLabelText(/Back/i)).toBeVisible(); + + expect(createButton).toBeDisabled(); + + // input drilldown name + const name = 'Test name'; + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: name }, + }); + + // select URL one + fireEvent.click(screen.getByText(/Go to URL/i)); + + // Input url + const URL = 'https://elastic.co'; + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: URL }, + }); + + [createHeading, createButton] = screen.getAllByText(/Create Drilldown/i); + + expect(createButton).toBeEnabled(); + fireEvent.click(createButton); + + expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); + + await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(1)); + expect(screen.getByText(name)).toBeVisible(); + const editButton = screen.getByText(/edit/i); + fireEvent.click(editButton); + + expect(screen.getByText(/Edit Drilldown/i)).toBeVisible(); + // check that wizard is prefilled with current drilldown values + expect(screen.getByLabelText(/name/i)).toHaveValue(name); + expect(screen.getByLabelText(/url/i)).toHaveValue(URL); + + // input new drilldown name + const newName = 'New drilldown name'; + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: newName }, + }); + fireEvent.click(screen.getByText(/save/i)); + + expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); + await wait(() => screen.getByText(newName)); + + // delete drilldown from edit view + fireEvent.click(screen.getByText(/edit/i)); + fireEvent.click(screen.getByText(/delete/i)); + + expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); + await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); +}); + +test('Can delete multiple drilldowns', async () => { + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + + const createDrilldown = async () => { + const oldCount = screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM).length; + fireEvent.click(screen.getByText(/Create new/i)); + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: 'test' }, + }); + fireEvent.click(screen.getByText(/Go to URL/i)); + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: 'https://elastic.co' }, + }); + fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); + await wait(() => + expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(oldCount + 1) + ); + }; + + await createDrilldown(); + await createDrilldown(); + await createDrilldown(); + + const checkboxes = screen.getAllByLabelText(/Select this drilldown/i); + expect(checkboxes).toHaveLength(3); + checkboxes.forEach(checkbox => fireEvent.click(checkbox)); + expect(screen.queryByText(/Create/i)).not.toBeInTheDocument(); + fireEvent.click(screen.getByText(/Delete \(3\)/i)); + + await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); +}); + +test('Create only mode', async () => { + const onClose = jest.fn(); + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: 'test' }, + }); + fireEvent.click(screen.getByText(/Go to URL/i)); + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: 'https://elastic.co' }, + }); + fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); + + await wait(() => expect(notifications.toasts.addSuccess).toBeCalled()); + expect(onClose).toBeCalled(); + expect(await mockDynamicActionManager.state.get().events.length).toBe(1); +}); + +test.todo("Error when can't fetch drilldown list"); + +test("Error when can't save drilldown changes", async () => { + const error = new Error('Oops'); + jest.spyOn(mockDynamicActionManager, 'createEvent').mockImplementationOnce(async () => { + throw error; + }); + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + fireEvent.click(screen.getByText(/Create new/i)); + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: 'test' }, + }); + fireEvent.click(screen.getByText(/Go to URL/i)); + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: 'https://elastic.co' }, + }); + fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); + await wait(() => + expect(notifications.toasts.addError).toBeCalledWith(error, { title: toastDrilldownsCRUDError }) + ); +}); + +test('Should show drilldown welcome message. Should be able to dismiss it', async () => { + let screen = render( + + ); + + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + + expect(screen.getByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeVisible(); + fireEvent.click(screen.getByText(/hide/i)); + expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull(); + cleanup(); + + screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull(); +}); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx new file mode 100644 index 0000000000000..0d4a67e325e4d --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx @@ -0,0 +1,331 @@ +/* + * 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, { useEffect, useState } from 'react'; +import useMountedState from 'react-use/lib/useMountedState'; +import { + AdvancedUiActionsActionFactory as ActionFactory, + AdvancedUiActionsStart, + UiActionsEnhancedDynamicActionManager as DynamicActionManager, + UiActionsEnhancedSerializedAction, + UiActionsEnhancedSerializedEvent, +} from '../../../../advanced_ui_actions/public'; +import { NotificationsStart } from '../../../../../../src/core/public'; +import { DrilldownWizardConfig, FlyoutDrilldownWizard } from '../flyout_drilldown_wizard'; +import { FlyoutListManageDrilldowns } from '../flyout_list_manage_drilldowns'; +import { IStorageWrapper } from '../../../../../../src/plugins/kibana_utils/public'; +import { + VALUE_CLICK_TRIGGER, + SELECT_RANGE_TRIGGER, + TriggerContextMapping, +} from '../../../../../../src/plugins/ui_actions/public'; +import { useContainerState } from '../../../../../../src/plugins/kibana_utils/public'; +import { DrilldownListItem } from '../list_manage_drilldowns'; +import { + toastDrilldownCreated, + toastDrilldownDeleted, + toastDrilldownEdited, + toastDrilldownsCRUDError, + toastDrilldownsDeleted, +} from './i18n'; + +interface ConnectedFlyoutManageDrilldownsProps { + placeContext: Context; + dynamicActionManager: DynamicActionManager; + viewMode?: 'create' | 'manage'; + onClose?: () => void; +} + +/** + * Represent current state (route) of FlyoutManageDrilldowns + */ +enum Routes { + Manage = 'manage', + Create = 'create', + Edit = 'edit', +} + +export function createFlyoutManageDrilldowns({ + advancedUiActions, + storage, + notifications, +}: { + advancedUiActions: AdvancedUiActionsStart; + storage: IStorageWrapper; + notifications: NotificationsStart; +}) { + // fine to assume this is static, + // because all action factories should be registered in setup phase + const allActionFactories = advancedUiActions.getActionFactories(); + const allActionFactoriesById = allActionFactories.reduce((acc, next) => { + acc[next.id] = next; + return acc; + }, {} as Record); + + return (props: ConnectedFlyoutManageDrilldownsProps) => { + const isCreateOnly = props.viewMode === 'create'; + + const selectedTriggers: Array = React.useMemo( + () => [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER], + [] + ); + + const factoryContext: object = React.useMemo( + () => ({ + placeContext: props.placeContext, + triggers: selectedTriggers, + }), + [props.placeContext, selectedTriggers] + ); + + const actionFactories = useCompatibleActionFactoriesForCurrentContext( + allActionFactories, + factoryContext + ); + + const [route, setRoute] = useState( + () => (isCreateOnly ? Routes.Create : Routes.Manage) // initial state is different depending on `viewMode` + ); + const [currentEditId, setCurrentEditId] = useState(null); + + const [shouldShowWelcomeMessage, onHideWelcomeMessage] = useWelcomeMessage(storage); + + const { + drilldowns, + createDrilldown, + editDrilldown, + deleteDrilldown, + } = useDrilldownsStateManager(props.dynamicActionManager, notifications); + + /** + * isCompatible promise is not yet resolved. + * Skip rendering until it is resolved + */ + if (!actionFactories) return null; + /** + * Drilldowns are not fetched yet or error happened during fetching + * In case of error user is notified with toast + */ + if (!drilldowns) return null; + + /** + * Needed for edit mode to prefill wizard fields with data from current edited drilldown + */ + function resolveInitialDrilldownWizardConfig(): DrilldownWizardConfig | undefined { + if (route !== Routes.Edit) return undefined; + if (!currentEditId) return undefined; + const drilldownToEdit = drilldowns?.find(d => d.eventId === currentEditId); + if (!drilldownToEdit) return undefined; + + return { + actionFactory: allActionFactoriesById[drilldownToEdit.action.factoryId], + actionConfig: drilldownToEdit.action.config as object, + name: drilldownToEdit.action.name, + }; + } + + /** + * Maps drilldown to list item view model + */ + function mapToDrilldownToDrilldownListItem( + drilldown: UiActionsEnhancedSerializedEvent + ): DrilldownListItem { + const actionFactory = allActionFactoriesById[drilldown.action.factoryId]; + return { + id: drilldown.eventId, + drilldownName: drilldown.action.name, + actionName: actionFactory?.getDisplayName(factoryContext) ?? drilldown.action.factoryId, + icon: actionFactory?.getIconType(factoryContext), + }; + } + + switch (route) { + case Routes.Create: + case Routes.Edit: + return ( + setRoute(Routes.Manage)} + onSubmit={({ actionConfig, actionFactory, name }) => { + if (route === Routes.Create) { + createDrilldown( + { + name, + config: actionConfig, + factoryId: actionFactory.id, + }, + selectedTriggers + ); + } else { + editDrilldown( + currentEditId!, + { + name, + config: actionConfig, + factoryId: actionFactory.id, + }, + selectedTriggers + ); + } + + if (isCreateOnly) { + if (props.onClose) { + props.onClose(); + } + } else { + setRoute(Routes.Manage); + } + + setCurrentEditId(null); + }} + onDelete={() => { + deleteDrilldown(currentEditId!); + setRoute(Routes.Manage); + setCurrentEditId(null); + }} + actionFactoryContext={factoryContext} + initialDrilldownWizardConfig={resolveInitialDrilldownWizardConfig()} + /> + ); + + case Routes.Manage: + default: + return ( + { + setCurrentEditId(null); + deleteDrilldown(ids); + }} + onEdit={id => { + setCurrentEditId(id); + setRoute(Routes.Edit); + }} + onCreate={() => { + setCurrentEditId(null); + setRoute(Routes.Create); + }} + onClose={props.onClose} + /> + ); + } + }; +} + +function useCompatibleActionFactoriesForCurrentContext( + actionFactories: Array>, + context: Context +) { + const [compatibleActionFactories, setCompatibleActionFactories] = useState< + Array> + >(); + useEffect(() => { + let canceled = false; + async function updateCompatibleFactoriesForContext() { + const compatibility = await Promise.all( + actionFactories.map(factory => factory.isCompatible(context)) + ); + if (canceled) return; + setCompatibleActionFactories(actionFactories.filter((_, i) => compatibility[i])); + } + updateCompatibleFactoriesForContext(); + return () => { + canceled = true; + }; + }, [context, actionFactories]); + + return compatibleActionFactories; +} + +function useWelcomeMessage(storage: IStorageWrapper): [boolean, () => void] { + const key = `drilldowns:hidWelcomeMessage`; + const [hidWelcomeMessage, setHidWelcomeMessage] = useState(storage.get(key) ?? false); + + return [ + !hidWelcomeMessage, + () => { + if (hidWelcomeMessage) return; + setHidWelcomeMessage(true); + storage.set(key, true); + }, + ]; +} + +function useDrilldownsStateManager( + actionManager: DynamicActionManager, + notifications: NotificationsStart +) { + const { events: drilldowns } = useContainerState(actionManager.state); + const [isLoading, setIsLoading] = useState(false); + const isMounted = useMountedState(); + + async function run(op: () => Promise) { + setIsLoading(true); + try { + await op(); + } catch (e) { + notifications.toasts.addError(e, { + title: toastDrilldownsCRUDError, + }); + if (!isMounted) return; + setIsLoading(false); + return; + } + } + + async function createDrilldown( + action: UiActionsEnhancedSerializedAction, + selectedTriggers: Array + ) { + await run(async () => { + await actionManager.createEvent(action, selectedTriggers); + notifications.toasts.addSuccess({ + title: toastDrilldownCreated.title, + text: toastDrilldownCreated.text(action.name), + }); + }); + } + + async function editDrilldown( + drilldownId: string, + action: UiActionsEnhancedSerializedAction, + selectedTriggers: Array + ) { + await run(async () => { + await actionManager.updateEvent(drilldownId, action, selectedTriggers); + notifications.toasts.addSuccess({ + title: toastDrilldownEdited.title, + text: toastDrilldownEdited.text(action.name), + }); + }); + } + + async function deleteDrilldown(drilldownIds: string | string[]) { + await run(async () => { + drilldownIds = Array.isArray(drilldownIds) ? drilldownIds : [drilldownIds]; + await actionManager.deleteEvents(drilldownIds); + notifications.toasts.addSuccess( + drilldownIds.length === 1 + ? { + title: toastDrilldownDeleted.title, + text: toastDrilldownDeleted.text, + } + : { + title: toastDrilldownsDeleted.title, + text: toastDrilldownsDeleted.text(drilldownIds.length), + } + ); + }); + } + + return { drilldowns, isLoading, createDrilldown, editDrilldown, deleteDrilldown }; +} diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts new file mode 100644 index 0000000000000..31384860786ef --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts @@ -0,0 +1,88 @@ +/* + * 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 toastDrilldownCreated = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle', + { + defaultMessage: 'Drilldown created', + } + ), + text: (drilldownName: string) => + i18n.translate('xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText', { + defaultMessage: 'You created "{drilldownName}". Save dashboard before testing.', + values: { + drilldownName, + }, + }), +}; + +export const toastDrilldownEdited = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle', + { + defaultMessage: 'Drilldown edited', + } + ), + text: (drilldownName: string) => + i18n.translate('xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText', { + defaultMessage: 'You edited "{drilldownName}". Save dashboard before testing.', + values: { + drilldownName, + }, + }), +}; + +export const toastDrilldownDeleted = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle', + { + defaultMessage: 'Drilldown deleted', + } + ), + text: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText', + { + defaultMessage: 'You deleted a drilldown.', + } + ), +}; + +export const toastDrilldownsDeleted = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle', + { + defaultMessage: 'Drilldowns deleted', + } + ), + text: (n: number) => + i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText', + { + defaultMessage: 'You deleted {n} drilldowns', + values: { + n, + }, + } + ), +}; + +export const toastDrilldownsCRUDError = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle', + { + defaultMessage: 'Error saving drilldown', + description: 'Title for generic error toast when persisting drilldown updates failed', + } +); + +export const toastDrilldownsFetchError = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsFetchErrorTitle', + { + defaultMessage: 'Error fetching drilldowns', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts new file mode 100644 index 0000000000000..f084a3e563c23 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts @@ -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 './connected_flyout_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts new file mode 100644 index 0000000000000..47a04222286cb --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts @@ -0,0 +1,89 @@ +/* + * 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 uuid from 'uuid'; +import { + UiActionsEnhancedDynamicActionManager as DynamicActionManager, + UiActionsEnhancedDynamicActionManagerState as DynamicActionManagerState, + UiActionsEnhancedSerializedAction, +} from '../../../../advanced_ui_actions/public'; +import { TriggerContextMapping } from '../../../../../../src/plugins/ui_actions/public'; +import { createStateContainer } from '../../../../../../src/plugins/kibana_utils/common'; + +class MockDynamicActionManager implements PublicMethodsOf { + public readonly state = createStateContainer({ + isFetchingEvents: false, + fetchCount: 0, + events: [], + }); + + async count() { + return this.state.get().events.length; + } + + async list() { + return this.state.get().events; + } + + async createEvent( + action: UiActionsEnhancedSerializedAction, + triggers: Array + ) { + const event = { + action, + triggers, + eventId: uuid(), + }; + const state = this.state.get(); + this.state.set({ + ...state, + events: [...state.events, event], + }); + } + + async deleteEvents(eventIds: string[]) { + const state = this.state.get(); + let events = state.events; + + eventIds.forEach(id => { + events = events.filter(e => e.eventId !== id); + }); + + this.state.set({ + ...state, + events, + }); + } + + async updateEvent( + eventId: string, + action: UiActionsEnhancedSerializedAction, + triggers: Array + ) { + const state = this.state.get(); + const events = state.events; + const idx = events.findIndex(e => e.eventId === eventId); + const event = { + eventId, + action, + triggers, + }; + + this.state.set({ + ...state, + events: [...events.slice(0, idx), event, ...events.slice(idx + 1)], + }); + } + + async deleteEvent() { + throw new Error('not implemented'); + } + + async start() {} + async stop() {} +} + +export const mockDynamicActionManager = (new MockDynamicActionManager() as unknown) as DynamicActionManager; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx index 7a9e19342f27c..c4a4630397f1c 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx @@ -8,6 +8,16 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; import { DrilldownHelloBar } from '.'; -storiesOf('components/DrilldownHelloBar', module).add('default', () => { - return ; -}); +const Demo = () => { + const [show, setShow] = React.useState(true); + return show ? ( + { + setShow(false); + }} + /> + ) : null; +}; + +storiesOf('components/DrilldownHelloBar', module).add('default', () => ); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx index 1ef714f7b86e2..48e17dadc810f 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx @@ -5,22 +5,58 @@ */ import React from 'react'; +import { + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiTextColor, + EuiText, + EuiLink, + EuiSpacer, + EuiButtonEmpty, + EuiIcon, +} from '@elastic/eui'; +import { txtHideHelpButtonLabel, txtHelpText, txtViewDocsLinkLabel } from './i18n'; export interface DrilldownHelloBarProps { docsLink?: string; + onHideClick?: () => void; } -/** - * @todo https://github.com/elastic/kibana/issues/55311 - */ -export const DrilldownHelloBar: React.FC = ({ docsLink }) => { +export const WELCOME_MESSAGE_TEST_SUBJ = 'drilldownsWelcomeMessage'; + +export const DrilldownHelloBar: React.FC = ({ + docsLink, + onHideClick = () => {}, +}) => { return ( -
-

- Drilldowns provide the ability to define a new behavior when interacting with a panel. You - can add multiple options or simply override the default filtering behavior. -

- View docs -
+ + +
+ +
+
+ + + {txtHelpText} + + {docsLink && ( + <> + + {txtViewDocsLinkLabel} + + )} + + + + {txtHideHelpButtonLabel} + + + + } + /> ); }; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts new file mode 100644 index 0000000000000..63dc95dabc0fb --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts @@ -0,0 +1,29 @@ +/* + * 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 txtHelpText = i18n.translate( + 'xpack.drilldowns.components.DrilldownHelloBar.helpText', + { + defaultMessage: + 'Drilldowns provide the ability to define a new behavior when interacting with a panel. You can add multiple options or simply override the default filtering behavior.', + } +); + +export const txtViewDocsLinkLabel = i18n.translate( + 'xpack.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel', + { + defaultMessage: 'View docs', + } +); + +export const txtHideHelpButtonLabel = i18n.translate( + 'xpack.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel', + { + defaultMessage: 'Hide', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx deleted file mode 100644 index 3748fc666c81c..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; - -// eslint-disable-next-line -export interface DrilldownPickerProps {} - -export const DrilldownPicker: React.FC = () => { - return ( - - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx deleted file mode 100644 index 4f024b7d9cd6a..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { EuiFlyout } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import { FlyoutCreateDrilldown } from '.'; - -storiesOf('components/FlyoutCreateDrilldown', module) - .add('default', () => { - return ; - }) - .add('open in flyout', () => { - return ( - - - - ); - }); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx deleted file mode 100644 index b45ac9197c7e0..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { EuiButton } from '@elastic/eui'; -import { FormCreateDrilldown } from '../form_create_drilldown'; -import { FlyoutFrame } from '../flyout_frame'; -import { txtCreateDrilldown } from './i18n'; -import { FlyoutCreateDrilldownActionContext } from '../../actions'; - -export interface FlyoutCreateDrilldownProps { - context: FlyoutCreateDrilldownActionContext; - onClose?: () => void; -} - -export const FlyoutCreateDrilldown: React.FC = ({ - context, - onClose, -}) => { - const footer = ( - {}} fill> - {txtCreateDrilldown} - - ); - - return ( - - - - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx new file mode 100644 index 0000000000000..152cd393b9d3e --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx @@ -0,0 +1,70 @@ +/* + * 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. + */ + +/* eslint-disable no-console */ + +import * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { FlyoutDrilldownWizard } from '.'; +import { + dashboardFactory, + urlFactory, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; + +storiesOf('components/FlyoutDrilldownWizard', module) + .add('default', () => { + return ; + }) + .add('open in flyout - create', () => { + return ( + {}}> + {}} + drilldownActionFactories={[urlFactory, dashboardFactory]} + /> + + ); + }) + .add('open in flyout - edit', () => { + return ( + {}}> + {}} + drilldownActionFactories={[urlFactory, dashboardFactory]} + initialDrilldownWizardConfig={{ + name: 'My fancy drilldown', + actionFactory: urlFactory as any, + actionConfig: { + url: 'https://elastic.co', + openInNewTab: true, + }, + }} + mode={'edit'} + /> + + ); + }) + .add('open in flyout - edit, just 1 action type', () => { + return ( + {}}> + {}} + drilldownActionFactories={[dashboardFactory]} + initialDrilldownWizardConfig={{ + name: 'My fancy drilldown', + actionFactory: urlFactory as any, + actionConfig: { + url: 'https://elastic.co', + openInNewTab: true, + }, + }} + mode={'edit'} + /> + + ); + }); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx new file mode 100644 index 0000000000000..8541aae06ff0c --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx @@ -0,0 +1,140 @@ +/* + * 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 } from 'react'; +import { EuiButton, EuiSpacer } from '@elastic/eui'; +import { FormDrilldownWizard } from '../form_drilldown_wizard'; +import { FlyoutFrame } from '../flyout_frame'; +import { + txtCreateDrilldownButtonLabel, + txtCreateDrilldownTitle, + txtDeleteDrilldownButtonLabel, + txtEditDrilldownButtonLabel, + txtEditDrilldownTitle, +} from './i18n'; +import { DrilldownHelloBar } from '../drilldown_hello_bar'; +import { AdvancedUiActionsActionFactory as ActionFactory } from '../../../../advanced_ui_actions/public'; + +export interface DrilldownWizardConfig { + name: string; + actionFactory?: ActionFactory; + actionConfig?: ActionConfig; +} + +export interface FlyoutDrilldownWizardProps { + drilldownActionFactories: Array>; + + onSubmit?: (drilldownWizardConfig: Required) => void; + onDelete?: () => void; + onClose?: () => void; + onBack?: () => void; + + mode?: 'create' | 'edit'; + initialDrilldownWizardConfig?: DrilldownWizardConfig; + + showWelcomeMessage?: boolean; + onWelcomeHideClick?: () => void; + + actionFactoryContext?: object; +} + +export function FlyoutDrilldownWizard({ + onClose, + onBack, + onSubmit = () => {}, + initialDrilldownWizardConfig, + mode = 'create', + onDelete = () => {}, + showWelcomeMessage = true, + onWelcomeHideClick, + drilldownActionFactories, + actionFactoryContext, +}: FlyoutDrilldownWizardProps) { + const [wizardConfig, setWizardConfig] = useState( + () => + initialDrilldownWizardConfig ?? { + name: '', + } + ); + + const isActionValid = ( + config: DrilldownWizardConfig + ): config is Required => { + if (!wizardConfig.name) return false; + if (!wizardConfig.actionFactory) return false; + if (!wizardConfig.actionConfig) return false; + + return wizardConfig.actionFactory.isConfigValid(wizardConfig.actionConfig); + }; + + const footer = ( + { + if (isActionValid(wizardConfig)) { + onSubmit(wizardConfig); + } + }} + fill + isDisabled={!isActionValid(wizardConfig)} + data-test-subj={'drilldownWizardSubmit'} + > + {mode === 'edit' ? txtEditDrilldownButtonLabel : txtCreateDrilldownButtonLabel} + + ); + + return ( + } + > + { + setWizardConfig({ + ...wizardConfig, + name: newName, + }); + }} + actionConfig={wizardConfig.actionConfig} + onActionConfigChange={newActionConfig => { + setWizardConfig({ + ...wizardConfig, + actionConfig: newActionConfig, + }); + }} + currentActionFactory={wizardConfig.actionFactory} + onActionFactoryChange={actionFactory => { + if (!actionFactory) { + setWizardConfig({ + ...wizardConfig, + actionFactory: undefined, + actionConfig: undefined, + }); + } else { + setWizardConfig({ + ...wizardConfig, + actionFactory, + actionConfig: actionFactory.createConfig(), + }); + } + }} + actionFactories={drilldownActionFactories} + actionFactoryContext={actionFactoryContext!} + /> + {mode === 'edit' && ( + <> + + + {txtDeleteDrilldownButtonLabel} + + + )} + + ); +} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts new file mode 100644 index 0000000000000..a4a2754a444ab --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts @@ -0,0 +1,42 @@ +/* + * 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 txtCreateDrilldownTitle = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle', + { + defaultMessage: 'Create Drilldown', + } +); + +export const txtEditDrilldownTitle = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle', + { + defaultMessage: 'Edit Drilldown', + } +); + +export const txtCreateDrilldownButtonLabel = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownButtonLabel', + { + defaultMessage: 'Create drilldown', + } +); + +export const txtEditDrilldownButtonLabel = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownButtonLabel', + { + defaultMessage: 'Save', + } +); + +export const txtDeleteDrilldownButtonLabel = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel', + { + defaultMessage: 'Delete drilldown', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts new file mode 100644 index 0000000000000..96ed23bf112c9 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts @@ -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 './flyout_drilldown_wizard'; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx index 2715637f6392f..cb223db556f56 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx @@ -21,6 +21,13 @@ storiesOf('components/FlyoutFrame', module) .add('with onClose', () => { return console.log('onClose')}>test; }) + .add('with onBack', () => { + return ( + console.log('onClose')} title={'Title'}> + test + + ); + }) .add('custom footer', () => { return click me!}>test; }) diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx index b5fb52fcf5c18..0a3989487745f 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx @@ -6,9 +6,11 @@ import React from 'react'; import { render } from 'react-dom'; -import { render as renderTestingLibrary, fireEvent } from '@testing-library/react'; +import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure'; import { FlyoutFrame } from '.'; +afterEach(cleanup); + describe('', () => { test('renders without crashing', () => { const div = document.createElement('div'); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx index 2945cfd739482..b55cbd88d0dc0 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx @@ -13,13 +13,16 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, + EuiButtonIcon, } from '@elastic/eui'; -import { txtClose } from './i18n'; +import { txtClose, txtBack } from './i18n'; export interface FlyoutFrameProps { title?: React.ReactNode; footer?: React.ReactNode; + banner?: React.ReactNode; onClose?: () => void; + onBack?: () => void; } /** @@ -30,11 +33,31 @@ export const FlyoutFrame: React.FC = ({ footer, onClose, children, + onBack, + banner, }) => { - const headerFragment = title && ( + const headerFragment = (title || onBack) && ( -

{title}

+ + {onBack && ( + +
+ +
+
+ )} + {title && ( + +

{title}

+
+ )} +
); @@ -64,7 +87,7 @@ export const FlyoutFrame: React.FC = ({ return ( <> {headerFragment} - {children} + {children} {footerFragment} ); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts index 257d7d36dbee1..23af89ebf9bc7 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts @@ -6,6 +6,10 @@ import { i18n } from '@kbn/i18n'; -export const txtClose = i18n.translate('xpack.drilldowns.components.FlyoutFrame.Close', { +export const txtClose = i18n.translate('xpack.drilldowns.components.FlyoutFrame.CloseButtonLabel', { defaultMessage: 'Close', }); + +export const txtBack = i18n.translate('xpack.drilldowns.components.FlyoutFrame.BackButtonLabel', { + defaultMessage: 'Back', +}); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx new file mode 100644 index 0000000000000..0529f0451b16a --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx @@ -0,0 +1,22 @@ +/* + * 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 * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { FlyoutListManageDrilldowns } from './flyout_list_manage_drilldowns'; + +storiesOf('components/FlyoutListManageDrilldowns', module).add('default', () => ( + {}}> + + +)); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx new file mode 100644 index 0000000000000..a44a7ccccb4dc --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx @@ -0,0 +1,46 @@ +/* + * 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 { FlyoutFrame } from '../flyout_frame'; +import { DrilldownListItem, ListManageDrilldowns } from '../list_manage_drilldowns'; +import { txtManageDrilldowns } from './i18n'; +import { DrilldownHelloBar } from '../drilldown_hello_bar'; + +export interface FlyoutListManageDrilldownsProps { + drilldowns: DrilldownListItem[]; + onClose?: () => void; + onCreate?: () => void; + onEdit?: (drilldownId: string) => void; + onDelete?: (drilldownIds: string[]) => void; + showWelcomeMessage?: boolean; + onWelcomeHideClick?: () => void; +} + +export function FlyoutListManageDrilldowns({ + drilldowns, + onClose = () => {}, + onCreate, + onDelete, + onEdit, + showWelcomeMessage = true, + onWelcomeHideClick, +}: FlyoutListManageDrilldownsProps) { + return ( + } + > + + + ); +} diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts similarity index 52% rename from x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx rename to x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts index 5627a5d6f4522..0dd4e37d4dddd 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { DrilldownPicker } from '.'; +import { i18n } from '@kbn/i18n'; -storiesOf('components/DrilldownPicker', module).add('default', () => { - return ; -}); +export const txtManageDrilldowns = i18n.translate( + 'xpack.drilldowns.components.FlyoutListManageDrilldowns.manageDrilldownsTitle', + { + defaultMessage: 'Manage Drilldowns', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts new file mode 100644 index 0000000000000..f8c9d224fb292 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts @@ -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 './flyout_list_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx deleted file mode 100644 index e7e1d67473e8c..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { EuiFlyout } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import { FormCreateDrilldown } from '.'; - -const DemoEditName: React.FC = () => { - const [name, setName] = React.useState(''); - - return ; -}; - -storiesOf('components/FormCreateDrilldown', module) - .add('default', () => { - return ; - }) - .add('[name=foobar]', () => { - return ; - }) - .add('can edit name', () => ) - .add('open in flyout', () => { - return ( - - - - ); - }); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx deleted file mode 100644 index 4422de604092b..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { EuiForm, EuiFormRow, EuiFieldText } from '@elastic/eui'; -import { DrilldownHelloBar } from '../drilldown_hello_bar'; -import { txtNameOfDrilldown, txtUntitledDrilldown, txtDrilldownAction } from './i18n'; -import { DrilldownPicker } from '../drilldown_picker'; - -const noop = () => {}; - -export interface FormCreateDrilldownProps { - name?: string; - onNameChange?: (name: string) => void; -} - -export const FormCreateDrilldown: React.FC = ({ - name = '', - onNameChange = noop, -}) => { - const nameFragment = ( - - onNameChange(event.target.value)} - data-test-subj="dynamicActionNameInput" - /> - - ); - - const triggerPicker =
Trigger Picker will be here
; - const actionPicker = ( - - - - ); - - return ( - <> - - {nameFragment} - {triggerPicker} - {actionPicker} - - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx new file mode 100644 index 0000000000000..2fc35eb6b5298 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx @@ -0,0 +1,29 @@ +/* + * 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 * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { FormDrilldownWizard } from '.'; + +const DemoEditName: React.FC = () => { + const [name, setName] = React.useState(''); + + return ( + <> + {' '} +
name: {name}
+ + ); +}; + +storiesOf('components/FormDrilldownWizard', module) + .add('default', () => { + return ; + }) + .add('[name=foobar]', () => { + return ; + }) + .add('can edit name', () => ); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx similarity index 60% rename from x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx rename to x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx index 6691966e47e64..d9c53ae6f737a 100644 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx @@ -6,41 +6,39 @@ import React from 'react'; import { render } from 'react-dom'; -import { FormCreateDrilldown } from '.'; -import { render as renderTestingLibrary, fireEvent } from '@testing-library/react'; +import { FormDrilldownWizard } from './form_drilldown_wizard'; +import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure'; import { txtNameOfDrilldown } from './i18n'; -describe('', () => { +afterEach(cleanup); + +describe('', () => { test('renders without crashing', () => { const div = document.createElement('div'); - render( {}} />, div); + render( {}} actionFactoryContext={{}} />, div); }); describe('[name=]', () => { test('if name not provided, uses to empty string', () => { const div = document.createElement('div'); - render(, div); + render(, div); - const input = div.querySelector( - '[data-test-subj="dynamicActionNameInput"]' - ) as HTMLInputElement; + const input = div.querySelector('[data-test-subj="drilldownNameInput"]') as HTMLInputElement; expect(input?.value).toBe(''); }); - test('can set name input field value', () => { + test('can set initial name input field value', () => { const div = document.createElement('div'); - render(, div); + render(, div); - const input = div.querySelector( - '[data-test-subj="dynamicActionNameInput"]' - ) as HTMLInputElement; + const input = div.querySelector('[data-test-subj="drilldownNameInput"]') as HTMLInputElement; expect(input?.value).toBe('foo'); - render(, div); + render(, div); expect(input?.value).toBe('bar'); }); @@ -48,7 +46,7 @@ describe('', () => { test('fires onNameChange callback on name change', () => { const onNameChange = jest.fn(); const utils = renderTestingLibrary( - + ); const input = utils.getByLabelText(txtNameOfDrilldown); diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx new file mode 100644 index 0000000000000..93b3710bf6cc6 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx @@ -0,0 +1,79 @@ +/* + * 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 { EuiFieldText, EuiForm, EuiFormRow, EuiSpacer } from '@elastic/eui'; +import { txtDrilldownAction, txtNameOfDrilldown, txtUntitledDrilldown } from './i18n'; +import { + AdvancedUiActionsActionFactory as ActionFactory, + ActionWizard, +} from '../../../../advanced_ui_actions/public'; + +const noopFn = () => {}; + +export interface FormDrilldownWizardProps { + name?: string; + onNameChange?: (name: string) => void; + + currentActionFactory?: ActionFactory; + onActionFactoryChange?: (actionFactory: ActionFactory | null) => void; + actionFactoryContext: object; + + actionConfig?: object; + onActionConfigChange?: (config: object) => void; + + actionFactories?: ActionFactory[]; +} + +export const FormDrilldownWizard: React.FC = ({ + name = '', + actionConfig, + currentActionFactory, + onNameChange = noopFn, + onActionConfigChange = noopFn, + onActionFactoryChange = noopFn, + actionFactories = [], + actionFactoryContext, +}) => { + const nameFragment = ( + + onNameChange(event.target.value)} + data-test-subj="drilldownNameInput" + /> + + ); + + const actionWizard = ( + 1 ? txtDrilldownAction : undefined} + fullWidth={true} + > + onActionFactoryChange(actionFactory)} + onConfigChange={config => onActionConfigChange(config)} + context={actionFactoryContext} + /> + + ); + + return ( + <> + + {nameFragment} + + {actionWizard} + + + ); +}; diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts similarity index 89% rename from x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts rename to x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts index 4c0e287935edd..e9b19ab0afa97 100644 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export const txtNameOfDrilldown = i18n.translate( 'xpack.drilldowns.components.FormCreateDrilldown.nameOfDrilldown', { - defaultMessage: 'Name of drilldown', + defaultMessage: 'Name', } ); @@ -23,6 +23,6 @@ export const txtUntitledDrilldown = i18n.translate( export const txtDrilldownAction = i18n.translate( 'xpack.drilldowns.components.FormCreateDrilldown.drilldownAction', { - defaultMessage: 'Drilldown action', + defaultMessage: 'Action', } ); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx similarity index 85% rename from x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx rename to x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx index c2c5a7e435b39..4aea824de00d7 100644 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './form_create_drilldown'; +export * from './form_drilldown_wizard'; diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts new file mode 100644 index 0000000000000..fbc7c9dcfb4a1 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts @@ -0,0 +1,36 @@ +/* + * 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 txtCreateDrilldown = i18n.translate( + 'xpack.drilldowns.components.ListManageDrilldowns.createDrilldownButtonLabel', + { + defaultMessage: 'Create new', + } +); + +export const txtEditDrilldown = i18n.translate( + 'xpack.drilldowns.components.ListManageDrilldowns.editDrilldownButtonLabel', + { + defaultMessage: 'Edit', + } +); + +export const txtDeleteDrilldowns = (count: number) => + i18n.translate('xpack.drilldowns.components.ListManageDrilldowns.deleteDrilldownsButtonLabel', { + defaultMessage: 'Delete ({count})', + values: { + count, + }, + }); + +export const txtSelectDrilldown = i18n.translate( + 'xpack.drilldowns.components.ListManageDrilldowns.selectThisDrilldownCheckboxLabel', + { + defaultMessage: 'Select this drilldown', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx new file mode 100644 index 0000000000000..82b6ce27af6d4 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx @@ -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 './list_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx new file mode 100644 index 0000000000000..eafe50bab2016 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx @@ -0,0 +1,19 @@ +/* + * 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 * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { ListManageDrilldowns } from './list_manage_drilldowns'; + +storiesOf('components/ListManageDrilldowns', module).add('default', () => ( + +)); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx new file mode 100644 index 0000000000000..4a4d67b08b1d3 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx @@ -0,0 +1,70 @@ +/* + * 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 { cleanup, fireEvent, render } from '@testing-library/react/pure'; +import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global +import { + DrilldownListItem, + ListManageDrilldowns, + TEST_SUBJ_DRILLDOWN_ITEM, +} from './list_manage_drilldowns'; + +// TODO: for some reason global cleanup from RTL doesn't work +// afterEach is not available for it globally during setup +afterEach(cleanup); + +const drilldowns: DrilldownListItem[] = [ + { id: '1', actionName: 'Dashboard', drilldownName: 'Drilldown 1' }, + { id: '2', actionName: 'Dashboard', drilldownName: 'Drilldown 2' }, + { id: '3', actionName: 'Dashboard', drilldownName: 'Drilldown 3' }, +]; + +test('Render list of drilldowns', () => { + const screen = render(); + expect(screen.getAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(drilldowns.length); +}); + +test('Emit onEdit() when clicking on edit drilldown', () => { + const fn = jest.fn(); + const screen = render(); + + const editButtons = screen.getAllByText('Edit'); + expect(editButtons).toHaveLength(drilldowns.length); + fireEvent.click(editButtons[1]); + expect(fn).toBeCalledWith(drilldowns[1].id); +}); + +test('Emit onCreate() when clicking on create drilldown', () => { + const fn = jest.fn(); + const screen = render(); + fireEvent.click(screen.getByText('Create new')); + expect(fn).toBeCalled(); +}); + +test('Delete button is not visible when non is selected', () => { + const fn = jest.fn(); + const screen = render(); + expect(screen.queryByText(/Delete/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Create/i)).toBeInTheDocument(); +}); + +test('Can delete drilldowns', () => { + const fn = jest.fn(); + const screen = render(); + + const checkboxes = screen.getAllByLabelText(/Select this drilldown/i); + expect(checkboxes).toHaveLength(3); + + fireEvent.click(checkboxes[1]); + fireEvent.click(checkboxes[2]); + + expect(screen.queryByText(/Create/i)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText(/Delete \(2\)/i)); + + expect(fn).toBeCalledWith([drilldowns[1].id, drilldowns[2].id]); +}); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx new file mode 100644 index 0000000000000..ab51c0a829ed3 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx @@ -0,0 +1,122 @@ +/* + * 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 { + EuiBasicTable, + EuiBasicTableColumn, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiTextColor, +} from '@elastic/eui'; +import React, { useState } from 'react'; +import { + txtCreateDrilldown, + txtDeleteDrilldowns, + txtEditDrilldown, + txtSelectDrilldown, +} from './i18n'; + +export interface DrilldownListItem { + id: string; + actionName: string; + drilldownName: string; + icon?: string; +} + +export interface ListManageDrilldownsProps { + drilldowns: DrilldownListItem[]; + + onEdit?: (id: string) => void; + onCreate?: () => void; + onDelete?: (ids: string[]) => void; +} + +const noop = () => {}; + +export const TEST_SUBJ_DRILLDOWN_ITEM = 'listManageDrilldownsItem'; + +export function ListManageDrilldowns({ + drilldowns, + onEdit = noop, + onCreate = noop, + onDelete = noop, +}: ListManageDrilldownsProps) { + const [selectedDrilldowns, setSelectedDrilldowns] = useState([]); + + const columns: Array> = [ + { + field: 'drilldownName', + name: 'Name', + truncateText: true, + width: '50%', + 'data-test-subj': 'drilldownListItemName', + }, + { + name: 'Action', + render: (drilldown: DrilldownListItem) => ( + + {drilldown.icon && ( + + + + )} + + {drilldown.actionName} + + + ), + }, + { + align: 'right', + render: (drilldown: DrilldownListItem) => ( + onEdit(drilldown.id)}> + {txtEditDrilldown} + + ), + }, + ]; + + return ( + <> + { + setSelectedDrilldowns(selection.map(drilldown => drilldown.id)); + }, + selectableMessage: () => txtSelectDrilldown, + }} + rowProps={{ + 'data-test-subj': TEST_SUBJ_DRILLDOWN_ITEM, + }} + hasActions={true} + /> + + {selectedDrilldowns.length === 0 ? ( + onCreate()}> + {txtCreateDrilldown} + + ) : ( + onDelete(selectedDrilldowns)} + data-test-subj={'listManageDeleteDrilldowns'} + > + {txtDeleteDrilldowns(selectedDrilldowns.length)} + + )} + + ); +} diff --git a/x-pack/plugins/drilldowns/public/index.ts b/x-pack/plugins/drilldowns/public/index.ts index 63e7a12235462..f976356822dce 100644 --- a/x-pack/plugins/drilldowns/public/index.ts +++ b/x-pack/plugins/drilldowns/public/index.ts @@ -7,10 +7,10 @@ import { DrilldownsPlugin } from './plugin'; export { - DrilldownsSetupContract, - DrilldownsSetupDependencies, - DrilldownsStartContract, - DrilldownsStartDependencies, + SetupContract as DrilldownsSetup, + SetupDependencies as DrilldownsSetupDependencies, + StartContract as DrilldownsStart, + StartDependencies as DrilldownsStartDependencies, } from './plugin'; export function plugin() { diff --git a/x-pack/plugins/drilldowns/public/mocks.ts b/x-pack/plugins/drilldowns/public/mocks.ts index bfade1674072a..18816243a3572 100644 --- a/x-pack/plugins/drilldowns/public/mocks.ts +++ b/x-pack/plugins/drilldowns/public/mocks.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DrilldownsSetupContract, DrilldownsStartContract } from '.'; +import { DrilldownsSetup, DrilldownsStart } from '.'; -export type Setup = jest.Mocked; -export type Start = jest.Mocked; +export type Setup = jest.Mocked; +export type Start = jest.Mocked; const createSetupContract = (): Setup => { const setupContract: Setup = { @@ -17,12 +17,14 @@ const createSetupContract = (): Setup => { }; const createStartContract = (): Start => { - const startContract: Start = {}; + const startContract: Start = { + FlyoutManageDrilldowns: jest.fn(), + }; return startContract; }; -export const bfetchPluginMock = { +export const drilldownsPluginMock = { createSetupContract, createStartContract, }; diff --git a/x-pack/plugins/drilldowns/public/plugin.ts b/x-pack/plugins/drilldowns/public/plugin.ts index b89172541b91e..0108e04df9c99 100644 --- a/x-pack/plugins/drilldowns/public/plugin.ts +++ b/x-pack/plugins/drilldowns/public/plugin.ts @@ -6,52 +6,41 @@ import { CoreStart, CoreSetup, Plugin } from 'src/core/public'; import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; -import { DrilldownService } from './service'; -import { - FlyoutCreateDrilldownActionContext, - FlyoutEditDrilldownActionContext, - OPEN_FLYOUT_ADD_DRILLDOWN, - OPEN_FLYOUT_EDIT_DRILLDOWN, -} from './actions'; - -export interface DrilldownsSetupDependencies { +import { AdvancedUiActionsSetup, AdvancedUiActionsStart } from '../../advanced_ui_actions/public'; +import { createFlyoutManageDrilldowns } from './components/connected_flyout_manage_drilldowns'; +import { Storage } from '../../../../src/plugins/kibana_utils/public'; + +export interface SetupDependencies { uiActions: UiActionsSetup; + advancedUiActions: AdvancedUiActionsSetup; } -export interface DrilldownsStartDependencies { +export interface StartDependencies { uiActions: UiActionsStart; + advancedUiActions: AdvancedUiActionsStart; } -export type DrilldownsSetupContract = Pick; - // eslint-disable-next-line -export interface DrilldownsStartContract {} +export interface SetupContract {} -declare module '../../../../src/plugins/ui_actions/public' { - export interface ActionContextMapping { - [OPEN_FLYOUT_ADD_DRILLDOWN]: FlyoutCreateDrilldownActionContext; - [OPEN_FLYOUT_EDIT_DRILLDOWN]: FlyoutEditDrilldownActionContext; - } +export interface StartContract { + FlyoutManageDrilldowns: ReturnType; } export class DrilldownsPlugin - implements - Plugin< - DrilldownsSetupContract, - DrilldownsStartContract, - DrilldownsSetupDependencies, - DrilldownsStartDependencies - > { - private readonly service = new DrilldownService(); - - public setup(core: CoreSetup, plugins: DrilldownsSetupDependencies): DrilldownsSetupContract { - this.service.bootstrap(core, plugins); - - return this.service; + implements Plugin { + public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { + return {}; } - public start(core: CoreStart, plugins: DrilldownsStartDependencies): DrilldownsStartContract { - return {}; + public start(core: CoreStart, plugins: StartDependencies): StartContract { + return { + FlyoutManageDrilldowns: createFlyoutManageDrilldowns({ + advancedUiActions: plugins.advancedUiActions, + storage: new Storage(localStorage), + notifications: core.notifications, + }), + }; } public stop() {} diff --git a/x-pack/plugins/drilldowns/public/service/drilldown_service.ts b/x-pack/plugins/drilldowns/public/service/drilldown_service.ts deleted file mode 100644 index 7745c30b4e335..0000000000000 --- a/x-pack/plugins/drilldowns/public/service/drilldown_service.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { CoreSetup } from 'src/core/public'; -// import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public'; -import { FlyoutCreateDrilldownAction, FlyoutEditDrilldownAction } from '../actions'; -import { DrilldownsSetupDependencies } from '../plugin'; - -export class DrilldownService { - bootstrap(core: CoreSetup, { uiActions }: DrilldownsSetupDependencies) { - const overlays = async () => (await core.getStartServices())[0].overlays; - - const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ overlays }); - uiActions.registerAction(actionFlyoutCreateDrilldown); - // uiActions.attachAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown); - - const actionFlyoutEditDrilldown = new FlyoutEditDrilldownAction({ overlays }); - uiActions.registerAction(actionFlyoutEditDrilldown); - // uiActions.attachAction(CONTEXT_MENU_TRIGGER, actionFlyoutEditDrilldown); - } - - /** - * Convenience method to register a drilldown. (It should set-up all the - * necessary triggers and actions.) - */ - registerDrilldown = (): void => { - throw new Error('not implemented'); - }; -} diff --git a/x-pack/plugins/embeddable_enhanced/README.md b/x-pack/plugins/embeddable_enhanced/README.md new file mode 100644 index 0000000000000..a0be90731fdb0 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/README.md @@ -0,0 +1 @@ +# X-Pack part of `embeddable` plugin diff --git a/x-pack/plugins/embeddable_enhanced/kibana.json b/x-pack/plugins/embeddable_enhanced/kibana.json new file mode 100644 index 0000000000000..780a1d5d89870 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/kibana.json @@ -0,0 +1,7 @@ +{ + "id": "embeddableEnhanced", + "version": "kibana", + "server": false, + "ui": true, + "requiredPlugins": ["embeddable", "advancedUiActions"] +} diff --git a/x-pack/plugins/embeddable_enhanced/public/actions/index.ts b/x-pack/plugins/embeddable_enhanced/public/actions/index.ts new file mode 100644 index 0000000000000..b47abd48fd269 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/actions/index.ts @@ -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 './panel_notifications_action'; diff --git a/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.test.ts b/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.test.ts new file mode 100644 index 0000000000000..839379387e094 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.test.ts @@ -0,0 +1,75 @@ +/* + * 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 { PanelNotificationsAction } from './panel_notifications_action'; +import { EnhancedEmbeddableContext } from '../types'; +import { ViewMode } from '../../../../../src/plugins/embeddable/public'; + +const createContext = (events: unknown[] = [], isEditMode = false): EnhancedEmbeddableContext => + ({ + embeddable: { + getInput: () => + ({ + viewMode: isEditMode ? ViewMode.EDIT : ViewMode.VIEW, + } as unknown), + enhancements: { + dynamicActions: { + state: { + get: () => + ({ + events, + } as unknown), + }, + }, + }, + }, + } as EnhancedEmbeddableContext); + +describe('PanelNotificationsAction', () => { + describe('getDisplayName', () => { + test('returns "0" if embeddable has no events', async () => { + const context = createContext(); + const action = new PanelNotificationsAction(); + + const name = await action.getDisplayName(context); + expect(name).toBe('0'); + }); + + test('returns "2" if embeddable has two events', async () => { + const context = createContext([{}, {}]); + const action = new PanelNotificationsAction(); + + const name = await action.getDisplayName(context); + expect(name).toBe('2'); + }); + }); + + describe('isCompatible', () => { + test('returns false if not in "edit" mode', async () => { + const context = createContext([{}]); + const action = new PanelNotificationsAction(); + + const result = await action.isCompatible(context); + expect(result).toBe(false); + }); + + test('returns true when in "edit" mode', async () => { + const context = createContext([{}], true); + const action = new PanelNotificationsAction(); + + const result = await action.isCompatible(context); + expect(result).toBe(true); + }); + + test('returns false when no embeddable has no events', async () => { + const context = createContext([], true); + const action = new PanelNotificationsAction(); + + const result = await action.isCompatible(context); + expect(result).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.ts b/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.ts new file mode 100644 index 0000000000000..19e0ac2a5a6d8 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.ts @@ -0,0 +1,34 @@ +/* + * 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 } from '../../../../../src/plugins/ui_actions/public'; +import { ViewMode } from '../../../../../src/plugins/embeddable/public'; +import { EnhancedEmbeddableContext, EnhancedEmbeddable } from '../types'; + +export const ACTION_PANEL_NOTIFICATIONS = 'ACTION_PANEL_NOTIFICATIONS'; + +/** + * This action renders in "edit" mode number of events (dynamic action) a panel + * has attached to it. + */ +export class PanelNotificationsAction implements ActionDefinition { + public readonly id = ACTION_PANEL_NOTIFICATIONS; + + private getEventCount(embeddable: EnhancedEmbeddable): number { + return embeddable.enhancements.dynamicActions.state.get().events.length; + } + + public readonly getDisplayName = ({ embeddable }: EnhancedEmbeddableContext) => { + return String(this.getEventCount(embeddable)); + }; + + public readonly isCompatible = async ({ embeddable }: EnhancedEmbeddableContext) => { + if (embeddable.getInput().viewMode !== ViewMode.EDIT) return false; + return this.getEventCount(embeddable) > 0; + }; + + public readonly execute = async () => {}; +} diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts b/x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.test.ts similarity index 72% rename from src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts rename to x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.test.ts index ddd84b0544345..f8b3a9dfb92d0 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts +++ b/x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.test.ts @@ -1,29 +1,18 @@ /* - * 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. + * 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 { Embeddable } from './embeddable'; -import { EmbeddableInput } from './i_embeddable'; -import { ViewMode } from '../types'; -import { EmbeddableActionStorage, SerializedEvent } from './embeddable_action_storage'; -import { of } from '../../../../kibana_utils/public'; +import { Embeddable, ViewMode } from '../../../../../src/plugins/embeddable/public'; +import { + EmbeddableActionStorage, + EmbeddableWithDynamicActionsInput, +} from './embeddable_action_storage'; +import { UiActionsEnhancedSerializedEvent } from '../../../advanced_ui_actions/public'; +import { of } from '../../../../../src/plugins/kibana_utils/public'; -class TestEmbeddable extends Embeddable { +class TestEmbeddable extends Embeddable { public readonly type = 'test'; constructor() { super({ id: 'test', viewMode: ViewMode.VIEW }, {}); @@ -42,62 +31,79 @@ describe('EmbeddableActionStorage', () => { test('can add event to embeddable', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const events1 = embeddable.getInput().events || []; + const events1 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events1).toEqual([]); await storage.create(event); - const events2 = embeddable.getInput().events || []; + const events2 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events2).toEqual([event]); }); + test('does not merge .getInput() into .updateInput()', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + const event: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + + const spy = jest.spyOn(embeddable, 'updateInput'); + + await storage.create(event); + + expect(spy.mock.calls[0][0].id).toBe(undefined); + expect(spy.mock.calls[0][0].viewMode).toBe(undefined); + }); + test('can create multiple events', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event3: SerializedEvent = { + const event3: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const events1 = embeddable.getInput().events || []; + const events1 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events1).toEqual([]); await storage.create(event1); - const events2 = embeddable.getInput().events || []; + const events2 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events2).toEqual([event1]); await storage.create(event2); await storage.create(event3); - const events3 = embeddable.getInput().events || []; + const events3 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events3).toEqual([event1, event2, event3]); }); test('throws when creating an event with the same ID', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -122,16 +128,16 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'foo', } as any, }; - const event2: SerializedEvent = { + const event2: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'bar', } as any, @@ -140,7 +146,7 @@ describe('EmbeddableActionStorage', () => { await storage.create(event1); await storage.update(event2); - const events = embeddable.getInput().events || []; + const events = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events).toEqual([event2]); }); @@ -148,30 +154,30 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'foo', } as any, }; - const event2: SerializedEvent = { + const event2: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'bar', } as any, }; - const event22: SerializedEvent = { + const event22: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'baz', } as any, }; - const event3: SerializedEvent = { + const event3: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'qux', } as any, @@ -181,17 +187,17 @@ describe('EmbeddableActionStorage', () => { await storage.create(event2); await storage.create(event3); - const events1 = embeddable.getInput().events || []; + const events1 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events1).toEqual([event1, event2, event3]); await storage.update(event22); - const events2 = embeddable.getInput().events || []; + const events2 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events2).toEqual([event1, event22, event3]); await storage.update(event2); - const events3 = embeddable.getInput().events || []; + const events3 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events3).toEqual([event1, event2, event3]); }); @@ -199,9 +205,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -217,14 +223,14 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -249,16 +255,16 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; await storage.create(event); await storage.remove(event.eventId); - const events = embeddable.getInput().events || []; + const events = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events).toEqual([]); }); @@ -266,23 +272,23 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'foo', } as any, }; - const event2: SerializedEvent = { + const event2: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'bar', } as any, }; - const event3: SerializedEvent = { + const event3: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'qux', } as any, @@ -292,22 +298,22 @@ describe('EmbeddableActionStorage', () => { await storage.create(event2); await storage.create(event3); - const events1 = embeddable.getInput().events || []; + const events1 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events1).toEqual([event1, event2, event3]); await storage.remove(event2.eventId); - const events2 = embeddable.getInput().events || []; + const events2 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events2).toEqual([event1, event3]); await storage.remove(event3.eventId); - const events3 = embeddable.getInput().events || []; + const events3 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events3).toEqual([event1]); await storage.remove(event1.eventId); - const events4 = embeddable.getInput().events || []; + const events4 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events4).toEqual([]); }); @@ -327,9 +333,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -355,9 +361,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -383,9 +389,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -402,19 +408,19 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID2', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event3: SerializedEvent = { + const event3: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID3', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -458,7 +464,7 @@ describe('EmbeddableActionStorage', () => { await storage.create({ eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }); @@ -466,7 +472,7 @@ describe('EmbeddableActionStorage', () => { await storage.create({ eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }); @@ -502,15 +508,15 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }; diff --git a/x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.ts b/x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.ts new file mode 100644 index 0000000000000..dcb44323f6d11 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.ts @@ -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 { + UiActionsEnhancedAbstractActionStorage as AbstractActionStorage, + UiActionsEnhancedSerializedEvent as SerializedEvent, +} from '../../../advanced_ui_actions/public'; +import { + EmbeddableInput, + EmbeddableOutput, + IEmbeddable, +} from '../../../../../src/plugins/embeddable/public'; + +export interface EmbeddableWithDynamicActionsInput extends EmbeddableInput { + enhancements?: { + dynamicActions?: { + events: SerializedEvent[]; + }; + }; +} + +export type EmbeddableWithDynamicActions< + I extends EmbeddableWithDynamicActionsInput = EmbeddableWithDynamicActionsInput, + O extends EmbeddableOutput = EmbeddableOutput +> = IEmbeddable; + +export class EmbeddableActionStorage extends AbstractActionStorage { + constructor(private readonly embbeddable: EmbeddableWithDynamicActions) { + super(); + } + + private put(input: EmbeddableWithDynamicActionsInput, events: SerializedEvent[]) { + this.embbeddable.updateInput({ + enhancements: { + ...(input.enhancements || {}), + dynamicActions: { + ...(input.enhancements?.dynamicActions || {}), + events, + }, + }, + }); + } + + public async create(event: SerializedEvent) { + const input = this.embbeddable.getInput(); + const events = input.enhancements?.dynamicActions?.events || []; + 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.put(input, [...events, event]); + } + + public async update(event: SerializedEvent) { + const input = this.embbeddable.getInput(); + const events = input.enhancements?.dynamicActions?.events || []; + 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.put(input, [...events.slice(0, index), event, ...events.slice(index + 1)]); + } + + public async remove(eventId: string) { + const input = this.embbeddable.getInput(); + const events = input.enhancements?.dynamicActions?.events || []; + 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.put(input, [...events.slice(0, index), ...events.slice(index + 1)]); + } + + public async read(eventId: string): Promise { + const input = this.embbeddable.getInput(); + const events = input.enhancements?.dynamicActions?.events || []; + 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; + } + + public async list(): Promise { + const input = this.embbeddable.getInput(); + const events = input.enhancements?.dynamicActions?.events || []; + return events; + } +} diff --git a/x-pack/plugins/embeddable_enhanced/public/embeddables/index.ts b/x-pack/plugins/embeddable_enhanced/public/embeddables/index.ts new file mode 100644 index 0000000000000..fabbc60a13f67 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/embeddables/index.ts @@ -0,0 +1,8 @@ +/* + * 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 './is_enhanced_embeddable'; +export * from './embeddable_action_storage'; diff --git a/x-pack/plugins/embeddable_enhanced/public/embeddables/is_enhanced_embeddable.ts b/x-pack/plugins/embeddable_enhanced/public/embeddables/is_enhanced_embeddable.ts new file mode 100644 index 0000000000000..f29430dc6a3de --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/embeddables/is_enhanced_embeddable.ts @@ -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 { IEmbeddable } from '../../../../../src/plugins/embeddable/public'; +import { EnhancedEmbeddable } from '../types'; + +export const isEnhancedEmbeddable = ( + maybeEnhancedEmbeddable: E +): maybeEnhancedEmbeddable is EnhancedEmbeddable => + typeof (maybeEnhancedEmbeddable as EnhancedEmbeddable) + ?.enhancements?.dynamicActions === 'object'; diff --git a/x-pack/plugins/embeddable_enhanced/public/index.ts b/x-pack/plugins/embeddable_enhanced/public/index.ts new file mode 100644 index 0000000000000..059acf9644820 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/index.ts @@ -0,0 +1,22 @@ +/* + * 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 { PluginInitializerContext } from 'src/core/public'; +import { EmbeddableEnhancedPlugin } from './plugin'; + +export { + SetupContract as EmbeddableEnhancedSetupContract, + SetupDependencies as EmbeddableEnhancedSetupDependencies, + StartContract as EmbeddableEnhancedStartContract, + StartDependencies as EmbeddableEnhancedStartDependencies, +} from './plugin'; + +export function plugin(context: PluginInitializerContext) { + return new EmbeddableEnhancedPlugin(context); +} + +export { EnhancedEmbeddable, EnhancedEmbeddableContext } from './types'; +export { isEnhancedEmbeddable } from './embeddables'; diff --git a/x-pack/plugins/embeddable_enhanced/public/mocks.ts b/x-pack/plugins/embeddable_enhanced/public/mocks.ts new file mode 100644 index 0000000000000..d048d1248b6ff --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/mocks.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EmbeddableEnhancedSetupContract, EmbeddableEnhancedStartContract } from '.'; + +export type Setup = jest.Mocked; +export type Start = jest.Mocked; + +const createSetupContract = (): Setup => { + const setupContract: Setup = {}; + + return setupContract; +}; + +const createStartContract = (): Start => { + const startContract: Start = {}; + + return startContract; +}; + +export const embeddableEnhancedPluginMock = { + createSetupContract, + createStartContract, +}; diff --git a/x-pack/plugins/embeddable_enhanced/public/plugin.ts b/x-pack/plugins/embeddable_enhanced/public/plugin.ts new file mode 100644 index 0000000000000..d48c4f9e860cc --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/plugin.ts @@ -0,0 +1,160 @@ +/* + * 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 { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; +import { SavedObjectAttributes } from 'kibana/public'; +import { + EmbeddableFactory, + EmbeddableFactoryDefinition, + EmbeddableInput, + EmbeddableOutput, + EmbeddableSetup, + EmbeddableStart, + IEmbeddable, + defaultEmbeddableFactoryProvider, + EmbeddableContext, + PANEL_NOTIFICATION_TRIGGER, +} from '../../../../src/plugins/embeddable/public'; +import { EnhancedEmbeddable, EnhancedEmbeddableContext } from './types'; +import { + EmbeddableActionStorage, + EmbeddableWithDynamicActions, +} from './embeddables/embeddable_action_storage'; +import { + UiActionsEnhancedDynamicActionManager as DynamicActionManager, + AdvancedUiActionsSetup, + AdvancedUiActionsStart, +} from '../../advanced_ui_actions/public'; +import { PanelNotificationsAction, ACTION_PANEL_NOTIFICATIONS } from './actions'; + +declare module '../../../../src/plugins/ui_actions/public' { + export interface ActionContextMapping { + [ACTION_PANEL_NOTIFICATIONS]: EnhancedEmbeddableContext; + } +} + +export interface SetupDependencies { + embeddable: EmbeddableSetup; + advancedUiActions: AdvancedUiActionsSetup; +} + +export interface StartDependencies { + embeddable: EmbeddableStart; + advancedUiActions: AdvancedUiActionsStart; +} + +// eslint-disable-next-line +export interface SetupContract {} + +// eslint-disable-next-line +export interface StartContract {} + +export class EmbeddableEnhancedPlugin + implements Plugin { + constructor(protected readonly context: PluginInitializerContext) {} + + private uiActions?: StartDependencies['advancedUiActions']; + + public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { + this.setCustomEmbeddableFactoryProvider(plugins); + + const panelNotificationAction = new PanelNotificationsAction(); + plugins.advancedUiActions.registerAction(panelNotificationAction); + plugins.advancedUiActions.attachAction(PANEL_NOTIFICATION_TRIGGER, panelNotificationAction.id); + + return {}; + } + + public start(core: CoreStart, plugins: StartDependencies): StartContract { + this.uiActions = plugins.advancedUiActions; + + return {}; + } + + public stop() {} + + private setCustomEmbeddableFactoryProvider(plugins: SetupDependencies) { + plugins.embeddable.setCustomEmbeddableFactoryProvider( + < + I extends EmbeddableInput = EmbeddableInput, + O extends EmbeddableOutput = EmbeddableOutput, + E extends IEmbeddable = IEmbeddable, + T extends SavedObjectAttributes = SavedObjectAttributes + >( + def: EmbeddableFactoryDefinition + ): EmbeddableFactory => { + const factory: EmbeddableFactory = defaultEmbeddableFactoryProvider( + def + ); + return { + ...factory, + create: async (...args) => { + const embeddable = await factory.create(...args); + if (!embeddable) return embeddable; + return this.enhanceEmbeddableWithDynamicActions(embeddable); + }, + createFromSavedObject: async (...args) => { + const embeddable = await factory.createFromSavedObject(...args); + if (!embeddable) return embeddable; + return this.enhanceEmbeddableWithDynamicActions(embeddable); + }, + }; + } + ); + } + + private enhanceEmbeddableWithDynamicActions( + embeddable: E + ): EnhancedEmbeddable { + const enhancedEmbeddable = embeddable as EnhancedEmbeddable; + + const storage = new EmbeddableActionStorage(embeddable as EmbeddableWithDynamicActions); + const dynamicActions = new DynamicActionManager({ + isCompatible: async (context: unknown) => { + if (!(context as EmbeddableContext)?.embeddable) { + // eslint-disable-next-line no-console + console.warn('For drilldowns to work action context should contain .embeddable field.'); + return false; + } + + return (context as EmbeddableContext).embeddable.runtimeId === embeddable.runtimeId; + }, + storage, + uiActions: this.uiActions!, + }); + + dynamicActions.start().catch(error => { + /* eslint-disable */ + console.log('Failed to start embeddable dynamic actions', embeddable); + console.error(error); + /* eslint-enable */ + }); + + const stop = () => { + dynamicActions.stop().catch(error => { + /* eslint-disable */ + console.log('Failed to stop embeddable dynamic actions', embeddable); + console.error(error); + /* eslint-enable */ + }); + }; + + embeddable.getInput$().subscribe({ + next: () => { + storage.reload$.next(); + }, + error: stop, + complete: stop, + }); + + enhancedEmbeddable.enhancements = { + ...enhancedEmbeddable.enhancements, + dynamicActions, + }; + + return enhancedEmbeddable; + } +} diff --git a/x-pack/plugins/embeddable_enhanced/public/types.ts b/x-pack/plugins/embeddable_enhanced/public/types.ts new file mode 100644 index 0000000000000..924605be332b2 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/types.ts @@ -0,0 +1,21 @@ +/* + * 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 { IEmbeddable } from '../../../../src/plugins/embeddable/public'; +import { UiActionsEnhancedDynamicActionManager as DynamicActionManager } from '../../advanced_ui_actions/public'; + +export type EnhancedEmbeddable = E & { + enhancements: { + /** + * Default implementation of dynamic action manager for embeddables. + */ + dynamicActions: DynamicActionManager; + }; +}; + +export interface EnhancedEmbeddableContext { + embeddable: EnhancedEmbeddable; +} diff --git a/x-pack/plugins/reporting/public/plugin.tsx b/x-pack/plugins/reporting/public/plugin.tsx index c40e7ad373eaf..66366cc0b520d 100644 --- a/x-pack/plugins/reporting/public/plugin.tsx +++ b/x-pack/plugins/reporting/public/plugin.tsx @@ -143,8 +143,7 @@ export class ReportingPublicPlugin implements Plugin { }, }); - uiActions.registerAction(action); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); share.register(csvReportingProvider({ apiClient, toasts, license$ })); share.register( diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d1270ea92c51e..481dfffd2e3a0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -886,7 +886,6 @@ "embeddableApi.addPanel.noMatchingObjectsMessage": "一致するオブジェクトが見つかりませんでした。", "embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} が追加されました", "embeddableApi.addPanel.Title": "パネルの追加", - "embeddableApi.customizePanel.action.displayName": "パネルをカスタマイズ", "embeddableApi.customizePanel.modal.cancel": "キャンセル", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleFormRowLabel": "パネルタイトル", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleInputAriaLabel": "パネルのカスタムタイトルを入力してください", @@ -6250,13 +6249,9 @@ "xpack.data.kueryAutocomplete.orOperatorDescription.oneOrMoreArgumentsText": "1つ以上の引数", "xpack.data.query.queryBar.cancelLongQuery": "キャンセル", "xpack.data.query.queryBar.runBeyond": "タイムアウトを越えて実行", - "xpack.drilldowns.components.FlyoutCreateDrilldown.CreateDrilldown": "ドリルダウンを作成", - "xpack.drilldowns.components.FlyoutFrame.Close": "閉じる", "xpack.drilldowns.components.FormCreateDrilldown.drilldownAction": "ドリルダウンアクション", "xpack.drilldowns.components.FormCreateDrilldown.nameOfDrilldown": "ドリルダウンの名前", "xpack.drilldowns.components.FormCreateDrilldown.untitledDrilldown": "無題のドリルダウン", - "xpack.drilldowns.FlyoutCreateDrilldownAction.displayName": "ドリルダウンを作成", - "xpack.drilldowns.panel.openFlyoutEditDrilldown.displayName": "ドリルダウンを管理", "xpack.endpoint.alertList.viewTitle": "アラートは有効な Rison エンコード文字列でなければなりません", "xpack.endpoint.alerts.errors.bad_rison": "", "xpack.endpoint.alerts.errors.before_cannot_be_used_with_after": "[before] を [after] と併用することはできません", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 32c91a6ef2931..ca0e070c9bfd4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -887,7 +887,6 @@ "embeddableApi.addPanel.noMatchingObjectsMessage": "未找到任何匹配对象。", "embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} 已添加", "embeddableApi.addPanel.Title": "添加面板", - "embeddableApi.customizePanel.action.displayName": "定制面板", "embeddableApi.customizePanel.modal.cancel": "取消", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleFormRowLabel": "面板标题", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleInputAriaLabel": "为面板输入定制标题", @@ -6255,13 +6254,9 @@ "xpack.data.kueryAutocomplete.orOperatorDescription.oneOrMoreArgumentsText": "一个或多个参数", "xpack.data.query.queryBar.cancelLongQuery": "取消", "xpack.data.query.queryBar.runBeyond": "运行超时", - "xpack.drilldowns.components.FlyoutCreateDrilldown.CreateDrilldown": "创建向下钻取", - "xpack.drilldowns.components.FlyoutFrame.Close": "关闭", "xpack.drilldowns.components.FormCreateDrilldown.drilldownAction": "向下钻取操作", "xpack.drilldowns.components.FormCreateDrilldown.nameOfDrilldown": "向下钻取的名称", "xpack.drilldowns.components.FormCreateDrilldown.untitledDrilldown": "未命名向下钻取", - "xpack.drilldowns.FlyoutCreateDrilldownAction.displayName": "创建向下钻取", - "xpack.drilldowns.panel.openFlyoutEditDrilldown.displayName": "管理向下钻取", "xpack.endpoint.alertList.viewTitle": "告警", "xpack.endpoint.alerts.errors.bad_rison": "必须是有效的 rison 编码字符串", "xpack.endpoint.alerts.errors.before_cannot_be_used_with_after": "[before] 不能与 [after] 一起使用", diff --git a/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_drilldowns.ts b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_drilldowns.ts new file mode 100644 index 0000000000000..1a90d5d1fe52a --- /dev/null +++ b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_drilldowns.ts @@ -0,0 +1,176 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +const DASHBOARD_WITH_PIE_CHART_NAME = 'Dashboard with Pie Chart'; +const DASHBOARD_WITH_AREA_CHART_NAME = 'Dashboard With Area Chart'; + +const DRILLDOWN_TO_PIE_CHART_NAME = 'Go to pie chart dashboard'; +const DRILLDOWN_TO_AREA_CHART_NAME = 'Go to area chart dashboard'; + +export default function({ getService, getPageObjects }: FtrProviderContext) { + const dashboardPanelActions = getService('dashboardPanelActions'); + const dashboardDrilldownPanelActions = getService('dashboardDrilldownPanelActions'); + const dashboardDrilldownsManage = getService('dashboardDrilldownsManage'); + const PageObjects = getPageObjects(['dashboard', 'common', 'header', 'timePicker']); + const kibanaServer = getService('kibanaServer'); + const esArchiver = getService('esArchiver'); + const pieChart = getService('pieChart'); + const log = getService('log'); + const browser = getService('browser'); + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + const filterBar = getService('filterBar'); + + describe('Dashboard Drilldowns', function() { + before(async () => { + log.debug('Dashboard Drilldowns:initTests'); + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.load('dashboard/drilldowns'); + await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash-*' }); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + }); + + after(async () => { + await esArchiver.unload('dashboard/drilldowns'); + }); + + it('should create dashboard to dashboard drilldown, use it, and then delete it', async () => { + await PageObjects.dashboard.gotoDashboardEditMode(DASHBOARD_WITH_PIE_CHART_NAME); + + // create drilldown + await dashboardPanelActions.openContextMenu(); + await dashboardDrilldownPanelActions.expectExistsCreateDrilldownAction(); + await dashboardDrilldownPanelActions.clickCreateDrilldown(); + await dashboardDrilldownsManage.expectsCreateDrilldownFlyoutOpen(); + await dashboardDrilldownsManage.fillInDashboardToDashboardDrilldownWizard({ + drilldownName: DRILLDOWN_TO_AREA_CHART_NAME, + destinationDashboardTitle: DASHBOARD_WITH_AREA_CHART_NAME, + }); + await dashboardDrilldownsManage.saveChanges(); + await dashboardDrilldownsManage.expectsCreateDrilldownFlyoutClose(); + + // check that drilldown notification badge is shown + expect(await PageObjects.dashboard.getPanelDrilldownCount()).to.be(1); + + // save dashboard, navigate to view mode + await PageObjects.dashboard.saveDashboard(DASHBOARD_WITH_PIE_CHART_NAME, { + saveAsNew: false, + waitDialogIsClosed: true, + }); + + // trigger drilldown action by clicking on a pie and picking drilldown action by it's name + await pieChart.filterOnPieSlice('40,000'); + await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened(); + + const href = await dashboardDrilldownPanelActions.getActionHrefByText( + DRILLDOWN_TO_AREA_CHART_NAME + ); + expect(typeof href).to.be('string'); // checking that action has a href + const dashboardIdFromHref = PageObjects.dashboard.getDashboardIdFromUrl(href); + + await navigateWithinDashboard(async () => { + await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_AREA_CHART_NAME); + }); + // checking that href is at least pointing to the same dashboard that we are navigated to by regular click + expect(dashboardIdFromHref).to.be(await PageObjects.dashboard.getDashboardIdFromCurrentUrl()); + + // check that we drilled-down with filter from pie chart + expect(await filterBar.getFilterCount()).to.be(1); + + const originalTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours(); + + // brush area chart and drilldown back to pie chat dashboard + await brushAreaChart(); + await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened(); + + await navigateWithinDashboard(async () => { + await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_PIE_CHART_NAME); + }); + + // because filters are preserved during navigation, we expect that only one slice is displayed (filter is still applied) + expect(await filterBar.getFilterCount()).to.be(1); + await pieChart.expectPieSliceCount(1); + + // check that new time range duration was applied + const newTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours(); + expect(newTimeRangeDurationHours).to.be.lessThan(originalTimeRangeDurationHours); + + // delete drilldown + await PageObjects.dashboard.switchToEditMode(); + await dashboardPanelActions.openContextMenu(); + await dashboardDrilldownPanelActions.expectExistsManageDrilldownsAction(); + await dashboardDrilldownPanelActions.clickManageDrilldowns(); + await dashboardDrilldownsManage.expectsManageDrilldownsFlyoutOpen(); + + await dashboardDrilldownsManage.deleteDrilldownsByTitles([DRILLDOWN_TO_AREA_CHART_NAME]); + await dashboardDrilldownsManage.closeFlyout(); + + // check that drilldown notification badge is shown + expect(await PageObjects.dashboard.getPanelDrilldownCount()).to.be(0); + }); + + it('browser back/forward navigation works after drilldown navigation', async () => { + await PageObjects.dashboard.loadSavedDashboard(DASHBOARD_WITH_AREA_CHART_NAME); + const originalTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours(); + await brushAreaChart(); + await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened(); + await navigateWithinDashboard(async () => { + await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_PIE_CHART_NAME); + }); + // check that new time range duration was applied + const newTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours(); + expect(newTimeRangeDurationHours).to.be.lessThan(originalTimeRangeDurationHours); + + await navigateWithinDashboard(async () => { + await browser.goBack(); + }); + + expect(await PageObjects.timePicker.getTimeDurationInHours()).to.be( + originalTimeRangeDurationHours + ); + }); + }); + + // utils which shouldn't be a part of test flow, but also too specific to be moved to pageobject or service + async function brushAreaChart() { + const areaChart = await testSubjects.find('visualizationLoader'); + expect(await areaChart.getAttribute('data-title')).to.be('Visualization漢字 AreaChart'); + await browser.dragAndDrop( + { + location: areaChart, + offset: { + x: -100, + y: 0, + }, + }, + { + location: areaChart, + offset: { + x: 100, + y: 0, + }, + } + ); + } + + async function navigateWithinDashboard(navigationTrigger: () => Promise) { + // before executing action which would trigger navigation: remember current dashboard id in url + const oldDashboardId = await PageObjects.dashboard.getDashboardIdFromCurrentUrl(); + // execute navigation action + await navigationTrigger(); + // wait until dashboard navigates to a new dashboard with area chart + await retry.waitFor('navigate to different dashboard', async () => { + const newDashboardId = await PageObjects.dashboard.getDashboardIdFromCurrentUrl(); + return typeof newDashboardId === 'string' && oldDashboardId !== newDashboardId; + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.waitForRenderComplete(); + } +} diff --git a/x-pack/test/functional/apps/dashboard/drilldowns/index.ts b/x-pack/test/functional/apps/dashboard/drilldowns/index.ts new file mode 100644 index 0000000000000..ab273018dc3f7 --- /dev/null +++ b/x-pack/test/functional/apps/dashboard/drilldowns/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function({ loadTestFile }: FtrProviderContext) { + describe('drilldowns', function() { + this.tags(['skipFirefox']); + loadTestFile(require.resolve('./dashboard_drilldowns')); + }); +} diff --git a/x-pack/test/functional/apps/dashboard/index.ts b/x-pack/test/functional/apps/dashboard/index.ts index 23825836caad3..2c8ac93c53fef 100644 --- a/x-pack/test/functional/apps/dashboard/index.ts +++ b/x-pack/test/functional/apps/dashboard/index.ts @@ -12,5 +12,6 @@ export default function({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./preserve_url')); loadTestFile(require.resolve('./reporting')); + loadTestFile(require.resolve('./drilldowns')); }); } diff --git a/x-pack/test/functional/es_archives/dashboard/drilldowns/data.json.gz b/x-pack/test/functional/es_archives/dashboard/drilldowns/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..a9b23ca7a579bdd2de633c67e386a31313162e2c GIT binary patch literal 2662 zcmV-s3YqmEiwFoonWQOZ*BnXT-|cpxD~$7Q?R=1L^3JKlBKISwl{U_ z*d9rn%*OU`AQBYOkOY?iEh`>9M|<7-zCfo>)0y@;dH{kHDanqr>jV7i;;teF7{9waCx6-id=jrT3esV z6h$bfGnyvOstZFsULpX=#mq@a%n6M|7ZaZ_1O9Oz8)_IsKJ`1*t9&Rzp=9=0F``)tU;pu+fBx;?fB#GK7;!W~(*S?F zK5{N9;}n9fksnjrIuG)mZ1gd@#qP!Q&)DJbF-Mhd1XCC#jz5;H{c(J8E_%Da&Sbc5 z46hpjoiUD>-~9b`~94$-ybHW3up6!Y^whEn+F-LqQ%Cg*eixZY<%qmj}lg}v8<*-tS zb38XHV@sig(PT3hV@pzu5)`sb2Qf}A(M;T7nb5?1I=@5-Cny-vxwywe0mG^VEJ7T1 z$Pa_y4Bz6QXcrnnvzfTQ66SE_h*p&Who%viB#9x zxO2UyY3h8&d_zEw;2`U0y5N|}b`pUtA%uY3HccTnzT+5t$hD`yq^p=%-atzLF8bsvxjj3fIFshiQj6c9pg#s>+p4CfowJ6yj<$!Dq691~Q^ z`{_hP53isF-bP(0rtgXzIP9o6-6t{#6MVl>4kw?fSsbHnO zuPASWRH)a=ug_@`3ZbwlpH{=na{+K9${M!>GHH^l*(ZaoR;OjkTLQGm zV)wd{5j?>@mjn_*owXMWuM?(8Pz2?EBC)4XC;4wom zz6fwO&~;O)}JvxKiH0lC_;yIF^i;(>DLoUt2-}F4L8*!adpJ?9RcJTIrPqbIhO#V|pjmNqIbbCk zGuSHMBl3=vJFgU^T?-DtACOikFMVAt0vXzq8vHBQx|5ycNpQ= z7VX+!jedqEuAPgA-aRxLP0n*@R*YQQ*O`qUNCd)tYcjW>Q8{)tp;zUgOnfp6Ipu3= zc=~jh_K23aqO*<^U1-*VT(M)iCz&+YlJ+x|0?!vCWfZF)w$@{AauZhcmQdT4fXb^& zc1(#{me_f{0-DhbG;&r+aaA9u$vs*wIBQQx8>=(5;EKgGV!GLQ6Jo~n7Y+FY7_Cvo zyEds_AI367)@-pmrqfh%s#1FdZ^aeOnUW=r<|Q-zzF8FCwLvxAC@&_rH7~E!FIs_* z8Z2-_z-pgns-11h%YwRBv+Fc}yyZ=?cD5@fxa+4j-3dCcw(F;vI?NodeK@aU?P|5< zN7w3W_ExC-GZp!1nn#jt-=@{)lh4{gUu|JIf0vEZX+W5-dx_Xy|I}m0v%>cCe4qPk zIA9J<@UKUjZ)4%6QQ3GB;vdAP=I3g=@d|$%+xDlFzIK0bLvr=2_V2O@>5^dG`yAI; zc*)z4wu!pKeMm9MV&s{&GZJ=8qm-{gQvWXGuWx}VE#sB;5_ZixZA4aQ1)PycdvLDv z@PeaL9Tc!t;vEL5*c$wtQ7TRj$Tk(0o@F7W+!vyjHwepsh-n~8mtkd>^46`(E>vZ* zkGGi5F^(?9(qUR+0HG`%?wgEqJKpq31&8bvIzKH(BeoLCTQVrKK z24$-xytWG+X*8t_=FJ`qHszfjE3#5*YO7XX0tbN2TjYusEMqu+r*u& z)oTw>dw_f2jz&fQk3vm3qRm zGU5F*5|x6~CoaB35e_CI{#WxpWnp8{UUYP|b~Hu5)mKMTi1QlM85(epsNvdOF~qex zY(v1RcmZ9g|4CLa_Bwrc-!q%2k9*B-ue0Agu&~?g47~Q#LdfZP-Arc5$7zAjPkT^! zYBzdx`ZjUd?x zc*{|mh|@#Brg#K?h*xg2YWzVu;%d?sGy@z%p`yk+J{Dk%w z#m94Tw#ZF(yoj{#2z%$n?lwWN!MUo_kO}q@%={dvM2rb11)kuiWN?Ke=2~J6@ec_N zg&(&~YrkoN3I-FaKkRgemf30#`k&S(JfZXhaf+_1jT2h#s@<8G=Fl7rt$xcoFxR?L zIsG^{i1CqdU#4?oBcGR2_QuR { + log.debug(`getActionWebElement: "${text}"`); + const menu = await testSubjects.find('multipleActionsContextMenu'); + const items = await menu.findAllByCssSelector('[data-test-subj*="embeddablePanelAction-"]'); + for (const item of items) { + const currentText = await item.getVisibleText(); + if (currentText === text) { + return item; + } + } + + throw new Error(`No action matching text "${text}"`); + } + })(); +} diff --git a/x-pack/test/functional/services/index.ts b/x-pack/test/functional/services/index.ts index aec91ba9e9034..f1d84f3054aa0 100644 --- a/x-pack/test/functional/services/index.ts +++ b/x-pack/test/functional/services/index.ts @@ -49,6 +49,10 @@ import { InfraSourceConfigurationFormProvider } from './infra_source_configurati import { LogsUiProvider } from './logs_ui'; import { MachineLearningProvider } from './ml'; import { TransformProvider } from './transform'; +import { + DashboardDrilldownPanelActionsProvider, + DashboardDrilldownsManageProvider, +} from './dashboard'; // define the name and providers for services that should be // available to your tests. If you don't specify anything here @@ -91,4 +95,6 @@ export const services = { logsUi: LogsUiProvider, ml: MachineLearningProvider, transform: TransformProvider, + dashboardDrilldownPanelActions: DashboardDrilldownPanelActionsProvider, + dashboardDrilldownsManage: DashboardDrilldownsManageProvider, }; From a532a91d7515a1075d3df32dd9c1b20081e2a641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Mon, 4 May 2020 16:15:43 +0200 Subject: [PATCH 003/153] [APM] Fix duplicate index patterns (#64883) --- x-pack/plugins/apm/server/tutorial/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x-pack/plugins/apm/server/tutorial/index.ts b/x-pack/plugins/apm/server/tutorial/index.ts index d8cbff9a1c27d..76e2456afa5df 100644 --- a/x-pack/plugins/apm/server/tutorial/index.ts +++ b/x-pack/plugins/apm/server/tutorial/index.ts @@ -13,6 +13,7 @@ import { ArtifactsSchema, TutorialsCategory } from '../../../../../src/plugins/home/server'; +import { APM_STATIC_INDEX_PATTERN_ID } from '../../common/index_pattern_constants'; const apmIntro = i18n.translate('xpack.apm.tutorial.introduction', { defaultMessage: @@ -39,6 +40,7 @@ export const tutorialProvider = ({ const savedObjects = [ { ...apmIndexPattern, + id: APM_STATIC_INDEX_PATTERN_ID, attributes: { ...apmIndexPattern.attributes, title: indexPatternTitle From f8349f6ce0c6b8d932088455e79dc747766fa850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Mon, 4 May 2020 15:22:18 +0100 Subject: [PATCH 004/153] [APM] Agent remote config: validation for Java agent configs (#63956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * validating java settings * adding min max support to duration * Agent config cleanup * refactoring * refactoring * refactoring * fixing i18n * validating min and max bytes * refactoring * refactoring * refactoring * accept number and string on amountAndUnitToString Co-authored-by: Elastic Machine Co-authored-by: Søren Louv-Jansen --- .../agent_configuration/amount_and_unit.ts | 11 +- .../agent_configuration_intake_rt.ts | 4 +- .../runtime_types/bytes_rt.test.ts | 97 +++++++++--- .../runtime_types/bytes_rt.ts | 65 +++++--- .../runtime_types/duration_rt.test.ts | 148 ++++++++++++------ .../runtime_types/duration_rt.ts | 43 +++-- .../runtime_types/float_rt.test.ts | 36 +++++ .../runtime_types/float_rt.ts | 28 ++++ .../runtime_types/get_range_type_message.ts | 41 +++++ .../runtime_types/integer_rt.test.ts | 70 ++++++--- .../runtime_types/integer_rt.ts | 19 ++- .../runtime_types/number_float_rt.test.ts | 36 ----- .../runtime_types/number_float_rt.ts | 36 ----- .../__snapshots__/index.test.ts.snap | 35 ++--- .../setting_definitions/general_settings.ts | 19 +-- .../setting_definitions/index.ts | 110 +++++++------ .../setting_definitions/java_settings.ts | 7 +- .../setting_definitions/types.d.ts | 47 +++--- .../SettingsPage/SettingFormRow.tsx | 19 ++- .../SettingsPage/SettingsPage.tsx | 4 +- .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - 22 files changed, 539 insertions(+), 344 deletions(-) create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/float_rt.test.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/float_rt.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/get_range_type_message.ts delete mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.test.ts delete mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.ts diff --git a/x-pack/plugins/apm/common/agent_configuration/amount_and_unit.ts b/x-pack/plugins/apm/common/agent_configuration/amount_and_unit.ts index d6520ae150539..cd64b3025a65b 100644 --- a/x-pack/plugins/apm/common/agent_configuration/amount_and_unit.ts +++ b/x-pack/plugins/apm/common/agent_configuration/amount_and_unit.ts @@ -4,17 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -interface AmountAndUnit { - amount: string; +export interface AmountAndUnit { + amount: number; unit: string; } export function amountAndUnitToObject(value: string): AmountAndUnit { // matches any postive and negative number and its unit. const [, amount = '', unit = ''] = value.match(/(^-?\d+)?(\w+)?/) || []; - return { amount, unit }; + return { amount: parseInt(amount, 10), unit }; } -export function amountAndUnitToString({ amount, unit }: AmountAndUnit) { +export function amountAndUnitToString({ + amount, + unit +}: Omit & { amount: string | number }) { return `${amount}${unit}`; } diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.ts index a0b1d5015b9ef..e903a56486b6e 100644 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.ts +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.ts @@ -6,11 +6,11 @@ import * as t from 'io-ts'; import { settingDefinitions } from '../setting_definitions'; +import { SettingValidation } from '../setting_definitions/types'; // retrieve validation from config definitions settings and validate on the server const knownSettings = settingDefinitions.reduce< - // TODO: is it possible to get rid of any? - Record> + Record >((acc, { key, validation }) => { acc[key] = validation; return acc; diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.test.ts index 596037645c002..4d786605b00c7 100644 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.test.ts @@ -4,35 +4,88 @@ * you may not use this file except in compliance with the Elastic License. */ -import { bytesRt } from './bytes_rt'; +import { getBytesRt } from './bytes_rt'; import { isRight } from 'fp-ts/lib/Either'; +import { PathReporter } from 'io-ts/lib/PathReporter'; describe('bytesRt', () => { - describe('it should not accept', () => { - [ - undefined, - null, - '', - 0, - 'foo', - true, - false, - '100', - 'mb', - '0kb', - '5gb', - '6tb' - ].map(input => { - it(`${JSON.stringify(input)}`, () => { - expect(isRight(bytesRt.decode(input))).toBe(false); + describe('must accept any amount and unit', () => { + const bytesRt = getBytesRt({}); + describe('it should not accept', () => { + ['mb', 1, '1', '5gb', '6tb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(bytesRt.decode(input))).toBe(false); + }); + }); + }); + + describe('it should accept', () => { + ['-1b', '0mb', '1b', '2kb', '3mb', '1000mb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(bytesRt.decode(input))).toBe(true); + }); }); }); }); + describe('must be at least 0b', () => { + const bytesRt = getBytesRt({ + min: '0b' + }); + + describe('it should not accept', () => { + ['mb', '-1kb', '5gb', '6tb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(bytesRt.decode(input))).toBe(false); + }); + }); + }); + + describe('it should return correct error message', () => { + ['-1kb', '5gb', '6tb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + const result = bytesRt.decode(input); + const message = PathReporter.report(result)[0]; + expect(message).toEqual('Must be greater than 0b'); + expect(isRight(result)).toBeFalsy(); + }); + }); + }); - describe('it should accept', () => { - ['1b', '2kb', '3mb'].map(input => { - it(`${JSON.stringify(input)}`, () => { - expect(isRight(bytesRt.decode(input))).toBe(true); + describe('it should accept', () => { + ['1b', '2kb', '3mb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(bytesRt.decode(input))).toBe(true); + }); + }); + }); + }); + describe('must be between 500b and 1kb', () => { + const bytesRt = getBytesRt({ + min: '500b', + max: '1kb' + }); + describe('it should not accept', () => { + ['mb', '-1b', '1b', '499b', '1025b', '2kb', '1mb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(bytesRt.decode(input))).toBe(false); + }); + }); + }); + describe('it should return correct error message', () => { + ['-1b', '1b', '499b', '1025b', '2kb', '1mb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + const result = bytesRt.decode(input); + const message = PathReporter.report(result)[0]; + expect(message).toEqual('Must be between 500b and 1kb'); + expect(isRight(result)).toBeFalsy(); + }); + }); + }); + describe('it should accept', () => { + ['500b', '1024b', '1kb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(bytesRt.decode(input))).toBe(true); + }); }); }); }); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.ts index d189fab89ae5d..9f49527438b49 100644 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.ts +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.ts @@ -7,27 +7,50 @@ import * as t from 'io-ts'; import { either } from 'fp-ts/lib/Either'; import { amountAndUnitToObject } from '../amount_and_unit'; +import { getRangeTypeMessage } from './get_range_type_message'; -export const BYTE_UNITS = ['b', 'kb', 'mb']; +function toBytes(amount: number, unit: string) { + switch (unit) { + case 'b': + return amount; + case 'kb': + return amount * 2 ** 10; + case 'mb': + return amount * 2 ** 20; + } +} -export const bytesRt = new t.Type( - 'bytesRt', - t.string.is, - (input, context) => { - return either.chain(t.string.validate(input, context), inputAsString => { - const { amount, unit } = amountAndUnitToObject(inputAsString); - const amountAsInt = parseInt(amount, 10); - const isValidUnit = BYTE_UNITS.includes(unit); - const isValid = amountAsInt > 0 && isValidUnit; +function amountAndUnitToBytes(value?: string): number | undefined { + if (value) { + const { amount, unit } = amountAndUnitToObject(value); + if (isFinite(amount) && unit) { + return toBytes(amount, unit); + } + } +} - return isValid - ? t.success(inputAsString) - : t.failure( - input, - context, - `Must have numeric amount and a valid unit (${BYTE_UNITS})` - ); - }); - }, - t.identity -); +export function getBytesRt({ min, max }: { min?: string; max?: string }) { + const minAsBytes = amountAndUnitToBytes(min) ?? -Infinity; + const maxAsBytes = amountAndUnitToBytes(max) ?? Infinity; + const message = getRangeTypeMessage(min, max); + + return new t.Type( + 'bytesRt', + t.string.is, + (input, context) => { + return either.chain(t.string.validate(input, context), inputAsString => { + const inputAsBytes = amountAndUnitToBytes(inputAsString); + + const isValidAmount = + inputAsBytes !== undefined && + inputAsBytes >= minAsBytes && + inputAsBytes <= maxAsBytes; + + return isValidAmount + ? t.success(inputAsString) + : t.failure(input, context, message); + }); + }, + t.identity + ); +} diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.test.ts index 98d0cb5f028c3..ebfd9d9a72704 100644 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.test.ts @@ -4,62 +4,122 @@ * you may not use this file except in compliance with the Elastic License. */ -/* - * 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 { durationRt, getDurationRt } from './duration_rt'; +import { getDurationRt } from './duration_rt'; import { isRight } from 'fp-ts/lib/Either'; +import { PathReporter } from 'io-ts/lib/PathReporter'; -describe('durationRt', () => { - describe('it should not accept', () => { - [ - undefined, - null, - '', - 0, - 'foo', - true, - false, - '100', - 's', - 'm', - '0ms', - '-1ms' - ].map(input => { - it(`${JSON.stringify(input)}`, () => { - expect(isRight(durationRt.decode(input))).toBe(false); +describe('getDurationRt', () => { + describe('must be at least 1m', () => { + const customDurationRt = getDurationRt({ min: '1m' }); + describe('it should not accept', () => { + [ + undefined, + null, + '', + 0, + 'foo', + true, + false, + '0m', + '-1m', + '1ms', + '1s' + ].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(customDurationRt.decode(input))).toBeFalsy(); + }); }); }); - }); - - describe('it should accept', () => { - ['1000ms', '2s', '3m', '1s'].map(input => { - it(`${JSON.stringify(input)}`, () => { - expect(isRight(durationRt.decode(input))).toBe(true); + describe('it should return correct error message', () => { + ['0m', '-1m', '1ms', '1s'].map(input => { + it(`${JSON.stringify(input)}`, () => { + const result = customDurationRt.decode(input); + const message = PathReporter.report(result)[0]; + expect(message).toEqual('Must be greater than 1m'); + expect(isRight(result)).toBeFalsy(); + }); + }); + }); + describe('it should accept', () => { + ['1m', '2m', '1000m'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(customDurationRt.decode(input))).toBeTruthy(); + }); }); }); }); -}); -describe('getDurationRt', () => { - const customDurationRt = getDurationRt({ min: -1 }); - describe('it should not accept', () => { - [undefined, null, '', 0, 'foo', true, false, '100', 's', 'm', '-2ms'].map( - input => { + describe('must be between 1ms and 1s', () => { + const customDurationRt = getDurationRt({ min: '1ms', max: '1s' }); + + describe('it should not accept', () => { + [ + undefined, + null, + '', + 0, + 'foo', + true, + false, + '-1s', + '0s', + '2s', + '1001ms', + '0ms', + '-1ms', + '0m', + '1m' + ].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(customDurationRt.decode(input))).toBeFalsy(); + }); + }); + }); + describe('it should return correct error message', () => { + ['-1s', '0s', '2s', '1001ms', '0ms', '-1ms', '0m', '1m'].map(input => { + it(`${JSON.stringify(input)}`, () => { + const result = customDurationRt.decode(input); + const message = PathReporter.report(result)[0]; + expect(message).toEqual('Must be between 1ms and 1s'); + expect(isRight(result)).toBeFalsy(); + }); + }); + }); + describe('it should accept', () => { + ['1s', '1ms', '50ms', '1000ms'].map(input => { it(`${JSON.stringify(input)}`, () => { - expect(isRight(customDurationRt.decode(input))).toBe(false); + expect(isRight(customDurationRt.decode(input))).toBeTruthy(); }); - } - ); + }); + }); }); + describe('must be max 1m', () => { + const customDurationRt = getDurationRt({ max: '1m' }); - describe('it should accept', () => { - ['1000ms', '2s', '3m', '1s', '-1s', '0ms'].map(input => { - it(`${JSON.stringify(input)}`, () => { - expect(isRight(customDurationRt.decode(input))).toBe(true); + describe('it should not accept', () => { + [undefined, null, '', 0, 'foo', true, false, '2m', '61s', '60001ms'].map( + input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(customDurationRt.decode(input))).toBeFalsy(); + }); + } + ); + }); + describe('it should return correct error message', () => { + ['2m', '61s', '60001ms'].map(input => { + it(`${JSON.stringify(input)}`, () => { + const result = customDurationRt.decode(input); + const message = PathReporter.report(result)[0]; + expect(message).toEqual('Must be less than 1m'); + expect(isRight(result)).toBeFalsy(); + }); + }); + }); + describe('it should accept', () => { + ['1m', '0m', '-1m', '60s', '6000ms', '1ms', '1s'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(customDurationRt.decode(input))).toBeTruthy(); + }); }); }); }); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.ts index b691276854fb0..cede5ed262558 100644 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.ts +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.ts @@ -6,32 +6,45 @@ import * as t from 'io-ts'; import { either } from 'fp-ts/lib/Either'; -import { amountAndUnitToObject } from '../amount_and_unit'; +import moment, { unitOfTime } from 'moment'; +import { amountAndUnitToObject, AmountAndUnit } from '../amount_and_unit'; +import { getRangeTypeMessage } from './get_range_type_message'; -export const DURATION_UNITS = ['ms', 's', 'm']; +function toMilliseconds({ amount, unit }: AmountAndUnit) { + return moment.duration(amount, unit as unitOfTime.Base); +} + +function amountAndUnitToMilliseconds(value?: string) { + if (value) { + const { amount, unit } = amountAndUnitToObject(value); + if (isFinite(amount) && unit) { + return toMilliseconds({ amount, unit }); + } + } +} + +export function getDurationRt({ min, max }: { min?: string; max?: string }) { + const minAsMilliseconds = amountAndUnitToMilliseconds(min) ?? -Infinity; + const maxAsMilliseconds = amountAndUnitToMilliseconds(max) ?? Infinity; + const message = getRangeTypeMessage(min, max); -export function getDurationRt({ min }: { min: number }) { return new t.Type( 'durationRt', t.string.is, (input, context) => { return either.chain(t.string.validate(input, context), inputAsString => { - const { amount, unit } = amountAndUnitToObject(inputAsString); - const amountAsInt = parseInt(amount, 10); - const isValidUnit = DURATION_UNITS.includes(unit); - const isValid = amountAsInt >= min && isValidUnit; + const inputAsMilliseconds = amountAndUnitToMilliseconds(inputAsString); + + const isValidAmount = + inputAsMilliseconds !== undefined && + inputAsMilliseconds >= minAsMilliseconds && + inputAsMilliseconds <= maxAsMilliseconds; - return isValid + return isValidAmount ? t.success(inputAsString) - : t.failure( - input, - context, - `Must have numeric amount and a valid unit (${DURATION_UNITS})` - ); + : t.failure(input, context, message); }); }, t.identity ); } - -export const durationRt = getDurationRt({ min: 1 }); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/float_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/float_rt.test.ts new file mode 100644 index 0000000000000..82fb8ee068b30 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/float_rt.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { floatRt } from './float_rt'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('floatRt', () => { + it('does not accept empty values', () => { + expect(isRight(floatRt.decode(undefined))).toBe(false); + expect(isRight(floatRt.decode(null))).toBe(false); + expect(isRight(floatRt.decode(''))).toBe(false); + }); + + it('should only accept stringified numbers', () => { + expect(isRight(floatRt.decode('0.5'))).toBe(true); + expect(isRight(floatRt.decode(0.5))).toBe(false); + }); + + it('checks if the number falls within 0, 1', () => { + expect(isRight(floatRt.decode('0'))).toBe(true); + expect(isRight(floatRt.decode('0.5'))).toBe(true); + expect(isRight(floatRt.decode('-0.1'))).toBe(false); + expect(isRight(floatRt.decode('1.1'))).toBe(false); + expect(isRight(floatRt.decode(NaN))).toBe(false); + }); + + it('checks whether the number of decimals is 3', () => { + expect(isRight(floatRt.decode('1'))).toBe(true); + expect(isRight(floatRt.decode('0.9'))).toBe(true); + expect(isRight(floatRt.decode('0.99'))).toBe(true); + expect(isRight(floatRt.decode('0.999'))).toBe(true); + expect(isRight(floatRt.decode('0.9999'))).toBe(false); + }); +}); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/float_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/float_rt.ts new file mode 100644 index 0000000000000..4aa166f84bfe9 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/float_rt.ts @@ -0,0 +1,28 @@ +/* + * 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 * as t from 'io-ts'; +import { either } from 'fp-ts/lib/Either'; + +export const floatRt = new t.Type( + 'floatRt', + t.string.is, + (input, context) => { + return either.chain(t.string.validate(input, context), inputAsString => { + const inputAsFloat = parseFloat(inputAsString); + const maxThreeDecimals = + parseFloat(inputAsFloat.toFixed(3)) === inputAsFloat; + + const isValid = + inputAsFloat >= 0 && inputAsFloat <= 1 && maxThreeDecimals; + + return isValid + ? t.success(inputAsString) + : t.failure(input, context, 'Must be a number between 0.000 and 1'); + }); + }, + t.identity +); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/get_range_type_message.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/get_range_type_message.ts new file mode 100644 index 0000000000000..5bd0fcb80c4dd --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/get_range_type_message.ts @@ -0,0 +1,41 @@ +/* + * 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 { isFinite } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { amountAndUnitToObject } from '../amount_and_unit'; + +function getRangeType(min?: number, max?: number) { + if (isFinite(min) && isFinite(max)) { + return 'between'; + } else if (isFinite(min)) { + return 'gt'; // greater than + } else if (isFinite(max)) { + return 'lt'; // less than + } +} + +export function getRangeTypeMessage( + min?: number | string, + max?: number | string +) { + return i18n.translate('xpack.apm.agentConfig.range.errorText', { + defaultMessage: `{rangeType, select, + between {Must be between {min} and {max}} + gt {Must be greater than {min}} + lt {Must be less than {max}} + other {Must be an integer} + }`, + values: { + min, + max, + rangeType: getRangeType( + typeof min === 'string' ? amountAndUnitToObject(min).amount : min, + typeof max === 'string' ? amountAndUnitToObject(max).amount : max + ) + } + }); +} diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.test.ts index ef7fbeed4331e..a0395a4a140d9 100644 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.test.ts @@ -4,43 +4,63 @@ * you may not use this file except in compliance with the Elastic License. */ -import { integerRt, getIntegerRt } from './integer_rt'; +import { getIntegerRt } from './integer_rt'; import { isRight } from 'fp-ts/lib/Either'; +import { PathReporter } from 'io-ts/lib/PathReporter'; -describe('integerRt', () => { - describe('it should not accept', () => { - [undefined, null, '', 'foo', 0, 55, NaN].map(input => { - it(`${JSON.stringify(input)}`, () => { - expect(isRight(integerRt.decode(input))).toBe(false); +describe('getIntegerRt', () => { + describe('with range', () => { + const integerRt = getIntegerRt({ + min: 0, + max: 32000 + }); + + describe('it should not accept', () => { + [NaN, undefined, null, '', 'foo', 0, 55, '-1', '-55', '33000'].map( + input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(integerRt.decode(input))).toBe(false); + }); + } + ); + }); + + describe('it should return correct error message', () => { + ['-1', '-55', '33000'].map(input => { + it(`${JSON.stringify(input)}`, () => { + const result = integerRt.decode(input); + const message = PathReporter.report(result)[0]; + expect(message).toEqual('Must be between 0 and 32000'); + expect(isRight(result)).toBeFalsy(); + }); }); }); - }); - describe('it should accept', () => { - ['-1234', '-1', '0', '1000', '32000', '100000'].map(input => { - it(`${JSON.stringify(input)}`, () => { - expect(isRight(integerRt.decode(input))).toBe(true); + describe('it should accept number between 0 and 32000', () => { + ['0', '1000', '32000'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(integerRt.decode(input))).toBe(true); + }); }); }); }); -}); -describe('getIntegerRt', () => { - const customIntegerRt = getIntegerRt({ min: 0, max: 32000 }); - describe('it should not accept', () => { - [undefined, null, '', 'foo', 0, 55, '-1', '-55', '33000', NaN].map( - input => { + describe('without range', () => { + const integerRt = getIntegerRt(); + + describe('it should not accept', () => { + [NaN, undefined, null, '', 'foo', 0, 55].map(input => { it(`${JSON.stringify(input)}`, () => { - expect(isRight(customIntegerRt.decode(input))).toBe(false); + expect(isRight(integerRt.decode(input))).toBe(false); }); - } - ); - }); + }); + }); - describe('it should accept', () => { - ['0', '1000', '32000'].map(input => { - it(`${JSON.stringify(input)}`, () => { - expect(isRight(customIntegerRt.decode(input))).toBe(true); + describe('it should accept any number', () => { + ['-100', '-1', '0', '1000', '32000', '100000'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(integerRt.decode(input))).toBe(true); + }); }); }); }); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.ts index 6dbf175c8b4ce..adb91992f756a 100644 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.ts +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.ts @@ -6,8 +6,17 @@ import * as t from 'io-ts'; import { either } from 'fp-ts/lib/Either'; +import { getRangeTypeMessage } from './get_range_type_message'; + +export function getIntegerRt({ + min = -Infinity, + max = Infinity +}: { + min?: number; + max?: number; +} = {}) { + const message = getRangeTypeMessage(min, max); -export function getIntegerRt({ min, max }: { min: number; max: number }) { return new t.Type( 'integerRt', t.string.is, @@ -17,15 +26,9 @@ export function getIntegerRt({ min, max }: { min: number; max: number }) { const isValid = inputAsInt >= min && inputAsInt <= max; return isValid ? t.success(inputAsString) - : t.failure( - input, - context, - `Number must be a valid number between ${min} and ${max}` - ); + : t.failure(input, context, message); }); }, t.identity ); } - -export const integerRt = getIntegerRt({ min: -Infinity, max: Infinity }); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.test.ts deleted file mode 100644 index ece229ca162fb..0000000000000 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { numberFloatRt } from './number_float_rt'; -import { isRight } from 'fp-ts/lib/Either'; - -describe('numberFloatRt', () => { - it('does not accept empty values', () => { - expect(isRight(numberFloatRt.decode(undefined))).toBe(false); - expect(isRight(numberFloatRt.decode(null))).toBe(false); - expect(isRight(numberFloatRt.decode(''))).toBe(false); - }); - - it('should only accept stringified numbers', () => { - expect(isRight(numberFloatRt.decode('0.5'))).toBe(true); - expect(isRight(numberFloatRt.decode(0.5))).toBe(false); - }); - - it('checks if the number falls within 0, 1', () => { - expect(isRight(numberFloatRt.decode('0'))).toBe(true); - expect(isRight(numberFloatRt.decode('0.5'))).toBe(true); - expect(isRight(numberFloatRt.decode('-0.1'))).toBe(false); - expect(isRight(numberFloatRt.decode('1.1'))).toBe(false); - expect(isRight(numberFloatRt.decode(NaN))).toBe(false); - }); - - it('checks whether the number of decimals is 3', () => { - expect(isRight(numberFloatRt.decode('1'))).toBe(true); - expect(isRight(numberFloatRt.decode('0.9'))).toBe(true); - expect(isRight(numberFloatRt.decode('0.99'))).toBe(true); - expect(isRight(numberFloatRt.decode('0.999'))).toBe(true); - expect(isRight(numberFloatRt.decode('0.9999'))).toBe(false); - }); -}); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.ts deleted file mode 100644 index f1890c9851a3d..0000000000000 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as t from 'io-ts'; -import { either } from 'fp-ts/lib/Either'; - -export function getNumberFloatRt({ min, max }: { min: number; max: number }) { - return new t.Type( - 'numberFloatRt', - t.string.is, - (input, context) => { - return either.chain(t.string.validate(input, context), inputAsString => { - const inputAsFloat = parseFloat(inputAsString); - const maxThreeDecimals = - parseFloat(inputAsFloat.toFixed(3)) === inputAsFloat; - - const isValid = - inputAsFloat >= min && inputAsFloat <= max && maxThreeDecimals; - - return isValid - ? t.success(inputAsString) - : t.failure( - input, - context, - `Number must be between ${min} and ${max}` - ); - }); - }, - t.identity - ); -} - -export const numberFloatRt = getNumberFloatRt({ min: 0, max: 1 }); diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap index ea706be9f584a..4f5763dcde582 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap @@ -4,24 +4,24 @@ exports[`settingDefinitions should have correct default values 1`] = ` Array [ Object { "key": "api_request_size", + "min": "0b", "type": "bytes", "units": Array [ "b", "kb", "mb", ], - "validationError": "Please specify an integer and a unit", "validationName": "bytesRt", }, Object { "key": "api_request_time", + "min": "1ms", "type": "duration", "units": Array [ "ms", "s", "m", ], - "validationError": "Please specify an integer and a unit", "validationName": "durationRt", }, Object { @@ -84,24 +84,25 @@ Array [ }, Object { "key": "profiling_inferred_spans_min_duration", + "min": "1ms", "type": "duration", "units": Array [ "ms", "s", "m", ], - "validationError": "Please specify an integer and a unit", "validationName": "durationRt", }, Object { "key": "profiling_inferred_spans_sampling_interval", + "max": "1s", + "min": "1ms", "type": "duration", "units": Array [ "ms", "s", "m", ], - "validationError": "Please specify an integer and a unit", "validationName": "durationRt", }, Object { @@ -111,81 +112,75 @@ Array [ }, Object { "key": "server_timeout", + "min": "1ms", "type": "duration", "units": Array [ "ms", "s", "m", ], - "validationError": "Please specify an integer and a unit", "validationName": "durationRt", }, Object { "key": "span_frames_min_duration", - "min": -1, + "min": "-1ms", "type": "duration", "units": Array [ "ms", "s", "m", ], - "validationError": "Please specify an integer and a unit", "validationName": "durationRt", }, Object { "key": "stack_trace_limit", + "max": undefined, + "min": undefined, "type": "integer", - "validationError": "Must be an integer", "validationName": "integerRt", }, Object { "key": "stress_monitor_cpu_duration_threshold", + "min": "1m", "type": "duration", "units": Array [ "ms", "s", "m", ], - "validationError": "Please specify an integer and a unit", "validationName": "durationRt", }, Object { "key": "stress_monitor_gc_relief_threshold", "type": "float", - "validationError": "Must be a number between 0.000 and 1", - "validationName": "numberFloatRt", + "validationName": "floatRt", }, Object { "key": "stress_monitor_gc_stress_threshold", "type": "float", - "validationError": "Must be a number between 0.000 and 1", - "validationName": "numberFloatRt", + "validationName": "floatRt", }, Object { "key": "stress_monitor_system_cpu_relief_threshold", "type": "float", - "validationError": "Must be a number between 0.000 and 1", - "validationName": "numberFloatRt", + "validationName": "floatRt", }, Object { "key": "stress_monitor_system_cpu_stress_threshold", "type": "float", - "validationError": "Must be a number between 0.000 and 1", - "validationName": "numberFloatRt", + "validationName": "floatRt", }, Object { "key": "transaction_max_spans", "max": 32000, "min": 0, "type": "integer", - "validationError": "Must be between 0 and 32000", "validationName": "integerRt", }, Object { "key": "transaction_sample_rate", "type": "float", - "validationError": "Must be a number between 0.000 and 1", - "validationName": "numberFloatRt", + "validationName": "floatRt", }, ] `; diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts index 7477238ba79ae..4ade59d489040 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -5,14 +5,9 @@ */ import { i18n } from '@kbn/i18n'; -import { getIntegerRt } from '../runtime_types/integer_rt'; import { captureBodyRt } from '../runtime_types/capture_body_rt'; import { RawSettingDefinition } from './types'; -import { getDurationRt } from '../runtime_types/duration_rt'; -/* - * Settings added here will show up in the UI and will be validated on the client and server - */ export const generalSettings: RawSettingDefinition[] = [ // API Request Size { @@ -144,7 +139,7 @@ export const generalSettings: RawSettingDefinition[] = [ { key: 'span_frames_min_duration', type: 'duration', - validation: getDurationRt({ min: -1 }), + min: '-1ms', defaultValue: '5ms', label: i18n.translate('xpack.apm.agentConfig.spanFramesMinDuration.label', { defaultMessage: 'Span frames minimum duration' @@ -156,8 +151,7 @@ export const generalSettings: RawSettingDefinition[] = [ 'In its default settings, the APM agent will collect a stack trace with every recorded span.\nWhile this is very helpful to find the exact place in your code that causes the span, collecting this stack trace does have some overhead. \nWhen setting this option to a negative value, like `-1ms`, stack traces will be collected for all spans. Setting it to a positive value, e.g. `5ms`, will limit stack trace collection to spans with durations equal to or longer than the given value, e.g. 5 milliseconds.\n\nTo disable stack trace collection for spans completely, set the value to `0ms`.' } ), - excludeAgents: ['js-base', 'rum-js', 'nodejs'], - min: -1 + excludeAgents: ['js-base', 'rum-js', 'nodejs'] }, // STACK_TRACE_LIMIT @@ -182,11 +176,8 @@ export const generalSettings: RawSettingDefinition[] = [ { key: 'transaction_max_spans', type: 'integer', - validation: getIntegerRt({ min: 0, max: 32000 }), - validationError: i18n.translate( - 'xpack.apm.agentConfig.transactionMaxSpans.errorText', - { defaultMessage: 'Must be between 0 and 32000' } - ), + min: 0, + max: 32000, defaultValue: '500', label: i18n.translate('xpack.apm.agentConfig.transactionMaxSpans.label', { defaultMessage: 'Transaction max spans' @@ -198,8 +189,6 @@ export const generalSettings: RawSettingDefinition[] = [ 'Limits the amount of spans that are recorded per transaction.' } ), - min: 0, - max: 32000, excludeAgents: ['js-base', 'rum-js'] }, diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.ts index 8786a94be096d..7869cd7d79e17 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.ts @@ -7,58 +7,75 @@ import * as t from 'io-ts'; import { sortBy } from 'lodash'; import { isRight } from 'fp-ts/lib/Either'; -import { i18n } from '@kbn/i18n'; +import { PathReporter } from 'io-ts/lib/PathReporter'; import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; import { booleanRt } from '../runtime_types/boolean_rt'; -import { integerRt } from '../runtime_types/integer_rt'; +import { getIntegerRt } from '../runtime_types/integer_rt'; import { isRumAgentName } from '../../agent_name'; -import { numberFloatRt } from '../runtime_types/number_float_rt'; -import { bytesRt, BYTE_UNITS } from '../runtime_types/bytes_rt'; -import { durationRt, DURATION_UNITS } from '../runtime_types/duration_rt'; +import { floatRt } from '../runtime_types/float_rt'; import { RawSettingDefinition, SettingDefinition } from './types'; import { generalSettings } from './general_settings'; import { javaSettings } from './java_settings'; +import { getDurationRt } from '../runtime_types/duration_rt'; +import { getBytesRt } from '../runtime_types/bytes_rt'; + +function getSettingDefaults(setting: RawSettingDefinition): SettingDefinition { + switch (setting.type) { + case 'select': + return { validation: t.string, ...setting }; -function getDefaultsByType(settingDefinition: RawSettingDefinition) { - switch (settingDefinition.type) { case 'boolean': - return { validation: booleanRt }; + return { validation: booleanRt, ...setting }; + case 'text': - return { validation: t.string }; - case 'integer': + return { validation: t.string, ...setting }; + + case 'integer': { + const { min, max } = setting; + return { - validation: integerRt, - validationError: i18n.translate( - 'xpack.apm.agentConfig.integer.errorText', - { defaultMessage: 'Must be an integer' } - ) + validation: getIntegerRt({ min, max }), + min, + max, + ...setting }; - case 'float': + } + + case 'float': { return { - validation: numberFloatRt, - validationError: i18n.translate( - 'xpack.apm.agentConfig.float.errorText', - { defaultMessage: 'Must be a number between 0.000 and 1' } - ) + validation: floatRt, + ...setting }; - case 'bytes': + } + + case 'bytes': { + const units = setting.units ?? ['b', 'kb', 'mb']; + const min = setting.min ?? '0b'; + const max = setting.max; + return { - validation: bytesRt, - units: BYTE_UNITS, - validationError: i18n.translate( - 'xpack.apm.agentConfig.bytes.errorText', - { defaultMessage: 'Please specify an integer and a unit' } - ) + validation: getBytesRt({ min, max }), + units, + min, + ...setting }; - case 'duration': + } + + case 'duration': { + const units = setting.units ?? ['ms', 's', 'm']; + const min = setting.min ?? '1ms'; + const max = setting.max; + return { - validation: durationRt, - units: DURATION_UNITS, - validationError: i18n.translate( - 'xpack.apm.agentConfig.bytes.errorText', - { defaultMessage: 'Please specify an integer and a unit' } - ) + validation: getDurationRt({ min, max }), + units, + min, + ...setting }; + } + + default: + return setting; } } @@ -91,23 +108,14 @@ export function filterByAgent(agentName?: AgentName) { }; } -export function isValid(setting: SettingDefinition, value: unknown) { - return isRight(setting.validation.decode(value)); +export function validateSetting(setting: SettingDefinition, value: unknown) { + const result = setting.validation.decode(value); + const message = PathReporter.report(result)[0]; + const isValid = isRight(result); + return { isValid, message }; } -export const settingDefinitions = sortBy( - [...generalSettings, ...javaSettings].map(def => { - const defWithDefaults = { - ...getDefaultsByType(def), - ...def - }; - - // ensure every option has validation - if (!defWithDefaults.validation) { - throw new Error(`Missing validation for ${def.key}`); - } - - return defWithDefaults as SettingDefinition; - }), +export const settingDefinitions: SettingDefinition[] = sortBy( + [...generalSettings, ...javaSettings].map(getSettingDefaults), 'key' ); diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/java_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/java_settings.ts index 2e10c74378549..bc8f19becf053 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/java_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/java_settings.ts @@ -99,7 +99,8 @@ export const javaSettings: RawSettingDefinition[] = [ 'The minimal time required in order to determine whether the system is either currently under stress, or that the stress detected previously has been relieved. All measurements during this time must be consistent in comparison to the relevant threshold in order to detect a change of stress state. Must be at least `1m`.' } ), - includeAgents: ['java'] + includeAgents: ['java'], + min: '1m' }, { key: 'stress_monitor_system_cpu_stress_threshold', @@ -176,7 +177,9 @@ export const javaSettings: RawSettingDefinition[] = [ 'The frequency at which stack traces are gathered within a profiling session. The lower you set it, the more accurate the durations will be. This comes at the expense of higher overhead and more spans for potentially irrelevant operations. The minimal duration of a profiling-inferred span is the same as the value of this setting.' } ), - includeAgents: ['java'] + includeAgents: ['java'], + min: '1ms', + max: '1s' }, { key: 'profiling_inferred_spans_min_duration', diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts index 815b8cb3d4e83..85a454b5f256a 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts @@ -7,6 +7,9 @@ import * as t from 'io-ts'; import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; +// TODO: is it possible to get rid of `any`? +export type SettingValidation = t.Type; + interface BaseSetting { /** * UI: unique key to identify setting @@ -25,7 +28,7 @@ interface BaseSetting { category?: string; /** - * UI: + * UI: Default value set by agent */ defaultValue?: string; @@ -39,16 +42,6 @@ interface BaseSetting { */ placeholder?: string; - /** - * runtime validation of the input - */ - validation?: t.Type; - - /** - * UI: error shown when the runtime validation fails - */ - validationError?: string; - /** * Limits the setting to no agents, except those specified in `includeAgents` */ @@ -62,36 +55,41 @@ interface BaseSetting { interface TextSetting extends BaseSetting { type: 'text'; -} - -interface IntegerSetting extends BaseSetting { - type: 'integer'; - min?: number; - max?: number; -} - -interface FloatSetting extends BaseSetting { - type: 'float'; + validation?: SettingValidation; } interface SelectSetting extends BaseSetting { type: 'select'; options: Array<{ text: string; value: string }>; + validation?: SettingValidation; } interface BooleanSetting extends BaseSetting { type: 'boolean'; } +interface FloatSetting extends BaseSetting { + type: 'float'; +} + +interface IntegerSetting extends BaseSetting { + type: 'integer'; + min?: number; + max?: number; +} + interface BytesSetting extends BaseSetting { type: 'bytes'; + min?: string; + max?: string; units?: string[]; } interface DurationSetting extends BaseSetting { type: 'duration'; + min?: string; + max?: string; units?: string[]; - min?: number; } export type RawSettingDefinition = @@ -104,5 +102,8 @@ export type RawSettingDefinition = | DurationSetting; export type SettingDefinition = RawSettingDefinition & { - validation: NonNullable; + /** + * runtime validation of input + */ + validation: SettingValidation; }; diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx index fcd75a05b01d9..6711fecc2376c 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx @@ -18,7 +18,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { SettingDefinition } from '../../../../../../../common/agent_configuration/setting_definitions/types'; -import { isValid } from '../../../../../../../common/agent_configuration/setting_definitions'; +import { validateSetting } from '../../../../../../../common/agent_configuration/setting_definitions'; import { amountAndUnitToString, amountAndUnitToObject @@ -92,12 +92,14 @@ function FormRow({ onChange( setting.key, - amountAndUnitToString({ amount: e.target.value, unit }) + amountAndUnitToString({ + amount: e.target.value, + unit + }) ) } /> @@ -137,7 +139,8 @@ export function SettingFormRow({ value?: string; onChange: (key: string, value: string) => void; }) { - const isInvalid = value != null && value !== '' && !isValid(setting, value); + const { isValid, message } = validateSetting(setting, value); + const isInvalid = value != null && value !== '' && !isValid; return ( } > - + diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx index e41bdaf0c9c09..bb3c2b3249363 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx @@ -29,7 +29,7 @@ import { AgentConfigurationIntake } from '../../../../../../../common/agent_conf import { filterByAgent, settingDefinitions, - isValid + validateSetting } from '../../../../../../../common/agent_configuration/setting_definitions'; import { saveConfig } from './saveConfig'; import { useApmPluginContext } from '../../../../../../hooks/useApmPluginContext'; @@ -79,7 +79,7 @@ export function SettingsPage({ // every setting must be valid for the form to be valid .every(def => { const value = newConfig.settings[def.key]; - return isValid(def, value); + return validateSetting(def, value).isValid; }) ); }, [newConfig.settings]); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 481dfffd2e3a0..da8673da67f42 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4065,7 +4065,6 @@ "xpack.apm.agentConfig.apiRequestSize.label": "API リクエストサイズ", "xpack.apm.agentConfig.apiRequestTime.description": "APM Server への HTTP リクエストを開いておく最大時間。\n\n注:この値は、APM Server の「read_timeout」設定よりも低くする必要があります。", "xpack.apm.agentConfig.apiRequestTime.label": "API リクエスト時間", - "xpack.apm.agentConfig.bytes.errorText": "整数と単位を指定してください", "xpack.apm.agentConfig.captureBody.description": "HTTP リクエストのトランザクションの場合、エージェントはオプションとしてリクエスト本文 (POST 変数など) をキャプチャすることができます。デフォルトは「off」です。", "xpack.apm.agentConfig.captureBody.label": "本文をキャプチャ", "xpack.apm.agentConfig.captureHeaders.description": "「true」に設定すると、エージェントは Cookie を含むリクエストヘッダーとレスポンスヘッダーをキャプチャします。\n\n注:これを「false」に設定すると、ネットワーク帯域幅、ディスク容量、およびオブジェクト割り当てが減少します。", @@ -4099,8 +4098,6 @@ "xpack.apm.agentConfig.editConfigTitle": "構成の編集", "xpack.apm.agentConfig.enableLogCorrelation.description": "エージェントが SLF4J のhttps://www.slf4j.org/api/org/slf4j/MDC.html[MDC] と融合してトレースログ相関を有効にすべきかどうかを指定するブール値。\n「true」に設定した場合、エージェントは現在アクティブなスパンとトランザクションの「trace.id」と「transaction.id」を MDC に設定します。\n詳細は <> をご覧ください。\n\n注:実行時にこの設定を有効にできますが、再起動しないと無効にはできません。", "xpack.apm.agentConfig.enableLogCorrelation.label": "ログ相関を有効にする", - "xpack.apm.agentConfig.float.errorText": "0.000 から 1 までの数字でなければなりません", - "xpack.apm.agentConfig.integer.errorText": "整数でなければなりません", "xpack.apm.agentConfig.logLevel.description": "エージェントのログ記録レベルを設定します", "xpack.apm.agentConfig.logLevel.label": "ログレベル", "xpack.apm.agentConfig.newConfig.description": "これで Kibana でエージェント構成を直接的に微調整できます。\n しかも、変更は APM エージェントに自動的に伝達されるので、再デプロイする必要はありません。", @@ -4150,7 +4147,6 @@ "xpack.apm.agentConfig.stressMonitorSystemCpuStressThreshold.description": "システム CPU 監視でシステム CPU ストレスの検出に使用するしきい値。\nシステム CPU が少なくとも「stress_monitor_cpu_duration_threshold」と同じ長さ以上の期間にわたってこのしきい値を超えると、監視機能はこれをストレス状態と見なします。", "xpack.apm.agentConfig.stressMonitorSystemCpuStressThreshold.label": "ストレス監視システム CPU ストレスしきい値", "xpack.apm.agentConfig.transactionMaxSpans.description": "トランザクションごとに記録される範囲を制限します。デフォルトは 500 です。", - "xpack.apm.agentConfig.transactionMaxSpans.errorText": "0 と 32000 の間でなければなりません", "xpack.apm.agentConfig.transactionMaxSpans.label": "トランザクションの最大範囲", "xpack.apm.agentConfig.transactionSampleRate.description": "デフォルトでは、エージェントはすべてのトランザクション (例えば、サービスへのリクエストなど) をサンプリングします。オーバーヘッドやストレージ要件を減らすには、サンプルレートの値を 0.0〜1.0 に設定します。全体的な時間とサンプリングされないトランザクションの結果は記録されますが、コンテキスト情報、ラベル、スパンは記録されません。", "xpack.apm.agentConfig.transactionSampleRate.label": "トランザクションのサンプルレート", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ca0e070c9bfd4..f66e9631b0168 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4066,7 +4066,6 @@ "xpack.apm.agentConfig.apiRequestSize.label": "API 请求大小", "xpack.apm.agentConfig.apiRequestTime.description": "使 APM Server 的 HTTP 请求保持开放的最大时间。\n\n注意:此值必须小于 APM Server 的 `read_timeout` 设置。", "xpack.apm.agentConfig.apiRequestTime.label": "API 请求时间", - "xpack.apm.agentConfig.bytes.errorText": "请指定整数和单位", "xpack.apm.agentConfig.captureBody.description": "有关属于 HTTP 请求的事务,代理可以选择性地捕获请求正文(例如 POST 变量)。默认为“off”。", "xpack.apm.agentConfig.captureBody.label": "捕获正文", "xpack.apm.agentConfig.captureHeaders.description": "如果设置为 `true`,代理将捕获请求和响应标头,包括 cookie。\n\n注意:将其设置为 `false` 可减少网络带宽、磁盘空间和对象分配。", @@ -4100,8 +4099,6 @@ "xpack.apm.agentConfig.editConfigTitle": "编辑配置", "xpack.apm.agentConfig.enableLogCorrelation.description": "指定是否应在 SLF4J 的 https://www.slf4j.org/api/org/slf4j/MDC.html[MDC] 中集成代理以启用跟踪-日志关联的布尔值。\n如果设置为 `true`,代理会将当前活动跨度和事务的 `trace.id` 和 `transaction.id` 设置为 MDC。\n请参阅 <> 以了解更多详情。\n\n注意:尽管允许在运行时启用此设置,但不重新启动将无法禁用。", "xpack.apm.agentConfig.enableLogCorrelation.label": "启用日志关联", - "xpack.apm.agentConfig.float.errorText": "必须是介于 0.000 和 1 之间的数字", - "xpack.apm.agentConfig.integer.errorText": "必须为整数", "xpack.apm.agentConfig.logLevel.description": "设置代理的日志记录级别", "xpack.apm.agentConfig.logLevel.label": "日志级别", "xpack.apm.agentConfig.newConfig.description": "这允许您直接在 Kibana 中微调\n 代理配置。最重要是,更改自动传播给您的 APM\n 代理,从而无需重新部署。", @@ -4151,7 +4148,6 @@ "xpack.apm.agentConfig.stressMonitorSystemCpuStressThreshold.description": "系统 CPU 监测用于检测系统 CPU 压力的阈值。\n如果系统 CPU 超过此阈值的持续时间至少有 `stress_monitor_cpu_duration_threshold`,\n监测会将其视为压力状态。", "xpack.apm.agentConfig.stressMonitorSystemCpuStressThreshold.label": "压力监测系统 cpu 压力阈值", "xpack.apm.agentConfig.transactionMaxSpans.description": "限制每个事务记录的跨度数量。默认值为 500。", - "xpack.apm.agentConfig.transactionMaxSpans.errorText": "必须介于 0 和 32000 之间", "xpack.apm.agentConfig.transactionMaxSpans.label": "事务最大跨度数", "xpack.apm.agentConfig.transactionSampleRate.description": "默认情况下,代理将采样每个事务(例如对服务的请求)。要减少开销和存储需要,可以将采样率设置介于 0.0 和 1.0 之间的值。我们仍记录整体时间和未采样事务的结果,但不记录上下文信息、标签和跨度。", "xpack.apm.agentConfig.transactionSampleRate.label": "事务采样率", From 55c94c8430168ccaf6d4bbf0ecb2af0a0a89f45b Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Mon, 4 May 2020 11:16:02 -0400 Subject: [PATCH 005/153] [Ingest] Add data to Overview page (#65024) --- .../common/types/rest_spec/agent.ts | 2 +- .../hooks/use_request/agents.ts | 9 + .../overview/components/agent_section.tsx | 91 ++++++++++ .../components/configuration_section.tsx | 79 +++++++++ .../components/datastream_section.tsx | 101 +++++++++++ .../components/integration_section.tsx | 78 +++++++++ .../overview/components/overview_panel.tsx | 26 +++ .../overview/components/overview_stats.tsx | 23 +++ .../sections/overview/index.tsx | 163 +----------------- 9 files changed, 417 insertions(+), 155 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_stats.tsx diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts index 64ed95db74f4c..7214611ca9122 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts @@ -152,7 +152,7 @@ export interface UpdateAgentRequest { export interface GetAgentStatusRequest { query: { - configId: string; + configId?: string; }; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts index 453bcf2bd81e7..cad1791af41be 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts @@ -62,6 +62,15 @@ export function sendGetAgentStatus( }); } +export function useGetAgentStatus(query: GetAgentStatusRequest['query'], options?: RequestOptions) { + return useRequest({ + method: 'get', + path: agentRouteService.getStatusPath(), + query, + ...options, + }); +} + export function sendPutAgentReassign( agentId: string, body: PutAgentReassignRequest['body'], diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx new file mode 100644 index 0000000000000..0f6d3c5b55ce6 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx @@ -0,0 +1,91 @@ +/* + * 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 { EuiFlexItem, EuiI18nNumber } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiButtonEmpty, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; +import { OverviewPanel } from './overview_panel'; +import { OverviewStats } from './overview_stats'; +import { useLink, useGetAgentStatus } from '../../../hooks'; +import { FLEET_PATH } from '../../../constants'; +import { Loading } from '../../fleet/components'; + +export const OverviewAgentSection = () => { + const agentStatusRequest = useGetAgentStatus({}); + + return ( + + +
+ +

+ +

+
+ + + +
+ + {agentStatusRequest.isLoading ? ( + + ) : ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + )} + +
+
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx new file mode 100644 index 0000000000000..b74cac9a62176 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx @@ -0,0 +1,79 @@ +/* + * 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 { EuiFlexItem, EuiI18nNumber } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiButtonEmpty, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; +import { OverviewPanel } from './overview_panel'; +import { OverviewStats } from './overview_stats'; +import { useLink, useGetDatasources } from '../../../hooks'; +import { AgentConfig } from '../../../types'; +import { AGENT_CONFIG_PATH } from '../../../constants'; +import { Loading } from '../../fleet/components'; + +export const OverviewConfigurationSection: React.FC<{ agentConfigs: AgentConfig[] }> = ({ + agentConfigs, +}) => { + const datasourcesRequest = useGetDatasources({ + page: 1, + perPage: 10000, + }); + + return ( + + +
+ +

+ +

+
+ + + +
+ + {datasourcesRequest.isLoading ? ( + + ) : ( + <> + + + + + + + + + + + + + + )} + +
+
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx new file mode 100644 index 0000000000000..7d1f0598a2767 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx @@ -0,0 +1,101 @@ +/* + * 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 { EuiFlexItem, EuiI18nNumber } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiButtonEmpty, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; +import { OverviewPanel } from './overview_panel'; +import { OverviewStats } from './overview_stats'; +import { useLink, useGetDataStreams, useStartDeps } from '../../../hooks'; +import { Loading } from '../../fleet/components'; +import { DATA_STREAM_PATH } from '../../../constants'; + +export const OverviewDatastreamSection: React.FC = () => { + const datastreamRequest = useGetDataStreams(); + const { + data: { fieldFormats }, + } = useStartDeps(); + + const total = datastreamRequest.data?.data_streams?.length ?? 0; + let sizeBytes = 0; + const namespaces = new Set(); + if (datastreamRequest.data) { + datastreamRequest.data.data_streams.forEach(val => { + namespaces.add(val.namespace); + sizeBytes += val.size_in_bytes; + }); + } + + let size: string; + try { + const formatter = fieldFormats.getInstance('bytes'); + size = formatter.convert(sizeBytes); + } catch (e) { + size = `${sizeBytes}b`; + } + + return ( + + +
+ +

+ +

+
+ + + +
+ + {datastreamRequest.isLoading ? ( + + ) : ( + <> + + + + + + + + + + + + + + + + {size} + + )} + +
+
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx new file mode 100644 index 0000000000000..f4c122af88371 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx @@ -0,0 +1,78 @@ +/* + * 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 { EuiFlexItem, EuiI18nNumber } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiButtonEmpty, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; +import { OverviewPanel } from './overview_panel'; +import { OverviewStats } from './overview_stats'; +import { useLink, useGetPackages } from '../../../hooks'; +import { EPM_PATH } from '../../../constants'; +import { Loading } from '../../fleet/components'; +import { InstallationStatus } from '../../../types'; + +export const OverviewIntegrationSection: React.FC = () => { + const packagesRequest = useGetPackages(); + + const total = packagesRequest.data?.response?.length ?? 0; + const installed = + packagesRequest.data?.response?.filter(p => p.status === InstallationStatus.installed) + ?.length ?? 0; + return ( + + +
+ +

+ +

+
+ + + +
+ + {packagesRequest.isLoading ? ( + + ) : ( + <> + + + + + + + + + + + + + + )} + +
+
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx new file mode 100644 index 0000000000000..41d7a7a5f0bc3 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx @@ -0,0 +1,26 @@ +/* + * 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 styled from 'styled-components'; +import { EuiPanel } from '@elastic/eui'; + +export const OverviewPanel = styled(EuiPanel).attrs(props => ({ + paddingSize: 'm', +}))` + header { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid ${props => props.theme.eui.euiColorLightShade}; + margin: -${props => props.theme.eui.paddingSizes.m} -${props => props.theme.eui.paddingSizes.m} + ${props => props.theme.eui.paddingSizes.m}; + padding: ${props => props.theme.eui.paddingSizes.s} ${props => props.theme.eui.paddingSizes.m}; + } + + h2 { + padding: ${props => props.theme.eui.paddingSizes.xs} 0; + } +`; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_stats.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_stats.tsx new file mode 100644 index 0000000000000..04de22c34fe6f --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_stats.tsx @@ -0,0 +1,23 @@ +/* + * 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 styled from 'styled-components'; +import { EuiDescriptionList } from '@elastic/eui'; + +export const OverviewStats = styled(EuiDescriptionList).attrs(props => ({ + compressed: true, + textStyle: 'reverse', + type: 'column', +}))` + & > * { + margin-top: ${props => props.theme.eui.paddingSizes.s} !important; + + &:first-child, + &:nth-child(2) { + margin-top: 0 !important; + } + } +`; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx index 70d8e7d6882f8..3cd778fb4f016 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx @@ -7,14 +7,8 @@ import React, { useState } from 'react'; import styled from 'styled-components'; import { EuiButton, - EuiButtonEmpty, EuiBetaBadge, - EuiPanel, EuiText, - EuiTitle, - EuiDescriptionList, - EuiDescriptionListDescription, - EuiDescriptionListTitle, EuiFlexGrid, EuiFlexGroup, EuiFlexItem, @@ -22,42 +16,12 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { WithHeaderLayout } from '../../layouts'; -import { useLink, useGetAgentConfigs } from '../../hooks'; +import { useGetAgentConfigs } from '../../hooks'; import { AgentEnrollmentFlyout } from '../fleet/agent_list_page/components'; -import { EPM_PATH, FLEET_PATH, AGENT_CONFIG_PATH, DATA_STREAM_PATH } from '../../constants'; - -const OverviewPanel = styled(EuiPanel).attrs(props => ({ - paddingSize: 'm', -}))` - header { - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid ${props => props.theme.eui.euiColorLightShade}; - margin: -${props => props.theme.eui.paddingSizes.m} -${props => props.theme.eui.paddingSizes.m} - ${props => props.theme.eui.paddingSizes.m}; - padding: ${props => props.theme.eui.paddingSizes.s} ${props => props.theme.eui.paddingSizes.m}; - } - - h2 { - padding: ${props => props.theme.eui.paddingSizes.xs} 0; - } -`; - -const OverviewStats = styled(EuiDescriptionList).attrs(props => ({ - compressed: true, - textStyle: 'reverse', - type: 'column', -}))` - & > * { - margin-top: ${props => props.theme.eui.paddingSizes.s} !important; - - &:first-child, - &:nth-child(2) { - margin-top: 0 !important; - } - } -`; +import { OverviewAgentSection } from './components/agent_section'; +import { OverviewConfigurationSection } from './components/configuration_section'; +import { OverviewIntegrationSection } from './components/integration_section'; +import { OverviewDatastreamSection } from './components/datastream_section'; const AlphaBadge = styled(EuiBetaBadge)` vertical-align: top; @@ -135,121 +99,12 @@ export const IngestManagerOverview: React.FunctionComponent = () => { )} - - -
- -

- -

-
- - - -
- - Total available - 999 - Installed - 1 - Updated available - 0 - -
-
+ + - - -
- -

- -

-
- - - -
- - Total configs - 1 - Data sources - 1 - -
-
+ - - -
- -

- -

-
- - - -
- - Total agents - 0 - Active - 0 - Offline - 0 - Error - 0 - -
-
- - - -
- -

- -

-
- - - -
- - Data streams - 0 - Name spaces - 0 - Total size - 0 MB - -
-
+
); From 3ba268d8a59a02c2d9aec123f2a701b2ba3e3f83 Mon Sep 17 00:00:00 2001 From: Kaarina Tungseth Date: Mon, 4 May 2020 10:22:40 -0500 Subject: [PATCH 006/153] [DOCS] Reformats settings tables (#64844) * Formats settings into tables * Formatting * Formatting --- docs/settings/alert-action-settings.asciidoc | 39 +- docs/settings/apm-settings.asciidoc | 39 +- docs/settings/dev-settings.asciidoc | 16 +- .../general-infra-logs-ui-settings.asciidoc | 31 +- docs/settings/graph-settings.asciidoc | 9 +- docs/settings/i18n-settings.asciidoc | 11 +- docs/settings/ml-settings.asciidoc | 27 +- docs/settings/monitoring-settings.asciidoc | 171 ++-- docs/settings/reporting-settings.asciidoc | 223 ++--- docs/settings/security-settings.asciidoc | 122 +-- docs/settings/spaces-settings.asciidoc | 20 +- docs/settings/telemetry-settings.asciidoc | 48 +- docs/setup/settings.asciidoc | 764 +++++++++++------- 13 files changed, 924 insertions(+), 596 deletions(-) diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index d4dbe9407b7a9..547b4fdedcec6 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -5,7 +5,7 @@ Alerting and action settings ++++ -Alerts and actions are enabled by default in {kib}, but require you configure the following in order to use them: +Alerts and actions are enabled by default in {kib}, but require you configure the following in order to use them: . <>. . <>. @@ -18,27 +18,36 @@ You can configure the following settings in the `kibana.yml` file. [[general-alert-action-settings]] ==== General settings -`xpack.encryptedSavedObjects.encryptionKey`:: +[cols="2*<"] +|=== -A string of 32 or more characters used to encrypt sensitive properties on alerts and actions before they're stored in {es}. Third party credentials — such as the username and password used to connect to an SMTP service — are an example of encrypted properties. -+ -If not set, {kib} will generate a random key on startup, but all alert and action functions will be blocked. Generated keys are not allowed for alerts and actions because when a new key is generated on restart, existing encrypted data becomes inaccessible. For the same reason, alerts and actions in high-availability deployments of {kib} will behave unexpectedly if the key isn't the same on all instances of {kib}. -+ -Although the key can be specified in clear text in `kibana.yml`, it's recommended to store this key securely in the <>. +| `xpack.encryptedSavedObjects.encryptionKey` + | A string of 32 or more characters used to encrypt sensitive properties on alerts and actions before they're stored in {es}. Third party credentials — such as the username and password used to connect to an SMTP service — are an example of encrypted properties. + + + + If not set, {kib} will generate a random key on startup, but all alert and action functions will be blocked. Generated keys are not allowed for alerts and actions because when a new key is generated on restart, existing encrypted data becomes inaccessible. For the same reason, alerts and actions in high-availability deployments of {kib} will behave unexpectedly if the key isn't the same on all instances of {kib}. + + + + Although the key can be specified in clear text in `kibana.yml`, it's recommended to store this key securely in the <>. + +|=== [float] [[action-settings]] ==== Action settings -`xpack.actions.whitelistedHosts`:: -A list of hostnames that {kib} is allowed to connect to when built-in actions are triggered. It defaults to `[*]`, allowing any host, but keep in mind the potential for SSRF attacks when hosts are not explicitly whitelisted. An empty list `[]` can be used to block built-in actions from making any external connections. -+ -Note that hosts associated with built-in actions, such as Slack and PagerDuty, are not automatically whitelisted. If you are not using the default `[*]` setting, you must ensure that the corresponding endpoints are whitelisted as well. +[cols="2*<"] +|=== + +| `xpack.actions.whitelistedHosts` + | A list of hostnames that {kib} is allowed to connect to when built-in actions are triggered. It defaults to `[*]`, allowing any host, but keep in mind the potential for SSRF attacks when hosts are not explicitly whitelisted. An empty list `[]` can be used to block built-in actions from making any external connections. + + + + Note that hosts associated with built-in actions, such as Slack and PagerDuty, are not automatically whitelisted. If you are not using the default `[*]` setting, you must ensure that the corresponding endpoints are whitelisted as well. + +| `xpack.actions.enabledActionTypes` + | A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, and `.webhook`. An empty list `[]` will disable all action types. + + + + Disabled action types will not appear as an option when creating new connectors, but existing connectors and actions of that type will remain in {kib} and will not function. -`xpack.actions.enabledActionTypes`:: -A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, and `.webhook`. An empty list `[]` will disable all action types. -+ -Disabled action types will not appear as an option when creating new connectors, but existing connectors and actions of that type will remain in {kib} and will not function. +|=== [float] [[alert-settings]] diff --git a/docs/settings/apm-settings.asciidoc b/docs/settings/apm-settings.asciidoc index fd53c3aeb3605..8844fcd03ae9a 100644 --- a/docs/settings/apm-settings.asciidoc +++ b/docs/settings/apm-settings.asciidoc @@ -38,27 +38,42 @@ If you'd like to change any of the default values, copy and paste the relevant settings into your `kibana.yml` configuration file. Changing these settings may disable features of the APM App. -xpack.apm.enabled:: Set to `false` to disable the APM app. Defaults to `true`. +[cols="2*<"] +|=== +| `xpack.apm.enabled` + | Set to `false` to disable the APM app. Defaults to `true`. -xpack.apm.ui.enabled:: Set to `false` to hide the APM app from the menu. Defaults to `true`. +| `xpack.apm.ui.enabled` + | Set to `false` to hide the APM app from the menu. Defaults to `true`. -xpack.apm.ui.transactionGroupBucketSize:: Number of top transaction groups displayed in the APM app. Defaults to `100`. +| `xpack.apm.ui.transactionGroupBucketSize` + | Number of top transaction groups displayed in the APM app. Defaults to `100`. -xpack.apm.ui.maxTraceItems:: Maximum number of child items displayed when viewing trace details. Defaults to `1000`. +| `xpack.apm.ui.maxTraceItems` + | Maximum number of child items displayed when viewing trace details. Defaults to `1000`. -apm_oss.indexPattern:: The index pattern used for integrations with Machine Learning and Query Bar. -It must match all apm indices. Defaults to `apm-*`. +| `apm_oss.indexPattern` + | The index pattern used for integrations with Machine Learning and Query Bar. + It must match all apm indices. Defaults to `apm-*`. -apm_oss.errorIndices:: Matcher for all {apm-server-ref}/error-indices.html[error indices]. Defaults to `apm-*`. +| `apm_oss.errorIndices` + | Matcher for all {apm-server-ref}/error-indices.html[error indices]. Defaults to `apm-*`. -apm_oss.onboardingIndices:: Matcher for all onboarding indices. Defaults to `apm-*`. +| `apm_oss.onboardingIndices` + | Matcher for all onboarding indices. Defaults to `apm-*`. -apm_oss.spanIndices:: Matcher for all {apm-server-ref}/span-indices.html[span indices]. Defaults to `apm-*`. +| `apm_oss.spanIndices` + | Matcher for all {apm-server-ref}/span-indices.html[span indices]. Defaults to `apm-*`. -apm_oss.transactionIndices:: Matcher for all {apm-server-ref}/transaction-indices.html[transaction indices]. Defaults to `apm-*`. +| `apm_oss.transactionIndices` + | Matcher for all {apm-server-ref}/transaction-indices.html[transaction indices]. Defaults to `apm-*`. -apm_oss.metricsIndices:: Matcher for all {apm-server-ref}/metricset-indices.html[metrics indices]. Defaults to `apm-*`. +| `apm_oss.metricsIndices` + | Matcher for all {apm-server-ref}/metricset-indices.html[metrics indices]. Defaults to `apm-*`. -apm_oss.sourcemapIndices:: Matcher for all {apm-server-ref}/sourcemap-indices.html[source map indices]. Defaults to `apm-*`. +| `apm_oss.sourcemapIndices` + | Matcher for all {apm-server-ref}/sourcemap-indices.html[source map indices]. Defaults to `apm-*`. + +|=== // end::general-apm-settings[] diff --git a/docs/settings/dev-settings.asciidoc b/docs/settings/dev-settings.asciidoc index 436f169b82ca3..c43b96a8668e0 100644 --- a/docs/settings/dev-settings.asciidoc +++ b/docs/settings/dev-settings.asciidoc @@ -12,12 +12,20 @@ They are enabled by default. [[grok-settings]] ==== Grok Debugger settings -`xpack.grokdebugger.enabled`:: -Set to `true` (default) to enable the <>. +[cols="2*<"] +|=== +| `xpack.grokdebugger.enabled` + | Set to `true` to enable the <>. Defaults to `true`. + +|=== [float] [[profiler-settings]] ==== {searchprofiler} Settings -`xpack.searchprofiler.enabled`:: -Set to `true` (default) to enable the <>. +[cols="2*<"] +|=== +| `xpack.searchprofiler.enabled` + | Set to `true` to enable the <>. Defaults to `true`. + +|=== diff --git a/docs/settings/general-infra-logs-ui-settings.asciidoc b/docs/settings/general-infra-logs-ui-settings.asciidoc index 7b32372a1f59a..2a9d4df1ff43c 100644 --- a/docs/settings/general-infra-logs-ui-settings.asciidoc +++ b/docs/settings/general-infra-logs-ui-settings.asciidoc @@ -1,17 +1,30 @@ -`xpack.infra.enabled`:: Set to `false` to disable the Logs and Metrics app plugin {kib}. Defaults to `true`. +[cols="2*<"] +|=== +| `xpack.infra.enabled` + | Set to `false` to disable the Logs and Metrics app plugin {kib}. Defaults to `true`. -`xpack.infra.sources.default.logAlias`:: Index pattern for matching indices that contain log data. Defaults to `filebeat-*,kibana_sample_data_logs*`. To match multiple wildcard patterns, use a comma to separate the names, with no space after the comma. For example, `logstash-app1-*,default-logs-*`. +| `xpack.infra.sources.default.logAlias` + | Index pattern for matching indices that contain log data. Defaults to `filebeat-*,kibana_sample_data_logs*`. To match multiple wildcard patterns, use a comma to separate the names, with no space after the comma. For example, `logstash-app1-*,default-logs-*`. -`xpack.infra.sources.default.metricAlias`:: Index pattern for matching indices that contain Metricbeat data. Defaults to `metricbeat-*`. To match multiple wildcard patterns, use a comma to separate the names, with no space after the comma. For example, `logstash-app1-*,default-logs-*`. +| `xpack.infra.sources.default.metricAlias` + | Index pattern for matching indices that contain Metricbeat data. Defaults to `metricbeat-*`. To match multiple wildcard patterns, use a comma to separate the names, with no space after the comma. For example, `logstash-app1-*,default-logs-*`. -`xpack.infra.sources.default.fields.timestamp`:: Timestamp used to sort log entries. Defaults to `@timestamp`. +| `xpack.infra.sources.default.fields.timestamp` + | Timestamp used to sort log entries. Defaults to `@timestamp`. -`xpack.infra.sources.default.fields.message`:: Fields used to display messages in the Logs app. Defaults to `['message', '@message']`. +| `xpack.infra.sources.default.fields.message` + | Fields used to display messages in the Logs app. Defaults to `['message', '@message']`. -`xpack.infra.sources.default.fields.tiebreaker`:: Field used to break ties between two entries with the same timestamp. Defaults to `_doc`. +| `xpack.infra.sources.default.fields.tiebreaker` + | Field used to break ties between two entries with the same timestamp. Defaults to `_doc`. -`xpack.infra.sources.default.fields.host`:: Field used to identify hosts. Defaults to `host.name`. +| `xpack.infra.sources.default.fields.host` + | Field used to identify hosts. Defaults to `host.name`. -`xpack.infra.sources.default.fields.container`:: Field used to identify Docker containers. Defaults to `container.id`. +| `xpack.infra.sources.default.fields.container` + | Field used to identify Docker containers. Defaults to `container.id`. -`xpack.infra.sources.default.fields.pod`:: Field used to identify Kubernetes pods. Defaults to `kubernetes.pod.uid`. +| `xpack.infra.sources.default.fields.pod` + | Field used to identify Kubernetes pods. Defaults to `kubernetes.pod.uid`. + +|=== diff --git a/docs/settings/graph-settings.asciidoc b/docs/settings/graph-settings.asciidoc index 7e597362b1cfc..8ccff21a26f74 100644 --- a/docs/settings/graph-settings.asciidoc +++ b/docs/settings/graph-settings.asciidoc @@ -10,5 +10,10 @@ You do not need to configure any settings to use the {graph-features}. [float] [[general-graph-settings]] ==== General graph settings -`xpack.graph.enabled`:: -Set to `false` to disable the {graph-features}. + +[cols="2*<"] +|=== +| `xpack.graph.enabled` + | Set to `false` to disable the {graph-features}. + +|=== diff --git a/docs/settings/i18n-settings.asciidoc b/docs/settings/i18n-settings.asciidoc index 4fe466bcb4580..6d92e74f17cb2 100644 --- a/docs/settings/i18n-settings.asciidoc +++ b/docs/settings/i18n-settings.asciidoc @@ -9,10 +9,7 @@ You do not need to configure any settings to run Kibana in English. ==== General i18n Settings `i18n.locale`:: -Kibana currently supports the following locales: -+ -- English - `en` (default) -- Chinese - `zh-CN` -- Japanese - `ja-JP` - - + {kib} supports the following locales: + * English - `en` (default) + * Chinese - `zh-CN` + * Japanese - `ja-JP` diff --git a/docs/settings/ml-settings.asciidoc b/docs/settings/ml-settings.asciidoc index 36578c909f513..24e38e73bca9b 100644 --- a/docs/settings/ml-settings.asciidoc +++ b/docs/settings/ml-settings.asciidoc @@ -11,12 +11,25 @@ enabled by default. [[general-ml-settings-kb]] ==== General {ml} settings -`xpack.ml.enabled`:: -Set to `true` (default) to enable {kib} {ml-features}. + -+ -If set to `false` in `kibana.yml`, the {ml} icon is hidden in this {kib} -instance. If `xpack.ml.enabled` is set to `true` in `elasticsearch.yml`, however, -you can still use the {ml} APIs. To disable {ml} entirely, see the -{ref}/ml-settings.html[{es} {ml} settings]. +[cols="2*<"] +|=== +| `xpack.ml.enabled` + | Set to `true` (default) to enable {kib} {ml-features}. + + + + If set to `false` in `kibana.yml`, the {ml} icon is hidden in this {kib} + instance. If `xpack.ml.enabled` is set to `true` in `elasticsearch.yml`, however, + you can still use the {ml} APIs. To disable {ml} entirely, see the + {ref}/ml-settings.html[{es} {ml} settings]. +|=== +[[data-visualizer-settings]] +==== {data-viz} settings + +[cols="2*<"] +|=== +| `xpack.ml.file_data_visualizer.max_file_size` + | Sets the file size limit when importing data in the {data-viz}. The default + value is `100MB`. The highest supported value for this setting is `1GB`. + +|=== diff --git a/docs/settings/monitoring-settings.asciidoc b/docs/settings/monitoring-settings.asciidoc index 6645f49029a51..f180f2c3ecc97 100644 --- a/docs/settings/monitoring-settings.asciidoc +++ b/docs/settings/monitoring-settings.asciidoc @@ -29,45 +29,49 @@ For more information, see [[monitoring-general-settings]] ==== General monitoring settings -`monitoring.enabled`:: -Set to `true` (default) to enable the {monitor-features} in {kib}. Unlike the -`monitoring.ui.enabled` setting, when this setting is `false`, the -monitoring back-end does not run and {kib} stats are not sent to the monitoring -cluster. - -`monitoring.ui.elasticsearch.hosts`:: -Specifies the location of the {es} cluster where your monitoring data is stored. -By default, this is the same as `elasticsearch.hosts`. This setting enables -you to use a single {kib} instance to search and visualize data in your -production cluster as well as monitor data sent to a dedicated monitoring -cluster. - -`monitoring.ui.elasticsearch.username`:: -Specifies the username used by {kib} monitoring to establish a persistent connection -in {kib} to the {es} monitoring cluster and to verify licensing status on the {es} -monitoring cluster. - -Every other request performed by the Stack Monitoring UI to the monitoring {es} -cluster uses the authenticated user's credentials, which must be the same on -both the {es} monitoring cluster and the {es} production cluster. - -If not set, {kib} uses the value of the `elasticsearch.username` setting. - -`monitoring.ui.elasticsearch.password`:: -Specifies the password used by {kib} monitoring to establish a persistent connection -in {kib} to the {es} monitoring cluster and to verify licensing status on the {es} -monitoring cluster. - -Every other request performed by the Stack Monitoring UI to the monitoring {es} -cluster uses the authenticated user's credentials, which must be the same on -both the {es} monitoring cluster and the {es} production cluster. - -If not set, {kib} uses the value of the `elasticsearch.password` setting. - -`monitoring.ui.elasticsearch.pingTimeout`:: -Specifies the time in milliseconds to wait for {es} to respond to internal -health checks. By default, it matches the `elasticsearch.pingTimeout` setting, -which has a default value of `30000`. +[cols="2*<"] +|=== +| `monitoring.enabled` + | Set to `true` (default) to enable the {monitor-features} in {kib}. Unlike the + `monitoring.ui.enabled` setting, when this setting is `false`, the + monitoring back-end does not run and {kib} stats are not sent to the monitoring + cluster. + +| `monitoring.ui.elasticsearch.hosts` + | Specifies the location of the {es} cluster where your monitoring data is stored. + By default, this is the same as `elasticsearch.hosts`. This setting enables + you to use a single {kib} instance to search and visualize data in your + production cluster as well as monitor data sent to a dedicated monitoring + cluster. + +| `monitoring.ui.elasticsearch.username` + | Specifies the username used by {kib} monitoring to establish a persistent connection + in {kib} to the {es} monitoring cluster and to verify licensing status on the {es} + monitoring cluster. + + + + Every other request performed by the Stack Monitoring UI to the monitoring {es} + cluster uses the authenticated user's credentials, which must be the same on + both the {es} monitoring cluster and the {es} production cluster. + + + + If not set, {kib} uses the value of the `elasticsearch.username` setting. + +| `monitoring.ui.elasticsearch.password` + | Specifies the password used by {kib} monitoring to establish a persistent connection + in {kib} to the {es} monitoring cluster and to verify licensing status on the {es} + monitoring cluster. + + + + Every other request performed by the Stack Monitoring UI to the monitoring {es} + cluster uses the authenticated user's credentials, which must be the same on + both the {es} monitoring cluster and the {es} production cluster. + + + + If not set, {kib} uses the value of the `elasticsearch.password` setting. + +| `monitoring.ui.elasticsearch.pingTimeout` + | Specifies the time in milliseconds to wait for {es} to respond to internal + health checks. By default, it matches the `elasticsearch.pingTimeout` setting, + which has a default value of `30000`. + +|=== [float] [[monitoring-collection-settings]] @@ -75,15 +79,18 @@ which has a default value of `30000`. These settings control how data is collected from {kib}. -`monitoring.kibana.collection.enabled`:: -Set to `true` (default) to enable data collection from the {kib} NodeJS server -for {kib} Dashboards to be featured in the Monitoring. +[cols="2*<"] +|=== +| `monitoring.kibana.collection.enabled` + | Set to `true` (default) to enable data collection from the {kib} NodeJS server + for {kib} Dashboards to be featured in the Monitoring. -`monitoring.kibana.collection.interval`:: -Specifies the number of milliseconds to wait in between data sampling on the -{kib} NodeJS server for the metrics that are displayed in the {kib} dashboards. -Defaults to `10000` (10 seconds). +| `monitoring.kibana.collection.interval` + | Specifies the number of milliseconds to wait in between data sampling on the + {kib} NodeJS server for the metrics that are displayed in the {kib} dashboards. + Defaults to `10000` (10 seconds). +|=== [float] [[monitoring-ui-settings]] @@ -94,27 +101,31 @@ However, the defaults work best in most circumstances. For more information about configuring {kib}, see {kibana-ref}/settings.html[Setting Kibana Server Properties]. -`monitoring.ui.elasticsearch.logFetchCount`:: -Specifies the number of log entries to display in the Monitoring UI. Defaults to -`10`. The maximum value is `50`. +[cols="2*<"] +|=== +| `monitoring.ui.elasticsearch.logFetchCount` + | Specifies the number of log entries to display in the Monitoring UI. Defaults to + `10`. The maximum value is `50`. -`monitoring.ui.max_bucket_size`:: -Specifies the number of term buckets to return out of the overall terms list when -performing terms aggregations to retrieve index and node metrics. For more -information about the `size` parameter, see -{ref}/search-aggregations-bucket-terms-aggregation.html#search-aggregations-bucket-terms-aggregation-size[Terms Aggregation]. -Defaults to `10000`. +| `monitoring.ui.max_bucket_size` + | Specifies the number of term buckets to return out of the overall terms list when + performing terms aggregations to retrieve index and node metrics. For more + information about the `size` parameter, see + {ref}/search-aggregations-bucket-terms-aggregation.html#search-aggregations-bucket-terms-aggregation-size[Terms Aggregation]. + Defaults to `10000`. -`monitoring.ui.min_interval_seconds`:: -Specifies the minimum number of seconds that a time bucket in a chart can -represent. Defaults to 10. If you modify the -`monitoring.ui.collection.interval` in `elasticsearch.yml`, use the same -value in this setting. +| `monitoring.ui.min_interval_seconds` + | Specifies the minimum number of seconds that a time bucket in a chart can + represent. Defaults to 10. If you modify the + `monitoring.ui.collection.interval` in `elasticsearch.yml`, use the same + value in this setting. -`monitoring.ui.enabled`:: -Set to `false` to hide the Monitoring UI in {kib}. The monitoring back-end -continues to run as an agent for sending {kib} stats to the monitoring -cluster. Defaults to `true`. +| `monitoring.ui.enabled` + | Set to `false` to hide the Monitoring UI in {kib}. The monitoring back-end + continues to run as an agent for sending {kib} stats to the monitoring + cluster. Defaults to `true`. + +|=== [float] [[monitoring-ui-cgroup-settings]] @@ -125,18 +136,20 @@ better decisions about your container performance, rather than guessing based on the overall machine performance. If you are not running your applications in a container, then Cgroup statistics are not useful. -`monitoring.ui.container.elasticsearch.enabled`:: - -For {es} clusters that are running in containers, this setting changes the -*Node Listing* to display the CPU utilization based on the reported Cgroup -statistics. It also adds the calculated Cgroup CPU utilization to the -*Node Overview* page instead of the overall operating system's CPU -utilization. Defaults to `false`. - -`monitoring.ui.container.logstash.enabled`:: - -For {ls} nodes that are running in containers, this setting -changes the {ls} *Node Listing* to display the CPU utilization -based on the reported Cgroup statistics. It also adds the -calculated Cgroup CPU utilization to the {ls} node detail -pages instead of the overall operating system’s CPU utilization. Defaults to `false`. +[cols="2*<"] +|=== +| `monitoring.ui.container.elasticsearch.enabled` + | For {es} clusters that are running in containers, this setting changes the + *Node Listing* to display the CPU utilization based on the reported Cgroup + statistics. It also adds the calculated Cgroup CPU utilization to the + *Node Overview* page instead of the overall operating system's CPU + utilization. Defaults to `false`. + +| `monitoring.ui.container.logstash.enabled` + | For {ls} nodes that are running in containers, this setting + changes the {ls} *Node Listing* to display the CPU utilization + based on the reported Cgroup statistics. It also adds the + calculated Cgroup CPU utilization to the {ls} node detail + pages instead of the overall operating system’s CPU utilization. Defaults to `false`. + +|=== diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index 9a45fb9ab1d0c..7c50dbf542d0d 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -14,45 +14,54 @@ You can configure `xpack.reporting` settings in your `kibana.yml` to: [float] [[general-reporting-settings]] ==== General reporting settings -[[xpack-enable-reporting]]`xpack.reporting.enabled`:: -Set to `false` to disable the {report-features}. -`xpack.reporting.encryptionKey`:: -Set to any text string. By default, Kibana will generate a random key when it -starts, which will cause pending reports to fail after restart. Configure this -setting to preserve the same key across multiple restarts and multiple instances of Kibana. +[cols="2*<"] +|=== +| [[xpack-enable-reporting]]`xpack.reporting.enabled` + | Set to `false` to disable the {report-features}. + +| `xpack.reporting.encryptionKey` + | Set to any text string. By default, {kib} will generate a random key when it + starts, which will cause pending reports to fail after restart. Configure this + setting to preserve the same key across multiple restarts and multiple instances of {kib}. + +|=== [float] [[reporting-kibana-server-settings]] -==== Kibana server settings +==== {kib} server settings -Reporting opens the {kib} web interface in a server process to generate -screenshots of {kib} visualizations. In most cases, the default settings -will work and you don't need to configure Reporting to communicate with {kib}. +Reporting opens the {kib} web interface in a server process to generate +screenshots of {kib} visualizations. In most cases, the default settings +will work and you don't need to configure Reporting to communicate with {kib}. However, if your client connections must go through a reverse-proxy -to access {kib}, Reporting configuration must have the proxy port, protocol, +to access {kib}, Reporting configuration must have the proxy port, protocol, and hostname set in the `xpack.reporting.kibanaServer.*` settings. -[NOTE] +[NOTE] ==== -If a reverse-proxy carries encrypted traffic from end-user -clients back to a {kib} server, the proxy port, protocol, and hostname -in Reporting settings must be valid for the encryption that the Reporting -browser will receive. Encrypted communications will fail if there are +If a reverse-proxy carries encrypted traffic from end-user +clients back to a {kib} server, the proxy port, protocol, and hostname +in Reporting settings must be valid for the encryption that the Reporting +browser will receive. Encrypted communications will fail if there are mismatches in the host information between the request and the certificate on the server. Configuring the `xpack.reporting.kibanaServer` settings to point to a -proxy host requires that the Kibana server has network access to the proxy. +proxy host requires that the {kib} server has network access to the proxy. ==== -`xpack.reporting.kibanaServer.port`:: -The port for accessing Kibana, if different from the `server.port` value. +[cols="2*<"] +|=== +| `xpack.reporting.kibanaServer.port` + | The port for accessing {kib}, if different from the `server.port` value. + +| `xpack.reporting.kibanaServer.protocol` + | The protocol for accessing {kib}, typically `http` or `https`. -`xpack.reporting.kibanaServer.protocol`:: -The protocol for accessing Kibana, typically `http` or `https`. +| `xpack.reporting.kibanaServer.hostname` + | The hostname for accessing {kib}, if different from the `server.host` value. -`xpack.reporting.kibanaServer.hostname`:: -The hostname for accessing {kib}, if different from the `server.host` value. +|=== [NOTE] ============ @@ -68,55 +77,67 @@ because, in the Reporting browser, it becomes an automatic redirect to `"0.0.0.0 ==== Background job settings Reporting generates reports in the background and jobs are coordinated using documents -in Elasticsearch. Depending on how often you generate reports and the overall number of +in {es}. Depending on how often you generate reports and the overall number of reports, you might need to change the following settings. -`xpack.reporting.queue.indexInterval`:: -How often the index that stores reporting jobs rolls over to a new index. -Valid values are `year`, `month`, `week`, `day`, and `hour`. Defaults to `week`. +[cols="2*<"] +|=== +| `xpack.reporting.queue.indexInterval` + | How often the index that stores reporting jobs rolls over to a new index. + Valid values are `year`, `month`, `week`, `day`, and `hour`. Defaults to `week`. -`xpack.reporting.queue.pollEnabled`:: -Set to `true` (default) to enable the Kibana instance to to poll the index for -pending jobs and claim them for execution. Setting this to `false` allows the -Kibana instance to only add new jobs to the reporting queue, list jobs, and -provide the downloads to completed report through the UI. +| `xpack.reporting.queue.pollEnabled` + | Set to `true` (default) to enable the {kib} instance to to poll the index for + pending jobs and claim them for execution. Setting this to `false` allows the + {kib} instance to only add new jobs to the reporting queue, list jobs, and + provide the downloads to completed report through the UI. + +|=== [NOTE] ============ -Running multiple instances of Kibana in a cluster for load balancing of +Running multiple instances of {kib} in a cluster for load balancing of reporting requires identical values for `xpack.reporting.encryptionKey` and, if security is enabled, `xpack.security.encryptionKey`. ============ -`xpack.reporting.queue.pollInterval`:: -Specifies the number of milliseconds that the reporting poller waits between polling the -index for any pending Reporting jobs. Defaults to `3000` (3 seconds). +[cols="2*<"] +|=== +| `xpack.reporting.queue.pollInterval` + | Specifies the number of milliseconds that the reporting poller waits between polling the + index for any pending Reporting jobs. Defaults to `3000` (3 seconds). + +| [[xpack-reporting-q-timeout]] `xpack.reporting.queue.timeout` + | How long each worker has to produce a report. If your machine is slow or under + heavy load, you might need to increase this timeout. Specified in milliseconds. + If a Reporting job execution time goes over this time limit, the job will be + marked as a failure and there will not be a download available. + Defaults to `120000` (two minutes). -[[xpack-reporting-q-timeout]]`xpack.reporting.queue.timeout`:: -How long each worker has to produce a report. If your machine is slow or under -heavy load, you might need to increase this timeout. Specified in milliseconds. -If a Reporting job execution time goes over this time limit, the job will be -marked as a failure and there will not be a download available. -Defaults to `120000` (two minutes). +|=== [float] [[reporting-capture-settings]] ==== Capture settings -Reporting works by capturing screenshots from Kibana. The following settings +Reporting works by capturing screenshots from {kib}. The following settings control the capturing process. -`xpack.reporting.capture.timeouts.openUrl`:: -How long to allow the Reporting browser to wait for the initial data of the -Kibana page to load. Defaults to `30000` (30 seconds). +[cols="2*<"] +|=== +| `xpack.reporting.capture.timeouts.openUrl` + | How long to allow the Reporting browser to wait for the initial data of the + {kib} page to load. Defaults to `30000` (30 seconds). + +| `xpack.reporting.capture.timeouts.waitForElements` + | How long to allow the Reporting browser to wait for the visualization panels to + load on the {kib} page. Defaults to `30000` (30 seconds). -`xpack.reporting.capture.timeouts.waitForElements`:: -How long to allow the Reporting browser to wait for the visualization panels to -load on the Kibana page. Defaults to `30000` (30 seconds). +| `xpack.reporting.capture.timeouts.renderComplete` + | How long to allow the Reporting browser to wait for each visualization to + signal that it is done renderings. Defaults to `30000` (30 seconds). -`xpack.reporting.capture.timeouts.renderComplete`:: -How long to allow the Reporting brwoser to wait for each visualization to -signal that it is done renderings. Defaults to `30000` (30 seconds). +|=== [NOTE] ============ @@ -126,20 +147,24 @@ capturing the page with a screenshot. As a result, a download will be available, but there will likely be errors in the visualizations in the report. ============ -`xpack.reporting.capture.maxAttempts`:: -If capturing a report fails for any reason, Kibana will re-attempt othe reporting -job, as many times as this setting. Defaults to `3`. +[cols="2*<"] +|=== +| `xpack.reporting.capture.maxAttempts` + | If capturing a report fails for any reason, {kib} will re-attempt other reporting + job, as many times as this setting. Defaults to `3`. -`xpack.reporting.capture.loadDelay`:: -When visualizations are not evented, this is the amount of time before -taking a screenshot. All visualizations that ship with Kibana are evented, so this -setting should not have much effect. If you are seeing empty images instead of -visualizations, try increasing this value. -Defaults to `3000` (3 seconds). +| `xpack.reporting.capture.loadDelay` + | When visualizations are not evented, this is the amount of time before + taking a screenshot. All visualizations that ship with {kib} are evented, so this + setting should not have much effect. If you are seeing empty images instead of + visualizations, try increasing this value. + Defaults to `3000` (3 seconds). -[[xpack-reporting-browser]]`xpack.reporting.capture.browser.type`:: -Specifies the browser to use to capture screenshots. This setting exists for -backward compatibility. The only valid option is `chromium`. +| [[xpack-reporting-browser]] `xpack.reporting.capture.browser.type` + | Specifies the browser to use to capture screenshots. This setting exists for + backward compatibility. The only valid option is `chromium`. + +|=== [float] [[reporting-chromium-settings]] @@ -147,47 +172,59 @@ backward compatibility. The only valid option is `chromium`. When `xpack.reporting.capture.browser.type` is set to `chromium` (default) you can also specify the following settings. -`xpack.reporting.capture.browser.chromium.disableSandbox`:: -Elastic recommends that you research the feasibility of enabling unprivileged user namespaces. -See Chromium Sandbox for additional information. Defaults to false for all operating systems except Debian, -Red Hat Linux, and CentOS which use true +[cols="2*<"] +|=== +| `xpack.reporting.capture.browser.chromium.disableSandbox` + | It is recommended that you research the feasibility of enabling unprivileged user namespaces. + See Chromium Sandbox for additional information. Defaults to false for all operating systems except Debian, + Red Hat Linux, and CentOS which use true. -`xpack.reporting.capture.browser.chromium.proxy.enabled`:: -Enables the proxy for Chromium to use. When set to `true`, you must also specify the -`xpack.reporting.capture.browser.chromium.proxy.server` setting. -Defaults to `false` +| `xpack.reporting.capture.browser.chromium.proxy.enabled` + | Enables the proxy for Chromium to use. When set to `true`, you must also specify the + `xpack.reporting.capture.browser.chromium.proxy.server` setting. + Defaults to `false`. -`xpack.reporting.capture.browser.chromium.proxy.server`:: -The uri for the proxy server. Providing the username and password for the proxy server via the uri is not supported. +| `xpack.reporting.capture.browser.chromium.proxy.server` + | The uri for the proxy server. Providing the username and password for the proxy server via the uri is not supported. -`xpack.reporting.capture.browser.chromium.proxy.bypass`:: -An array of hosts that should not go through the proxy server and should use a direct connection instead. -Examples of valid entries are "elastic.co", "*.elastic.co", ".elastic.co", ".elastic.co:5601" +| `xpack.reporting.capture.browser.chromium.proxy.bypass` + | An array of hosts that should not go through the proxy server and should use a direct connection instead. + Examples of valid entries are "elastic.co", "*.elastic.co", ".elastic.co", ".elastic.co:5601". +|=== [float] [[reporting-csv-settings]] ==== CSV settings -[[xpack-reporting-csv]]`xpack.reporting.csv.maxSizeBytes`:: -The maximum size of a CSV file before being truncated. This setting exists to prevent -large exports from causing performance and storage issues. -Defaults to `10485760` (10mB) + +[cols="2*<"] +|=== +| [[xpack-reporting-csv]] `xpack.reporting.csv.maxSizeBytes` + | The maximum size of a CSV file before being truncated. This setting exists to prevent + large exports from causing performance and storage issues. + Defaults to `10485760` (10mB). + +|=== [float] [[reporting-advanced-settings]] ==== Advanced settings -`xpack.reporting.index`:: -Reporting uses a weekly index in Elasticsearch to store the reporting job and -the report content. The index is automatically created if it does not already -exist. Configure this to a unique value, beginning with `.reporting-`, for every -Kibana instance that has a unique `kibana.index` setting. Defaults to `.reporting` +[cols="2*<"] +|=== +| `xpack.reporting.index` + | Reporting uses a weekly index in {es} to store the reporting job and + the report content. The index is automatically created if it does not already + exist. Configure this to a unique value, beginning with `.reporting-`, for every + {kib} instance that has a unique `kibana.index` setting. Defaults to `.reporting`. + +| `xpack.reporting.roles.allow` + | Specifies the roles in addition to superusers that can use reporting. + Defaults to `[ "reporting_user" ]`. + -`xpack.reporting.roles.allow`:: -Specifies the roles in addition to superusers that can use reporting. -Defaults to `[ "reporting_user" ]` -+ --- -NOTE: Each user has access to only their own reports. +|=== --- +[NOTE] +============ +Each user has access to only their own reports. +============ diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index 16d68a7759f77..8f6905d643139 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -12,55 +12,83 @@ You do not need to configure any additional settings to use the [[general-security-settings]] ==== General security settings -`xpack.security.enabled`:: -By default, {kib} automatically detects whether to enable the -{security-features} based on the license and whether {es} {security-features} -are enabled. -+ -Do not set this to `false`; it disables the login form, user and role management -screens, and authorization using <>. To disable -{security-features} entirely, see -{ref}/security-settings.html[{es} security settings]. - -`xpack.security.audit.enabled`:: -Set to `true` to enable audit logging for security events. By default, it is set -to `false`. For more details see <>. +[cols="2*<"] +|=== +| `xpack.security.enabled` + | By default, {kib} automatically detects whether to enable the + {security-features} based on the license and whether {es} {security-features} + are enabled. + + + + Do not set this to `false`; it disables the login form, user and role management + screens, and authorization using <>. To disable + {security-features} entirely, see + {ref}/security-settings.html[{es} security settings]. + +| `xpack.security.audit.enabled` + | Set to `true` to enable audit logging for security events. By default, it is set + to `false`. For more details see <>. + +|=== [float] [[security-ui-settings]] ==== User interface security settings -You can configure the following settings in the `kibana.yml` file: - -`xpack.security.cookieName`:: -Sets the name of the cookie used for the session. The default value is `"sid"`. - -`xpack.security.encryptionKey`:: -An arbitrary string of 32 characters or more that is used to encrypt credentials -in a cookie. It is crucial that this key is not exposed to users of {kib}. By -default, a value is automatically generated in memory. If you use that default -behavior, all sessions are invalidated when {kib} restarts. -In addition, high-availability deployments of {kib} will behave unexpectedly -if this setting isn't the same for all instances of {kib}. - -`xpack.security.secureCookies`:: -Sets the `secure` flag of the session cookie. The default value is `false`. It -is automatically set to `true` if `server.ssl.enabled` is set to `true`. Set -this to `true` if SSL is configured outside of {kib} (for example, you are -routing requests through a load balancer or proxy). - -`xpack.security.session.idleTimeout`:: -Sets the session duration. The format is a string of `[ms|s|m|h|d|w|M|Y]` -(e.g. '70ms', '5s', '3d', '1Y'). By default, sessions stay active until the -browser is closed. When this is set to an explicit idle timeout, closing the -browser still requires the user to log back in to {kib}. - -`xpack.security.session.lifespan`:: -Sets the maximum duration, also known as "absolute timeout". The format is a -string of `[ms|s|m|h|d|w|M|Y]` (e.g. '70ms', '5s', '3d', '1Y'). By default, -a session can be renewed indefinitely. When this value is set, a session will end -once its lifespan is exceeded, even if the user is not idle. NOTE: if `idleTimeout` -is not set, this setting will still cause sessions to expire. - -`xpack.security.loginAssistanceMessage`:: -Adds a message to the login screen. Useful for displaying information about maintenance windows, links to corporate sign up pages etc. +You can configure the following settings in the `kibana.yml` file. + +[cols="2*<"] +|=== +| `xpack.security.cookieName` + | Sets the name of the cookie used for the session. The default value is `"sid"`. + +| `xpack.security.encryptionKey` + | An arbitrary string of 32 characters or more that is used to encrypt credentials + in a cookie. It is crucial that this key is not exposed to users of {kib}. By + default, a value is automatically generated in memory. If you use that default + behavior, all sessions are invalidated when {kib} restarts. + In addition, high-availability deployments of {kib} will behave unexpectedly + if this setting isn't the same for all instances of {kib}. + +| `xpack.security.secureCookies` + | Sets the `secure` flag of the session cookie. The default value is `false`. It + is automatically set to `true` if `server.ssl.enabled` is set to `true`. Set + this to `true` if SSL is configured outside of {kib} (for example, you are + routing requests through a load balancer or proxy). + +| `xpack.security.session.idleTimeout` + | Sets the session duration. By default, sessions stay active until the + browser is closed. When this is set to an explicit idle timeout, closing the + browser still requires the user to log back in to {kib}. + +|=== + +[TIP] +============ +The format is a string of `[ms|s|m|h|d|w|M|Y]` +(e.g. '70ms', '5s', '3d', '1Y'). +============ + +[cols="2*<"] +|=== + +| `xpack.security.session.lifespan` + | Sets the maximum duration, also known as "absolute timeout". By default, + a session can be renewed indefinitely. When this value is set, a session will end + once its lifespan is exceeded, even if the user is not idle. NOTE: if `idleTimeout` + is not set, this setting will still cause sessions to expire. + +|=== + +[TIP] +============ +The format is a +string of `[ms|s|m|h|d|w|M|Y]` (e.g. '70ms', '5s', '3d', '1Y'). +============ + +[cols="2*<"] +|=== + +| `xpack.security.loginAssistanceMessage` + | Adds a message to the login screen. Useful for displaying information about maintenance windows, links to corporate sign up pages etc. + +|=== diff --git a/docs/settings/spaces-settings.asciidoc b/docs/settings/spaces-settings.asciidoc index bb0a15b29a087..bda5f00f762cd 100644 --- a/docs/settings/spaces-settings.asciidoc +++ b/docs/settings/spaces-settings.asciidoc @@ -5,18 +5,22 @@ Spaces settings ++++ -By default, Spaces is enabled in Kibana, and you can secure Spaces using +By default, Spaces is enabled in Kibana, and you can secure Spaces using roles when Security is enabled. [float] [[spaces-settings]] ==== Spaces settings -`xpack.spaces.enabled`:: -Set to `true` (default) to enable Spaces in {kib}. +[cols="2*<"] +|=== +| `xpack.spaces.enabled` + | Set to `true` (default) to enable Spaces in {kib}. -`xpack.spaces.maxSpaces`:: -The maximum amount of Spaces that can be used with this instance of Kibana. Some operations -in Kibana return all spaces using a single `_search` from Elasticsearch, so this must be -set lower than the `index.max_result_window` in Elasticsearch. -Defaults to `1000`. \ No newline at end of file +| `xpack.spaces.maxSpaces` + | The maximum amount of Spaces that can be used with this instance of {kib}. Some operations + in {kib} return all spaces using a single `_search` from {es}, so this must be + set lower than the `index.max_result_window` in {es}. + Defaults to `1000`. + +|=== diff --git a/docs/settings/telemetry-settings.asciidoc b/docs/settings/telemetry-settings.asciidoc index ad5f53ad879f8..33f167b13b310 100644 --- a/docs/settings/telemetry-settings.asciidoc +++ b/docs/settings/telemetry-settings.asciidoc @@ -8,7 +8,7 @@ By default, Usage Collection (also known as Telemetry) is enabled. This helps us learn about the {kib} features that our users are most interested in, so we can focus our efforts on making them even better. -You can control whether this data is sent from the {kib} servers, or if it should be sent +You can control whether this data is sent from the {kib} servers, or if it should be sent from the user's browser, in case a firewall is blocking the connections from the server. Additionally, you can decide to completely disable this feature either in the config file or in {kib} via *Management > Kibana > Advanced Settings > Usage Data*. See our https://www.elastic.co/legal/privacy-statement[Privacy Statement] to learn more. @@ -17,22 +17,30 @@ See our https://www.elastic.co/legal/privacy-statement[Privacy Statement] to lea [[telemetry-general-settings]] ==== General telemetry settings -`telemetry.enabled`:: *Default: true*. -Set to `true` to send cluster statistics to Elastic. Reporting your -cluster statistics helps us improve your user experience. Your data is never -shared with anyone. Set to `false` to disable statistics reporting from any -browser connected to the {kib} instance. - -`telemetry.sendUsageFrom`:: *Default: 'browser'*. -Set to `'server'` to report the cluster statistics from the {kib} server. -If the server fails to connect to our endpoint at https://telemetry.elastic.co/, it assumes -it is behind a firewall and falls back to `'browser'` to send it from users' browsers -when they are navigating through {kib}. - -`telemetry.optIn`:: *Default: true*. -Set to `true` to automatically opt into reporting cluster statistics. You can also opt out through -*Advanced Settings* in {kib}. - -`telemetry.allowChangingOptInStatus`:: *Default: true*. -Set to `true` to allow overwriting the `telemetry.optIn` setting via the {kib} UI. -Note: When `false`, `telemetry.optIn` must be `true`. To disable telemetry and not allow users to change that parameter, use `telemetry.enabled`. +[cols="2*<"] +|=== +| `telemetry.enabled` + | Set to `true` to send cluster statistics to Elastic. Reporting your + cluster statistics helps us improve your user experience. Your data is never + shared with anyone. Set to `false` to disable statistics reporting from any + browser connected to the {kib} instance. Defaults to `true`. + +| `telemetry.sendUsageFrom` + | Set to `'server'` to report the cluster statistics from the {kib} server. + If the server fails to connect to our endpoint at https://telemetry.elastic.co/, it assumes + it is behind a firewall and falls back to `'browser'` to send it from users' browsers + when they are navigating through {kib}. Defaults to 'browser'. + +| `telemetry.optIn` + | Set to `true` to automatically opt into reporting cluster statistics. You can also opt out through + *Advanced Settings* in {kib}. Defaults to `true`. + +| `telemetry.allowChangingOptInStatus` + | Set to `true` to allow overwriting the `telemetry.optIn` setting via the {kib} UI. Defaults to `true`. + + +|=== + +[NOTE] +============ +When `false`, `telemetry.optIn` must be `true`. To disable telemetry and not allow users to change that parameter, use `telemetry.enabled`. +============ diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 41fe8d337c03b..cc662af08b8f1 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -1,7 +1,7 @@ [[settings]] -== Configuring Kibana +== Configuring {kib} -The Kibana server reads properties from the `kibana.yml` file on startup. The +The {kib} server reads properties from the `kibana.yml` file on startup. The location of this file differs depending on how you installed {kib}. For example, if you installed {kib} from an archive distribution (`.tar.gz` or `.zip`), by default it is in `$KIBANA_HOME/config`. By default, with package distributions @@ -11,444 +11,622 @@ The default host and port settings configure {kib} to run on `localhost:5601`. T variety of other options. Finally, environment variables can be injected into configuration using `${MY_ENV_VAR}` syntax. -.Kibana configuration settings +[cols="2*<"] +|=== -`console.enabled:`:: *Default: true* Set to false to disable Console. Toggling -this will cause the server to regenerate assets on the next startup, which may -cause a delay before pages start being served. +| `console.enabled:` + | Toggling this causes the server to regenerate assets on the next startup, +which may cause a delay before pages start being served. +Set to `false` to disable Console. *Default: `true`* -`cpu.cgroup.path.override:`:: Override for cgroup cpu path when mounted in a -manner that is inconsistent with `/proc/self/cgroup` +| `cpu.cgroup.path.override:` + | Override for cgroup cpu path when mounted in a +manner that is inconsistent with `/proc/self/cgroup`. -`cpuacct.cgroup.path.override:`:: Override for cgroup cpuacct path when mounted -in a manner that is inconsistent with `/proc/self/cgroup` +| `cpuacct.cgroup.path.override:` + | Override for cgroup cpuacct path when mounted +in a manner that is inconsistent with `/proc/self/cgroup`. -`csp.rules:`:: A template -https://w3c.github.io/webappsec-csp/[content-security-policy] that disables -certain unnecessary and potentially insecure capabilities in the browser. We -strongly recommend that you keep the default CSP rules that ship with Kibana. +| `csp.rules:` + | A https://w3c.github.io/webappsec-csp/[content-security-policy] template +that disables certain unnecessary and potentially insecure capabilities in +the browser. It is strongly recommended that you keep the default CSP rules +that ship with {kib}. -`csp.strict:`:: *Default: `true`* Blocks access to Kibana to any browser that -does not enforce even rudimentary CSP rules. In practice, this will disable +| `csp.strict:` + | Blocks {kib} access to any browser that +does not enforce even rudimentary CSP rules. In practice, this disables support for older, less safe browsers like Internet Explorer. -See <> for more information. - -`csp.warnLegacyBrowsers:`:: *Default: `true`* Shows a warning message after -loading Kibana to any browser that does not enforce even rudimentary CSP rules, -though Kibana is still accessible. This configuration is effectively ignored -when `csp.strict` is enabled. - -`elasticsearch.customHeaders:`:: *Default: `{}`* Header names and values to send -to Elasticsearch. Any custom headers cannot be overwritten by client-side -headers, regardless of the `elasticsearch.requestHeadersWhitelist` configuration. - -`elasticsearch.hosts:`:: *Default: `[ "http://localhost:9200" ]`* The URLs of the {es} instances to use for all your queries. All nodes -listed here must be on the same cluster. +For more information, refer to <>. +*Default: `true`* + +| `csp.warnLegacyBrowsers:` + | Shows a warning message after loading {kib} to any browser that does not +enforce even rudimentary CSP rules, though {kib} is still accessible. This +configuration is effectively ignored when `csp.strict` is enabled. +*Default: `true`* + +| `elasticsearch.customHeaders:` + | Header names and values to send to {es}. Any custom headers cannot be +overwritten by client-side headers, regardless of the +`elasticsearch.requestHeadersWhitelist` configuration. *Default: `{}`* + +| `elasticsearch.hosts:` + | The URLs of the {es} instances to use for all your queries. All nodes +listed here must be on the same cluster. *Default: `[ "http://localhost:9200" ]`* + -To enable SSL/TLS for outbound connections to {es}, use the `https` protocol in this setting. - -`elasticsearch.logQueries:`:: *Default: `false`* Logs queries sent to -Elasticsearch. Requires `logging.verbose` set to `true`. This is useful for -seeing the query DSL generated by applications that currently do not have an -inspector, for example Timelion and Monitoring. - -`elasticsearch.pingTimeout:`:: -*Default: the value of the `elasticsearch.requestTimeout` setting* Time in -milliseconds to wait for Elasticsearch to respond to pings. - -`elasticsearch.preserveHost:`:: *Default: true* When this setting’s value is -true, Kibana uses the hostname specified in the `server.host` setting. When the -value of this setting is `false`, Kibana uses the hostname of the host that -connects to this Kibana instance. - -`elasticsearch.requestHeadersWhitelist:`:: *Default: `[ 'authorization' ]`* List -of Kibana client-side headers to send to Elasticsearch. To send *no* client-side -headers, set this value to [] (an empty list). -Removing the `authorization` header from being whitelisted means that you cannot -use <> in Kibana. - -`elasticsearch.requestTimeout:`:: *Default: 30000* Time in milliseconds to wait -for responses from the back end or Elasticsearch. This value must be a positive -integer. - -`elasticsearch.shardTimeout:`:: *Default: 30000* Time in milliseconds for -Elasticsearch to wait for responses from shards. Set to 0 to disable. - -`elasticsearch.sniffInterval:`:: *Default: false* Time in milliseconds between -requests to check Elasticsearch for an updated list of nodes. - -`elasticsearch.sniffOnStart:`:: *Default: false* Attempt to find other -Elasticsearch nodes on startup. - -`elasticsearch.sniffOnConnectionFault:`:: *Default: false* Update the list of -Elasticsearch nodes immediately following a connection fault. - -`elasticsearch.ssl.alwaysPresentCertificate:`:: *Default: false* Controls {kib}'s behavior in regard to presenting a client certificate when -requested by {es}. This setting applies to all outbound SSL/TLS connections to {es}, including requests that are proxied for end users. -+ -WARNING: If {es} uses certificates to authenticate end users with a PKI realm and `elasticsearch.ssl.alwaysPresentCertificate` is `true`, -proxied requests may be executed as the identity that is tied to the {kib} server. - -`elasticsearch.ssl.certificate:` and `elasticsearch.ssl.key:`:: Paths to a PEM-encoded X.509 client certificate and its corresponding -private key. These are used by {kib} to authenticate itself when making outbound SSL/TLS connections to {es}. For this setting to take -effect, the `xpack.security.http.ssl.client_authentication` setting in {es} must be also be set to `"required"` or `"optional"` to request a -client certificate from {kib}. +To enable SSL/TLS for outbound connections to {es}, use the `https` protocol +in this setting. + +| `elasticsearch.logQueries:` + | Log queries sent to {es}. Requires `logging.verbose` set to `true`. +This is useful for seeing the query DSL generated by applications that +currently do not have an inspector, for example Timelion and Monitoring. +*Default: `false`* + +| `elasticsearch.pingTimeout:` + | Time in milliseconds to wait for {es} to respond to pings. +*Default: the value of the `elasticsearch.requestTimeout` setting* + +| `elasticsearch.preserveHost:` + | When the value is `true`, {kib} uses the hostname specified in the +`server.host` setting. When the value is `false`, {kib} uses +the hostname of the host that connects to this {kib} instance. *Default: `true`* + +| `elasticsearch.requestHeadersWhitelist:` + | List of {kib} client-side headers to send to {es}. To send *no* client-side +headers, set this value to [] (an empty list). Removing the `authorization` +header from being whitelisted means that you cannot use +<> in {kib}. +*Default: `[ 'authorization' ]`* + +| `elasticsearch.requestTimeout:` + | Time in milliseconds to wait for responses from the back end or {es}. +This value must be a positive integer. *Default: `30000`* + +| `elasticsearch.shardTimeout:` + | Time in milliseconds for {es} to wait for responses from shards. +Set to 0 to disable. *Default: `30000`* + +| `elasticsearch.sniffInterval:` + | Time in milliseconds between requests to check {es} for an updated list of +nodes. *Default: `false`* + +| `elasticsearch.sniffOnStart:` + | Attempt to find other {es} nodes on startup. *Default: `false`* + +| `elasticsearch.sniffOnConnectionFault:` + | Update the list of {es} nodes immediately following a connection fault. +*Default: `false`* + +| `elasticsearch.ssl.alwaysPresentCertificate:` + | Controls {kib} behavior in regard to presenting a client certificate when +requested by {es}. This setting applies to all outbound SSL/TLS connections +to {es}, including requests that are proxied for end users. *Default: `false`* + +|=== + +[WARNING] +============ +When {es} uses certificates to authenticate end users with a PKI realm +and `elasticsearch.ssl.alwaysPresentCertificate` is `true`, +proxied requests may be executed as the identity that is tied to the {kib} +server. +============ + +[cols="2*<"] +|=== + +| `elasticsearch.ssl.certificate:` and `elasticsearch.ssl.key:` + | Paths to a PEM-encoded X.509 client certificate and its corresponding +private key. These are used by {kib} to authenticate itself when making +outbound SSL/TLS connections to {es}. For this setting to take effect, the +`xpack.security.http.ssl.client_authentication` setting in {es} must be also +be set to `"required"` or `"optional"` to request a client certificate from +{kib}. + +|=== + +[NOTE] +============ +These settings cannot be used in conjunction with `elasticsearch.ssl.keystore.path`. +============ + +[cols="2*<"] +|=== + +| `elasticsearch.ssl.certificateAuthorities:` + | Paths to one or more PEM-encoded X.509 certificate authority (CA) +certificates, which make up a trusted certificate chain for {es}. This chain is +used by {kib} to establish trust when making outbound SSL/TLS connections to +{es}. + -NOTE: These settings cannot be used in conjunction with `elasticsearch.ssl.keystore.path`. - -`elasticsearch.ssl.certificateAuthorities:`:: Paths to one or more PEM-encoded X.509 certificate authority (CA) certificates which make up a -trusted certificate chain for {es}. This chain is used by {kib} to establish trust when making outbound SSL/TLS connections to {es}. +In addition to this setting, trusted certificates may be specified via +`elasticsearch.ssl.keystore.path` and/or `elasticsearch.ssl.truststore.path`. + +| `elasticsearch.ssl.keyPassphrase:` + | The password that decrypts the private key that is specified +via `elasticsearch.ssl.key`. This value is optional, as the key may not be +encrypted. + +| `elasticsearch.ssl.keystore.path:` + | Path to a PKCS#12 keystore that contains an X.509 client certificate and it's +corresponding private key. These are used by {kib} to authenticate itself when +making outbound SSL/TLS connections to {es}. For this setting, you must also set +the `xpack.security.http.ssl.client_authentication` setting in {es} to +`"required"` or `"optional"` to request a client certificate from {kib}. + -In addition to this setting, trusted certificates may be specified via `elasticsearch.ssl.keystore.path` and/or +If the keystore contains any additional certificates, they are used as a +trusted certificate chain for {es}. This chain is used by {kib} to establish +trust when making outbound SSL/TLS connections to {es}. In addition to this +setting, trusted certificates may be specified via +`elasticsearch.ssl.certificateAuthorities` and/or `elasticsearch.ssl.truststore.path`. -`elasticsearch.ssl.keyPassphrase:`:: The password that will be used to decrypt the private key that is specified via -`elasticsearch.ssl.key`. This value is optional, as the key may not be encrypted. +|=== -`elasticsearch.ssl.keystore.path:`:: Path to a PKCS#12 keystore that contains an X.509 client certificate and its corresponding private key. -These are used by {kib} to authenticate itself when making outbound SSL/TLS connections to {es}. For this setting to take effect, the -`xpack.security.http.ssl.client_authentication` setting in {es} must also be set to `"required"` or `"optional"` to request a client -certificate from {kib}. -+ --- -If the keystore contains any additional certificates, those will be used as a trusted certificate chain for {es}. This chain is used by -{kib} to establish trust when making outbound SSL/TLS connections to {es}. In addition to this setting, trusted certificates may be -specified via `elasticsearch.ssl.certificateAuthorities` and/or `elasticsearch.ssl.truststore.path`. +[NOTE] +============ +This setting cannot be used in conjunction with +`elasticsearch.ssl.certificate` or `elasticsearch.ssl.key`. +============ -NOTE: This setting cannot be used in conjunction with `elasticsearch.ssl.certificate` or `elasticsearch.ssl.key`. --- +[cols="2*<"] +|=== -`elasticsearch.ssl.keystore.password:`:: The password that will be used to decrypt the keystore that is specified via -`elasticsearch.ssl.keystore.path`. If the keystore has no password, leave this unset. If the keystore has an empty password, set this to +| `elasticsearch.ssl.keystore.password:` + | The password that decrypts the keystore specified via +`elasticsearch.ssl.keystore.path`. If the keystore has no password, leave this +as blank. If the keystore has an empty password, set this to `""`. -`elasticsearch.ssl.truststore.path:`:: Path to a PKCS#12 trust store that contains one or more X.509 certificate authority (CA) certificates -which make up a trusted certificate chain for {es}. This chain is used by {kib} to establish trust when making outbound SSL/TLS connections -to {es}. +| `elasticsearch.ssl.truststore.path:`:: + | Path to a PKCS#12 trust store that contains one or more X.509 certificate +authority (CA) certificates, which make up a trusted certificate chain for +{es}. This chain is used by {kib} to establish trust when making outbound +SSL/TLS connections to {es}. + -In addition to this setting, trusted certificates may be specified via `elasticsearch.ssl.certificateAuthorities` and/or +In addition to this setting, trusted certificates may be specified via +`elasticsearch.ssl.certificateAuthorities` and/or `elasticsearch.ssl.keystore.path`. -`elasticsearch.ssl.truststore.password:`:: The password that will be used to decrypt the trust store specified via -`elasticsearch.ssl.truststore.path`. If the trust store has no password, leave this unset. If the trust store has an empty password, set -this to `""`. - -`elasticsearch.ssl.verificationMode:`:: *Default: `"full"`* Controls the verification of the server certificate that {kib} receives when -making an outbound SSL/TLS connection to {es}. Valid values are `"full"`, `"certificate"`, and `"none"`. Using `"full"` will perform -hostname verification, using `"certificate"` will skip hostname verification, and using `"none"` will skip verification entirely. - -`elasticsearch.startupTimeout:`:: *Default: 5000* Time in milliseconds to wait -for Elasticsearch at Kibana startup before retrying. - -`elasticsearch.username:` and `elasticsearch.password:`:: If your Elasticsearch -is protected with basic authentication, these settings provide the username and -password that the Kibana server uses to perform maintenance on the Kibana index -at startup. Your Kibana users still need to authenticate with Elasticsearch, -which is proxied through the Kibana server. - -`interpreter.enableInVisualize`:: *Default: true* Enables use of interpreter in -Visualize. - -`kibana.defaultAppId:`:: *Default: "home"* The default application to load. - -`kibana.index:`:: *Default: ".kibana"* Kibana uses an index in Elasticsearch to -store saved searches, visualizations and dashboards. Kibana creates a new index -if the index doesn’t already exist. If you configure a custom index, the name must -be lowercase, and conform to {es} {ref}/indices-create-index.html[index name limitations]. - -`kibana.autocompleteTimeout:`:: *Default: "1000"* Time in milliseconds to wait -for autocomplete suggestions from Elasticsearch. This value must be a whole number -greater than zero. - -`kibana.autocompleteTerminateAfter:`:: *Default: "100000"* Maximum number of -documents loaded by each shard to generate autocomplete suggestions. This value -must be a whole number greater than zero. - -`logging.dest:`:: *Default: `stdout`* Enables you specify a file where Kibana -stores log output. - -`logging.json:`:: *Default: false* Logs output as JSON. When set to `true`, the -logs will be formatted as JSON strings that include timestamp, log level, context, message -text and any other metadata that may be associated with the log message itself. -If `logging.dest.stdout` is set and there is no interactive terminal ("TTY"), this setting -will default to `true`. - -`logging.quiet:`:: *Default: false* Set the value of this setting to `true` to -suppress all logging output other than error messages. - -`logging.rotate:`:: [experimental] Specifies the options for the logging rotate feature. +|`elasticsearch.ssl.truststore.password:` + | The password that decrypts the trust store specified via +`elasticsearch.ssl.truststore.path`. If the trust store has no password, +leave this as blank. If the trust store has an empty password, set this to `""`. + +| `elasticsearch.ssl.verificationMode:` + | Controls the verification of the server certificate that {kib} receives when +making an outbound SSL/TLS connection to {es}. Valid values are `"full"`, +`"certificate"`, and `"none"`. Using `"full"` performs hostname verification, +using `"certificate"` skips hostname verification, and using `"none"` skips +verification entirely. *Default: `"full"`* + +| `elasticsearch.startupTimeout:` + | Time in milliseconds to wait for {es} at {kib} startup before retrying. +*Default: `5000`* + +| `elasticsearch.username:` and `elasticsearch.password:` + | If your {es} is protected with basic authentication, these settings provide +the username and password that the {kib} server uses to perform maintenance +on the {kib} index at startup. {kib} users still need to authenticate with +{es}, which is proxied through the {kib} server. + +| `interpreter.enableInVisualize` + | Enables use of interpreter in Visualize. *Default: `true`* + +| `kibana.defaultAppId:` + | The default application to load. *Default: `"home"`* + +| `kibana.index:` + | {kib} uses an index in {es} to store saved searches, visualizations, and +dashboards. {kib} creates a new index if the index doesn’t already exist. +If you configure a custom index, the name must be lowercase, and conform to the +{es} {ref}/indices-create-index.html[index name limitations]. +*Default: `".kibana"`* + +| `kibana.autocompleteTimeout:` + | Time in milliseconds to wait for autocomplete suggestions from {es}. +This value must be a whole number greater than zero. *Default: `"1000"`* + +| `kibana.autocompleteTerminateAfter:` + | Maximum number of documents loaded by each shard to generate autocomplete +suggestions. This value must be a whole number greater than zero. +*Default: `"100000"`* + +| `logging.dest:` + | Enables you to specify a file where {kib} stores log output. +*Default: `stdout`* + +| `logging.json:` + | Logs output as JSON. When set to `true`, the logs are formatted as JSON +strings that include timestamp, log level, context, message text, and any other +metadata that may be associated with the log message. +When `logging.dest.stdout` is set, and there is no interactive terminal ("TTY"), +this setting defaults to `true`. *Default: `false`* + +| `logging.quiet:` + | Set the value of this setting to `true` to suppress all logging output other +than error messages. *Default: `false`* + +| `logging.rotate:` + | experimental[] Specifies the options for the logging rotate feature. When not defined, all the sub options defaults would be applied. The following example shows a valid logging rotate configuration: -+ + +|=== + +[source,text] -- - logging.rotate: - enabled: true - everyBytes: 10485760 - keepFiles: 10 + logging.rotate: + enabled: true + everyBytes: 10485760 + keepFiles: 10 -- -`logging.rotate.enabled:`:: [experimental] *Default: false* Set the value of this setting to `true` to +[cols="2*<"] +|=== + +| `logging.rotate.enabled:` + | experimental[] Set the value of this setting to `true` to enable log rotation. If you do not have a `logging.dest` set that is different from `stdout` -that feature would not take any effect. +that feature would not take any effect. *Default: `false`* -`logging.rotate.everyBytes:`:: [experimental] *Default: 10485760* The maximum size of a log file (that is `not an exact` limit). After the +| `logging.rotate.everyBytes:` + | experimental[] The maximum size of a log file (that is `not an exact` limit). After the limit is reached, a new log file is generated. The default size limit is 10485760 (10 MB) and -this option should be in the range of 1048576 (1 MB) to 1073741824 (1 GB). +this option should be in the range of 1048576 (1 MB) to 1073741824 (1 GB). *Default: `10485760`* -`logging.rotate.keepFiles:`:: [experimental] *Default: 7* The number of most recent rotated log files to keep +| `logging.rotate.keepFiles:` + | experimental[] The number of most recent rotated log files to keep on disk. Older files are deleted during log rotation. The default value is 7. The `logging.rotate.keepFiles` -option has to be in the range of 2 to 1024 files. +option has to be in the range of 2 to 1024 files. *Default: `7`* -`logging.rotate.pollingInterval:`:: [experimental] *Default: 10000* The number of milliseconds for the polling strategy in case -the `logging.rotate.usePolling` is enabled. That option has to be in the range of 5000 to 3600000 milliseconds. +| `logging.rotate.pollingInterval:` + | experimental[] The number of milliseconds for the polling strategy in case +the `logging.rotate.usePolling` is enabled. `logging.rotate.usePolling` must be in the 5000 to 3600000 millisecond range. *Default: `10000`* -`logging.rotate.usePolling:`:: [experimental] *Default: false* By default we try to understand the best way to monitoring +| `logging.rotate.usePolling:` + | experimental[] By default we try to understand the best way to monitoring the log file and warning about it. Please be aware there are some systems where watch api is not accurate. In those cases, in order to get the feature working, -the `polling` method could be used enabling that option. +the `polling` method could be used enabling that option. *Default: `false`* -`logging.silent:`:: *Default: false* Set the value of this setting to `true` to -suppress all logging output. +| `logging.silent:` + | Set the value of this setting to `true` to +suppress all logging output. *Default: `false`* -`logging.timezone`:: *Default: UTC* Set to the canonical timezone id -(for example, `America/Los_Angeles`) to log events using that timezone. A list of timezones can -be referenced at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. +| `logging.timezone` + | Set to the canonical timezone ID +(for example, `America/Los_Angeles`) to log events using that timezone. For a +list of timezones, refer to https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. *Default: `UTC`* -[[logging-verbose]]`logging.verbose:`:: *Default: false* Set the value of this -setting to `true` to log all events, including system usage information and all -requests. Supported on Elastic Cloud Enterprise. +| [[logging-verbose]] `logging.verbose:` + | Set to `true` to log all events, including system usage information and all +requests. Supported on {ece}. *Default: `false`* -`map.includeElasticMapsService:`:: *Default: true* -Set to false to disable connections to Elastic Maps Service. +| `map.includeElasticMapsService:` + | Set to `false` to disable connections to Elastic Maps Service. When `includeElasticMapsService` is turned off, only the vector layers configured by `map.regionmap` -and the tile layer configured by `map.tilemap.url` will be available in <>. +and the tile layer configured by `map.tilemap.url` are available in <>. *Default: `true`* -`map.proxyElasticMapsServiceInMaps:`:: *Default: false* -Set to true to proxy all <> Elastic Maps Service requests through the Kibana server. +| `map.proxyElasticMapsServiceInMaps:` + | Set to `true` to proxy all <> Elastic Maps Service +requests through the {kib} server. *Default: `false`* -[[regionmap-settings]] `map.regionmap:`:: Specifies additional vector layers for +| [[regionmap-settings]] `map.regionmap:` + | Specifies additional vector layers for use in <> visualizations. Supported on {ece}. Each layer object points to an external vector file that contains a geojson FeatureCollection. The file must use the https://en.wikipedia.org/wiki/World_Geodetic_System[WGS84 coordinate reference system (ESPG:4326)] and only include polygons. If the file is hosted on a separate domain from -Kibana, the server needs to be CORS-enabled so Kibana can download the file. -[[region-map-configuration-example]] +{kib}, the server needs to be CORS-enabled so {kib} can download the file. The following example shows a valid region map configuration. -+ + +|=== + +[source,text] -- - map - includeElasticMapsService: false - regionmap: - layers: - - name: "Departments of France" - url: "http://my.cors.enabled.server.org/france_departements.geojson" - attribution: "INRAP" - fields: - - name: "department" - description: "Full department name" - - name: "INSEE" - description: "INSEE numeric identifier" +map.regionmap: + includeElasticMapsService: false + layers: + - name: "Departments of France" + url: "http://my.cors.enabled.server.org/france_departements.geojson" + attribution: "INRAP" + fields: + - name: "department" + description: "Full department name" + - name: "INSEE" + description: "INSEE numeric identifier" -- -[[regionmap-ES-map]]`map.includeElasticMapsService:`:: Turns on or off -whether layers from the Elastic Maps Service should be included in the vector -layer option list. Supported on Elastic Cloud Enterprise. By turning this off, +[cols="2*<"] +|=== + +| [[regionmap-ES-map]] `map.includeElasticMapsService:` + | Turns on or off whether layers from the Elastic Maps Service should be included in the vector +layer option list. Supported on {ece}. By turning this off, only the layers that are configured here will be included. The default is `true`. This also affects whether tile-service from the Elastic Maps Service will be available. -[[regionmap-attribution]]`map.regionmap.layers[].attribution:`:: Optional. -References the originating source of the geojson file. Supported on {ece}. +| [[regionmap-attribution]] `map.regionmap.layers[].attribution:` + | Optional. References the originating source of the geojson file. +Supported on {ece}. -[[regionmap-fields]]`map.regionmap.layers[].fields[]:`:: Mandatory. Each layer +| [[regionmap-fields]] `map.regionmap.layers[].fields[]:` + | Mandatory. Each layer can contain multiple fields to indicate what properties from the geojson -features you wish to expose. This <> shows how to define multiple -properties. Supported on {ece}. +features you wish to expose. Supported on {ece}. The following shows how to define multiple +properties: + +|=== -[[regionmap-field-description]]`map.regionmap.layers[].fields[].description:`:: -Mandatory. The human readable text that is shown under the Options tab when +[source,text] +-- +map.regionmap: + includeElasticMapsService: false + layers: + - name: "Departments of France" + url: "http://my.cors.enabled.server.org/france_departements.geojson" + attribution: "INRAP" + fields: + - name: "department" + description: "Full department name" + - name: "INSEE" + description: "INSEE numeric identifier" +-- + +[cols="2*<"] +|=== + +| [[regionmap-field-description]] `map.regionmap.layers[].fields[].description:` + | Mandatory. The human readable text that is shown under the Options tab when building the Region Map visualization. Supported on {ece}. -[[regionmap-field-name]]`map.regionmap.layers[].fields[].name:`:: Mandatory. +| [[regionmap-field-name]] `map.regionmap.layers[].fields[].name:` + | Mandatory. This value is used to do an inner-join between the document stored in -Elasticsearch and the geojson file. For example, if the field in the geojson is -called `Location` and has city names, there must be a field in Elasticsearch -that holds the same values that Kibana can then use to lookup for the geoshape +{es} and the geojson file. For example, if the field in the geojson is +called `Location` and has city names, there must be a field in {es} +that holds the same values that {kib} can then use to lookup for the geoshape data. Supported on {ece}. -[[regionmap-name]]`map.regionmap.layers[].name:`:: Mandatory. A description of +| [[regionmap-name]] `map.regionmap.layers[].name:` + | Mandatory. A description of the map being provided. Supported on {ece}. -[[regionmap-url]]`map.regionmap.layers[].url:`:: Mandatory. The location of the +| [[regionmap-url]] `map.regionmap.layers[].url:` + | Mandatory. The location of the geojson file as provided by a webserver. Supported on {ece}. -[[tilemap-settings]] `map.tilemap.options.attribution:`:: +| [[tilemap-settings]] `map.tilemap.options.attribution:` + | The map attribution string. Supported on {ece}. *Default: `"© [Elastic Maps Service](https://www.elastic.co/elastic-maps-service)"`* -The map attribution string. Supported on {ece}. -[[tilemap-max-zoom]]`map.tilemap.options.maxZoom:`:: *Default: 10* The maximum -zoom level. Supported on {ece}. +| [[tilemap-max-zoom]] `map.tilemap.options.maxZoom:` + | The maximum zoom level. Supported on {ece}. *Default: `10`* -[[tilemap-min-zoom]]`map.tilemap.options.minZoom:`:: *Default: 1* The minimum -zoom level. Supported on {ece}. +| [[tilemap-min-zoom]] `map.tilemap.options.minZoom:` + | The minimum zoom level. Supported on {ece}. *Default: `1`* -[[tilemap-subdomains]]`map.tilemap.options.subdomains:`:: An array of subdomains +| [[tilemap-subdomains]] `map.tilemap.options.subdomains:` + | An array of subdomains used by the tile service. Specify the position of the subdomain the URL with the token `{s}`. Supported on {ece}. -[[tilemap-url]]`map.tilemap.url:`:: The URL to the tileservice that Kibana uses +| [[tilemap-url]] `map.tilemap.url:` + | The URL to the tileservice that {kib} uses to display map tiles in tilemap visualizations. Supported on {ece}. By default, -Kibana reads this url from an external metadata service, but users can still +{kib} reads this URL from an external metadata service, but users can override this parameter to use their own Tile Map Service. For example: `"https://tiles.elastic.co/v2/default/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana"` -`newsfeed.enabled:` :: *Default: `true`* Controls whether to enable the newsfeed -system for the Kibana UI notification center. Set to `false` to disable the -newsfeed system. +| `newsfeed.enabled:` + | Controls whether to enable the newsfeed +system for the {kib} UI notification center. Set to `false` to disable the +newsfeed system. *Default: `true`* -`path.data:`:: *Default: `data`* The path where Kibana stores persistent data -not saved in Elasticsearch. +| `path.data:` + | The path where {kib} stores persistent data +not saved in {es}. *Default: `data`* -`pid.file:`:: Specifies the path where Kibana creates the process ID file. +| `pid.file:` + | Specifies the path where {kib} creates the process ID file. -`ops.interval:`:: *Default: 5000* Set the interval in milliseconds to sample -system and process performance metrics. The minimum value is 100. +| `ops.interval:` + | Set the interval in milliseconds to sample +system and process performance metrics. The minimum value is 100. *Default: `5000`* -`server.basePath:`:: Enables you to specify a path to mount Kibana at if you are -running behind a proxy. Use the `server.rewriteBasePath` setting to tell Kibana +| `server.basePath:` + | Enables you to specify a path to mount {kib} at if you are +running behind a proxy. Use the `server.rewriteBasePath` setting to tell {kib} if it should remove the basePath from requests it receives, and to prevent a deprecation warning at startup. This setting cannot end in a slash (`/`). -[[server-compression]]`server.compression.enabled:`:: *Default: `true`* Set to `false` to disable HTTP compression for all responses. +| [[server-compression]] `server.compression.enabled:` + | Set to `false` to disable HTTP compression for all responses. *Default: `true`* -`server.compression.referrerWhitelist:`:: *Default: none* Specifies an array of trusted hostnames, such as the Kibana host, or a reverse -proxy sitting in front of it. This determines whether HTTP compression may be used for responses, based on the request's `Referer` header. -This setting may not be used when `server.compression.enabled` is set to `false`. +| `server.compression.referrerWhitelist:` + | Specifies an array of trusted hostnames, such as the {kib} host, or a reverse +proxy sitting in front of it. This determines whether HTTP compression may be used for responses, based on the request `Referer` header. +This setting may not be used when `server.compression.enabled` is set to `false`. *Default: `none`* -`server.customResponseHeaders:`:: *Default: `{}`* Header names and values to - send on all responses to the client from the Kibana server. +| `server.customResponseHeaders:` + | Header names and values to +send on all responses to the client from the {kib} server. *Default: `{}`* -`server.host:`:: *Default: "localhost"* This setting specifies the host of the -back end server. To allow remote users to connect, set the value to the IP address or DNS name of the {kib} server. +| `server.host:` + | This setting specifies the host of the +back end server. To allow remote users to connect, set the value to the IP address or DNS name of the {kib} server. *Default: `"localhost"`* -`server.keepaliveTimeout:`:: *Default: "120000"* The number of milliseconds to wait for additional data before restarting -the `server.socketTimeout` counter. +| `server.keepaliveTimeout:` + | The number of milliseconds to wait for additional data before restarting +the `server.socketTimeout` counter. *Default: `"120000"`* -`server.maxPayloadBytes:`:: *Default: 1048576* The maximum payload size in bytes -for incoming server requests. +| `server.maxPayloadBytes:` + | The maximum payload size in bytes +for incoming server requests. *Default: `1048576`* -`server.name:`:: *Default: "your-hostname"* A human-readable display name that -identifies this Kibana instance. +| `server.name:` + | A human-readable display name that +identifies this {kib} instance. *Default: `"your-hostname"`* -`server.port:`:: *Default: 5601* Kibana is served by a back end server. This -setting specifies the port to use. +| `server.port:` + | {kib} is served by a back end server. This +setting specifies the port to use. *Default: `5601`* -`server.rewriteBasePath:`:: *Default: deprecated* Specifies whether Kibana should +| `server.rewriteBasePath:` + | Specifies whether {kib} should rewrite requests that are prefixed with `server.basePath` or require that they -are rewritten by your reverse proxy. In Kibana 6.3 and earlier, the default is -`false`. In Kibana 7.x, the setting is deprecated. In Kibana 8.0 and later, the -default is `true`. +are rewritten by your reverse proxy. In {kib} 6.3 and earlier, the default is +`false`. In {kib} 7.x, the setting is deprecated. In {kib} 8.0 and later, the +default is `true`. *Default: `deprecated`* -`server.socketTimeout:`:: *Default: "120000"* The number of milliseconds to wait before closing an -inactive socket. +| `server.socketTimeout:` + | The number of milliseconds to wait before closing an +inactive socket. *Default: `"120000"`* -`server.ssl.certificate:` and `server.ssl.key:`:: Paths to a PEM-encoded X.509 server certificate and its corresponding private key. These -are used by {kib} to establish trust when receiving inbound SSL/TLS connections from end users. -+ -NOTE: These settings cannot be used in conjunction with `server.ssl.keystore.path`. +| `server.ssl.certificate:` and `server.ssl.key:` + | Paths to a PEM-encoded X.509 server certificate and its corresponding private key. These +are used by {kib} to establish trust when receiving inbound SSL/TLS connections from users. + +|=== -`server.ssl.certificateAuthorities:`:: Paths to one or more PEM-encoded X.509 certificate authority (CA) certificates which make up a +[NOTE] +============ +These settings cannot be used in conjunction with `server.ssl.keystore.path`. +============ + +[cols="2*<"] +|=== + +| `server.ssl.certificateAuthorities:` + | Paths to one or more PEM-encoded X.509 certificate authority (CA) certificates which make up a trusted certificate chain for {kib}. This chain is used by {kib} to establish trust when receiving inbound SSL/TLS connections from end users. If PKI authentication is enabled, this chain is also used by {kib} to verify client certificates from end users. + In addition to this setting, trusted certificates may be specified via `server.ssl.keystore.path` and/or `server.ssl.truststore.path`. -`server.ssl.cipherSuites:`:: *Default: ECDHE-RSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-RSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES256-GCM-SHA384, DHE-RSA-AES128-GCM-SHA256, ECDHE-RSA-AES128-SHA256, DHE-RSA-AES128-SHA256, ECDHE-RSA-AES256-SHA384, DHE-RSA-AES256-SHA384, ECDHE-RSA-AES256-SHA256, DHE-RSA-AES256-SHA256, HIGH,!aNULL, !eNULL, !EXPORT, !DES, !RC4, !MD5, !PSK, !SRP, !CAMELLIA*. -Details on the format, and the valid options, are available via the +| `server.ssl.cipherSuites:` + | Details on the format, and the valid options, are available via the https://www.openssl.org/docs/man1.0.2/apps/ciphers.html#CIPHER-LIST-FORMAT[OpenSSL cipher list format documentation]. +*Default: `ECDHE-RSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-RSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES256-GCM-SHA384, DHE-RSA-AES128-GCM-SHA256, ECDHE-RSA-AES128-SHA256, DHE-RSA-AES128-SHA256, ECDHE-RSA-AES256-SHA384, DHE-RSA-AES256-SHA384, ECDHE-RSA-AES256-SHA256, DHE-RSA-AES256-SHA256, HIGH,!aNULL, !eNULL, !EXPORT, !DES, !RC4, !MD5, !PSK, !SRP, !CAMELLIA`*. -`server.ssl.clientAuthentication:`:: *Default: `"none"`* Controls {kib}’s behavior in regard to requesting a certificate from client +| `server.ssl.clientAuthentication:` + | Controls the behavior in {kib} for requesting a certificate from client connections. Valid values are `"required"`, `"optional"`, and `"none"`. Using `"required"` will refuse to establish the connection unless a client presents a certificate, using `"optional"` will allow a client to present a certificate if it has one, and using `"none"` will -prevent a client from presenting a certificate. +prevent a client from presenting a certificate. *Default: `"none"`* -`server.ssl.enabled:`:: *Default: `false`* Enables SSL/TLS for inbound connections to {kib}. When set to `true`, a certificate and its +| `server.ssl.enabled:` + | Enables SSL/TLS for inbound connections to {kib}. When set to `true`, a certificate and its corresponding private key must be provided. These can be specified via `server.ssl.keystore.path` or the combination of -`server.ssl.certificate` and `server.ssl.key`. +`server.ssl.certificate` and `server.ssl.key`. *Default: `false`* -`server.ssl.keyPassphrase:`:: The password that will be used to decrypt the private key that is specified via `server.ssl.key`. This value +| `server.ssl.keyPassphrase:` + | The password that decrypts the private key that is specified via `server.ssl.key`. This value is optional, as the key may not be encrypted. -`server.ssl.keystore.path:`:: Path to a PKCS#12 keystore that contains an X.509 server certificate and its corresponding private key. If the +| `server.ssl.keystore.path:` + | Path to a PKCS#12 keystore that contains an X.509 server certificate and its corresponding private key. If the keystore contains any additional certificates, those will be used as a trusted certificate chain for {kib}. All of these are used by {kib} to establish trust when receiving inbound SSL/TLS connections from end users. The certificate chain is also used by {kib} to verify client certificates from end users when PKI authentication is enabled. + --- In addition to this setting, trusted certificates may be specified via `server.ssl.certificateAuthorities` and/or `server.ssl.truststore.path`. -NOTE: This setting cannot be used in conjunction with `server.ssl.certificate` or `server.ssl.key`. --- +|=== + +[NOTE] +============ +This setting cannot be used in conjunction with `server.ssl.certificate` or `server.ssl.key` +============ -`server.ssl.keystore.password:`:: The password that will be used to decrypt the keystore specified via `server.ssl.keystore.path`. If the +[cols="2*<"] +|=== + +| `server.ssl.keystore.password:` + | The password that will be used to decrypt the keystore specified via `server.ssl.keystore.path`. If the keystore has no password, leave this unset. If the keystore has an empty password, set this to `""`. -`server.ssl.truststore.path:`:: Path to a PKCS#12 trust store that contains one or more X.509 certificate authority (CA) certificates which +| `server.ssl.truststore.path:` + | Path to a PKCS#12 trust store that contains one or more X.509 certificate authority (CA) certificates which make up a trusted certificate chain for {kib}. This chain is used by {kib} to establish trust when receiving inbound SSL/TLS connections from end users. If PKI authentication is enabled, this chain is also used by {kib} to verify client certificates from end users. + In addition to this setting, trusted certificates may be specified via `server.ssl.certificateAuthorities` and/or `server.ssl.keystore.path`. -`server.ssl.truststore.password:`:: The password that will be used to decrypt the trust store specified via `server.ssl.truststore.path`. If +| `server.ssl.truststore.password:` + | The password that will be used to decrypt the trust store specified via `server.ssl.truststore.path`. If the trust store has no password, leave this unset. If the trust store has an empty password, set this to `""`. -`server.ssl.redirectHttpFromPort:`:: Kibana will bind to this port and redirect +| `server.ssl.redirectHttpFromPort:` + | {kib} binds to this port and redirects all http requests to https over the port configured as `server.port`. -`server.ssl.supportedProtocols:`:: *Default: TLSv1.1, TLSv1.2* An array of -supported protocols with versions. Valid protocols: `TLSv1`, `TLSv1.1`, `TLSv1.2` +| `server.ssl.supportedProtocols:` + | An array of supported protocols with versions. +Valid protocols: `TLSv1`, `TLSv1.1`, `TLSv1.2`. *Default: TLSv1.1, TLSv1.2* -`server.xsrf.whitelist:`:: It is not recommended to disable protections for +| `server.xsrf.whitelist:` + | It is not recommended to disable protections for arbitrary API endpoints. Instead, supply the `kbn-xsrf` header. The `server.xsrf.whitelist` setting requires the following format: -[source,text] +|=== +[source,text] ---- *Default: [ ]* An array of API endpoints which should be exempt from Cross-Site Request Forgery ("XSRF") protections. ---- -`status.allowAnonymous:`:: *Default: false* If authentication is enabled, -setting this to `true` enables unauthenticated users to access the Kibana -server status API and status page. +[cols="2*<"] +|=== + +| `status.allowAnonymous:` + | If authentication is enabled, +setting this to `true` enables unauthenticated users to access the {kib} +server status API and status page. *Default: `false`* -`telemetry.allowChangingOptInStatus`:: *Default: true*. If `true`, -users are able to change the telemetry setting at a later time in -<>. If `false`, +| `telemetry.allowChangingOptInStatus` + | When `true`, users are able to change the telemetry setting at a later time in +<>. When `false`, {kib} looks at the value of `telemetry.optIn` to determine whether to send telemetry data or not. `telemetry.allowChangingOptInStatus` and `telemetry.optIn` -cannot be `false` at the same time. +cannot be `false` at the same time. *Default: `true`*. -`telemetry.optIn`:: *Default: true* If `true`, telemetry data is sent to Elastic. - If `false`, collection of telemetry data is disabled. - To enable telemetry and prevent users from disabling it, - set `telemetry.allowChangingOptInStatus` to `false` and `telemetry.optIn` to `true`. +| `telemetry.optIn` + | When `true`, telemetry data is sent to Elastic. +When `false`, collection of telemetry data is disabled. +To enable telemetry and prevent users from disabling it, +set `telemetry.allowChangingOptInStatus` to `false` and `telemetry.optIn` to `true`. +*Default: `true`* -`telemetry.enabled`:: *Default: true* Reporting your cluster statistics helps +| `telemetry.enabled` + | Reporting your cluster statistics helps us improve your user experience. Your data is never shared with anyone. Set to `false` to disable telemetry capabilities entirely. You can alternatively opt -out through the *Advanced Settings* in {kib}. +out through *Advanced Settings*. *Default: `true`* + +| `vis_type_vega.enableExternalUrls:` + | Set this value to true to allow Vega to use any URL to access external data +sources and images. When false, Vega can only get data from {es}. *Default: `false`* -`vis_type_vega.enableExternalUrls:`:: *Default: false* Set this value to true to allow Vega to use any URL to access external data sources and images. If false, Vega can only get data from Elasticsearch. +| `xpack.license_management.enabled` + | Set this value to false to +disable the License Management UI. *Default: `true`* -`xpack.license_management.enabled`:: *Default: true* Set this value to false to -disable the License Management user interface. +| `xpack.rollup.enabled:` + | Set this value to false to disable the +Rollup UI. *Default: true* -`xpack.rollup.enabled:`:: *Default: true* Set this value to false to disable the -Rollup user interface. +| `i18n.locale` + | Set this value to change the {kib} interface language. +Valid locales are: `en`, `zh-CN`, `ja-JP`. *Default: `en`* -`i18n.locale`:: *Default: en* Set this value to change the Kibana interface language. Valid locales are: `en`, `zh-CN`, `ja-JP`. +|=== include::{docdir}/settings/alert-action-settings.asciidoc[] include::{docdir}/settings/apm-settings.asciidoc[] From fb6d325fe92ab9426f6ca4108cad608cff88e592 Mon Sep 17 00:00:00 2001 From: Kaarina Tungseth Date: Mon, 4 May 2020 10:49:14 -0500 Subject: [PATCH 007/153] [DOCS} Fixes 404s in master (#64911) --- docs/redirects.asciidoc | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/redirects.asciidoc b/docs/redirects.asciidoc index a5503969a3ec1..85d580de9475f 100644 --- a/docs/redirects.asciidoc +++ b/docs/redirects.asciidoc @@ -70,3 +70,13 @@ This page has moved. Please see <>. == Maps This page has moved. Please see <>. + +[role="exclude",id="development-embedding-visualizations"] +== Embedding Visualizations + +This page was deleted. See <>. + +[role="exclude",id="development-create-visualization"] +== Developing Visualizations + +This page was deleted. See <>. From 86c64af553bec850358c9d86f588489f2d82ac12 Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Mon, 4 May 2020 18:16:48 +0200 Subject: [PATCH 008/153] Bump mapbox-gl dependency from 1.9.0 to 1.10.0 (#64670) --- x-pack/package.json | 4 +- yarn.lock | 93 +++++++++++++++++++++++++++++---------------- 2 files changed, 62 insertions(+), 35 deletions(-) diff --git a/x-pack/package.json b/x-pack/package.json index dcc9b8c61cb96..5d1fbaa5784e0 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -81,7 +81,7 @@ "@types/json-stable-stringify": "^1.0.32", "@types/jsonwebtoken": "^7.2.8", "@types/lodash": "^3.10.1", - "@types/mapbox-gl": "^1.8.1", + "@types/mapbox-gl": "^1.9.1", "@types/memoize-one": "^4.1.0", "@types/mime": "^2.0.1", "@types/mocha": "^7.0.2", @@ -280,7 +280,7 @@ "lodash.topath": "^4.5.2", "lodash.uniqby": "^4.7.0", "lz-string": "^1.4.4", - "mapbox-gl": "^1.9.0", + "mapbox-gl": "^1.10.0", "mapbox-gl-draw-rectangle-mode": "^1.0.4", "markdown-it": "^10.0.0", "memoize-one": "^5.0.0", diff --git a/yarn.lock b/yarn.lock index 346c4d76d24c9..3c233b76f1a48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2116,7 +2116,7 @@ resolved "https://registry.yarnpkg.com/@mapbox/geojson-normalize/-/geojson-normalize-0.0.1.tgz#1da1e6b3a7add3ad29909b30f438f60581b7cd80" integrity sha1-HaHms6et060pkJsw9Dj2BYG3zYA= -"@mapbox/geojson-rewind@^0.4.0", "@mapbox/geojson-rewind@^0.4.1": +"@mapbox/geojson-rewind@^0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@mapbox/geojson-rewind/-/geojson-rewind-0.4.1.tgz#357d79300adb7fec7c1f091512988bca6458f068" integrity sha512-mxo2MEr7izA1uOXcDsw99Kgg6xW3P4H2j4n1lmldsgviIelpssvP+jQDivFKOHrOVJDpTTi5oZJvRcHtU9Uufw== @@ -2126,6 +2126,14 @@ minimist "^1.2.5" sharkdown "^0.1.0" +"@mapbox/geojson-rewind@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@mapbox/geojson-rewind/-/geojson-rewind-0.5.0.tgz#91f0ad56008c120caa19414b644d741249f4f560" + integrity sha512-73l/qJQgj/T/zO1JXVfuVvvKDgikD/7D/rHAD28S9BG1OTstgmftrmqfCx4U+zQAmtsB6HcDA3a7ymdnJZAQgg== + dependencies: + concat-stream "~2.0.0" + minimist "^1.2.5" + "@mapbox/geojson-types@^1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz#9aecf642cb00eab1080a57c4f949a65b4a5846d6" @@ -2166,20 +2174,20 @@ resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-rtl-text/-/mapbox-gl-rtl-text-0.2.3.tgz#a26ecfb3f0061456d93ee8570dd9587d226ea8bd" integrity sha512-RaCYfnxULUUUxNwcUimV9C/o2295ktTyLEUzD/+VWkqXqvaVfFcZ5slytGzb2Sd/Jj4MlbxD0DCZbfa6CzcmMw== -"@mapbox/mapbox-gl-supported@^1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.4.0.tgz#36946b22944fe2cfa43cfafd5ef36fdb54a069e4" - integrity sha512-ZD0Io4XK+/vU/4zpANjOtdWfVszAgnaMPsGR6LKsWh4kLIEv9qoobTVmJPPuwuM+ZI2b3BlZ6DYw1XHVmv6YTA== +"@mapbox/mapbox-gl-supported@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz#f60b6a55a5d8e5ee908347d2ce4250b15103dc8e" + integrity sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg== "@mapbox/point-geometry@0.1.0", "@mapbox/point-geometry@^0.1.0", "@mapbox/point-geometry@~0.1.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz#8a83f9335c7860effa2eeeca254332aa0aeed8f2" integrity sha1-ioP5M1x4YO/6Lu7KJUMyqgru2PI= -"@mapbox/tiny-sdf@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@mapbox/tiny-sdf/-/tiny-sdf-1.1.0.tgz#b0b8f5c22005e6ddb838f421ffd257c1f74f9a20" - integrity sha512-dnhyk8X2BkDRWImgHILYAGgo+kuciNYX30CUKj/Qd5eNjh54OWM/mdOS/PWsPeN+3abtN+QDGYM4G220ynVJKA== +"@mapbox/tiny-sdf@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@mapbox/tiny-sdf/-/tiny-sdf-1.1.1.tgz#16a20c470741bfe9191deb336f46e194da4a91ff" + integrity sha512-Ihn1nZcGIswJ5XGbgFAvVumOgWpvIjBX9jiRlIl46uQG9vJOF51ViBYHF95rEZupuyQbEmhLaDPLQlU7fUTsBg== "@mapbox/unitbezier@^0.0.0": version "0.0.0" @@ -4350,10 +4358,10 @@ resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03" integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w== -"@types/mapbox-gl@^1.8.1": - version "1.8.1" - resolved "https://registry.yarnpkg.com/@types/mapbox-gl/-/mapbox-gl-1.8.1.tgz#dbc12da1324d5bdb3dbf71b90b77cac17994a1a3" - integrity sha512-DdT/YzpGiYITkj2cUwyqPilPbtZURr1E0vZX0KTyyeNP0t0bxNyKoXo0seAcvUd2MsMgFYwFQh1WRC3x2V0kKQ== +"@types/mapbox-gl@^1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@types/mapbox-gl/-/mapbox-gl-1.9.1.tgz#78b62f8a1ead78bc525a4c1db84bb71fa0fcc579" + integrity sha512-5LS/fljbGjCPfjtOK5+pz8TT0PL4bBXTnN/PDbPtTQMqQdY/KWTWE4jRPuo0fL5wctd543DCptEUTydn+JK+gA== dependencies: "@types/geojson" "*" @@ -9539,6 +9547,16 @@ concat-stream@~1.5.0: readable-stream "~2.0.0" typedarray "~0.0.5" +concat-stream@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1" + integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.0.2" + typedarray "^0.0.6" + conf@^1.1.2, conf@^1.3.1: version "1.4.0" resolved "https://registry.yarnpkg.com/conf/-/conf-1.4.0.tgz#1ea66c9d7a9b601674a5bb9d2b8dc3c726625e67" @@ -10355,7 +10373,7 @@ css@2.X, css@^2.0.0, css@^2.2.1, css@^2.2.3, css@^2.2.4: source-map-resolve "^0.5.2" urix "^0.1.0" -csscolorparser@~1.0.2: +csscolorparser@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/csscolorparser/-/csscolorparser-1.0.3.tgz#b34f391eea4da8f3e98231e2ccd8df9c041f171b" integrity sha1-s085HupNqPPpgjHizNjfnAQfFxs= @@ -14586,10 +14604,10 @@ github-username@^3.0.0: dependencies: gh-got "^5.0.0" -gl-matrix@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/gl-matrix/-/gl-matrix-3.0.0.tgz#888301ac7650e148c3865370e13ec66d08a8381f" - integrity sha512-PD4mVH/C/Zs64kOozeFnKY8ybhgwxXXQYGWdB4h68krAHknWJgk9uKOn6z8YElh5//vs++90pb6csrTIDWnexA== +gl-matrix@^3.2.1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/gl-matrix/-/gl-matrix-3.3.0.tgz#232eef60b1c8b30a28cbbe75b2caf6c48fd6358b" + integrity sha512-COb7LDz+SXaHtl/h4LeaFcNdJdAQSDeVqjiIihSXNrkWObZLhDI4hIkZC11Aeqp7bcE72clzB0BnDXr2SmslRA== glob-all@^3.1.0, glob-all@^3.2.1: version "3.2.1" @@ -20091,33 +20109,33 @@ mapbox-gl-draw-rectangle-mode@^1.0.4: resolved "https://registry.yarnpkg.com/mapbox-gl-draw-rectangle-mode/-/mapbox-gl-draw-rectangle-mode-1.0.4.tgz#42987d68872a5fb5cc5d76d3375ee20cd8bab8f7" integrity sha512-BdF6nwEK2p8n9LQoMPzBO8LhddW1fe+d5vK8HQIei+4VcRnUbKNsEj7Z15FsJxCHzsc2BQKXbESx5GaE8x0imQ== -mapbox-gl@^1.9.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/mapbox-gl/-/mapbox-gl-1.9.0.tgz#53e3e13c99483f362b07a8a763f2d61d580255a5" - integrity sha512-PKpoiB2pPUMrqFfBJpt/oA8On3zcp0adEoDS2YIC2RA6o4EZ9Sq2NPZocb64y7ra3mLUvEb7ps1pLVlPMh6y7w== +mapbox-gl@^1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/mapbox-gl/-/mapbox-gl-1.10.0.tgz#c33e74d1f328e820e245ff8ed7b5dbbbc4be204f" + integrity sha512-SrJXcR9s5yEsPuW2kKKumA1KqYW9RrL8j7ZcIh6glRQ/x3lwNMfwz/UEJAJcVNgeX+fiwzuBoDIdeGB/vSkZLQ== dependencies: - "@mapbox/geojson-rewind" "^0.4.0" + "@mapbox/geojson-rewind" "^0.5.0" "@mapbox/geojson-types" "^1.0.2" "@mapbox/jsonlint-lines-primitives" "^2.0.2" - "@mapbox/mapbox-gl-supported" "^1.4.0" + "@mapbox/mapbox-gl-supported" "^1.5.0" "@mapbox/point-geometry" "^0.1.0" - "@mapbox/tiny-sdf" "^1.1.0" + "@mapbox/tiny-sdf" "^1.1.1" "@mapbox/unitbezier" "^0.0.0" "@mapbox/vector-tile" "^1.3.1" "@mapbox/whoots-js" "^3.1.0" - csscolorparser "~1.0.2" + csscolorparser "~1.0.3" earcut "^2.2.2" geojson-vt "^3.2.1" - gl-matrix "^3.0.0" + gl-matrix "^3.2.1" grid-index "^1.1.0" - minimist "0.0.8" + minimist "^1.2.5" murmurhash-js "^1.0.0" pbf "^3.2.1" potpack "^1.0.1" quickselect "^2.0.0" rw "^1.3.3" supercluster "^7.0.0" - tinyqueue "^2.0.0" + tinyqueue "^2.0.3" vt-pbf "^3.1.1" mapcap@^1.0.0: @@ -25121,6 +25139,15 @@ readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable string_decoder "~1.1.1" util-deprecate "~1.0.1" +readable-stream@^3.0.2: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readable-stream@~1.1.0: version "1.1.14" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" @@ -29031,10 +29058,10 @@ tinymath@1.2.1: resolved "https://registry.yarnpkg.com/tinymath/-/tinymath-1.2.1.tgz#f97ed66c588cdbf3c19dfba2ae266ee323db7e47" integrity sha512-8CYutfuHR3ywAJus/3JUhaJogZap1mrUQGzNxdBiQDhP3H0uFdQenvaXvqI8lMehX4RsanRZzxVfjMBREFdQaA== -tinyqueue@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-2.0.2.tgz#b4fe66d28a5b503edb99c149f87910059782a0cc" - integrity sha512-1oUV+ZAQaeaf830ui/p5JZpzGBw46qs1pKHcfqIc6/QxYDQuEmcBLIhiT0xAxLnekz+qxQusubIYk4cAS8TB2A== +tinyqueue@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-2.0.3.tgz#64d8492ebf39e7801d7bd34062e29b45b2035f08" + integrity sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA== title-case@^2.1.0: version "2.1.1" From 496f49247419d80ea3d400a35753ba499f2f2bc3 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 4 May 2020 18:39:09 +0200 Subject: [PATCH 009/153] Fix 37422 (#64215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 💡 rename Filter -> ExpressionValueFilter * refactor: 💡 use new filter type in Canvas * fix: 🐛 fix tests after refactor Co-authored-by: Elastic Machine --- .../common/expression_types/specs/filter.ts | 32 ++++++++++--------- src/plugins/expressions/public/index.ts | 2 +- src/plugins/expressions/server/index.ts | 2 +- .../functions/common/exactly.test.js | 2 +- .../functions/common/exactly.ts | 14 +++++--- .../functions/common/saved_lens.test.ts | 15 +++++++-- .../functions/common/saved_lens.ts | 8 ++--- .../functions/common/saved_map.test.ts | 15 +++++++-- .../functions/common/saved_map.ts | 4 +-- .../functions/common/saved_search.test.ts | 15 +++++++-- .../functions/common/saved_search.ts | 4 +-- .../common/saved_visualization.test.ts | 15 +++++++-- .../functions/common/saved_visualization.ts | 4 +-- .../functions/common/timefilter.test.js | 2 +- .../functions/common/timefilter.ts | 11 ++++--- .../functions/server/demodata.test.ts | 7 ++-- .../functions/server/demodata/index.ts | 9 ++++-- .../functions/server/escount.ts | 15 +++++++-- .../functions/server/esdocs.ts | 12 +++++-- .../functions/server/essql.ts | 9 ++++-- .../canvas/common/lib/datatable/query.js | 4 +-- .../canvas/public/functions/filters.ts | 9 ++++-- .../canvas/public/functions/timelion.ts | 7 ++-- .../lib/build_embeddable_filters.test.ts | 12 ++++--- .../public/lib/build_embeddable_filters.ts | 13 ++++---- .../canvas/server/lib/get_es_filter.js | 8 ++--- x-pack/legacy/plugins/canvas/types/state.ts | 4 +-- 27 files changed, 165 insertions(+), 89 deletions(-) diff --git a/src/plugins/expressions/common/expression_types/specs/filter.ts b/src/plugins/expressions/common/expression_types/specs/filter.ts index 01d6b8a603db6..fc1c086e817c9 100644 --- a/src/plugins/expressions/common/expression_types/specs/filter.ts +++ b/src/plugins/expressions/common/expression_types/specs/filter.ts @@ -17,29 +17,31 @@ * under the License. */ -import { ExpressionTypeDefinition } from '../types'; - -const name = 'filter'; +import { ExpressionTypeDefinition, ExpressionValueBoxed } from '../types'; /** * Represents an object that is a Filter. */ -export interface Filter { - type?: string; - value?: string; - column?: string; - and: Filter[]; - to?: string; - from?: string; - query?: string | null; -} +export type ExpressionValueFilter = ExpressionValueBoxed< + 'filter', + { + filterType?: string; + value?: string; + column?: string; + and: ExpressionValueFilter[]; + to?: string; + from?: string; + query?: string | null; + } +>; -export const filter: ExpressionTypeDefinition = { - name, +export const filter: ExpressionTypeDefinition<'filter', ExpressionValueFilter> = { + name: 'filter', from: { null: () => { return { - type: name, + type: 'filter', + filterType: 'filter', // Any meta data you wish to pass along. meta: {}, // And filters. If you need an "or", create a filter type for it. diff --git a/src/plugins/expressions/public/index.ts b/src/plugins/expressions/public/index.ts index 6814764ee5faa..ee3fbd7a7b0b0 100644 --- a/src/plugins/expressions/public/index.ts +++ b/src/plugins/expressions/public/index.ts @@ -78,7 +78,7 @@ export { ExpressionValueRender, ExpressionValueSearchContext, ExpressionValueUnboxed, - Filter, + ExpressionValueFilter, Font, FontLabel, FontStyle, diff --git a/src/plugins/expressions/server/index.ts b/src/plugins/expressions/server/index.ts index e41135b693922..61d3838466bef 100644 --- a/src/plugins/expressions/server/index.ts +++ b/src/plugins/expressions/server/index.ts @@ -69,7 +69,7 @@ export { ExpressionValueRender, ExpressionValueSearchContext, ExpressionValueUnboxed, - Filter, + ExpressionValueFilter, Font, FontLabel, FontStyle, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.test.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.test.js index f03bc54757c3c..2b9bdb59afbdf 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.test.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.test.js @@ -18,7 +18,7 @@ describe('exactly', () => { it("adds an exactly object to 'and'", () => { const result = fn(emptyFilter, { column: 'name', value: 'product2' }); - expect(result.and[0]).toHaveProperty('type', 'exactly'); + expect(result.and[0]).toHaveProperty('filterType', 'exactly'); }); describe('args', () => { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.ts index 88a24186d6044..5031e8029957b 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Filter, ExpressionFunctionDefinition } from '../../../types'; +import { ExpressionValueFilter, ExpressionFunctionDefinition } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -13,7 +13,12 @@ interface Arguments { filterGroup: string; } -export function exactly(): ExpressionFunctionDefinition<'exactly', Filter, Arguments, Filter> { +export function exactly(): ExpressionFunctionDefinition< + 'exactly', + ExpressionValueFilter, + Arguments, + ExpressionValueFilter +> { const { help, args: argHelp } = getFunctionHelp().exactly; return { @@ -43,8 +48,9 @@ export function exactly(): ExpressionFunctionDefinition<'exactly', Filter, Argum fn: (input, args) => { const { value, column } = args; - const filter = { - type: 'exactly', + const filter: ExpressionValueFilter = { + type: 'filter', + filterType: 'exactly', value, column, and: [], diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts index 6b197148e6373..882d1e2ea58b9 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts @@ -6,14 +6,23 @@ jest.mock('ui/new_platform'); import { savedLens } from './saved_lens'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; +import { ExpressionValueFilter } from '../../../types'; -const filterContext = { +const filterContext: ExpressionValueFilter = { + type: 'filter', and: [ - { and: [], value: 'filter-value', column: 'filter-column', type: 'exactly' }, { + type: 'filter', + and: [], + value: 'filter-value', + column: 'filter-column', + filterType: 'exactly', + }, + { + type: 'filter', and: [], column: 'time-column', - type: 'time', + filterType: 'time', from: '2019-06-04T04:00:00.000Z', to: '2019-06-05T04:00:00.000Z', }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts index 2985a68cf855c..8fc55ddf9cc59 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts @@ -8,7 +8,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { TimeRange, Filter as DataFilter } from 'src/plugins/data/public'; import { EmbeddableInput } from 'src/plugins/embeddable/public'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; -import { Filter, TimeRange as TimeRangeArg } from '../../../types'; +import { ExpressionValueFilter, TimeRange as TimeRangeArg } from '../../../types'; import { EmbeddableTypes, EmbeddableExpressionType, @@ -37,7 +37,7 @@ type Return = EmbeddableExpression; export function savedLens(): ExpressionFunctionDefinition< 'savedLens', - Filter | null, + ExpressionValueFilter | null, Arguments, Return > { @@ -63,8 +63,8 @@ export function savedLens(): ExpressionFunctionDefinition< }, }, type: EmbeddableExpressionType, - fn: (context, args) => { - const filters = context ? context.and : []; + fn: (input, args) => { + const filters = input ? input.and : []; return { type: EmbeddableExpressionType, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts index 63dbae55790a3..74e41a030de35 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts @@ -6,14 +6,23 @@ jest.mock('ui/new_platform'); import { savedMap } from './saved_map'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; +import { ExpressionValueFilter } from '../../../types'; -const filterContext = { +const filterContext: ExpressionValueFilter = { + type: 'filter', and: [ - { and: [], value: 'filter-value', column: 'filter-column', type: 'exactly' }, { + type: 'filter', + and: [], + value: 'filter-value', + column: 'filter-column', + filterType: 'exactly', + }, + { + type: 'filter', and: [], column: 'time-column', - type: 'time', + filterType: 'time', from: '2019-06-04T04:00:00.000Z', to: '2019-06-05T04:00:00.000Z', }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts index cba19ce7da80f..df316d0dd182f 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts @@ -6,7 +6,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; -import { Filter, MapCenter, TimeRange as TimeRangeArg } from '../../../types'; +import { ExpressionValueFilter, MapCenter, TimeRange as TimeRangeArg } from '../../../types'; import { EmbeddableTypes, EmbeddableExpressionType, @@ -32,7 +32,7 @@ type Output = EmbeddableExpression; export function savedMap(): ExpressionFunctionDefinition< 'savedMap', - Filter | null, + ExpressionValueFilter | null, Arguments, Output > { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.test.ts index 67356dae5b3e3..9bd32202b563a 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.test.ts @@ -6,14 +6,23 @@ jest.mock('ui/new_platform'); import { savedSearch } from './saved_search'; import { buildEmbeddableFilters } from '../../../public/lib/build_embeddable_filters'; +import { ExpressionValueFilter } from '../../../types'; -const filterContext = { +const filterContext: ExpressionValueFilter = { + type: 'filter', and: [ - { and: [], value: 'filter-value', column: 'filter-column', type: 'exactly' }, { + type: 'filter', + and: [], + value: 'filter-value', + column: 'filter-column', + filterType: 'exactly', + }, + { + type: 'filter', and: [], column: 'time-column', - type: 'time', + filterType: 'time', from: '2019-06-04T04:00:00.000Z', to: '2019-06-05T04:00:00.000Z', }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts index 87dc7eb5e814c..277d035ed0958 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts @@ -13,7 +13,7 @@ import { } from '../../expression_types'; import { buildEmbeddableFilters } from '../../../public/lib/build_embeddable_filters'; -import { Filter } from '../../../types'; +import { ExpressionValueFilter } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -24,7 +24,7 @@ type Output = EmbeddableExpression & { id: SearchInput['id' export function savedSearch(): ExpressionFunctionDefinition< 'savedSearch', - Filter | null, + ExpressionValueFilter | null, Arguments, Output > { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts index 754a113b87554..8327c1433b9af 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts @@ -6,14 +6,23 @@ jest.mock('ui/new_platform'); import { savedVisualization } from './saved_visualization'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; +import { ExpressionValueFilter } from '../../../types'; -const filterContext = { +const filterContext: ExpressionValueFilter = { + type: 'filter', and: [ - { and: [], value: 'filter-value', column: 'filter-column', type: 'exactly' }, { + type: 'filter', + and: [], + value: 'filter-value', + column: 'filter-column', + filterType: 'exactly', + }, + { + type: 'filter', and: [], column: 'time-column', - type: 'time', + filterType: 'time', from: '2019-06-04T04:00:00.000Z', to: '2019-06-05T04:00:00.000Z', }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts index d98fea2ec1be8..94c7a1c8a9eea 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts @@ -12,7 +12,7 @@ import { EmbeddableExpression, } from '../../expression_types'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; -import { Filter, TimeRange as TimeRangeArg, SeriesStyle } from '../../../types'; +import { ExpressionValueFilter, TimeRange as TimeRangeArg, SeriesStyle } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -31,7 +31,7 @@ const defaultTimeRange = { export function savedVisualization(): ExpressionFunctionDefinition< 'savedVisualization', - Filter | null, + ExpressionValueFilter | null, Arguments, Output > { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.test.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.test.js index aeab0d50c31a7..834b9d195856c 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.test.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.test.js @@ -44,7 +44,7 @@ describe('timefilter', () => { from: fromDate, to: toDate, }).and[0] - ).toHaveProperty('type', 'time'); + ).toHaveProperty('filterType', 'time'); }); describe('args', () => { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.ts index 249faf6141b46..ff7b56d8194df 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.ts @@ -5,7 +5,7 @@ */ import dateMath from '@elastic/datemath'; -import { Filter, ExpressionFunctionDefinition } from '../../../types'; +import { ExpressionValueFilter, ExpressionFunctionDefinition } from '../../../types'; import { getFunctionHelp, getFunctionErrors } from '../../../i18n'; interface Arguments { @@ -17,9 +17,9 @@ interface Arguments { export function timefilter(): ExpressionFunctionDefinition< 'timefilter', - Filter, + ExpressionValueFilter, Arguments, - Filter + ExpressionValueFilter > { const { help, args: argHelp } = getFunctionHelp().timefilter; const errors = getFunctionErrors().timefilter; @@ -58,8 +58,9 @@ export function timefilter(): ExpressionFunctionDefinition< } const { from, to, column } = args; - const filter: Filter = { - type: 'time', + const filter: ExpressionValueFilter = { + type: 'filter', + filterType: 'time', column, and: [], }; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata.test.ts index 94b2d5228665b..2b517664793a7 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata.test.ts @@ -5,12 +5,11 @@ */ import { demodata } from './demodata'; +import { ExpressionValueFilter } from '../../../types'; -const nullFilter = { +const nullFilter: ExpressionValueFilter = { type: 'filter', - meta: {}, - size: null, - sort: [], + filterType: 'filter', and: [], }; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts index 5cebae5bb669f..843e2bda47e12 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts @@ -10,14 +10,19 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions'; import { queryDatatable } from '../../../../common/lib/datatable/query'; import { DemoRows } from './demo_rows_types'; import { getDemoRows } from './get_demo_rows'; -import { Filter, Datatable, DatatableColumn, DatatableRow } from '../../../../types'; +import { ExpressionValueFilter, Datatable, DatatableColumn, DatatableRow } from '../../../../types'; import { getFunctionHelp } from '../../../../i18n'; interface Arguments { type: string; } -export function demodata(): ExpressionFunctionDefinition<'demodata', Filter, Arguments, Datatable> { +export function demodata(): ExpressionFunctionDefinition< + 'demodata', + ExpressionValueFilter, + Arguments, + Datatable +> { const { help, args: argHelp } = getFunctionHelp().demodata; return { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/escount.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/escount.ts index ffb8bb4f3e2a7..3f5d0610b4c72 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/escount.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/escount.ts @@ -4,7 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ExpressionFunctionDefinition, Filter } from 'src/plugins/expressions/common'; +import { + ExpressionFunctionDefinition, + ExpressionValueFilter, +} from 'src/plugins/expressions/common'; // @ts-ignore untyped local import { buildESRequest } from '../../../server/lib/build_es_request'; import { getFunctionHelp } from '../../../i18n'; @@ -14,7 +17,12 @@ interface Arguments { query: string; } -export function escount(): ExpressionFunctionDefinition<'escount', Filter, Arguments, any> { +export function escount(): ExpressionFunctionDefinition< + 'escount', + ExpressionValueFilter, + Arguments, + any +> { const { help, args: argHelp } = getFunctionHelp().escount; return { @@ -40,7 +48,8 @@ export function escount(): ExpressionFunctionDefinition<'escount', Filter, Argum fn: (input, args, handlers) => { input.and = input.and.concat([ { - type: 'luceneQueryString', + type: 'filter', + filterType: 'luceneQueryString', query: args.query, and: [], }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts index 5bff06bb3933b..d60297ee2da3f 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts @@ -8,7 +8,7 @@ import squel from 'squel'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions'; // @ts-ignore untyped local import { queryEsSQL } from '../../../server/lib/query_es_sql'; -import { Filter } from '../../../types'; +import { ExpressionValueFilter } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -20,7 +20,12 @@ interface Arguments { count: number; } -export function esdocs(): ExpressionFunctionDefinition<'esdocs', Filter, Arguments, any> { +export function esdocs(): ExpressionFunctionDefinition< + 'esdocs', + ExpressionValueFilter, + Arguments, + any +> { const { help, args: argHelp } = getFunctionHelp().esdocs; return { @@ -67,7 +72,8 @@ export function esdocs(): ExpressionFunctionDefinition<'esdocs', Filter, Argumen input.and = input.and.concat([ { - type: 'luceneQueryString', + type: 'filter', + filterType: 'luceneQueryString', query: args.query, and: [], }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/essql.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/essql.ts index cdb6b5af82015..b972f5a3bd4a6 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/essql.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/essql.ts @@ -7,7 +7,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; // @ts-ignore untyped local import { queryEsSQL } from '../../../server/lib/query_es_sql'; -import { Filter } from '../../../types'; +import { ExpressionValueFilter } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -16,7 +16,12 @@ interface Arguments { timezone: string; } -export function essql(): ExpressionFunctionDefinition<'essql', Filter, Arguments, any> { +export function essql(): ExpressionFunctionDefinition< + 'essql', + ExpressionValueFilter, + Arguments, + any +> { const { help, args: argHelp } = getFunctionHelp().essql; return { diff --git a/x-pack/legacy/plugins/canvas/common/lib/datatable/query.js b/x-pack/legacy/plugins/canvas/common/lib/datatable/query.js index f61e2b6434697..63945ce7690f9 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/datatable/query.js +++ b/x-pack/legacy/plugins/canvas/common/lib/datatable/query.js @@ -15,14 +15,14 @@ export function queryDatatable(datatable, query) { if (query.and) { query.and.forEach(filter => { // handle exact matches - if (filter.type === 'exactly') { + if (filter.filterType === 'exactly') { datatable.rows = datatable.rows.filter(row => { return row[filter.column] === filter.value; }); } // handle time filters - if (filter.type === 'time') { + if (filter.filterType === 'time') { const columnNames = datatable.columns.map(col => col.name); // remove row if no column match diff --git a/x-pack/legacy/plugins/canvas/public/functions/filters.ts b/x-pack/legacy/plugins/canvas/public/functions/filters.ts index 2a3bc481d7dae..16d0bb0fff708 100644 --- a/x-pack/legacy/plugins/canvas/public/functions/filters.ts +++ b/x-pack/legacy/plugins/canvas/public/functions/filters.ts @@ -11,7 +11,7 @@ import { interpretAst } from '../lib/run_interpreter'; // @ts-ignore untyped local import { getState } from '../state/store'; import { getGlobalFilters } from '../state/selectors/workpad'; -import { Filter } from '../../types'; +import { ExpressionValueFilter } from '../../types'; import { getFunctionHelp } from '../../i18n'; import { InitializeArguments } from '.'; @@ -41,7 +41,12 @@ function getFiltersByGroup(allFilters: string[], groups?: string[], ungrouped = }); } -type FiltersFunction = ExpressionFunctionDefinition<'filters', null, Arguments, Filter>; +type FiltersFunction = ExpressionFunctionDefinition< + 'filters', + null, + Arguments, + ExpressionValueFilter +>; export function filtersFunctionFactory(initialize: InitializeArguments): () => FiltersFunction { return function filters(): FiltersFunction { diff --git a/x-pack/legacy/plugins/canvas/public/functions/timelion.ts b/x-pack/legacy/plugins/canvas/public/functions/timelion.ts index e59d798108945..d07b3bf6d1d1c 100644 --- a/x-pack/legacy/plugins/canvas/public/functions/timelion.ts +++ b/x-pack/legacy/plugins/canvas/public/functions/timelion.ts @@ -11,7 +11,7 @@ import { ExpressionFunctionDefinition, DatatableRow } from 'src/plugins/expressi import { fetch } from '../../common/lib/fetch'; // @ts-ignore untyped local import { buildBoolArray } from '../../server/lib/build_bool_array'; -import { Datatable, Filter } from '../../types'; +import { Datatable, ExpressionValueFilter } from '../../types'; import { getFunctionHelp } from '../../i18n'; import { InitializeArguments } from './'; @@ -49,7 +49,7 @@ function parseDateMath( type TimelionFunction = ExpressionFunctionDefinition< 'timelion', - Filter, + ExpressionValueFilter, Arguments, Promise >; @@ -94,11 +94,10 @@ export function timelionFunctionFactory(initialize: InitializeArguments): () => fn: (input, args): Promise => { // Timelion requires a time range. Use the time range from the timefilter element in the // workpad, if it exists. Otherwise fall back on the function args. - const timeFilter = input.and.find(and => and.type === 'time'); + const timeFilter = input.and.find(and => and.filterType === 'time'); const range = timeFilter ? { min: timeFilter.from, max: timeFilter.to } : parseDateMath({ from: args.from, to: args.to }, args.timezone, initialize.timefilter); - const body = { extended: { es: { diff --git a/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.test.ts b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.test.ts index b422a9451293f..77be181d47378 100644 --- a/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.test.ts +++ b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.test.ts @@ -5,19 +5,21 @@ */ import { buildEmbeddableFilters } from './build_embeddable_filters'; -import { Filter } from '../../types'; +import { ExpressionValueFilter } from '../../types'; -const columnFilter: Filter = { +const columnFilter: ExpressionValueFilter = { + type: 'filter', and: [], value: 'filter-value', column: 'filter-column', - type: 'exactly', + filterType: 'exactly', }; -const timeFilter: Filter = { +const timeFilter: ExpressionValueFilter = { + type: 'filter', and: [], column: 'time-column', - type: 'time', + filterType: 'time', from: '2019-06-04T04:00:00.000Z', to: '2019-06-05T04:00:00.000Z', }; diff --git a/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts index 1a5d2119a94b6..aa915d0d3d02a 100644 --- a/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts +++ b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Filter } from '../../types'; +import { ExpressionValueFilter } from '../../types'; // @ts-ignore Untyped Local import { buildBoolArray } from './build_bool_array'; import { @@ -20,9 +20,9 @@ export interface EmbeddableFilterInput { const TimeFilterType = 'time'; -function getTimeRangeFromFilters(filters: Filter[]): TimeRange | undefined { +function getTimeRangeFromFilters(filters: ExpressionValueFilter[]): TimeRange | undefined { const timeFilter = filters.find( - filter => filter.type !== undefined && filter.type === TimeFilterType + filter => filter.filterType !== undefined && filter.filterType === TimeFilterType ); return timeFilter !== undefined && timeFilter.from !== undefined && timeFilter.to !== undefined @@ -33,11 +33,12 @@ function getTimeRangeFromFilters(filters: Filter[]): TimeRange | undefined { : undefined; } -export function getQueryFilters(filters: Filter[]): DataFilter[] { - return buildBoolArray(filters).map(esFilters.buildQueryFilter); +export function getQueryFilters(filters: ExpressionValueFilter[]): DataFilter[] { + const dataFilters = filters.map(filter => ({ ...filter, type: filter.filterType })); + return buildBoolArray(dataFilters).map(esFilters.buildQueryFilter); } -export function buildEmbeddableFilters(filters: Filter[]): EmbeddableFilterInput { +export function buildEmbeddableFilters(filters: ExpressionValueFilter[]): EmbeddableFilterInput { return { timeRange: getTimeRangeFromFilters(filters), filters: getQueryFilters(filters), diff --git a/x-pack/legacy/plugins/canvas/server/lib/get_es_filter.js b/x-pack/legacy/plugins/canvas/server/lib/get_es_filter.js index e8a4d704118e8..7c025ed8dee9b 100644 --- a/x-pack/legacy/plugins/canvas/server/lib/get_es_filter.js +++ b/x-pack/legacy/plugins/canvas/server/lib/get_es_filter.js @@ -14,13 +14,13 @@ import * as filters from './filters'; export function getESFilter(filter) { - if (!filters[filter.type]) { - throw new Error(`Unknown filter type: ${filter.type}`); + if (!filters[filter.filterType]) { + throw new Error(`Unknown filter type: ${filter.filterType}`); } try { - return filters[filter.type](filter); + return filters[filter.filterType](filter); } catch (e) { - throw new Error(`Could not create elasticsearch filter from ${filter.type}`); + throw new Error(`Could not create elasticsearch filter from ${filter.filterType}`); } } diff --git a/x-pack/legacy/plugins/canvas/types/state.ts b/x-pack/legacy/plugins/canvas/types/state.ts index 13c8f7a9176ab..e9b580f81e668 100644 --- a/x-pack/legacy/plugins/canvas/types/state.ts +++ b/x-pack/legacy/plugins/canvas/types/state.ts @@ -6,7 +6,7 @@ import { Datatable, - Filter, + ExpressionValueFilter, ExpressionImage, ExpressionFunction, KibanaContext, @@ -46,7 +46,7 @@ interface ElementStatsType { type ExpressionType = | Datatable - | Filter + | ExpressionValueFilter | ExpressionImage | KibanaContext | KibanaDatatable From 306a5fe55ebbf05d095e5f0504e495cef2032049 Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Mon, 4 May 2020 10:53:06 -0600 Subject: [PATCH 010/153] Use brotli compression for some KP assets (#64367) --- package.json | 2 + packages/kbn-optimizer/package.json | 2 + .../basic_optimization.test.ts | 39 +++++++--- .../src/worker/webpack.config.ts | 11 +++ packages/kbn-ui-shared-deps/package.json | 1 + packages/kbn-ui-shared-deps/webpack.config.js | 11 +++ renovate.json5 | 2 + src/dev/renovate/package_groups.ts | 12 ++- .../bundles_route/dynamic_asset_response.ts | 45 +++++++++++- test/functional/apps/bundles/index.js | 73 +++++++++++++++++++ test/functional/config.js | 3 +- test/functional/services/index.ts | 2 + test/functional/services/supertest.ts | 29 ++++++++ typings/accept.d.ts | 23 ++++++ yarn.lock | 42 ++++++++--- 15 files changed, 274 insertions(+), 23 deletions(-) create mode 100644 test/functional/apps/bundles/index.js create mode 100644 test/functional/services/supertest.ts create mode 100644 typings/accept.d.ts diff --git a/package.json b/package.json index 1e3ddc976aa67..e711235e16ea5 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "@types/tar": "^4.0.3", "JSONStream": "1.3.5", "abortcontroller-polyfill": "^1.4.0", + "accept": "3.0.2", "angular": "^1.7.9", "angular-aria": "^1.7.9", "angular-elastic": "^2.5.1", @@ -310,6 +311,7 @@ "@percy/agent": "^0.26.0", "@testing-library/react": "^9.3.2", "@testing-library/react-hooks": "^3.2.1", + "@types/accept": "3.1.1", "@types/angular": "^1.6.56", "@types/angular-mocks": "^1.7.0", "@types/babel__core": "^7.1.2", diff --git a/packages/kbn-optimizer/package.json b/packages/kbn-optimizer/package.json index b3e5a8c518682..b7c9a63897bf9 100644 --- a/packages/kbn-optimizer/package.json +++ b/packages/kbn-optimizer/package.json @@ -14,6 +14,7 @@ "@kbn/babel-preset": "1.0.0", "@kbn/dev-utils": "1.0.0", "@kbn/ui-shared-deps": "1.0.0", + "@types/compression-webpack-plugin": "^2.0.1", "@types/estree": "^0.0.44", "@types/loader-utils": "^1.1.3", "@types/watchpack": "^1.1.5", @@ -23,6 +24,7 @@ "autoprefixer": "^9.7.4", "babel-loader": "^8.0.6", "clean-webpack-plugin": "^3.0.0", + "compression-webpack-plugin": "^3.1.0", "cpy": "^8.0.0", "css-loader": "^3.4.2", "del": "^5.1.0", diff --git a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts index ad743933e1171..248b0b7cf4c97 100644 --- a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts +++ b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts @@ -19,6 +19,7 @@ import Path from 'path'; import Fs from 'fs'; +import Zlib from 'zlib'; import { inspect } from 'util'; import cpy from 'cpy'; @@ -124,17 +125,12 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { ); assert('produce zero unexpected states', otherStates.length === 0, otherStates); - expect( - Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, 'plugins/foo/target/public/foo.plugin.js'), 'utf8') - ).toMatchSnapshot('foo bundle'); - - expect( - Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, 'plugins/foo/target/public/1.plugin.js'), 'utf8') - ).toMatchSnapshot('1 async bundle'); - - expect( - Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, 'plugins/bar/target/public/bar.plugin.js'), 'utf8') - ).toMatchSnapshot('bar bundle'); + expectFileMatchesSnapshotWithCompression('plugins/foo/target/public/foo.plugin.js', 'foo bundle'); + expectFileMatchesSnapshotWithCompression( + 'plugins/foo/target/public/1.plugin.js', + '1 async bundle' + ); + expectFileMatchesSnapshotWithCompression('plugins/bar/target/public/bar.plugin.js', 'bar bundle'); const foo = config.bundles.find(b => b.id === 'foo')!; expect(foo).toBeTruthy(); @@ -203,3 +199,24 @@ it('uses cache on second run and exist cleanly', async () => { ] `); }); + +/** + * Verifies that the file matches the expected output and has matching compressed variants. + */ +const expectFileMatchesSnapshotWithCompression = (filePath: string, snapshotLabel: string) => { + const raw = Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, filePath), 'utf8'); + expect(raw).toMatchSnapshot(snapshotLabel); + + // Verify the brotli variant matches + expect( + // @ts-ignore @types/node is missing the brotli functions + Zlib.brotliDecompressSync( + Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, `${filePath}.br`)) + ).toString() + ).toEqual(raw); + + // Verify the gzip variant matches + expect( + Zlib.gunzipSync(Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, `${filePath}.gz`))).toString() + ).toEqual(raw); +}; diff --git a/packages/kbn-optimizer/src/worker/webpack.config.ts b/packages/kbn-optimizer/src/worker/webpack.config.ts index cc3fa8c2720de..95e826e7620aa 100644 --- a/packages/kbn-optimizer/src/worker/webpack.config.ts +++ b/packages/kbn-optimizer/src/worker/webpack.config.ts @@ -28,6 +28,7 @@ import TerserPlugin from 'terser-webpack-plugin'; import webpackMerge from 'webpack-merge'; // @ts-ignore import { CleanWebpackPlugin } from 'clean-webpack-plugin'; +import CompressionPlugin from 'compression-webpack-plugin'; import * as UiSharedDeps from '@kbn/ui-shared-deps'; import { Bundle, WorkerConfig, parseDirPath, DisallowedSyntaxPlugin } from '../common'; @@ -319,6 +320,16 @@ export function getWebpackConfig(bundle: Bundle, worker: WorkerConfig) { IS_KIBANA_DISTRIBUTABLE: `"true"`, }, }), + new CompressionPlugin({ + algorithm: 'brotliCompress', + filename: '[path].br', + test: /\.(js|css)$/, + }), + new CompressionPlugin({ + algorithm: 'gzip', + filename: '[path].gz', + test: /\.(js|css)$/, + }), ], optimization: { diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index a60e2b0449d95..ec61e8519c960 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -14,6 +14,7 @@ "@kbn/i18n": "1.0.0", "abortcontroller-polyfill": "^1.4.0", "angular": "^1.7.9", + "compression-webpack-plugin": "^3.1.0", "core-js": "^3.6.4", "custom-event-polyfill": "^0.3.0", "elasticsearch-browser": "^16.7.0", diff --git a/packages/kbn-ui-shared-deps/webpack.config.js b/packages/kbn-ui-shared-deps/webpack.config.js index bf63c57765859..52e7bb620b50b 100644 --- a/packages/kbn-ui-shared-deps/webpack.config.js +++ b/packages/kbn-ui-shared-deps/webpack.config.js @@ -20,6 +20,7 @@ const Path = require('path'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const CompressionPlugin = require('compression-webpack-plugin'); const { REPO_ROOT } = require('@kbn/dev-utils'); const webpack = require('webpack'); @@ -117,5 +118,15 @@ exports.getWebpackConfig = ({ dev = false } = {}) => ({ new webpack.DefinePlugin({ 'process.env.NODE_ENV': dev ? '"development"' : '"production"', }), + new CompressionPlugin({ + algorithm: 'brotliCompress', + filename: '[path].br', + test: /\.(js|css)$/, + }), + new CompressionPlugin({ + algorithm: 'gzip', + filename: '[path].gz', + test: /\.(js|css)$/, + }), ], }); diff --git a/renovate.json5 b/renovate.json5 index c0ddcaf4f23c8..61b2485ecf44b 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -398,6 +398,8 @@ '@types/good-squeeze', 'inert', '@types/inert', + 'accept', + '@types/accept', ], }, { diff --git a/src/dev/renovate/package_groups.ts b/src/dev/renovate/package_groups.ts index 1bc65fd149f47..9f5aa8556ac21 100644 --- a/src/dev/renovate/package_groups.ts +++ b/src/dev/renovate/package_groups.ts @@ -159,7 +159,17 @@ export const RENOVATE_PACKAGE_GROUPS: PackageGroup[] = [ { name: 'hapi', packageWords: ['hapi'], - packageNames: ['hapi', 'joi', 'boom', 'hoek', 'h2o2', '@elastic/good', 'good-squeeze', 'inert'], + packageNames: [ + 'hapi', + 'joi', + 'boom', + 'hoek', + 'h2o2', + '@elastic/good', + 'good-squeeze', + 'inert', + 'accept', + ], }, { diff --git a/src/optimize/bundles_route/dynamic_asset_response.ts b/src/optimize/bundles_route/dynamic_asset_response.ts index a020c6935eeec..2f5395341abb1 100644 --- a/src/optimize/bundles_route/dynamic_asset_response.ts +++ b/src/optimize/bundles_route/dynamic_asset_response.ts @@ -21,6 +21,7 @@ import Fs from 'fs'; import { resolve } from 'path'; import { promisify } from 'util'; +import Accept from 'accept'; import Boom from 'boom'; import Hapi from 'hapi'; @@ -37,6 +38,41 @@ const asyncOpen = promisify(Fs.open); const asyncClose = promisify(Fs.close); const asyncFstat = promisify(Fs.fstat); +async function tryToOpenFile(filePath: string) { + try { + return await asyncOpen(filePath, 'r'); + } catch (e) { + if (e.code === 'ENOENT') { + return undefined; + } else { + throw e; + } + } +} + +async function selectCompressedFile(acceptEncodingHeader: string | undefined, path: string) { + let fd: number | undefined; + let fileEncoding: 'gzip' | 'br' | undefined; + + const supportedEncodings = Accept.encodings(acceptEncodingHeader, ['br', 'gzip']); + + if (supportedEncodings[0] === 'br') { + fileEncoding = 'br'; + fd = await tryToOpenFile(`${path}.br`); + } + if (!fd && supportedEncodings.includes('gzip')) { + fileEncoding = 'gzip'; + fd = await tryToOpenFile(`${path}.gz`); + } + if (!fd) { + fileEncoding = undefined; + // Use raw open to trigger exception if it does not exist + fd = await asyncOpen(path, 'r'); + } + + return { fd, fileEncoding }; +} + /** * Create a Hapi response for the requested path. This is designed * to replicate a subset of the features provided by Hapi's Inert @@ -74,6 +110,7 @@ export async function createDynamicAssetResponse({ isDist: boolean; }) { let fd: number | undefined; + let fileEncoding: 'gzip' | 'br' | undefined; try { const path = resolve(bundlesPath, request.params.path); @@ -86,7 +123,7 @@ export async function createDynamicAssetResponse({ // we use and manage a file descriptor mostly because // that's what Inert does, and since we are accessing // the file 2 or 3 times per request it seems logical - fd = await asyncOpen(path, 'r'); + ({ fd, fileEncoding } = await selectCompressedFile(request.headers['accept-encoding'], path)); const stat = await asyncFstat(fd); const hash = isDist ? undefined : await getFileHash(fileHashCache, path, stat, fd); @@ -113,6 +150,12 @@ export async function createDynamicAssetResponse({ response.header('cache-control', 'must-revalidate'); } + // If we manually selected a compressed file, specify the encoding header. + // Otherwise, let Hapi automatically gzip the response. + if (fileEncoding) { + response.header('content-encoding', fileEncoding); + } + return response; } catch (error) { if (fd) { diff --git a/test/functional/apps/bundles/index.js b/test/functional/apps/bundles/index.js new file mode 100644 index 0000000000000..8a25c7cd1fafc --- /dev/null +++ b/test/functional/apps/bundles/index.js @@ -0,0 +1,73 @@ +/* + * 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. + */ + +/** + * These supertest-based tests live in the functional test suite because they depend on the optimizer bundles being built + * and served + */ +export default function({ getService }) { + const supertest = getService('supertest'); + + describe('bundle compression', function() { + this.tags('ciGroup12'); + + let buildNum; + before(async () => { + const resp = await supertest.get('/api/status').expect(200); + buildNum = resp.body.version.build_number; + }); + + it('returns gzip files when client only supports gzip', () => + supertest + // We use the kbn-ui-shared-deps for these tests since they are always built with br compressed outputs, + // even in dev. Bundles built by @kbn/optimizer are only built with br compression in dist mode. + .get(`/${buildNum}/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.js`) + .set('Accept-Encoding', 'gzip') + .expect(200) + .expect('Content-Encoding', 'gzip')); + + it('returns br files when client only supports br', () => + supertest + .get(`/${buildNum}/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.js`) + .set('Accept-Encoding', 'br') + .expect(200) + .expect('Content-Encoding', 'br')); + + it('returns br files when client only supports gzip and br', () => + supertest + .get(`/${buildNum}/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.js`) + .set('Accept-Encoding', 'gzip, br') + .expect(200) + .expect('Content-Encoding', 'br')); + + it('returns gzip files when client prefers gzip', () => + supertest + .get(`/${buildNum}/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.js`) + .set('Accept-Encoding', 'gzip;q=1.0, br;q=0.5') + .expect(200) + .expect('Content-Encoding', 'gzip')); + + it('returns gzip files when no brotli version exists', () => + supertest + .get(`/${buildNum}/bundles/commons.style.css`) // legacy optimizer does not create brotli outputs + .set('Accept-Encoding', 'gzip, br') + .expect(200) + .expect('Content-Encoding', 'gzip')); + }); +} diff --git a/test/functional/config.js b/test/functional/config.js index 0fbde95afe12c..8cc0a34e352a9 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -25,11 +25,12 @@ export default async function({ readConfigFile }) { return { testFiles: [ + require.resolve('./apps/bundles'), require.resolve('./apps/console'), - require.resolve('./apps/getting_started'), require.resolve('./apps/context'), require.resolve('./apps/dashboard'), require.resolve('./apps/discover'), + require.resolve('./apps/getting_started'), require.resolve('./apps/home'), require.resolve('./apps/management'), require.resolve('./apps/saved_objects_management'), diff --git a/test/functional/services/index.ts b/test/functional/services/index.ts index a10bb013b3af4..02ed9e9865d9a 100644 --- a/test/functional/services/index.ts +++ b/test/functional/services/index.ts @@ -51,6 +51,7 @@ import { ToastsProvider } from './toasts'; import { PieChartProvider } from './visualizations'; import { ListingTableProvider } from './listing_table'; import { SavedQueryManagementComponentProvider } from './saved_query_management_component'; +import { KibanaSupertestProvider } from './supertest'; export const services = { ...commonServiceProviders, @@ -83,4 +84,5 @@ export const services = { toasts: ToastsProvider, savedQueryManagementComponent: SavedQueryManagementComponentProvider, elasticChart: ElasticChartProvider, + supertest: KibanaSupertestProvider, }; diff --git a/test/functional/services/supertest.ts b/test/functional/services/supertest.ts new file mode 100644 index 0000000000000..30c7db87a8f8b --- /dev/null +++ b/test/functional/services/supertest.ts @@ -0,0 +1,29 @@ +/* + * 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 { FtrProviderContext } from 'test/functional/ftr_provider_context'; +import { format as formatUrl } from 'url'; + +import supertestAsPromised from 'supertest-as-promised'; + +export function KibanaSupertestProvider({ getService }: FtrProviderContext) { + const config = getService('config'); + const kibanaServerUrl = formatUrl(config.get('servers.kibana')); + return supertestAsPromised(kibanaServerUrl); +} diff --git a/typings/accept.d.ts b/typings/accept.d.ts new file mode 100644 index 0000000000000..69cadc7491eeb --- /dev/null +++ b/typings/accept.d.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ + +declare module 'accept' { + // @types/accept does not include the `preferences` argument so we override the type to include it + export function encodings(encodingHeader?: string, preferences?: string[]): string[]; +} diff --git a/yarn.lock b/yarn.lock index 3c233b76f1a48..941143e76483e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3624,6 +3624,11 @@ dependencies: "@turf/helpers" "6.x" +"@types/accept@3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@types/accept/-/accept-3.1.1.tgz#74457f6afabd23181e32b6bafae238bda0ce0da7" + integrity sha512-pXwi0bKUriKuNUv7d1xwbxKTqyTIzmMr1StxcGARmiuTLQyjNo+YwDq0w8dzY8wQjPofdgs1hvQLTuJaGuSKiQ== + "@types/angular-mocks@^1.7.0": version "1.7.0" resolved "https://registry.yarnpkg.com/@types/angular-mocks/-/angular-mocks-1.7.0.tgz#310d999a3c47c10ecd8eef466b5861df84799429" @@ -3852,6 +3857,13 @@ dependencies: "@types/color-convert" "*" +"@types/compression-webpack-plugin@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/compression-webpack-plugin/-/compression-webpack-plugin-2.0.1.tgz#4db78c398c8e973077cc530014d6513f1c693951" + integrity sha512-40oKg2aByfUPShpYBkldYwOcO34yaqOIPdlUlR1+F3MFl2WfpqYq2LFKOcgjU70d1r1L8r99XHkxYdhkGajHSw== + dependencies: + "@types/webpack" "*" + "@types/cookiejar@*": version "2.1.0" resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.0.tgz#4b7daf2c51696cfc70b942c11690528229d1a1ce" @@ -5454,7 +5466,7 @@ abortcontroller-polyfill@^1.4.0: resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.4.0.tgz#0d5eb58e522a461774af8086414f68e1dda7a6c4" integrity sha512-3ZFfCRfDzx3GFjO6RAkYx81lPGpUS20ISxux9gLxuKnqafNcFQo59+IoZqpO2WvQlyc287B62HDnDdNYRmlvWA== -accept@3.x.x: +accept@3.0.2, accept@3.x.x: version "3.0.2" resolved "https://registry.yarnpkg.com/accept/-/accept-3.0.2.tgz#83e41cec7e1149f3fd474880423873db6c6cc9ac" integrity sha512-bghLXFkCOsC1Y2TZ51etWfKDs6q249SAoHTZVfzWWdlZxoij+mgkj9AmUJWQpDY48TfnrTDIe43Xem4zdMe7mQ== @@ -9501,6 +9513,18 @@ compressible@~2.0.16: dependencies: mime-db ">= 1.40.0 < 2" +compression-webpack-plugin@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/compression-webpack-plugin/-/compression-webpack-plugin-3.1.0.tgz#9f510172a7b5fae5aad3b670652e8bd7997aeeca" + integrity sha512-iqTHj3rADN4yHwXMBrQa/xrncex/uEQy8QHlaTKxGchT/hC0SdlJlmL/5eRqffmWq2ep0/Romw6Ld39JjTR/ug== + dependencies: + cacache "^13.0.1" + find-cache-dir "^3.0.0" + neo-async "^2.5.0" + schema-utils "^2.6.1" + serialize-javascript "^2.1.2" + webpack-sources "^1.0.1" + compression@^1.7.4: version "1.7.4" resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f" @@ -31654,18 +31678,18 @@ webpack-merge@4.2.2, webpack-merge@^4.2.2: dependencies: lodash "^4.17.15" -webpack-sources@^1.1.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.3.0.tgz#2a28dcb9f1f45fe960d8f1493252b5ee6530fa85" - integrity sha512-OiVgSrbGu7NEnEvQJJgdSFPl2qWKkWq5lHMhgiToIiN9w34EBnjYzSYs+VbL5KoYiLNtFFa7BZIKxRED3I32pA== +webpack-sources@^1.0.1, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" + integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== dependencies: source-list-map "^2.0.0" source-map "~0.6.1" -webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: - version "1.4.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" - integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== +webpack-sources@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.3.0.tgz#2a28dcb9f1f45fe960d8f1493252b5ee6530fa85" + integrity sha512-OiVgSrbGu7NEnEvQJJgdSFPl2qWKkWq5lHMhgiToIiN9w34EBnjYzSYs+VbL5KoYiLNtFFa7BZIKxRED3I32pA== dependencies: source-list-map "^2.0.0" source-map "~0.6.1" From 6ab1b20eeb5587d66535ff9c97f54370b67ea0a1 Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Mon, 4 May 2020 10:53:47 -0600 Subject: [PATCH 011/153] Display global loading bar while applications are mounting (#64556) --- .../application_service.test.ts.snap | 1 + .../application/application_service.test.ts | 77 +++++++++++++++- .../application/application_service.tsx | 4 + .../integration_tests/router.test.tsx | 30 ++++--- .../application/integration_tests/utils.tsx | 5 +- .../public/application/ui/app_container.scss | 25 ++++++ .../application/ui/app_container.test.tsx | 90 ++++++++++++++++++- .../public/application/ui/app_container.tsx | 35 ++++++-- src/core/public/application/ui/app_router.tsx | 6 +- 9 files changed, 242 insertions(+), 31 deletions(-) create mode 100644 src/core/public/application/ui/app_container.scss diff --git a/src/core/public/application/__snapshots__/application_service.test.ts.snap b/src/core/public/application/__snapshots__/application_service.test.ts.snap index 376b320b64ea9..c085fb028cd5a 100644 --- a/src/core/public/application/__snapshots__/application_service.test.ts.snap +++ b/src/core/public/application/__snapshots__/application_service.test.ts.snap @@ -80,5 +80,6 @@ exports[`#start() getComponent returns renderable JSX tree 1`] = ` } mounters={Map {}} setAppLeaveHandler={[Function]} + setIsMounting={[Function]} /> `; diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index e29837aecb125..04ff844ffc150 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -20,7 +20,7 @@ import { createElement } from 'react'; import { BehaviorSubject, Subject } from 'rxjs'; import { bufferCount, take, takeUntil } from 'rxjs/operators'; -import { shallow } from 'enzyme'; +import { shallow, mount } from 'enzyme'; import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; import { contextServiceMock } from '../context/context_service.mock'; @@ -30,6 +30,7 @@ import { MockCapabilitiesService, MockHistory } from './application_service.test import { MockLifecycle } from './test_types'; import { ApplicationService } from './application_service'; import { App, AppNavLinkStatus, AppStatus, AppUpdater, LegacyApp } from './types'; +import { act } from 'react-dom/test-utils'; const createApp = (props: Partial): App => { return { @@ -452,9 +453,9 @@ describe('#setup()', () => { const container = setupDeps.context.createContextContainer.mock.results[0].value; const pluginId = Symbol(); - const mount = () => () => undefined; - registerMountContext(pluginId, 'test' as any, mount); - expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', mount); + const appMount = () => () => undefined; + registerMountContext(pluginId, 'test' as any, appMount); + expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', appMount); }); }); @@ -809,6 +810,74 @@ describe('#start()', () => { `); }); + it('updates httpLoadingCount$ while mounting', async () => { + // Use a memory history so that mounting the component will work + const { createMemoryHistory } = jest.requireActual('history'); + const history = createMemoryHistory(); + setupDeps.history = history; + + const flushPromises = () => new Promise(resolve => setImmediate(resolve)); + // Create an app and a promise that allows us to control when the app completes mounting + const createWaitingApp = (props: Partial): [App, () => void] => { + let finishMount: () => void; + const mountPromise = new Promise(resolve => (finishMount = resolve)); + const app = { + id: 'some-id', + title: 'some-title', + mount: async () => { + await mountPromise; + return () => undefined; + }, + ...props, + }; + + return [app, finishMount!]; + }; + + // Create some dummy applications + const { register } = service.setup(setupDeps); + const [alphaApp, finishAlphaMount] = createWaitingApp({ id: 'alpha' }); + const [betaApp, finishBetaMount] = createWaitingApp({ id: 'beta' }); + register(Symbol(), alphaApp); + register(Symbol(), betaApp); + + const { navigateToApp, getComponent } = await service.start(startDeps); + const httpLoadingCount$ = startDeps.http.addLoadingCountSource.mock.calls[0][0]; + const stop$ = new Subject(); + const currentLoadingCount$ = new BehaviorSubject(0); + httpLoadingCount$.pipe(takeUntil(stop$)).subscribe(currentLoadingCount$); + const loadingPromise = httpLoadingCount$.pipe(bufferCount(5), takeUntil(stop$)).toPromise(); + mount(getComponent()!); + + await act(() => navigateToApp('alpha')); + expect(currentLoadingCount$.value).toEqual(1); + await act(async () => { + finishAlphaMount(); + await flushPromises(); + }); + expect(currentLoadingCount$.value).toEqual(0); + + await act(() => navigateToApp('beta')); + expect(currentLoadingCount$.value).toEqual(1); + await act(async () => { + finishBetaMount(); + await flushPromises(); + }); + expect(currentLoadingCount$.value).toEqual(0); + + stop$.next(); + const loadingCounts = await loadingPromise; + expect(loadingCounts).toMatchInlineSnapshot(` + Array [ + 0, + 1, + 0, + 1, + 0, + ] + `); + }); + it('sets window.location.href when navigating to legacy apps', async () => { setupDeps.http = httpServiceMock.createSetupContract({ basePath: '/test' }); setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index bafa1932e5e92..0dd77072e9eaf 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -238,6 +238,9 @@ export class ApplicationService { throw new Error('ApplicationService#setup() must be invoked before start.'); } + const httpLoadingCount$ = new BehaviorSubject(0); + http.addLoadingCountSource(httpLoadingCount$); + this.registrationClosed = true; window.addEventListener('beforeunload', this.onBeforeUnload); @@ -303,6 +306,7 @@ export class ApplicationService { mounters={availableMounters} appStatuses$={applicationStatuses$} setAppLeaveHandler={this.setAppLeaveHandler} + setIsMounting={isMounting => httpLoadingCount$.next(isMounting ? 1 : 0)} /> ); }, diff --git a/src/core/public/application/integration_tests/router.test.tsx b/src/core/public/application/integration_tests/router.test.tsx index 2f26bc1409104..915c58b28ad6d 100644 --- a/src/core/public/application/integration_tests/router.test.tsx +++ b/src/core/public/application/integration_tests/router.test.tsx @@ -40,7 +40,7 @@ describe('AppContainer', () => { }; const mockMountersToMounters = () => new Map([...mounters].map(([appId, { mounter }]) => [appId, mounter])); - const setAppLeaveHandlerMock = () => undefined; + const noop = () => undefined; const mountersToAppStatus$ = () => { return new BehaviorSubject( @@ -86,7 +86,8 @@ describe('AppContainer', () => { history={globalHistory} mounters={mockMountersToMounters()} appStatuses$={appStatuses$} - setAppLeaveHandler={setAppLeaveHandlerMock} + setAppLeaveHandler={noop} + setIsMounting={noop} /> ); }); @@ -98,7 +99,7 @@ describe('AppContainer', () => { expect(app1.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /app/app1 html: App 1
" @@ -110,7 +111,7 @@ describe('AppContainer', () => { expect(app1Unmount).toHaveBeenCalled(); expect(app2.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /app/app2 html:
App 2
" @@ -124,7 +125,7 @@ describe('AppContainer', () => { expect(standardApp.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /app/app1 html: App 1
" @@ -136,7 +137,7 @@ describe('AppContainer', () => { expect(standardAppUnmount).toHaveBeenCalled(); expect(chromelessApp.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /chromeless-a/path html:
Chromeless A
" @@ -148,7 +149,7 @@ describe('AppContainer', () => { expect(chromelessAppUnmount).toHaveBeenCalled(); expect(standardApp.mounter.mount).toHaveBeenCalledTimes(2); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /app/app1 html: App 1
" @@ -162,7 +163,7 @@ describe('AppContainer', () => { expect(chromelessAppA.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /chromeless-a/path html:
Chromeless A
" @@ -174,7 +175,7 @@ describe('AppContainer', () => { expect(chromelessAppAUnmount).toHaveBeenCalled(); expect(chromelessAppB.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /chromeless-b/path html:
Chromeless B
" @@ -186,7 +187,7 @@ describe('AppContainer', () => { expect(chromelessAppBUnmount).toHaveBeenCalled(); expect(chromelessAppA.mounter.mount).toHaveBeenCalledTimes(2); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /chromeless-a/path html:
Chromeless A
" @@ -214,7 +215,8 @@ describe('AppContainer', () => { history={globalHistory} mounters={mockMountersToMounters()} appStatuses$={mountersToAppStatus$()} - setAppLeaveHandler={setAppLeaveHandlerMock} + setAppLeaveHandler={noop} + setIsMounting={noop} /> ); @@ -245,7 +247,8 @@ describe('AppContainer', () => { history={globalHistory} mounters={mockMountersToMounters()} appStatuses$={mountersToAppStatus$()} - setAppLeaveHandler={setAppLeaveHandlerMock} + setAppLeaveHandler={noop} + setIsMounting={noop} /> ); @@ -286,7 +289,8 @@ describe('AppContainer', () => { history={globalHistory} mounters={mockMountersToMounters()} appStatuses$={mountersToAppStatus$()} - setAppLeaveHandler={setAppLeaveHandlerMock} + setAppLeaveHandler={noop} + setIsMounting={noop} /> ); diff --git a/src/core/public/application/integration_tests/utils.tsx b/src/core/public/application/integration_tests/utils.tsx index 9092177da5ad4..fa04b56f83ba1 100644 --- a/src/core/public/application/integration_tests/utils.tsx +++ b/src/core/public/application/integration_tests/utils.tsx @@ -18,6 +18,7 @@ */ import React, { ReactElement } from 'react'; +import { act } from 'react-dom/test-utils'; import { mount } from 'enzyme'; import { I18nProvider } from '@kbn/i18n/react'; @@ -34,7 +35,9 @@ export const createRenderer = (element: ReactElement | null): Renderer => { return () => new Promise(async resolve => { if (dom) { - dom.update(); + await act(async () => { + dom.update(); + }); } setImmediate(() => resolve(dom)); // flushes any pending promises }); diff --git a/src/core/public/application/ui/app_container.scss b/src/core/public/application/ui/app_container.scss new file mode 100644 index 0000000000000..4f8fec10a97e1 --- /dev/null +++ b/src/core/public/application/ui/app_container.scss @@ -0,0 +1,25 @@ +.appContainer__loading { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: $euiZLevel1; + animation-name: appContainerFadeIn; + animation-iteration-count: 1; + animation-timing-function: ease-in; + animation-duration: 2s; +} + +@keyframes appContainerFadeIn { + 0% { + opacity: 0; + } + + 50% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} diff --git a/src/core/public/application/ui/app_container.test.tsx b/src/core/public/application/ui/app_container.test.tsx index c538227e8f098..2ee71a5bde7dc 100644 --- a/src/core/public/application/ui/app_container.test.tsx +++ b/src/core/public/application/ui/app_container.test.tsx @@ -18,6 +18,7 @@ */ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { mount } from 'enzyme'; import { AppContainer } from './app_container'; @@ -28,6 +29,12 @@ import { ScopedHistory } from '../scoped_history'; describe('AppContainer', () => { const appId = 'someApp'; const setAppLeaveHandler = jest.fn(); + const setIsMounting = jest.fn(); + + beforeEach(() => { + setAppLeaveHandler.mockClear(); + setIsMounting.mockClear(); + }); const flushPromises = async () => { await new Promise(async resolve => { @@ -67,6 +74,7 @@ describe('AppContainer', () => { appStatus={AppStatus.inaccessible} mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} + setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath) @@ -86,10 +94,86 @@ describe('AppContainer', () => { expect(wrapper.text()).toEqual(''); - resolvePromise(); - await flushPromises(); - wrapper.update(); + await act(async () => { + resolvePromise(); + await flushPromises(); + wrapper.update(); + }); expect(wrapper.text()).toContain('some-content'); }); + + it('should call setIsMounting while mounting', async () => { + const [waitPromise, resolvePromise] = createResolver(); + const mounter = createMounter(waitPromise); + + const wrapper = mount( + + // Create a history using the appPath as the current location + new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath) + } + /> + ); + + expect(setIsMounting).toHaveBeenCalledTimes(1); + expect(setIsMounting).toHaveBeenLastCalledWith(true); + + await act(async () => { + resolvePromise(); + await flushPromises(); + wrapper.update(); + }); + + expect(setIsMounting).toHaveBeenCalledTimes(2); + expect(setIsMounting).toHaveBeenLastCalledWith(false); + }); + + it('should call setIsMounting(false) if mounting throws', async () => { + const [waitPromise, resolvePromise] = createResolver(); + const mounter = { + appBasePath: '/base-path', + appRoute: '/some-route', + unmountBeforeMounting: false, + mount: async ({ element }: AppMountParameters) => { + await waitPromise; + throw new Error(`Mounting failed!`); + }, + }; + + const wrapper = mount( + + // Create a history using the appPath as the current location + new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath) + } + /> + ); + + expect(setIsMounting).toHaveBeenCalledTimes(1); + expect(setIsMounting).toHaveBeenLastCalledWith(true); + + // await expect( + await act(async () => { + resolvePromise(); + await flushPromises(); + wrapper.update(); + }); + // ).rejects.toThrow(); + + expect(setIsMounting).toHaveBeenCalledTimes(2); + expect(setIsMounting).toHaveBeenLastCalledWith(false); + }); }); diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index e12a0f2cf2fcd..aad7e6dcf270a 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -26,9 +26,11 @@ import React, { MutableRefObject, } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; import { AppLeaveHandler, AppStatus, AppUnmount, Mounter } from '../types'; import { AppNotFound } from './app_not_found_screen'; import { ScopedHistory } from '../scoped_history'; +import './app_container.scss'; interface Props { /** Path application is mounted on without the global basePath */ @@ -38,6 +40,7 @@ interface Props { appStatus: AppStatus; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; createScopedHistory: (appUrl: string) => ScopedHistory; + setIsMounting: (isMounting: boolean) => void; } export const AppContainer: FunctionComponent = ({ @@ -47,7 +50,9 @@ export const AppContainer: FunctionComponent = ({ setAppLeaveHandler, createScopedHistory, appStatus, + setIsMounting, }: Props) => { + const [showSpinner, setShowSpinner] = useState(true); const [appNotFound, setAppNotFound] = useState(false); const elementRef = useRef(null); const unmountRef: MutableRefObject = useRef(null); @@ -65,28 +70,42 @@ export const AppContainer: FunctionComponent = ({ } setAppNotFound(false); + setIsMounting(true); if (mounter.unmountBeforeMounting) { unmount(); } const mount = async () => { - unmountRef.current = - (await mounter.mount({ - appBasePath: mounter.appBasePath, - history: createScopedHistory(appPath), - element: elementRef.current!, - onAppLeave: handler => setAppLeaveHandler(appId, handler), - })) || null; + setShowSpinner(true); + try { + unmountRef.current = + (await mounter.mount({ + appBasePath: mounter.appBasePath, + history: createScopedHistory(appPath), + element: elementRef.current!, + onAppLeave: handler => setAppLeaveHandler(appId, handler), + })) || null; + } catch (e) { + // TODO: add error UI + } finally { + setShowSpinner(false); + setIsMounting(false); + } }; mount(); return unmount; - }, [appId, appStatus, mounter, createScopedHistory, setAppLeaveHandler, appPath]); + }, [appId, appStatus, mounter, createScopedHistory, setAppLeaveHandler, appPath, setIsMounting]); return ( {appNotFound && } + {showSpinner && ( +
+ +
+ )}
); diff --git a/src/core/public/application/ui/app_router.tsx b/src/core/public/application/ui/app_router.tsx index 4c135c5769067..ea7c5c9308fe2 100644 --- a/src/core/public/application/ui/app_router.tsx +++ b/src/core/public/application/ui/app_router.tsx @@ -32,6 +32,7 @@ interface Props { history: History; appStatuses$: Observable>; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; + setIsMounting: (isMounting: boolean) => void; } interface Params { @@ -43,6 +44,7 @@ export const AppRouter: FunctionComponent = ({ mounters, setAppLeaveHandler, appStatuses$, + setIsMounting, }) => { const appStatuses = useObservable(appStatuses$, new Map()); const createScopedHistory = useMemo( @@ -67,7 +69,7 @@ export const AppRouter: FunctionComponent = ({ appPath={url} appStatus={appStatuses.get(appId) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ appId, mounter, setAppLeaveHandler }} + {...{ appId, mounter, setAppLeaveHandler, setIsMounting }} /> )} />, @@ -92,7 +94,7 @@ export const AppRouter: FunctionComponent = ({ appId={id} appStatus={appStatuses.get(id) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ mounter, setAppLeaveHandler }} + {...{ mounter, setAppLeaveHandler, setIsMounting }} /> ); }} From 5e972e14d147e3c5232114690f61a96a493a28ae Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Mon, 4 May 2020 13:00:56 -0400 Subject: [PATCH 012/153] [Fleet] Better display of fleet requirements (#65027) --- .../ingest_manager/common/types/index.ts | 1 + .../common/types/rest_spec/fleet_setup.ts | 5 + x-pack/plugins/ingest_manager/kibana.json | 2 +- .../ingest_manager/hooks/use_fleet_status.tsx | 69 ++++++++++++ .../ingest_manager/hooks/use_request/index.ts | 1 + .../ingest_manager/hooks/use_request/setup.ts | 10 +- .../applications/ingest_manager/index.tsx | 5 +- .../agent_enrollment_flyout/index.tsx | 58 +++++++--- .../ingest_manager/sections/fleet/index.tsx | 18 ++- .../sections/fleet/setup_page/index.tsx | 103 ++++++++++++------ .../ingest_manager/types/index.ts | 2 + x-pack/plugins/ingest_manager/server/index.ts | 1 + .../plugins/ingest_manager/server/plugin.ts | 17 ++- .../server/routes/setup/handlers.ts | 47 +++++--- .../server/routes/setup/index.ts | 5 +- .../server/services/agent_config.ts | 10 +- .../server/services/app_context.ts | 27 ++++- .../server/services/datasource.ts | 3 + .../ingest_manager/server/services/output.ts | 5 +- .../ingest_manager/server/services/setup.ts | 7 +- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 22 files changed, 309 insertions(+), 91 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_fleet_status.tsx diff --git a/x-pack/plugins/ingest_manager/common/types/index.ts b/x-pack/plugins/ingest_manager/common/types/index.ts index 748bb14d2d35d..b357d0c2d75f4 100644 --- a/x-pack/plugins/ingest_manager/common/types/index.ts +++ b/x-pack/plugins/ingest_manager/common/types/index.ts @@ -14,6 +14,7 @@ export interface IngestManagerConfigType { }; fleet: { enabled: boolean; + tlsCheckDisabled: boolean; defaultOutputHost: string; kibana: { host?: string; diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts index c4ba8ee595acf..ae4cb4e3fce49 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts @@ -7,3 +7,8 @@ export interface CreateFleetSetupResponse { isInitialized: boolean; } + +export interface GetFleetStatusResponse { + isReady: boolean; + missing_requirements: Array<'tls_required' | 'api_keys' | 'fleet_admin_user'>; +} diff --git a/x-pack/plugins/ingest_manager/kibana.json b/x-pack/plugins/ingest_manager/kibana.json index cef1a293c104b..382ea0444093d 100644 --- a/x-pack/plugins/ingest_manager/kibana.json +++ b/x-pack/plugins/ingest_manager/kibana.json @@ -5,5 +5,5 @@ "ui": true, "configPath": ["xpack", "ingestManager"], "requiredPlugins": ["licensing", "data", "encryptedSavedObjects"], - "optionalPlugins": ["security", "features"] + "optionalPlugins": ["security", "features", "cloud"] } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_fleet_status.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_fleet_status.tsx new file mode 100644 index 0000000000000..ef40c171b9ca3 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_fleet_status.tsx @@ -0,0 +1,69 @@ +/* + * 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, useContext, useEffect } from 'react'; +import { useConfig } from './use_config'; +import { sendGetFleetStatus } from './use_request'; +import { GetFleetStatusResponse } from '../types'; + +interface FleetStatusState { + enabled: boolean; + isLoading: boolean; + isReady: boolean; + missingRequirements?: GetFleetStatusResponse['missing_requirements']; +} + +interface FleetStatus extends FleetStatusState { + refresh: () => Promise; +} + +const FleetStatusContext = React.createContext(undefined); + +export const FleetStatusProvider: React.FC = ({ children }) => { + const config = useConfig(); + const [state, setState] = useState({ + enabled: config.fleet.enabled, + isLoading: false, + isReady: false, + }); + async function sendGetStatus() { + try { + setState(s => ({ ...s, isLoading: true })); + const res = await sendGetFleetStatus(); + if (res.error) { + throw res.error; + } + + setState(s => ({ + ...s, + isLoading: false, + isReady: res.data?.isReady ?? false, + missingRequirements: res.data?.missing_requirements, + })); + } catch (error) { + setState(s => ({ ...s, isLoading: true })); + } + } + useEffect(() => { + sendGetStatus(); + }, []); + + return ( + sendGetStatus() }}> + {children} + + ); +}; + +export function useFleetStatus(): FleetStatus { + const context = useContext(FleetStatusContext); + + if (!context) { + throw new Error('FleetStatusContext not set'); + } + + return context; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts index c39d2a5860bf0..25cdffc5c6651 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts @@ -12,3 +12,4 @@ export * from './enrollment_api_keys'; export * from './epm'; export * from './outputs'; export * from './settings'; +export * from './setup'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/setup.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/setup.ts index 04fdf9f66948f..e4e84e4701f13 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/setup.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/setup.ts @@ -5,7 +5,8 @@ */ import { sendRequest } from './use_request'; -import { setupRouteService } from '../../services'; +import { setupRouteService, fleetSetupRouteService } from '../../services'; +import { GetFleetStatusResponse } from '../../types'; export const sendSetup = () => { return sendRequest({ @@ -13,3 +14,10 @@ export const sendSetup = () => { method: 'post', }); }; + +export const sendGetFleetStatus = () => { + return sendRequest({ + path: fleetSetupRouteService.getFleetSetupPath(), + method: 'get', + }); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx index 295a35693726f..f0a0c90a18c24 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx @@ -23,6 +23,7 @@ import { IngestManagerOverview, EPMApp, AgentConfigApp, FleetApp, DataStreamApp import { CoreContext, DepsContext, ConfigContext, setHttpClient, useConfig } from './hooks'; import { PackageInstallProvider } from './sections/epm/hooks'; import { sendSetup } from './hooks/use_request/setup'; +import { FleetStatusProvider } from './hooks/use_fleet_status'; import './index.scss'; export interface ProtectedRouteProps extends RouteProps { @@ -142,7 +143,9 @@ const IngestManagerApp = ({ - + + + diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/index.tsx index dd34e7260b27b..e9347ccd2d6c9 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/index.tsx @@ -15,11 +15,15 @@ import { EuiButtonEmpty, EuiButton, EuiFlyoutFooter, + EuiLink, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { AgentConfig } from '../../../../../types'; import { APIKeySelection } from './key_selection'; import { EnrollmentInstructions } from './instructions'; +import { useFleetStatus } from '../../../../../hooks/use_fleet_status'; +import { useLink } from '../../../../../hooks'; +import { FLEET_PATH } from '../../../../../constants'; interface Props { onClose: () => void; @@ -30,8 +34,11 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ onClose, agentConfigs = [], }) => { + const fleetStatus = useFleetStatus(); const [selectedAPIKeyId, setSelectedAPIKeyId] = useState(); + const fleetLink = useLink(FLEET_PATH); + return ( @@ -45,12 +52,33 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ - setSelectedAPIKeyId(keyId)} - /> - - + {fleetStatus.isReady ? ( + <> + setSelectedAPIKeyId(keyId)} + /> + + + + ) : ( + <> + + + + ), + }} + /> + + )} @@ -62,14 +90,16 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ /> - - - - - + {fleetStatus.isReady && ( + + + + + + )} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx index fac81ecc19cd1..b9c5418dbf6f3 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx @@ -6,35 +6,31 @@ import React from 'react'; import { HashRouter as Router, Route, Switch, Redirect } from 'react-router-dom'; import { Loading } from '../../components'; -import { useConfig, useCore, useRequest } from '../../hooks'; +import { useConfig, useCore } from '../../hooks'; import { AgentListPage } from './agent_list_page'; import { SetupPage } from './setup_page'; import { AgentDetailsPage } from './agent_details_page'; import { NoAccessPage } from './error_pages/no_access'; -import { fleetSetupRouteService } from '../../services'; import { EnrollmentTokenListPage } from './enrollment_token_list_page'; import { ListLayout } from './components/list_layout'; +import { useFleetStatus } from '../../hooks/use_fleet_status'; export const FleetApp: React.FunctionComponent = () => { const core = useCore(); const { fleet } = useConfig(); - const setupRequest = useRequest({ - method: 'get', - path: fleetSetupRouteService.getFleetSetupPath(), - }); + const fleetStatus = useFleetStatus(); if (!fleet.enabled) return null; - if (setupRequest.isLoading) { + if (fleetStatus.isLoading) { return ; } - if (setupRequest.data.isInitialized === false) { + if (fleetStatus.isReady === false) { return ( { - await setupRequest.sendRequest(); - }} + missingRequirements={fleetStatus.missingRequirements || []} + refresh={fleetStatus.refresh} /> ); } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx index 96d4d01d67a49..4d89268c14b28 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx @@ -18,10 +18,12 @@ import { import { sendRequest, useCore } from '../../../hooks'; import { fleetSetupRouteService } from '../../../services'; import { WithoutHeaderLayout } from '../../../layouts'; +import { GetFleetStatusResponse } from '../../../types'; export const SetupPage: React.FunctionComponent<{ refresh: () => Promise; -}> = ({ refresh }) => { + missingRequirements: GetFleetStatusResponse['missing_requirements']; +}> = ({ refresh, missingRequirements }) => { const [isFormLoading, setIsFormLoading] = useState(false); const core = useCore(); @@ -40,46 +42,81 @@ export const SetupPage: React.FunctionComponent<{ } }; + const content = + missingRequirements.includes('tls_required') || missingRequirements.includes('api_keys') ? ( + <> + + + + +

+ +

+
+ + + , + }} + /> + + + + ) : ( + <> + + + + +

+ +

+
+ + + + + + +
+ + + +
+
+ + + ); + return ( - + - - - - -

- -

-
- - - - - - -
- - - -
-
- + {content}
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts index 602015d23cefb..ca5bf999aa81a 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts @@ -20,6 +20,8 @@ export { DatasourceConfigRecordEntry, Output, DataStream, + // API schema - misc setup, status + GetFleetStatusResponse, // API schemas - Agent Config GetAgentConfigsResponse, GetAgentConfigsResponseItem, diff --git a/x-pack/plugins/ingest_manager/server/index.ts b/x-pack/plugins/ingest_manager/server/index.ts index 951ff2337d8c7..6096af8d80801 100644 --- a/x-pack/plugins/ingest_manager/server/index.ts +++ b/x-pack/plugins/ingest_manager/server/index.ts @@ -26,6 +26,7 @@ export const config = { }), fleet: schema.object({ enabled: schema.boolean({ defaultValue: true }), + tlsCheckDisabled: schema.boolean({ defaultValue: false }), kibana: schema.object({ host: schema.maybe(schema.string()), ca_sha256: schema.maybe(schema.string()), diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index 3448685d1f279..3b0837565c36c 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -11,6 +11,7 @@ import { Plugin, PluginInitializerContext, SavedObjectsServiceStart, + HttpServerInfo, } from 'kibana/server'; import { LicensingPluginSetup, ILicense } from '../../licensing/server'; import { @@ -42,7 +43,6 @@ import { registerOutputRoutes, registerSettingsRoutes, } from './routes'; - import { IngestManagerConfigType } from '../common'; import { appContextService, @@ -52,12 +52,14 @@ import { AgentService, } from './services'; import { getAgentStatusById } from './services/agents'; +import { CloudSetup } from '../../cloud/server'; export interface IngestManagerSetupDeps { licensing: LicensingPluginSetup; security?: SecurityPluginSetup; features?: FeaturesPluginSetup; encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; + cloud?: CloudSetup; } export type IngestManagerStartDeps = object; @@ -67,6 +69,9 @@ export interface IngestManagerAppContext { security?: SecurityPluginSetup; config$?: Observable; savedObjects: SavedObjectsServiceStart; + isProductionMode: boolean; + serverInfo?: HttpServerInfo; + cloud?: CloudSetup; } export type IngestManagerSetupContract = void; @@ -100,16 +105,23 @@ export class IngestManagerPlugin private licensing$!: Observable; private config$: Observable; private security: SecurityPluginSetup | undefined; + private cloud: CloudSetup | undefined; + + private isProductionMode: boolean; + private serverInfo: HttpServerInfo | undefined; constructor(private readonly initializerContext: PluginInitializerContext) { this.config$ = this.initializerContext.config.create(); + this.isProductionMode = this.initializerContext.env.mode.prod; } public async setup(core: CoreSetup, deps: IngestManagerSetupDeps) { + this.serverInfo = core.http.getServerInfo(); this.licensing$ = deps.licensing.license$; if (deps.security) { this.security = deps.security; } + this.cloud = deps.cloud; registerSavedObjects(core.savedObjects); registerEncryptedSavedObjects(deps.encryptedSavedObjects); @@ -184,6 +196,9 @@ export class IngestManagerPlugin security: this.security, config$: this.config$, savedObjects: core.savedObjects, + isProductionMode: this.isProductionMode, + serverInfo: this.serverInfo, + cloud: this.cloud, }); licenseService.start(this.licensing$); return { diff --git a/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts index 837e73b966feb..542dfa9cefe8f 100644 --- a/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts @@ -4,28 +4,43 @@ * you may not use this file except in compliance with the Elastic License. */ import { RequestHandler } from 'src/core/server'; -import { outputService } from '../../services'; -import { CreateFleetSetupResponse } from '../../../common'; +import { outputService, appContextService } from '../../services'; +import { GetFleetStatusResponse } from '../../../common'; import { setupIngestManager, setupFleet } from '../../services/setup'; -export const getFleetSetupHandler: RequestHandler = async (context, request, response) => { +export const getFleetStatusHandler: RequestHandler = async (context, request, response) => { const soClient = context.core.savedObjects.client; - const successBody: CreateFleetSetupResponse = { isInitialized: true }; - const failureBody: CreateFleetSetupResponse = { isInitialized: false }; try { - const adminUser = await outputService.getAdminUser(soClient); - if (adminUser) { - return response.ok({ - body: successBody, - }); - } else { - return response.ok({ - body: failureBody, - }); + const isAdminUserSetup = (await outputService.getAdminUser(soClient)) !== null; + const isApiKeysEnabled = await appContextService.getSecurity().authc.areAPIKeysEnabled(); + const isTLSEnabled = appContextService.getServerInfo().protocol === 'https'; + const isProductionMode = appContextService.getIsProductionMode(); + const isCloud = appContextService.getCloud()?.isCloudEnabled ?? false; + const isTLSCheckDisabled = appContextService.getConfig()?.fleet?.tlsCheckDisabled ?? false; + + const missingRequirements: GetFleetStatusResponse['missing_requirements'] = []; + if (!isAdminUserSetup) { + missingRequirements.push('fleet_admin_user'); } - } catch (e) { + if (!isApiKeysEnabled) { + missingRequirements.push('api_keys'); + } + if (!isTLSCheckDisabled && !isCloud && isProductionMode && !isTLSEnabled) { + missingRequirements.push('tls_required'); + } + + const body: GetFleetStatusResponse = { + isReady: missingRequirements.length === 0, + missing_requirements: missingRequirements, + }; + return response.ok({ - body: failureBody, + body, + }); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, }); } }; diff --git a/x-pack/plugins/ingest_manager/server/routes/setup/index.ts b/x-pack/plugins/ingest_manager/server/routes/setup/index.ts index 5ee7ee7733220..43dcf47d26c18 100644 --- a/x-pack/plugins/ingest_manager/server/routes/setup/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/setup/index.ts @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ import { IRouter } from 'src/core/server'; + import { PLUGIN_ID, FLEET_SETUP_API_ROUTES, SETUP_API_ROUTE } from '../../constants'; import { IngestManagerConfigType } from '../../../common'; import { - getFleetSetupHandler, + getFleetStatusHandler, createFleetSetupHandler, ingestManagerSetupHandler, } from './handlers'; @@ -36,7 +37,7 @@ export const registerRoutes = (router: IRouter, config: IngestManagerConfigType) validate: false, options: { tags: [`access:${PLUGIN_ID}-read`] }, }, - getFleetSetupHandler + getFleetStatusHandler ); // Create Fleet setup diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.ts index 5ecbaff8ad71e..84bcd7db3f7b1 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts @@ -314,10 +314,12 @@ class AgentConfigService { if (!config) { return null; } - const defaultOutput = await outputService.get( - soClient, - await outputService.getDefaultOutputId(soClient) - ); + + const defaultOutputId = await outputService.getDefaultOutputId(soClient); + if (!defaultOutputId) { + throw new Error('Default output is not setup'); + } + const defaultOutput = await outputService.get(soClient, defaultOutputId); const agentConfig: FullAgentConfig = { id: config.id, diff --git a/x-pack/plugins/ingest_manager/server/services/app_context.ts b/x-pack/plugins/ingest_manager/server/services/app_context.ts index e917d2edd1309..5e538ad84b4c2 100644 --- a/x-pack/plugins/ingest_manager/server/services/app_context.ts +++ b/x-pack/plugins/ingest_manager/server/services/app_context.ts @@ -5,11 +5,12 @@ */ import { BehaviorSubject, Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { SavedObjectsServiceStart } from 'src/core/server'; +import { SavedObjectsServiceStart, HttpServerInfo } from 'src/core/server'; import { EncryptedSavedObjectsPluginStart } from '../../../encrypted_saved_objects/server'; import { SecurityPluginSetup } from '../../../security/server'; import { IngestManagerConfigType } from '../../common'; import { IngestManagerAppContext } from '../plugin'; +import { CloudSetup } from '../../../cloud/server'; class AppContextService { private encryptedSavedObjects: EncryptedSavedObjectsPluginStart | undefined; @@ -17,11 +18,17 @@ class AppContextService { private config$?: Observable; private configSubject$?: BehaviorSubject; private savedObjects: SavedObjectsServiceStart | undefined; + private serverInfo: HttpServerInfo | undefined; + private isProductionMode: boolean = false; + private cloud?: CloudSetup; public async start(appContext: IngestManagerAppContext) { this.encryptedSavedObjects = appContext.encryptedSavedObjects; this.security = appContext.security; this.savedObjects = appContext.savedObjects; + this.serverInfo = appContext.serverInfo; + this.isProductionMode = appContext.isProductionMode; + this.cloud = appContext.cloud; if (appContext.config$) { this.config$ = appContext.config$; @@ -41,9 +48,16 @@ class AppContextService { } public getSecurity() { + if (!this.security) { + throw new Error('Secury service not set.'); + } return this.security; } + public getCloud() { + return this.cloud; + } + public getConfig() { return this.configSubject$?.value; } @@ -58,6 +72,17 @@ class AppContextService { } return this.savedObjects; } + + public getIsProductionMode() { + return this.isProductionMode; + } + + public getServerInfo() { + if (!this.serverInfo) { + throw new Error('Server info not set.'); + } + return this.serverInfo; + } } export const appContextService = new AppContextService(); diff --git a/x-pack/plugins/ingest_manager/server/services/datasource.ts b/x-pack/plugins/ingest_manager/server/services/datasource.ts index affd9b2755881..0497bc5a2b541 100644 --- a/x-pack/plugins/ingest_manager/server/services/datasource.ts +++ b/x-pack/plugins/ingest_manager/server/services/datasource.ts @@ -196,6 +196,9 @@ class DatasourceService { outputService.getDefaultOutputId(soClient), ]); if (pkgInfo) { + if (!defaultOutputId) { + throw new Error('Default output is not set'); + } return packageToConfigDatasource(pkgInfo, '', defaultOutputId); } } diff --git a/x-pack/plugins/ingest_manager/server/services/output.ts b/x-pack/plugins/ingest_manager/server/services/output.ts index 395c9af4a4ca2..3628c5bd9e183 100644 --- a/x-pack/plugins/ingest_manager/server/services/output.ts +++ b/x-pack/plugins/ingest_manager/server/services/output.ts @@ -48,7 +48,7 @@ class OutputService { }); if (!outputs.saved_objects.length) { - throw new Error('No default output'); + return null; } return outputs.saved_objects[0].id; @@ -56,6 +56,9 @@ class OutputService { public async getAdminUser(soClient: SavedObjectsClientContract) { const defaultOutputId = await this.getDefaultOutputId(soClient); + if (!defaultOutputId) { + return null; + } const so = await appContextService .getEncryptedSavedObjects() ?.getDecryptedAsInternalUser(OUTPUT_SAVED_OBJECT_TYPE, defaultOutputId); diff --git a/x-pack/plugins/ingest_manager/server/services/setup.ts b/x-pack/plugins/ingest_manager/server/services/setup.ts index 390e240841611..3619628bd4f8b 100644 --- a/x-pack/plugins/ingest_manager/server/services/setup.ts +++ b/x-pack/plugins/ingest_manager/server/services/setup.ts @@ -109,7 +109,12 @@ export async function setupFleet( }); // save fleet admin user - await outputService.updateOutput(soClient, await outputService.getDefaultOutputId(soClient), { + const defaultOutputId = await outputService.getDefaultOutputId(soClient); + if (!defaultOutputId) { + throw new Error('Default output does not exist'); + } + + await outputService.updateOutput(soClient, defaultOutputId, { fleet_enroll_username: FLEET_ENROLL_USERNAME, fleet_enroll_password: password, }); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index da8673da67f42..11d1f864a3619 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8370,9 +8370,7 @@ "xpack.ingestManager.noAccess.accessDeniedTitle": "アクセスが拒否されました", "xpack.ingestManager.overviewPageSubtitle": "Ingest Manager についてのロレムイプサム説明文。", "xpack.ingestManager.overviewPageTitle": "Ingest Manager", - "xpack.ingestManager.setupPage.description": "フリートを使用するには、Elastic ユーザーを作成する必要があります。このユーザーは、API キーを作成して、logs-* および metrics-* に書き込むことができます。", "xpack.ingestManager.setupPage.enableFleet": "ユーザーを作成してフリートを有効にます", - "xpack.ingestManager.setupPage.title": "フリートを有効にする", "xpack.ingestManager.unenrollAgents.confirmModal.cancelButtonLabel": "キャンセル", "xpack.ingestManager.unenrollAgents.confirmModal.confirmButtonLabel": "登録解除", "xpack.ingestManager.unenrollAgents.confirmModal.deleteMultipleTitle": "{count, plural, one {# エージェント} other {# エージェント}}の登録を解除しますか?", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f66e9631b0168..9ee3b3d8f5931 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8376,9 +8376,7 @@ "xpack.ingestManager.noAccess.accessDeniedTitle": "访问被拒绝", "xpack.ingestManager.overviewPageSubtitle": "Lorem ipsum some description about ingest manager.", "xpack.ingestManager.overviewPageTitle": "Ingest Manager", - "xpack.ingestManager.setupPage.description": "要使用 Fleet,必须创建 Elastic 用户。此用户可以创建 API 密钥并写入到 logs-* and metrics-*。", "xpack.ingestManager.setupPage.enableFleet": "创建用户并启用 Fleet", - "xpack.ingestManager.setupPage.title": "启用 Fleet", "xpack.ingestManager.unenrollAgents.confirmModal.cancelButtonLabel": "取消", "xpack.ingestManager.unenrollAgents.confirmModal.confirmButtonLabel": "取消注册", "xpack.ingestManager.unenrollAgents.confirmModal.deleteMultipleTitle": "取消注册 {count, plural, one {# 个代理} other {# 个代理}}?", From 122450a4c889f50fc8a96ad30d849b7a260ad025 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 4 May 2020 18:04:42 +0100 Subject: [PATCH 013/153] chore(NA): skip functional test for visualize axis scalling preventing es snapshot promotion (#65100) --- test/functional/apps/visualize/_area_chart.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/visualize/_area_chart.js b/test/functional/apps/visualize/_area_chart.js index 8f2012d7f184d..05544029f62d7 100644 --- a/test/functional/apps/visualize/_area_chart.js +++ b/test/functional/apps/visualize/_area_chart.js @@ -242,7 +242,9 @@ export default function({ getService, getPageObjects }) { await inspector.close(); }); - it('does not scale top hit agg', async () => { + // Preventing ES Promotion for master (8.0) + // https://github.com/elastic/kibana/issues/64734 + it.skip('does not scale top hit agg', async () => { const expectedTableData = [ ['2015-09-20 00:00', '6', '9.035KB'], ['2015-09-20 01:00', '9', '5.854KB'], From 9db27dba56c245a118fd03ffa4e31475195ffbfe Mon Sep 17 00:00:00 2001 From: Ross Wolf <31489089+rw-access@users.noreply.github.com> Date: Mon, 4 May 2020 11:07:09 -0600 Subject: [PATCH 014/153] [SIEM] Remove forgotten rules that weren't deleted (#64974) * Remove stray rules that should've been deleted * Update rule.ts and tests * Remove deleted prebuilt rules from cypress ES archive (#1) --- x-pack/plugins/siem/cypress/objects/rule.ts | 2 +- .../rules/prepackaged_rules/index.ts | 39 ++++++------- .../windows_execution_via_regsvr32.json | 51 ----------------- ...windows_signed_binary_proxy_execution.json | 54 ------------------ ...uspicious_process_started_by_a_script.json | 54 ------------------ .../prebuilt_rules_loaded/data.json.gz | Bin 41865 -> 41851 bytes 6 files changed, 18 insertions(+), 182 deletions(-) delete mode 100644 x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_regsvr32.json delete mode 100644 x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_signed_binary_proxy_execution.json delete mode 100644 x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_suspicious_process_started_by_a_script.json diff --git a/x-pack/plugins/siem/cypress/objects/rule.ts b/x-pack/plugins/siem/cypress/objects/rule.ts index ce920aeb957af..4e0189ea597da 100644 --- a/x-pack/plugins/siem/cypress/objects/rule.ts +++ b/x-pack/plugins/siem/cypress/objects/rule.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export const totalNumberOfPrebuiltRules = 130; +export const totalNumberOfPrebuiltRules = 127; interface Mitre { tactic: string; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts index c24f5bb64ef5e..9e185b5a5ef7c 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts @@ -118,25 +118,23 @@ import rule108 from './windows_execution_msbuild_started_renamed.json'; import rule109 from './windows_execution_msbuild_started_unusal_process.json'; import rule110 from './windows_execution_via_compiled_html_file.json'; import rule111 from './windows_execution_via_net_com_assemblies.json'; -import rule112 from './windows_execution_via_regsvr32.json'; -import rule113 from './windows_execution_via_trusted_developer_utilities.json'; -import rule114 from './windows_html_help_executable_program_connecting_to_the_internet.json'; -import rule115 from './windows_injection_msbuild.json'; -import rule116 from './windows_misc_lolbin_connecting_to_the_internet.json'; -import rule117 from './windows_modification_of_boot_config.json'; -import rule118 from './windows_msxsl_network.json'; -import rule119 from './windows_net_command_system_account.json'; -import rule120 from './windows_persistence_via_application_shimming.json'; -import rule121 from './windows_priv_escalation_via_accessibility_features.json'; -import rule122 from './windows_process_discovery_via_tasklist_command.json'; -import rule123 from './windows_rare_user_runas_event.json'; -import rule124 from './windows_rare_user_type10_remote_login.json'; -import rule125 from './windows_register_server_program_connecting_to_the_internet.json'; -import rule126 from './windows_signed_binary_proxy_execution.json'; -import rule127 from './windows_suspicious_pdf_reader.json'; -import rule128 from './windows_suspicious_process_started_by_a_script.json'; -import rule129 from './windows_uac_bypass_event_viewer.json'; -import rule130 from './windows_whoami_command_activity.json'; +import rule112 from './windows_execution_via_trusted_developer_utilities.json'; +import rule113 from './windows_html_help_executable_program_connecting_to_the_internet.json'; +import rule114 from './windows_injection_msbuild.json'; +import rule115 from './windows_misc_lolbin_connecting_to_the_internet.json'; +import rule116 from './windows_modification_of_boot_config.json'; +import rule117 from './windows_msxsl_network.json'; +import rule118 from './windows_net_command_system_account.json'; +import rule119 from './windows_persistence_via_application_shimming.json'; +import rule120 from './windows_priv_escalation_via_accessibility_features.json'; +import rule121 from './windows_process_discovery_via_tasklist_command.json'; +import rule122 from './windows_rare_user_runas_event.json'; +import rule123 from './windows_rare_user_type10_remote_login.json'; +import rule124 from './windows_register_server_program_connecting_to_the_internet.json'; +import rule125 from './windows_suspicious_pdf_reader.json'; +import rule126 from './windows_uac_bypass_event_viewer.json'; +import rule127 from './windows_whoami_command_activity.json'; + export const rawRules = [ rule1, rule2, @@ -265,7 +263,4 @@ export const rawRules = [ rule125, rule126, rule127, - rule128, - rule129, - rule130, ]; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_regsvr32.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_regsvr32.json deleted file mode 100644 index e8e7ddfc168dc..0000000000000 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_regsvr32.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "description": "Identifies scrobj.dll loaded into unusual Microsoft processes. This may indicate a malicious scriptlet is being executed in the target process.", - "index": [ - "winlogbeat-*" - ], - "language": "kuery", - "max_signals": 100, - "name": "Suspicious Script Object Execution", - "query": "event.code: 1 and scrobj.dll and (process.name:certutil.exe or process.name:regsvr32.exe or process.name:rundll32.exe)", - "risk_score": 21, - "rule_id": "b7333d08-be4b-4cb4-b81e-924ae37b3143", - "severity": "low", - "tags": [ - "Elastic", - "Windows" - ], - "threat": [ - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0005", - "name": "Defense Evasion", - "reference": "https://attack.mitre.org/tactics/TA0005/" - }, - "technique": [ - { - "id": "T1064", - "name": "Scripting", - "reference": "https://attack.mitre.org/techniques/T1064/" - } - ] - }, - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0002", - "name": "Execution", - "reference": "https://attack.mitre.org/tactics/TA0002/" - }, - "technique": [ - { - "id": "T1064", - "name": "Scripting", - "reference": "https://attack.mitre.org/techniques/T1064/" - } - ] - } - ], - "type": "query", - "version": 1 -} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_signed_binary_proxy_execution.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_signed_binary_proxy_execution.json deleted file mode 100644 index be4ccef2a0887..0000000000000 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_signed_binary_proxy_execution.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "description": "Binaries signed with trusted digital certificates can execute on Windows systems protected by digital signature validation. Adversaries may use these binaries to 'live off the land' and execute malicious files that could bypass application whitelisting and signature validation.", - "false_positives": [ - "Security testing may produce events like this. Activity of this kind performed by non-engineers and ordinary users is unusual." - ], - "index": [ - "winlogbeat-*" - ], - "language": "kuery", - "max_signals": 100, - "name": "Execution via Signed Binary", - "query": "event.code:1 and http and (process.name:certutil.exe or process.name:msiexec.exe)", - "risk_score": 21, - "rule_id": "7edb573f-1f9b-4161-8c19-c7c383bb17f2", - "severity": "low", - "tags": [ - "Elastic", - "Windows" - ], - "threat": [ - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0005", - "name": "Defense Evasion", - "reference": "https://attack.mitre.org/tactics/TA0005/" - }, - "technique": [ - { - "id": "T1218", - "name": "Signed Binary Proxy Execution", - "reference": "https://attack.mitre.org/techniques/T1218/" - } - ] - }, - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0002", - "name": "Execution", - "reference": "https://attack.mitre.org/tactics/TA0002/" - }, - "technique": [ - { - "id": "T1218", - "name": "Signed Binary Proxy Execution", - "reference": "https://attack.mitre.org/techniques/T1218/" - } - ] - } - ], - "type": "query", - "version": 1 -} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_suspicious_process_started_by_a_script.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_suspicious_process_started_by_a_script.json deleted file mode 100644 index 235a04f8063fc..0000000000000 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_suspicious_process_started_by_a_script.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "description": "Identifies a suspicious process being spawned from a script interpreter, which could be indicative of a potential phishing attack.", - "false_positives": [ - "Security testing may produce events like this. Activity of this kind performed by non-engineers and ordinary users is unusual." - ], - "index": [ - "winlogbeat-*" - ], - "language": "kuery", - "max_signals": 100, - "name": "Suspicious Process spawning from Script Interpreter", - "query": "(process.parent.name:cmd.exe or process.parent.name:cscript.exe or process.parent.name:mshta.exe or process.parent.name:powershell.exe or process.parent.name:rundll32.exe or process.parent.name:wscript.exe or process.parent.name:wmiprvse.exe) and (process.name:bitsadmin.exe or process.name:certutil.exe or mshta.exe or process.name:nslookup.exe or process.name:schtasks.exe) and event.code:1", - "risk_score": 21, - "rule_id": "89db767d-99f9-479f-8052-9205fd3090c4", - "severity": "low", - "tags": [ - "Elastic", - "Windows" - ], - "threat": [ - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0005", - "name": "Defense Evasion", - "reference": "https://attack.mitre.org/tactics/TA0005/" - }, - "technique": [ - { - "id": "T1064", - "name": "Scripting", - "reference": "https://attack.mitre.org/techniques/T1064/" - } - ] - }, - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0002", - "name": "Execution", - "reference": "https://attack.mitre.org/tactics/TA0002/" - }, - "technique": [ - { - "id": "T1064", - "name": "Scripting", - "reference": "https://attack.mitre.org/techniques/T1064/" - } - ] - } - ], - "type": "query", - "version": 1 -} diff --git a/x-pack/test/siem_cypress/es_archives/prebuilt_rules_loaded/data.json.gz b/x-pack/test/siem_cypress/es_archives/prebuilt_rules_loaded/data.json.gz index 573c006d1507d9c84c581c9abab7d60d96bfebe3..cac63ed9c585f993b805b80657b9888a40f7d819 100644 GIT binary patch literal 41851 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

-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 Date: Mon, 4 May 2020 10:07:23 -0700 Subject: [PATCH 015/153] [Canvas] Updates function reference docs (#64741) --- .../canvas/canvas-function-reference.asciidoc | 271 +++++++++++++++++- 1 file changed, 256 insertions(+), 15 deletions(-) diff --git a/docs/canvas/canvas-function-reference.asciidoc b/docs/canvas/canvas-function-reference.asciidoc index 16aaf55802b17..657e3ec8b8bb1 100644 --- a/docs/canvas/canvas-function-reference.asciidoc +++ b/docs/canvas/canvas-function-reference.asciidoc @@ -42,10 +42,10 @@ filters | metric "Average uptime" metricFont={ font size=48 family="'Open Sans', Helvetica, Arial, sans-serif" - color={ - if {all {gte 0} {lt 0.8}} then="red" else="green" - } - align="center" lHeight=48 + color={ + if {all {gte 0} {lt 0.8}} then="red" else="green" + } + align="center" lHeight=48 } | render ---- @@ -324,12 +324,14 @@ case if={lte 50} then="green" ---- math "random()" | progress shape="gauge" label={formatnumber "0%"} - font={font size=24 family="'Open Sans', Helvetica, Arial, sans-serif" align="center" - color={ - switch {case if={lte 0.5} then="green"} - {case if={all {gt 0.5} {lte 0.75}} then="orange"} - default="red" - }} + font={ + font size=24 family="'Open Sans', Helvetica, Arial, sans-serif" align="center" + color={ + switch {case if={lte 0.5} then="green"} + {case if={all {gt 0.5} {lte 0.75}} then="orange"} + default="red" + } + } valueColor={ switch {case if={lte 0.5} then="green"} {case if={all {gt 0.5} {lte 0.75}} then="orange"} @@ -693,7 +695,25 @@ Alias: `value` [[demodata_fn]] === `demodata` -A mock data set that includes project CI times with usernames, countries, and run phases. +A sample data set that includes project CI times with usernames, countries, and run phases. + +*Expression syntax* +[source,js] +---- +demodata +demodata "ci" +demodata type="shirts" +---- + +*Code example* +[source,text] +---- +filters +| demodata +| table +| render +---- +`demodata` is a mock data set that you can use to start playing around in Canvas. *Accepts:* `filter` @@ -837,6 +857,28 @@ Alias: `value` Query Elasticsearch for the number of hits matching the specified query. +*Expression syntax* +[source,js] +---- +escount index="logstash-*" +escount "currency:"EUR"" index="kibana_sample_data_ecommerce" +escount query="response:404" index="kibana_sample_data_logs" +---- + +*Code example* +[source,text] +---- +filters +| escount "Cancelled:true" index="kibana_sample_data_flights" +| math "value" +| progress shape="semicircle" + label={formatnumber 0,0} + font={font size=24 family="'Open Sans', Helvetica, Arial, sans-serif" color="#000000" align=center} + max={filters | escount index="kibana_sample_data_flights"} +| render +---- +The first `escount` expression retrieves the number of flights that were cancelled. The second `escount` expression retrieves the total number of flights. + *Accepts:* `filter` [cols="3*^<"] @@ -867,6 +909,34 @@ Default: `_all` Query Elasticsearch for raw documents. Specify the fields you want to retrieve, especially if you are asking for a lot of rows. +*Expression syntax* +[source,js] +---- +esdocs index="logstash-*" +esdocs "currency:"EUR"" index="kibana_sample_data_ecommerce" +esdocs query="response:404" index="kibana_sample_data_logs" +esdocs index="kibana_sample_data_flights" count=100 +esdocs index="kibana_sample_data_flights" sort="AvgTicketPrice, asc" +---- + +*Code example* +[source,text] +---- +filters +| esdocs index="kibana_sample_data_ecommerce" + fields="customer_gender, taxful_total_price, order_date" + sort="order_date, asc" + count=10000 +| mapColumn "order_date" + fn={getCell "order_date" | date {context} | rounddate "YYYY-MM-DD"} +| alterColumn "order_date" type="date" +| pointseries x="order_date" y="sum(taxful_total_price)" color="customer_gender" +| plot defaultStyle={seriesStyle lines=3} + palette={palette "#7ECAE3" "#003A4D" gradient=true} +| render +---- +This retrieves the first 10000 documents data from the `kibana_sample_data_ecommerce` index sorted by `order_date` in ascending order, and only requests the `customer_gender`, `taxful_total_price`, and `order_date` fields. + *Accepts:* `filter` [cols="3*^<"] @@ -915,6 +985,23 @@ Default: `_all` Queries Elasticsearch using Elasticsearch SQL. +*Expression syntax* +[source,js] +---- +essql query="SELECT * FROM "logstash*"" +essql "SELECT * FROM "apm*"" count=10000 +---- + +*Code example* +[source,text] +---- +filters +| essql query="SELECT Carrier, FlightDelayMin, AvgTicketPrice FROM "kibana_sample_data_flights"" +| table +| render +---- +This retrieves the `Carrier`, `FlightDelayMin`, and `AvgTicketPrice` fields from the "kibana_sample_data_flights" index. + *Accepts:* `filter` [cols="3*^<"] @@ -1107,7 +1194,7 @@ Default: `false` [[font_fn]] === `font` -Creates a font style. +Create a font style. *Expression syntax* [source,js] @@ -1244,7 +1331,7 @@ Alias: `format` [[formatnumber_fn]] === `formatnumber` -Formats a number into a formatted number string using the <>. +Formats a number into a formatted number string using the Numeral pattern. *Expression syntax* [source,js] @@ -1276,7 +1363,7 @@ The `formatnumber` subexpression receives the same `context` as the `progress` f Alias: `format` |`string` -|A <> string. For example, `"0.0a"` or `"0%"`. +|A Numeral pattern format string. For example, `"0.0a"` or `"0%"`. |=== *Returns:* `string` @@ -1559,6 +1646,34 @@ Alias: `value` [[m_fns]] == M +[float] +[[mapCenter_fn]] +=== `mapCenter` + +Returns an object with the center coordinates and zoom level of the map. + +*Accepts:* `null` + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|`lat` *** +|`number` +|Latitude for the center of the map + +|`lon` *** +|`number` +|Longitude for the center of the map + +|`zoom` *** +|`number` +|Zoom level of the map +|=== + +*Returns:* `mapCenter` + + [float] [[mapColumn_fn]] === `mapColumn` @@ -1612,6 +1727,12 @@ Default: `""` |The CSS font properties for the content. For example, "font-family" or "font-weight". Default: `${font}` + +|`openLinksInNewTab` +|`boolean` +|A true or false value for opening links in a new tab. The default value is `false`. Setting to `true` opens all links in a new tab. + +Default: `false` |=== *Returns:* `render` @@ -1675,7 +1796,7 @@ Default: `${font size=48 family="'Open Sans', Helvetica, Arial, sans-serif" colo Alias: `format` |`string` -|A <> string. For example, `"0.0a"` or `"0%"`. +|A Numeral pattern format string. For example, `"0.0a"` or `"0%"`. |=== *Returns:* `render` @@ -2184,6 +2305,102 @@ Returns the number of rows. Pairs with <> to get the count of unique col [[s_fns]] == S +[float] +[[savedLens_fn]] +=== `savedLens` + +Returns an embeddable for a saved Lens visualization object. + +*Accepts:* `any` + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|`id` +|`string` +|The ID of the saved Lens visualization object + +|`timerange` +|`timerange` +|The timerange of data that should be included + +|`title` +|`string` +|The title for the Lens visualization object +|=== + +*Returns:* `embeddable` + + +[float] +[[savedMap_fn]] +=== `savedMap` + +Returns an embeddable for a saved map object. + +*Accepts:* `any` + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|`center` +|`mapCenter` +|The center and zoom level the map should have + +|`hideLayer` † +|`string` +|The IDs of map layers that should be hidden + +|`id` +|`string` +|The ID of the saved map object + +|`timerange` +|`timerange` +|The timerange of data that should be included + +|`title` +|`string` +|The title for the map +|=== + +*Returns:* `embeddable` + + +[float] +[[savedVisualization_fn]] +=== `savedVisualization` + +Returns an embeddable for a saved visualization object. + +*Accepts:* `any` + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|`colors` † +|`seriesStyle` +|Defines the color to use for a specific series + +|`hideLegend` +|`boolean` +|Specifies the option to hide the legend + +|`id` +|`string` +|The ID of the saved visualization object + +|`timerange` +|`timerange` +|The timerange of data that should be included +|=== + +*Returns:* `embeddable` + + [float] [[seriesStyle_fn]] === `seriesStyle` @@ -2579,6 +2796,30 @@ Default: `"now"` *Returns:* `datatable` +[float] +[[timerange_fn]] +=== `timerange` + +An object that represents a span of time. + +*Accepts:* `null` + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|`from` *** +|`string` +|The start of the time range + +|`to` *** +|`string` +|The end of the time range +|=== + +*Returns:* `timerange` + + [float] [[to_fn]] === `to` From f62df99ae38801b905dd10d281dbf972c5b17578 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Mon, 4 May 2020 19:09:53 +0200 Subject: [PATCH 016/153] [ML] Embeddable Anomaly Swimlane (#64056) * [ML] embeddables setup * [ML] fix initialization * [ML] ts refactoring * [ML] refactor time_buckets.js * [ML] async services * [ML] extract job_selector_flyout.tsx * [ML] fetch overall swimlane data * [ML] import explorer styles * [ML] revert package_globs.ts * [ML] refactor with container, services with DI * [ML] resize throttle, fetch based on chart width * [ML] swimlane embeddable setup * [ML] explorer service * [ML] chart_tooltip_service * [ML] fix types * [ML] overall type for single job with no influencers * [ML] improve anomaly_swimlane_initializer ux * [ML] fix services initialization, unsubscribe on destroy * [ML] support custom time range * [ML] add tooltip * [ML] rollback initGetSwimlaneBucketInterval * [ML] new tooltip service * [ML] MlTooltipComponent with render props, fix warning * [ML] fix typo in the filename * [ML] remove redundant time range output * [ML] fix time_buckets.test.js jest tests * [ML] fix explorer chart tests * [ML] swimlane tests * [ML] store job ids instead of complete job objects * [ML] swimlane limit input * [ML] memo tooltip component, loading indicator * [ML] scrollable content * [ML] support query and filters * [ML] handle query syntax errors * [ML] rename anomaly_swimlane_service * [ML] introduce constants * [ML] edit panel title during setup * [ML] withTimeRangeSelector * [ML] rename explorer_service * [ML] getJobs$ method with one API call * [ML] fix groups selection * [ML] swimlane input resolver hook * [ML] useSwimlaneInputResolver tests * [ML] factory test * [ML] container test * [ML] set wrapper * [ML] tooltip tests * [ML] fix displayScore * [ML] label colors * [ML] support edit mode * [ML] call super render Co-authored-by: Elastic Machine --- x-pack/plugins/ml/kibana.json | 4 +- .../chart_tooltip/chart_tooltip.tsx | 182 +++++----- .../chart_tooltip/chart_tooltip_service.d.ts | 42 --- .../chart_tooltip/chart_tooltip_service.js | 37 -- .../chart_tooltip_service.test.ts | 63 +++- .../chart_tooltip/chart_tooltip_service.ts | 73 ++++ .../components/chart_tooltip/index.ts | 4 +- .../components/job_selector/job_selector.tsx | 280 ++-------------- .../job_selector_badge/{index.js => index.ts} | 0 ...lector_badge.js => job_selector_badge.tsx} | 35 +- .../job_selector/job_selector_flyout.tsx | 289 ++++++++++++++++ .../job_selector_table/job_selector_table.js | 2 +- .../{index.js => index.ts} | 0 ..._badges.js => new_selection_id_badges.tsx} | 32 +- .../datavisualizer/index_based/page.tsx | 4 +- ...s.snap => explorer_swimlane.test.tsx.snap} | 0 .../application/explorer/_explorer.scss | 316 +++++++++--------- .../public/application/explorer/explorer.js | 63 ++-- .../explorer_chart_distribution.js | 12 +- .../explorer_chart_distribution.test.js | 34 +- .../explorer_chart_single_metric.js | 14 +- .../explorer_chart_single_metric.test.js | 34 +- .../explorer_charts_container.js | 109 +++--- .../explorer_charts_container.test.js | 12 +- .../explorer/explorer_constants.ts | 10 +- .../explorer/explorer_dashboard_service.ts | 7 +- ...ane.test.js => explorer_swimlane.test.tsx} | 50 ++- ...orer_swimlane.js => explorer_swimlane.tsx} | 253 ++++++++------ .../application/explorer/explorer_utils.d.ts | 10 +- .../application/explorer/explorer_utils.js | 4 +- .../components/charts/common/settings.ts | 4 +- .../jobs/new_job/pages/new_job/page.tsx | 4 +- .../services/anomaly_detector_service.ts | 58 ++++ .../application/services/explorer_service.ts | 308 +++++++++++++++++ .../application/services/http_service.ts | 104 +++++- .../services/results_service/index.ts | 2 + .../results_service/results_service.d.ts | 11 +- .../timeseries_chart/timeseries_chart.d.ts | 2 + .../timeseries_chart/timeseries_chart.js | 23 +- .../timeseries_chart_annotations.ts | 8 +- .../timeseriesexplorer/timeseriesexplorer.js | 30 +- .../timeseriesexplorer_utils.js | 6 +- .../public/application/util/chart_utils.d.ts | 7 + .../ml/public/application/util/date_utils.ts | 2 +- .../public/application/util/time_buckets.d.ts | 37 +- .../public/application/util/time_buckets.js | 26 +- .../application/util/time_buckets.test.js | 28 +- .../anomaly_swimlane_embeddable.tsx | 115 +++++++ ...omaly_swimlane_embeddable_factory.test.tsx | 50 +++ .../anomaly_swimlane_embeddable_factory.ts | 81 +++++ .../anomaly_swimlane_initializer.tsx | 201 +++++++++++ .../anomaly_swimlane_setup_flyout.tsx | 95 ++++++ .../explorer_swimlane_container.test.tsx | 124 +++++++ .../explorer_swimlane_container.tsx | 122 +++++++ .../embeddables/anomaly_swimlane/index.ts | 8 + .../swimlane_input_resolver.test.ts | 271 +++++++++++++++ .../swimlane_input_resolver.ts | 211 ++++++++++++ x-pack/plugins/ml/public/embeddables/index.ts | 23 ++ x-pack/plugins/ml/public/plugin.ts | 13 + .../ui_actions/edit_swimlane_panel_action.tsx | 65 ++++ x-pack/plugins/ml/public/ui_actions/index.ts | 30 ++ 61 files changed, 3100 insertions(+), 944 deletions(-) delete mode 100644 x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts delete mode 100644 x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js create mode 100644 x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts rename x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/{index.js => index.ts} (100%) rename x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/{job_selector_badge.js => job_selector_badge.tsx} (68%) create mode 100644 x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx rename x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/{index.js => index.ts} (100%) rename x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/{new_selection_id_badges.js => new_selection_id_badges.tsx} (80%) rename x-pack/plugins/ml/public/application/explorer/__snapshots__/{explorer_swimlane.test.js.snap => explorer_swimlane.test.tsx.snap} (100%) rename x-pack/plugins/ml/public/application/explorer/{explorer_swimlane.test.js => explorer_swimlane.test.tsx} (70%) rename x-pack/plugins/ml/public/application/explorer/{explorer_swimlane.js => explorer_swimlane.tsx} (79%) create mode 100644 x-pack/plugins/ml/public/application/services/anomaly_detector_service.ts create mode 100644 x-pack/plugins/ml/public/application/services/explorer_service.ts create mode 100644 x-pack/plugins/ml/public/application/util/chart_utils.d.ts create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.test.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.test.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/index.ts create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts create mode 100644 x-pack/plugins/ml/public/embeddables/index.ts create mode 100644 x-pack/plugins/ml/public/ui_actions/edit_swimlane_panel_action.tsx create mode 100644 x-pack/plugins/ml/public/ui_actions/index.ts diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index 038f61b3a33b7..e9d4aff3484b1 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -13,7 +13,9 @@ "home", "licensing", "usageCollection", - "share" + "share", + "embeddable", + "uiActions" ], "optionalPlugins": [ "security", diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx index 9cc42a4df2f66..decd1275fe884 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx @@ -4,56 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import classNames from 'classnames'; -import React, { useRef, FC } from 'react'; +import TooltipTrigger from 'react-popper-tooltip'; import { TooltipValueFormatter } from '@elastic/charts'; -import useObservable from 'react-use/lib/useObservable'; -import { chartTooltip$, ChartTooltipState, ChartTooltipValue } from './chart_tooltip_service'; +import './_index.scss'; -type RefValue = HTMLElement | null; - -function useRefWithCallback(chartTooltipState?: ChartTooltipState) { - const ref = useRef(null); - - return (node: RefValue) => { - ref.current = node; - - if ( - node !== null && - node.parentElement !== null && - chartTooltipState !== undefined && - chartTooltipState.isTooltipVisible - ) { - const parentBounding = node.parentElement.getBoundingClientRect(); - - const { targetPosition, offset } = chartTooltipState; - - const contentWidth = document.body.clientWidth - parentBounding.left; - const tooltipWidth = node.clientWidth; - - let left = targetPosition.left + offset.x - parentBounding.left; - if (left + tooltipWidth > contentWidth) { - // the tooltip is hanging off the side of the page, - // so move it to the other side of the target - left = left - (tooltipWidth + offset.x); - } - - const top = targetPosition.top + offset.y - parentBounding.top; - - if ( - chartTooltipState.tooltipPosition.left !== left || - chartTooltipState.tooltipPosition.top !== top - ) { - // render the tooltip with adjusted position. - chartTooltip$.next({ - ...chartTooltipState, - tooltipPosition: { left, top }, - }); - } - } - }; -} +import { ChildrenArg, TooltipTriggerProps } from 'react-popper-tooltip/dist/types'; +import { ChartTooltipService, ChartTooltipValue, TooltipData } from './chart_tooltip_service'; const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFormatter) => { if (!headerData) { @@ -63,48 +22,101 @@ const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFo return formatter ? formatter(headerData) : headerData.label; }; -export const ChartTooltip: FC = () => { - const chartTooltipState = useObservable(chartTooltip$); - const chartTooltipElement = useRefWithCallback(chartTooltipState); +const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) => { + const [tooltipData, setData] = useState([]); + const refCallback = useRef(); - if (chartTooltipState === undefined || !chartTooltipState.isTooltipVisible) { - return

; - } + useEffect(() => { + const subscription = service.tooltipState$.subscribe(tooltipState => { + if (refCallback.current) { + // update trigger + refCallback.current(tooltipState.target); + } + setData(tooltipState.tooltipData); + }); + return () => { + subscription.unsubscribe(); + }; + }, []); + + const triggerCallback = useCallback( + (({ triggerRef }) => { + // obtain the reference to the trigger setter callback + // to update the target based on changes from the service. + refCallback.current = triggerRef; + // actual trigger is resolved by the service, hence don't render + return null; + }) as TooltipTriggerProps['children'], + [] + ); + + const tooltipCallback = useCallback( + (({ tooltipRef, getTooltipProps }) => { + return ( +
+ {tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && ( +
{renderHeader(tooltipData[0])}
+ )} + {tooltipData.length > 1 && ( +
+ {tooltipData + .slice(1) + .map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => { + const classes = classNames('mlChartTooltip__item', { + /* eslint @typescript-eslint/camelcase:0 */ + echTooltip__rowHighlighted: isHighlighted, + }); + return ( +
+ {label} + {value} +
+ ); + })} +
+ )} +
+ ); + }) as TooltipTriggerProps['tooltip'], + [tooltipData] + ); - const { tooltipData, tooltipHeaderFormatter, tooltipPosition } = chartTooltipState; - const transform = `translate(${tooltipPosition.left}px, ${tooltipPosition.top}px)`; + const isTooltipShown = tooltipData.length > 0; return ( -
- {tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && ( -
- {renderHeader(tooltipData[0], tooltipHeaderFormatter)} -
- )} - {tooltipData.length > 1 && ( -
- {tooltipData - .slice(1) - .map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => { - const classes = classNames('mlChartTooltip__item', { - /* eslint @typescript-eslint/camelcase:0 */ - echTooltip__rowHighlighted: isHighlighted, - }); - return ( -
- {label} - {value} -
- ); - })} -
- )} -
+ + {triggerCallback} + + ); +}); + +interface MlTooltipComponentProps { + children: (tooltipService: ChartTooltipService) => React.ReactElement; +} + +export const MlTooltipComponent: FC = ({ children }) => { + const service = useMemo(() => new ChartTooltipService(), []); + + return ( + <> + + {children(service)} + ); }; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts deleted file mode 100644 index e6b0b6b4270bd..0000000000000 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { BehaviorSubject } from 'rxjs'; - -import { TooltipValue, TooltipValueFormatter } from '@elastic/charts'; - -export declare const getChartTooltipDefaultState: () => ChartTooltipState; - -export interface ChartTooltipValue extends TooltipValue { - skipHeader?: boolean; -} - -interface ChartTooltipState { - isTooltipVisible: boolean; - offset: ToolTipOffset; - targetPosition: ClientRect; - tooltipData: ChartTooltipValue[]; - tooltipHeaderFormatter?: TooltipValueFormatter; - tooltipPosition: { left: number; top: number }; -} - -export declare const chartTooltip$: BehaviorSubject; - -interface ToolTipOffset { - x: number; - y: number; -} - -interface MlChartTooltipService { - show: ( - tooltipData: ChartTooltipValue[], - target?: HTMLElement | null, - offset?: ToolTipOffset - ) => void; - hide: () => void; -} - -export declare const mlChartTooltipService: MlChartTooltipService; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js deleted file mode 100644 index 59cf98e5ffd71..0000000000000 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { BehaviorSubject } from 'rxjs'; - -export const getChartTooltipDefaultState = () => ({ - isTooltipVisible: false, - tooltipData: [], - offset: { x: 0, y: 0 }, - targetPosition: { left: 0, top: 0 }, - tooltipPosition: { left: 0, top: 0 }, -}); - -export const chartTooltip$ = new BehaviorSubject(getChartTooltipDefaultState()); - -export const mlChartTooltipService = { - show: (tooltipData, target, offset = { x: 0, y: 0 }) => { - if (typeof target !== 'undefined' && target !== null) { - chartTooltip$.next({ - ...chartTooltip$.getValue(), - isTooltipVisible: true, - offset, - targetPosition: target.getBoundingClientRect(), - tooltipData, - }); - } - }, - hide: () => { - chartTooltip$.next({ - ...getChartTooltipDefaultState(), - isTooltipVisible: false, - }); - }, -}; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts index aa1dbf92b0677..231854cd264c2 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts @@ -4,18 +4,61 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getChartTooltipDefaultState, mlChartTooltipService } from './chart_tooltip_service'; +import { + ChartTooltipService, + getChartTooltipDefaultState, + TooltipData, +} from './chart_tooltip_service'; -describe('ML - mlChartTooltipService', () => { - it('service API duck typing', () => { - expect(typeof mlChartTooltipService).toBe('object'); - expect(typeof mlChartTooltipService.show).toBe('function'); - expect(typeof mlChartTooltipService.hide).toBe('function'); +describe('ChartTooltipService', () => { + let service: ChartTooltipService; + + beforeEach(() => { + service = new ChartTooltipService(); + }); + + test('should update the tooltip state on show and hide', () => { + const spy = jest.fn(); + + service.tooltipState$.subscribe(spy); + + expect(spy).toHaveBeenCalledWith(getChartTooltipDefaultState()); + + const update = [ + { + label: 'new tooltip', + }, + ] as TooltipData; + const mockEl = document.createElement('div'); + + service.show(update, mockEl); + + expect(spy).toHaveBeenCalledWith({ + isTooltipVisible: true, + tooltipData: update, + offset: { x: 0, y: 0 }, + target: mockEl, + }); + + service.hide(); + + expect(spy).toHaveBeenCalledWith({ + isTooltipVisible: false, + tooltipData: ([] as unknown) as TooltipData, + offset: { x: 0, y: 0 }, + target: null, + }); }); - it('should fail silently when target is not defined', () => { - expect(() => { - mlChartTooltipService.show(getChartTooltipDefaultState().tooltipData, null); - }).not.toThrow('Call to show() should fail silently.'); + test('update the tooltip state only on a new value', () => { + const spy = jest.fn(); + + service.tooltipState$.subscribe(spy); + + expect(spy).toHaveBeenCalledWith(getChartTooltipDefaultState()); + + service.hide(); + + expect(spy).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts new file mode 100644 index 0000000000000..b524e18102a95 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts @@ -0,0 +1,73 @@ +/* + * 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 { BehaviorSubject, Observable } from 'rxjs'; +import { isEqual } from 'lodash'; +import { TooltipValue, TooltipValueFormatter } from '@elastic/charts'; +import { distinctUntilChanged } from 'rxjs/operators'; + +export interface ChartTooltipValue extends TooltipValue { + skipHeader?: boolean; +} + +export interface TooltipHeader { + skipHeader: boolean; +} + +export type TooltipData = ChartTooltipValue[]; + +export interface ChartTooltipState { + isTooltipVisible: boolean; + offset: TooltipOffset; + tooltipData: TooltipData; + tooltipHeaderFormatter?: TooltipValueFormatter; + target: HTMLElement | null; +} + +interface TooltipOffset { + x: number; + y: number; +} + +export const getChartTooltipDefaultState = (): ChartTooltipState => ({ + isTooltipVisible: false, + tooltipData: ([] as unknown) as TooltipData, + offset: { x: 0, y: 0 }, + target: null, +}); + +export class ChartTooltipService { + private chartTooltip$ = new BehaviorSubject(getChartTooltipDefaultState()); + + public tooltipState$: Observable = this.chartTooltip$ + .asObservable() + .pipe(distinctUntilChanged(isEqual)); + + public show( + tooltipData: TooltipData, + target: HTMLElement, + offset: TooltipOffset = { x: 0, y: 0 } + ) { + if (!target) { + throw new Error('target is required for the tooltip positioning'); + } + + this.chartTooltip$.next({ + ...this.chartTooltip$.getValue(), + isTooltipVisible: true, + offset, + tooltipData, + target, + }); + } + + public hide() { + this.chartTooltip$.next({ + ...getChartTooltipDefaultState(), + isTooltipVisible: false, + }); + } +} diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts index 75c65ebaa0f50..ec19fe18bd324 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { mlChartTooltipService } from './chart_tooltip_service'; -export { ChartTooltip } from './chart_tooltip'; +export { ChartTooltipService } from './chart_tooltip_service'; +export { MlTooltipComponent } from './chart_tooltip'; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx index 381e5e75356c1..f709c161bef17 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx @@ -4,45 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import PropTypes from 'prop-types'; - -import { - EuiButton, - EuiButtonEmpty, - EuiFlexItem, - EuiFlexGroup, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiSwitch, - EuiTitle, -} from '@elastic/eui'; +import React, { useState, useEffect } from 'react'; +import { EuiButtonEmpty, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useMlKibana } from '../../contexts/kibana'; import { Dictionary } from '../../../../common/types/common'; -import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; -import { ml } from '../../services/ml_api_service'; import { useUrlState } from '../../util/url_state'; // @ts-ignore -import { JobSelectorTable } from './job_selector_table/index'; -// @ts-ignore import { IdBadges } from './id_badges/index'; -// @ts-ignore -import { NewSelectionIdBadges } from './new_selection_id_badges/index'; -import { - getGroupsFromJobs, - getTimeRangeFromSelection, - normalizeTimes, -} from './job_select_service_utils'; +import { BADGE_LIMIT, JobSelectorFlyout, JobSelectorFlyoutProps } from './job_selector_flyout'; +import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; interface GroupObj { groupId: string; jobIds: string[]; } + function mergeSelection( jobIds: string[], groupObjs: GroupObj[], @@ -71,7 +49,7 @@ function mergeSelection( } type GroupsMap = Dictionary; -function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { +export function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { const map: GroupsMap = {}; if (selectedGroups.length) { @@ -83,81 +61,38 @@ function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { return map; } -const BADGE_LIMIT = 10; -const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels - interface JobSelectorProps { dateFormatTz: string; singleSelection: boolean; timeseriesOnly: boolean; } +export interface JobSelectionMaps { + jobsMap: Dictionary; + groupsMap: Dictionary; +} + export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: JobSelectorProps) { const [globalState, setGlobalState] = useUrlState('_g'); const selectedJobIds = globalState?.ml?.jobIds ?? []; const selectedGroups = globalState?.ml?.groups ?? []; - const [jobs, setJobs] = useState([]); - const [groups, setGroups] = useState([]); - const [maps, setMaps] = useState({ groupsMap: getInitialGroupsMap(selectedGroups), jobsMap: {} }); + const [maps, setMaps] = useState({ + groupsMap: getInitialGroupsMap(selectedGroups), + jobsMap: {}, + }); const [selectedIds, setSelectedIds] = useState( mergeSelection(selectedJobIds, selectedGroups, singleSelection) ); - const [newSelection, setNewSelection] = useState( - mergeSelection(selectedJobIds, selectedGroups, singleSelection) - ); - const [showAllBadges, setShowAllBadges] = useState(false); const [showAllBarBadges, setShowAllBarBadges] = useState(false); - const [applyTimeRange, setApplyTimeRange] = useState(true); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); - const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH); - const flyoutEl = useRef<{ flyout: HTMLElement }>(null); - const { - services: { notifications }, - } = useMlKibana(); // Ensure JobSelectionBar gets updated when selection via globalState changes. useEffect(() => { setSelectedIds(mergeSelection(selectedJobIds, selectedGroups, singleSelection)); }, [JSON.stringify([selectedJobIds, selectedGroups])]); - // Ensure current selected ids always show up in flyout - useEffect(() => { - setNewSelection(selectedIds); - }, [isFlyoutVisible]); // eslint-disable-line - - // Wrap handleResize in useCallback as it is a dependency for useEffect on line 131 below. - // Not wrapping it would cause this dependency to change on every render - const handleResize = useCallback(() => { - if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) { - // get all cols in flyout table - const tableHeaderCols: NodeListOf = flyoutEl.current.flyout.querySelectorAll( - 'table thead th' - ); - // get the width of the last col - const derivedWidth = tableHeaderCols[tableHeaderCols.length - 1].offsetWidth - 16; - const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth); - setJobs(normalizedJobs); - const { groups: updatedGroups } = getGroupsFromJobs(normalizedJobs); - setGroups(updatedGroups); - setGanttBarWidth(derivedWidth); - } - }, [dateFormatTz, jobs]); - - useEffect(() => { - // Ensure ganttBar width gets calculated on resize - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('resize', handleResize); - }; - }, [handleResize]); - - useEffect(() => { - handleResize(); - }, [handleResize, jobs]); - function closeFlyout() { setIsFlyoutVisible(false); } @@ -168,78 +103,26 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J function handleJobSelectionClick() { showFlyout(); - - ml.jobs - .jobsWithTimerange(dateFormatTz) - .then(resp => { - const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH); - const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs); - setJobs(normalizedJobs); - setGroups(groupsWithTimerange); - setMaps({ groupsMap, jobsMap: resp.jobsMap }); - }) - .catch((err: any) => { - console.error('Error fetching jobs with time range', err); // eslint-disable-line - const { toasts } = notifications; - toasts.addDanger({ - title: i18n.translate('xpack.ml.jobSelector.jobFetchErrorMessage', { - defaultMessage: 'An error occurred fetching jobs. Refresh and try again.', - }), - }); - }); - } - - function handleNewSelection({ selectionFromTable }: { selectionFromTable: any }) { - setNewSelection(selectionFromTable); } - function applySelection() { - // allNewSelection will be a list of all job ids (including those from groups) selected from the table - const allNewSelection: string[] = []; - const groupSelection: Array<{ groupId: string; jobIds: string[] }> = []; - - newSelection.forEach(id => { - if (maps.groupsMap[id] !== undefined) { - // Push all jobs from selected groups into the newSelection list - allNewSelection.push(...maps.groupsMap[id]); - // if it's a group - push group obj to set in global state - groupSelection.push({ groupId: id, jobIds: maps.groupsMap[id] }); - } else { - allNewSelection.push(id); - } - }); - // create a Set to remove duplicate values - const allNewSelectionUnique = Array.from(new Set(allNewSelection)); - + const applySelection: JobSelectorFlyoutProps['onSelectionConfirmed'] = ({ + newSelection, + jobIds, + groups: newGroups, + time, + }) => { setSelectedIds(newSelection); - setNewSelection([]); - - closeFlyout(); - - const time = applyTimeRange - ? getTimeRangeFromSelection(jobs, allNewSelectionUnique) - : undefined; setGlobalState({ ml: { - jobIds: allNewSelectionUnique, - groups: groupSelection, + jobIds, + groups: newGroups, }, ...(time !== undefined ? { time } : {}), }); - } - - function toggleTimerangeSwitch() { - setApplyTimeRange(!applyTimeRange); - } - - function removeId(id: string) { - setNewSelection(newSelection.filter(item => item !== id)); - } - function clearSelection() { - setNewSelection([]); - } + closeFlyout(); + }; function renderJobSelectionBar() { return ( @@ -280,103 +163,16 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J function renderFlyout() { if (isFlyoutVisible) { return ( - - - -

- {i18n.translate('xpack.ml.jobSelector.flyoutTitle', { - defaultMessage: 'Job selection', - })} -

-
-
- - - - - setShowAllBadges(!showAllBadges)} - showAllBadges={showAllBadges} - /> - - - - - - {!singleSelection && newSelection.length > 0 && ( - - {i18n.translate('xpack.ml.jobSelector.clearAllFlyoutButton', { - defaultMessage: 'Clear all', - })} - - )} - - - - - - - - - - - - - - {i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', { - defaultMessage: 'Apply', - })} - - - - - {i18n.translate('xpack.ml.jobSelector.closeFlyoutButton', { - defaultMessage: 'Close', - })} - - - - -
+ ); } } @@ -388,9 +184,3 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J
); } - -JobSelector.propTypes = { - selectedJobIds: PropTypes.array, - singleSelection: PropTypes.bool, - timeseriesOnly: PropTypes.bool, -}; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.js b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.js rename to x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.ts diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx similarity index 68% rename from x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js rename to x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx index 4d2ab01e2a054..b2cae278c0e77 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx @@ -4,18 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { PropTypes } from 'prop-types'; -import { EuiBadge } from '@elastic/eui'; -import { tabColor } from '../../../../../common/util/group_color_utils'; +import React, { FC } from 'react'; +import { EuiBadge, EuiBadgeProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { tabColor } from '../../../../../common/util/group_color_utils'; -export function JobSelectorBadge({ icon, id, isGroup = false, numJobs, removeId }) { +interface JobSelectorBadgeProps { + icon?: boolean; + id: string; + isGroup?: boolean; + numJobs?: number; + removeId?: Function; +} + +export const JobSelectorBadge: FC = ({ + icon, + id, + isGroup = false, + numJobs, + removeId, +}) => { const color = isGroup ? tabColor(id) : 'hollow'; - let props = { color }; + let props = { color } as EuiBadgeProps; let jobCount; - if (icon === true) { + if (icon === true && removeId) { + // @ts-ignore props = { ...props, iconType: 'cross', @@ -37,11 +51,4 @@ export function JobSelectorBadge({ icon, id, isGroup = false, numJobs, removeId {`${id}${jobCount ? jobCount : ''}`} ); -} -JobSelectorBadge.propTypes = { - icon: PropTypes.bool, - id: PropTypes.string.isRequired, - isGroup: PropTypes.bool, - numJobs: PropTypes.number, - removeId: PropTypes.func, }; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx new file mode 100644 index 0000000000000..66aa05d2aaa97 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx @@ -0,0 +1,289 @@ +/* + * 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, { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexItem, + EuiFlexGroup, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiSwitch, + EuiTitle, +} from '@elastic/eui'; +import { NewSelectionIdBadges } from './new_selection_id_badges'; +// @ts-ignore +import { JobSelectorTable } from './job_selector_table'; +import { + getGroupsFromJobs, + getTimeRangeFromSelection, + normalizeTimes, +} from './job_select_service_utils'; +import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; +import { ml } from '../../services/ml_api_service'; +import { useMlKibana } from '../../contexts/kibana'; +import { JobSelectionMaps } from './job_selector'; + +export const BADGE_LIMIT = 10; +export const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels + +export interface JobSelectorFlyoutProps { + dateFormatTz: string; + selectedIds?: string[]; + newSelection?: string[]; + onFlyoutClose: () => void; + onJobsFetched?: (maps: JobSelectionMaps) => void; + onSelectionChange?: (newSelection: string[]) => void; + onSelectionConfirmed: (payload: { + newSelection: string[]; + jobIds: string[]; + groups: Array<{ groupId: string; jobIds: string[] }>; + time: any; + }) => void; + singleSelection: boolean; + timeseriesOnly: boolean; + maps: JobSelectionMaps; + withTimeRangeSelector?: boolean; +} + +export const JobSelectorFlyout: FC = ({ + dateFormatTz, + selectedIds = [], + singleSelection, + timeseriesOnly, + onJobsFetched, + onSelectionChange, + onSelectionConfirmed, + onFlyoutClose, + maps, + withTimeRangeSelector = true, +}) => { + const { + services: { notifications }, + } = useMlKibana(); + + const [newSelection, setNewSelection] = useState(selectedIds); + + const [showAllBadges, setShowAllBadges] = useState(false); + const [applyTimeRange, setApplyTimeRange] = useState(true); + const [jobs, setJobs] = useState([]); + const [groups, setGroups] = useState([]); + const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH); + const [jobGroupsMaps, setJobGroupsMaps] = useState(maps); + + const flyoutEl = useRef<{ flyout: HTMLElement }>(null); + + function applySelection() { + // allNewSelection will be a list of all job ids (including those from groups) selected from the table + const allNewSelection: string[] = []; + const groupSelection: Array<{ groupId: string; jobIds: string[] }> = []; + + newSelection.forEach(id => { + if (jobGroupsMaps.groupsMap[id] !== undefined) { + // Push all jobs from selected groups into the newSelection list + allNewSelection.push(...jobGroupsMaps.groupsMap[id]); + // if it's a group - push group obj to set in global state + groupSelection.push({ groupId: id, jobIds: jobGroupsMaps.groupsMap[id] }); + } else { + allNewSelection.push(id); + } + }); + // create a Set to remove duplicate values + const allNewSelectionUnique = Array.from(new Set(allNewSelection)); + + const time = applyTimeRange + ? getTimeRangeFromSelection(jobs, allNewSelectionUnique) + : undefined; + + onSelectionConfirmed({ + newSelection: allNewSelectionUnique, + jobIds: allNewSelectionUnique, + groups: groupSelection, + time, + }); + } + + function removeId(id: string) { + setNewSelection(newSelection.filter(item => item !== id)); + } + + function toggleTimerangeSwitch() { + setApplyTimeRange(!applyTimeRange); + } + + function clearSelection() { + setNewSelection([]); + } + + function handleNewSelection({ selectionFromTable }: { selectionFromTable: any }) { + setNewSelection(selectionFromTable); + } + + // Wrap handleResize in useCallback as it is a dependency for useEffect on line 131 below. + // Not wrapping it would cause this dependency to change on every render + const handleResize = useCallback(() => { + if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) { + // get all cols in flyout table + const tableHeaderCols: NodeListOf = flyoutEl.current.flyout.querySelectorAll( + 'table thead th' + ); + // get the width of the last col + const derivedWidth = tableHeaderCols[tableHeaderCols.length - 1].offsetWidth - 16; + const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth); + setJobs(normalizedJobs); + const { groups: updatedGroups } = getGroupsFromJobs(normalizedJobs); + setGroups(updatedGroups); + setGanttBarWidth(derivedWidth); + } + }, [dateFormatTz, jobs]); + + // Fetch jobs list on flyout open + useEffect(() => { + fetchJobs(); + }, []); + + async function fetchJobs() { + try { + const resp = await ml.jobs.jobsWithTimerange(dateFormatTz); + const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH); + const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs); + setJobs(normalizedJobs); + setGroups(groupsWithTimerange); + setJobGroupsMaps({ groupsMap, jobsMap: resp.jobsMap }); + + if (onJobsFetched) { + onJobsFetched({ groupsMap, jobsMap: resp.jobsMap }); + } + } catch (e) { + console.error('Error fetching jobs with time range', e); // eslint-disable-line + const { toasts } = notifications; + toasts.addDanger({ + title: i18n.translate('xpack.ml.jobSelector.jobFetchErrorMessage', { + defaultMessage: 'An error occurred fetching jobs. Refresh and try again.', + }), + }); + } + } + + useEffect(() => { + // Ensure ganttBar width gets calculated on resize + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [handleResize]); + + useEffect(() => { + handleResize(); + }, [handleResize, jobs]); + + return ( + + + +

+ {i18n.translate('xpack.ml.jobSelector.flyoutTitle', { + defaultMessage: 'Job selection', + })} +

+
+
+ + + + + setShowAllBadges(!showAllBadges)} + showAllBadges={showAllBadges} + /> + + + + + + {!singleSelection && newSelection.length > 0 && ( + + {i18n.translate('xpack.ml.jobSelector.clearAllFlyoutButton', { + defaultMessage: 'Clear all', + })} + + )} + + {withTimeRangeSelector && ( + + + + )} + + + + + + + + + + {i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', { + defaultMessage: 'Apply', + })} + + + + + {i18n.translate('xpack.ml.jobSelector.closeFlyoutButton', { + defaultMessage: 'Close', + })} + + + + +
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js index 64793d15f1e4a..c55e03776c09d 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js @@ -224,7 +224,7 @@ export function JobSelectorTable({ {jobs.length === 0 && } {jobs.length !== 0 && singleSelection === true && renderJobsTable()} - {jobs.length !== 0 && singleSelection === undefined && renderTabs()} + {jobs.length !== 0 && !singleSelection && renderTabs()} ); } diff --git a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.js b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.js rename to x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.ts diff --git a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx similarity index 80% rename from x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js rename to x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx index 67dce47323889..4c018e72f3e10 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js +++ b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx @@ -4,20 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { PropTypes } from 'prop-types'; +import React, { FC, MouseEventHandler } from 'react'; import { EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; -import { JobSelectorBadge } from '../job_selector_badge'; import { i18n } from '@kbn/i18n'; +import { JobSelectorBadge } from '../job_selector_badge'; +import { JobSelectionMaps } from '../job_selector'; -export function NewSelectionIdBadges({ +interface NewSelectionIdBadgesProps { + limit: number; + maps: JobSelectionMaps; + newSelection: string[]; + onDeleteClick?: Function; + onLinkClick?: MouseEventHandler; + showAllBadges?: boolean; +} + +export const NewSelectionIdBadges: FC = ({ limit, maps, newSelection, onDeleteClick, onLinkClick, showAllBadges, -}) { +}) => { const badges = []; for (let i = 0; i < newSelection.length; i++) { @@ -60,16 +69,5 @@ export function NewSelectionIdBadges({ ); } - return badges; -} -NewSelectionIdBadges.propTypes = { - limit: PropTypes.number, - maps: PropTypes.shape({ - jobsMap: PropTypes.object, - groupsMap: PropTypes.object, - }), - newSelection: PropTypes.array, - onDeleteClick: PropTypes.func, - onLinkClick: PropTypes.func, - showAllBadges: PropTypes.bool, + return <>{badges}; }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx index 86ffc4a2614b9..06d89ab782167 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx @@ -41,7 +41,7 @@ import { useMlContext } from '../../contexts/ml'; import { kbnTypeToMLJobType } from '../../util/field_types_utils'; import { useTimefilter } from '../../contexts/kibana'; import { timeBasedIndexCheck, getQueryFromSavedSearch } from '../../util/index_utils'; -import { TimeBuckets } from '../../util/time_buckets'; +import { getTimeBucketsFromCache } from '../../util/time_buckets'; import { useUrlState } from '../../util/url_state'; import { FieldRequestConfig, FieldVisConfig } from './common'; import { ActionsPanel } from './components/actions_panel'; @@ -318,7 +318,7 @@ export const Page: FC = () => { // Obtain the interval to use for date histogram aggregations // (such as the document count chart). Aim for 75 bars. - const buckets = new TimeBuckets(); + const buckets = getTimeBucketsFromCache(); const tf = timefilter as any; let earliest: number | undefined; diff --git a/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.js.snap b/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap similarity index 100% rename from x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.js.snap rename to x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap diff --git a/x-pack/plugins/ml/public/application/explorer/_explorer.scss b/x-pack/plugins/ml/public/application/explorer/_explorer.scss index 9fb2f0c3bed94..cfcba081983c2 100644 --- a/x-pack/plugins/ml/public/application/explorer/_explorer.scss +++ b/x-pack/plugins/ml/public/application/explorer/_explorer.scss @@ -106,164 +106,6 @@ padding: 0; margin-bottom: $euiSizeS; - div.ml-swimlanes { - margin: 0px 0px 0px 10px; - - div.cells-marker-container { - margin-left: 176px; - height: 22px; - white-space: nowrap; - - // background-color: #CCC; - .sl-cell { - height: 10px; - display: inline-block; - vertical-align: top; - margin-top: 16px; - text-align: center; - visibility: hidden; - cursor: default; - - i { - color: $euiColorDarkShade; - } - } - - .sl-cell-hover { - visibility: visible; - - i { - display: block; - margin-top: -6px; - } - } - - .sl-cell-active-hover { - visibility: visible; - - .floating-time-label { - display: inline-block; - } - } - } - - div.lane { - height: 30px; - border-bottom: 0px; - border-radius: 2px; - margin-top: -1px; - white-space: nowrap; - - div.lane-label { - display: inline-block; - font-size: 13px; - height: 30px; - text-align: right; - vertical-align: middle; - border-radius: 2px; - padding-right: 5px; - margin-right: 5px; - border: 1px solid transparent; - overflow: hidden; - text-overflow: ellipsis; - } - - div.lane-label.lane-label-masked { - opacity: 0.3; - } - - div.cells-container { - border: $euiBorderThin; - border-right: 0px; - display: inline-block; - height: 30px; - vertical-align: middle; - background-color: $euiColorEmptyShade; - - .sl-cell { - color: $euiColorEmptyShade; - cursor: default; - display: inline-block; - height: 29px; - border-right: $euiBorderThin; - vertical-align: top; - position: relative; - - .sl-cell-inner, - .sl-cell-inner-dragselect { - height: 26px; - margin: 1px; - border-radius: 2px; - text-align: center; - } - - .sl-cell-inner.sl-cell-inner-masked { - opacity: 0.2; - } - - .sl-cell-inner.sl-cell-inner-selected, - .sl-cell-inner-dragselect.sl-cell-inner-selected { - border: 2px solid $euiColorDarkShade; - } - - .sl-cell-inner.sl-cell-inner-selected.sl-cell-inner-masked, - .sl-cell-inner-dragselect.sl-cell-inner-selected.sl-cell-inner-masked { - border: 2px solid $euiColorFullShade; - opacity: 0.4; - } - } - - .sl-cell:hover { - .sl-cell-inner { - opacity: 0.8; - cursor: pointer; - } - } - - .sl-cell.ds-selected { - - .sl-cell-inner, - .sl-cell-inner-dragselect { - border: 2px solid $euiColorDarkShade; - border-radius: 2px; - opacity: 1; - } - } - - } - } - - div.lane:last-child { - div.cells-container { - .sl-cell { - border-bottom: $euiBorderThin; - } - } - } - - .time-tick-labels { - height: 25px; - margin-top: $euiSizeXS / 2; - margin-left: 175px; - - /* hide d3's domain line */ - path.domain { - display: none; - } - - /* hide d3's tick line */ - g.tick line { - display: none; - } - - /* override d3's default tick styles */ - g.tick text { - font-size: 11px; - fill: $euiColorMediumShade; - } - } - } - line.gridLine { stroke: $euiBorderColor; fill: none; @@ -328,3 +170,161 @@ } } } + +.ml-swimlanes { + margin: 0px 0px 0px 10px; + + div.cells-marker-container { + margin-left: 176px; + height: 22px; + white-space: nowrap; + + // background-color: #CCC; + .sl-cell { + height: 10px; + display: inline-block; + vertical-align: top; + margin-top: 16px; + text-align: center; + visibility: hidden; + cursor: default; + + i { + color: $euiColorDarkShade; + } + } + + .sl-cell-hover { + visibility: visible; + + i { + display: block; + margin-top: -6px; + } + } + + .sl-cell-active-hover { + visibility: visible; + + .floating-time-label { + display: inline-block; + } + } + } + + div.lane { + height: 30px; + border-bottom: 0px; + border-radius: 2px; + margin-top: -1px; + white-space: nowrap; + + div.lane-label { + display: inline-block; + font-size: 13px; + height: 30px; + text-align: right; + vertical-align: middle; + border-radius: 2px; + padding-right: 5px; + margin-right: 5px; + border: 1px solid transparent; + overflow: hidden; + text-overflow: ellipsis; + } + + div.lane-label.lane-label-masked { + opacity: 0.3; + } + + div.cells-container { + border: $euiBorderThin; + border-right: 0px; + display: inline-block; + height: 30px; + vertical-align: middle; + background-color: $euiColorEmptyShade; + + .sl-cell { + color: $euiColorEmptyShade; + cursor: default; + display: inline-block; + height: 29px; + border-right: $euiBorderThin; + vertical-align: top; + position: relative; + + .sl-cell-inner, + .sl-cell-inner-dragselect { + height: 26px; + margin: 1px; + border-radius: 2px; + text-align: center; + } + + .sl-cell-inner.sl-cell-inner-masked { + opacity: 0.2; + } + + .sl-cell-inner.sl-cell-inner-selected, + .sl-cell-inner-dragselect.sl-cell-inner-selected { + border: 2px solid $euiColorDarkShade; + } + + .sl-cell-inner.sl-cell-inner-selected.sl-cell-inner-masked, + .sl-cell-inner-dragselect.sl-cell-inner-selected.sl-cell-inner-masked { + border: 2px solid $euiColorFullShade; + opacity: 0.4; + } + } + + .sl-cell:hover { + .sl-cell-inner { + opacity: 0.8; + cursor: pointer; + } + } + + .sl-cell.ds-selected { + + .sl-cell-inner, + .sl-cell-inner-dragselect { + border: 2px solid $euiColorDarkShade; + border-radius: 2px; + opacity: 1; + } + } + + } + } + + div.lane:last-child { + div.cells-container { + .sl-cell { + border-bottom: $euiBorderThin; + } + } + } + + .time-tick-labels { + height: 25px; + margin-top: $euiSizeXS / 2; + margin-left: 175px; + + /* hide d3's domain line */ + path.domain { + display: none; + } + + /* hide d3's tick line */ + g.tick line { + display: none; + } + + /* override d3's default tick styles */ + g.tick text { + font-size: 11px; + fill: $euiColorMediumShade; + } + } +} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index d61d56d07b644..86d16776b68e2 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -36,9 +36,8 @@ import { ExplorerNoJobsFound, ExplorerNoResultsFound, } from './components'; -import { ChartTooltip } from '../components/chart_tooltip'; import { ExplorerSwimlane } from './explorer_swimlane'; -import { TimeBuckets } from '../util/time_buckets'; +import { getTimeBucketsFromCache } from '../util/time_buckets'; import { InfluencersList } from '../components/influencers_list'; import { ALLOW_CELL_RANGE_SELECTION, @@ -81,6 +80,7 @@ import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; import { ResizeChecker } from '../../../../../../src/plugins/kibana_utils/public'; import { getTimefilter, getToastNotifications } from '../util/dependency_cache'; +import { MlTooltipComponent } from '../components/chart_tooltip'; function mapSwimlaneOptionsToEuiOptions(options) { return options.map(option => ({ @@ -179,6 +179,8 @@ export class Explorer extends React.Component { // Required to redraw the time series chart when the container is resized. this.resizeChecker = new ResizeChecker(this.resizeRef.current); this.resizeChecker.on('resize', this.resizeHandler); + + this.timeBuckets = getTimeBucketsFromCache(); } componentWillUnmount() { @@ -358,9 +360,6 @@ export class Explorer extends React.Component { return (
- {/* Make sure ChartTooltip is inside wrapping div with 0px left/right padding so positioning can be inferred correctly. */} - - {noInfluencersConfigured === false && influencers !== undefined && (
{showOverallSwimlane && ( - + + {tooltipService => ( + + )} + )}
@@ -494,17 +498,22 @@ export class Explorer extends React.Component { onMouseLeave={this.onSwimlaneLeaveHandler} data-test-subj="mlAnomalyExplorerSwimlaneViewBy" > - + + {tooltipService => ( + + )} +
)} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js index 5fc1160093a49..03426869b0ccf 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js @@ -29,9 +29,8 @@ import { removeLabelOverlap, } from '../../util/chart_utils'; import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; -import { TimeBuckets } from '../../util/time_buckets'; +import { getTimeBucketsFromCache } from '../../util/time_buckets'; import { mlFieldFormatService } from '../../services/field_format_service'; -import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; import { CHART_TYPE } from '../explorer_constants'; @@ -50,6 +49,7 @@ export class ExplorerChartDistribution extends React.Component { static propTypes = { seriesConfig: PropTypes.object, severity: PropTypes.number, + tooltipService: PropTypes.object.isRequired, }; componentDidMount() { @@ -61,7 +61,7 @@ export class ExplorerChartDistribution extends React.Component { } renderChart() { - const { tooManyBuckets } = this.props; + const { tooManyBuckets, tooltipService } = this.props; const element = this.rootNode; const config = this.props.seriesConfig; @@ -259,7 +259,7 @@ export class ExplorerChartDistribution extends React.Component { function drawRareChartAxes() { // Get the scaled date format to use for x axis tick labels. - const timeBuckets = new TimeBuckets(); + const timeBuckets = getTimeBucketsFromCache(); const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) }; timeBuckets.setBounds(bounds); timeBuckets.setInterval('auto'); @@ -397,7 +397,7 @@ export class ExplorerChartDistribution extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => mlChartTooltipService.hide()); + .on('mouseout', () => tooltipService.hide()); // Update all dots to new positions. dots @@ -550,7 +550,7 @@ export class ExplorerChartDistribution extends React.Component { }); } - mlChartTooltipService.show(tooltipData, circle, { + tooltipService.show(tooltipData, circle, { x: LINE_CHART_ANOMALY_RADIUS * 3, y: LINE_CHART_ANOMALY_RADIUS * 2, }); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js index 71d777db5b2ec..06fd82204c1e1 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js @@ -10,11 +10,13 @@ import seriesConfig from './__mocks__/mock_series_config_rare.json'; // Mock TimeBuckets and mlFieldFormatService, they don't play well // with the jest based test setup yet. jest.mock('../../util/time_buckets', () => ({ - TimeBuckets: function() { - this.setBounds = jest.fn(); - this.setInterval = jest.fn(); - this.getScaledDateFormat = jest.fn(); - }, + getTimeBucketsFromCache: jest.fn(() => { + return { + setBounds: jest.fn(), + setInterval: jest.fn(), + getScaledDateFormat: jest.fn(), + }; + }), })); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { @@ -43,8 +45,16 @@ describe('ExplorerChart', () => { afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); test('Initialize', () => { + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + const wrapper = mountWithIntl( - + ); // without setting any attributes and corresponding data @@ -59,10 +69,16 @@ describe('ExplorerChart', () => { loading: true, }; + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + const wrapper = mountWithIntl( ); @@ -83,12 +99,18 @@ describe('ExplorerChart', () => { chartLimits: chartLimits(chartData), }; + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + // We create the element including a wrapper which sets the width: return mountWithIntl(
); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js index dd9479be931a7..82041af39ca15 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js @@ -38,10 +38,9 @@ import { showMultiBucketAnomalyTooltip, } from '../../util/chart_utils'; import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; -import { TimeBuckets } from '../../util/time_buckets'; +import { getTimeBucketsFromCache } from '../../util/time_buckets'; import { mlEscape } from '../../util/string_utils'; import { mlFieldFormatService } from '../../services/field_format_service'; -import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; import { i18n } from '@kbn/i18n'; @@ -53,6 +52,7 @@ export class ExplorerChartSingleMetric extends React.Component { tooManyBuckets: PropTypes.bool, seriesConfig: PropTypes.object, severity: PropTypes.number.isRequired, + tooltipService: PropTypes.object.isRequired, }; componentDidMount() { @@ -64,7 +64,7 @@ export class ExplorerChartSingleMetric extends React.Component { } renderChart() { - const { tooManyBuckets } = this.props; + const { tooManyBuckets, tooltipService } = this.props; const element = this.rootNode; const config = this.props.seriesConfig; @@ -191,7 +191,7 @@ export class ExplorerChartSingleMetric extends React.Component { function drawLineChartAxes() { // Get the scaled date format to use for x axis tick labels. - const timeBuckets = new TimeBuckets(); + const timeBuckets = getTimeBucketsFromCache(); const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) }; timeBuckets.setBounds(bounds); timeBuckets.setInterval('auto'); @@ -309,7 +309,7 @@ export class ExplorerChartSingleMetric extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => mlChartTooltipService.hide()); + .on('mouseout', () => tooltipService.hide()); const isAnomalyVisible = d => _.has(d, 'anomalyScore') && Number(d.anomalyScore) >= severity; @@ -354,7 +354,7 @@ export class ExplorerChartSingleMetric extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => mlChartTooltipService.hide()); + .on('mouseout', () => tooltipService.hide()); // Add rectangular markers for any scheduled events. const scheduledEventMarkers = lineChartGroup @@ -503,7 +503,7 @@ export class ExplorerChartSingleMetric extends React.Component { }); } - mlChartTooltipService.show(tooltipData, circle, { + tooltipService.show(tooltipData, circle, { x: LINE_CHART_ANOMALY_RADIUS * 3, y: LINE_CHART_ANOMALY_RADIUS * 2, }); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js index ca3e52308a936..54f541ceb7c3d 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js @@ -10,11 +10,13 @@ import seriesConfig from './__mocks__/mock_series_config_filebeat.json'; // Mock TimeBuckets and mlFieldFormatService, they don't play well // with the jest based test setup yet. jest.mock('../../util/time_buckets', () => ({ - TimeBuckets: function() { - this.setBounds = jest.fn(); - this.setInterval = jest.fn(); - this.getScaledDateFormat = jest.fn(); - }, + getTimeBucketsFromCache: jest.fn(() => { + return { + setBounds: jest.fn(), + setInterval: jest.fn(), + getScaledDateFormat: jest.fn(), + }; + }), })); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { @@ -43,8 +45,16 @@ describe('ExplorerChart', () => { afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); test('Initialize', () => { + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + const wrapper = mountWithIntl( - + ); // without setting any attributes and corresponding data @@ -59,10 +69,16 @@ describe('ExplorerChart', () => { loading: true, }; + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + const wrapper = mountWithIntl( ); @@ -83,12 +99,18 @@ describe('ExplorerChart', () => { chartLimits: chartLimits(chartData), }; + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + // We create the element including a wrapper which sets the width: return mountWithIntl(
); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index 99de38c1e0a84..5b95931d31ab6 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import $ from 'jquery'; - import React from 'react'; import { @@ -29,6 +27,7 @@ import { ExplorerChartLabel } from './components/explorer_chart_label'; import { CHART_TYPE } from '../explorer_constants'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { MlTooltipComponent } from '../../components/chart_tooltip'; const textTooManyBuckets = i18n.translate('xpack.ml.explorer.charts.tooManyBucketsDescription', { defaultMessage: @@ -121,19 +120,29 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) chartType === CHART_TYPE.POPULATION_DISTRIBUTION ) { return ( - + + {tooltipService => ( + + )} + ); } return ( - + + {tooltipService => ( + + )} + ); })()} @@ -141,48 +150,36 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) } // Flex layout wrapper for all explorer charts -export class ExplorerChartsContainer extends React.Component { - componentDidMount() { - // Create a div for the tooltip. - $('.ml-explorer-charts-tooltip').remove(); - $('body').append( - '