diff --git a/.ci/end2end.groovy b/.ci/end2end.groovy index 5cf6efe324ac3..38fed4aca19dc 100644 --- a/.ci/end2end.groovy +++ b/.ci/end2end.groovy @@ -25,7 +25,7 @@ pipeline { durabilityHint('PERFORMANCE_OPTIMIZED') } triggers { - issueCommentTrigger('(?i).*jenkins\\W+run\\W+(?:the\\W+)?e2e(?:\\W+please)?.*') + issueCommentTrigger('(?i)(retest|.*jenkins\\W+run\\W+(?:the\\W+)?e2e?.*)') } parameters { booleanParam(name: 'FORCE', defaultValue: false, description: 'Whether to force the run.') @@ -60,8 +60,14 @@ pipeline { } } steps { + notifyStatus('Starting services', 'PENDING') dir("${APM_ITS}"){ - sh './scripts/compose.py start master --no-kibana --no-xpack-secure' + sh './scripts/compose.py start master --no-kibana' + } + } + post { + unsuccessful { + notifyStatus('Environmental issue', 'FAILURE') } } } @@ -77,10 +83,16 @@ pipeline { JENKINS_NODE_COOKIE = 'dontKillMe' } steps { + notifyStatus('Preparing kibana', 'PENDING') dir("${BASE_DIR}"){ sh script: "${CYPRESS_DIR}/ci/prepare-kibana.sh" } } + post { + unsuccessful { + notifyStatus('Kibana warm up failed', 'FAILURE') + } + } } stage('Smoke Tests'){ options { skipDefaultCheckout() } @@ -91,6 +103,7 @@ pipeline { } } steps{ + notifyStatus('Running smoke tests', 'PENDING') dir("${BASE_DIR}"){ sh ''' jobs -l @@ -112,6 +125,12 @@ pipeline { archiveArtifacts(allowEmptyArchive: false, artifacts: 'apm-its.log') } } + unsuccessful { + notifyStatus('Test failures', 'FAILURE') + } + success { + notifyStatus('Tests passed', 'SUCCESS') + } } } } @@ -123,3 +142,7 @@ pipeline { } } } + +def notifyStatus(String description, String status) { + withGithubNotify.notify('end2end-for-apm-ui', description, status, getBlueoceanDisplayURL()) +} diff --git a/examples/ui_action_examples/public/hello_world_trigger.ts b/examples/ui_action_examples/public/hello_world_trigger.ts index 999a7d9864707..929c9aecab17b 100644 --- a/examples/ui_action_examples/public/hello_world_trigger.ts +++ b/examples/ui_action_examples/public/hello_world_trigger.ts @@ -18,11 +18,9 @@ */ import { Trigger } from '../../../src/plugins/ui_actions/public'; -import { HELLO_WORLD_ACTION_TYPE } from './hello_world_action'; export const HELLO_WORLD_TRIGGER_ID = 'HELLO_WORLD_TRIGGER_ID'; export const helloWorldTrigger: Trigger = { id: HELLO_WORLD_TRIGGER_ID, - actionIds: [HELLO_WORLD_ACTION_TYPE], }; diff --git a/examples/ui_action_examples/public/plugin.ts b/examples/ui_action_examples/public/plugin.ts index ef0689227d6bd..bf62b4d973d4d 100644 --- a/examples/ui_action_examples/public/plugin.ts +++ b/examples/ui_action_examples/public/plugin.ts @@ -19,7 +19,7 @@ import { Plugin, CoreSetup, CoreStart } from '../../../src/core/public'; import { UiActionsSetup, UiActionsStart } from '../../../src/plugins/ui_actions/public'; -import { createHelloWorldAction } from './hello_world_action'; +import { createHelloWorldAction, HELLO_WORLD_ACTION_TYPE } from './hello_world_action'; import { helloWorldTrigger } from './hello_world_trigger'; interface UiActionExamplesSetupDependencies { @@ -33,8 +33,9 @@ interface UiActionExamplesStartDependencies { export class UiActionExamplesPlugin implements Plugin { - public setup(core: CoreSetup, deps: UiActionExamplesSetupDependencies) { - deps.uiActions.registerTrigger(helloWorldTrigger); + public setup(core: CoreSetup, { uiActions }: UiActionExamplesSetupDependencies) { + uiActions.registerTrigger(helloWorldTrigger); + uiActions.attachAction(helloWorldTrigger.id, HELLO_WORLD_ACTION_TYPE); } public start(coreStart: CoreStart, deps: UiActionExamplesStartDependencies) { diff --git a/examples/ui_actions_explorer/public/plugin.tsx b/examples/ui_actions_explorer/public/plugin.tsx index 9c5f967a466bf..981ad97a31b46 100644 --- a/examples/ui_actions_explorer/public/plugin.tsx +++ b/examples/ui_actions_explorer/public/plugin.tsx @@ -56,15 +56,12 @@ export class UiActionsExplorerPlugin implements Plugin, deps: SetupDeps) { deps.uiActions.registerTrigger({ id: COUNTRY_TRIGGER, - actionIds: [], }); deps.uiActions.registerTrigger({ id: PHONE_TRIGGER, - actionIds: [], }); deps.uiActions.registerTrigger({ id: USER_TRIGGER, - actionIds: [], }); deps.uiActions.registerAction(lookUpWeatherAction); deps.uiActions.registerAction(viewInMapsAction); diff --git a/src/legacy/core_plugins/kibana/index.js b/src/legacy/core_plugins/kibana/index.js index ea81193c1dd0a..8e6bae0b588bc 100644 --- a/src/legacy/core_plugins/kibana/index.js +++ b/src/legacy/core_plugins/kibana/index.js @@ -77,6 +77,7 @@ export default function(kibana) { order: -1003, url: `${kbnBaseUrl}#/discover`, euiIconType: 'discoverApp', + disableSubUrlTracking: true, category: DEFAULT_APP_CATEGORIES.analyze, }, { @@ -87,6 +88,7 @@ export default function(kibana) { order: -1002, url: `${kbnBaseUrl}#/visualize`, euiIconType: 'visualizeApp', + disableSubUrlTracking: true, category: DEFAULT_APP_CATEGORIES.analyze, }, { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts index fa5354a17b6d9..fe7beafcad18c 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts @@ -165,7 +165,7 @@ export class DashboardStateManager { // make sure url ('_a') matches initial state this.kbnUrlStateStorage.set(this.STATE_STORAGE_KEY, initialState, { replace: true }); - // setup state syncing utils. state container will be synched with url into `this.STATE_STORAGE_KEY` query param + // setup state syncing utils. state container will be synced with url into `this.STATE_STORAGE_KEY` query param this.stateSyncRef = syncState({ storageKey: this.STATE_STORAGE_KEY, stateContainer: { @@ -173,10 +173,20 @@ export class DashboardStateManager { set: (state: DashboardAppState | null) => { // sync state required state container to be able to handle null // overriding set() so it could handle null coming from url - this.stateContainer.set({ - ...this.stateDefaults, - ...state, - }); + if (state) { + this.stateContainer.set({ + ...this.stateDefaults, + ...state, + }); + } else { + // Do nothing in case when state from url is empty, + // this fixes: https://github.com/elastic/kibana/issues/57789 + // There are not much cases when state in url could become empty: + // 1. User manually removed `_a` from the url + // 2. Browser is navigating away from the page and most likely there is no `_a` in the url. + // In this case we don't want to do any state updates + // and just allow $scope.$on('destroy') fire later and clean up everything + } }, }, stateStorage: this.kbnUrlStateStorage, diff --git a/src/legacy/core_plugins/kibana/public/discover/plugin.ts b/src/legacy/core_plugins/kibana/public/discover/plugin.ts index 565382313e369..e8ded9d99f892 100644 --- a/src/legacy/core_plugins/kibana/public/discover/plugin.ts +++ b/src/legacy/core_plugins/kibana/public/discover/plugin.ts @@ -16,11 +16,17 @@ * specific language governing permissions and limitations * under the License. */ + +import { BehaviorSubject } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { AppMountParameters, CoreSetup, CoreStart, Plugin } from 'kibana/public'; import angular, { auto } from 'angular'; import { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; -import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { + DataPublicPluginStart, + DataPublicPluginSetup, + getQueryStateContainer, +} from '../../../../../plugins/data/public'; import { registerFeature } from './np_ready/register_feature'; import './kibana_services'; import { IEmbeddableStart, IEmbeddableSetup } from '../../../../../plugins/embeddable/public'; @@ -30,7 +36,10 @@ import { NavigationPublicPluginStart as NavigationStart } from '../../../../../p import { ChartsPluginStart } from '../../../../../plugins/charts/public'; import { buildServices } from './build_services'; import { SharePluginStart } from '../../../../../plugins/share/public'; -import { KibanaLegacySetup } from '../../../../../plugins/kibana_legacy/public'; +import { + KibanaLegacySetup, + AngularRenderedAppUpdater, +} from '../../../../../plugins/kibana_legacy/public'; import { DocViewsRegistry } from './np_ready/doc_views/doc_views_registry'; import { DocViewInput, DocViewInputFn } from './np_ready/doc_views/doc_views_types'; import { DocViewTable } from './np_ready/components/table/table'; @@ -40,6 +49,7 @@ import { VisualizationsStart, VisualizationsSetup, } from '../../../visualizations/public/np_ready/public'; +import { createKbnUrlTracker } from '../../../../../plugins/kibana_utils/public'; /** * These are the interfaces with your public contracts. You should export these @@ -56,6 +66,7 @@ export interface DiscoverSetupPlugins { kibanaLegacy: KibanaLegacySetup; home: HomePublicPluginSetup; visualizations: VisualizationsSetup; + data: DataPublicPluginSetup; } export interface DiscoverStartPlugins { uiActions: UiActionsStart; @@ -81,6 +92,9 @@ export class DiscoverPlugin implements Plugin { private docViewsRegistry: DocViewsRegistry | null = null; private embeddableInjector: auto.IInjectorService | null = null; private getEmbeddableInjector: (() => Promise) | null = null; + private appStateUpdater = new BehaviorSubject(() => ({})); + private stopUrlTracking: (() => void) | undefined = undefined; + /** * why are those functions public? they are needed for some mocha tests * can be removed once all is Jest @@ -89,6 +103,27 @@ export class DiscoverPlugin implements Plugin { public initializeServices?: () => Promise<{ core: CoreStart; plugins: DiscoverStartPlugins }>; setup(core: CoreSetup, plugins: DiscoverSetupPlugins): DiscoverSetup { + const { querySyncStateContainer, stop: stopQuerySyncStateContainer } = getQueryStateContainer( + plugins.data.query + ); + const { appMounted, appUnMounted, stop: stopUrlTracker } = createKbnUrlTracker({ + baseUrl: core.http.basePath.prepend('/app/kibana'), + defaultSubUrl: '#/discover', + storageKey: 'lastUrl:discover', + navLinkUpdater$: this.appStateUpdater, + toastNotifications: core.notifications.toasts, + stateParams: [ + { + kbnUrlKey: '_g', + stateUpdate$: querySyncStateContainer.state$, + }, + ], + }); + this.stopUrlTracking = () => { + stopQuerySyncStateContainer(); + stopUrlTracker(); + }; + this.getEmbeddableInjector = this.getInjector.bind(this); this.docViewsRegistry = new DocViewsRegistry(this.getEmbeddableInjector); this.docViewsRegistry.addDocView({ @@ -108,6 +143,8 @@ export class DiscoverPlugin implements Plugin { plugins.kibanaLegacy.registerLegacyApp({ id: 'discover', title: 'Discover', + updater$: this.appStateUpdater.asObservable(), + navLinkId: 'kibana:discover', order: -1004, euiIconType: 'discoverApp', mount: async (params: AppMountParameters) => { @@ -117,11 +154,16 @@ export class DiscoverPlugin implements Plugin { if (!this.initializeInnerAngular) { throw Error('Discover plugin method initializeInnerAngular is undefined'); } + appMounted(); await this.initializeServices(); await this.initializeInnerAngular(); const { renderApp } = await import('./np_ready/application'); - return renderApp(innerAngularName, params.element); + const unmount = await renderApp(innerAngularName, params.element); + return () => { + unmount(); + appUnMounted(); + }; }, }); registerFeature(plugins.home); @@ -160,6 +202,12 @@ export class DiscoverPlugin implements Plugin { this.registerEmbeddable(core, plugins); } + stop() { + if (this.stopUrlTracking) { + this.stopUrlTracking(); + } + } + /** * register embeddable with a slimmer embeddable version of inner angular */ diff --git a/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts b/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts index 6082fb8428ac3..096877d5824c4 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts @@ -35,7 +35,6 @@ import { DataPublicPluginStart, IndexPatternsContract } from '../../../../../plu import { VisualizationsStart } from '../../../visualizations/public'; import { SavedVisualizations } from './np_ready/types'; import { UsageCollectionSetup } from '../../../../../plugins/usage_collection/public'; -import { Chrome } from './legacy_imports'; import { KibanaLegacyStart } from '../../../../../plugins/kibana_legacy/public'; export interface VisualizeKibanaServices { @@ -47,7 +46,6 @@ export interface VisualizeKibanaServices { embeddable: IEmbeddableStart; getBasePath: () => string; indexPatterns: IndexPatternsContract; - legacyChrome: Chrome; localStorage: Storage; navigation: NavigationStart; toastNotifications: ToastsStart; @@ -61,6 +59,7 @@ export interface VisualizeKibanaServices { visualizations: VisualizationsStart; usageCollection?: UsageCollectionSetup; I18nContext: I18nStart['Context']; + setActiveUrl: (newUrl: string) => void; } let services: VisualizeKibanaServices | null = null; diff --git a/src/legacy/core_plugins/kibana/public/visualize/legacy.ts b/src/legacy/core_plugins/kibana/public/visualize/legacy.ts index bc2d700f6c6a1..fbbc7ab944daf 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/legacy.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/legacy.ts @@ -18,19 +18,14 @@ */ import { PluginInitializerContext } from 'kibana/public'; -import { legacyChrome, npSetup, npStart } from './legacy_imports'; +import { npSetup, npStart } from 'ui/new_platform'; import { start as visualizations } from '../../../visualizations/public/np_ready/public/legacy'; import { plugin } from './index'; const instance = plugin({ env: npSetup.plugins.kibanaLegacy.env, } as PluginInitializerContext); -instance.setup(npSetup.core, { - ...npSetup.plugins, - __LEGACY: { - legacyChrome, - }, -}); +instance.setup(npSetup.core, npSetup.plugins); instance.start(npStart.core, { ...npStart.plugins, visualizations, diff --git a/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts b/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts index ac9fc227406ff..92433799ba420 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/legacy_imports.ts @@ -24,11 +24,6 @@ * directly where they are needed. */ -import chrome from 'ui/chrome'; - -export const legacyChrome = chrome; -export { Chrome } from 'ui/chrome'; - // @ts-ignore export { AppState, AppStateProvider } from 'ui/state_management/app_state'; export { State } from 'ui/state_management/state'; @@ -39,8 +34,6 @@ export { StateManagementConfigProvider } from 'ui/state_management/config_provid export { stateMonitorFactory } from 'ui/state_management/state_monitor_factory'; export { PersistedState } from 'ui/persisted_state'; -export { npSetup, npStart } from 'ui/new_platform'; - export { subscribeWithScope } from 'ui/utils/subscribe_with_scope'; // @ts-ignore export { EventsProvider } from 'ui/events'; diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts b/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts index 3d5fd6605f56b..bd7b478f827a6 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/application.ts @@ -45,7 +45,7 @@ import { VisualizeKibanaServices } from '../kibana_services'; let angularModuleInstance: IModule | null = null; -export const renderApp = async ( +export const renderApp = ( element: HTMLElement, appBasePath: string, deps: VisualizeKibanaServices @@ -58,7 +58,6 @@ export const renderApp = async ( { core: deps.core, env: deps.pluginInitializerContext.env }, true ); - // custom routing stuff initVisualizeApp(angularModuleInstance, deps); } const $injector = mountVisualizeApp(appBasePath, element); diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js index 657104344662f..409d4b41fbe69 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js @@ -90,13 +90,13 @@ function VisualizeAppController( }, }, toastNotifications, - legacyChrome, chrome, getBasePath, core: { docLinks }, savedQueryService, uiSettings, I18nContext, + setActiveUrl, } = getServices(); const filterStateManager = new FilterStateManager(globalState, getAppState, filterManager); @@ -580,10 +580,7 @@ function VisualizeAppController( }); // Manually insert a new url so the back button will open the saved visualization. $window.history.pushState({}, '', savedVisualizationParsedUrl.getRootRelativePath()); - // Since we aren't reloading the page, only inserting a new browser history item, we need to manually update - // the last url for this app, so directly clicking on the Visualize tab will also bring the user to the saved - // url, not the unsaved one. - legacyChrome.trackSubUrlForApp('kibana:visualize', savedVisualizationParsedUrl); + setActiveUrl(savedVisualizationParsedUrl.appPath); const lastDashboardAbsoluteUrl = chrome.navLinks.get('kibana:dashboard').url; const dashboardParsedUrl = absoluteToParsedUrl( diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing.js index cae1e40cd445a..c0cc499b598f0 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing.js @@ -36,7 +36,6 @@ export function VisualizeListingController($injector, $scope, createNewVis) { const { addBasePath, chrome, - legacyChrome, savedObjectsClient, savedVisualizations, data: { @@ -100,17 +99,13 @@ export function VisualizeListingController($injector, $scope, createNewVis) { selectedItems.map(item => { return savedObjectsClient.delete(item.savedObjectType, item.id); }) - ) - .then(() => { - legacyChrome.untrackNavLinksForDeletedSavedObjects(selectedItems.map(item => item.id)); - }) - .catch(error => { - toastNotifications.addError(error, { - title: i18n.translate('kbn.visualize.visualizeListingDeleteErrorTitle', { - defaultMessage: 'Error deleting visualization', - }), - }); + ).catch(error => { + toastNotifications.addError(error, { + title: i18n.translate('kbn.visualize.visualizeListingDeleteErrorTitle', { + defaultMessage: 'Error deleting visualization', + }), }); + }); }; chrome.setBreadcrumbs([ diff --git a/src/legacy/core_plugins/kibana/public/visualize/plugin.ts b/src/legacy/core_plugins/kibana/public/visualize/plugin.ts index 16715677d1e20..22804685db3cc 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/plugin.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/plugin.ts @@ -17,6 +17,7 @@ * under the License. */ +import { BehaviorSubject } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { @@ -28,12 +29,19 @@ import { SavedObjectsClientContract, } from 'kibana/public'; -import { Storage } from '../../../../../plugins/kibana_utils/public'; -import { DataPublicPluginStart } from '../../../../../plugins/data/public'; +import { Storage, createKbnUrlTracker } from '../../../../../plugins/kibana_utils/public'; +import { + DataPublicPluginStart, + DataPublicPluginSetup, + getQueryStateContainer, +} from '../../../../../plugins/data/public'; import { IEmbeddableStart } from '../../../../../plugins/embeddable/public'; import { NavigationPublicPluginStart as NavigationStart } from '../../../../../plugins/navigation/public'; import { SharePluginStart } from '../../../../../plugins/share/public'; -import { KibanaLegacySetup } from '../../../../../plugins/kibana_legacy/public'; +import { + KibanaLegacySetup, + AngularRenderedAppUpdater, +} from '../../../../../plugins/kibana_legacy/public'; import { VisualizationsStart } from '../../../visualizations/public'; import { VisualizeConstants } from './np_ready/visualize_constants'; import { setServices, VisualizeKibanaServices } from './kibana_services'; @@ -42,7 +50,6 @@ import { HomePublicPluginSetup, } from '../../../../../plugins/home/public'; import { UsageCollectionSetup } from '../../../../../plugins/usage_collection/public'; -import { Chrome } from './legacy_imports'; export interface VisualizePluginStartDependencies { data: DataPublicPluginStart; @@ -53,12 +60,10 @@ export interface VisualizePluginStartDependencies { } export interface VisualizePluginSetupDependencies { - __LEGACY: { - legacyChrome: Chrome; - }; home: HomePublicPluginSetup; kibanaLegacy: KibanaLegacySetup; usageCollection?: UsageCollectionSetup; + data: DataPublicPluginSetup; } export class VisualizePlugin implements Plugin { @@ -70,46 +75,72 @@ export class VisualizePlugin implements Plugin { share: SharePluginStart; visualizations: VisualizationsStart; } | null = null; + private appStateUpdater = new BehaviorSubject(() => ({})); + private stopUrlTracking: (() => void) | undefined = undefined; constructor(private initializerContext: PluginInitializerContext) {} public async setup( core: CoreSetup, - { home, kibanaLegacy, __LEGACY, usageCollection }: VisualizePluginSetupDependencies + { home, kibanaLegacy, usageCollection, data }: VisualizePluginSetupDependencies ) { + const { querySyncStateContainer, stop: stopQuerySyncStateContainer } = getQueryStateContainer( + data.query + ); + const { appMounted, appUnMounted, stop: stopUrlTracker, setActiveUrl } = createKbnUrlTracker({ + baseUrl: core.http.basePath.prepend('/app/kibana'), + defaultSubUrl: '#/visualize', + storageKey: 'lastUrl:visualize', + navLinkUpdater$: this.appStateUpdater, + toastNotifications: core.notifications.toasts, + stateParams: [ + { + kbnUrlKey: '_g', + stateUpdate$: querySyncStateContainer.state$, + }, + ], + }); + this.stopUrlTracking = () => { + stopQuerySyncStateContainer(); + stopUrlTracker(); + }; + kibanaLegacy.registerLegacyApp({ id: 'visualize', title: 'Visualize', + updater$: this.appStateUpdater.asObservable(), + navLinkId: 'kibana:visualize', mount: async (params: AppMountParameters) => { const [coreStart] = await core.getStartServices(); + if (this.startDependencies === null) { throw new Error('not started yet'); } + appMounted(); const { savedObjectsClient, embeddable, navigation, visualizations, - data, + data: dataStart, share, } = this.startDependencies; const deps: VisualizeKibanaServices = { - ...__LEGACY, pluginInitializerContext: this.initializerContext, addBasePath: coreStart.http.basePath.prepend, core: coreStart, chrome: coreStart.chrome, - data, + data: dataStart, embeddable, getBasePath: core.http.basePath.get, - indexPatterns: data.indexPatterns, + indexPatterns: dataStart.indexPatterns, localStorage: new Storage(localStorage), navigation, savedObjectsClient, savedVisualizations: visualizations.getSavedVisualizationsLoader(), - savedQueryService: data.query.savedQueries, + savedQueryService: dataStart.query.savedQueries, share, toastNotifications: coreStart.notifications.toasts, uiSettings: coreStart.uiSettings, @@ -118,11 +149,16 @@ export class VisualizePlugin implements Plugin { visualizations, usageCollection, I18nContext: coreStart.i18n.Context, + setActiveUrl, }; setServices(deps); const { renderApp } = await import('./np_ready/application'); - return renderApp(params.element, params.appBasePath, deps); + const unmount = renderApp(params.element, params.appBasePath, deps); + return () => { + unmount(); + appUnMounted(); + }; }, }); @@ -153,4 +189,10 @@ export class VisualizePlugin implements Plugin { visualizations, }; } + + stop() { + if (this.stopUrlTracking) { + this.stopUrlTracking(); + } + } } diff --git a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js index 4e52f6f6bafec..38b3434ef9c48 100644 --- a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js +++ b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js @@ -61,6 +61,10 @@ const mockCore = { }, }; +let refreshInterval = undefined; +let isTimeRangeSelectorEnabled = true; +let isAutoRefreshSelectorEnabled = true; + export const npSetup = { core: mockCore, plugins: { @@ -101,7 +105,14 @@ export const npSetup = { }, query: { filterManager: { + getFetches$: sinon.fake(), + getFilters: sinon.fake(), + getAppFilters: sinon.fake(), getGlobalFilters: sinon.fake(), + removeFilter: sinon.fake(), + addFilters: sinon.fake(), + setFilters: sinon.fake(), + removeAll: sinon.fake(), getUpdates$: mockObservable, }, timefilter: { @@ -110,6 +121,41 @@ export const npSetup = { getRefreshInterval: sinon.fake(), getTimeUpdate$: mockObservable, getRefreshIntervalUpdate$: mockObservable, + getFetch$: mockObservable, + getAutoRefreshFetch$: mockObservable, + getEnabledUpdated$: mockObservable, + getTimeUpdate$: mockObservable, + getRefreshIntervalUpdate$: mockObservable, + isTimeRangeSelectorEnabled: () => { + return isTimeRangeSelectorEnabled; + }, + isAutoRefreshSelectorEnabled: () => { + return isAutoRefreshSelectorEnabled; + }, + disableAutoRefreshSelector: () => { + isAutoRefreshSelectorEnabled = false; + }, + enableAutoRefreshSelector: () => { + isAutoRefreshSelectorEnabled = true; + }, + getRefreshInterval: () => { + return refreshInterval; + }, + setRefreshInterval: interval => { + refreshInterval = interval; + }, + enableTimeRangeSelector: () => { + isTimeRangeSelectorEnabled = true; + }, + disableTimeRangeSelector: () => { + isTimeRangeSelectorEnabled = false; + }, + getTime: sinon.fake(), + setTime: sinon.fake(), + getActiveBounds: sinon.fake(), + getBounds: sinon.fake(), + calculateBounds: sinon.fake(), + createFilter: sinon.fake(), }, history: sinon.fake(), }, @@ -183,10 +229,6 @@ export const npSetup = { }, }; -let refreshInterval = undefined; -let isTimeRangeSelectorEnabled = true; -let isAutoRefreshSelectorEnabled = true; - export const npStart = { core: { chrome: { diff --git a/src/plugins/embeddable/public/bootstrap.ts b/src/plugins/embeddable/public/bootstrap.ts index 3ca84549c559d..9a364e84092ca 100644 --- a/src/plugins/embeddable/public/bootstrap.ts +++ b/src/plugins/embeddable/public/bootstrap.ts @@ -17,7 +17,7 @@ * under the License. */ -import { UiActionsSetup } from 'src/plugins/ui_actions/public'; +import { UiActionsSetup, Trigger } from 'src/plugins/ui_actions/public'; import { CONTEXT_MENU_TRIGGER, APPLY_FILTER_TRIGGER, @@ -34,35 +34,30 @@ import { * @param api */ export const bootstrap = (uiActions: UiActionsSetup) => { - const triggerContext = { + const triggerContext: Trigger = { id: CONTEXT_MENU_TRIGGER, title: 'Context menu', description: 'Triggered on top-right corner context-menu select.', - actionIds: [], }; - const triggerFilter = { + const triggerFilter: Trigger = { id: APPLY_FILTER_TRIGGER, title: 'Filter click', description: 'Triggered when user applies filter to an embeddable.', - actionIds: [], }; - const triggerBadge = { + const triggerBadge: Trigger = { id: PANEL_BADGE_TRIGGER, title: 'Panel badges', description: 'Actions appear in title bar when an embeddable loads in a panel', - actionIds: [], }; - const selectRangeTrigger = { + const selectRangeTrigger: Trigger = { id: SELECT_RANGE_TRIGGER, title: 'Select range', description: 'Applies a range filter', - actionIds: [], }; - const valueClickTrigger = { + const valueClickTrigger: Trigger = { id: VALUE_CLICK_TRIGGER, title: 'Value clicked', description: 'Value was clicked', - actionIds: [], }; const actionApplyFilter = createFilterAction(); diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 9982c632f36fb..79d59317767d9 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -25,7 +25,7 @@ import { nextTick } from 'test_utils/enzyme_helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; import { I18nProvider } from '@kbn/i18n/react'; import { CONTEXT_MENU_TRIGGER } from '../triggers'; -import { Action, UiActionsApi } from 'src/plugins/ui_actions/public'; +import { Action, UiActionsStart } from 'src/plugins/ui_actions/public'; import { Trigger, GetEmbeddableFactory, ViewMode } from '../types'; import { EmbeddableFactory, isErrorEmbeddable } from '../embeddables'; import { EmbeddablePanel } from './embeddable_panel'; @@ -52,7 +52,6 @@ const getEmbeddableFactory: GetEmbeddableFactory = (id: string) => embeddableFac const editModeAction = createEditModeAction(); const trigger: Trigger = { id: CONTEXT_MENU_TRIGGER, - actionIds: [editModeAction.id], }; const embeddableFactory = new ContactCardEmbeddableFactory( {} as any, @@ -177,7 +176,7 @@ test('HelloWorldContainer in view mode hides edit mode actions', async () => { const renderInEditModeAndOpenContextMenu = async ( embeddableInputs: any, - getActions: UiActionsApi['getTriggerCompatibleActions'] = () => Promise.resolve([]) + getActions: UiActionsStart['getTriggerCompatibleActions'] = () => Promise.resolve([]) ) => { const inspector = inspectorPluginMock.createStartContract(); diff --git a/src/plugins/embeddable/public/lib/types.ts b/src/plugins/embeddable/public/lib/types.ts index 1bd71163db44c..68ea5bc17f7c9 100644 --- a/src/plugins/embeddable/public/lib/types.ts +++ b/src/plugins/embeddable/public/lib/types.ts @@ -24,7 +24,6 @@ export interface Trigger { id: string; title?: string; description?: string; - actionIds: string[]; } export interface PropertySpec { diff --git a/src/plugins/embeddable/public/tests/test_plugin.ts b/src/plugins/embeddable/public/tests/test_plugin.ts index d9e1a75d92bf3..1edc332780336 100644 --- a/src/plugins/embeddable/public/tests/test_plugin.ts +++ b/src/plugins/embeddable/public/tests/test_plugin.ts @@ -19,8 +19,8 @@ import { CoreSetup, CoreStart } from 'src/core/public'; // eslint-disable-next-line -import { uiActionsTestPlugin } from 'src/plugins/ui_actions/public/tests'; -import { UiActionsApi } from 'src/plugins/ui_actions/public'; +import { uiActionsPluginMock } from 'src/plugins/ui_actions/public/mocks'; +import { UiActionsStart } from 'src/plugins/ui_actions/public'; import { coreMock } from '../../../../core/public/mocks'; import { EmbeddablePublicPlugin, IEmbeddableSetup, IEmbeddableStart } from '../plugin'; @@ -30,14 +30,14 @@ export interface TestPluginReturn { coreStart: CoreStart; setup: IEmbeddableSetup; doStart: (anotherCoreStart?: CoreStart) => IEmbeddableStart; - uiActions: UiActionsApi; + uiActions: UiActionsStart; } export const testPlugin = ( coreSetup: CoreSetup = coreMock.createSetup(), coreStart: CoreStart = coreMock.createStart() ): TestPluginReturn => { - const uiActions = uiActionsTestPlugin(coreSetup, coreStart); + const uiActions = uiActionsPluginMock.createPlugin(coreSetup, coreStart); const initializerContext = {} as any; const plugin = new EmbeddablePublicPlugin(initializerContext); const setup = plugin.setup(coreSetup, { uiActions: uiActions.setup }); diff --git a/src/plugins/expressions/common/execution/execution.test.ts b/src/plugins/expressions/common/execution/execution.test.ts index 3937bd309327d..eeb1ed80e8d0d 100644 --- a/src/plugins/expressions/common/execution/execution.test.ts +++ b/src/plugins/expressions/common/execution/execution.test.ts @@ -21,6 +21,7 @@ import { Execution } from './execution'; import { parseExpression } from '../ast'; import { createUnitTestExecutor } from '../test_helpers'; import { ExpressionFunctionDefinition } from '../../public'; +import { ExecutionContract } from './execution_contract'; const createExecution = ( expression: string = 'foo bar=123', @@ -48,7 +49,7 @@ const run = async ( describe('Execution', () => { test('can instantiate', () => { const execution = createExecution('foo bar=123'); - expect(execution.params.ast.chain[0].arguments.bar).toEqual([123]); + expect(execution.state.get().ast.chain[0].arguments.bar).toEqual([123]); }); test('initial input is null at creation', () => { @@ -127,6 +128,40 @@ describe('Execution', () => { }); }); + describe('.expression', () => { + test('uses expression passed in to constructor', () => { + const expression = 'add val="1"'; + const executor = createUnitTestExecutor(); + const execution = new Execution({ + executor, + expression, + }); + expect(execution.expression).toBe(expression); + }); + + test('generates expression from AST if not passed to constructor', () => { + const expression = 'add val="1"'; + const executor = createUnitTestExecutor(); + const execution = new Execution({ + ast: parseExpression(expression), + executor, + }); + expect(execution.expression).toBe(expression); + }); + }); + + describe('.contract', () => { + test('is instance of ExecutionContract', () => { + const execution = createExecution('add val=1'); + expect(execution.contract).toBeInstanceOf(ExecutionContract); + }); + + test('execution returns the same expression string', () => { + const execution = createExecution('add val=1'); + expect(execution.expression).toBe(execution.contract.getExpression()); + }); + }); + describe('execution context', () => { test('context.variables is an object', async () => { const { result } = (await run('introspectContext key="variables"')) as any; diff --git a/src/plugins/expressions/common/execution/execution.ts b/src/plugins/expressions/common/execution/execution.ts index 7f4efafc13de8..2a272e187cffc 100644 --- a/src/plugins/expressions/common/execution/execution.ts +++ b/src/plugins/expressions/common/execution/execution.ts @@ -24,17 +24,25 @@ import { createError } from '../util'; import { Defer } from '../../../kibana_utils/common'; import { RequestAdapter, DataAdapter } from '../../../inspector/common'; import { isExpressionValueError } from '../expression_types/specs/error'; -import { ExpressionAstExpression, ExpressionAstFunction, parse } from '../ast'; +import { + ExpressionAstExpression, + ExpressionAstFunction, + parse, + formatExpression, + parseExpression, +} from '../ast'; import { ExecutionContext, DefaultInspectorAdapters } from './types'; import { getType } from '../expression_types'; import { ArgumentType, ExpressionFunction } from '../expression_functions'; import { getByAlias } from '../util/get_by_alias'; +import { ExecutionContract } from './execution_contract'; export interface ExecutionParams< ExtraContext extends Record = Record > { executor: Executor; - ast: ExpressionAstExpression; + ast?: ExpressionAstExpression; + expression?: string; context?: ExtraContext; } @@ -85,6 +93,19 @@ export class Execution< */ private readonly firstResultFuture = new Defer(); + /** + * Contract is a public representation of `Execution` instances. Contract we + * can return to other plugins for their consumption. + */ + public readonly contract: ExecutionContract< + ExtraContext, + Input, + Output, + InspectorAdapters + > = new ExecutionContract(this); + + public readonly expression: string; + public get result(): Promise { return this.firstResultFuture.promise; } @@ -94,7 +115,17 @@ export class Execution< } constructor(public readonly params: ExecutionParams) { - const { executor, ast } = params; + const { executor } = params; + + if (!params.ast && !params.expression) { + throw new TypeError('Execution params should contain at least .ast or .expression key.'); + } else if (params.ast && params.expression) { + throw new TypeError('Execution params cannot contain both .ast and .expression key.'); + } + + this.expression = params.expression || formatExpression(params.ast!); + const ast = params.ast || parseExpression(this.expression); + this.state = createExecutionContainer({ ...executor.state.get(), state: 'not-started', diff --git a/src/plugins/expressions/common/execution/execution_contract.test.ts b/src/plugins/expressions/common/execution/execution_contract.test.ts new file mode 100644 index 0000000000000..c33f8a1a0f36e --- /dev/null +++ b/src/plugins/expressions/common/execution/execution_contract.test.ts @@ -0,0 +1,140 @@ +/* + * 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 { Execution } from './execution'; +import { parseExpression } from '../ast'; +import { createUnitTestExecutor } from '../test_helpers'; +import { ExecutionContract } from './execution_contract'; + +const createExecution = ( + expression: string = 'foo bar=123', + context: Record = {} +) => { + const executor = createUnitTestExecutor(); + const execution = new Execution({ + executor, + ast: parseExpression(expression), + context, + }); + return execution; +}; + +describe('ExecutionContract', () => { + test('can instantiate', () => { + const execution = createExecution('foo bar=123'); + const contract = new ExecutionContract(execution); + expect(contract).toBeInstanceOf(ExecutionContract); + }); + + test('can get the AST of expression', () => { + const execution = createExecution('foo bar=123'); + const contract = new ExecutionContract(execution); + expect(contract.getAst()).toMatchObject({ + type: 'expression', + chain: expect.any(Array), + }); + }); + + test('can get expression string', () => { + const execution = createExecution('foo bar=123'); + const contract = new ExecutionContract(execution); + expect(contract.getExpression()).toBe('foo bar=123'); + }); + + test('can cancel execution', () => { + const execution = createExecution('foo bar=123'); + const spy = jest.spyOn(execution, 'cancel'); + const contract = new ExecutionContract(execution); + + expect(spy).toHaveBeenCalledTimes(0); + contract.cancel(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test('can get inspector adapters', () => { + const execution = createExecution('foo bar=123'); + const contract = new ExecutionContract(execution); + expect(contract.inspect()).toMatchObject({ + data: expect.any(Object), + requests: expect.any(Object), + }); + }); + + test('can get error result of the expression execution', async () => { + const execution = createExecution('foo bar=123'); + const contract = new ExecutionContract(execution); + execution.start(); + + const result = await contract.getData(); + + expect(result).toMatchObject({ + type: 'error', + }); + }); + + test('can get result of the expression execution', async () => { + const execution = createExecution('var_set name="foo" value="bar" | var name="foo"'); + const contract = new ExecutionContract(execution); + execution.start(); + + const result = await contract.getData(); + + expect(result).toBe('bar'); + }); + + describe('isPending', () => { + test('is true if execution has not been started', async () => { + const execution = createExecution('var_set name="foo" value="bar" | var name="foo"'); + const contract = new ExecutionContract(execution); + expect(contract.isPending).toBe(true); + }); + + test('is true when execution just started', async () => { + const execution = createExecution('var_set name="foo" value="bar" | var name="foo"'); + const contract = new ExecutionContract(execution); + + execution.start(); + + expect(contract.isPending).toBe(true); + }); + + test('is false when execution finished successfully', async () => { + const execution = createExecution('var_set name="foo" value="bar" | var name="foo"'); + const contract = new ExecutionContract(execution); + + execution.start(); + await execution.result; + + expect(contract.isPending).toBe(false); + expect(execution.state.get().state).toBe('result'); + }); + + test('is false when execution finished with error', async () => { + const execution = createExecution('var_set name="foo" value="bar" | var name="foo"'); + const contract = new ExecutionContract(execution); + + execution.start(); + await execution.result; + execution.state.get().state = 'error'; + + expect(contract.isPending).toBe(false); + expect(execution.state.get().state).toBe('error'); + }); + }); +}); diff --git a/src/plugins/expressions/common/execution/execution_contract.ts b/src/plugins/expressions/common/execution/execution_contract.ts new file mode 100644 index 0000000000000..8c784352b9fdf --- /dev/null +++ b/src/plugins/expressions/common/execution/execution_contract.ts @@ -0,0 +1,90 @@ +/* + * 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 { Execution } from './execution'; + +/** + * `ExecutionContract` is a wrapper around `Execution` class. It provides the + * same functionality but does not expose Expressions plugin internals. + */ +export class ExecutionContract< + ExtraContext extends Record = Record, + Input = unknown, + Output = unknown, + InspectorAdapters = unknown +> { + public get isPending(): boolean { + const state = this.execution.state.get().state; + const finished = state === 'error' || state === 'result'; + return !finished; + } + + constructor( + protected readonly execution: Execution + ) {} + + /** + * Cancel the execution of the expression. This will set abort signal + * (available in execution context) to aborted state, letting expression + * functions to stop their execution. + */ + cancel = () => { + this.execution.cancel(); + }; + + /** + * Returns the final output of expression, if any error happens still + * wraps that error into `ExpressionValueError` type and returns that. + * This function never throws. + */ + getData = async () => { + try { + return await this.execution.result; + } catch (e) { + return { + type: 'error', + error: { + type: e.type, + message: e.message, + stack: e.stack, + }, + }; + } + }; + + /** + * Get string representation of the expression. Returns the original string + * if execution was started from a string. If execution was started from an + * AST this method returns a string generated from AST. + */ + getExpression = () => { + return this.execution.expression; + }; + + /** + * Get AST used to execute the expression. + */ + getAst = () => this.execution.state.get().ast; + + /** + * Get Inspector adapters provided to all functions of expression through + * execution context. + */ + inspect = () => this.execution.inspectorAdapters; +} diff --git a/src/plugins/expressions/common/execution/index.ts b/src/plugins/expressions/common/execution/index.ts index 2452b0999d23e..fd5c0244438d7 100644 --- a/src/plugins/expressions/common/execution/index.ts +++ b/src/plugins/expressions/common/execution/index.ts @@ -20,3 +20,4 @@ export * from './types'; export * from './container'; export * from './execution'; +export * from './execution_contract'; diff --git a/src/plugins/expressions/common/executor/executor.execution.test.ts b/src/plugins/expressions/common/executor/executor.execution.test.ts new file mode 100644 index 0000000000000..eec7b5c907e29 --- /dev/null +++ b/src/plugins/expressions/common/executor/executor.execution.test.ts @@ -0,0 +1,59 @@ +/* + * 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 { Executor } from './executor'; +import { parseExpression } from '../ast'; + +// eslint-disable-next-line +const { __getArgs } = require('../execution/execution'); + +jest.mock('../execution/execution', () => { + const mockedModule = { + args: undefined, + __getArgs: () => mockedModule.args, + Execution: function ExecutionMock(...args: any) { + mockedModule.args = args; + }, + }; + + return mockedModule; +}); + +describe('Executor mocked execution tests', () => { + describe('createExecution()', () => { + describe('when execution is created from string', () => { + test('passes expression string to Execution', () => { + const executor = new Executor(); + executor.createExecution('foo bar="baz"'); + + expect(__getArgs()[0].expression).toBe('foo bar="baz"'); + }); + }); + + describe('when execution is created from AST', () => { + test('does not pass in expression string', () => { + const executor = new Executor(); + const ast = parseExpression('foo bar="baz"'); + executor.createExecution(ast); + + expect(__getArgs()[0].expression).toBe(undefined); + }); + }); + }); +}); diff --git a/src/plugins/expressions/common/executor/executor.test.ts b/src/plugins/expressions/common/executor/executor.test.ts index 502728bb66403..4e43cedd18157 100644 --- a/src/plugins/expressions/common/executor/executor.test.ts +++ b/src/plugins/expressions/common/executor/executor.test.ts @@ -130,7 +130,7 @@ describe('Executor', () => { const execution = executor.createExecution('foo bar="baz"'); expect(execution).toBeInstanceOf(Execution); - expect(execution.params.ast.chain[0].function).toBe('foo'); + expect(execution.state.get().ast.chain[0].function).toBe('foo'); }); test('returns Execution object from AST', () => { @@ -139,7 +139,7 @@ describe('Executor', () => { const execution = executor.createExecution(ast); expect(execution).toBeInstanceOf(Execution); - expect(execution.params.ast.chain[0].function).toBe('foo'); + expect(execution.state.get().ast.chain[0].function).toBe('foo'); }); test('Execution inherits context from Executor', () => { diff --git a/src/plugins/expressions/common/executor/executor.ts b/src/plugins/expressions/common/executor/executor.ts index 5c27201b43fc0..24c1648a8cd0f 100644 --- a/src/plugins/expressions/common/executor/executor.ts +++ b/src/plugins/expressions/common/executor/executor.ts @@ -22,12 +22,12 @@ import { ExecutorState, ExecutorContainer } from './container'; import { createExecutorContainer } from './container'; import { AnyExpressionFunctionDefinition, ExpressionFunction } from '../expression_functions'; -import { Execution } from '../execution/execution'; +import { Execution, ExecutionParams } from '../execution/execution'; import { IRegistry } from '../types'; import { ExpressionType } from '../expression_types/expression_type'; import { AnyExpressionTypeDefinition } from '../expression_types/types'; import { getType } from '../expression_types'; -import { ExpressionAstExpression, ExpressionAstNode, parseExpression } from '../ast'; +import { ExpressionAstExpression, ExpressionAstNode } from '../ast'; import { typeSpecs } from '../expression_types/specs'; import { functionSpecs } from '../expression_functions/specs'; @@ -186,19 +186,27 @@ export class Executor = Record = Record>( + public createExecution< + ExtraContext extends Record = Record, + Input = unknown, + Output = unknown + >( ast: string | ExpressionAstExpression, context: ExtraContext = {} as ExtraContext - ): Execution { - if (typeof ast === 'string') ast = parseExpression(ast); - const execution = new Execution({ - ast, + ): Execution { + const params: ExecutionParams = { executor: this, context: { ...this.context, ...context, } as Context & ExtraContext, - }); + }; + + if (typeof ast === 'string') params.expression = ast; + else params.ast = ast; + + const execution = new Execution(params); + return execution; } } diff --git a/src/plugins/expressions/common/service/expressions_services.ts b/src/plugins/expressions/common/service/expressions_services.ts index 8543fbe0fced2..9663c05f0d7c2 100644 --- a/src/plugins/expressions/common/service/expressions_services.ts +++ b/src/plugins/expressions/common/service/expressions_services.ts @@ -20,6 +20,7 @@ import { Executor } from '../executor'; import { ExpressionRendererRegistry } from '../expression_renderers'; import { ExpressionAstExpression } from '../ast'; +import { ExecutionContract } from '../execution/execution_contract'; export type ExpressionsServiceSetup = ReturnType; export type ExpressionsServiceStart = ReturnType; @@ -117,6 +118,26 @@ export class ExpressionsService { context?: ExtraContext ): Promise => this.executor.run(ast, input, context); + /** + * Starts expression execution and immediately returns `ExecutionContract` + * instance that tracks the progress of the execution and can be used to + * interact with the execution. + */ + public readonly execute = < + Input = unknown, + Output = unknown, + ExtraContext extends Record = Record + >( + ast: string | ExpressionAstExpression, + // This any is for legacy reasons. + input: Input = { type: 'null' } as any, + context?: ExtraContext + ): ExecutionContract => { + const execution = this.executor.createExecution(ast, context); + execution.start(input); + return execution.contract; + }; + public setup() { const { executor, renderers, registerFunction, run } = this; @@ -144,7 +165,7 @@ export class ExpressionsService { } public start() { - const { executor, renderers, run } = this; + const { execute, executor, renderers, run } = this; const getFunction = executor.getFunction.bind(executor); const getFunctions = executor.getFunctions.bind(executor); @@ -154,6 +175,7 @@ export class ExpressionsService { const getTypes = executor.getTypes.bind(executor); return { + execute, getFunction, getFunctions, getRenderer, diff --git a/src/plugins/expressions/public/execute.test.ts b/src/plugins/expressions/public/execute.test.ts deleted file mode 100644 index 2f2a303bad4c4..0000000000000 --- a/src/plugins/expressions/public/execute.test.ts +++ /dev/null @@ -1,100 +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 { execute, ExpressionDataHandler } from './execute'; -import { ExpressionAstExpression, parseExpression } from '../common'; - -jest.mock('./services', () => ({ - getInterpreter: () => { - return { - interpretAst: async (expression: ExpressionAstExpression) => { - return {}; - }, - }; - }, - getNotifications: jest.fn(() => { - return { - toasts: { - addError: jest.fn(() => {}), - }, - }; - }), -})); - -describe('execute helper function', () => { - it('returns ExpressionDataHandler instance', () => { - const response = execute(''); - expect(response).toBeInstanceOf(ExpressionDataHandler); - }); -}); - -describe('ExpressionDataHandler', () => { - const expressionString = ''; - - describe('constructor', () => { - it('accepts expression string', () => { - const expressionDataHandler = new ExpressionDataHandler(expressionString, {}); - expect(expressionDataHandler.getExpression()).toEqual(expressionString); - }); - - it('accepts expression AST', () => { - const expressionAST = parseExpression(expressionString) as ExpressionAstExpression; - const expressionDataHandler = new ExpressionDataHandler(expressionAST, {}); - expect(expressionDataHandler.getExpression()).toEqual(expressionString); - expect(expressionDataHandler.getAst()).toEqual(expressionAST); - }); - - it('allows passing in context', () => { - const expressionDataHandler = new ExpressionDataHandler(expressionString, { - context: { test: 'hello' }, - }); - expect(expressionDataHandler.getExpression()).toEqual(expressionString); - }); - - it('allows passing in search context', () => { - const expressionDataHandler = new ExpressionDataHandler(expressionString, { - searchContext: { filters: [] }, - }); - expect(expressionDataHandler.getExpression()).toEqual(expressionString); - }); - }); - - describe('getData()', () => { - it('returns a promise', () => { - const expressionDataHandler = new ExpressionDataHandler(expressionString, {}); - expect(expressionDataHandler.getData()).toBeInstanceOf(Promise); - }); - - it('promise resolves with data', async () => { - const expressionDataHandler = new ExpressionDataHandler(expressionString, {}); - expect(await expressionDataHandler.getData()).toEqual({}); - }); - }); - - it('cancel() aborts request', () => { - const expressionDataHandler = new ExpressionDataHandler(expressionString, {}); - expressionDataHandler.cancel(); - }); - - it('inspect() returns correct inspector adapters', () => { - const expressionDataHandler = new ExpressionDataHandler(expressionString, {}); - expect(expressionDataHandler.inspect()).toHaveProperty('requests'); - expect(expressionDataHandler.inspect()).toHaveProperty('data'); - }); -}); diff --git a/src/plugins/expressions/public/execute.ts b/src/plugins/expressions/public/execute.ts deleted file mode 100644 index c07fb9ad0549c..0000000000000 --- a/src/plugins/expressions/public/execute.ts +++ /dev/null @@ -1,138 +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 { DataAdapter, RequestAdapter, Adapters } from '../../inspector/public'; -import { getInterpreter } from './services'; -import { IExpressionLoaderParams } from './types'; -import { - ExpressionAstExpression, - parseExpression, - formatExpression, - ExpressionValue, -} from '../common'; - -/** - * The search context describes a specific context (filters, time range and query) - * that will be applied to the expression for execution. Not every expression will - * be effected by that. You have to use special functions - * that will pick up this search context and forward it to following functions that - * understand it. - */ - -export class ExpressionDataHandler { - private abortController: AbortController; - private expression: string; - private ast: ExpressionAstExpression; - - private inspectorAdapters: Adapters; - private promise: Promise; - - public isPending: boolean = true; - constructor(expression: string | ExpressionAstExpression, params: IExpressionLoaderParams) { - if (typeof expression === 'string') { - this.expression = expression; - this.ast = parseExpression(expression); - } else { - this.ast = expression; - this.expression = formatExpression(this.ast); - } - - this.abortController = new AbortController(); - this.inspectorAdapters = params.inspectorAdapters || this.getActiveInspectorAdapters(); - - const defaultInput = { type: 'null' }; - const interpreter = getInterpreter(); - this.promise = interpreter - .interpretAst(this.ast, params.context || defaultInput, { - search: params.searchContext, - inspectorAdapters: this.inspectorAdapters, - abortSignal: this.abortController.signal, - variables: params.variables, - }) - .then( - (v: ExpressionValue) => { - this.isPending = false; - return v; - }, - () => { - this.isPending = false; - } - ) as Promise; - } - - cancel = () => { - this.abortController.abort(); - }; - - getData = async () => { - try { - return await this.promise; - } catch (e) { - return { - type: 'error', - error: { - type: e.type, - message: e.message, - stack: e.stack, - }, - }; - } - }; - - getExpression = () => { - return this.expression; - }; - - getAst = () => { - return this.ast; - }; - - inspect = () => { - return this.inspectorAdapters; - }; - - /** - * Returns an object of all inspectors for this vis object. - * This must only be called after this.type has properly be initialized, - * since we need to read out data from the the vis type to check which - * inspectors are available. - */ - private getActiveInspectorAdapters = (): Adapters => { - const adapters: Adapters = {}; - - // Add the requests inspector adapters if the vis type explicitly requested it via - // inspectorAdapters.requests: true in its definition or if it's using the courier - // request handler, since that will automatically log its requests. - adapters.requests = new RequestAdapter(); - - // Add the data inspector adapter if the vis type requested it or if the - // vis is using courier, since we know that courier supports logging - // its data. - adapters.data = new DataAdapter(); - - return adapters; - }; -} - -export function execute( - expression: string | ExpressionAstExpression, - params: IExpressionLoaderParams = {} -): ExpressionDataHandler { - return new ExpressionDataHandler(expression, params); -} diff --git a/src/plugins/expressions/public/index.ts b/src/plugins/expressions/public/index.ts index 5f64c11f4efe6..06dd951cd5410 100644 --- a/src/plugins/expressions/public/index.ts +++ b/src/plugins/expressions/public/index.ts @@ -37,7 +37,6 @@ export { ReactExpressionRendererProps, ReactExpressionRendererType, } from './react_expression_renderer'; -export { ExpressionDataHandler } from './execute'; export { ExpressionRenderHandler } from './render'; export { AnyExpressionFunctionDefinition, @@ -48,6 +47,7 @@ export { DatatableColumnType, DatatableRow, Execution, + ExecutionContract, ExecutionContainer, ExecutionContext, ExecutionParams, diff --git a/src/plugins/expressions/public/loader.test.ts b/src/plugins/expressions/public/loader.test.ts index 480434244d6f5..e07a22a5e1d60 100644 --- a/src/plugins/expressions/public/loader.test.ts +++ b/src/plugins/expressions/public/loader.test.ts @@ -19,10 +19,12 @@ import { first, skip, toArray } from 'rxjs/operators'; import { loader, ExpressionLoader } from './loader'; -import { ExpressionDataHandler } from './execute'; import { Observable } from 'rxjs'; import { ExpressionAstExpression, parseExpression, IInterpreterRenderHandlers } from '../common'; +// eslint-disable-next-line +const { __getLastExecution } = require('./services'); + const element: HTMLElement = null as any; jest.mock('./services', () => { @@ -33,7 +35,13 @@ jest.mock('./services', () => { }, }, }; - return { + + // eslint-disable-next-line + const service = new (require('../common/service/expressions_services').ExpressionsService as any)(); + + const moduleMock = { + __execution: undefined, + __getLastExecution: () => moduleMock.__execution, getInterpreter: () => { return { interpretAst: async (expression: ExpressionAstExpression) => { @@ -51,17 +59,19 @@ jest.mock('./services', () => { }, }; }), + getExpressionsService: () => service, }; -}); -jest.mock('./execute', () => { - const actual = jest.requireActual('./execute'); - return { - ExpressionDataHandler: jest - .fn() - .mockImplementation((...args) => new actual.ExpressionDataHandler(...args)), - execute: jest.fn().mockReturnValue(actual.execute), + const execute = service.execute; + service.execute = (...args: any) => { + const execution = execute(...args); + jest.spyOn(execution, 'getData'); + jest.spyOn(execution, 'cancel'); + moduleMock.__execution = execution; + return execution; }; + + return moduleMock; }); describe('execute helper function', () => { @@ -97,9 +107,9 @@ describe('ExpressionLoader', () => { }); it('emits on $data when data is available', async () => { - const expressionLoader = new ExpressionLoader(element, expressionString, {}); + const expressionLoader = new ExpressionLoader(element, 'var foo', { variables: { foo: 123 } }); const response = await expressionLoader.data$.pipe(first()).toPromise(); - expect(response).toEqual({ type: 'render', as: 'test' }); + expect(response).toBe(123); }); it('emits on loading$ on initial load and on updates', async () => { @@ -128,94 +138,13 @@ describe('ExpressionLoader', () => { }); it('cancels the previous request when the expression is updated', () => { - const cancelMock = jest.fn(); - - (ExpressionDataHandler as jest.Mock).mockImplementationOnce(() => ({ - getData: () => true, - cancel: cancelMock, - isPending: () => true, - inspect: () => {}, - })); - - const expressionLoader = new ExpressionLoader(element, expressionString, {}); - expressionLoader.update('new', {}); - - expect(cancelMock).toHaveBeenCalledTimes(1); - }); - - it('does not send an observable message if a request was aborted', () => { - const cancelMock = jest.fn(); - - const getData = jest - .fn() - .mockResolvedValueOnce({ - type: 'error', - error: { - name: 'AbortError', - }, - }) - .mockResolvedValueOnce({ - type: 'real', - }); - - (ExpressionDataHandler as jest.Mock).mockImplementationOnce(() => ({ - getData, - cancel: cancelMock, - isPending: () => true, - inspect: () => {}, - })); - - (ExpressionDataHandler as jest.Mock).mockImplementationOnce(() => ({ - getData, - cancel: cancelMock, - isPending: () => true, - inspect: () => {}, - })); - - const expressionLoader = new ExpressionLoader(element, expressionString, {}); - - expect.assertions(2); - expressionLoader.data$.subscribe({ - next(data) { - expect(data).toEqual({ - type: 'real', - }); - }, - error() { - expect(false).toEqual('Should not be called'); - }, - }); - - expressionLoader.update('new expression', {}); - - expect(getData).toHaveBeenCalledTimes(2); - }); - - it('sends an observable error if the data fetching failed', () => { - const cancelMock = jest.fn(); - - const getData = jest.fn().mockResolvedValue('rejected'); - - (ExpressionDataHandler as jest.Mock).mockImplementationOnce(() => ({ - getData, - cancel: cancelMock, - isPending: () => true, - inspect: () => {}, - })); - - const expressionLoader = new ExpressionLoader(element, expressionString, {}); - - expect.assertions(2); - expressionLoader.data$.subscribe({ - next(data) { - expect(data).toEqual('Should not be called'); - }, - error(error) { - expect(error.message).toEqual('Could not fetch data'); - }, - }); + const expressionLoader = new ExpressionLoader(element, 'var foo', {}); + const execution = __getLastExecution(); + jest.spyOn(execution, 'cancel'); - expect(getData).toHaveBeenCalledTimes(1); + expect(execution.cancel).toHaveBeenCalledTimes(0); + expressionLoader.update('var bar', {}); + expect(execution.cancel).toHaveBeenCalledTimes(1); }); it('inspect() returns correct inspector adapters', () => { diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts index 320a8469fe9e3..4600922e076fa 100644 --- a/src/plugins/expressions/public/loader.ts +++ b/src/plugins/expressions/public/loader.ts @@ -20,11 +20,11 @@ import { BehaviorSubject, Observable, Subject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import { Adapters, InspectorSession } from '../../inspector/public'; -import { ExpressionDataHandler } from './execute'; import { ExpressionRenderHandler } from './render'; import { IExpressionLoaderParams } from './types'; import { ExpressionAstExpression } from '../common'; -import { getInspector } from './services'; +import { getInspector, getExpressionsService } from './services'; +import { ExecutionContract } from '../common/execution/execution_contract'; type Data = any; @@ -35,7 +35,7 @@ export class ExpressionLoader { events$: ExpressionRenderHandler['events$']; loading$: Observable; - private dataHandler: ExpressionDataHandler | undefined; + private execution: ExecutionContract | undefined; private renderHandler: ExpressionRenderHandler; private dataSubject: Subject; private loadingSubject: Subject; @@ -93,26 +93,26 @@ export class ExpressionLoader { this.dataSubject.complete(); this.loadingSubject.complete(); this.renderHandler.destroy(); - if (this.dataHandler) { - this.dataHandler.cancel(); + if (this.execution) { + this.execution.cancel(); } } cancel() { - if (this.dataHandler) { - this.dataHandler.cancel(); + if (this.execution) { + this.execution.cancel(); } } getExpression(): string | undefined { - if (this.dataHandler) { - return this.dataHandler.getExpression(); + if (this.execution) { + return this.execution.getExpression(); } } getAst(): ExpressionAstExpression | undefined { - if (this.dataHandler) { - return this.dataHandler.getAst(); + if (this.execution) { + return this.execution.getAst(); } } @@ -130,9 +130,7 @@ export class ExpressionLoader { } inspect(): Adapters | undefined { - if (this.dataHandler) { - return this.dataHandler.inspect(); - } + return this.execution ? (this.execution.inspect() as Adapters) : undefined; } update(expression?: string | ExpressionAstExpression, params?: IExpressionLoaderParams): void { @@ -150,15 +148,19 @@ export class ExpressionLoader { expression: string | ExpressionAstExpression, params: IExpressionLoaderParams ): Promise => { - if (this.dataHandler && this.dataHandler.isPending) { - this.dataHandler.cancel(); + if (this.execution && this.execution.isPending) { + this.execution.cancel(); } this.setParams(params); - this.dataHandler = new ExpressionDataHandler(expression, params); - if (!params.inspectorAdapters) params.inspectorAdapters = this.dataHandler.inspect(); - const prevDataHandler = this.dataHandler; + this.execution = getExpressionsService().execute(expression, params.context, { + search: params.searchContext, + variables: params.variables || {}, + inspectorAdapters: params.inspectorAdapters, + }); + if (!params.inspectorAdapters) params.inspectorAdapters = this.execution.inspect() as Adapters; + const prevDataHandler = this.execution; const data = await prevDataHandler.getData(); - if (this.dataHandler !== prevDataHandler) { + if (this.execution !== prevDataHandler) { return; } this.dataSubject.next(data); diff --git a/src/plugins/expressions/public/mocks.tsx b/src/plugins/expressions/public/mocks.tsx index 70760ada83955..40ae698bc95eb 100644 --- a/src/plugins/expressions/public/mocks.tsx +++ b/src/plugins/expressions/public/mocks.tsx @@ -65,7 +65,6 @@ const createSetupContract = (): Setup => { const createStartContract = (): Start => { return { execute: jest.fn(), - ExpressionDataHandler: jest.fn(), ExpressionLoader: jest.fn(), ExpressionRenderHandler: jest.fn(), getFunction: jest.fn(), diff --git a/src/plugins/expressions/public/plugin.test.ts b/src/plugins/expressions/public/plugin.test.ts index 5437a7d21f338..fdfd583eac9de 100644 --- a/src/plugins/expressions/public/plugin.test.ts +++ b/src/plugins/expressions/public/plugin.test.ts @@ -65,6 +65,15 @@ describe('ExpressionsPublicPlugin', () => { } `); }); + + test('"kibana" function return value of type "kibana_context"', async () => { + const { doStart } = await expressionsPluginMock.createPlugin(); + const start = await doStart(); + const execution = start.execute('kibana'); + const result = await execution.getData(); + + expect((result as any).type).toBe('kibana_context'); + }); }); }); }); diff --git a/src/plugins/expressions/public/plugin.ts b/src/plugins/expressions/public/plugin.ts index 6799b1590f252..aac429b365c48 100644 --- a/src/plugins/expressions/public/plugin.ts +++ b/src/plugins/expressions/public/plugin.ts @@ -36,11 +36,11 @@ import { setInterpreter, setRenderersRegistry, setNotifications, + setExpressionsService, } from './services'; import { kibanaContext as kibanaContextFunction } from './expression_functions/kibana_context'; import { ReactExpressionRenderer } from './react_expression_renderer'; import { ExpressionLoader, loader } from './loader'; -import { ExpressionDataHandler, execute } from './execute'; import { render, ExpressionRenderHandler } from './render'; export interface ExpressionsSetupDeps { @@ -92,8 +92,6 @@ export interface ExpressionsSetup extends ExpressionsServiceSetup { } export interface ExpressionsStart extends ExpressionsServiceStart { - execute: typeof execute; - ExpressionDataHandler: typeof ExpressionDataHandler; ExpressionLoader: typeof ExpressionLoader; ExpressionRenderHandler: typeof ExpressionRenderHandler; loader: typeof loader; @@ -118,6 +116,7 @@ export class ExpressionsPublicPlugin executor.registerFunction(kibanaContextFunction()); setRenderersRegistry(renderers); + setExpressionsService(this.expressions); const expressionsSetup = expressions.setup(); @@ -180,8 +179,6 @@ export class ExpressionsPublicPlugin return { ...expressionsStart, - execute, - ExpressionDataHandler, ExpressionLoader, ExpressionRenderHandler, loader, diff --git a/src/plugins/expressions/public/services.ts b/src/plugins/expressions/public/services.ts index 75ec4826ea45a..4fdff9b151ac2 100644 --- a/src/plugins/expressions/public/services.ts +++ b/src/plugins/expressions/public/services.ts @@ -22,6 +22,7 @@ import { createKibanaUtilsCore, createGetterSetter } from '../../kibana_utils/pu import { ExpressionInterpreter } from './types'; import { Start as IInspector } from '../../inspector/public'; import { ExpressionsSetup } from './plugin'; +import { ExpressionsService } from '../common'; export const { getCoreStart, setCoreStart, savedObjects } = createKibanaUtilsCore(); @@ -37,3 +38,7 @@ export const [getNotifications, setNotifications] = createGetterSetter('Renderers registry'); + +export const [getExpressionsService, setExpressionsService] = createGetterSetter< + ExpressionsService +>('ExpressionsService'); diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.test.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.test.ts index 4b17d8517328b..4cf74d991ceb9 100644 --- a/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.test.ts +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.test.ts @@ -181,4 +181,10 @@ describe('kbnUrlTracker', () => { `"/app/test#/start?state1=(key1:abc)&state2=(key2:def)"` ); }); + + test('set url to storage when setActiveUrl was called', () => { + createTracker(); + urlTracker.setActiveUrl('/deep/path/4'); + expect(storage.getItem('storageKey')).toEqual('#/deep/path/4'); + }); }); diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts index 6f3f64ea7b941..2edd135c184ec 100644 --- a/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_tracker.ts @@ -36,6 +36,7 @@ export interface KbnUrlTracker { * Unregistering the url tracker. This won't reset the current state of the nav link */ stop: () => void; + setActiveUrl: (newUrl: string) => void; } /** @@ -130,20 +131,24 @@ export function createKbnUrlTracker({ } } + function setActiveUrl(newUrl: string) { + const urlWithHashes = baseUrl + '#' + newUrl; + let urlWithStates = ''; + try { + urlWithStates = unhashUrl(urlWithHashes); + } catch (e) { + toastNotifications.addDanger(e.message); + } + + activeUrl = getActiveSubUrl(urlWithStates || urlWithHashes); + storageInstance.setItem(storageKey, activeUrl); + } + function onMountApp() { unsubscribe(); // track current hash when within app unsubscribeURLHistory = historyInstance.listen(location => { - const urlWithHashes = baseUrl + '#' + location.pathname + location.search; - let urlWithStates = ''; - try { - urlWithStates = unhashUrl(urlWithHashes); - } catch (e) { - toastNotifications.addDanger(e.message); - } - - activeUrl = getActiveSubUrl(urlWithStates || urlWithHashes); - storageInstance.setItem(storageKey, activeUrl); + setActiveUrl(location.pathname + location.search); }); } @@ -188,5 +193,6 @@ export function createKbnUrlTracker({ stop() { unsubscribe(); }, + setActiveUrl, }; } diff --git a/src/plugins/ui_actions/README.md b/src/plugins/ui_actions/README.md index 02942b7d5b406..c4e02b551c884 100644 --- a/src/plugins/ui_actions/README.md +++ b/src/plugins/ui_actions/README.md @@ -1,10 +1,10 @@ # UI Actions -An API for: - - creating custom functionality (`actions`) - - creating custom user interaction events (`triggers`) - - attaching and detaching `actions` to `triggers`. - - emitting `trigger` events - - executing `actions` attached to a given `trigger`. - - exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger. +An API for: +- creating custom functionality (`actions`) +- creating custom user interaction events (`triggers`) +- attaching and detaching `actions` to `triggers`. +- emitting `trigger` events +- executing `actions` attached to a given `trigger`. +- exposing a context menu for the user to choose the appropriate action when there are multiple actions attached to a single trigger. diff --git a/src/plugins/ui_actions/public/triggers/incompatible_action_error.ts b/src/plugins/ui_actions/public/actions/incompatible_action_error.ts similarity index 100% rename from src/plugins/ui_actions/public/triggers/incompatible_action_error.ts rename to src/plugins/ui_actions/public/actions/incompatible_action_error.ts diff --git a/src/plugins/ui_actions/public/actions/index.ts b/src/plugins/ui_actions/public/actions/index.ts index feb9a8de62eb3..64bfd368e3dfa 100644 --- a/src/plugins/ui_actions/public/actions/index.ts +++ b/src/plugins/ui_actions/public/actions/index.ts @@ -17,5 +17,6 @@ * under the License. */ -export { Action } from './action'; -export { createAction } from './create_action'; +export * from './action'; +export * from './create_action'; +export * from './incompatible_action_error'; diff --git a/src/plugins/ui_actions/public/actions/register_action.ts b/src/plugins/ui_actions/public/actions/register_action.ts deleted file mode 100644 index 5738be63c9592..0000000000000 --- a/src/plugins/ui_actions/public/actions/register_action.ts +++ /dev/null @@ -1,28 +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 { UiActionsApiPure } from '../types'; - -export const registerAction: UiActionsApiPure['registerAction'] = ({ actions }) => action => { - if (actions.has(action.id)) { - throw new Error(`Action [action.id = ${action.id}] already registered.`); - } - - actions.set(action.id, action); -}; diff --git a/src/plugins/ui_actions/public/api.ts b/src/plugins/ui_actions/public/api.ts deleted file mode 100644 index 9a6fd04b14e10..0000000000000 --- a/src/plugins/ui_actions/public/api.ts +++ /dev/null @@ -1,55 +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 { - UiActionsApi, - UiActionsDependenciesInternal, - UiActionsDependencies, - UiActionsApiPure, -} from './types'; -import { attachAction } from './triggers/attach_action'; -import { detachAction } from './triggers/detach_action'; -import { executeTriggerActions } from './triggers/execute_trigger_actions'; -import { getTrigger } from './triggers/get_trigger'; -import { getTriggerActions } from './triggers/get_trigger_actions'; -import { getTriggerCompatibleActions } from './triggers/get_trigger_compatible_actions'; -import { registerAction } from './actions/register_action'; -import { registerTrigger } from './triggers/register_trigger'; - -export const pureApi: UiActionsApiPure = { - attachAction, - detachAction, - executeTriggerActions, - getTrigger, - getTriggerActions, - getTriggerCompatibleActions, - registerAction, - registerTrigger, -}; - -export const createApi = (deps: UiActionsDependencies) => { - const partialApi: Partial = {}; - const depsInternal: UiActionsDependenciesInternal = { ...deps, api: partialApi }; - for (const [key, fn] of Object.entries(pureApi)) { - (partialApi as any)[key] = fn(depsInternal); - } - Object.freeze(partialApi); - const api = partialApi as UiActionsApi; - return { api, depsInternal }; -}; diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index 7b80a8ea830c0..3dce2c1f4c257 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -101,10 +101,9 @@ function convertPanelActionToContextMenuItem({ }): EuiContextMenuPanelItemDescriptor { const menuPanelItem: EuiContextMenuPanelItemDescriptor = { name: action.MenuItem - ? // Cast to `any` because `name` typed to string. - (React.createElement(uiToReactComponent(action.MenuItem), { + ? React.createElement(uiToReactComponent(action.MenuItem), { context: actionContext, - }) as any) + }) : action.getDisplayName(actionContext), icon: action.getIconType(actionContext), panel: _.get(action, 'childContextMenuPanel.id'), diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index 427dbecb7aee4..83a08b11fa4c2 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -19,19 +19,30 @@ import { PluginInitializerContext } from '../../../core/public'; import { UiActionsPlugin } from './plugin'; +import { UiActionsService } from './service'; export function plugin(initializerContext: PluginInitializerContext) { return new UiActionsPlugin(initializerContext); } export { UiActionsSetup, UiActionsStart } from './plugin'; -export { - Action, - Trigger, - UiActionsApi, - GetActionsCompatibleWithTrigger, - ExecuteTriggerActions, -} from './types'; -export { createAction } from './actions'; +export { UiActionsServiceParams, UiActionsService } from './service'; +export { Action, createAction, IncompatibleActionError } from './actions'; export { buildContextMenuForActions } from './context_menu'; -export { IncompatibleActionError } from './triggers'; +export { Trigger } from './triggers'; + +/** + * @deprecated + * + * Use `UiActionsStart['getTriggerCompatibleActions']` or + * `UiActionsService['getTriggerCompatibleActions']` instead. + */ +export type GetActionsCompatibleWithTrigger = UiActionsService['getTriggerCompatibleActions']; + +/** + * @deprecated + * + * Use `UiActionsStart['executeTriggerActions']` or + * `UiActionsService['executeTriggerActions']` instead. + */ +export type ExecuteTriggerActions = UiActionsService['executeTriggerActions']; diff --git a/src/plugins/ui_actions/public/mocks.ts b/src/plugins/ui_actions/public/mocks.ts index 273c5dcf83e81..d2ba901f1040d 100644 --- a/src/plugins/ui_actions/public/mocks.ts +++ b/src/plugins/ui_actions/public/mocks.ts @@ -17,9 +17,9 @@ * under the License. */ +import { CoreSetup, CoreStart } from 'src/core/public'; import { UiActionsSetup, UiActionsStart } from '.'; import { plugin as pluginInitializer } from '.'; -// eslint-disable-next-line import { coreMock } from '../../../core/public/mocks'; export type Setup = jest.Mocked; @@ -45,17 +45,20 @@ const createStartContract = (): Start => { getTrigger: jest.fn(), getTriggerActions: jest.fn((id: string) => []), getTriggerCompatibleActions: jest.fn(), + clear: jest.fn(), + fork: jest.fn(), }; return startContract; }; -const createPlugin = async () => { +const createPlugin = ( + coreSetup: CoreSetup = coreMock.createSetup(), + coreStart: CoreStart = coreMock.createStart() +) => { const pluginInitializerContext = coreMock.createPluginInitializerContext(); - const coreSetup = coreMock.createSetup(); - const coreStart = coreMock.createStart(); const plugin = pluginInitializer(pluginInitializerContext); - const setup = await plugin.setup(coreSetup); + const setup = plugin.setup(coreSetup); return { pluginInitializerContext, @@ -63,7 +66,7 @@ const createPlugin = async () => { coreStart, plugin, setup, - doStart: async () => await plugin.start(coreStart), + doStart: (anotherCoreStart: CoreStart = coreStart) => plugin.start(anotherCoreStart), }; }; diff --git a/src/plugins/ui_actions/public/plugin.ts b/src/plugins/ui_actions/public/plugin.ts index 12a9b7cbc6526..0874803db7d37 100644 --- a/src/plugins/ui_actions/public/plugin.ts +++ b/src/plugins/ui_actions/public/plugin.ts @@ -17,43 +17,30 @@ * under the License. */ -import { CoreStart, PluginInitializerContext, CoreSetup, Plugin } from 'src/core/public'; -import { UiActionsApi, ActionRegistry, TriggerRegistry } from './types'; -import { createApi } from './api'; - -export interface UiActionsSetup { - attachAction: UiActionsApi['attachAction']; - detachAction: UiActionsApi['detachAction']; - registerAction: UiActionsApi['registerAction']; - registerTrigger: UiActionsApi['registerTrigger']; -} +import { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; +import { UiActionsService } from './service'; + +export type UiActionsSetup = Pick< + UiActionsService, + 'attachAction' | 'detachAction' | 'registerAction' | 'registerTrigger' +>; -export type UiActionsStart = UiActionsApi; +export type UiActionsStart = PublicMethodsOf; export class UiActionsPlugin implements Plugin { - private readonly triggers: TriggerRegistry = new Map(); - private readonly actions: ActionRegistry = new Map(); - private api!: UiActionsApi; + private readonly service = new UiActionsService(); - constructor(initializerContext: PluginInitializerContext) { - this.api = createApi({ triggers: this.triggers, actions: this.actions }).api; - } + constructor(initializerContext: PluginInitializerContext) {} public setup(core: CoreSetup): UiActionsSetup { - return { - registerTrigger: this.api.registerTrigger, - registerAction: this.api.registerAction, - attachAction: this.api.attachAction, - detachAction: this.api.detachAction, - }; + return this.service; } public start(core: CoreStart): UiActionsStart { - return this.api; + return this.service; } public stop() { - this.actions.clear(); - this.triggers.clear(); + this.service.clear(); } } diff --git a/src/plugins/ui_actions/public/tests/helpers.ts b/src/plugins/ui_actions/public/service/index.ts similarity index 76% rename from src/plugins/ui_actions/public/tests/helpers.ts rename to src/plugins/ui_actions/public/service/index.ts index d1a4a71705a81..3998a2ea255cb 100644 --- a/src/plugins/ui_actions/public/tests/helpers.ts +++ b/src/plugins/ui_actions/public/service/index.ts @@ -17,12 +17,4 @@ * under the License. */ -import { UiActionsDependencies } from '../types'; - -export const createDeps = (): UiActionsDependencies => { - const deps: UiActionsDependencies = { - actions: new Map(), - triggers: new Map(), - }; - return deps; -}; +export * from './ui_actions_service'; diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts new file mode 100644 index 0000000000000..2bbe106c49a25 --- /dev/null +++ b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts @@ -0,0 +1,465 @@ +/* + * 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 { UiActionsService } from './ui_actions_service'; +import { Action } from '../actions'; +import { createRestrictedAction, createHelloWorldAction } from '../tests/test_samples'; +import { ActionRegistry, TriggerRegistry } from '../types'; +import { Trigger } from '../triggers'; + +const testAction1: Action = { + id: 'action1', + order: 1, + type: 'type1', + execute: async () => {}, + getDisplayName: () => 'test1', + getIconType: () => '', + isCompatible: async () => true, +}; + +const testAction2: Action = { + id: 'action2', + order: 2, + type: 'type2', + execute: async () => {}, + getDisplayName: () => 'test2', + getIconType: () => '', + isCompatible: async () => true, +}; + +describe('UiActionsService', () => { + test('can instantiate', () => { + new UiActionsService(); + }); + + describe('.registerTrigger()', () => { + test('can register a trigger', () => { + const service = new UiActionsService(); + service.registerTrigger({ + id: 'test', + }); + }); + }); + + describe('.getTrigger()', () => { + test('can get Trigger from registry', () => { + const service = new UiActionsService(); + service.registerTrigger({ + description: 'foo', + id: 'bar', + title: 'baz', + }); + + const trigger = service.getTrigger('bar'); + + expect(trigger).toEqual({ + description: 'foo', + id: 'bar', + title: 'baz', + }); + }); + + test('throws if trigger does not exist', () => { + const service = new UiActionsService(); + + expect(() => service.getTrigger('foo')).toThrowError( + 'Trigger [triggerId = foo] does not exist.' + ); + }); + }); + + describe('.registerAction()', () => { + test('can register an action', () => { + const service = new UiActionsService(); + service.registerAction({ + id: 'test', + execute: async () => {}, + getDisplayName: () => 'test', + getIconType: () => '', + isCompatible: async () => true, + type: 'test', + }); + }); + }); + + describe('.getTriggerActions()', () => { + const action1: Action = { + id: 'action1', + order: 1, + type: 'type1', + execute: async () => {}, + getDisplayName: () => 'test', + getIconType: () => '', + isCompatible: async () => true, + }; + const action2: Action = { + id: 'action2', + order: 2, + type: 'type2', + execute: async () => {}, + getDisplayName: () => 'test', + getIconType: () => '', + isCompatible: async () => true, + }; + + test('returns actions set on trigger', () => { + const service = new UiActionsService(); + + service.registerAction(action1); + service.registerAction(action2); + service.registerTrigger({ + description: 'foo', + id: 'trigger', + title: 'baz', + }); + + const list0 = service.getTriggerActions('trigger'); + + expect(list0).toHaveLength(0); + + service.attachAction('trigger', 'action1'); + const list1 = service.getTriggerActions('trigger'); + + expect(list1).toHaveLength(1); + expect(list1).toEqual([action1]); + + service.attachAction('trigger', 'action2'); + const list2 = service.getTriggerActions('trigger'); + + expect(list2).toHaveLength(2); + expect(!!list2.find(({ id }: any) => id === 'action1')).toBe(true); + expect(!!list2.find(({ id }: any) => id === 'action2')).toBe(true); + }); + }); + + describe('.getTriggerCompatibleActions()', () => { + test('can register and get actions', async () => { + const actions: ActionRegistry = new Map(); + const service = new UiActionsService({ actions }); + const helloWorldAction = createHelloWorldAction({} as any); + const length = actions.size; + + service.registerAction(helloWorldAction); + + expect(actions.size - length).toBe(1); + expect(actions.get(helloWorldAction.id)).toBe(helloWorldAction); + }); + + test('getTriggerCompatibleActions returns attached actions', async () => { + const service = new UiActionsService(); + const helloWorldAction = createHelloWorldAction({} as any); + + service.registerAction(helloWorldAction); + + const testTrigger: Trigger = { + id: 'MY-TRIGGER', + title: 'My trigger', + }; + service.registerTrigger(testTrigger); + service.attachAction('MY-TRIGGER', helloWorldAction.id); + + const compatibleActions = await service.getTriggerCompatibleActions('MY-TRIGGER', {}); + + expect(compatibleActions.length).toBe(1); + expect(compatibleActions[0].id).toBe(helloWorldAction.id); + }); + + test('filters out actions not applicable based on the context', async () => { + const service = new UiActionsService(); + const restrictedAction = createRestrictedAction<{ accept: boolean }>(context => { + return context.accept; + }); + + service.registerAction(restrictedAction); + + const testTrigger: Trigger = { + id: 'MY-TRIGGER', + title: 'My trigger', + }; + + service.registerTrigger(testTrigger); + service.attachAction(testTrigger.id, restrictedAction.id); + + const compatibleActions1 = await service.getTriggerCompatibleActions(testTrigger.id, { + accept: true, + }); + + expect(compatibleActions1.length).toBe(1); + + const compatibleActions2 = await service.getTriggerCompatibleActions(testTrigger.id, { + accept: false, + }); + + expect(compatibleActions2.length).toBe(0); + }); + + test(`throws an error with an invalid trigger ID`, async () => { + const service = new UiActionsService(); + + await expect(service.getTriggerCompatibleActions('I do not exist', {})).rejects.toMatchObject( + new Error('Trigger [triggerId = I do not exist] does not exist.') + ); + }); + + test('returns empty list if trigger not attached to any action', async () => { + const service = new UiActionsService(); + const testTrigger: Trigger = { + id: '123', + title: '123', + }; + service.registerTrigger(testTrigger); + + const actions = await service.getTriggerCompatibleActions(testTrigger.id, {}); + + expect(actions).toEqual([]); + }); + }); + + describe('.fork()', () => { + test('returns a new instance of the service', () => { + const service1 = new UiActionsService(); + const service2 = service1.fork(); + + expect(service1).not.toBe(service2); + expect(service2).toBeInstanceOf(UiActionsService); + }); + + test('triggers registered in original service are available in original an forked services', () => { + const service1 = new UiActionsService(); + service1.registerTrigger({ + id: 'foo', + }); + const service2 = service1.fork(); + + const trigger1 = service1.getTrigger('foo'); + const trigger2 = service2.getTrigger('foo'); + + expect(trigger1.id).toBe('foo'); + expect(trigger2.id).toBe('foo'); + }); + + test('triggers registered in forked service are not available in original service', () => { + const service1 = new UiActionsService(); + const service2 = service1.fork(); + + service2.registerTrigger({ + id: 'foo', + }); + + expect(() => service1.getTrigger('foo')).toThrowErrorMatchingInlineSnapshot( + `"Trigger [triggerId = foo] does not exist."` + ); + + const trigger2 = service2.getTrigger('foo'); + expect(trigger2.id).toBe('foo'); + }); + + test('forked service preserves trigger-to-actions mapping', () => { + const service1 = new UiActionsService(); + + service1.registerTrigger({ + id: 'foo', + }); + service1.registerAction(testAction1); + service1.attachAction('foo', testAction1.id); + + const service2 = service1.fork(); + + const actions1 = service1.getTriggerActions('foo'); + const actions2 = service2.getTriggerActions('foo'); + + expect(actions1).toHaveLength(1); + expect(actions2).toHaveLength(1); + expect(actions1[0].id).toBe(testAction1.id); + expect(actions2[0].id).toBe(testAction1.id); + }); + + test('new attachments in fork do not appear in original service', () => { + const service1 = new UiActionsService(); + + service1.registerTrigger({ + id: 'foo', + }); + service1.registerAction(testAction1); + service1.registerAction(testAction2); + service1.attachAction('foo', testAction1.id); + + const service2 = service1.fork(); + + expect(service1.getTriggerActions('foo')).toHaveLength(1); + expect(service2.getTriggerActions('foo')).toHaveLength(1); + + service2.attachAction('foo', testAction2.id); + + expect(service1.getTriggerActions('foo')).toHaveLength(1); + expect(service2.getTriggerActions('foo')).toHaveLength(2); + }); + + test('new attachments in original service do not appear in fork', () => { + const service1 = new UiActionsService(); + + service1.registerTrigger({ + id: 'foo', + }); + service1.registerAction(testAction1); + service1.registerAction(testAction2); + service1.attachAction('foo', testAction1.id); + + const service2 = service1.fork(); + + expect(service1.getTriggerActions('foo')).toHaveLength(1); + expect(service2.getTriggerActions('foo')).toHaveLength(1); + + service1.attachAction('foo', testAction2.id); + + expect(service1.getTriggerActions('foo')).toHaveLength(2); + expect(service2.getTriggerActions('foo')).toHaveLength(1); + }); + }); + + describe('registries', () => { + const HELLO_WORLD_ACTION_ID = 'HELLO_WORLD_ACTION_ID'; + + test('can register trigger', () => { + const triggers: TriggerRegistry = new Map(); + const service = new UiActionsService({ triggers }); + + service.registerTrigger({ + description: 'foo', + id: 'bar', + title: 'baz', + }); + + expect(triggers.get('bar')).toEqual({ + description: 'foo', + id: 'bar', + title: 'baz', + }); + }); + + test('can register action', () => { + const actions: ActionRegistry = new Map(); + const service = new UiActionsService({ actions }); + + service.registerAction({ + id: HELLO_WORLD_ACTION_ID, + order: 13, + } as any); + + expect(actions.get(HELLO_WORLD_ACTION_ID)).toMatchObject({ + id: HELLO_WORLD_ACTION_ID, + order: 13, + }); + }); + + test('can attach an action to a trigger', () => { + const service = new UiActionsService(); + + const trigger: Trigger = { + id: 'MY-TRIGGER', + }; + const action = { + id: HELLO_WORLD_ACTION_ID, + order: 25, + } as any; + + service.registerTrigger(trigger); + service.registerAction(action); + service.attachAction('MY-TRIGGER', HELLO_WORLD_ACTION_ID); + + const actions = service.getTriggerActions(trigger.id); + + expect(actions.length).toBe(1); + expect(actions[0].id).toBe(HELLO_WORLD_ACTION_ID); + }); + + test('can detach an action to a trigger', () => { + const service = new UiActionsService(); + + const trigger: Trigger = { + id: 'MY-TRIGGER', + }; + const action = { + id: HELLO_WORLD_ACTION_ID, + order: 25, + } as any; + + service.registerTrigger(trigger); + service.registerAction(action); + service.attachAction(trigger.id, HELLO_WORLD_ACTION_ID); + service.detachAction(trigger.id, HELLO_WORLD_ACTION_ID); + + const actions2 = service.getTriggerActions(trigger.id); + expect(actions2).toEqual([]); + }); + + test('detaching an invalid action from a trigger throws an error', async () => { + const service = new UiActionsService(); + + const action = { + id: HELLO_WORLD_ACTION_ID, + order: 25, + } as any; + + service.registerAction(action); + expect(() => service.detachAction('i do not exist', HELLO_WORLD_ACTION_ID)).toThrowError( + 'No trigger [triggerId = i do not exist] exists, for detaching action [actionId = HELLO_WORLD_ACTION_ID].' + ); + }); + + test('attaching an invalid action to a trigger throws an error', async () => { + const service = new UiActionsService(); + + const action = { + id: HELLO_WORLD_ACTION_ID, + order: 25, + } as any; + + service.registerAction(action); + expect(() => service.attachAction('i do not exist', HELLO_WORLD_ACTION_ID)).toThrowError( + 'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = HELLO_WORLD_ACTION_ID].' + ); + }); + + test('cannot register another action with the same ID', async () => { + const service = new UiActionsService(); + + const action = { + id: HELLO_WORLD_ACTION_ID, + order: 25, + } as any; + + service.registerAction(action); + expect(() => service.registerAction(action)).toThrowError( + 'Action [action.id = HELLO_WORLD_ACTION_ID] already registered.' + ); + }); + + test('cannot register another trigger with the same ID', async () => { + const service = new UiActionsService(); + + const trigger = { id: 'MY-TRIGGER' } as any; + + service.registerTrigger(trigger); + expect(() => service.registerTrigger(trigger)).toThrowError( + 'Trigger [trigger.id = MY-TRIGGER] already registered.' + ); + }); + }); +}); diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.ts b/src/plugins/ui_actions/public/service/ui_actions_service.ts new file mode 100644 index 0000000000000..a62d2aa356435 --- /dev/null +++ b/src/plugins/ui_actions/public/service/ui_actions_service.ts @@ -0,0 +1,194 @@ +/* + * 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 { TriggerRegistry, ActionRegistry, TriggerToActionsRegistry } from '../types'; +import { Action } from '../actions'; +import { Trigger } from '../triggers/trigger'; +import { buildContextMenuForActions, openContextMenu } from '../context_menu'; + +export interface UiActionsServiceParams { + readonly triggers?: TriggerRegistry; + readonly actions?: ActionRegistry; + + /** + * A 1-to-N mapping from `Trigger` to zero or more `Action`. + */ + readonly triggerToActions?: TriggerToActionsRegistry; +} + +export class UiActionsService { + protected readonly triggers: TriggerRegistry; + protected readonly actions: ActionRegistry; + protected readonly triggerToActions: TriggerToActionsRegistry; + + constructor({ + triggers = new Map(), + actions = new Map(), + triggerToActions = new Map(), + }: UiActionsServiceParams = {}) { + this.triggers = triggers; + this.actions = actions; + this.triggerToActions = triggerToActions; + } + + public readonly registerTrigger = (trigger: Trigger) => { + if (this.triggers.has(trigger.id)) { + throw new Error(`Trigger [trigger.id = ${trigger.id}] already registered.`); + } + + this.triggers.set(trigger.id, trigger); + this.triggerToActions.set(trigger.id, []); + }; + + public readonly getTrigger = (id: string) => { + const trigger = this.triggers.get(id); + + if (!trigger) { + throw new Error(`Trigger [triggerId = ${id}] does not exist.`); + } + + return trigger; + }; + + public readonly registerAction = (action: Action) => { + if (this.actions.has(action.id)) { + throw new Error(`Action [action.id = ${action.id}] already registered.`); + } + + this.actions.set(action.id, action); + }; + + public readonly attachAction = (triggerId: string, actionId: string): void => { + const trigger = this.triggers.get(triggerId); + + if (!trigger) { + throw new Error( + `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${actionId}].` + ); + } + + const actionIds = this.triggerToActions.get(triggerId); + + if (!actionIds!.find(id => id === actionId)) { + this.triggerToActions.set(triggerId, [...actionIds!, actionId]); + } + }; + + public readonly detachAction = (triggerId: string, actionId: string) => { + const trigger = this.triggers.get(triggerId); + + if (!trigger) { + throw new Error( + `No trigger [triggerId = ${triggerId}] exists, for detaching action [actionId = ${actionId}].` + ); + } + + const actionIds = this.triggerToActions.get(triggerId); + + this.triggerToActions.set( + triggerId, + actionIds!.filter(id => id !== actionId) + ); + }; + + public readonly getTriggerActions = (triggerId: string) => { + // This line checks if trigger exists, otherwise throws. + this.getTrigger!(triggerId); + + const actionIds = this.triggerToActions.get(triggerId); + const actions = actionIds! + .map(actionId => this.actions.get(actionId)) + .filter(Boolean) as Action[]; + + return actions; + }; + + public readonly getTriggerCompatibleActions = async (triggerId: string, context: C) => { + const actions = this.getTriggerActions!(triggerId); + const isCompatibles = await Promise.all(actions.map(action => action.isCompatible(context))); + return actions.reduce( + (acc, action, i) => (isCompatibles[i] ? [...acc, action] : acc), + [] + ); + }; + + private async executeSingleAction(action: Action, actionContext: A) { + const href = action.getHref && action.getHref(actionContext); + + if (href) { + window.location.href = href; + return; + } + + await action.execute(actionContext); + } + + private async executeMultipleActions(actions: Action[], actionContext: C) { + const panel = await buildContextMenuForActions({ + actions, + actionContext, + closeMenu: () => session.close(), + }); + const session = openContextMenu([panel]); + } + + public readonly executeTriggerActions = async (triggerId: string, actionContext: C) => { + const actions = await this.getTriggerCompatibleActions!(triggerId, actionContext); + + if (!actions.length) { + throw new Error( + `No compatible actions found to execute for trigger [triggerId = ${triggerId}].` + ); + } + + if (actions.length === 1) { + await this.executeSingleAction(actions[0], actionContext); + return; + } + + await this.executeMultipleActions(actions, actionContext); + }; + + /** + * Removes all registered triggers and actions. + */ + public readonly clear = () => { + this.actions.clear(); + this.triggers.clear(); + this.triggerToActions.clear(); + }; + + /** + * "Fork" a separate instance of `UiActionsService` that inherits all existing + * triggers and actions, but going forward all new triggers and actions added + * to this instance of `UiActionsService` are only available within this instance. + */ + public readonly fork = (): UiActionsService => { + const triggers: TriggerRegistry = new Map(); + const actions: ActionRegistry = new Map(); + const triggerToActions: TriggerToActionsRegistry = new Map(); + + for (const [key, value] of this.triggers.entries()) triggers.set(key, value); + for (const [key, value] of this.actions.entries()) actions.set(key, value); + for (const [key, value] of this.triggerToActions.entries()) + triggerToActions.set(key, [...value]); + + return new UiActionsService({ triggers, actions, triggerToActions }); + }; +} diff --git a/src/plugins/ui_actions/public/tests/README.md b/src/plugins/ui_actions/public/tests/README.md new file mode 100644 index 0000000000000..8ea3a89e7c120 --- /dev/null +++ b/src/plugins/ui_actions/public/tests/README.md @@ -0,0 +1,2 @@ +This folder contains integration tests for the `ui_actions` plugin and +`test_samples` that other plugins can use in their tests. diff --git a/src/plugins/ui_actions/public/triggers/execute_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts similarity index 86% rename from src/plugins/ui_actions/public/triggers/execute_trigger_actions.test.ts rename to src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts index 7f2506daee268..f8c196a623499 100644 --- a/src/plugins/ui_actions/public/triggers/execute_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts @@ -19,7 +19,8 @@ import { Action, createAction } from '../actions'; import { openContextMenu } from '../context_menu'; -import { UiActionsTestPluginReturn, uiActionsTestPlugin } from '../tests/test_plugin'; +import { uiActionsPluginMock } from '../mocks'; +import { Trigger } from '../triggers'; jest.mock('../context_menu'); @@ -37,14 +38,14 @@ function createTestAction(id: string, checkCompatibility: (context: A) => boo }); } -let uiActions: UiActionsTestPluginReturn; +let uiActions: ReturnType; const reset = () => { - uiActions = uiActionsTestPlugin(); + uiActions = uiActionsPluginMock.createPlugin(); uiActions.setup.registerTrigger({ id: CONTACT_USER_TRIGGER, - actionIds: ['SEND_MESSAGE_ACTION'], }); + uiActions.setup.attachAction(CONTACT_USER_TRIGGER, 'SEND_MESSAGE_ACTION'); executeFn.mockReset(); openContextMenuSpy.mockReset(); @@ -53,14 +54,15 @@ beforeEach(reset); test('executes a single action mapped to a trigger', async () => { const { setup, doStart } = uiActions; - const trigger = { + const trigger: Trigger = { id: 'MY-TRIGGER', title: 'My trigger', - actionIds: ['test1'], }; const action = createTestAction('test1', () => true); + setup.registerTrigger(trigger); setup.registerAction(action); + setup.attachAction(trigger.id, 'test1'); const context = {}; const start = doStart(); @@ -72,12 +74,13 @@ test('executes a single action mapped to a trigger', async () => { test('throws an error if there are no compatible actions to execute', async () => { const { setup, doStart } = uiActions; - const trigger = { + const trigger: Trigger = { id: 'MY-TRIGGER', title: 'My trigger', - actionIds: ['testaction'], }; + setup.registerTrigger(trigger); + setup.attachAction(trigger.id, 'testaction'); const context = {}; const start = doStart(); @@ -88,14 +91,15 @@ test('throws an error if there are no compatible actions to execute', async () = test('does not execute an incompatible action', async () => { const { setup, doStart } = uiActions; - const trigger = { + const trigger: Trigger = { id: 'MY-TRIGGER', title: 'My trigger', - actionIds: ['test1'], }; const action = createTestAction<{ name: string }>('test1', ({ name }) => name === 'executeme'); + setup.registerTrigger(trigger); setup.registerAction(action); + setup.attachAction(trigger.id, 'test1'); const start = doStart(); const context = { @@ -108,16 +112,18 @@ test('does not execute an incompatible action', async () => { test('shows a context menu when more than one action is mapped to a trigger', async () => { const { setup, doStart } = uiActions; - const trigger = { + const trigger: Trigger = { id: 'MY-TRIGGER', title: 'My trigger', - actionIds: ['test1', 'test2'], }; const action1 = createTestAction('test1', () => true); const action2 = createTestAction('test2', () => true); + setup.registerTrigger(trigger); setup.registerAction(action1); setup.registerAction(action2); + setup.attachAction(trigger.id, 'test1'); + setup.attachAction(trigger.id, 'test2'); expect(openContextMenu).toHaveBeenCalledTimes(0); @@ -134,7 +140,6 @@ test('passes whole action context to isCompatible()', async () => { const trigger = { id: 'MY-TRIGGER', title: 'My trigger', - actionIds: ['test'], }; const action = createTestAction<{ foo: string }>('test', ({ foo }) => { expect(foo).toEqual('bar'); @@ -143,6 +148,8 @@ test('passes whole action context to isCompatible()', async () => { setup.registerTrigger(trigger); setup.registerAction(action); + setup.attachAction(trigger.id, 'test'); + const start = doStart(); const context = { foo: 'bar' }; diff --git a/src/plugins/ui_actions/public/triggers/get_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts similarity index 93% rename from src/plugins/ui_actions/public/triggers/get_trigger_actions.test.ts rename to src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts index aef4114ffb4c6..e91acd4c7151b 100644 --- a/src/plugins/ui_actions/public/triggers/get_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts @@ -18,7 +18,7 @@ */ import { Action } from '../actions'; -import { uiActionsTestPlugin } from '../tests/test_plugin'; +import { uiActionsPluginMock } from '../mocks'; const action1: Action = { id: 'action1', @@ -32,11 +32,10 @@ const action2: Action = { } as any; test('returns actions set on trigger', () => { - const { setup, doStart } = uiActionsTestPlugin(); + const { setup, doStart } = uiActionsPluginMock.createPlugin(); setup.registerAction(action1); setup.registerAction(action2); setup.registerTrigger({ - actionIds: [], description: 'foo', id: 'trigger', title: 'baz', diff --git a/src/plugins/ui_actions/public/triggers/get_trigger_compatible_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts similarity index 82% rename from src/plugins/ui_actions/public/triggers/get_trigger_compatible_actions.test.ts rename to src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts index f4d2ea48ff6b9..a966003973aba 100644 --- a/src/plugins/ui_actions/public/triggers/get_trigger_compatible_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts @@ -18,35 +18,30 @@ */ import { createSayHelloAction } from '../tests/test_samples/say_hello_action'; -import { UiActionsTestPluginReturn, uiActionsTestPlugin } from '../tests/test_plugin'; +import { uiActionsPluginMock } from '../mocks'; import { createRestrictedAction, createHelloWorldAction } from '../tests/test_samples'; import { Action } from '../actions'; +import { Trigger } from '../triggers'; let action: Action<{ name: string }>; -let uiActions: UiActionsTestPluginReturn; +let uiActions: ReturnType; beforeEach(() => { - uiActions = uiActionsTestPlugin(); + uiActions = uiActionsPluginMock.createPlugin(); action = createSayHelloAction({} as any); uiActions.setup.registerAction(action); uiActions.setup.registerTrigger({ id: 'trigger', title: 'trigger', - actionIds: [], }); uiActions.setup.attachAction('trigger', action.id); }); -test('can register and get actions', async () => { - const { setup, plugin } = uiActions; +test('can register action', async () => { + const { setup } = uiActions; const helloWorldAction = createHelloWorldAction({} as any); - const length = (plugin as any).actions.size; setup.registerAction(helloWorldAction); - - expect((plugin as any).actions.size - length).toBe(1); - expect((plugin as any).actions.get(action.id)).toBe(action); - expect((plugin as any).actions.get(helloWorldAction.id)).toBe(helloWorldAction); }); test('getTriggerCompatibleActions returns attached actions', async () => { @@ -55,10 +50,9 @@ test('getTriggerCompatibleActions returns attached actions', async () => { setup.registerAction(helloWorldAction); - const testTrigger = { + const testTrigger: Trigger = { id: 'MY-TRIGGER', title: 'My trigger', - actionIds: [], }; setup.registerTrigger(testTrigger); setup.attachAction('MY-TRIGGER', helloWorldAction.id); @@ -78,13 +72,13 @@ test('filters out actions not applicable based on the context', async () => { setup.registerAction(restrictedAction); - const testTrigger = { + const testTrigger: Trigger = { id: 'MY-TRIGGER', title: 'My trigger', - actionIds: [restrictedAction.id], }; setup.registerTrigger(testTrigger); + setup.attachAction(testTrigger.id, restrictedAction.id); const start = doStart(); let actions = await start.getTriggerCompatibleActions(testTrigger.id, { accept: true }); @@ -107,10 +101,9 @@ test(`throws an error with an invalid trigger ID`, async () => { test(`with a trigger mapping that maps to an non-existing action returns empty list`, async () => { const { setup, doStart } = uiActions; - const testTrigger = { + const testTrigger: Trigger = { id: '123', title: '123', - actionIds: ['I do not exist'], }; setup.registerTrigger(testTrigger); diff --git a/src/plugins/ui_actions/public/tests/index.ts b/src/plugins/ui_actions/public/tests/index.ts index 6f5610a7beb64..dbc34abb8acb4 100644 --- a/src/plugins/ui_actions/public/tests/index.ts +++ b/src/plugins/ui_actions/public/tests/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { uiActionsTestPlugin } from './test_plugin'; +export * from './test_samples'; diff --git a/src/plugins/ui_actions/public/tests/test_plugin.ts b/src/plugins/ui_actions/public/tests/test_plugin.ts deleted file mode 100644 index dcc42fd9f6fb2..0000000000000 --- a/src/plugins/ui_actions/public/tests/test_plugin.ts +++ /dev/null @@ -1,49 +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 { CoreSetup, CoreStart } from 'src/core/public'; -import { UiActionsPlugin, UiActionsSetup, UiActionsStart } from '../plugin'; - -export interface UiActionsTestPluginReturn { - plugin: UiActionsPlugin; - coreSetup: CoreSetup; - coreStart: CoreStart; - setup: UiActionsSetup; - doStart: (anotherCoreStart?: CoreStart) => UiActionsStart; -} - -export const uiActionsTestPlugin = ( - coreSetup: CoreSetup = {} as CoreSetup, - coreStart: CoreStart = {} as CoreStart -): UiActionsTestPluginReturn => { - const initializerContext = {} as any; - const plugin = new UiActionsPlugin(initializerContext); - const setup = plugin.setup(coreSetup); - - return { - plugin, - coreSetup, - coreStart, - setup, - doStart: (anotherCoreStart: CoreStart = coreStart) => { - const start = plugin.start(anotherCoreStart); - return start; - }, - }; -}; diff --git a/src/plugins/ui_actions/public/triggers/attach_action.ts b/src/plugins/ui_actions/public/triggers/attach_action.ts deleted file mode 100644 index 6c0beeae2bcd7..0000000000000 --- a/src/plugins/ui_actions/public/triggers/attach_action.ts +++ /dev/null @@ -1,37 +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 { UiActionsApiPure } from '../types'; - -export const attachAction: UiActionsApiPure['attachAction'] = ({ triggers }) => ( - triggerId, - actionId -) => { - const trigger = triggers.get(triggerId); - - if (!trigger) { - throw new Error( - `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${actionId}].` - ); - } - - if (!trigger.actionIds.find(id => id === actionId)) { - trigger.actionIds.push(actionId); - } -}; diff --git a/src/plugins/ui_actions/public/triggers/detach_action.ts b/src/plugins/ui_actions/public/triggers/detach_action.ts deleted file mode 100644 index 710dcf9f5621b..0000000000000 --- a/src/plugins/ui_actions/public/triggers/detach_action.ts +++ /dev/null @@ -1,35 +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 { UiActionsApiPure } from '../types'; - -export const detachAction: UiActionsApiPure['detachAction'] = ({ triggers }) => ( - triggerId, - actionId -) => { - const trigger = triggers.get(triggerId); - - if (!trigger) { - throw new Error( - `No trigger [triggerId = ${triggerId}] exists, for detaching action [actionId = ${actionId}].` - ); - } - - trigger.actionIds = trigger.actionIds.filter(id => id !== actionId); -}; diff --git a/src/plugins/ui_actions/public/triggers/execute_trigger_actions.ts b/src/plugins/ui_actions/public/triggers/execute_trigger_actions.ts deleted file mode 100644 index 71f69eb3bdc29..0000000000000 --- a/src/plugins/ui_actions/public/triggers/execute_trigger_actions.ts +++ /dev/null @@ -1,59 +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 { UiActionsApiPure } from '../types'; -import { buildContextMenuForActions, openContextMenu } from '../context_menu'; -import { Action } from '../actions'; - -const executeSingleAction = async (action: Action, actionContext: A) => { - const href = action.getHref && action.getHref(actionContext); - - // TODO: Do we need a `getHref()` special case? - if (href) { - window.location.href = href; - return; - } - - await action.execute(actionContext); -}; - -export const executeTriggerActions: UiActionsApiPure['executeTriggerActions'] = ({ api }) => async ( - triggerId, - actionContext -) => { - const actions = await api.getTriggerCompatibleActions!(triggerId, actionContext); - - if (!actions.length) { - throw new Error( - `No compatible actions found to execute for trigger [triggerId = ${triggerId}].` - ); - } - - if (actions.length === 1) { - await executeSingleAction(actions[0], actionContext); - return; - } - - const panel = await buildContextMenuForActions({ - actions, - actionContext, - closeMenu: () => session.close(), - }); - const session = openContextMenu([panel]); -}; diff --git a/src/plugins/ui_actions/public/triggers/get_trigger.test.ts b/src/plugins/ui_actions/public/triggers/get_trigger.test.ts deleted file mode 100644 index 88dd5a8990c9d..0000000000000 --- a/src/plugins/ui_actions/public/triggers/get_trigger.test.ts +++ /dev/null @@ -1,48 +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 { createApi } from '../api'; -import { createDeps } from '../tests/helpers'; - -test('can get Trigger from registry', () => { - const deps = createDeps(); - const { api } = createApi(deps); - api.registerTrigger({ - actionIds: [], - description: 'foo', - id: 'bar', - title: 'baz', - }); - - const trigger = api.getTrigger('bar'); - - expect(trigger).toEqual({ - actionIds: [], - description: 'foo', - id: 'bar', - title: 'baz', - }); -}); - -test('throws if trigger does not exist', () => { - const deps = createDeps(); - const { api } = createApi(deps); - - expect(() => api.getTrigger('foo')).toThrowError('Trigger [triggerId = foo] does not exist.'); -}); diff --git a/src/plugins/ui_actions/public/triggers/get_trigger.ts b/src/plugins/ui_actions/public/triggers/get_trigger.ts deleted file mode 100644 index 5c96200261a90..0000000000000 --- a/src/plugins/ui_actions/public/triggers/get_trigger.ts +++ /dev/null @@ -1,30 +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 { UiActionsApiPure } from '../types'; - -export const getTrigger: UiActionsApiPure['getTrigger'] = ({ triggers }) => id => { - const trigger = triggers.get(id); - - if (!trigger) { - throw new Error(`Trigger [triggerId = ${id}] does not exist.`); - } - - return trigger; -}; diff --git a/src/plugins/ui_actions/public/triggers/get_trigger_actions.ts b/src/plugins/ui_actions/public/triggers/get_trigger_actions.ts deleted file mode 100644 index 37d7d5534c8c1..0000000000000 --- a/src/plugins/ui_actions/public/triggers/get_trigger_actions.ts +++ /dev/null @@ -1,29 +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 { UiActionsApiPure } from '../types'; -import { Action } from '../actions'; - -export const getTriggerActions: UiActionsApiPure['getTriggerActions'] = ({ - api, - actions, -}) => id => { - const trigger = api.getTrigger!(id); - return trigger.actionIds.map(actionId => actions.get(actionId)).filter(Boolean) as Action[]; -}; diff --git a/src/plugins/ui_actions/public/triggers/get_trigger_compatible_actions.ts b/src/plugins/ui_actions/public/triggers/get_trigger_compatible_actions.ts deleted file mode 100644 index 8be0db7561db9..0000000000000 --- a/src/plugins/ui_actions/public/triggers/get_trigger_compatible_actions.ts +++ /dev/null @@ -1,32 +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 { UiActionsApiPure } from '../types'; -import { Action } from '../actions/action'; - -export const getTriggerCompatibleActions: UiActionsApiPure['getTriggerCompatibleActions'] = ({ - api, -}) => async (triggerId, context) => { - const actions = api.getTriggerActions!(triggerId); - const isCompatibles = await Promise.all(actions.map(action => action.isCompatible(context))); - return actions.reduce( - (acc, action, i) => (isCompatibles[i] ? [...acc, action] : acc), - [] - ); -}; diff --git a/src/plugins/ui_actions/public/triggers/index.ts b/src/plugins/ui_actions/public/triggers/index.ts index 3006a5428f45e..a34c6eda61ba0 100644 --- a/src/plugins/ui_actions/public/triggers/index.ts +++ b/src/plugins/ui_actions/public/triggers/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { IncompatibleActionError } from './incompatible_action_error'; +export { Trigger } from './trigger'; diff --git a/src/plugins/ui_actions/public/triggers/register_trigger.ts b/src/plugins/ui_actions/public/triggers/register_trigger.ts deleted file mode 100644 index c9a7bb211d05a..0000000000000 --- a/src/plugins/ui_actions/public/triggers/register_trigger.ts +++ /dev/null @@ -1,28 +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 { UiActionsApiPure } from '../types'; - -export const registerTrigger: UiActionsApiPure['registerTrigger'] = ({ triggers }) => trigger => { - if (triggers.has(trigger.id)) { - throw new Error(`Trigger [trigger.id = ${trigger.id}] already registered.`); - } - - triggers.set(trigger.id, trigger); -}; diff --git a/src/plugins/ui_actions/public/triggers/registry.test.ts b/src/plugins/ui_actions/public/triggers/registry.test.ts deleted file mode 100644 index 6edb2b19a95e4..0000000000000 --- a/src/plugins/ui_actions/public/triggers/registry.test.ts +++ /dev/null @@ -1,149 +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 { createApi } from '../api'; -import { createDeps } from '../tests/helpers'; - -const HELLO_WORLD_ACTION_ID = 'HELLO_WORLD_ACTION_ID'; - -test('can register trigger', () => { - const deps = createDeps(); - const { api } = createApi(deps); - - api.registerTrigger({ - actionIds: [], - description: 'foo', - id: 'bar', - title: 'baz', - }); - - expect(deps.triggers.get('bar')).toEqual({ - actionIds: [], - description: 'foo', - id: 'bar', - title: 'baz', - }); -}); - -test('can register action', () => { - const deps = createDeps(); - const { api } = createApi(deps); - - api.registerAction({ - id: HELLO_WORLD_ACTION_ID, - order: 13, - } as any); - - expect(deps.actions.get(HELLO_WORLD_ACTION_ID)).toMatchObject({ - id: HELLO_WORLD_ACTION_ID, - order: 13, - }); -}); - -test('can attach an action to a trigger', () => { - const deps = createDeps(); - const { api } = createApi(deps); - const trigger = { - id: 'MY-TRIGGER', - actionIds: [], - }; - const action = { - id: HELLO_WORLD_ACTION_ID, - order: 25, - } as any; - - expect(trigger.actionIds).toEqual([]); - - api.registerTrigger(trigger); - api.registerAction(action); - api.attachAction('MY-TRIGGER', HELLO_WORLD_ACTION_ID); - - expect(trigger.actionIds).toEqual([HELLO_WORLD_ACTION_ID]); -}); - -test('can detach an action to a trigger', () => { - const deps = createDeps(); - const { api } = createApi(deps); - const trigger = { - id: 'MY-TRIGGER', - actionIds: [], - }; - const action = { - id: HELLO_WORLD_ACTION_ID, - order: 25, - } as any; - - expect(trigger.actionIds).toEqual([]); - - api.registerTrigger(trigger); - api.registerAction(action); - api.attachAction('MY-TRIGGER', HELLO_WORLD_ACTION_ID); - api.detachAction('MY-TRIGGER', HELLO_WORLD_ACTION_ID); - - expect(trigger.actionIds).toEqual([]); -}); - -test('detaching an invalid action from a trigger throws an error', async () => { - const { api } = createApi({ actions: new Map(), triggers: new Map() }); - const action = { - id: HELLO_WORLD_ACTION_ID, - order: 25, - } as any; - - api.registerAction(action); - expect(() => api.detachAction('i do not exist', HELLO_WORLD_ACTION_ID)).toThrowError( - 'No trigger [triggerId = i do not exist] exists, for detaching action [actionId = HELLO_WORLD_ACTION_ID].' - ); -}); - -test('attaching an invalid action to a trigger throws an error', async () => { - const { api } = createApi({ actions: new Map(), triggers: new Map() }); - const action = { - id: HELLO_WORLD_ACTION_ID, - order: 25, - } as any; - - api.registerAction(action); - expect(() => api.attachAction('i do not exist', HELLO_WORLD_ACTION_ID)).toThrowError( - 'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = HELLO_WORLD_ACTION_ID].' - ); -}); - -test('cannot register another action with the same ID', async () => { - const { api } = createApi({ actions: new Map(), triggers: new Map() }); - const action = { - id: HELLO_WORLD_ACTION_ID, - order: 25, - } as any; - - api.registerAction(action); - expect(() => api.registerAction(action)).toThrowError( - 'Action [action.id = HELLO_WORLD_ACTION_ID] already registered.' - ); -}); - -test('cannot register another trigger with the same ID', async () => { - const { api } = createApi({ actions: new Map(), triggers: new Map() }); - const trigger = { id: 'MY-TRIGGER' } as any; - - api.registerTrigger(trigger); - expect(() => api.registerTrigger(trigger)).toThrowError( - 'Trigger [trigger.id = MY-TRIGGER] already registered.' - ); -}); diff --git a/src/plugins/ui_actions/public/triggers/trigger.ts b/src/plugins/ui_actions/public/triggers/trigger.ts index 3db11953053d5..ba83f5619e250 100644 --- a/src/plugins/ui_actions/public/triggers/trigger.ts +++ b/src/plugins/ui_actions/public/triggers/trigger.ts @@ -21,5 +21,4 @@ export interface Trigger { id: string; title?: string; description?: string; - actionIds: string[]; } diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index ed4728342b751..9bd6ffdef2af3 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -20,39 +20,6 @@ import { Action } from './actions/action'; import { Trigger } from './triggers/trigger'; -export { Action } from './actions'; -export { Trigger } from './triggers/trigger'; - -export type ExecuteTriggerActions = (triggerId: string, actionContext: A) => Promise; - -export type GetActionsCompatibleWithTrigger = ( - triggerId: string, - context: C -) => Promise; - -export interface UiActionsApi { - attachAction: (triggerId: string, actionId: string) => void; - detachAction: (triggerId: string, actionId: string) => void; - executeTriggerActions: ExecuteTriggerActions; - getTrigger: (id: string) => Trigger; - getTriggerActions: (id: string) => Action[]; - getTriggerCompatibleActions: (triggerId: string, context: C) => Promise>>; - registerAction: (action: Action) => void; - registerTrigger: (trigger: Trigger) => void; -} - -export interface UiActionsDependencies { - actions: ActionRegistry; - triggers: TriggerRegistry; -} - -export interface UiActionsDependenciesInternal extends UiActionsDependencies { - api: Readonly>; -} - -export type UiActionsApiPure = { - [K in keyof UiActionsApi]: (deps: UiActionsDependenciesInternal) => UiActionsApi[K]; -}; - export type TriggerRegistry = Map; export type ActionRegistry = Map; +export type TriggerToActionsRegistry = Map; diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/np_ready/app/components/main.tsx b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/np_ready/app/components/main.tsx index 41e466fddd11e..a50248a5b6fa3 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/np_ready/app/components/main.tsx +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/np_ready/app/components/main.tsx @@ -22,7 +22,7 @@ import { EuiPage, EuiPageBody, EuiPageContent, EuiPageContentHeader } from '@ela import { first } from 'rxjs/operators'; import { IInterpreterRenderHandlers, ExpressionValue } from 'src/plugins/expressions'; import { RequestAdapter, DataAdapter } from '../../../../../../../../src/plugins/inspector'; -import { Adapters, ExpressionRenderHandler, ExpressionDataHandler } from '../../types'; +import { Adapters, ExpressionRenderHandler } from '../../types'; import { getExpressions } from '../../services'; declare global { @@ -31,7 +31,7 @@ declare global { expressions: string, context?: ExpressionValue, initialContext?: ExpressionValue - ) => ReturnType; + ) => any; renderPipelineResponse: (context?: ExpressionValue) => Promise; } } @@ -61,12 +61,9 @@ class Main extends React.Component<{}, State> { data: new DataAdapter(), }; return getExpressions() - .execute(expression, { + .execute(expression, context || { type: 'null' }, { inspectorAdapters: adapters, - context, - // TODO: naming / typing is confusing and doesn't match here - // searchContext is also a way to set initialContext and Context can't be set to SearchContext - searchContext: initialContext as any, + search: initialContext as any, }) .getData(); }; diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/np_ready/types.ts b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/np_ready/types.ts index 6e0a93e4a3cb1..123baa1183c48 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/np_ready/types.ts +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/np_ready/types.ts @@ -17,12 +17,7 @@ * under the License. */ -import { - ExpressionsStart, - ExpressionRenderHandler, - ExpressionDataHandler, -} from 'src/plugins/expressions/public'; - +import { ExpressionsStart, ExpressionRenderHandler } from 'src/plugins/expressions/public'; import { Adapters } from 'src/plugins/inspector/public'; -export { ExpressionsStart, ExpressionRenderHandler, ExpressionDataHandler, Adapters }; +export { ExpressionsStart, ExpressionRenderHandler, Adapters }; diff --git a/test/interpreter_functional/test_suites/run_pipeline/basic.ts b/test/interpreter_functional/test_suites/run_pipeline/basic.ts index 77853b0bcd6a4..a2172dd2da1ba 100644 --- a/test/interpreter_functional/test_suites/run_pipeline/basic.ts +++ b/test/interpreter_functional/test_suites/run_pipeline/basic.ts @@ -22,7 +22,7 @@ import { ExpectExpression, expectExpressionProvider } from './helpers'; import { FtrProviderContext } from '../../../functional/ftr_provider_context'; // this file showcases how to use testing utilities defined in helpers.ts together with the kbn_tp_run_pipeline -// test plugin to write autmated tests for interprete +// test plugin to write automated tests for interpreter export default function({ getService, updateBaselines, diff --git a/test/interpreter_functional/test_suites/run_pipeline/helpers.ts b/test/interpreter_functional/test_suites/run_pipeline/helpers.ts index 015c311c30aef..00693845bb266 100644 --- a/test/interpreter_functional/test_suites/run_pipeline/helpers.ts +++ b/test/interpreter_functional/test_suites/run_pipeline/helpers.ts @@ -20,10 +20,8 @@ import expect from '@kbn/expect'; import { ExpressionValue } from 'src/plugins/expressions'; import { FtrProviderContext } from '../../../functional/ftr_provider_context'; -import { ExpressionDataHandler } from '../../plugins/kbn_tp_run_pipeline/public/np_ready/types'; -type UnWrapPromise = T extends Promise ? U : T; -export type ExpressionResult = UnWrapPromise>; +export type ExpressionResult = any; export type ExpectExpression = ( name: string, @@ -112,7 +110,7 @@ export function expectExpressionProvider({ if (!_currentContext.type) _currentContext.type = 'null'; return window .runPipeline(_expression, _currentContext, _initialContext) - .then(expressionResult => { + .then((expressionResult: any) => { done(expressionResult); return expressionResult; }); diff --git a/x-pack/legacy/plugins/apm/cypress/ci/kibana.dev.yml b/x-pack/legacy/plugins/apm/cypress/ci/kibana.dev.yml index 3082391f23a15..db57db9a1abe9 100644 --- a/x-pack/legacy/plugins/apm/cypress/ci/kibana.dev.yml +++ b/x-pack/legacy/plugins/apm/cypress/ci/kibana.dev.yml @@ -2,3 +2,6 @@ # Disabled plugins ######################## logging.verbose: true +elasticsearch.username: "kibana_system_user" +elasticsearch.password: "changeme" +xpack.security.encryptionKey: "something_at_least_32_characters" diff --git a/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js b/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js index bb95d3e420d2a..beb5918e277ae 100644 --- a/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js +++ b/x-pack/legacy/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js @@ -325,9 +325,17 @@ export class ImportView extends Component { onIndexChange = e => { const name = e.target.value; + const { indexNames, indexPattern, indexPatternNames } = this.state; + this.setState({ index: name, - indexNameError: isIndexNameValid(name, this.state.indexNames), + indexNameError: isIndexNameValid(name, indexNames), + // if index pattern has been altered, check that it still matches the inputted index + ...(indexPattern === '' + ? {} + : { + indexPatternNameError: isIndexPatternNameValid(indexPattern, indexPatternNames, name), + }), }); }; diff --git a/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js b/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js index cf13d329182ba..9317d3c6c3e07 100644 --- a/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js +++ b/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js @@ -450,6 +450,31 @@ export const elasticsearchJsPlugin = (Client, config, components) => { method: 'POST', }); + ml.buckets = ca({ + urls: [ + { + fmt: '/_ml/anomaly_detectors/<%=jobId%>/results/buckets', + req: { + jobId: { + type: 'string', + }, + }, + }, + { + fmt: '/_ml/anomaly_detectors/<%=jobId%>/results/buckets/<%=timestamp%>', + req: { + jobId: { + type: 'string', + }, + timestamp: { + type: 'string', + }, + }, + }, + ], + method: 'POST', + }); + ml.overallBuckets = ca({ url: { fmt: '/_ml/anomaly_detectors/<%=jobId%>/results/overall_buckets', diff --git a/x-pack/legacy/plugins/ml/server/routes/anomaly_detectors.ts b/x-pack/legacy/plugins/ml/server/routes/anomaly_detectors.ts index 64af1f67bce29..927646e4f0acc 100644 --- a/x-pack/legacy/plugins/ml/server/routes/anomaly_detectors.ts +++ b/x-pack/legacy/plugins/ml/server/routes/anomaly_detectors.ts @@ -36,7 +36,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { try { const results = await context.ml!.mlClient.callAsCurrentUser('ml.jobs'); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -67,7 +67,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { const { jobId } = request.params; const results = await context.ml!.mlClient.callAsCurrentUser('ml.jobs', { jobId }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -94,7 +94,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { try { const results = await context.ml!.mlClient.callAsCurrentUser('ml.jobStats'); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -125,7 +125,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { const { jobId } = request.params; const results = await context.ml!.mlClient.callAsCurrentUser('ml.jobStats', { jobId }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -160,7 +160,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { body: request.body, }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -195,7 +195,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { body: request.body, }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -228,7 +228,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { jobId, }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -265,7 +265,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { } const results = await context.ml!.mlClient.callAsCurrentUser('ml.closeJob', options); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -302,7 +302,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { } const results = await context.ml!.mlClient.callAsCurrentUser('ml.deleteJob', options); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -332,7 +332,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { body: request.body, }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -368,7 +368,57 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { duration, }); return response.ok({ - body: { ...results }, + body: results, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + + /** + * @apiGroup AnomalyDetectors + * + * @api {post} /api/ml/anomaly_detectors/:jobId/results/buckets Obtain bucket scores for the specified job ID + * @apiName GetOverallBuckets + * @apiDescription The get buckets API presents a chronological view of the records, grouped by bucket. + * + * @apiParam {String} jobId Job ID. + * @apiParam {String} timestamp. + * + * @apiSuccess {Number} count + * @apiSuccess {Object[]} buckets + */ + router.post( + { + path: '/api/ml/anomaly_detectors/{jobId}/results/buckets/{timestamp?}', + validate: { + params: schema.object({ + jobId: schema.string(), + timestamp: schema.maybe(schema.string()), + }), + body: schema.object({ + anomaly_score: schema.maybe(schema.number()), + desc: schema.maybe(schema.boolean()), + end: schema.maybe(schema.string()), + exclude_interim: schema.maybe(schema.boolean()), + expand: schema.maybe(schema.boolean()), + 'page.from': schema.maybe(schema.number()), + 'page.size': schema.maybe(schema.number()), + sort: schema.maybe(schema.string()), + start: schema.maybe(schema.string()), + }), + }, + }, + licensePreRoutingFactory(xpackMainPlugin, async (context, request, response) => { + try { + const results = await context.ml!.mlClient.callAsCurrentUser('ml.buckets', { + jobId: request.params.jobId, + timestamp: request.params.timestamp, + ...request.body, + }); + return response.ok({ + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -413,7 +463,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { end: request.body.end, }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -449,7 +499,7 @@ export function jobRoutes({ xpackMainPlugin, router }: RouteInitialization) { }; const results = await context.ml!.mlClient.callAsCurrentUser('ml.categories', options); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); diff --git a/x-pack/legacy/plugins/ml/server/routes/data_frame_analytics.ts b/x-pack/legacy/plugins/ml/server/routes/data_frame_analytics.ts index f134820adbb48..6541fa541a59f 100644 --- a/x-pack/legacy/plugins/ml/server/routes/data_frame_analytics.ts +++ b/x-pack/legacy/plugins/ml/server/routes/data_frame_analytics.ts @@ -40,7 +40,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti try { const results = await context.ml!.mlClient.callAsCurrentUser('ml.getDataFrameAnalytics'); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -71,7 +71,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti analyticsId, }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -97,7 +97,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti 'ml.getDataFrameAnalyticsStats' ); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -131,7 +131,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti } ); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -170,7 +170,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti } ); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -201,7 +201,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti } ); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -241,7 +241,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti } ); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -277,7 +277,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti } ); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -310,7 +310,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti analyticsId, }); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); @@ -353,7 +353,7 @@ export function dataFrameAnalyticsRoutes({ xpackMainPlugin, router }: RouteIniti options ); return response.ok({ - body: { ...results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/fields_browser/fields_browser.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/fields_browser/fields_browser.spec.ts index 2889d78891a06..6e8ef93a54016 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/fields_browser/fields_browser.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/fields_browser/fields_browser.spec.ts @@ -22,11 +22,9 @@ import { FIELDS_BROWSER_HEADER_HOST_GEO_CONTINENT_NAME_HEADER, } from '../../../screens/timeline/fields_browser'; -import { - openTimeline, - populateTimeline, - openTimelineFieldsBrowser, -} from '../../../tasks/timeline/main'; +import { populateTimeline, openTimelineFieldsBrowser } from '../../../tasks/timeline/main'; + +import { openTimeline } from '../../../tasks/siem_main'; import { clearFieldsBrowser, diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/inspect/inspect.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/inspect/inspect.spec.ts index e7411aba11af5..1555470f5eee7 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/inspect/inspect.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/inspect/inspect.spec.ts @@ -12,10 +12,10 @@ import { } from '../../../screens/inspect'; import { executeTimelineKQL, - openTimeline, openTimelineSettings, openTimelineInspectButton, } from '../../../tasks/timeline/main'; +import { openTimeline } from '../../../tasks/siem_main'; import { DEFAULT_TIMEOUT, loginAndWaitForPage } from '../../../tasks/login'; import { closesModal, openStatsAndTables } from '../../../tasks/inspect'; diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts index 3d251c1c6bcac..c3fedfb06939b 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts @@ -13,7 +13,8 @@ import { } from '../../../tasks/hosts/all_hosts'; import { HOSTS_NAMES } from '../../../screens/hosts/all_hosts'; import { DEFAULT_TIMEOUT, loginAndWaitForPage } from '../../../tasks/login'; -import { openTimeline, createNewTimeline } from '../../../tasks/timeline/main'; +import { createNewTimeline } from '../../../tasks/timeline/main'; +import { openTimeline } from '../../../tasks/siem_main'; import { TIMELINE_DATA_PROVIDERS_EMPTY, TIMELINE_DATA_PROVIDERS, diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts index 63fe56371a4cd..b7faaaac1c06c 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/flyout_button.spec.ts @@ -4,44 +4,34 @@ * you may not use this file except in compliance with the Elastic License. */ +import { HOSTS_PAGE } from '../../../urls/navigation'; +import { waitForAllHostsToBeLoaded, dragFirstHostToTimeline } from '../../../tasks/hosts/all_hosts'; +import { loginAndWaitForPage } from '../../../tasks/login'; +import { openTimelineIfClosed, openTimeline } from '../../../tasks/siem_main'; import { TIMELINE_FLYOUT_BODY, TIMELINE_NOT_READY_TO_DROP_BUTTON, -} from '../../lib/timeline/selectors'; -import { ALL_HOSTS_WIDGET_DRAGGABLE_HOSTS } from '../../lib/hosts/selectors'; -import { HOSTS_PAGE } from '../../lib/urls'; -import { waitForAllHostsWidget } from '../../lib/hosts/helpers'; -import { loginAndWaitForPage } from '../../lib/util/helpers'; -import { drag } from '../../lib/drag_n_drop/helpers'; -import { createNewTimeline, toggleTimelineVisibility } from '../../lib/timeline/helpers'; +} from '../../../screens/timeline/main'; +import { createNewTimeline } from '../../../tasks/timeline/main'; describe('timeline flyout button', () => { before(() => { loginAndWaitForPage(HOSTS_PAGE); + waitForAllHostsToBeLoaded(); }); afterEach(() => { - cy.get('[data-test-subj="kibanaChrome"]').then($page => { - if ($page.find('[data-test-subj="flyoutOverlay"]').length === 1) { - toggleTimelineVisibility(); - } - }); - + openTimelineIfClosed(); createNewTimeline(); }); it('toggles open the timeline', () => { - toggleTimelineVisibility(); - + openTimeline(); cy.get(TIMELINE_FLYOUT_BODY).should('have.css', 'visibility', 'visible'); }); it('sets the flyout button background to euiColorSuccess with a 10% alpha channel when the user starts dragging a host, but is not hovering over the flyout button', () => { - waitForAllHostsWidget(); - - cy.get(ALL_HOSTS_WIDGET_DRAGGABLE_HOSTS) - .first() - .then(host => drag(host)); + dragFirstHostToTimeline(); cy.get(TIMELINE_NOT_READY_TO_DROP_BUTTON).should( 'have.css', diff --git a/x-pack/legacy/plugins/siem/cypress/screens/siem_main.ts b/x-pack/legacy/plugins/siem/cypress/screens/siem_main.ts new file mode 100644 index 0000000000000..d4eeeb036ee95 --- /dev/null +++ b/x-pack/legacy/plugins/siem/cypress/screens/siem_main.ts @@ -0,0 +1,9 @@ +/* + * 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 MAIN_PAGE = '[data-test-subj="kibanaChrome"]'; + +export const TIMELINE_TOGGLE_BUTTON = '[data-test-subj="flyoutOverlay"]'; diff --git a/x-pack/legacy/plugins/siem/cypress/screens/timeline/main.ts b/x-pack/legacy/plugins/siem/cypress/screens/timeline/main.ts index 60c9c2ab44372..4c722ffa5f215 100644 --- a/x-pack/legacy/plugins/siem/cypress/screens/timeline/main.ts +++ b/x-pack/legacy/plugins/siem/cypress/screens/timeline/main.ts @@ -32,3 +32,8 @@ export const TIMELINE_DATA_PROVIDERS_EMPTY = export const TIMELINE_DROPPED_DATA_PROVIDERS = '[data-test-subj="dataProviders"] [data-test-subj="providerContainer"]'; + +export const TIMELINE_FLYOUT_BODY = '[data-test-subj="eui-flyout-body"]'; + +export const TIMELINE_NOT_READY_TO_DROP_BUTTON = + '[data-test-subj="flyout-button-not-ready-to-drop"]'; diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/siem_main.ts b/x-pack/legacy/plugins/siem/cypress/tasks/siem_main.ts new file mode 100644 index 0000000000000..8501bb3d94e26 --- /dev/null +++ b/x-pack/legacy/plugins/siem/cypress/tasks/siem_main.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MAIN_PAGE, TIMELINE_TOGGLE_BUTTON } from '../screens/siem_main'; +import { DEFAULT_TIMEOUT } from '../tasks/login'; + +export const openTimelineIfClosed = () => { + cy.get(MAIN_PAGE).then($page => { + if ($page.find(TIMELINE_TOGGLE_BUTTON).length === 1) { + openTimeline(); + } + }); +}; + +export const openTimeline = () => { + cy.get(TIMELINE_TOGGLE_BUTTON, { timeout: DEFAULT_TIMEOUT }).click(); +}; diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/timeline/main.ts b/x-pack/legacy/plugins/siem/cypress/tasks/timeline/main.ts index 068b6dd9f8bd4..f347c072a3584 100644 --- a/x-pack/legacy/plugins/siem/cypress/tasks/timeline/main.ts +++ b/x-pack/legacy/plugins/siem/cypress/tasks/timeline/main.ts @@ -7,7 +7,6 @@ import { DEFAULT_TIMEOUT } from '../../integration/lib/util/helpers'; import { - TIMELINE_TOGGLE_BUTTON, SEARCH_OR_FILTER_CONTAINER, TIMELINE_FIELDS_BUTTON, SERVER_SIDE_EVENT_COUNT, @@ -19,10 +18,6 @@ import { export const hostExistsQuery = 'host.name: *'; -export const openTimeline = () => { - cy.get(TIMELINE_TOGGLE_BUTTON, { timeout: DEFAULT_TIMEOUT }).click(); -}; - export const populateTimeline = () => { cy.get(`${SEARCH_OR_FILTER_CONTAINER} input`).type(`${hostExistsQuery} {enter}`); cy.get(SERVER_SIDE_EVENT_COUNT, { timeout: DEFAULT_TIMEOUT })