mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
Fleshed out communication layer between embeddables and dashboard (#17446)
* Flesh out communication layer between embeddables and dashboard * fix flaky legend colors * Address code review comments * Remove embeddableHandlerCache and push render/destroy handling of Embeddable instance into react component * Fix a bug and adds tests that would have failed * Whoops, fix toggleExpandPanel, in view mode it's not in the panel options * Update readme based on newest implementation, after pushing all embeddable lifecycle handling to the embeddable_viewport component * Push embeddable handling back up to dashboard_panel, get rid of embeddable_viewport The options menu will eventually need access to the embeddable for things like pluggable panel actions. * Update README.md * Fix jest tests * Add two failing tests to catch current bugs * Fix max call size exceeded err * fix time range bug * Add failing test for filter field index pattern bug * Fix bug with index patterns * Expand on definition of an embeddable * Address code review comments * address some code comments rename personalization => customization add more optional config overrides into an Embeddable constructor * Fix refactor bug
This commit is contained in:
parent
4eac8ed4f2
commit
33262a7d5a
61 changed files with 1433 additions and 684 deletions
174
src/core_plugins/kibana/public/dashboard/README.md
Normal file
174
src/core_plugins/kibana/public/dashboard/README.md
Normal file
|
@ -0,0 +1,174 @@
|
|||
## Dashboard State Walkthrough
|
||||
|
||||
A high level walk through of types of dashboard state and how dashboard and
|
||||
embeddables communicate with each other. An "embeddable" is anything that can be dropped
|
||||
on a dashboard. It is a pluggable system so new embeddables can be created, as
|
||||
long as they adhere to the communication protocol. Currently the only two embeddable types
|
||||
are saved searches and visualizations. A truly pluggable embeddable system is still a
|
||||
WIP - as the UI currently only supports adding visualizations and saved searches to a dashboard.
|
||||
|
||||
|
||||
### Types of state
|
||||
|
||||
**Embeddable metadata** - Data the embeddable instance gives the dashboard once as a
|
||||
return value of EmbeddableFactory.create. Data such as edit link and title go in
|
||||
here. We may later decide to move some of this data to the dynamic embeddable state
|
||||
(for instance, if we implemented inline editing a title could change), but we keep the
|
||||
separation because it allows us to force some consistency in our UX. For example, we may
|
||||
not want a visualization to all of a sudden go from supporting drilldown links to
|
||||
not supporting it, as it would mean disappearing panel context menu items.
|
||||
|
||||
**Embeddable state** - Data the embeddable gives the dashboard throughout it's lifecycle as
|
||||
things update and the user interacts with it. This is communicated to the dashboard via the
|
||||
function `onEmbeddableStateChanged` that is passed in to the `EmbeddableFactory.create` call.
|
||||
|
||||
**Container state** - Data the dashboard gives to the embeddable throughout it's lifecycle
|
||||
as things update and the user interacts with Kibana. This is communicated to the embeddable via
|
||||
the function `onContainerStateChanged` which is returned from the `EmbeddableFactory.create` call
|
||||
|
||||
**Container metadata** - State that only needs to be given to the embeddable once,
|
||||
and does not change thereafter. This will contain data given to dashboard when a new embeddable is
|
||||
added to a dashboard. Currently, this is really only the saved object id.
|
||||
|
||||
**Dashboard storage data** - Data persisted in elasticsearch. Should not be coupled to the redux tree.
|
||||
|
||||
**Dashboard redux tree** - State stored in the dashboard redux tree.
|
||||
|
||||
**EmbeddableFactory metadata** - Data that is true for all instances of the given type and does not change.
|
||||
I'm not sure if *any* data belongs here but we talked about it, so keeping it in here. We thought initially
|
||||
it could be supportsDrillDowns but for type visualizations, for example, this depends on the visualization
|
||||
"subtype" (e.g. input controls vs line chart).
|
||||
|
||||
|
||||
|
||||
### Dashboard/Embeddable communication psuedocode
|
||||
```js
|
||||
dashboard_panel.js:
|
||||
|
||||
// The Dashbaord Panel react component handles the lifecycle of the
|
||||
// embeddable as well as rendering. If we ever need access to the embeddable
|
||||
// object externally, we may need to rethink this.
|
||||
class EmbeddableViewport extends Component {
|
||||
componentDidMount() {
|
||||
if (!initialized) {
|
||||
this.props.embeddableFactory.create(panelMetadata, this.props.embeddableStateChanged)
|
||||
.then(embeddable => {
|
||||
this.embeddable = embeddable;
|
||||
this.embeddable.onContainerStateChanged(this.props.containerState);
|
||||
this.embeddable.render(this.panelElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.embeddable.destroy();
|
||||
}
|
||||
|
||||
// We let react/redux tell us when to tell the embeddable that some container
|
||||
// state changed.
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.embeddable && !_.isEqual(prevProps.containerState, this.props.containerState)) {
|
||||
this.embeddable.onContainerStateChanged(this.props.containerState);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<PanelHeaderContainer embeddable={this.embeddable} />
|
||||
<div ref={panelElement => this.panelElement = panelElement}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
------
|
||||
actions/embeddable.js:
|
||||
|
||||
/**
|
||||
* This is the main communication point for embeddables to send state
|
||||
* changes to dashboard.
|
||||
* @param {EmbeddableState} newEmbeddableState
|
||||
*/
|
||||
function onEmbeddableStateChanged(newEmbeddableState) {
|
||||
// Map embeddable state properties into our redux tree.
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### Container state
|
||||
State communicated to the embeddable.
|
||||
```
|
||||
{
|
||||
// Contains per panel customizations like sort, columns, and color choices.
|
||||
// This shape is defined by the embeddable. Dashboard stores it and tracks updates
|
||||
// to it.
|
||||
embeddableCustomization: Object,
|
||||
hidePanelTitles: boolean,
|
||||
title: string,
|
||||
|
||||
// TODO:
|
||||
filters: FilterObject,
|
||||
timeRange: TimeRangeObject,
|
||||
darkTheme: boolean,
|
||||
}
|
||||
```
|
||||
|
||||
### Container metadata
|
||||
```
|
||||
{
|
||||
// Any shape needed to initialize an embeddable. Gets saved to storage. Created when
|
||||
// a new embeddable is added. Currently just includes the object id.
|
||||
embeddableConfiguration: Object,
|
||||
}
|
||||
```
|
||||
|
||||
### Embeddable Metadata
|
||||
```
|
||||
{
|
||||
// Index patterns used by this embeddable. This information is currently
|
||||
// used by the filter on a dashboard for which fields to show in the
|
||||
// dropdown. Otherwise we'd have to show all fields over all indexes and
|
||||
// if no embeddables use those index patterns, there really is no point
|
||||
// to filtering on them.
|
||||
indexPatterns: Array.<IndexPatterns>,
|
||||
|
||||
// Dashboard navigates to this url when the user clicks 'Edit visualization'
|
||||
// in the panel context menu.
|
||||
editUrl: string,
|
||||
|
||||
// Title to be shown in the panel. Can be overridden at the panel level.
|
||||
title: string,
|
||||
|
||||
// TODO:
|
||||
// If this is true, then dashboard will show a "configure drill down
|
||||
// links" menu option in the context menu for the panel.
|
||||
supportsDrillDowns: boolean,
|
||||
}
|
||||
```
|
||||
|
||||
### Embeddable State
|
||||
Embeddable state is the data that the embeddable gives dashboard when something changes
|
||||
|
||||
```
|
||||
{
|
||||
// This will contain per panel embeddable state, such as pie colors and saved search columns.
|
||||
embeddableCustomization: Object,
|
||||
// If a filter action was initiated by a user action (e.g. clicking on a bar chart)
|
||||
// This is how dashboard will know and update itself to match.
|
||||
stagedFilters: FilterObject,
|
||||
|
||||
|
||||
// TODO: More possible options to go in here:
|
||||
error: Error,
|
||||
isLoading: boolean,
|
||||
renderComplete: boolean,
|
||||
appliedtimeRange: TimeRangeObject,
|
||||
stagedTimeRange: TimeRangeObject,
|
||||
// This information will need to be exposed so other plugins (e.g. ML)
|
||||
// can register panel actions.
|
||||
esQuery: Object,
|
||||
// Currently applied filters
|
||||
appliedFilters: FilterObject,
|
||||
}
|
||||
```
|
|
@ -1,102 +0,0 @@
|
|||
import ngMock from 'ng_mock';
|
||||
import expect from 'expect.js';
|
||||
|
||||
import { DashboardStateManager } from '../dashboard_state_manager';
|
||||
|
||||
describe('DashboardState', function () {
|
||||
let AppState;
|
||||
let dashboardState;
|
||||
let savedDashboard;
|
||||
let SavedDashboard;
|
||||
let timefilter;
|
||||
let dashboardConfig;
|
||||
const mockQuickTimeRanges = [{ from: 'now/w', to: 'now/w', display: 'This week', section: 0 }];
|
||||
const mockIndexPattern = { id: 'index1' };
|
||||
|
||||
function initDashboardState() {
|
||||
dashboardState = new DashboardStateManager(savedDashboard, AppState, dashboardConfig);
|
||||
}
|
||||
|
||||
beforeEach(ngMock.module('kibana'));
|
||||
beforeEach(ngMock.inject(function ($injector) {
|
||||
timefilter = $injector.get('timefilter');
|
||||
AppState = $injector.get('AppState');
|
||||
SavedDashboard = $injector.get('SavedDashboard');
|
||||
dashboardConfig = $injector.get('dashboardConfig');
|
||||
savedDashboard = new SavedDashboard();
|
||||
}));
|
||||
|
||||
describe('syncTimefilterWithDashboard', function () {
|
||||
it('syncs quick time', function () {
|
||||
savedDashboard.timeRestore = true;
|
||||
savedDashboard.timeFrom = 'now/w';
|
||||
savedDashboard.timeTo = 'now/w';
|
||||
|
||||
timefilter.time.from = '2015-09-19 06:31:44.000';
|
||||
timefilter.time.to = '2015-09-29 06:31:44.000';
|
||||
timefilter.time.mode = 'absolute';
|
||||
|
||||
initDashboardState();
|
||||
dashboardState.syncTimefilterWithDashboard(timefilter, mockQuickTimeRanges);
|
||||
|
||||
expect(timefilter.time.mode).to.equal('quick');
|
||||
expect(timefilter.time.to).to.equal('now/w');
|
||||
expect(timefilter.time.from).to.equal('now/w');
|
||||
});
|
||||
|
||||
it('syncs relative time', function () {
|
||||
savedDashboard.timeRestore = true;
|
||||
savedDashboard.timeFrom = 'now-13d';
|
||||
savedDashboard.timeTo = 'now';
|
||||
|
||||
timefilter.time.from = '2015-09-19 06:31:44.000';
|
||||
timefilter.time.to = '2015-09-29 06:31:44.000';
|
||||
timefilter.time.mode = 'absolute';
|
||||
|
||||
initDashboardState();
|
||||
dashboardState.syncTimefilterWithDashboard(timefilter, mockQuickTimeRanges);
|
||||
|
||||
expect(timefilter.time.mode).to.equal('relative');
|
||||
expect(timefilter.time.to).to.equal('now');
|
||||
expect(timefilter.time.from).to.equal('now-13d');
|
||||
});
|
||||
|
||||
it('syncs absolute time', function () {
|
||||
savedDashboard.timeRestore = true;
|
||||
savedDashboard.timeFrom = '2015-09-19 06:31:44.000';
|
||||
savedDashboard.timeTo = '2015-09-29 06:31:44.000';
|
||||
|
||||
timefilter.time.from = 'now/w';
|
||||
timefilter.time.to = 'now/w';
|
||||
timefilter.time.mode = 'quick';
|
||||
|
||||
initDashboardState();
|
||||
dashboardState.syncTimefilterWithDashboard(timefilter, mockQuickTimeRanges);
|
||||
|
||||
expect(timefilter.time.mode).to.equal('absolute');
|
||||
expect(timefilter.time.to).to.equal(savedDashboard.timeTo);
|
||||
expect(timefilter.time.from).to.equal(savedDashboard.timeFrom);
|
||||
});
|
||||
});
|
||||
|
||||
describe('panelIndexPatternMapping', function () {
|
||||
it('registers index pattern', function () {
|
||||
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 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 DashboardStateManager(savedDashboard, AppState, dashboardConfig);
|
||||
state.registerPanelIndexPatternMap('markdownPanel1', undefined);
|
||||
expect(state.getPanelIndexPatterns().length).to.equal(0);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* A poor excuse for a mock just to get some basic tests to run in jest without requiring the injector.
|
||||
* This could be improved if we extract the appState and state classes externally of their angular providers.
|
||||
* @return {AppStateMock}
|
||||
*/
|
||||
export function getAppStateMock() {
|
||||
class AppStateMock {
|
||||
constructor(defaults) {
|
||||
Object.assign(this, defaults);
|
||||
}
|
||||
|
||||
on() {}
|
||||
off() {}
|
||||
toJSON() { return ''; }
|
||||
save() {}
|
||||
}
|
||||
|
||||
return AppStateMock;
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
export function getContainerApiMock(config = {}) {
|
||||
const containerApiMockDefaults = {
|
||||
addFilter: () => {},
|
||||
getAppState: () => {},
|
||||
registerPanelIndexPattern: () => {},
|
||||
updatePanel: () => {}
|
||||
};
|
||||
return Object.assign(containerApiMockDefaults, config);
|
||||
}
|
|
@ -1,11 +1,7 @@
|
|||
/* global jest */
|
||||
export function getEmbeddableFactoryMock(config) {
|
||||
const embeddableFactoryMockDefaults = {
|
||||
getEditPath: () => {},
|
||||
getTitleFor: () => {},
|
||||
render: jest.fn(() => Promise.resolve({})),
|
||||
destroy: () => {},
|
||||
addDestroyEmeddable: () => {},
|
||||
create: jest.fn(() => Promise.resolve({})),
|
||||
};
|
||||
return Object.assign(embeddableFactoryMockDefaults, config);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
|
||||
export function getSavedDashboardMock(config) {
|
||||
const defaults = {
|
||||
id: '123',
|
||||
title: 'my dashboard',
|
||||
panelsJSON: '[]',
|
||||
searchSource: {
|
||||
getOwn: (param) => param
|
||||
}
|
||||
};
|
||||
return Object.assign(defaults, config);
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export { getAppStateMock } from './get_app_state_mock';
|
||||
export { getSavedDashboardMock } from './get_saved_dashboard_mock';
|
||||
export { getEmbeddableFactoryMock } from './get_embeddable_factories_mock';
|
|
@ -1,58 +1,41 @@
|
|||
import { createAction } from 'redux-actions';
|
||||
import _ from 'lodash';
|
||||
|
||||
export const destroyEmbeddable = createAction('DESTROY_EMBEDDABLE',
|
||||
/**
|
||||
*
|
||||
* @param panelId {string}
|
||||
* @param embeddableFactory {EmbeddableFactory}
|
||||
* @return {string} - the panel id
|
||||
*/
|
||||
(panelId, embeddableFactory) => {
|
||||
if (embeddableFactory) {
|
||||
embeddableFactory.destroy(panelId);
|
||||
}
|
||||
return panelId;
|
||||
}
|
||||
);
|
||||
import {
|
||||
updatePanel
|
||||
} from './panels';
|
||||
|
||||
export const embeddableRenderFinished = createAction('EMBEDDABLE_RENDER_FINISHED',
|
||||
/**
|
||||
* @param panelId {string}
|
||||
* @param embeddable {Embeddable}
|
||||
*/
|
||||
(panelId, embeddable) => ({ embeddable, panelId })
|
||||
);
|
||||
import {
|
||||
getPanel,
|
||||
getEmbeddableCustomization,
|
||||
} from '../../selectors/dashboard_selectors';
|
||||
|
||||
export const embeddableRenderError = createAction('EMBEDDABLE_RENDER_ERROR',
|
||||
/**
|
||||
*
|
||||
* @param panelId {string}
|
||||
* @param error {string|object}
|
||||
*/
|
||||
(panelId, error) => ({ panelId, error })
|
||||
);
|
||||
export const embeddableIsInitializing = createAction('EMBEDDABLE_IS_INITIALIZING');
|
||||
export const embeddableIsInitialized = createAction('EMBEDDABLE_INITIALIZED');
|
||||
export const setStagedFilter = createAction('SET_STAGED_FILTER');
|
||||
export const clearStagedFilters = createAction('CLEAR_STAGED_FILTERS');
|
||||
export const embeddableError = createAction('EMBEDDABLE_ERROR');
|
||||
|
||||
/**
|
||||
*
|
||||
* @param embeddableFactory {EmbeddableFactory}
|
||||
* @param panelElement {Node}
|
||||
* @param panel {PanelState}
|
||||
* @param containerApi {ContainerAPI}
|
||||
* @return {function(*, *)}
|
||||
* The main point of communication from the embeddable to the dashboard. Any time state in the embeddable
|
||||
* changes, this function will be called. The data is then extracted from EmbeddableState and stored in
|
||||
* redux so the appropriate actions are taken and UI updated.
|
||||
|
||||
* @param {string} panelId - the id of the panel whose state has changed.
|
||||
* @param {EmbeddableState} embeddableState - the new state of the embeddable.
|
||||
*/
|
||||
export function renderEmbeddable(embeddableFactory, panelElement, panel, containerApi) {
|
||||
return (dispatch) => {
|
||||
if (!embeddableFactory) {
|
||||
dispatch(embeddableRenderError(panel.panelIndex, new Error(`Invalid embeddable type "${panel.type}"`)));
|
||||
return;
|
||||
export function embeddableStateChanged({ panelId, embeddableState }) {
|
||||
return (dispatch, getState) => {
|
||||
// Translate embeddableState to things redux cares about.
|
||||
const customization = getEmbeddableCustomization(getState(), panelId);
|
||||
if (!_.isEqual(embeddableState.customization, customization)) {
|
||||
const panel = getPanel(getState(), panelId);
|
||||
dispatch(updatePanel({ ...panel, embeddableConfig: _.cloneDeep(embeddableState.customization) }));
|
||||
}
|
||||
|
||||
return embeddableFactory.render(panelElement, panel, containerApi)
|
||||
.then(embeddable => {
|
||||
return dispatch(embeddableRenderFinished(panel.panelIndex, embeddable));
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch(embeddableRenderError(panel.panelIndex, error.message));
|
||||
});
|
||||
if (embeddableState.stagedFilter) {
|
||||
dispatch(setStagedFilter({ stagedFilter: embeddableState.stagedFilter, panelId }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import { store } from '../../store';
|
||||
import {
|
||||
clearStagedFilters,
|
||||
setStagedFilter,
|
||||
embeddableIsInitialized,
|
||||
embeddableIsInitializing,
|
||||
} from '../actions';
|
||||
|
||||
import {
|
||||
getStagedFilters,
|
||||
} from '../../selectors';
|
||||
|
||||
beforeAll(() => {
|
||||
store.dispatch(embeddableIsInitializing('foo1'));
|
||||
store.dispatch(embeddableIsInitializing('foo2'));
|
||||
store.dispatch(embeddableIsInitialized({ panelId: 'foo1', metadata: {} }));
|
||||
store.dispatch(embeddableIsInitialized({ panelId: 'foo2', metadata: {} }));
|
||||
});
|
||||
|
||||
describe('staged filters', () => {
|
||||
test('getStagedFilters initially is empty', () => {
|
||||
const stagedFilters = getStagedFilters(store.getState());
|
||||
expect(stagedFilters.length).toBe(0);
|
||||
});
|
||||
|
||||
test('can set a staged filter', () => {
|
||||
store.dispatch(setStagedFilter({ stagedFilter: ['imafilter'], panelId: 'foo1' }));
|
||||
const stagedFilters = getStagedFilters(store.getState());
|
||||
expect(stagedFilters.length).toBe(1);
|
||||
});
|
||||
|
||||
test('getStagedFilters returns filters for all embeddables', () => {
|
||||
store.dispatch(setStagedFilter({ stagedFilter: ['imafilter'], panelId: 'foo2' }));
|
||||
const stagedFilters = getStagedFilters(store.getState());
|
||||
expect(stagedFilters.length).toBe(2);
|
||||
});
|
||||
|
||||
test('clearStagedFilters clears all filters', () => {
|
||||
store.dispatch(clearStagedFilters());
|
||||
const stagedFilters = getStagedFilters(store.getState());
|
||||
expect(stagedFilters.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,6 @@
|
|||
export * from './view';
|
||||
export * from './panels';
|
||||
|
||||
export {
|
||||
renderEmbeddable,
|
||||
embeddableRenderFinished,
|
||||
embeddableRenderError,
|
||||
destroyEmbeddable,
|
||||
} from './embeddables';
|
||||
export * from './embeddables';
|
||||
|
||||
export {
|
||||
updateDescription,
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { createAction } from 'redux-actions';
|
||||
|
||||
export const deletePanel = createAction('DELETE_PANEL');
|
||||
|
||||
export const updatePanel = createAction('UPDATE_PANEL');
|
||||
export const resetPanelTitle = createAction('RESET_PANEl_TITLE');
|
||||
export const setPanelTitle = createAction('SET_PANEl_TITLE',
|
||||
|
|
|
@ -6,3 +6,4 @@ export const minimizePanel = createAction('MINIMIZE_PANEL');
|
|||
export const updateIsFullScreenMode = createAction('UPDATE_IS_FULL_SCREEN_MODE');
|
||||
export const updateUseMargins = createAction('UPDATE_USE_MARGINS');
|
||||
export const updateHidePanelTitles = createAction('HIDE_PANEL_TITLES');
|
||||
export const updateTimeRange = createAction('UPDATE_TIME_RANGE');
|
||||
|
|
|
@ -66,7 +66,6 @@
|
|||
</div>
|
||||
|
||||
<dashboard-viewport-provider
|
||||
get-container-api="getContainerApi"
|
||||
get-embeddable-factory="getEmbeddableFactory"
|
||||
>
|
||||
</dashboard-viewport-provider>
|
||||
|
|
|
@ -20,7 +20,6 @@ import { DashboardStateManager } from './dashboard_state_manager';
|
|||
import { saveDashboard } from './lib';
|
||||
import { showCloneModal } from './top_nav/show_clone_modal';
|
||||
import { migrateLegacyQuery } from 'ui/utils/migrateLegacyQuery';
|
||||
import { DashboardContainerAPI } from './dashboard_container_api';
|
||||
import * as filterActions from 'ui/doc_table/actions/filter';
|
||||
import { FilterManagerProvider } from 'ui/filter_manager';
|
||||
import { EmbeddableFactoriesRegistryProvider } from 'ui/embeddable/embeddable_factories_registry';
|
||||
|
@ -67,18 +66,17 @@ app.directive('dashboardApp', function ($injector) {
|
|||
docTitle.change(dash.title);
|
||||
}
|
||||
|
||||
const dashboardStateManager = new DashboardStateManager(dash, AppState, dashboardConfig.getHideWriteControls());
|
||||
const dashboardStateManager = new DashboardStateManager({
|
||||
savedDashboard: dash,
|
||||
AppState,
|
||||
hideWriteControls: dashboardConfig.getHideWriteControls(),
|
||||
addFilter: ({ field, value, operator, index }) => {
|
||||
filterActions.addFilter(field, value, operator, index, dashboardStateManager.getAppState(), filterManager);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.getDashboardState = () => dashboardStateManager;
|
||||
$scope.appState = dashboardStateManager.getAppState();
|
||||
$scope.containerApi = new DashboardContainerAPI(
|
||||
dashboardStateManager,
|
||||
(field, value, operator, index) => {
|
||||
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.
|
||||
|
@ -141,6 +139,8 @@ app.directive('dashboardApp', function ($injector) {
|
|||
courier.fetch(...args);
|
||||
};
|
||||
$scope.timefilter = timefilter;
|
||||
dashboardStateManager.handleTimeChange($scope.timefilter);
|
||||
|
||||
$scope.expandedPanel = null;
|
||||
$scope.dashboardViewMode = dashboardStateManager.getViewMode();
|
||||
|
||||
|
@ -212,11 +212,6 @@ app.directive('dashboardApp', function ($injector) {
|
|||
$scope.$watch('model.timeRestore', () => dashboardStateManager.setTimeRestore($scope.model.timeRestore));
|
||||
$scope.indexPatterns = [];
|
||||
|
||||
$scope.registerPanelIndexPattern = (panelIndex, pattern) => {
|
||||
dashboardStateManager.registerPanelIndexPatternMap(panelIndex, pattern);
|
||||
$scope.indexPatterns = dashboardStateManager.getPanelIndexPatterns();
|
||||
};
|
||||
|
||||
$scope.onPanelRemoved = (panelIndex) => {
|
||||
dashboardStateManager.removePanel(panelIndex);
|
||||
$scope.indexPatterns = dashboardStateManager.getPanelIndexPatterns();
|
||||
|
@ -224,7 +219,12 @@ app.directive('dashboardApp', function ($injector) {
|
|||
|
||||
$scope.$watch('model.query', $scope.updateQueryAndFetch);
|
||||
|
||||
$scope.$listen(timefilter, 'fetch', $scope.refresh);
|
||||
$scope.$listen(timefilter, 'fetch', () => {
|
||||
dashboardStateManager.handleTimeChange($scope.timefilter);
|
||||
// Currently discover relies on this logic to re-fetch. We need to refactor it to rely instead on the
|
||||
// directly passed down time filter. Then we can get rid of this reliance on scope broadcasts.
|
||||
$scope.refresh();
|
||||
});
|
||||
|
||||
function updateViewMode(newMode) {
|
||||
$scope.topNavMenu = getTopNavConfig(newMode, navActions, dashboardConfig.getHideWriteControls()); // eslint-disable-line no-use-before-define
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
import { ContainerAPI } from 'ui/embeddable';
|
||||
|
||||
export class DashboardContainerAPI extends ContainerAPI {
|
||||
constructor(dashboardState, addFilter) {
|
||||
super();
|
||||
this.dashboardState = dashboardState;
|
||||
this.addFilter = addFilter;
|
||||
}
|
||||
|
||||
updatePanel(panelIndex, panelAttributes) {
|
||||
return this.dashboardState.updatePanel(panelIndex, panelAttributes);
|
||||
}
|
||||
|
||||
registerPanelIndexPattern(panelIndex, pattern) {
|
||||
this.dashboardState.registerPanelIndexPatternMap(panelIndex, pattern);
|
||||
this.dashboardState.saveState();
|
||||
}
|
||||
|
||||
getHidePanelTitles() {
|
||||
return this.dashboardState.getHidePanelTitles();
|
||||
}
|
||||
|
||||
onEmbeddableConfigChanged(panelIndex, listener) {
|
||||
this.dashboardState.registerEmbeddableConfigChangeListener(panelIndex, listener);
|
||||
}
|
||||
}
|
110
src/core_plugins/kibana/public/dashboard/dashboard_state.test.js
Normal file
110
src/core_plugins/kibana/public/dashboard/dashboard_state.test.js
Normal file
|
@ -0,0 +1,110 @@
|
|||
import { DashboardStateManager } from './dashboard_state_manager';
|
||||
import { embeddableIsInitialized, setPanels } from './actions';
|
||||
import { getAppStateMock, getSavedDashboardMock } from './__tests__';
|
||||
import { store } from '../store';
|
||||
|
||||
jest.mock('ui/chrome', () => ({ getKibanaVersion: () => '6.0.0' }), { virtual: true });
|
||||
|
||||
|
||||
describe('DashboardState', function () {
|
||||
let dashboardState;
|
||||
const savedDashboard = getSavedDashboardMock();
|
||||
const timefilter = { time: {} };
|
||||
const mockQuickTimeRanges = [{ from: 'now/w', to: 'now/w', display: 'This week', section: 0 }];
|
||||
const mockIndexPattern = { id: 'index1' };
|
||||
|
||||
function initDashboardState() {
|
||||
dashboardState = new DashboardStateManager({
|
||||
savedDashboard,
|
||||
AppState: getAppStateMock(),
|
||||
hideWriteControls: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe('syncTimefilterWithDashboard', function () {
|
||||
test('syncs quick time', function () {
|
||||
savedDashboard.timeRestore = true;
|
||||
savedDashboard.timeFrom = 'now/w';
|
||||
savedDashboard.timeTo = 'now/w';
|
||||
|
||||
timefilter.time.from = '2015-09-19 06:31:44.000';
|
||||
timefilter.time.to = '2015-09-29 06:31:44.000';
|
||||
timefilter.time.mode = 'absolute';
|
||||
|
||||
initDashboardState();
|
||||
dashboardState.syncTimefilterWithDashboard(timefilter, mockQuickTimeRanges);
|
||||
|
||||
expect(timefilter.time.mode).toBe('quick');
|
||||
expect(timefilter.time.to).toBe('now/w');
|
||||
expect(timefilter.time.from).toBe('now/w');
|
||||
});
|
||||
|
||||
test('syncs relative time', function () {
|
||||
savedDashboard.timeRestore = true;
|
||||
savedDashboard.timeFrom = 'now-13d';
|
||||
savedDashboard.timeTo = 'now';
|
||||
|
||||
timefilter.time.from = '2015-09-19 06:31:44.000';
|
||||
timefilter.time.to = '2015-09-29 06:31:44.000';
|
||||
timefilter.time.mode = 'absolute';
|
||||
|
||||
initDashboardState();
|
||||
dashboardState.syncTimefilterWithDashboard(timefilter, mockQuickTimeRanges);
|
||||
|
||||
expect(timefilter.time.mode).toBe('relative');
|
||||
expect(timefilter.time.to).toBe('now');
|
||||
expect(timefilter.time.from).toBe('now-13d');
|
||||
});
|
||||
|
||||
test('syncs absolute time', function () {
|
||||
savedDashboard.timeRestore = true;
|
||||
savedDashboard.timeFrom = '2015-09-19 06:31:44.000';
|
||||
savedDashboard.timeTo = '2015-09-29 06:31:44.000';
|
||||
|
||||
timefilter.time.from = 'now/w';
|
||||
timefilter.time.to = 'now/w';
|
||||
timefilter.time.mode = 'quick';
|
||||
|
||||
initDashboardState();
|
||||
dashboardState.syncTimefilterWithDashboard(timefilter, mockQuickTimeRanges);
|
||||
|
||||
expect(timefilter.time.mode).toBe('absolute');
|
||||
expect(timefilter.time.to).toBe(savedDashboard.timeTo);
|
||||
expect(timefilter.time.from).toBe(savedDashboard.timeFrom);
|
||||
});
|
||||
});
|
||||
|
||||
describe('panelIndexPatternMapping', function () {
|
||||
beforeAll(() => {
|
||||
initDashboardState();
|
||||
});
|
||||
|
||||
function simulateNewEmbeddableWithIndexPattern({ panelId, indexPattern }) {
|
||||
store.dispatch(setPanels({ [panelId]: { panelIndex: panelId } }));
|
||||
const metadata = { title: 'my embeddable title', editUrl: 'editme', indexPattern };
|
||||
store.dispatch(embeddableIsInitialized({ metadata, panelId: panelId }));
|
||||
}
|
||||
|
||||
test('initially has no index patterns', () => {
|
||||
expect(dashboardState.getPanelIndexPatterns().length).toBe(0);
|
||||
});
|
||||
|
||||
test('registers index pattern when an embeddable is initialized with one', async () => {
|
||||
simulateNewEmbeddableWithIndexPattern({ panelId: 'foo1', indexPattern: mockIndexPattern });
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
expect(dashboardState.getPanelIndexPatterns().length).toBe(1);
|
||||
});
|
||||
|
||||
test('registers unique index patterns', async () => {
|
||||
simulateNewEmbeddableWithIndexPattern({ panelId: 'foo2', indexPattern: mockIndexPattern });
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
expect(dashboardState.getPanelIndexPatterns().length).toBe(1);
|
||||
});
|
||||
|
||||
test('does not register undefined index pattern for panels with no index pattern', async () => {
|
||||
simulateNewEmbeddableWithIndexPattern({ panelId: 'foo2' });
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
expect(dashboardState.getPanelIndexPatterns().length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -14,6 +14,8 @@ import {
|
|||
updateTitle,
|
||||
updateDescription,
|
||||
updateHidePanelTitles,
|
||||
updateTimeRange,
|
||||
clearStagedFilters,
|
||||
} from './actions';
|
||||
import { stateMonitorFactory } from 'ui/state_management/state_monitor_factory';
|
||||
import { createPanelState } from './panel';
|
||||
|
@ -27,41 +29,29 @@ import {
|
|||
getDescription,
|
||||
getUseMargins,
|
||||
getHidePanelTitles,
|
||||
getStagedFilters,
|
||||
getEmbeddables,
|
||||
getEmbeddableMetadata
|
||||
} from '../selectors';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* - filters
|
||||
*
|
||||
* State that is only stored in the Store:
|
||||
* - embeddables
|
||||
* - maximizedPanelId
|
||||
*
|
||||
* State that is shared and needs to be synced:
|
||||
* - fullScreenMode - changes propagate from AppState -> Store and from Store -> AppState.
|
||||
* - viewMode - changes only propagate from AppState -> Store
|
||||
* - panels - changes propagate from AppState -> Store and from Store -> AppState.
|
||||
*
|
||||
*
|
||||
* app. There are two "sources of truth" that need to stay in sync - AppState (aka the `_a` portion of the url) and
|
||||
* the Store. They aren't complete duplicates of each other as AppState has state that the Store doesn't, and vice
|
||||
* versa. They should be as decoupled as possible so updating the store won't affect bwc of urls.
|
||||
*/
|
||||
export class DashboardStateManager {
|
||||
/**
|
||||
*
|
||||
* @param savedDashboard {SavedDashboard}
|
||||
* @param AppState {AppState} The AppState class to use when instantiating a new AppState instance.
|
||||
* @param hideWriteControls {boolean} true if write controls should be hidden.
|
||||
* @param {SavedDashboard} savedDashboard
|
||||
* @param {AppState} AppState The AppState class to use when instantiating a new AppState instance.
|
||||
* @param {boolean} hideWriteControls true if write controls should be hidden.
|
||||
* @param {function} addFilter a function that can be used to add a filter bar filter
|
||||
*/
|
||||
constructor(savedDashboard, AppState, hideWriteControls) {
|
||||
constructor({ savedDashboard, AppState, hideWriteControls, addFilter }) {
|
||||
this.savedDashboard = savedDashboard;
|
||||
this.hideWriteControls = hideWriteControls;
|
||||
this.addFilter = addFilter;
|
||||
|
||||
this.stateDefaults = getAppStateDefaults(this.savedDashboard, this.hideWriteControls);
|
||||
|
||||
|
@ -85,13 +75,13 @@ export class DashboardStateManager {
|
|||
this.panelIndexPatternMapping = {};
|
||||
|
||||
PanelUtils.initPanelIndexes(this.getPanels());
|
||||
|
||||
this.createStateMonitor();
|
||||
|
||||
// Always start out with all panels minimized when a dashboard is first loaded.
|
||||
store.dispatch(minimizePanel());
|
||||
this._pushAppStateChangesToStore();
|
||||
|
||||
this.embeddableConfigChangeListeners = {};
|
||||
this.changeListeners = [];
|
||||
|
||||
this.unsubscribe = store.subscribe(() => this._handleStoreChanges());
|
||||
|
@ -101,14 +91,6 @@ export class DashboardStateManager {
|
|||
});
|
||||
}
|
||||
|
||||
registerEmbeddableConfigChangeListener(panelIndex, callback) {
|
||||
let panelListeners = this.embeddableConfigChangeListeners[panelIndex];
|
||||
if (!panelListeners) {
|
||||
panelListeners = this.embeddableConfigChangeListeners[panelIndex] = [];
|
||||
}
|
||||
panelListeners.push(callback);
|
||||
}
|
||||
|
||||
registerChangeListener(callback) {
|
||||
this.changeListeners.push(callback);
|
||||
}
|
||||
|
@ -129,26 +111,11 @@ export class DashboardStateManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* For each embeddable config in appState that differs from that in the redux store, trigger the change listeners
|
||||
* using the appState version as the "source of truth". This is because currently the only way to update an embeddable
|
||||
* config from the dashboard side is via the url. Eventually we want to let users modify it via a "reset link" in
|
||||
* the panel config, or even a way to modify it in the panel config. When this is introduced it would go through
|
||||
* redux and we'd have to update appState. At that point, we'll need to handle changes coming from both directions.
|
||||
* Ideally we can introduce react-redux-router for a more seamless way to keep url changes and ui changes in sync.
|
||||
* ... until then... we are stuck with this manual crap. :(
|
||||
* Fixes https://github.com/elastic/kibana/issues/15720
|
||||
* Time is part of global state so we need to deal with it outside of _pushAppStateChangesToStore.
|
||||
* @param {Object} newTimeFilter
|
||||
*/
|
||||
triggerEmbeddableConfigUpdateListeners() {
|
||||
const state = store.getState();
|
||||
for(const appStatePanel of this.appState.panels) {
|
||||
const storePanel = getPanel(state, appStatePanel.panelIndex);
|
||||
if (storePanel && !_.isEqual(appStatePanel.embeddableConfig, storePanel.embeddableConfig)) {
|
||||
const panelListeners = this.embeddableConfigChangeListeners[appStatePanel.panelIndex];
|
||||
if (panelListeners) {
|
||||
panelListeners.forEach(listener => listener(appStatePanel.embeddableConfig));
|
||||
}
|
||||
}
|
||||
}
|
||||
handleTimeChange(newTimeFilter) {
|
||||
store.dispatch(updateTimeRange(newTimeFilter));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -159,12 +126,10 @@ export class DashboardStateManager {
|
|||
// 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()) {
|
||||
this.triggerEmbeddableConfigUpdateListeners();
|
||||
|
||||
// Translate appState panels data into the data expected by redux, copying the panel objects as we do so
|
||||
// because the panels inside appState can be mutated, while redux state should never be mutated directly.
|
||||
const panelsMap = this.getPanels().reduce((acc, panel) => {
|
||||
acc[panel.panelIndex] = { ...panel };
|
||||
acc[panel.panelIndex] = _.cloneDeep(panel);
|
||||
return acc;
|
||||
}, {});
|
||||
store.dispatch(setPanels(panelsMap));
|
||||
|
@ -210,12 +175,32 @@ export class DashboardStateManager {
|
|||
if (!this._areStoreAndAppStatePanelsEqual()) {
|
||||
const panels = getPanels(store.getState());
|
||||
this.appState.panels = [];
|
||||
this.panelIndexPatternMapping = {};
|
||||
Object.values(panels).map(panel => {
|
||||
this.appState.panels.push({ ...panel });
|
||||
this.appState.panels.push(_.cloneDeep(panel));
|
||||
});
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
_.forEach(getEmbeddables(store.getState()), (embeddable, panelId) => {
|
||||
if (embeddable.initialized && !this.panelIndexPatternMapping.hasOwnProperty(panelId)) {
|
||||
const indexPattern = getEmbeddableMetadata(store.getState(), panelId).indexPattern;
|
||||
if (indexPattern) {
|
||||
this.panelIndexPatternMapping[panelId] = indexPattern;
|
||||
this.dirty = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const stagedFilters = getStagedFilters(store.getState());
|
||||
stagedFilters.forEach(filter => {
|
||||
this.addFilter(filter);
|
||||
});
|
||||
if (stagedFilters.length > 0) {
|
||||
this.saveState();
|
||||
store.dispatch(clearStagedFilters());
|
||||
}
|
||||
|
||||
const fullScreen = getFullScreenMode(store.getState());
|
||||
if (fullScreen !== this.getFullScreenMode()) {
|
||||
this.setFullScreenMode(fullScreen);
|
||||
|
@ -234,12 +219,6 @@ export class DashboardStateManager {
|
|||
this.saveState();
|
||||
}
|
||||
|
||||
registerPanelIndexPatternMap(panelIndex, indexPattern) {
|
||||
if (indexPattern) {
|
||||
this.panelIndexPatternMapping[panelIndex] = indexPattern;
|
||||
}
|
||||
}
|
||||
|
||||
getPanelIndexPatterns() {
|
||||
return _.uniq(Object.values(this.panelIndexPatternMapping));
|
||||
}
|
||||
|
@ -513,7 +492,8 @@ export class DashboardStateManager {
|
|||
|
||||
/**
|
||||
* Updates timeFilter to match the time saved with the dashboard.
|
||||
* @param timeFilter
|
||||
* @param {Object} timeFilter
|
||||
* @param {Object} timeFilter.time
|
||||
* @param quickTimeRanges
|
||||
*/
|
||||
syncTimefilterWithDashboard(timeFilter, quickTimeRanges) {
|
||||
|
|
|
@ -36,14 +36,9 @@ exports[`renders DashboardGrid 1`] = `
|
|||
<Connect(DashboardPanel)
|
||||
embeddableFactory={
|
||||
Object {
|
||||
"addDestroyEmeddable": [Function],
|
||||
"destroy": [Function],
|
||||
"getEditPath": [Function],
|
||||
"getTitleFor": [Function],
|
||||
"render": [MockFunction],
|
||||
"create": [MockFunction],
|
||||
}
|
||||
}
|
||||
getContainerApi={[Function]}
|
||||
onPanelBlurred={[Function]}
|
||||
onPanelFocused={[Function]}
|
||||
panelId="1"
|
||||
|
@ -61,14 +56,9 @@ exports[`renders DashboardGrid 1`] = `
|
|||
<Connect(DashboardPanel)
|
||||
embeddableFactory={
|
||||
Object {
|
||||
"addDestroyEmeddable": [Function],
|
||||
"destroy": [Function],
|
||||
"getEditPath": [Function],
|
||||
"getTitleFor": [Function],
|
||||
"render": [MockFunction],
|
||||
"create": [MockFunction],
|
||||
}
|
||||
}
|
||||
getContainerApi={[Function]}
|
||||
onPanelBlurred={[Function]}
|
||||
onPanelFocused={[Function]}
|
||||
panelId="2"
|
||||
|
|
|
@ -175,7 +175,6 @@ export class DashboardGrid extends React.Component {
|
|||
renderDOM() {
|
||||
const {
|
||||
panels,
|
||||
getContainerApi,
|
||||
maximizedPanelId
|
||||
} = this.props;
|
||||
const { focusedPanelIndex } = this.state;
|
||||
|
@ -206,7 +205,6 @@ export class DashboardGrid extends React.Component {
|
|||
>
|
||||
<DashboardPanel
|
||||
panelId={panel.panelIndex}
|
||||
getContainerApi={getContainerApi}
|
||||
embeddableFactory={this.embeddableFactoryMap[panel.type]}
|
||||
onPanelFocused={this.onPanelFocused}
|
||||
onPanelBlurred={this.onPanelBlurred}
|
||||
|
@ -239,7 +237,6 @@ export class DashboardGrid extends React.Component {
|
|||
|
||||
DashboardGrid.propTypes = {
|
||||
panels: PropTypes.object.isRequired,
|
||||
getContainerApi: PropTypes.func.isRequired,
|
||||
getEmbeddableFactory: PropTypes.func.isRequired,
|
||||
dashboardViewMode: PropTypes.oneOf([DashboardViewMode.EDIT, DashboardViewMode.VIEW]).isRequired,
|
||||
onPanelsUpdated: PropTypes.func.isRequired,
|
||||
|
|
|
@ -3,8 +3,7 @@ import { shallow } from 'enzyme';
|
|||
import sizeMe from 'react-sizeme';
|
||||
|
||||
import { DashboardViewMode } from '../dashboard_view_mode';
|
||||
import { getContainerApiMock } from '../__tests__/get_container_api_mock';
|
||||
import { getEmbeddableFactoryMock } from '../__tests__/get_embeddable_factories_mock';
|
||||
import { getEmbeddableFactoryMock } from '../__tests__';
|
||||
|
||||
import { DashboardGrid } from './dashboard_grid';
|
||||
|
||||
|
@ -37,7 +36,6 @@ function getProps(props = {}) {
|
|||
}
|
||||
},
|
||||
getEmbeddableFactory: () => getEmbeddableFactoryMock(),
|
||||
getContainerApi: () => getContainerApiMock(),
|
||||
onPanelsUpdated: () => {},
|
||||
useMargins: true,
|
||||
};
|
||||
|
|
|
@ -4,11 +4,10 @@ import { Provider } from 'react-redux';
|
|||
import _ from 'lodash';
|
||||
import sizeMe from 'react-sizeme';
|
||||
|
||||
import { getContainerApiMock } from '../__tests__/get_container_api_mock';
|
||||
import { getEmbeddableFactoryMock } from '../__tests__/get_embeddable_factories_mock';
|
||||
import { getEmbeddableFactoryMock } from '../__tests__';
|
||||
import { store } from '../../store';
|
||||
import { DashboardGridContainer } from './dashboard_grid_container';
|
||||
import { updatePanels } from '../actions';
|
||||
import { updatePanels, updateTimeRange } from '../actions';
|
||||
|
||||
jest.mock('ui/chrome', () => ({ getKibanaVersion: () => '6.3.0' }), { virtual: true });
|
||||
|
||||
|
@ -23,7 +22,6 @@ function getProps(props = {}) {
|
|||
const defaultTestProps = {
|
||||
hidden: false,
|
||||
getEmbeddableFactory: () => getEmbeddableFactoryMock(),
|
||||
getContainerApi: () => getContainerApiMock(),
|
||||
};
|
||||
return Object.assign(defaultTestProps, props);
|
||||
}
|
||||
|
@ -46,6 +44,7 @@ beforeAll(() => {
|
|||
removeAllRanges: () => {}
|
||||
};
|
||||
};
|
||||
store.dispatch(updateTimeRange({ to: 'now', from: 'now-15m' }));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
|
|
@ -15,6 +15,7 @@ export function getAppStateDefaults(savedDashboard, hideWriteControls) {
|
|||
};
|
||||
|
||||
// For BWC in pre 6.1 versions where uiState was stored at the dashboard level, not at the panel level.
|
||||
// TODO: introduce a migration for this
|
||||
if (savedDashboard.uiStateJSON) {
|
||||
const uiState = JSON.parse(savedDashboard.uiStateJSON);
|
||||
appState.panels.forEach(panel => {
|
||||
|
@ -22,5 +23,21 @@ export function getAppStateDefaults(savedDashboard, hideWriteControls) {
|
|||
});
|
||||
delete savedDashboard.uiStateJSON;
|
||||
}
|
||||
|
||||
// For BWC of pre 6.4 where search embeddables stored state directly on the panel and not under embeddableConfig.
|
||||
// TODO: introduce a migration for this
|
||||
appState.panels.forEach(panel => {
|
||||
if (panel.columns || panel.sort) {
|
||||
panel.embeddableConfig = {
|
||||
...panel.embeddableConfig,
|
||||
columns: panel.columns,
|
||||
sort: panel.sort
|
||||
};
|
||||
delete panel.columns;
|
||||
delete panel.sort;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return appState;
|
||||
}
|
||||
|
|
|
@ -1,13 +1,60 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { PanelHeader } from './panel_header';
|
||||
import { PanelError } from './panel_error';
|
||||
|
||||
export class DashboardPanel extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
error: props.embeddableFactory ? null : `No factory found for embeddable`,
|
||||
};
|
||||
|
||||
this.mounted = false;
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
this.props.renderEmbeddable(this.panelElement, this.props.panel);
|
||||
this.mounted = true;
|
||||
const {
|
||||
initialized,
|
||||
embeddableFactory,
|
||||
embeddableIsInitializing,
|
||||
panel,
|
||||
embeddableStateChanged,
|
||||
embeddableIsInitialized,
|
||||
embeddableError,
|
||||
} = this.props;
|
||||
|
||||
if (!initialized) {
|
||||
embeddableIsInitializing();
|
||||
embeddableFactory.create(panel, embeddableStateChanged)
|
||||
.then((embeddable) => {
|
||||
if (this.mounted) {
|
||||
this.embeddable = embeddable;
|
||||
embeddableIsInitialized(embeddable.metadata);
|
||||
this.embeddable.onContainerStateChanged(this.props.containerState);
|
||||
this.embeddable.render(this.panelElement);
|
||||
} else {
|
||||
embeddable.destroy();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (this.mounted) {
|
||||
embeddableError(error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.destroy();
|
||||
this.mounted = false;
|
||||
if (this.embeddable) {
|
||||
this.embeddable.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
onFocus = () => {
|
||||
|
@ -24,26 +71,42 @@ export class DashboardPanel extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.onDestroy();
|
||||
}
|
||||
|
||||
renderEmbeddedContent() {
|
||||
renderEmbeddableViewport() {
|
||||
return (
|
||||
<div
|
||||
id="embeddedPanel"
|
||||
className="panel-content"
|
||||
ref={panelElement => this.panelElement = panelElement}
|
||||
/>
|
||||
>
|
||||
{!this.props.initialized && 'loading...'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
if (this.embeddable && !_.isEqual(nextProps.containerState, this.props.containerState)) {
|
||||
this.embeddable.onContainerStateChanged(nextProps.containerState);
|
||||
}
|
||||
|
||||
return nextProps.error !== this.props.error ||
|
||||
nextProps.initialized !== this.props.initialized;
|
||||
}
|
||||
|
||||
renderEmbeddedError() {
|
||||
return <PanelError error={this.props.error} />;
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
const { error } = this.props;
|
||||
if (error) {
|
||||
return this.renderEmbeddedError(error);
|
||||
} else {
|
||||
return this.renderEmbeddableViewport();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { viewOnlyMode, error, panel, embeddableFactory } = this.props;
|
||||
const { viewOnlyMode, panel } = this.props;
|
||||
const classes = classNames('panel panel-default', this.props.className, {
|
||||
'panel--edit-mode': !viewOnlyMode
|
||||
});
|
||||
|
@ -58,11 +121,10 @@ export class DashboardPanel extends React.Component {
|
|||
data-test-subj="dashboardPanel"
|
||||
>
|
||||
<PanelHeader
|
||||
embeddableFactory={embeddableFactory}
|
||||
panelId={panel.panelIndex}
|
||||
/>
|
||||
|
||||
{error ? this.renderEmbeddedError() : this.renderEmbeddedContent()}
|
||||
{this.renderContent()}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
@ -71,17 +133,28 @@ export class DashboardPanel extends React.Component {
|
|||
}
|
||||
|
||||
DashboardPanel.propTypes = {
|
||||
panel: PropTypes.shape({
|
||||
panelIndex: PropTypes.string,
|
||||
}),
|
||||
renderEmbeddable: PropTypes.func.isRequired,
|
||||
viewOnlyMode: PropTypes.bool.isRequired,
|
||||
onDestroy: PropTypes.func.isRequired,
|
||||
onPanelFocused: PropTypes.func,
|
||||
onPanelBlurred: PropTypes.func,
|
||||
error: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.object
|
||||
]),
|
||||
embeddableFactory: PropTypes.object.isRequired,
|
||||
destroy: PropTypes.func.isRequired,
|
||||
containerState: PropTypes.shape({
|
||||
timeRange: PropTypes.object.isRequired,
|
||||
embeddableCustomization: PropTypes.object,
|
||||
hidePanelTitles: PropTypes.bool.isRequired,
|
||||
}),
|
||||
embeddableFactory: PropTypes.shape({
|
||||
create: PropTypes.func,
|
||||
}).isRequired,
|
||||
embeddableStateChanged: PropTypes.func.isRequired,
|
||||
embeddableIsInitialized: PropTypes.func.isRequired,
|
||||
embeddableError: PropTypes.func.isRequired,
|
||||
embeddableIsInitializing: PropTypes.func.isRequired,
|
||||
initialized: PropTypes.bool.isRequired,
|
||||
panel: PropTypes.shape({
|
||||
panelIndex: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
|
|
@ -5,12 +5,14 @@ import { DashboardPanel } from './dashboard_panel';
|
|||
import { DashboardViewMode } from '../dashboard_view_mode';
|
||||
import { PanelError } from '../panel/panel_error';
|
||||
import { store } from '../../store';
|
||||
import { getEmbeddableFactoryMock } from '../__tests__/get_embeddable_factories_mock';
|
||||
|
||||
import {
|
||||
updateViewMode,
|
||||
setPanels,
|
||||
updateTimeRange,
|
||||
} from '../actions';
|
||||
import { Provider } from 'react-redux';
|
||||
import { getEmbeddableFactoryMock } from '../__tests__/get_embeddable_factories_mock';
|
||||
|
||||
import {
|
||||
takeMountedSnapshot,
|
||||
|
@ -19,15 +21,20 @@ import {
|
|||
function getProps(props = {}) {
|
||||
const defaultTestProps = {
|
||||
panel: { panelIndex: 'foo1' },
|
||||
renderEmbeddable: jest.fn(),
|
||||
viewOnlyMode: false,
|
||||
onDestroy: () => {},
|
||||
destroy: () => {},
|
||||
initialized: true,
|
||||
embeddableIsInitialized: () => {},
|
||||
embeddableIsInitializing: () => {},
|
||||
embeddableStateChanged: () => {},
|
||||
embeddableError: () => {},
|
||||
embeddableFactory: getEmbeddableFactoryMock(),
|
||||
};
|
||||
return _.defaultsDeep(props, defaultTestProps);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
store.dispatch(updateTimeRange({ to: 'now', from: 'now-15m' }));
|
||||
store.dispatch(updateViewMode(DashboardViewMode.EDIT));
|
||||
store.dispatch(setPanels({ 'foo1': { panelIndex: 'foo1' } }));
|
||||
});
|
||||
|
@ -37,12 +44,6 @@ test('DashboardPanel matches snapshot', () => {
|
|||
expect(takeMountedSnapshot(component)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('Calls render', () => {
|
||||
const props = getProps();
|
||||
mount(<Provider store={store}><DashboardPanel {...props} /></Provider>);
|
||||
expect(props.renderEmbeddable.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
test('renders an error when error prop is passed', () => {
|
||||
const props = getProps({
|
||||
error: 'Simulated error'
|
||||
|
|
|
@ -5,39 +5,52 @@ import { DashboardPanel } from './dashboard_panel';
|
|||
import { DashboardViewMode } from '../dashboard_view_mode';
|
||||
|
||||
import {
|
||||
renderEmbeddable,
|
||||
destroyEmbeddable
|
||||
deletePanel, embeddableError, embeddableIsInitialized, embeddableIsInitializing, embeddableStateChanged,
|
||||
} from '../actions';
|
||||
|
||||
import {
|
||||
getPanel,
|
||||
getEmbeddable,
|
||||
getFullScreenMode,
|
||||
getViewMode,
|
||||
getEmbeddableTitle,
|
||||
getEmbeddableEditUrl,
|
||||
getMaximizedPanelId,
|
||||
getEmbeddableError,
|
||||
getPanelType, getContainerState, getPanel, getEmbeddableInitialized,
|
||||
} from '../selectors';
|
||||
|
||||
const mapStateToProps = ({ dashboard }, { panelId }) => {
|
||||
const mapStateToProps = ({ dashboard }, { embeddableFactory, panelId }) => {
|
||||
const embeddable = getEmbeddable(dashboard, panelId);
|
||||
let error = null;
|
||||
if (!embeddableFactory) {
|
||||
const panelType = getPanelType(dashboard, panelId);
|
||||
error = `No embeddable factory found for panel type ${panelType}`;
|
||||
} else {
|
||||
error = (embeddable && getEmbeddableError(dashboard, panelId)) || '';
|
||||
}
|
||||
const initialized = embeddable ? getEmbeddableInitialized(dashboard, panelId) : false;
|
||||
return {
|
||||
title: embeddable ? getEmbeddableTitle(dashboard, panelId) : '',
|
||||
editUrl: embeddable ? getEmbeddableEditUrl(dashboard, panelId) : '',
|
||||
error: embeddable ? getEmbeddableError(dashboard, panelId) : '',
|
||||
|
||||
error,
|
||||
viewOnlyMode: getFullScreenMode(dashboard) || getViewMode(dashboard) === DashboardViewMode.VIEW,
|
||||
isExpanded: getMaximizedPanelId(dashboard) === panelId,
|
||||
panel: getPanel(dashboard, panelId),
|
||||
containerState: getContainerState(dashboard, panelId),
|
||||
initialized,
|
||||
panel: getPanel(dashboard, panelId)
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { embeddableFactory, panelId, getContainerApi }) => ({
|
||||
renderEmbeddable: (panelElement, panel) => (
|
||||
dispatch(renderEmbeddable(embeddableFactory, panelElement, panel, getContainerApi()))
|
||||
const mapDispatchToProps = (dispatch, { panelId }) => ({
|
||||
destroy: () => (
|
||||
dispatch(deletePanel(panelId))
|
||||
),
|
||||
onDestroy: () => dispatch(destroyEmbeddable(panelId, embeddableFactory)),
|
||||
embeddableIsInitializing: () => (
|
||||
dispatch(embeddableIsInitializing(panelId))
|
||||
),
|
||||
embeddableIsInitialized: (metadata) => (
|
||||
dispatch(embeddableIsInitialized({ panelId, metadata }))
|
||||
),
|
||||
embeddableStateChanged: (embeddableState) => (
|
||||
dispatch(embeddableStateChanged({ panelId, embeddableState }))
|
||||
),
|
||||
embeddableError: (errorMessage) => (
|
||||
dispatch(embeddableError({ panelId, error: errorMessage }))
|
||||
)
|
||||
});
|
||||
|
||||
export const DashboardPanelContainer = connect(
|
||||
|
@ -51,9 +64,6 @@ DashboardPanelContainer.propTypes = {
|
|||
* @type {EmbeddableFactory}
|
||||
*/
|
||||
embeddableFactory: PropTypes.shape({
|
||||
destroy: PropTypes.func.isRequired,
|
||||
render: PropTypes.func.isRequired,
|
||||
addDestroyEmeddable: PropTypes.func.isRequired,
|
||||
create: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
getContainerApi: PropTypes.func.isRequired,
|
||||
};
|
||||
|
|
|
@ -7,29 +7,28 @@ import { PanelError } from '../panel/panel_error';
|
|||
import { store } from '../../store';
|
||||
import {
|
||||
updateViewMode,
|
||||
setPanels,
|
||||
setPanels, updateTimeRange,
|
||||
} from '../actions';
|
||||
import { Provider } from 'react-redux';
|
||||
import { getEmbeddableFactoryMock } from '../__tests__/get_embeddable_factories_mock';
|
||||
import { getContainerApiMock } from '../__tests__/get_container_api_mock';
|
||||
|
||||
function getProps(props = {}) {
|
||||
const defaultTestProps = {
|
||||
panelId: 'foo1',
|
||||
embeddableFactory: getEmbeddableFactoryMock(),
|
||||
getContainerApi: () => getContainerApiMock(),
|
||||
};
|
||||
return _.defaultsDeep(props, defaultTestProps);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
store.dispatch(updateViewMode(DashboardViewMode.EDIT));
|
||||
store.dispatch(updateTimeRange({ to: 'now', from: 'now-15m' }));
|
||||
store.dispatch(setPanels({ 'foo1': { panelIndex: 'foo1' } }));
|
||||
});
|
||||
|
||||
test('renders an error when embeddableFactory.render throws an error', (done) => {
|
||||
test('renders an error when embeddableFactory.create throws an error', (done) => {
|
||||
const props = getProps();
|
||||
props.embeddableFactory.render = () => {
|
||||
props.embeddableFactory.create = () => {
|
||||
return new Promise(() => {
|
||||
throw new Error('simulated error');
|
||||
});
|
||||
|
|
|
@ -14,18 +14,17 @@ import {
|
|||
} from '../../actions';
|
||||
|
||||
import {
|
||||
getEmbeddable,
|
||||
getPanel,
|
||||
getMaximizedPanelId,
|
||||
getFullScreenMode,
|
||||
getViewMode,
|
||||
getHidePanelTitles,
|
||||
getEmbeddableTitle
|
||||
} from '../../selectors';
|
||||
|
||||
const mapStateToProps = ({ dashboard }, { panelId }) => {
|
||||
const embeddable = getEmbeddable(dashboard, panelId);
|
||||
const panel = getPanel(dashboard, panelId);
|
||||
const embeddableTitle = embeddable ? embeddable.title : '';
|
||||
const embeddableTitle = getEmbeddableTitle(dashboard, panelId);
|
||||
return {
|
||||
title: panel.title === undefined ? embeddableTitle : panel.title,
|
||||
isExpanded: getMaximizedPanelId(dashboard) === panelId,
|
||||
|
@ -42,7 +41,7 @@ const mapDispatchToProps = (dispatch, { panelId }) => ({
|
|||
const mergeProps = (stateProps, dispatchProps, ownProps) => {
|
||||
const { isExpanded, isViewOnlyMode, title, hidePanelTitles } = stateProps;
|
||||
const { onMaximizePanel, onMinimizePanel } = dispatchProps;
|
||||
const { panelId, embeddableFactory } = ownProps;
|
||||
const { panelId } = ownProps;
|
||||
let actions;
|
||||
|
||||
if (isViewOnlyMode) {
|
||||
|
@ -50,12 +49,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => {
|
|||
<PanelMinimizeIcon onMinimize={onMinimizePanel} /> :
|
||||
<PanelMaximizeIcon onMaximize={onMaximizePanel} />;
|
||||
} else {
|
||||
actions = (
|
||||
<PanelOptionsMenuContainer
|
||||
panelId={panelId}
|
||||
embeddableFactory={embeddableFactory}
|
||||
/>
|
||||
);
|
||||
actions = <PanelOptionsMenuContainer panelId={panelId} />;
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -74,12 +68,4 @@ export const PanelHeaderContainer = connect(
|
|||
|
||||
PanelHeaderContainer.propTypes = {
|
||||
panelId: PropTypes.string.isRequired,
|
||||
/**
|
||||
* @type {EmbeddableFactory}
|
||||
*/
|
||||
embeddableFactory: PropTypes.shape({
|
||||
destroy: PropTypes.func.isRequired,
|
||||
render: PropTypes.func.isRequired,
|
||||
addDestroyEmeddable: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
|
|
@ -11,15 +11,13 @@ import {
|
|||
setPanels,
|
||||
setPanelTitle,
|
||||
resetPanelTitle,
|
||||
embeddableRenderFinished,
|
||||
embeddableIsInitialized,
|
||||
} from '../../actions';
|
||||
import { getEmbeddableFactoryMock } from '../../__tests__/get_embeddable_factories_mock';
|
||||
import { findTestSubject } from '@elastic/eui/lib/test';
|
||||
|
||||
function getProps(props = {}) {
|
||||
const defaultTestProps = {
|
||||
panelId: 'foo1',
|
||||
embeddableFactory: getEmbeddableFactoryMock(),
|
||||
};
|
||||
return _.defaultsDeep(props, defaultTestProps);
|
||||
}
|
||||
|
@ -29,7 +27,8 @@ let component;
|
|||
beforeAll(() => {
|
||||
store.dispatch(updateViewMode(DashboardViewMode.EDIT));
|
||||
store.dispatch(setPanels({ 'foo1': { panelIndex: 'foo1' } }));
|
||||
store.dispatch(embeddableRenderFinished('foo1', { title: 'my embeddable title', editUrl: 'editme' }));
|
||||
const metadata = { title: 'my embeddable title', editUrl: 'editme' };
|
||||
store.dispatch(embeddableIsInitialized({ metadata, panelId: 'foo1' }));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
|
|
@ -5,7 +5,6 @@ import { PanelOptionsMenu } from './panel_options_menu';
|
|||
|
||||
import {
|
||||
deletePanel,
|
||||
destroyEmbeddable,
|
||||
maximizePanel,
|
||||
minimizePanel,
|
||||
resetPanelTitle,
|
||||
|
@ -17,12 +16,13 @@ import {
|
|||
getEmbeddableEditUrl,
|
||||
getMaximizedPanelId,
|
||||
getPanel,
|
||||
getEmbeddableTitle
|
||||
} from '../../selectors';
|
||||
|
||||
const mapStateToProps = ({ dashboard }, { panelId }) => {
|
||||
const embeddable = getEmbeddable(dashboard, panelId);
|
||||
const panel = getPanel(dashboard, panelId);
|
||||
const embeddableTitle = embeddable ? embeddable.title : '';
|
||||
const embeddableTitle = getEmbeddableTitle(dashboard, panelId);
|
||||
return {
|
||||
panelTitle: panel.title === undefined ? embeddableTitle : panel.title,
|
||||
editUrl: embeddable ? getEmbeddableEditUrl(dashboard, panelId) : null,
|
||||
|
@ -35,10 +35,9 @@ const mapStateToProps = ({ dashboard }, { panelId }) => {
|
|||
* @param embeddableFactory {EmbeddableFactory}
|
||||
* @param panelId {string}
|
||||
*/
|
||||
const mapDispatchToProps = (dispatch, { embeddableFactory, panelId }) => ({
|
||||
const mapDispatchToProps = (dispatch, { panelId }) => ({
|
||||
onDeletePanel: () => {
|
||||
dispatch(deletePanel(panelId));
|
||||
dispatch(destroyEmbeddable(panelId, embeddableFactory));
|
||||
},
|
||||
onMaximizePanel: () => dispatch(maximizePanel(panelId)),
|
||||
onMinimizePanel: () => dispatch(minimizePanel()),
|
||||
|
@ -68,12 +67,4 @@ export const PanelOptionsMenuContainer = connect(
|
|||
|
||||
PanelOptionsMenuContainer.propTypes = {
|
||||
panelId: PropTypes.string.isRequired,
|
||||
/**
|
||||
* @type {EmbeddableFactory}
|
||||
*/
|
||||
embeddableFactory: PropTypes.shape({
|
||||
destroy: PropTypes.func.isRequired,
|
||||
render: PropTypes.func.isRequired,
|
||||
addDestroyEmeddable: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
|
|
@ -28,6 +28,7 @@ export function PanelOptionsMenuForm({ title, onReset, onUpdatePanelTitle, onClo
|
|||
<label className="kuiFormLabel" htmlFor="panelTitleInput">Panel title</label>
|
||||
<input
|
||||
id="panelTitleInput"
|
||||
data-test-subj="customDashboardPanelTitleInput"
|
||||
name="min"
|
||||
type="text"
|
||||
className="kuiTextInput"
|
||||
|
@ -37,6 +38,7 @@ export function PanelOptionsMenuForm({ title, onReset, onUpdatePanelTitle, onClo
|
|||
/>
|
||||
<KuiButton
|
||||
buttonType="hollow"
|
||||
data-test-subj="resetCustomDashboardPanelTitle"
|
||||
onClick={onReset}
|
||||
>
|
||||
Reset title
|
||||
|
|
|
@ -1,13 +1,55 @@
|
|||
import { handleActions } from 'redux-actions';
|
||||
import _ from 'lodash';
|
||||
|
||||
import {
|
||||
embeddableRenderFinished,
|
||||
embeddableRenderError,
|
||||
destroyEmbeddable,
|
||||
embeddableIsInitializing,
|
||||
embeddableError,
|
||||
embeddableIsInitialized,
|
||||
setStagedFilter,
|
||||
clearStagedFilters,
|
||||
deletePanel,
|
||||
} from '../actions';
|
||||
|
||||
export const embeddables = handleActions({
|
||||
[destroyEmbeddable]:
|
||||
[clearStagedFilters]:
|
||||
(embeddables) => {
|
||||
return _.mapValues(embeddables, (embeddable) => _.omit({ ...embeddable }, ['stagedFilter']));
|
||||
},
|
||||
|
||||
[embeddableIsInitialized]:
|
||||
/**
|
||||
*
|
||||
* @param embeddables {Object.<string, EmbeddableState>}
|
||||
* @param payload {Object}
|
||||
* @param payload.panelId {string} Panel id of embeddable that was initialized
|
||||
* @param payload.metadata {object} Metadata for the embeddable that was initialized
|
||||
* @return {Object.<string, EmbeddableState>}
|
||||
*/
|
||||
(embeddables, { payload }) => {
|
||||
return {
|
||||
...embeddables,
|
||||
[payload.panelId]: {
|
||||
...embeddables[payload.panelId],
|
||||
initialized: true,
|
||||
metadata: { ...payload.metadata },
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// TODO: Currently only saved search uses this to apply a filter. When visualize uses it too, we will need to
|
||||
// support multiple staged filters.
|
||||
[setStagedFilter]:
|
||||
(embeddables, { payload }) => {
|
||||
return {
|
||||
...embeddables,
|
||||
[payload.panelId]: {
|
||||
...embeddables[payload.panelId],
|
||||
stagedFilter: payload.stagedFilter,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
[deletePanel]:
|
||||
/**
|
||||
*
|
||||
* @param embeddables {Object.<string, EmbeddableState>}
|
||||
|
@ -20,7 +62,7 @@ export const embeddables = handleActions({
|
|||
return embeddablesCopy;
|
||||
},
|
||||
|
||||
[embeddableRenderFinished]:
|
||||
[embeddableIsInitializing]:
|
||||
/**
|
||||
*
|
||||
* @param embeddables {Object.<string, EmbeddableState>}
|
||||
|
@ -32,14 +74,14 @@ export const embeddables = handleActions({
|
|||
(embeddables, { payload }) => {
|
||||
return {
|
||||
...embeddables,
|
||||
[payload.panelId]: {
|
||||
...payload.embeddable,
|
||||
[payload]: {
|
||||
initialized: false,
|
||||
error: undefined,
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
[embeddableRenderError]:
|
||||
[embeddableError]:
|
||||
/**
|
||||
*
|
||||
* @param embeddables {Object.<string, EmbeddableState>}
|
||||
|
|
|
@ -1,33 +1,26 @@
|
|||
import { store } from '../../store';
|
||||
import { embeddableRenderError, embeddableRenderFinished } from '../actions';
|
||||
import {
|
||||
embeddableIsInitializing, setPanels,
|
||||
} from '../actions';
|
||||
import {
|
||||
getEmbeddableError,
|
||||
getEmbeddableTitle,
|
||||
getEmbeddableEditUrl,
|
||||
getEmbeddableInitialized,
|
||||
} from '../../selectors';
|
||||
|
||||
test('embeddableRenderError stores an error on the embeddable', () => {
|
||||
store.dispatch(embeddableRenderError('1', new Error('Opps, something bad happened!')));
|
||||
|
||||
const error = getEmbeddableError(store.getState(), '1');
|
||||
expect(error.message).toBe('Opps, something bad happened!');
|
||||
beforeAll(() => {
|
||||
store.dispatch(setPanels({ 'foo1': { panelIndex: 'foo1' } }));
|
||||
});
|
||||
|
||||
describe('embeddableRenderFinished', () => {
|
||||
test('stores a new embeddable object and clears the error', () => {
|
||||
store.dispatch(embeddableRenderFinished('1', { title: 'My Embeddable' }));
|
||||
const embeddableTitle = getEmbeddableTitle(store.getState(), '1');
|
||||
expect(embeddableTitle).toBe('My Embeddable');
|
||||
describe('embeddableIsInitializing', () => {
|
||||
test('clears the error', () => {
|
||||
store.dispatch(embeddableIsInitializing('foo1'));
|
||||
const initialized = getEmbeddableInitialized(store.getState(), 'foo1');
|
||||
expect(initialized).toBe(false);
|
||||
});
|
||||
|
||||
test('and clears the error', () => {
|
||||
const error = getEmbeddableError(store.getState(), '1');
|
||||
const error = getEmbeddableError(store.getState(), 'foo1');
|
||||
expect(error).toBe(undefined);
|
||||
});
|
||||
|
||||
test('getEmbeddableEditUrl', () => {
|
||||
store.dispatch(embeddableRenderFinished('1', { title: 'My Embeddable', editUrl: 'vis/edit/me' }));
|
||||
const url = getEmbeddableEditUrl(store.getState(), '1');
|
||||
expect(url).toBe('vis/edit/me');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
updateUseMargins,
|
||||
updateHidePanelTitles,
|
||||
updateIsFullScreenMode,
|
||||
updateTimeRange,
|
||||
} from '../actions';
|
||||
|
||||
import { DashboardViewMode } from '../dashboard_view_mode';
|
||||
|
@ -16,6 +17,11 @@ export const view = handleActions({
|
|||
viewMode: payload
|
||||
}),
|
||||
|
||||
[updateTimeRange]: (state, { payload }) => ({
|
||||
...state,
|
||||
timeRange: payload
|
||||
}),
|
||||
|
||||
[updateUseMargins]: (state, { payload }) => ({
|
||||
...state,
|
||||
useMargins: payload
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ViewState
|
||||
* @property {DashboardViewMode} viewMode
|
||||
|
@ -6,7 +8,7 @@
|
|||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} EmbeddableState
|
||||
* @typedef {Object} EmbeddableReduxState
|
||||
* @property {string} title
|
||||
* @property {string} editUrl
|
||||
* @property {string|object} error
|
||||
|
@ -15,7 +17,7 @@
|
|||
/**
|
||||
* @typedef {Object} DashboardState
|
||||
* @property {Object.<string, PanelState>} panels
|
||||
* @property {Object.<string, EmbeddableState>} embeddables
|
||||
* @property {Object.<string, EmbeddableReduxState>} embeddables
|
||||
* @property {ViewState} view
|
||||
*/
|
||||
|
||||
|
@ -38,10 +40,16 @@ export const getPanel = (dashboard, panelId) => getPanels(dashboard)[panelId];
|
|||
*/
|
||||
export const getPanelType = (dashboard, panelId) => getPanel(dashboard, panelId).type;
|
||||
|
||||
export const getEmbeddables = (dashboard) => dashboard.embeddables;
|
||||
|
||||
// TODO: rename panel.embeddableConfig to embeddableCustomization. Because it's on the panel that's stored on a
|
||||
// dashboard, renaming this will require a migration step.
|
||||
export const getEmbeddableCustomization = (dashboard, panelId) => getPanel(dashboard, panelId).embeddableConfig;
|
||||
|
||||
/**
|
||||
* @param dashboard {DashboardState}
|
||||
* @param panelId {string}
|
||||
* @return {EmbeddableState}
|
||||
* @return {EmbeddableReduxState}
|
||||
*/
|
||||
export const getEmbeddable = (dashboard, panelId) => dashboard.embeddables[panelId];
|
||||
|
||||
|
@ -56,13 +64,44 @@ export const getEmbeddableError = (dashboard, panelId) => getEmbeddable(dashboar
|
|||
* @param panelId {string}
|
||||
* @return {string}
|
||||
*/
|
||||
export const getEmbeddableTitle = (dashboard, panelId) => getEmbeddable(dashboard, panelId).title;
|
||||
export const getEmbeddableTitle = (dashboard, panelId) => {
|
||||
const embeddable = getEmbeddable(dashboard, panelId);
|
||||
return embeddable && embeddable.initialized ? embeddable.metadata.title : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* @param dashboard {DashboardState}
|
||||
* @param panelId {string}
|
||||
* @return {boolean}
|
||||
*/
|
||||
export const getEmbeddableRenderComplete = (dashboard, panelId) => getEmbeddable(dashboard, panelId).renderComplete;
|
||||
|
||||
/**
|
||||
* @param dashboard {DashboardState}
|
||||
* @param panelId {string}
|
||||
* @return {boolean}
|
||||
*/
|
||||
export const getEmbeddableInitialized = (dashboard, panelId) => getEmbeddable(dashboard, panelId).initialized;
|
||||
|
||||
export const getEmbeddableStagedFilter = (dashboard, panelId) => getEmbeddable(dashboard, panelId).stagedFilter;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param dashboard {DashboardState}
|
||||
* @param panelId {string}
|
||||
* @return {EmbeddableMetadata}
|
||||
*/
|
||||
export const getEmbeddableMetadata = (dashboard, panelId) => getEmbeddable(dashboard, panelId).metadata;
|
||||
|
||||
/**
|
||||
* @param dashboard {DashboardState}
|
||||
* @param panelId {string}
|
||||
* @return {string}
|
||||
*/
|
||||
export const getEmbeddableEditUrl = (dashboard, panelId) => getEmbeddable(dashboard, panelId).editUrl;
|
||||
export const getEmbeddableEditUrl = (dashboard, panelId) => {
|
||||
const embeddable = getEmbeddable(dashboard, panelId);
|
||||
return embeddable && embeddable.initialized ? embeddable.metadata.editUrl : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* @param dashboard {DashboardState}
|
||||
|
@ -89,19 +128,60 @@ export const getHidePanelTitles = dashboard => dashboard.view.hidePanelTitles;
|
|||
* @return {string|undefined}
|
||||
*/
|
||||
export const getMaximizedPanelId = dashboard => dashboard.view.maximizedPanelId;
|
||||
/**
|
||||
* @param dashboard {DashboardState}
|
||||
* @return {string|undefined}
|
||||
*/
|
||||
export const getTimeRange = dashboard => dashboard.view.timeRange;
|
||||
|
||||
/**
|
||||
* @typedef {Object} DashboardMetadata
|
||||
* @property {string} title
|
||||
* @property {string} description
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param dashboard {DashboardState}
|
||||
* @return {MetadataState}
|
||||
* @return {DashboardMetadata}
|
||||
*/
|
||||
export const getMetadata = dashboard => dashboard.metadata;
|
||||
/**
|
||||
* @param dashboard {MetadataState}
|
||||
* @param dashboard {DashboardState}
|
||||
* @return {string}
|
||||
*/
|
||||
export const getTitle = dashboard => dashboard.metadata.title;
|
||||
/**
|
||||
* @param dashboard {MetadataState}
|
||||
* @param dashboard {DashboardState}
|
||||
* @return {string}
|
||||
*/
|
||||
export const getDescription = dashboard => dashboard.metadata.description;
|
||||
|
||||
/**
|
||||
* This state object is specifically for communicating to embeddables and it's structure is not tied to
|
||||
* the redux tree structure.
|
||||
* @typedef {Object} ContainerState
|
||||
* @property {Object} timeRange
|
||||
* @property {Object} embeddableCustomization
|
||||
* @property {boolean} hidePanelTitles
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param dashboard {DashboardState}
|
||||
* @param panelId {string}
|
||||
* @return {ContainerState}
|
||||
*/
|
||||
export const getContainerState = (dashboard, panelId) => ({
|
||||
timeRange: _.cloneDeep(getTimeRange(dashboard)),
|
||||
embeddableCustomization: _.cloneDeep(getEmbeddableCustomization(dashboard, panelId) || {}),
|
||||
hidePanelTitles: getHidePanelTitles(dashboard),
|
||||
customTitle: getPanel(dashboard, panelId).title,
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param embeddables {Array.<EmbeddableState>}
|
||||
* @return {Array.<{ field, value, operator, index }>} Array of filters any embeddables wish dashboard to apply
|
||||
*/
|
||||
export const getStagedFilters = ({ embeddables }) => _.compact(_.map(embeddables, 'stagedFilter'));
|
||||
|
||||
|
|
|
@ -4,7 +4,6 @@ import { DashboardGrid } from '../grid';
|
|||
import { ExitFullScreenButton } from '../components/exit_full_screen_button';
|
||||
|
||||
export function DashboardViewport({
|
||||
getContainerApi,
|
||||
maximizedPanelId,
|
||||
getEmbeddableFactory,
|
||||
panelCount,
|
||||
|
@ -25,7 +24,6 @@ export function DashboardViewport({
|
|||
{ isFullScreenMode && <ExitFullScreenButton onExitFullScreenMode={onExitFullScreenMode} /> }
|
||||
<DashboardGrid
|
||||
getEmbeddableFactory={getEmbeddableFactory}
|
||||
getContainerApi={getContainerApi}
|
||||
maximizedPanelId={maximizedPanelId}
|
||||
/>
|
||||
</div>
|
||||
|
@ -33,7 +31,6 @@ export function DashboardViewport({
|
|||
}
|
||||
|
||||
DashboardViewport.propTypes = {
|
||||
getContainerApi: PropTypes.func,
|
||||
getEmbeddableFactory: PropTypes.func,
|
||||
maximizedPanelId: PropTypes.string,
|
||||
panelCount: PropTypes.number,
|
||||
|
|
|
@ -13,6 +13,5 @@ export function DashboardViewportProvider(props) {
|
|||
}
|
||||
|
||||
DashboardViewportProvider.propTypes = {
|
||||
getContainerApi: PropTypes.func.isRequired,
|
||||
getEmbeddableFactory: PropTypes.func.isRequired,
|
||||
};
|
||||
|
|
|
@ -0,0 +1,122 @@
|
|||
import angular from 'angular';
|
||||
import { Embeddable } from 'ui/embeddable';
|
||||
import searchTemplate from './search_template.html';
|
||||
import * as columnActions from 'ui/doc_table/actions/columns';
|
||||
|
||||
export class SearchEmbeddable extends Embeddable {
|
||||
constructor({ onEmbeddableStateChanged, savedSearch, editUrl, loader, $rootScope, $compile }) {
|
||||
super();
|
||||
this.onEmbeddableStateChanged = onEmbeddableStateChanged;
|
||||
this.savedSearch = savedSearch;
|
||||
this.loader = loader;
|
||||
this.$rootScope = $rootScope;
|
||||
this.$compile = $compile;
|
||||
this.customization = {};
|
||||
|
||||
/**
|
||||
* @type {EmbeddableMetadata}
|
||||
*/
|
||||
this.metadata = {
|
||||
title: savedSearch.title,
|
||||
editUrl,
|
||||
indexPattern: this.savedSearch.searchSource.get('index'),
|
||||
};
|
||||
}
|
||||
|
||||
emitEmbeddableStateChange(embeddableState) {
|
||||
this.onEmbeddableStateChanged(embeddableState);
|
||||
}
|
||||
|
||||
getEmbeddableState() {
|
||||
return {
|
||||
customization: this.customization
|
||||
};
|
||||
}
|
||||
|
||||
pushContainerStateParamsToScope() {
|
||||
// If there is column or sort data on the panel, that means the original columns or sort settings have
|
||||
// been overridden in a dashboard.
|
||||
this.searchScope.columns = this.customization.columns || this.savedSearch.columns;
|
||||
this.searchScope.sort = this.customization.sort || this.savedSearch.sort;
|
||||
this.searchScope.sharedItemTitle = this.panelTitle;
|
||||
}
|
||||
|
||||
onContainerStateChanged(containerState) {
|
||||
this.customization = containerState.embeddableCustomization || {};
|
||||
this.panelTitle = '';
|
||||
if (!containerState.hidePanelTitles) {
|
||||
this.panelTitle = containerState.customTitle !== undefined ?
|
||||
containerState.customTitle :
|
||||
this.savedSearch.title;
|
||||
}
|
||||
|
||||
if (this.searchScope) {
|
||||
this.pushContainerStateParamsToScope();
|
||||
}
|
||||
}
|
||||
|
||||
initializeSearchScope() {
|
||||
this.searchScope = this.$rootScope.$new();
|
||||
|
||||
this.pushContainerStateParamsToScope();
|
||||
|
||||
this.searchScope.description = this.savedSearch.description;
|
||||
this.searchScope.searchSource = this.savedSearch.searchSource;
|
||||
|
||||
this.searchScope.setSortOrder = (columnName, direction) => {
|
||||
this.searchScope.sort = this.customization.sort = [columnName, direction];
|
||||
this.emitEmbeddableStateChange(this.getEmbeddableState());
|
||||
};
|
||||
|
||||
this.searchScope.addColumn = (columnName) => {
|
||||
this.savedSearch.searchSource.get('index').popularizeField(columnName, 1);
|
||||
columnActions.addColumn(this.searchScope.columns, columnName);
|
||||
this.searchScope.columns = this.customization.columns = this.searchScope.columns;
|
||||
this.emitEmbeddableStateChange(this.getEmbeddableState());
|
||||
};
|
||||
|
||||
this.searchScope.removeColumn = (columnName) => {
|
||||
this.savedSearch.searchSource.get('index').popularizeField(columnName, 1);
|
||||
columnActions.removeColumn(this.searchScope.columns, columnName);
|
||||
this.customization.columns = this.searchScope.columns;
|
||||
this.emitEmbeddableStateChange(this.getEmbeddableState());
|
||||
};
|
||||
|
||||
this.searchScope.moveColumn = (columnName, newIndex) => {
|
||||
columnActions.moveColumn(this.searchScope.columns, columnName, newIndex);
|
||||
this.customization.columns = this.searchScope.columns;
|
||||
this.emitEmbeddableStateChange(this.getEmbeddableState());
|
||||
};
|
||||
|
||||
this.searchScope.filter = (field, value, operator) => {
|
||||
const index = this.savedSearch.searchSource.get('index').id;
|
||||
const stagedFilter = {
|
||||
field,
|
||||
value,
|
||||
operator,
|
||||
index
|
||||
};
|
||||
this.emitEmbeddableStateChange({
|
||||
...this.getEmbeddableState(),
|
||||
stagedFilter,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
render(domNode) {
|
||||
this.domNode = domNode;
|
||||
this.initializeSearchScope();
|
||||
this.searchInstance = this.$compile(searchTemplate)(this.searchScope);
|
||||
const rootNode = angular.element(domNode);
|
||||
rootNode.append(this.searchInstance);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.savedSearch.destroy();
|
||||
if (this.searchScope) {
|
||||
this.searchInstance.remove();
|
||||
this.searchScope.$destroy();
|
||||
delete this.searchScope;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,102 +1,42 @@
|
|||
import searchTemplate from './search_template.html';
|
||||
import angular from 'angular';
|
||||
import 'ui/doc_table';
|
||||
|
||||
import * as columnActions from 'ui/doc_table/actions/columns';
|
||||
import { PersistedState } from 'ui/persisted_state';
|
||||
import { EmbeddableFactory, Embeddable } from 'ui/embeddable';
|
||||
import { EmbeddableFactory } from 'ui/embeddable';
|
||||
import { SearchEmbeddable } from './search_embeddable';
|
||||
|
||||
export class SearchEmbeddableFactory extends EmbeddableFactory {
|
||||
|
||||
constructor($compile, $rootScope, searchLoader, Promise, courier) {
|
||||
constructor($compile, $rootScope, searchLoader) {
|
||||
super();
|
||||
this.$compile = $compile;
|
||||
this.searchLoader = searchLoader;
|
||||
this.$rootScope = $rootScope;
|
||||
this.name = 'search';
|
||||
this.Promise = Promise;
|
||||
this.courier = courier;
|
||||
}
|
||||
|
||||
getEditPath(panelId) {
|
||||
return this.searchLoader.urlFor(panelId);
|
||||
}
|
||||
|
||||
getTitleFor(panelId) {
|
||||
return this.searchLoader.get(panelId).then(savedObject => savedObject.title);
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {Object} panelMetadata. Currently just passing in panelState but it's more than we need, so we should
|
||||
* decouple this to only include data given to us from the embeddable when it's added to the dashboard. Generally
|
||||
* will be just the object id, but could be anything depending on the plugin.
|
||||
* @param onEmbeddableStateChanged
|
||||
* @return {Promise.<Embeddable>}
|
||||
*/
|
||||
create(panelMetadata, onEmbeddableStateChanged) {
|
||||
const searchId = panelMetadata.id;
|
||||
const editUrl = this.getEditPath(searchId);
|
||||
|
||||
render(domNode, panel, container) {
|
||||
const searchScope = this.$rootScope.$new();
|
||||
searchScope.editPath = this.getEditPath(panel.id);
|
||||
return this.searchLoader.get(panel.id)
|
||||
return this.searchLoader.get(searchId)
|
||||
.then(savedObject => {
|
||||
if (!container.getHidePanelTitles()) {
|
||||
searchScope.sharedItemTitle = panel.title !== undefined ? panel.title : savedObject.title;
|
||||
}
|
||||
searchScope.savedObj = savedObject;
|
||||
searchScope.panel = panel;
|
||||
container.registerPanelIndexPattern(panel.panelIndex, savedObject.searchSource.get('index'));
|
||||
|
||||
// If there is column or sort data on the panel, that means the original columns or sort settings have
|
||||
// been overridden in a dashboard.
|
||||
searchScope.columns = searchScope.panel.columns || searchScope.savedObj.columns;
|
||||
searchScope.sort = searchScope.panel.sort || searchScope.savedObj.sort;
|
||||
|
||||
const parsedUiState = savedObject.uiStateJSON ? JSON.parse(savedObject.uiStateJSON) : {};
|
||||
searchScope.uiState = new PersistedState({
|
||||
...parsedUiState,
|
||||
...panel.embeddableConfig,
|
||||
});
|
||||
const uiStateChangeHandler = () => {
|
||||
searchScope.panel = container.updatePanel(
|
||||
searchScope.panel.panelIndex,
|
||||
{ embeddableConfig: searchScope.uiState.toJSON() }
|
||||
);
|
||||
};
|
||||
searchScope.uiState.on('change', uiStateChangeHandler);
|
||||
|
||||
searchScope.setSortOrder = function setSortOrder(columnName, direction) {
|
||||
searchScope.panel = container.updatePanel(searchScope.panel.panelIndex, { sort: [columnName, direction] });
|
||||
searchScope.sort = searchScope.panel.sort;
|
||||
};
|
||||
|
||||
searchScope.addColumn = function addColumn(columnName) {
|
||||
savedObject.searchSource.get('index').popularizeField(columnName, 1);
|
||||
columnActions.addColumn(searchScope.columns, columnName);
|
||||
searchScope.panel = container.updatePanel(searchScope.panel.panelIndex, { columns: searchScope.columns });
|
||||
};
|
||||
|
||||
searchScope.removeColumn = function removeColumn(columnName) {
|
||||
savedObject.searchSource.get('index').popularizeField(columnName, 1);
|
||||
columnActions.removeColumn(searchScope.columns, columnName);
|
||||
searchScope.panel = container.updatePanel(searchScope.panel.panelIndex, { columns: searchScope.columns });
|
||||
};
|
||||
|
||||
searchScope.moveColumn = function moveColumn(columnName, newIndex) {
|
||||
columnActions.moveColumn(searchScope.columns, columnName, newIndex);
|
||||
searchScope.panel = container.updatePanel(searchScope.panel.panelIndex, { columns: searchScope.columns });
|
||||
};
|
||||
|
||||
searchScope.filter = function (field, value, operator) {
|
||||
const index = savedObject.searchSource.get('index').id;
|
||||
container.addFilter(field, value, operator, index);
|
||||
};
|
||||
|
||||
const searchInstance = this.$compile(searchTemplate)(searchScope);
|
||||
const rootNode = angular.element(domNode);
|
||||
rootNode.append(searchInstance);
|
||||
|
||||
this.addDestroyEmeddable(panel.panelIndex, () => {
|
||||
searchScope.uiState.off('change', uiStateChangeHandler);
|
||||
searchInstance.remove();
|
||||
searchScope.savedObj.destroy();
|
||||
searchScope.$destroy();
|
||||
});
|
||||
|
||||
return new Embeddable({
|
||||
title: savedObject.title,
|
||||
editUrl: searchScope.editPath
|
||||
return new SearchEmbeddable({
|
||||
onEmbeddableStateChanged,
|
||||
savedSearch: savedObject,
|
||||
editUrl,
|
||||
loader: this.searchLoader,
|
||||
$rootScope: this.$rootScope,
|
||||
$compile: this.$compile,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<doc-table
|
||||
search-source="savedObj.searchSource"
|
||||
search-source="searchSource"
|
||||
sorting="sort"
|
||||
columns="columns"
|
||||
data-shared-item
|
||||
data-title="{{sharedItemTitle}}"
|
||||
data-description="{{savedObj.description}}"
|
||||
data-description="{{description}}"
|
||||
render-complete
|
||||
class="panel-content"
|
||||
filter="filter"
|
||||
|
@ -12,5 +12,6 @@
|
|||
on-change-sort-order="setSortOrder"
|
||||
on-move-column="moveColumn"
|
||||
on-remove-column="removeColumn"
|
||||
data-test-subj="embeddedSavedSearchDocTable"
|
||||
>
|
||||
</doc-table>
|
||||
|
|
|
@ -15,16 +15,24 @@ export const getPanels = state => DashboardSelectors.getPanels(getDashboard(stat
|
|||
export const getPanel = (state, panelId) => DashboardSelectors.getPanel(getDashboard(state), panelId);
|
||||
export const getPanelType = (state, panelId) => DashboardSelectors.getPanelType(getDashboard(state), panelId);
|
||||
|
||||
export const getEmbeddables = state => DashboardSelectors.getEmbeddables(getDashboard(state));
|
||||
export const getEmbeddableError = (state, panelId) =>
|
||||
DashboardSelectors.getEmbeddableError((getDashboard(state)), panelId);
|
||||
export const getEmbeddableTitle = (state, panelId) => DashboardSelectors.getEmbeddableTitle(getDashboard(state), panelId);
|
||||
export const getEmbeddableEditUrl = (state, panelId) => DashboardSelectors.getEmbeddableEditUrl(getDashboard(state), panelId);
|
||||
export const getEmbeddableInitialized = (state, panelId) => DashboardSelectors.getEmbeddableInitialized(getDashboard(state), panelId);
|
||||
export const getEmbeddableCustomization =
|
||||
(state, panelId) => DashboardSelectors.getEmbeddableCustomization(getDashboard(state), panelId);
|
||||
export const getEmbeddableStagedFilter =
|
||||
(state, panelId) => DashboardSelectors.getEmbeddableStagedFilter(getDashboard(state), panelId);
|
||||
export const getEmbeddableMetadata =
|
||||
(state, panelId) => DashboardSelectors.getEmbeddableMetadata(getDashboard(state), panelId);
|
||||
|
||||
export const getStagedFilters = state => DashboardSelectors.getStagedFilters(getDashboard(state));
|
||||
export const getViewMode = state => DashboardSelectors.getViewMode(getDashboard(state));
|
||||
export const getFullScreenMode = state => DashboardSelectors.getFullScreenMode(getDashboard(state));
|
||||
export const getMaximizedPanelId = state => DashboardSelectors.getMaximizedPanelId(getDashboard(state));
|
||||
export const getUseMargins = state => DashboardSelectors.getUseMargins(getDashboard(state));
|
||||
export const getHidePanelTitles = state => DashboardSelectors.getHidePanelTitles(getDashboard(state));
|
||||
export const getTimeRange = state => DashboardSelectors.getTimeRange(getDashboard(state));
|
||||
|
||||
export const getTitle = state => DashboardSelectors.getTitle(getDashboard(state));
|
||||
export const getDescription = state => DashboardSelectors.getDescription(getDashboard(state));
|
||||
|
|
|
@ -0,0 +1,112 @@
|
|||
import { PersistedState } from 'ui/persisted_state';
|
||||
import { Embeddable } from 'ui/embeddable';
|
||||
import chrome from 'ui/chrome';
|
||||
import _ from 'lodash';
|
||||
|
||||
export class VisualizeEmbeddable extends Embeddable {
|
||||
constructor({ onEmbeddableStateChanged, savedVisualization, editUrl, loader }) {
|
||||
super();
|
||||
this._onEmbeddableStateChanged = onEmbeddableStateChanged;
|
||||
this.savedVisualization = savedVisualization;
|
||||
this.loader = loader;
|
||||
|
||||
const parsedUiState = savedVisualization.uiStateJSON ? JSON.parse(savedVisualization.uiStateJSON) : {};
|
||||
this.uiState = new PersistedState(parsedUiState);
|
||||
|
||||
this.uiState.on('change', this._uiStateChangeHandler);
|
||||
|
||||
/**
|
||||
* @type {EmbeddableMetadata}
|
||||
*/
|
||||
this.metadata = {
|
||||
title: savedVisualization.title,
|
||||
editUrl,
|
||||
indexPattern: this.savedVisualization.vis.indexPattern
|
||||
};
|
||||
}
|
||||
|
||||
_uiStateChangeHandler = () => {
|
||||
this.customization = this.uiState.toJSON();
|
||||
this._onEmbeddableStateChanged(this.getEmbeddableState());
|
||||
};
|
||||
|
||||
getEmbeddableState() {
|
||||
return {
|
||||
customization: this.customization,
|
||||
};
|
||||
}
|
||||
|
||||
getHandlerParams() {
|
||||
return {
|
||||
uiState: this.uiState,
|
||||
// Append visualization to container instead of replacing its content
|
||||
append: true,
|
||||
timeRange: this.timeRange,
|
||||
cssClass: `panel-content panel-content--fullWidth`,
|
||||
// The chrome is permanently hidden in "embed mode" in which case we don't want to show the spy pane, since
|
||||
// we deem that situation to be more public facing and want to hide more detailed information.
|
||||
showSpyPanel: !chrome.getIsChromePermanentlyHidden(),
|
||||
dataAttrs: {
|
||||
'shared-item': '',
|
||||
title: this.panelTitle,
|
||||
description: this.savedVisualization.description,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onContainerStateChanged(containerState) {
|
||||
const customization = containerState.embeddableCustomization;
|
||||
let isDirty = false;
|
||||
if (!_.isEqual(this.customization, customization)) {
|
||||
// Turn this off or the uiStateChangeHandler will fire for every modification.
|
||||
this.uiState.off('change', this._uiStateChangeHandler);
|
||||
this.uiState.clearAllKeys();
|
||||
Object.getOwnPropertyNames(customization).forEach(key => {
|
||||
this.uiState.set(key, customization[key]);
|
||||
});
|
||||
this.customization = customization;
|
||||
isDirty = true;
|
||||
this.uiState.on('change', this._uiStateChangeHandler);
|
||||
}
|
||||
|
||||
let derivedPanelTitle = '';
|
||||
if (!containerState.hidePanelTitles) {
|
||||
derivedPanelTitle = containerState.customTitle !== undefined ?
|
||||
containerState.customTitle :
|
||||
this.savedVisualization.title;
|
||||
}
|
||||
|
||||
if (this.panelTitle !== derivedPanelTitle) {
|
||||
this.panelTitle = derivedPanelTitle;
|
||||
isDirty = true;
|
||||
}
|
||||
|
||||
if (isDirty && this.handler && this.domNode) {
|
||||
// TODO: We need something like this in the handler
|
||||
// this.handler.update(this.getHandlerParams());
|
||||
// For now:
|
||||
this.destroy();
|
||||
this.handler = this.loader.embedVisualizationWithSavedObject(
|
||||
this.domNode,
|
||||
this.savedVisualization,
|
||||
this.getHandlerParams());
|
||||
}
|
||||
}
|
||||
|
||||
render(domNode) {
|
||||
this.domNode = domNode;
|
||||
this.handler = this.loader.embedVisualizationWithSavedObject(
|
||||
domNode,
|
||||
this.savedVisualization,
|
||||
this.getHandlerParams());
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.uiState.off('change', this._uiStateChangeHandler);
|
||||
this.savedVisualization.destroy();
|
||||
if (this.handler) {
|
||||
this.handler.destroy();
|
||||
this.handler.getElement().remove();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,101 +1,58 @@
|
|||
import $ from 'jquery';
|
||||
import { getVisualizeLoader } from 'ui/visualize/loader';
|
||||
|
||||
import { UtilsBrushEventProvider as utilsBrushEventProvider } from 'ui/utils/brush_event';
|
||||
import { FilterBarClickHandlerProvider as filterBarClickHandlerProvider } from 'ui/filter_bar/filter_bar_click_handler';
|
||||
import { EmbeddableFactory, Embeddable } from 'ui/embeddable';
|
||||
import { PersistedState } from 'ui/persisted_state';
|
||||
import { VisualizeEmbeddable } from './visualize_embeddable';
|
||||
|
||||
import labDisabledTemplate from './visualize_lab_disabled.html';
|
||||
|
||||
import chrome from 'ui/chrome';
|
||||
|
||||
export class VisualizeEmbeddableFactory extends EmbeddableFactory {
|
||||
constructor(savedVisualizations, timefilter, Promise, Private, config) {
|
||||
constructor(savedVisualizations, config) {
|
||||
super();
|
||||
this._config = config;
|
||||
this.savedVisualizations = savedVisualizations;
|
||||
this.name = 'visualization';
|
||||
this.Promise = Promise;
|
||||
this.brushEvent = utilsBrushEventProvider(timefilter);
|
||||
this.filterBarClickHandler = filterBarClickHandlerProvider(Private);
|
||||
}
|
||||
|
||||
getEditPath(panelId) {
|
||||
return this.savedVisualizations.urlFor(panelId);
|
||||
}
|
||||
|
||||
render(domNode, panel, container) {
|
||||
const editUrl = this.getEditPath(panel.id);
|
||||
/**
|
||||
*
|
||||
* @param {Object} panelMetadata. Currently just passing in panelState but it's more than we need, so we should
|
||||
* decouple this to only include data given to us from the embeddable when it's added to the dashboard. Generally
|
||||
* will be just the object id, but could be anything depending on the plugin.
|
||||
* @param {function} onEmbeddableStateChanged
|
||||
* @return {Promise.<{ metadata, onContainerStateChanged, render, destroy }>}
|
||||
*/
|
||||
create(panelMetadata, onEmbeddableStateChanged) {
|
||||
const visId = panelMetadata.id;
|
||||
const editUrl = this.getEditPath(visId);
|
||||
|
||||
const waitFor = [ getVisualizeLoader(), this.savedVisualizations.get(panel.id) ];
|
||||
return this.Promise.all(waitFor)
|
||||
const waitFor = [ getVisualizeLoader(), this.savedVisualizations.get(visId) ];
|
||||
return Promise.all(waitFor)
|
||||
.then(([loader, savedObject]) => {
|
||||
const isLabsEnabled = this._config.get('visualize:enableLabs');
|
||||
|
||||
if (!isLabsEnabled && savedObject.vis.type.stage === 'lab') {
|
||||
const template = $(labDisabledTemplate);
|
||||
template.find('.disabledLabVisualization__title').text(savedObject.title);
|
||||
$(domNode).html(template);
|
||||
|
||||
return new Embeddable({
|
||||
title: savedObject.title
|
||||
metadata: {
|
||||
title: savedObject.title,
|
||||
},
|
||||
render: (domNode) => {
|
||||
const template = $(labDisabledTemplate);
|
||||
template.find('.disabledLabVisualization__title').text(savedObject.title);
|
||||
$(domNode).html(template);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return new VisualizeEmbeddable({
|
||||
onEmbeddableStateChanged,
|
||||
savedVisualization: savedObject,
|
||||
editUrl,
|
||||
loader,
|
||||
});
|
||||
}
|
||||
|
||||
let panelTitle;
|
||||
if (!container.getHidePanelTitles()) {
|
||||
panelTitle = panel.title !== undefined ? panel.title : savedObject.title;
|
||||
}
|
||||
|
||||
const parsedUiState = savedObject.uiStateJSON ? JSON.parse(savedObject.uiStateJSON) : {};
|
||||
const uiState = new PersistedState({
|
||||
...parsedUiState,
|
||||
...panel.embeddableConfig,
|
||||
});
|
||||
container.onEmbeddableConfigChanged(panel.panelIndex, newEmbeddableConfig => {
|
||||
uiState.clearAllKeys();
|
||||
Object.getOwnPropertyNames(newEmbeddableConfig).forEach(key => {
|
||||
uiState.set(key, newEmbeddableConfig[key]);
|
||||
});
|
||||
});
|
||||
|
||||
const uiStateChangeHandler = () => {
|
||||
panel = container.updatePanel(
|
||||
panel.panelIndex,
|
||||
{ embeddableConfig: uiState.toJSON() }
|
||||
);
|
||||
};
|
||||
uiState.on('change', uiStateChangeHandler);
|
||||
|
||||
container.registerPanelIndexPattern(panel.panelIndex, savedObject.vis.indexPattern);
|
||||
|
||||
const handler = loader.embedVisualizationWithSavedObject(domNode, savedObject, {
|
||||
uiState: uiState,
|
||||
// Append visualization to container instead of replacing its content
|
||||
append: true,
|
||||
cssClass: `panel-content panel-content--fullWidth`,
|
||||
// The chrome is permanently hidden in "embed mode" in which case we don't want to show the spy pane, since
|
||||
// we deem that situation to be more public facing and want to hide more detailed information.
|
||||
showSpyPanel: !chrome.getIsChromePermanentlyHidden(),
|
||||
dataAttrs: {
|
||||
'shared-item': '',
|
||||
title: panelTitle,
|
||||
description: savedObject.description,
|
||||
}
|
||||
});
|
||||
|
||||
this.addDestroyEmeddable(panel.panelIndex, () => {
|
||||
uiState.off('change', uiStateChangeHandler);
|
||||
handler.getElement().remove();
|
||||
savedObject.destroy();
|
||||
handler.destroy();
|
||||
});
|
||||
|
||||
return new Embeddable({
|
||||
title: savedObject.title,
|
||||
editUrl: editUrl
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,11 +4,8 @@ import { EmbeddableFactoriesRegistryProvider } from 'ui/embeddable/embeddable_fa
|
|||
export function visualizeEmbeddableFactoryProvider(Private) {
|
||||
const VisualizeEmbeddableFactoryProvider = (
|
||||
savedVisualizations,
|
||||
timefilter,
|
||||
Promise,
|
||||
Private,
|
||||
config) => {
|
||||
return new VisualizeEmbeddableFactory(savedVisualizations, timefilter, Promise, Private, config);
|
||||
return new VisualizeEmbeddableFactory(savedVisualizations, config);
|
||||
};
|
||||
return Private(VisualizeEmbeddableFactoryProvider);
|
||||
}
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
/**
|
||||
* The ContainerAPI is an interface for embeddable objects to interact with the container they are embedded within.
|
||||
*/
|
||||
export class ContainerAPI {
|
||||
/**
|
||||
* Available so the embeddable object can trigger a filter action.
|
||||
* @param field
|
||||
* @param value
|
||||
* @param operator
|
||||
* @param index
|
||||
*/
|
||||
addFilter(/*field, value, operator, index */) {
|
||||
throw new Error('Must implement addFilter.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this to tell the container that this panel uses a particular index pattern.
|
||||
* @param {string} panelIndex - a unique id that identifies the panel to update.
|
||||
* @param {string} indexPattern - an index pattern the panel uses
|
||||
*/
|
||||
registerPanelIndexPattern(/* panelIndex, indexPattern */) {
|
||||
throw new Error('Must implement registerPanelIndexPattern.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} panelIndex - a unique id that identifies the panel to update.
|
||||
* @param {Object} panelAttributes - the new panel attributes that will be applied to the panel.
|
||||
* @return {Object} - the updated panel.
|
||||
*/
|
||||
updatePanel(/*paneIndex, panelAttributes */) {
|
||||
throw new Error('Must implement updatePanel.');
|
||||
}
|
||||
|
||||
getHidePanelTitles() {
|
||||
return this.dashboardState.getHidePanelTitles();
|
||||
}
|
||||
}
|
|
@ -1,6 +1,48 @@
|
|||
/**
|
||||
* @typedef {Object} EmbeddableMetadata - data that does not change over the course of the embeddables life span.
|
||||
* @property {string} title
|
||||
* @property {string|undefined} editUrl
|
||||
* @property {IndexPattern} indexPattern
|
||||
*/
|
||||
|
||||
|
||||
export class Embeddable {
|
||||
constructor(config) {
|
||||
this.title = config.title || '';
|
||||
this.editUrl = config.editUrl || '';
|
||||
/**
|
||||
*
|
||||
* @param {Object|undefined} config
|
||||
* @param {EmbeddableMetadata|undefined} config.metadata optional metadata
|
||||
* @param {function|undefined} config.render optional render method
|
||||
* @param {function|undefined} config.destroy optional destroy method
|
||||
* @param {function|undefined} config.onContainerStateChanged optional onContainerStateChanged method
|
||||
*/
|
||||
constructor(config = {}) {
|
||||
/**
|
||||
* @type {EmbeddableMetadata}
|
||||
*/
|
||||
this.metadata = config.metadata || {};
|
||||
|
||||
if (config.render) {
|
||||
this.render = config.render;
|
||||
}
|
||||
|
||||
if (config.destroy) {
|
||||
this.destroy = config.destroy;
|
||||
}
|
||||
|
||||
if (config.onContainerStateChanged) {
|
||||
this.onContainerStateChanged = config.onContainerStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ContainerState} containerState
|
||||
*/
|
||||
onContainerStateChanged(/*containerState*/) {}
|
||||
|
||||
/**
|
||||
* @param {Element} domNode - the dom node to mount the rendered embeddable on
|
||||
*/
|
||||
render(/*domNode*/) {}
|
||||
|
||||
destroy() {}
|
||||
}
|
||||
|
|
|
@ -1,33 +1,30 @@
|
|||
/**
|
||||
* The EmbeddableFactory renders an embeddable of a certain type at a given dom node.
|
||||
* @typedef {Object} EmbeddableState
|
||||
* @property {Object} customization - any customization data that should be stored at the panel level. For
|
||||
* example, pie slice colors, or custom per panel sort order or columns.
|
||||
* @property {Object} stagedFilter - a possible filter the embeddable wishes dashboard to apply.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* @callback onEmbeddableStateChanged
|
||||
* @param {EmbeddableState} embeddableState
|
||||
*/
|
||||
|
||||
/**
|
||||
* The EmbeddableFactory creates and initializes an embeddable instance
|
||||
*/
|
||||
export class EmbeddableFactory {
|
||||
constructor() {
|
||||
this.destroyEmbeddableMap = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} domNode - the dom node to mount the rendered embeddable on
|
||||
* @param {PanelState} panel - a panel object which container information about the panel. Can also be modified to
|
||||
* 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.<Embeddable>} A promise that resolves to a function that should be used to destroy the
|
||||
* rendered embeddable.
|
||||
*
|
||||
* @param {Object} containerMetadata. Currently just passing in panelState but it's more than we need, so we should
|
||||
* decouple this to only include data given to us from the embeddable when it's added to the dashboard. Generally
|
||||
* will be just the object id, but could be anything depending on the plugin.
|
||||
* @param {onEmbeddableStateChanged} onEmbeddableStateChanged - embeddable should call this function with updated
|
||||
* state whenever something changes that the dashboard should know about.
|
||||
* @return {Promise.<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];
|
||||
}
|
||||
create(/* containerMetadata, onEmbeddableStateChanged*/) {
|
||||
throw new Error('Must implement create.');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
export { EmbeddableFactory } from './embeddable_factory';
|
||||
export { Embeddable } from './embeddable';
|
||||
export { EmbeddableFactoriesRegistryProvider } from './embeddable_factories_registry';
|
||||
export { ContainerAPI } from './container_api';
|
||||
|
|
|
@ -45,6 +45,7 @@
|
|||
>
|
||||
<div class="kuiFieldGroupSection filterEditor__wideField">
|
||||
<filter-field-select
|
||||
data-test-subj="filterfieldSuggestionList"
|
||||
index-patterns="filterEditor.indexPatterns"
|
||||
field="filterEditor.field"
|
||||
on-select="filterEditor.onFieldSelect(field)"
|
||||
|
|
|
@ -4,6 +4,8 @@ export default function ({ getService, getPageObjects }) {
|
|||
const PageObjects = getPageObjects(['dashboard', 'header']);
|
||||
const dashboardExpect = getService('dashboardExpect');
|
||||
const remote = getService('remote');
|
||||
const log = getService('log');
|
||||
|
||||
let kibanaBaseUrl;
|
||||
|
||||
const urlQuery = `` +
|
||||
|
@ -38,7 +40,7 @@ export default function ({ getService, getPageObjects }) {
|
|||
|
||||
it('loads an unsaved dashboard', async function () {
|
||||
const url = `${kibanaBaseUrl}#/dashboard?${urlQuery}`;
|
||||
|
||||
log.debug(`Navigating to ${url}`);
|
||||
await remote.get(url, true);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import expect from 'expect.js';
|
||||
|
||||
import {
|
||||
VisualizeConstants
|
||||
} from '../../../../src/core_plugins/kibana/public/visualize/visualize_constants';
|
||||
|
@ -85,31 +84,12 @@ export default function ({ getService, getPageObjects }) {
|
|||
expect(isDarkThemeOn).to.equal(true);
|
||||
});
|
||||
|
||||
it('should have data-shared-items-count set to the number of visualizations', function checkSavedItemsCount() {
|
||||
return retry.tryForTime(10000, () => PageObjects.dashboard.getSharedItemsCount())
|
||||
.then(function (count) {
|
||||
log.info('data-shared-items-count = ' + count);
|
||||
expect(count).to.eql(testVisualizationTitles.length);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have panels with expected data-shared-item title and description', function () {
|
||||
return retry.tryForTime(10000, function () {
|
||||
return PageObjects.dashboard.getPanelSharedItemData()
|
||||
.then(function (data) {
|
||||
expect(data.map(item => item.title)).to.eql(testVisualizationTitles);
|
||||
expect(data.map(item => item.description)).to.eql(testVisualizationDescriptions);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to hide all panel titles', async function () {
|
||||
await PageObjects.dashboard.checkHideTitle();
|
||||
await retry.tryForTime(10000, async function () {
|
||||
const titles = await PageObjects.dashboard.getPanelTitles();
|
||||
expect(titles[0]).to.eql('');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it('should be able to unhide all panel titles', async function () {
|
||||
|
|
42
test/functional/apps/dashboard/_dashboard_filter_bar.js
Normal file
42
test/functional/apps/dashboard/_dashboard_filter_bar.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { PIE_CHART_VIS_NAME } from '../../page_objects/dashboard_page';
|
||||
|
||||
export default function ({ getService, getPageObjects }) {
|
||||
const dashboardExpect = getService('dashboardExpect');
|
||||
const dashboardVisualizations = getService('dashboardVisualizations');
|
||||
const testSubjects = getService('testSubjects');
|
||||
const PageObjects = getPageObjects(['dashboard', 'header', 'visualize']);
|
||||
|
||||
describe('dashboard filter bar', function describeIndexTests() {
|
||||
before(async function () {
|
||||
await PageObjects.dashboard.initTests();
|
||||
await PageObjects.dashboard.preserveCrossAppState();
|
||||
});
|
||||
|
||||
after(async function () {
|
||||
// avoids any 'Object with id x not found' errors when switching tests.
|
||||
await PageObjects.header.clickVisualize();
|
||||
await PageObjects.visualize.gotoLandingPage();
|
||||
await PageObjects.header.clickDashboard();
|
||||
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||
});
|
||||
|
||||
it('Filter bar field list uses default index pattern on an empty dashboard', async () => {
|
||||
await PageObjects.dashboard.clickNewDashboard();
|
||||
await testSubjects.click('addFilter');
|
||||
await dashboardExpect.fieldSuggestionIndexPatterns(['logstash-*']);
|
||||
});
|
||||
|
||||
// TODO: Use a data set that has more than one index pattern to better test this.
|
||||
it('Filter bar field list shows index pattern of vis when one is added', async () => {
|
||||
await PageObjects.dashboard.addVisualizations([PIE_CHART_VIS_NAME]);
|
||||
await testSubjects.click('filterfieldSuggestionList');
|
||||
await dashboardExpect.fieldSuggestionIndexPatterns(['logstash-*']);
|
||||
});
|
||||
|
||||
it('Filter bar field list works when a vis with no index pattern is added', async () => {
|
||||
await dashboardVisualizations.createAndAddMarkdown({ name: 'markdown', markdown: 'hi ima markdown' });
|
||||
await testSubjects.click('addFilter');
|
||||
await dashboardExpect.fieldSuggestionIndexPatterns(['logstash-*']);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
import expect from 'expect.js';
|
||||
|
||||
export default function ({ getService, getPageObjects }) {
|
||||
const retry = getService('retry');
|
||||
const log = getService('log');
|
||||
const dashboardVisualizations = getService('dashboardVisualizations');
|
||||
const testSubjects = getService('testSubjects');
|
||||
const PageObjects = getPageObjects(['dashboard']);
|
||||
const testVisualizationTitles = [PageObjects.dashboard.getTestVisualizationNames()[0], 'saved search'];
|
||||
const testVisualizationDescriptions = [PageObjects.dashboard.getTestVisualizationDescriptions()[0], ''];
|
||||
|
||||
describe('dashboard shared attributes', function describeIndexTests() {
|
||||
before(async function () {
|
||||
await PageObjects.dashboard.initTests();
|
||||
await PageObjects.dashboard.preserveCrossAppState();
|
||||
await PageObjects.dashboard.clickNewDashboard();
|
||||
await PageObjects.dashboard.addVisualizations([PageObjects.dashboard.getTestVisualizationNames()[0]]);
|
||||
await dashboardVisualizations.createAndAddSavedSearch({ name: 'saved search', fields: ['bytes', 'agent'] });
|
||||
});
|
||||
|
||||
it('should have data-shared-items-count set to the number of visualizations', function checkSavedItemsCount() {
|
||||
return retry.tryForTime(10000, () => PageObjects.dashboard.getSharedItemsCount())
|
||||
.then(function (count) {
|
||||
log.info('data-shared-items-count = ' + count);
|
||||
expect(count).to.eql(testVisualizationTitles.length);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have panels with expected data-shared-item title and description', async () => {
|
||||
await retry.try(async () => {
|
||||
await PageObjects.dashboard.getPanelSharedItemData()
|
||||
.then(function (data) {
|
||||
expect(data.map(item => item.title)).to.eql(testVisualizationTitles);
|
||||
expect(data.map(item => item.description)).to.eql(testVisualizationDescriptions);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('data-shared-item title should update a viz when using a custom panel title', async () => {
|
||||
const CUSTOM_VIS_TITLE = 'ima custom title for a vis!';
|
||||
await PageObjects.dashboard.setCustomPanelTitle(CUSTOM_VIS_TITLE);
|
||||
await retry.try(async () => {
|
||||
const sharedData = await PageObjects.dashboard.getPanelSharedItemData();
|
||||
const foundSharedItemTitle = !!sharedData.find(item => {
|
||||
return item.title === CUSTOM_VIS_TITLE;
|
||||
});
|
||||
expect(foundSharedItemTitle).to.be(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('data-shared-item title is cleared with an empty panel title string', async () => {
|
||||
await PageObjects.dashboard.setCustomPanelTitle('h\b');
|
||||
await retry.try(async () => {
|
||||
const sharedData = await PageObjects.dashboard.getPanelSharedItemData();
|
||||
const foundSharedItemTitle = !!sharedData.find(item => {
|
||||
return item.title === '';
|
||||
});
|
||||
expect(foundSharedItemTitle).to.be(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('data-shared-item title can be reset', async () => {
|
||||
await PageObjects.dashboard.resetCustomPanelTitle();
|
||||
await retry.try(async () => {
|
||||
const sharedData = await PageObjects.dashboard.getPanelSharedItemData();
|
||||
const foundOriginalSharedItemTitle = !!sharedData.find(item => {
|
||||
return item.title === testVisualizationTitles[0];
|
||||
});
|
||||
expect(foundOriginalSharedItemTitle).to.be(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('data-shared-item title should update a saved search when using a custom panel title', async () => {
|
||||
const CUSTOM_SEARCH_TITLE = 'ima custom title for a search!';
|
||||
const panels = await PageObjects.dashboard.getDashboardPanels();
|
||||
// The reverse is only to take advantage of the fact that the saved search is last at the time of writing this
|
||||
// test which speeds things up.
|
||||
const searchPanel = await Promise.race(panels.map(async panel => {
|
||||
return new Promise(async resolve => {
|
||||
const savedSearchPanel = await testSubjects.descendantExists('embeddedSavedSearchDocTable', panel);
|
||||
if (savedSearchPanel) {
|
||||
resolve(panel);
|
||||
}
|
||||
});
|
||||
}));
|
||||
await PageObjects.dashboard.setCustomPanelTitle(CUSTOM_SEARCH_TITLE, searchPanel);
|
||||
await retry.try(async () => {
|
||||
const sharedData = await PageObjects.dashboard.getPanelSharedItemData();
|
||||
const foundSharedItemTitle = !!sharedData.find(item => {
|
||||
return item.title === CUSTOM_SEARCH_TITLE;
|
||||
});
|
||||
console.log('foundSharedItemTitle: ' + foundSharedItemTitle);
|
||||
|
||||
expect(foundSharedItemTitle).to.be(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -21,7 +21,6 @@ export default function ({ getService, getPageObjects }) {
|
|||
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||
});
|
||||
|
||||
|
||||
it('Overriding colors on an area chart is preserved', async () => {
|
||||
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||
|
||||
|
@ -32,8 +31,13 @@ export default function ({ getService, getPageObjects }) {
|
|||
await PageObjects.dashboard.saveDashboard('Overridden colors');
|
||||
|
||||
await PageObjects.dashboard.clickEdit();
|
||||
await PageObjects.visualize.clickLegendOption('Count');
|
||||
await PageObjects.visualize.selectNewLegendColorChoice('#EA6460');
|
||||
|
||||
// Opening legend colors has been flaky.
|
||||
retry.try(async () => {
|
||||
await PageObjects.visualize.clickLegendOption('Count');
|
||||
await PageObjects.visualize.selectNewLegendColorChoice('#EA6460');
|
||||
});
|
||||
|
||||
await PageObjects.dashboard.saveDashboard('Overridden colors');
|
||||
|
||||
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||
|
|
41
test/functional/apps/dashboard/_dashboard_time_picker.js
Normal file
41
test/functional/apps/dashboard/_dashboard_time_picker.js
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { PIE_CHART_VIS_NAME } from '../../page_objects/dashboard_page';
|
||||
|
||||
export default function ({ getService, getPageObjects }) {
|
||||
const dashboardExpect = getService('dashboardExpect');
|
||||
const dashboardVisualizations = getService('dashboardVisualizations');
|
||||
const PageObjects = getPageObjects(['dashboard', 'header', 'visualize']);
|
||||
|
||||
describe('dashboard time picker', function describeIndexTests() {
|
||||
before(async function () {
|
||||
await PageObjects.dashboard.initTests();
|
||||
await PageObjects.dashboard.preserveCrossAppState();
|
||||
});
|
||||
|
||||
after(async function () {
|
||||
// avoids any 'Object with id x not found' errors when switching tests.
|
||||
await PageObjects.header.clickVisualize();
|
||||
await PageObjects.visualize.gotoLandingPage();
|
||||
await PageObjects.header.clickDashboard();
|
||||
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||
});
|
||||
|
||||
it('Visualization updated when time picker changes', async () => {
|
||||
await PageObjects.dashboard.clickNewDashboard();
|
||||
await PageObjects.dashboard.addVisualizations([PIE_CHART_VIS_NAME]);
|
||||
await dashboardExpect.pieSliceCount(0);
|
||||
|
||||
await PageObjects.dashboard.setTimepickerInDataRange();
|
||||
await dashboardExpect.pieSliceCount(10);
|
||||
});
|
||||
|
||||
it('Saved search updated when time picker changes', async () => {
|
||||
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||
await PageObjects.dashboard.clickNewDashboard();
|
||||
await dashboardVisualizations.createAndAddSavedSearch({ name: 'saved search', fields: ['bytes', 'agent'] });
|
||||
await dashboardExpect.docTableFieldCount(150);
|
||||
|
||||
await PageObjects.header.setQuickTime('Today');
|
||||
await dashboardExpect.docTableFieldCount(0);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -3,6 +3,9 @@ export default function ({ getService, loadTestFile }) {
|
|||
|
||||
describe('dashboard app', function () {
|
||||
before(() => remote.setWindowSize(1200, 900));
|
||||
loadTestFile(require.resolve('./_dashboard_filter_bar'));
|
||||
loadTestFile(require.resolve('./_dashboard_time_picker'));
|
||||
loadTestFile(require.resolve('./_dashboard_shared_attributes'));
|
||||
loadTestFile(require.resolve('./_bwc_shared_urls'));
|
||||
loadTestFile(require.resolve('./_dashboard_queries'));
|
||||
loadTestFile(require.resolve('./_dashboard_snapshots'));
|
||||
|
|
|
@ -471,6 +471,10 @@ export function DashboardPageProvider({ getService, getPageObjects }) {
|
|||
return this.getTestVisualizations().map(visualization => visualization.name);
|
||||
}
|
||||
|
||||
getTestVisualizationDescriptions() {
|
||||
return this.getTestVisualizations().map(visualization => visualization.description);
|
||||
}
|
||||
|
||||
async showPanelEditControlsDropdownMenu() {
|
||||
log.debug('showPanelEditControlsDropdownMenu');
|
||||
const editLinkExists = await testSubjects.exists('dashboardPanelEditLink');
|
||||
|
@ -555,19 +559,53 @@ export function DashboardPageProvider({ getService, getPageObjects }) {
|
|||
});
|
||||
}
|
||||
|
||||
async arePanelMainMenuOptionsOpen(panel) {
|
||||
log.debug('arePanelMainMenuOptionsOpen');
|
||||
// Sub menu used arbitrarily - any option on the main menu panel would do.
|
||||
return panel ?
|
||||
await testSubjects.descendantExists('dashboardPanelOptionsSubMenuLink', panel) :
|
||||
await testSubjects.exists('dashboardPanelOptionsSubMenuLink');
|
||||
}
|
||||
|
||||
async openPanelOptions(panel) {
|
||||
log.debug('openPanelOptions');
|
||||
const panelOpen = await this.arePanelMainMenuOptionsOpen(panel);
|
||||
if (!panelOpen) {
|
||||
await retry.try(async () => {
|
||||
await (panel ? remote.moveMouseTo(panel) : testSubjects.moveMouseTo('dashboardPanelTitle'));
|
||||
const toggleMenuItem = panel ?
|
||||
await testSubjects.findDescendant('dashboardPanelToggleMenuIcon', panel) :
|
||||
await testSubjects.find('dashboardPanelToggleMenuIcon');
|
||||
await toggleMenuItem.click();
|
||||
const panelOpen = await this.arePanelMainMenuOptionsOpen(panel);
|
||||
if (!panelOpen) { throw new Error('Panel menu still not open'); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async toggleExpandPanel(panel) {
|
||||
log.debug('toggleExpandPanel');
|
||||
await (panel ? remote.moveMouseTo(panel) : testSubjects.moveMouseTo('dashboardPanelTitle'));
|
||||
const expandShown = await testSubjects.exists('dashboardPanelExpandIcon');
|
||||
if (!expandShown) {
|
||||
const toggleMenuItem = panel ?
|
||||
await testSubjects.findDescendant('dashboardPanelToggleMenuIcon', panel) :
|
||||
testSubjects.find('dashboardPanelToggleMenuIcon');
|
||||
await toggleMenuItem.click();
|
||||
await this.openPanelOptions(panel);
|
||||
}
|
||||
await testSubjects.click('dashboardPanelExpandIcon');
|
||||
}
|
||||
|
||||
async setCustomPanelTitle(customTitle, panel) {
|
||||
log.debug(`setCustomPanelTitle(${customTitle}, ${panel})`);
|
||||
await this.openPanelOptions(panel);
|
||||
await testSubjects.click('dashboardPanelOptionsSubMenuLink');
|
||||
await testSubjects.setValue('customDashboardPanelTitleInput', customTitle);
|
||||
}
|
||||
|
||||
async resetCustomPanelTitle(panel) {
|
||||
log.debug('resetCustomPanelTitle');
|
||||
await this.openPanelOptions(panel);
|
||||
await testSubjects.click('dashboardPanelOptionsSubMenuLink');
|
||||
await testSubjects.click('resetCustomDashboardPanelTitle');
|
||||
}
|
||||
|
||||
async getSharedItemsCount() {
|
||||
log.debug('in getSharedItemsCount');
|
||||
const attributeName = 'data-shared-items-count';
|
||||
|
|
|
@ -4,6 +4,7 @@ export function DashboardExpectProvider({ getService, getPageObjects }) {
|
|||
const log = getService('log');
|
||||
const retry = getService('retry');
|
||||
const testSubjects = getService('testSubjects');
|
||||
const filterBar = getService('filterBar');
|
||||
const PageObjects = getPageObjects(['dashboard']);
|
||||
|
||||
return new class DashboardExpect {
|
||||
|
@ -30,5 +31,18 @@ export function DashboardExpectProvider({ getService, getPageObjects }) {
|
|||
expect(selectedLegendColor.length).to.be(expectedCount);
|
||||
});
|
||||
}
|
||||
|
||||
async docTableFieldCount(expectedCount) {
|
||||
log.debug(`DashboardExpect.docTableFieldCount(${expectedCount})`);
|
||||
await retry.try(async () => {
|
||||
const docTableCellCounts = await testSubjects.findAll(`docTableField`);
|
||||
expect(docTableCellCounts.length).to.be(expectedCount);
|
||||
});
|
||||
}
|
||||
|
||||
async fieldSuggestionIndexPatterns(expectedIndexPatterns) {
|
||||
const indexPatterns = await filterBar.getFilterFieldIndexPatterns();
|
||||
expect(indexPatterns).to.eql(expectedIndexPatterns);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -51,5 +51,19 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }) {
|
|||
}
|
||||
await PageObjects.dashboard.addSavedSearch(name);
|
||||
}
|
||||
|
||||
async createAndAddMarkdown({ name, markdown }) {
|
||||
log.debug(`createAndAddMarkdown(${markdown})`);
|
||||
const inViewMode = await PageObjects.dashboard.getIsInViewMode();
|
||||
if (inViewMode) {
|
||||
await PageObjects.dashboard.clickEdit();
|
||||
}
|
||||
await PageObjects.dashboard.clickAddVisualization();
|
||||
await PageObjects.dashboard.clickAddNewVisualizationLink();
|
||||
await PageObjects.visualize.clickMarkdownWidget();
|
||||
await PageObjects.visualize.setMarkdownTxt(markdown);
|
||||
await PageObjects.visualize.clickGo();
|
||||
await PageObjects.visualize.saveVisualization(name);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -47,6 +47,15 @@ export function FilterBarProvider({ getService }) {
|
|||
const spans = await testSubjects.findAll('filterEditorPhrases');
|
||||
return await Promise.all(spans.map(el => el.getVisibleText()));
|
||||
}
|
||||
|
||||
async getFilterFieldIndexPatterns() {
|
||||
const indexPatterns = [];
|
||||
const groups = await find.allByCssSelector('.ui-select-choices-group-label');
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
indexPatterns.push(await groups[i].getVisibleText());
|
||||
}
|
||||
return indexPatterns;
|
||||
}
|
||||
}
|
||||
|
||||
return new FilterBar();
|
||||
|
|
|
@ -75,8 +75,13 @@ export function FindProvider({ getService }) {
|
|||
return await this.allByCustom(remote => remote.findAllByCssSelector(selector), timeout);
|
||||
}
|
||||
|
||||
async descendantExistsByCssSelector(selector, parentElement, timeout = 1000) {
|
||||
log.debug('Find.descendantExistsByCssSelector: ' + selector);
|
||||
return await this.exists(async () => await parentElement.findDisplayedByCssSelector(selector), timeout);
|
||||
}
|
||||
|
||||
async descendantDisplayedByCssSelector(selector, parentElement) {
|
||||
log.debug('Find.childDisplayedByCssSelector: ' + selector);
|
||||
log.debug('Find.descendantDisplayedByCssSelector: ' + selector);
|
||||
return await this._ensureElement(async () => await parentElement.findDisplayedByCssSelector(selector));
|
||||
}
|
||||
|
||||
|
|
|
@ -35,6 +35,10 @@ export function TestSubjectsProvider({ getService }) {
|
|||
});
|
||||
}
|
||||
|
||||
async descendantExists(selector, parentElement) {
|
||||
return await find.descendantExistsByCssSelector(testSubjSelector(selector), parentElement);
|
||||
}
|
||||
|
||||
async findDescendant(selector, parentElement) {
|
||||
return await find.descendantDisplayedByCssSelector(testSubjSelector(selector), parentElement);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue