Introduce redux into dashboard (#14518) (#14521)

* 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:
Stacey Gammon 2017-10-23 21:31:55 -04:00 committed by GitHub
parent 2765fea3e3
commit e5e3dbb94d
56 changed files with 1414 additions and 676 deletions

View file

@ -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",

View file

@ -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);
});

View file

@ -0,0 +1,10 @@
export function getContainerApiMock(config = {}) {
const containerApiMockDefaults = {
addFilter: () => {},
getAppState: () => {},
createChildUistate: () => {},
registerPanelIndexPattern: () => {},
updatePanel: () => {}
};
return Object.assign(containerApiMockDefaults, config);
}

View file

@ -0,0 +1,8 @@
export function getEmbeddableHandlerMock(config) {
const embeddableHandlerMockDefaults = {
getEditPath: () => {},
getTitleFor: () => {},
render: jest.fn()
};
return Object.assign(embeddableHandlerMockDefaults, config);
}

View file

@ -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));
});
};
}

View 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';

View 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;
});

View 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');

View file

@ -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>

View file

@ -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();
}
});

View file

@ -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() {

View file

@ -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();
}
}

View file

@ -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)>

View file

@ -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,
};

View file

@ -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);
});

View file

@ -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);

View file

@ -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);
});

View file

@ -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
}));
}
}
});

View file

@ -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']));
}
}

View file

@ -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,
};
}

View file

@ -0,0 +1,2 @@
export { saveDashboard } from './save_dashboard';
export { getAppStateDefaults } from './get_app_state_defaults';

View file

@ -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;
});
}

View file

@ -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;
}

View file

@ -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
]),
};

View file

@ -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);
});

View file

@ -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);

View file

@ -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

View file

@ -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;
}
/**

View file

@ -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;

View file

@ -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);
});
});
});

View file

@ -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));

View 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));

View 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;

View file

@ -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');
});

View 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));

View 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;

View file

@ -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;

View file

@ -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>

View file

@ -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,
};

View file

@ -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);

View file

@ -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,
};

View file

@ -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
});
});
}
}

View 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;

View 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)
);

View file

@ -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
});
});
}
}

View file

@ -0,0 +1,6 @@
export class Embeddable {
constructor(config) {
this.title = config.title || '';
this.editUrl = config.editUrl || '';
}
}

View file

@ -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];
}
}
}

View file

@ -7,3 +7,4 @@ export const EmbeddableHandlersRegistryProvider = uiRegistry({
name: 'embeddableHandlers',
index: ['name']
});

View file

@ -1,3 +1,4 @@
export { EmbeddableHandler } from './embeddable_handler';
export { Embeddable } from './embeddable';
export { EmbeddableHandlersRegistryProvider } from './embeddable_handlers_registry';
export { ContainerAPI } from './container_api';

View file

@ -16,6 +16,7 @@
name="filter"
type="text"
autocomplete="off"
data-test-subj="savedObjectFinderSearchInput"
>
</div>
</div>

View file

@ -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();

View file

@ -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']);

View 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);
});
});
});
}

View file

@ -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);

View file

@ -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'));

View file

@ -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);