From aafbe72822b947740b6a63b2f3d13acd28f0c0e8 Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Tue, 5 Sep 2017 16:34:02 -0400 Subject: [PATCH 01/16] Initial check-in to replace gridster with react-grid-layout and reactify panels --- package.json | 4 +- .../dashboard/__tests__/dashboard_panels.js | 126 -------- .../public/dashboard/__tests__/panel.js | 79 ----- .../kibana/public/dashboard/dashboard.html | 17 +- .../kibana/public/dashboard/dashboard.js | 37 ++- .../public/dashboard/dashboard_constants.js | 2 + .../public/dashboard/dashboard_state.js | 4 +- .../kibana/public/dashboard/grid.js | 273 ------------------ .../__snapshots__/dashboard_grid.test.js.snap | 85 ++++++ .../public/dashboard/grid/dashboard_grid.js | 139 +++++++++ .../dashboard/grid/dashboard_grid.test.js | 105 +++++++ .../dashboard_panel.test.js.snap | 202 +++++++++++++ .../dashboard/panel/__tests__/panel_state.js | 47 +++ .../dashboard/panel/__tests__/panel_utils.js | 17 ++ .../public/dashboard/panel/dashboard_panel.js | 111 +++++++ .../dashboard/panel/dashboard_panel.test.js | 46 +++ .../kibana/public/dashboard/panel/index.js | 3 +- .../kibana/public/dashboard/panel/panel.html | 80 ----- .../kibana/public/dashboard/panel/panel.js | 111 ------- .../public/dashboard/panel/panel_header.js | 69 +++++ .../public/dashboard/panel/panel_menu_item.js | 27 ++ .../dashboard/panel/panel_options_menu.js | 90 ++++++ .../public/dashboard/panel/panel_state.js | 83 +++++- .../public/dashboard/panel/panel_utils.js | 20 +- .../kibana/public/dashboard/styles/index.less | 94 ++++-- .../embeddable/search_embeddable_handler.js | 6 + .../visualize_embeddable_handler.js | 5 +- src/jest/config.json | 2 + .../public/embeddable/embeddable_handler.js | 2 + src/ui/public/styles/dark-theme.less | 4 +- test/functional/apps/dashboard/_dashboard.js | 44 +-- test/functional/apps/dashboard/_view_edit.js | 39 +-- .../functional/page_objects/dashboard_page.js | 38 +-- ui_framework/dist/ui_framework.css | 3 +- .../src/components/popover/_popover.scss | 3 +- webpackShims/gridster.js | 3 - 36 files changed, 1217 insertions(+), 803 deletions(-) delete mode 100644 src/core_plugins/kibana/public/dashboard/__tests__/dashboard_panels.js delete mode 100644 src/core_plugins/kibana/public/dashboard/__tests__/panel.js delete mode 100644 src/core_plugins/kibana/public/dashboard/grid.js create mode 100644 src/core_plugins/kibana/public/dashboard/grid/__snapshots__/dashboard_grid.test.js.snap create mode 100644 src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js create mode 100644 src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.test.js create mode 100644 src/core_plugins/kibana/public/dashboard/panel/__snapshots__/dashboard_panel.test.js.snap create mode 100644 src/core_plugins/kibana/public/dashboard/panel/__tests__/panel_state.js create mode 100644 src/core_plugins/kibana/public/dashboard/panel/__tests__/panel_utils.js create mode 100644 src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js create mode 100644 src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.test.js delete mode 100644 src/core_plugins/kibana/public/dashboard/panel/panel.html delete mode 100644 src/core_plugins/kibana/public/dashboard/panel/panel.js create mode 100644 src/core_plugins/kibana/public/dashboard/panel/panel_header.js create mode 100644 src/core_plugins/kibana/public/dashboard/panel/panel_menu_item.js create mode 100644 src/core_plugins/kibana/public/dashboard/panel/panel_options_menu.js delete mode 100644 webpackShims/gridster.js diff --git a/package.json b/package.json index 38d7d3bd840cc..176ceaa311c98 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,6 @@ "glob": "5.0.13", "glob-all": "3.0.1", "good-squeeze": "2.1.0", - "gridster": "0.5.6", "h2o2": "5.1.1", "handlebars": "4.0.5", "hapi": "14.2.0", @@ -171,6 +170,7 @@ "react-anything-sortable": "1.6.1", "react-color": "2.11.7", "react-dom": "15.6.1", + "react-grid-layout": "0.14.7", "react-input-autosize": "1.1.0", "react-input-range": "1.2.1", "react-markdown": "2.4.2", @@ -178,6 +178,8 @@ "react-router": "2.0.0", "react-router-redux": "4.0.4", "react-select": "1.0.0-rc.5", + + "react-sizeme": "2.3.4", "react-sortable": "1.1.0", "react-test-renderer": "15.6.1", "react-toggle": "3.0.1", diff --git a/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_panels.js b/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_panels.js deleted file mode 100644 index 109e9c34e9624..0000000000000 --- a/src/core_plugins/kibana/public/dashboard/__tests__/dashboard_panels.js +++ /dev/null @@ -1,126 +0,0 @@ -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(($injector, $rootScope, $controller, $compile, $route) => { - AppState = $injector.get('AppState'); - $scope = $rootScope.$new(); - $route.current = { - locals: { - dash: dashboard - }, - params: {} - }; - - const dashboardState = new DashboardState(dashboard, AppState, false); - $scope.containerApi = new DashboardContainerAPI(dashboardState); - $el = angular.element(` - - - `); - $compile($el)($scope); - $scope.$digest(); - }); - } - - function findPanelWithVisualizationId(id) { - return $scope.panels.find((panel) => { return panel.id === id; }); - } - - beforeEach(() => { - ngMock.module('kibana'); - }); - - afterEach(() => { - $scope.$destroy(); - $el.remove(); - }); - - it('loads with no vizualizations', function () { - ngMock.inject((SavedDashboard) => { - const dash = new SavedDashboard(); - dash.init(); - compile(dash); - }); - expect($scope.panels.length).to.be(0); - }); - - it('loads one vizualization', function () { - ngMock.inject((SavedDashboard) => { - const dash = new SavedDashboard(); - dash.init(); - dash.panelsJSON = `[{"col":3,"id":"foo1","row":1,"size_x":2,"size_y":2,"type":"visualization"}]`; - compile(dash); - }); - expect($scope.panels.length).to.be(1); - }); - - it('loads vizualizations in correct order', function () { - ngMock.inject((SavedDashboard) => { - const dash = new SavedDashboard(); - dash.init(); - dash.panelsJSON = `[ - {"col":3,"id":"foo1","row":1,"size_x":2,"size_y":2,"type":"visualization"}, - {"col":5,"id":"foo2","row":1,"size_x":2,"size_y":2,"type":"visualization"}, - {"col":9,"id":"foo3","row":1,"size_x":2,"size_y":2,"type":"visualization"}, - {"col":11,"id":"foo4","row":1,"size_x":2,"size_y":2,"type":"visualization"}, - {"col":1,"id":"foo5","row":1,"size_x":2,"size_y":2,"type":"visualization"}, - {"col":7,"id":"foo6","row":1,"size_x":2,"size_y":2,"type":"visualization"}, - {"col":4,"id":"foo7","row":6,"size_x":3,"size_y":2,"type":"visualization"}, - {"col":1,"id":"foo8","row":8,"size_x":3,"size_y":2,"type":"visualization"}, - {"col":10,"id":"foo9","row":8,"size_x":3,"size_y":2,"type":"visualization"}, - {"col":10,"id":"foo10","row":6,"size_x":3,"size_y":2,"type":"visualization"}, - {"col":4,"id":"foo11","row":8,"size_x":3,"size_y":2,"type":"visualization"}, - {"col":7,"id":"foo12","row":8,"size_x":3,"size_y":2,"type":"visualization"}, - {"col":1,"id":"foo13","row":6,"size_x":3,"size_y":2,"type":"visualization"}, - {"col":7,"id":"foo14","row":6,"size_x":3,"size_y":2,"type":"visualization"}, - {"col":5,"id":"foo15","row":3,"size_x":6,"size_y":3,"type":"visualization"}, - {"col":1,"id":"foo17","row":3,"size_x":4,"size_y":3,"type":"visualization"}]`; - compile(dash); - }); - expect($scope.panels.length).to.be(16); - 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) => { - const 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.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/__tests__/panel.js b/src/core_plugins/kibana/public/dashboard/__tests__/panel.js deleted file mode 100644 index 35b807ff0cf54..0000000000000 --- a/src/core_plugins/kibana/public/dashboard/__tests__/panel.js +++ /dev/null @@ -1,79 +0,0 @@ -import expect from 'expect.js'; -import ngMock from 'ng_mock'; -import Promise from 'bluebird'; -import sinon from 'sinon'; -import noDigestPromise from 'test_utils/no_digest_promises'; -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, $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(); - const dashboardState = new DashboardState(savedDashboard, AppState, false); - parentScope.containerApi = new DashboardContainerAPI(dashboardState); - parentScope.getVisClickHandler = sinon.stub(); - parentScope.getVisBrushHandler = sinon.stub(); - parentScope.registerPanelIndexPattern = sinon.stub(); - parentScope.panel = { - col: 3, - id: 'foo1', - row: 1, - size_x: 2, - size_y: 2, - type: 'visualization' - }; - $el = $compile(` - - `)(parentScope); - $scope = $el.isolateScope(); - parentScope.$digest(); - }); - } - - 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.renderPromise.then(() => { - expect($scope.error).to.be('Could not locate that visualization (id: foo1)'); - parentScope.$digest(); - const content = $el.find('.panel-content'); - 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.renderPromise.then(() => { - expect($scope.error).not.to.be.ok(); - parentScope.$digest(); - const content = $el.find('.panel-content'); - 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 4586d851d2c33..07573ee07e654 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard.html +++ b/src/core_plugins/kibana/public/dashboard/dashboard.html @@ -79,13 +79,14 @@

is-full-screen-mode="!chrome.getVisible()" is-expanded="true" dashboard-view-mode="dashboardViewMode" - container-api="containerApi" - toggle-expand="toggleExpandPanel(expandedPanel.panelIndex)" + get-embeddable-handler="getEmbeddableHandler" + get-container-api="getContainerApi" + on-toggle-expanded="minimizeExpandedPanel" > - diff --git a/src/core_plugins/kibana/public/dashboard/dashboard.js b/src/core_plugins/kibana/public/dashboard/dashboard.js index 0d52e6cae2165..ea55029f12c2a 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard.js @@ -4,8 +4,6 @@ import { uiModules } from 'ui/modules'; import uiRoutes from 'ui/routes'; import chrome from 'ui/chrome'; -import 'plugins/kibana/dashboard/grid'; -import 'plugins/kibana/dashboard/panel/panel'; import 'ui/query_bar'; import { SavedObjectNotFound } from 'ui/errors'; @@ -28,16 +26,36 @@ import { keyCodes } from 'ui_framework/services'; import { DashboardContainerAPI } from './dashboard_container_api'; import * as filterActions from 'ui/doc_table/actions/filter'; import { FilterManagerProvider } from 'ui/filter_manager'; +import { EmbeddableHandlersRegistryProvider } from 'ui/embeddable/embeddable_handlers_registry'; + +import { + DashboardGrid +} from './grid/dashboard_grid'; + +import { + DashboardPanel +} from './panel'; const app = uiModules.get('app/dashboard', [ 'elasticsearch', 'ngRoute', + 'react', 'kibana/courier', 'kibana/config', 'kibana/notify', 'kibana/typeahead', ]); +_.once(() => { + app.directive('dashboardGrid', function (reactDirective) { + return reactDirective(DashboardGrid); + }); + + app.directive('dashboardPanel', function (reactDirective) { + return reactDirective(DashboardPanel); + }); +})(); + uiRoutes .when(DashboardConstants.CREATE_NEW_DASHBOARD_URL, { template: dashboardTemplate, @@ -95,6 +113,8 @@ app.directive('dashboardApp', function ($injector) { const docTitle = Private(DocTitleProvider); const notify = new Notifier({ location: 'Dashboard' }); $scope.queryDocLinks = documentationLinks.query; + const embeddableHandlers = Private(EmbeddableHandlersRegistryProvider); + $scope.getEmbeddableHandler = panelType => embeddableHandlers.byName[panelType]; const dash = $scope.dash = $route.current.locals.dash; if (dash.id) { @@ -110,6 +130,7 @@ app.directive('dashboardApp', function ($injector) { dashboardState.saveState(); } ); + $scope.getContainerApi = () => $scope.containerApi; // The 'previouslyStored' check is so we only update the time filter on dashboard open, not during // normal cross app navigation. @@ -181,13 +202,13 @@ app.directive('dashboardApp', function ($injector) { !dashboardConfig.getHideWriteControls() ); - $scope.toggleExpandPanel = (panelIndex) => { - if ($scope.expandedPanel && $scope.expandedPanel.panelIndex === panelIndex) { - $scope.expandedPanel = null; - } else { - $scope.expandedPanel = + $scope.minimizeExpandedPanel = () => { + $scope.expandedPanel = null; + }; + + $scope.expandPanel = (panelIndex) => { + $scope.expandedPanel = dashboardState.getPanels().find((panel) => panel.panelIndex === panelIndex); - } }; $scope.updateQueryAndFetch = function (query) { diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_constants.js b/src/core_plugins/kibana/public/dashboard/dashboard_constants.js index 16542f1edaf94..6bdb743804f25 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard_constants.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard_constants.js @@ -5,6 +5,8 @@ export const DashboardConstants = { CREATE_NEW_DASHBOARD_URL: '/dashboard', }; +export const DASHBOARD_GRID_COLUMN_COUNT = 12; + export function createDashboardEditUrl(id) { return `/dashboard/${id}`; } diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_state.js b/src/core_plugins/kibana/public/dashboard/dashboard_state.js index e8e90a6f71011..0302b356ebe24 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard_state.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard_state.js @@ -6,7 +6,7 @@ import { PanelUtils } from './panel/panel_utils'; import moment from 'moment'; import { stateMonitorFactory } from 'ui/state_management/state_monitor_factory'; -import { createPanelState, getPersistedStateId } from 'plugins/kibana/dashboard/panel/panel_state'; +import { createPanelState, getPersistedStateId } from 'plugins/kibana/dashboard/panel'; function getStateDefaults(dashboard, hideWriteControls) { return { @@ -298,7 +298,7 @@ export class DashboardState { */ addNewPanel(id, type) { const maxPanelIndex = PanelUtils.getMaxPanelIndex(this.getPanels()); - this.getPanels().push(createPanelState(id, type, maxPanelIndex)); + this.getPanels().push(createPanelState(id, type, maxPanelIndex, this.getPanels())); } removePanel(panelIndex) { diff --git a/src/core_plugins/kibana/public/dashboard/grid.js b/src/core_plugins/kibana/public/dashboard/grid.js deleted file mode 100644 index 54fd3fab1ab79..0000000000000 --- a/src/core_plugins/kibana/public/dashboard/grid.js +++ /dev/null @@ -1,273 +0,0 @@ -import _ from 'lodash'; -import $ from 'jquery'; -import { Binder } from 'ui/binder'; -import chrome from 'ui/chrome'; -import 'gridster'; -import { uiModules } from 'ui/modules'; -import { DashboardViewMode } from 'plugins/kibana/dashboard/dashboard_view_mode'; -import { PanelUtils } from 'plugins/kibana/dashboard/panel/panel_utils'; - -const app = uiModules.get('app/dashboard'); - -app.directive('dashboardGrid', function ($compile, Notifier) { - return { - restrict: 'E', - scope: { - /** - * What view mode the dashboard is currently in - edit or view only. - * @type {DashboardViewMode} - */ - dashboardViewMode: '=', - /** - * Trigger after a panel has been removed from the grid. - */ - onPanelRemoved: '=', - /** - * Contains information about this panel. - * @type {Array} - */ - panels: '=', - /** - * Call when changes should be propagated to the url and thus saved in state. - * @type {function} - */ - saveState: '=', - /** - * Expand or collapse a panel, so it either takes up the whole screen or goes back to its - * natural size. - * @type {function} - */ - toggleExpand: '=', - /** - * @type {DashboardContainerApi} - */ - containerApi: '=', - }, - link: function ($scope, $el) { - const notify = new Notifier(); - const $container = $el; - $el = $('
    ').appendTo($container); - - const $window = $(window); - const binder = new Binder($scope); - - let gridster; // defined in init() - - // number of columns to render - const COLS = 12; - // number of pixed between each column/row - const SPACER = 0; - // pixels used by all of the spacers (gridster puts have a spacer on the ends) - const spacerSize = SPACER * COLS; - - // debounced layout function is safe to call as much as possible - const safeLayout = _.debounce(layout, 200); - /** - * Mapping of panelIndex to the angular element in the grid. - */ - const panelElementMapping = {}; - - // Tell gridster to remove the panel, and cleanup our metadata - function removePanelFromGrid(panelIndex, silent) { - const panelElement = panelElementMapping[panelIndex]; - // remove from grister 'silently' (don't reorganize after) - gridster.remove_widget(panelElement, silent); - delete panelElementMapping[panelIndex]; - } - - $scope.removePanel = (panelIndex) => { - removePanelFromGrid(panelIndex); - $scope.onPanelRemoved(panelIndex); - }; - - $scope.findPanelByPanelIndex = PanelUtils.findPanelByPanelIndex; - $scope.isFullScreenMode = !chrome.getVisible(); - - function init() { - $el.addClass('gridster'); - - gridster = $el.gridster({ - max_cols: COLS, - min_cols: COLS, - autogenerate_stylesheet: false, - resize: { - enabled: true, - stop: readGridsterChangeHandler - }, - draggable: { - handle: '[data-dashboard-panel-drag-handle]', - stop: readGridsterChangeHandler - } - }).data('gridster'); - - function setResizeCapability() { - if ($scope.dashboardViewMode === DashboardViewMode.VIEW) { - gridster.disable_resize(); - } else { - gridster.enable_resize(); - } - } - - // This is necessary to enable text selection within gridster elements - // http://stackoverflow.com/questions/21561027/text-not-selectable-from-editable-div-which-is-draggable - binder.jqOn($el, 'mousedown', function () { - gridster.disable().disable_resize(); - }); - binder.jqOn($el, 'mouseup', function enableResize() { - gridster.enable(); - setResizeCapability(); - }); - - $scope.$watch('dashboardViewMode', () => { - setResizeCapability(); - }); - - $scope.$watchCollection('panels', function (panels) { - const currentPanels = gridster.$widgets.toArray().map( - el => { - const panel = PanelUtils.findPanelByPanelIndex(el.panelIndex, $scope.panels); - if (panel) { - // A panel may have had its state updated, refresh gridster with the latest values. - const panelElement = panelElementMapping[panel.panelIndex]; - PanelUtils.refreshElementSizeAndPosition(panel, panelElement); - return panel; - } else { - return { panelIndex: el.panelIndex }; - } - } - ); - - // Panels in the grid that are missing from the panels array. This can happen if the url is modified, and a - // panel is manually removed. - const removed = _.difference(currentPanels, panels); - // Panels that have been added. - const added = _.difference(panels, currentPanels); - - removed.forEach(panel => $scope.removePanel(panel.panelIndex)); - - if (added.length) { - // See issue https://github.com/elastic/kibana/issues/2138 and the - // subsequent fix for why we need to sort here. Short story is that - // gridster can fail to render widgets in the correct order, depending - // on the specific order of the panels. - // See https://github.com/ducksboard/gridster.js/issues/147 - // for some additional back story. - added.sort((a, b) => { - if (a.row === b.row) { - return a.col - b.col; - } else { - return a.row - b.row; - } - }); - added.forEach(addPanel); - } - - if (added.length || removed.length) { - $scope.saveState(); - } - layout(); - }); - - $scope.$on('$destroy', function () { - safeLayout.cancel(); - $window.off('resize', safeLayout); - - if (!gridster) return; - gridster.$widgets.each(function (i, widget) { - const panelElement = panelElementMapping[widget.panelIndex]; - // stop any animations - panelElement.stop(); - removePanelFromGrid(widget.panelIndex, true); - }); - }); - - safeLayout(); - $window.on('resize', safeLayout); - $scope.$on('ready:vis', safeLayout); - $scope.$on('globalNav:update', safeLayout); - $scope.$on('reLayout', safeLayout); - } - - // tell gridster to add the panel, and create additional meatadata like $scope - function addPanel(panel) { - PanelUtils.initializeDefaults(panel); - const panelHtml = ` -
  • - -
  • `; - const panelElement = $compile(panelHtml)($scope); - panelElementMapping[panel.panelIndex] = panelElement; - // Store the panelIndex on the widget so it can be used to retrieve the panelElement - // from the mapping. - panelElement[0].panelIndex = panel.panelIndex; - - // tell gridster to use the widget - gridster.add_widget(panelElement, panel.size_x, panel.size_y, panel.col, panel.row); - - // Gridster may change the position of the widget when adding it, make sure the panel - // contains the latest info. - PanelUtils.refreshSizeAndPosition(panel, panelElement); - } - - // When gridster tell us it made a change, update each of the panel objects - function readGridsterChangeHandler() { - // ensure that our panel objects keep their size in sync - gridster.$widgets.each(function (i, widget) { - const panel = PanelUtils.findPanelByPanelIndex(widget.panelIndex, $scope.panels); - const panelElement = panelElementMapping[panel.panelIndex]; - PanelUtils.refreshSizeAndPosition(panel, panelElement); - }); - - $scope.saveState(); - } - - // calculate the position and sizing of the gridster el, and the columns within it - // then tell gridster to "reflow" -- which is definitely not supported. - // we may need to consider using a different library - function reflowGridster() { - if ($container.hasClass('ng-hide')) { - return; - } - - // https://github.com/gcphost/gridster-responsive/blob/97fe43d4b312b409696b1d702e1afb6fbd3bba71/jquery.gridster.js#L1208-L1235 - const g = gridster; - - g.options.widget_margins = [SPACER / 2, SPACER / 2]; - g.options.widget_base_dimensions = [($container.width() - spacerSize) / COLS, 100]; - g.min_widget_width = (g.options.widget_margins[0] * 2) + g.options.widget_base_dimensions[0]; - g.min_widget_height = (g.options.widget_margins[1] * 2) + g.options.widget_base_dimensions[1]; - - g.$widgets.each(function (i, widget) { - g.resize_widget($(widget)); - }); - - g.generate_grid_and_stylesheet(); - g.generate_stylesheet({ namespace: '.gridster' }); - - g.get_widgets_from_DOM(); - // We can't call this method if the gridmap is empty. This was found - // when the user double clicked the "New Dashboard" icon. See - // https://github.com/elastic/kibana4/issues/390 - if (gridster.gridmap.length > 0) g.set_dom_grid_height(); - g.drag_api.set_limits(COLS * g.min_widget_width); - } - - function layout() { - const complete = notify.event('reflow dashboard'); - reflowGridster(); - readGridsterChangeHandler(); - complete(); - } - - init(); - } - }; -}); diff --git a/src/core_plugins/kibana/public/dashboard/grid/__snapshots__/dashboard_grid.test.js.snap b/src/core_plugins/kibana/public/dashboard/grid/__snapshots__/dashboard_grid.test.js.snap new file mode 100644 index 0000000000000..e0c505e73cc69 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/grid/__snapshots__/dashboard_grid.test.js.snap @@ -0,0 +1,85 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders DashboardGrid 1`] = ` + +
    + +
    +
    + +
    +
    +`; + +exports[`renders DashboardGrid with no visualizations 1`] = ` + +`; diff --git a/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js new file mode 100644 index 0000000000000..f0a24610aac34 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js @@ -0,0 +1,139 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import _ from 'lodash'; +import ReactGridLayout from 'react-grid-layout'; +import { PanelUtils } from '../panel/panel_utils'; +import { DashboardViewMode } from '../dashboard_view_mode'; +import { DashboardPanel } from '../panel/dashboard_panel'; +import { DASHBOARD_GRID_COLUMN_COUNT } from '../dashboard_constants'; +import sizeMe from 'react-sizeme'; + +const config = { monitorWidth: true }; + +function ResponsiveGrid({ size, isViewMode, buildLayoutFromPanels, onLayoutChange, children }) { + return ( + + {children} + + ); +} + +// Using sizeMe sets up the grid to be re-rendered automatically not only when the window size changes, but also +// when the container size changes, so it works for Full Screen mode switches. +const ResponsiveSizedGrid = sizeMe(config)(ResponsiveGrid); + + +export class DashboardGrid extends React.Component { + constructor(props) { + super(props); + + this.state = { + layout: this.buildLayoutFromPanels() + }; + } + + buildLayoutFromPanels() { + return _.map(this.props.panels, panel => { + if (panel.size_x || panel.size_y || panel.col || panel.row) { + PanelUtils.convertOldPanelData(panel); + } + return panel.gridData; + }); + } + + onLayoutChange = (layout) => { + const { panels, getContainerApi } = this.props; + + const containerApi = getContainerApi(); + + layout.forEach(panelLayout => { + const panelUpdated = _.find(panels, panel => panel.panelIndex.toString() === panelLayout.i); + panelUpdated.gridData = { + x: panelLayout.x, + y: panelLayout.y, + w: panelLayout.w, + h: panelLayout.h, + i: panelLayout.i, + version: panelLayout.version + }; + panelUpdated.gridData.version = 6.0; + containerApi.updatePanel(panelUpdated.panelIndex, panelUpdated); + }); + }; + + generateDOM() { + const { + panels, + onPanelRemoved, + expandPanel, + isFullScreenMode, + getEmbeddableHandler, + getContainerApi, + dashboardViewMode + } = this.props; + + // Part of our unofficial API - need to render in a consistent order for plugins. + const panelsInOrder = panels.slice(0); + panelsInOrder.sort((panelA, panelB) => { + if (panelA.gridData.y === panelB.gridData.y) { + return panelA.gridData.x - panelB.gridData.x; + } else { + return panelA.gridData.y - panelB.gridData.y; + } + }); + + return _.map(panelsInOrder, panel => { + return ( +
    + +
    + ); + }); + } + + render() { + const { dashboardViewMode } = this.props; + const isViewMode = dashboardViewMode === DashboardViewMode.EDIT; + return ( + + {this.generateDOM()} + + ); + } +} + +DashboardGrid.propTypes = { + isFullScreenMode: PropTypes.bool.isRequired, + panels: PropTypes.array.isRequired, + getContainerApi: PropTypes.func.isRequired, + getEmbeddableHandler: PropTypes.func.isRequired, + dashboardViewMode: PropTypes.oneOf([DashboardViewMode.EDIT, DashboardViewMode.VIEW]).isRequired, + expandPanel: PropTypes.func.isRequired, + onPanelRemoved: PropTypes.func.isRequired, +}; + diff --git a/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.test.js b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.test.js new file mode 100644 index 0000000000000..7ad93bd582954 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.test.js @@ -0,0 +1,105 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { DashboardViewMode } from '../dashboard_view_mode'; +import { PanelUtils } from '../panel/panel_utils'; + +import { DashboardGrid } from './dashboard_grid'; +import { DashboardPanel } from '../panel/dashboard_panel'; + +const getContainerApi = () => { + return { + addFilter: () => {}, + getAppState: () => {}, + createChildUistate: () => {}, + registerPanelIndexPattern: () => {}, + updatePanel: () => {} + }; +}; + +const embeddableHandlerMock = { + getEditPath: () => {}, + getTitleFor: () => {}, + render: jest.fn() +}; + +function getProps(props = {}) { + const defaultTestProps = { + dashboardViewMode: DashboardViewMode.EDIT, + isFullScreenMode: false, + panels: [{ + gridData: { x: 0, y: 0, w: 6, h: 6, i: 1 }, + panelIndex: '1', + type: 'visualization', + id: '123' + },{ + gridData: { x: 6, y: 6, w: 6, h: 6, i: 2 }, + panelIndex: '2', + type: 'visualization', + id: '456' + }], + getEmbeddableHandler: () => embeddableHandlerMock, + isExpanded: false, + getContainerApi, + expandPanel: () => {}, + onPanelRemoved: () => {} + }; + return Object.assign(defaultTestProps, props); +} + +test('renders DashboardGrid', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + const panelElements = component.find(DashboardPanel); + expect(panelElements.length).toBe(2); +}); + +test('renders DashboardGrid with no visualizations', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +function createOldPanelData(col, id, row, sizeX, sizeY, panelIndex) { + return { col, id, row, size_x: sizeX, size_y: sizeY, type: 'visualization', panelIndex }; +} + +function findPanelWithId(panelElements, id) { + for (let i = 0; i < panelElements.length; i++) { + if (panelElements.at(i).props().panel.id === id) { + return panelElements.at(i); + } + } + return null; +} + +test('Loads old panel data in the right order', () => { + const panelData = [ + createOldPanelData(3, 'foo1', 1, 2, 2, 1), + createOldPanelData(5, 'foo2', 1, 2, 2, 2), + createOldPanelData(9, 'foo3', 1, 2, 2, 3), + createOldPanelData(11, 'foo4', 1, 2, 2, 4), + createOldPanelData(1, 'foo5', 1, 2, 2, 5), + createOldPanelData(7, 'foo6', 1, 2, 2, 6), + createOldPanelData(4, 'foo7', 6, 3, 2, 7), + createOldPanelData(1, 'foo8', 8, 3, 2, 8), + createOldPanelData(10, 'foo9', 8, 3, 2, 9), + createOldPanelData(10, 'foo10', 6, 3, 2, 10), + createOldPanelData(4, 'foo11', 8, 3, 2, 11), + createOldPanelData(7, 'foo12', 8, 3, 2, 12), + createOldPanelData(1, 'foo13', 6, 3, 2, 13), + createOldPanelData(7, 'foo14', 6, 3, 2, 14), + createOldPanelData(5, 'foo15', 3, 6, 3, 15), + createOldPanelData(1, 'foo17', 3, 4, 3, 16) + ]; + panelData.forEach(oldPanel => PanelUtils.convertOldPanelData(oldPanel)); + const props = getProps({ panels: panelData }); + + const component = shallow(); + const panelElements = component.find(DashboardPanel); + expect(panelElements.length).toBe(16); + + const foo8PanelElement = findPanelWithId(panelElements, 'foo8'); + const panel = foo8PanelElement.props().panel; + expect(panel.row).toBe(undefined); + expect(panel.gridData.y).toBe(7); + expect(panel.gridData.x).toBe(0); +}); diff --git a/src/core_plugins/kibana/public/dashboard/panel/__snapshots__/dashboard_panel.test.js.snap b/src/core_plugins/kibana/public/dashboard/panel/__snapshots__/dashboard_panel.test.js.snap new file mode 100644 index 0000000000000..b2ab4da0548d3 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/panel/__snapshots__/dashboard_panel.test.js.snap @@ -0,0 +1,202 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DashboardPanel matches snapshot 1`] = ` + +
    +
    + +
    + +
    + + + + + } + className="dashboardPanelPopOver" + closePopover={[Function]} + isOpen={false} + > + +
    + + + +
    + +
      + + +
    • +
    • +
      +
      + + +
    • +
    • +
      +
      + + +
    • +
    • +
      +
      +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +`; diff --git a/src/core_plugins/kibana/public/dashboard/panel/__tests__/panel_state.js b/src/core_plugins/kibana/public/dashboard/panel/__tests__/panel_state.js new file mode 100644 index 0000000000000..29665ddd0550c --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/panel/__tests__/panel_state.js @@ -0,0 +1,47 @@ +import expect from 'expect.js'; + +import { createPanelState } from '../panel_state'; + +function createPanelWithDimensions(x, y, w, h) { + return { + gridData: { + x, y, w, h + } + }; +} + +describe('Panel state', function () { + it('finds a spot on the right', function () { + // Default setup after a single panel, of default size, is on the grid + const panels = [createPanelWithDimensions(0, 0, 6, 6)]; + + const panel = createPanelState('1', 'a type', '1', panels); + expect(panel.gridData.x).to.equal(6); + expect(panel.gridData.y).to.equal(0); + }); + + it('finds a spot on the right when the panel is taller than any other panel on the grid', function () { + // Should be a little empty spot on the right. + const panels = [ + createPanelWithDimensions(0, 0, 6, 9), + createPanelWithDimensions(6, 0, 6, 6), + ]; + + const panel = createPanelState('1', 'a type', '1', panels); + expect(panel.gridData.x).to.equal(6); + expect(panel.gridData.y).to.equal(6); + }); + + it('finds an empty spot in the middle of the grid', function () { + const panels = [ + createPanelWithDimensions(0, 0, 12, 1), + createPanelWithDimensions(0, 1, 1, 6), + createPanelWithDimensions(10, 1, 1, 6), + createPanelWithDimensions(0, 11, 12, 1), + ]; + + const panel = createPanelState('1', 'a type', '1', panels); + expect(panel.gridData.x).to.equal(1); + expect(panel.gridData.y).to.equal(1); + }); +}); diff --git a/src/core_plugins/kibana/public/dashboard/panel/__tests__/panel_utils.js b/src/core_plugins/kibana/public/dashboard/panel/__tests__/panel_utils.js new file mode 100644 index 0000000000000..f8ebf46eed645 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/panel/__tests__/panel_utils.js @@ -0,0 +1,17 @@ +import { PanelUtils } from '../panel_utils'; +import { DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT } from '../panel_state'; + +describe('PanelUtils', function () { + it('convertOldPanelData gives supplies width and height when missing', () => { + const panelData = [ + { col: 3, id: 'foo1', row: 1, type: 'visualization', panelIndex: 1 }, + { col: 3, id: 'foo2', row: 1, size_x: 3, size_y: 2, type: 'visualization', panelIndex: 2 } + ]; + panelData.forEach(oldPanel => PanelUtils.convertOldPanelData(oldPanel)); + expect(panelData[0].gridData.w = DEFAULT_PANEL_WIDTH); + expect(panelData[0].gridData.h = DEFAULT_PANEL_HEIGHT); + + expect(panelData[1].gridData.w = 3); + expect(panelData[1].gridData.h = 2); + }); +}); diff --git a/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js b/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js new file mode 100644 index 0000000000000..9430047da62d9 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js @@ -0,0 +1,111 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { DashboardViewMode } from '../dashboard_view_mode'; +import { PanelHeader } from './panel_header'; + +export class DashboardPanel extends React.Component { + + constructor(props) { + super(props); + this.state = {}; + this.embeddable = null; + this.embeddableHandler = null; + this.parentNode = null; + } + + async componentWillMount() { + const { getEmbeddableHandler, panel, getContainerApi } = this.props; + + this.containerApi = getContainerApi(); + this.embeddableHandler = getEmbeddableHandler(panel.type); + const editUrl = await this.embeddableHandler.getEditPath(panel.id); + const title = await this.embeddableHandler.getTitleFor(panel.id); + + this.setState({ editUrl, title }); + + this.destroyEmbeddable = await this.embeddableHandler.render( + this.panelElement, + panel, + this.containerApi); + } + + isViewOnlyMode() { + return this.props.dashboardViewMode === DashboardViewMode.VIEW || this.props.isFullScreenMode; + } + + getParentNode() { + if (!this.parentNode) { + this.parentNode = ReactDOM.findDOMNode(this).parentNode; + } + return this.parentNode; + } + + toggleExpandedPanel = () => this.props.onToggleExpanded(this.props.panel.panelIndex); + deletePanel = () => { + this.props.onDeletePanel(this.props.panel.panelIndex); + }; + onEditPanel = () => window.location = this.state.editUrl; + + /** + * Setting the zIndex on onFocus and onBlur allows popups, like the panel menu, to appear above other panels in the + * grid. + */ + onFocus = () => { + this.getParentNode().style.zIndex = 1; + }; + onBlur = () => { + this.getParentNode().style.zIndex = 'auto'; + }; + + componentWillUnmount() { + this.destroyEmbeddable(); + } + + render() { + const { title } = this.state; + const { dashboardViewMode, isFullScreenMode, isExpanded } = this.props; + const classes = classNames('panel panel-default', this.props.className, { + 'panel--edit-mode': !this.isViewOnlyMode() + }); + return ( +
    +
    + +
    this.panelElement = panelElement} + /> +
    +
    + ); + } +} + +DashboardPanel.propTypes = { + dashboardViewMode: PropTypes.oneOf([DashboardViewMode.EDIT, DashboardViewMode.VIEW]).isRequired, + isFullScreenMode: PropTypes.bool.isRequired, + panel: PropTypes.object.isRequired, + getEmbeddableHandler: PropTypes.func.isRequired, + isExpanded: PropTypes.bool.isRequired, + getContainerApi: PropTypes.func.isRequired, + onToggleExpanded: PropTypes.func.isRequired, + onDeletePanel: PropTypes.func +}; diff --git a/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.test.js b/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.test.js new file mode 100644 index 0000000000000..38a67c4f9fc91 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.test.js @@ -0,0 +1,46 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { DashboardViewMode } from '../dashboard_view_mode'; +import { DashboardPanel } from './dashboard_panel'; + +const containerApiMock = { + addFilter: () => {}, + getAppState: () => {}, + createChildUistate: () => {}, + registerPanelIndexPattern: () => {}, + updatePanel: () => {} +}; + +const embeddableHandlerMock = { + getEditPath: () => {}, + getTitleFor: () => {}, + render: jest.fn() +}; + +function getProps(props = {}) { + const defaultTestProps = { + dashboardViewMode: DashboardViewMode.EDIT, + isFullScreenMode: false, + panel: { + gridData: { x: 0, y: 0, w: 6, h: 6, i: 1 }, + panelIndex: '1', + type: 'visualization', + id: 'foo1' + }, + getEmbeddableHandler: () => embeddableHandlerMock, + isExpanded: false, + getContainerApi: () => containerApiMock, + onToggleExpanded: () => {}, + onDeletePanel: () => {} + }; + return Object.assign(props, defaultTestProps); +} + +test('DashboardPanel matches snapshot', () => { + const component = mount(); + expect(component).toMatchSnapshot(); +}); + +test('and calls render', () => { + expect(embeddableHandlerMock.render.mock.calls.length).toBe(1); +}); diff --git a/src/core_plugins/kibana/public/dashboard/panel/index.js b/src/core_plugins/kibana/public/dashboard/panel/index.js index 01563f8b57527..a83e0b5bae450 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/index.js +++ b/src/core_plugins/kibana/public/dashboard/panel/index.js @@ -1 +1,2 @@ -import './panel'; +export { DashboardPanel } from './dashboard_panel'; +export { createPanelState, getPersistedStateId } from './panel_state'; diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel.html b/src/core_plugins/kibana/public/dashboard/panel/panel.html deleted file mode 100644 index d20d0fdde1bee..0000000000000 --- a/src/core_plugins/kibana/public/dashboard/panel/panel.html +++ /dev/null @@ -1,80 +0,0 @@ -
    -
    - - {{::title}} - -
    - - - - - - - - - - - - - - - - - -
    -
    -
    - -
    - - -
    - -
    -
    diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel.js b/src/core_plugins/kibana/public/dashboard/panel/panel.js deleted file mode 100644 index 2c2a53e1b643a..0000000000000 --- a/src/core_plugins/kibana/public/dashboard/panel/panel.js +++ /dev/null @@ -1,111 +0,0 @@ -import 'ui/visualize'; -import 'ui/doc_table'; -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 { DashboardViewMode } from '../dashboard_view_mode'; -import { EmbeddableHandlersRegistryProvider } from 'ui/embeddable/embeddable_handlers_registry'; - -uiModules -.get('app/dashboard') -.directive('dashboardPanel', function (Notifier, Private, $injector) { - const services = savedObjectManagementRegistry.all().map(function (serviceObj) { - const service = $injector.get(serviceObj.service); - return { - type: service.type, - name: serviceObj.service - }; - }); - - return { - restrict: 'E', - template: panelTemplate, - scope: { - /** - * What view mode the dashboard is currently in - edit or view only. - * @type {DashboardViewMode} - */ - dashboardViewMode: '=', - /** - * Whether or not the dashboard this panel is contained on is in 'full screen mode'. - * @type {boolean} - */ - isFullScreenMode: '=', - /** - * Contains information about this panel. - * @type {PanelState} - */ - panel: '=', - /** - * Handles removing this panel from the grid. - * @type {function} - */ - remove: '&', - /** - * Expand or collapse the current panel, so it either takes up the whole screen or goes back to its - * natural size. - * @type {function} - */ - toggleExpand: '&', - /** - * @type {boolean} - */ - isExpanded: '=', - /** - * @type {DashboardContainerApi} - */ - containerApi: '=' - }, - link: function ($scope, element) { - if (!$scope.panel.id || !$scope.panel.type) return; - - $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); - } - }; -}); diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_header.js b/src/core_plugins/kibana/public/dashboard/panel/panel_header.js new file mode 100644 index 0000000000000..aff4163126348 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_header.js @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { KuiKeyboardAccessible } from 'ui_framework/components'; +import { PanelOptionsMenu } from './panel_options_menu'; + +export class PanelHeader extends React.Component { + constructor(props) { + super(props); + } + + getOptionsDropDown() { + return ( + + ); + } + + getExpandToggle() { + const { isExpanded } = this.props; + const classes = classNames('kuiIcon', null, { 'fa-expand': !isExpanded, 'fa-compress': isExpanded }); + return ( + + + + + ); + } + + render() { + return ( +
    + + {this.props.title} + + +
    + {this.props.isViewOnlyMode ? this.getExpandToggle() : this.getOptionsDropDown()} +
    +
    + ); + } +} + +PanelHeader.propTypes = { + title: PropTypes.string, + onEditPanel: PropTypes.func.isRequired, + onDeletePanel: PropTypes.func.isRequired, + onToggleExpand: PropTypes.func.isRequired, + isExpanded: PropTypes.bool.isRequired +}; diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_menu_item.js b/src/core_plugins/kibana/public/dashboard/panel/panel_menu_item.js new file mode 100644 index 0000000000000..df80d5f8cd6ee --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_menu_item.js @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { KuiMenuItem } from 'ui_framework/components'; + +export function PanelMenuItem({ iconClass, onClick, label, ...props }) { + const iconClasses = classNames('kuiButton__icon kuiIcon', iconClass); + return ( + + + ); +} + +PanelMenuItem.propTypes = { + iconClass: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + label: PropTypes.string.isRequired +}; diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_options_menu.js b/src/core_plugins/kibana/public/dashboard/panel/panel_options_menu.js new file mode 100644 index 0000000000000..1596e0a90ae99 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_options_menu.js @@ -0,0 +1,90 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { PanelMenuItem } from './panel_menu_item'; +import { + KuiPopover, + KuiMenu, + KuiKeyboardAccessible +} from 'ui_framework/components'; + +export class PanelOptionsMenu extends React.Component { + constructor(props) { + super(props); + + this.state = { + showMenu: false + }; + } + + toggleMenu = () => { + this.setState({ showMenu: !this.state.showMenu }); + }; + closeMenu = () => this.setState({ showMenu: false }); + + getEditVisualizationMenuItem() { + return ( + + ); + } + + getDeleteMenuItem() { + return ( + + ); + } + + getToggleExpandMenuItem() { + return ( + + ); + } + + render() { + return ( + + + + )} + isOpen={this.state.showMenu} + anchorPosition="right" + closePopover={this.closeMenu} + > + + {this.getEditVisualizationMenuItem()} + {this.getToggleExpandMenuItem()} + {this.props.isExpanded ? null : this.getDeleteMenuItem()} + + + ); + } +} + +PanelOptionsMenu.propTypes = { + onEditPanel: PropTypes.func.isRequired, + onToggleExpandPanel: PropTypes.func.isRequired, + isExpanded: PropTypes.bool.isRequired, + onDeletePanel: PropTypes.func, // Not available when the panel is expanded. +}; diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_state.js b/src/core_plugins/kibana/public/dashboard/panel/panel_state.js index 25ece385e3d50..64f4ddcf20c8d 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_state.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_state.js @@ -1,36 +1,101 @@ export const DEFAULT_PANEL_WIDTH = 6; export const DEFAULT_PANEL_HEIGHT = 3; +import { DASHBOARD_GRID_COLUMN_COUNT } from '../dashboard_constants'; + /** * 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} panelIndex - Unique id to represent this panel in the grid. Note that this is * NOT the index in the panels array. While it may initially represent that, it is not * updated with changes in a dashboard, and is simply used as a unique identifier. The name * remains as panelIndex for backward compatibility reasons - changing it can break reporting. - * @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. + * @property {Object} gridData + * @property {number} gridData.w - Width of the panel. + * @property {number} gridData.h - Height of the panel. + * @property {number} gridData.x - Column position of the panel. + * @property {number} gridData.y - Row position of the panel. */ +// Look for the smallest y and x value where the default panel will fit. +function findTopLeftMostOpenSpace(width, height, currentPanels) { + let maxY = -1; + + for (let i = 0; i < currentPanels.length; i++) { + const panel = currentPanels[i]; + maxY = Math.max(panel.gridData.y + panel.gridData.h, maxY); + } + + // Handle case of empty grid. + if (maxY < 0) { + return { x: 0, y: 0 }; + } + + const grid = new Array(maxY); + for (let y = 0; y < maxY; y++) { + grid[y] = new Array(DASHBOARD_GRID_COLUMN_COUNT).fill(0); + } + + for (let i = 0; i < currentPanels.length; i++) { + const panel = currentPanels[i]; + for (let x = panel.gridData.x; x < panel.gridData.x + panel.gridData.w; x++) { + for (let y = panel.gridData.y; y < panel.gridData.y + panel.gridData.h; y++) { + grid[y][x] = 1; + } + } + } + + for (let y = 0; y < maxY; y++) { + for (let x = 0; x < DASHBOARD_GRID_COLUMN_COUNT; x++) { + if (grid[y][x] === 1) { + // Space is filled + continue; + } else { + for (let h = y; h < Math.min(y + height, maxY); h++) { + for (let w = x; w < Math.min(x + width, DASHBOARD_GRID_COLUMN_COUNT); w++) { + const spaceIsEmpty = grid[h][w] === 0; + const fitsPanelWidth = w === x + width - 1; + // If the panel is taller than any other panel in the current grid, it can still fit in the space, hence + // we check the minimum of maxY and the panel height. + const fitsPanelHeight = h === Math.min(y + height - 1, maxY - 1); + + if (spaceIsEmpty && fitsPanelWidth && fitsPanelHeight) { + // Found space + return { x, y }; + } else if (grid[h][w] === 1) { + // x, y spot doesn't work, break. + break; + } + } + } + } + } + } + return { x: 0, y: Infinity }; +} + /** * Creates and initializes a basic panel state. * @param {number} id * @param {string} type * @param {number} panelIndex + * @param {Array} currentPanels * @return {PanelState} */ -export function createPanelState(id, type, panelIndex) { +export function createPanelState(id, type, panelIndex, currentPanels) { + const { x, y } = findTopLeftMostOpenSpace(DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT, currentPanels); return { - size_x: DEFAULT_PANEL_WIDTH, - size_y: DEFAULT_PANEL_HEIGHT, + gridData: { + w: DEFAULT_PANEL_WIDTH, + h: DEFAULT_PANEL_HEIGHT, + x, + y, + i: panelIndex.toString() + }, panelIndex: panelIndex, type: type, id: id diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_utils.js b/src/core_plugins/kibana/public/dashboard/panel/panel_utils.js index b2783c8c17d81..d27a5a98e5803 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_utils.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_utils.js @@ -8,8 +8,9 @@ export class PanelUtils { * @param {PanelState} panel */ static initializeDefaults(panel) { - panel.size_x = panel.size_x || DEFAULT_PANEL_WIDTH; - panel.size_y = panel.size_y || DEFAULT_PANEL_HEIGHT; + panel.gridData = panel.gridData || {}; + panel.gridData.w = panel.gridData.w || DEFAULT_PANEL_WIDTH; + panel.gridData.h = panel.gridData.h || DEFAULT_PANEL_HEIGHT; if (!panel.id) { // In the interest of backwards comparability @@ -23,6 +24,21 @@ export class PanelUtils { } } + static convertOldPanelData(panel) { + panel.gridData = { + x: panel.col - 1, + y: panel.row - 1, + w: panel.size_x || DEFAULT_PANEL_WIDTH, + h: panel.size_y || DEFAULT_PANEL_HEIGHT, + i: panel.panelIndex.toString(), + version: 6, + }; + delete panel.size_x; + delete panel.size_y; + delete panel.row; + delete panel.col; + } + /** * Ensures that the panel object has the latest size/pos info. * @param {PanelState} panel diff --git a/src/core_plugins/kibana/public/dashboard/styles/index.less b/src/core_plugins/kibana/public/dashboard/styles/index.less index 39d67124f6a79..957107771809e 100644 --- a/src/core_plugins/kibana/public/dashboard/styles/index.less +++ b/src/core_plugins/kibana/public/dashboard/styles/index.less @@ -2,6 +2,9 @@ @import (reference) "~ui/styles/mixins"; @import "~ui/styles/local_search.less"; +@import "../../../../../../node_modules/react-grid-layout/css/styles.css"; +@import "../../../../../../node_modules/react-resizable/css/styles.css"; + .fullScreenModePlaceholder { text-align: center; width: 100%; @@ -10,6 +13,13 @@ position: absolute; } +/** + * 1. If we don't give the resizable handler a larger z index value the spy toggle will take over the mouse hover. + */ +.react-resizable-handle { + z-index: 10; /* 1 */ +} + .exitFullScreenMode { height: 40px; left: 0px; @@ -52,7 +62,6 @@ */ .exitFullScreenModeText { - display: block; background: @globalColorBlue; color: tint(@globalColorBlue, 70%); line-height: 40px; @@ -77,7 +86,7 @@ width: 450px; } -dashboard-grid { +.dashboard-grid { display: block; margin: 0; padding: 5px; @@ -93,24 +102,17 @@ dashboard-grid { border-radius: 4px; } -.gridster { - list-style-type: none; - display: block; +/** + * 1. Not entirely sure why but when the panel is within the grid, it requires height 100%. When it's an expanded + * panel, however, outside the grid, height: 100% will cause the panel not to expand properly. + * 2. We need this so the panel menu pop up shows up outside the boundaries of a panel. + */ +.react-grid-layout { background-color: @dashboard-bg; - margin: 0; - padding: 0; - .gs-resize-handle { - background-position: 50% 50% !important; - bottom: 0 !important; - right: 0 !important; - padding: 4px; - height: 25px; - width: 25px; - } - - i.remove { - cursor: pointer; + .dashboard-panel { + height: 100%; /* 1. */ + overflow: visible; /* 2. */ } .gs-w { @@ -127,6 +129,10 @@ dashboard-grid { .visualize-show-spy { visibility: visible; } + + .panel-heading { + cursor: pointer; + } } .dashboard-container { @@ -135,17 +141,22 @@ dashboard-grid { flex-direction: column; } +dashboard-panel { + flex: 1; + display: flex; +} + /** * 1. Fix Firefox bug where a value of overflow: hidden will prevent scrolling in a panel where the spy panel does * not have enough room. * 2. react-select used in input control vis needs `visible` overflow to avoid clipping selection list */ -dashboard-panel { +.dashboard-panel { + z-index: auto; flex: 1; display: flex; flex-direction: column; - height: 100%; background: @dashboard-panel-bg; color: @dashboard-panel-color; padding: 0; @@ -165,7 +176,7 @@ dashboard-panel { justify-content: flex-start; .panel-heading { - padding: 0px 0px 0px 5px; + padding: 2px 10px 2px 5px; flex: 0 0 auto; white-space: nowrap; display: flex; @@ -174,6 +185,40 @@ dashboard-panel { background-color: @white; border: none; + /** + * 1. The popover aligns with the right side of this icon, so we want the right edge as far so the right as + * possible to make the arrows line up. + */ + .dashboardPanelPopOver { + margin-right: -10px; /* 1. */ + } + + /** + * 1. Required to get the pop up component arrow to line up with the menu icon. + */ + .panel-dropdown { + padding: 0 20px; /* 1. */ + } + + .kuiPopover__body { + z-index: 25; + } + + .dashboardPanelMenuItem { + padding: 10px; + color: @text-color; + + p { + display: inline; + padding: 0 0 0 5px; + } + + &:hover { + color: @link-hover-color; + } + + } + .panel-title { font-size: inherit; @@ -235,3 +280,10 @@ dashboard-panel { overflow: auto; /* 1 */ } } + +/** + * Allows popups inside each panel appear in front of other panels, if the boundaries are outside the panel itself. + */ +.react-grid-item:active { + z-index: 1; /* 1. */ +} 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 index 1c12be3e8cab4..f816b13ec771d 100644 --- a/src/core_plugins/kibana/public/discover/embeddable/search_embeddable_handler.js +++ b/src/core_plugins/kibana/public/discover/embeddable/search_embeddable_handler.js @@ -76,6 +76,12 @@ export class SearchEmbeddableHandler extends EmbeddableHandler { const searchInstance = this.$compile(searchTemplate)(searchScope); const rootNode = angular.element(domNode); rootNode.append(searchInstance); + + return () => { + searchInstance.remove(); + searchScope.savedObj.destroy(); + searchScope.$destroy(); + }; }); } } 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 index 23372e65d15a3..f5a15b215c2be 100644 --- a/src/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_handler.js +++ b/src/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_handler.js @@ -56,10 +56,11 @@ export class VisualizeEmbeddableHandler extends EmbeddableHandler { const rootNode = angular.element(domNode); rootNode.append(visualizationInstance); - visualizationInstance.on('$destroy', function () { + return () => { + visualizationInstance.remove(); visualizeScope.savedObj.destroy(); visualizeScope.$destroy(); - }); + }; }); } } diff --git a/src/jest/config.json b/src/jest/config.json index 57360bdfd4bcc..5dcf367e9c1df 100644 --- a/src/jest/config.json +++ b/src/jest/config.json @@ -15,6 +15,8 @@ "moduleNameMapper": { "^ui_framework/components": "/ui_framework/components", "^ui_framework/services": "/ui_framework/services", + "^ui/(.*)": "/src/ui/public/$1", + "^plugins/kibana/(.*)": "/src/core_plugins/kibana/public/$1", "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/src/jest/file_mock.js", "\\.(css|less|scss)$": "/src/jest/style_mock.js" }, diff --git a/src/ui/public/embeddable/embeddable_handler.js b/src/ui/public/embeddable/embeddable_handler.js index aee16ca8e6049..a60fec577d7a8 100644 --- a/src/ui/public/embeddable/embeddable_handler.js +++ b/src/ui/public/embeddable/embeddable_handler.js @@ -26,6 +26,8 @@ export class EmbeddableHandler { * 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. + * @return {Promise.} A promise that resolves to a function that should be used to destroy the + * rendered embeddable. */ render(/* domNode, panel, container */) { throw new Error('Must implement render.'); diff --git a/src/ui/public/styles/dark-theme.less b/src/ui/public/styles/dark-theme.less index dc281d164cbcb..4d5d7ad5c8073 100644 --- a/src/ui/public/styles/dark-theme.less +++ b/src/ui/public/styles/dark-theme.less @@ -528,11 +528,11 @@ background-color: @dashboard-bg; } - .gridster { + .react-grid-layout { background-color: @dashboard-bg; } - dashboard-panel { + .dashboard-panel { background: @dashboard-panel-bg; color: @dashboard-panel-color; diff --git a/test/functional/apps/dashboard/_dashboard.js b/test/functional/apps/dashboard/_dashboard.js index bd1f24db015ec..b7b4c51550407 100644 --- a/test/functional/apps/dashboard/_dashboard.js +++ b/test/functional/apps/dashboard/_dashboard.js @@ -2,7 +2,6 @@ import expect from 'expect.js'; import { DEFAULT_PANEL_WIDTH, - DEFAULT_PANEL_HEIGHT, } from '../../../../src/core_plugins/kibana/public/dashboard/panel/panel_state'; import { @@ -76,29 +75,6 @@ export default function ({ getService, getPageObjects }) { }); }); - it('should have all the expected initial sizes', function checkVisualizationSizes() { - const width = DEFAULT_PANEL_WIDTH; - const height = DEFAULT_PANEL_HEIGHT; - const titles = PageObjects.dashboard.getTestVisualizationNames(); - const visObjects = [ - { dataCol: '1', dataRow: '1', dataSizeX: width, dataSizeY: height, title: titles[0] }, - { dataCol: width + 1, dataRow: '1', dataSizeX: width, dataSizeY: height, title: titles[1] }, - { dataCol: '1', dataRow: height + 1, dataSizeX: width, dataSizeY: height, title: titles[2] }, - { dataCol: width + 1, dataRow: height + 1, dataSizeX: width, dataSizeY: height, title: titles[3] }, - { dataCol: '1', dataRow: (height * 2) + 1, dataSizeX: width, dataSizeY: height, title: titles[4] }, - { dataCol: width + 1, dataRow: (height * 2) + 1, dataSizeX: width, dataSizeY: height, title: titles[5] }, - { dataCol: '1', dataRow: (height * 3) + 1, dataSizeX: width, dataSizeY: height, title: titles[6] } - ]; - return retry.tryForTime(10000, function () { - return PageObjects.dashboard.getPanelSizeData() - .then(function (panelTitles) { - log.info('visualization titles = ' + panelTitles); - screenshots.take('Dashboard-visualization-sizes'); - expect(panelTitles).to.eql(visObjects); - }); - }); - }); - describe('filters', async function () { it('are not selected by default', async function () { const filters = await PageObjects.dashboard.getFilters(1000); @@ -156,10 +132,16 @@ export default function ({ getService, getPageObjects }) { it('for panel size parameters', async function () { const currentUrl = await remote.getCurrentUrl(); - const newUrl = currentUrl.replace(`size_x:${DEFAULT_PANEL_WIDTH}`, `size_x:${DEFAULT_PANEL_WIDTH * 2}`); + const currentPanelDimensions = await PageObjects.dashboard.getPanelDimensions(); + const newUrl = currentUrl.replace(`w:${DEFAULT_PANEL_WIDTH}`, `w:${DEFAULT_PANEL_WIDTH * 2}`); await remote.get(newUrl.toString(), false); - const allPanelInfo = await PageObjects.dashboard.getPanelSizeData(); - expect(allPanelInfo[0].dataSizeX).to.equal(`${DEFAULT_PANEL_WIDTH * 2}`); + await retry.try(async () => { + const newPanelDimensions = await PageObjects.dashboard.getPanelDimensions(); + if (newPanelDimensions.length < 0) { + throw new Error('No panel dimensions...'); + } + expect(newPanelDimensions[0].width).to.equal(currentPanelDimensions[0].width * 2); + }); }); }); @@ -303,11 +285,9 @@ export default function ({ getService, getPageObjects }) { await PageObjects.header.clickToastOK(); const visualizations = PageObjects.dashboard.getTestVisualizations(); - return retry.tryForTime(10000, async function () { - const panelTitles = await PageObjects.dashboard.getPanelSizeData(); - log.info('visualization titles = ' + panelTitles.map(item => item.title)); - screenshots.take('Dashboard-visualization-sizes'); - expect(panelTitles.length).to.eql(visualizations.length + 1); + return retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(visualizations.length + 1); }); }); diff --git a/test/functional/apps/dashboard/_view_edit.js b/test/functional/apps/dashboard/_view_edit.js index 9d476691be90e..801096c71e59d 100644 --- a/test/functional/apps/dashboard/_view_edit.js +++ b/test/functional/apps/dashboard/_view_edit.js @@ -52,24 +52,20 @@ export default function ({ getService, getPageObjects }) { await PageObjects.dashboard.gotoDashboardLandingPage(); await PageObjects.dashboard.clickDashboardByLinkText(dashboardName); - const editLinkExists = await testSubjects.exists('dashboardPanelEditLink'); - const moveExists = await testSubjects.exists('dashboardPanelMoveIcon'); - const removeExists = await testSubjects.exists('dashboardPanelRemoveIcon'); - - expect(editLinkExists).to.equal(false); - expect(moveExists).to.equal(false); - expect(removeExists).to.equal(false); + const panelToggleMenu = await testSubjects.exists('dashboardPanelToggleMenuIcon'); + expect(panelToggleMenu).to.equal(false); }); it('are shown in edit mode', async function () { await PageObjects.dashboard.clickEdit(); + const panelToggleMenu = await testSubjects.exists('dashboardPanelToggleMenuIcon'); + expect(panelToggleMenu).to.equal(true); + await testSubjects.click('dashboardPanelToggleMenuIcon'); const editLinkExists = await testSubjects.exists('dashboardPanelEditLink'); - const moveExists = await testSubjects.exists('dashboardPanelMoveIcon'); const removeExists = await testSubjects.exists('dashboardPanelRemoveIcon'); expect(editLinkExists).to.equal(true); - expect(moveExists).to.equal(true); expect(removeExists).to.equal(true); }); @@ -79,24 +75,20 @@ export default function ({ getService, getPageObjects }) { await PageObjects.header.clickToastOK(); await PageObjects.dashboard.toggleExpandPanel(); - const editLinkExists = await testSubjects.exists('dashboardPanelEditLink'); - const moveExists = await testSubjects.exists('dashboardPanelMoveIcon'); - const removeExists = await testSubjects.exists('dashboardPanelRemoveIcon'); - - expect(editLinkExists).to.equal(false); - expect(moveExists).to.equal(false); - expect(removeExists).to.equal(false); + const panelToggleMenu = await testSubjects.exists('dashboardPanelToggleMenuIcon'); + expect(panelToggleMenu).to.equal(false); }); - it('in edit mode hides move and remove icons ', async function () { + it('in edit mode hides remove icons ', async function () { await PageObjects.dashboard.clickEdit(); + const panelToggleMenu = await testSubjects.exists('dashboardPanelToggleMenuIcon'); + expect(panelToggleMenu).to.equal(true); + await testSubjects.click('dashboardPanelToggleMenuIcon'); const editLinkExists = await testSubjects.exists('dashboardPanelEditLink'); - const moveExists = await testSubjects.exists('dashboardPanelMoveIcon'); const removeExists = await testSubjects.exists('dashboardPanelRemoveIcon'); expect(editLinkExists).to.equal(true); - expect(moveExists).to.equal(false); expect(removeExists).to.equal(false); await PageObjects.dashboard.toggleExpandPanel(); @@ -108,6 +100,7 @@ export default function ({ getService, getPageObjects }) { describe('panel expand control', function () { it('shown in edit mode', async function () { await PageObjects.dashboard.gotoDashboardEditMode(dashboardName); + await testSubjects.click('dashboardPanelToggleMenuIcon'); const expandExists = await testSubjects.exists('dashboardPanelExpandIcon'); expect(expandExists).to.equal(true); }); @@ -212,8 +205,8 @@ export default function ({ getService, getPageObjects }) { await PageObjects.common.clickConfirmOnModal(); const visualizations = PageObjects.dashboard.getTestVisualizations(); - const panelTitles = await PageObjects.dashboard.getPanelSizeData(); - expect(panelTitles.length).to.eql(visualizations.length); + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(visualizations.length); }); it('when an existing vis is added', async function () { @@ -224,8 +217,8 @@ export default function ({ getService, getPageObjects }) { await PageObjects.common.clickConfirmOnModal(); const visualizations = PageObjects.dashboard.getTestVisualizations(); - const panelTitles = await PageObjects.dashboard.getPanelSizeData(); - expect(panelTitles.length).to.eql(visualizations.length); + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(visualizations.length); }); }); diff --git a/test/functional/page_objects/dashboard_page.js b/test/functional/page_objects/dashboard_page.js index edd7bc102873c..a2cb020560bed 100644 --- a/test/functional/page_objects/dashboard_page.js +++ b/test/functional/page_objects/dashboard_page.js @@ -31,6 +31,7 @@ export function DashboardPageProvider({ getService, getPageObjects }) { async clickEditVisualization() { log.debug('clickEditVisualization'); + await testSubjects.click('dashboardPanelToggleMenuIcon'); await testSubjects.click('dashboardPanelEditLink'); await retry.try(async () => { @@ -377,20 +378,25 @@ export function DashboardPageProvider({ getService, getPageObjects }) { return await testSubjects.findAll('dashboardPanel'); } - async getPanelSizeData() { - const titleObjects = await find.allByCssSelector('li.gs-w'); // These are gridster-defined elements and classes - async function getTitles(chart) { - const dataCol = await chart.getAttribute('data-col'); - const dataRow = await chart.getAttribute('data-row'); - const dataSizeX = await chart.getAttribute('data-sizex'); - const dataSizeY = await chart.getAttribute('data-sizey'); - const childElement = await testSubjects.findDescendant('dashboardPanelTitle', chart); - const title = await childElement.getVisibleText(); - return { dataCol, dataRow, dataSizeX, dataSizeY, title }; + + async getPanelDimensions() { + const panels = await find.allByCssSelector('.react-grid-item'); // These are gridster-defined elements and classes + async function getPanelDimensions(panel) { + const size = await panel.getSize(); + return { + width: size.width, + height: size.height + }; } - const getTitlePromises = _.map(titleObjects, getTitles); - return await Promise.all(getTitlePromises); + const getDimensionsPromises = _.map(panels, getPanelDimensions); + return await Promise.all(getDimensionsPromises); + } + + async getPanelCount() { + log.debug('getPanelCount'); + const panels = await find.allByCssSelector('.react-grid-item'); + return panels.length; } getTestVisualizations() { @@ -465,13 +471,9 @@ export function DashboardPageProvider({ getService, getPageObjects }) { log.debug('toggleExpandPanel'); const expandShown = await testSubjects.exists('dashboardPanelExpandIcon'); if (!expandShown) { - const panelElements = await find.allByCssSelector('span.panel-title'); - log.debug('click title'); - await retry.try(() => panelElements[0].click()); // Click to simulate hover. + await testSubjects.click('dashboardPanelToggleMenuIcon'); } - const expandButton = await testSubjects.find('dashboardPanelExpandIcon'); - log.debug('click expand icon'); - await retry.try(() => expandButton.click()); + await testSubjects.click('dashboardPanelExpandIcon'); } async getSharedItemsCount() { diff --git a/ui_framework/dist/ui_framework.css b/ui_framework/dist/ui_framework.css index a51004bc0cff5..8dfe46f9b4afe 100644 --- a/ui_framework/dist/ui_framework.css +++ b/ui_framework/dist/ui_framework.css @@ -2795,6 +2795,7 @@ main { .kuiPopover.kuiPopover-isOpen .kuiPopover__body { opacity: 1; visibility: visible; + display: inline-block; z-index: 1; margin-top: 10px; box-shadow: 0 16px 16px -8px rgba(0, 0, 0, 0.1); } @@ -2817,7 +2818,7 @@ main { -webkit-transform-origin: center top; transform-origin: center top; opacity: 0; - visibility: hidden; + display: none; margin-top: 32px; } .kuiPopover__body:before { position: absolute; diff --git a/ui_framework/src/components/popover/_popover.scss b/ui_framework/src/components/popover/_popover.scss index 559f091821458..65d1e48809159 100644 --- a/ui_framework/src/components/popover/_popover.scss +++ b/ui_framework/src/components/popover/_popover.scss @@ -10,6 +10,7 @@ .kuiPopover__body { opacity: 1; visibility: visible; + display: inline-block; z-index: 1; margin-top: 10px; box-shadow: 0 16px 16px -8px rgba(0, 0, 0, 0.1); @@ -33,7 +34,7 @@ backface-visibility: hidden; transform-origin: center top; opacity: 0; - visibility: hidden; + display: none; margin-top: 32px; // This fakes a border on the arrow. diff --git a/webpackShims/gridster.js b/webpackShims/gridster.js deleted file mode 100644 index 381358646f616..0000000000000 --- a/webpackShims/gridster.js +++ /dev/null @@ -1,3 +0,0 @@ -require('jquery'); -require('node_modules/gridster/dist/jquery.gridster.css'); -require('script!node_modules/gridster/dist/jquery.gridster'); From bee007f8046d86a9e51b957ca784dee6a7370214 Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Wed, 13 Sep 2017 20:17:35 -0400 Subject: [PATCH 02/16] # This is a combination of 3 commits. # This is the 1st commit message: Add margin of error to test determining panel widths # This is the commit message #2: use real kibana version when creating panel data. Will make future conversions easier. # This is the commit message #3: Fix lint errors --- .../dashboard/grid/dashboard_grid.test.js | 2 ++ .../public/dashboard/panel/panel_state.js | 2 ++ .../public/dashboard/panel/panel_utils.js | 29 ++----------------- src/ui/public/chrome/api/apps.js | 5 ++++ test/functional/apps/dashboard/_dashboard.js | 6 +++- 5 files changed, 16 insertions(+), 28 deletions(-) diff --git a/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.test.js b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.test.js index 7ad93bd582954..88a3276f1be36 100644 --- a/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.test.js +++ b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.test.js @@ -6,6 +6,8 @@ import { PanelUtils } from '../panel/panel_utils'; import { DashboardGrid } from './dashboard_grid'; import { DashboardPanel } from '../panel/dashboard_panel'; +jest.mock('ui/chrome', () => ({ getKibanaVersion: () => '6.0.0' }), { virtual: true }); + const getContainerApi = () => { return { addFilter: () => {}, diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_state.js b/src/core_plugins/kibana/public/dashboard/panel/panel_state.js index 64f4ddcf20c8d..c5d0238d5a70f 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_state.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_state.js @@ -2,6 +2,7 @@ export const DEFAULT_PANEL_WIDTH = 6; export const DEFAULT_PANEL_HEIGHT = 3; import { DASHBOARD_GRID_COLUMN_COUNT } from '../dashboard_constants'; +import chrome from 'ui/chrome'; /** * Represents a panel on a grid. Keeps track of position in the grid and what visualization it @@ -96,6 +97,7 @@ export function createPanelState(id, type, panelIndex, currentPanels) { y, i: panelIndex.toString() }, + version: chrome.getKibanaVersion(), panelIndex: panelIndex, type: type, id: id diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_utils.js b/src/core_plugins/kibana/public/dashboard/panel/panel_utils.js index d27a5a98e5803..b04607e89cb38 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_utils.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_utils.js @@ -1,4 +1,5 @@ import { DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT } from 'plugins/kibana/dashboard/panel/panel_state'; +import chrome from 'ui/chrome'; import _ from 'lodash'; @@ -31,7 +32,7 @@ export class PanelUtils { w: panel.size_x || DEFAULT_PANEL_WIDTH, h: panel.size_y || DEFAULT_PANEL_HEIGHT, i: panel.panelIndex.toString(), - version: 6, + version: chrome.getKibanaVersion(), }; delete panel.size_x; delete panel.size_y; @@ -39,32 +40,6 @@ export class PanelUtils { delete panel.col; } - /** - * Ensures that the panel object has the latest size/pos info. - * @param {PanelState} panel - * @param {Element} panelElement - jQuery element representing the element in the UI - */ - static refreshSizeAndPosition(panel, panelElement) { - const data = panelElement.coords().grid; - panel.size_x = data.size_x; - panel.size_y = data.size_y; - panel.col = data.col; - panel.row = data.row; - } - - /** - * Ensures that the grid element matches the latest size/pos info in the panel element. - * @param {PanelState} panel - * @param {Element} panelElement - jQuery element representing the element in the UI - */ - static refreshElementSizeAndPosition(panel, panelElement) { - const data = panelElement.coords().grid; - data.size_x = panel.size_x; - data.size_y = panel.size_y; - data.col = panel.col; - data.row = panel.row; - } - /** * Returns the panel with the given panelIndex from the panels array (*NOT* the panel at the given index). * @param panelIndex {number} - Note this is *NOT* the index of the panel in the panels array. diff --git a/src/ui/public/chrome/api/apps.js b/src/ui/public/chrome/api/apps.js index 42c532b2d4e91..96c45fbfd33d6 100644 --- a/src/ui/public/chrome/api/apps.js +++ b/src/ui/public/chrome/api/apps.js @@ -38,6 +38,11 @@ export default function (chrome, internals) { return internals.showAppsLink == null ? internals.nav.length > 1 : internals.showAppsLink; }; + + chrome.getKibanaVersion = function () { + return internals.version; + }; + chrome.getApp = function () { return clone(internals.app); }; diff --git a/test/functional/apps/dashboard/_dashboard.js b/test/functional/apps/dashboard/_dashboard.js index b7b4c51550407..29f7863f534da 100644 --- a/test/functional/apps/dashboard/_dashboard.js +++ b/test/functional/apps/dashboard/_dashboard.js @@ -140,7 +140,11 @@ export default function ({ getService, getPageObjects }) { if (newPanelDimensions.length < 0) { throw new Error('No panel dimensions...'); } - expect(newPanelDimensions[0].width).to.equal(currentPanelDimensions[0].width * 2); + // Some margin of error is allowed, I've noticed it being off by one pixel. Probably something to do with + // an odd width and dividing by two. Note that if we add margins, we'll have to adjust this as well. + const marginOfError = 5; + expect(newPanelDimensions[0].width).to.be.lessThan(currentPanelDimensions[0].width * 2 + marginOfError); + expect(newPanelDimensions[0].width).to.be.greaterThan(currentPanelDimensions[0].width * 2 - marginOfError); }); }); }); From 75075c29aa00d7076eb5459d268166d342fbcded Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Wed, 13 Sep 2017 20:17:35 -0400 Subject: [PATCH 03/16] Add margin of error to test determining panel widths use real kibana version when creating panel data. Will make future conversions easier. Move default height and width to dashboard_constants so those that need it don't end up including extra stuff like ui/chrome --- .../kibana/public/dashboard/dashboard_constants.js | 3 ++- .../kibana/public/dashboard/panel/__tests__/panel_utils.js | 2 +- .../kibana/public/dashboard/panel/panel_state.js | 5 +---- .../kibana/public/dashboard/panel/panel_utils.js | 2 +- test/functional/apps/dashboard/_dashboard.js | 2 +- 5 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_constants.js b/src/core_plugins/kibana/public/dashboard/dashboard_constants.js index 6bdb743804f25..ed4b62367c4c5 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard_constants.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard_constants.js @@ -4,7 +4,8 @@ export const DashboardConstants = { LANDING_PAGE_PATH: '/dashboards', CREATE_NEW_DASHBOARD_URL: '/dashboard', }; - +export const DEFAULT_PANEL_WIDTH = 6; +export const DEFAULT_PANEL_HEIGHT = 3; export const DASHBOARD_GRID_COLUMN_COUNT = 12; export function createDashboardEditUrl(id) { diff --git a/src/core_plugins/kibana/public/dashboard/panel/__tests__/panel_utils.js b/src/core_plugins/kibana/public/dashboard/panel/__tests__/panel_utils.js index f8ebf46eed645..a500716a2cab6 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/__tests__/panel_utils.js +++ b/src/core_plugins/kibana/public/dashboard/panel/__tests__/panel_utils.js @@ -1,5 +1,5 @@ import { PanelUtils } from '../panel_utils'; -import { DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT } from '../panel_state'; +import { DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT } from '../../dashboard_constants'; describe('PanelUtils', function () { it('convertOldPanelData gives supplies width and height when missing', () => { diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_state.js b/src/core_plugins/kibana/public/dashboard/panel/panel_state.js index c5d0238d5a70f..03778a99d2f70 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_state.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_state.js @@ -1,7 +1,4 @@ -export const DEFAULT_PANEL_WIDTH = 6; -export const DEFAULT_PANEL_HEIGHT = 3; - -import { DASHBOARD_GRID_COLUMN_COUNT } from '../dashboard_constants'; +import { DASHBOARD_GRID_COLUMN_COUNT, DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT } from '../dashboard_constants'; import chrome from 'ui/chrome'; /** diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_utils.js b/src/core_plugins/kibana/public/dashboard/panel/panel_utils.js index b04607e89cb38..358ed1b94c3c0 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_utils.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_utils.js @@ -1,4 +1,4 @@ -import { DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT } from 'plugins/kibana/dashboard/panel/panel_state'; +import { DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT } from 'plugins/kibana/dashboard/dashboard_constants'; import chrome from 'ui/chrome'; import _ from 'lodash'; diff --git a/test/functional/apps/dashboard/_dashboard.js b/test/functional/apps/dashboard/_dashboard.js index 29f7863f534da..370bbe540e947 100644 --- a/test/functional/apps/dashboard/_dashboard.js +++ b/test/functional/apps/dashboard/_dashboard.js @@ -2,7 +2,7 @@ import expect from 'expect.js'; import { DEFAULT_PANEL_WIDTH, -} from '../../../../src/core_plugins/kibana/public/dashboard/panel/panel_state'; +} from '../../../../src/core_plugins/kibana/public/dashboard/dashboard_constants'; import { VisualizeConstants From e0321be4d95366c54c4375aae0519ce2aefa7ce5 Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Wed, 20 Sep 2017 12:41:00 -0400 Subject: [PATCH 04/16] Remove unnecessary _.once when creating react directives in dashboard.js --- package.json | 1 - .../kibana/public/dashboard/dashboard.js | 14 ++++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 176ceaa311c98..e682686a9fc00 100644 --- a/package.json +++ b/package.json @@ -178,7 +178,6 @@ "react-router": "2.0.0", "react-router-redux": "4.0.4", "react-select": "1.0.0-rc.5", - "react-sizeme": "2.3.4", "react-sortable": "1.1.0", "react-test-renderer": "15.6.1", diff --git a/src/core_plugins/kibana/public/dashboard/dashboard.js b/src/core_plugins/kibana/public/dashboard/dashboard.js index ea55029f12c2a..fddba4013732b 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard.js @@ -46,15 +46,13 @@ const app = uiModules.get('app/dashboard', [ 'kibana/typeahead', ]); -_.once(() => { - app.directive('dashboardGrid', function (reactDirective) { - return reactDirective(DashboardGrid); - }); +app.directive('dashboardGrid', function (reactDirective) { + return reactDirective(DashboardGrid); +}); - app.directive('dashboardPanel', function (reactDirective) { - return reactDirective(DashboardPanel); - }); -})(); +app.directive('dashboardPanel', function (reactDirective) { + return reactDirective(DashboardPanel); +}); uiRoutes .when(DashboardConstants.CREATE_NEW_DASHBOARD_URL, { From e58ac8d1f070c136ea6fd5cedcb793813f5705e6 Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Wed, 20 Sep 2017 12:46:29 -0400 Subject: [PATCH 05/16] Remove unnecessary constructors --- .../kibana/public/dashboard/grid/dashboard_grid.js | 10 +++------- .../kibana/public/dashboard/panel/panel_header.js | 3 --- .../public/dashboard/panel/panel_options_menu.js | 10 +++------- 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js index f0a24610aac34..1c8c1cdfe4e99 100644 --- a/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js +++ b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js @@ -36,13 +36,9 @@ const ResponsiveSizedGrid = sizeMe(config)(ResponsiveGrid); export class DashboardGrid extends React.Component { - constructor(props) { - super(props); - - this.state = { - layout: this.buildLayoutFromPanels() - }; - } + state = { + layout: this.buildLayoutFromPanels() + }; buildLayoutFromPanels() { return _.map(this.props.panels, panel => { diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_header.js b/src/core_plugins/kibana/public/dashboard/panel/panel_header.js index aff4163126348..0ffe05b75b77a 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_header.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_header.js @@ -6,9 +6,6 @@ import { KuiKeyboardAccessible } from 'ui_framework/components'; import { PanelOptionsMenu } from './panel_options_menu'; export class PanelHeader extends React.Component { - constructor(props) { - super(props); - } getOptionsDropDown() { return ( diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_options_menu.js b/src/core_plugins/kibana/public/dashboard/panel/panel_options_menu.js index 1596e0a90ae99..b0a0a2ca1e477 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_options_menu.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_options_menu.js @@ -8,13 +8,9 @@ import { } from 'ui_framework/components'; export class PanelOptionsMenu extends React.Component { - constructor(props) { - super(props); - - this.state = { - showMenu: false - }; - } + state = { + showMenu: false + }; toggleMenu = () => { this.setState({ showMenu: !this.state.showMenu }); From 1f70da29ac83c73fd75e7e2904d9f147d91287cf Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Wed, 20 Sep 2017 13:05:17 -0400 Subject: [PATCH 06/16] Use componentDidMount instead of componentWillMount bc of async calls, and handle case where destroyEmbeddable is not defined. --- .../public/dashboard/panel/dashboard_panel.js | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js b/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js index 9430047da62d9..f333728bca9c0 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js +++ b/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js @@ -14,9 +14,11 @@ export class DashboardPanel extends React.Component { this.embeddable = null; this.embeddableHandler = null; this.parentNode = null; + this._isMounted = false; } - async componentWillMount() { + async componentDidMount() { + this._isMounted = true; const { getEmbeddableHandler, panel, getContainerApi } = this.props; this.containerApi = getContainerApi(); @@ -24,12 +26,16 @@ export class DashboardPanel extends React.Component { const editUrl = await this.embeddableHandler.getEditPath(panel.id); const title = await this.embeddableHandler.getTitleFor(panel.id); - this.setState({ editUrl, title }); + // TODO: use redux instead of the isMounted anti-pattern to handle the case when the component is unmounted + // before the async calls above return. + if (this._isMounted) { + this.setState({ editUrl, title }); - this.destroyEmbeddable = await this.embeddableHandler.render( - this.panelElement, - panel, - this.containerApi); + this.destroyEmbeddable = await this.embeddableHandler.render( + this.panelElement, + panel, + this.containerApi); + } } isViewOnlyMode() { @@ -61,7 +67,14 @@ export class DashboardPanel extends React.Component { }; componentWillUnmount() { - this.destroyEmbeddable(); + // This is required because it's possible the component becomes unmounted before embeddableHandler.render returns. + // This is really an anti-pattern and could be cleaned up by implementing a redux framework for dashboard state. + // Because implementing that may be a very large change in and of itself, it will be a second step, and we'll live + // with this anti-pattern for the time being. + this._isMounted = false; + if (this.destroyEmbeddable) { + this.destroyEmbeddable(); + } } render() { From 70031508027cf3e175789a9e029237183569de07 Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Wed, 20 Sep 2017 13:29:59 -0400 Subject: [PATCH 07/16] Remove unnecessary null in classNames --- src/core_plugins/kibana/public/dashboard/panel/panel_header.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_header.js b/src/core_plugins/kibana/public/dashboard/panel/panel_header.js index 0ffe05b75b77a..567e44b1bdfce 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_header.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_header.js @@ -20,7 +20,7 @@ export class PanelHeader extends React.Component { getExpandToggle() { const { isExpanded } = this.props; - const classes = classNames('kuiIcon', null, { 'fa-expand': !isExpanded, 'fa-compress': isExpanded }); + const classes = classNames('kuiIcon', { 'fa-expand': !isExpanded, 'fa-compress': isExpanded }); return ( Date: Wed, 20 Sep 2017 13:32:27 -0400 Subject: [PATCH 08/16] Use loads defaultsDeep instead of Object.assign --- .../kibana/public/dashboard/panel/dashboard_panel.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.test.js b/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.test.js index 38a67c4f9fc91..8be3970f005b0 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.test.js +++ b/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.test.js @@ -1,4 +1,5 @@ import React from 'react'; +import _ from 'lodash'; import { mount } from 'enzyme'; import { DashboardViewMode } from '../dashboard_view_mode'; import { DashboardPanel } from './dashboard_panel'; @@ -33,7 +34,7 @@ function getProps(props = {}) { onToggleExpanded: () => {}, onDeletePanel: () => {} }; - return Object.assign(props, defaultTestProps); + return _.defaultsDeep(props, defaultTestProps); } test('DashboardPanel matches snapshot', () => { From 5064127b5a65f647f612cf2219b565057b2f45eb Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Wed, 20 Sep 2017 13:35:33 -0400 Subject: [PATCH 09/16] use render* instead of get* for functions returning an element --- .../kibana/public/dashboard/grid/dashboard_grid.js | 4 ++-- .../kibana/public/dashboard/panel/panel_header.js | 6 +++--- .../public/dashboard/panel/panel_options_menu.js | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js index 1c8c1cdfe4e99..002eb2756eb2d 100644 --- a/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js +++ b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js @@ -69,7 +69,7 @@ export class DashboardGrid extends React.Component { }); }; - generateDOM() { + renderDOM() { const { panels, onPanelRemoved, @@ -117,7 +117,7 @@ export class DashboardGrid extends React.Component { buildLayoutFromPanels={this.buildLayoutFromPanels()} onLayoutChange={this.onLayoutChange} > - {this.generateDOM()} + {this.renderDOM()} ); } diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_header.js b/src/core_plugins/kibana/public/dashboard/panel/panel_header.js index 567e44b1bdfce..5926f5a32078e 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_header.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_header.js @@ -7,7 +7,7 @@ import { PanelOptionsMenu } from './panel_options_menu'; export class PanelHeader extends React.Component { - getOptionsDropDown() { + renderOptionsDropDown() { return (
    - {this.props.isViewOnlyMode ? this.getExpandToggle() : this.getOptionsDropDown()} + {this.props.isViewOnlyMode ? this.renderExpandToggle() : this.renderOptionsDropDown()}
    ); diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_options_menu.js b/src/core_plugins/kibana/public/dashboard/panel/panel_options_menu.js index b0a0a2ca1e477..362c3b690c9ca 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_options_menu.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_options_menu.js @@ -17,7 +17,7 @@ export class PanelOptionsMenu extends React.Component { }; closeMenu = () => this.setState({ showMenu: false }); - getEditVisualizationMenuItem() { + renderEditVisualizationMenuItem() { return ( - {this.getEditVisualizationMenuItem()} - {this.getToggleExpandMenuItem()} - {this.props.isExpanded ? null : this.getDeleteMenuItem()} + {this.renderEditVisualizationMenuItem()} + {this.renderToggleExpandMenuItem()} + {this.props.isExpanded ? null : this.renderDeleteMenuItem()} ); From 0e85f7c74c0a9fe4b2e06e8e1c1a5136a5d32ff3 Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Wed, 20 Sep 2017 13:37:16 -0400 Subject: [PATCH 10/16] use relative css paths --- src/core_plugins/kibana/public/dashboard/styles/index.less | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/core_plugins/kibana/public/dashboard/styles/index.less b/src/core_plugins/kibana/public/dashboard/styles/index.less index 957107771809e..3d8478e4cd677 100644 --- a/src/core_plugins/kibana/public/dashboard/styles/index.less +++ b/src/core_plugins/kibana/public/dashboard/styles/index.less @@ -1,9 +1,8 @@ @import (reference) "~ui/styles/variables"; @import (reference) "~ui/styles/mixins"; @import "~ui/styles/local_search.less"; - -@import "../../../../../../node_modules/react-grid-layout/css/styles.css"; -@import "../../../../../../node_modules/react-resizable/css/styles.css"; +@import "~react-grid-layout/css/styles.css"; +@import "~react-resizable/css/styles.css"; .fullScreenModePlaceholder { text-align: center; From 6d10b5a24ac15ecc6b1c4e0dae31a61d67ee335f Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Wed, 20 Sep 2017 13:37:58 -0400 Subject: [PATCH 11/16] Use local import path --- src/core_plugins/kibana/public/dashboard/dashboard_state.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_state.js b/src/core_plugins/kibana/public/dashboard/dashboard_state.js index 0302b356ebe24..f291a68f4e485 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard_state.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard_state.js @@ -6,7 +6,7 @@ import { PanelUtils } from './panel/panel_utils'; import moment from 'moment'; import { stateMonitorFactory } from 'ui/state_management/state_monitor_factory'; -import { createPanelState, getPersistedStateId } from 'plugins/kibana/dashboard/panel'; +import { createPanelState, getPersistedStateId } from './panel'; function getStateDefaults(dashboard, hideWriteControls) { return { From f61e26877b5f1e6ac6aa566fea4d5ad238edb618 Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Wed, 20 Sep 2017 13:53:22 -0400 Subject: [PATCH 12/16] Switch to local imports and remove need for plugins path in jest tests --- src/core_plugins/kibana/public/dashboard/panel/panel_utils.js | 2 +- src/jest/config.json | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_utils.js b/src/core_plugins/kibana/public/dashboard/panel/panel_utils.js index 358ed1b94c3c0..44b5eb35dcdc8 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_utils.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_utils.js @@ -1,4 +1,4 @@ -import { DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT } from 'plugins/kibana/dashboard/dashboard_constants'; +import { DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT } from '../dashboard_constants'; import chrome from 'ui/chrome'; import _ from 'lodash'; diff --git a/src/jest/config.json b/src/jest/config.json index 5dcf367e9c1df..6b84171706dad 100644 --- a/src/jest/config.json +++ b/src/jest/config.json @@ -16,7 +16,6 @@ "^ui_framework/components": "/ui_framework/components", "^ui_framework/services": "/ui_framework/services", "^ui/(.*)": "/src/ui/public/$1", - "^plugins/kibana/(.*)": "/src/core_plugins/kibana/public/$1", "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/src/jest/file_mock.js", "\\.(css|less|scss)$": "/src/jest/style_mock.js" }, From f7347ddb021fdf113336d118387886f3c37515df Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Wed, 20 Sep 2017 13:58:26 -0400 Subject: [PATCH 13/16] Improve accessibility of max/min panel toggle icon --- .../public/dashboard/panel/panel_header.js | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_header.js b/src/core_plugins/kibana/public/dashboard/panel/panel_header.js index 5926f5a32078e..d6437341fb649 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_header.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_header.js @@ -2,7 +2,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { KuiKeyboardAccessible } from 'ui_framework/components'; import { PanelOptionsMenu } from './panel_options_menu'; export class PanelHeader extends React.Component { @@ -21,20 +20,19 @@ export class PanelHeader extends React.Component { renderExpandToggle() { const { isExpanded } = this.props; const classes = classNames('kuiIcon', { 'fa-expand': !isExpanded, 'fa-compress': isExpanded }); + const ariaLabel = isExpanded ? 'Minimize panel' : 'Maximize panel'; return ( - - - - + ); } From fca9d0d4a2de34b80137bb719a7f8d318a7a2a7e Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Wed, 20 Sep 2017 14:19:01 -0400 Subject: [PATCH 14/16] remove unused css Had to implement this via code --- src/core_plugins/kibana/public/dashboard/styles/index.less | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/core_plugins/kibana/public/dashboard/styles/index.less b/src/core_plugins/kibana/public/dashboard/styles/index.less index 3d8478e4cd677..8d7fb63a54391 100644 --- a/src/core_plugins/kibana/public/dashboard/styles/index.less +++ b/src/core_plugins/kibana/public/dashboard/styles/index.less @@ -279,10 +279,3 @@ dashboard-panel { overflow: auto; /* 1 */ } } - -/** - * Allows popups inside each panel appear in front of other panels, if the boundaries are outside the panel itself. - */ -.react-grid-item:active { - z-index: 1; /* 1. */ -} From 6290a44e8de7785545dced3f4098523acb5c6321 Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Wed, 20 Sep 2017 14:25:11 -0400 Subject: [PATCH 15/16] disable eslint rule for setState in componentDidMount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Am not aware of a better way to handle this, aside from switching to redux, since it’s recommended not to put async calls in componentWillMount. Since I plan to investigate redux next, disabling for now. Open to other’s opinions on the matter. --- .../kibana/public/dashboard/panel/dashboard_panel.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js b/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js index f333728bca9c0..64f6899b71b3e 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js +++ b/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js @@ -27,8 +27,10 @@ export class DashboardPanel extends React.Component { const title = await this.embeddableHandler.getTitleFor(panel.id); // TODO: use redux instead of the isMounted anti-pattern to handle the case when the component is unmounted - // before the async calls above return. + // before the async calls above return. We can then get rid of the eslint disable line. Without redux, there is + // not a better option, since you aren't supposed to run async calls inside of componentWillMount. if (this._isMounted) { + /* eslint-disable react/no-did-mount-set-state */ this.setState({ editUrl, title }); this.destroyEmbeddable = await this.embeddableHandler.render( From f5e28ed54f119d3f34f90b2f42fa9ecb4f0aea6d Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Wed, 20 Sep 2017 21:00:03 -0400 Subject: [PATCH 16/16] Add redux for viewport state management in dashboard. --- package.json | 4 +- .../kibana/public/dashboard/action_types.js | 10 ++ .../public/dashboard/dashboard_actions.js | 111 ++++++++++++++++ .../{dashboard.html => dashboard_app.html} | 24 +--- .../{dashboard.js => dashboard_app.js} | 94 ++++---------- .../dashboard/dashboard_container_api.js | 8 +- .../public/dashboard/dashboard_reducers.js | 106 ++++++++++++++++ ...rd_state.js => dashboard_state_manager.js} | 118 ++++++++++++++---- .../public/dashboard/dashboard_store.js | 19 +++ .../public/dashboard/grid/dashboard_grid.js | 49 +++----- .../grid/dashboard_grid_container.js | 24 ++++ .../kibana/public/dashboard/index.js | 42 ++++++- .../dashboard/lib/convert_time_to_string.js | 10 ++ .../dashboard/{ => lib}/filter_utils.js | 0 .../dashboard/lib/get_app_state_defaults.js | 17 +++ .../kibana/public/dashboard/lib/index.js | 1 + .../public/dashboard/lib/save_dashboard.js | 27 ++++ .../dashboard/lib/update_saved_dashboard.js | 15 +++ .../public/dashboard/panel/dashboard_panel.js | 74 ++++------- .../panel/dashboard_panel_container.js | 40 ++++++ .../kibana/public/dashboard/styles/index.less | 18 +-- .../dashboard/viewport/dashboard_container.js | 64 ++++++++++ .../dashboard/viewport/dashboard_viewport.js | 20 +++ .../embeddable/search_embeddable_handler.js | 14 ++- src/core_plugins/kibana/public/reducers.js | 10 ++ src/core_plugins/kibana/public/store.js | 12 ++ .../visualize_embeddable_handler.js | 24 ++-- src/ui/public/embeddable/embeddable.js | 6 + .../public/embeddable/embeddable_handler.js | 28 ++--- .../embeddable_handlers_registry.js | 1 + src/ui/public/embeddable/index.js | 1 + .../state_management/state_monitor_factory.js | 8 +- .../state_management/update_app_state.js | 18 +++ 33 files changed, 773 insertions(+), 244 deletions(-) create mode 100644 src/core_plugins/kibana/public/dashboard/action_types.js create mode 100644 src/core_plugins/kibana/public/dashboard/dashboard_actions.js rename src/core_plugins/kibana/public/dashboard/{dashboard.html => dashboard_app.html} (81%) rename src/core_plugins/kibana/public/dashboard/{dashboard.js => dashboard_app.js} (85%) create mode 100644 src/core_plugins/kibana/public/dashboard/dashboard_reducers.js rename src/core_plugins/kibana/public/dashboard/{dashboard_state.js => dashboard_state_manager.js} (80%) create mode 100644 src/core_plugins/kibana/public/dashboard/dashboard_store.js create mode 100644 src/core_plugins/kibana/public/dashboard/grid/dashboard_grid_container.js create mode 100644 src/core_plugins/kibana/public/dashboard/lib/convert_time_to_string.js rename src/core_plugins/kibana/public/dashboard/{ => lib}/filter_utils.js (100%) create mode 100644 src/core_plugins/kibana/public/dashboard/lib/get_app_state_defaults.js create mode 100644 src/core_plugins/kibana/public/dashboard/lib/index.js create mode 100644 src/core_plugins/kibana/public/dashboard/lib/save_dashboard.js create mode 100644 src/core_plugins/kibana/public/dashboard/lib/update_saved_dashboard.js create mode 100644 src/core_plugins/kibana/public/dashboard/panel/dashboard_panel_container.js create mode 100644 src/core_plugins/kibana/public/dashboard/viewport/dashboard_container.js create mode 100644 src/core_plugins/kibana/public/dashboard/viewport/dashboard_viewport.js create mode 100644 src/core_plugins/kibana/public/reducers.js create mode 100644 src/core_plugins/kibana/public/store.js create mode 100644 src/ui/public/embeddable/embeddable.js create mode 100644 src/ui/public/state_management/update_app_state.js diff --git a/package.json b/package.json index e682686a9fc00..ec5976c186b7b 100644 --- a/package.json +++ b/package.json @@ -183,8 +183,8 @@ "react-test-renderer": "15.6.1", "react-toggle": "3.0.1", "reactcss": "1.0.7", - "redux": "3.0.0", - "redux-thunk": "0.1.0", + "redux": "3.7.2", + "redux-thunk": "2.2.0", "request": "2.61.0", "resize-observer-polyfill": "1.2.1", "rimraf": "2.4.3", diff --git a/src/core_plugins/kibana/public/dashboard/action_types.js b/src/core_plugins/kibana/public/dashboard/action_types.js new file mode 100644 index 0000000000000..fd889d8661bc2 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/action_types.js @@ -0,0 +1,10 @@ +export const EMBEDDABLE_RENDER_REQUESTED = 'EMBEDDABLE_RENDER_REQUESTED'; +export const EMBEDDABLE_RENDER_FINISHED = 'EMBEDDABLE_RENDER_FINISHED'; +export const UPDATE_PANELS = 'UPDATE_PANELS'; +export const UPDATE_PANEL = 'UPDATE_PANEL'; +export const ADD_NEW_PANEl = 'ADD_NEW_PANEl'; +export const EMBEDDABLE_RENDER_ERROR = 'EMBEDDABLE_RENDER_ERROR'; +export const UPDATE_VIEW_MODE = 'UPDATE_VIEW_MODE'; +export const UPDATE_MAXIMIZED_PANEl_ID = 'UPDATE_MAXIMIZED_PANEl_ID'; +export const DELETE_PANEL = 'DELETE_PANEL'; +export const UPDATE_IS_FULL_SCREEN_MODE = 'UPDATE_IS_FULL_SCREEN_MODE'; diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_actions.js b/src/core_plugins/kibana/public/dashboard/dashboard_actions.js new file mode 100644 index 0000000000000..2e2e742f3054c --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/dashboard_actions.js @@ -0,0 +1,111 @@ +import { + EMBEDDABLE_RENDER_FINISHED, + EMBEDDABLE_RENDER_REQUESTED, + EMBEDDABLE_RENDER_ERROR, + UPDATE_PANELS, + UPDATE_VIEW_MODE, + UPDATE_MAXIMIZED_PANEl_ID, + DELETE_PANEL, + UPDATE_PANEL, + ADD_NEW_PANEl, + UPDATE_IS_FULL_SCREEN_MODE, +} from './action_types'; + +function embeddableRenderRequested(panelId) { + return { + type: EMBEDDABLE_RENDER_REQUESTED, + panelId: panelId + } +} + +function embeddableRenderFinished(panelId, embeddable) { + return { + type: EMBEDDABLE_RENDER_FINISHED, + embeddable, + panelId, + } +} + +function embeddableRenderError(panelId, error) { + return { + type: EMBEDDABLE_RENDER_ERROR, + panelId, + error + } +} + +export function deletePanel(panelId) { + return { + type: DELETE_PANEL, + panelId + } +} + +export function updateViewMode(viewMode) { + return { + type: UPDATE_VIEW_MODE, + viewMode + } +} + +export function maximizePanel(panelId) { + return { + type: UPDATE_MAXIMIZED_PANEl_ID, + maximizedPanelId: panelId + } +} + +export function minimizePanel() { + return { + type: UPDATE_MAXIMIZED_PANEl_ID, + maximizedPanelId: undefined + } +} + +export function updatePanel(panel) { + return { + type: UPDATE_PANEL, + panel + } +} + +export function addNewPanel(panel) { + return { + type: ADD_NEW_PANEl, + panel + } +} + +export function updateIsFullScreenMode(isFullScreenMode) { + return { + type: UPDATE_IS_FULL_SCREEN_MODE, + isFullScreenMode + } +} + +export function renderEmbeddable(embeddableHandler, panelElement, panelId, containerApi) { + return (dispatch, getState) => { + dispatch(embeddableRenderRequested(panelId)); + const { dashboardState } = getState(); + const panelState = dashboardState.panels[panelId]; + return embeddableHandler.render(panelElement, panelState, containerApi) + .then(embeddable => { + return dispatch(embeddableRenderFinished(panelId, embeddable)); + }) + .catch(error => { + dispatch(embeddableRenderError(panelId, error)); + console.log('err: ', error); + }) + } +} + +export function updatePanels(panels) { + const panelsMap = {}; + panels.forEach(panel => { + panelsMap[panel.panelIndex] = panel; + }); + return { + type: UPDATE_PANELS, + panels: panelsMap, + } +} diff --git a/src/core_plugins/kibana/public/dashboard/dashboard.html b/src/core_plugins/kibana/public/dashboard/dashboard_app.html similarity index 81% rename from src/core_plugins/kibana/public/dashboard/dashboard.html rename to src/core_plugins/kibana/public/dashboard/dashboard_app.html index 07573ee07e654..e7b6d8e40a1b2 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard.html +++ b/src/core_plugins/kibana/public/dashboard/dashboard_app.html @@ -77,26 +77,10 @@

    - - - + > + + diff --git a/src/core_plugins/kibana/public/dashboard/dashboard.js b/src/core_plugins/kibana/public/dashboard/dashboard_app.js similarity index 85% rename from src/core_plugins/kibana/public/dashboard/dashboard.js rename to src/core_plugins/kibana/public/dashboard/dashboard_app.js index fddba4013732b..7b2e5e8775411 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard_app.js @@ -1,8 +1,9 @@ import _ from 'lodash'; import angular from 'angular'; import { uiModules } from 'ui/modules'; -import uiRoutes from 'ui/routes'; import chrome from 'ui/chrome'; +import { store } from '../store'; +import { updatePanels } from './dashboard_actions'; import 'ui/query_bar'; @@ -11,13 +12,13 @@ import { getDashboardTitle, getUnsavedChangesWarningMessage } from './dashboard_ import { DashboardViewMode } from './dashboard_view_mode'; import { TopNavIds } from './top_nav/top_nav_ids'; import { ConfirmationButtonTypes } from 'ui/modals/confirm_modal'; -import dashboardTemplate from 'plugins/kibana/dashboard/dashboard.html'; import { FilterBarQueryFilterProvider } from 'ui/filter_bar/query_filter'; 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 { DashboardState } from './dashboard_state'; +import { DashboardStateManager } from './dashboard_state_manager'; +import { saveDashboard } from './lib'; import { notify } from 'ui/notify'; import { documentationLinks } from 'ui/documentation_links/documentation_links'; import { showCloneModal } from './top_nav/show_clone_modal'; @@ -28,13 +29,7 @@ import * as filterActions from 'ui/doc_table/actions/filter'; import { FilterManagerProvider } from 'ui/filter_manager'; import { EmbeddableHandlersRegistryProvider } from 'ui/embeddable/embeddable_handlers_registry'; -import { - DashboardGrid -} from './grid/dashboard_grid'; - -import { - DashboardPanel -} from './panel'; +import { DashboardViewport } from './viewport/dashboard_viewport'; const app = uiModules.get('app/dashboard', [ 'elasticsearch', @@ -46,51 +41,10 @@ const app = uiModules.get('app/dashboard', [ 'kibana/typeahead', ]); -app.directive('dashboardGrid', function (reactDirective) { - return reactDirective(DashboardGrid); -}); - -app.directive('dashboardPanel', function (reactDirective) { - return reactDirective(DashboardPanel); +app.directive('dashboardViewport', function (reactDirective) { + return reactDirective(DashboardViewport); }); -uiRoutes - .when(DashboardConstants.CREATE_NEW_DASHBOARD_URL, { - template: dashboardTemplate, - resolve: { - dash: function (savedDashboards, courier) { - return savedDashboards.get() - .catch(courier.redirectWhenMissing({ - 'dashboard': DashboardConstants.LANDING_PAGE_PATH - })); - } - } - }) - .when(createDashboardEditUrl(':id'), { - template: dashboardTemplate, - resolve: { - dash: function (savedDashboards, Notifier, $route, $location, courier, kbnUrl, AppState) { - const id = $route.current.params.id; - return savedDashboards.get(id) - .catch((error) => { - // Preserve BWC of v5.3.0 links for new, unsaved dashboards. - // See https://github.com/elastic/kibana/issues/10951 for more context. - if (error instanceof SavedObjectNotFound && id === 'create') { - // Note "new AppState" is neccessary so the state in the url is preserved through the redirect. - kbnUrl.redirect(DashboardConstants.CREATE_NEW_DASHBOARD_URL, {}, new AppState()); - notify.error( - 'The url "dashboard/create" is deprecated and will be removed in 6.0. Please update your bookmarks.'); - } else { - throw error; - } - }) - .catch(courier.redirectWhenMissing({ - 'dashboard' : DashboardConstants.LANDING_PAGE_PATH - })); - } - } - }); - app.directive('dashboardApp', function ($injector) { const Notifier = $injector.get('Notifier'); const courier = $injector.get('courier'); @@ -119,13 +73,15 @@ app.directive('dashboardApp', function ($injector) { docTitle.change(dash.title); } - const dashboardState = new DashboardState(dash, AppState, dashboardConfig.getHideWriteControls()); + const dashboardState = new DashboardStateManager(dash, AppState, dashboardConfig.getHideWriteControls()); + + $scope.getDashboardState = () => dashboardState; $scope.appState = dashboardState.getAppState(); $scope.containerApi = new DashboardContainerAPI( dashboardState, (field, value, operator, index) => { filterActions.addFilter(field, value, operator, index, dashboardState.getAppState(), filterManager); - dashboardState.saveState(); + dashboardState.saveAppState(); } ); $scope.getContainerApi = () => $scope.containerApi; @@ -155,7 +111,8 @@ app.directive('dashboardApp', function ($injector) { this.appStatus = { dirty: !dash.id }; - dashboardState.stateMonitor.onChange(status => { + + dashboardState.registerChangeListener(status => { this.appStatus.dirty = status.dirty || !dash.id; updateState(); }); @@ -301,19 +258,20 @@ app.directive('dashboardApp', function ($injector) { }; $scope.save = function () { - return dashboardState.saveDashboard(angular.toJson, timefilter).then(function (id) { - $scope.kbnTopNav.close('save'); - if (id) { - notify.info(`Saved Dashboard as "${dash.title}"`); - if (dash.id !== $routeParams.id) { - kbnUrl.change(createDashboardEditUrl(dash.id)); - } else { - docTitle.change(dash.lastSavedTitle); - updateViewMode(DashboardViewMode.VIEW); + return Promise.resolve(saveDashboard(angular.toJson, timefilter, dashboardState)) + .then(function (id) { + $scope.kbnTopNav.close('save'); + if (id) { + notify.info(`Saved Dashboard as "${dash.title}"`); + if (dash.id !== $routeParams.id) { + kbnUrl.change(createDashboardEditUrl(dash.id)); + } else { + docTitle.change(dash.lastSavedTitle); + updateViewMode(DashboardViewMode.VIEW); + } } - } - return id; - }).catch(notify.error); + return id; + }).catch(notify.error); }; $scope.showFilterBar = () => filterBar.getFilters().length > 0 || !$scope.fullScreenMode; diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_container_api.js b/src/core_plugins/kibana/public/dashboard/dashboard_container_api.js index dafe443037660..3581f12af322a 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard_container_api.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard_container_api.js @@ -1,4 +1,7 @@ +import _ from 'lodash'; import { ContainerAPI } from 'ui/embeddable'; +import { store } from '../store'; +import { updatePanel } from './dashboard_actions'; export class DashboardContainerAPI extends ContainerAPI { constructor(dashboardState, addFilter) { @@ -8,10 +11,7 @@ export class DashboardContainerAPI extends ContainerAPI { } updatePanel(panelIndex, panelAttributes) { - const panelToUpdate = this.dashboardState.getPanels().find((panel) => panel.panelIndex === panelIndex); - Object.assign(panelToUpdate, panelAttributes); - this.dashboardState.saveState(); - return panelToUpdate; + return this.dashboardState.updatePanel(panelIndex, panelAttributes); } getAppState() { diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_reducers.js b/src/core_plugins/kibana/public/dashboard/dashboard_reducers.js new file mode 100644 index 0000000000000..eaaa90d0f94ef --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/dashboard_reducers.js @@ -0,0 +1,106 @@ +import _ from 'lodash'; + +import { + UPDATE_PANELS, + EMBEDDABLE_RENDER_FINISHED, + EMBEDDABLE_RENDER_ERROR, + UPDATE_VIEW_MODE, + UPDATE_MAXIMIZED_PANEl_ID, + DELETE_PANEL, + UPDATE_PANEL, + ADD_NEW_PANEl, + UPDATE_IS_FULL_SCREEN_MODE +} from './action_types'; + +import { getInitialState } from './dashboard_store'; + +export const dashboardReducers = (state = getInitialState(), action) => { + switch (action.type) { + case EMBEDDABLE_RENDER_FINISHED: { + return { + ...state, + embeddables: { + ...state.embeddables, + [action.panelId]: action.embeddable + }, + panels: { + ...state.panels, + [action.panelId]: { + ...state.panels[action.panelId], + renderError: null + } + } + }; + } + case UPDATE_PANELS: { + return { + ...state, + panels: _.cloneDeep(action.panels) + } + } + case UPDATE_PANEL: { + return { + ...state, + panels: { + ...state.panels, + [action.panel.panelIndex]: _.defaultsDeep(state.panels[action.panel.panelIndex], action.panel) + } + } + } + case ADD_NEW_PANEl: { + return { + ...state, + panels: { + ...state.panels, + [action.panel.panelIndex]: action.panel + } + } + } + case UPDATE_VIEW_MODE: { + return { + ...state, + viewMode: action.viewMode + } + } + case UPDATE_IS_FULL_SCREEN_MODE: { + return { + ...state, + isFullScreenMode: action.isFullScreenMode + } + } + case UPDATE_MAXIMIZED_PANEl_ID: { + return { + ...state, + maximizedPanelId: action.maximizedPanelId + } + } + case EMBEDDABLE_RENDER_ERROR: { + return { + ...state, + panels: { + ...state.panels, + [action.panelId]: { + ...state.panels[action.panelId], + renderError: action.error + } + } + } + } + case DELETE_PANEL: { + const stateCopy = { + ...state, + panels: { + ...state.panels + }, + embeddables: { + ...state.embeddables + } + }; + delete stateCopy.panels[action.panelId]; + delete stateCopy.embeddables[action.panelId]; + return stateCopy; + } + default: + return state; + } +}; diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_state.js b/src/core_plugins/kibana/public/dashboard/dashboard_state_manager.js similarity index 80% rename from src/core_plugins/kibana/public/dashboard/dashboard_state.js rename to src/core_plugins/kibana/public/dashboard/dashboard_state_manager.js index f291a68f4e485..2198998692848 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard_state.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard_state_manager.js @@ -1,27 +1,16 @@ import _ from 'lodash'; -import { FilterUtils } from './filter_utils'; +import { FilterUtils } from './lib/filter_utils'; import { DashboardViewMode } from './dashboard_view_mode'; import { PanelUtils } from './panel/panel_utils'; +import { store } from '../store'; +import { updateViewMode, updatePanels, updateIsFullScreenMode } from './dashboard_actions'; + import moment from 'moment'; import { stateMonitorFactory } from 'ui/state_management/state_monitor_factory'; import { createPanelState, getPersistedStateId } from './panel'; - -function getStateDefaults(dashboard, hideWriteControls) { - return { - fullScreenMode: false, - title: dashboard.title, - description: dashboard.description, - timeRestore: dashboard.timeRestore, - panels: dashboard.panelsJSON ? JSON.parse(dashboard.panelsJSON) : [], - options: dashboard.optionsJSON ? JSON.parse(dashboard.optionsJSON) : {}, - uiState: dashboard.uiStateJSON ? JSON.parse(dashboard.uiStateJSON) : {}, - query: FilterUtils.getQueryFilterForDashboard(dashboard), - filters: FilterUtils.getFilterBarsForDashboard(dashboard), - viewMode: dashboard.id || hideWriteControls ? DashboardViewMode.VIEW : DashboardViewMode.EDIT, - }; -} +import { getAppStateDefaults } from './lib/get_app_state_defaults'; /** * Depending on how a dashboard is loaded, the filter object may contain a $$hashKey and $state that will throw @@ -54,18 +43,42 @@ function areTimesEqual(timeA, timeB) { return convertTimeToString(timeA) === convertTimeToString(timeB); } -export class DashboardState { +/** + * Dashboard state manager handles connecting angular and redux state between the angular and react portions of the + * app. There are two "sources of truth" that need to stay in sync - AppState and the Store. They aren't complete + * duplicates of each other as AppState has state that the Store doesn't, and vice versa. + * + * State that is only stored in AppState: + * - title + * - description + * - timeRestore + * - query + * - uiState + * - filters + * + * State that is only stored in the Store: + * - embeddables + * - maximizedPanelId + * + * State that is shared and needs to be synced: + * - fullScreenMode - changes only propagate from AppState -> Store + * - viewMode - changes only propagate from AppState -> Store + * - panels - changes propagate from AppState -> Store and from Store -> AppState. + * + * + */ +export class DashboardStateManager { /** * * @param savedDashboard {SavedDashboard} - * @param AppState {AppState} + * @param AppState {AppState} The AppState class to use when instantiating a new AppState instance. * @param hideWriteControls {boolean} true if write controls should be hidden. */ constructor(savedDashboard, AppState, hideWriteControls) { this.savedDashboard = savedDashboard; this.hideWriteControls = hideWriteControls; - this.stateDefaults = getStateDefaults(this.savedDashboard, this.hideWriteControls); + this.stateDefaults = getAppStateDefaults(this.savedDashboard, this.hideWriteControls); this.appState = new AppState(this.stateDefaults); this.uiState = this.appState.makeStateful('uiState'); @@ -74,7 +87,7 @@ export class DashboardState { // We can't compare the filters stored on this.appState to this.savedDashboard because in order to apply // the filters to the visualizations, we need to save it on the dashboard. We keep track of the original // filter state in order to let the user know if their filters changed and provide this specific information - //in the 'lose changes' warning message. + // in the 'lose changes' warning message. this.lastSavedDashboardFilters = this.getFilterState(); // A mapping of panel index to the index pattern it uses. @@ -82,6 +95,57 @@ export class DashboardState { PanelUtils.initPanelIndexes(this.getPanels()); this.createStateMonitor(); + + store.dispatch(updatePanels(this.getPanels())); + + this.changeListeners = []; + + store.subscribe(() => this._handleStoreChanges()); + this.stateMonitor.onChange(status => { + this.changeListeners.forEach(listener => listener(status)); + this._pushAppStateChangesToStore(); + }); + } + + /** + * Changes made to app state outside of direct calls to this class will need to be propagated to the store. + * @private + */ + _pushAppStateChangesToStore() { + store.dispatch(updatePanels(this.getPanels())); + store.dispatch(updateViewMode(this.getViewMode())); + store.dispatch(updateIsFullScreenMode(this.getFullScreenMode())); + } + + registerChangeListener(callback) { + this.changeListeners.push(callback); + } + + _handleStoreChanges() { + const { dashboardState } = store.getState(); + + // We need to run this comparison check or we can enter an infinite loop. + let differencesFound = false; + for (let i = 0; i < this.appState.panels.length; i++) { + const appStatePanel = this.appState.panels[i]; + if (!_.isEqual(appStatePanel, dashboardState.panels[appStatePanel.panelIndex])) { + differencesFound = true; + break; + } + } + + if (!differencesFound) { + return; + } + + // The only state that the store deals with that appState cares about is the panels array. Every other state change + // (that appState cares about) is initiated from appState (e.g. view mode). + this.appState.panels = []; + _.map(dashboardState.panels, panel => { + this.appState.panels.push(panel); + }); + this.changeListeners.forEach(listener => listener(status)); + this.saveState(); } getFullScreenMode() { @@ -117,7 +181,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.hideWriteControls); + this.stateDefaults = getAppStateDefaults(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; @@ -291,6 +355,13 @@ export class DashboardState { return this.appState.panels; } + updatePanel(panelIndex, panelAttributes) { + const updatedPanel = this.getPanels().find((panel) => panel.panelIndex === panelIndex); + Object.assign(updatedPanel, panelAttributes); + this.saveState(); + return updatedPanel; + } + /** * Creates and initializes a basic panel, adding it to the state. * @param {number} id @@ -298,7 +369,9 @@ export class DashboardState { */ addNewPanel(id, type) { const maxPanelIndex = PanelUtils.getMaxPanelIndex(this.getPanels()); - this.getPanels().push(createPanelState(id, type, maxPanelIndex, this.getPanels())); + const newPanel = createPanelState(id, type, maxPanelIndex, this.getPanels()); + this.getPanels().push(newPanel); + this.saveState(); } removePanel(panelIndex) { @@ -443,6 +516,7 @@ export class DashboardState { */ switchViewMode(newMode) { this.appState.viewMode = newMode; + store.dispatch(updateViewMode(newMode)); this.saveState(); } diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_store.js b/src/core_plugins/kibana/public/dashboard/dashboard_store.js new file mode 100644 index 0000000000000..7ea6a1a74c29d --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/dashboard_store.js @@ -0,0 +1,19 @@ +import { DashboardViewMode } from './dashboard_view_mode'; + +/** + * This is a poor name choice for this state but we have dashboard_state and DashboardState to contend with. + * Redux state trees hold no logic, while DashboardState does, and is tightly somewhat tightly to angular. The + * end goal will be to replace dashboard_state with this component, and then we can improve the naming. + * + * @type {{}} + */ +export function getInitialState() { + return { + panels: {}, // Mapping of panel ids to panel state + // Mapping of panel id to the function that should be used to destroy the rendered embeddable: + embeddables: {}, // Panel id to embeddable object + isFullScreenMode: false, + viewMode: DashboardViewMode.VIEW, + maximizedPanelId: undefined + } +} diff --git a/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js index 002eb2756eb2d..7f6341e657125 100644 --- a/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js +++ b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js @@ -4,7 +4,7 @@ import _ from 'lodash'; import ReactGridLayout from 'react-grid-layout'; import { PanelUtils } from '../panel/panel_utils'; import { DashboardViewMode } from '../dashboard_view_mode'; -import { DashboardPanel } from '../panel/dashboard_panel'; +import { DashboardPanelContainer } from '../panel/dashboard_panel_container'; import { DASHBOARD_GRID_COLUMN_COUNT } from '../dashboard_constants'; import sizeMe from 'react-sizeme'; @@ -50,38 +50,32 @@ export class DashboardGrid extends React.Component { } onLayoutChange = (layout) => { - const { panels, getContainerApi } = this.props; - - const containerApi = getContainerApi(); - + const { onPanelUpdated } = this.props; layout.forEach(panelLayout => { - const panelUpdated = _.find(panels, panel => panel.panelIndex.toString() === panelLayout.i); - panelUpdated.gridData = { - x: panelLayout.x, - y: panelLayout.y, - w: panelLayout.w, - h: panelLayout.h, - i: panelLayout.i, + const updatedPanel = { + panelIndex: panelLayout.i, + gridData: { + x: panelLayout.x, + y: panelLayout.y, + w: panelLayout.w, + h: panelLayout.h, + i: panelLayout.i, + }, version: panelLayout.version }; - panelUpdated.gridData.version = 6.0; - containerApi.updatePanel(panelUpdated.panelIndex, panelUpdated); + onPanelUpdated(updatedPanel); }); }; renderDOM() { const { panels, - onPanelRemoved, - expandPanel, - isFullScreenMode, getEmbeddableHandler, getContainerApi, - dashboardViewMode } = this.props; // Part of our unofficial API - need to render in a consistent order for plugins. - const panelsInOrder = panels.slice(0); + const panelsInOrder = Object.values(panels); panelsInOrder.sort((panelA, panelB) => { if (panelA.gridData.y === panelB.gridData.y) { return panelA.gridData.x - panelB.gridData.x; @@ -93,15 +87,10 @@ export class DashboardGrid extends React.Component { return _.map(panelsInOrder, panel => { return (
    -
    ); @@ -124,12 +113,10 @@ export class DashboardGrid extends React.Component { } DashboardGrid.propTypes = { - isFullScreenMode: PropTypes.bool.isRequired, - panels: PropTypes.array.isRequired, + panels: PropTypes.object.isRequired, getContainerApi: PropTypes.func.isRequired, getEmbeddableHandler: PropTypes.func.isRequired, dashboardViewMode: PropTypes.oneOf([DashboardViewMode.EDIT, DashboardViewMode.VIEW]).isRequired, - expandPanel: PropTypes.func.isRequired, - onPanelRemoved: PropTypes.func.isRequired, + onPanelUpdated: PropTypes.func.isRequired, }; diff --git a/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid_container.js b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid_container.js new file mode 100644 index 0000000000000..db53ee245c795 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid_container.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux' +import { DashboardGrid } from './dashboard_grid'; +import { updatePanel } from '../dashboard_actions'; + +const mapStateToProps = ({ dashboardState }, ownProps) => { + return { + panels: dashboardState.panels, + getContainerApi: ownProps.getContainerApi, + getEmbeddableHandler: ownProps.getEmbeddableHandler, + dashboardViewMode: dashboardState.viewMode, + }; +}; + +const mapDispatchToProps = (dispatch, ownProps) => ({ + onPanelUpdated: updatedPanel => { + dispatch(updatePanel(updatedPanel)); + } +}); + +export const DashboardGridContainer = connect( + mapStateToProps, + mapDispatchToProps +)(DashboardGrid); + diff --git a/src/core_plugins/kibana/public/dashboard/index.js b/src/core_plugins/kibana/public/dashboard/index.js index fbcb1ae500260..3d741eea943cd 100644 --- a/src/core_plugins/kibana/public/dashboard/index.js +++ b/src/core_plugins/kibana/public/dashboard/index.js @@ -1,12 +1,15 @@ -import 'plugins/kibana/dashboard/dashboard'; +import 'plugins/kibana/dashboard/dashboard_app'; import 'plugins/kibana/dashboard/saved_dashboard/saved_dashboards'; import 'plugins/kibana/dashboard/styles/index.less'; import 'plugins/kibana/dashboard/dashboard_config'; import uiRoutes from 'ui/routes'; +import dashboardTemplate from 'plugins/kibana/dashboard/dashboard_app.html'; import dashboardListingTemplate from './listing/dashboard_listing.html'; + import { DashboardListingController } from './listing/dashboard_listing'; -import { DashboardConstants } from './dashboard_constants'; +import { DashboardConstants, createDashboardEditUrl } from './dashboard_constants'; +import { SavedObjectNotFound } from 'ui/errors'; uiRoutes .defaults(/dashboard/, { @@ -16,4 +19,39 @@ uiRoutes template: dashboardListingTemplate, controller: DashboardListingController, controllerAs: 'listingController' + }) + .when(DashboardConstants.CREATE_NEW_DASHBOARD_URL, { + template: dashboardTemplate, + resolve: { + dash: function (savedDashboards, courier) { + return savedDashboards.get() + .catch(courier.redirectWhenMissing({ + 'dashboard': DashboardConstants.LANDING_PAGE_PATH + })); + } + } + }) + .when(createDashboardEditUrl(':id'), { + template: dashboardTemplate, + resolve: { + dash: function (savedDashboards, Notifier, $route, $location, courier, kbnUrl, AppState) { + const id = $route.current.params.id; + return savedDashboards.get(id) + .catch((error) => { + // Preserve BWC of v5.3.0 links for new, unsaved dashboards. + // See https://github.com/elastic/kibana/issues/10951 for more context. + if (error instanceof SavedObjectNotFound && id === 'create') { + // Note "new AppState" is neccessary so the state in the url is preserved through the redirect. + kbnUrl.redirect(DashboardConstants.CREATE_NEW_DASHBOARD_URL, {}, new AppState()); + notify.error( + 'The url "dashboard/create" is deprecated and will be removed in 6.0. Please update your bookmarks.'); + } else { + throw error; + } + }) + .catch(courier.redirectWhenMissing({ + 'dashboard' : DashboardConstants.LANDING_PAGE_PATH + })); + } + } }); diff --git a/src/core_plugins/kibana/public/dashboard/lib/convert_time_to_string.js b/src/core_plugins/kibana/public/dashboard/lib/convert_time_to_string.js new file mode 100644 index 0000000000000..1ef9337f3e826 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/lib/convert_time_to_string.js @@ -0,0 +1,10 @@ +import moment from 'moment'; + +/** + * Converts the time to a string, if it isn't already. + * @param time {string|Moment} + * @returns {string} + */ +function convertTimeToString(time) { + return typeof time === 'string' ? time : moment(time).toString(); +} diff --git a/src/core_plugins/kibana/public/dashboard/filter_utils.js b/src/core_plugins/kibana/public/dashboard/lib/filter_utils.js similarity index 100% rename from src/core_plugins/kibana/public/dashboard/filter_utils.js rename to src/core_plugins/kibana/public/dashboard/lib/filter_utils.js diff --git a/src/core_plugins/kibana/public/dashboard/lib/get_app_state_defaults.js b/src/core_plugins/kibana/public/dashboard/lib/get_app_state_defaults.js new file mode 100644 index 0000000000000..5880cf3503e37 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/lib/get_app_state_defaults.js @@ -0,0 +1,17 @@ +import { DashboardViewMode } from '../dashboard_view_mode'; +import { FilterUtils } from './filter_utils'; + +export function getAppStateDefaults(savedDashboard, hideWriteControls) { + return { + fullScreenMode: false, + title: savedDashboard.title, + description: savedDashboard.description, + timeRestore: savedDashboard.timeRestore, + panels: savedDashboard.panelsJSON ? JSON.parse(savedDashboard.panelsJSON) : [], + options: savedDashboard.optionsJSON ? JSON.parse(savedDashboard.optionsJSON) : {}, + uiState: savedDashboard.uiStateJSON ? JSON.parse(savedDashboard.uiStateJSON) : {}, + query: FilterUtils.getQueryFilterForDashboard(savedDashboard), + filters: FilterUtils.getFilterBarsForDashboard(savedDashboard), + viewMode: savedDashboard.id || hideWriteControls ? DashboardViewMode.VIEW : DashboardViewMode.EDIT, + }; +} diff --git a/src/core_plugins/kibana/public/dashboard/lib/index.js b/src/core_plugins/kibana/public/dashboard/lib/index.js new file mode 100644 index 0000000000000..5cbae8cf151d9 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/lib/index.js @@ -0,0 +1 @@ +export { saveDashboard } from './save_dashboard'; diff --git a/src/core_plugins/kibana/public/dashboard/lib/save_dashboard.js b/src/core_plugins/kibana/public/dashboard/lib/save_dashboard.js new file mode 100644 index 0000000000000..82a1375b69b04 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/lib/save_dashboard.js @@ -0,0 +1,27 @@ +import { DashboardViewMode } from '../dashboard_view_mode'; +import { updateSavedDashboard } from './update_saved_dashboard'; +import { getAppStateDefaults } from './get_app_state_defaults'; + +/** + * Saves the dashboard. + * @param toJson {function} A custom toJson function. Used because the previous code used + * the angularized toJson version, and it was unclear whether there was a reason not to use + * JSON.stringify + * @param timefilter + * @returns {Promise} A promise that if resolved, will contain the id of the newly saved + * dashboard. + */ +export function saveDashboard(toJson, timeFilter, dashboardStateManager) { + dashboardStateManager.saveState(); + + const savedDashboard = dashboardStateManager.savedDashboard; + const appState = dashboardStateManager.appState; + + updateSavedDashboard(savedDashboard, appState, dashboardStateManager.uiState, timeFilter, toJson); + + return savedDashboard.save() + .then((id) => { + dashboardStateManager.resetState(); + return id; + }); +} diff --git a/src/core_plugins/kibana/public/dashboard/lib/update_saved_dashboard.js b/src/core_plugins/kibana/public/dashboard/lib/update_saved_dashboard.js new file mode 100644 index 0000000000000..33a63895df116 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/lib/update_saved_dashboard.js @@ -0,0 +1,15 @@ +import { convertTimeToString } from './convert_time_to_string'; + +export function updateSavedDashboard(savedDashboard, appState, uiState, timeFilter, toJson) { + savedDashboard.title = appState.title; + savedDashboard.description = appState.description; + savedDashboard.timeRestore = appState.timeRestore; + savedDashboard.panelsJSON = toJson(appState.panels); + savedDashboard.uiStateJSON = toJson(uiState.getChanges()); + savedDashboard.optionsJSON = toJson(appState.options); + + savedDashboard.timeFrom = savedDashboard.timeRestore ? convertTimeToString(timeFilter.time.from) : undefined; + savedDashboard.timeTo = savedDashboard.timeRestore ? convertTimeToString(timeFilter.time.to) : undefined; + const timeRestoreObj = _.pick(timeFilter.refreshInterval, ['display', 'pause', 'section', 'value']); + savedDashboard.refreshInterval = savedDashboard.timeRestore ? timeRestoreObj : undefined; +} diff --git a/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js b/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js index 64f6899b71b3e..91231fcf0c977 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js +++ b/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js @@ -3,45 +3,17 @@ import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { DashboardViewMode } from '../dashboard_view_mode'; import { PanelHeader } from './panel_header'; export class DashboardPanel extends React.Component { constructor(props) { super(props); - this.state = {}; - this.embeddable = null; - this.embeddableHandler = null; this.parentNode = null; - this._isMounted = false; } async componentDidMount() { - this._isMounted = true; - const { getEmbeddableHandler, panel, getContainerApi } = this.props; - - this.containerApi = getContainerApi(); - this.embeddableHandler = getEmbeddableHandler(panel.type); - const editUrl = await this.embeddableHandler.getEditPath(panel.id); - const title = await this.embeddableHandler.getTitleFor(panel.id); - - // TODO: use redux instead of the isMounted anti-pattern to handle the case when the component is unmounted - // before the async calls above return. We can then get rid of the eslint disable line. Without redux, there is - // not a better option, since you aren't supposed to run async calls inside of componentWillMount. - if (this._isMounted) { - /* eslint-disable react/no-did-mount-set-state */ - this.setState({ editUrl, title }); - - this.destroyEmbeddable = await this.embeddableHandler.render( - this.panelElement, - panel, - this.containerApi); - } - } - - isViewOnlyMode() { - return this.props.dashboardViewMode === DashboardViewMode.VIEW || this.props.isFullScreenMode; + this.props.renderEmbeddable(this.panelElement); } getParentNode() { @@ -51,11 +23,16 @@ export class DashboardPanel extends React.Component { return this.parentNode; } - toggleExpandedPanel = () => this.props.onToggleExpanded(this.props.panel.panelIndex); - deletePanel = () => { - this.props.onDeletePanel(this.props.panel.panelIndex); + toggleExpandedPanel = () => { + const { isExpanded, onMaximizePanel, onMinimizePanel } = this.props; + if (isExpanded) { + onMinimizePanel(); + } else { + onMaximizePanel(); + } }; - onEditPanel = () => window.location = this.state.editUrl; + deletePanel = () => this.props.onDeletePanel(); + onEditPanel = () => window.location = this.props.editUrl; /** * Setting the zIndex on onFocus and onBlur allows popups, like the panel menu, to appear above other panels in the @@ -69,21 +46,13 @@ export class DashboardPanel extends React.Component { }; componentWillUnmount() { - // This is required because it's possible the component becomes unmounted before embeddableHandler.render returns. - // This is really an anti-pattern and could be cleaned up by implementing a redux framework for dashboard state. - // Because implementing that may be a very large change in and of itself, it will be a second step, and we'll live - // with this anti-pattern for the time being. - this._isMounted = false; - if (this.destroyEmbeddable) { - this.destroyEmbeddable(); - } + this.props.onDestroy(); } render() { - const { title } = this.state; - const { dashboardViewMode, isFullScreenMode, isExpanded } = this.props; + const { viewOnlyMode, isExpanded, title } = this.props; const classes = classNames('panel panel-default', this.props.className, { - 'panel--edit-mode': !this.isViewOnlyMode() + 'panel--edit-mode': !viewOnlyMode }); return (
    { + const embeddable = dashboardState.embeddables[ownProps.panelId]; + const title = embeddable ? embeddable.title : ''; + const editUrl = embeddable ? embeddable.editUrl : ''; + return { + viewOnlyMode: dashboardState.isFullScreenMode || dashboardState.viewMode === DashboardViewMode.VIEW, + title, + editUrl, + isExpanded: dashboardState.maximizedPanelId === ownProps.panelId, + }; +}; + +const mapDispatchToProps = (dispatch, ownProps) => ({ + renderEmbeddable: (panelElement) => { + dispatch(renderEmbeddable(ownProps.embeddableHandler, panelElement, ownProps.panelId, ownProps.getContainerApi())) + }, + onDeletePanel: () => { + dispatch(deletePanel(ownProps.panelId)); + }, + onMaximizePanel: () => { + dispatch(maximizePanel(ownProps.panelId)); + }, + onMinimizePanel: () => { + dispatch(minimizePanel()); + }, + onDestroy: () => { + ownProps.embeddableHandler.destroy(ownProps.panelId); + } +}); + +export const DashboardPanelContainer = connect( + mapStateToProps, + mapDispatchToProps +)(DashboardPanel); + diff --git a/src/core_plugins/kibana/public/dashboard/styles/index.less b/src/core_plugins/kibana/public/dashboard/styles/index.less index 8d7fb63a54391..db80779e71da3 100644 --- a/src/core_plugins/kibana/public/dashboard/styles/index.less +++ b/src/core_plugins/kibana/public/dashboard/styles/index.less @@ -101,17 +101,14 @@ border-radius: 4px; } -/** - * 1. Not entirely sure why but when the panel is within the grid, it requires height 100%. When it's an expanded - * panel, however, outside the grid, height: 100% will cause the panel not to expand properly. - * 2. We need this so the panel menu pop up shows up outside the boundaries of a panel. +/**. + * 1. We need this so the panel menu pop up shows up outside the boundaries of a panel. */ .react-grid-layout { background-color: @dashboard-bg; .dashboard-panel { - height: 100%; /* 1. */ - overflow: visible; /* 2. */ + overflow: visible; /* 1. */ } .gs-w { @@ -140,9 +137,15 @@ flex-direction: column; } -dashboard-panel { +/** + * Needs to correspond with the react root nested inside angular. + */ +dashboard-viewport { flex: 1; display: flex; + [data-reactroot] { + flex: 1; + } } /** @@ -155,6 +158,7 @@ dashboard-panel { flex: 1; display: flex; flex-direction: column; + height: 100%; background: @dashboard-panel-bg; color: @dashboard-panel-color; diff --git a/src/core_plugins/kibana/public/dashboard/viewport/dashboard_container.js b/src/core_plugins/kibana/public/dashboard/viewport/dashboard_container.js new file mode 100644 index 0000000000000..ea1d8078fe010 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/viewport/dashboard_container.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux' +import { DashboardPanelContainer } from '../panel/dashboard_panel_container'; +import { DashboardGridContainer } from '../grid/dashboard_grid_container'; + +function Dashboard({ getContainerApi, maximizedPanelId, maximizedPanelEmbeddableHandler, getEmbeddableHandler }) { + function renderMaximizedPanel() { + return ( + + ); + } + + function renderDashboardGrid() { + return ( + + ); + } + + return ( +
    + {maximizedPanelId === undefined ? renderDashboardGrid() : renderMaximizedPanel()} +
    + ); +} + +Dashboard.propTypes = { + getContainerApi: PropTypes.func, + getEmbeddableHandler: PropTypes.func, + maximizedPanelId: PropTypes.number, + maximizedPanelEmbeddableHandler: PropTypes.object +}; + +const mapStateToProps = ({ dashboardState }, ownProps) => { + if (dashboardState.maximizedPanelId !== undefined) { + const panel = dashboardState.panels[dashboardState.maximizedPanelId]; + const maximizedPanelEmbeddableHandler = ownProps.getEmbeddableHandler(panel.type); + return { + maximizedPanelId: dashboardState.maximizedPanelId, + maximizedPanelEmbeddableHandler + } + } else { + return { + getEmbeddableHandler: ownProps.getEmbeddableHandler, + getContainerApi: ownProps.getContainerApi + } + } +}; + +const mapDispatchToProps = (dispatch, ownProps) => ({ +}); + +export const DashboardContainer = connect( + mapStateToProps, + mapDispatchToProps +)(Dashboard); + diff --git a/src/core_plugins/kibana/public/dashboard/viewport/dashboard_viewport.js b/src/core_plugins/kibana/public/dashboard/viewport/dashboard_viewport.js new file mode 100644 index 0000000000000..c954c5b6b7c69 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/viewport/dashboard_viewport.js @@ -0,0 +1,20 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { store } from '../../store'; +import { Provider } from 'react-redux'; +import { DashboardContainer } from './dashboard_container'; + +export class DashboardViewport extends React.Component { + render() { + return ( + + + + ); + } +} + +DashboardViewport.propTypes = { + getContainerApi: PropTypes.func.isRequired, + getEmbeddableHandler: PropTypes.func.isRequired, +}; 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 index f816b13ec771d..29aef9878d59c 100644 --- a/src/core_plugins/kibana/public/discover/embeddable/search_embeddable_handler.js +++ b/src/core_plugins/kibana/public/discover/embeddable/search_embeddable_handler.js @@ -2,8 +2,7 @@ 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'; - +import { EmbeddableHandler, Embeddable } from 'ui/embeddable'; export class SearchEmbeddableHandler extends EmbeddableHandler { @@ -17,7 +16,7 @@ export class SearchEmbeddableHandler extends EmbeddableHandler { } getEditPath(panelId) { - return this.Promise.resolve(this.searchLoader.urlFor(panelId)); + return this.searchLoader.urlFor(panelId); } getTitleFor(panelId) { @@ -77,11 +76,16 @@ export class SearchEmbeddableHandler extends EmbeddableHandler { const rootNode = angular.element(domNode); rootNode.append(searchInstance); - return () => { + this.addDestroyEmeddable(panel.panelIndex, () => { searchInstance.remove(); searchScope.savedObj.destroy(); searchScope.$destroy(); - }; + }); + + return new Embeddable({ + title: savedObject.title, + editUrl: searchScope.editPath + }); }); } } diff --git a/src/core_plugins/kibana/public/reducers.js b/src/core_plugins/kibana/public/reducers.js new file mode 100644 index 0000000000000..23e3ac32ea710 --- /dev/null +++ b/src/core_plugins/kibana/public/reducers.js @@ -0,0 +1,10 @@ +import { combineReducers } from 'redux' +import { dashboardReducers } from './dashboard/dashboard_reducers'; + +/** + * Only a single reducer now, but eventually there should be one for each sub app that is part of the + * core kibana plugins. + */ +export const reducers = combineReducers({ + dashboardState: dashboardReducers +}); diff --git a/src/core_plugins/kibana/public/store.js b/src/core_plugins/kibana/public/store.js new file mode 100644 index 0000000000000..1364362b5da56 --- /dev/null +++ b/src/core_plugins/kibana/public/store.js @@ -0,0 +1,12 @@ +import { createStore, applyMiddleware } from 'redux'; +import { reducers } from './reducers'; +import thunkMiddleware from 'redux-thunk'; +import { getInitialState } from './dashboard/dashboard_store'; + +export const store = createStore( + reducers, + { + dashboardState: getInitialState() + }, + applyMiddleware(thunkMiddleware) +); 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 index f5a15b215c2be..04740b12f91a1 100644 --- a/src/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_handler.js +++ b/src/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_handler.js @@ -5,6 +5,8 @@ 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 { Embeddable } from 'ui/embeddable'; + import chrome from 'ui/chrome'; export class VisualizeEmbeddableHandler extends EmbeddableHandler { @@ -20,20 +22,13 @@ export class VisualizeEmbeddableHandler extends EmbeddableHandler { } getEditPath(panelId) { - return this.Promise.resolve(this.visualizeLoader.urlFor(panelId)); - } - - getTitleFor(panelId) { - return this.visualizeLoader.get(panelId).then(savedObject => savedObject.title); + return this.visualizeLoader.urlFor(panelId); } 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); - }) + visualizeScope.editUrl = this.getEditPath(panel.id); + return this.visualizeLoader.get(panel.id) .then(savedObject => { visualizeScope.savedObj = savedObject; visualizeScope.panel = panel; @@ -56,11 +51,16 @@ export class VisualizeEmbeddableHandler extends EmbeddableHandler { const rootNode = angular.element(domNode); rootNode.append(visualizationInstance); - return () => { + this.addDestroyEmeddable(panel.panelIndex, () => { visualizationInstance.remove(); visualizeScope.savedObj.destroy(); visualizeScope.$destroy(); - }; + }); + + return new Embeddable({ + title: savedObject.title, + editUrl: visualizeScope.editUrl + }); }); } } diff --git a/src/ui/public/embeddable/embeddable.js b/src/ui/public/embeddable/embeddable.js new file mode 100644 index 0000000000000..c111cb997ed99 --- /dev/null +++ b/src/ui/public/embeddable/embeddable.js @@ -0,0 +1,6 @@ +export class Embeddable { + constructor(config) { + this.title = config.title || ''; + this.editUrl = config.editUrl || ''; + } +} diff --git a/src/ui/public/embeddable/embeddable_handler.js b/src/ui/public/embeddable/embeddable_handler.js index a60fec577d7a8..0e64b909a2c57 100644 --- a/src/ui/public/embeddable/embeddable_handler.js +++ b/src/ui/public/embeddable/embeddable_handler.js @@ -3,21 +3,8 @@ * 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.'); + constructor() { + this.destroyEmbeddableMap = {}; } /** @@ -26,10 +13,19 @@ export class EmbeddableHandler { * 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. - * @return {Promise.} A promise that resolves to a function that should be used to destroy the + * @return {Promise.} A promise that resolves to a function that should be used to destroy the * rendered embeddable. */ render(/* domNode, panel, container */) { throw new Error('Must implement render.'); } + + addDestroyEmeddable(panelIndex, destroyEmbeddable) { + this.destroyEmbeddableMap[panelIndex] = destroyEmbeddable; + } + + destroy(panelIndex) { + this.destroyEmbeddableMap[panelIndex](); + delete this.destroyEmbeddableMap[panelIndex]; + } } diff --git a/src/ui/public/embeddable/embeddable_handlers_registry.js b/src/ui/public/embeddable/embeddable_handlers_registry.js index 408288c842173..592df6c513f61 100644 --- a/src/ui/public/embeddable/embeddable_handlers_registry.js +++ b/src/ui/public/embeddable/embeddable_handlers_registry.js @@ -7,3 +7,4 @@ export const EmbeddableHandlersRegistryProvider = uiRegistry({ name: 'embeddableHandlers', index: ['name'] }); + diff --git a/src/ui/public/embeddable/index.js b/src/ui/public/embeddable/index.js index bdd73ce99a110..654d75a75b8b3 100644 --- a/src/ui/public/embeddable/index.js +++ b/src/ui/public/embeddable/index.js @@ -1,3 +1,4 @@ export { EmbeddableHandler } from './embeddable_handler'; +export { Embeddable } from './embeddable'; export { EmbeddableHandlersRegistryProvider } from './embeddable_handlers_registry'; export { ContainerAPI } from './container_api'; diff --git a/src/ui/public/state_management/state_monitor_factory.js b/src/ui/public/state_management/state_monitor_factory.js index 4b667b330b08d..df8bdcb6125a0 100644 --- a/src/ui/public/state_management/state_monitor_factory.js +++ b/src/ui/public/state_management/state_monitor_factory.js @@ -37,9 +37,11 @@ function stateMonitor(state, customInitialState) { function dispatchChange(type = null, keys = []) { const status = getStatus(); - changeHandlers.forEach(changeHandler => { - changeHandler(status, type, keys); - }); + if (changeHandlers) { + changeHandlers.forEach(changeHandler => { + changeHandler(status, type, keys); + }); + } } function dispatchFetch(keys) { diff --git a/src/ui/public/state_management/update_app_state.js b/src/ui/public/state_management/update_app_state.js new file mode 100644 index 0000000000000..747549e74b342 --- /dev/null +++ b/src/ui/public/state_management/update_app_state.js @@ -0,0 +1,18 @@ +import _ from 'lodash'; + +/** + * Syncs values from pureState to appState, and then saves the appState so that changes are propagated. + * Helps connect the angularized AppState with pure redux stores. + * + * @param appState {AppState} - An instance of AppState. Holds state and manages synchronization between the url, + * angular and other apps. + * @param pureState {Object} - a pure state tree (no functionality). + */ +export function updateAppState(appState, pureState) { + for (const key in pureState) { + if (pureState.hasOwnProperty(key)) { + appState[key] = _.cloneDeep(pureState[key]); + } + } + appState.save(); +}