diff --git a/src/core_plugins/kibana/index.js b/src/core_plugins/kibana/index.js index a8299059baf32..436e3e776911c 100644 --- a/src/core_plugins/kibana/index.js +++ b/src/core_plugins/kibana/index.js @@ -43,7 +43,8 @@ module.exports = function (kibana) { 'navbarExtensions', 'managementSections', 'devTools', - 'docViews' + 'docViews', + 'embeddableHandlers', ], injectVars, }, diff --git a/src/core_plugins/kibana/public/dashboard/__tests__/panel.js b/src/core_plugins/kibana/public/dashboard/__tests__/panel.js index 5d489ef34d84e..08ebb355bcbbe 100644 --- a/src/core_plugins/kibana/public/dashboard/__tests__/panel.js +++ b/src/core_plugins/kibana/public/dashboard/__tests__/panel.js @@ -15,7 +15,17 @@ describe('dashboard panel', function () { function init(mockDocResponse) { ngMock.module('kibana'); ngMock.inject(($rootScope, $compile, esAdmin) => { - sinon.stub(esAdmin, 'mget').returns(Promise.resolve({ docs: [ mockDocResponse ] })); + sinon.stub(esAdmin, 'mget', function (request) { + const response = { + docs: [] + }; + request.body.docs.forEach(() => { + response.docs.push(mockDocResponse); + }); + + return Promise.resolve(response); + }); + sinon.stub(esAdmin.indices, 'getFieldMapping').returns(Promise.resolve({ '.kibana': { mappings: { @@ -63,7 +73,7 @@ describe('dashboard panel', function () { expect($scope.error).to.be('Could not locate that visualization (id: foo1)'); parentScope.$digest(); const content = $el.find('.panel-content'); - expect(content).to.have.length(0); + expect(content.children().length).to.be(0); }); }); @@ -73,7 +83,7 @@ describe('dashboard panel', function () { expect($scope.error).not.to.be.ok(); parentScope.$digest(); const content = $el.find('.panel-content'); - expect(content).to.have.length(1); + expect(content.children().length).to.be.greaterThan(0); }); }); }); diff --git a/src/core_plugins/kibana/public/dashboard/panel/get_object_loaders_for_dashboard.js b/src/core_plugins/kibana/public/dashboard/panel/get_object_loaders_for_dashboard.js deleted file mode 100644 index 40c99b3c29372..0000000000000 --- a/src/core_plugins/kibana/public/dashboard/panel/get_object_loaders_for_dashboard.js +++ /dev/null @@ -1,11 +0,0 @@ -import { uiModules } from 'ui/modules'; -const module = uiModules.get('app/dashboard'); - -/** - * We have more types available than just 'search' and 'visualization' but as of now, they - * can't be added to a dashboard. - */ -module.factory('getObjectLoadersForDashboard', function (savedSearches, savedVisualizations) { - return () => [savedSearches, savedVisualizations]; -}); - diff --git a/src/core_plugins/kibana/public/dashboard/panel/load_saved_object.js b/src/core_plugins/kibana/public/dashboard/panel/load_saved_object.js deleted file mode 100644 index d9df46dbdcd37..0000000000000 --- a/src/core_plugins/kibana/public/dashboard/panel/load_saved_object.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Retrieves the saved object represented by the panel and returns it, along with the appropriate - * edit Url. - * @param {Array.} loaders - The available loaders for different panel types. - * @param {PanelState} panel - * @returns {Promise.<{savedObj: SavedObject, editUrl: String}>} - */ -export function loadSavedObject(loaders, panel) { - const loader = loaders.find((loader) => loader.type === panel.type); - if (!loader) { - throw new Error(`No loader for object of type ${panel.type}`); - } - return loader.get(panel.id) - .then(savedObj => ({ savedObj, editUrl: loader.urlFor(panel.id) })); -} diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel.html b/src/core_plugins/kibana/public/dashboard/panel/panel.html index ddb46a61feafd..05bea3c746a3b 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel.html +++ b/src/core_plugins/kibana/public/dashboard/panel/panel.html @@ -1,11 +1,11 @@ -
+
- {{::savedObj.title}} + {{::title}} - - - - - + >
diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel.js b/src/core_plugins/kibana/public/dashboard/panel/panel.js index 310ec7cc547db..79aa8aae5a227 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel.js @@ -1,23 +1,17 @@ import _ from 'lodash'; import 'ui/visualize'; import 'ui/doc_table'; -import * as columnActions from 'ui/doc_table/actions/columns'; -import 'plugins/kibana/dashboard/panel/get_object_loaders_for_dashboard'; import 'plugins/kibana/visualize/saved_visualizations'; import 'plugins/kibana/discover/saved_searches'; -import { FilterManagerProvider } from 'ui/filter_manager'; import { uiModules } from 'ui/modules'; import panelTemplate from 'plugins/kibana/dashboard/panel/panel.html'; import { savedObjectManagementRegistry } from 'plugins/kibana/management/saved_object_registry'; -import { getPersistedStateId } from 'plugins/kibana/dashboard/panel/panel_state'; -import { loadSavedObject } from 'plugins/kibana/dashboard/panel/load_saved_object'; import { DashboardViewMode } from '../dashboard_view_mode'; +import { EmbeddableHandlersRegistryProvider } from 'ui/registry/embeddable_handlers'; uiModules .get('app/dashboard') -.directive('dashboardPanel', function (savedVisualizations, savedSearches, Notifier, Private, $injector, getObjectLoadersForDashboard) { - const filterManager = Private(FilterManagerProvider); - +.directive('dashboardPanel', function (Notifier, Private, $injector) { const services = savedObjectManagementRegistry.all().map(function (serviceObj) { const service = $injector.get(serviceObj.service); return { @@ -84,94 +78,58 @@ uiModules link: function ($scope, element) { if (!$scope.panel.id || !$scope.panel.type) return; - /** - * Initializes the panel for the saved object. - * @param {{savedObj: SavedObject, editUrl: String}} savedObjectInfo - */ - function initializePanel(savedObjectInfo) { - $scope.savedObj = savedObjectInfo.savedObj; - $scope.editUrl = savedObjectInfo.editUrl; - - element.on('$destroy', function () { - $scope.savedObj.destroy(); - $scope.$destroy(); - }); + const saveState = (panel) => { + $scope.panel = Object.assign($scope.panel, panel); + $scope.saveState(); + }; - // create child ui state from the savedObj - const uiState = $scope.savedObj.uiStateJSON ? JSON.parse($scope.savedObj.uiStateJSON) : {}; - $scope.uiState = $scope.createChildUiState(getPersistedStateId($scope.panel), uiState); + $scope.isViewOnlyMode = () => { + return $scope.dashboardViewMode === DashboardViewMode.VIEW || $scope.isFullScreenMode; + }; - if ($scope.panel.type === savedVisualizations.type && $scope.savedObj.vis) { - $scope.savedObj.vis.setUiState($scope.uiState); - $scope.savedObj.vis.listeners.click = $scope.getVisClickHandler(); - $scope.savedObj.vis.listeners.brush = $scope.getVisBrushHandler(); - } else if ($scope.panel.type === savedSearches.type) { - // This causes changes to a saved search to be hidden, but also allows - // the user to locally modify and save changes to a saved search only in a dashboard. - // See https://github.com/elastic/kibana/issues/9523 for more details. - $scope.panel.columns = $scope.panel.columns || $scope.savedObj.columns; - $scope.panel.sort = $scope.panel.sort || $scope.savedObj.sort; + // TODO: vis actions should be generalized for use by all panel renderers, e.g. updateFilters, updateTimeRange. + const actions = { + getVisClickHandler: $scope.getVisClickHandler, + getVisBrushHandler: $scope.getVisBrushHandler, + saveState, + getIsViewOnlyMode: $scope.isViewOnlyMode, + createChildUiState: $scope.createChildUiState + }; - $scope.setSortOrder = function setSortOrder(columnName, direction) { - $scope.panel.sort = [columnName, direction]; - $scope.saveState(); - }; + const handleError = (error) => { + $scope.error = error.message; - $scope.addColumn = function addColumn(columnName) { - $scope.savedObj.searchSource.get('index').popularizeField(columnName, 1); - columnActions.addColumn($scope.panel.columns, columnName); - $scope.saveState(); // sync to sharing url - }; + // Dashboard listens for this broadcast, once for every visualization (pendingVisCount). + // We need to broadcast even in the event of an error or it'll never fetch the data for + // other visualizations. + $scope.$root.$broadcast('ready:vis'); - $scope.removeColumn = function removeColumn(columnName) { - $scope.savedObj.searchSource.get('index').popularizeField(columnName, 1); - columnActions.removeColumn($scope.panel.columns, columnName); - $scope.saveState(); // sync to sharing url - }; + // If the savedObjectType matches the panel type, this means the object itself has been deleted, + // so we shouldn't even have an edit link. If they don't match, it means something else is wrong + // with the object (but the object still exists), so we link to the object editor instead. + const objectItselfDeleted = error.savedObjectType === $scope.panel.type; + if (objectItselfDeleted) return; - $scope.moveColumn = function moveColumn(columnName, newIndex) { - columnActions.moveColumn($scope.panel.columns, columnName, newIndex); - $scope.saveState(); // sync to sharing url - }; - } + const type = $scope.panel.type; + const id = $scope.panel.id; + const service = _.find(services, { type: type }); + if (!service) return; - $scope.filter = function (field, value, operator) { - const index = $scope.savedObj.searchSource.get('index').id; - filterManager.add(field, value, operator, index); - }; + $scope.editUrl = '#management/kibana/objects/' + service.name + '/' + id + '?notFound=' + error.savedObjectType; + }; + const embeddableHandlers = Private(EmbeddableHandlersRegistryProvider); + const embeddableHandler = embeddableHandlers.byName[$scope.panel.type]; + if (!embeddableHandler) { + handleError(`No embeddable handler for panel type ${$scope.panel.type} was found.`); + return; } - - $scope.loadedPanel = loadSavedObject(getObjectLoadersForDashboard(), $scope.panel) - .then(initializePanel) - .catch(function (e) { - $scope.error = e.message; - - // Dashboard listens for this broadcast, once for every visualization (pendingVisCount). - // We need to broadcast even in the event of an error or it'll never fetch the data for - // other visualizations. - $scope.$root.$broadcast('ready:vis'); - - // If the savedObjectType matches the panel type, this means the object itself has been deleted, - // so we shouldn't even have an edit link. If they don't match, it means something else is wrong - // with the object (but the object still exists), so we link to the object editor instead. - const objectItselfDeleted = e.savedObjectType === $scope.panel.type; - if (objectItselfDeleted) return; - - const type = $scope.panel.type; - const id = $scope.panel.id; - const service = _.find(services, { type: type }); - if (!service) return; - - $scope.editUrl = '#management/kibana/objects/' + service.name + '/' + id + '?notFound=' + e.savedObjectType; - }); - - /** - * @returns {boolean} True if the user can only view, not edit. - */ - $scope.isViewOnlyMode = () => { - return $scope.dashboardViewMode === DashboardViewMode.VIEW || $scope.isFullScreenMode; - }; + $scope.editUrl = embeddableHandler.getEditPath($scope.panel); + embeddableHandler.getTitleFor($scope.panel).then(title => { + $scope.title = title; + }); + $scope.loadedPanel = + embeddableHandler.renderAt(element.find('.panel-content').get(0), $scope.panel, actions).catch(handleError); } }; }); diff --git a/src/core_plugins/kibana/public/discover/embeddable/search_embeddable_handler.js b/src/core_plugins/kibana/public/discover/embeddable/search_embeddable_handler.js new file mode 100644 index 0000000000000..957bd6e3d36f8 --- /dev/null +++ b/src/core_plugins/kibana/public/discover/embeddable/search_embeddable_handler.js @@ -0,0 +1,79 @@ +import searchTemplate from './search_template.html'; +import angular from 'angular'; +import * as columnActions from 'ui/doc_table/actions/columns'; +import { getPersistedStateId } from 'plugins/kibana/dashboard/panel/panel_state'; +import { FilterManagerProvider } from 'ui/filter_manager'; + +export class SearchEmbeddableHandler { + constructor($compile, $rootScope, searchLoader, Private) { + this.$compile = $compile; + this.searchLoader = searchLoader; + this.filterManager = Private(FilterManagerProvider); + this.$rootScope = $rootScope; + this.name = 'search'; + this.title = 'Saved Searches'; + } + + getEditPath(panel) { + return this.searchLoader.urlFor(panel.id); + } + + canRenderType(type) { + return type === 'search'; + } + + getTitleFor(panel) { + return this.searchLoader.get(panel.id).then(savedObject => savedObject.title); + } + + renderAt(domNode, panel, actions) { + return this.searchLoader.get(panel.id).then((savedObject) => { + const editUrl = this.searchLoader.urlFor(panel.id); + const searchScope = this.$rootScope.$new(); + searchScope.editUrl = editUrl; + searchScope.savedObj = savedObject; + searchScope.panel = panel; + + // This causes changes to a saved search to be hidden, but also allows + // the user to locally modify and save changes to a saved search only in a dashboard. + // See https://github.com/elastic/kibana/issues/9523 for more details. + actions.saveState({ + columns: searchScope.panel.columns || searchScope.savedObj.columns, + sort: searchScope.panel.sort || searchScope.savedObj.sort + }); + + const uiState = savedObject.uiStateJSON ? JSON.parse(savedObject.uiStateJSON) : {}; + searchScope.uiState = actions.createChildUiState(getPersistedStateId(panel), uiState); + + searchScope.setSortOrder = function setSortOrder(columnName, direction) { + actions.saveState({ sort: [columnName, direction] }); + }; + + searchScope.addColumn = function addColumn(columnName) { + savedObject.searchSource.get('index').popularizeField(columnName, 1); + columnActions.addColumn(searchScope.panel.columns, columnName); + actions.saveState({}); // sync to sharing url + }; + + searchScope.removeColumn = function removeColumn(columnName) { + savedObject.searchSource.get('index').popularizeField(columnName, 1); + columnActions.removeColumn(searchScope.panel.columns, columnName); + actions.saveState({}); // sync to sharing url + }; + + searchScope.moveColumn = function moveColumn(columnName, newIndex) { + columnActions.moveColumn(searchScope.panel.columns, columnName, newIndex); + actions.saveState({}); // sync to sharing url + }; + + searchScope.filter = function (field, value, operator) { + const index = savedObject.searchSource.get('index').id; + this.filterManager.add(field, value, operator, index); + }; + + const searchInstance = this.$compile(searchTemplate)(searchScope); + const rootNode = angular.element(domNode); + rootNode.append(searchInstance); + }); + } +} diff --git a/src/core_plugins/kibana/public/discover/embeddable/search_embeddable_handler_provider.js b/src/core_plugins/kibana/public/discover/embeddable/search_embeddable_handler_provider.js new file mode 100644 index 0000000000000..5b08f33857dcd --- /dev/null +++ b/src/core_plugins/kibana/public/discover/embeddable/search_embeddable_handler_provider.js @@ -0,0 +1,8 @@ +import { SearchEmbeddableHandler } from './search_embeddable_handler'; + +export function searchEmbeddableHandlerProvider(Private) { + const SearchEmbeddableHandlerProvider = ($compile, $rootScope, savedSearches, Private) => { + return new SearchEmbeddableHandler($compile, $rootScope, savedSearches, Private); + }; + return Private(SearchEmbeddableHandlerProvider); +} diff --git a/src/core_plugins/kibana/public/discover/embeddable/search_template.html b/src/core_plugins/kibana/public/discover/embeddable/search_template.html new file mode 100644 index 0000000000000..0bb48561b27df --- /dev/null +++ b/src/core_plugins/kibana/public/discover/embeddable/search_template.html @@ -0,0 +1,16 @@ + + diff --git a/src/core_plugins/kibana/public/discover/index.js b/src/core_plugins/kibana/public/discover/index.js index 68a83fc5ae97a..77ce70e54b58a 100644 --- a/src/core_plugins/kibana/public/discover/index.js +++ b/src/core_plugins/kibana/public/discover/index.js @@ -6,7 +6,11 @@ import 'plugins/kibana/discover/components/field_chooser/field_chooser'; import 'plugins/kibana/discover/controllers/discover'; import 'plugins/kibana/discover/styles/main.less'; import 'ui/doc_table/components/table_row'; + import { SavedObjectRegistryProvider } from 'ui/saved_objects/saved_object_registry'; import { savedSearchProvider } from 'plugins/kibana/discover/saved_searches/saved_search_register'; +import { EmbeddableHandlersRegistryProvider } from 'ui/registry/embeddable_handlers'; +import { searchEmbeddableHandlerProvider } from './embeddable/search_embeddable_handler_provider'; SavedObjectRegistryProvider.register(savedSearchProvider); +EmbeddableHandlersRegistryProvider.register(searchEmbeddableHandlerProvider); diff --git a/src/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_handler.js b/src/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_handler.js new file mode 100644 index 0000000000000..843a92985150a --- /dev/null +++ b/src/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_handler.js @@ -0,0 +1,52 @@ +import angular from 'angular'; + +import visualizationTemplate from './visualize_template.html'; +import { getPersistedStateId } from 'plugins/kibana/dashboard/panel/panel_state'; + +export class VisualizeEmbeddableHandler { + constructor($compile, $rootScope, visualizeLoader) { + this.$compile = $compile; + this.visualizeLoader = visualizeLoader; + this.$rootScope = $rootScope; + this.name = 'visualization'; + this.title = 'Visualizations'; + } + + getEditPath(panel) { + return this.visualizeLoader.urlFor(panel.id); + } + + canRenderType(type) { + return type === 'visualization'; + } + + getTitleFor(panel) { + return this.visualizeLoader.get(panel.id).then(savedObject => savedObject.title); + } + + renderAt(domNode, panel, actions) { + return this.visualizeLoader.get(panel.id).then((savedObject) => { + const visualizeScope = this.$rootScope.$new(); + visualizeScope.editUrl = this.getEditPath(panel); + visualizeScope.savedObj = savedObject; + visualizeScope.panel = panel; + + const uiState = savedObject.uiStateJSON ? JSON.parse(savedObject.uiStateJSON) : {}; + visualizeScope.uiState = actions.createChildUiState(getPersistedStateId(panel), uiState); + + visualizeScope.savedObj.vis.setUiState(uiState); + visualizeScope.savedObj.vis.listeners.click = actions.getVisClickHandler(); + visualizeScope.savedObj.vis.listeners.brush = actions.getVisBrushHandler(); + visualizeScope.isFullScreenMode = actions.getIsViewOnlyMode(); + + const visualizationInstance = this.$compile(visualizationTemplate)(visualizeScope); + const rootNode = angular.element(domNode); + rootNode.append(visualizationInstance); + + visualizationInstance.on('$destroy', function () { + visualizeScope.savedObj.destroy(); + visualizeScope.$destroy(); + }); + }); + } +} diff --git a/src/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_handler_provider.js b/src/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_handler_provider.js new file mode 100644 index 0000000000000..43868332aeef9 --- /dev/null +++ b/src/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_handler_provider.js @@ -0,0 +1,8 @@ +import { VisualizeEmbeddableHandler } from './visualize_embeddable_handler'; + +export function visualizeEmbeddableHandlerProvider(Private) { + const VisualizeEmbeddableHandlerProvider = ($compile, $rootScope, savedVisualizations) => { + return new VisualizeEmbeddableHandler($compile, $rootScope, savedVisualizations); + }; + return Private(VisualizeEmbeddableHandlerProvider); +} diff --git a/src/core_plugins/kibana/public/visualize/embeddable/visualize_template.html b/src/core_plugins/kibana/public/visualize/embeddable/visualize_template.html new file mode 100644 index 0000000000000..c76a33ea535a6 --- /dev/null +++ b/src/core_plugins/kibana/public/visualize/embeddable/visualize_template.html @@ -0,0 +1,11 @@ + + diff --git a/src/core_plugins/kibana/public/visualize/index.js b/src/core_plugins/kibana/public/visualize/index.js index cb5ef507f4fb3..43c1cd26511a1 100644 --- a/src/core_plugins/kibana/public/visualize/index.js +++ b/src/core_plugins/kibana/public/visualize/index.js @@ -18,12 +18,15 @@ import 'plugins/kibana/visualize/saved_visualizations/_saved_vis'; import 'plugins/kibana/visualize/saved_visualizations/saved_visualizations'; import 'ui/directives/scroll_bottom'; import 'ui/filters/sort_prefix_first'; + import uiRoutes from 'ui/routes'; import visualizeListingTemplate from './listing/visualize_listing.html'; import { VisualizeListingController } from './listing/visualize_listing'; import { VisualizeConstants } from './visualize_constants'; import { SavedObjectRegistryProvider } from 'ui/saved_objects/saved_object_registry'; import { savedVisualizationProvider } from 'plugins/kibana/visualize/saved_visualizations/saved_visualization_register'; +import { EmbeddableHandlersRegistryProvider } from 'ui/registry/embeddable_handlers'; +import { visualizeEmbeddableHandlerProvider } from './embeddable/visualize_embeddable_handler_provider'; uiRoutes .defaults(/visualize/, { @@ -38,3 +41,4 @@ uiRoutes // preloading SavedObjectRegistryProvider.register(savedVisualizationProvider); +EmbeddableHandlersRegistryProvider.register(visualizeEmbeddableHandlerProvider); diff --git a/src/ui/public/registry/embeddable_handlers.js b/src/ui/public/registry/embeddable_handlers.js new file mode 100644 index 0000000000000..0e093928c35e9 --- /dev/null +++ b/src/ui/public/registry/embeddable_handlers.js @@ -0,0 +1,7 @@ +import { uiRegistry } from 'ui/registry/_registry'; + +export const EmbeddableHandlersRegistryProvider = uiRegistry({ + name: 'embeddableHandlers', + index: ['name'], + order: ['title'] +}); diff --git a/src/ui/ui_exports.js b/src/ui/ui_exports.js index 74cf53c07e077..78b8270d9a23f 100644 --- a/src/ui/ui_exports.js +++ b/src/ui/ui_exports.js @@ -89,6 +89,7 @@ class UiExports { }; case 'visTypes': + case 'embeddableHandlers': case 'fieldFormats': case 'spyModes': case 'chromeNavControls':