Create the concept of embeddableHandlers (#12146)

* Move dashboard panel rendering logic to each registered type.

* Remove dashboard knowledge of click and brush handlers for visualizations

Move it to the VisualizeEmbeddableHandler.

* merge master with manual changes

* No need to use lodash

* Add EmbeddableHandler base class

* Use correct path to embeddable_handlers_registry

* clean up

* Set visualize scope uiState that is of type PersistedState, otherwise it won't actually be set.

* add retry to loading saved search data

* Fix handleError param and remove unnecessary private param

* Rename savePanelState updatePanel and return the new object rather than mutating the original

* Make ContainerAPI a base class and move the dashboard specific functionality into a new class

* Make api's async and clean up documentation

* Fix panel tests

* Fix bug which broke tests - need to pass container-api to dashboard-panel

* Address code comments

- Rename onFilter to addFilter
- Use angular promises instead of async/await
- fix jsdoc
- rename createChildUiState to getInitialState

* changed the wrong variable name

* no need for async or Promise.reject on interface

* add tests that will fail due to spy pane issue in this PR

* Fix logic with spy pane toggle

There is still a bit of a bug here as mentioned in
https://github.com/elastic/kibana/issues/13340 but it will be fixed
separately as it’s also an issue in master

* Fix failing test
This commit is contained in:
Stacey Gammon 2017-08-08 11:27:30 -04:00 committed by GitHub
parent 9f05b8d5b4
commit 6e744521b3
25 changed files with 453 additions and 294 deletions

View file

@ -59,7 +59,8 @@ export default function (kibana) {
'navbarExtensions',
'managementSections',
'devTools',
'docViews'
'docViews',
'embeddableHandlers',
],
injectVars,
},

View file

@ -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(`
<dashboard-app>
<dashboard-grid
@ -25,12 +31,9 @@ describe('dashboard panels', function () {
ng-if="!hasExpandedPanel()"
on-panel-removed="onPanelRemoved"
panels="panels"
get-vis-click-handler="filterBarClickHandler"
get-vis-brush-handler="brushEvent"
save-state="saveState"
toggle-expand="toggleExpandPanel"
create-child-ui-state="createChildUiState"
toggle-expand="toggleExpandPanel"
container-api="containerApi"
></dashboard-grid>
</dashboard-app>`);
$compile($el)($scope);

View file

@ -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"
>
</dashboard-panel>`)(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);
});
});
});

View file

@ -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"
></dashboard-grid>
<dashboard-panel
@ -98,12 +92,7 @@
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)"
></dashboard-panel>

View file

@ -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());

View file

@ -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();
}
}

View file

@ -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;
}
/**

View file

@ -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<PanelState>}
*/
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">
>
</li>`;
const panelElement = $compile(panelHtml)($scope);
panelElementMapping[panel.panelIndex] = panelElement;

View file

@ -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];
});

View file

@ -1,16 +0,0 @@
/**
* Retrieves the saved object represented by the panel and returns it, along with the appropriate
* edit Url.
* @param {Array.<SavedObjectLoader>} 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) };
});
}

View file

@ -1,11 +1,11 @@
<div class="panel panel-default" data-test-subj="dashboardPanel" ng-class="{'panel--edit-mode': !isViewOnlyMode()}" ng-switch on="panel.type" ng-if="savedObj || error">
<div class="panel panel-default" data-test-subj="dashboardPanel" ng-class="{'panel--edit-mode': !isViewOnlyMode()}" ng-switch on="panel.type">
<div class="panel-heading">
<span
data-test-subj="dashboardPanelTitle"
class="panel-title"
aria-label="{{:: 'Dashboard panel: ' + savedObj.title }}"
aria-label="{{:: 'Dashboard panel: ' + title }}"
>
{{::savedObj.title}}
{{::title}}
</span>
<div class="kuiMicroButtonGroup">
<a
@ -76,36 +76,5 @@
<span ng-bind-html="error | markdown"></span>
</div>
<visualize
ng-if="!error"
ng-switch-when="visualization"
show-spy-panel="!isFullScreenMode"
saved-obj="savedObj"
app-state="appState"
ui-state="uiState"
data-shared-item
data-title="{{savedObj.title}}"
data-description="{{savedObj.description}}"
render-counter
class="panel-content">
</visualize>
<doc-table
ng-if="!error"
ng-switch-when="search"
search-source="savedObj.searchSource"
sorting="panel.sort"
columns="panel.columns"
data-shared-item
data-title="{{savedObj.title}}"
data-description="{{savedObj.description}}"
render-counter
class="panel-content"
filter="filter"
on-add-column="addColumn"
on-change-sort-order="setSortOrder"
on-move-column="moveColumn"
on-remove-column="removeColumn"
>
</doc-table>
<div id="embeddedPanel" class="panel-content"></div>
</div>

View file

@ -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}
* @type {DashboardContainerApi}
*/
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: '=',
/**
* 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);
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;
$scope.setSortOrder = function setSortOrder(columnName, direction) {
$scope.panel.sort = [columnName, direction];
$scope.saveState();
};
$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
};
$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
};
$scope.moveColumn = function moveColumn(columnName, newIndex) {
columnActions.moveColumn($scope.panel.columns, columnName, newIndex);
$scope.saveState(); // sync to sharing url
};
}
$scope.filter = function (field, value, operator) {
const index = $scope.savedObj.searchSource.get('index').id;
$scope.onFilter(field, value, operator, index);
};
}
$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;
};
const panelId = $scope.panel.id;
// TODO: This function contains too much internal panel knowledge. Logic should be pushed to embeddable handlers.
const handleError = (error) => {
$scope.error = error.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 = error.savedObjectType === $scope.panel.type;
if (objectItselfDeleted) return;
const type = $scope.panel.type;
const service = services.find(service => service.type === type);
if (!service) return;
$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;
}
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);
}
};
});

View file

@ -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);
});
}
}

View file

@ -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);

View file

@ -0,0 +1,16 @@
<doc-table
search-source="savedObj.searchSource"
sorting="panel.sort"
columns="panel.columns"
data-shared-item
data-title="{{savedObj.title}}"
data-description="{{savedObj.description}}"
render-counter
class="panel-content"
filter="filter"
on-add-column="addColumn"
on-change-sort-order="setSortOrder"
on-move-column="moveColumn"
on-remove-column="removeColumn"
>
</doc-table>

View file

@ -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();
});
});
}
}

View file

@ -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);

View file

@ -0,0 +1,11 @@
<visualize
show-spy-panel="!isFullScreenMode"
saved-obj="savedObj"
app-state="appState"
ui-state="uiState"
data-shared-item
data-title="{{savedObj.title}}"
data-description="{{savedObj.description}}"
render-counter
class="panel-content">
</visualize>

View file

@ -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.');
}
}

View file

@ -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.<string>} 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.<string>} - 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.<void>} A promise that resolves when the object is finished rendering.
*/
render(/* domNode, panel, container */) {
throw new Error('Must implement render.');
}
}

View file

@ -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']
});

View file

@ -0,0 +1,3 @@
export { EmbeddableHandler } from './embeddable_handler';
export { EmbeddableHandlersRegistryProvider } from './embeddable_handlers_registry';
export { ContainerAPI } from './container_api';

View file

@ -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':

View file

@ -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);
});

View file

@ -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);
});
});
});
});