mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
* Initial check in of introducing redux in dashboard * Use redux-actions and redux-thunks to reduce boilerplate * Make sure all panels are minimized from the start when a dashboard is loaded - we don't want a panel id from a different dashboard in the state tree on a fresh open. * Remove unused file * use classnames dependency instead of manual logic * First pass on selectors, handleActions, and more segmented reducers. * Fix bugs with selectors and reducers and add tests that would have caught them. * Fix issue plus tests discover was not returning a promise * Make expanding a panel purely a css modification which avoids all re-renders * Found another bug with initial state not being set correctly on a hard refresh * Remove check for change handlers now that the event handler bug is fixed * rename dashboardState => dashboard for reducers and redux state tree * Remove unnecessary top level describe in jest tests * Navigate back to landing page at the end of the newly added test suite * Fix lint errors * Stabilize flaky tests by waiting until saved object search is finished loading results * Don't leak subscriptions to the store. * use selectors to grab dashboard panel off state. * Remove use of getState in dispatcher to avoid circular reference and still use selectors * use spread over object.assign * No need to pass second param in when the input is simply returned as-is.
This commit is contained in:
parent
2765fea3e3
commit
e5e3dbb94d
56 changed files with 1414 additions and 676 deletions
|
@ -184,8 +184,9 @@
|
|||
"react-sortable": "1.1.0",
|
||||
"react-toggle": "3.0.1",
|
||||
"reactcss": "1.0.7",
|
||||
"redux": "3.0.0",
|
||||
"redux-thunk": "0.1.0",
|
||||
"redux": "3.7.2",
|
||||
"redux-actions": "2.2.1",
|
||||
"redux-thunk": "2.2.0",
|
||||
"request": "2.61.0",
|
||||
"resize-observer-polyfill": "1.2.1",
|
||||
"rimraf": "2.4.3",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import ngMock from 'ng_mock';
|
||||
import expect from 'expect.js';
|
||||
|
||||
import { DashboardState } from '../dashboard_state';
|
||||
import { DashboardStateManager } from '../dashboard_state_manager';
|
||||
|
||||
describe('DashboardState', function () {
|
||||
let AppState;
|
||||
|
@ -14,7 +14,7 @@ describe('DashboardState', function () {
|
|||
const mockIndexPattern = { id: 'index1' };
|
||||
|
||||
function initDashboardState() {
|
||||
dashboardState = new DashboardState(savedDashboard, AppState, dashboardConfig);
|
||||
dashboardState = new DashboardStateManager(savedDashboard, AppState, dashboardConfig);
|
||||
}
|
||||
|
||||
beforeEach(ngMock.module('kibana'));
|
||||
|
@ -82,20 +82,20 @@ describe('DashboardState', function () {
|
|||
|
||||
describe('panelIndexPatternMapping', function () {
|
||||
it('registers index pattern', function () {
|
||||
const state = new DashboardState(savedDashboard, AppState, dashboardConfig);
|
||||
const state = new DashboardStateManager(savedDashboard, AppState, dashboardConfig);
|
||||
state.registerPanelIndexPatternMap('panel1', mockIndexPattern);
|
||||
expect(state.getPanelIndexPatterns().length).to.equal(1);
|
||||
});
|
||||
|
||||
it('registers unique index patterns', function () {
|
||||
const state = new DashboardState(savedDashboard, AppState, dashboardConfig);
|
||||
const state = new DashboardStateManager(savedDashboard, AppState, dashboardConfig);
|
||||
state.registerPanelIndexPatternMap('panel1', mockIndexPattern);
|
||||
state.registerPanelIndexPatternMap('panel2', mockIndexPattern);
|
||||
expect(state.getPanelIndexPatterns().length).to.equal(1);
|
||||
});
|
||||
|
||||
it('does not register undefined index pattern for panels with no index pattern', function () {
|
||||
const state = new DashboardState(savedDashboard, AppState, dashboardConfig);
|
||||
const state = new DashboardStateManager(savedDashboard, AppState, dashboardConfig);
|
||||
state.registerPanelIndexPatternMap('markdownPanel1', undefined);
|
||||
expect(state.getPanelIndexPatterns().length).to.equal(0);
|
||||
});
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
export function getContainerApiMock(config = {}) {
|
||||
const containerApiMockDefaults = {
|
||||
addFilter: () => {},
|
||||
getAppState: () => {},
|
||||
createChildUistate: () => {},
|
||||
registerPanelIndexPattern: () => {},
|
||||
updatePanel: () => {}
|
||||
};
|
||||
return Object.assign(containerApiMockDefaults, config);
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
export function getEmbeddableHandlerMock(config) {
|
||||
const embeddableHandlerMockDefaults = {
|
||||
getEditPath: () => {},
|
||||
getTitleFor: () => {},
|
||||
render: jest.fn()
|
||||
};
|
||||
return Object.assign(embeddableHandlerMockDefaults, config);
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { createAction } from 'redux-actions';
|
||||
|
||||
export const destroyEmbeddable =
|
||||
createAction('DESTROY_EMBEDDABLE', (panelId, embeddableHandler) => {
|
||||
if (embeddableHandler) {
|
||||
embeddableHandler.destroy(panelId);
|
||||
}
|
||||
return panelId;
|
||||
});
|
||||
|
||||
export const embeddableRenderFinished =
|
||||
createAction('EMBEDDABLE_RENDER_FINISHED', (panelId, embeddable) => ({ embeddable, panelId }));
|
||||
|
||||
export const embeddableRenderError =
|
||||
createAction('EMBEDDABLE_RENDER_ERROR', (panelId, error) => ({ panelId, error }));
|
||||
|
||||
/**
|
||||
*
|
||||
* @param embeddableHandler {EmbeddableHandler}
|
||||
* @param panelElement {Node}
|
||||
* @param panel {PanelState}
|
||||
* @param containerApi {ContainerAPI}
|
||||
* @return {function(*, *)}
|
||||
*/
|
||||
export function renderEmbeddable(embeddableHandler, panelElement, panel, containerApi) {
|
||||
return (dispatch) => {
|
||||
if (!embeddableHandler) {
|
||||
dispatch(embeddableRenderError(panel.panelIndex, new Error(`Invalid embeddable type "${panel.type}"`)));
|
||||
return;
|
||||
}
|
||||
|
||||
return embeddableHandler.render(panelElement, panel, containerApi)
|
||||
.then(embeddable => {
|
||||
return dispatch(embeddableRenderFinished(panel.panelIndex, embeddable));
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(embeddableRenderError(panel.panelIndex, error));
|
||||
});
|
||||
};
|
||||
}
|
19
src/core_plugins/kibana/public/dashboard/actions/index.js
Normal file
19
src/core_plugins/kibana/public/dashboard/actions/index.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
export {
|
||||
updateViewMode,
|
||||
updateIsFullScreenMode,
|
||||
minimizePanel,
|
||||
maximizePanel
|
||||
} from './view';
|
||||
|
||||
export {
|
||||
updatePanel,
|
||||
updatePanels,
|
||||
deletePanel,
|
||||
} from './panels';
|
||||
|
||||
export {
|
||||
renderEmbeddable,
|
||||
embeddableRenderFinished,
|
||||
embeddableRenderError,
|
||||
destroyEmbeddable,
|
||||
} from './embeddables';
|
17
src/core_plugins/kibana/public/dashboard/actions/panels.js
Normal file
17
src/core_plugins/kibana/public/dashboard/actions/panels.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { createAction } from 'redux-actions';
|
||||
|
||||
export const deletePanel = createAction('DELETE_PANEL');
|
||||
|
||||
export const updatePanel = createAction('UPDATE_PANEL');
|
||||
|
||||
/**
|
||||
* @param panels {Array<PanelState>}
|
||||
* @return {Object}
|
||||
*/
|
||||
export const updatePanels = createAction('UPDATE_PANELS', panels => {
|
||||
const panelsMap = {};
|
||||
panels.forEach(panel => {
|
||||
panelsMap[panel.panelIndex] = panel;
|
||||
});
|
||||
return panelsMap;
|
||||
});
|
6
src/core_plugins/kibana/public/dashboard/actions/view.js
Normal file
6
src/core_plugins/kibana/public/dashboard/actions/view.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { createAction } from 'redux-actions';
|
||||
|
||||
export const updateViewMode = createAction('UPDATE_VIEW_MODE');
|
||||
export const maximizePanel = createAction('MAXIMIZE_PANEl');
|
||||
export const minimizePanel = createAction('MINIMIZE_PANEL');
|
||||
export const updateIsFullScreenMode = createAction('UPDATE_IS_FULL_SCREEN_MODE');
|
|
@ -77,26 +77,10 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<dashboard-grid
|
||||
ng-show="!hasExpandedPanel()"
|
||||
panels="panels"
|
||||
is-full-screen-mode="!chrome.getVisible()"
|
||||
dashboard-view-mode="dashboardViewMode"
|
||||
get-embeddable-handler="getEmbeddableHandler"
|
||||
<dashboard-viewport-provider
|
||||
get-container-api="getContainerApi"
|
||||
expand-panel="expandPanel"
|
||||
data-shared-items-count="{{panels.length}}"
|
||||
on-panel-removed="onPanelRemoved"
|
||||
></dashboard-grid>
|
||||
get-embeddable-handler="getEmbeddableHandler"
|
||||
>
|
||||
</dashboard-viewport-provider>
|
||||
|
||||
<dashboard-panel
|
||||
ng-if="hasExpandedPanel()"
|
||||
panel="expandedPanel"
|
||||
is-full-screen-mode="!chrome.getVisible()"
|
||||
is-expanded="true"
|
||||
dashboard-view-mode="dashboardViewMode"
|
||||
get-embeddable-handler="getEmbeddableHandler"
|
||||
get-container-api="getContainerApi"
|
||||
on-toggle-expanded="minimizeExpandedPanel"
|
||||
></dashboard-panel>
|
||||
</dashboard-app>
|
|
@ -1,24 +1,21 @@
|
|||
import _ from 'lodash';
|
||||
import angular from 'angular';
|
||||
import { uiModules } from 'ui/modules';
|
||||
import uiRoutes from 'ui/routes';
|
||||
import chrome from 'ui/chrome';
|
||||
|
||||
import 'ui/query_bar';
|
||||
|
||||
import { SavedObjectNotFound } from 'ui/errors';
|
||||
import { getDashboardTitle, getUnsavedChangesWarningMessage } from './dashboard_strings';
|
||||
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 { notify } from 'ui/notify';
|
||||
import { DashboardStateManager } from './dashboard_state_manager';
|
||||
import { saveDashboard } from './lib';
|
||||
import { documentationLinks } from 'ui/documentation_links/documentation_links';
|
||||
import { showCloneModal } from './top_nav/show_clone_modal';
|
||||
import { migrateLegacyQuery } from 'ui/utils/migrateLegacyQuery';
|
||||
|
@ -28,13 +25,7 @@ import * as filterActions from 'ui/doc_table/actions/filter';
|
|||
import { FilterManagerProvider } from 'ui/filter_manager';
|
||||
import { EmbeddableHandlersRegistryProvider } from 'ui/embeddable/embeddable_handlers_registry';
|
||||
|
||||
import {
|
||||
DashboardGrid
|
||||
} from './grid/dashboard_grid';
|
||||
|
||||
import {
|
||||
DashboardPanel
|
||||
} from './panel';
|
||||
import { DashboardViewportProvider } from './viewport/dashboard_viewport_provider';
|
||||
|
||||
const app = uiModules.get('app/dashboard', [
|
||||
'elasticsearch',
|
||||
|
@ -46,51 +37,10 @@ const app = uiModules.get('app/dashboard', [
|
|||
'kibana/typeahead',
|
||||
]);
|
||||
|
||||
app.directive('dashboardGrid', function (reactDirective) {
|
||||
return reactDirective(DashboardGrid);
|
||||
app.directive('dashboardViewportProvider', function (reactDirective) {
|
||||
return reactDirective(DashboardViewportProvider);
|
||||
});
|
||||
|
||||
app.directive('dashboardPanel', function (reactDirective) {
|
||||
return reactDirective(DashboardPanel);
|
||||
});
|
||||
|
||||
uiRoutes
|
||||
.when(DashboardConstants.CREATE_NEW_DASHBOARD_URL, {
|
||||
template: dashboardTemplate,
|
||||
resolve: {
|
||||
dash: function (savedDashboards, courier) {
|
||||
return savedDashboards.get()
|
||||
.catch(courier.redirectWhenMissing({
|
||||
'dashboard': DashboardConstants.LANDING_PAGE_PATH
|
||||
}));
|
||||
}
|
||||
}
|
||||
})
|
||||
.when(createDashboardEditUrl(':id'), {
|
||||
template: dashboardTemplate,
|
||||
resolve: {
|
||||
dash: function (savedDashboards, Notifier, $route, $location, courier, kbnUrl, AppState) {
|
||||
const id = $route.current.params.id;
|
||||
return savedDashboards.get(id)
|
||||
.catch((error) => {
|
||||
// Preserve BWC of v5.3.0 links for new, unsaved dashboards.
|
||||
// See https://github.com/elastic/kibana/issues/10951 for more context.
|
||||
if (error instanceof SavedObjectNotFound && id === 'create') {
|
||||
// Note "new AppState" is neccessary so the state in the url is preserved through the redirect.
|
||||
kbnUrl.redirect(DashboardConstants.CREATE_NEW_DASHBOARD_URL, {}, new AppState());
|
||||
notify.error(
|
||||
'The url "dashboard/create" is deprecated and will be removed in 6.0. Please update your bookmarks.');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
.catch(courier.redirectWhenMissing({
|
||||
'dashboard' : DashboardConstants.LANDING_PAGE_PATH
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.directive('dashboardApp', function ($injector) {
|
||||
const Notifier = $injector.get('Notifier');
|
||||
const courier = $injector.get('courier');
|
||||
|
@ -119,52 +69,55 @@ app.directive('dashboardApp', function ($injector) {
|
|||
docTitle.change(dash.title);
|
||||
}
|
||||
|
||||
const dashboardState = new DashboardState(dash, AppState, dashboardConfig.getHideWriteControls());
|
||||
$scope.appState = dashboardState.getAppState();
|
||||
const dashboardStateManager = new DashboardStateManager(dash, AppState, dashboardConfig.getHideWriteControls());
|
||||
|
||||
$scope.getDashboardState = () => dashboardStateManager;
|
||||
$scope.appState = dashboardStateManager.getAppState();
|
||||
$scope.containerApi = new DashboardContainerAPI(
|
||||
dashboardState,
|
||||
dashboardStateManager,
|
||||
(field, value, operator, index) => {
|
||||
filterActions.addFilter(field, value, operator, index, dashboardState.getAppState(), filterManager);
|
||||
dashboardState.saveState();
|
||||
filterActions.addFilter(field, value, operator, index, dashboardStateManager.getAppState(), filterManager);
|
||||
dashboardStateManager.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.
|
||||
if (dashboardState.getIsTimeSavedWithDashboard() && !getAppState.previouslyStored()) {
|
||||
dashboardState.syncTimefilterWithDashboard(timefilter, quickRanges);
|
||||
if (dashboardStateManager.getIsTimeSavedWithDashboard() && !getAppState.previouslyStored()) {
|
||||
dashboardStateManager.syncTimefilterWithDashboard(timefilter, quickRanges);
|
||||
}
|
||||
|
||||
const updateState = () => {
|
||||
// Following the "best practice" of always have a '.' in your ng-models –
|
||||
// https://github.com/angular/angular.js/wiki/Understanding-Scopes
|
||||
$scope.model = {
|
||||
query: dashboardState.getQuery(),
|
||||
darkTheme: dashboardState.getDarkTheme(),
|
||||
timeRestore: dashboardState.getTimeRestore(),
|
||||
title: dashboardState.getTitle(),
|
||||
description: dashboardState.getDescription(),
|
||||
query: dashboardStateManager.getQuery(),
|
||||
darkTheme: dashboardStateManager.getDarkTheme(),
|
||||
timeRestore: dashboardStateManager.getTimeRestore(),
|
||||
title: dashboardStateManager.getTitle(),
|
||||
description: dashboardStateManager.getDescription(),
|
||||
};
|
||||
$scope.panels = dashboardState.getPanels();
|
||||
$scope.fullScreenMode = dashboardState.getFullScreenMode();
|
||||
$scope.indexPatterns = dashboardState.getPanelIndexPatterns();
|
||||
$scope.panels = dashboardStateManager.getPanels();
|
||||
$scope.fullScreenMode = dashboardStateManager.getFullScreenMode();
|
||||
$scope.indexPatterns = dashboardStateManager.getPanelIndexPatterns();
|
||||
};
|
||||
|
||||
// Part of the exposed plugin API - do not remove without careful consideration.
|
||||
this.appStatus = {
|
||||
dirty: !dash.id
|
||||
};
|
||||
dashboardState.stateMonitor.onChange(status => {
|
||||
|
||||
dashboardStateManager.registerChangeListener(status => {
|
||||
this.appStatus.dirty = status.dirty || !dash.id;
|
||||
updateState();
|
||||
});
|
||||
|
||||
dashboardState.applyFilters(
|
||||
dashboardState.getQuery() || { query: '', language: config.get('search:queryLanguage') },
|
||||
dashboardStateManager.applyFilters(
|
||||
dashboardStateManager.getQuery() || { query: '', language: config.get('search:queryLanguage') },
|
||||
filterBar.getFilters()
|
||||
);
|
||||
let pendingVisCount = _.size(dashboardState.getPanels());
|
||||
let pendingVisCount = _.size(dashboardStateManager.getPanels());
|
||||
|
||||
timefilter.enabled = true;
|
||||
dash.searchSource.highlightAll(true);
|
||||
|
@ -179,24 +132,24 @@ app.directive('dashboardApp', function ($injector) {
|
|||
};
|
||||
$scope.timefilter = timefilter;
|
||||
$scope.expandedPanel = null;
|
||||
$scope.dashboardViewMode = dashboardState.getViewMode();
|
||||
$scope.dashboardViewMode = dashboardStateManager.getViewMode();
|
||||
|
||||
$scope.landingPageUrl = () => `#${DashboardConstants.LANDING_PAGE_PATH}`;
|
||||
$scope.hasExpandedPanel = () => $scope.expandedPanel !== null;
|
||||
$scope.getDashTitle = () => getDashboardTitle(
|
||||
dashboardState.getTitle(),
|
||||
dashboardState.getViewMode(),
|
||||
dashboardState.getIsDirty(timefilter));
|
||||
dashboardStateManager.getTitle(),
|
||||
dashboardStateManager.getViewMode(),
|
||||
dashboardStateManager.getIsDirty(timefilter));
|
||||
$scope.newDashboard = () => { kbnUrl.change(DashboardConstants.CREATE_NEW_DASHBOARD_URL, {}); };
|
||||
$scope.saveState = () => dashboardState.saveState();
|
||||
$scope.saveState = () => dashboardStateManager.saveState();
|
||||
$scope.getShouldShowEditHelp = () => (
|
||||
!dashboardState.getPanels().length &&
|
||||
dashboardState.getIsEditMode() &&
|
||||
!dashboardStateManager.getPanels().length &&
|
||||
dashboardStateManager.getIsEditMode() &&
|
||||
!dashboardConfig.getHideWriteControls()
|
||||
);
|
||||
$scope.getShouldShowViewHelp = () => (
|
||||
!dashboardState.getPanels().length &&
|
||||
dashboardState.getIsViewMode() &&
|
||||
!dashboardStateManager.getPanels().length &&
|
||||
dashboardStateManager.getIsViewMode() &&
|
||||
!dashboardConfig.getHideWriteControls()
|
||||
);
|
||||
|
||||
|
@ -206,24 +159,24 @@ app.directive('dashboardApp', function ($injector) {
|
|||
|
||||
$scope.expandPanel = (panelIndex) => {
|
||||
$scope.expandedPanel =
|
||||
dashboardState.getPanels().find((panel) => panel.panelIndex === panelIndex);
|
||||
dashboardStateManager.getPanels().find((panel) => panel.panelIndex === panelIndex);
|
||||
};
|
||||
|
||||
$scope.updateQueryAndFetch = function (query) {
|
||||
// reset state if language changes
|
||||
if ($scope.model.query.language && $scope.model.query.language !== query.language) {
|
||||
filterBar.removeAll();
|
||||
dashboardState.getAppState().$newFilters = [];
|
||||
dashboardStateManager.getAppState().$newFilters = [];
|
||||
}
|
||||
$scope.model.query = migrateLegacyQuery(query);
|
||||
dashboardState.applyFilters($scope.model.query, filterBar.getFilters());
|
||||
dashboardStateManager.applyFilters($scope.model.query, filterBar.getFilters());
|
||||
$scope.refresh();
|
||||
};
|
||||
|
||||
// called by the saved-object-finder when a user clicks a vis
|
||||
$scope.addVis = function (hit, showToast = true) {
|
||||
pendingVisCount++;
|
||||
dashboardState.addNewPanel(hit.id, 'visualization');
|
||||
dashboardStateManager.addNewPanel(hit.id, 'visualization');
|
||||
if (showToast) {
|
||||
notify.info(`Visualization successfully added to your dashboard`);
|
||||
}
|
||||
|
@ -231,27 +184,27 @@ app.directive('dashboardApp', function ($injector) {
|
|||
|
||||
$scope.addSearch = function (hit) {
|
||||
pendingVisCount++;
|
||||
dashboardState.addNewPanel(hit.id, 'search');
|
||||
dashboardStateManager.addNewPanel(hit.id, 'search');
|
||||
notify.info(`Search successfully added to your dashboard`);
|
||||
};
|
||||
|
||||
$scope.$watch('model.darkTheme', () => {
|
||||
dashboardState.setDarkTheme($scope.model.darkTheme);
|
||||
dashboardStateManager.setDarkTheme($scope.model.darkTheme);
|
||||
updateTheme();
|
||||
});
|
||||
$scope.$watch('model.description', () => dashboardState.setDescription($scope.model.description));
|
||||
$scope.$watch('model.title', () => dashboardState.setTitle($scope.model.title));
|
||||
$scope.$watch('model.timeRestore', () => dashboardState.setTimeRestore($scope.model.timeRestore));
|
||||
$scope.$watch('model.description', () => dashboardStateManager.setDescription($scope.model.description));
|
||||
$scope.$watch('model.title', () => dashboardStateManager.setTitle($scope.model.title));
|
||||
$scope.$watch('model.timeRestore', () => dashboardStateManager.setTimeRestore($scope.model.timeRestore));
|
||||
$scope.indexPatterns = [];
|
||||
|
||||
$scope.registerPanelIndexPattern = (panelIndex, pattern) => {
|
||||
dashboardState.registerPanelIndexPatternMap(panelIndex, pattern);
|
||||
$scope.indexPatterns = dashboardState.getPanelIndexPatterns();
|
||||
dashboardStateManager.registerPanelIndexPatternMap(panelIndex, pattern);
|
||||
$scope.indexPatterns = dashboardStateManager.getPanelIndexPatterns();
|
||||
};
|
||||
|
||||
$scope.onPanelRemoved = (panelIndex) => {
|
||||
dashboardState.removePanel(panelIndex);
|
||||
$scope.indexPatterns = dashboardState.getPanelIndexPatterns();
|
||||
dashboardStateManager.removePanel(panelIndex);
|
||||
$scope.indexPatterns = dashboardStateManager.getPanelIndexPatterns();
|
||||
};
|
||||
|
||||
$scope.$watch('model.query', $scope.updateQueryAndFetch);
|
||||
|
@ -260,14 +213,14 @@ app.directive('dashboardApp', function ($injector) {
|
|||
|
||||
function updateViewMode(newMode) {
|
||||
$scope.topNavMenu = getTopNavConfig(newMode, navActions, dashboardConfig.getHideWriteControls()); // eslint-disable-line no-use-before-define
|
||||
dashboardState.switchViewMode(newMode);
|
||||
dashboardStateManager.switchViewMode(newMode);
|
||||
$scope.dashboardViewMode = newMode;
|
||||
}
|
||||
|
||||
const onChangeViewMode = (newMode) => {
|
||||
const isPageRefresh = newMode === dashboardState.getViewMode();
|
||||
const isPageRefresh = newMode === dashboardStateManager.getViewMode();
|
||||
const isLeavingEditMode = !isPageRefresh && newMode === DashboardViewMode.VIEW;
|
||||
const willLoseChanges = isLeavingEditMode && dashboardState.getIsDirty(timefilter);
|
||||
const willLoseChanges = isLeavingEditMode && dashboardStateManager.getIsDirty(timefilter);
|
||||
|
||||
if (!willLoseChanges) {
|
||||
updateViewMode(newMode);
|
||||
|
@ -275,7 +228,7 @@ app.directive('dashboardApp', function ($injector) {
|
|||
}
|
||||
|
||||
function revertChangesAndExitEditMode() {
|
||||
dashboardState.resetState();
|
||||
dashboardStateManager.resetState();
|
||||
kbnUrl.change(dash.id ? createDashboardEditUrl(dash.id) : DashboardConstants.CREATE_NEW_DASHBOARD_URL);
|
||||
// This is only necessary for new dashboards, which will default to Edit mode.
|
||||
updateViewMode(DashboardViewMode.VIEW);
|
||||
|
@ -283,13 +236,13 @@ app.directive('dashboardApp', function ($injector) {
|
|||
// We need to do a hard reset of the timepicker. appState will not reload like
|
||||
// it does on 'open' because it's been saved to the url and the getAppState.previouslyStored() check on
|
||||
// reload will cause it not to sync.
|
||||
if (dashboardState.getIsTimeSavedWithDashboard()) {
|
||||
dashboardState.syncTimefilterWithDashboard(timefilter, quickRanges);
|
||||
if (dashboardStateManager.getIsTimeSavedWithDashboard()) {
|
||||
dashboardStateManager.syncTimefilterWithDashboard(timefilter, quickRanges);
|
||||
}
|
||||
}
|
||||
|
||||
confirmModal(
|
||||
getUnsavedChangesWarningMessage(dashboardState.getChangedFilterTypes(timefilter)),
|
||||
getUnsavedChangesWarningMessage(dashboardStateManager.getChangedFilterTypes(timefilter)),
|
||||
{
|
||||
onConfirm: revertChangesAndExitEditMode,
|
||||
onCancel: _.noop,
|
||||
|
@ -301,26 +254,27 @@ 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 saveDashboard(angular.toJson, timefilter, dashboardStateManager)
|
||||
.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;
|
||||
let onRouteChange;
|
||||
const setFullScreenMode = (fullScreenMode) => {
|
||||
$scope.fullScreenMode = fullScreenMode;
|
||||
dashboardState.setFullScreenMode(fullScreenMode);
|
||||
dashboardStateManager.setFullScreenMode(fullScreenMode);
|
||||
chrome.setVisible(!fullScreenMode);
|
||||
$scope.$broadcast('reLayout');
|
||||
|
||||
|
@ -336,7 +290,7 @@ app.directive('dashboardApp', function ($injector) {
|
|||
}
|
||||
};
|
||||
|
||||
$scope.$watch('fullScreenMode', () => setFullScreenMode(dashboardState.getFullScreenMode()));
|
||||
$scope.$watch('fullScreenMode', () => setFullScreenMode(dashboardStateManager.getFullScreenMode()));
|
||||
|
||||
$scope.exitFullScreenMode = () => setFullScreenMode(false);
|
||||
|
||||
|
@ -365,15 +319,15 @@ app.directive('dashboardApp', function ($injector) {
|
|||
navActions[TopNavIds.CLONE] = () => {
|
||||
const currentTitle = $scope.model.title;
|
||||
const onClone = (newTitle) => {
|
||||
dashboardState.savedDashboard.copyOnSave = true;
|
||||
dashboardState.setTitle(newTitle);
|
||||
dashboardStateManager.savedDashboard.copyOnSave = true;
|
||||
dashboardStateManager.setTitle(newTitle);
|
||||
return $scope.save().then(id => {
|
||||
// If the save wasn't successful, put the original title back.
|
||||
if (!id) {
|
||||
$scope.model.title = currentTitle;
|
||||
// There is a watch on $scope.model.title that *should* call this automatically but
|
||||
// angular is failing to trigger it, so do so manually here.
|
||||
dashboardState.setTitle(currentTitle);
|
||||
dashboardStateManager.setTitle(currentTitle);
|
||||
}
|
||||
return id;
|
||||
});
|
||||
|
@ -381,25 +335,25 @@ app.directive('dashboardApp', function ($injector) {
|
|||
|
||||
showCloneModal(onClone, currentTitle, $rootScope, $compile);
|
||||
};
|
||||
updateViewMode(dashboardState.getViewMode());
|
||||
updateViewMode(dashboardStateManager.getViewMode());
|
||||
|
||||
// update root source when filters update
|
||||
$scope.$listen(filterBar, 'update', function () {
|
||||
dashboardState.applyFilters($scope.model.query, filterBar.getFilters());
|
||||
dashboardStateManager.applyFilters($scope.model.query, filterBar.getFilters());
|
||||
});
|
||||
|
||||
// update data when filters fire fetch event
|
||||
$scope.$listen(filterBar, 'fetch', $scope.refresh);
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
dashboardState.destroy();
|
||||
dashboardStateManager.destroy();
|
||||
|
||||
// Remove dark theme to keep it from affecting the appearance of other apps.
|
||||
setLightTheme();
|
||||
});
|
||||
|
||||
function updateTheme() {
|
||||
dashboardState.getDarkTheme() ? setDarkTheme() : setLightTheme();
|
||||
dashboardStateManager.getDarkTheme() ? setDarkTheme() : setLightTheme();
|
||||
}
|
||||
|
||||
function setDarkTheme() {
|
||||
|
@ -415,7 +369,7 @@ app.directive('dashboardApp', function ($injector) {
|
|||
$scope.$on('ready:vis', function () {
|
||||
if (pendingVisCount > 0) pendingVisCount--;
|
||||
if (pendingVisCount === 0) {
|
||||
dashboardState.saveState();
|
||||
dashboardStateManager.saveState();
|
||||
$scope.refresh();
|
||||
}
|
||||
});
|
|
@ -8,10 +8,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() {
|
||||
|
|
|
@ -1,71 +1,51 @@
|
|||
import _ from 'lodash';
|
||||
import { FilterUtils } from './filter_utils';
|
||||
|
||||
import { DashboardViewMode } from './dashboard_view_mode';
|
||||
import { PanelUtils } from './panel/panel_utils';
|
||||
import moment from 'moment';
|
||||
|
||||
import { DashboardViewMode } from './dashboard_view_mode';
|
||||
import { FilterUtils } from './lib/filter_utils';
|
||||
import { PanelUtils } from './panel/panel_utils';
|
||||
import { store } from '../store';
|
||||
import { updateViewMode, updatePanels, updateIsFullScreenMode, minimizePanel } from './actions';
|
||||
import { stateMonitorFactory } from 'ui/state_management/state_monitor_factory';
|
||||
import { createPanelState, getPersistedStateId } from './panel';
|
||||
|
||||
function getStateDefaults(dashboard, hideWriteControls) {
|
||||
return {
|
||||
fullScreenMode: false,
|
||||
title: dashboard.title,
|
||||
description: dashboard.description,
|
||||
timeRestore: dashboard.timeRestore,
|
||||
panels: dashboard.panelsJSON ? JSON.parse(dashboard.panelsJSON) : [],
|
||||
options: dashboard.optionsJSON ? JSON.parse(dashboard.optionsJSON) : {},
|
||||
uiState: dashboard.uiStateJSON ? JSON.parse(dashboard.uiStateJSON) : {},
|
||||
query: FilterUtils.getQueryFilterForDashboard(dashboard),
|
||||
filters: FilterUtils.getFilterBarsForDashboard(dashboard),
|
||||
viewMode: dashboard.id || hideWriteControls ? DashboardViewMode.VIEW : DashboardViewMode.EDIT,
|
||||
};
|
||||
}
|
||||
import { getAppStateDefaults } from './lib';
|
||||
|
||||
/**
|
||||
* Depending on how a dashboard is loaded, the filter object may contain a $$hashKey and $state that will throw
|
||||
* off a filter comparison. This removes those variables.
|
||||
* @param filters {Array.<Object>}
|
||||
* @returns {Array.<Object>}
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
*/
|
||||
function cleanFiltersForComparison(filters) {
|
||||
return _.map(filters, (filter) => _.omit(filter, ['$$hashKey', '$state']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the time to a string, if it isn't already.
|
||||
* @param time {string|Moment}
|
||||
* @returns {string}
|
||||
*/
|
||||
function convertTimeToString(time) {
|
||||
return typeof time === 'string' ? time : moment(time).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the two times, making sure they are in both compared in string format. Absolute times
|
||||
* are sometimes stored as moment objects, but converted to strings when reloaded. Relative times are
|
||||
* strings that are not convertible to moment objects.
|
||||
* @param timeA {string|Moment}
|
||||
* @param timeB {string|Moment}
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function areTimesEqual(timeA, timeB) {
|
||||
return convertTimeToString(timeA) === convertTimeToString(timeB);
|
||||
}
|
||||
|
||||
export class DashboardState {
|
||||
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 +54,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 +62,78 @@ export class DashboardState {
|
|||
|
||||
PanelUtils.initPanelIndexes(this.getPanels());
|
||||
this.createStateMonitor();
|
||||
|
||||
// Always start out with all panels minimized when a dashboard is first loaded.
|
||||
store.dispatch(minimizePanel());
|
||||
store.dispatch(updatePanels(this.getPanels()));
|
||||
store.dispatch(updateViewMode(this.getViewMode()));
|
||||
store.dispatch(updateIsFullScreenMode(this.getFullScreenMode()));
|
||||
|
||||
this.changeListeners = [];
|
||||
|
||||
this.unsubscribe = store.subscribe(() => this._handleStoreChanges());
|
||||
this.stateMonitor.onChange(status => {
|
||||
this.changeListeners.forEach(listener => listener(status));
|
||||
this._pushAppStateChangesToStore();
|
||||
});
|
||||
}
|
||||
|
||||
registerChangeListener(callback) {
|
||||
this.changeListeners.push(callback);
|
||||
}
|
||||
|
||||
_areStoreAndAppStatePanelsEqual() {
|
||||
const { dashboard } = 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, dashboard.panels[appStatePanel.panelIndex])) {
|
||||
differencesFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return !differencesFound;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes made to app state outside of direct calls to this class will need to be propagated to the store.
|
||||
* @private
|
||||
*/
|
||||
_pushAppStateChangesToStore() {
|
||||
// We need these checks, or you can get into a loop where a change is triggered by the store, which updates
|
||||
// AppState, which then dispatches the change here, which will end up triggering setState warnings.
|
||||
if (!this._areStoreAndAppStatePanelsEqual()) {
|
||||
store.dispatch(updatePanels(this.getPanels()));
|
||||
}
|
||||
|
||||
const { dashboard } = store.getState();
|
||||
if (dashboard.viewMode !== this.getViewMode()) {
|
||||
store.dispatch(updateViewMode(this.getViewMode()));
|
||||
}
|
||||
|
||||
if (dashboard.view.isFullScreenMode !== this.getFullScreenMode()) {
|
||||
store.dispatch(updateIsFullScreenMode(this.getFullScreenMode()));
|
||||
}
|
||||
}
|
||||
|
||||
_handleStoreChanges() {
|
||||
if (this._areStoreAndAppStatePanelsEqual()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dashboard } = store.getState();
|
||||
// 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(dashboard.panels, panel => {
|
||||
this.appState.panels.push(panel);
|
||||
});
|
||||
this.changeListeners.forEach(function (listener) {
|
||||
return listener({ dirty: true, clean: false });
|
||||
});
|
||||
this.saveState();
|
||||
}
|
||||
|
||||
getFullScreenMode() {
|
||||
|
@ -117,7 +169,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;
|
||||
|
@ -239,8 +291,10 @@ export class DashboardState {
|
|||
* or if it's a new dashboard, if the query differs from the default.
|
||||
*/
|
||||
getFilterBarChanged() {
|
||||
return !_.isEqual(cleanFiltersForComparison(this.appState.filters),
|
||||
cleanFiltersForComparison(this.getLastSavedFilterBars()));
|
||||
return !_.isEqual(
|
||||
FilterUtils.cleanFiltersForComparison(this.appState.filters),
|
||||
FilterUtils.cleanFiltersForComparison(this.getLastSavedFilterBars())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -249,8 +303,8 @@ export class DashboardState {
|
|||
*/
|
||||
getTimeChanged(timeFilter) {
|
||||
return (
|
||||
!areTimesEqual(this.lastSavedDashboardFilters.timeFrom, timeFilter.time.from) ||
|
||||
!areTimesEqual(this.lastSavedDashboardFilters.timeTo, timeFilter.time.to)
|
||||
!FilterUtils.areTimesEqual(this.lastSavedDashboardFilters.timeFrom, timeFilter.time.from) ||
|
||||
!FilterUtils.areTimesEqual(this.lastSavedDashboardFilters.timeTo, timeFilter.time.to)
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -291,6 +345,16 @@ export class DashboardState {
|
|||
return this.appState.panels;
|
||||
}
|
||||
|
||||
updatePanel(panelIndex, panelAttributes) {
|
||||
const originalPanel = this.getPanels().find((panel) => panel.panelIndex === panelIndex);
|
||||
const updatedPanel = {
|
||||
...originalPanel,
|
||||
...panelAttributes,
|
||||
};
|
||||
this.saveState();
|
||||
return updatedPanel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and initializes a basic panel, adding it to the state.
|
||||
* @param {number} id
|
||||
|
@ -298,7 +362,9 @@ export class DashboardState {
|
|||
*/
|
||||
addNewPanel(id, type) {
|
||||
const maxPanelIndex = PanelUtils.getMaxPanelIndex(this.getPanels());
|
||||
this.getPanels().push(createPanelState(id, type, maxPanelIndex, this.getPanels()));
|
||||
const newPanel = createPanelState(id, type, maxPanelIndex, this.getPanels());
|
||||
this.getPanels().push(newPanel);
|
||||
this.saveState();
|
||||
}
|
||||
|
||||
removePanel(panelIndex) {
|
||||
|
@ -375,41 +441,6 @@ export class DashboardState {
|
|||
this.appState.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the dashboard.
|
||||
* @param toJson {function} A custom toJson function. Used because the previous code used
|
||||
* the angularized toJson version, and it was unclear whether there was a reason not to use
|
||||
* JSON.stringify
|
||||
* @param timefilter
|
||||
* @returns {Promise<string>} A promise that if resolved, will contain the id of the newly saved
|
||||
* dashboard.
|
||||
*/
|
||||
saveDashboard(toJson, timeFilter) {
|
||||
this.saveState();
|
||||
|
||||
const timeRestoreObj = _.pick(timeFilter.refreshInterval, ['display', 'pause', 'section', 'value']);
|
||||
this.savedDashboard.title = this.getTitle();
|
||||
this.savedDashboard.description = this.getDescription();
|
||||
this.savedDashboard.timeRestore = this.appState.timeRestore;
|
||||
this.savedDashboard.panelsJSON = toJson(this.appState.panels);
|
||||
this.savedDashboard.uiStateJSON = toJson(this.uiState.getChanges());
|
||||
this.savedDashboard.timeFrom = this.savedDashboard.timeRestore ? convertTimeToString(timeFilter.time.from) : undefined;
|
||||
this.savedDashboard.timeTo = this.savedDashboard.timeRestore ? convertTimeToString(timeFilter.time.to) : undefined;
|
||||
this.savedDashboard.refreshInterval = this.savedDashboard.timeRestore ? timeRestoreObj : undefined;
|
||||
this.savedDashboard.optionsJSON = toJson(this.appState.options);
|
||||
|
||||
return this.savedDashboard.save()
|
||||
.then((id) => {
|
||||
this.lastSavedDashboardFilters = this.getFilterState();
|
||||
this.stateDefaults = getStateDefaults(this.savedDashboard);
|
||||
this.stateDefaults.viewMode = DashboardViewMode.VIEW;
|
||||
// Make sure new app state defaults are using the new defaults.
|
||||
this.appState.setDefaults(this.stateDefaults);
|
||||
this.stateMonitor.setInitialState(this.appState.toJSON());
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the current filter state to the dashboard.
|
||||
* @param filter {Array.<Object>} An array of filter bar filters.
|
||||
|
@ -454,5 +485,6 @@ export class DashboardState {
|
|||
this.stateMonitor.destroy();
|
||||
}
|
||||
this.savedDashboard.destroy();
|
||||
this.unsubscribe();
|
||||
}
|
||||
}
|
|
@ -23,58 +23,38 @@ exports[`renders DashboardGrid 1`] = `
|
|||
}
|
||||
onLayoutChange={[Function]}
|
||||
>
|
||||
<div>
|
||||
<DashboardPanel
|
||||
dashboardViewMode="edit"
|
||||
getContainerApi={[Function]}
|
||||
getEmbeddableHandler={[Function]}
|
||||
isExpanded={false}
|
||||
isFullScreenMode={false}
|
||||
onDeletePanel={[Function]}
|
||||
onPanelBlurred={[Function]}
|
||||
onPanelFocused={[Function]}
|
||||
onToggleExpanded={[Function]}
|
||||
panel={
|
||||
<div
|
||||
className=""
|
||||
>
|
||||
<Connect(DashboardPanel)
|
||||
embeddableHandler={
|
||||
Object {
|
||||
"gridData": Object {
|
||||
"h": 6,
|
||||
"i": 1,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"id": "123",
|
||||
"panelIndex": "1",
|
||||
"type": "visualization",
|
||||
"getEditPath": [Function],
|
||||
"getTitleFor": [Function],
|
||||
"render": [Function],
|
||||
}
|
||||
}
|
||||
getContainerApi={[Function]}
|
||||
onPanelBlurred={[Function]}
|
||||
onPanelFocused={[Function]}
|
||||
panelId="1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<DashboardPanel
|
||||
dashboardViewMode="edit"
|
||||
getContainerApi={[Function]}
|
||||
getEmbeddableHandler={[Function]}
|
||||
isExpanded={false}
|
||||
isFullScreenMode={false}
|
||||
onDeletePanel={[Function]}
|
||||
onPanelBlurred={[Function]}
|
||||
onPanelFocused={[Function]}
|
||||
onToggleExpanded={[Function]}
|
||||
panel={
|
||||
<div
|
||||
className=""
|
||||
>
|
||||
<Connect(DashboardPanel)
|
||||
embeddableHandler={
|
||||
Object {
|
||||
"gridData": Object {
|
||||
"h": 6,
|
||||
"i": 2,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 6,
|
||||
},
|
||||
"id": "456",
|
||||
"panelIndex": "2",
|
||||
"type": "visualization",
|
||||
"getEditPath": [Function],
|
||||
"getTitleFor": [Function],
|
||||
"render": [Function],
|
||||
}
|
||||
}
|
||||
getContainerApi={[Function]}
|
||||
onPanelBlurred={[Function]}
|
||||
onPanelFocused={[Function]}
|
||||
panelId="2"
|
||||
/>
|
||||
</div>
|
||||
</SizeMe(ResponsiveGrid)>
|
||||
|
|
|
@ -2,26 +2,34 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import _ from 'lodash';
|
||||
import ReactGridLayout from 'react-grid-layout';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { PanelUtils } from '../panel/panel_utils';
|
||||
import { DashboardViewMode } from '../dashboard_view_mode';
|
||||
import { DashboardPanel } from '../panel/dashboard_panel';
|
||||
import { DashboardPanelContainer } from '../panel/dashboard_panel_container';
|
||||
import { DASHBOARD_GRID_COLUMN_COUNT } from '../dashboard_constants';
|
||||
import sizeMe from 'react-sizeme';
|
||||
|
||||
const config = { monitorWidth: true };
|
||||
let lastValidGridSize = 0;
|
||||
|
||||
function ResponsiveGrid({ size, isViewMode, layout, onLayoutChange, children }) {
|
||||
function ResponsiveGrid({ size, isViewMode, layout, onLayoutChange, children, maximizedPanelId }) {
|
||||
// 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;
|
||||
const classes = classNames({
|
||||
'layout-view': isViewMode,
|
||||
'layout-edit': !isViewMode,
|
||||
'layout-maximized-panel': maximizedPanelId !== undefined,
|
||||
});
|
||||
|
||||
// We can't take advantage of isDraggable or isResizable due to performance concerns:
|
||||
// https://github.com/STRML/react-grid-layout/issues/240
|
||||
return (
|
||||
<ReactGridLayout
|
||||
width={lastValidGridSize}
|
||||
className={isViewMode ? 'layout-view' : 'layout-edit'}
|
||||
className={classes}
|
||||
isDraggable={true}
|
||||
isResizable={true}
|
||||
margin={[0, 0]}
|
||||
|
@ -54,8 +62,8 @@ export class DashboardGrid extends React.Component {
|
|||
}
|
||||
|
||||
buildLayoutFromPanels() {
|
||||
return this.props.panels.map(panel => {
|
||||
if (panel.size_x || panel.size_y || panel.col || panel.row) {
|
||||
return _.map(this.props.panels, panel => {
|
||||
if (!panel.version) {
|
||||
PanelUtils.convertOldPanelData(panel);
|
||||
}
|
||||
return panel.gridData;
|
||||
|
@ -63,18 +71,19 @@ export class DashboardGrid extends React.Component {
|
|||
}
|
||||
|
||||
onLayoutChange = (layout) => {
|
||||
const { panels, getContainerApi } = this.props;
|
||||
const containerApi = getContainerApi();
|
||||
const { onPanelUpdated } = this.props;
|
||||
layout.forEach(panelLayout => {
|
||||
const panelUpdated = _.find(panels, panel => panel.panelIndex.toString() === panelLayout.i);
|
||||
panelUpdated.gridData = {
|
||||
x: panelLayout.x,
|
||||
y: panelLayout.y,
|
||||
w: panelLayout.w,
|
||||
h: panelLayout.h,
|
||||
i: panelLayout.i,
|
||||
const updatedPanel = {
|
||||
panelIndex: panelLayout.i,
|
||||
gridData: {
|
||||
x: panelLayout.x,
|
||||
y: panelLayout.y,
|
||||
w: panelLayout.w,
|
||||
h: panelLayout.h,
|
||||
i: panelLayout.i,
|
||||
}
|
||||
};
|
||||
containerApi.updatePanel(panelUpdated.panelIndex, panelUpdated);
|
||||
onPanelUpdated(updatedPanel);
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -95,16 +104,13 @@ export class DashboardGrid extends React.Component {
|
|||
renderDOM() {
|
||||
const {
|
||||
panels,
|
||||
onPanelRemoved,
|
||||
expandPanel,
|
||||
isFullScreenMode,
|
||||
getEmbeddableHandler,
|
||||
getContainerApi,
|
||||
dashboardViewMode
|
||||
maximizedPanelId
|
||||
} = this.props;
|
||||
|
||||
// Part of our unofficial API - need to render in a consistent order for plugins.
|
||||
const panelsInOrder = panels.slice(0);
|
||||
const panelsInOrder = Object.keys(panels).map(key => panels[key]);
|
||||
panelsInOrder.sort((panelA, panelB) => {
|
||||
if (panelA.gridData.y === panelB.gridData.y) {
|
||||
return panelA.gridData.x - panelB.gridData.x;
|
||||
|
@ -113,21 +119,23 @@ export class DashboardGrid extends React.Component {
|
|||
}
|
||||
});
|
||||
|
||||
return panelsInOrder.map(panel => {
|
||||
return _.map(panelsInOrder, panel => {
|
||||
const expandPanel = maximizedPanelId !== undefined && maximizedPanelId === panel.panelIndex;
|
||||
const hidePanel = maximizedPanelId !== undefined && maximizedPanelId !== panel.panelIndex;
|
||||
const classes = classNames({
|
||||
'grid-item--expanded': expandPanel,
|
||||
'grid-item--hidden': hidePanel,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className={classes}
|
||||
key={panel.panelIndex.toString()}
|
||||
ref={reactGridItem => { this.gridItems[panel.panelIndex] = reactGridItem; }}
|
||||
>
|
||||
<DashboardPanel
|
||||
panel={panel}
|
||||
onDeletePanel={onPanelRemoved}
|
||||
onToggleExpanded={expandPanel}
|
||||
isExpanded={false}
|
||||
isFullScreenMode={isFullScreenMode}
|
||||
getEmbeddableHandler={getEmbeddableHandler}
|
||||
<DashboardPanelContainer
|
||||
panelId={`${panel.panelIndex}`}
|
||||
getContainerApi={getContainerApi}
|
||||
dashboardViewMode={dashboardViewMode}
|
||||
embeddableHandler={getEmbeddableHandler(panel.type)}
|
||||
onPanelFocused={this.onPanelFocused}
|
||||
onPanelBlurred={this.onPanelBlurred}
|
||||
/>
|
||||
|
@ -137,13 +145,14 @@ export class DashboardGrid extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { dashboardViewMode } = this.props;
|
||||
const { dashboardViewMode, maximizedPanelId } = this.props;
|
||||
const isViewMode = dashboardViewMode === DashboardViewMode.VIEW;
|
||||
return (
|
||||
<ResponsiveSizedGrid
|
||||
isViewMode={isViewMode}
|
||||
layout={this.buildLayoutFromPanels()}
|
||||
onLayoutChange={this.onLayoutChange}
|
||||
maximizedPanelId={maximizedPanelId}
|
||||
>
|
||||
{this.renderDOM()}
|
||||
</ResponsiveSizedGrid>
|
||||
|
@ -152,12 +161,10 @@ export class DashboardGrid extends React.Component {
|
|||
}
|
||||
|
||||
DashboardGrid.propTypes = {
|
||||
isFullScreenMode: PropTypes.bool.isRequired,
|
||||
panels: PropTypes.array.isRequired,
|
||||
panels: PropTypes.object.isRequired,
|
||||
getContainerApi: PropTypes.func.isRequired,
|
||||
getEmbeddableHandler: PropTypes.func.isRequired,
|
||||
dashboardViewMode: PropTypes.oneOf([DashboardViewMode.EDIT, DashboardViewMode.VIEW]).isRequired,
|
||||
expandPanel: PropTypes.func.isRequired,
|
||||
onPanelRemoved: PropTypes.func.isRequired,
|
||||
onPanelUpdated: PropTypes.func.isRequired,
|
||||
maximizedPanelId: PropTypes.string,
|
||||
};
|
||||
|
||||
|
|
|
@ -1,49 +1,40 @@
|
|||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { DashboardViewMode } from '../dashboard_view_mode';
|
||||
import { PanelUtils } from '../panel/panel_utils';
|
||||
import { getContainerApiMock } from '../__tests__/get_container_api_mock';
|
||||
import { getEmbeddableHandlerMock } from '../__tests__/get_embeddable_handlers_mock';
|
||||
|
||||
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,
|
||||
panels: {
|
||||
'1': {
|
||||
gridData: { x: 0, y: 0, w: 6, h: 6, i: 1 },
|
||||
panelIndex: '1',
|
||||
type: 'visualization',
|
||||
id: '123',
|
||||
version: '7.0.0',
|
||||
},
|
||||
'2': {
|
||||
gridData: { x: 6, y: 6, w: 6, h: 6, i: 2 },
|
||||
panelIndex: '2',
|
||||
type: 'visualization',
|
||||
id: '456',
|
||||
version: '7.0.0',
|
||||
}
|
||||
},
|
||||
getEmbeddableHandler: () => getEmbeddableHandlerMock(),
|
||||
getContainerApi: () => getContainerApiMock(),
|
||||
isExpanded: false,
|
||||
getContainerApi,
|
||||
expandPanel: () => {},
|
||||
onPanelRemoved: () => {}
|
||||
onPanelRemoved: () => {},
|
||||
onPanelUpdated: () => {},
|
||||
};
|
||||
return Object.assign(defaultTestProps, props);
|
||||
}
|
||||
|
@ -51,57 +42,12 @@ function getProps(props = {}) {
|
|||
test('renders DashboardGrid', () => {
|
||||
const component = shallow(<DashboardGrid {...getProps()} />);
|
||||
expect(component).toMatchSnapshot();
|
||||
const panelElements = component.find(DashboardPanel);
|
||||
const panelElements = component.find('Connect(DashboardPanel)');
|
||||
expect(panelElements.length).toBe(2);
|
||||
});
|
||||
|
||||
test('renders DashboardGrid with no visualizations', () => {
|
||||
const component = shallow(<DashboardGrid {...getProps({ panels: [] })} />);
|
||||
const component = shallow(<DashboardGrid {...getProps({ panels: {} })} />);
|
||||
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(<DashboardGrid {...props} />);
|
||||
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);
|
||||
});
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { DashboardGrid } from './dashboard_grid';
|
||||
import { updatePanel } from '../actions';
|
||||
import { getPanels, getViewMode } from '../reducers';
|
||||
|
||||
const mapStateToProps = ({ dashboard }) => ({
|
||||
panels: getPanels(dashboard),
|
||||
dashboardViewMode: getViewMode(dashboard),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
onPanelUpdated: updatedPanel => dispatch(updatePanel(updatedPanel)),
|
||||
});
|
||||
|
||||
export const DashboardGridContainer = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(DashboardGrid);
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { Provider } from 'react-redux';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { getContainerApiMock } from '../__tests__/get_container_api_mock';
|
||||
import { getEmbeddableHandlerMock } from '../__tests__/get_embeddable_handlers_mock';
|
||||
import { store } from '../../store';
|
||||
import { DashboardGridContainer } from './dashboard_grid_container';
|
||||
import { updatePanels } from '../actions';
|
||||
|
||||
jest.mock('ui/chrome', () => ({ getKibanaVersion: () => '6.0.0' }), { virtual: true });
|
||||
|
||||
function getProps(props = {}) {
|
||||
const defaultTestProps = {
|
||||
hidden: false,
|
||||
getEmbeddableHandler: () => getEmbeddableHandlerMock(),
|
||||
getContainerApi: () => getContainerApiMock(),
|
||||
};
|
||||
return Object.assign(defaultTestProps, props);
|
||||
}
|
||||
|
||||
function createOldPanelData(col, id, row, sizeX, sizeY, panelIndex) {
|
||||
return { col, id, row, size_x: sizeX, size_y: sizeY, type: 'visualization', panelIndex };
|
||||
}
|
||||
|
||||
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)
|
||||
];
|
||||
|
||||
store.dispatch(updatePanels(panelData));
|
||||
|
||||
mount(<Provider store={store}><DashboardGridContainer {...getProps()} /></Provider>);
|
||||
|
||||
const panels = store.getState().dashboard.panels;
|
||||
expect(Object.keys(panels).length).toBe(16);
|
||||
|
||||
const foo8Panel = _.find(panels, panel => panel.id === 'foo8');
|
||||
expect(foo8Panel.row).toBe(undefined);
|
||||
expect(foo8Panel.gridData.y).toBe(7);
|
||||
expect(foo8Panel.gridData.x).toBe(0);
|
||||
});
|
|
@ -1,12 +1,16 @@
|
|||
import 'plugins/kibana/dashboard/dashboard';
|
||||
import 'plugins/kibana/dashboard/dashboard_app';
|
||||
import 'plugins/kibana/dashboard/saved_dashboard/saved_dashboards';
|
||||
import 'plugins/kibana/dashboard/styles/index.less';
|
||||
import 'plugins/kibana/dashboard/dashboard_config';
|
||||
import uiRoutes from 'ui/routes';
|
||||
import { notify } from 'ui/notify';
|
||||
|
||||
import dashboardTemplate from 'plugins/kibana/dashboard/dashboard_app.html';
|
||||
import dashboardListingTemplate from './listing/dashboard_listing.html';
|
||||
|
||||
import { DashboardListingController } from './listing/dashboard_listing';
|
||||
import { DashboardConstants } from './dashboard_constants';
|
||||
import { DashboardConstants, createDashboardEditUrl } from './dashboard_constants';
|
||||
import { SavedObjectNotFound } from 'ui/errors';
|
||||
|
||||
uiRoutes
|
||||
.defaults(/dashboard/, {
|
||||
|
@ -16,4 +20,39 @@ uiRoutes
|
|||
template: dashboardListingTemplate,
|
||||
controller: DashboardListingController,
|
||||
controllerAs: 'listingController'
|
||||
})
|
||||
.when(DashboardConstants.CREATE_NEW_DASHBOARD_URL, {
|
||||
template: dashboardTemplate,
|
||||
resolve: {
|
||||
dash: function (savedDashboards, courier) {
|
||||
return savedDashboards.get()
|
||||
.catch(courier.redirectWhenMissing({
|
||||
'dashboard': DashboardConstants.LANDING_PAGE_PATH
|
||||
}));
|
||||
}
|
||||
}
|
||||
})
|
||||
.when(createDashboardEditUrl(':id'), {
|
||||
template: dashboardTemplate,
|
||||
resolve: {
|
||||
dash: function (savedDashboards, Notifier, $route, $location, courier, kbnUrl, AppState) {
|
||||
const id = $route.current.params.id;
|
||||
return savedDashboards.get(id)
|
||||
.catch((error) => {
|
||||
// Preserve BWC of v5.3.0 links for new, unsaved dashboards.
|
||||
// See https://github.com/elastic/kibana/issues/10951 for more context.
|
||||
if (error instanceof SavedObjectNotFound && id === 'create') {
|
||||
// Note "new AppState" is neccessary so the state in the url is preserved through the redirect.
|
||||
kbnUrl.redirect(DashboardConstants.CREATE_NEW_DASHBOARD_URL, {}, new AppState());
|
||||
notify.error(
|
||||
'The url "dashboard/create" is deprecated and will be removed in 6.0. Please update your bookmarks.');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
.catch(courier.redirectWhenMissing({
|
||||
'dashboard' : DashboardConstants.LANDING_PAGE_PATH
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
|
||||
/**
|
||||
* @typedef {Object} QueryFilter
|
||||
|
@ -51,4 +52,35 @@ export class FilterUtils {
|
|||
static getFilterBarsForDashboard(dashboard) {
|
||||
return _.reject(this.getDashboardFilters(dashboard), this.isQueryFilter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the time to a string, if it isn't already.
|
||||
* @param time {string|Moment}
|
||||
* @returns {string}
|
||||
*/
|
||||
static convertTimeToString(time) {
|
||||
return typeof time === 'string' ? time : moment(time).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the two times, making sure they are in both compared in string format. Absolute times
|
||||
* are sometimes stored as moment objects, but converted to strings when reloaded. Relative times are
|
||||
* strings that are not convertible to moment objects.
|
||||
* @param timeA {string|Moment}
|
||||
* @param timeB {string|Moment}
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static areTimesEqual(timeA, timeB) {
|
||||
return this.convertTimeToString(timeA) === this.convertTimeToString(timeB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Depending on how a dashboard is loaded, the filter object may contain a $$hashKey and $state that will throw
|
||||
* off a filter comparison. This removes those variables.
|
||||
* @param filters {Array.<Object>}
|
||||
* @returns {Array.<Object>}
|
||||
*/
|
||||
static cleanFiltersForComparison(filters) {
|
||||
return _.map(filters, (filter) => _.omit(filter, ['$$hashKey', '$state']));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import { DashboardViewMode } from '../dashboard_view_mode';
|
||||
import { FilterUtils } from './filter_utils';
|
||||
|
||||
export function getAppStateDefaults(savedDashboard, hideWriteControls) {
|
||||
return {
|
||||
fullScreenMode: false,
|
||||
title: savedDashboard.title,
|
||||
description: savedDashboard.description,
|
||||
timeRestore: savedDashboard.timeRestore,
|
||||
panels: savedDashboard.panelsJSON ? JSON.parse(savedDashboard.panelsJSON) : [],
|
||||
options: savedDashboard.optionsJSON ? JSON.parse(savedDashboard.optionsJSON) : {},
|
||||
uiState: savedDashboard.uiStateJSON ? JSON.parse(savedDashboard.uiStateJSON) : {},
|
||||
query: FilterUtils.getQueryFilterForDashboard(savedDashboard),
|
||||
filters: FilterUtils.getFilterBarsForDashboard(savedDashboard),
|
||||
viewMode: savedDashboard.id || hideWriteControls ? DashboardViewMode.VIEW : DashboardViewMode.EDIT,
|
||||
};
|
||||
}
|
2
src/core_plugins/kibana/public/dashboard/lib/index.js
Normal file
2
src/core_plugins/kibana/public/dashboard/lib/index.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { saveDashboard } from './save_dashboard';
|
||||
export { getAppStateDefaults } from './get_app_state_defaults';
|
|
@ -0,0 +1,26 @@
|
|||
import { updateSavedDashboard } from './update_saved_dashboard';
|
||||
|
||||
/**
|
||||
* Saves the dashboard.
|
||||
* @param toJson {function} A custom toJson function. Used because the previous code used
|
||||
* the angularized toJson version, and it was unclear whether there was a reason not to use
|
||||
* JSON.stringify
|
||||
* @param timeFilter
|
||||
* @param dashboardStateManager {DashboardStateManager}
|
||||
* @returns {Promise<string>} A promise that if resolved, will contain the id of the newly saved
|
||||
* dashboard.
|
||||
*/
|
||||
export function saveDashboard(toJson, timeFilter, dashboardStateManager) {
|
||||
dashboardStateManager.saveState();
|
||||
|
||||
const savedDashboard = dashboardStateManager.savedDashboard;
|
||||
const appState = dashboardStateManager.appState;
|
||||
|
||||
updateSavedDashboard(savedDashboard, appState, dashboardStateManager.uiState, timeFilter, toJson);
|
||||
|
||||
return savedDashboard.save()
|
||||
.then((id) => {
|
||||
dashboardStateManager.resetState();
|
||||
return id;
|
||||
});
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import _ from 'lodash';
|
||||
import { FilterUtils } from './filter_utils';
|
||||
|
||||
export function updateSavedDashboard(savedDashboard, appState, uiState, timeFilter, toJson) {
|
||||
savedDashboard.title = appState.title;
|
||||
savedDashboard.description = appState.description;
|
||||
savedDashboard.timeRestore = appState.timeRestore;
|
||||
savedDashboard.panelsJSON = toJson(appState.panels);
|
||||
savedDashboard.uiStateJSON = toJson(uiState.getChanges());
|
||||
savedDashboard.optionsJSON = toJson(appState.options);
|
||||
|
||||
savedDashboard.timeFrom = savedDashboard.timeRestore ?
|
||||
FilterUtils.convertTimeToString(timeFilter.time.from)
|
||||
: undefined;
|
||||
savedDashboard.timeTo = savedDashboard.timeRestore ?
|
||||
FilterUtils.convertTimeToString(timeFilter.time.to)
|
||||
: undefined;
|
||||
const timeRestoreObj = _.pick(timeFilter.refreshInterval, ['display', 'pause', 'section', 'value']);
|
||||
savedDashboard.refreshInterval = savedDashboard.timeRestore ? timeRestoreObj : undefined;
|
||||
}
|
|
@ -2,94 +2,41 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { DashboardViewMode } from '../dashboard_view_mode';
|
||||
import { PanelHeader } from './panel_header';
|
||||
import { PanelError } from './panel_error';
|
||||
|
||||
export class DashboardPanel extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
this.embeddable = null;
|
||||
this.embeddableHandler = null;
|
||||
this._isMounted = false;
|
||||
async componentDidMount() {
|
||||
this.props.renderEmbeddable(this.panelElement, this.props.panel);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
const { getEmbeddableHandler, panel, getContainerApi } = this.props;
|
||||
|
||||
this.containerApi = getContainerApi();
|
||||
this.embeddableHandler = getEmbeddableHandler(panel.type);
|
||||
|
||||
if (!this.embeddableHandler) {
|
||||
/* eslint-disable react/no-did-mount-set-state */
|
||||
this.setState({ error: `Invalid panel type ${panel.type}` });
|
||||
toggleExpandedPanel = () => {
|
||||
const { isExpanded, onMaximizePanel, onMinimizePanel } = this.props;
|
||||
if (isExpanded) {
|
||||
onMinimizePanel();
|
||||
} else {
|
||||
onMaximizePanel();
|
||||
}
|
||||
|
||||
// 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.embeddableHandler.render(
|
||||
this.panelElement,
|
||||
panel,
|
||||
this.containerApi)
|
||||
.then(destroyEmbeddable => this.destroyEmbeddable = destroyEmbeddable)
|
||||
.catch(error => {
|
||||
const message = error.message || JSON.stringify(error);
|
||||
this.setState({ error: message });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
deletePanel = () => this.props.onDeletePanel();
|
||||
onEditPanel = () => window.location = this.props.editUrl;
|
||||
|
||||
onFocus = () => {
|
||||
const { onPanelFocused } = this.props;
|
||||
const { onPanelFocused, panel } = this.props;
|
||||
if (onPanelFocused) {
|
||||
onPanelFocused(this.props.panel.panelIndex);
|
||||
onPanelFocused(panel.panelIndex);
|
||||
}
|
||||
};
|
||||
|
||||
onBlur = () => {
|
||||
const { onPanelBlurred } = this.props;
|
||||
const { onPanelBlurred, panel } = this.props;
|
||||
if (onPanelBlurred) {
|
||||
onPanelBlurred(this.props.panel.panelIndex);
|
||||
onPanelBlurred(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();
|
||||
}
|
||||
this.props.onDestroy();
|
||||
}
|
||||
|
||||
renderEmbeddedContent() {
|
||||
|
@ -103,14 +50,29 @@ export class DashboardPanel extends React.Component {
|
|||
}
|
||||
|
||||
renderEmbeddedError() {
|
||||
return <PanelError error={this.state.error} />;
|
||||
const { error } = this.props;
|
||||
const errorMessage = error.message || JSON.stringify(error);
|
||||
return <PanelError error={errorMessage} />;
|
||||
}
|
||||
|
||||
renderEmbeddedContent() {
|
||||
return (
|
||||
<div
|
||||
id="embeddedPanel"
|
||||
className="panel-content"
|
||||
ref={panelElement => this.panelElement = panelElement}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderEmbeddedError() {
|
||||
return <PanelError error={this.props.error} />;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { title } = this.state;
|
||||
const { dashboardViewMode, isFullScreenMode, isExpanded } = this.props;
|
||||
const { viewOnlyMode, isExpanded, title, error } = this.props;
|
||||
const classes = classNames('panel panel-default', this.props.className, {
|
||||
'panel--edit-mode': !this.isViewOnlyMode()
|
||||
'panel--edit-mode': !viewOnlyMode
|
||||
});
|
||||
return (
|
||||
<div
|
||||
|
@ -128,10 +90,10 @@ export class DashboardPanel extends React.Component {
|
|||
onEditPanel={this.onEditPanel}
|
||||
onToggleExpand={this.toggleExpandedPanel}
|
||||
isExpanded={isExpanded}
|
||||
isViewOnlyMode={isFullScreenMode || dashboardViewMode === DashboardViewMode.VIEW}
|
||||
isViewOnlyMode={viewOnlyMode}
|
||||
/>
|
||||
|
||||
{this.state.error ? this.renderEmbeddedError() : this.renderEmbeddedContent()}
|
||||
{error ? this.renderEmbeddedError() : this.renderEmbeddedContent()}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
@ -140,14 +102,22 @@ export class DashboardPanel extends React.Component {
|
|||
}
|
||||
|
||||
DashboardPanel.propTypes = {
|
||||
dashboardViewMode: PropTypes.oneOf([DashboardViewMode.EDIT, DashboardViewMode.VIEW]).isRequired,
|
||||
isFullScreenMode: PropTypes.bool.isRequired,
|
||||
panel: PropTypes.object.isRequired,
|
||||
getEmbeddableHandler: PropTypes.func.isRequired,
|
||||
panel: PropTypes.shape({
|
||||
panelIndex: PropTypes.string,
|
||||
}),
|
||||
renderEmbeddable: PropTypes.func.isRequired,
|
||||
isExpanded: PropTypes.bool.isRequired,
|
||||
getContainerApi: PropTypes.func.isRequired,
|
||||
onToggleExpanded: PropTypes.func.isRequired,
|
||||
onMaximizePanel: PropTypes.func.isRequired,
|
||||
onMinimizePanel: PropTypes.func.isRequired,
|
||||
viewOnlyMode: PropTypes.bool.isRequired,
|
||||
onDestroy: PropTypes.func.isRequired,
|
||||
onDeletePanel: PropTypes.func,
|
||||
editUrl: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
onPanelFocused: PropTypes.func,
|
||||
onPanelBlurred: PropTypes.func,
|
||||
error: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.object
|
||||
]),
|
||||
};
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import { mount } from 'enzyme';
|
||||
import { DashboardViewMode } from '../dashboard_view_mode';
|
||||
import { DashboardPanel } from './dashboard_panel';
|
||||
import { PanelError } from '../panel/panel_error';
|
||||
|
||||
|
@ -9,35 +8,16 @@ import {
|
|||
takeMountedSnapshot,
|
||||
} from 'ui_framework/src/test';
|
||||
|
||||
const containerApiMock = {
|
||||
addFilter: () => {},
|
||||
getAppState: () => {},
|
||||
createChildUistate: () => {},
|
||||
registerPanelIndexPattern: () => {},
|
||||
updatePanel: () => {}
|
||||
};
|
||||
|
||||
const embeddableHandlerMock = {
|
||||
getEditPath: () => Promise.resolve('editPath'),
|
||||
getTitleFor: () => Promise.resolve('title'),
|
||||
render: jest.fn(() => Promise.resolve(() => {}))
|
||||
};
|
||||
|
||||
function getProps(props = {}) {
|
||||
const defaultTestProps = {
|
||||
dashboardViewMode: DashboardViewMode.EDIT,
|
||||
viewOnlyMode: false,
|
||||
isFullScreenMode: false,
|
||||
panel: {
|
||||
gridData: { x: 0, y: 0, w: 6, h: 6, i: 1 },
|
||||
panelIndex: '1',
|
||||
type: 'visualization',
|
||||
id: 'foo1'
|
||||
},
|
||||
getEmbeddableHandler: () => embeddableHandlerMock,
|
||||
onMaximizePanel: () => {},
|
||||
onMinimizePanel: () => {},
|
||||
panelId: 'foo1',
|
||||
renderEmbeddable: jest.fn(),
|
||||
isExpanded: false,
|
||||
getContainerApi: () => containerApiMock,
|
||||
onToggleExpanded: () => {},
|
||||
onDeletePanel: () => {}
|
||||
onDestroy: () => {}
|
||||
};
|
||||
return _.defaultsDeep(props, defaultTestProps);
|
||||
}
|
||||
|
@ -47,27 +27,19 @@ test('DashboardPanel matches snapshot', () => {
|
|||
expect(takeMountedSnapshot(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('and calls render', () => {
|
||||
expect(embeddableHandlerMock.render.mock.calls.length).toBe(1);
|
||||
test('Calls render', () => {
|
||||
const props = getProps();
|
||||
mount(<DashboardPanel {...props} />);
|
||||
expect(props.renderEmbeddable.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
test('renders an error message when an error is thrown', () => {
|
||||
test('renders an error when error prop is passed', () => {
|
||||
const props = getProps({
|
||||
getEmbeddableHandler: () => {
|
||||
return {
|
||||
getEditPath: () => Promise.resolve('editPath'),
|
||||
getTitleFor: () => Promise.resolve('title'),
|
||||
render: () => Promise.reject(new Error({ message: 'simulated error' }))
|
||||
};
|
||||
}
|
||||
error: 'Simulated error'
|
||||
});
|
||||
|
||||
const component = mount(<DashboardPanel {...props} />);
|
||||
return new Promise(resolve => {
|
||||
return process.nextTick(() => {
|
||||
const panelElements = component.find(PanelError);
|
||||
expect(panelElements.length).toBe(1);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const panelError = component.find(PanelError);
|
||||
expect(panelError.length).toBe(1);
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { DashboardPanel } from './dashboard_panel';
|
||||
import { DashboardViewMode } from '../dashboard_view_mode';
|
||||
|
||||
import {
|
||||
renderEmbeddable,
|
||||
maximizePanel,
|
||||
minimizePanel,
|
||||
deletePanel,
|
||||
destroyEmbeddable
|
||||
} from '../actions';
|
||||
|
||||
import {
|
||||
getPanel,
|
||||
getEmbeddable,
|
||||
getFullScreenMode,
|
||||
getViewMode,
|
||||
getEmbeddableTitle,
|
||||
getEmbeddableEditUrl,
|
||||
getMaximizedPanelId,
|
||||
getEmbeddableError,
|
||||
} from '../reducers';
|
||||
|
||||
const mapStateToProps = ({ dashboard }, { panelId }) => {
|
||||
const embeddable = getEmbeddable(dashboard, panelId);
|
||||
return {
|
||||
title: embeddable ? getEmbeddableTitle(dashboard, panelId) : '',
|
||||
editUrl: embeddable ? getEmbeddableEditUrl(dashboard, panelId) : '',
|
||||
error: embeddable ? getEmbeddableError(dashboard, panelId) : '',
|
||||
|
||||
viewOnlyMode: getFullScreenMode(dashboard) || getViewMode(dashboard) === DashboardViewMode.VIEW,
|
||||
isExpanded: getMaximizedPanelId(dashboard) === panelId,
|
||||
panel: getPanel(dashboard, panelId)
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { embeddableHandler, panelId, getContainerApi }) => ({
|
||||
renderEmbeddable: (panelElement, panel) => (
|
||||
dispatch(renderEmbeddable(embeddableHandler, panelElement, panel, getContainerApi()))
|
||||
),
|
||||
onDeletePanel: () => {
|
||||
dispatch(deletePanel(panelId));
|
||||
dispatch(destroyEmbeddable(panelId, embeddableHandler));
|
||||
},
|
||||
onMaximizePanel: () => dispatch(maximizePanel(panelId)),
|
||||
onMinimizePanel: () => dispatch(minimizePanel()),
|
||||
onDestroy: () => dispatch(destroyEmbeddable(panelId, embeddableHandler)),
|
||||
});
|
||||
|
||||
export const DashboardPanelContainer = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(DashboardPanel);
|
|
@ -7,6 +7,7 @@ import chrome from 'ui/chrome';
|
|||
*
|
||||
* @typedef {Object} PanelState
|
||||
* @property {number} id - Id of the visualization contained in the panel.
|
||||
* @property {string} version - Version of Kibana this panel was created in.
|
||||
* @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
|
||||
|
|
|
@ -31,13 +31,15 @@ export class PanelUtils {
|
|||
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(),
|
||||
i: panel.panelIndex.toString()
|
||||
};
|
||||
panel.version = chrome.getKibanaVersion();
|
||||
delete panel.size_x;
|
||||
delete panel.size_y;
|
||||
delete panel.row;
|
||||
delete panel.col;
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import { handleActions } from 'redux-actions';
|
||||
|
||||
import {
|
||||
embeddableRenderFinished,
|
||||
embeddableRenderError,
|
||||
} from '../actions';
|
||||
|
||||
export const embeddable = handleActions({
|
||||
|
||||
[embeddableRenderFinished]: (state, { payload }) => ({
|
||||
...state,
|
||||
...payload.embeddable,
|
||||
error: undefined,
|
||||
}),
|
||||
|
||||
[embeddableRenderError]: (state, { payload: { error } }) => ({
|
||||
...state,
|
||||
error,
|
||||
}),
|
||||
}, {
|
||||
error: undefined,
|
||||
title: '',
|
||||
editUrl: '',
|
||||
});
|
||||
|
||||
export const getTitle = state => state.title;
|
||||
export const getEditUrl = state => state.editUrl;
|
||||
export const getError = state => state.error;
|
|
@ -0,0 +1,26 @@
|
|||
import { store } from '../../store';
|
||||
import { embeddableRenderError, embeddableRenderFinished } from '../actions';
|
||||
import { getEmbeddableError, getEmbeddableTitle } from '../reducers';
|
||||
import { getDashboard } from '../../reducers';
|
||||
|
||||
describe('embeddable reducers', () => {
|
||||
test('embeddableRenderError stores an error on the embeddable', () => {
|
||||
store.dispatch(embeddableRenderError('1', new Error('Opps, something bad happened!')));
|
||||
|
||||
const error = getEmbeddableError(getDashboard(store.getState()), '1');
|
||||
expect(error.message).toBe('Opps, something bad happened!');
|
||||
});
|
||||
|
||||
describe('embeddableRenderFinished', () => {
|
||||
test('stores a new embeddable object and clears the error', () => {
|
||||
store.dispatch(embeddableRenderFinished('1', { title: 'My Embeddable' }));
|
||||
const embeddableTitle = getEmbeddableTitle(getDashboard(store.getState()), '1');
|
||||
expect(embeddableTitle).toBe('My Embeddable');
|
||||
});
|
||||
|
||||
test('and clears the error', () => {
|
||||
const error = getEmbeddableError(getDashboard(store.getState()), '1');
|
||||
expect(error).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,32 @@
|
|||
import { handleActions, combineActions } from 'redux-actions';
|
||||
|
||||
import {
|
||||
embeddableRenderFinished,
|
||||
embeddableRenderError,
|
||||
destroyEmbeddable,
|
||||
} from '../actions';
|
||||
|
||||
import {
|
||||
embeddable,
|
||||
getTitle,
|
||||
getEditUrl,
|
||||
getError,
|
||||
} from './embeddable';
|
||||
|
||||
export const embeddables = handleActions({
|
||||
[destroyEmbeddable]: (state, { payload }) => {
|
||||
const stateCopy = { ...state };
|
||||
delete stateCopy[payload];
|
||||
return stateCopy;
|
||||
},
|
||||
|
||||
[combineActions(embeddableRenderFinished, embeddableRenderError)]: (state, action) => ({
|
||||
...state,
|
||||
[action.payload.panelId]: embeddable(state[action.payload.panelId], action),
|
||||
}),
|
||||
}, {});
|
||||
|
||||
export const getEmbeddable = (state, panelId) => state[panelId];
|
||||
export const getEmbeddableTitle = (state, panelId) => getTitle(getEmbeddable(state, panelId));
|
||||
export const getEmbeddableEditUrl = (state, panelId) => getEditUrl(getEmbeddable(state, panelId));
|
||||
export const getEmbeddableError = (state, panelId) => getError(getEmbeddable(state, panelId));
|
42
src/core_plugins/kibana/public/dashboard/reducers/index.js
Normal file
42
src/core_plugins/kibana/public/dashboard/reducers/index.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { combineReducers } from 'redux';
|
||||
import {
|
||||
embeddables,
|
||||
getEmbeddableTitle as getEmbeddableTitleFromEmbeddables,
|
||||
getEmbeddableEditUrl as getEmbeddableEditUrlFromEmbeddables,
|
||||
getEmbeddableError as getEmbeddableErrorFromEmbeddables,
|
||||
getEmbeddable as getEmbeddableFromEmbeddables,
|
||||
} from './embeddables';
|
||||
|
||||
import {
|
||||
panels,
|
||||
getPanel as getPanelFromPanels,
|
||||
getPanelType as getPanelTypeFromPanels
|
||||
} from './panels';
|
||||
|
||||
import {
|
||||
view,
|
||||
getViewMode as getViewModeFromView,
|
||||
getFullScreenMode as getFullScreenModeFromView,
|
||||
getMaximizedPanelId as getMaximizedPanelIdFromView
|
||||
} from './view';
|
||||
|
||||
export const dashboard = combineReducers({
|
||||
view,
|
||||
panels,
|
||||
embeddables,
|
||||
});
|
||||
|
||||
export const getPanels = state => state.panels;
|
||||
export const getPanel = (state, panelId) => getPanelFromPanels(getPanels(state), panelId);
|
||||
export const getPanelType = (state, panelId) => getPanelTypeFromPanels(getPanels(state), panelId);
|
||||
|
||||
export const getEmbeddables = state => state.embeddables;
|
||||
export const getEmbeddable = (state, panelId) => getEmbeddableFromEmbeddables(getEmbeddables(state), panelId);
|
||||
export const getEmbeddableError = (state, panelId) => getEmbeddableErrorFromEmbeddables(getEmbeddables(state), panelId);
|
||||
export const getEmbeddableTitle = (state, panelId) => getEmbeddableTitleFromEmbeddables(getEmbeddables(state), panelId);
|
||||
export const getEmbeddableEditUrl = (state, panelId) => getEmbeddableEditUrlFromEmbeddables(getEmbeddables(state), panelId);
|
||||
|
||||
export const getView = state => state.view;
|
||||
export const getViewMode = state => getViewModeFromView(getView(state));
|
||||
export const getFullScreenMode = state => getFullScreenModeFromView(getView(state));
|
||||
export const getMaximizedPanelId = state => getMaximizedPanelIdFromView(getView(state));
|
18
src/core_plugins/kibana/public/dashboard/reducers/panel.js
Normal file
18
src/core_plugins/kibana/public/dashboard/reducers/panel.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { handleActions } from 'redux-actions';
|
||||
import _ from 'lodash';
|
||||
|
||||
import {
|
||||
updatePanel,
|
||||
} from '../actions';
|
||||
|
||||
export const panel = handleActions({
|
||||
[updatePanel]: (state, { payload }) => _.defaultsDeep(payload, state),
|
||||
}, {
|
||||
panelIndex: undefined,
|
||||
id: undefined,
|
||||
type: undefined,
|
||||
version: undefined,
|
||||
gridData: {}
|
||||
});
|
||||
|
||||
export const getPanelType = state => state.type;
|
|
@ -0,0 +1,26 @@
|
|||
import { store } from '../../store';
|
||||
import { updatePanel, updatePanels } from '../actions';
|
||||
import { getPanel } from '../reducers';
|
||||
import { getDashboard } from '../../reducers';
|
||||
|
||||
test('UpdatePanel updates a panel', () => {
|
||||
store.dispatch(updatePanels([{ panelIndex: '1', gridData: {} }]));
|
||||
|
||||
store.dispatch(updatePanel({
|
||||
panelIndex: '1',
|
||||
gridData: {
|
||||
x: 1,
|
||||
y: 5,
|
||||
w: 10,
|
||||
h: 1,
|
||||
id: '1'
|
||||
}
|
||||
}));
|
||||
|
||||
const panel = getPanel(getDashboard(store.getState()), '1');
|
||||
expect(panel.gridData.x).toBe(1);
|
||||
expect(panel.gridData.y).toBe(5);
|
||||
expect(panel.gridData.w).toBe(10);
|
||||
expect(panel.gridData.h).toBe(1);
|
||||
expect(panel.gridData.id).toBe('1');
|
||||
});
|
28
src/core_plugins/kibana/public/dashboard/reducers/panels.js
Normal file
28
src/core_plugins/kibana/public/dashboard/reducers/panels.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
import _ from 'lodash';
|
||||
import { handleActions } from 'redux-actions';
|
||||
|
||||
import {
|
||||
deletePanel,
|
||||
updatePanel,
|
||||
updatePanels,
|
||||
} from '../actions';
|
||||
|
||||
import { panel, getPanelType as getPanelTypeFromPanel } from './panel';
|
||||
|
||||
export const panels = handleActions({
|
||||
[updatePanels]: (state, { payload }) => _.cloneDeep(payload),
|
||||
|
||||
[deletePanel]: (state, { payload }) => {
|
||||
const stateCopy = { ...state };
|
||||
delete stateCopy[payload];
|
||||
return stateCopy;
|
||||
},
|
||||
|
||||
[updatePanel]: (state, action) => ({
|
||||
...state,
|
||||
[action.payload.panelIndex]: panel(state[action.payload.panelIndex], action),
|
||||
}),
|
||||
}, {});
|
||||
|
||||
export const getPanel = (state, panelId) => state[panelId];
|
||||
export const getPanelType = (state, panelId) => getPanelTypeFromPanel(getPanel(state, panelId));
|
29
src/core_plugins/kibana/public/dashboard/reducers/view.js
Normal file
29
src/core_plugins/kibana/public/dashboard/reducers/view.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { handleActions, combineActions } from 'redux-actions';
|
||||
import { updateViewMode, maximizePanel, minimizePanel, updateIsFullScreenMode } from '../actions';
|
||||
import { DashboardViewMode } from '../dashboard_view_mode';
|
||||
|
||||
export const view = handleActions({
|
||||
[updateViewMode]: (state, { payload }) => ({
|
||||
...state,
|
||||
viewMode: payload
|
||||
}),
|
||||
|
||||
[combineActions(maximizePanel, minimizePanel)]: (state, { payload }) => ({
|
||||
...state,
|
||||
maximizedPanelId: payload
|
||||
}),
|
||||
|
||||
[updateIsFullScreenMode]: (state, { payload }) => ({
|
||||
...state,
|
||||
isFullScreenMode: payload
|
||||
}),
|
||||
}, {
|
||||
isFullScreenMode: false,
|
||||
viewMode: DashboardViewMode.VIEW,
|
||||
maximizedPanelId: undefined
|
||||
});
|
||||
|
||||
|
||||
export const getViewMode = state => state.viewMode;
|
||||
export const getFullScreenMode = state => state.isFullScreenMode;
|
||||
export const getMaximizedPanelId = state => state.maximizedPanelId;
|
|
@ -31,6 +31,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Need to override the react grid layout height when a single panel is expanded. Important is required because
|
||||
* otherwise the heigh is set inline.
|
||||
*/
|
||||
.layout-maximized-panel {
|
||||
height: 100% !important; /* 1. */
|
||||
}
|
||||
|
||||
.exitFullScreenMode {
|
||||
height: 40px;
|
||||
left: 0px;
|
||||
|
@ -43,7 +51,6 @@
|
|||
z-index: 5;
|
||||
}
|
||||
|
||||
|
||||
.exitFullScreenMode:hover,
|
||||
.exitFullScreenMode:focus {
|
||||
transition: all .5s ease;
|
||||
|
@ -113,17 +120,14 @@
|
|||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Not entirely sure why but when the panel is within the grid, it requires height 100%. When it's an expanded
|
||||
* panel, however, outside the grid, height: 100% will cause the panel not to expand properly.
|
||||
* 2. We need this so the panel menu pop up shows up outside the boundaries of a panel.
|
||||
/**.
|
||||
* 1. We need this so the panel menu pop up shows up outside the boundaries of a panel.
|
||||
*/
|
||||
.react-grid-layout {
|
||||
background-color: @dashboard-bg;
|
||||
|
||||
.dashboard-panel {
|
||||
height: 100%; /* 1. */
|
||||
overflow: visible; /* 2. */
|
||||
overflow: visible; /* 1. */
|
||||
}
|
||||
|
||||
.gs-w {
|
||||
|
@ -135,6 +139,19 @@
|
|||
border: 2px dashed transparent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. For expanded panel mode, we want to hide the grid, but keep it rendered so it doesn't cause all visualizations
|
||||
* to re-render once the panel is minimized, which is a time consuming operation.
|
||||
*/
|
||||
.panel--hidden-mode {
|
||||
display: none; /* 1. */
|
||||
}
|
||||
|
||||
.panel--expanded-mode {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel--edit-mode {
|
||||
border-color: @kibanaGray4;
|
||||
.visualize-show-spy {
|
||||
|
@ -152,9 +169,31 @@
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
dashboard-panel {
|
||||
/**
|
||||
* Needs to correspond with the react root nested inside angular.
|
||||
*/
|
||||
dashboard-viewport-provider {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
[data-reactroot] {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a single panel is expanded, all the other panels are hidden in the grid.
|
||||
*/
|
||||
.grid-item--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* We need to mark this as important because react grid layout sets the width and height of the panels inline.
|
||||
*/
|
||||
.grid-item--expanded {
|
||||
height: 100% !important; /* 1. */
|
||||
width: 100% !important; /* 1. */
|
||||
transform: none !important; /* 1. */
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -167,6 +206,7 @@ dashboard-panel {
|
|||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
background: @dashboard-panel-bg;
|
||||
color: @dashboard-panel-color;
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
class="kuiTab"
|
||||
ng-click="mode='visualization'"
|
||||
aria-label="List visualizations"
|
||||
data-test-subj="addVisualizationTab"
|
||||
>
|
||||
Visualization
|
||||
</button>
|
||||
|
@ -18,6 +19,7 @@
|
|||
class="kuiTab"
|
||||
ng-click="mode='search'"
|
||||
aria-label="List saved searches"
|
||||
data-test-subj="addSavedSearchTab"
|
||||
>
|
||||
Saved Search
|
||||
</button>
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { DashboardGridContainer } from '../grid/dashboard_grid_container';
|
||||
|
||||
export function DashboardViewport({
|
||||
getContainerApi,
|
||||
maximizedPanelId,
|
||||
getEmbeddableHandler,
|
||||
panelCount,
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-shared-items-count={panelCount}
|
||||
>
|
||||
<DashboardGridContainer
|
||||
getEmbeddableHandler={getEmbeddableHandler}
|
||||
getContainerApi={getContainerApi}
|
||||
maximizedPanelId={maximizedPanelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DashboardViewport.propTypes = {
|
||||
getContainerApi: PropTypes.func,
|
||||
getEmbeddableHandler: PropTypes.func,
|
||||
maximizedPanelId: PropTypes.string,
|
||||
panelCount: PropTypes.number,
|
||||
};
|
|
@ -0,0 +1,16 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { DashboardViewport } from './dashboard_viewport';
|
||||
import { getMaximizedPanelId, getPanels } from '../reducers';
|
||||
|
||||
const mapStateToProps = ({ dashboard }) => {
|
||||
const maximizedPanelId = getMaximizedPanelId(dashboard);
|
||||
return {
|
||||
maximizedPanelId,
|
||||
panelCount: Object.keys(getPanels(dashboard)).length,
|
||||
};
|
||||
};
|
||||
|
||||
export const DashboardViewportContainer = connect(
|
||||
mapStateToProps
|
||||
)(DashboardViewport);
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { store } from '../../store';
|
||||
import { Provider } from 'react-redux';
|
||||
import { DashboardViewportContainer } from './dashboard_viewport_container';
|
||||
|
||||
export function DashboardViewportProvider(props) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<DashboardViewportContainer {...props} />
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
DashboardViewportProvider.propTypes = {
|
||||
getContainerApi: PropTypes.func.isRequired,
|
||||
getEmbeddableHandler: PropTypes.func.isRequired,
|
||||
};
|
|
@ -4,8 +4,7 @@ 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';
|
||||
|
||||
import { EmbeddableHandler, Embeddable } from 'ui/embeddable';
|
||||
|
||||
export class SearchEmbeddableHandler extends EmbeddableHandler {
|
||||
|
||||
|
@ -19,7 +18,7 @@ export class SearchEmbeddableHandler extends EmbeddableHandler {
|
|||
}
|
||||
|
||||
getEditPath(panelId) {
|
||||
return this.Promise.resolve(this.searchLoader.urlFor(panelId));
|
||||
return this.searchLoader.urlFor(panelId);
|
||||
}
|
||||
|
||||
getTitleFor(panelId) {
|
||||
|
@ -28,11 +27,8 @@ export class SearchEmbeddableHandler extends EmbeddableHandler {
|
|||
|
||||
render(domNode, panel, container) {
|
||||
const searchScope = this.$rootScope.$new();
|
||||
return this.getEditPath(panel.id)
|
||||
.then(editPath => {
|
||||
searchScope.editPath = editPath;
|
||||
return this.searchLoader.get(panel.id);
|
||||
})
|
||||
searchScope.editPath = this.getEditPath(panel.id);
|
||||
return this.searchLoader.get(panel.id)
|
||||
.then(savedObject => {
|
||||
searchScope.savedObj = savedObject;
|
||||
searchScope.panel = panel;
|
||||
|
@ -79,11 +75,16 @@ export class SearchEmbeddableHandler extends EmbeddableHandler {
|
|||
const rootNode = angular.element(domNode);
|
||||
rootNode.append(searchInstance);
|
||||
|
||||
return () => {
|
||||
this.addDestroyEmeddable(panel.panelIndex, () => {
|
||||
searchInstance.remove();
|
||||
searchScope.savedObj.destroy();
|
||||
searchScope.$destroy();
|
||||
};
|
||||
});
|
||||
|
||||
return new Embeddable({
|
||||
title: savedObject.title,
|
||||
editUrl: searchScope.editPath
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
12
src/core_plugins/kibana/public/reducers.js
Normal file
12
src/core_plugins/kibana/public/reducers.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { combineReducers } from 'redux';
|
||||
import { dashboard } from './dashboard/reducers';
|
||||
|
||||
/**
|
||||
* Only a single reducer now, but eventually there should be one for each sub app that is part of the
|
||||
* core kibana plugins.
|
||||
*/
|
||||
export const reducers = combineReducers({
|
||||
dashboard
|
||||
});
|
||||
|
||||
export const getDashboard = state => state.dashboard;
|
13
src/core_plugins/kibana/public/store.js
Normal file
13
src/core_plugins/kibana/public/store.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { createStore, applyMiddleware, compose } from 'redux';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import { reducers } from './reducers';
|
||||
|
||||
const enhancers = [ applyMiddleware(thunk) ];
|
||||
window.__REDUX_DEVTOOLS_EXTENSION__ && enhancers.push(window.__REDUX_DEVTOOLS_EXTENSION__());
|
||||
|
||||
export const store = createStore(
|
||||
reducers,
|
||||
{},
|
||||
compose(...enhancers)
|
||||
);
|
|
@ -5,7 +5,8 @@ import visualizationTemplate from './visualize_template.html';
|
|||
import { getPersistedStateId } from 'plugins/kibana/dashboard/panel/panel_state';
|
||||
import { UtilsBrushEventProvider as utilsBrushEventProvider } from 'ui/utils/brush_event';
|
||||
import { FilterBarClickHandlerProvider as filterBarClickHandlerProvider } from 'ui/filter_bar/filter_bar_click_handler';
|
||||
import { EmbeddableHandler } from 'ui/embeddable';
|
||||
import { EmbeddableHandler, Embeddable } from 'ui/embeddable';
|
||||
|
||||
import chrome from 'ui/chrome';
|
||||
|
||||
export class VisualizeEmbeddableHandler extends EmbeddableHandler {
|
||||
|
@ -21,20 +22,13 @@ export class VisualizeEmbeddableHandler extends EmbeddableHandler {
|
|||
}
|
||||
|
||||
getEditPath(panelId) {
|
||||
return this.Promise.resolve(this.visualizeLoader.urlFor(panelId));
|
||||
}
|
||||
|
||||
getTitleFor(panelId) {
|
||||
return this.visualizeLoader.get(panelId).then(savedObject => savedObject.title);
|
||||
return this.visualizeLoader.urlFor(panelId);
|
||||
}
|
||||
|
||||
render(domNode, panel, container) {
|
||||
const visualizeScope = this.$rootScope.$new();
|
||||
return this.getEditPath(panel.id)
|
||||
.then(editPath => {
|
||||
visualizeScope.editUrl = editPath;
|
||||
return this.visualizeLoader.get(panel.id);
|
||||
})
|
||||
visualizeScope.editUrl = this.getEditPath(panel.id);
|
||||
return this.visualizeLoader.get(panel.id)
|
||||
.then(savedObject => {
|
||||
visualizeScope.savedObj = savedObject;
|
||||
visualizeScope.panel = panel;
|
||||
|
@ -57,11 +51,16 @@ export class VisualizeEmbeddableHandler extends EmbeddableHandler {
|
|||
const rootNode = angular.element(domNode);
|
||||
rootNode.append(visualizationInstance);
|
||||
|
||||
return () => {
|
||||
this.addDestroyEmeddable(panel.panelIndex, () => {
|
||||
visualizationInstance.remove();
|
||||
visualizeScope.savedObj.destroy();
|
||||
visualizeScope.$destroy();
|
||||
};
|
||||
});
|
||||
|
||||
return new Embeddable({
|
||||
title: savedObject.title,
|
||||
editUrl: visualizeScope.editUrl
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
6
src/ui/public/embeddable/embeddable.js
Normal file
6
src/ui/public/embeddable/embeddable.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export class Embeddable {
|
||||
constructor(config) {
|
||||
this.title = config.title || '';
|
||||
this.editUrl = config.editUrl || '';
|
||||
}
|
||||
}
|
|
@ -3,21 +3,8 @@
|
|||
* container that supports EmbeddableHandlers.
|
||||
*/
|
||||
export class EmbeddableHandler {
|
||||
/**
|
||||
* @param {string} panelId - the id of the panel to grab the title for.
|
||||
* @return {Promise.<string>} a promise that resolves with the path that dictates where the user will be navigated to
|
||||
* when they click the edit icon.
|
||||
*/
|
||||
getEditPath(/* panelId */) {
|
||||
throw new Error('Must implement getEditPath.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} panelId - the id of the panel to grab the title for.
|
||||
* @return {Promise.<string>} - Promise that resolves with the title to display for the particular panel.
|
||||
*/
|
||||
getTitleFor(/* panelId */) {
|
||||
throw new Error('Must implement getTitleFor.');
|
||||
constructor() {
|
||||
this.destroyEmbeddableMap = {};
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -26,10 +13,22 @@ export class EmbeddableHandler {
|
|||
* store per panel information.
|
||||
* @property {ContainerApi} containerApi - an id to specify the object that this panel contains.
|
||||
* @param {Promise.<void>} A promise that resolves when the object is finished rendering.
|
||||
* @return {Promise.<function>} A promise that resolves to a function that should be used to destroy the
|
||||
* @return {Promise.<Embeddable>} A promise that resolves to a function that should be used to destroy the
|
||||
* rendered embeddable.
|
||||
*/
|
||||
render(/* domNode, panel, container */) {
|
||||
throw new Error('Must implement render.');
|
||||
}
|
||||
|
||||
addDestroyEmeddable(panelIndex, destroyEmbeddable) {
|
||||
this.destroyEmbeddableMap[panelIndex] = destroyEmbeddable;
|
||||
}
|
||||
|
||||
destroy(panelIndex) {
|
||||
// Possible there is no destroy function mapped, for instance if there was an error thrown during render.
|
||||
if (this.destroyEmbeddableMap[panelIndex]) {
|
||||
this.destroyEmbeddableMap[panelIndex]();
|
||||
delete this.destroyEmbeddableMap[panelIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,3 +7,4 @@ export const EmbeddableHandlersRegistryProvider = uiRegistry({
|
|||
name: 'embeddableHandlers',
|
||||
index: ['name']
|
||||
});
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export { EmbeddableHandler } from './embeddable_handler';
|
||||
export { Embeddable } from './embeddable';
|
||||
export { EmbeddableHandlersRegistryProvider } from './embeddable_handlers_registry';
|
||||
export { ContainerAPI } from './container_api';
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
name="filter"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
data-test-subj="savedObjectFinderSearchInput"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,9 +1,5 @@
|
|||
import expect from 'expect.js';
|
||||
|
||||
import {
|
||||
DEFAULT_PANEL_WIDTH,
|
||||
} from '../../../../src/core_plugins/kibana/public/dashboard/dashboard_constants';
|
||||
|
||||
import {
|
||||
VisualizeConstants
|
||||
} from '../../../../src/core_plugins/kibana/public/visualize/visualize_constants';
|
||||
|
@ -111,38 +107,6 @@ export default function ({ getService, getPageObjects }) {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Directly modifying url updates dashboard state', () => {
|
||||
it('for query parameter', async function () {
|
||||
const currentQuery = await PageObjects.dashboard.getQuery();
|
||||
expect(currentQuery).to.equal('');
|
||||
const currentUrl = await remote.getCurrentUrl();
|
||||
const newUrl = currentUrl.replace('query:%27%27', 'query:%27hi%27');
|
||||
// Don't add the timestamp to the url or it will cause a hard refresh and we want to test a
|
||||
// soft refresh.
|
||||
await remote.get(newUrl.toString(), false);
|
||||
const newQuery = await PageObjects.dashboard.getQuery();
|
||||
expect(newQuery).to.equal('hi');
|
||||
});
|
||||
|
||||
it('for panel size parameters', async function () {
|
||||
const currentUrl = await remote.getCurrentUrl();
|
||||
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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('expanding a panel', () => {
|
||||
it('hides other panels', async () => {
|
||||
await PageObjects.dashboard.toggleExpandPanel();
|
||||
|
|
|
@ -1,8 +1,15 @@
|
|||
import expect from 'expect.js';
|
||||
|
||||
import { PIE_CHART_VIS_NAME } from '../../page_objects/dashboard_page';
|
||||
import {
|
||||
DEFAULT_PANEL_WIDTH,
|
||||
} from '../../../../src/core_plugins/kibana/public/dashboard/dashboard_constants';
|
||||
|
||||
export default function ({ getService, getPageObjects }) {
|
||||
const PageObjects = getPageObjects(['dashboard', 'visualize', 'header']);
|
||||
const testSubjects = getService('testSubjects');
|
||||
const remote = getService('remote');
|
||||
const retry = getService('retry');
|
||||
|
||||
describe('dashboard state', function describeIndexTests() {
|
||||
before(async function () {
|
||||
|
@ -15,7 +22,49 @@ export default function ({ getService, getPageObjects }) {
|
|||
await PageObjects.header.clickDashboard();
|
||||
});
|
||||
|
||||
after(async function () {
|
||||
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||
});
|
||||
|
||||
describe('Directly modifying url updates dashboard state', () => {
|
||||
it('for query parameter', async function () {
|
||||
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||
await PageObjects.dashboard.clickNewDashboard();
|
||||
|
||||
const currentQuery = await PageObjects.dashboard.getQuery();
|
||||
expect(currentQuery).to.equal('');
|
||||
const currentUrl = await remote.getCurrentUrl();
|
||||
const newUrl = currentUrl.replace('query:%27%27', 'query:%27hi%27');
|
||||
// Don't add the timestamp to the url or it will cause a hard refresh and we want to test a
|
||||
// soft refresh.
|
||||
await remote.get(newUrl.toString(), false);
|
||||
const newQuery = await PageObjects.dashboard.getQuery();
|
||||
expect(newQuery).to.equal('hi');
|
||||
});
|
||||
|
||||
it('for panel size parameters', async function () {
|
||||
await PageObjects.dashboard.addVisualization(PIE_CHART_VIS_NAME);
|
||||
const currentUrl = await remote.getCurrentUrl();
|
||||
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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Tile map with no changes will update with visualization changes', async () => {
|
||||
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||
|
||||
await PageObjects.dashboard.clickNewDashboard();
|
||||
await PageObjects.dashboard.setTimepickerInDataRange();
|
||||
await PageObjects.dashboard.addVisualizations(['Visualization TileMap']);
|
||||
|
|
165
test/functional/apps/dashboard/_panel_controls.js
Normal file
165
test/functional/apps/dashboard/_panel_controls.js
Normal file
|
@ -0,0 +1,165 @@
|
|||
import expect from 'expect.js';
|
||||
|
||||
import { PIE_CHART_VIS_NAME } from '../../page_objects/dashboard_page';
|
||||
import {
|
||||
VisualizeConstants
|
||||
} from '../../../../src/core_plugins/kibana/public/visualize/visualize_constants';
|
||||
|
||||
export default function ({ getService, getPageObjects }) {
|
||||
const testSubjects = getService('testSubjects');
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
const remote = getService('remote');
|
||||
const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'discover']);
|
||||
const dashboardName = 'Dashboard Panel Controls Test';
|
||||
|
||||
describe('dashboard panel controls', function viewEditModeTests() {
|
||||
before(async function () {
|
||||
await PageObjects.dashboard.initTests();
|
||||
await kibanaServer.uiSettings.disableToastAutohide();
|
||||
await remote.refresh();
|
||||
|
||||
// This flip between apps fixes the url so state is preserved when switching apps in test mode.
|
||||
// Without this flip the url in test mode looks something like
|
||||
// "http://localhost:5620/app/kibana?_t=1486069030837#/dashboard?_g=...."
|
||||
// after the initial flip, the url will look like this: "http://localhost:5620/app/kibana#/dashboard?_g=...."
|
||||
await PageObjects.header.clickVisualize();
|
||||
await PageObjects.header.clickDashboard();
|
||||
});
|
||||
|
||||
after(async function () {
|
||||
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||
});
|
||||
|
||||
describe('panel edit controls', function () {
|
||||
before(async() => {
|
||||
await PageObjects.dashboard.clickNewDashboard();
|
||||
await PageObjects.dashboard.setTimepickerInDataRange();
|
||||
await PageObjects.dashboard.addVisualization(PIE_CHART_VIS_NAME);
|
||||
});
|
||||
|
||||
it('are hidden in view mode', async function () {
|
||||
await PageObjects.dashboard.saveDashboard(dashboardName);
|
||||
await PageObjects.header.clickToastOK();
|
||||
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 removeExists = await testSubjects.exists('dashboardPanelRemoveIcon');
|
||||
|
||||
expect(editLinkExists).to.equal(true);
|
||||
expect(removeExists).to.equal(true);
|
||||
});
|
||||
|
||||
// Based off an actual bug encountered in a PR where a hard refresh in edit mode did not show the edit mode
|
||||
// controls.
|
||||
it ('are shown in edit mode after a hard refresh', async () => {
|
||||
const currentUrl = await remote.getCurrentUrl();
|
||||
// the second parameter of true will include the timestamp in the url and trigger a hard refresh.
|
||||
await remote.get(currentUrl.toString(), true);
|
||||
|
||||
await PageObjects.dashboard.showPanelEditControlsDropdownMenu();
|
||||
const editLinkExists = await testSubjects.exists('dashboardPanelEditLink');
|
||||
const removeExists = await testSubjects.exists('dashboardPanelRemoveIcon');
|
||||
|
||||
expect(editLinkExists).to.equal(true);
|
||||
expect(removeExists).to.equal(true);
|
||||
|
||||
// Get rid of the timestamp in the url.
|
||||
await remote.get(currentUrl.toString(), false);
|
||||
});
|
||||
|
||||
|
||||
describe('on an expanded panel', function () {
|
||||
it('are hidden in view mode', async function () {
|
||||
await PageObjects.dashboard.saveDashboard(dashboardName);
|
||||
await PageObjects.header.clickToastOK();
|
||||
await PageObjects.dashboard.toggleExpandPanel();
|
||||
|
||||
const panelToggleMenu = await testSubjects.exists('dashboardPanelToggleMenuIcon');
|
||||
expect(panelToggleMenu).to.equal(false);
|
||||
});
|
||||
|
||||
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 removeExists = await testSubjects.exists('dashboardPanelRemoveIcon');
|
||||
|
||||
expect(editLinkExists).to.equal(true);
|
||||
expect(removeExists).to.equal(false);
|
||||
|
||||
await PageObjects.dashboard.toggleExpandPanel();
|
||||
});
|
||||
});
|
||||
|
||||
describe('visualization object edit menu', () => {
|
||||
it('opens a visualization when edit link is clicked', async () => {
|
||||
await PageObjects.dashboard.clickDashboardPanelEditLink();
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
const currentUrl = await remote.getCurrentUrl();
|
||||
expect(currentUrl).to.contain(VisualizeConstants.EDIT_PATH);
|
||||
});
|
||||
|
||||
it('deletes the visualization when delete link is clicked', async () => {
|
||||
await PageObjects.header.clickDashboard();
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.dashboard.clickDashboardPanelRemoveIcon();
|
||||
|
||||
const panelCount = await PageObjects.dashboard.getPanelCount();
|
||||
expect(panelCount).to.be(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saved search object edit menu', () => {
|
||||
before(async () => {
|
||||
await PageObjects.header.clickDiscover();
|
||||
await PageObjects.discover.clickFieldListItemAdd('bytes');
|
||||
await PageObjects.discover.saveSearch('my search');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.header.clickToastOK();
|
||||
await PageObjects.header.clickDashboard();
|
||||
await PageObjects.dashboard.addSavedSearch('my search');
|
||||
|
||||
const panelCount = await PageObjects.dashboard.getPanelCount();
|
||||
expect(panelCount).to.be(1);
|
||||
});
|
||||
|
||||
it('opens a saved search when edit link is clicked', async () => {
|
||||
await PageObjects.dashboard.clickDashboardPanelEditLink();
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
const queryName = await PageObjects.discover.getCurrentQueryName();
|
||||
expect(queryName).to.be('my search');
|
||||
});
|
||||
|
||||
it('deletes the saved search when delete link is clicked', async () => {
|
||||
await PageObjects.header.clickDashboard();
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.dashboard.clickDashboardPanelRemoveIcon();
|
||||
|
||||
const panelCount = await PageObjects.dashboard.getPanelCount();
|
||||
expect(panelCount).to.be(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Panel expand should also be shown in view mode, but only on mouse hover.
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -47,65 +47,6 @@ export default function ({ getService, getPageObjects }) {
|
|||
expect(inViewMode).to.equal(true);
|
||||
});
|
||||
|
||||
describe('panel edit controls', function () {
|
||||
it('are hidden in view mode', async function () {
|
||||
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||
await PageObjects.dashboard.clickDashboardByLinkText(dashboardName);
|
||||
|
||||
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 removeExists = await testSubjects.exists('dashboardPanelRemoveIcon');
|
||||
|
||||
expect(editLinkExists).to.equal(true);
|
||||
expect(removeExists).to.equal(true);
|
||||
});
|
||||
|
||||
describe('on an expanded panel', function () {
|
||||
it('are hidden in view mode', async function () {
|
||||
await PageObjects.dashboard.saveDashboard(dashboardName);
|
||||
await PageObjects.header.clickToastOK();
|
||||
await PageObjects.dashboard.toggleExpandPanel();
|
||||
|
||||
const panelToggleMenu = await testSubjects.exists('dashboardPanelToggleMenuIcon');
|
||||
expect(panelToggleMenu).to.equal(false);
|
||||
});
|
||||
|
||||
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 removeExists = await testSubjects.exists('dashboardPanelRemoveIcon');
|
||||
|
||||
expect(editLinkExists).to.equal(true);
|
||||
expect(removeExists).to.equal(false);
|
||||
|
||||
await PageObjects.dashboard.toggleExpandPanel();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Panel expand should also be shown in view mode, but only on mouse hover.
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save', function () {
|
||||
it('auto exits out of edit mode', async function () {
|
||||
await PageObjects.dashboard.gotoDashboardEditMode(dashboardName);
|
||||
|
|
|
@ -4,6 +4,7 @@ export default function ({ getService, loadTestFile }) {
|
|||
describe('dashboard app', function () {
|
||||
before(() => remote.setWindowSize(1200, 900));
|
||||
|
||||
loadTestFile(require.resolve('./_panel_controls'));
|
||||
loadTestFile(require.resolve('./_view_edit'));
|
||||
loadTestFile(require.resolve('./_dashboard'));
|
||||
loadTestFile(require.resolve('./_dashboard_state'));
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import _ from 'lodash';
|
||||
import { DashboardConstants } from '../../../src/core_plugins/kibana/public/dashboard/dashboard_constants';
|
||||
|
||||
export const PIE_CHART_VIS_NAME = 'Visualization PieChart';
|
||||
|
||||
export function DashboardPageProvider({ getService, getPageObjects }) {
|
||||
const log = getService('log');
|
||||
const find = getService('find');
|
||||
|
@ -220,6 +222,7 @@ export function DashboardPageProvider({ getService, getPageObjects }) {
|
|||
const visFilter = await find.byCssSelector('input[placeholder="Visualizations Filter..."]');
|
||||
await visFilter.click();
|
||||
await remote.pressKeys(vizName);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
}
|
||||
|
||||
async clickVizNameLink(vizName) {
|
||||
|
@ -236,6 +239,25 @@ export function DashboardPageProvider({ getService, getPageObjects }) {
|
|||
await this.clickEdit();
|
||||
}
|
||||
|
||||
async filterSearchNames(name) {
|
||||
await testSubjects.setValue('savedObjectFinderSearchInput', name);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
}
|
||||
|
||||
async clickSavedSearchTab() {
|
||||
await testSubjects.click('addSavedSearchTab');
|
||||
}
|
||||
|
||||
async addSavedSearch(searchName) {
|
||||
await this.clickAddVisualization();
|
||||
await this.clickSavedSearchTab();
|
||||
await this.filterSearchNames(searchName);
|
||||
|
||||
await find.clickByPartialLinkText(searchName);
|
||||
await PageObjects.header.clickToastOK();
|
||||
await this.clickAddVisualization();
|
||||
}
|
||||
|
||||
async addVisualization(vizName) {
|
||||
await this.clickAddVisualization();
|
||||
log.debug('filter visualization (' + vizName + ')');
|
||||
|
@ -401,7 +423,7 @@ export function DashboardPageProvider({ getService, getPageObjects }) {
|
|||
|
||||
getTestVisualizations() {
|
||||
return [
|
||||
{ name: 'Visualization PieChart', description: 'PieChart' },
|
||||
{ name: PIE_CHART_VIS_NAME, description: 'PieChart' },
|
||||
{ name: 'Visualization☺ VerticalBarChart', description: 'VerticalBarChart' },
|
||||
{ name: 'Visualization漢字 AreaChart', description: 'AreaChart' },
|
||||
{ name: 'Visualization☺漢字 DataTable', description: 'DataTable' },
|
||||
|
@ -415,6 +437,22 @@ export function DashboardPageProvider({ getService, getPageObjects }) {
|
|||
return this.getTestVisualizations().map(visualization => visualization.name);
|
||||
}
|
||||
|
||||
async showPanelEditControlsDropdownMenu() {
|
||||
const editLinkExists = await testSubjects.exists('dashboardPanelEditLink');
|
||||
if (editLinkExists) return;
|
||||
await testSubjects.click('dashboardPanelToggleMenuIcon');
|
||||
}
|
||||
|
||||
async clickDashboardPanelEditLink() {
|
||||
await this.showPanelEditControlsDropdownMenu();
|
||||
await testSubjects.click('dashboardPanelEditLink');
|
||||
}
|
||||
|
||||
async clickDashboardPanelRemoveIcon() {
|
||||
await this.showPanelEditControlsDropdownMenu();
|
||||
await testSubjects.click('dashboardPanelRemoveIcon');
|
||||
}
|
||||
|
||||
async addVisualizations(visualizations) {
|
||||
for (const vizName of visualizations) {
|
||||
await this.addVisualization(vizName);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue