diff --git a/src/core_plugins/kibana/index.js b/src/core_plugins/kibana/index.js index b237df06da533..9c287f42e6658 100644 --- a/src/core_plugins/kibana/index.js +++ b/src/core_plugins/kibana/index.js @@ -59,7 +59,8 @@ export default function (kibana) { 'navbarExtensions', 'managementSections', 'devTools', - 'docViews' + 'docViews', + 'embeddableHandlers', ], injectVars, }, diff --git a/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_panels.js b/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_panels.js index 87903c241bdd5..109e9c34e9624 100644 --- a/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_panels.js +++ b/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_panels.js @@ -2,14 +2,18 @@ import angular from 'angular'; import expect from 'expect.js'; import ngMock from 'ng_mock'; import 'plugins/kibana/dashboard/saved_dashboard/saved_dashboard'; +import { DashboardContainerAPI } from '../dashboard_container_api'; +import { DashboardState } from '../dashboard_state'; import { DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT } from 'plugins/kibana/dashboard/panel/panel_state'; describe('dashboard panels', function () { let $scope; let $el; + let AppState; function compile(dashboard) { - ngMock.inject(($rootScope, $controller, $compile, $route) => { + ngMock.inject(($injector, $rootScope, $controller, $compile, $route) => { + AppState = $injector.get('AppState'); $scope = $rootScope.$new(); $route.current = { locals: { @@ -18,6 +22,8 @@ describe('dashboard panels', function () { params: {} }; + const dashboardState = new DashboardState(dashboard, AppState, false); + $scope.containerApi = new DashboardContainerAPI(dashboardState); $el = angular.element(` `); $compile($el)($scope); diff --git a/src/core_plugins/kibana/public/dashboard/__tests__/panel.js b/src/core_plugins/kibana/public/dashboard/__tests__/panel.js index 677a3731526b7..35b807ff0cf54 100644 --- a/src/core_plugins/kibana/public/dashboard/__tests__/panel.js +++ b/src/core_plugins/kibana/public/dashboard/__tests__/panel.js @@ -3,28 +3,30 @@ import ngMock from 'ng_mock'; import Promise from 'bluebird'; import sinon from 'sinon'; import noDigestPromise from 'test_utils/no_digest_promises'; -import mockUiState from 'fixtures/mock_ui_state'; -import { SavedObjectsClientProvider } from 'ui/saved_objects'; +import { DashboardContainerAPI } from '../dashboard_container_api'; +import { DashboardState } from '../dashboard_state'; +import { SavedObjectsClient } from 'ui/saved_objects'; describe('dashboard panel', function () { let $scope; let $el; let parentScope; + let savedDashboard; + let AppState; noDigestPromise.activateForSuite(); function init(mockDocResponse) { ngMock.module('kibana'); - ngMock.inject(($rootScope, $compile, Private) => { - Private.swap(SavedObjectsClientProvider, () => { - return { - get: sinon.stub().returns(Promise.resolve(mockDocResponse)) - }; - }); - + ngMock.inject(($rootScope, $compile, Private, $injector) => { + const SavedDashboard = $injector.get('SavedDashboard'); + AppState = $injector.get('AppState'); + savedDashboard = new SavedDashboard(); + sinon.stub(SavedObjectsClient.prototype, 'get').returns(Promise.resolve(mockDocResponse)); parentScope = $rootScope.$new(); parentScope.saveState = sinon.stub(); - parentScope.createChildUiState = sinon.stub().returns(mockUiState); + const dashboardState = new DashboardState(savedDashboard, AppState, false); + parentScope.containerApi = new DashboardContainerAPI(dashboardState); parentScope.getVisClickHandler = sinon.stub(); parentScope.getVisBrushHandler = sinon.stub(); parentScope.registerPanelIndexPattern = sinon.stub(); @@ -41,11 +43,8 @@ describe('dashboard panel', function () { panel="panel" is-full-screen-mode="false" is-expanded="false" - get-vis-click-handler="getVisClickHandler" - get-vis-brush-handler="getVisBrushHandler" - save-state="saveState" - register-panel-index-pattern="registerPanelIndexPattern" - create-child-ui-state="createChildUiState"> + container-api="containerApi" + > `)(parentScope); $scope = $el.isolateScope(); parentScope.$digest(); @@ -53,27 +52,28 @@ describe('dashboard panel', function () { } afterEach(() => { + SavedObjectsClient.prototype.get.restore(); $scope.$destroy(); $el.remove(); }); it('should not visualize the visualization if it does not exist', function () { init({ found: false }); - return $scope.loadedPanel.then(() => { + return $scope.renderPromise.then(() => { 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); }); }); it('should try to visualize the visualization if found', function () { init({ id: 'foo1', type: 'visualization', _version: 2, attributes: {} }); - return $scope.loadedPanel.then(() => { + return $scope.renderPromise.then(() => { 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/dashboard.html b/src/core_plugins/kibana/public/dashboard/dashboard.html index 4fdcd23a8710c..e0b6ede1d73fb 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard.html +++ b/src/core_plugins/kibana/public/dashboard/dashboard.html @@ -80,16 +80,10 @@

on-panel-removed="onPanelRemoved" dashboard-view-mode="dashboardViewMode" panels="panels" - get-vis-click-handler="getFilterBarClickHandler" - get-vis-brush-handler="getBrushEvent" save-state="saveState" - app-state="appState" toggle-expand="toggleExpandPanel" - create-child-ui-state="createChildUiState" - toggle-expand="toggleExpandPanel" - register-panel-index-pattern="registerPanelIndexPattern" data-shared-items-count="{{panels.length}}" - on-filter="filter" + container-api="containerApi" > is-full-screen-mode="!chrome.getVisible()" is-expanded="true" dashboard-view-mode="dashboardViewMode" - get-vis-click-handler="getFilterBarClickHandler" - get-vis-brush-handler="getBrushEvent" - save-state="saveState" - app-state="appState" - register-panel-index-pattern="registerPanelIndexPattern" - create-child-ui-state="createChildUiState" + container-api="containerApi" toggle-expand="toggleExpandPanel(expandedPanel.panelIndex)" > diff --git a/src/core_plugins/kibana/public/dashboard/dashboard.js b/src/core_plugins/kibana/public/dashboard/dashboard.js index da1e9a87526b1..c2c4318db4435 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard.js @@ -19,16 +19,14 @@ import { DocTitleProvider } from 'ui/doc_title'; import { getTopNavConfig } from './top_nav/get_top_nav_config'; import { DashboardConstants, createDashboardEditUrl } from './dashboard_constants'; import { VisualizeConstants } from 'plugins/kibana/visualize/visualize_constants'; -import { UtilsBrushEventProvider } from 'ui/utils/brush_event'; -import { FilterBarClickHandlerProvider } from 'ui/filter_bar/filter_bar_click_handler'; import { DashboardState } from './dashboard_state'; import { notify } from 'ui/notify'; -import './panel/get_object_loaders_for_dashboard'; import { documentationLinks } from 'ui/documentation_links/documentation_links'; import { showCloneModal } from './top_nav/show_clone_modal'; import { migrateLegacyQuery } from 'ui/utils/migrateLegacyQuery'; import { QueryManagerProvider } from 'ui/query_manager'; import { ESC_KEY_CODE } from 'ui_framework/services'; +import { DashboardContainerAPI } from './dashboard_container_api'; const app = uiModules.get('app/dashboard', [ 'elasticsearch', @@ -86,8 +84,6 @@ app.directive('dashboardApp', function ($injector) { const confirmModal = $injector.get('confirmModal'); const config = $injector.get('config'); const Private = $injector.get('Private'); - const brushEvent = Private(UtilsBrushEventProvider); - const filterBarClickHandler = Private(FilterBarClickHandlerProvider); return { restrict: 'E', @@ -103,8 +99,10 @@ app.directive('dashboardApp', function ($injector) { docTitle.change(dash.title); } - const dashboardState = new DashboardState(dash, AppState, dashboardConfig); + const dashboardState = new DashboardState(dash, AppState, dashboardConfig.getHideWriteControls()); + $scope.appState = dashboardState.getAppState(); const queryManager = Private(QueryManagerProvider)(dashboardState.getAppState()); + $scope.containerApi = new DashboardContainerAPI(dashboardState, queryManager); // The 'previouslyStored' check is so we only update the time filter on dashboard open, not during // normal cross app navigation. @@ -124,6 +122,7 @@ app.directive('dashboardApp', function ($injector) { }; $scope.panels = dashboardState.getPanels(); $scope.fullScreenMode = dashboardState.getFullScreenMode(); + $scope.indexPatterns = dashboardState.getPanelIndexPatterns(); }; // Part of the exposed plugin API - do not remove without careful consideration. @@ -155,11 +154,8 @@ app.directive('dashboardApp', function ($injector) { $scope.timefilter = timefilter; $scope.expandedPanel = null; $scope.dashboardViewMode = dashboardState.getViewMode(); - $scope.appState = dashboardState.getAppState(); $scope.landingPageUrl = () => `#${DashboardConstants.LANDING_PAGE_PATH}`; - $scope.getBrushEvent = () => brushEvent(dashboardState.getAppState()); - $scope.getFilterBarClickHandler = () => filterBarClickHandler(dashboardState.getAppState()); $scope.hasExpandedPanel = () => $scope.expandedPanel !== null; $scope.getDashTitle = () => getDashboardTitle( dashboardState.getTitle(), @@ -212,17 +208,6 @@ app.directive('dashboardApp', function ($injector) { notify.info(`Search successfully added to your dashboard`); }; - /** - * Creates a child ui state for the panel. It's passed the ui state to use, but needs to - * be generated from the parent (why, I don't know yet). - * @param path {String} - the unique path for this ui state. - * @param uiState {Object} - the uiState for the child. - * @returns {Object} - */ - $scope.createChildUiState = function createChildUiState(path, uiState) { - return dashboardState.uiState.createChild(path, uiState, true); - }; - $scope.$watch('model.darkTheme', () => { dashboardState.setDarkTheme($scope.model.darkTheme); updateTheme(); @@ -242,12 +227,6 @@ app.directive('dashboardApp', function ($injector) { $scope.indexPatterns = dashboardState.getPanelIndexPatterns(); }; - $scope.filter = function (field, value, operator, index) { - queryManager.add(field, value, operator, index); - updateState(); - }; - - $scope.$watch('model.query', (newQuery) => { $scope.model.query = migrateLegacyQuery(newQuery); dashboardState.applyFilters($scope.model.query, filterBar.getFilters()); diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_container_api.js b/src/core_plugins/kibana/public/dashboard/dashboard_container_api.js new file mode 100644 index 0000000000000..f8ca5a98f5826 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/dashboard_container_api.js @@ -0,0 +1,34 @@ +import { ContainerAPI } from 'ui/embeddable'; + +export class DashboardContainerAPI extends ContainerAPI { + constructor(dashboardState, queryManager) { + super(); + this.dashboardState = dashboardState; + this.queryManager = queryManager; + } + + addFilter(field, value, operator, index) { + this.queryManager.add(field, value, operator, index); + } + + updatePanel(panelIndex, panelAttributes) { + const panelToUpdate = this.dashboardState.getPanels().find((panel) => panel.panelIndex === panelIndex); + Object.assign(panelToUpdate, panelAttributes); + this.dashboardState.saveState(); + return panelToUpdate; + } + + getAppState() { + return this.dashboardState.appState; + } + + createChildUistate(path, initialState) { + return this.dashboardState.uiState.createChild(path, initialState, true); + } + + registerPanelIndexPattern(panelIndex, pattern) { + this.dashboardState.registerPanelIndexPatternMap(panelIndex, pattern); + this.dashboardState.saveState(); + } + +} diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_state.js b/src/core_plugins/kibana/public/dashboard/dashboard_state.js index f355482d2b8f1..e8e90a6f71011 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard_state.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard_state.js @@ -59,13 +59,13 @@ export class DashboardState { * * @param savedDashboard {SavedDashboard} * @param AppState {AppState} - * @param dashboardConfig {DashboardConfigProvider} + * @param hideWriteControls {boolean} true if write controls should be hidden. */ - constructor(savedDashboard, AppState, dashboardConfig) { + constructor(savedDashboard, AppState, hideWriteControls) { this.savedDashboard = savedDashboard; - this.dashboardConfig = dashboardConfig; + this.hideWriteControls = hideWriteControls; - this.stateDefaults = getStateDefaults(this.savedDashboard, this.dashboardConfig.getHideWriteControls()); + this.stateDefaults = getStateDefaults(this.savedDashboard, this.hideWriteControls); this.appState = new AppState(this.stateDefaults); this.uiState = this.appState.makeStateful('uiState'); @@ -117,7 +117,7 @@ export class DashboardState { // The right way to fix this might be to ensure the defaults object stored on state is a deep // clone, but given how much code uses the state object, I determined that to be too risky of a change for // now. TODO: revisit this! - this.stateDefaults = getStateDefaults(this.savedDashboard, this.dashboardConfig.getHideWriteControls()); + this.stateDefaults = getStateDefaults(this.savedDashboard, this.hideWriteControls); // The original query won't be restored by the above because the query on this.savedDashboard is applied // in place in order for it to affect the visualizations. this.stateDefaults.query = this.lastSavedDashboardFilters.query; @@ -259,7 +259,7 @@ export class DashboardState { * @returns {DashboardViewMode} */ getViewMode() { - return this.dashboardConfig.getHideWriteControls() ? DashboardViewMode.VIEW : this.appState.viewMode; + return this.hideWriteControls ? DashboardViewMode.VIEW : this.appState.viewMode; } /** diff --git a/src/core_plugins/kibana/public/dashboard/grid.js b/src/core_plugins/kibana/public/dashboard/grid.js index 7cf648e9d371b..54fd3fab1ab79 100644 --- a/src/core_plugins/kibana/public/dashboard/grid.js +++ b/src/core_plugins/kibana/public/dashboard/grid.js @@ -18,17 +18,6 @@ app.directive('dashboardGrid', function ($compile, Notifier) { * @type {DashboardViewMode} */ dashboardViewMode: '=', - /** - * Used to create a child persisted state for the panel from parent state. - * @type {function} - Returns a {PersistedState} child uiState for this scope. - */ - createChildUiState: '=', - /** - * Registers an index pattern with the dashboard app used by each panel. The index patterns are used by the - * filter bar for generating field suggestions. - * @type {function(IndexPattern)} - */ - registerPanelIndexPattern: '=', /** * Trigger after a panel has been removed from the grid. */ @@ -38,22 +27,11 @@ app.directive('dashboardGrid', function ($compile, Notifier) { * @type {Array} */ panels: '=', - /** - * Returns a click handler for a visualization. - * @type {function} - */ - getVisClickHandler: '=', - /** - * Returns a brush event handler for a visualization. - * @type {function} - */ - getVisBrushHandler: '=', /** * Call when changes should be propagated to the url and thus saved in state. * @type {function} */ saveState: '=', - appState: '=', /** * Expand or collapse a panel, so it either takes up the whole screen or goes back to its * natural size. @@ -61,10 +39,9 @@ app.directive('dashboardGrid', function ($compile, Notifier) { */ toggleExpand: '=', /** - * Called when a filter action has been triggered by a panel - * @type {function} + * @type {DashboardContainerApi} */ - onFilter: '=', + containerApi: '=', }, link: function ($scope, $el) { const notify = new Notifier(); @@ -222,14 +199,9 @@ app.directive('dashboardGrid', function ($compile, Notifier) { is-full-screen-mode="isFullScreenMode" is-expanded="false" dashboard-view-mode="dashboardViewMode" - get-vis-click-handler="getVisClickHandler" - get-vis-brush-handler="getVisBrushHandler" - save-state="saveState" - app-state="appState" - register-panel-index-pattern="registerPanelIndexPattern" + container-api="containerApi" toggle-expand="toggleExpand(${panel.panelIndex})" - create-child-ui-state="createChildUiState" - on-filter="onFilter"> + > `; const panelElement = $compile(panelHtml)($scope); panelElementMapping[panel.panelIndex] = panelElement; 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 b7f3bed8364d3..0000000000000 --- a/src/core_plugins/kibana/public/dashboard/panel/load_saved_object.js +++ /dev/null @@ -1,16 +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 => { - return { 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 38d56f67777ea..d20d0fdde1bee 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 173f0c0454812..2c2a53e1b643a 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel.js @@ -1,21 +1,16 @@ -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 { 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/embeddable/embeddable_handlers_registry'; uiModules .get('app/dashboard') -.directive('dashboardPanel', function (savedVisualizations, savedSearches, Notifier, Private, $injector, getObjectLoadersForDashboard) { - +.directive('dashboardPanel', function (Notifier, Private, $injector) { const services = savedObjectManagementRegistry.all().map(function (serviceObj) { const service = $injector.get(serviceObj.service); return { @@ -38,17 +33,6 @@ uiModules * @type {boolean} */ isFullScreenMode: '=', - /** - * Used to create a child persisted state for the panel from parent state. - * @type {function} - Returns a {PersistedState} child uiState for this scope. - */ - createChildUiState: '=', - /** - * Registers an index pattern with the dashboard app used by this panel. Used by the filter bar for - * generating field suggestions. - * @type {function(IndexPattern)} - */ - registerPanelIndexPattern: '=', /** * Contains information about this panel. * @type {PanelState} @@ -70,122 +54,58 @@ uiModules */ isExpanded: '=', /** - * Returns a click handler for a visualization. - * @type {function} - */ - getVisClickHandler: '=', - /** - * Returns a brush event handler for a visualization. - * @type {function} + * @type {DashboardContainerApi} */ - getVisBrushHandler: '=', - /** - * Call when changes should be propagated to the url and thus saved in state. - * @type {function} - */ - saveState: '=', - /** - * Called when a filter action has been triggered - * @type {function} - */ - onFilter: '=', - appState: '=', + containerApi: '=' }, 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(); - }); - - // 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(); - $scope.registerPanelIndexPattern($scope.panel.panelIndex, $scope.savedObj.vis.indexPattern); - } else if ($scope.panel.type === savedSearches.type) { - if ($scope.savedObj.searchSource) { - $scope.registerPanelIndexPattern($scope.panel.panelIndex, $scope.savedObj.searchSource.get('index')); - } - // 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; + const panelId = $scope.panel.id; - $scope.setSortOrder = function setSortOrder(columnName, direction) { - $scope.panel.sort = [columnName, direction]; - $scope.saveState(); - }; + // TODO: This function contains too much internal panel knowledge. Logic should be pushed to embeddable handlers. + 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 service = services.find(service => service.type === type); + if (!service) return; - $scope.filter = function (field, value, operator) { - const index = $scope.savedObj.searchSource.get('index').id; - $scope.onFilter(field, value, operator, index); - }; + $scope.editUrl = '#management/kibana/objects/' + service.name + '/' + panelId + '?notFound=' + error.savedObjectType; + }; + const embeddableHandlers = Private(EmbeddableHandlersRegistryProvider); + const embeddableHandler = embeddableHandlers.byName[$scope.panel.type]; + if (!embeddableHandler) { + handleError(new Error(`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; - }; + embeddableHandler.getEditPath(panelId).then(path => { + $scope.editUrl = path; + }); + embeddableHandler.getTitleFor(panelId).then(title => { + $scope.title = title; + }); + $scope.renderPromise = embeddableHandler.render( + element.find('#embeddedPanel').get(0), + $scope.panel, + $scope.containerApi) + .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..1c12be3e8cab4 --- /dev/null +++ b/src/core_plugins/kibana/public/discover/embeddable/search_embeddable_handler.js @@ -0,0 +1,81 @@ +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 { EmbeddableHandler } from 'ui/embeddable'; + + +export class SearchEmbeddableHandler extends EmbeddableHandler { + + constructor($compile, $rootScope, searchLoader, Promise) { + super(); + this.$compile = $compile; + this.searchLoader = searchLoader; + this.$rootScope = $rootScope; + this.name = 'search'; + this.Promise = Promise; + } + + getEditPath(panelId) { + return this.Promise.resolve(this.searchLoader.urlFor(panelId)); + } + + getTitleFor(panelId) { + return this.searchLoader.get(panelId).then(savedObject => savedObject.title); + } + + render(domNode, panel, container) { + const searchScope = this.$rootScope.$new(); + return this.getEditPath(panel.id) + .then(editPath => { + searchScope.editPath = editPath; + return this.searchLoader.get(panel.id); + }) + .then(savedObject => { + searchScope.savedObj = savedObject; + searchScope.panel = panel; + container.registerPanelIndexPattern(panel.panelIndex, savedObject.searchSource.get('index')); + + // 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. + searchScope.panel = container.updatePanel(searchScope.panel.panelIndex, { + columns: searchScope.panel.columns || searchScope.savedObj.columns, + sort: searchScope.panel.sort || searchScope.savedObj.sort + }); + + const uiState = savedObject.uiStateJSON ? JSON.parse(savedObject.uiStateJSON) : {}; + searchScope.uiState = container.createChildUistate(getPersistedStateId(panel), uiState); + + searchScope.setSortOrder = function setSortOrder(columnName, direction) { + searchScope.panel = container.updatePanel(searchScope.panel.panelIndex, { sort: [columnName, direction] }); + }; + + searchScope.addColumn = function addColumn(columnName) { + savedObject.searchSource.get('index').popularizeField(columnName, 1); + columnActions.addColumn(searchScope.panel.columns, columnName); + searchScope.panel = container.updatePanel(searchScope.panel.panelIndex, { columns: searchScope.panel.columns }); + }; + + searchScope.removeColumn = function removeColumn(columnName) { + savedObject.searchSource.get('index').popularizeField(columnName, 1); + columnActions.removeColumn(searchScope.panel.columns, columnName); + searchScope.panel = container.updatePanel(searchScope.panel.panelIndex, { columns: searchScope.panel.columns }); + }; + + searchScope.moveColumn = function moveColumn(columnName, newIndex) { + columnActions.moveColumn(searchScope.panel.columns, columnName, newIndex); + searchScope.panel = container.updatePanel(searchScope.panel.panelIndex, { columns: searchScope.panel.columns }); + }; + + searchScope.filter = function (field, value, operator) { + const index = savedObject.searchSource.get('index').id; + container.addFilter(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..d7fa5dc8b1f00 --- /dev/null +++ b/src/core_plugins/kibana/public/discover/embeddable/search_embeddable_handler_provider.js @@ -0,0 +1,12 @@ +import { SearchEmbeddableHandler } from './search_embeddable_handler'; +import { EmbeddableHandlersRegistryProvider } from 'ui/embeddable/embeddable_handlers_registry'; + +export function searchEmbeddableHandlerProvider(Private) { + const SearchEmbeddableHandlerProvider = ($compile, $rootScope, savedSearches, Promise) => { + return new SearchEmbeddableHandler($compile, $rootScope, savedSearches, Promise); + }; + return Private(SearchEmbeddableHandlerProvider); +} + + +EmbeddableHandlersRegistryProvider.register(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/visualize/embeddable/visualize_embeddable_handler.js b/src/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_handler.js new file mode 100644 index 0000000000000..8bb5c6e9ccd8c --- /dev/null +++ b/src/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_handler.js @@ -0,0 +1,63 @@ +import angular from 'angular'; + +import visualizationTemplate from './visualize_template.html'; +import { getPersistedStateId } from 'plugins/kibana/dashboard/panel/panel_state'; +import { UtilsBrushEventProvider as utilsBrushEventProvider } from 'ui/utils/brush_event'; +import { FilterBarClickHandlerProvider as filterBarClickHandlerProvider } from 'ui/filter_bar/filter_bar_click_handler'; +import { EmbeddableHandler } from 'ui/embeddable'; +import chrome from 'ui/chrome'; + +export class VisualizeEmbeddableHandler extends EmbeddableHandler { + constructor($compile, $rootScope, visualizeLoader, timefilter, Notifier, Promise) { + super(); + this.$compile = $compile; + this.visualizeLoader = visualizeLoader; + this.$rootScope = $rootScope; + this.name = 'visualization'; + this.Promise = Promise; + this.brushEvent = utilsBrushEventProvider(timefilter); + this.filterBarClickHandler = filterBarClickHandlerProvider(Notifier); + } + + getEditPath(panelId) { + return this.Promise.resolve(this.visualizeLoader.urlFor(panelId)); + } + + getTitleFor(panelId) { + return this.visualizeLoader.get(panelId).then(savedObject => savedObject.title); + } + + render(domNode, panel, container) { + const visualizeScope = this.$rootScope.$new(); + return this.getEditPath(panel.id) + .then(editPath => { + visualizeScope.editUrl = editPath; + return this.visualizeLoader.get(panel.id); + }) + .then(savedObject => { + visualizeScope.savedObj = savedObject; + visualizeScope.panel = panel; + + const uiState = savedObject.uiStateJSON ? JSON.parse(savedObject.uiStateJSON) : {}; + visualizeScope.uiState = container.createChildUistate(getPersistedStateId(panel), uiState); + + visualizeScope.savedObj.vis.setUiState(visualizeScope.uiState); + + visualizeScope.savedObj.vis.listeners.click = this.filterBarClickHandler(container.getAppState()); + visualizeScope.savedObj.vis.listeners.brush = this.brushEvent(container.getAppState()); + visualizeScope.isFullScreenMode = !chrome.getVisible(); + + container.registerPanelIndexPattern(panel.panelIndex, visualizeScope.savedObj.vis.indexPattern); + + 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..1f6704f1f921f --- /dev/null +++ b/src/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_handler_provider.js @@ -0,0 +1,17 @@ +import { VisualizeEmbeddableHandler } from './visualize_embeddable_handler'; +import { EmbeddableHandlersRegistryProvider } from 'ui/embeddable/embeddable_handlers_registry'; + +export function visualizeEmbeddableHandlerProvider(Private) { + const VisualizeEmbeddableHandlerProvider = ( + $compile, + $rootScope, + savedVisualizations, + timefilter, + Notifier, + Promise) => { + return new VisualizeEmbeddableHandler($compile, $rootScope, savedVisualizations, timefilter, Notifier, Promise); + }; + return Private(VisualizeEmbeddableHandlerProvider); +} + +EmbeddableHandlersRegistryProvider.register(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..4a7d8fd6481a5 --- /dev/null +++ b/src/core_plugins/kibana/public/visualize/embeddable/visualize_template.html @@ -0,0 +1,11 @@ + + diff --git a/src/ui/public/embeddable/container_api.js b/src/ui/public/embeddable/container_api.js new file mode 100644 index 0000000000000..e1471a88d7a70 --- /dev/null +++ b/src/ui/public/embeddable/container_api.js @@ -0,0 +1,51 @@ +/** + * The ContainerAPI is an interface for embeddable objects to interact with the container they are embedded within. + */ +export class ContainerAPI { + /** + * Available so the embeddable object can trigger a filter action. + * @param field + * @param value + * @param operator + * @param index + */ + addFilter(/*field, value, operator, index */) { + throw new Error('Must implement addFilter.'); + } + + /** + * @return {AppState} + */ + getAppState() { + throw new Error('Must implement getAppState.'); + } + + /** + * Creates a new state for the panel. It's passed the ui state object to use, and is returned + * a PersistedState. + * @param path {String} - the unique path for this ui state. + * @param initialState {Object} - the initial state to use for the child. + * @returns {PersistedState} + */ + createChildUistate(/* path, initialState */) { + throw new Error('Must implement getInitalState.'); + } + + /** + * Call this to tell the container that this panel uses a particular index pattern. + * @param {string} panelIndex - a unique id that identifies the panel to update. + * @param {string} indexPattern - an index pattern the panel uses + */ + registerPanelIndexPattern(/* panelIndex, indexPattern */) { + throw new Error('Must implement registerPanelIndexPattern.'); + } + + /** + * @param {string} panelIndex - a unique id that identifies the panel to update. + * @param {Object} panelAttributes - the new panel attributes that will be applied to the panel. + * @return {Object} - the updated panel. + */ + updatePanel(/*paneIndex, panelAttributes */) { + throw new Error('Must implement updatePanel.'); + } +} diff --git a/src/ui/public/embeddable/embeddable_handler.js b/src/ui/public/embeddable/embeddable_handler.js new file mode 100644 index 0000000000000..aee16ca8e6049 --- /dev/null +++ b/src/ui/public/embeddable/embeddable_handler.js @@ -0,0 +1,33 @@ +/** + * The EmbeddableHandler defines how to render and embed any object into the Dashboard, or some other + * container that supports EmbeddableHandlers. + */ +export class EmbeddableHandler { + /** + * @param {string} panelId - the id of the panel to grab the title for. + * @return {Promise.} a promise that resolves with the path that dictates where the user will be navigated to + * when they click the edit icon. + */ + getEditPath(/* panelId */) { + throw new Error('Must implement getEditPath.'); + } + + /** + * @param {string} panelId - the id of the panel to grab the title for. + * @return {Promise.} - Promise that resolves with the title to display for the particular panel. + */ + getTitleFor(/* panelId */) { + throw new Error('Must implement getTitleFor.'); + } + + /** + * @param {Element} domNode - the dom node to mount the rendered embeddable on + * @param {PanelState} panel - a panel object which container information about the panel. Can also be modified to + * store per panel information. + * @property {ContainerApi} containerApi - an id to specify the object that this panel contains. + * @param {Promise.} A promise that resolves when the object is finished rendering. + */ + render(/* domNode, panel, container */) { + throw new Error('Must implement render.'); + } +} diff --git a/src/ui/public/embeddable/embeddable_handlers_registry.js b/src/ui/public/embeddable/embeddable_handlers_registry.js new file mode 100644 index 0000000000000..408288c842173 --- /dev/null +++ b/src/ui/public/embeddable/embeddable_handlers_registry.js @@ -0,0 +1,9 @@ +import { uiRegistry } from 'ui/registry/_registry'; + +/** + * Registry of functions (EmbeddableHandlerProviders) which return an EmbeddableHandler. + */ +export const EmbeddableHandlersRegistryProvider = uiRegistry({ + name: 'embeddableHandlers', + index: ['name'] +}); diff --git a/src/ui/public/embeddable/index.js b/src/ui/public/embeddable/index.js new file mode 100644 index 0000000000000..bdd73ce99a110 --- /dev/null +++ b/src/ui/public/embeddable/index.js @@ -0,0 +1,3 @@ +export { EmbeddableHandler } from './embeddable_handler'; +export { EmbeddableHandlersRegistryProvider } from './embeddable_handlers_registry'; +export { ContainerAPI } from './container_api'; diff --git a/src/ui/ui_exports.js b/src/ui/ui_exports.js index 70606553717ca..ba8c57f8479d2 100644 --- a/src/ui/ui_exports.js +++ b/src/ui/ui_exports.js @@ -21,6 +21,10 @@ export default class UiExports { visEditorTypes: [ 'ui/vis/editors/default/default', ], + embeddableHandlers: [ + 'plugins/kibana/visualize/embeddable/visualize_embeddable_handler_provider', + 'plugins/kibana/discover/embeddable/search_embeddable_handler_provider', + ], }; this.urlBasePath = urlBasePath; this.exportConsumer = _.memoize(this.exportConsumer); @@ -105,6 +109,7 @@ export default class UiExports { case 'visRequestHandlers': case 'visEditorTypes': case 'savedObjectTypes': + case 'embeddableHandlers': case 'fieldFormats': case 'fieldFormatEditors': case 'spyModes': diff --git a/test/functional/apps/dashboard/_dashboard.js b/test/functional/apps/dashboard/_dashboard.js index 0c1df9a6b0ab3..48f32a81e2231 100644 --- a/test/functional/apps/dashboard/_dashboard.js +++ b/test/functional/apps/dashboard/_dashboard.js @@ -182,7 +182,22 @@ export default function ({ getService, getPageObjects }) { expect(spyToggleExists).to.be(true); }); + // This was an actual bug that appeared, where the spy pane appeared on panels after adding them, but + // disappeared when a new dashboard was opened up. + it('shows the spy pane toggle directly after opening a dashboard', async () => { + await PageObjects.dashboard.saveDashboard('spy pane test'); + await PageObjects.dashboard.gotoDashboardLandingPage(); + await PageObjects.dashboard.loadSavedDashboard('spy pane test'); + const panels = await PageObjects.dashboard.getDashboardPanels(); + // Simulate hover + await remote.moveMouseTo(panels[0]); + const spyToggleExists = await PageObjects.visualize.getSpyToggleExists(); + expect(spyToggleExists).to.be(true); + }); + it('shows other panels after being minimized', async () => { + // Panels are all minimized on a fresh open of a dashboard, so we need to re-expand in order to then minimize. + await PageObjects.dashboard.toggleExpandPanel(); await PageObjects.dashboard.toggleExpandPanel(); const panels = await PageObjects.dashboard.getDashboardPanels(); const visualizations = PageObjects.dashboard.getTestVisualizations(); @@ -222,6 +237,7 @@ export default function ({ getService, getPageObjects }) { describe('full screen mode', () => { it('option not available in edit mode', async () => { + await PageObjects.dashboard.clickEdit(); const exists = await PageObjects.dashboard.fullScreenModeMenuItemExists(); expect(exists).to.be(false); }); diff --git a/test/functional/apps/discover/_discover.js b/test/functional/apps/discover/_discover.js index 90636becb209a..818b5d50c2c5b 100644 --- a/test/functional/apps/discover/_discover.js +++ b/test/functional/apps/discover/_discover.js @@ -310,10 +310,12 @@ export default function ({ getService, getPageObjects }) { description: 'A Saved Search Description' }; - await PageObjects.discover.loadSavedSearch(expected.title); - const { title, description } = await PageObjects.common.getSharedItemTitleAndDescription(); - expect(title).to.eql(expected.title); - expect(description).to.eql(expected.description); + await retry.try(async () => { + await PageObjects.discover.loadSavedSearch(expected.title); + const { title, description } = await PageObjects.common.getSharedItemTitleAndDescription(); + expect(title).to.eql(expected.title); + expect(description).to.eql(expected.description); + }); }); }); });