diff --git a/package.json b/package.json index 38d7d3bd840cc..ec5976c186b7b 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,12 +178,13 @@ "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", "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/__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/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 4586d851d2c33..e7b6d8e40a1b2 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard.html +++ b/src/core_plugins/kibana/public/dashboard/dashboard_app.html @@ -77,25 +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 84% rename from src/core_plugins/kibana/public/dashboard/dashboard.js rename to src/core_plugins/kibana/public/dashboard/dashboard_app.js index 0d52e6cae2165..7b2e5e8775411 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard_app.js @@ -1,11 +1,10 @@ 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 'plugins/kibana/dashboard/grid'; -import 'plugins/kibana/dashboard/panel/panel'; import 'ui/query_bar'; import { SavedObjectNotFound } from 'ui/errors'; @@ -13,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,52 +27,23 @@ 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 { DashboardViewport } from './viewport/dashboard_viewport'; const app = uiModules.get('app/dashboard', [ 'elasticsearch', 'ngRoute', + 'react', 'kibana/courier', 'kibana/config', 'kibana/notify', 'kibana/typeahead', ]); -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('dashboardViewport', function (reactDirective) { + return reactDirective(DashboardViewport); +}); app.directive('dashboardApp', function ($injector) { const Notifier = $injector.get('Notifier'); @@ -95,21 +65,26 @@ 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) { 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; // The 'previouslyStored' check is so we only update the time filter on dashboard open, not during // normal cross app navigation. @@ -136,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(); }); @@ -181,13 +157,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) { @@ -282,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_constants.js b/src/core_plugins/kibana/public/dashboard/dashboard_constants.js index 16542f1edaf94..ed4b62367c4c5 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_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 79% rename from src/core_plugins/kibana/public/dashboard/dashboard_state.js rename to src/core_plugins/kibana/public/dashboard/dashboard_state_manager.js index e8e90a6f71011..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 'plugins/kibana/dashboard/panel/panel_state'; - -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 { createPanelState, getPersistedStateId } from './panel'; +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)); + 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.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 = $('