From b2ffeb71d22f1f2366d890e1cad4020b5c8e8fc5 Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Mon, 2 Oct 2017 11:45:36 -0400 Subject: [PATCH] Replace gridster with react-grid-layout (#13853) (#14242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial check-in to replace gridster with react-grid-layout and reactify panels * # 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 * 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 * Remove unnecessary _.once when creating react directives in dashboard.js * Remove unnecessary constructors * Use componentDidMount instead of componentWillMount bc of async calls, and handle case where destroyEmbeddable is not defined. * Remove unnecessary null in classNames * Use loads defaultsDeep instead of Object.assign * use render* instead of get* for functions returning an element * use relative css paths * Use local import path * Switch to local imports and remove need for plugins path in jest tests * Improve accessibility of max/min panel toggle icon * remove unused css Had to implement this via code * disable eslint rule for setState in componentDidMount 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. * Use native map instead of lodash * Have the grid handle setting the z-indexes of the right reactgriditem * Make the draggable handle the title, not the whole heading Otherwise the drag event often takes over click events when trying to open the panel options menu and it gets really annoying. * Change from click to mouse down detector in KuiOutsideClickDector so drags also close pop ups. * Fix mistaken commit Code from the redux PR snuck into this one. * Run getEditPath and getTitle async calls in parallel - no need to wait on the return value of one before starting the others. * Fix tests: update snapshots, add promise returns. * version being added to panelData in the wrong spot caused isDirty flag to be true when it shouldn't be * Fix unmounting/mounting problem with panels due to view/edit mode switch * Fix bug where panels get squashed to one side when view mode is changed while a panel is expanded. * Update snapshots to match wrong view mode comparison * Improve naming of a variable * Fix issue with pop over hiding behind tile maps * Previous panel.js included ui/doc_table and ui/visualize - needed to include them in the chain for Dash only mode but not in that file. * Fix bad merge: remove baseline screenshots --- package.json | 3 +- .../dashboard/__tests__/dashboard_panels.js | 126 -------- .../public/dashboard/__tests__/panel.js | 79 ----- .../kibana/public/dashboard/dashboard.html | 17 +- .../kibana/public/dashboard/dashboard.js | 35 ++- .../public/dashboard/dashboard_constants.js | 3 + .../public/dashboard/dashboard_state.js | 4 +- .../kibana/public/dashboard/grid.js | 273 ------------------ .../__snapshots__/dashboard_grid.test.js.snap | 89 ++++++ .../public/dashboard/grid/dashboard_grid.js | 156 ++++++++++ .../dashboard/grid/dashboard_grid.test.js | 107 +++++++ .../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 | 127 ++++++++ .../dashboard/panel/dashboard_panel.test.js | 47 +++ .../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 | 64 ++++ .../public/dashboard/panel/panel_menu_item.js | 27 ++ .../dashboard/panel/panel_options_menu.js | 86 ++++++ .../public/dashboard/panel/panel_state.js | 86 +++++- .../public/dashboard/panel/panel_utils.js | 45 ++- .../kibana/public/dashboard/styles/index.less | 103 +++++-- .../embeddable/search_embeddable_handler.js | 8 + .../visualize_embeddable_handler.js | 6 +- src/jest/config.json | 1 + src/ui/public/chrome/api/apps.js | 5 + .../public/embeddable/embeddable_handler.js | 2 + src/ui/public/styles/dark-theme.less | 4 +- test/functional/apps/dashboard/_dashboard.js | 48 ++- test/functional/apps/dashboard/_view_edit.js | 39 +-- .../functional/page_objects/dashboard_page.js | 38 +-- ui_framework/dist/ui_framework.css | 3 +- .../outside_click_detector.js | 8 +- .../src/components/popover/_popover.scss | 3 +- webpackShims/gridster.js | 3 - 38 files changed, 1272 insertions(+), 833 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 30aa3c178ae00f..7bbd96666f9710 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", @@ -172,6 +171,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", @@ -179,6 +179,7 @@ "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-toggle": "3.0.1", "reactcss": "1.0.7", 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 109e9c34e96242..00000000000000 --- 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 35b807ff0cf547..00000000000000 --- 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 4586d851d2c338..07573ee07e6541 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 0d52e6cae21656..fddba4013732b3 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,34 @@ 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', ]); +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 +111,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 +128,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 +200,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 16542f1edaf94b..ed4b62367c4c52 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard_constants.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard_constants.js @@ -4,6 +4,9 @@ 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) { 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 e8e90a6f71011c..f291a68f4e4859 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 './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 54fd3fab1ab79d..00000000000000 --- 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 00000000000000..1d3bcbd00cc13d --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/grid/__snapshots__/dashboard_grid.test.js.snap @@ -0,0 +1,89 @@ +// 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 00000000000000..c84589f381383f --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js @@ -0,0 +1,156 @@ +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 }; +let lastValidGridSize = 0; + +function ResponsiveGrid({ size, isViewMode, layout, onLayoutChange, children }) { + // This is to prevent a bug where view mode changes when the panel is expanded. View mode changes will trigger + // the grid to re-render, but when a panel is expanded, the size will be 0. Minimizing the panel won't cause the + // grid to re-render so it'll show a grid with a width of 0. + lastValidGridSize = size.width > 0 ? size.width : lastValidGridSize; + // We can't take advantage of isDraggable or isResizable due to performance concerns: + // https://github.com/STRML/react-grid-layout/issues/240 + 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); + // A mapping of panelIndexes to grid items so we can set the zIndex appropriately on the last focused + // item. + this.gridItems = {}; + this.state = { + layout: this.buildLayoutFromPanels() + }; + } + + buildLayoutFromPanels() { + return this.props.panels.map(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, + }; + containerApi.updatePanel(panelUpdated.panelIndex, panelUpdated); + }); + }; + + onPanelFocused = panelIndex => { + this.gridItems[panelIndex].style.zIndex = '1'; + }; + onPanelBlurred = panelIndex => { + this.gridItems[panelIndex].style.zIndex = 'auto'; + }; + + 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); + 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 panelsInOrder.map(panel => { + return ( +
    { this.gridItems[panel.panelIndex] = reactGridItem; }} + > + +
    + ); + }); + } + + render() { + const { dashboardViewMode } = this.props; + const isViewMode = dashboardViewMode === DashboardViewMode.VIEW; + return ( + + {this.renderDOM()} + + ); + } +} + +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 00000000000000..88a3276f1be369 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.test.js @@ -0,0 +1,107 @@ +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'; + +jest.mock('ui/chrome', () => ({ getKibanaVersion: () => '6.0.0' }), { virtual: true }); + +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 00000000000000..b2ab4da0548d32 --- /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 00000000000000..29665ddd0550ca --- /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 00000000000000..a500716a2cab68 --- /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 '../../dashboard_constants'; + +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 00000000000000..76c6b7a494cd3f --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js @@ -0,0 +1,127 @@ +import React from 'react'; +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._isMounted = false; + } + + async componentDidMount() { + this._isMounted = true; + const { getEmbeddableHandler, panel, getContainerApi } = this.props; + + this.containerApi = getContainerApi(); + this.embeddableHandler = getEmbeddableHandler(panel.type); + + // 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. + + /* eslint-disable react/no-did-mount-set-state */ + this.embeddableHandler.getEditPath(panel.id).then(editUrl => { + if (this._isMounted) { this.setState({ editUrl }); } + }); + + /* eslint-disable react/no-did-mount-set-state */ + this.embeddableHandler.getTitleFor(panel.id).then(title => { + if (this._isMounted) { this.setState({ title }); } + }); + + if (this._isMounted) { + this.destroyEmbeddable = await this.embeddableHandler.render( + this.panelElement, + panel, + this.containerApi); + } + } + + isViewOnlyMode() { + return this.props.dashboardViewMode === DashboardViewMode.VIEW || this.props.isFullScreenMode; + } + + toggleExpandedPanel = () => this.props.onToggleExpanded(this.props.panel.panelIndex); + deletePanel = () => { + this.props.onDeletePanel(this.props.panel.panelIndex); + }; + onEditPanel = () => window.location = this.state.editUrl; + + onFocus = () => { + const { onPanelFocused } = this.props; + if (onPanelFocused) { + onPanelFocused(this.props.panel.panelIndex); + } + }; + onBlur = () => { + const { onPanelBlurred } = this.props; + if (onPanelBlurred) { + onPanelBlurred(this.props.panel.panelIndex); + } + }; + + 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(); + } + } + + 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, + onPanelFocused: PropTypes.func, + onPanelBlurred: 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 00000000000000..3cc6b99b3a0e61 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.test.js @@ -0,0 +1,47 @@ +import React from 'react'; +import _ from 'lodash'; +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: () => Promise.resolve('editPath'), + getTitleFor: () => Promise.resolve('title'), + 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 _.defaultsDeep(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 01563f8b575278..a83e0b5bae4501 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 d20d0fdde1bee8..00000000000000 --- 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 2c2a53e1b643a5..00000000000000 --- 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 00000000000000..d6437341fb649c --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_header.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { PanelOptionsMenu } from './panel_options_menu'; + +export class PanelHeader extends React.Component { + + renderOptionsDropDown() { + return ( + + ); + } + + renderExpandToggle() { + const { isExpanded } = this.props; + const classes = classNames('kuiIcon', { 'fa-expand': !isExpanded, 'fa-compress': isExpanded }); + const ariaLabel = isExpanded ? 'Minimize panel' : 'Maximize panel'; + return ( + + ); + } + + render() { + return ( +
    + + {this.props.title} + + +
    + {this.props.isViewOnlyMode ? this.renderExpandToggle() : this.renderOptionsDropDown()} +
    +
    + ); + } +} + +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 00000000000000..df80d5f8cd6eef --- /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 00000000000000..362c3b690c9ca8 --- /dev/null +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_options_menu.js @@ -0,0 +1,86 @@ +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 { + state = { + showMenu: false + }; + + toggleMenu = () => { + this.setState({ showMenu: !this.state.showMenu }); + }; + closeMenu = () => this.setState({ showMenu: false }); + + renderEditVisualizationMenuItem() { + return ( + + ); + } + + renderDeleteMenuItem() { + return ( + + ); + } + + renderToggleExpandMenuItem() { + return ( + + ); + } + + render() { + return ( + + + + )} + isOpen={this.state.showMenu} + anchorPosition="right" + closePopover={this.closeMenu} + > + + {this.renderEditVisualizationMenuItem()} + {this.renderToggleExpandMenuItem()} + {this.props.isExpanded ? null : this.renderDeleteMenuItem()} + + + ); + } +} + +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 25ece385e3d50f..03778a99d2f70e 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_state.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_state.js @@ -1,5 +1,5 @@ -export const DEFAULT_PANEL_WIDTH = 6; -export const DEFAULT_PANEL_HEIGHT = 3; +import { DASHBOARD_GRID_COLUMN_COUNT, DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT } 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 @@ -7,30 +7,94 @@ export const DEFAULT_PANEL_HEIGHT = 3; * * @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() + }, + 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 b2783c8c17d811..44b5eb35dcdc87 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 { DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT } from '../dashboard_constants'; +import chrome from 'ui/chrome'; import _ from 'lodash'; @@ -8,8 +9,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,30 +25,19 @@ export class PanelUtils { } } - /** - * 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; + 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: chrome.getKibanaVersion(), + }; + delete panel.size_x; + delete panel.size_y; + delete panel.row; + delete panel.col; } /** diff --git a/src/core_plugins/kibana/public/dashboard/styles/index.less b/src/core_plugins/kibana/public/dashboard/styles/index.less index 39d67124f6a798..129e4e5079dcc9 100644 --- a/src/core_plugins/kibana/public/dashboard/styles/index.less +++ b/src/core_plugins/kibana/public/dashboard/styles/index.less @@ -1,6 +1,8 @@ @import (reference) "~ui/styles/variables"; @import (reference) "~ui/styles/mixins"; @import "~ui/styles/local_search.less"; +@import "~react-grid-layout/css/styles.css"; +@import "~react-resizable/css/styles.css"; .fullScreenModePlaceholder { text-align: center; @@ -10,6 +12,25 @@ position: absolute; } +.layout-edit { + /** + * 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 */ + } +} + +.layout-view { + /** + * 1. Due to https://github.com/STRML/react-grid-layout/issues/240 we have to manually hide the resizable + * element. + */ + .react-resizable-handle { + display: none; /* 1. */ + } +} + .exitFullScreenMode { height: 40px; left: 0px; @@ -52,7 +73,6 @@ */ .exitFullScreenModeText { - display: block; background: @globalColorBlue; color: tint(@globalColorBlue, 70%); line-height: 40px; @@ -77,7 +97,7 @@ width: 450px; } -dashboard-grid { +.dashboard-grid { display: block; margin: 0; padding: 5px; @@ -93,24 +113,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 +140,10 @@ dashboard-grid { .visualize-show-spy { visibility: visible; } + + .panel-heading { + cursor: pointer; + } } .dashboard-container { @@ -135,17 +152,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 +187,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 +196,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; @@ -220,10 +276,15 @@ dashboard-panel { } } + /** + * 1. For tile maps, this is necessary to get the panel pop over to show up. Otherwise the tilemap ends up having + * a bigger z-index than the pop over. + */ .panel-content { display: flex; flex: 1 1 100%; height: auto; + 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 1c12be3e8cab40..2e34375d2ece55 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 @@ -1,5 +1,7 @@ import searchTemplate from './search_template.html'; import angular from 'angular'; +import 'ui/doc_table'; + import * as columnActions from 'ui/doc_table/actions/columns'; import { getPersistedStateId } from 'plugins/kibana/dashboard/panel/panel_state'; import { EmbeddableHandler } from 'ui/embeddable'; @@ -76,6 +78,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 23372e65d15a35..f398f6051e12fe 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 @@ -1,4 +1,5 @@ import angular from 'angular'; +import 'ui/visualize'; import visualizationTemplate from './visualize_template.html'; import { getPersistedStateId } from 'plugins/kibana/dashboard/panel/panel_state'; @@ -56,10 +57,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 57360bdfd4bcca..6b84171706dada 100644 --- a/src/jest/config.json +++ b/src/jest/config.json @@ -15,6 +15,7 @@ "moduleNameMapper": { "^ui_framework/components": "/ui_framework/components", "^ui_framework/services": "/ui_framework/services", + "^ui/(.*)": "/src/ui/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/chrome/api/apps.js b/src/ui/public/chrome/api/apps.js index 42c532b2d4e91b..96c45fbfd33d65 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/src/ui/public/embeddable/embeddable_handler.js b/src/ui/public/embeddable/embeddable_handler.js index aee16ca8e6049e..a60fec577d7a8c 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 dc281d164cbcbe..4d5d7ad5c80739 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 c187e1043a015c..03c6f37d0cda46 100644 --- a/test/functional/apps/dashboard/_dashboard.js +++ b/test/functional/apps/dashboard/_dashboard.js @@ -2,8 +2,7 @@ import expect from 'expect.js'; import { DEFAULT_PANEL_WIDTH, - DEFAULT_PANEL_HEIGHT, -} from '../../../../src/core_plugins/kibana/public/dashboard/panel/panel_state'; +} from '../../../../src/core_plugins/kibana/public/dashboard/dashboard_constants'; import { VisualizeConstants @@ -70,28 +69,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); - expect(panelTitles).to.eql(visObjects); - }); - }); - }); - describe('filters', async function () { it('are not selected by default', async function () { const filters = await PageObjects.dashboard.getFilters(1000); @@ -149,10 +126,20 @@ 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...'); + } + // 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); + }); }); }); @@ -296,10 +283,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)); - 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 9d476691be90ec..801096c71e59db 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 edd7bc102873c1..a2cb020560beda 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 467aa72c4595f4..b121a572dfe595 100644 --- a/ui_framework/dist/ui_framework.css +++ b/ui_framework/dist/ui_framework.css @@ -2831,6 +2831,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); } @@ -2853,7 +2854,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/outside_click_detector/outside_click_detector.js b/ui_framework/src/components/outside_click_detector/outside_click_detector.js index cf5e15836a4235..3be42e13a43afc 100644 --- a/ui_framework/src/components/outside_click_detector/outside_click_detector.js +++ b/ui_framework/src/components/outside_click_detector/outside_click_detector.js @@ -9,7 +9,7 @@ export class KuiOutsideClickDetector extends Component { static propTypes = { children: PropTypes.node.isRequired, onOutsideClick: PropTypes.func.isRequired, - } + }; onClickOutside = event => { if (!this.wrapperRef) { @@ -25,14 +25,14 @@ export class KuiOutsideClickDetector extends Component { } this.props.onOutsideClick(); - } + }; componentDidMount() { - document.addEventListener('click', this.onClickOutside); + document.addEventListener('mousedown', this.onClickOutside); } componentWillUnmount() { - document.removeEventListener('click', this.onClickOutside); + document.removeEventListener('mousedown', this.onClickOutside); } render() { diff --git a/ui_framework/src/components/popover/_popover.scss b/ui_framework/src/components/popover/_popover.scss index 0b0cc0e98aa198..a330bc44fbe236 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 381358646f6167..00000000000000 --- 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');