mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
Drilldowns (#61219)
* Add drilldown wizard components * Dynamic actions (#58216) * feat: 🎸 add DynamicAction and FactoryAction types * feat: 🎸 add Mutable<T> type to @kbn/utility-types * feat: 🎸 add ActionInternal and ActionContract * chore: 🤖 remove unused file * feat: 🎸 improve action interfaces * docs: ✏️ add JSDocs * feat: 🎸 simplify ui_actions interfaces * fix: 🐛 fix TypeScript types * feat: 🎸 add AbstractPresentable interface * feat: 🎸 add AbstractConfigurable interface * feat: 🎸 use AbstractPresentable in ActionInternal * test: 💍 fix ui_actions Jest tests * feat: 🎸 add state container to action * perf: ⚡️ convert MenuItem to React component on Action instance * refactor: 💡 rename AbsractPresentable -> Presentable * refactor: 💡 rename AbstractConfigurable -> Configurable * feat: 🎸 add Storybook to ui_actions * feat: 🎸 add <ErrorConfigureAction> component * feat: 🎸 improve <ConfigureAction> component * chore: 🤖 use .story file extension prefix for Storybook * feat: 🎸 improve <ErrorConfigureAction> component * feat: 🎸 show error if dynamic action has CollectConfig missing * feat: 🎸 render sample action configuration component * feat: 🎸 connect action config to <ConfigureAction> * feat: 🎸 improve <ConfigureAction> stories * test: 💍 add ActionInternal serialize/deserialize tests * feat: 🎸 add ActionContract * feat: 🎸 split action Context into Execution and Presentation * fix: 🐛 fix TypeScript error * refactor: 💡 extract state container hooks to module scope * docs: ✏️ fix typos * chore: 🤖 remove Mutable<t> type * test: 💍 don't cast to any getActions() function * style: 💄 avoid using unnecessary types * chore: 🤖 address PR review comments * chore: 🤖 rename ActionContext generic * chore: 🤖 remove order from state container * chore: 🤖 remove deprecation notice on getHref * test: 💍 fix tests after order field change * remove comments Co-authored-by: Matt Kime <matt@mattki.me> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> * Drilldown context menu (#59638) * fix: 🐛 fix TypeScript error * feat: 🎸 add CONTEXT_MENU_DRILLDOWNS_TRIGGER trigger * fix: 🐛 correctly order context menu items * fix: 🐛 set correct order on drilldown flyout actions * fix: 🐛 clean up context menu building functions * feat: 🎸 add context menu separator action * Add basic ActionFactoryService. Pass data from it into components instead of mocks * Dashboard x pack (#59653) * feat: 🎸 add dashboard_enhanced plugin to x-pack * feat: 🎸 improve context menu separator * feat: 🎸 move drilldown flyout actions to dashboard_enhanced * fix: 🐛 fix exports from ui_actions plugin * feat: 🎸 "implement" registerDrilldown() method * fix ConfigurableBaseConfig type * Implement connected flyout_manage_drilldowns component * Simplify connected flyout manage drilldowns component. Remove intermediate component * clean up data-testid workaround in new components * Connect welcome message to storage Not sure, but use LocalStorage. Didn’t find a way to persist user settings. looks like uiSettings are not user scoped. * require `context` in Presentable. drill context down through wizard components * Drilldown factory (#59823) * refactor: 💡 import storage interface from ui_actions plugin * refactor: 💡 make actions not-dynamic * feat: 🎸 fix TypeScript errors, reshuffle types and code * fix: 🐛 fix more TypeScript errors * fix: 🐛 fix TypeScript import error * Drilldown registration (#59834) * feat: 🎸 improve drilldown registration method * fix: 🐛 set up translations for dashboard_enhanced plugin * Drilldown events 3 (#59854) * feat: 🎸 add serialize/unserialize to action * feat: 🎸 pass in uiActions service into Embeddable * feat: 🎸 merge ui_actions oss and basic plugins * refactor: 💡 move action factory registry to OSS * fix: 🐛 fix TypeScript errors * Drilldown events 4 (#59876) * feat: 🎸 mock sample drilldown execute methods * feat: 🎸 add .dynamicActions manager to Embeddable * feat: 🎸 add first version of dynamic action manager * Drilldown events 5 (#59885) * feat: 🎸 display drilldowns in context menu only on one embed * feat: 🎸 clear dynamic actions from registry when embed unloads * fix: 🐛 fix OSS TypeScript errors * basic integration of components with dynamicActionManager * fix: 🐛 don't overwrite explicitInput with combined input (#59938) * display drilldown count in embeddable edit mode * display drilldown count in embeddable edit mode * improve wizard components. more tests. * partial progress, dashboard drilldowns (#59977) * partial progress, dashboard drilldowns * partial progress, dashboard drilldowns * feat: 🎸 improve dashboard drilldown setup * feat: 🎸 wire in services into dashboard drilldown * chore: 🤖 add Storybook to dashboard_enhanced * feat: 🎸 create presentational <DashboardDrilldownConfig> * test: 💍 add <DashboardDrilldownConfig> stories * test: 💍 use presentation dashboar config component * feat: 🎸 wire in services into React component * docs: ✏️ add README to /components folder * feat: 🎸 increase importance of Dashboard drilldown * feat: 🎸 improve icon definition in drilldowns * chore: 🤖 remove unnecessary comment * chore: 🤖 add todos Co-authored-by: streamich <streamich@gmail.com> * Manage drilldowns toasts. Add basic error handling. * support order in action factory selector * fix column order in manage drilldowns list * remove accidental debug info * bunch of nit ui fixes * Drilldowns reactive action manager (#60099) * feat: 🎸 improve isConfigValid return type * feat: 🎸 make DynamicActionManager reactive * docs: ✏️ add JSDocs to public mehtods of DynamicActionManager * feat: 🎸 make panel top-right corner number badge reactive * fix: 🐛 correctly await for .deleteEvents() * Drilldowns various 2 (#60103) * chore: 🤖 address review comments * test: 💍 fix embeddable_panel.test.tsx tests * chore: 🤖 clean up ActionInternal * chore: 🤖 make isConfigValid a simple predicate * chore: 🤖 fix TypeScript type errors * test: 💍 stub DynamicActionManager tests (#60104) * Drilldowns review 1 (#60139) * refactor: 💡 improve generic types * fix: 🐛 don't overwrite icon * fix: 🐛 fix x-pack TypeScript errors * fix: 🐛 fix TypeScript error * fix: 🐛 correct merge * Drilldowns various 4 (#60264) * feat: 🎸 hide "Create drilldown" from context menu when needed * style: 💄 remove AnyDrilldown type * feat: 🎸 add drilldown factory context * chore: 🤖 remove sample drilldown * fix: 🐛 increase spacing between action factory picker * workaround issue with closing flyout when navigating away Adds overlay just like other flyouts which makes this defect harder to bump in * fix react key issue in action_wizard * don’t open 2 flyouts * fix action order https://github.com/elastic/kibana/issues/60138 * Drilldowns reload stored (#60336) * style: 💄 don't use double equals __ * feat: 🎸 add reload$ to ActionStorage interface * feat: 🎸 add reload$ to embeddable event storage * feat: 🎸 add storage syncing to DynamicActionManager * refactor: 💡 use state from DynamicActionManager in React * fix: 🐛 add check for manager being stopped * Drilldowns triggers (#60339) * feat: 🎸 make use of supportedTriggers() * feat: 🎸 pass in context to configuration component * feat: 🎸 augment factory context * fix: 🐛 stop infinite re-rendering * Drilldowns multitrigger (#60357) * feat: 🎸 add support for multiple triggers * feat: 🎸 enable Drilldowns for TSVB Although TSVB brushing event is now broken on master, KibanaApp plans to fix it in 7.7 * "Create drilldown" flyout - design cleanup (#60309) * create drilldown flyout cleanup * remove border from selectedActionFactoryContainer * adjust callout in DrilldownHello * update form labels * remove unused file * fix type error Co-authored-by: Anton Dosov <anton.dosov@elastic.co> * basic unit tests for flyout_create_drildown action * Drilldowns finalize (#60371) * fix: 🐛 align flyout content to left side * fix: 🐛 move context menu item number 1px lower * fix: 🐛 move flyout back nav chevron up * fix: 🐛 fix type check after refactor * basic unit tests for drilldown actions * Drilldowns finalize 2 (#60510) * test: 💍 fix test mock * chore: 🤖 remove unused UiActionsService methods * refactor: 💡 cleanup UiActionsService action registration * fix: 🐛 add missing functionality after refactor * test: 💍 add action factory tests * test: 💍 add DynamicActionManager tests * feat: 🎸 capture error if it happens during initial load * fix: 🐛 register correctly CSV action * feat: 🎸 don't show "OPTIONS" title on drilldown context menus * feat: 🎸 add server-side for x-pack dashboard plugin * feat: 🎸 disable Drilldowns for TSVB * feat: 🎸 enable drilldowns on kibana.yml feature flag * feat: 🎸 add feature flag comment to kibana.yml * feat: 🎸 remove places from drilldown interface * refactor: 💡 remove place in factory context * chore: 🤖 remove doExecute * remove not needed now error_configure_action component * remove workaround for storybook * feat: 🎸 improve DrilldownDefinition interface * style: 💄 replace any by unknown * chore: 🤖 remove any * chore: 🤖 make isConfigValid return type a boolean * refactor: 💡 move getDisplayName to factory, remove deprecated * style: 💄 remove any * feat: 🎸 improve ActionFactoryDefinition * refactor: 💡 change visualize_embeddable params * feat: 🎸 add dashboard dependency to dashboard_enhanced * style: 💄 rename drilldown plugin life-cycle contracts * refactor: 💡 do naming adjustments for dashboard drilldown * fix: 🐛 fix Type error * fix: 🐛 fix TypeScript type errors * test: 💍 fix test after refactor * refactor: 💡 rename context -> placeContext in React component * chore: 🤖 remove setting from kibana.yml * refactor: 💡 change return type of getAction as per review * remove custom css per review * refactor: 💡 rename drilldownCount to eventCount * style: 💄 remove any * refactor: 💡 change how uiActions are passed to vis embeddable * style: 💄 remove unused import * fix: 🐛 pass in uiActions to visualize_embeddable * fix: 🐛 correctly register action * fix: 🐛 fix type error * chore: 🤖 remove unused translations * Dynamic actions to xpack (#62647) * feat: 🎸 set up sample action factory provider * feat: 🎸 create dashboard_enhanced plugin * feat: 🎸 add EnhancedEmbeddable interface * refactor: 💡 move DynamicActionManager to x-pack * feat: 🎸 connect dynamic action manager to embeddable life-cycle * test: 💍 fix Jest tests after refactor * fix: 🐛 fix type error Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> * refactor: 💡 move action factories to x-pack (#63190) * refactor: 💡 move action factories to x-pack * fix: 🐛 use correct plugin embeddable deps * test: 💍 fix Jest test after refactor * chore: 🤖 remove kibana.yml flag (#62441) * Panel top right (#63466) * feat: 🎸 add PANEL_NOTIFICATION_TRIGGER * feat: 🎸 add PanelNotificationsAction action * test: 💍 add PanelNotificationsAction unit tests * refactor: 💡 revert addTriggerAction() change * style: 💄 remove unused import * fix: 🐛 fix typecheck errors after merge * support getHref in drilldowns (#63727) * chore: 🤖 remove ui_actions storybook config * update docs * fix ts * fix: 🐛 fix broken merge * [Drilldowns] Dashboard to dashboard drilldown (#63108) * partial progress on async loading / searching of dashboard titles * feat: 🎸 make combobox full width * filtering combobox polish * storybook fix * implement navigating to dashboard, seems like a type problem * try navToApp * filter out current dashboard * rough draft linking to a dashboard * remove note * typefix * fix navigation from dashboard to dashboard except for back button - that would be addressed separatly * partial progress getting filters from action data * fix issue with getIndexPatterns undefined we can’t import those functions as static functions, instead we have to expose them on plugin contract because they are statefull * fix filter / time passing into url * typefix * dashboard to dashboard drilldown functional test and back button fix * documentation update * chore clean-ups fix type * basic unit test for dashboard drilldown * remove test todos decided to skip those tests because not clear how to test due to EuiCombobox is using react-virtualized and options list is not rendered in jsdom env * remove config * improve back button with filter comparison tweak * dashboard filters/date option off by default * revert change to config/kibana.yml * remove unneeded comments * use default time range as appropriate * fix type, add filter icon, add text * fix test * change how time range is restored and improve back button for drilldowns * resolve conflicts * fix async compile issue * remove redundant test * wip * wip * fix * temp skip tests * fix * handle missing dashboard edge case * fix api * refactor action filter creation utils * updating * updating docs * improve * fix storybook * post merge fixes * fix payload emitted in brush event * properly export createRange action * improve tests * add test * post merge fixes * improve * fix * improve * fix build * wip getHref support * implement getHref() * give proper name to a story * use sync start services * update text * fix types * fix ts * fix docs * move clone below drilldowns (near replace) * remove redundant comments * refactor action filter creation utils * updating * updating docs * fix payload emitted in brush event * properly export createRange action * some more updates * fixing types * ... * inline EventData * fix typescript in lens and update docs * improve filters types * docs * merge * @mdefazio review * adjust actions order * docs * @stacey-gammon review Co-authored-by: Matt Kime <matt@mattki.me> Co-authored-by: streamich <streamich@gmail.com> Co-authored-by: ppisljar <peter.pisljar@gmail.com> * fix docs * nit fixes * chore: 🤖 remove uiActions from Embeddable dependencies * chore: 🤖 don't export ActionInternal from ui_actions * test: 💍 remove uiActions deps in x-pack test mocks * chore: 🤖 cleanup ui_actions types * docs: ✏️ add JSDoc comment to addTriggerAction() * docs: ✏️ regenerate docs * Drilldown demo 2 (#64300) * chore: 🤖 add example of Discover drilldown to sample plugin * fix: 🐛 show drilldowns with higher "order" first * feat: 🎸 add createStartServicesGetter() to /public kibana_util * feat: 🎸 load index patterns in Discover drilldown * feat: 🎸 add toggle for index pattern selection * feat: 🎸 add spacer to separate unrelated config fields * fix: 🐛 correctly configre setup core * feat: 🎸 navigate to correct index pattern * chore: 🤖 fix type check errors * fix: 🐛 make index pattern select full width * fix: 🐛 add getHref support * feat: 🎸 add example plugin ability to X-Pack * refactor: 💡 move Discover drilldown example to X-Pack * feat: 🎸 add dashboard-to-url drilldown example * feat: 🎸 add new tab support for URL drilldown * feat: 🎸 add "hello world" drilldown example * docs: ✏️ add README * feat: 🎸 add getHref support * chore: 🤖 cleanup after moving examples to X-Pack * docs: ✏️ add to README.md info on how to find drilldowns * feat: 🎸 store events in .enhancements field * docs: ✏️ add comment to range trigger title * refactor: 💡 move Configurable interface into kibana_utils * chore: 🤖 simplify internal component types * refactor: 💡 move registerDrilldwon() to advanced_ui_actions * test: 💍 update functional test data * merge * docs: ✏️ make drilldown enhancement comment more general * fix: 🐛 return public type from registerAction() call * docs: ✏️ add comment to value click trigger title field * docs: ✏️ improve comment * fix: 🐛 use second argument of CollectConfigProps interface * fix: 🐛 add workaround for Firefox rendering issue See: https://github.com/elastic/kibana/pull/61219/#pullrequestreview-402903330 * chore: 🤖 delete unused file * fix: 🐛 import type from new location * style: 💄 make generic type variable name sconsistent * fix: 🐛 show "Create drilldown" only on dashboard * test: 💍 add extra unit test for root embeddable type * docs: ✏️ update generated docs * chore: 🤖 add example warnings to sample drilldowns * docs: ✏️ add links to example warnings * feat: 🎸 add URL drilldown validation and https:// prefixing * fix: 🐛 disable drilldowns for lens * refactor: 💡 remove PlaceContext from DrilldownDefinition * fix: 🐛 fix type check error * feat: 🎸 show warning message if embeddable not provided Co-authored-by: Anton Dosov <anton.dosov@elastic.co> Co-authored-by: Matt Kime <matt@mattki.me> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> Co-authored-by: Andrea Del Rio <delrio.andre@gmail.com> Co-authored-by: ppisljar <peter.pisljar@gmail.com>
This commit is contained in:
parent
cb00e5e7bb
commit
360b9c1200
213 changed files with 7816 additions and 1189 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -18,9 +18,8 @@
|
|||
*/
|
||||
|
||||
import { UiActionExamplesPlugin } from './plugin';
|
||||
import { PluginInitializer } from '../../../src/core/public';
|
||||
|
||||
export const plugin: PluginInitializer<void, void> = () => new UiActionExamplesPlugin();
|
||||
export const plugin = () => new UiActionExamplesPlugin();
|
||||
|
||||
export { HELLO_WORLD_TRIGGER_ID } from './hello_world_trigger';
|
||||
export { ACTION_HELLO_WORLD } from './hello_world_action';
|
||||
|
|
|
@ -17,15 +17,19 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { Plugin, CoreSetup } from '../../../src/core/public';
|
||||
import { UiActionsSetup } from '../../../src/plugins/ui_actions/public';
|
||||
import { Plugin, CoreSetup, CoreStart } from '../../../src/core/public';
|
||||
import { UiActionsSetup, UiActionsStart } from '../../../src/plugins/ui_actions/public';
|
||||
import { createHelloWorldAction, ACTION_HELLO_WORLD } from './hello_world_action';
|
||||
import { helloWorldTrigger, HELLO_WORLD_TRIGGER_ID } from './hello_world_trigger';
|
||||
|
||||
interface UiActionExamplesSetupDependencies {
|
||||
export interface UiActionExamplesSetupDependencies {
|
||||
uiActions: UiActionsSetup;
|
||||
}
|
||||
|
||||
export interface UiActionExamplesStartDependencies {
|
||||
uiActions: UiActionsStart;
|
||||
}
|
||||
|
||||
declare module '../../../src/plugins/ui_actions/public' {
|
||||
export interface TriggerContextMapping {
|
||||
[HELLO_WORLD_TRIGGER_ID]: {};
|
||||
|
@ -37,8 +41,12 @@ declare module '../../../src/plugins/ui_actions/public' {
|
|||
}
|
||||
|
||||
export class UiActionExamplesPlugin
|
||||
implements Plugin<void, void, UiActionExamplesSetupDependencies> {
|
||||
public setup(core: CoreSetup, { uiActions }: UiActionExamplesSetupDependencies) {
|
||||
implements
|
||||
Plugin<void, void, UiActionExamplesSetupDependencies, UiActionExamplesStartDependencies> {
|
||||
public setup(
|
||||
core: CoreSetup<UiActionExamplesStartDependencies>,
|
||||
{ uiActions }: UiActionExamplesSetupDependencies
|
||||
) {
|
||||
uiActions.registerTrigger(helloWorldTrigger);
|
||||
|
||||
const helloWorldAction = createHelloWorldAction(async () => ({
|
||||
|
@ -46,9 +54,10 @@ export class UiActionExamplesPlugin
|
|||
}));
|
||||
|
||||
uiActions.registerAction(helloWorldAction);
|
||||
uiActions.attachAction(helloWorldTrigger.id, helloWorldAction);
|
||||
uiActions.addTriggerAction(helloWorldTrigger.id, helloWorldAction);
|
||||
}
|
||||
|
||||
public start() {}
|
||||
public start(core: CoreStart, plugins: UiActionExamplesStartDependencies) {}
|
||||
|
||||
public stop() {}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
{}
|
||||
|
|
|
@ -79,21 +79,21 @@ export class UiActionsExplorerPlugin implements Plugin<void, void, {}, StartDeps
|
|||
|
||||
const startServices = core.getStartServices();
|
||||
|
||||
deps.uiActions.attachAction(
|
||||
deps.uiActions.addTriggerAction(
|
||||
USER_TRIGGER,
|
||||
createPhoneUserAction(async () => (await startServices)[1].uiActions)
|
||||
);
|
||||
deps.uiActions.attachAction(
|
||||
deps.uiActions.addTriggerAction(
|
||||
USER_TRIGGER,
|
||||
createEditUserAction(async () => (await startServices)[0].overlays.openModal)
|
||||
);
|
||||
|
||||
deps.uiActions.attachAction(COUNTRY_TRIGGER, viewInMapsAction);
|
||||
deps.uiActions.attachAction(COUNTRY_TRIGGER, lookUpWeatherAction);
|
||||
deps.uiActions.attachAction(COUNTRY_TRIGGER, showcasePluggability);
|
||||
deps.uiActions.attachAction(PHONE_TRIGGER, makePhoneCallAction);
|
||||
deps.uiActions.attachAction(PHONE_TRIGGER, showcasePluggability);
|
||||
deps.uiActions.attachAction(USER_TRIGGER, showcasePluggability);
|
||||
deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, viewInMapsAction);
|
||||
deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, lookUpWeatherAction);
|
||||
deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, showcasePluggability);
|
||||
deps.uiActions.addTriggerAction(PHONE_TRIGGER, makePhoneCallAction);
|
||||
deps.uiActions.addTriggerAction(PHONE_TRIGGER, showcasePluggability);
|
||||
deps.uiActions.addTriggerAction(USER_TRIGGER, showcasePluggability);
|
||||
|
||||
core.application.register({
|
||||
id: 'uiActionsExplorer',
|
||||
|
|
|
@ -91,6 +91,7 @@ export interface OverlayFlyoutStart {
|
|||
export interface OverlayFlyoutOpenOptions {
|
||||
className?: string;
|
||||
closeButtonAriaLabel?: string;
|
||||
ownFocus?: boolean;
|
||||
'data-test-subj'?: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
|
|
|
@ -39,7 +39,7 @@ export interface ClonePanelActionContext {
|
|||
export class ClonePanelAction implements ActionByType<typeof ACTION_CLONE_PANEL> {
|
||||
public readonly type = ACTION_CLONE_PANEL;
|
||||
public readonly id = ACTION_CLONE_PANEL;
|
||||
public order = 11;
|
||||
public order = 45;
|
||||
|
||||
constructor(private core: CoreStart) {}
|
||||
|
||||
|
|
|
@ -37,7 +37,7 @@ export interface ReplacePanelActionContext {
|
|||
export class ReplacePanelAction implements ActionByType<typeof ACTION_REPLACE_PANEL> {
|
||||
public readonly type = ACTION_REPLACE_PANEL;
|
||||
public readonly id = ACTION_REPLACE_PANEL;
|
||||
public order = 11;
|
||||
public order = 3;
|
||||
|
||||
constructor(
|
||||
private core: CoreStart,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -42,6 +42,7 @@ export function createFilterAction(
|
|||
return createAction<typeof ACTION_GLOBAL_APPLY_FILTER>({
|
||||
type: ACTION_GLOBAL_APPLY_FILTER,
|
||||
id: ACTION_GLOBAL_APPLY_FILTER,
|
||||
getIconType: () => 'filter',
|
||||
getDisplayName: () => {
|
||||
return i18n.translate('data.filter.applyFilterActionTitle', {
|
||||
defaultMessage: 'Apply filter to current view',
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -37,10 +37,14 @@ const indexPatternCache = createIndexPatternCache();
|
|||
|
||||
type IndexPatternCachedFieldType = 'id' | 'title';
|
||||
|
||||
export interface IndexPatternSavedObjectAttrs {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export class IndexPatternsService {
|
||||
private config: IUiSettingsClient;
|
||||
private savedObjectsClient: SavedObjectsClientContract;
|
||||
private savedObjectsCache?: Array<SimpleSavedObject<Record<string, any>>> | null;
|
||||
private savedObjectsCache?: Array<SimpleSavedObject<IndexPatternSavedObjectAttrs>> | null;
|
||||
private apiClient: IndexPatternsApiClient;
|
||||
ensureDefaultIndexPattern: EnsureDefaultIndexPattern;
|
||||
|
||||
|
@ -53,7 +57,7 @@ export class IndexPatternsService {
|
|||
|
||||
private async refreshSavedObjectsCache() {
|
||||
this.savedObjectsCache = (
|
||||
await this.savedObjectsClient.find<Record<string, any>>({
|
||||
await this.savedObjectsClient.find<IndexPatternSavedObjectAttrs>({
|
||||
type: 'index-pattern',
|
||||
fields: ['title'],
|
||||
perPage: 10000,
|
||||
|
|
|
@ -126,12 +126,12 @@ export class DataPublicPlugin implements Plugin<DataPublicPluginSetup, DataPubli
|
|||
createFilterAction(queryService.filterManager, queryService.timefilter.timefilter)
|
||||
);
|
||||
|
||||
uiActions.attachAction(
|
||||
uiActions.addTriggerAction(
|
||||
SELECT_RANGE_TRIGGER,
|
||||
selectRangeAction(queryService.filterManager, queryService.timefilter.timefilter)
|
||||
);
|
||||
|
||||
uiActions.attachAction(
|
||||
uiActions.addTriggerAction(
|
||||
VALUE_CLICK_TRIGGER,
|
||||
valueClickAction(queryService.filterManager, queryService.timefilter.timefilter)
|
||||
);
|
||||
|
@ -172,7 +172,10 @@ export class DataPublicPlugin implements Plugin<DataPublicPluginSetup, DataPubli
|
|||
});
|
||||
setSearchService(search);
|
||||
|
||||
uiActions.attachAction(APPLY_FILTER_TRIGGER, uiActions.getAction(ACTION_GLOBAL_APPLY_FILTER));
|
||||
uiActions.addTriggerAction(
|
||||
APPLY_FILTER_TRIGGER,
|
||||
uiActions.getAction(ACTION_GLOBAL_APPLY_FILTER)
|
||||
);
|
||||
|
||||
const dataServices = {
|
||||
actions: {
|
||||
|
|
|
@ -361,6 +361,7 @@ export const esFilters: {
|
|||
generateFilters: typeof generateFilters;
|
||||
onlyDisabledFiltersChanged: (newFilters?: import("../common").Filter[] | undefined, oldFilters?: import("../common").Filter[] | undefined) => boolean;
|
||||
changeTimeFilter: typeof changeTimeFilter;
|
||||
convertRangeFilterToTimeRangeString: typeof convertRangeFilterToTimeRangeString;
|
||||
mapAndFlattenFilters: (filters: import("../common").Filter[]) => import("../common").Filter[];
|
||||
extractTimeFilter: typeof extractTimeFilter;
|
||||
};
|
||||
|
@ -1783,52 +1784,53 @@ export type TSearchStrategyProvider<T extends TStrategyTypes> = (context: ISearc
|
|||
// src/plugins/data/common/es_query/filters/match_all_filter.ts:28:3 - (ae-forgotten-export) The symbol "MatchAllFilterMeta" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/common/es_query/filters/phrase_filter.ts:33:3 - (ae-forgotten-export) The symbol "PhraseFilterMeta" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/common/es_query/filters/phrases_filter.ts:31:3 - (ae-forgotten-export) The symbol "PhrasesFilterMeta" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "FilterLabel" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "FILTERS" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "getDisplayValueFromFilter" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "generateFilters" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "changeTimeFilter" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "extractTimeFilter" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:135:21 - (ae-forgotten-export) The symbol "buildEsQuery" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:135:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:135:21 - (ae-forgotten-export) The symbol "luceneStringToDsl" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:135:21 - (ae-forgotten-export) The symbol "decorateQuery" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "getRoutes" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:377:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:378:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:387:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:393:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:394:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:397:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:398:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "FilterLabel" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "FILTERS" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "getDisplayValueFromFilter" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "generateFilters" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "changeTimeFilter" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "convertRangeFilterToTimeRangeString" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "extractTimeFilter" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:137:21 - (ae-forgotten-export) The symbol "buildEsQuery" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:137:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:137:21 - (ae-forgotten-export) The symbol "luceneStringToDsl" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:137:21 - (ae-forgotten-export) The symbol "decorateQuery" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "getRoutes" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:379:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:380:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:391:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:395:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/index.ts:403:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/query/state_sync/connect_to_query_state.ts:33:33 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/query/state_sync/connect_to_query_state.ts:37:1 - (ae-forgotten-export) The symbol "QueryStateChange" needs to be exported by the entry point index.d.ts
|
||||
// src/plugins/data/public/types.ts:52:5 - (ae-forgotten-export) The symbol "createFiltersFromValueClickAction" needs to be exported by the entry point index.d.ts
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -34,7 +34,7 @@ interface ActionContext {
|
|||
export class EditPanelAction implements Action<ActionContext> {
|
||||
public readonly type = ACTION_EDIT_PANEL;
|
||||
public readonly id = ACTION_EDIT_PANEL;
|
||||
public order = 15;
|
||||
public order = 50;
|
||||
|
||||
constructor(
|
||||
private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'],
|
||||
|
|
|
@ -16,14 +16,13 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { isEqual, cloneDeep } from 'lodash';
|
||||
|
||||
import { cloneDeep, isEqual } from 'lodash';
|
||||
import * as Rx from 'rxjs';
|
||||
import { Adapters } from '../types';
|
||||
import { Adapters, ViewMode } from '../types';
|
||||
import { IContainer } from '../containers';
|
||||
import { IEmbeddable, EmbeddableInput, EmbeddableOutput } from './i_embeddable';
|
||||
import { ViewMode } from '../types';
|
||||
import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable';
|
||||
import { TriggerContextMapping } from '../ui_actions';
|
||||
import { EmbeddableActionStorage } from './embeddable_action_storage';
|
||||
|
||||
function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) {
|
||||
return input.hidePanelTitles ? '' : input.title === undefined ? output.defaultTitle : input.title;
|
||||
|
@ -33,6 +32,10 @@ export abstract class Embeddable<
|
|||
TEmbeddableInput extends EmbeddableInput = EmbeddableInput,
|
||||
TEmbeddableOutput extends EmbeddableOutput = EmbeddableOutput
|
||||
> implements IEmbeddable<TEmbeddableInput, TEmbeddableOutput> {
|
||||
static runtimeId: number = 0;
|
||||
|
||||
public readonly runtimeId = Embeddable.runtimeId++;
|
||||
|
||||
public readonly parent?: IContainer;
|
||||
public readonly isContainer: boolean = false;
|
||||
public abstract readonly type: string;
|
||||
|
@ -51,11 +54,6 @@ export abstract class Embeddable<
|
|||
// TODO: Rename to destroyed.
|
||||
private destoyed: boolean = false;
|
||||
|
||||
private __actionStorage?: EmbeddableActionStorage;
|
||||
public get actionStorage(): EmbeddableActionStorage {
|
||||
return this.__actionStorage || (this.__actionStorage = new EmbeddableActionStorage(this));
|
||||
}
|
||||
|
||||
constructor(input: TEmbeddableInput, output: TEmbeddableOutput, parent?: IContainer) {
|
||||
this.id = input.id;
|
||||
this.output = {
|
||||
|
@ -158,8 +156,10 @@ export abstract class Embeddable<
|
|||
*/
|
||||
public destroy(): void {
|
||||
this.destoyed = true;
|
||||
|
||||
this.input$.complete();
|
||||
this.output$.complete();
|
||||
|
||||
if (this.parentSubscription) {
|
||||
this.parentSubscription.unsubscribe();
|
||||
}
|
||||
|
|
|
@ -1,126 +0,0 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Embeddable } from '..';
|
||||
|
||||
/**
|
||||
* Below two interfaces are here temporarily, they will move to `ui_actions`
|
||||
* plugin once #58216 is merged.
|
||||
*/
|
||||
export interface SerializedEvent {
|
||||
eventId: string;
|
||||
triggerId: string;
|
||||
action: unknown;
|
||||
}
|
||||
export interface ActionStorage {
|
||||
create(event: SerializedEvent): Promise<void>;
|
||||
update(event: SerializedEvent): Promise<void>;
|
||||
remove(eventId: string): Promise<void>;
|
||||
read(eventId: string): Promise<SerializedEvent>;
|
||||
count(): Promise<number>;
|
||||
list(): Promise<SerializedEvent[]>;
|
||||
}
|
||||
|
||||
export class EmbeddableActionStorage implements ActionStorage {
|
||||
constructor(private readonly embbeddable: Embeddable<any, any>) {}
|
||||
|
||||
async create(event: SerializedEvent) {
|
||||
const input = this.embbeddable.getInput();
|
||||
const events = (input.events || []) as SerializedEvent[];
|
||||
const exists = !!events.find(({ eventId }) => eventId === event.eventId);
|
||||
|
||||
if (exists) {
|
||||
throw new Error(
|
||||
`[EEXIST]: Event with [eventId = ${event.eventId}] already exists on ` +
|
||||
`[embeddable.id = ${input.id}, embeddable.title = ${input.title}].`
|
||||
);
|
||||
}
|
||||
|
||||
this.embbeddable.updateInput({
|
||||
...input,
|
||||
events: [...events, event],
|
||||
});
|
||||
}
|
||||
|
||||
async update(event: SerializedEvent) {
|
||||
const input = this.embbeddable.getInput();
|
||||
const events = (input.events || []) as SerializedEvent[];
|
||||
const index = events.findIndex(({ eventId }) => eventId === event.eventId);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error(
|
||||
`[ENOENT]: Event with [eventId = ${event.eventId}] could not be ` +
|
||||
`updated as it does not exist in ` +
|
||||
`[embeddable.id = ${input.id}, embeddable.title = ${input.title}].`
|
||||
);
|
||||
}
|
||||
|
||||
this.embbeddable.updateInput({
|
||||
...input,
|
||||
events: [...events.slice(0, index), event, ...events.slice(index + 1)],
|
||||
});
|
||||
}
|
||||
|
||||
async remove(eventId: string) {
|
||||
const input = this.embbeddable.getInput();
|
||||
const events = (input.events || []) as SerializedEvent[];
|
||||
const index = events.findIndex(event => eventId === event.eventId);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error(
|
||||
`[ENOENT]: Event with [eventId = ${eventId}] could not be ` +
|
||||
`removed as it does not exist in ` +
|
||||
`[embeddable.id = ${input.id}, embeddable.title = ${input.title}].`
|
||||
);
|
||||
}
|
||||
|
||||
this.embbeddable.updateInput({
|
||||
...input,
|
||||
events: [...events.slice(0, index), ...events.slice(index + 1)],
|
||||
});
|
||||
}
|
||||
|
||||
async read(eventId: string): Promise<SerializedEvent> {
|
||||
const input = this.embbeddable.getInput();
|
||||
const events = (input.events || []) as SerializedEvent[];
|
||||
const event = events.find(ev => eventId === ev.eventId);
|
||||
|
||||
if (!event) {
|
||||
throw new Error(
|
||||
`[ENOENT]: Event with [eventId = ${eventId}] could not be found in ` +
|
||||
`[embeddable.id = ${input.id}, embeddable.title = ${input.title}].`
|
||||
);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
private __list() {
|
||||
const input = this.embbeddable.getInput();
|
||||
return (input.events || []) as SerializedEvent[];
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this.__list().length;
|
||||
}
|
||||
|
||||
async list(): Promise<SerializedEvent[]> {
|
||||
return this.__list();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -45,7 +45,7 @@ import { inspectorPluginMock } from '../../../../inspector/public/mocks';
|
|||
import { EuiBadge } from '@elastic/eui';
|
||||
import { embeddablePluginMock } from '../../mocks';
|
||||
|
||||
const actionRegistry = new Map<string, Action<object | undefined | string | number>>();
|
||||
const actionRegistry = new Map<string, Action>();
|
||||
const triggerRegistry = new Map<string, Trigger>();
|
||||
|
||||
const { setup, doStart } = embeddablePluginMock.createInstance();
|
||||
|
@ -214,13 +214,17 @@ const renderInEditModeAndOpenContextMenu = async (
|
|||
};
|
||||
|
||||
test('HelloWorldContainer in edit mode hides disabledActions', async () => {
|
||||
const action: Action = {
|
||||
const action = {
|
||||
id: 'FOO',
|
||||
type: 'FOO' as ActionType,
|
||||
getIconType: () => undefined,
|
||||
getDisplayName: () => 'foo',
|
||||
isCompatible: async () => true,
|
||||
execute: async () => {},
|
||||
order: 10,
|
||||
getHref: () => {
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
};
|
||||
const getActions = () => Promise.resolve([action]);
|
||||
|
||||
|
@ -246,13 +250,17 @@ test('HelloWorldContainer in edit mode hides disabledActions', async () => {
|
|||
});
|
||||
|
||||
test('HelloWorldContainer hides disabled badges', async () => {
|
||||
const action: Action = {
|
||||
const action = {
|
||||
id: 'BAR',
|
||||
type: 'BAR' as ActionType,
|
||||
getIconType: () => undefined,
|
||||
getDisplayName: () => 'bar',
|
||||
isCompatible: async () => true,
|
||||
execute: async () => {},
|
||||
order: 10,
|
||||
getHref: () => {
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
};
|
||||
const getActions = () => Promise.resolve([action]);
|
||||
|
||||
|
|
|
@ -25,7 +25,12 @@ import { CoreStart, OverlayStart } from '../../../../../core/public';
|
|||
import { toMountPoint } from '../../../../kibana_react/public';
|
||||
|
||||
import { Start as InspectorStartContract } from '../inspector';
|
||||
import { CONTEXT_MENU_TRIGGER, PANEL_BADGE_TRIGGER, EmbeddableContext } from '../triggers';
|
||||
import {
|
||||
CONTEXT_MENU_TRIGGER,
|
||||
PANEL_BADGE_TRIGGER,
|
||||
PANEL_NOTIFICATION_TRIGGER,
|
||||
EmbeddableContext,
|
||||
} from '../triggers';
|
||||
import { IEmbeddable } from '../embeddables/i_embeddable';
|
||||
import { ViewMode } from '../types';
|
||||
|
||||
|
@ -38,6 +43,14 @@ import { EditPanelAction } from '../actions';
|
|||
import { CustomizePanelModal } from './panel_header/panel_actions/customize_title/customize_panel_modal';
|
||||
import { EmbeddableStart } from '../../plugin';
|
||||
|
||||
const sortByOrderField = (
|
||||
{ order: orderA }: { order?: number },
|
||||
{ order: orderB }: { order?: number }
|
||||
) => (orderB || 0) - (orderA || 0);
|
||||
|
||||
const removeById = (disabledActions: string[]) => ({ id }: { id: string }) =>
|
||||
disabledActions.indexOf(id) === -1;
|
||||
|
||||
interface Props {
|
||||
embeddable: IEmbeddable<any, any>;
|
||||
getActions: UiActionsService['getTriggerCompatibleActions'];
|
||||
|
@ -58,6 +71,7 @@ interface State {
|
|||
hidePanelTitles: boolean;
|
||||
closeContextMenu: boolean;
|
||||
badges: Array<Action<EmbeddableContext>>;
|
||||
notifications: Array<Action<EmbeddableContext>>;
|
||||
}
|
||||
|
||||
export class EmbeddablePanel extends React.Component<Props, State> {
|
||||
|
@ -83,6 +97,7 @@ export class EmbeddablePanel extends React.Component<Props, State> {
|
|||
hidePanelTitles,
|
||||
closeContextMenu: false,
|
||||
badges: [],
|
||||
notifications: [],
|
||||
};
|
||||
|
||||
this.embeddableRoot = React.createRef();
|
||||
|
@ -104,6 +119,22 @@ export class EmbeddablePanel extends React.Component<Props, State> {
|
|||
});
|
||||
}
|
||||
|
||||
private async refreshNotifications() {
|
||||
let notifications = await this.props.getActions(PANEL_NOTIFICATION_TRIGGER, {
|
||||
embeddable: this.props.embeddable,
|
||||
});
|
||||
if (!this.mounted) return;
|
||||
|
||||
const { disabledActions } = this.props.embeddable.getInput();
|
||||
if (disabledActions) {
|
||||
notifications = notifications.filter(badge => disabledActions.indexOf(badge.id) === -1);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
notifications,
|
||||
});
|
||||
}
|
||||
|
||||
public UNSAFE_componentWillMount() {
|
||||
this.mounted = true;
|
||||
const { embeddable } = this.props;
|
||||
|
@ -116,6 +147,7 @@ export class EmbeddablePanel extends React.Component<Props, State> {
|
|||
});
|
||||
|
||||
this.refreshBadges();
|
||||
this.refreshNotifications();
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -127,6 +159,7 @@ export class EmbeddablePanel extends React.Component<Props, State> {
|
|||
});
|
||||
|
||||
this.refreshBadges();
|
||||
this.refreshNotifications();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -176,6 +209,7 @@ export class EmbeddablePanel extends React.Component<Props, State> {
|
|||
closeContextMenu={this.state.closeContextMenu}
|
||||
title={title}
|
||||
badges={this.state.badges}
|
||||
notifications={this.state.notifications}
|
||||
embeddable={this.props.embeddable}
|
||||
headerId={headerId}
|
||||
/>
|
||||
|
@ -202,13 +236,14 @@ export class EmbeddablePanel extends React.Component<Props, State> {
|
|||
};
|
||||
|
||||
private getActionContextMenuPanel = async () => {
|
||||
let actions = await this.props.getActions(CONTEXT_MENU_TRIGGER, {
|
||||
let regularActions = await this.props.getActions(CONTEXT_MENU_TRIGGER, {
|
||||
embeddable: this.props.embeddable,
|
||||
});
|
||||
|
||||
const { disabledActions } = this.props.embeddable.getInput();
|
||||
if (disabledActions) {
|
||||
actions = actions.filter(action => disabledActions.indexOf(action.id) === -1);
|
||||
const removeDisabledActions = removeById(disabledActions);
|
||||
regularActions = regularActions.filter(removeDisabledActions);
|
||||
}
|
||||
|
||||
const createGetUserData = (overlays: OverlayStart) =>
|
||||
|
@ -247,16 +282,10 @@ export class EmbeddablePanel extends React.Component<Props, State> {
|
|||
new EditPanelAction(this.props.getEmbeddableFactory, this.props.application),
|
||||
];
|
||||
|
||||
const sorted = actions
|
||||
.concat(extraActions)
|
||||
.sort((a: Action<EmbeddableContext>, b: Action<EmbeddableContext>) => {
|
||||
const bOrder = b.order || 0;
|
||||
const aOrder = a.order || 0;
|
||||
return bOrder - aOrder;
|
||||
});
|
||||
const sortedActions = [...regularActions, ...extraActions].sort(sortByOrderField);
|
||||
|
||||
return await buildContextMenuForActions({
|
||||
actions: sorted,
|
||||
actions: sortedActions,
|
||||
actionContext: { embeddable: this.props.embeddable },
|
||||
closeMenu: this.closeMyContextMenuPanel,
|
||||
});
|
||||
|
|
|
@ -33,15 +33,13 @@ interface ActionContext {
|
|||
export class CustomizePanelTitleAction implements Action<ActionContext> {
|
||||
public readonly type = ACTION_CUSTOMIZE_PANEL;
|
||||
public id = ACTION_CUSTOMIZE_PANEL;
|
||||
public order = 10;
|
||||
public order = 40;
|
||||
|
||||
constructor(private readonly getDataFromUser: GetUserData) {
|
||||
this.order = 10;
|
||||
}
|
||||
constructor(private readonly getDataFromUser: GetUserData) {}
|
||||
|
||||
public getDisplayName() {
|
||||
return i18n.translate('embeddableApi.customizePanel.action.displayName', {
|
||||
defaultMessage: 'Customize panel',
|
||||
defaultMessage: 'Edit panel title',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ interface ActionContext {
|
|||
export class InspectPanelAction implements Action<ActionContext> {
|
||||
public readonly type = ACTION_INSPECT_PANEL;
|
||||
public readonly id = ACTION_INSPECT_PANEL;
|
||||
public order = 10;
|
||||
public order = 20;
|
||||
|
||||
constructor(private readonly inspector: InspectorStartContract) {}
|
||||
|
||||
|
|
|
@ -41,7 +41,7 @@ function hasExpandedPanelInput(
|
|||
export class RemovePanelAction implements Action<ActionContext> {
|
||||
public readonly type = REMOVE_PANEL_ACTION;
|
||||
public readonly id = REMOVE_PANEL_ACTION;
|
||||
public order = 5;
|
||||
public order = 1;
|
||||
|
||||
constructor() {}
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ import {
|
|||
EuiIcon,
|
||||
EuiToolTip,
|
||||
EuiScreenReaderOnly,
|
||||
EuiNotificationBadge,
|
||||
} from '@elastic/eui';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
@ -38,6 +39,7 @@ export interface PanelHeaderProps {
|
|||
getActionContextMenuPanel: () => Promise<EuiContextMenuPanelDescriptor>;
|
||||
closeContextMenu: boolean;
|
||||
badges: Array<Action<EmbeddableContext>>;
|
||||
notifications: Array<Action<EmbeddableContext>>;
|
||||
embeddable: IEmbeddable;
|
||||
headerId?: string;
|
||||
}
|
||||
|
@ -56,6 +58,22 @@ function renderBadges(badges: Array<Action<EmbeddableContext>>, embeddable: IEmb
|
|||
));
|
||||
}
|
||||
|
||||
function renderNotifications(
|
||||
notifications: Array<Action<EmbeddableContext>>,
|
||||
embeddable: IEmbeddable
|
||||
) {
|
||||
return notifications.map(notification => (
|
||||
<EuiNotificationBadge
|
||||
data-test-subj={`embeddablePanelNotification-${notification.id}`}
|
||||
key={notification.id}
|
||||
style={{ marginTop: '4px', marginRight: '4px' }}
|
||||
onClick={() => notification.execute({ embeddable })}
|
||||
>
|
||||
{notification.getDisplayName({ embeddable })}
|
||||
</EuiNotificationBadge>
|
||||
));
|
||||
}
|
||||
|
||||
function renderTooltip(description: string) {
|
||||
return (
|
||||
description !== '' && (
|
||||
|
@ -88,6 +106,7 @@ export function PanelHeader({
|
|||
getActionContextMenuPanel,
|
||||
closeContextMenu,
|
||||
badges,
|
||||
notifications,
|
||||
embeddable,
|
||||
headerId,
|
||||
}: PanelHeaderProps) {
|
||||
|
@ -147,7 +166,7 @@ export function PanelHeader({
|
|||
)}
|
||||
{renderBadges(badges, embeddable)}
|
||||
</h2>
|
||||
|
||||
{renderNotifications(notifications, embeddable)}
|
||||
<PanelOptionsMenu
|
||||
isViewMode={isViewMode}
|
||||
getActionContextMenuPanel={getActionContextMenuPanel}
|
||||
|
|
|
@ -17,16 +17,16 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { Trigger } from '../../../../ui_actions/public';
|
||||
import { KibanaDatatable } from '../../../../expressions';
|
||||
import { Trigger } from '../../../../ui_actions/public';
|
||||
import { IEmbeddable } from '..';
|
||||
|
||||
export interface EmbeddableContext {
|
||||
embeddable: IEmbeddable;
|
||||
}
|
||||
|
||||
export interface ValueClickTriggerContext {
|
||||
embeddable?: IEmbeddable;
|
||||
export interface ValueClickTriggerContext<T extends IEmbeddable = IEmbeddable> {
|
||||
embeddable?: T;
|
||||
timeFieldName?: string;
|
||||
data: {
|
||||
data: Array<{
|
||||
|
@ -39,8 +39,12 @@ export interface ValueClickTriggerContext {
|
|||
};
|
||||
}
|
||||
|
||||
export interface RangeSelectTriggerContext {
|
||||
embeddable?: IEmbeddable;
|
||||
export const isValueClickTriggerContext = (
|
||||
context: ValueClickTriggerContext | RangeSelectTriggerContext
|
||||
): context is ValueClickTriggerContext => context.data && 'data' in context.data;
|
||||
|
||||
export interface RangeSelectTriggerContext<T extends IEmbeddable = IEmbeddable> {
|
||||
embeddable?: T;
|
||||
timeFieldName?: string;
|
||||
data: {
|
||||
table: KibanaDatatable;
|
||||
|
@ -49,6 +53,10 @@ export interface RangeSelectTriggerContext {
|
|||
};
|
||||
}
|
||||
|
||||
export const isRangeSelectTriggerContext = (
|
||||
context: ValueClickTriggerContext | RangeSelectTriggerContext
|
||||
): context is RangeSelectTriggerContext => context.data && 'range' in context.data;
|
||||
|
||||
export const CONTEXT_MENU_TRIGGER = 'CONTEXT_MENU_TRIGGER';
|
||||
export const contextMenuTrigger: Trigger<'CONTEXT_MENU_TRIGGER'> = {
|
||||
id: CONTEXT_MENU_TRIGGER,
|
||||
|
@ -60,5 +68,12 @@ export const PANEL_BADGE_TRIGGER = 'PANEL_BADGE_TRIGGER';
|
|||
export const panelBadgeTrigger: Trigger<'PANEL_BADGE_TRIGGER'> = {
|
||||
id: PANEL_BADGE_TRIGGER,
|
||||
title: 'Panel badges',
|
||||
description: 'Actions appear in title bar when an embeddable loads in a panel',
|
||||
description: 'Actions appear in title bar when an embeddable loads in a panel.',
|
||||
};
|
||||
|
||||
export const PANEL_NOTIFICATION_TRIGGER = 'PANEL_NOTIFICATION_TRIGGER';
|
||||
export const panelNotificationTrigger: Trigger<'PANEL_NOTIFICATION_TRIGGER'> = {
|
||||
id: PANEL_NOTIFICATION_TRIGGER,
|
||||
title: 'Panel notifications',
|
||||
description: 'Actions appear in top-right corner of a panel.',
|
||||
};
|
||||
|
|
|
@ -16,7 +16,12 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { EmbeddableStart, EmbeddableSetup } from '.';
|
||||
import {
|
||||
EmbeddableStart,
|
||||
EmbeddableSetup,
|
||||
EmbeddableSetupDependencies,
|
||||
EmbeddableStartDependencies,
|
||||
} from '.';
|
||||
import { EmbeddablePublicPlugin } from './plugin';
|
||||
import { coreMock } from '../../../core/public/mocks';
|
||||
|
||||
|
@ -45,14 +50,14 @@ const createStartContract = (): Start => {
|
|||
return startContract;
|
||||
};
|
||||
|
||||
const createInstance = () => {
|
||||
const createInstance = (setupPlugins: Partial<EmbeddableSetupDependencies> = {}) => {
|
||||
const plugin = new EmbeddablePublicPlugin({} as any);
|
||||
const setup = plugin.setup(coreMock.createSetup(), {
|
||||
uiActions: uiActionsPluginMock.createSetupContract(),
|
||||
uiActions: setupPlugins.uiActions || uiActionsPluginMock.createSetupContract(),
|
||||
});
|
||||
const doStart = () =>
|
||||
const doStart = (startPlugins: Partial<EmbeddableStartDependencies> = {}) =>
|
||||
plugin.start(coreMock.createStart(), {
|
||||
uiActions: uiActionsPluginMock.createStartContract(),
|
||||
uiActions: startPlugins.uiActions || uiActionsPluginMock.createStartContract(),
|
||||
inspector: inspectorPluginMock.createStartContract(),
|
||||
});
|
||||
return {
|
||||
|
|
|
@ -24,15 +24,58 @@ import { Comparator, Connect, StateContainer, UnboxState } from './types';
|
|||
|
||||
const { useContext, useLayoutEffect, useRef, createElement: h } = React;
|
||||
|
||||
/**
|
||||
* Returns the latest state of a state container.
|
||||
*
|
||||
* @param container State container which state to track.
|
||||
*/
|
||||
export const useContainerState = <Container extends StateContainer<any, any>>(
|
||||
container: Container
|
||||
): UnboxState<Container> => useObservable(container.state$, container.get());
|
||||
|
||||
/**
|
||||
* Apply selector to state container to extract only needed information. Will
|
||||
* re-render your component only when the section changes.
|
||||
*
|
||||
* @param container State container which state to track.
|
||||
* @param selector Function used to pick parts of state.
|
||||
* @param comparator Comparator function used to memoize previous result, to not
|
||||
* re-render React component if state did not change. By default uses
|
||||
* `fast-deep-equal` package.
|
||||
*/
|
||||
export const useContainerSelector = <Container extends StateContainer<any, any>, Result>(
|
||||
container: Container,
|
||||
selector: (state: UnboxState<Container>) => Result,
|
||||
comparator: Comparator<Result> = defaultComparator
|
||||
): Result => {
|
||||
const { state$, get } = container;
|
||||
const lastValueRef = useRef<Result>(get());
|
||||
const [value, setValue] = React.useState<Result>(() => {
|
||||
const newValue = selector(get());
|
||||
lastValueRef.current = newValue;
|
||||
return newValue;
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
const subscription = state$.subscribe((currentState: UnboxState<Container>) => {
|
||||
const newValue = selector(currentState);
|
||||
if (!comparator(lastValueRef.current, newValue)) {
|
||||
lastValueRef.current = newValue;
|
||||
setValue(newValue);
|
||||
}
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [state$, comparator]);
|
||||
return value;
|
||||
};
|
||||
|
||||
export const createStateContainerReactHelpers = <Container extends StateContainer<any, any>>() => {
|
||||
const context = React.createContext<Container>(null as any);
|
||||
|
||||
const useContainer = (): Container => useContext(context);
|
||||
|
||||
const useState = (): UnboxState<Container> => {
|
||||
const { state$, get } = useContainer();
|
||||
const value = useObservable(state$, get());
|
||||
return value;
|
||||
const container = useContainer();
|
||||
return useContainerState(container);
|
||||
};
|
||||
|
||||
const useTransitions: () => Container['transitions'] = () => useContainer().transitions;
|
||||
|
@ -41,24 +84,8 @@ export const createStateContainerReactHelpers = <Container extends StateContaine
|
|||
selector: (state: UnboxState<Container>) => Result,
|
||||
comparator: Comparator<Result> = defaultComparator
|
||||
): Result => {
|
||||
const { state$, get } = useContainer();
|
||||
const lastValueRef = useRef<Result>(get());
|
||||
const [value, setValue] = React.useState<Result>(() => {
|
||||
const newValue = selector(get());
|
||||
lastValueRef.current = newValue;
|
||||
return newValue;
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
const subscription = state$.subscribe((currentState: UnboxState<Container>) => {
|
||||
const newValue = selector(currentState);
|
||||
if (!comparator(lastValueRef.current, newValue)) {
|
||||
lastValueRef.current = newValue;
|
||||
setValue(newValue);
|
||||
}
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [state$, comparator]);
|
||||
return value;
|
||||
const container = useContainer();
|
||||
return useContainerSelector<Container, Result>(container, selector, comparator);
|
||||
};
|
||||
|
||||
const connect: Connect<UnboxState<Container>> = mapStateToProp => component => props =>
|
||||
|
|
|
@ -43,7 +43,7 @@ export interface BaseStateContainer<State extends BaseState> {
|
|||
|
||||
export interface StateContainer<
|
||||
State extends BaseState,
|
||||
PureTransitions extends object,
|
||||
PureTransitions extends object = object,
|
||||
PureSelectors extends object = {}
|
||||
> extends BaseStateContainer<State> {
|
||||
transitions: Readonly<PureTransitionsToTransitions<PureTransitions>>;
|
||||
|
|
20
src/plugins/kibana_utils/index.ts
Normal file
20
src/plugins/kibana_utils/index.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export { createStateContainer, StateContainer, of } from './common';
|
|
@ -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() {
|
||||
|
|
60
src/plugins/kibana_utils/public/ui/configurable.ts
Normal file
60
src/plugins/kibana_utils/public/ui/configurable.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { UiComponent } from '../../common/ui/ui_component';
|
||||
|
||||
/**
|
||||
* Represents something that can be configured by user using UI.
|
||||
*/
|
||||
export interface Configurable<Config extends object = object, Context = object> {
|
||||
/**
|
||||
* Create default config for this item, used when item is created for the first time.
|
||||
*/
|
||||
readonly createConfig: () => Config;
|
||||
|
||||
/**
|
||||
* Is this config valid. Used to validate user's input before saving.
|
||||
*/
|
||||
readonly isConfigValid: (config: Config) => boolean;
|
||||
|
||||
/**
|
||||
* `UiComponent` to be rendered when collecting configuration for this item.
|
||||
*/
|
||||
readonly CollectConfig: UiComponent<CollectConfigProps<Config, Context>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props provided to `CollectConfig` component on every re-render.
|
||||
*/
|
||||
export interface CollectConfigProps<Config extends object = object, Context = object> {
|
||||
/**
|
||||
* Current (latest) config of the item.
|
||||
*/
|
||||
config: Config;
|
||||
|
||||
/**
|
||||
* Callback called when user updates the config in UI.
|
||||
*/
|
||||
onConfig: (config: Config) => void;
|
||||
|
||||
/**
|
||||
* Context information about where component is being rendered.
|
||||
*/
|
||||
context: Context;
|
||||
}
|
20
src/plugins/kibana_utils/public/ui/index.ts
Normal file
20
src/plugins/kibana_utils/public/ui/index.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export * from './configurable';
|
|
@ -19,10 +19,12 @@
|
|||
|
||||
import { UiComponent } from 'src/plugins/kibana_utils/public';
|
||||
import { ActionType, ActionContextMapping } from '../types';
|
||||
import { Presentable } from '../util/presentable';
|
||||
|
||||
export type ActionByType<T extends ActionType> = Action<ActionContextMapping[T], T>;
|
||||
|
||||
export interface Action<Context = {}, T = ActionType> {
|
||||
export interface Action<Context extends {} = {}, T = ActionType>
|
||||
extends Partial<Presentable<Context>> {
|
||||
/**
|
||||
* Determined the order when there is more than one action matched to a trigger.
|
||||
* Higher numbers are displayed first.
|
||||
|
@ -63,14 +65,30 @@ export interface Action<Context = {}, T = ActionType> {
|
|||
isCompatible(context: Context): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* If this returns something truthy, this will be used as [href] attribute on a link if possible (e.g. in context menu item)
|
||||
* to support right click -> open in a new tab behavior.
|
||||
* For regular click navigation is prevented and `execute()` takes control.
|
||||
* Executes the action.
|
||||
*/
|
||||
getHref?(context: Context): Promise<string | undefined>;
|
||||
execute(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenience interface used to register an action.
|
||||
*/
|
||||
export interface ActionDefinition<Context extends object = object>
|
||||
extends Partial<Presentable<Context>> {
|
||||
/**
|
||||
* ID of the action that uniquely identifies this action in the actions registry.
|
||||
*/
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* ID of the factory for this action. Used to construct dynamic actions.
|
||||
*/
|
||||
readonly type?: ActionType;
|
||||
|
||||
/**
|
||||
* Executes the action.
|
||||
*/
|
||||
execute(context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
export type ActionContext<A> = A extends ActionDefinition<infer Context> ? Context : never;
|
||||
|
|
|
@ -1,72 +0,0 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { UiComponent } from 'src/plugins/kibana_utils/public';
|
||||
import { ActionType, ActionContextMapping } from '../types';
|
||||
|
||||
export interface ActionDefinition<T extends ActionType> {
|
||||
/**
|
||||
* Determined the order when there is more than one action matched to a trigger.
|
||||
* Higher numbers are displayed first.
|
||||
*/
|
||||
order?: number;
|
||||
|
||||
/**
|
||||
* A unique identifier for this action instance.
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* The action type is what determines the context shape.
|
||||
*/
|
||||
readonly type: T;
|
||||
|
||||
/**
|
||||
* Optional EUI icon type that can be displayed along with the title.
|
||||
*/
|
||||
getIconType?(context: ActionContextMapping[T]): string;
|
||||
|
||||
/**
|
||||
* Returns a title to be displayed to the user.
|
||||
* @param context
|
||||
*/
|
||||
getDisplayName?(context: ActionContextMapping[T]): string;
|
||||
|
||||
/**
|
||||
* `UiComponent` to render when displaying this action as a context menu item.
|
||||
* If not provided, `getDisplayName` will be used instead.
|
||||
*/
|
||||
MenuItem?: UiComponent<{ context: ActionContextMapping[T] }>;
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves to true if this action is compatible given the context,
|
||||
* otherwise resolves to false.
|
||||
*/
|
||||
isCompatible?(context: ActionContextMapping[T]): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* If this returns something truthy, this is used in addition to the `execute` method when clicked.
|
||||
*/
|
||||
getHref?(context: ActionContextMapping[T]): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Executes the action.
|
||||
*/
|
||||
execute(context: ActionContextMapping[T]): Promise<void>;
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
58
src/plugins/ui_actions/public/actions/action_internal.ts
Normal file
58
src/plugins/ui_actions/public/actions/action_internal.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Action, ActionContext as Context, ActionDefinition } from './action';
|
||||
import { Presentable } from '../util/presentable';
|
||||
import { uiToReactComponent } from '../../../kibana_react/public';
|
||||
import { ActionType } from '../types';
|
||||
|
||||
export class ActionInternal<A extends ActionDefinition = ActionDefinition>
|
||||
implements Action<Context<A>>, Presentable<Context<A>> {
|
||||
constructor(public readonly definition: A) {}
|
||||
|
||||
public readonly id: string = this.definition.id;
|
||||
public readonly type: ActionType = this.definition.type || '';
|
||||
public readonly order: number = this.definition.order || 0;
|
||||
public readonly MenuItem? = this.definition.MenuItem;
|
||||
public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined;
|
||||
|
||||
public execute(context: Context<A>) {
|
||||
return this.definition.execute(context);
|
||||
}
|
||||
|
||||
public getIconType(context: Context<A>): string | undefined {
|
||||
if (!this.definition.getIconType) return undefined;
|
||||
return this.definition.getIconType(context);
|
||||
}
|
||||
|
||||
public getDisplayName(context: Context<A>): string {
|
||||
if (!this.definition.getDisplayName) return `Action: ${this.id}`;
|
||||
return this.definition.getDisplayName(context);
|
||||
}
|
||||
|
||||
public async isCompatible(context: Context<A>): Promise<boolean> {
|
||||
if (!this.definition.isCompatible) return true;
|
||||
return await this.definition.isCompatible(context);
|
||||
}
|
||||
|
||||
public async getHref(context: Context<A>): Promise<string | undefined> {
|
||||
if (!this.definition.getHref) return undefined;
|
||||
return await this.definition.getHref(context);
|
||||
}
|
||||
}
|
|
@ -17,11 +17,19 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { ActionContextMapping } from '../types';
|
||||
import { ActionByType } from './action';
|
||||
import { ActionType } from '../types';
|
||||
import { ActionDefinition } from './action_definition';
|
||||
import { ActionDefinition } from './action';
|
||||
|
||||
export function createAction<T extends ActionType>(action: ActionDefinition<T>): ActionByType<T> {
|
||||
interface ActionDefinitionByType<T extends ActionType>
|
||||
extends Omit<ActionDefinition<ActionContextMapping[T]>, 'id'> {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function createAction<T extends ActionType>(
|
||||
action: ActionDefinitionByType<T>
|
||||
): ActionByType<T> {
|
||||
return {
|
||||
getIconType: () => undefined,
|
||||
order: 0,
|
||||
|
@ -29,5 +37,5 @@ export function createAction<T extends ActionType>(action: ActionDefinition<T>):
|
|||
isCompatible: () => Promise.resolve(true),
|
||||
getDisplayName: () => '',
|
||||
...action,
|
||||
};
|
||||
} as ActionByType<T>;
|
||||
}
|
||||
|
|
|
@ -18,5 +18,6 @@
|
|||
*/
|
||||
|
||||
export * from './action';
|
||||
export * from './action_internal';
|
||||
export * from './create_action';
|
||||
export * from './incompatible_action_error';
|
||||
|
|
|
@ -24,19 +24,25 @@ import { i18n } from '@kbn/i18n';
|
|||
import { uiToReactComponent } from '../../../kibana_react/public';
|
||||
import { Action } from '../actions';
|
||||
|
||||
export const defaultTitle = i18n.translate('uiActions.actionPanel.title', {
|
||||
defaultMessage: 'Options',
|
||||
});
|
||||
|
||||
/**
|
||||
* Transforms an array of Actions to the shape EuiContextMenuPanel expects.
|
||||
*/
|
||||
export async function buildContextMenuForActions<A>({
|
||||
export async function buildContextMenuForActions<Context extends object>({
|
||||
actions,
|
||||
actionContext,
|
||||
title = defaultTitle,
|
||||
closeMenu,
|
||||
}: {
|
||||
actions: Array<Action<A>>;
|
||||
actionContext: A;
|
||||
actions: Array<Action<Context>>;
|
||||
actionContext: Context;
|
||||
title?: string;
|
||||
closeMenu: () => void;
|
||||
}): Promise<EuiContextMenuPanelDescriptor> {
|
||||
const menuItems = await buildEuiContextMenuPanelItems<A>({
|
||||
const menuItems = await buildEuiContextMenuPanelItems<Context>({
|
||||
actions,
|
||||
actionContext,
|
||||
closeMenu,
|
||||
|
@ -44,9 +50,7 @@ export async function buildContextMenuForActions<A>({
|
|||
|
||||
return {
|
||||
id: 'mainMenu',
|
||||
title: i18n.translate('uiActions.actionPanel.title', {
|
||||
defaultMessage: 'Options',
|
||||
}),
|
||||
title,
|
||||
items: menuItems,
|
||||
};
|
||||
}
|
||||
|
@ -54,49 +58,41 @@ export async function buildContextMenuForActions<A>({
|
|||
/**
|
||||
* Transform an array of Actions into the shape needed to build an EUIContextMenu
|
||||
*/
|
||||
async function buildEuiContextMenuPanelItems<A>({
|
||||
async function buildEuiContextMenuPanelItems<Context extends object>({
|
||||
actions,
|
||||
actionContext,
|
||||
closeMenu,
|
||||
}: {
|
||||
actions: Array<Action<A>>;
|
||||
actionContext: A;
|
||||
actions: Array<Action<Context>>;
|
||||
actionContext: Context;
|
||||
closeMenu: () => void;
|
||||
}) {
|
||||
const items: EuiContextMenuPanelItemDescriptor[] = [];
|
||||
const promises = actions.map(async action => {
|
||||
const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length);
|
||||
const promises = actions.map(async (action, index) => {
|
||||
const isCompatible = await action.isCompatible(actionContext);
|
||||
if (!isCompatible) {
|
||||
return;
|
||||
}
|
||||
|
||||
items.push(
|
||||
await convertPanelActionToContextMenuItem({
|
||||
action,
|
||||
actionContext,
|
||||
closeMenu,
|
||||
})
|
||||
);
|
||||
items[index] = await convertPanelActionToContextMenuItem({
|
||||
action,
|
||||
actionContext,
|
||||
closeMenu,
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
return items;
|
||||
return items.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {ContextMenuAction} action
|
||||
* @param {Embeddable} embeddable
|
||||
* @return {Promise<EuiContextMenuPanelItemDescriptor>}
|
||||
*/
|
||||
async function convertPanelActionToContextMenuItem<A>({
|
||||
async function convertPanelActionToContextMenuItem<Context extends object>({
|
||||
action,
|
||||
actionContext,
|
||||
closeMenu,
|
||||
}: {
|
||||
action: Action<A>;
|
||||
actionContext: A;
|
||||
action: Action<Context>;
|
||||
actionContext: Context;
|
||||
closeMenu: () => void;
|
||||
}): Promise<EuiContextMenuPanelItemDescriptor> {
|
||||
const menuPanelItem: EuiContextMenuPanelItemDescriptor = {
|
||||
|
|
|
@ -149,7 +149,11 @@ export function openContextMenu(
|
|||
anchorPosition="downRight"
|
||||
withTitle
|
||||
>
|
||||
<EuiContextMenu initialPanelId="mainMenu" panels={panels} />
|
||||
<EuiContextMenu
|
||||
initialPanelId="mainMenu"
|
||||
panels={panels}
|
||||
data-test-subj={props['data-test-subj']}
|
||||
/>
|
||||
</EuiPopover>,
|
||||
container
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -28,10 +28,12 @@ export type Start = jest.Mocked<UiActionsStart>;
|
|||
|
||||
const createSetupContract = (): Setup => {
|
||||
const setupContract: Setup = {
|
||||
addTriggerAction: jest.fn(),
|
||||
attachAction: jest.fn(),
|
||||
detachAction: jest.fn(),
|
||||
registerAction: jest.fn(),
|
||||
registerTrigger: jest.fn(),
|
||||
unregisterAction: jest.fn(),
|
||||
};
|
||||
return setupContract;
|
||||
};
|
||||
|
@ -39,16 +41,18 @@ const createSetupContract = (): Setup => {
|
|||
const createStartContract = (): Start => {
|
||||
const startContract: Start = {
|
||||
attachAction: jest.fn(),
|
||||
registerAction: jest.fn(),
|
||||
registerTrigger: jest.fn(),
|
||||
getAction: jest.fn(),
|
||||
unregisterAction: jest.fn(),
|
||||
addTriggerAction: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
detachAction: jest.fn(),
|
||||
executeTriggerActions: jest.fn(),
|
||||
fork: jest.fn(),
|
||||
getAction: jest.fn(),
|
||||
getTrigger: jest.fn(),
|
||||
getTriggerActions: jest.fn((id: TriggerId) => []),
|
||||
getTriggerCompatibleActions: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
fork: jest.fn(),
|
||||
registerAction: jest.fn(),
|
||||
registerTrigger: jest.fn(),
|
||||
};
|
||||
|
||||
return startContract;
|
||||
|
|
|
@ -23,7 +23,12 @@ import { selectRangeTrigger, valueClickTrigger, applyFilterTrigger } from './tri
|
|||
|
||||
export type UiActionsSetup = Pick<
|
||||
UiActionsService,
|
||||
'attachAction' | 'detachAction' | 'registerAction' | 'registerTrigger'
|
||||
| 'addTriggerAction'
|
||||
| 'attachAction'
|
||||
| 'detachAction'
|
||||
| 'registerAction'
|
||||
| 'registerTrigger'
|
||||
| 'unregisterAction'
|
||||
>;
|
||||
|
||||
export type UiActionsStart = PublicMethodsOf<UiActionsService>;
|
||||
|
|
|
@ -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].'
|
||||
);
|
||||
});
|
||||
|
|
|
@ -23,9 +23,8 @@ import {
|
|||
TriggerToActionsRegistry,
|
||||
TriggerId,
|
||||
TriggerContextMapping,
|
||||
ActionType,
|
||||
} from '../types';
|
||||
import { Action, ActionByType } from '../actions';
|
||||
import { ActionInternal, Action, ActionDefinition, ActionContext } from '../actions';
|
||||
import { Trigger, TriggerContext } from '../triggers/trigger';
|
||||
import { TriggerInternal } from '../triggers/trigger_internal';
|
||||
import { TriggerContract } from '../triggers/trigger_contract';
|
||||
|
@ -76,49 +75,41 @@ export class UiActionsService {
|
|||
return trigger.contract;
|
||||
};
|
||||
|
||||
public readonly registerAction = <T extends ActionType>(action: ActionByType<T>) => {
|
||||
if (this.actions.has(action.id)) {
|
||||
throw new Error(`Action [action.id = ${action.id}] already registered.`);
|
||||
public readonly registerAction = <A extends ActionDefinition>(
|
||||
definition: A
|
||||
): Action<ActionContext<A>> => {
|
||||
if (this.actions.has(definition.id)) {
|
||||
throw new Error(`Action [action.id = ${definition.id}] already registered.`);
|
||||
}
|
||||
|
||||
const action = new ActionInternal(definition);
|
||||
|
||||
this.actions.set(action.id, action);
|
||||
|
||||
return action;
|
||||
};
|
||||
|
||||
public readonly getAction = <T extends ActionType>(id: string): ActionByType<T> => {
|
||||
if (!this.actions.has(id)) {
|
||||
throw new Error(`Action [action.id = ${id}] not registered.`);
|
||||
public readonly unregisterAction = (actionId: string): void => {
|
||||
if (!this.actions.has(actionId)) {
|
||||
throw new Error(`Action [action.id = ${actionId}] is not registered.`);
|
||||
}
|
||||
|
||||
return this.actions.get(id) as ActionByType<T>;
|
||||
this.actions.delete(actionId);
|
||||
};
|
||||
|
||||
public readonly attachAction = <TType extends TriggerId, AType extends ActionType>(
|
||||
triggerId: TType,
|
||||
// The action can accept partial or no context, but if it needs context not provided
|
||||
// by this type of trigger, typescript will complain. yay!
|
||||
action: ActionByType<AType> & Action<TriggerContextMapping[TType]>
|
||||
): void => {
|
||||
if (!this.actions.has(action.id)) {
|
||||
this.registerAction(action);
|
||||
} else {
|
||||
const registeredAction = this.actions.get(action.id);
|
||||
if (registeredAction !== action) {
|
||||
throw new Error(`A different action instance with this id is already registered.`);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly attachAction = <T extends TriggerId>(triggerId: T, actionId: string): void => {
|
||||
const trigger = this.triggers.get(triggerId);
|
||||
|
||||
if (!trigger) {
|
||||
throw new Error(
|
||||
`No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${action.id}].`
|
||||
`No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${actionId}].`
|
||||
);
|
||||
}
|
||||
|
||||
const actionIds = this.triggerToActions.get(triggerId);
|
||||
|
||||
if (!actionIds!.find(id => id === action.id)) {
|
||||
this.triggerToActions.set(triggerId, [...actionIds!, action.id]);
|
||||
if (!actionIds!.find(id => id === actionId)) {
|
||||
this.triggerToActions.set(triggerId, [...actionIds!, actionId]);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -139,6 +130,32 @@ export class UiActionsService {
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* `addTriggerAction` is similar to `attachAction` as it attaches action to a
|
||||
* trigger, but it also registers the action, if it has not been registered, yet.
|
||||
*
|
||||
* `addTriggerAction` also infers better typing of the `action` argument.
|
||||
*/
|
||||
public readonly addTriggerAction = <T extends TriggerId>(
|
||||
triggerId: T,
|
||||
// The action can accept partial or no context, but if it needs context not provided
|
||||
// by this type of trigger, typescript will complain. yay!
|
||||
action: Action<TriggerContextMapping[T]>
|
||||
): void => {
|
||||
if (!this.actions.has(action.id)) this.registerAction(action);
|
||||
this.attachAction(triggerId, action.id);
|
||||
};
|
||||
|
||||
public readonly getAction = <T extends ActionDefinition>(
|
||||
id: string
|
||||
): Action<ActionContext<T>> => {
|
||||
if (!this.actions.has(id)) {
|
||||
throw new Error(`Action [action.id = ${id}] not registered.`);
|
||||
}
|
||||
|
||||
return this.actions.get(id) as ActionInternal<T>;
|
||||
};
|
||||
|
||||
public readonly getTriggerActions = <T extends TriggerId>(
|
||||
triggerId: T
|
||||
): Array<Action<TriggerContextMapping[T]>> => {
|
||||
|
@ -147,9 +164,9 @@ export class UiActionsService {
|
|||
|
||||
const actionIds = this.triggerToActions.get(triggerId);
|
||||
|
||||
const actions = actionIds!.map(actionId => this.actions.get(actionId)).filter(Boolean) as Array<
|
||||
Action<TriggerContextMapping[T]>
|
||||
>;
|
||||
const actions = actionIds!
|
||||
.map(actionId => this.actions.get(actionId) as ActionInternal)
|
||||
.filter(Boolean);
|
||||
|
||||
return actions as Array<Action<TriggerContext<T>>>;
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -16,4 +16,5 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export { createHelloWorldAction } from './hello_world_action';
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
|
|
|
@ -65,8 +65,11 @@ export class TriggerInternal<T extends TriggerId> {
|
|||
const panel = await buildContextMenuForActions({
|
||||
actions,
|
||||
actionContext: context,
|
||||
title: this.trigger.title,
|
||||
closeMenu: () => session.close(),
|
||||
});
|
||||
const session = openContextMenu([panel]);
|
||||
const session = openContextMenu([panel], {
|
||||
'data-test-subj': 'multipleActionsContextMenu',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { ActionByType } from './actions/action';
|
||||
import { ActionInternal } from './actions/action_internal';
|
||||
import { TriggerInternal } from './triggers/trigger_internal';
|
||||
import { Filter } from '../../data/public';
|
||||
import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers';
|
||||
|
@ -25,7 +25,7 @@ import { IEmbeddable } from '../../embeddable/public';
|
|||
import { RangeSelectTriggerContext, ValueClickTriggerContext } from '../../embeddable/public';
|
||||
|
||||
export type TriggerRegistry = Map<TriggerId, TriggerInternal<any>>;
|
||||
export type ActionRegistry = Map<string, ActionByType<any>>;
|
||||
export type ActionRegistry = Map<string, ActionInternal>;
|
||||
export type TriggerToActionsRegistry = Map<TriggerId, string[]>;
|
||||
|
||||
const DEFAULT_TRIGGER = '';
|
||||
|
|
20
src/plugins/ui_actions/public/util/index.ts
Normal file
20
src/plugins/ui_actions/public/util/index.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export * from './presentable';
|
65
src/plugins/ui_actions/public/util/presentable.ts
Normal file
65
src/plugins/ui_actions/public/util/presentable.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { UiComponent } from 'src/plugins/kibana_utils/public';
|
||||
|
||||
/**
|
||||
* Represents something that can be displayed to user in UI.
|
||||
*/
|
||||
export interface Presentable<Context extends object = object> {
|
||||
/**
|
||||
* ID that uniquely identifies this object.
|
||||
*/
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* Determines the display order in relation to other items. Higher numbers are
|
||||
* displayed first.
|
||||
*/
|
||||
readonly order: number;
|
||||
|
||||
/**
|
||||
* `UiComponent` to render when displaying this entity as a context menu item.
|
||||
* If not provided, `getDisplayName` will be used instead.
|
||||
*/
|
||||
readonly MenuItem?: UiComponent<{ context: Context }>;
|
||||
|
||||
/**
|
||||
* Optional EUI icon type that can be displayed along with the title.
|
||||
*/
|
||||
getIconType(context: Context): string | undefined;
|
||||
|
||||
/**
|
||||
* Returns a title to be displayed to the user.
|
||||
*/
|
||||
getDisplayName(context: Context): string;
|
||||
|
||||
/**
|
||||
* This method should return a link if this item can be clicked on. The link
|
||||
* is used to navigate user if user middle-clicks it or Ctrl + clicks or
|
||||
* right-clicks and selects "Open in new tab".
|
||||
*/
|
||||
getHref?(context: Context): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves to true if this item is compatible given
|
||||
* the context and should be displayed to user, otherwise resolves to false.
|
||||
*/
|
||||
isCompatible(context: Context): Promise<boolean>;
|
||||
}
|
|
@ -265,6 +265,7 @@ export class VisualizeEmbeddable extends Embeddable<VisualizeInput, VisualizeOut
|
|||
timeFieldName: this.vis.data.indexPattern!.timeFieldName!,
|
||||
data: event.data,
|
||||
};
|
||||
|
||||
getUiActions()
|
||||
.getTrigger(triggerId)
|
||||
.exec(context);
|
||||
|
|
|
@ -104,16 +104,21 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide
|
|||
|
||||
public async getDashboardIdFromCurrentUrl() {
|
||||
const currentUrl = await browser.getCurrentUrl();
|
||||
const urlSubstring = 'kibana#/dashboard/';
|
||||
const startOfIdIndex = currentUrl.indexOf(urlSubstring) + urlSubstring.length;
|
||||
const endIndex = currentUrl.indexOf('?');
|
||||
const id = currentUrl.substring(startOfIdIndex, endIndex < 0 ? currentUrl.length : endIndex);
|
||||
const id = this.getDashboardIdFromUrl(currentUrl);
|
||||
|
||||
log.debug(`Dashboard id extracted from ${currentUrl} is ${id}`);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
public getDashboardIdFromUrl(url: string) {
|
||||
const urlSubstring = 'kibana#/dashboard/';
|
||||
const startOfIdIndex = url.indexOf(urlSubstring) + urlSubstring.length;
|
||||
const endIndex = url.indexOf('?');
|
||||
const id = url.substring(startOfIdIndex, endIndex < 0 ? url.length : endIndex);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if already on the dashboard landing page (that page doesn't have a link to itself).
|
||||
* @returns {Promise<boolean>}
|
||||
|
@ -512,6 +517,20 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide
|
|||
|
||||
return checkList.filter(viz => viz.isPresent === false).map(viz => viz.name);
|
||||
}
|
||||
|
||||
public async getPanelDrilldownCount(panelIndex = 0): Promise<number> {
|
||||
log.debug('getPanelDrilldownCount');
|
||||
const panel = (await this.getDashboardPanels())[panelIndex];
|
||||
try {
|
||||
const count = await panel.findByTestSubject(
|
||||
'embeddablePanelNotification-ACTION_PANEL_NOTIFICATIONS'
|
||||
);
|
||||
return Number.parseInt(await count.getVisibleText(), 10);
|
||||
} catch (e) {
|
||||
// if not found then this is 0 (we don't show badge with 0)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new DashboardPage();
|
||||
|
|
|
@ -27,14 +27,10 @@ export class SampelPanelActionTestPlugin
|
|||
implements Plugin<SampelPanelActionTestPluginSetup, SampelPanelActionTestPluginStart> {
|
||||
public setup(core: CoreSetup, { uiActions }: { uiActions: UiActionsSetup }) {
|
||||
const samplePanelAction = createSamplePanelAction(core.getStartServices);
|
||||
|
||||
uiActions.registerAction(samplePanelAction);
|
||||
uiActions.attachAction(CONTEXT_MENU_TRIGGER, samplePanelAction);
|
||||
|
||||
const samplePanelLink = createSamplePanelLink();
|
||||
|
||||
uiActions.registerAction(samplePanelLink);
|
||||
uiActions.attachAction(CONTEXT_MENU_TRIGGER, samplePanelLink);
|
||||
uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, samplePanelAction);
|
||||
uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, samplePanelLink);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
Once you click "Create drilldown" you should be able to see drilldowns added by
|
||||
this sample plugin.
|
||||
|
||||

|
||||
|
||||
|
||||
### `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.
|
||||
|
|
|
@ -5,6 +5,6 @@
|
|||
"configPath": ["ui_actions_enhanced_examples"],
|
||||
"server": false,
|
||||
"ui": true,
|
||||
"requiredPlugins": ["uiActions", "data"],
|
||||
"requiredPlugins": ["advancedUiActions", "data"],
|
||||
"optionalPlugins": []
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
This folder contains a one-file example of the most basic drilldown implementation.
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiFormRow, EuiFieldText } from '@elastic/eui';
|
||||
import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public';
|
||||
import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/advanced_ui_actions/public';
|
||||
import {
|
||||
RangeSelectTriggerContext,
|
||||
ValueClickTriggerContext,
|
||||
} from '../../../../../src/plugins/embeddable/public';
|
||||
import { CollectConfigProps } from '../../../../../src/plugins/kibana_utils/public';
|
||||
|
||||
export type ActionContext = RangeSelectTriggerContext | ValueClickTriggerContext;
|
||||
|
||||
export interface Config {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN = 'SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN';
|
||||
|
||||
export class DashboardHelloWorldDrilldown implements Drilldown<Config, ActionContext> {
|
||||
public readonly id = SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN;
|
||||
|
||||
public readonly order = 6;
|
||||
|
||||
public readonly getDisplayName = () => 'Say hello drilldown';
|
||||
|
||||
public readonly euiIcon = 'cheer';
|
||||
|
||||
private readonly ReactCollectConfig: React.FC<CollectConfigProps<Config>> = ({
|
||||
config,
|
||||
onConfig,
|
||||
}) => (
|
||||
<EuiFormRow label="Enter your name" fullWidth>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
value={config.name}
|
||||
onChange={event => onConfig({ ...config, name: event.target.value })}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
|
||||
public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig);
|
||||
|
||||
public readonly createConfig = () => ({
|
||||
name: '',
|
||||
});
|
||||
|
||||
public readonly isConfigValid = (config: Config): config is Config => {
|
||||
return !!config.name;
|
||||
};
|
||||
|
||||
public readonly execute = async (config: Config, context: ActionContext) => {
|
||||
alert(`Hello, ${config.name}`);
|
||||
};
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import useMountedState from 'react-use/lib/useMountedState';
|
||||
import { CollectConfigProps } from './types';
|
||||
import { DiscoverDrilldownConfig, IndexPatternItem } from './components/discover_drilldown_config';
|
||||
import { Params } from './drilldown';
|
||||
|
||||
export interface CollectConfigContainerProps extends CollectConfigProps {
|
||||
params: Params;
|
||||
}
|
||||
|
||||
export const CollectConfigContainer: React.FC<CollectConfigContainerProps> = ({
|
||||
config,
|
||||
onConfig,
|
||||
params: { start },
|
||||
}) => {
|
||||
const isMounted = useMountedState();
|
||||
const [indexPatterns, setIndexPatterns] = useState<IndexPatternItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const indexPatternSavedObjects = await start().plugins.data.indexPatterns.getCache();
|
||||
if (!isMounted()) return;
|
||||
setIndexPatterns(
|
||||
indexPatternSavedObjects
|
||||
? indexPatternSavedObjects.map(indexPattern => ({
|
||||
id: indexPattern.id,
|
||||
title: indexPattern.attributes.title,
|
||||
}))
|
||||
: []
|
||||
);
|
||||
})();
|
||||
}, [isMounted, start]);
|
||||
|
||||
return (
|
||||
<DiscoverDrilldownConfig
|
||||
activeIndexPatternId={config.indexPatternId}
|
||||
indexPatterns={indexPatterns}
|
||||
onIndexPatternSelect={indexPatternId => {
|
||||
onConfig({ ...config, indexPatternId });
|
||||
}}
|
||||
customIndexPattern={config.customIndexPattern}
|
||||
onCustomIndexPatternToggle={() =>
|
||||
onConfig({
|
||||
...config,
|
||||
customIndexPattern: !config.customIndexPattern,
|
||||
indexPatternId: undefined,
|
||||
})
|
||||
}
|
||||
carryFiltersAndQuery={config.carryFiltersAndQuery}
|
||||
onCarryFiltersAndQueryToggle={() =>
|
||||
onConfig({
|
||||
...config,
|
||||
carryFiltersAndQuery: !config.carryFiltersAndQuery,
|
||||
})
|
||||
}
|
||||
carryTimeRange={config.carryTimeRange}
|
||||
onCarryTimeRangeToggle={() =>
|
||||
onConfig({
|
||||
...config,
|
||||
carryTimeRange: !config.carryTimeRange,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiFormRow, EuiSelect, EuiSwitch, EuiSpacer, EuiCallOut } from '@elastic/eui';
|
||||
import { txtChooseDestinationIndexPattern } from './i18n';
|
||||
|
||||
export interface IndexPatternItem {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface DiscoverDrilldownConfigProps {
|
||||
activeIndexPatternId?: string;
|
||||
indexPatterns: IndexPatternItem[];
|
||||
onIndexPatternSelect: (indexPatternId: string) => void;
|
||||
customIndexPattern?: boolean;
|
||||
onCustomIndexPatternToggle?: () => void;
|
||||
carryFiltersAndQuery?: boolean;
|
||||
onCarryFiltersAndQueryToggle?: () => void;
|
||||
carryTimeRange?: boolean;
|
||||
onCarryTimeRangeToggle?: () => void;
|
||||
}
|
||||
|
||||
export const DiscoverDrilldownConfig: React.FC<DiscoverDrilldownConfigProps> = ({
|
||||
activeIndexPatternId,
|
||||
indexPatterns,
|
||||
onIndexPatternSelect,
|
||||
customIndexPattern,
|
||||
onCustomIndexPatternToggle,
|
||||
carryFiltersAndQuery,
|
||||
onCarryFiltersAndQueryToggle,
|
||||
carryTimeRange,
|
||||
onCarryTimeRangeToggle,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<EuiCallOut title="Example warning!" color="warning" iconType="help">
|
||||
<p>
|
||||
This is an example drilldown. It is meant as a starting point for developers, so they can
|
||||
grab this code and get started. It does not provide a complete working functionality but
|
||||
serves as a getting started example.
|
||||
</p>
|
||||
<p>
|
||||
Implementation of the actual <em>Go to Discover</em> drilldown is tracked in{' '}
|
||||
<a href="https://github.com/elastic/kibana/issues/60227">#60227</a>
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="xl" />
|
||||
{!!onCustomIndexPatternToggle && (
|
||||
<>
|
||||
<EuiFormRow hasChildLabel={false}>
|
||||
<EuiSwitch
|
||||
name="customIndexPattern"
|
||||
label="Use custom index pattern"
|
||||
checked={!!customIndexPattern}
|
||||
onChange={onCustomIndexPatternToggle}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
{!!customIndexPattern && (
|
||||
<EuiFormRow fullWidth label={txtChooseDestinationIndexPattern}>
|
||||
<EuiSelect
|
||||
name="selectDashboard"
|
||||
hasNoInitialSelection={true}
|
||||
fullWidth
|
||||
options={[
|
||||
{ id: '', text: 'Pick one...' },
|
||||
...indexPatterns.map(({ id, title }) => ({ value: id, text: title })),
|
||||
]}
|
||||
value={activeIndexPatternId || ''}
|
||||
onChange={e => onIndexPatternSelect(e.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
<EuiSpacer size="xl" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!onCarryFiltersAndQueryToggle && (
|
||||
<EuiFormRow hasChildLabel={false}>
|
||||
<EuiSwitch
|
||||
name="carryFiltersAndQuery"
|
||||
label="Carry over filters and query"
|
||||
checked={!!carryFiltersAndQuery}
|
||||
onChange={onCarryFiltersAndQueryToggle}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
{!!onCarryTimeRangeToggle && (
|
||||
<EuiFormRow hasChildLabel={false}>
|
||||
<EuiSwitch
|
||||
name="carryTimeRange"
|
||||
label="Carry over time range"
|
||||
checked={!!carryTimeRange}
|
||||
onChange={onCarryTimeRangeToggle}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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';
|
|
@ -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';
|
|
@ -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';
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { StartDependencies as Start } from '../plugin';
|
||||
import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public';
|
||||
import { StartServicesGetter } from '../../../../../src/plugins/kibana_utils/public';
|
||||
import { ActionContext, Config, CollectConfigProps } from './types';
|
||||
import { CollectConfigContainer } from './collect_config_container';
|
||||
import { SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN } from './constants';
|
||||
import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/advanced_ui_actions/public';
|
||||
import { txtGoToDiscover } from './i18n';
|
||||
|
||||
const isOutputWithIndexPatterns = (
|
||||
output: unknown
|
||||
): output is { indexPatterns: Array<{ id: string }> } => {
|
||||
if (!output || typeof output !== 'object') return false;
|
||||
return Array.isArray((output as any).indexPatterns);
|
||||
};
|
||||
|
||||
export interface Params {
|
||||
start: StartServicesGetter<Pick<Start, 'data'>>;
|
||||
}
|
||||
|
||||
export class DashboardToDiscoverDrilldown implements Drilldown<Config, ActionContext> {
|
||||
constructor(protected readonly params: Params) {}
|
||||
|
||||
public readonly id = SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN;
|
||||
|
||||
public readonly order = 10;
|
||||
|
||||
public readonly getDisplayName = () => txtGoToDiscover;
|
||||
|
||||
public readonly euiIcon = 'discoverApp';
|
||||
|
||||
private readonly ReactCollectConfig: React.FC<CollectConfigProps> = props => (
|
||||
<CollectConfigContainer {...props} params={this.params} />
|
||||
);
|
||||
|
||||
public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig);
|
||||
|
||||
public readonly createConfig = () => ({
|
||||
customIndexPattern: false,
|
||||
carryFiltersAndQuery: true,
|
||||
carryTimeRange: true,
|
||||
});
|
||||
|
||||
public readonly isConfigValid = (config: Config): config is Config => {
|
||||
if (config.customIndexPattern && !config.indexPatternId) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
private readonly getPath = async (config: Config, context: ActionContext): Promise<string> => {
|
||||
let indexPatternId =
|
||||
!!config.customIndexPattern && !!config.indexPatternId ? config.indexPatternId : '';
|
||||
|
||||
if (!indexPatternId && !!context.embeddable) {
|
||||
const output = context.embeddable!.getOutput();
|
||||
if (isOutputWithIndexPatterns(output) && output.indexPatterns.length > 0) {
|
||||
indexPatternId = output.indexPatterns[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
const index = indexPatternId ? `,index:'${indexPatternId}'` : '';
|
||||
return `#/discover?_g=(filters:!(),refreshInterval:(pause:!f,value:900000),time:(from:now-7d,to:now))&_a=(columns:!(_source),filters:!()${index},interval:auto,query:(language:kuery,query:''),sort:!())`;
|
||||
};
|
||||
|
||||
public readonly getHref = async (config: Config, context: ActionContext): Promise<string> => {
|
||||
return `kibana${await this.getPath(config, context)}`;
|
||||
};
|
||||
|
||||
public readonly execute = async (config: Config, context: ActionContext) => {
|
||||
const path = await this.getPath(config, context);
|
||||
|
||||
await this.params.start().core.application.navigateToApp('kibana', {
|
||||
path,
|
||||
});
|
||||
};
|
||||
}
|
|
@ -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)',
|
||||
});
|
|
@ -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';
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import {
|
||||
RangeSelectTriggerContext,
|
||||
ValueClickTriggerContext,
|
||||
} from '../../../../../src/plugins/embeddable/public';
|
||||
import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public';
|
||||
|
||||
export type ActionContext = RangeSelectTriggerContext | ValueClickTriggerContext;
|
||||
|
||||
export interface Config {
|
||||
/**
|
||||
* Whether to use a user selected index pattern, stored in `indexPatternId` field.
|
||||
*/
|
||||
customIndexPattern: boolean;
|
||||
|
||||
/**
|
||||
* ID of index pattern picked by user in UI. If not set, drilldown will use
|
||||
* the index pattern of the visualization.
|
||||
*/
|
||||
indexPatternId?: string;
|
||||
|
||||
/**
|
||||
* Whether to carry over source dashboard filters and query.
|
||||
*/
|
||||
carryFiltersAndQuery: boolean;
|
||||
|
||||
/**
|
||||
* Whether to carry over source dashboard time range.
|
||||
*/
|
||||
carryTimeRange: boolean;
|
||||
}
|
||||
|
||||
export type CollectConfigProps = CollectConfigPropsBase<Config>;
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiFormRow, EuiSwitch, EuiFieldText, EuiCallOut, EuiSpacer } from '@elastic/eui';
|
||||
import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public';
|
||||
import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/advanced_ui_actions/public';
|
||||
import {
|
||||
RangeSelectTriggerContext,
|
||||
ValueClickTriggerContext,
|
||||
} from '../../../../../src/plugins/embeddable/public';
|
||||
import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public';
|
||||
|
||||
function isValidUrl(url: string) {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type ActionContext = RangeSelectTriggerContext | ValueClickTriggerContext;
|
||||
|
||||
export interface Config {
|
||||
url: string;
|
||||
openInNewTab: boolean;
|
||||
}
|
||||
|
||||
export type CollectConfigProps = CollectConfigPropsBase<Config>;
|
||||
|
||||
const SAMPLE_DASHBOARD_TO_URL_DRILLDOWN = 'SAMPLE_DASHBOARD_TO_URL_DRILLDOWN';
|
||||
|
||||
export class DashboardToUrlDrilldown implements Drilldown<Config, ActionContext> {
|
||||
public readonly id = SAMPLE_DASHBOARD_TO_URL_DRILLDOWN;
|
||||
|
||||
public readonly order = 8;
|
||||
|
||||
public readonly getDisplayName = () => 'Go to URL (example)';
|
||||
|
||||
public readonly euiIcon = 'link';
|
||||
|
||||
private readonly ReactCollectConfig: React.FC<CollectConfigProps> = ({ config, onConfig }) => (
|
||||
<>
|
||||
<EuiCallOut title="Example warning!" color="warning" iconType="help">
|
||||
<p>
|
||||
This is an example drilldown. It is meant as a starting point for developers, so they can
|
||||
grab this code and get started. It does not provide a complete working functionality but
|
||||
serves as a getting started example.
|
||||
</p>
|
||||
<p>
|
||||
Implementation of the actual <em>Go to URL</em> drilldown is tracked in{' '}
|
||||
<a href="https://github.com/elastic/kibana/issues/55324">#55324</a>
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer size="xl" />
|
||||
<EuiFormRow label="Enter target URL" fullWidth>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
name="url"
|
||||
placeholder="Enter URL"
|
||||
value={config.url}
|
||||
onChange={event => onConfig({ ...config, url: event.target.value })}
|
||||
onBlur={() => {
|
||||
if (!config.url) return;
|
||||
if (/https?:\/\//.test(config.url)) return;
|
||||
onConfig({ ...config, url: 'https://' + config.url });
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow hasChildLabel={false}>
|
||||
<EuiSwitch
|
||||
name="openInNewTab"
|
||||
label="Open in new tab?"
|
||||
checked={config.openInNewTab}
|
||||
onChange={() => onConfig({ ...config, openInNewTab: !config.openInNewTab })}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
);
|
||||
|
||||
public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig);
|
||||
|
||||
public readonly createConfig = () => ({
|
||||
url: '',
|
||||
openInNewTab: false,
|
||||
});
|
||||
|
||||
public readonly isConfigValid = (config: Config): config is Config => {
|
||||
if (!config.url) return false;
|
||||
return isValidUrl(config.url);
|
||||
};
|
||||
|
||||
/**
|
||||
* `getHref` is need to support mouse middle-click and Cmd + Click behavior
|
||||
* to open a link in new tab.
|
||||
*/
|
||||
public readonly getHref = async (config: Config, context: ActionContext) => {
|
||||
return config.url;
|
||||
};
|
||||
|
||||
public readonly execute = async (config: Config, context: ActionContext) => {
|
||||
const url = await this.getHref(config, context);
|
||||
|
||||
if (config.openInNewTab) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
window.location.href = url;
|
||||
}
|
||||
};
|
||||
}
|
|
@ -5,24 +5,37 @@
|
|||
*/
|
||||
|
||||
import { Plugin, CoreSetup, CoreStart } from '../../../../src/core/public';
|
||||
import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public';
|
||||
import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public';
|
||||
import {
|
||||
AdvancedUiActionsSetup,
|
||||
AdvancedUiActionsStart,
|
||||
} from '../../../../x-pack/plugins/advanced_ui_actions/public';
|
||||
import { DashboardHelloWorldDrilldown } from './dashboard_hello_world_drilldown';
|
||||
import { DashboardToUrlDrilldown } from './dashboard_to_url_drilldown';
|
||||
import { DashboardToDiscoverDrilldown } from './dashboard_to_discover_drilldown';
|
||||
import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public';
|
||||
|
||||
export interface SetupDependencies {
|
||||
data: DataPublicPluginSetup;
|
||||
uiActions: UiActionsSetup;
|
||||
advancedUiActions: AdvancedUiActionsSetup;
|
||||
}
|
||||
|
||||
export interface StartDependencies {
|
||||
data: DataPublicPluginStart;
|
||||
uiActions: UiActionsStart;
|
||||
advancedUiActions: AdvancedUiActionsStart;
|
||||
}
|
||||
|
||||
export class UiActionsEnhancedExamplesPlugin
|
||||
implements Plugin<void, void, SetupDependencies, StartDependencies> {
|
||||
public setup(core: CoreSetup<StartDependencies>, plugins: SetupDependencies) {
|
||||
// eslint-disable-next-line
|
||||
console.log('ui_actions_enhanced_examples');
|
||||
public setup(
|
||||
core: CoreSetup<StartDependencies>,
|
||||
{ advancedUiActions: uiActions }: SetupDependencies
|
||||
) {
|
||||
const start = createStartServicesGetter(core.getStartServices);
|
||||
|
||||
uiActions.registerDrilldown(new DashboardHelloWorldDrilldown());
|
||||
uiActions.registerDrilldown(new DashboardToUrlDrilldown());
|
||||
uiActions.registerDrilldown(new DashboardToDiscoverDrilldown({ start }));
|
||||
}
|
||||
|
||||
public start(core: CoreStart, plugins: StartDependencies) {}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,3 @@
|
|||
.auaActionWizard__selectedActionFactoryContainer {
|
||||
background-color: $euiColorLightestShade;
|
||||
padding: $euiSize;
|
||||
}
|
||||
|
||||
.auaActionWizard__actionFactoryItem {
|
||||
.euiKeyPadMenuItem__label {
|
||||
height: #{$euiSizeXL};
|
||||
|
|
|
@ -6,28 +6,26 @@
|
|||
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { dashboardDrilldownActionFactory, Demo, urlDrilldownActionFactory } from './test_data';
|
||||
import { Demo, dashboardFactory, urlFactory } from './test_data';
|
||||
|
||||
storiesOf('components/ActionWizard', module)
|
||||
.add('default', () => (
|
||||
<Demo actionFactories={[dashboardDrilldownActionFactory, urlDrilldownActionFactory]} />
|
||||
))
|
||||
.add('default', () => <Demo actionFactories={[dashboardFactory, urlFactory]} />)
|
||||
.add('Only one factory is available', () => (
|
||||
// to make sure layout doesn't break
|
||||
<Demo actionFactories={[dashboardDrilldownActionFactory]} />
|
||||
<Demo actionFactories={[dashboardFactory]} />
|
||||
))
|
||||
.add('Long list of action factories', () => (
|
||||
// to make sure layout doesn't break
|
||||
<Demo
|
||||
actionFactories={[
|
||||
dashboardDrilldownActionFactory,
|
||||
urlDrilldownActionFactory,
|
||||
dashboardDrilldownActionFactory,
|
||||
urlDrilldownActionFactory,
|
||||
dashboardDrilldownActionFactory,
|
||||
urlDrilldownActionFactory,
|
||||
dashboardDrilldownActionFactory,
|
||||
urlDrilldownActionFactory,
|
||||
dashboardFactory,
|
||||
urlFactory,
|
||||
dashboardFactory,
|
||||
urlFactory,
|
||||
dashboardFactory,
|
||||
urlFactory,
|
||||
dashboardFactory,
|
||||
urlFactory,
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -8,24 +8,17 @@ import React from 'react';
|
|||
import { cleanup, fireEvent, render } from '@testing-library/react/pure';
|
||||
import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global
|
||||
import { TEST_SUBJ_ACTION_FACTORY_ITEM, TEST_SUBJ_SELECTED_ACTION_FACTORY } from './action_wizard';
|
||||
import {
|
||||
dashboardDrilldownActionFactory,
|
||||
dashboards,
|
||||
Demo,
|
||||
urlDrilldownActionFactory,
|
||||
} from './test_data';
|
||||
import { dashboardFactory, dashboards, Demo, urlFactory } from './test_data';
|
||||
|
||||
// TODO: afterEach is not available for it globally during setup
|
||||
// https://github.com/elastic/kibana/issues/59469
|
||||
afterEach(cleanup);
|
||||
|
||||
test('Pick and configure action', () => {
|
||||
const screen = render(
|
||||
<Demo actionFactories={[dashboardDrilldownActionFactory, urlDrilldownActionFactory]} />
|
||||
);
|
||||
const screen = render(<Demo actionFactories={[dashboardFactory, urlFactory]} />);
|
||||
|
||||
// check that all factories are displayed to pick
|
||||
expect(screen.getAllByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).toHaveLength(2);
|
||||
expect(screen.getAllByTestId(new RegExp(TEST_SUBJ_ACTION_FACTORY_ITEM))).toHaveLength(2);
|
||||
|
||||
// select URL one
|
||||
fireEvent.click(screen.getByText(/Go to URL/i));
|
||||
|
@ -47,11 +40,11 @@ test('Pick and configure action', () => {
|
|||
});
|
||||
|
||||
test('If only one actions factory is available then actionFactory selection is emitted without user input', () => {
|
||||
const screen = render(<Demo actionFactories={[urlDrilldownActionFactory]} />);
|
||||
const screen = render(<Demo actionFactories={[urlFactory]} />);
|
||||
|
||||
// check that no factories are displayed to pick from
|
||||
expect(screen.queryByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(TEST_SUBJ_SELECTED_ACTION_FACTORY)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(new RegExp(TEST_SUBJ_ACTION_FACTORY_ITEM))).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(new RegExp(TEST_SUBJ_SELECTED_ACTION_FACTORY))).toBeInTheDocument();
|
||||
|
||||
// Input url
|
||||
const URL = 'https://elastic.co';
|
||||
|
|
|
@ -16,40 +16,20 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { txtChangeButton } from './i18n';
|
||||
import './action_wizard.scss';
|
||||
|
||||
// TODO: this interface is temporary for just moving forward with the component
|
||||
// and it will be imported from the ../ui_actions when implemented properly
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
export type ActionBaseConfig = {};
|
||||
export interface ActionFactory<Config extends ActionBaseConfig = ActionBaseConfig> {
|
||||
type: string; // TODO: type should be tied to Action and ActionByType
|
||||
displayName: string;
|
||||
iconType?: string;
|
||||
wizard: React.FC<ActionFactoryWizardProps<Config>>;
|
||||
createConfig: () => Config;
|
||||
isValid: (config: Config) => boolean;
|
||||
}
|
||||
|
||||
export interface ActionFactoryWizardProps<Config extends ActionBaseConfig> {
|
||||
config?: Config;
|
||||
|
||||
/**
|
||||
* Callback called when user updates the config in UI.
|
||||
*/
|
||||
onConfig: (config: Config) => void;
|
||||
}
|
||||
import { ActionFactory } from '../../dynamic_actions';
|
||||
|
||||
export interface ActionWizardProps {
|
||||
/**
|
||||
* List of available action factories
|
||||
*/
|
||||
actionFactories: Array<ActionFactory<any>>; // any here to be able to pass array of ActionFactory<Config> with different configs
|
||||
actionFactories: ActionFactory[];
|
||||
|
||||
/**
|
||||
* Currently selected action factory
|
||||
* undefined - is allowed and means that non is selected
|
||||
* undefined - is allowed and means that none is selected
|
||||
*/
|
||||
currentActionFactory?: ActionFactory;
|
||||
|
||||
/**
|
||||
* Action factory selected changed
|
||||
* null - means user click "change" and removed action factory selection
|
||||
|
@ -59,12 +39,17 @@ export interface ActionWizardProps {
|
|||
/**
|
||||
* current config for currently selected action factory
|
||||
*/
|
||||
config?: ActionBaseConfig;
|
||||
config?: object;
|
||||
|
||||
/**
|
||||
* config changed
|
||||
*/
|
||||
onConfigChange: (config: ActionBaseConfig) => void;
|
||||
onConfigChange: (config: object) => void;
|
||||
|
||||
/**
|
||||
* Context will be passed into ActionFactory's methods
|
||||
*/
|
||||
context: object;
|
||||
}
|
||||
|
||||
export const ActionWizard: React.FC<ActionWizardProps> = ({
|
||||
|
@ -73,6 +58,7 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
|
|||
onActionFactoryChange,
|
||||
onConfigChange,
|
||||
config,
|
||||
context,
|
||||
}) => {
|
||||
// auto pick action factory if there is only 1 available
|
||||
if (!currentActionFactory && actionFactories.length === 1) {
|
||||
|
@ -87,6 +73,7 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
|
|||
onDeselect={() => {
|
||||
onActionFactoryChange(null);
|
||||
}}
|
||||
context={context}
|
||||
config={config}
|
||||
onConfigChange={newConfig => {
|
||||
onConfigChange(newConfig);
|
||||
|
@ -97,6 +84,7 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
|
|||
|
||||
return (
|
||||
<ActionFactorySelector
|
||||
context={context}
|
||||
actionFactories={actionFactories}
|
||||
onActionFactorySelected={actionFactory => {
|
||||
onActionFactoryChange(actionFactory);
|
||||
|
@ -105,15 +93,16 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
interface SelectedActionFactoryProps<Config extends ActionBaseConfig = ActionBaseConfig> {
|
||||
actionFactory: ActionFactory<Config>;
|
||||
config: Config;
|
||||
onConfigChange: (config: Config) => void;
|
||||
interface SelectedActionFactoryProps {
|
||||
actionFactory: ActionFactory;
|
||||
config: object;
|
||||
context: object;
|
||||
onConfigChange: (config: object) => void;
|
||||
showDeselect: boolean;
|
||||
onDeselect: () => void;
|
||||
}
|
||||
|
||||
export const TEST_SUBJ_SELECTED_ACTION_FACTORY = 'selected-action-factory';
|
||||
export const TEST_SUBJ_SELECTED_ACTION_FACTORY = 'selectedActionFactory';
|
||||
|
||||
const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({
|
||||
actionFactory,
|
||||
|
@ -121,28 +110,28 @@ const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({
|
|||
showDeselect,
|
||||
onConfigChange,
|
||||
config,
|
||||
context,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="auaActionWizard__selectedActionFactoryContainer"
|
||||
data-test-subj={TEST_SUBJ_SELECTED_ACTION_FACTORY}
|
||||
data-testid={TEST_SUBJ_SELECTED_ACTION_FACTORY}
|
||||
data-test-subj={`${TEST_SUBJ_SELECTED_ACTION_FACTORY}-${actionFactory.id}`}
|
||||
>
|
||||
<header>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s">
|
||||
{actionFactory.iconType && (
|
||||
{actionFactory.getIconType(context) && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type={actionFactory.iconType} size="m" />
|
||||
<EuiIcon type={actionFactory.getIconType(context)!} size="m" />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={true}>
|
||||
<EuiText>
|
||||
<h4>{actionFactory.displayName}</h4>
|
||||
<h4>{actionFactory.getDisplayName(context)}</h4>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
{showDeselect && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty size="s" onClick={() => onDeselect()}>
|
||||
<EuiButtonEmpty size="xs" onClick={() => onDeselect()}>
|
||||
{txtChangeButton}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
|
@ -151,10 +140,11 @@ const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({
|
|||
</header>
|
||||
<EuiSpacer size="m" />
|
||||
<div>
|
||||
{actionFactory.wizard({
|
||||
config,
|
||||
onConfig: onConfigChange,
|
||||
})}
|
||||
<actionFactory.ReactCollectConfig
|
||||
config={config}
|
||||
onConfig={onConfigChange}
|
||||
context={context}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -162,14 +152,16 @@ const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({
|
|||
|
||||
interface ActionFactorySelectorProps {
|
||||
actionFactories: ActionFactory[];
|
||||
context: object;
|
||||
onActionFactorySelected: (actionFactory: ActionFactory) => void;
|
||||
}
|
||||
|
||||
export const TEST_SUBJ_ACTION_FACTORY_ITEM = 'action-factory-item';
|
||||
export const TEST_SUBJ_ACTION_FACTORY_ITEM = 'actionFactoryItem';
|
||||
|
||||
const ActionFactorySelector: React.FC<ActionFactorySelectorProps> = ({
|
||||
actionFactories,
|
||||
onActionFactorySelected,
|
||||
context,
|
||||
}) => {
|
||||
if (actionFactories.length === 0) {
|
||||
// this is not user facing, as it would be impossible to get into this state
|
||||
|
@ -177,20 +169,30 @@ const ActionFactorySelector: React.FC<ActionFactorySelectorProps> = ({
|
|||
return <div>No action factories to pick from</div>;
|
||||
}
|
||||
|
||||
// The below style is applied to fix Firefox rendering bug.
|
||||
// See: https://github.com/elastic/kibana/pull/61219/#pullrequestreview-402903330
|
||||
const firefoxBugFix = {
|
||||
willChange: 'opacity',
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiFlexGroup wrap>
|
||||
{actionFactories.map(actionFactory => (
|
||||
<EuiKeyPadMenuItem
|
||||
className="auaActionWizard__actionFactoryItem"
|
||||
key={actionFactory.type}
|
||||
label={actionFactory.displayName}
|
||||
data-testid={TEST_SUBJ_ACTION_FACTORY_ITEM}
|
||||
data-test-subj={TEST_SUBJ_ACTION_FACTORY_ITEM}
|
||||
onClick={() => onActionFactorySelected(actionFactory)}
|
||||
>
|
||||
{actionFactory.iconType && <EuiIcon type={actionFactory.iconType} size="m" />}
|
||||
</EuiKeyPadMenuItem>
|
||||
))}
|
||||
<EuiFlexGroup gutterSize="m" wrap={true} style={firefoxBugFix}>
|
||||
{[...actionFactories]
|
||||
.sort((f1, f2) => f2.order - f1.order)
|
||||
.map(actionFactory => (
|
||||
<EuiFlexItem grow={false} key={actionFactory.id}>
|
||||
<EuiKeyPadMenuItem
|
||||
className="auaActionWizard__actionFactoryItem"
|
||||
label={actionFactory.getDisplayName(context)}
|
||||
data-test-subj={`${TEST_SUBJ_ACTION_FACTORY_ITEM}-${actionFactory.id}`}
|
||||
onClick={() => onActionFactorySelected(actionFactory)}
|
||||
>
|
||||
{actionFactory.getIconType(context) && (
|
||||
<EuiIcon type={actionFactory.getIconType(context)!} size="m" />
|
||||
)}
|
||||
</EuiKeyPadMenuItem>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -9,6 +9,6 @@ import { i18n } from '@kbn/i18n';
|
|||
export const txtChangeButton = i18n.translate(
|
||||
'xpack.advancedUiActions.components.actionWizard.changeButton',
|
||||
{
|
||||
defaultMessage: 'change',
|
||||
defaultMessage: 'Change',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -6,124 +6,161 @@
|
|||
|
||||
import React, { useState } from 'react';
|
||||
import { EuiFieldText, EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui';
|
||||
import { ActionFactory, ActionBaseConfig, ActionWizard } from './action_wizard';
|
||||
import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import { ActionWizard } from './action_wizard';
|
||||
import { ActionFactoryDefinition, ActionFactory } from '../../dynamic_actions';
|
||||
import { CollectConfigProps } from '../../../../../../src/plugins/kibana_utils/public';
|
||||
|
||||
type ActionBaseConfig = object;
|
||||
|
||||
export const dashboards = [
|
||||
{ id: 'dashboard1', title: 'Dashboard 1' },
|
||||
{ id: 'dashboard2', title: 'Dashboard 2' },
|
||||
];
|
||||
|
||||
export const dashboardDrilldownActionFactory: ActionFactory<{
|
||||
interface DashboardDrilldownConfig {
|
||||
dashboardId?: string;
|
||||
useCurrentDashboardFilters: boolean;
|
||||
useCurrentDashboardDataRange: boolean;
|
||||
}> = {
|
||||
type: 'Dashboard',
|
||||
displayName: 'Go to Dashboard',
|
||||
iconType: 'dashboardApp',
|
||||
useCurrentFilters: boolean;
|
||||
useCurrentDateRange: boolean;
|
||||
}
|
||||
|
||||
function DashboardDrilldownCollectConfig(props: CollectConfigProps<DashboardDrilldownConfig>) {
|
||||
const config = props.config ?? {
|
||||
dashboardId: undefined,
|
||||
useCurrentFilters: true,
|
||||
useCurrentDateRange: true,
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow label="Choose destination dashboard:">
|
||||
<EuiSelect
|
||||
name="selectDashboard"
|
||||
hasNoInitialSelection={true}
|
||||
options={dashboards.map(({ id, title }) => ({ value: id, text: title }))}
|
||||
value={config.dashboardId}
|
||||
onChange={e => {
|
||||
props.onConfig({ ...config, dashboardId: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow hasChildLabel={false}>
|
||||
<EuiSwitch
|
||||
name="useCurrentFilters"
|
||||
label="Use current dashboard's filters"
|
||||
checked={config.useCurrentFilters}
|
||||
onChange={() =>
|
||||
props.onConfig({
|
||||
...config,
|
||||
useCurrentFilters: !config.useCurrentFilters,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow hasChildLabel={false}>
|
||||
<EuiSwitch
|
||||
name="useCurrentDateRange"
|
||||
label="Use current dashboard's date range"
|
||||
checked={config.useCurrentDateRange}
|
||||
onChange={() =>
|
||||
props.onConfig({
|
||||
...config,
|
||||
useCurrentDateRange: !config.useCurrentDateRange,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const dashboardDrilldownActionFactory: ActionFactoryDefinition<
|
||||
DashboardDrilldownConfig,
|
||||
any,
|
||||
any
|
||||
> = {
|
||||
id: 'Dashboard',
|
||||
getDisplayName: () => 'Go to Dashboard',
|
||||
getIconType: () => 'dashboardApp',
|
||||
createConfig: () => {
|
||||
return {
|
||||
dashboardId: undefined,
|
||||
useCurrentDashboardDataRange: true,
|
||||
useCurrentDashboardFilters: true,
|
||||
useCurrentFilters: true,
|
||||
useCurrentDateRange: true,
|
||||
};
|
||||
},
|
||||
isValid: config => {
|
||||
isConfigValid: (config: DashboardDrilldownConfig): config is DashboardDrilldownConfig => {
|
||||
if (!config.dashboardId) return false;
|
||||
return true;
|
||||
},
|
||||
wizard: props => {
|
||||
const config = props.config ?? {
|
||||
dashboardId: undefined,
|
||||
useCurrentDashboardDataRange: true,
|
||||
useCurrentDashboardFilters: true,
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow label="Choose destination dashboard:">
|
||||
<EuiSelect
|
||||
name="selectDashboard"
|
||||
hasNoInitialSelection={true}
|
||||
options={dashboards.map(({ id, title }) => ({ value: id, text: title }))}
|
||||
value={config.dashboardId}
|
||||
onChange={e => {
|
||||
props.onConfig({ ...config, dashboardId: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow hasChildLabel={false}>
|
||||
<EuiSwitch
|
||||
name="useCurrentFilters"
|
||||
label="Use current dashboard's filters"
|
||||
checked={config.useCurrentDashboardFilters}
|
||||
onChange={() =>
|
||||
props.onConfig({
|
||||
...config,
|
||||
useCurrentDashboardFilters: !config.useCurrentDashboardFilters,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow hasChildLabel={false}>
|
||||
<EuiSwitch
|
||||
name="useCurrentDateRange"
|
||||
label="Use current dashboard's date range"
|
||||
checked={config.useCurrentDashboardDataRange}
|
||||
onChange={() =>
|
||||
props.onConfig({
|
||||
...config,
|
||||
useCurrentDashboardDataRange: !config.useCurrentDashboardDataRange,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
);
|
||||
CollectConfig: reactToUiComponent(DashboardDrilldownCollectConfig),
|
||||
|
||||
isCompatible(context?: object): Promise<boolean> {
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
order: 0,
|
||||
create: () => ({
|
||||
id: 'test',
|
||||
execute: async () => alert('Navigate to dashboard!'),
|
||||
}),
|
||||
};
|
||||
|
||||
export const urlDrilldownActionFactory: ActionFactory<{ url: string; openInNewTab: boolean }> = {
|
||||
type: 'Url',
|
||||
displayName: 'Go to URL',
|
||||
iconType: 'link',
|
||||
export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory);
|
||||
|
||||
interface UrlDrilldownConfig {
|
||||
url: string;
|
||||
openInNewTab: boolean;
|
||||
}
|
||||
function UrlDrilldownCollectConfig(props: CollectConfigProps<UrlDrilldownConfig>) {
|
||||
const config = props.config ?? {
|
||||
url: '',
|
||||
openInNewTab: false,
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow label="Enter target URL">
|
||||
<EuiFieldText
|
||||
placeholder="Enter URL"
|
||||
name="url"
|
||||
value={config.url}
|
||||
onChange={event => props.onConfig({ ...config, url: event.target.value })}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow hasChildLabel={false}>
|
||||
<EuiSwitch
|
||||
name="openInNewTab"
|
||||
label="Open in new tab?"
|
||||
checked={config.openInNewTab}
|
||||
onChange={() => props.onConfig({ ...config, openInNewTab: !config.openInNewTab })}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export const urlDrilldownActionFactory: ActionFactoryDefinition<UrlDrilldownConfig> = {
|
||||
id: 'Url',
|
||||
getDisplayName: () => 'Go to URL',
|
||||
getIconType: () => 'link',
|
||||
createConfig: () => {
|
||||
return {
|
||||
url: '',
|
||||
openInNewTab: false,
|
||||
};
|
||||
},
|
||||
isValid: config => {
|
||||
isConfigValid: (config: UrlDrilldownConfig): config is UrlDrilldownConfig => {
|
||||
if (!config.url) return false;
|
||||
return true;
|
||||
},
|
||||
wizard: props => {
|
||||
const config = props.config ?? {
|
||||
url: '',
|
||||
openInNewTab: false,
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<EuiFormRow label="Enter target URL">
|
||||
<EuiFieldText
|
||||
placeholder="Enter URL"
|
||||
name="url"
|
||||
value={config.url}
|
||||
onChange={event => props.onConfig({ ...config, url: event.target.value })}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow hasChildLabel={false}>
|
||||
<EuiSwitch
|
||||
name="openInNewTab"
|
||||
label="Open in new tab?"
|
||||
checked={config.openInNewTab}
|
||||
onChange={() => props.onConfig({ ...config, openInNewTab: !config.openInNewTab })}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
);
|
||||
CollectConfig: reactToUiComponent(UrlDrilldownCollectConfig),
|
||||
|
||||
order: 10,
|
||||
isCompatible(context?: object): Promise<boolean> {
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
create: () => null as any,
|
||||
};
|
||||
|
||||
export const urlFactory = new ActionFactory(urlDrilldownActionFactory);
|
||||
|
||||
export function Demo({ actionFactories }: { actionFactories: Array<ActionFactory<any>> }) {
|
||||
const [state, setState] = useState<{
|
||||
currentActionFactory?: ActionFactory;
|
||||
|
@ -157,14 +194,15 @@ export function Demo({ actionFactories }: { actionFactories: Array<ActionFactory
|
|||
changeActionFactory(newActionFactory);
|
||||
}}
|
||||
currentActionFactory={state.currentActionFactory}
|
||||
context={{}}
|
||||
/>
|
||||
<div style={{ marginTop: '44px' }} />
|
||||
<hr />
|
||||
<div>Action Factory Type: {state.currentActionFactory?.type}</div>
|
||||
<div>Action Factory Id: {state.currentActionFactory?.id}</div>
|
||||
<div>Action Factory Config: {JSON.stringify(state.config)}</div>
|
||||
<div>
|
||||
Is config valid:{' '}
|
||||
{JSON.stringify(state.currentActionFactory?.isValid(state.config!) ?? false)}
|
||||
{JSON.stringify(state.currentActionFactory?.isConfigValid(state.config!) ?? false)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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';
|
|
@ -44,7 +44,7 @@ export class CustomTimeRangeAction implements ActionByType<typeof CUSTOM_TIME_RA
|
|||
private dateFormat?: string;
|
||||
private commonlyUsedRanges: CommonlyUsedRange[];
|
||||
public readonly id = CUSTOM_TIME_RANGE;
|
||||
public order = 7;
|
||||
public order = 30;
|
||||
|
||||
constructor({
|
||||
openModal,
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { ActionFactoryDefinition } from '../dynamic_actions';
|
||||
|
||||
/**
|
||||
* This is a convenience interface to register a drilldown. Drilldown has
|
||||
* ability to collect configuration from user. Once drilldown is executed it
|
||||
* receives the collected information together with the context of the
|
||||
* user's interaction.
|
||||
*
|
||||
* `Config` is a serializable object containing the configuration that the
|
||||
* drilldown is able to collect using UI.
|
||||
*
|
||||
* `PlaceContext` is an object that the app that opens drilldown management
|
||||
* flyout provides to the React component, specifying the contextual information
|
||||
* about that app. For example, on Dashboard app this context contains
|
||||
* information about the current embeddable and dashboard.
|
||||
*
|
||||
* `ExecutionContext` is an object created in response to user's interaction
|
||||
* and provided to the `execute` function of the drilldown. This object contains
|
||||
* information about the action user performed.
|
||||
*/
|
||||
export interface DrilldownDefinition<
|
||||
Config extends object = object,
|
||||
ExecutionContext extends object = object
|
||||
> {
|
||||
/**
|
||||
* Globally unique identifier for this drilldown.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Determines the display order of the drilldowns in the flyout picker.
|
||||
* Higher numbers are displayed first.
|
||||
*/
|
||||
order?: number;
|
||||
|
||||
/**
|
||||
* Function that returns default config for this drilldown.
|
||||
*/
|
||||
createConfig: ActionFactoryDefinition<Config, object, ExecutionContext>['createConfig'];
|
||||
|
||||
/**
|
||||
* `UiComponent` that collections config for this drilldown. You can create
|
||||
* a React component and transform it `UiComponent` using `uiToReactComponent`
|
||||
* helper from `kibana_utils` plugin.
|
||||
*
|
||||
* ```tsx
|
||||
* import React from 'react';
|
||||
* import { uiToReactComponent } from 'src/plugins/kibana_utils';
|
||||
* import { CollectConfigProps } from 'src/plugins/kibana_utils/public';
|
||||
*
|
||||
* type Props = CollectConfigProps<Config>;
|
||||
*
|
||||
* const ReactCollectConfig: React.FC<Props> = () => {
|
||||
* return <div>Collecting config...'</div>;
|
||||
* };
|
||||
*
|
||||
* export const CollectConfig = uiToReactComponent(ReactCollectConfig);
|
||||
* ```
|
||||
*/
|
||||
CollectConfig: ActionFactoryDefinition<Config, object, ExecutionContext>['CollectConfig'];
|
||||
|
||||
/**
|
||||
* A validator function for the config object. Should always return a boolean
|
||||
* given any input.
|
||||
*/
|
||||
isConfigValid: ActionFactoryDefinition<Config, object, ExecutionContext>['isConfigValid'];
|
||||
|
||||
/**
|
||||
* Name of EUI icon to display when showing this drilldown to user.
|
||||
*/
|
||||
euiIcon?: string;
|
||||
|
||||
/**
|
||||
* Should return an internationalized name of the drilldown, which will be
|
||||
* displayed to the user.
|
||||
*/
|
||||
getDisplayName: () => string;
|
||||
|
||||
/**
|
||||
* Implements the "navigation" action of the drilldown. This happens when
|
||||
* user clicks something in the UI that executes a trigger to which this
|
||||
* drilldown was attached.
|
||||
*
|
||||
* @param config Config object that user configured this drilldown with.
|
||||
* @param context Object that represents context in which the underlying
|
||||
* `UIAction` of this drilldown is being executed in.
|
||||
*/
|
||||
execute(config: Config, context: ExecutionContext): void;
|
||||
|
||||
/**
|
||||
* A link where drilldown should navigate on middle click or Ctrl + click.
|
||||
*/
|
||||
getHref?(config: Config, context: ExecutionContext): Promise<string | undefined>;
|
||||
}
|
|
@ -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';
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { uiToReactComponent } from '../../../../../src/plugins/kibana_react/public';
|
||||
import {
|
||||
UiActionsActionDefinition as ActionDefinition,
|
||||
UiActionsPresentable as Presentable,
|
||||
} from '../../../../../src/plugins/ui_actions/public';
|
||||
import { ActionFactoryDefinition } from './action_factory_definition';
|
||||
import { Configurable } from '../../../../../src/plugins/kibana_utils/public';
|
||||
import { SerializedAction } from './types';
|
||||
|
||||
export class ActionFactory<
|
||||
Config extends object = object,
|
||||
FactoryContext extends object = object,
|
||||
ActionContext extends object = object
|
||||
> implements Omit<Presentable<FactoryContext>, 'getHref'>, Configurable<Config, FactoryContext> {
|
||||
constructor(
|
||||
protected readonly def: ActionFactoryDefinition<Config, FactoryContext, ActionContext>
|
||||
) {}
|
||||
|
||||
public readonly id = this.def.id;
|
||||
public readonly order = this.def.order || 0;
|
||||
public readonly MenuItem? = this.def.MenuItem;
|
||||
public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined;
|
||||
|
||||
public readonly CollectConfig = this.def.CollectConfig;
|
||||
public readonly ReactCollectConfig = uiToReactComponent(this.CollectConfig);
|
||||
public readonly createConfig = this.def.createConfig;
|
||||
public readonly isConfigValid = this.def.isConfigValid;
|
||||
|
||||
public getIconType(context: FactoryContext): string | undefined {
|
||||
if (!this.def.getIconType) return undefined;
|
||||
return this.def.getIconType(context);
|
||||
}
|
||||
|
||||
public getDisplayName(context: FactoryContext): string {
|
||||
if (!this.def.getDisplayName) return '';
|
||||
return this.def.getDisplayName(context);
|
||||
}
|
||||
|
||||
public async isCompatible(context: FactoryContext): Promise<boolean> {
|
||||
if (!this.def.isCompatible) return true;
|
||||
return await this.def.isCompatible(context);
|
||||
}
|
||||
|
||||
public create(
|
||||
serializedAction: Omit<SerializedAction<Config>, 'factoryId'>
|
||||
): ActionDefinition<ActionContext> {
|
||||
return this.def.create(serializedAction);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import {
|
||||
UiActionsActionDefinition as ActionDefinition,
|
||||
UiActionsPresentable as Presentable,
|
||||
} from '../../../../../src/plugins/ui_actions/public';
|
||||
import { Configurable } from '../../../../../src/plugins/kibana_utils/public';
|
||||
import { SerializedAction } from './types';
|
||||
|
||||
/**
|
||||
* This is a convenience interface for registering new action factories.
|
||||
*/
|
||||
export interface ActionFactoryDefinition<
|
||||
Config extends object = object,
|
||||
FactoryContext extends object = object,
|
||||
ActionContext extends object = object
|
||||
>
|
||||
extends Partial<Omit<Presentable<FactoryContext>, 'getHref'>>,
|
||||
Configurable<Config, FactoryContext> {
|
||||
/**
|
||||
* Unique ID of the action factory. This ID is used to identify this action
|
||||
* factory in the registry as well as to construct actions of this type and
|
||||
* identify this action factory when presenting it to the user in UI.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* This method should return a definition of a new action, normally used to
|
||||
* register it in `ui_actions` registry.
|
||||
*/
|
||||
create(
|
||||
serializedAction: Omit<SerializedAction<Config>, 'factoryId'>
|
||||
): ActionDefinition<ActionContext>;
|
||||
}
|
|
@ -0,0 +1,635 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { DynamicActionManager } from './dynamic_action_manager';
|
||||
import { ActionStorage, MemoryActionStorage } from './dynamic_action_storage';
|
||||
import { UiActionsService } from '../../../../../src/plugins/ui_actions/public';
|
||||
import { ActionInternal } from '../../../../../src/plugins/ui_actions/public/actions';
|
||||
import { of } from '../../../../../src/plugins/kibana_utils';
|
||||
import { UiActionsServiceEnhancements } from '../services';
|
||||
import { ActionFactoryDefinition } from './action_factory_definition';
|
||||
import { SerializedAction, SerializedEvent } from './types';
|
||||
|
||||
const actionFactoryDefinition1: ActionFactoryDefinition = {
|
||||
id: 'ACTION_FACTORY_1',
|
||||
CollectConfig: {} as any,
|
||||
createConfig: () => ({}),
|
||||
isConfigValid: (() => true) as any,
|
||||
create: ({ name }) => ({
|
||||
id: '',
|
||||
execute: async () => {},
|
||||
getDisplayName: () => name,
|
||||
}),
|
||||
};
|
||||
|
||||
const actionFactoryDefinition2: ActionFactoryDefinition = {
|
||||
id: 'ACTION_FACTORY_2',
|
||||
CollectConfig: {} as any,
|
||||
createConfig: () => ({}),
|
||||
isConfigValid: (() => true) as any,
|
||||
create: ({ name }) => ({
|
||||
id: '',
|
||||
execute: async () => {},
|
||||
getDisplayName: () => name,
|
||||
}),
|
||||
};
|
||||
|
||||
const event1: SerializedEvent = {
|
||||
eventId: 'EVENT_ID_1',
|
||||
triggers: ['VALUE_CLICK_TRIGGER'],
|
||||
action: {
|
||||
factoryId: actionFactoryDefinition1.id,
|
||||
name: 'Action 1',
|
||||
config: {},
|
||||
},
|
||||
};
|
||||
|
||||
const event2: SerializedEvent = {
|
||||
eventId: 'EVENT_ID_2',
|
||||
triggers: ['VALUE_CLICK_TRIGGER'],
|
||||
action: {
|
||||
factoryId: actionFactoryDefinition1.id,
|
||||
name: 'Action 2',
|
||||
config: {},
|
||||
},
|
||||
};
|
||||
|
||||
const event3: SerializedEvent = {
|
||||
eventId: 'EVENT_ID_3',
|
||||
triggers: ['VALUE_CLICK_TRIGGER'],
|
||||
action: {
|
||||
factoryId: actionFactoryDefinition2.id,
|
||||
name: 'Action 3',
|
||||
config: {},
|
||||
},
|
||||
};
|
||||
|
||||
const setup = (events: readonly SerializedEvent[] = []) => {
|
||||
const isCompatible = async () => true;
|
||||
const storage: ActionStorage = new MemoryActionStorage(events);
|
||||
const actions = new Map<string, ActionInternal>();
|
||||
const uiActions = new UiActionsService({
|
||||
actions,
|
||||
});
|
||||
const uiActionsEnhancements = new UiActionsServiceEnhancements();
|
||||
const manager = new DynamicActionManager({
|
||||
isCompatible,
|
||||
storage,
|
||||
uiActions: { ...uiActions, ...uiActionsEnhancements },
|
||||
});
|
||||
|
||||
uiActions.registerTrigger({
|
||||
id: 'VALUE_CLICK_TRIGGER',
|
||||
});
|
||||
|
||||
return {
|
||||
isCompatible,
|
||||
actions,
|
||||
storage,
|
||||
uiActions: { ...uiActions, ...uiActionsEnhancements },
|
||||
manager,
|
||||
};
|
||||
};
|
||||
|
||||
describe('DynamicActionManager', () => {
|
||||
test('can instantiate', () => {
|
||||
const { manager } = setup([event1]);
|
||||
expect(manager).toBeInstanceOf(DynamicActionManager);
|
||||
});
|
||||
|
||||
describe('.start()', () => {
|
||||
test('instantiates stored events', async () => {
|
||||
const { manager, actions, uiActions } = setup([event1]);
|
||||
const create1 = jest.fn();
|
||||
const create2 = jest.fn();
|
||||
|
||||
uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 });
|
||||
uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 });
|
||||
|
||||
expect(create1).toHaveBeenCalledTimes(0);
|
||||
expect(create2).toHaveBeenCalledTimes(0);
|
||||
expect(actions.size).toBe(0);
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(create1).toHaveBeenCalledTimes(1);
|
||||
expect(create2).toHaveBeenCalledTimes(0);
|
||||
expect(actions.size).toBe(1);
|
||||
});
|
||||
|
||||
test('does nothing when no events stored', async () => {
|
||||
const { manager, actions, uiActions } = setup();
|
||||
const create1 = jest.fn();
|
||||
const create2 = jest.fn();
|
||||
|
||||
uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 });
|
||||
uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 });
|
||||
|
||||
expect(create1).toHaveBeenCalledTimes(0);
|
||||
expect(create2).toHaveBeenCalledTimes(0);
|
||||
expect(actions.size).toBe(0);
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(create1).toHaveBeenCalledTimes(0);
|
||||
expect(create2).toHaveBeenCalledTimes(0);
|
||||
expect(actions.size).toBe(0);
|
||||
});
|
||||
|
||||
test('UI state is empty before manager starts', async () => {
|
||||
const { manager } = setup([event1]);
|
||||
|
||||
expect(manager.state.get()).toMatchObject({
|
||||
events: [],
|
||||
isFetchingEvents: false,
|
||||
fetchCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('loads events into UI state', async () => {
|
||||
const { manager, uiActions } = setup([event1, event2, event3]);
|
||||
|
||||
uiActions.registerActionFactory(actionFactoryDefinition1);
|
||||
uiActions.registerActionFactory(actionFactoryDefinition2);
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(manager.state.get()).toMatchObject({
|
||||
events: [event1, event2, event3],
|
||||
isFetchingEvents: false,
|
||||
fetchCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('sets isFetchingEvents to true while fetching events', async () => {
|
||||
const { manager, uiActions } = setup([event1, event2, event3]);
|
||||
|
||||
uiActions.registerActionFactory(actionFactoryDefinition1);
|
||||
uiActions.registerActionFactory(actionFactoryDefinition2);
|
||||
|
||||
const promise = manager.start().catch(() => {});
|
||||
|
||||
expect(manager.state.get().isFetchingEvents).toBe(true);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(manager.state.get().isFetchingEvents).toBe(false);
|
||||
});
|
||||
|
||||
test('throws if storage threw', async () => {
|
||||
const { manager, storage } = setup([event1]);
|
||||
|
||||
storage.list = async () => {
|
||||
throw new Error('baz');
|
||||
};
|
||||
|
||||
const [, error] = await of(manager.start());
|
||||
|
||||
expect(error).toEqual(new Error('baz'));
|
||||
});
|
||||
|
||||
test('sets UI state error if error happened during initial fetch', async () => {
|
||||
const { manager, storage } = setup([event1]);
|
||||
|
||||
storage.list = async () => {
|
||||
throw new Error('baz');
|
||||
};
|
||||
|
||||
await of(manager.start());
|
||||
|
||||
expect(manager.state.get().fetchError!.message).toBe('baz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('.stop()', () => {
|
||||
test('removes events from UI actions registry', async () => {
|
||||
const { manager, actions, uiActions } = setup([event1, event2]);
|
||||
const create1 = jest.fn();
|
||||
const create2 = jest.fn();
|
||||
|
||||
uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 });
|
||||
uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 });
|
||||
|
||||
expect(actions.size).toBe(0);
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(actions.size).toBe(2);
|
||||
|
||||
await manager.stop();
|
||||
|
||||
expect(actions.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('.createEvent()', () => {
|
||||
describe('when storage succeeds', () => {
|
||||
test('stores new event in storage', async () => {
|
||||
const { manager, storage, uiActions } = setup([]);
|
||||
|
||||
uiActions.registerActionFactory(actionFactoryDefinition1);
|
||||
await manager.start();
|
||||
|
||||
const action: SerializedAction<unknown> = {
|
||||
factoryId: actionFactoryDefinition1.id,
|
||||
name: 'foo',
|
||||
config: {},
|
||||
};
|
||||
|
||||
expect(await storage.count()).toBe(0);
|
||||
|
||||
await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']);
|
||||
|
||||
expect(await storage.count()).toBe(1);
|
||||
|
||||
const [event] = await storage.list();
|
||||
|
||||
expect(event).toMatchObject({
|
||||
eventId: expect.any(String),
|
||||
triggers: ['VALUE_CLICK_TRIGGER'],
|
||||
action: {
|
||||
factoryId: actionFactoryDefinition1.id,
|
||||
name: 'foo',
|
||||
config: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('adds event to UI state', async () => {
|
||||
const { manager, uiActions } = setup([]);
|
||||
const action: SerializedAction<unknown> = {
|
||||
factoryId: actionFactoryDefinition1.id,
|
||||
name: 'foo',
|
||||
config: {},
|
||||
};
|
||||
|
||||
uiActions.registerActionFactory(actionFactoryDefinition1);
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(manager.state.get().events.length).toBe(0);
|
||||
|
||||
await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']);
|
||||
|
||||
expect(manager.state.get().events.length).toBe(1);
|
||||
});
|
||||
|
||||
test('optimistically adds event to UI state', async () => {
|
||||
const { manager, uiActions } = setup([]);
|
||||
const action: SerializedAction<unknown> = {
|
||||
factoryId: actionFactoryDefinition1.id,
|
||||
name: 'foo',
|
||||
config: {},
|
||||
};
|
||||
|
||||
uiActions.registerActionFactory(actionFactoryDefinition1);
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(manager.state.get().events.length).toBe(0);
|
||||
|
||||
const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e);
|
||||
|
||||
expect(manager.state.get().events.length).toBe(1);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(manager.state.get().events.length).toBe(1);
|
||||
});
|
||||
|
||||
test('instantiates event in actions service', async () => {
|
||||
const { manager, uiActions, actions } = setup([]);
|
||||
const action: SerializedAction<unknown> = {
|
||||
factoryId: actionFactoryDefinition1.id,
|
||||
name: 'foo',
|
||||
config: {},
|
||||
};
|
||||
|
||||
uiActions.registerActionFactory(actionFactoryDefinition1);
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(actions.size).toBe(0);
|
||||
|
||||
await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']);
|
||||
|
||||
expect(actions.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when storage fails', () => {
|
||||
test('throws an error', async () => {
|
||||
const { manager, storage, uiActions } = setup([]);
|
||||
|
||||
storage.create = async () => {
|
||||
throw new Error('foo');
|
||||
};
|
||||
|
||||
uiActions.registerActionFactory(actionFactoryDefinition1);
|
||||
await manager.start();
|
||||
|
||||
const action: SerializedAction<unknown> = {
|
||||
factoryId: actionFactoryDefinition1.id,
|
||||
name: 'foo',
|
||||
config: {},
|
||||
};
|
||||
|
||||
const [, error] = await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER']));
|
||||
|
||||
expect(error).toEqual(new Error('foo'));
|
||||
});
|
||||
|
||||
test('does not add even to UI state', async () => {
|
||||
const { manager, storage, uiActions } = setup([]);
|
||||
const action: SerializedAction<unknown> = {
|
||||
factoryId: actionFactoryDefinition1.id,
|
||||
name: 'foo',
|
||||
config: {},
|
||||
};
|
||||
|
||||
storage.create = async () => {
|
||||
throw new Error('foo');
|
||||
};
|
||||
uiActions.registerActionFactory(actionFactoryDefinition1);
|
||||
|
||||
await manager.start();
|
||||
await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER']));
|
||||
|
||||
expect(manager.state.get().events.length).toBe(0);
|
||||
});
|
||||
|
||||
test('optimistically adds event to UI state and then removes it', async () => {
|
||||
const { manager, storage, uiActions } = setup([]);
|
||||
const action: SerializedAction<unknown> = {
|
||||
factoryId: actionFactoryDefinition1.id,
|
||||
name: 'foo',
|
||||
config: {},
|
||||
};
|
||||
|
||||
storage.create = async () => {
|
||||
throw new Error('foo');
|
||||
};
|
||||
uiActions.registerActionFactory(actionFactoryDefinition1);
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(manager.state.get().events.length).toBe(0);
|
||||
|
||||
const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e);
|
||||
|
||||
expect(manager.state.get().events.length).toBe(1);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(manager.state.get().events.length).toBe(0);
|
||||
});
|
||||
|
||||
test('does not instantiate event in actions service', async () => {
|
||||
const { manager, storage, uiActions, actions } = setup([]);
|
||||
const action: SerializedAction<unknown> = {
|
||||
factoryId: actionFactoryDefinition1.id,
|
||||
name: 'foo',
|
||||
config: {},
|
||||
};
|
||||
|
||||
storage.create = async () => {
|
||||
throw new Error('foo');
|
||||
};
|
||||
uiActions.registerActionFactory(actionFactoryDefinition1);
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(actions.size).toBe(0);
|
||||
|
||||
await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER']));
|
||||
|
||||
expect(actions.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('.updateEvent()', () => {
|
||||
describe('when storage succeeds', () => {
|
||||
test('un-registers old event from ui actions service and registers the new one', async () => {
|
||||
const { manager, actions, uiActions } = setup([event3]);
|
||||
|
||||
uiActions.registerActionFactory(actionFactoryDefinition2);
|
||||
await manager.start();
|
||||
|
||||
expect(actions.size).toBe(1);
|
||||
|
||||
const registeredAction1 = actions.values().next().value;
|
||||
|
||||
expect(registeredAction1.getDisplayName()).toBe('Action 3');
|
||||
|
||||
const action: SerializedAction<unknown> = {
|
||||
factoryId: actionFactoryDefinition2.id,
|
||||
name: 'foo',
|
||||
config: {},
|
||||
};
|
||||
|
||||
await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']);
|
||||
|
||||
expect(actions.size).toBe(1);
|
||||
|
||||
const registeredAction2 = actions.values().next().value;
|
||||
|
||||
expect(registeredAction2.getDisplayName()).toBe('foo');
|
||||
});
|
||||
|
||||
test('updates event in storage', async () => {
|
||||
const { manager, storage, uiActions } = setup([event3]);
|
||||
const storageUpdateSpy = jest.spyOn(storage, 'update');
|
||||
|
||||
uiActions.registerActionFactory(actionFactoryDefinition2);
|
||||
await manager.start();
|
||||
|
||||
const action: SerializedAction<unknown> = {
|
||||
factoryId: actionFactoryDefinition2.id,
|
||||
name: 'foo',
|
||||
config: {},
|
||||
};
|
||||
|
||||
expect(storageUpdateSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']);
|
||||
|
||||
expect(storageUpdateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(storageUpdateSpy.mock.calls[0][0]).toMatchObject({
|
||||
eventId: expect.any(String),
|
||||
triggers: ['VALUE_CLICK_TRIGGER'],
|
||||
action: {
|
||||
factoryId: actionFactoryDefinition2.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('updates event in UI state', async () => {
|
||||
const { manager, uiActions } = setup([event3]);
|
||||
|
||||
uiActions.registerActionFactory(actionFactoryDefinition2);
|
||||
await manager.start();
|
||||
|
||||
const action: SerializedAction<unknown> = {
|
||||
factoryId: actionFactoryDefinition2.id,
|
||||
name: 'foo',
|
||||
config: {},
|
||||
};
|
||||
|
||||
expect(manager.state.get().events[0].action.name).toBe('Action 3');
|
||||
|
||||
await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']);
|
||||
|
||||
expect(manager.state.get().events[0].action.name).toBe('foo');
|
||||
});
|
||||
|
||||
test('optimistically updates event in UI state', async () => {
|
||||
const { manager, uiActions } = setup([event3]);
|
||||
|
||||
uiActions.registerActionFactory(actionFactoryDefinition2);
|
||||
await manager.start();
|
||||
|
||||
const action: SerializedAction<unknown> = {
|
||||
factoryId: actionFactoryDefinition2.id,
|
||||
name: 'foo',
|
||||
config: {},
|
||||
};
|
||||
|
||||
expect(manager.state.get().events[0].action.name).toBe('Action 3');
|
||||
|
||||
const promise = manager
|
||||
.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])
|
||||
.catch(e => e);
|
||||
|
||||
expect(manager.state.get().events[0].action.name).toBe('foo');
|
||||
|
||||
await promise;
|
||||
});
|
||||
});
|
||||
|
||||
describe('when storage fails', () => {
|
||||
test('throws error', async () => {
|
||||
const { manager, storage, uiActions } = setup([event3]);
|
||||
|
||||
storage.update = () => {
|
||||
throw new Error('bar');
|
||||
};
|
||||
uiActions.registerActionFactory(actionFactoryDefinition2);
|
||||
await manager.start();
|
||||
|
||||
const action: SerializedAction<unknown> = {
|
||||
factoryId: actionFactoryDefinition2.id,
|
||||
name: 'foo',
|
||||
config: {},
|
||||
};
|
||||
|
||||
const [, error] = await of(
|
||||
manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])
|
||||
);
|
||||
|
||||
expect(error).toEqual(new Error('bar'));
|
||||
});
|
||||
|
||||
test('keeps the old action in actions registry', async () => {
|
||||
const { manager, storage, actions, uiActions } = setup([event3]);
|
||||
|
||||
storage.update = () => {
|
||||
throw new Error('bar');
|
||||
};
|
||||
uiActions.registerActionFactory(actionFactoryDefinition2);
|
||||
await manager.start();
|
||||
|
||||
expect(actions.size).toBe(1);
|
||||
|
||||
const registeredAction1 = actions.values().next().value;
|
||||
|
||||
expect(registeredAction1.getDisplayName()).toBe('Action 3');
|
||||
|
||||
const action: SerializedAction<unknown> = {
|
||||
factoryId: actionFactoryDefinition2.id,
|
||||
name: 'foo',
|
||||
config: {},
|
||||
};
|
||||
|
||||
await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']));
|
||||
|
||||
expect(actions.size).toBe(1);
|
||||
|
||||
const registeredAction2 = actions.values().next().value;
|
||||
|
||||
expect(registeredAction2.getDisplayName()).toBe('Action 3');
|
||||
});
|
||||
|
||||
test('keeps old event in UI state', async () => {
|
||||
const { manager, storage, uiActions } = setup([event3]);
|
||||
|
||||
storage.update = () => {
|
||||
throw new Error('bar');
|
||||
};
|
||||
uiActions.registerActionFactory(actionFactoryDefinition2);
|
||||
await manager.start();
|
||||
|
||||
const action: SerializedAction<unknown> = {
|
||||
factoryId: actionFactoryDefinition2.id,
|
||||
name: 'foo',
|
||||
config: {},
|
||||
};
|
||||
|
||||
expect(manager.state.get().events[0].action.name).toBe('Action 3');
|
||||
|
||||
await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']));
|
||||
|
||||
expect(manager.state.get().events[0].action.name).toBe('Action 3');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('.deleteEvents()', () => {
|
||||
describe('when storage succeeds', () => {
|
||||
test('removes all actions from uiActions service', async () => {
|
||||
const { manager, actions, uiActions } = setup([event2, event1]);
|
||||
|
||||
uiActions.registerActionFactory(actionFactoryDefinition1);
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(actions.size).toBe(2);
|
||||
|
||||
await manager.deleteEvents([event1.eventId, event2.eventId]);
|
||||
|
||||
expect(actions.size).toBe(0);
|
||||
});
|
||||
|
||||
test('removes all events from storage', async () => {
|
||||
const { manager, uiActions, storage } = setup([event2, event1]);
|
||||
|
||||
uiActions.registerActionFactory(actionFactoryDefinition1);
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(await storage.list()).toEqual([event2, event1]);
|
||||
|
||||
await manager.deleteEvents([event1.eventId, event2.eventId]);
|
||||
|
||||
expect(await storage.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test('removes all events from UI state', async () => {
|
||||
const { manager, uiActions } = setup([event2, event1]);
|
||||
|
||||
uiActions.registerActionFactory(actionFactoryDefinition1);
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(manager.state.get().events).toEqual([event2, event1]);
|
||||
|
||||
await manager.deleteEvents([event1.eventId, event2.eventId]);
|
||||
|
||||
expect(manager.state.get().events).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,273 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { ActionStorage } from './dynamic_action_storage';
|
||||
import {
|
||||
TriggerContextMapping,
|
||||
UiActionsActionDefinition as ActionDefinition,
|
||||
} from '../../../../../src/plugins/ui_actions/public';
|
||||
import { defaultState, transitions, selectors, State } from './dynamic_action_manager_state';
|
||||
import { StateContainer, createStateContainer } from '../../../../../src/plugins/kibana_utils';
|
||||
import { StartContract } from '../plugin';
|
||||
import { SerializedAction, SerializedEvent } from './types';
|
||||
|
||||
const compareEvents = (
|
||||
a: ReadonlyArray<{ eventId: string }>,
|
||||
b: ReadonlyArray<{ eventId: string }>
|
||||
) => {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (a[i].eventId !== b[i].eventId) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
export type DynamicActionManagerState = State;
|
||||
|
||||
export interface DynamicActionManagerParams {
|
||||
storage: ActionStorage;
|
||||
uiActions: Pick<
|
||||
StartContract,
|
||||
'registerAction' | 'attachAction' | 'unregisterAction' | 'detachAction' | 'getActionFactory'
|
||||
>;
|
||||
isCompatible: <C = unknown>(context: C) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export class DynamicActionManager {
|
||||
static idPrefixCounter = 0;
|
||||
|
||||
private readonly idPrefix = `D_ACTION_${DynamicActionManager.idPrefixCounter++}_`;
|
||||
private stopped: boolean = false;
|
||||
private reloadSubscription?: Subscription;
|
||||
|
||||
/**
|
||||
* UI State of the dynamic action manager.
|
||||
*/
|
||||
protected readonly ui = createStateContainer(defaultState, transitions, selectors);
|
||||
|
||||
constructor(protected readonly params: DynamicActionManagerParams) {}
|
||||
|
||||
protected getEvent(eventId: string): SerializedEvent {
|
||||
const oldEvent = this.ui.selectors.getEvent(eventId);
|
||||
if (!oldEvent) throw new Error(`Could not find event [eventId = ${eventId}].`);
|
||||
return oldEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* We prefix action IDs with a unique `.idPrefix`, so we can render the
|
||||
* same dashboard twice on the screen.
|
||||
*/
|
||||
protected generateActionId(eventId: string): string {
|
||||
return this.idPrefix + eventId;
|
||||
}
|
||||
|
||||
protected reviveAction(event: SerializedEvent) {
|
||||
const { eventId, triggers, action } = event;
|
||||
const { uiActions, isCompatible } = this.params;
|
||||
|
||||
const actionId = this.generateActionId(eventId);
|
||||
const factory = uiActions.getActionFactory(event.action.factoryId);
|
||||
const actionDefinition: ActionDefinition = {
|
||||
...factory.create(action as SerializedAction<object>),
|
||||
id: actionId,
|
||||
isCompatible,
|
||||
};
|
||||
|
||||
uiActions.registerAction(actionDefinition);
|
||||
for (const trigger of triggers) uiActions.attachAction(trigger as any, actionId);
|
||||
}
|
||||
|
||||
protected killAction({ eventId, triggers }: SerializedEvent) {
|
||||
const { uiActions } = this.params;
|
||||
const actionId = this.generateActionId(eventId);
|
||||
|
||||
for (const trigger of triggers) uiActions.detachAction(trigger as any, actionId);
|
||||
uiActions.unregisterAction(actionId);
|
||||
}
|
||||
|
||||
private syncId = 0;
|
||||
|
||||
/**
|
||||
* This function is called every time stored events might have changed not by
|
||||
* us. For example, when in edit mode on dashboard user presses "back" button
|
||||
* in the browser, then contents of storage changes.
|
||||
*/
|
||||
private onSync = () => {
|
||||
if (this.stopped) return;
|
||||
|
||||
(async () => {
|
||||
const syncId = ++this.syncId;
|
||||
const events = await this.params.storage.list();
|
||||
|
||||
if (this.stopped) return;
|
||||
if (syncId !== this.syncId) return;
|
||||
if (compareEvents(events, this.ui.get().events)) return;
|
||||
|
||||
for (const event of this.ui.get().events) this.killAction(event);
|
||||
for (const event of events) this.reviveAction(event);
|
||||
this.ui.transitions.finishFetching(events);
|
||||
})().catch(error => {
|
||||
/* eslint-disable */
|
||||
console.log('Dynamic action manager storage reload failed.');
|
||||
console.error(error);
|
||||
/* eslint-enable */
|
||||
});
|
||||
};
|
||||
|
||||
// Public API: ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read-only state container of dynamic action manager. Use it to perform all
|
||||
* *read* operations.
|
||||
*/
|
||||
public readonly state: StateContainer<State> = this.ui;
|
||||
|
||||
/**
|
||||
* 1. Loads all events from @type {DynamicActionStorage} storage.
|
||||
* 2. Creates actions for each event in `ui_actions` registry.
|
||||
* 3. Adds events to UI state.
|
||||
* 4. Does nothing if dynamic action manager was stopped or if event fetching
|
||||
* is already taking place.
|
||||
*/
|
||||
public async start() {
|
||||
if (this.stopped) return;
|
||||
if (this.ui.get().isFetchingEvents) return;
|
||||
|
||||
this.ui.transitions.startFetching();
|
||||
try {
|
||||
const events = await this.params.storage.list();
|
||||
for (const event of events) this.reviveAction(event);
|
||||
this.ui.transitions.finishFetching(events);
|
||||
} catch (error) {
|
||||
this.ui.transitions.failFetching(error instanceof Error ? error : { message: String(error) });
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (this.params.storage.reload$) {
|
||||
this.reloadSubscription = this.params.storage.reload$.subscribe(this.onSync);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Removes all events from `ui_actions` registry.
|
||||
* 2. Puts dynamic action manager is stopped state.
|
||||
*/
|
||||
public async stop() {
|
||||
this.stopped = true;
|
||||
const events = await this.params.storage.list();
|
||||
|
||||
for (const event of events) {
|
||||
this.killAction(event);
|
||||
}
|
||||
|
||||
if (this.reloadSubscription) {
|
||||
this.reloadSubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new event.
|
||||
*
|
||||
* 1. Stores event in @type {DynamicActionStorage} storage.
|
||||
* 2. Optimistically adds it to UI state, and rolls back on failure.
|
||||
* 3. Adds action to `ui_actions` registry.
|
||||
*
|
||||
* @param action Dynamic action for which to create an event.
|
||||
* @param triggers List of triggers to which action should react.
|
||||
*/
|
||||
public async createEvent(
|
||||
action: SerializedAction<unknown>,
|
||||
triggers: Array<keyof TriggerContextMapping>
|
||||
) {
|
||||
const event: SerializedEvent = {
|
||||
eventId: uuidv4(),
|
||||
triggers,
|
||||
action,
|
||||
};
|
||||
|
||||
this.ui.transitions.addEvent(event);
|
||||
try {
|
||||
await this.params.storage.create(event);
|
||||
this.reviveAction(event);
|
||||
} catch (error) {
|
||||
this.ui.transitions.removeEvent(event.eventId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing event. Fails if event with given `eventId` does not
|
||||
* exit.
|
||||
*
|
||||
* 1. Updates the event in @type {DynamicActionStorage} storage.
|
||||
* 2. Optimistically replaces the old event by the new one in UI state, and
|
||||
* rolls back on failure.
|
||||
* 3. Replaces action in `ui_actions` registry with the new event.
|
||||
*
|
||||
*
|
||||
* @param eventId ID of the event to replace.
|
||||
* @param action New action for which to create the event.
|
||||
* @param triggers List of triggers to which action should react.
|
||||
*/
|
||||
public async updateEvent(
|
||||
eventId: string,
|
||||
action: SerializedAction<unknown>,
|
||||
triggers: Array<keyof TriggerContextMapping>
|
||||
) {
|
||||
const event: SerializedEvent = {
|
||||
eventId,
|
||||
triggers,
|
||||
action,
|
||||
};
|
||||
const oldEvent = this.getEvent(eventId);
|
||||
this.killAction(oldEvent);
|
||||
|
||||
this.reviveAction(event);
|
||||
this.ui.transitions.replaceEvent(event);
|
||||
|
||||
try {
|
||||
await this.params.storage.update(event);
|
||||
} catch (error) {
|
||||
this.killAction(event);
|
||||
this.reviveAction(oldEvent);
|
||||
this.ui.transitions.replaceEvent(oldEvent);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes existing event. Throws if event does not exist.
|
||||
*
|
||||
* 1. Removes the event from @type {DynamicActionStorage} storage.
|
||||
* 2. Optimistically removes event from UI state, and puts it back on failure.
|
||||
* 3. Removes associated action from `ui_actions` registry.
|
||||
*
|
||||
* @param eventId ID of the event to remove.
|
||||
*/
|
||||
public async deleteEvent(eventId: string) {
|
||||
const event = this.getEvent(eventId);
|
||||
|
||||
this.killAction(event);
|
||||
this.ui.transitions.removeEvent(eventId);
|
||||
|
||||
try {
|
||||
await this.params.storage.remove(eventId);
|
||||
} catch (error) {
|
||||
this.reviveAction(event);
|
||||
this.ui.transitions.addEvent(event);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes multiple events at once.
|
||||
*
|
||||
* @param eventIds List of event IDs.
|
||||
*/
|
||||
public async deleteEvents(eventIds: string[]) {
|
||||
await Promise.all(eventIds.map(this.deleteEvent.bind(this)));
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue