This commit is contained in:
Nathan Reese 2018-09-11 17:07:48 -06:00 committed by GitHub
parent 28450485e2
commit eadf7e847b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 831 additions and 685 deletions

View file

@ -43,7 +43,7 @@ import { showCloneModal } from './top_nav/show_clone_modal';
import { showSaveModal } from './top_nav/show_save_modal';
import { showAddPanel } from './top_nav/show_add_panel';
import { showOptionsPopover } from './top_nav/show_options_popover';
import { showShareContextMenu } from 'ui/share';
import { showShareContextMenu, ShareContextMenuExtensionsRegistryProvider } from 'ui/share';
import { migrateLegacyQuery } from 'ui/utils/migrateLegacyQuery';
import * as filterActions from 'ui/doc_table/actions/filter';
import { FilterManagerProvider } from 'ui/filter_manager';
@ -86,6 +86,7 @@ app.directive('dashboardApp', function ($injector) {
const embeddableFactories = Private(EmbeddableFactoriesRegistryProvider);
const panelActionsRegistry = Private(ContextMenuActionsRegistryProvider);
const getUnhashableStates = Private(getUnhashableStatesProvider);
const shareContextMenuExtensions = Private(ShareContextMenuExtensionsRegistryProvider);
panelActionsStore.initializeFromRegistry(panelActionsRegistry);
@ -133,14 +134,6 @@ app.directive('dashboardApp', function ($injector) {
dirty: !dash.id
};
this.getSharingTitle = () => {
return dash.title;
};
this.getSharingType = () => {
return 'dashboard';
};
dashboardStateManager.registerChangeListener(status => {
this.appStatus.dirty = status.dirty || !dash.id;
updateState();
@ -409,6 +402,11 @@ app.directive('dashboardApp', function ($injector) {
getUnhashableStates,
objectId: dash.id,
objectType: 'dashboard',
shareContextMenuExtensions,
sharingData: {
title: dash.title,
},
isDirty: dashboardStateManager.getIsDirty(),
});
};

View file

@ -470,10 +470,10 @@ export class DashboardStateManager {
* @returns {boolean} True if the dashboard has changed since the last save (or, is new).
*/
getIsDirty(timeFilter) {
return this.isDirty ||
// Filter bar comparison is done manually (see cleanFiltersForComparison for the reason) and time picker
// changes are not tracked by the state monitor.
this.getFiltersChanged(timeFilter);
// Filter bar comparison is done manually (see cleanFiltersForComparison for the reason) and time picker
// changes are not tracked by the state monitor.
const hasTimeFilterChanged = timeFilter ? this.getFiltersChanged(timeFilter) : false;
return this.isDirty || hasTimeFilterChanged;
}
getPanels() {

View file

@ -53,7 +53,7 @@ import { recentlyAccessed } from 'ui/persisted_log';
import { getDocLink } from 'ui/documentation_links';
import '../components/fetch_error';
import { getPainlessError } from './get_painless_error';
import { showShareContextMenu } from 'ui/share';
import { showShareContextMenu, ShareContextMenuExtensionsRegistryProvider } from 'ui/share';
import { getUnhashableStatesProvider } from 'ui/state_management/state_hashing';
import { Inspector } from 'ui/inspector';
import { RequestAdapter } from 'ui/inspector/adapters';
@ -162,6 +162,7 @@ function discoverController(
location: 'Discover'
});
const getUnhashableStates = Private(getUnhashableStatesProvider);
const shareContextMenuExtensions = Private(ShareContextMenuExtensionsRegistryProvider);
const inspectorAdapters = {
requests: new RequestAdapter()
};
@ -179,6 +180,10 @@ function discoverController(
const savedSearch = $route.current.locals.savedSearch;
$scope.$on('$destroy', savedSearch.destroy);
const $appStatus = $scope.appStatus = this.appStatus = {
dirty: !savedSearch.id
};
$scope.topNavMenu = [{
key: 'new',
description: 'New Search',
@ -198,13 +203,20 @@ function discoverController(
key: 'share',
description: 'Share Search',
testId: 'shareTopNavButton',
run: (menuItem, navController, anchorElement) => {
run: async (menuItem, navController, anchorElement) => {
const sharingData = await this.getSharingData();
showShareContextMenu({
anchorElement,
allowEmbed: false,
getUnhashableStates,
objectId: savedSearch.id,
objectType: 'search',
shareContextMenuExtensions,
sharingData: {
...sharingData,
title: savedSearch.title,
},
isDirty: $appStatus.dirty,
});
}
}, {
@ -239,9 +251,6 @@ function discoverController(
docTitle.change(`Discover${pageTitleSuffix}`);
let stateMonitor;
const $appStatus = $scope.appStatus = this.appStatus = {
dirty: !savedSearch.id
};
const $state = $scope.state = new AppState(getStateDefaults());
@ -306,14 +315,6 @@ function discoverController(
};
};
this.getSharingType = () => {
return 'search';
};
this.getSharingTitle = () => {
return savedSearch.title;
};
$scope.uiState = $state.makeStateful('uiState');
function getStateDefaults() {

View file

@ -43,6 +43,7 @@ import 'uiExports/embeddableFactories';
import 'uiExports/inspectorViews';
import 'uiExports/search';
import 'uiExports/autocompleteProviders';
import 'uiExports/shareContextMenuExtensions';
import 'ui/autoload/all';
import './home';

View file

@ -42,7 +42,7 @@ import { migrateLegacyQuery } from 'ui/utils/migrateLegacyQuery';
import { recentlyAccessed } from 'ui/persisted_log';
import { timefilter } from 'ui/timefilter';
import { getVisualizeLoader } from '../../../../../ui/public/visualize/loader';
import { showShareContextMenu } from 'ui/share';
import { showShareContextMenu, ShareContextMenuExtensionsRegistryProvider } from 'ui/share';
import { getUnhashableStatesProvider } from 'ui/state_management/state_hashing';
uiRoutes
@ -117,6 +117,7 @@ function VisEditor(
const docTitle = Private(DocTitleProvider);
const queryFilter = Private(FilterBarQueryFilterProvider);
const getUnhashableStates = Private(getUnhashableStatesProvider);
const shareContextMenuExtensions = Private(ShareContextMenuExtensionsRegistryProvider);
// Retrieve the resolved SavedVis instance.
const savedVis = $route.current.locals.savedVis;
@ -138,6 +139,10 @@ function VisEditor(
$scope.vis = vis;
const $appStatus = this.appStatus = {
dirty: !savedVis.id
};
$scope.topNavMenu = [{
key: 'save',
description: 'Save Visualization',
@ -156,12 +161,19 @@ function VisEditor(
description: 'Share Visualization',
testId: 'shareTopNavButton',
run: (menuItem, navController, anchorElement) => {
const hasUnappliedChanges = vis.dirty;
const hasUnsavedChanges = $appStatus.dirty;
showShareContextMenu({
anchorElement,
allowEmbed: true,
getUnhashableStates,
objectId: savedVis.id,
objectType: 'visualization',
shareContextMenuExtensions,
sharingData: {
title: savedVis.title,
},
isDirty: hasUnappliedChanges || hasUnsavedChanges,
});
}
}, {
@ -190,18 +202,6 @@ function VisEditor(
let stateMonitor;
const $appStatus = this.appStatus = {
dirty: !savedVis.id
};
this.getSharingTitle = () => {
return savedVis.title;
};
this.getSharingType = () => {
return 'visualization';
};
if (savedVis.id) {
docTitle.change(savedVis.title);
}

View file

@ -27,6 +27,7 @@ declare class Chrome {
public getBasePath(): string;
public getXsrfToken(): string;
public getKibanaVersion(): string;
public getUiSettingsClient(): any;
}
declare const chrome: Chrome;

View file

@ -2,11 +2,12 @@
exports[`should only render permalink panel when there are no other panels 1`] = `
<EuiContextMenu
data-test-subj="shareContextMenu"
initialPanelId={1}
panels={
Array [
Object {
"content": <ShareUrlContent
"content": <UrlPanelContent
getUnhashableStates={[Function]}
objectType="dashboard"
/>,
@ -20,11 +21,12 @@ exports[`should only render permalink panel when there are no other panels 1`] =
exports[`should render context menu panel when there are more than one panel 1`] = `
<EuiContextMenu
data-test-subj="shareContextMenu"
initialPanelId={3}
panels={
Array [
Object {
"content": <ShareUrlContent
"content": <UrlPanelContent
getUnhashableStates={[Function]}
objectType="dashboard"
/>,
@ -32,7 +34,7 @@ exports[`should render context menu panel when there are more than one panel 1`]
"title": "Permalink",
},
Object {
"content": <ShareUrlContent
"content": <UrlPanelContent
getUnhashableStates={[Function]}
isEmbedded={true}
objectType="dashboard"
@ -44,11 +46,13 @@ exports[`should render context menu panel when there are more than one panel 1`]
"id": 3,
"items": Array [
Object {
"data-test-subj": "sharePanel-Embedcode",
"icon": "console",
"name": "Embed code",
"panel": 2,
},
Object {
"data-test-subj": "sharePanel-Permalinks",
"icon": "link",
"name": "Permalinks",
"panel": 1,

View file

@ -2,7 +2,7 @@
exports[`render 1`] = `
<EuiForm
className="shareUrlContentForm"
className="sharePanelContent"
data-test-subj="shareUrlForm"
>
<EuiFormRow
@ -128,6 +128,7 @@ exports[`render 1`] = `
</EuiFormRow>
<EuiCopy
afterMessage="Copied"
anchorClassName="sharePanel__copyAnchor"
textToCopy="about:blank"
/>
</EuiForm>
@ -135,7 +136,7 @@ exports[`render 1`] = `
exports[`should enable saved object export option when objectId is provided 1`] = `
<EuiForm
className="shareUrlContentForm"
className="sharePanelContent"
data-test-subj="shareUrlForm"
>
<EuiFormRow
@ -260,6 +261,7 @@ exports[`should enable saved object export option when objectId is provided 1`]
</EuiFormRow>
<EuiCopy
afterMessage="Copied"
anchorClassName="sharePanel__copyAnchor"
textToCopy="about:blank"
/>
</EuiForm>

View file

@ -18,33 +18,46 @@
*/
import React, { Component } from 'react';
import './share_panel_content.less';
import { EuiContextMenuPanelDescriptor, EuiContextMenuPanelItemDescriptor } from '@elastic/eui';
import { EuiContextMenu } from '@elastic/eui';
import { ShareUrlContent } from './share_url_content';
import { ShareAction, ShareActionProvider } from 'ui/share/share_action';
import { UrlPanelContent } from './url_panel_content';
interface Props {
allowEmbed: boolean;
objectId?: string;
objectType: string;
getUnhashableStates: () => object[];
shareContextMenuExtensions?: ShareActionProvider[];
sharingData: any;
isDirty: boolean;
onClose: () => void;
}
export class ShareContextMenu extends Component<Props> {
public render() {
const { panels, initialPanelId } = this.getPanels();
return <EuiContextMenu initialPanelId={initialPanelId} panels={panels} />;
return (
<EuiContextMenu
initialPanelId={initialPanelId}
panels={panels}
data-test-subj="shareContextMenu"
/>
);
}
private getPanels = () => {
const panels = [];
const menuItems = [];
const panels: EuiContextMenuPanelDescriptor[] = [];
const menuItems: EuiContextMenuPanelItemDescriptor[] = [];
const permalinkPanel = {
id: panels.length + 1,
title: 'Permalink',
content: (
<ShareUrlContent
<UrlPanelContent
objectId={this.props.objectId}
objectType={this.props.objectType}
getUnhashableStates={this.props.getUnhashableStates}
@ -63,7 +76,7 @@ export class ShareContextMenu extends Component<Props> {
id: panels.length + 1,
title: 'Embed Code',
content: (
<ShareUrlContent
<UrlPanelContent
isEmbedded
objectId={this.props.objectId}
objectType={this.props.objectType}
@ -79,15 +92,51 @@ export class ShareContextMenu extends Component<Props> {
});
}
// TODO add plugable panels here
if (this.props.shareContextMenuExtensions) {
const {
objectType,
objectId,
getUnhashableStates,
sharingData,
isDirty,
onClose,
} = this.props;
this.props.shareContextMenuExtensions.forEach((provider: ShareActionProvider) => {
provider
.getShareActions({
objectType,
objectId,
getUnhashableStates,
sharingData,
isDirty,
onClose,
})
.forEach(({ shareMenuItem, panel }: ShareAction) => {
const panelId = panels.length + 1;
panels.push({
...panel,
id: panelId,
});
menuItems.push({
...shareMenuItem,
panel: panelId,
});
});
});
}
if (menuItems.length > 1) {
const topLevelMenuPanel = {
id: panels.length + 1,
title: `Share this ${this.props.objectType}`,
items: menuItems.sort((a, b) => {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
}),
items: menuItems
.map(menuItem => {
menuItem['data-test-subj'] = `sharePanel-${menuItem.name.replace(' ', '')}`;
return menuItem;
})
.sort((a, b) => {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
}),
};
panels.push(topLevelMenuPanel);
}

View file

@ -0,0 +1,11 @@
.sharePanelContent{
padding: 16px;
}
.sharePanel__copyAnchor {
width: 100%;
}
.sharePanel__button {
width: 100%;
}

View file

@ -1,3 +0,0 @@
.shareUrlContentForm{
padding: 16px;
}

View file

@ -23,11 +23,11 @@ import React from 'react';
import { shallow } from 'enzyme';
import {
ShareUrlContent,
} from './share_url_content';
UrlPanelContent,
} from './url_panel_content';
test('render', () => {
const component = shallow(<ShareUrlContent
const component = shallow(<UrlPanelContent
objectType="dashboard"
getUnhashableStates={() => {}}
/>);
@ -35,7 +35,7 @@ test('render', () => {
});
test('should enable saved object export option when objectId is provided', () => {
const component = shallow(<ShareUrlContent
const component = shallow(<UrlPanelContent
objectId="id1"
objectType="dashboard"
getUnhashableStates={() => {}}

View file

@ -24,7 +24,6 @@ declare module '@elastic/eui' {
}
import React, { Component } from 'react';
import './share_url_content.less';
import {
EuiButton,
@ -67,7 +66,7 @@ interface State {
shortUrlErrorMsg?: string;
}
export class ShareUrlContent extends Component<Props, State> {
export class UrlPanelContent extends Component<Props, State> {
private mounted?: boolean;
private shortUrlCache?: string;
@ -99,22 +98,25 @@ export class ShareUrlContent extends Component<Props, State> {
public render() {
return (
<EuiForm className="shareUrlContentForm" data-test-subj="shareUrlForm">
<EuiForm className="sharePanelContent" data-test-subj="shareUrlForm">
{this.renderExportAsRadioGroup()}
{this.renderShortUrlSwitch()}
<EuiCopy textToCopy={this.state.url}>
<EuiCopy textToCopy={this.state.url} anchorClassName="sharePanel__copyAnchor">
{(copy: () => void) => (
<EuiButton
fill
onClick={copy}
disabled={this.state.isCreatingShortUrl || this.state.url === ''}
data-share-url={this.state.url}
data-test-subj="copyShareUrlButton"
>
Copy {this.props.isEmbedded ? 'iFrame code' : 'link'}
</EuiButton>
<EuiFormRow>
<EuiButton
fill
onClick={copy}
disabled={this.state.isCreatingShortUrl || this.state.url === ''}
data-share-url={this.state.url}
data-test-subj="copyShareUrlButton"
size="s"
>
Copy {this.props.isEmbedded ? 'iFrame code' : 'link'}
</EuiButton>
</EuiFormRow>
)}
</EuiCopy>
</EuiForm>

View file

@ -18,3 +18,4 @@
*/
export { showShareContextMenu } from './show_share_context_menu';
export { ShareContextMenuExtensionsRegistryProvider } from './share_action_registry';

View file

@ -0,0 +1,59 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you mayexport
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { EuiContextMenuPanelDescriptor, EuiContextMenuPanelItemDescriptor } from '@elastic/eui';
export interface ShareActionProps {
objectType: string;
objectId?: string;
getUnhashableStates: () => object[];
sharingData: any;
isDirty: boolean;
onClose: () => void;
}
export interface ShareAction {
shareMenuItem: EuiContextMenuPanelItemDescriptor;
panel: EuiContextMenuPanelDescriptor;
}
export interface ShareActionProvider {
readonly id: string;
getShareActions: (actionProps: ShareActionProps) => ShareAction[];
}

View file

@ -0,0 +1,26 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// @ts-ignore: implicit any for JS file
import { uiRegistry } from 'ui/registry/_registry';
export const ShareContextMenuExtensionsRegistryProvider = uiRegistry({
name: 'shareContextMenuExtensions',
index: ['id'],
});

View file

@ -26,6 +26,7 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { ShareContextMenu } from './components/share_context_menu';
import { ShareActionProvider } from './share_action';
import { EuiWrappingPopover } from '@elastic/eui';
@ -44,6 +45,9 @@ interface ShowProps {
getUnhashableStates: () => object[];
objectId?: string;
objectType: string;
shareContextMenuExtensions?: ShareActionProvider[];
sharingData: any;
isDirty: boolean;
}
export function showShareContextMenu({
@ -52,6 +56,9 @@ export function showShareContextMenu({
getUnhashableStates,
objectId,
objectType,
shareContextMenuExtensions,
sharingData,
isDirty,
}: ShowProps) {
if (isOpen) {
onClose();
@ -76,6 +83,10 @@ export function showShareContextMenu({
getUnhashableStates={getUnhashableStates}
objectId={objectId}
objectType={objectType}
shareContextMenuExtensions={shareContextMenuExtensions}
sharingData={sharingData}
isDirty={isDirty}
onClose={onClose}
/>
</EuiWrappingPopover>
);

26
src/ui/public/utils/query_string.d.ts vendored Normal file
View file

@ -0,0 +1,26 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
declare class QueryStringClass {
public param(key: string, value: string): string;
}
declare const QueryString: QueryStringClass;
export { QueryString };

View file

@ -54,6 +54,7 @@ export {
visualize,
search,
autocompleteProviders,
shareContextMenuExtensions,
} from './ui_app_extensions';
export {

View file

@ -52,6 +52,7 @@ export const hacks = appExtension;
export const home = appExtension;
export const inspectorViews = appExtension;
export const search = appExtension;
export const shareContextMenuExtensions = appExtension;
// Add a visualize app extension that should be used for visualize specific stuff
export const visualize = appExtension;

View file

@ -19,13 +19,34 @@
export function SharePageProvider({ getService, getPageObjects }) {
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['visualize']);
const PageObjects = getPageObjects(['visualize', 'common']);
const log = getService('log');
class SharePage {
async isShareMenuOpen() {
return await testSubjects.exists('shareContextMenu');
}
async clickShareTopNavButton() {
return testSubjects.click('shareTopNavButton');
}
async openShareMenuItem(itemTitle) {
log.debug(`openShareMenuItem title:${itemTitle}`);
const isShareMenuOpen = await this.isShareMenuOpen();
if (!isShareMenuOpen) {
await this.clickShareTopNavButton();
} else {
// there is no easy way to ensure the menu is at the top level
// so just close the existing menu
await this.clickShareTopNavButton();
// and then re-open the menu
await this.clickShareTopNavButton();
}
return testSubjects.click(`sharePanel-${itemTitle.replace(' ', '')}`);
}
async getSharedUrl() {
return await testSubjects.getAttribute('copyShareUrlButton', 'data-share-url');
}

View file

@ -89,6 +89,7 @@
"@kbn/ui-framework": "link:../packages/kbn-ui-framework",
"@samverschueren/stream-to-observable": "^0.3.0",
"@slack/client": "^4.2.2",
"@types/moment-timezone": "^0.5.8",
"angular-paging": "2.2.1",
"angular-resource": "1.4.9",
"angular-sanitize": "1.4.9",

View file

@ -27,6 +27,7 @@ import 'uiExports/docViews';
import 'uiExports/fieldFormats';
import 'uiExports/search';
import 'uiExports/autocompleteProviders';
import 'uiExports/shareContextMenuExtensions';
import _ from 'lodash';
import 'ui/autoload/all';
import 'plugins/kibana/dashboard';

View file

@ -1,15 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { JobParamsProvider } from './job_params_provider';
import { metadata } from '../metadata';
export function register(registry) {
registry.register({
...metadata,
JobParamsProvider
});
}

View file

@ -1,19 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export function JobParamsProvider() {
return async function (controller) {
const title = controller.getSharingTitle();
const type = controller.getSharingType();
const sharingData = await controller.getSharingData();
return {
title,
type,
...sharingData
};
};
}

View file

@ -1,17 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './options';
import { JobParamsProvider } from './job_params_provider';
import { metadata } from '../metadata';
export function register(registry) {
registry.register({
...metadata,
JobParamsProvider,
optionsTemplate: `<pdf-options />`
});
}

View file

@ -1,41 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import chrome from 'ui/chrome';
import {
getUnhashableStatesProvider,
unhashUrl,
} from 'ui/state_management/state_hashing';
import moment from 'moment-timezone';
import { getLayout } from './layouts';
export function JobParamsProvider(Private, config) {
const getUnhashableStates = Private(getUnhashableStatesProvider);
function parseRelativeUrl(location) {
// We need to convert the hashed states in the URL back into their original RISON values,
// because this URL will be sent to the API.
const unhashedUrl = unhashUrl(location.href, getUnhashableStates());
const relativeUrl = unhashedUrl.replace(location.origin + chrome.getBasePath(), '');
return relativeUrl;
}
return function jobParams(controller, options) {
const layout = getLayout(options.layoutId);
const browserTimezone = config.get('dateFormat:tz') === 'Browser' ? moment.tz.guess() : config.get('dateFormat:tz');
const relativeUrl = parseRelativeUrl(window.location);
return {
title: controller.getSharingTitle(),
objectType: controller.getSharingType(),
browserTimezone: browserTimezone,
relativeUrls: [ relativeUrl ],
layout: layout.getJobParams(),
};
};
}

View file

@ -1,20 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { print } from './print';
import { preserveLayout } from './preserve_layout';
import { LayoutTypes } from '../../common/constants';
export function getLayout(name) {
switch (name) {
case LayoutTypes.PRINT:
return print;
case LayoutTypes.PRESERVE_LAYOUT:
return preserveLayout;
default:
throw new Error(`Unexpected layout of ${name}`);
}
}

View file

@ -1,22 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { LayoutTypes } from '../../common/constants';
export const preserveLayout = {
getJobParams() {
const el = document.querySelector('[data-shared-items-container]');
const bounds = el.getBoundingClientRect();
return {
id: LayoutTypes.PRESERVE_LAYOUT,
dimensions: {
height: bounds.height,
width: bounds.width,
}
};
}
};

View file

@ -1,10 +0,0 @@
<div class="pdf-options">
<label>
<input type="radio" ng-model="options.layoutId" value="print">
Optimize PDF for printing
</label>
<label data-test-subj="preserveLayoutOption">
<input type="radio" ng-model="options.layoutId" value="preserve_layout">
Preserve existing layout in PDF
</label>
</div>

View file

@ -1,22 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { uiModules } from 'ui/modules';
import template from './options.html';
const module = uiModules.get('xpack/reporting');
module.directive('pdfOptions', () => {
return {
restrict: 'E',
template,
link: function ($scope) {
if (!$scope.options.layoutId) {
$scope.options.layoutId = 'print';
}
}
};
});

View file

@ -32,10 +32,9 @@ export const reporting = (kibana) => {
require: ['kibana', 'elasticsearch', 'xpack_main'],
uiExports: {
navbarExtensions: [
'plugins/reporting/controls/discover',
'plugins/reporting/controls/visualize',
'plugins/reporting/controls/dashboard',
shareContextMenuExtensions: [
'plugins/reporting/share_context_menu/register_csv_reporting',
'plugins/reporting/share_context_menu/register_reporting',
],
hacks: ['plugins/reporting/hacks/job_completion_notifier'],
home: ['plugins/reporting/register_feature'],

View file

@ -0,0 +1,190 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
// TODO: Remove once typescript definitions are in EUI
declare module '@elastic/eui' {
export const EuiCopy: React.SFC<any>;
export const EuiForm: React.SFC<any>;
}
import { EuiButton, EuiCopy, EuiForm, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui';
import React, { Component, ReactElement } from 'react';
import { KFetchError } from 'ui/kfetch/kfetch_error';
import { toastNotifications } from 'ui/notify';
import url from 'url';
import { reportingClient } from '../lib/reporting_client';
interface Props {
reportType: string;
objectId?: string;
objectType: string;
getJobParams: () => any;
options?: ReactElement<any>;
isDirty: boolean;
onClose: () => void;
}
interface State {
isStale: boolean;
absoluteUrl: string;
}
export class ReportingPanelContent extends Component<Props, State> {
private mounted?: boolean;
constructor(props: Props) {
super(props);
this.state = {
isStale: false,
absoluteUrl: '',
};
}
public componentWillUnmount() {
window.removeEventListener('hashchange', this.markAsStale);
window.removeEventListener('resize', this.setAbsoluteReportGenerationUrl);
this.mounted = false;
}
public componentDidMount() {
this.mounted = true;
this.setAbsoluteReportGenerationUrl();
window.addEventListener('hashchange', this.markAsStale, false);
window.addEventListener('resize', this.setAbsoluteReportGenerationUrl);
}
public render() {
if (this.isNotSaved() || this.props.isDirty || this.state.isStale) {
return (
<EuiForm className="sharePanelContent" data-test-subj="shareReportingForm">
<EuiFormRow helpText={'Please save your work before generating a report.'}>
{this.renderGenerateReportButton(true)}
</EuiFormRow>
</EuiForm>
);
}
const reportMsg = `${this.prettyPrintReportingType()}s can take a minute or two to generate based upon the size of your ${
this.props.objectType
}.`;
return (
<EuiForm className="sharePanelContent" data-test-subj="shareReportingForm">
<EuiText size="s">
<p>{reportMsg}</p>
</EuiText>
<EuiSpacer size="s" />
{this.props.options}
{this.renderGenerateReportButton(false)}
<EuiSpacer size="s" />
<EuiText size="s">
<p>
Alternatively, copy this POST URL to call generation from outside Kibana or from
Watcher.
</p>
</EuiText>
<EuiSpacer size="s" />
<EuiCopy textToCopy={this.state.absoluteUrl} anchorClassName="sharePanel__copyAnchor">
{(copy: () => void) => (
<EuiButton className="sharePanel__button" onClick={copy} size="s">
Copy POST URL
</EuiButton>
)}
</EuiCopy>
</EuiForm>
);
}
private renderGenerateReportButton = (isDisabled: boolean) => {
return (
<EuiButton
className="sharePanel__button"
disabled={isDisabled}
fill
onClick={this.createReportingJob}
data-test-subj="generateReportButton"
size="s"
>
Generate {this.prettyPrintReportingType()}
</EuiButton>
);
};
private prettyPrintReportingType = () => {
switch (this.props.reportType) {
case 'printablePdf':
return 'PDF';
case 'csv':
return 'CSV';
default:
return this.props.reportType;
}
};
private markAsStale = () => {
if (!this.mounted) {
return;
}
this.setState({ isStale: true });
};
private isNotSaved = () => {
return this.props.objectId === undefined || this.props.objectId === '';
};
private setAbsoluteReportGenerationUrl = () => {
if (!this.mounted) {
return;
}
const relativePath = reportingClient.getReportingJobPath(
this.props.reportType,
this.props.getJobParams()
);
const absoluteUrl = url.resolve(window.location.href, relativePath);
this.setState({ absoluteUrl });
};
private createReportingJob = () => {
return reportingClient
.createReportingJob(this.props.reportType, this.props.getJobParams())
.then(() => {
toastNotifications.addSuccess({
title: `Queued report for ${this.props.objectType}`,
text: 'Track its progress in Management',
'data-test-subj': 'queueReportSuccess',
});
this.props.onClose();
})
.catch((kfetchError: KFetchError) => {
if (kfetchError.message === 'not exportable') {
return toastNotifications.addWarning({
title: `Only saved ${this.props.objectType} can be exported`,
text: 'Please save your work first',
});
}
const defaultMessage =
kfetchError.res.status === 403
? `You don't have permission to generate this report.`
: `Can't reach the server. Please try again.`;
toastNotifications.addDanger({
title: 'Reporting error',
text: kfetchError.message || defaultMessage,
'data-test-subj': 'queueReportError',
});
});
};
}

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiSpacer, EuiSwitch } from '@elastic/eui';
import React, { Component, Fragment } from 'react';
import { ReportingPanelContent } from './reporting_panel_content';
interface Props {
reportType: string;
objectId?: string;
objectType: string;
getJobParams: () => any;
isDirty: boolean;
onClose: () => void;
}
interface State {
usePrintLayout: boolean;
}
export class ScreenCapturePanelContent extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
usePrintLayout: false,
};
}
public render() {
return (
<ReportingPanelContent
reportType={this.props.reportType}
objectType={this.props.objectType}
objectId={this.props.objectId}
getJobParams={this.getJobParams}
options={this.renderOptions()}
isDirty={this.props.isDirty}
onClose={this.props.onClose}
/>
);
}
private renderOptions = () => {
return (
<Fragment>
<EuiSwitch
label="Optimize for printing"
checked={this.state.usePrintLayout}
onChange={this.handlePrintLayoutChange}
data-test-subj="usePrintLayout"
/>
<EuiSpacer size="s" />
</Fragment>
);
};
private handlePrintLayoutChange = (evt: any) => {
this.setState({ usePrintLayout: evt.target.checked });
};
private getLayout = () => {
if (this.state.usePrintLayout) {
return { id: 'print' };
}
const el = document.querySelector('[data-shared-items-container]');
const bounds = el ? el.getBoundingClientRect() : { height: 768, width: 1024 };
return {
id: 'preserve_layout',
dimensions: {
height: bounds.height,
width: bounds.width,
},
};
};
private getJobParams = () => {
const jobParams = this.props.getJobParams();
jobParams.layout = this.getLayout();
return jobParams;
};
}

View file

@ -1,31 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import 'plugins/reporting/directives/export_config';
import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info';
import { NavBarExtensionsRegistryProvider } from 'ui/registry/navbar_extensions';
import { DashboardConstants } from 'plugins/kibana/dashboard/dashboard_constants';
function dashboardReportProvider(Private, $location, dashboardConfig) {
const xpackInfo = Private(XPackInfoProvider);
return {
appName: 'dashboard',
key: 'reporting-dashboard',
label: 'Reporting',
template: `<export-config object-type="Dashboard" enabled-export-type="printablePdf"></export-config>`,
description: 'Dashboard Report',
hideButton: () => (
dashboardConfig.getHideWriteControls()
|| $location.path() === DashboardConstants.LANDING_PAGE_PATH
|| !xpackInfo.get('features.reporting.printablePdf.showLinks', false)
),
disableButton: () => !xpackInfo.get('features.reporting.printablePdf.enableLinks', false),
tooltip: () => xpackInfo.get('features.reporting.printablePdf.message'),
testId: 'topNavReportingLink',
};
}
NavBarExtensionsRegistryProvider.register(dashboardReportProvider);

View file

@ -1,27 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import 'plugins/reporting/directives/export_config';
import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info';
import { NavBarExtensionsRegistryProvider } from 'ui/registry/navbar_extensions';
function discoverReportProvider(Private) {
const xpackInfo = Private(XPackInfoProvider);
return {
appName: 'discover',
key: 'reporting-discover',
label: 'Reporting',
template: '<export-config object-type="Search" enabled-export-type="csv"></export-config>',
description: 'Search Report',
hideButton: () => !xpackInfo.get('features.reporting.csv.showLinks', false),
disableButton: () => !xpackInfo.get('features.reporting.csv.enableLinks', false),
tooltip: () => xpackInfo.get('features.reporting.csv.message'),
testId: 'topNavReportingLink',
};
}
NavBarExtensionsRegistryProvider.register(discoverReportProvider);

View file

@ -1,38 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import 'plugins/reporting/directives/export_config';
import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info';
import { NavBarExtensionsRegistryProvider } from 'ui/registry/navbar_extensions';
import { VisualizeConstants } from 'plugins/kibana/visualize/visualize_constants';
function visualizeReportProvider(Private, $location) {
const xpackInfo = Private(XPackInfoProvider);
return {
appName: 'visualize',
key: 'reporting-visualize',
label: 'Reporting',
template: `
<export-config
object-type="Visualization"
enabled-export-type="printablePdf"
options="{ layoutId: 'preserve_layout' }"
></export-config>`,
description: 'Visualization Report',
hideButton: () => (
$location.path() === VisualizeConstants.LANDING_PAGE_PATH
|| $location.path() === VisualizeConstants.WIZARD_STEP_1_PAGE_PATH
|| $location.path() === VisualizeConstants.WIZARD_STEP_2_PAGE_PATH
|| !xpackInfo.get('features.reporting.printablePdf.showLinks', false)
),
disableButton: () => !xpackInfo.get('features.reporting.printablePdf.enableLinks', false),
tooltip: () => xpackInfo.get('features.reporting.printablePdf.message'),
testId: 'topNavReportingLink',
};
}
NavBarExtensionsRegistryProvider.register(visualizeReportProvider);

View file

@ -1,55 +0,0 @@
<div ng-show="!exportConfig.isDirty()">
<div class="kuiLocalDropdownSection">
<h2 class="kuiLocalDropdownTitle">
Reporting
</h2>
<div class="input-group generate-controls">
<div class="options"></div>
<button
class="kuiButton kuiButton--primary"
data-test-subj="generateReportButton"
ng-click="exportConfig.export()"
>
Generate {{ exportConfig.exportType.name }}
</button>
</div>
</div>
<div class="kuiLocalDropdownSection">
<!-- Header -->
<div class="kuiLocalDropdownHeader">
<label
class="kuiLocalDropdownHeader__label"
for="reportGenerationUrl"
>
Generation URL
</label>
<div class="kuiLocalDropdownHeader__actions">
<a
class="kuiLocalDropdownHeader__action"
ng-click="exportConfig.copyToClipboard('#reportGenerationUrl')"
kbn-accessible-click
>
Copy
</a>
</div>
</div>
<!-- Input -->
<input
id="reportGenerationUrl"
class="kuiLocalDropdownInput"
type="text"
readonly
data-test-subj="reportGenerationUrl"
value="{{ exportConfig.absoluteUrl || 'Loading...' }}"
ng-click="updateUrl()"
/>
</div>
</div>
<div ng-show="exportConfig.isDirty()" data-test-subj="unsavedChangesReportingWarning">
Please save your work before generating a report.
</div>

View file

@ -1,141 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import angular from 'angular';
import { debounce } from 'lodash';
import 'plugins/reporting/services/document_control';
import 'plugins/reporting/services/export_types';
import './export_config.less';
import template from 'plugins/reporting/directives/export_config/export_config.html';
import { toastNotifications } from 'ui/notify';
import { uiModules } from 'ui/modules';
import { stateMonitorFactory } from 'ui/state_management/state_monitor_factory';
import url from 'url';
const module = uiModules.get('xpack/reporting');
module.directive('exportConfig', ($rootScope, reportingDocumentControl, reportingExportTypes, $location, $compile) => {
const createAbsoluteUrl = relativePath => {
return url.resolve($location.absUrl(), relativePath);
};
return {
restrict: 'E',
scope: {},
require: ['?^dashboardApp', '?^visualizeApp', '?^discoverApp'],
controllerAs: 'exportConfig',
template,
transclude: true,
async link($scope, $el, $attr, controllers) {
const actualControllers = controllers.filter(c => c !== null);
if (actualControllers.length !== 1) {
throw new Error(`Expected there to be 1 controller, but there are ${actualControllers.length}`);
}
const controller = actualControllers[0];
$scope.exportConfig.isDirty = () => controller.appStatus.dirty;
if (controller.appStatus.dirty) {
return;
}
const exportTypeId = $attr.enabledExportType;
$scope.exportConfig.exportType = reportingExportTypes.getById(exportTypeId);
$scope.exportConfig.objectType = $attr.objectType;
$scope.options = $attr.options ? $scope.$eval($attr.options) : {};
if ($scope.exportConfig.exportType.optionsTemplate) {
$el.find('.options').append($compile($scope.exportConfig.exportType.optionsTemplate)($scope));
}
$scope.getRelativePath = (options) => {
return reportingDocumentControl.getPath($scope.exportConfig.exportType, controller, options || $scope.options);
};
$scope.updateUrl = (options) => {
return $scope.getRelativePath(options)
.then(relativePath => {
$scope.exportConfig.absoluteUrl = createAbsoluteUrl(relativePath);
});
};
$scope.$watch('options', newOptions => $scope.updateUrl(newOptions), true);
await $scope.updateUrl();
},
controller($scope, $document, $window, $timeout, globalState) {
const stateMonitor = stateMonitorFactory.create(globalState);
stateMonitor.onChange(() => {
if ($scope.exportConfig.isDirty()) {
return;
}
$scope.updateUrl();
});
const onResize = debounce(() => {
$scope.updateUrl();
}, 200);
angular.element($window).on('resize', onResize);
$scope.$on('$destroy', () => {
angular.element($window).off('resize', onResize);
stateMonitor.destroy();
});
this.export = () => {
return $scope.getRelativePath()
.then(relativePath => {
return reportingDocumentControl.create(relativePath);
})
.then(() => {
toastNotifications.addSuccess({
title: `Queued report for ${this.objectType}`,
text: 'Track its progress in Management',
'data-test-subj': 'queueReportSuccess',
});
})
.catch((err) => {
if (err.message === 'not exportable') {
return toastNotifications.addWarning({
title: 'Only saved dashboards can be exported',
text: 'Please save your work first',
});
}
toastNotifications.addDanger({
title: 'Reporting error',
text: err.message || `Can't reach the server. Please try again.`,
'data-test-subj': 'queueReportError',
});
});
};
this.copyToClipboard = selector => {
// updating the URL in the input because it could have potentially changed and we missed the update
$scope.updateUrl()
.then(() => {
// we're using $timeout to make sure the URL has been updated in the HTML as this is where
// we're copying the ext from
$timeout(() => {
const copyTextarea = $document.find(selector)[0];
copyTextarea.select();
try {
const isCopied = document.execCommand('copy');
if (isCopied) {
toastNotifications.add('URL copied to clipboard');
} else {
toastNotifications.add('Press Ctrl+C to copy URL');
}
} catch (err) {
toastNotifications.add('Press Ctrl+C to copy URL');
}
});
});
};
}
};
});

View file

@ -1,19 +0,0 @@
export-config {
.generate-controls {
button {
margin-right: 10px;
}
}
.input-group {
.clipboard-button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.form-control.url {
cursor: text;
}
}
}

View file

@ -1,7 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './export_config';

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { kfetch } from 'ui/kfetch';
// @ts-ignore
import rison from 'rison-node';
import chrome from 'ui/chrome';
import { QueryString } from 'ui/utils/query_string';
import { jobCompletionNotifications } from '../services/job_completion_notifications';
const API_BASE_URL = '/api/reporting/generate';
class ReportingClient {
public getReportingJobPath = (exportType: string, jobParams: object) => {
return `${chrome.addBasePath(API_BASE_URL)}/${exportType}?${QueryString.param(
'jobParams',
rison.encode(jobParams)
)}`;
};
public createReportingJob = async (exportType: string, jobParams: any) => {
const query = {
jobParams: rison.encode(jobParams),
};
const resp = await kfetch({ method: 'POST', pathname: `${API_BASE_URL}/${exportType}`, query });
jobCompletionNotifications.add(resp.job.id);
return resp;
};
}
export const reportingClient = new ReportingClient();

View file

@ -1,38 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import 'plugins/reporting/services/job_completion_notifications';
import chrome from 'ui/chrome';
import rison from 'rison-node';
import { uiModules } from 'ui/modules';
import { QueryString } from 'ui/utils/query_string';
uiModules.get('xpack/reporting')
.service('reportingDocumentControl', function (Private, $http, reportingJobCompletionNotifications, $injector) {
const $Promise = $injector.get('Promise');
const mainEntry = '/api/reporting/generate';
const reportPrefix = chrome.addBasePath(mainEntry);
const getJobParams = (exportType, controller, options) => {
const jobParamsProvider = Private(exportType.JobParamsProvider);
return $Promise.resolve(jobParamsProvider(controller, options));
};
this.getPath = (exportType, controller, options) => {
return getJobParams(exportType, controller, options)
.then(jobParams => {
return `${reportPrefix}/${exportType.id}?${QueryString.param('jobParams', rison.encode(jobParams))}`;
});
};
this.create = (relativePath) => {
return $http.post(relativePath, {})
.then(({ data }) => {
reportingJobCompletionNotifications.add(data.job.id);
return data;
});
};
});

View file

@ -1,20 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { uiModules } from 'ui/modules';
import { ExportTypesRegistry } from '../../common/export_types_registry';
export const exportTypesRegistry = new ExportTypesRegistry();
const context = require.context('../../export_types', true, /public\/index.js/);
context.keys().forEach(key => context(key).register(exportTypesRegistry));
uiModules.get('xpack/reporting')
.service('reportingExportTypes', function () {
this.getById = (exportTypeId) => {
return exportTypesRegistry.getById(exportTypeId);
};
});

View file

@ -4,12 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { LayoutTypes } from '../../common/constants';
declare class JobCompletionNotifications {
public add(jobId: string): void;
}
export const print = {
getJobParams() {
return {
id: LayoutTypes.PRINT
};
}
};
declare const jobCompletionNotifications: JobCompletionNotifications;
export { jobCompletionNotifications };

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
// @ts-ignore: implicit any for JS file
import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info';
import React from 'react';
import { ShareActionProps } from 'ui/share/share_action';
import { ShareContextMenuExtensionsRegistryProvider } from 'ui/share/share_action_registry';
import { ReportingPanelContent } from '../components/reporting_panel_content';
function reportingProvider(Private: any) {
const xpackInfo = Private(XPackInfoProvider);
const getShareActions = ({
objectType,
objectId,
sharingData,
isDirty,
onClose,
}: ShareActionProps) => {
if ('search' !== objectType) {
return [];
}
const getJobParams = () => {
return {
...sharingData,
type: objectType,
};
};
const shareActions = [];
if (xpackInfo.get('features.reporting.csv.showLinks', false)) {
const panelTitle = 'CSV Reports';
shareActions.push({
shareMenuItem: {
name: panelTitle,
icon: 'document',
toolTipContent: xpackInfo.get('features.reporting.csv.message'),
disabled: !xpackInfo.get('features.reporting.csv.enableLinks', false) ? true : false,
['data-test-subj']: 'csvReportMenuItem',
},
panel: {
title: panelTitle,
content: (
<ReportingPanelContent
reportType="csv"
objectType={objectType}
objectId={objectId}
getJobParams={getJobParams}
isDirty={isDirty}
onClose={onClose}
/>
),
},
});
}
return shareActions;
};
return {
id: 'csvReports',
getShareActions,
};
}
ShareContextMenuExtensionsRegistryProvider.register(reportingProvider);

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import moment from 'moment-timezone';
// @ts-ignore: implicit any for JS file
import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info';
import React from 'react';
import chrome from 'ui/chrome';
import { ShareActionProps } from 'ui/share/share_action';
import { ShareContextMenuExtensionsRegistryProvider } from 'ui/share/share_action_registry';
import { unhashUrl } from 'ui/state_management/state_hashing';
import { ScreenCapturePanelContent } from '../components/screen_capture_panel_content';
function reportingProvider(Private: any, dashboardConfig: any) {
const xpackInfo = Private(XPackInfoProvider);
const getShareActions = ({
objectType,
objectId,
getUnhashableStates,
sharingData,
isDirty,
onClose,
}: ShareActionProps) => {
if (!['dashboard', 'visualization'].includes(objectType)) {
return [];
}
// Dashboard only mode does not currently support reporting
// https://github.com/elastic/kibana/issues/18286
if (objectType === 'dashboard' && dashboardConfig.getHideWriteControls()) {
return [];
}
const getReportingJobParams = () => {
// Replace hashes with original RISON values.
const unhashedUrl = unhashUrl(window.location.href, getUnhashableStates());
const relativeUrl = unhashedUrl.replace(window.location.origin + chrome.getBasePath(), '');
const browserTimezone =
chrome.getUiSettingsClient().get('dateFormat:tz') === 'Browser'
? moment.tz.guess()
: chrome.getUiSettingsClient().get('dateFormat:tz');
return {
...sharingData,
objectType,
browserTimezone,
relativeUrls: [relativeUrl],
};
};
const shareActions = [];
if (xpackInfo.get('features.reporting.printablePdf.showLinks', false)) {
const panelTitle = 'PDF Reports';
shareActions.push({
shareMenuItem: {
name: panelTitle,
icon: 'document',
toolTipContent: xpackInfo.get('features.reporting.printablePdf.message'),
disabled: !xpackInfo.get('features.reporting.printablePdf.enableLinks', false)
? true
: false,
['data-test-subj']: 'pdfReportMenuItem',
},
panel: {
title: panelTitle,
content: (
<ScreenCapturePanelContent
reportType="printablePdf"
objectType={objectType}
objectId={objectId}
getJobParams={getReportingJobParams}
isDirty={isDirty}
onClose={onClose}
/>
),
},
});
}
// TODO register PNG menu item once PNG is supported on server side
return shareActions;
};
return {
id: 'screenCaptureReports',
getShareActions,
};
}
ShareContextMenuExtensionsRegistryProvider.register(reportingProvider);

View file

@ -78,7 +78,7 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.common.navigateToApp('discover');
await PageObjects.discover.loadSavedSearch('A Saved Search');
log.debug('click Reporting button');
await PageObjects.reporting.openReportingPanel();
await PageObjects.reporting.openCsvReportingPanel();
await PageObjects.reporting.clickGenerateReportButton();
const queueReportError = await PageObjects.reporting.getQueueReportError();
expect(queueReportError).to.be(true);

View file

@ -15,7 +15,7 @@ export function ReportingPageProvider({ getService, getPageObjects }) {
const esArchiver = getService('esArchiver');
const remote = getService('remote');
const kibanaServer = getService('kibanaServer');
const PageObjects = getPageObjects(['common', 'security', 'header', 'settings']);
const PageObjects = getPageObjects(['common', 'security', 'header', 'settings', 'share']);
class ReportingPage {
async initTests() {
@ -31,18 +31,6 @@ export function ReportingPageProvider({ getService, getPageObjects }) {
await remote.setWindowSize(1600, 850);
}
async clickTopNavReportingLink() {
await retry.try(() => testSubjects.click('topNavReportingLink'));
}
async isReportingPanelOpen() {
const generateReportButtonExists = await this.getGenerateReportButtonExists();
const unsavedChangesWarningExists = await this.getUnsavedChangesWarningExists();
const isOpen = generateReportButtonExists || unsavedChangesWarningExists;
log.debug('isReportingPanelOpen: ' + isOpen);
return isOpen;
}
async getUrlOfTab(tabIndex) {
return await retry.try(async () => {
log.debug(`reportingPage.getUrlOfTab(${tabIndex}`);
@ -118,20 +106,14 @@ export function ReportingPageProvider({ getService, getPageObjects }) {
});
}
async openReportingPanel() {
log.debug('openReportingPanel');
await retry.try(async () => {
const isOpen = await this.isReportingPanelOpen();
async openCsvReportingPanel() {
log.debug('openCsvReportingPanel');
await PageObjects.share.openShareMenuItem('CSV Reports');
}
if (!isOpen) {
await this.clickTopNavReportingLink();
}
const wasOpened = await this.isReportingPanelOpen();
if (!wasOpened) {
throw new Error('Reporting panel was not opened successfully');
}
});
async openPdfReportingPanel() {
log.debug('openPdfReportingPanel');
await PageObjects.share.openShareMenuItem('PDF Reports');
}
async clickDownloadReportButton(timeout) {
@ -143,14 +125,6 @@ export function ReportingPageProvider({ getService, getPageObjects }) {
await Promise.all(toasts.map(t => t.click()));
}
async getUnsavedChangesWarningExists() {
return await testSubjects.exists('unsavedChangesReportingWarning');
}
async getGenerateReportButtonExists() {
return await testSubjects.exists('generateReportButton');
}
async getQueueReportError() {
return await testSubjects.exists('queueReportError');
}
@ -159,8 +133,8 @@ export function ReportingPageProvider({ getService, getPageObjects }) {
return await retry.try(() => testSubjects.find('generateReportButton'));
}
async clickPreserveLayoutOption() {
await retry.try(() => testSubjects.click('preserveLayoutOption'));
async checkUsePrintLayout() {
await retry.try(() => testSubjects.click('usePrintLayout'));
}
async clickGenerateReportButton() {

View file

@ -29,19 +29,18 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.reporting.initTests();
});
const expectUnsavedChangesWarning = async () => {
await PageObjects.reporting.openReportingPanel();
const warningExists = await PageObjects.reporting.getUnsavedChangesWarningExists();
expect(warningExists).to.be(true);
const buttonExists = await PageObjects.reporting.getGenerateReportButtonExists();
expect(buttonExists).to.be(false);
const expectDisabledGenerateReportButton = async () => {
const generateReportButton = await PageObjects.reporting.getGenerateReportButton();
await retry.try(async () => {
const isDisabled = await generateReportButton.getProperty('disabled');
expect(isDisabled).to.be(true);
});
};
const expectEnabledGenerateReportButton = async () => {
await PageObjects.reporting.openReportingPanel();
const printPdfButton = await PageObjects.reporting.getGenerateReportButton();
const generateReportButton = await PageObjects.reporting.getGenerateReportButton();
await retry.try(async () => {
const isDisabled = await printPdfButton.getProperty('disabled');
const isDisabled = await generateReportButton.getProperty('disabled');
expect(isDisabled).to.be(false);
});
};
@ -72,11 +71,13 @@ export default function ({ getService, getPageObjects }) {
it('is not available if new', async () => {
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.clickNewDashboard();
await expectUnsavedChangesWarning();
await PageObjects.reporting.openPdfReportingPanel();
await expectDisabledGenerateReportButton();
});
it('becomes available when saved', async () => {
await PageObjects.dashboard.saveDashboard('mydash');
await PageObjects.reporting.openPdfReportingPanel();
await expectEnabledGenerateReportButton();
});
});
@ -101,7 +102,8 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.dashboard.saveDashboard('report test');
await PageObjects.reporting.openReportingPanel();
await PageObjects.reporting.openPdfReportingPanel();
await PageObjects.reporting.checkUsePrintLayout();
await PageObjects.reporting.clickGenerateReportButton();
await PageObjects.reporting.clickDownloadReportButton(60000);
@ -128,7 +130,8 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.dashboard.switchToEditMode();
await PageObjects.dashboard.useMargins(true);
await PageObjects.dashboard.saveDashboard('report test');
await PageObjects.reporting.openReportingPanel();
await PageObjects.reporting.openPdfReportingPanel();
await PageObjects.reporting.checkUsePrintLayout();
await PageObjects.reporting.clickGenerateReportButton();
await PageObjects.reporting.clickDownloadReportButton(60000);
@ -156,9 +159,8 @@ export default function ({ getService, getPageObjects }) {
// report than phantom.
this.timeout(360000);
await PageObjects.reporting.openReportingPanel();
await PageObjects.reporting.openPdfReportingPanel();
await PageObjects.reporting.forceSharedItemsContainerSize({ width: 1405 });
await PageObjects.reporting.clickPreserveLayoutOption();
await PageObjects.reporting.clickGenerateReportButton();
await PageObjects.reporting.removeForceSharedItemsContainerSize();
@ -190,23 +192,25 @@ export default function ({ getService, getPageObjects }) {
describe('Generate CSV button', () => {
it('is not available if new', async () => {
await PageObjects.common.navigateToApp('discover');
await expectUnsavedChangesWarning();
await PageObjects.reporting.openCsvReportingPanel();
await expectDisabledGenerateReportButton();
});
it('becomes available when saved', async () => {
await PageObjects.discover.saveSearch('my search');
await PageObjects.reporting.openCsvReportingPanel();
await expectEnabledGenerateReportButton();
});
it('generates a report with data', async () => {
await PageObjects.reporting.setTimepickerInDataRange();
await PageObjects.reporting.clickTopNavReportingLink();
await PageObjects.reporting.openCsvReportingPanel();
await expectReportCanBeCreated();
});
it('generates a report with no data', async () => {
await PageObjects.reporting.setTimepickerInNoDataRange();
await PageObjects.reporting.clickTopNavReportingLink();
await PageObjects.reporting.openCsvReportingPanel();
await expectReportCanBeCreated();
});
});
@ -218,7 +222,8 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.common.navigateToUrl('visualize', 'new');
await PageObjects.visualize.clickAreaChart();
await PageObjects.visualize.clickNewSearch();
await expectUnsavedChangesWarning();
await PageObjects.reporting.openPdfReportingPanel();
await expectDisabledGenerateReportButton();
});
it('becomes available when saved', async () => {
@ -227,6 +232,7 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.visualize.selectAggregation('Date Histogram');
await PageObjects.visualize.clickGo();
await PageObjects.visualize.saveVisualizationExpectSuccess('my viz');
await PageObjects.reporting.openPdfReportingPanel();
await expectEnabledGenerateReportButton();
});
@ -235,7 +241,7 @@ export default function ({ getService, getPageObjects }) {
// function is taking about 15 seconds per comparison in jenkins.
this.timeout(180000);
await PageObjects.reporting.openReportingPanel();
await PageObjects.reporting.openPdfReportingPanel();
await PageObjects.reporting.clickGenerateReportButton();
await PageObjects.reporting.clickDownloadReportButton(60000);

View file

@ -158,6 +158,12 @@
version "1.5.3"
resolved "https://registry.yarnpkg.com/@types/loglevel/-/loglevel-1.5.3.tgz#adfce55383edc5998a2170ad581b3e23d6adb5b8"
"@types/moment-timezone@^0.5.8":
version "0.5.8"
resolved "https://registry.yarnpkg.com/@types/moment-timezone/-/moment-timezone-0.5.8.tgz#92aba9bc238cabf69a27a1a4f52e0ebb8f10f896"
dependencies:
moment ">=2.14.0"
"@types/node@*":
version "9.3.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-9.3.0.tgz#3a129cda7c4e5df2409702626892cb4b96546dd5"
@ -5211,6 +5217,10 @@ moment@2.x.x, "moment@>= 2.9.0", moment@^2.13.0, moment@^2.20.1:
version "2.20.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.20.1.tgz#d6eb1a46cbcc14a2b2f9434112c1ff8907f313fd"
moment@>=2.14.0:
version "2.22.2"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"
ms@0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098"

View file

@ -454,6 +454,12 @@
version "2.0.29"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-2.0.29.tgz#5002e14f75e2d71e564281df0431c8c1b4a2a36a"
"@types/moment-timezone@^0.5.8":
version "0.5.8"
resolved "https://registry.yarnpkg.com/@types/moment-timezone/-/moment-timezone-0.5.8.tgz#92aba9bc238cabf69a27a1a4f52e0ebb8f10f896"
dependencies:
moment ">=2.14.0"
"@types/node@*":
version "9.4.7"
resolved "https://registry.yarnpkg.com/@types/node/-/node-9.4.7.tgz#57d81cd98719df2c9de118f2d5f3b1120dcd7275"
@ -8995,6 +9001,10 @@ moment@2.x.x, "moment@>= 2.9.0", moment@^2.10.6, moment@^2.13.0, moment@^2.20.1:
version "2.21.0"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.21.0.tgz#2a114b51d2a6ec9e6d83cf803f838a878d8a023a"
moment@>=2.14.0:
version "2.22.2"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"
move-concurrently@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"