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 0611c8eba587a..fd7d982cd7194 100644 --- a/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_panels.js +++ b/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_panels.js @@ -2,12 +2,13 @@ import angular from 'angular'; import expect from 'expect.js'; import ngMock from 'ng_mock'; import 'plugins/kibana/dashboard/services/_saved_dashboard'; +import { DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT } from '../components/panel/lib/panel_state'; describe('dashboard panels', function () { let $scope; let $el; - const compile = (dashboard) => { + function compile(dashboard) { ngMock.inject(($rootScope, $controller, $compile, $route) => { $scope = $rootScope.$new(); $route.current = { @@ -19,12 +20,16 @@ describe('dashboard panels', function () { $el = angular.element(` - `); + `); $compile($el)($scope); $scope.$digest(); }); }; + function findPanelWithVisualizationId(id) { + return $scope.state.panels.find((panel) => { return panel.id === id; }); + } + beforeEach(() => { ngMock.module('kibana'); }); @@ -77,10 +82,30 @@ describe('dashboard panels', function () { compile(dash); }); expect($scope.state.panels.length).to.be(16); - const foo8Panel = $scope.state.panels.find( - (panel) => { return panel.id === 'foo8'; }); + const foo8Panel = findPanelWithVisualizationId('foo8'); expect(foo8Panel).to.not.be(null); expect(foo8Panel.row).to.be(8); expect(foo8Panel.col).to.be(1); }); + + it('initializes visualizations with the default size', function () { + ngMock.inject((SavedDashboard) => { + let dash = new SavedDashboard(); + dash.init(); + dash.panelsJSON = `[ + {"col":3,"id":"foo1","row":1,"type":"visualization"}, + {"col":5,"id":"foo2","row":1,"size_x":5,"size_y":9,"type":"visualization"}]`; + compile(dash); + }); + expect($scope.state.panels.length).to.be(2); + const foo1Panel = findPanelWithVisualizationId('foo1'); + expect(foo1Panel).to.not.be(null); + expect(foo1Panel.size_x).to.be(DEFAULT_PANEL_WIDTH); + expect(foo1Panel.size_y).to.be(DEFAULT_PANEL_HEIGHT); + + const foo2Panel = findPanelWithVisualizationId('foo2'); + expect(foo2Panel).to.not.be(null); + expect(foo2Panel.size_x).to.be(5); + expect(foo2Panel.size_y).to.be(9); + }); }); diff --git a/src/core_plugins/kibana/public/dashboard/components/panel/lib/panel_state.js b/src/core_plugins/kibana/public/dashboard/components/panel/lib/panel_state.js new file mode 100644 index 0000000000000..55d58c88d7e21 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/components/panel/lib/panel_state.js @@ -0,0 +1,35 @@ +export const DEFAULT_PANEL_WIDTH = 3; +export const DEFAULT_PANEL_HEIGHT = 2; + +/** + * Represents a panel on a grid. Keeps track of position in the grid and what visualization it + * contains. + * + * @typedef {Object} PanelState + * @property {number} id - Id of the visualization contained in the panel. + * @property {Element} $el - A reference to the gridster widget holding this panel. Used to + * update the size and column attributes. TODO: move out of panel state as this couples state to ui. + * @property {string} type - Type of the visualization in the panel. + * @property {number} panelId - Unique id to represent this panel in the grid. + * @property {number} size_x - Width of the panel. + * @property {number} size_y - Height of the panel. + * @property {number} col - Column index in the grid. + * @property {number} row - Row index in the grid. + */ + +/** + * Creates and initializes a basic panel state. + * @param {number} id + * @param {string} type + * @param {number} panelId + * @return {PanelState} + */ +export function createPanelState(id, type, panelId) { + return { + size_x: DEFAULT_PANEL_WIDTH, + size_y: DEFAULT_PANEL_HEIGHT, + panelId: panelId, + type: type, + id: id + }; +} diff --git a/src/core_plugins/kibana/public/dashboard/components/panel/lib/panel_utils.js b/src/core_plugins/kibana/public/dashboard/components/panel/lib/panel_utils.js new file mode 100644 index 0000000000000..5856d71f884f6 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/components/panel/lib/panel_utils.js @@ -0,0 +1,45 @@ +import { DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT } from 'plugins/kibana/dashboard/components/panel/lib/panel_state'; + +export class PanelUtils { + /** + * Fills in default parameters where not specified. + * @param {PanelState} panel + */ + static initializeDefaults(panel) { + panel.size_x = panel.size_x || DEFAULT_PANEL_WIDTH; + panel.size_y = panel.size_y || DEFAULT_PANEL_HEIGHT; + + if (!panel.id) { + // In the interest of backwards comparability + if (panel.visId) { + panel.id = panel.visId; + panel.type = 'visualization'; + delete panel.visId; + } else { + throw new Error('Missing object id on panel'); + } + } + } + + /** + * Ensures that the panel object has the latest size/pos info. + * @param {PanelState} panel + */ + static refreshSizeAndPosition(panel) { + const data = panel.$el.coords().grid; + panel.size_x = data.size_x; + panel.size_y = data.size_y; + panel.col = data.col; + panel.row = data.row; + } + + /** + * $el is a circular structure because it contains a reference to it's parent panel, + * so it needs to be removed before it can be serialized (we also don't + * want it to show up in the url). + * @param {PanelState} panel + */ + static makeSerializeable(panel) { + delete panel.$el; + } +} diff --git a/src/core_plugins/kibana/public/dashboard/components/panel/panel.html b/src/core_plugins/kibana/public/dashboard/components/panel/panel.html index 874a62b75522a..1cba6ed1d7ca5 100644 --- a/src/core_plugins/kibana/public/dashboard/components/panel/panel.html +++ b/src/core_plugins/kibana/public/dashboard/components/panel/panel.html @@ -4,13 +4,13 @@ {{::savedObj.title}}
- + - + - +
@@ -26,7 +26,7 @@ ng-switch-when="visualization" vis="savedObj.vis" search-source="savedObj.searchSource" - show-spy-panel="chrome.getVisible()" + show-spy-panel="!isFullScreenMode" ui-state="uiState" render-counter class="panel-content"> diff --git a/src/core_plugins/kibana/public/dashboard/components/panel/panel.js b/src/core_plugins/kibana/public/dashboard/components/panel/panel.js deleted file mode 100644 index b386335eb096d..0000000000000 --- a/src/core_plugins/kibana/public/dashboard/components/panel/panel.js +++ /dev/null @@ -1,85 +0,0 @@ -import _ from 'lodash'; -import 'ui/visualize'; -import 'ui/doc_table'; -import { loadPanelProvider } from 'plugins/kibana/dashboard/components/panel/lib/load_panel'; -import FilterManagerProvider from 'ui/filter_manager'; -import uiModules from 'ui/modules'; -import panelTemplate from 'plugins/kibana/dashboard/components/panel/panel.html'; - -uiModules -.get('app/dashboard') -.directive('dashboardPanel', function (savedVisualizations, savedSearches, Notifier, Private, $injector) { - const loadPanel = Private(loadPanelProvider); - const filterManager = Private(FilterManagerProvider); - - const services = require('plugins/kibana/management/saved_object_registry').all().map(function (serviceObj) { - const service = $injector.get(serviceObj.service); - return { - type: service.type, - name: serviceObj.service - }; - }); - - const getPanelId = function (panel) { - return ['P', panel.panelIndex].join('-'); - }; - - return { - restrict: 'E', - template: panelTemplate, - link: function ($scope) { - // using $scope inheritance, panels are available in AppState - const $state = $scope.state; - - // receives $scope.panel from the dashboard grid directive, seems like should be isolate? - $scope.$watch('id', function () { - if (!$scope.panel.id || !$scope.panel.type) return; - - loadPanel($scope.panel, $scope) - .then(function (panelConfig) { - // These could be done in loadPanel, putting them here to make them more explicit - $scope.savedObj = panelConfig.savedObj; - $scope.editUrl = panelConfig.editUrl; - $scope.$on('$destroy', function () { - panelConfig.savedObj.destroy(); - $scope.parentUiState.removeChild(getPanelId(panelConfig.panel)); - }); - - // create child ui state from the savedObj - const uiState = panelConfig.uiState || {}; - $scope.uiState = $scope.parentUiState.createChild(getPanelId(panelConfig.panel), uiState, true); - const panelSavedVis = _.get(panelConfig, 'savedObj.vis'); // Sometimes this will be a search, and undef - if (panelSavedVis) { - panelSavedVis.setUiState($scope.uiState); - } - - $scope.filter = function (field, value, operator) { - const index = $scope.savedObj.searchSource.get('index').id; - filterManager.add(field, value, operator, index); - }; - }) - .catch(function (e) { - $scope.error = e.message; - - // 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; - }); - - }); - - $scope.remove = function () { - _.pull($state.panels, $scope.panel); - }; - } - }; -}); diff --git a/src/core_plugins/kibana/public/dashboard/directives/dashboard_panel.js b/src/core_plugins/kibana/public/dashboard/directives/dashboard_panel.js new file mode 100644 index 0000000000000..ed1736349991d --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/directives/dashboard_panel.js @@ -0,0 +1,104 @@ +import _ from 'lodash'; +import 'ui/visualize'; +import 'ui/doc_table'; +import { loadPanelProvider } from 'plugins/kibana/dashboard/components/panel/lib/load_panel'; +import FilterManagerProvider from 'ui/filter_manager'; +import uiModules from 'ui/modules'; +import panelTemplate from 'plugins/kibana/dashboard/components/panel/panel.html'; + +uiModules +.get('app/dashboard') +.directive('dashboardPanel', function (savedVisualizations, savedSearches, Notifier, Private, $injector) { + const loadPanel = Private(loadPanelProvider); + const filterManager = Private(FilterManagerProvider); + + const services = require('plugins/kibana/management/saved_object_registry').all().map(function (serviceObj) { + const service = $injector.get(serviceObj.service); + return { + type: service.type, + name: serviceObj.service + }; + }); + + /** + * Returns a unique id for storing the panel state in the persistent ui. + * @param {PanelState} panel + * @returns {string} + */ + const getPersistedStateId = function (panel) { + return `P-${panel.panelId}`; + }; + + return { + restrict: 'E', + template: panelTemplate, + scope: { + /** + * Whether or not the dashboard this panel is contained on is in 'full screen mode'. + * @type {boolean} + */ + isFullScreenMode: '=', + /** + * The parent's persisted state is used to create a child persisted state for the + * panel. + * @type {PersistedState} + */ + parentUiState: '=', + /** + * Contains information about this panel. + * @type {PanelState} + */ + panel: '=', + /** + * Handles removing this panel from the grid. + * @type {() => void} + */ + remove: '&' + }, + link: function ($scope, element) { + if (!$scope.panel.id || !$scope.panel.type) return; + + loadPanel($scope.panel, $scope) + .then(function (panelConfig) { + // These could be done in loadPanel, putting them here to make them more explicit + $scope.savedObj = panelConfig.savedObj; + $scope.editUrl = panelConfig.editUrl; + + element.on('$destroy', function () { + panelConfig.savedObj.destroy(); + $scope.parentUiState.removeChild(getPersistedStateId(panelConfig.panel)); + $scope.$destroy(); + }); + + // create child ui state from the savedObj + const uiState = panelConfig.uiState || {}; + $scope.uiState = $scope.parentUiState.createChild(getPersistedStateId(panelConfig.panel), uiState, true); + const panelSavedVis = _.get(panelConfig, 'savedObj.vis'); // Sometimes this will be a search, and undef + if (panelSavedVis) { + panelSavedVis.setUiState($scope.uiState); + } + + $scope.filter = function (field, value, operator) { + const index = $scope.savedObj.searchSource.get('index').id; + filterManager.add(field, value, operator, index); + }; + }) + .catch(function (e) { + $scope.error = e.message; + + // 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; + }); + } + }; +}); diff --git a/src/core_plugins/kibana/public/dashboard/directives/grid.js b/src/core_plugins/kibana/public/dashboard/directives/grid.js index 2204ff6d2779e..2dc6822ef0751 100644 --- a/src/core_plugins/kibana/public/dashboard/directives/grid.js +++ b/src/core_plugins/kibana/public/dashboard/directives/grid.js @@ -3,6 +3,7 @@ import $ from 'jquery'; import Binder from 'ui/binder'; import 'gridster'; import uiModules from 'ui/modules'; +import { PanelUtils } from 'plugins/kibana/dashboard/components/panel/lib/panel_utils'; const app = uiModules.get('app/dashboard'); @@ -33,6 +34,24 @@ app.directive('dashboardGrid', function ($compile, Notifier) { // debounced layout function is safe to call as much as possible const safeLayout = _.debounce(layout, 200); + $scope.removePanelFromState = (panelId) => { + _.remove($scope.state.panels, function (panel) { + return panel.panelId === panelId; + }); + }; + + /** + * Removes the panel with the given id from the $scope.state.panels array. Does not + * remove the ui element from gridster - that is triggered by a watcher that is + * triggered on changes made to $scope.state.panels. + * @param panelId {number} + */ + $scope.getPanelByPanelId = (panelId) => { + return _.find($scope.state.panels, function (panel) { + return panel.panelId === panelId; + }); + }; + function init() { $el.addClass('gridster'); @@ -90,7 +109,7 @@ app.directive('dashboardGrid', function ($compile, Notifier) { }; // ensure that every panel can be serialized now that we are done - $state.panels.forEach(makePanelSerializeable); + $state.panels.forEach(PanelUtils.makeSerializeable); // alert interested parties that we have finished processing changes to the panels // TODO: change this from event based to calling a method on dashboardApp @@ -108,7 +127,7 @@ app.directive('dashboardGrid', function ($compile, Notifier) { panel.$el.stop(); removePanel(panel, true); // not that we will, but lets be safe - makePanelSerializeable(panel); + PanelUtils.makeSerializeable(panel); }); }); @@ -121,81 +140,44 @@ app.directive('dashboardGrid', function ($compile, Notifier) { // return the panel object for an element. // // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // ALWAYS CALL makePanelSerializeable AFTER YOU ARE DONE WITH IT + // ALWAYS CALL PanelUtils.makeSerializeable AFTER YOU ARE DONE WITH IT // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! function getPanelFor(el) { const $panel = el.jquery ? el : $(el); const panel = $panel.data('panel'); - panel.$el = $panel; - panel.$scope = $panel.data('$scope'); - return panel; } - // since the $el and $scope are circular structures, they need to be - // removed from panel before it can be serialized (we also wouldn't - // want them to show up in the url) - function makePanelSerializeable(panel) { - delete panel.$el; - delete panel.$scope; - } - // tell gridster to remove the panel, and cleanup our metadata function removePanel(panel, silent) { // remove from grister 'silently' (don't reorganize after) gridster.remove_widget(panel.$el, silent); - - // destroy the scope - panel.$scope.$destroy(); - panel.$el.removeData('panel'); - panel.$el.removeData('$scope'); } // tell gridster to add the panel, and create additional meatadata like $scope function addPanel(panel) { - _.defaults(panel, { - size_x: 3, - size_y: 2 - }); - - // ignore panels that don't have vis id's - if (!panel.id) { - // In the interest of backwards compat - if (panel.visId) { - panel.id = panel.visId; - panel.type = 'visualization'; - delete panel.visId; - } else { - throw new Error('missing object id on panel'); - } - } + PanelUtils.initializeDefaults(panel); - panel.$scope = $scope.$new(); - panel.$scope.panel = panel; - panel.$scope.parentUiState = $scope.uiState; - - panel.$el = $compile('
  • ')(panel.$scope); + const panelHtml = ` +
  • + +
  • `; + panel.$el = $compile(panelHtml)($scope); // tell gridster to use the widget gridster.add_widget(panel.$el, panel.size_x, panel.size_y, panel.col, panel.row); - // update size/col/etc. - refreshPanelStats(panel); + // Gridster may change the position of the widget when adding it, make sure the panel + // contains the latest info. + PanelUtils.refreshSizeAndPosition(panel); - // stash the panel and it's scope in the element's data + // stash the panel in the element's data panel.$el.data('panel', panel); - panel.$el.data('$scope', panel.$scope); - } - - // ensure that the panel object has the latest size/pos info - function refreshPanelStats(panel) { - const data = panel.$el.coords().grid; - panel.size_x = data.size_x; - panel.size_y = data.size_y; - panel.col = data.col; - panel.row = data.row; } // when gridster tell us it made a change, update each of the panel objects @@ -203,9 +185,8 @@ app.directive('dashboardGrid', function ($compile, Notifier) { // ensure that our panel objects keep their size in sync gridster.$widgets.each(function (i, el) { const panel = getPanelFor(el); - refreshPanelStats(panel); - panel.$scope.$broadcast('resize'); - makePanelSerializeable(panel); + PanelUtils.refreshSizeAndPosition(panel); + PanelUtils.makeSerializeable(panel); $scope.$root.$broadcast('change:vis'); }); } diff --git a/src/core_plugins/kibana/public/dashboard/index.js b/src/core_plugins/kibana/public/dashboard/index.js index 05bdabc73ba43..607633f747aff 100644 --- a/src/core_plugins/kibana/public/dashboard/index.js +++ b/src/core_plugins/kibana/public/dashboard/index.js @@ -7,7 +7,7 @@ import 'ui/notify'; import 'ui/typeahead'; import 'ui/share'; import 'plugins/kibana/dashboard/directives/grid'; -import 'plugins/kibana/dashboard/components/panel/panel'; +import 'plugins/kibana/dashboard/directives/dashboard_panel'; import 'plugins/kibana/dashboard/services/saved_dashboards'; import 'plugins/kibana/dashboard/styles/main.less'; import FilterBarQueryFilterProvider from 'ui/filter_bar/query_filter'; @@ -17,6 +17,7 @@ import uiRoutes from 'ui/routes'; import uiModules from 'ui/modules'; import indexTemplate from 'plugins/kibana/dashboard/index.html'; import { savedDashboardRegister } from 'plugins/kibana/dashboard/services/saved_dashboard_register'; +import { createPanelState } from 'plugins/kibana/dashboard/components/panel/lib/panel_state'; require('ui/saved_objects/saved_object_registry').register(savedDashboardRegister); const app = uiModules.get('app/dashboard', [ @@ -152,7 +153,7 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, docTitle.change(dash.title); } - initPanelIndices(); + initPanelIds(); // watch for state changes and update the appStatus.dirty value stateMonitor = stateMonitorFactory.create($state, stateDefaults); @@ -171,24 +172,23 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, $scope.$emit('application.load'); } - function initPanelIndices() { - // find the largest panelIndex in all the panels - let maxIndex = getMaxPanelIndex(); + function initPanelIds() { + // find the largest panelId in all the panels + let maxIndex = getMaxPanelId(); - // ensure that all panels have a panelIndex + // ensure that all panels have a panelId $scope.state.panels.forEach(function (panel) { - if (!panel.panelIndex) { - panel.panelIndex = maxIndex++; + if (!panel.panelId) { + panel.panelId = maxIndex++; } }); } - function getMaxPanelIndex() { - let index = $scope.state.panels.reduce(function (idx, panel) { - // if panel is missing an index, add one and increment the index - return Math.max(idx, panel.panelIndex || idx); + function getMaxPanelId() { + let maxId = $scope.state.panels.reduce(function (id, panel) { + return Math.max(id, panel.panelId || id); }, 0); - return ++index; + return ++maxId; } function updateQueryOnRootSource() { @@ -272,12 +272,12 @@ app.directive('dashboardApp', function (Notifier, courier, AppState, timefilter, // called by the saved-object-finder when a user clicks a vis $scope.addVis = function (hit) { pendingVis++; - $state.panels.push({ id: hit.id, type: 'visualization', panelIndex: getMaxPanelIndex() }); + $state.panels.push(createPanelState(hit.id, 'visualization', getMaxPanelId())); }; $scope.addSearch = function (hit) { pendingVis++; - $state.panels.push({ id: hit.id, type: 'search', panelIndex: getMaxPanelIndex() }); + $state.panels.push(createPanelState(hit.id, 'search', getMaxPanelId())); }; // Setup configurable values for config directive, after objects are initialized