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:
Stacey Gammon 2018-04-13 15:23:23 -04:00 committed by GitHub
parent 4eac8ed4f2
commit 33262a7d5a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 1433 additions and 684 deletions

View 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,
}
```

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,6 @@
export * from './view';
export * from './panels';
export {
renderEmbeddable,
embeddableRenderFinished,
embeddableRenderError,
destroyEmbeddable,
} from './embeddables';
export * from './embeddables';
export {
updateDescription,

View file

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

View file

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

View file

@ -66,7 +66,6 @@
</div>
<dashboard-viewport-provider
get-container-api="getContainerApi"
get-embeddable-factory="getEmbeddableFactory"
>
</dashboard-viewport-provider>

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,6 +13,5 @@ export function DashboardViewportProvider(props) {
}
DashboardViewportProvider.propTypes = {
getContainerApi: PropTypes.func.isRequired,
getEmbeddableFactory: PropTypes.func.isRequired,
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
export { EmbeddableFactory } from './embeddable_factory';
export { Embeddable } from './embeddable';
export { EmbeddableFactoriesRegistryProvider } from './embeddable_factories_registry';
export { ContainerAPI } from './container_api';

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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